✦ Kiori

API reference

Base URL: https://api.kiori.io (the hosted beta) or http://127.0.0.1:7700 (self-hosted). Every request (except /health, /v1/personality and /v1/access) needs a project key:

Authorization: Bearer kio_...
Content-Type: application/json

Keys made before the rename start with psy_ and keep working. The whole API is also described as OpenAPI 3.1 at /openapi.json (no key needed): import it into Postman, or generate a client in any language.

Errors come back as { "error": { "message": "...", "code": "..." } } with an HTTP status: 400 bad input, 401 bad key, 404 not found, 409 an external_id already in use, 402 model work your project can't do yet (NEEDS_KEY: add your model key; BAD_KEY: your provider refused it; CREDITS: your provider says the key is out of credits), 429 too many requests (see Retry-After), 502 the model provider failed or answered badly, 503 no model key configured on a self-hosted server.

Your model key. Kiori builds and updates brains with a model (the organizer and the auditor). On the hosted beta, every project brings its own: add your provider and key in the console (Settings), or with PATCH /v1/project { "settings": { "llm": { "key": "...", "base_url": "https://openrouter.ai/api/v1" } } }. Kiori calls your provider with it and you pay them directly. Without one, context still works (recall by words), and building, learning, checking and sleeping answer 402 NEEDS_KEY.

Characters and stories can be referred to by our id (chr_..., sto_...) or by your own id (external_id), in paths and in bodies alike: GET /v1/stories/chat_7 works. Wherever a list of characters is accepted, names work too.

How names are matched (in characters, present, speaker): a character's exact name or one of their aliases, or a shortened form that fits exactly one character ("Luc" → Luc Moreau, "Doctor Burcu" → Burcu Aydemir). Two full names that only share a word are two people: Ana Moreau being present doesn't make Luc Moreau present. A short form that fits several people ("Moreau") refers to nobody; inside a story, the story's own characters are looked up first, so "Peter" means the Peter of that story. Where you list characters, a name that fits nobody is a 404.

The easy ways in

POST /v1/turn — one call per turn

{ "story": "...", "messages": [...], "characters", "max_characters", "budget", "limit", "place" }

Returns { story: { id, external_id, created }, synced, characters: [...as context], check_recommended, warnings? }. check_recommended is true when the player's last message brings up the past: run /check on the reply before showing it.

POST /v1/chat/completions — the drop-in (OpenAI-compatible)

Your usual chat completion request, sent to Kiori instead of your provider, with your Kiori key as the API key. Kiori syncs the messages (as /turn), appends each character's context to your system prompt (or adds one), passes the request on to your provider and returns its answer as it is, and records the reply. stream: true streams straight through.

GET /v1/models — your provider's models, passed on (same headers or settings)

Characters

POST /v1/characters — build a brain

FieldType
external_idstringyour own id; creating again with it gives the same character back (existing: true, no second build)
character_cardobject or stringa Tavern / SillyTavern card, v1, v2 or v3, as JSON or as the card PNG in base64: its name, description, personality, scenario, example dialogue and lorebook are used, and its scenario, first message and examples stay on the character's card. The card can also be sent as the whole body
namestringrequired (unless the card has it)
descriptionstringwho they are; the more, the more faithful
lorestringextra canon for this character (the project's lore is always included)
pronounsshe \he \theyoptional
archetypestringoptional starting point, see GET /v1/personality
personalityobjectoptional fixed values, { "param_id": 0-100 }

Returns at once with status: "building"; the organizer writes the brain in the background (about a minute and a half) and the status becomes ready (or failed, with status_detail). Poll GET /v1/characters/:id, or send "wait": true to hold the request until it's done. The SDK's characters.create waits for you.

GET /v1/characters · GET /v1/characters/:id

A character: id, name, aliases, pronouns, status, card {who, looks, personality, voice, core}, personality {41 values}, profile (text), attachment, deviations [...], memory_counts, origin.

PATCH /v1/characters/:id

Any of name, aliases, pronouns, card {...}, personality {...}, external_id. Card fields are merged. A new name keeps the old one as an alias, so events that use the old name still count.

DELETE /v1/characters/:id

GET /v1/characters/:id/memories

Query: story (include that story's layer), kind, q (recall order for a moment), limit.

POST /v1/characters/:id/memories — write memories yourself

{ "memories": [{ "kind", "title", "body", "keys": [], "about": [], "importance": 0-1, "emotion": 0-1 }], "story": "sto_..." } (no story = core). Kinds: memory person place belief feeling secret habit skill object fact intention trait.

PATCH /v1/memories/:id · DELETE /v1/memories/:id

Edit title, body, keys, importance (0-1), strength (0-1), status (active, done, deleted, merged). An edited text gets a new meaning fingerprint, so recall follows the edit. DELETE marks the memory deleted (it stops being recalled).

GET /v1/characters/:id/graph?story= — nodes and edges for a graph view

POST /v1/characters/:id/sleep — { "story": "sto_..." } (optional): fade, gist, merge

GET /v1/characters/:id/export?story= — the brain as an Obsidian vault (zip)

Stories

POST /v1/stories

{ "external_id", "title", "player": { "name", "description" }, "characters": ["chr_...", your id, or "Name"] }. With an external_id that already has a story, that story comes back (existing: true; characters listed that aren't in it yet join it). The player's description is for your narrator only; characters never receive it. A character that isn't in the project is a 404 (nothing is created).

GET /v1/stories · GET /v1/stories/:id · DELETE /v1/stories/:id

A story: id, title, player, events (count), characters [{id, name, learned_seq, unlearned}], status, learning. Deleting a story deletes its events and everything it taught (the characters' core brains stay).

POST /v1/stories/:id/characters — { "characters": [...] }

POST /v1/stories/:id/events — record what happened

{ "events": [
  { "role": "player", "text": "I hand her the letter." },
  { "role": "character", "speaker": "Mira", "text": "Mira: Where did you find this?", "present": ["Mira", "Leo"] },
  { "role": "narrator", "text": "*Rain hammers the glass.*\nMira: Sit down.", "scene": "lighthouse, night" }
] }

Returns { from, to, learning }. Learning starts by itself once 20 events are waiting beyond the newest 4; those 4 are the ones still being rerolled or edited, so they wait for the next round (POST /learn learns everything now).

POST /v1/stories/:id/sync — your messages as they are now

{ "messages": [{ "id", "role", "content" }, ...] }, oldest first (OpenAI-style, as in /turn). New messages are recorded; from the first one that was edited, rerolled or deleted, the story is rewound and written again. Anything after the last message sent counts as deleted. Messages are matched by id, or by their words when they have none. The same list twice changes nothing. Returns { added, kept, rewound_from, from, to, learning }.

GET /v1/stories/:id/events?from=&to=

Each event: seq, id (yours), role, speaker, text, present, scene, inferred, created_at.

DELETE /v1/stories/:id/events?from=N — rewind

N is an event number (a whole number). Removes event N and everything after it (an edited or rerolled message). What those events taught is taken back and learned again from what remains; the rest of the story stays learned, so nobody forgets it meanwhile. Returns { removed_from, relearning } (how many characters relearn a part).

POST /v1/stories/:id/context — what each character brings to this moment

Field
charactersids or names (default: everyone in the story), or "scene": whoever is in the current scene, whoever spoke last first (the player alone there: nobody)
max_characterswith "scene": at most this many (default 4, up to 20)
messagesrecent messages, strings or {text} (default: the last 6 events)
queryinstead of messages: the moment, in words
budgetmax characters of text per character (default 6000)
limitmemories to recall (default 10)
placetrue: first work out who is where in the events nobody has placed yet (one small model call). Use it in stories with several characters when you don't send present. If that call fails, the last placed scene is used and the answer carries warnings

Returns { characters: [{ character, text, memories, relations, state, facts, forgot, intentions, mind }] }. Put text in the prompt of whoever voices that character. No model call (unless place finds unplaced events); it costs nothing but an embedding.

POST /v1/stories/:id/check — the auditor

{ "draft": "...", "characters": [...] (default: whoever speaks in the draft, then the story's characters its narration names, up to 6), "speaker": "Mira" (a draft without Name: lines), "place": true (as in context) } Returns { ok, issues: [{ character, type, quote, why }], fixed, checked }. Types: leak, contradiction, invented_past, unsupported. fixed changes only the problem lines and keeps the draft's language.

POST /v1/stories/:id/learn — { "wait": true }

Learns everything new now. A long stretch is learned in order, piece by piece (about 24 scenes per model call), each piece building on the last. With wait: true (default) returns per character: events seen, items written, items dropped for lack of evidence. With wait: false it starts and returns; GET /v1/stories/:id shows learning and, after, status.last. The SDK polls for you.

POST /v1/stories/:id/rebuild

Forgets what this story taught and learns it again from the events. Runs in the background (wait: true to hold the request).

GET /v1/stories/:id/witnesses

Scenes with who was present: exactly what each character could know.

Project

Getting access

POST /v1/access — ask for access (no key needed)

{ "email", "name", "company", "use_case" }. The admin answers in the console; approved, you get a sign-in link by email. A few requests an hour from one address.

Admin (self-hosting)

X-Admin-Key: kia_... (in data/local/admin.key; older installs have a psa_... key, which keeps working)