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

# Full developer E2E path

This is the full "first real integration" path. Use it when your app needs more
than a smoke-test character:

1. upload authored voice, persona, style anchors, and identity image;
2. optionally migrate prior chat from another companion app;
3. publish playable-character and NPC relationship graphs;
4. start or reuse the committed session;
5. send a message and render the reply from session events;
6. correlate that turn's run, trace, settled usage, and message-level cost;
7. read public-safe relationship evidence and opt-in scores.

For the shortest possible chat loop, start with
[Quickstart](./quickstart). For this full path, keep every call server-side and
use only `/v1/*`.

## What This Proves

When this path works, your app has proven that a user can bring a planned
character plus existing relationship history into LoreOS and get back:

| Product need                             | API proof                                                                                                            |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Voice, samples, and style anchors        | `POST /v1/imports` with `character.voice_samples`, `example_dialogues`, profile, direction, life template, and seeds |
| Primary portrait                         | `identity_image` on import commit, or Visual Asset APIs later                                                        |
| Prior relationship memory                | Temporal extraction progress, preview staged ledgers, and commit counts                                              |
| Playable character-to-character relation | `/v1/world/graph/validate -> import -> publish`                                                                      |
| NPC/supporting cast                      | `/v1/characters/{slug}/world/graph/validate -> import -> publish`                                                    |
| Chat render readiness                    | `message.created` event with `role: "character"`                                                                     |
| Turn execution and settled cost          | the send response's `run_ref` matches `/runs`, `/trace`, `/usage`, and `/usage/message-costs`                        |
| Relationship proof                       | `relationship-snapshot`, `memories`, `open-loops`, `life-feed`, `identity-continuity`                                |
| Affection/romantic scores                | `PATCH /v1/apps/{app_id}` opt-in, then `relationship-scores`                                                         |
| App-owned relationship tier              | `PATCH /v1/sessions/{session_id}/relationship-context`                                                               |
| User safety controls                     | `PATCH /lifecycle`, `POST /block`, `POST /report`, memory delete, external-user delete                               |

## Fresh App/Key Acceptance

The full internal L3 has two layers:

* `scripts/v1_developer_happy_path_l3.py` proves the full public runtime path with
  a temporary provisioned app key.
* `scripts/v1_fresh_app_key_authoring_l3.py` proves the self-serve handoff:
  `/v1/account/*` creates a fresh app and `ck_` runtime key, then that same key
  validates, creates, bulk-creates, lists, starts a session, receives a real
  reply event, lists end-users, and exports opt-in relationship scores.

This matters because account tokens (`lat_...`) and Clerk Account API keys
(`ak_...`) are not LoreOS runtime keys. Runtime calls to `/v1/characters`,
`/v1/sessions`, `/v1/external-users`, and relationship evidence must use a
LoreOS `ck_...` key issued for the selected app.

## API Sequence

```text
POST /v1/imports
GET  /v1/imports/{import_id}                    poll until status=ready
GET  /v1/imports/{import_id}/preview
POST /v1/imports/{import_id}/commit             -> character + session + identity image

POST /v1/characters                             create another playable character
POST /v1/world/graph/validate
POST /v1/world/graph/import
POST /v1/world/graph/versions/{graph_version_id}/publish

POST /v1/characters/{slug}/world/graph/validate
POST /v1/characters/{slug}/world/graph/import
POST /v1/characters/{slug}/world/graph/versions/{graph_version_id}/publish
GET  /v1/characters/{slug}/npcs

POST /v1/sessions/{session_id}/messages
GET  /v1/sessions/{session_id}/events?since={cursor}
GET  /v1/sessions/{session_id}/runs
GET  /v1/sessions/{session_id}/trace
GET  /v1/usage?group_by=session
GET  /v1/usage/message-costs?session_id={session_id}&modality=text

GET  /v1/sessions/{session_id}/relationship-snapshot
POST /v1/sessions/{session_id}/memories
PATCH /v1/sessions/{session_id}/relationship-context
GET  /v1/sessions/{session_id}/relationship-scores
GET  /v1/me
PATCH /v1/apps/{app_id} { "expose_relational_numerics": true }
GET  /v1/sessions/{session_id}/relationship-scores

POST /v1/sessions/{session_id}/report
PATCH /v1/sessions/{session_id}/lifecycle { "action": "pause" }
PATCH /v1/sessions/{session_id}/lifecycle { "action": "resume" }
```

## 1. Create The Import

Use `mode: "hybrid"` when you have both an authored character bundle and prior
chat history.

```json
{
  "mode": "hybrid",
  "external_user_ref": "user_123",
  "external_user_display_name": "Daniel",
  "character": {
    "slug": "lina",
    "display_name": "Lina",
    "locale": "ko-KR",
    "primary_reply_language": "ko-KR",
    "affordance_schema": "dating",
    "profile": {
      "visible_bio": "A careful product diarist with dry warmth.",
      "visible_interests": ["late-night demos", "blue notebooks"],
      "identity_anchors": ["dry warmth", "keeps emotional pressure low"]
    },
    "voice_samples": [
      {
        "scenario_tag": "project_memory",
        "sample_role": "anchor",
        "text": "네가 지우려던 버전일수록 이상하게 제일 오래 남더라."
      }
    ],
    "example_dialogues": [
      {
        "scenario_tag": "fragile_demo",
        "turns": [
          { "role": "user", "text": "그 데모 얘기 아직 해도 돼?" },
          { "role": "character", "text": "응. 대신 판정 말고 기록처럼 얘기하자." }
        ]
      }
    ],
    "greeting": {
      "bubbles": ["왔어? 파란 노트 얘기는 아직 접어두지 않았어."]
    }
  },
  "identity_image": {
    "source_url": "https://cdn.example.com/lina.png",
    "asset_key": "identity.primary",
    "label": "Lina identity",
    "visual_summary": "Canonical portrait for Lina."
  },
  "transcript": {
    "metadata": { "vendor": "nomi", "character_name": "Lina" },
    "messages": [
      { "index": 0, "speaker": "human", "text": "Remember the demo I was afraid to ship?" },
      { "index": 1, "speaker": "character", "text": "The one you almost deleted, yes." },
      { "index": 2, "speaker": "human", "text": "Minseo told me not to hide it." },
      { "index": 3, "speaker": "character", "text": "Minseo was right." }
    ]
  }
}
```

`identity_image.source_url` must be a direct, publicly fetchable `https` image
URL. If the image fetch fails, the character can still publish, but
`data.identity_image` will contain a safe error and no portrait is registered.

## 2. Poll The Import

Background extraction runs through Temporal by default. The create response
contains `data.temporal_workflow_id` and `data.temporal_task_queue`.

Poll:

```text
GET /v1/imports/{import_id}
```

Wait for `data.status` to become `ready`. While it runs, check:

```json
{
  "status": "extracting",
  "temporal_workflow_id": "v1-character-import-extraction:...",
  "temporal_task_queue": "auto-dating-background",
  "progress": {
    "phase": "extracting",
    "current_step": "conversation_signal",
    "current_step_label": "Extracting shared facts and conversation signals",
    "percent_complete": 25,
    "heartbeat": {
      "last_heartbeat_at": "2026-06-18T07:52:52Z",
      "is_stale": false
    },
    "poll_after_seconds": 2,
    "terminal": false,
    "preview_ready": false,
    "can_commit": false
  },
  "extraction_summary": {
    "progress": {
      "phase": "extracting",
      "current_step": "conversation_signal"
    }
  },
  "last_heartbeat_at": "2026-06-18T07:52:52Z"
}
```

Use `data.progress` for UI state. `progress.percent_complete` is a coarse
checkpoint percentage, `progress.heartbeat.is_stale` tells you whether the
Temporal worker has stopped updating recently, and `progress.poll_after_seconds`
is the recommended polling delay. `data.extraction_summary.progress` is kept for
backward compatibility with older integrations.

After `progress.preview_ready` becomes `true`, inspect:

```text
GET /v1/imports/{import_id}/preview
```

Look for staged ledger counts such as `world_model_claims`,
`character_counterpart_beliefs`, and `relational_state_rows`.

## 3. Commit The Import

```text
POST /v1/imports/{import_id}/commit
```

For transcript imports, this synchronous step materializes actor relationship
evidence and can take longer than 60 seconds. Configure a client read timeout of
at least 180 seconds. If the client times out, poll
`GET /v1/imports/{import_id}`. Do not resubmit while `data.status` is
`committing`; after it becomes `committed`, repeat the commit request to recover
the original `data.commit` envelope without applying the import twice.

Important fields:

```json
{
  "data": {
    "commit": {
      "character": { "slug": "lina", "status": "published" },
      "session_id": "sess_...",
      "import_batch_id": "batch_...",
      "claims_written": 7,
      "beliefs_written": 3,
      "relational_state_set": true,
      "native_actor_core": {
        "native_ready": true,
        "imported_turn_count": 6,
        "directed_lens_count": 2,
        "claim_memory_count": 2,
        "belief_count": 4,
        "indexed_document_count": 9
      }
    },
    "identity_image": {
      "asset_id": "...",
      "asset_key": "identity.primary",
      "asset_status": "active",
      "promotion_status": "approved_anchor",
      "servable": true
    }
  }
}
```

`data.commit.native_actor_core` is the current runtime authority. Require
`native_ready: true`, confirm `imported_turn_count`, and inspect the native lens,
memory, belief, and indexed-document counts. The legacy top-level counters such
as `claims_written` and `relational_state_set` can remain zero/false after a
successful native import, so coding agents must not use those fields alone as
the success gate.

For `authoring_only`, `data.commit.session_id` is `null`; create a session
yourself with `POST /v1/sessions`. For `hybrid`, use the committed `session_id`
because it carries the imported relationship state.

## 4. Publish Relationship Graphs

The two graph families are deliberately separate. Tooling can branch on the OpenAPI
`x-loreos-scope` field instead of guessing from the title:

| Use this when                                      | Validate operation                                | Scope           | Do not use it for                            |
| -------------------------------------------------- | ------------------------------------------------- | --------------- | -------------------------------------------- |
| Both endpoints are playable characters in this app | `POST /v1/world/graph/validate`                   | `app-roster`    | A root character's NPC/supporting cast       |
| One root character owns NPC/supporting-cast nodes  | `POST /v1/characters/{slug}/world/graph/validate` | `character-npc` | Playable character-to-character roster edges |

Playable characters use the app-roster graph:

```json
{
  "edges": [
    {
      "source_ref": "lina",
      "target_ref": "mira",
      "source_node_type": "character",
      "target_node_type": "character",
      "relation_type": "launch_collaborator",
      "label": "protective practical collaborator",
      "visibility_tier": "planning_only",
      "canon_tier": "soft_canon"
    }
  ]
}
```

NPCs and supporting cast use the character graph:

```json
{
  "npcs": [
    {
      "npc_ref": "joon-reviewer",
      "display_name": "Joon",
      "aliases": ["준", "Joon"],
      "role": "late-night reviewer",
      "supporting_cast_status": "developer_promoted",
      "visibility_tier": "speakable_after_introduced",
      "canon_tier": "soft_canon"
    }
  ],
  "edges": [
    {
      "source_ref": "lina",
      "target_ref": "joon-reviewer",
      "target_node_type": "npc",
      "relation_type": "trusted_reviewer",
      "label": "trusted late-night reviewer",
      "visibility_tier": "speakable_after_introduced"
    }
  ]
}
```

Always validate, import, then publish. Published character graphs materialize
NPC rows, which you can inspect with:

```text
GET /v1/characters/{slug}/npcs
GET /v1/characters/{slug}/supporting-cast
```

Include every spelling that users may type in `aliases`, especially localized
names such as `준` for `Joon`. The native runtime resolves a referenced third
party deterministically from `display_name` and `aliases`; it does not guess
transliterations. Without the matching alias, the graph is still valid and
inspectable, but that mention will not select the NPC relationship card for the
reply context.

## 5. Send And Render A Reply

`POST /messages` returns a cursor, not the reply.

```text
POST /v1/sessions/{session_id}/messages
GET  /v1/sessions/{session_id}/events?since={cursor}
```

Render when you see:

```json
{
  "type": "message.created",
  "role": "character",
  "payload": {
    "text": "...",
    "bubbles": ["...", "..."]
  }
}
```

Do not wait for a separate `delivered` status before showing text that is
already committed to the event log. Delivery status is for webhook or managed
channel push attempts.

After rendering, use the `run_ref` returned by `POST /messages` to correlate the
same turn across the public observability and billing views:

```text
GET /v1/sessions/{session_id}/runs
GET /v1/sessions/{session_id}/trace
GET /v1/usage?group_by=session
GET /v1/usage/message-costs?session_id={session_id}&modality=text
```

Require a `/runs` row whose `trace_id` equals `run_ref`, `/trace` rows whose
`run_ref` equals it, and a message-cost row whose `trace_id` equals it. The
settled turn credits in `/runs`, `/trace`, and the message drilldown should
agree. The session usage total can be larger because it may also include other
settled work attributed to that session. Cost settlement happens after reply
generation, so poll these reads with a bounded timeout instead of assuming the
ledger is ready when the reply event first appears.

Keep `include_preview=false` unless a trusted operator explicitly needs message
text in a cost view. `/trace` contains the conversation event text by design;
`/runs` stays aggregate and does not expose raw model ids, prompts, provider
payloads, or private engine context.

## 6. Read Relationship Evidence

Start with:

```text
GET /v1/sessions/{session_id}/relationship-snapshot
```

Then use narrower endpoints as needed:

```text
GET  /v1/sessions/{session_id}/memories
POST /v1/sessions/{session_id}/memories
GET  /v1/sessions/{session_id}/open-loops
GET  /v1/sessions/{session_id}/life-feed
GET  /v1/sessions/{session_id}/identity-continuity
```

These are public-safe projections. They do not expose raw prompts, raw
world-model rows, private Story Room plans, raw extractor traces, or provider
payloads.

## 7. Opt In To Scores

Numeric relationship scores are redacted by default.

```text
GET /v1/sessions/{session_id}/relationship-scores
```

Default response:

```json
{
  "redacted": true,
  "opt_in_required": true,
  "relationship_scores": []
}
```

Opt in per app:

```text
GET   /v1/me
PATCH /v1/apps/{app_id} { "expose_relational_numerics": true }
GET   /v1/sessions/{session_id}/relationship-scores
```

After opt-in, dating schemas include final 0..1 values such as
`affection_toward_user`, `romantic_interest`, `trust_level`, `comfort_level`,
and `boundary_level`.

The response includes `score_contract`, `updated_at`, and `version`. Scores can
move down or decay, so use them for app-specific pacing/unlocks within the same
schema, not as a universal cross-app leaderboard.

For dashboard sync after opt-in:

```text
GET /v1/characters/{slug}/relationship-scores?limit=50
GET /v1/external-users/{external_user_ref}/relationship-scores?character={slug}&limit=50
```

These return final per-session score exports with `session_id`, `character`,
`external_user_ref`, `updated_at`, `version`, and `relationship_scores[]`. They
do not return raw conversation text or model traces.

To inject app-owned relationship context without overwriting canon/persona:

```text
PATCH /v1/sessions/{session_id}/relationship-context
```

Use this for labels such as "founding friend" or "inner circle". The write is a
TTL-scoped, planning-guarded session context event; it does not overwrite learned
relational scores and should not be contradicted by generic first-meeting assumptions.

The score endpoint never exposes raw extractor deltas, patch history, signal
events, critic reasoning, or prompt text. It may list those names under
`omitted_fields` to make the privacy boundary explicit.

## Implementation Notes For Agents

* Use `response.data`, not top-level response fields.
* Persist `data.cursor` from `POST /messages` and poll with `since=cursor`.
* Treat unknown event types as additive.
* Use `Idempotency-Key` on `POST /messages`.
* Use the committed `session_id` from a hybrid import; it is the session that
  contains imported relationship state.
* Use direct, fetchable image URLs for import identity images.
* Do not generate against `https://api.loreos.app/openapi.json`; use
  `https://api.loreos.app/v1/openapi.json`.