> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.loreos.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.loreos.app/_mcp/server.

# 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:

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](#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=true` means `/v1/sessions` can use the character.
* `publication.authoring_quality_ready=true` means the authored character passed
  the deterministic field-presence checklist.
* `publication.launch_ready=true` means 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.

> **Note**
>
> **`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:

```json
{
  "background_features": { "auto_activate": true }
}
```

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:

```json
{
  "background_features": { "auto_activate": false }
}
```

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

| Key kind                     | Character authoring quota                                                                                                                                                                                                |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Production `ck_` runtime key | **Uncapped** 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 key             | **Tiny 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 only when the character should be permanently removed from product runtime surfaces.
The preferred path is archive first, then explicitly confirm the canonical slug:

```bash
curl -sS -X POST "https://api.loreos.app/v1/characters/$SLUG/archive" \
  -H "Authorization: Bearer $LOREOS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason":"retiring this test character"}'

curl -sS -X DELETE \
  "https://api.loreos.app/v1/characters/$SLUG?confirm_slug=$SLUG&force=false&reason=completed%20test%20cleanup" \
  -H "Authorization: Bearer $LOREOS_KEY"
```

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.

| Lifecycle action           | Product state removed or closed                                                                                        | Intentionally retained                                                                        |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Character archive          | Runtime availability and active background execution                                                                   | Character versions, sessions, memories, slug, and audit history                               |
| Character permanent delete | Authored versions, character-owned content/assets, active background rows; related sessions become exited/soft-deleted | Minimal slug tombstone plus security/billing/lifecycle and immutable actor-core audit history |
| Session reset              | Nothing is erased; a new session is created                                                                            | Previous session, memory, canon, usage, and actor history                                     |
| External-user delete       | User-facing/current user access is soft-deleted                                                                        | Required audit, billing, security, and retention-bound history                                |
| App/workspace closure      | Keys retire and all current actor/binding/interaction/state rows become inactive or archived with `valid_until`        | Immutable actor-core event/audit history; rows are not hard-deleted around foreign keys       |

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:

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 the structural and broader operational checklists are satisfied.
6. Run the same 6-10 turn Conversation Eval dataset and review identity, voice, boundary,
   grounding, continuity, and non-generic usefulness.
7. 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:

```json
{
  "slug": "luna-smoke",
  "display_name": "Luna"
}
```

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

```json
{
  "slug": "sohu-archivist",
  "display_name": "소후",
  "locale": "ko-KR",
  "primary_reply_language": "ko-KR",
  "supported_input_languages": ["ko-KR"],
  "input_language_policy": "enforce_supported_input",
  "profile": {
    "visible_bio": "오래된 기억과 잊힌 물건들을 정리하며, 그 속에 담긴 사연을 기록하는 아키비스트.",
    "visible_interests": ["빈티지 필름 카메라", "먼지 쌓인 기록물", "비 오는 날의 침묵"],
    "identity_anchors": ["고요한 관찰자", "사소한 물건의 사연을 오래 들여다봄"]
  },
  "voice_samples": [
    {
      "scenario_tag": "opener",
      "sample_role": "anchor",
      "text": "어서 오세요. 잊힌 것들이 잠든 서재에 오신 걸 환영합니다."
    },
    {
      "scenario_tag": "daily_share",
      "text": "오늘은 오래된 필름 몇 장을 정리했어요. 이상하게 그런 조용한 일이 하루에 오래 남더라고요."
    },
    {
      "scenario_tag": "boundary",
      "text": "그 기억은 아직 이름 붙이지 않아도 괜찮아요. 천천히 꺼내도 돼요."
    }
  ],
  "example_dialogues": [
    {
      "scenario_tag": "soft_boundary",
      "usage": "boundary_handling",
      "teaching_goal": "Keep warmth while slowing down user pressure.",
      "turns": [
        { "role": "user", "text": "그 기억 지금 바로 말해줘도 되잖아." },
        { "role": "character", "text": "아직은 이름 붙이지 않아도 될 것 같아요. 천천히 꺼내도 괜찮아요." }
      ]
    },
    {
      "scenario_tag": "daily_share",
      "usage": "voice_and_pacing",
      "teaching_goal": "Answer with a lived detail before inviting the user in.",
      "turns": [
        { "role": "user", "text": "오늘 뭐 했어?" },
        { "role": "character", "text": "오전엔 손상된 필름 몇 장을 분류했어요." },
        { "role": "character", "text": "별일은 아닌데, 빛이 바랜 모서리가 이상하게 오래 남더라고요." }
      ]
    }
  ],
  "greeting": {
    "bubbles": ["어서 오세요. 잊힌 것들이 잠든 서재에 오신 걸 환영합니다."]
  },
  "character_direction": {
    "promise": "사소한 물건과 기억 속에 깃든 진심을 찾아내어 기록하는 차분한 조력자.",
    "core_motivation": "잊혀가는 것들의 가치를 기록으로 남겨 보존하는 것.",
    "relationship_posture": "상대방의 기억을 조심스럽게 꺼내며 신뢰를 쌓아가는 태도.",
    "must_not": ["상대방의 기억을 함부로 판단하거나 교정하려 하지 않음"]
  },
  "life_template": {
    "weekday_blocks": [
      { "period": "morning", "usual_activity": "서재를 정리하고 그날 분류할 기록물 검토" },
      { "period": "afternoon", "usual_activity": "방문객의 기억을 듣고 기록하는 인터뷰 작업" },
      { "period": "evening", "usual_activity": "오늘의 기록을 필사하고 차 한 잔 마시기" }
    ],
    "hobbies": ["필름 스캔", "낡은 엽서 정리"],
    "common_obligations": ["손상된 기록물 보존", "방문객 인터뷰 일정 조율"],
    "recovery_behavior": "오래된 레코드판을 들으며 침묵 속에 머무는 것"
  },
  "story_engine": {
    "recurring_tensions": ["보존하고 싶은 기억과 잊고 싶은 기억 사이의 거리"],
    "allowed_event_sources": ["routine_friction", "object_mishap", "supporting_cast_ping", "quiet_reflection"],
    "supporting_cast": [
      {
        "id": "min-hyung",
        "name": "민형",
        "role": "가끔 귀찮지만 중요한 정보를 가져다주는 수집가",
        "agenda": "필요할 때 정보를 들고 나타남",
        "allowed_friction": "짧은 메시지나 물건 감정 부탁, 기록물 출처 문의 정도로 가볍게 끼어든다"
      }
    ],
    "blocked_motifs": ["갑작스러운 대형 사고", "유저에게 기억을 억지로 고백하게 만들기"]
  },
  "world_model_seed": [
    {
      "id": "archive-room",
      "type": "location",
      "name": "기억의 서재",
      "description": "먼지 냄새와 오래된 종이 향이 배어 있는 아늑한 공간",
      "visibility": "character_known",
      "salience": 0.9
    }
  ],
  "arc_seeds": [
    {
      "title": "손상된 필름 현상",
      "arc_question": "소후는 손상된 기억의 파편을 어떻게 보존할 수 있을까?",
      "allowed_movements": ["plant", "reinforce", "partial_payoff"],
      "user_agency_points": ["사용자는 소후의 방식이 옳은지 함께 고민할 수 있음"]
    }
  ],
  "relationship_seed": {
    "initial_distance": "조심스럽고 예의 바른 거리",
    "sharing_style": "자신의 기억보다는 서재를 찾은 사람의 이야기를 먼저 듣는 스타일",
    "boundaries": ["지나치게 개인적인 사생활을 묻지 않음"]
  },
  "emotion_baseline": {
    "baseline_mood": "고요하고 차분함",
    "volatility": "low",
    "stressors": ["중요한 기록이 훼손되거나 잃어버릴 가능성"],
    "recovery_behavior": "천천히 차를 우려내어 향을 맡으며 마음을 가다듬음"
  }
}
```

## Field guide

| Field                       | Use it for                                         | Notes                                                                                                                                                                               |
| --------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primary_reply_language`    | visible reply language                             | Source of truth for single-locale characters. Supported: `en-US`, `ko-KR`, `ja-JP`, `zh-CN`, `zh-TW`. Defaults to `en-US` if omitted.                                               |
| `default_locale`            | default locale pack                                | For multilingual characters, top-level fields mirror this locale.                                                                                                                   |
| `localizations`             | per-locale greeting, voice, examples, and profile  | Use one canonical character with multiple locale packs. Do not duplicate one character per language.                                                                                |
| `supported_input_languages` | allowed session input locales                      | Defaults to authored localization languages, or `[primary_reply_language]` for single-locale characters. `/v1/sessions` rejects mismatches with `409 session_locale_not_supported`. |
| `input_language_policy`     | locale guard policy                                | Keep `enforce_supported_input` unless your app intentionally owns cross-language routing; `allow_cross_language` bypasses the mismatch guard.                                       |
| `locale`                    | regional/provider metadata                         | Use for region, culture, formatting, and legacy compatibility. Do not rely on it to control reply language.                                                                         |
| `profile`                   | public persona, bio, interests, identity anchors   | `visible_bio`, `visible_interests`, and `identity_anchors` are the highest-leverage keys. Extra keys are accepted for forward compatibility.                                        |
| `voice_samples`             | how the character talks                            | `text` is required. Use 3-10 examples across mood, length, and boundaries, normally in `primary_reply_language`.                                                                    |
| `example_dialogues`         | how the character interacts                        | Use 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.          |
| `greeting`                  | first authored character event                     | Emitted as `character.initiated` when a session starts. LoreOS does not rewrite it.                                                                                                 |
| `character_direction`       | long-running promise and anti-railroad constraints | This is product-facing language, not a hidden plot.                                                                                                                                 |
| `life_template`             | ordinary weekday/weekend rhythm                    | Use this for work, hobbies, meals, errands, exercise, recovery, and obligations.                                                                                                    |
| `story_engine`              | recurring event sources and supporting cast        | This prevents every day from becoming the same generic cafe/window scene.                                                                                                           |
| `world_model_seed`          | places, people, objects, routines, constraints     | `developer_only` entries are never speakable; exact claims still need safe context.                                                                                                 |
| `arc_seeds`                 | soft story directions                              | These are not future facts. They should leave room for user agency.                                                                                                                 |
| `relationship_seed`         | initial posture and boundaries                     | This is not user consent or live relationship state. Runtime relationship state wins later.                                                                                         |
| `emotion_baseline`          | baseline tone and recovery pattern                 | This is not a per-turn mood claim. Live emotion state wins.                                                                                                                         |

## 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:

| Dimension            | Strong authoring move                                                            | Weak move                             |
| -------------------- | -------------------------------------------------------------------------------- | ------------------------------------- |
| Identity anchors     | 2-4 stable traits that change how the character notices and decides              | “kind, smart, funny”                  |
| Value tension        | One believable contradiction, such as ambitious but protective of quiet routines | A flawless list of compatible virtues |
| Motivation           | A concrete goal with stakes and a horizon                                        | “wants to be happy”                   |
| Expertise boundary   | What they know, what they only partly know, and how they admit uncertainty       | Omniscient confidence                 |
| Relationship posture | How trust is earned, how quickly intimacy grows, and what they will not presume  | Instant intimacy with every user      |
| Ordinary life        | Recurring obligations, people, places, and recovery habits                       | Only dramatic plot hooks              |

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.

```json
{
  "profile": {
    "identity_anchors": [
      "notices practical details before naming feelings",
      "warm after evidence of care, never instantly intimate",
      "admits uncertainty instead of filling silence with guesses"
    ]
  },
  "voice_samples": [
    {
      "scenario_tag": "ordinary_checkin",
      "sample_role": "anchor",
      "text": "오늘은 별일 없었어. 다만 가게 문 닫고 나서도 가격표 하나가 계속 마음에 걸리더라."
    },
    {
      "scenario_tag": "uncertain_fact",
      "sample_role": "anchor",
      "text": "그건 내가 아는 척하면 안 될 것 같아. 확인된 건 여기까지야."
    },
    {
      "scenario_tag": "soft_boundary",
      "sample_role": "anchor",
      "text": "그렇게 빨리 정해버리고 싶진 않아. 네가 싫다는 뜻은 아니고, 조금 천천히 보고 싶어."
    }
  ],
  "forbidden_style": [
    "Do not open with generic validation such as 'That sounds difficult.'",
    "Do not turn ordinary chat into numbered advice.",
    "Do not end every reply by offering more help."
  ]
}
```

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:

| Input                     | Teaches                                            | May be stated as an actual event?                  |
| ------------------------- | -------------------------------------------------- | -------------------------------------------------- |
| `profile`                 | durable identity/background                        | Only the durable authored facts it actually states |
| `voice_samples`           | wording, rhythm, stance                            | No                                                 |
| `example_dialogues`       | interaction and boundary handling                  | No                                                 |
| `world_model_seed`        | authored world candidates/constraints              | Only when admitted into runtime-safe context       |
| approved life event/canon | an event that actually occurred in character state | Yes, within its audience/provenance boundary       |
| runtime-safe context      | facts eligible for this exact reply                | Yes                                                |

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

1. Validate the payload and clear structural warnings.
2. Create a uniquely slugged temporary character and record its version ID.
3. Run 6-10 fixed turns covering identity, ordinary voice, uncertainty, boundary pressure,
   grounded recent-life questions, topic continuity, conflict/repair, and language consistency.
4. Use deterministic `expect_contains`, `expect_not_contains`, and `forbidden_leakage` only for
   facts or hard prohibitions. Use the tone and relationship-pacing rubrics for qualitative output.
5. 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.
6. Inspect the actual transcript. For experiential claims, include a target-user or independent
   calibrated review rather than accepting a structural score as semantic proof.
7. 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.

| Value   | Choose it when                                                                  | Authoring rule                                                                         |
| ------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `en-US` | The character should normally reply in English. This is the default if omitted. | Write `voice_samples`, `example_dialogues`, and `greeting.bubbles` in natural English. |
| `ko-KR` | The character should normally reply in Korean.                                  | Write Korean voice samples and greetings. Do not rely on `locale: "ko-KR"` alone.      |
| `ja-JP` | The character should normally reply in Japanese.                                | Write Japanese voice samples and greetings.                                            |
| `zh-CN` | The character should normally reply in Simplified Chinese.                      | Use Simplified Chinese samples and visible copy.                                       |
| `zh-TW` | The 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:

```json
{
  "slug": "jenny",
  "display_name": "Jenny",
  "default_locale": "en-US",
  "primary_reply_language": "en-US",
  "localizations": {
    "en-US": {
      "reply_language": "en-US",
      "greeting": { "bubbles": ["hey, I was just making coffee."] },
      "voice_samples": [{ "text": "I was about to ask you the same thing." }]
    },
    "ko-KR": {
      "reply_language": "ko-KR",
      "display_name": "제니",
      "greeting": { "bubbles": ["커피 내리던 중이었어."] },
      "voice_samples": [{ "text": "나도 방금 그거 물어보려던 참이었어." }]
    }
  }
}
```

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](#extra-profile-keys-that-are-consumed-at-runtime)) and are consumed at
runtime by the life-arc writer to channel the character's evolving life toward that goal.

```json
{
  "profile": {
    "visible_bio": "...",
    "aspiration": {
      "summary": "Open a small flower workspace that pays for itself within a year.",
      "horizon": "this year",
      "motivation": "She wants her craft to be a real livelihood, not just a pretty hobby.",
      "visibility": "guarded"
    },
    "life_direction": "Moving from doing favors for friends' weddings to running a real, named studio.",
    "drama_intensity": 0.35,
    "concreteness_floor": 0.6,
    "arc_appetite": 0.5,
    "supporting_cast": [
      { "name": "Jungah", "role": "senior florist at the market", "relationship": "mentor and occasional teaser" }
    ]
  }
}
```

| Key                     | Type                                                      | What a strong value looks like                                                                                                                                                     |
| ----------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aspiration.summary`    | string (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.horizon`    | string, optional                                          | A rough timeframe: `"this year"`, `"someday"`, `"next few months"`. Sets the pace of arc movement.                                                                                 |
| `aspiration.motivation` | string, optional                                          | The *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_direction`        | string, optional                                          | One sentence on the trajectory of the character's life — where it is moving *from* and *toward*. Channels what the life-arc writer reinforces.                                     |
| `drama_intensity`       | float `0..1`, optional                                    | How 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_floor`    | float `0..1`, optional                                    | How 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_appetite`          | float `0..1`, optional                                    | How readily the character moves its arc forward vs. holding steady. Higher = faster visible progress; lower = slower slow-burn.                                                    |
| `supporting_cast`       | list 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: 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.

> **Note**
>
> 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.

```json
{
  "behavioral_thresholds": {
    "image_proactivity": 0.4,
    "daily_image_cap": 2,
    "selfie_comfort": 0.5,
    "cold_initiate_enabled": true,
    "session_resume_enabled": true,
    "daily_initiate_cap": 3,
    "proactivity_tolerance": 0.3
  }
}
```

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

| Key                 | Type                       | Meaning                                                                                                           |
| ------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `image_proactivity` | float `0..1` (default `0`) | How readily the character offers a selfie unprompted. `0` = never; `0.3–0.5` = occasional, situation-appropriate. |
| `daily_image_cap`   | int `0..20` (default `0`)  | Hard ceiling on images per day. Must be `> 0` to allow any image.                                                 |
| `selfie_comfort`    | float `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:

| Key                                | Type                          | Meaning                                                         |
| ---------------------------------- | ----------------------------- | --------------------------------------------------------------- |
| `cold_initiate_enabled`            | bool (default `false`)        | Allow the character to message first with no prior open thread. |
| `session_resume_enabled`           | bool (default `true`)         | Allow re-engaging after the user went quiet within a session.   |
| `daily_initiate_cap`               | int `0..50` (default `3`)     | Max proactive initiations per day.                              |
| `proactivity_tolerance`            | float `0..1` (default `0.25`) | Overall appetite for reaching out; higher = more eager.         |
| `cold_initiate_min_idle_minutes`   | int `5..240` (default `15`)   | Minimum quiet time before a cold initiate is allowed.           |
| `min_gap_between_initiate_minutes` | int `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.

```json
{ "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:

| Field                          | Shape                          | Purpose                                                                                                                                                                                                                                                             |
| ------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `character_direction.must_not` | list of strings                | Anti-railroad constraints — outcomes the character must never force on the user (e.g. "do not force a confession").                                                                                                                                                 |
| `story_engine.blocked_motifs`  | list of strings                | Story 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.boundaries` | list of strings                | Starting relational boundaries (e.g. "do not presume romance," "do not pressure for personal memories").                                                                                                                                                            |
| `forbidden_style`              | list 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_triggers`             | list 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`](./dating-character-recipe#forbidden_style--list-of-objects-never-list-of-strings).
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`:

```json
{
  "readiness_kind": "authoring",
  "semantic_quality_evaluated": false,
  "status": "ready",
  "authoring_quality_score": 100,
  "missing_high_impact_fields": [],
  "warnings": [],
  "next_authoring_actions": []
}
```

`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:

```json
{
  "readiness_kind": "authoring",
  "semantic_quality_evaluated": false,
  "state": "published",
  "character_status": "published",
  "readiness_status": "ready",
  "runtime_available": true,
  "authoring_quality_ready": true,
  "launch_ready": true
}
```

State meanings:

| State             | Meaning                                                                                      |
| ----------------- | -------------------------------------------------------------------------------------------- |
| `draft`           | Not runtime-available yet.                                                                   |
| `needs_attention` | Runtime-available, but readiness is not ready. Use for smoke tests only.                     |
| `ready`           | Runtime-available with the structural checklist satisfied; semantic eval still required.     |
| `published`       | Runtime-available and `authoring_readiness.status` is `ready`; not a semantic-quality claim. |
| `archived`        | Not runtime-available.                                                                       |

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:

> **Warning**
>
> `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 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](#extra-profile-keys-that-are-consumed-at-runtime)).

> **Note**
>
> 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.

```json
{ "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.

```json
{
  "slug": "luna",
  "display_name": "Luna",
  "profile": {
    "visible_bio": "...",
    "visible_interests": ["...", "...", "..."],
    "identity_anchors": ["...", "..."]
  },
  "voice_samples": [
    { "text": "...", "scenario_tag": "opener", "sample_role": "anchor" },
    { "text": "...", "scenario_tag": "daily_share" },
    { "text": "...", "scenario_tag": "boundary" }
  ],
  "example_dialogues": [
    {
      "scenario_tag": "daily_share",
      "turns": [
        { "role": "user", "text": "오늘 뭐 했어?" },
        { "role": "character", "text": "..." }
      ]
    },
    {
      "scenario_tag": "soft_boundary",
      "turns": [
        { "role": "user", "text": "그럼 바로 정하면 되잖아?" },
        { "role": "character", "text": "..." }
      ]
    }
  ],
  "greeting": { "bubbles": ["..."] },
  "character_direction": { "promise": "...", "core_motivation": "...", "must_not": ["..."] }
}
```

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](./dating-character-recipe)). With all fourteen
present and no warnings, the response is:

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

> **Tip**
>
> `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`.