Character authoring
POST /v1/characters can accept only slug and display_name, but that is a
smoke test, not a production character. A production character should give
LoreOS enough typed state to answer three questions:
- Who is this character?
- How do they talk?
- What kind of life do they live between messages?
LoreOS stores these fields as versioned character content, validates the shape,
returns authoring_readiness, emits the authored greeting when a session starts,
passes deeper authoring fields into runtime chat as private planning context, and
drives the character’s between-message life (Story Room weekly arcs and daily-life
packages read these fields today — see What happens at runtime).
Create, validate, list, get, and update responses also return publication.
Use it to separate runtime availability from structural authoring completeness:
publication.runtime_available=truemeans/v1/sessionscan use the character.publication.authoring_quality_ready=truemeans the authored character passed the deterministic field-presence checklist.publication.launch_ready=truemeans both runtime availability and authoring presence checks are ready. Despite the legacy field name, it is not a conversation-quality score.
Every authoring/publication readiness object includes readiness_kind="authoring" and
semantic_quality_evaluated=false. GET /v1/characters/{slug}/readiness instead returns
readiness_kind="launch", but its score is still a deterministic operational checklist
(persona text, samples, images, budget, delivery), not proof that conversations are good.
POST /v1/characters auto-publishes — there is no separate publish endpoint. A created
character is published immediately, so publication.runtime_available=true on create
regardless of readiness, and /v1/sessions can use it right away. The readiness
checklist (authoring_readiness / publication.authoring_quality_ready) is advisory, not
a gate: a needs_attention character is fully runtime-available. A structural pass only says
that expected fields exist. It does not judge whether persona, voice, boundaries, grounding, or
multi-turn continuity are effective. Require both a structural pass and a Conversation Eval
semantic pass before showing the character to real users.
Choose background cost before create
Character creation also has an independent recurring-workflow decision. The default request policy is:
When the server provisioning policy is live, that requests all four paid
background workflows: story_room (daily), offscreen_heartbeat (~10 min),
ambient_life (hourly), and offscreen_scene (~5 min). This is a durable cost
and schedule side effect, separate from the one-time character row.
For validation, tests, or an app that will activate life features later, opt out in the same create request:
The opt-out is checked before schedule registration, so there is no
create-then-deactivate window. Read
data.background_features.auto_activation_requested and auto_activated for
the exact result, then activate individual features only when wanted. A coding
agent must include this cost choice in mutation approval instead of silently
accepting the default.
Agent-safe flow: call POST /v1/characters/validate, read
authoring_readiness.next_authoring_actions, create or patch the character, then
run POST /v1/characters/{slug}/conversation-evals over the quality rubric below.
GET /v1/characters/{slug}/readiness is a broader launch checklist and may still
ask for visual identity, inventory, budget, or delivery work even after
authoring_readiness.status="ready". Completion requires both the structural/operational
checks and a reviewed semantic eval; no single readiness boolean represents both.
Before seeding a roster, call GET /v1/characters/authoring-limits. It returns
bulk_create_max_per_request, demo sandbox caps when relevant, readiness targets,
and the publication-state glossary.
Authoring quota
POST /v1/characters/validate dry-runs do not consume quota (no write, no character
count increment) — so you can validate as many drafts as you like before spending a create.
Character lifecycle
Use POST /v1/characters/{slug}/archive when you want to remove a character from active
inventory without losing the canonical character id or slug history. Archived characters
are hidden from the default GET /v1/characters list and cannot start sessions, receive
new messages on existing sessions, or activate background features.
Restore with POST /v1/characters/{slug}/restore.
Use delete only when the character should be permanently removed from product runtime surfaces. The preferred path is archive first, then explicitly confirm the canonical slug:
Use force=true only when a human-approved destructive workflow intentionally skips the reversible
archive step. A successful delete returns 202; poll GET /v1/characters/{slug}/deletion until
data.deletion_request.request_status == "completed". completed_at is present but null while
requested/running, so key presence is never a terminal test. Stop and surface
data.deletion_request.error_message when status is failed.
Deleted canonical slugs remain permanently reserved. Create each test character with a new unique
slug; retrying POST /v1/characters with a tombstoned slug returns 409 slug_reserved. Security,
billing, lifecycle, and immutable actor-core audit history follow their own retention rules.
A character tombstone or deleted session makes retained actor history ineligible for normal runtime
reads. Closing a whole temporary app/workspace additionally requires the account/operator lifecycle;
do not write a test helper that hard-deletes social_actors or tenant roots. The acceptance check for
that cleanup is active tenant/app/actor/character/session counts all zero and the retired key
returning 401 or 403.
POST /v1/characters/{slug}/block is not character deletion. It blocks one end-user’s
relationship with that character and should not be used for inventory cleanup.
The drive-to-ready loop
To climb a character to launch quality without burning quota on failed creates:
POST /v1/characters/validate— dry-run scoring, no quota cost.- Read
authoring_readiness.next_authoring_actionsfor the exact missing fields. POST /v1/characters(first time) orPATCH /v1/characters/{slug}(subsequent) to apply the fix.GET /v1/characters/{slug}/readinessto re-check.- Repeat 2–4 until the structural and broader operational checklists are satisfied.
- Run the same 6-10 turn Conversation Eval dataset and review identity, voice, boundary, grounding, continuity, and non-generic usefulness.
- Change only the fields implicated by failed evidence, then rerun the identical dataset.
POST /v1/characters does not currently define an Idempotency-Key contract. Its default
background_features.auto_activate=true path can register recurring paid schedules, so after an
ambiguous timeout do not blindly repeat the create. First read
GET /v1/characters/{slug} and GET /v1/characters/{slug}/feature-status. Setting
background_features.auto_activate=false skips schedule registration atomically and makes an
identical immediate retry conditionally safe only while no archive/restore/other lifecycle change
occurred between attempts.
Minimal vs. production-quality
Minimal characters are allowed so agents can smoke-test the API quickly:
That character can chat, but it will feel generic. For a character users should care about, include the fields below.
Field guide
Effective persona and voice contract for coding agents
Do not turn a user brief directly into a pile of pleasant adjectives. Before writing JSON, extract five separate requirements: identity, voice, interaction pattern, life direction, and grounding boundary. Map each requirement to one field and make every sample teach a deliberate behavior.
Persona rubric
A useful persona needs enough internal structure to make choices, not just a biography:
Put stable public identity in profile, choice-shaping direction in character_direction, daily
constraints in life_template, recurring friction in story_engine, and relational limits in
relationship_seed. Give the character at least one specific preference they can defend and one
area where they naturally say “I do not know” rather than inventing.
Voice rubric
Write a small voice contract before writing samples. Specify:
- preferred and forbidden vocabulary;
- typical sentence and bubble length;
- certainty style (direct, hedged, self-correcting, or observational);
- how often the character asks a question back;
- whether emotion is named directly or shown indirectly;
- emoji, laughter, interjection, punctuation, and code-switch frequency;
- honorific/formality changes and exactly what triggers them;
- how voice changes under closeness, conflict, refusal, embarrassment, and silence; and
- assistant-like phrases to forbid, such as generic validation, numbered advice, constant offers to help, or a tidy moral at the end of every reply.
Encode stable prohibitions in forbidden_style. Use 3-10 voice_samples to demonstrate different
states rather than paraphrasing one neutral line. Use 2-5 example_dialogues to teach interaction
shape: ordinary check-in, uncertainty, soft boundary, disagreement/repair, and increased closeness.
Bad samples are interchangeable (“That sounds nice!”, “I understand how you feel”), all have the
same length/mood, or describe voice in metadata without demonstrating it in text. A strong set
makes two independent readers identify the same character without seeing display_name.
Truth-source boundary
Style guidance must never silently become a claim that an event happened:
Do not put a made-up “today” event into a voice sample. The runtime or an evaluator may otherwise mistake stylistic specificity for grounded history. Conversely, do not remove all specificity in the name of safety: approved or runtime-safe facts should produce concrete, useful replies.
Required author, eval, improve loop
- Validate the payload and clear structural warnings.
- Create a uniquely slugged temporary character and record its version ID.
- Run 6-10 fixed turns covering identity, ordinary voice, uncertainty, boundary pressure, grounded recent-life questions, topic continuity, conflict/repair, and language consistency.
- Use deterministic
expect_contains,expect_not_contains, andforbidden_leakageonly for facts or hard prohibitions. Use the tone and relationship-pacing rubrics for qualitative output. - Separately score identity expression, distinct voice, boundary fidelity, grounding, continuity, and useful specificity. A non-empty reply is not a pass. Generic but safe output fails the value floor; vivid but invented output fails the error ceiling.
- Inspect the actual transcript. For experiential claims, include a target-user or independent calibrated review rather than accepting a structural score as semantic proof.
- Change only the fields implicated by failed turns, keep the dataset fixed, and rerun against the new immutable version.
Report structural readiness and semantic eval as two separate results. A character is complete only when both pass for the intended audience and use case.
Reply language and input locale guard
primary_reply_language is the language users should normally see in character replies for
a single-locale character. Choose it from the product experience you are shipping, not from
where the character lives, which language the developer speaks, or the language of the latest
user message.
locale is separate metadata for regional hints, provider behavior, formatting, and legacy
compatibility. A character can have locale: "ko-KR" and primary_reply_language: "en-US"
if the character is culturally Korean but your product wants English replies.
All four language fields (locale, primary_reply_language, supported_input_languages,
input_language_policy) are also readable: they are returned by GET /v1/characters
(list) and GET /v1/characters/{slug} (one), so your app can read them per character and do
its own client-side exposure or input-blocking without maintaining a separate
character→language mapping.
supported_input_languages is the runtime guard for POST /v1/sessions.locale.
If a character is KO-only, leave it as ["ko-KR"]; a session request with
"locale":"en-US" returns 409 session_locale_not_supported. Add languages only
when the character is intentionally authored to handle them, or set
input_language_policy="allow_cross_language" when your app owns catalog routing.
For multilingual characters, create one canonical character and add locale packs:
Start a Korean session with POST /v1/sessions {"character":"jenny","locale":"ko-KR",...}.
That session sees only the Korean greeting, voice samples, example dialogues, localized
profile, and reply language. Other locale packs are not injected into the runtime context.
Manage packs after creation with GET /v1/characters/{slug}/localizations and
PATCH /v1/characters/{slug}/localizations/{locale}.
If a developer-facing slug needs to change, use
POST /v1/characters/{slug}/rename {"slug":"new-slug"}. character_id remains the stable
identity and the old slug stays as an app-scoped alias.
The runtime may understand multilingual user input, quoted text, names, URLs, or source
material, but it does not switch the character’s reply language just because the latest
user message was in another language. If you want stable code-switching, author it
deliberately in voice_samples and example_dialogues, but still set one primary reply
language as the default visible language.
Re-publishing a single-locale slug with a different primary_reply_language returns
409 primary_reply_language_fixed. For multilingual support, add localizations instead
of creating duplicate language-specific characters.
North star: aspiration, life direction, and drama dials
The single biggest lever against a flat, aimless character is giving it a direction:
a conscious dream it is moving toward and dials that shape how its off-chat life evolves.
These keys ride inside profile (the profile object accepts extra keys — see
Extra profile keys) and are consumed at
runtime by the life-arc writer to channel the character’s evolving life toward that goal.
Note.
aspiration.summary,name, androleare required where shown; everything else is optional. All of these are vertical-agnostic — a tutor, companion, or any other character can carry a life direction, not just dating personas. Because they ride onprofile, aPATCHthat sends a newprofilereplaces it wholesale: include the full profile (these keys plus your visible fields) to change one of them.
Behavioral thresholds: selfies and proactive messages
behavioral_thresholds controls two headline behaviors: the character sending selfies,
and the character messaging first. Pass it as a flat object on POST/PATCH; omitted
keys keep the engine defaults, and a PATCH deep-merges (you only send the keys you
want to change).
Selfies are off by default — set image_proactivity above 0 to turn them on.
Proactive messaging is on by default for characters you create here: both
session_resume_enabled (re-nudge an idle chat) and cold_initiate_enabled (reach out
first after a long silence). Send "cold_initiate_enabled": false to opt out.
Proactive messages are delivered through the same session_events log as replies, so a
polling, SSE, or webhook integration all receive them — a character.initiated event. If
your characters are not reaching out, check the per-session
GET /v1/sessions/{session_id}/proactive-preferences window and daily_initiate_cap
before changing thresholds: a quiet-hours window or an exhausted daily cap suppresses a
send without changing any of these values.
Images (all default 0 = no images). Selfies stay off until you raise these and
upload an identity image (POST /v1/characters/{slug}/visual-assets):
Proactive messages. Whether the character can start or resume a conversation:
Emission and exit (advanced — how the character paces and withdraws):
withhold_propensity (float, default 0.4), emit_delay_when_low_affection (bool,
default true), withdrawal_phrasing_style ("soft" | "direct" | "cold"),
exit_boundary_violation_count (int 1..10, default 3), and re_engage_openness
(float, default 0.5). Leave these at defaults unless you are deliberately tuning tone.
Per-session override:
POST /v1/sessionsacceptsproactive_enabledat creation, andPATCH /v1/sessions/{id}/proactive-controlsacceptsproactive_enabledfor existing sessions, without changing the character.
Affordance schema: the relational domain
affordance_schema declares which relationship the engine is tracking. It defaults to
the dating domain; a built-in tutor preset swaps the dating-flavored dimensions
(affection, romantic interest, trust) for learning-flavored ones (rapport, mastery
confidence, frustration). You can pass:
- a preset name —
"dating"(default) or"tutor"; - a
{ "preset": "tutor" }pointer (equivalent); - a full inline schema for a custom domain.
Only
datingandtutorare built-in presets. There is nocompanionpreset — acompanion(or any other) domain must be passed as a full inline schema (aschema_version, adomainlabel, and a list ofdimensions, each withkey,prompt_rendering_name, and optionaldefault/range/lower_is_open). Passing an unknown preset name is rejected with422.GET /v1/characters/{slug}/runtime-previewshows the declared dimension names the engine will track.
Safety and forbidden boundaries
LoreOS spreads “what this character must never do” across a few typed surfaces. Set all of the relevant ones — they are read at different stages of generation and reply:
forbidden_style and handoff_triggers are typed list[dict] — each entry must be an
object (a list of plain strings is a 422). The schema accepts any keys, but for
forbidden_style only expression / pattern_description / anchor_tier are actually read
at runtime (arbitrary keys like rule/severity are stored but ignored). The exact accepted
shape, the consumed keys, and the string-vs-object 422 are worked through in
Recipe → forbidden_style.
Set these together with must_not/blocked_motifs/boundaries so safety is consistent
across voice, story, and relationship.
Extra profile keys that ARE consumed at runtime
profile accepts extra keys for forward compatibility. This is a footgun: an unknown
profile key is silently preserved but never read, and you only get a soft warnings entry
(profile_unknown_keys) — not a hard error. A typo like visible_intrests becomes dead
state.
The profile keys that are not in the visible-profile schema but are consumed at runtime (so they are expected, not typos) are the north-star keys:
aspirationlife_directiondrama_intensityconcreteness_floorarc_appetitesupporting_cast
Any other extra profile key is treated as dead state. Check the authoring_readiness.warnings
for profile_unknown_keys after every create/update to catch typos early.
Readiness
Every create/get/update response includes authoring_readiness:
needs_attention does not block the API. Coding agents should treat
missing_high_impact_fields and next_authoring_actions as a structural checklist before
semantic evaluation. ready means enough expected fields are present; it does not mean those
fields are coherent, distinctive, grounded, or effective in conversation.
Publication state
publication is returned on create, validate, list, get, and update responses:
State meanings:
Agents must not use either launch_ready or runtime_available alone to decide whether a
character can be shown to real end users. Require launch_ready for structural/operational setup
and a separate passing conversation-quality eval for the intended cohort.
For POST /v1/characters/bulk, use results[].status only for per-item
transport outcome: ok means the item created or republished, error means the
item failed while other items could still succeed. Successful items include
character_status and publication for runtime/publish readiness. Do not treat
results[].status as the character publish state.
How readiness is scored
authoring_readiness is computed from fourteen presence checks, and the rule is exact:
authoring_quality_score is a field-completeness percentage, not a persona, voice, grounding, or
conversation-quality score. Generic or contradictory content can score 100. The response therefore
states semantic_quality_evaluated=false.
authoring_quality_score= the percentage of the fourteen checks that are present (so each check is worth ~7 points; e.g. 13/14 present rounds to93).- The fourteen checks are:
profile.visible_bio,profile.visible_interests,profile.identity_anchors,voice_samples >= 3,example_dialogues >= 2,greeting,onboarding,character_direction,life_template,story_engine,world_model_seed,arc_seeds,relationship_seed,emotion_baseline. statusis"ready"only when the score is>= 85and there are zerowarnings. Otherwise it is"needs_attention".
voice_samples and example_dialogues are deliberately separate. A voice sample is a single
line that teaches phrase shape. An example dialogue is a short exchange that teaches how the
character handles user pressure, curiosity, flirting, ordinary check-ins, and topic pivots.
Runtime treats example dialogues as private style-and-pacing guidance, not as events that
happened in canon.
Four warnings can hold you at needs_attention even with a decent score:
needs_loreos_depth— fires if any of the seven “depth” fields is missing (character_direction,life_template,story_engine,world_model_seed,arc_seeds,relationship_seed,emotion_baseline). This is the warning that makes “ready” require real depth, not just a high score.voice_samples_few— fires with fewer than 3 voice samples.example_dialogues_few— fires with fewer than 2 user↔character example exchanges.profile_unknown_keys— fires on a profile key the engine doesn’t recognize (a typo). The north-star keys do not trip it (see Extra profile keys).
The scorer only ever returns "ready" or "needs_attention". The third documented status,
"invalid", means the body was rejected by schema validation before scoring — i.e. you
got a 422 (invalid_character / validation_error) and no readiness object at all, not a
scored "invalid".
Three tiers, and the status each one returns
Use these as the rungs to climb. The status for each is exactly what the scorer returns.
Tier 1 — smoke-test minimum → needs_attention (score 0).
The least the API accepts. Lets an agent prove the create/session/message loop, nothing more.
Returns status: "needs_attention", authoring_quality_score: 0, warnings
["needs_loreos_depth", "voice_samples_few", "example_dialogues_few"], and all fourteen checks in
missing_high_impact_fields. (Adding just a greeting nudges the score to 7 — still
needs_attention.) This character can chat but will feel generic.
Tier 2 — good chat character → still needs_attention (score ~46).
Enough to feel authored in conversation — persona, voice, an authored first message, and a direction — but not yet the full between-message life.
Returns status: "needs_attention" with authoring_quality_score around 50 and a single
needs_loreos_depth warning — because the six remaining depth fields (life_template,
story_engine, world_model_seed, arc_seeds, relationship_seed, emotion_baseline) are
still missing. A character at this tier chats well but its off-chat life stays thin.
Tier 3 — production character → ready (score up to 100).
Every block filled. This is the level the platform’s life/Story-Room engine needs to give the character a real, directed life between messages.
Fill all fourteen checks (a complete, copy-paste body is in Recipe: a production-grade dating character). With all fourteen present and no warnings, the response is:
onboarding is the one check you can omit and still reach ready: with the other thirteen
present (all six depth fields, 3+ voice samples, 2+ example dialogues, greeting, and the
three profile keys) the score is 93 and there are no warnings, so status is ready.
Add onboarding anyway — it’s private self-knowledge the character draws on for planning —
but know it isn’t the gate.
Climbing from needs_attention to ready
- Add the three
profilekeys (visible_bio,visible_interests,identity_anchors). - Add 3 or more
voice_samples(each withtext) → clearsvoice_samples_few. - Add 2 or more
example_dialogues(each with bothuserandcharacterturns) → clearsexample_dialogues_few. - Add a
greetingso the first message is authored. - Add all seven depth fields —
character_direction,life_template,story_engine,world_model_seed,arc_seeds,relationship_seed,emotion_baseline→ clearsneeds_loreos_depth(a partial set does not clear it). - Re-run
POST /v1/characters/validate(a dry run; same scoring, no write) untilwarnings: []andauthoring_quality_score >= 85. Watch forprofile_unknown_keys— it means a profile key is misspelled.
What happens at runtime
When you start a session:
- LoreOS emits
greeting.bubblesascharacter.initiated. - Runtime chat receives profile, voice, and deeper authoring fields as private planning context.
- The model may use that context for tone, pacing, life rhythm, and plausible daily details.
- The model may not quote or reveal private authoring context as if it were a committed event, user consent, or future plan.
Story Room consumes these fields directly today. life_template feeds daily-life
planning, story_engine feeds recurring event sources and supporting cast,
arc_seeds feed soft weekly direction, and character_direction,
relationship_seed, and emotion_baseline shape the tone and constraints of the
generated life. The north-star keys (aspiration / life_direction / the dials)
channel where that life is heading. These are not “planning-only” placeholders —
they drive the weekly and daily Story Room generation that the runtime then speaks
from.
Good authoring checklist
primary_reply_languageis explicit and matches the language users should see.- Multilingual characters use
localizations; they are not duplicated per language. profile.visible_bionames a concrete life, not “a friendly person.”profile.visible_interestshas at least three specific interests.profile.identity_anchorshas stable identity/style anchors.voice_sampleshas at least three lines, ideally five or more, in the same language asprimary_reply_languageunless code-switching is intentionally authored.example_dialogueshas at least two compact user↔character exchanges: one ordinary daily-share/check-in and one pressure/boundary/pivot case.greetingis specific enough to feel authored.character_direction.promisesays what kind of character experience you are promising.life_templateincludes real day blocks: work, meals, hobbies, obligations, social contact, exercise, recovery, or rest.story_engine.allowed_event_sourcesincludes low-risk friction and social contact sources.relationship_seedandemotion_baselineset the starting posture without forcing the user.profile.aspiration+life_directiongive the character a direction so its off-chat life is not flat or aimless.behavioral_thresholdsare set deliberately if you want selfies (raiseimage_proactivity/daily_image_capand upload an identity image) or proactive messages (cold_initiate_enabled/daily_initiate_cap).affordance_schemamatches the relationship you are building (datingdefault,tutorpreset, or a full inline schema for any other domain).- Safety boundaries are set across
must_not,blocked_motifs,boundaries,forbidden_style, andhandoff_triggers.
Common mistakes
- Assuming
localecontrols speech. Useprimary_reply_languagefor visible reply language on single-locale characters, orlocalizations[locale].reply_languagefor multilingual characters. - Duplicating the same character per language. Use one character with locale packs.
- Mixing English, Korean, Japanese, or Chinese voice samples accidentally inside one locale pack. Mixed-language anchors should be intentional.
- Putting
character_directioninsideprofile. It is a top-level field. - Sending
voice_sampleswithouttext. That is a validation error. - Treating
arc_seedsas a script. They are soft directions and must allow user intervention. - Using only visual texture. A character also needs obligations, relationships, and ordinary activities.
- Putting secret future plans in speakable fields like
visible_bio.