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

# Quickstart

Everything below uses the base URL `https://api.loreos.app` and your key as
`Authorization: Bearer $LOREOS_KEY`. Replies are **asynchronous** — you send a message,
then read the reply from the session's events.

Using Cursor, Claude Code, Codex, Devin, or another coding agent? Read
**[Build with a coding agent](https://docs.loreos.app/getting-started/build-with-a-coding-agent)**
first, then come back here for the raw API flow.

## 1. Create a character

`slug` and `display_name` are the only required fields, but set
`primary_reply_language` explicitly for any real character. It controls the language of
visible character replies. `locale` is regional/provider metadata; it does **not** control
speech. If you omit `primary_reply_language`, it defaults to `en-US`.

Use voice samples in the same language as `primary_reply_language` unless the character has
a deliberately authored code-switching style.

```bash
curl -X POST https://api.loreos.app/v1/characters \
  -H "Authorization: Bearer $LOREOS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "luna",
    "display_name": "Luna",
    "locale": "en-US",
    "primary_reply_language": "en-US",
    "background_features": { "auto_activate": false },
    "voice_samples": [
      { "text": "hey, give me one second. I was in the middle of something small." },
      { "text": "I can be honest without making it dramatic." },
      { "text": "tell me the short version first. we can make sense of it slowly." }
    ]
}'
```

Representative response:

```json
{
  "schema_version": "v0",
  "data": {
    "character_id": "char_...",
    "slug": "luna",
    "display_name": "Luna",
    "primary_reply_language": "en-US",
    "status": "published",
    "version_number": 1,
    "background_features": {
      "auto_activation_requested": false,
      "default_auto_activate": true,
      "auto_activated": []
    },
    "authoring_readiness": {
      "readiness_kind": "authoring",
      "semantic_quality_evaluated": false,
      "status": "needs_attention",
      "authoring_quality_score": 21,
      "missing_high_impact_fields": ["example_dialogues", "character_direction", "life_template"],
      "next_authoring_actions": ["Add example dialogues and life/story fields before launch."]
    },
    "publication": {
      "readiness_kind": "authoring",
      "semantic_quality_evaluated": false,
      "state": "needs_attention",
      "runtime_available": true,
      "authoring_quality_ready": false,
      "launch_ready": false
    }
  },
  "next_actions": [
    {
      "command": "POST /v1/sessions {\"character\": \"luna\", \"external_user_ref\": \"<your-user-id>\"}",
      "description": "Start a chat session for one of your end-users"
    }
  ]
}
```

The quickstart opts out of recurring background work so this four-call smoke has
no Story Room/offscreen schedule side effect. In normal creates,
`background_features.auto_activate` defaults to `true` and requests four paid
recurring workflows when the server policy is live: `story_room` (daily),
`offscreen_heartbeat` (\~10 min), `ambient_life` (hourly), and
`offscreen_scene` (\~5 min). Keep the explicit `false` until you intend to fund
and use the character's between-message life.

`status="published"` means the character is runtime-available. It does **not**
mean the character is production-quality. The readiness fields are structural presence and
operational setup checks; `semantic_quality_evaluated=false` makes that limit explicit. Use them
to clear missing setup, then run and inspect Conversation Eval for identity, voice, boundary,
grounding, continuity, and useful specificity before launch. A minimal character can be created
and chatted with while still returning `publication.state="needs_attention"`.

For roster seeding, call `GET /v1/characters/authoring-limits` first. It tells an
agent the bulk-create limit, demo sandbox cap if relevant, readiness targets, and
publication-state meanings. Its `field_enum_reference` lists the allowed values + numeric
ranges for the fields that most often 422 (usage, allowed_event_sources, visibility,
behavioral_thresholds, ...), so you don't have to reverse-engineer them.

To **list** your characters (e.g. a roster page), call `GET /v1/characters` — it returns a
summary per character with `character_id`, `app_id`, `slug`, and `display_name`. Add
`?expand=profile` to also get each character's `visible_bio` + `visible_interests` in the
list, so a roster UI doesn't need an N+1 `GET /v1/characters/{slug}` per card. (A single
character's full authored fields come back nested under `data.content`.) The default list
is active inventory only. Use `GET /v1/characters?status=archived` to show archived
characters, or `?status=all` for active + archived. Deleted characters are not returned.

Supported `primary_reply_language` values:

| Value   | Choose it when                                                                             |
| ------- | ------------------------------------------------------------------------------------------ |
| `en-US` | The character should normally reply in English. This is the default if omitted.            |
| `ko-KR` | The character should normally reply in Korean. Use Korean voice samples and greetings.     |
| `ja-JP` | The character should normally reply in Japanese. Use Japanese voice samples and greetings. |
| `zh-CN` | The character should normally reply in Simplified Chinese.                                 |
| `zh-TW` | The character should normally reply in Traditional Chinese.                                |

The character may understand user input in other languages, but it will not switch its reply
language just because the latest user message did. Re-publishing the same `slug` with a
different `primary_reply_language` returns `409 primary_reply_language_fixed`.

For multilingual characters, do not create one character per language. Create one character
and add locale packs with `localizations`, or later with
`PATCH /v1/characters/{slug}/localizations/{locale}`:

```json
{
  "slug": "luna",
  "display_name": "Luna",
  "default_locale": "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 locale-specific session with `"locale":"ko-KR"`. LoreOS selects exactly one locale
pack for that session; other locale voice samples, examples, greetings, and localized profile
fields are not injected into the runtime context. If the developer-facing slug needs to
change, use `POST /v1/characters/{slug}/rename {"slug":"new-slug"}`. `character_id` stays
stable and the old slug remains an alias.

To remove a character from inventory without losing it, call
`POST /v1/characters/{slug}/archive`; restore it with
`POST /v1/characters/{slug}/restore`. Archived characters cannot start sessions or receive
new messages on existing sessions. To permanently remove an archived character, call
`DELETE /v1/characters/{slug}?confirm_slug={canonical_slug}&force=false&reason={url_encoded_reason}`
and poll `GET /v1/characters/{slug}/deletion` until
`data.deletion_request.request_status == "completed"`. Do not treat a present-but-null
`completed_at` as completion. `force=true` is only for an explicitly approved destructive flow
that intentionally skips archive. Deleted slugs stay reserved, so every disposable test character
needs a unique slug. Do not use
`POST /v1/characters/{slug}/block` for cleanup; block is a per-end-user safety control.

## 2. Start a session

A session is one character talking to one of your end-users. You choose the
`external_user_ref` — any opaque id you control (your user's id in your system). LoreOS
never logs your end-user in; it's just a reference.

```bash
curl -X POST https://api.loreos.app/v1/sessions \
  -H "Authorization: Bearer $LOREOS_KEY" \
  -H "Idempotency-Key: session-user-123-luna-1" \
  -H "Content-Type: application/json" \
  -d '{
    "character": "luna",
    "external_user_ref": "user_123"
  }'
```

Save the `session_id` from the response's `data`.
If this request times out or returns a retryable 503, resend the same body with
the same `Idempotency-Key`. LoreOS returns the original session instead of
creating a duplicate.

Representative response:

```json
{
  "schema_version": "v0",
 "data": {
    "session_id": "sess_...",
    "character": "luna",
    "canonical_character": "luna",
    "external_user_ref": "user_123",
    "initial_event_cursor": 0,
    "language_guard": {
      "requested_locale": "en-US",
      "character_primary_reply_language": "en-US",
      "supported_input_languages": ["en-US"],
      "effective_locale": "en-US",
      "effective_reply_language": "en-US",
      "input_language_policy": "enforce_supported_input",
      "accepted": true
    }
  },
  "next_actions": [
    {
      "command": "POST /v1/sessions/sess_.../messages {\"text\": \"hello!\"}",
      "description": "Send the end-user's first message"
    },
    {
      "command": "GET /v1/sessions/sess_.../events?since=0",
      "description": "Poll the event log for the character's replies"
    }
  ]
}
```

## 3. Send a message

```bash
curl -X POST https://api.loreos.app/v1/sessions/$SESSION_ID/messages \
  -H "Authorization: Bearer $LOREOS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "text": "hey, what are you up to?" }'
```

Representative response:

```json
{
  "schema_version": "v0",
  "data": {
    "accepted": true,
    "cursor": 12,
    "sent_turn_index": 1,
    "run_ref": "run_...",
    "reply_mode": "fast"
  },
  "next_actions": [
    {
      "command": "GET /v1/sessions/sess_.../events?since=12",
      "description": "Poll for the character's reply"
    }
  ]
}
```

## 4. Read the reply

The character replies asynchronously. Poll the session's events until the reply lands:

```bash
curl https://api.loreos.app/v1/sessions/$SESSION_ID/events \
  -H "Authorization: Bearer $LOREOS_KEY"
```

Look for a character event after the user-message cursor:

```json
{
  "schema_version": "v0",
  "data": {
    "session_id": "sess_...",
    "events": [
      {
        "cursor": 13,
        "type": "message.created",
        "event_type": "message.created",
        "role": "character",
        "payload": {
          "text": "hey, I was just wrapping up a small thing. what are you up to?",
          "bubbles": ["hey, I was just wrapping up a small thing.", "what are you up to?"]
        }
      }
    ],
    "next_cursor": 13
  },
  "next_actions": [
    {
      "command": "GET /v1/sessions/sess_.../events?since=13",
      "description": "Poll again for newer events"
    }
  ]
}
```

Read `type` as the canonical event name; `event_type` is a compatibility alias. Replies
arrive as `message.created` (role `character`). An authored greeting — or a
proactive message the character sends first — arrives as `character.initiated`. **Both
carry `payload.bubbles`** (an ordered list of message bubbles); `payload.text` is the
joined convenience form. Render `bubbles` when present.

For chat UI rendering, the committed `message.created` or `character.initiated` event is the
ready signal. Do not wait for a separate `delivered` status before showing the reply;
delivery fields are push-channel bookkeeping for webhook/Telegram delivery attempts, not the
source of truth for whether text is visible in the event log.

Prefer a live push? Stream instead: `GET /v1/sessions/{session_id}/events/stream` (SSE).

## What the responses look like

Every response is wrapped in an envelope:

```json
{
  "schema_version": "...",
  "data": { "...": "the result" },
  "next_actions": [ { "...": "what to call next" } ]
}
```

Errors are nested under `detail`: `{ "detail": { "code", "message", "fix" } }` — read
`body.detail.code`, not a top-level `code`. The full model — async runs, end-users, usage
and limits — is in **[Core concepts](https://docs.loreos.app/getting-started/core-concepts)**.