Full developer E2E path

Import a character bundle, migrate prior chat, publish a graph, chat, and read relationship evidence.

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. 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 needAPI proof
Voice, samples, and style anchorsPOST /v1/imports with character.voice_samples, example_dialogues, profile, direction, life template, and seeds
Primary portraitidentity_image on import commit, or Visual Asset APIs later
Prior relationship memoryTemporal 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 readinessmessage.created event with role: "character"
Turn execution and settled costthe send response’s run_ref matches /runs, /trace, /usage, and /usage/message-costs
Relationship proofrelationship-snapshot, memories, open-loops, life-feed, identity-continuity
Affection/romantic scoresPATCH /v1/apps/{app_id} opt-in, then relationship-scores
App-owned relationship tierPATCH /v1/sessions/{session_id}/relationship-context
User safety controlsPATCH /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

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.

1{
2 "mode": "hybrid",
3 "external_user_ref": "user_123",
4 "external_user_display_name": "Daniel",
5 "character": {
6 "slug": "lina",
7 "display_name": "Lina",
8 "locale": "ko-KR",
9 "primary_reply_language": "ko-KR",
10 "affordance_schema": "dating",
11 "profile": {
12 "visible_bio": "A careful product diarist with dry warmth.",
13 "visible_interests": ["late-night demos", "blue notebooks"],
14 "identity_anchors": ["dry warmth", "keeps emotional pressure low"]
15 },
16 "voice_samples": [
17 {
18 "scenario_tag": "project_memory",
19 "sample_role": "anchor",
20 "text": "네가 지우려던 버전일수록 이상하게 제일 오래 남더라."
21 }
22 ],
23 "example_dialogues": [
24 {
25 "scenario_tag": "fragile_demo",
26 "turns": [
27 { "role": "user", "text": "그 데모 얘기 아직 해도 돼?" },
28 { "role": "character", "text": "응. 대신 판정 말고 기록처럼 얘기하자." }
29 ]
30 }
31 ],
32 "greeting": {
33 "bubbles": ["왔어? 파란 노트 얘기는 아직 접어두지 않았어."]
34 }
35 },
36 "identity_image": {
37 "source_url": "https://cdn.example.com/lina.png",
38 "asset_key": "identity.primary",
39 "label": "Lina identity",
40 "visual_summary": "Canonical portrait for Lina."
41 },
42 "transcript": {
43 "metadata": { "vendor": "nomi", "character_name": "Lina" },
44 "messages": [
45 { "index": 0, "speaker": "human", "text": "Remember the demo I was afraid to ship?" },
46 { "index": 1, "speaker": "character", "text": "The one you almost deleted, yes." },
47 { "index": 2, "speaker": "human", "text": "Minseo told me not to hide it." },
48 { "index": 3, "speaker": "character", "text": "Minseo was right." }
49 ]
50 }
51}

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:

GET /v1/imports/{import_id}

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

1{
2 "status": "extracting",
3 "temporal_workflow_id": "v1-character-import-extraction:...",
4 "temporal_task_queue": "auto-dating-background",
5 "progress": {
6 "phase": "extracting",
7 "current_step": "conversation_signal",
8 "current_step_label": "Extracting shared facts and conversation signals",
9 "percent_complete": 25,
10 "heartbeat": {
11 "last_heartbeat_at": "2026-06-18T07:52:52Z",
12 "is_stale": false
13 },
14 "poll_after_seconds": 2,
15 "terminal": false,
16 "preview_ready": false,
17 "can_commit": false
18 },
19 "extraction_summary": {
20 "progress": {
21 "phase": "extracting",
22 "current_step": "conversation_signal"
23 }
24 },
25 "last_heartbeat_at": "2026-06-18T07:52:52Z"
26}

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:

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

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:

1{
2 "data": {
3 "commit": {
4 "character": { "slug": "lina", "status": "published" },
5 "session_id": "sess_...",
6 "import_batch_id": "batch_...",
7 "claims_written": 7,
8 "beliefs_written": 3,
9 "relational_state_set": true,
10 "native_actor_core": {
11 "native_ready": true,
12 "imported_turn_count": 6,
13 "directed_lens_count": 2,
14 "claim_memory_count": 2,
15 "belief_count": 4,
16 "indexed_document_count": 9
17 }
18 },
19 "identity_image": {
20 "asset_id": "...",
21 "asset_key": "identity.primary",
22 "asset_status": "active",
23 "promotion_status": "approved_anchor",
24 "servable": true
25 }
26 }
27}

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 whenValidate operationScopeDo not use it for
Both endpoints are playable characters in this appPOST /v1/world/graph/validateapp-rosterA root character’s NPC/supporting cast
One root character owns NPC/supporting-cast nodesPOST /v1/characters/{slug}/world/graph/validatecharacter-npcPlayable character-to-character roster edges

Playable characters use the app-roster graph:

1{
2 "edges": [
3 {
4 "source_ref": "lina",
5 "target_ref": "mira",
6 "source_node_type": "character",
7 "target_node_type": "character",
8 "relation_type": "launch_collaborator",
9 "label": "protective practical collaborator",
10 "visibility_tier": "planning_only",
11 "canon_tier": "soft_canon"
12 }
13 ]
14}

NPCs and supporting cast use the character graph:

1{
2 "npcs": [
3 {
4 "npc_ref": "joon-reviewer",
5 "display_name": "Joon",
6 "aliases": ["", "Joon"],
7 "role": "late-night reviewer",
8 "supporting_cast_status": "developer_promoted",
9 "visibility_tier": "speakable_after_introduced",
10 "canon_tier": "soft_canon"
11 }
12 ],
13 "edges": [
14 {
15 "source_ref": "lina",
16 "target_ref": "joon-reviewer",
17 "target_node_type": "npc",
18 "relation_type": "trusted_reviewer",
19 "label": "trusted late-night reviewer",
20 "visibility_tier": "speakable_after_introduced"
21 }
22 ]
23}

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

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.

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

Render when you see:

1{
2 "type": "message.created",
3 "role": "character",
4 "payload": {
5 "text": "...",
6 "bubbles": ["...", "..."]
7 }
8}

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:

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:

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

Then use narrower endpoints as needed:

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.

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

Default response:

1{
2 "redacted": true,
3 "opt_in_required": true,
4 "relationship_scores": []
5}

Opt in per app:

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:

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:

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.