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

1{
2 "schema_version": "v0",
3 "data": {
4 "character_id": "char_...",
5 "slug": "luna",
6 "display_name": "Luna",
7 "primary_reply_language": "en-US",
8 "status": "published",
9 "version_number": 1,
10 "authoring_readiness": {
11 "status": "needs_attention",
12 "authoring_quality_score": 21,
13 "missing_high_impact_fields": ["example_dialogues", "character_direction", "life_template"],
14 "next_authoring_actions": ["Add example dialogues and life/story fields before launch."]
15 },
16 "publication": {
17 "state": "needs_attention",
18 "runtime_available": true,
19 "authoring_quality_ready": false,
20 "launch_ready": false
21 }
22 },
23 "next_actions": [
24 {
25 "command": "POST /v1/sessions {\"character\": \"luna\", \"external_user_ref\": \"<your-user-id>\"}",
26 "description": "Start a chat session for one of your end-users"
27 }
28 ]
29}

status="published" means the character is runtime-available. It does not mean the character is production-quality. Use publication.launch_ready, publication.authoring_quality_ready, and authoring_readiness.status for the launch decision. 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}:

1{
2 "slug": "luna",
3 "display_name": "Luna",
4 "default_locale": "en-US",
5 "localizations": {
6 "en-US": {
7 "reply_language": "en-US",
8 "greeting": { "bubbles": ["hey, I was just making coffee."] },
9 "voice_samples": [{ "text": "I was about to ask you the same thing." }]
10 },
11 "ko-KR": {
12 "reply_language": "ko-KR",
13 "display_name": "루나",
14 "greeting": { "bubbles": ["커피 내리던 중이었어."] },
15 "voice_samples": [{ "text": "나도 방금 그거 물어보려던 참이었어." }]
16 }
17 }
18}

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} and poll GET /v1/characters/{slug}/deletion until complete. 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 "Content-Type: application/json" \
> -d '{
> "character": "luna",
> "external_user_ref": "user_123"
> }'

Save the session_id from the response’s data.

Representative response:

1{
2 "schema_version": "v0",
3 "data": {
4 "session_id": "sess_...",
5 "character": "luna",
6 "canonical_character": "luna",
7 "external_user_ref": "user_123",
8 "initial_event_cursor": 0,
9 "language_guard": {
10 "requested_locale": "en-US",
11 "character_primary_reply_language": "en-US",
12 "supported_input_languages": ["en-US"],
13 "effective_locale": "en-US",
14 "effective_reply_language": "en-US",
15 "input_language_policy": "enforce_supported_input",
16 "accepted": true
17 }
18 },
19 "next_actions": [
20 {
21 "command": "POST /v1/sessions/sess_.../messages {\"text\": \"hello!\"}",
22 "description": "Send the end-user's first message"
23 },
24 {
25 "command": "GET /v1/sessions/sess_.../events?since=0",
26 "description": "Poll the event log for the character's replies"
27 }
28 ]
29}

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:

1{
2 "schema_version": "v0",
3 "data": {
4 "accepted": true,
5 "cursor": 12,
6 "sent_turn_index": 1,
7 "run_ref": "run_...",
8 "reply_mode": "fast"
9 },
10 "next_actions": [
11 {
12 "command": "GET /v1/sessions/sess_.../events?since=12",
13 "description": "Poll for the character's reply"
14 }
15 ]
16}

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:

1{
2 "schema_version": "v0",
3 "data": {
4 "session_id": "sess_...",
5 "events": [
6 {
7 "cursor": 13,
8 "type": "message.created",
9 "event_type": "message.created",
10 "role": "character",
11 "payload": {
12 "text": "hey, I was just wrapping up a small thing. what are you up to?",
13 "bubbles": ["hey, I was just wrapping up a small thing.", "what are you up to?"]
14 }
15 }
16 ],
17 "next_cursor": 13
18 },
19 "next_actions": [
20 {
21 "command": "GET /v1/sessions/sess_.../events?since=13",
22 "description": "Poll again for newer events"
23 }
24 ]
25}

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:

1{
2 "schema_version": "...",
3 "data": { "...": "the result" },
4 "next_actions": [ { "...": "what to call next" } ]
5}

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.