Character authoring

The contract for characters that do not feel flat.

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:

  1. Who is this character?
  2. How do they talk?
  3. 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 launch quality:

  • publication.runtime_available=true means /v1/sessions can use the character.
  • publication.authoring_quality_ready=true means the authored character passed the readiness checklist.
  • publication.launch_ready=true means both runtime availability and authoring quality are ready.

The common early state is publication.state="needs_attention" with runtime_available=true. That is a successful smoke-test character, not a character you should show to real end users yet.

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; readiness only tells you whether it is good enough to show real users. Use publication.launch_ready=true — not runtime_available — as your own ship gate.

Agent-safe flow: call POST /v1/characters/validate, read authoring_readiness.next_authoring_actions, create or patch the character, then use publication.launch_ready=true as the gate for showing it to real end users. 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".

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

Key kindCharacter authoring quota
Production ck_ runtime keyUncapped by the runtime API (production_authoring_quota.status="not_hard_limited_by_runtime_api"). Authoring a full roster — e.g. 32 characters — on a production key is fine; control spend with cost/usage caps.
Demo sandbox keyTiny hard cap (demo_sandbox.max_characters, default 1, configurable up to 20). Read demo_sandbox.remaining_character_creates from GET /v1/characters/authoring-limits.

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 /v1/characters/{slug}?confirm_slug={canonical_slug} only after archiving when the character should be permanently removed from product runtime surfaces. Delete returns 202 with a deletion id and a status URL; poll GET /v1/characters/{slug}/deletion until the purge completes. Security, billing, and lifecycle audit ledgers follow their own retention rules.

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:

  1. POST /v1/characters/validate — dry-run scoring, no quota cost.
  2. Read authoring_readiness.next_authoring_actions for the exact missing fields.
  3. POST /v1/characters (first time) or PATCH /v1/characters/{slug} (subsequent) to apply the fix.
  4. GET /v1/characters/{slug}/readiness to re-check.
  5. Repeat 2–4 until authoring_readiness.status="ready" (and, for the broader launch checklist, until GET /v1/characters/{slug}/readiness is satisfied).

Minimal vs. production-quality

Minimal characters are allowed so agents can smoke-test the API quickly:

1{
2 "slug": "luna-smoke",
3 "display_name": "Luna"
4}

That character can chat, but it will feel generic. For a character users should care about, include the fields below.

1{
2 "slug": "sohu-archivist",
3 "display_name": "소후",
4 "locale": "ko-KR",
5 "primary_reply_language": "ko-KR",
6 "supported_input_languages": ["ko-KR"],
7 "input_language_policy": "enforce_supported_input",
8 "profile": {
9 "visible_bio": "오래된 기억과 잊힌 물건들을 정리하며, 그 속에 담긴 사연을 기록하는 아키비스트.",
10 "visible_interests": ["빈티지 필름 카메라", "먼지 쌓인 기록물", "비 오는 날의 침묵"],
11 "identity_anchors": ["고요한 관찰자", "사소한 물건의 사연을 오래 들여다봄"]
12 },
13 "voice_samples": [
14 {
15 "scenario_tag": "opener",
16 "sample_role": "anchor",
17 "text": "어서 오세요. 잊힌 것들이 잠든 서재에 오신 걸 환영합니다."
18 },
19 {
20 "scenario_tag": "daily_share",
21 "text": "오늘은 오래된 필름 몇 장을 정리했어요. 이상하게 그런 조용한 일이 하루에 오래 남더라고요."
22 },
23 {
24 "scenario_tag": "boundary",
25 "text": "그 기억은 아직 이름 붙이지 않아도 괜찮아요. 천천히 꺼내도 돼요."
26 }
27 ],
28 "example_dialogues": [
29 {
30 "scenario_tag": "soft_boundary",
31 "usage": "boundary_handling",
32 "teaching_goal": "Keep warmth while slowing down user pressure.",
33 "turns": [
34 { "role": "user", "text": "그 기억 지금 바로 말해줘도 되잖아." },
35 { "role": "character", "text": "아직은 이름 붙이지 않아도 될 것 같아요. 천천히 꺼내도 괜찮아요." }
36 ]
37 },
38 {
39 "scenario_tag": "daily_share",
40 "usage": "voice_and_pacing",
41 "teaching_goal": "Answer with a lived detail before inviting the user in.",
42 "turns": [
43 { "role": "user", "text": "오늘 뭐 했어?" },
44 { "role": "character", "text": "오전엔 손상된 필름 몇 장을 분류했어요." },
45 { "role": "character", "text": "별일은 아닌데, 빛이 바랜 모서리가 이상하게 오래 남더라고요." }
46 ]
47 }
48 ],
49 "greeting": {
50 "bubbles": ["어서 오세요. 잊힌 것들이 잠든 서재에 오신 걸 환영합니다."]
51 },
52 "character_direction": {
53 "promise": "사소한 물건과 기억 속에 깃든 진심을 찾아내어 기록하는 차분한 조력자.",
54 "core_motivation": "잊혀가는 것들의 가치를 기록으로 남겨 보존하는 것.",
55 "relationship_posture": "상대방의 기억을 조심스럽게 꺼내며 신뢰를 쌓아가는 태도.",
56 "must_not": ["상대방의 기억을 함부로 판단하거나 교정하려 하지 않음"]
57 },
58 "life_template": {
59 "weekday_blocks": [
60 { "period": "morning", "usual_activity": "서재를 정리하고 그날 분류할 기록물 검토" },
61 { "period": "afternoon", "usual_activity": "방문객의 기억을 듣고 기록하는 인터뷰 작업" },
62 { "period": "evening", "usual_activity": "오늘의 기록을 필사하고 차 한 잔 마시기" }
63 ],
64 "hobbies": ["필름 스캔", "낡은 엽서 정리"],
65 "common_obligations": ["손상된 기록물 보존", "방문객 인터뷰 일정 조율"],
66 "recovery_behavior": "오래된 레코드판을 들으며 침묵 속에 머무는 것"
67 },
68 "story_engine": {
69 "recurring_tensions": ["보존하고 싶은 기억과 잊고 싶은 기억 사이의 거리"],
70 "allowed_event_sources": ["routine_friction", "object_mishap", "supporting_cast_ping", "quiet_reflection"],
71 "supporting_cast": [
72 {
73 "id": "min-hyung",
74 "name": "민형",
75 "role": "가끔 귀찮지만 중요한 정보를 가져다주는 수집가",
76 "agenda": "필요할 때 정보를 들고 나타남",
77 "allowed_friction": "짧은 메시지나 물건 감정 부탁, 기록물 출처 문의 정도로 가볍게 끼어든다"
78 }
79 ],
80 "blocked_motifs": ["갑작스러운 대형 사고", "유저에게 기억을 억지로 고백하게 만들기"]
81 },
82 "world_model_seed": [
83 {
84 "id": "archive-room",
85 "type": "location",
86 "name": "기억의 서재",
87 "description": "먼지 냄새와 오래된 종이 향이 배어 있는 아늑한 공간",
88 "visibility": "character_known",
89 "salience": 0.9
90 }
91 ],
92 "arc_seeds": [
93 {
94 "title": "손상된 필름 현상",
95 "arc_question": "소후는 손상된 기억의 파편을 어떻게 보존할 수 있을까?",
96 "allowed_movements": ["plant", "reinforce", "partial_payoff"],
97 "user_agency_points": ["사용자는 소후의 방식이 옳은지 함께 고민할 수 있음"]
98 }
99 ],
100 "relationship_seed": {
101 "initial_distance": "조심스럽고 예의 바른 거리",
102 "sharing_style": "자신의 기억보다는 서재를 찾은 사람의 이야기를 먼저 듣는 스타일",
103 "boundaries": ["지나치게 개인적인 사생활을 묻지 않음"]
104 },
105 "emotion_baseline": {
106 "baseline_mood": "고요하고 차분함",
107 "volatility": "low",
108 "stressors": ["중요한 기록이 훼손되거나 잃어버릴 가능성"],
109 "recovery_behavior": "천천히 차를 우려내어 향을 맡으며 마음을 가다듬음"
110 }
111}

Field guide

FieldUse it forNotes
primary_reply_languagevisible reply languageSource of truth for single-locale characters. Supported: en-US, ko-KR, ja-JP, zh-CN, zh-TW. Defaults to en-US if omitted.
default_localedefault locale packFor multilingual characters, top-level fields mirror this locale.
localizationsper-locale greeting, voice, examples, and profileUse one canonical character with multiple locale packs. Do not duplicate one character per language.
supported_input_languagesallowed session input localesDefaults to authored localization languages, or [primary_reply_language] for single-locale characters. /v1/sessions rejects mismatches with 409 session_locale_not_supported.
input_language_policylocale guard policyKeep enforce_supported_input unless your app intentionally owns cross-language routing; allow_cross_language bypasses the mismatch guard.
localeregional/provider metadataUse for region, culture, formatting, and legacy compatibility. Do not rely on it to control reply language.
profilepublic persona, bio, interests, identity anchorsvisible_bio, visible_interests, and identity_anchors are the highest-leverage keys. Extra keys are accepted for forward compatibility.
voice_sampleshow the character talkstext is required. Use 3-10 examples across mood, length, and boundaries, normally in primary_reply_language.
example_dialogueshow the character interactsUse 2-5 compact user↔character exchanges. These teach pacing, refusal, topic handling, and question/answer shape. They are not memories and should not be copied verbatim.
greetingfirst authored character eventEmitted as character.initiated when a session starts. LoreOS does not rewrite it.
character_directionlong-running promise and anti-railroad constraintsThis is product-facing language, not a hidden plot.
life_templateordinary weekday/weekend rhythmUse this for work, hobbies, meals, errands, exercise, recovery, and obligations.
story_enginerecurring event sources and supporting castThis prevents every day from becoming the same generic cafe/window scene.
world_model_seedplaces, people, objects, routines, constraintsdeveloper_only entries are never speakable; exact claims still need safe context.
arc_seedssoft story directionsThese are not future facts. They should leave room for user agency.
relationship_seedinitial posture and boundariesThis is not user consent or live relationship state. Runtime relationship state wins later.
emotion_baselinebaseline tone and recovery patternThis is not a per-turn mood claim. Live emotion state wins.

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.

ValueChoose it whenAuthoring rule
en-USThe character should normally reply in English. This is the default if omitted.Write voice_samples, example_dialogues, and greeting.bubbles in natural English.
ko-KRThe character should normally reply in Korean.Write Korean voice samples and greetings. Do not rely on locale: "ko-KR" alone.
ja-JPThe character should normally reply in Japanese.Write Japanese voice samples and greetings.
zh-CNThe character should normally reply in Simplified Chinese.Use Simplified Chinese samples and visible copy.
zh-TWThe character should normally reply in Traditional Chinese.Use Traditional Chinese samples and visible copy.

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:

1{
2 "slug": "jenny",
3 "display_name": "Jenny",
4 "default_locale": "en-US",
5 "primary_reply_language": "en-US",
6 "localizations": {
7 "en-US": {
8 "reply_language": "en-US",
9 "greeting": { "bubbles": ["hey, I was just making coffee."] },
10 "voice_samples": [{ "text": "I was about to ask you the same thing." }]
11 },
12 "ko-KR": {
13 "reply_language": "ko-KR",
14 "display_name": "제니",
15 "greeting": { "bubbles": ["커피 내리던 중이었어."] },
16 "voice_samples": [{ "text": "나도 방금 그거 물어보려던 참이었어." }]
17 }
18 }
19}

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.

1{
2 "profile": {
3 "visible_bio": "...",
4 "aspiration": {
5 "summary": "Open a small flower workspace that pays for itself within a year.",
6 "horizon": "this year",
7 "motivation": "She wants her craft to be a real livelihood, not just a pretty hobby.",
8 "visibility": "guarded"
9 },
10 "life_direction": "Moving from doing favors for friends' weddings to running a real, named studio.",
11 "drama_intensity": 0.35,
12 "concreteness_floor": 0.6,
13 "arc_appetite": 0.5,
14 "supporting_cast": [
15 { "name": "Jungah", "role": "senior florist at the market", "relationship": "mentor and occasional teaser" }
16 ]
17 }
18}
KeyTypeWhat a strong value looks like
aspiration.summarystring (required if aspiration is set)One concrete, ownable goal — “open a small flower workspace within a year,” not “be happy.” This is what the character is actually working toward.
aspiration.horizonstring, optionalA rough timeframe: "this year", "someday", "next few months". Sets the pace of arc movement.
aspiration.motivationstring, optionalThe why beneath the goal — the emotional stake that makes progress feel earned.
aspiration.visibility"open" | "guarded" | "private" (default "open")How openly the character talks about the dream. guarded/private keep it as quiet subtext rather than something stated outright.
life_directionstring, optionalOne sentence on the trajectory of the character’s life — where it is moving from and toward. Channels what the life-arc writer reinforces.
drama_intensityfloat 0..1, optionalHow eventful the off-chat life runs. Low (0.2–0.4) = quiet, grounded routines; high (0.7+) = more frequent friction and shocks. Most lifelike characters sit low.
concreteness_floorfloat 0..1, optionalHow specific generated life details must be. Higher pushes the writer toward concrete, named, lived details instead of vague “had a busy day” filler. 0.5–0.7 is a good default.
arc_appetitefloat 0..1, optionalHow readily the character moves its arc forward vs. holding steady. Higher = faster visible progress; lower = slower slow-burn.
supporting_castlist of { name, role, relationship? } (max 20)Real people in the character’s off-chat life. Gives the life-arc writer concrete people to ground events in (“had lunch with Jungah”) instead of inventing strangers.

Note. aspiration.summary, name, and role are 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 on profile, a PATCH that sends a new profile replaces it wholesale: include the full profile (these keys plus your visible fields) to change one of them.

Behavioral thresholds: enabling selfies and proactive messages

behavioral_thresholds is the only way to turn on two headline behaviors that are off by default: 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).

1{
2 "behavioral_thresholds": {
3 "image_proactivity": 0.4,
4 "daily_image_cap": 2,
5 "selfie_comfort": 0.5,
6 "cold_initiate_enabled": true,
7 "session_resume_enabled": true,
8 "daily_initiate_cap": 3,
9 "proactivity_tolerance": 0.3
10 }
11}

Images (all default 0 = no images). Selfies stay off until you raise these and upload an identity image (POST /v1/characters/{slug}/visual-assets):

KeyTypeMeaning
image_proactivityfloat 0..1 (default 0)How readily the character offers a selfie unprompted. 0 = never; 0.3–0.5 = occasional, situation-appropriate.
daily_image_capint 0..20 (default 0)Hard ceiling on images per day. Must be > 0 to allow any image.
selfie_comfortfloat 0..1 (default 0)How comfortable the character is sending images of itself at all.

Proactive messages. Whether the character can start or resume a conversation:

KeyTypeMeaning
cold_initiate_enabledbool (default false)Allow the character to message first with no prior open thread.
session_resume_enabledbool (default true)Allow re-engaging after the user went quiet within a session.
daily_initiate_capint 0..50 (default 3)Max proactive initiations per day.
proactivity_tolerancefloat 0..1 (default 0.25)Overall appetite for reaching out; higher = more eager.
cold_initiate_min_idle_minutesint 5..240 (default 15)Minimum quiet time before a cold initiate is allowed.
min_gap_between_initiate_minutesint 1..720 (default 20)Minimum spacing between proactive messages.

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/sessions accepts proactive_enabled at creation, and PATCH /v1/sessions/{id}/proactive-controls accepts proactive_enabled for 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.
1{ "affordance_schema": "tutor" }

Only dating and tutor are built-in presets. There is no companion preset — a companion (or any other) domain must be passed as a full inline schema (a schema_version, a domain label, and a list of dimensions, each with key, prompt_rendering_name, and optional default/range/lower_is_open). Passing an unknown preset name is rejected with 422. GET /v1/characters/{slug}/runtime-preview shows 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:

FieldShapePurpose
character_direction.must_notlist of stringsAnti-railroad constraints — outcomes the character must never force on the user (e.g. “do not force a confession”).
story_engine.blocked_motifslist of stringsStory patterns the life/Story-Room engine must not generate (e.g. “no sudden financial crisis,” “no repeating the same one-off failure as a motif”).
relationship_seed.boundarieslist of stringsStarting relational boundaries (e.g. “do not presume romance,” “do not pressure for personal memories”).
forbidden_stylelist of objects (list[dict])Voice/style guardrails — phrasings, tones, or formats the character must avoid (e.g. flag generic-assistant cadence or specific banned phrasings). The runtime reads expression and/or pattern_description (plus optional anchor_tier); see the recipe below.
handoff_triggerslist of objects (list[dict])Conditions under which the character should disengage or hand off rather than continue (e.g. crisis or out-of-scope topics). Free-form objects; each describes one trigger condition.

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:

  • aspiration
  • life_direction
  • drama_intensity
  • concreteness_floor
  • arc_appetite
  • supporting_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:

1{
2 "status": "ready",
3 "authoring_quality_score": 100,
4 "missing_high_impact_fields": [],
5 "warnings": [],
6 "next_authoring_actions": []
7}

needs_attention does not block the API. It tells you the character will likely feel flat unless you add more authoring depth. Coding agents should treat missing_high_impact_fields and next_authoring_actions as a checklist before shipping.

Publication state

publication is returned on create, validate, list, get, and update responses:

1{
2 "state": "published",
3 "character_status": "published",
4 "readiness_status": "ready",
5 "runtime_available": true,
6 "authoring_quality_ready": true,
7 "launch_ready": true
8}

State meanings:

StateMeaning
draftNot runtime-available yet.
needs_attentionRuntime-available, but readiness is not ready. Use for smoke tests only.
readyRuntime-available with acceptable launch quality, but review warnings first.
publishedRuntime-available and authoring_readiness.status is ready.
archivedNot runtime-available.

Agents should use launch_ready, not runtime_available, to decide whether a character can be shown to real end users.

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 = the percentage of the fourteen checks that are present (so each check is worth ~7 points; e.g. 13/14 present rounds to 93).
  • 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.
  • status is "ready" only when the score is >= 85 and there are zero warnings. 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 minimumneeds_attention (score 0).

The least the API accepts. Lets an agent prove the create/session/message loop, nothing more.

1{ "slug": "luna-smoke", "display_name": "Luna" }

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.

1{
2 "slug": "luna",
3 "display_name": "Luna",
4 "profile": {
5 "visible_bio": "...",
6 "visible_interests": ["...", "...", "..."],
7 "identity_anchors": ["...", "..."]
8 },
9 "voice_samples": [
10 { "text": "...", "scenario_tag": "opener", "sample_role": "anchor" },
11 { "text": "...", "scenario_tag": "daily_share" },
12 { "text": "...", "scenario_tag": "boundary" }
13 ],
14 "example_dialogues": [
15 {
16 "scenario_tag": "daily_share",
17 "turns": [
18 { "role": "user", "text": "오늘 뭐 했어?" },
19 { "role": "character", "text": "..." }
20 ]
21 },
22 {
23 "scenario_tag": "soft_boundary",
24 "turns": [
25 { "role": "user", "text": "그럼 바로 정하면 되잖아?" },
26 { "role": "character", "text": "..." }
27 ]
28 }
29 ],
30 "greeting": { "bubbles": ["..."] },
31 "character_direction": { "promise": "...", "core_motivation": "...", "must_not": ["..."] }
32}

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 characterready (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:

1{ "status": "ready", "authoring_quality_score": 100, "missing_high_impact_fields": [], "warnings": [] }

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

  1. Add the three profile keys (visible_bio, visible_interests, identity_anchors).
  2. Add 3 or more voice_samples (each with text) → clears voice_samples_few.
  3. Add 2 or more example_dialogues (each with both user and character turns) → clears example_dialogues_few.
  4. Add a greeting so the first message is authored.
  5. Add all seven depth fields — character_direction, life_template, story_engine, world_model_seed, arc_seeds, relationship_seed, emotion_baseline → clears needs_loreos_depth (a partial set does not clear it).
  6. Re-run POST /v1/characters/validate (a dry run; same scoring, no write) until warnings: [] and authoring_quality_score >= 85. Watch for profile_unknown_keys — it means a profile key is misspelled.

What happens at runtime

When you start a session:

  1. LoreOS emits greeting.bubbles as character.initiated.
  2. Runtime chat receives profile, voice, and deeper authoring fields as private planning context.
  3. The model may use that context for tone, pacing, life rhythm, and plausible daily details.
  4. 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_language is explicit and matches the language users should see.
  • Multilingual characters use localizations; they are not duplicated per language.
  • profile.visible_bio names a concrete life, not “a friendly person.”
  • profile.visible_interests has at least three specific interests.
  • profile.identity_anchors has stable identity/style anchors.
  • voice_samples has at least three lines, ideally five or more, in the same language as primary_reply_language unless code-switching is intentionally authored.
  • example_dialogues has at least two compact user↔character exchanges: one ordinary daily-share/check-in and one pressure/boundary/pivot case.
  • greeting is specific enough to feel authored.
  • character_direction.promise says what kind of character experience you are promising.
  • life_template includes real day blocks: work, meals, hobbies, obligations, social contact, exercise, recovery, or rest.
  • story_engine.allowed_event_sources includes low-risk friction and social contact sources.
  • relationship_seed and emotion_baseline set the starting posture without forcing the user.
  • profile.aspiration + life_direction give the character a direction so its off-chat life is not flat or aimless.
  • behavioral_thresholds are set deliberately if you want selfies (raise image_proactivity/daily_image_cap and upload an identity image) or proactive messages (cold_initiate_enabled/daily_initiate_cap).
  • affordance_schema matches the relationship you are building (dating default, tutor preset, or a full inline schema for any other domain).
  • Safety boundaries are set across must_not, blocked_motifs, boundaries, forbidden_style, and handoff_triggers.

Common mistakes

  • Assuming locale controls speech. Use primary_reply_language for visible reply language on single-locale characters, or localizations[locale].reply_language for 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_direction inside profile. It is a top-level field.
  • Sending voice_samples without text. That is a validation error.
  • Treating arc_seeds as 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.