Skip to navigation

Quickstart

Your first character reply in four calls.

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

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:

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

ValueChoose it when
en-USThe character should normally reply in English. This is the default if omitted.
ko-KRThe character should normally reply in Korean. Use Korean voice samples and greetings.
ja-JPThe character should normally reply in Japanese. Use Japanese voice samples and greetings.
zh-CNThe character should normally reply in Simplified Chinese.
zh-TWThe 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}:

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

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:

{
"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

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:

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

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:

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

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