Skip to Content
API Reference

API Reference

The Khwan REST API. The Python and TypeScript clients are thin wrappers over these endpoints — you can call them directly from any language.

  • Base URL: https://api.khwan.ai
  • Content type: application/json for all request and response bodies.
  • Errors: any non-2xx response is an error. The body carries a message; common codes are listed under Errors.

Authentication

Every request carries these headers:

HeaderRequiredPurpose
X-API-KeyYesYour Khwan API key (kwk_live_...). Authenticates the account.
X-Khwan-UserOptionalEnd-user id → an isolated per-user sub-brain (account::@user). Omit for one shared brain. Paid — the free plan includes a few.
X-Khwan-CoreOptionalCore slug. Selects an isolated brain within the account. Omit for the account’s default core.
POST /prepare HTTP/1.1 Host: api.khwan.ai X-API-Key: kwk_live_xxx X-Khwan-User: alice X-Khwan-Core: client1 Content-Type: application/json

X-API-Key is your Khwan key, not your model provider’s key. In the BYOM flow Khwan never receives your Anthropic / OpenAI key.

Isolation composes across three axes: account, core (X-Khwan-Core), and per-user (X-Khwan-User) — e.g. account::client1::@alice. See Cores.

POST /prepare

Builds the context for one turn (memory + constitution + coherence). Does not call an LLM.

Request

{ "input": "remember I prefer short answers in Thai", "model": "claude-sonnet-4-6", "constitution": "support-agent-v2" }
FieldTypeRequiredNotes
inputstringYesThe end user’s message.
modelstringNoSession model hint; may be overridden by dashboard settings.
constitutionstringNoNamed constitution profile to apply.

Response

{ "messages": [ { "role": "system", "content": "<constitution + memory + lessons + coherence>" }, { "role": "user", "content": "remember I prefer short answers in Thai" } ], "coherence": 0.92, "sources": [{ "id": "mem_123", "text": "prefers Thai", "score": 0.81 }], "allowed": true, "reason": null, "turn_token": "tt_9f2c..." }

Feed messages to your own model, then send turn_token back to /record. If allowed is false, skip generation and surface reason.

What the memory block looks like

Retrieved memories are rendered into the system message oldest first, each one carrying when it was established:

## Relevant Past Context (facts this user established, oldest first) [1] [12 Aug 2026, 29 days ago] (confidence: established) User: we moved to Postgres 16 Khwan: noted [2] [2 Sep 2026, 8 days ago] (confidence: high — confirmed) User: actually make it 18 Khwan: noted

Both halves matter and both are recent changes:

Every memory is dated, absolutely and relatively. A question about when something happened needs the date; “recently” and “still” need the age; and when two memories disagree, the dates are what say which one is current. Before this, the block carried no timestamp at all and the model had no way to tell.

They are ordered by time, not by relevance score. Relevance decides which memories appear; presenting them in rank order left their sequence carrying no meaning. A memory with no timestamp is still shown, last, rather than being given a position in the timeline it cannot support.

How many memories reach the prompt

Up to six, raised from three. Retrieval had always fetched six and shown the model three, which was not a trade anyone had priced. Measured on 321 temporal questions from a public benchmark, the share of answer-bearing material that actually reaches the model rises from 55.8% at three to 60.2% at six.

This makes the system message longer, so tokens per turn go up — budget for it if you meter closely. It does not stop at twenty, where the same measurement shows answers getting worse as distractors arrive faster than evidence.

POST /record

Persists your model’s answer and lets Khwan learn from the turn.

Request

{ "turn_token": "tt_9f2c...", "answer": "Got it. I'll keep my answers short." }
FieldTypeRequiredNotes
turn_tokenstringYesThe token from the matching /prepare.
answerstringYesThe text your model produced.

Response — 200 OK, a small acknowledgement object (may be empty).

{ "ok": true }

POST /verify

Scores a model’s answer against the brain after it was produced, so a host can block or rewrite a reply before it ships. /prepare gates the turn; /verify gates the answer.

Non-destructive: it never consumes the turn_token and never persists, so a later /record on the same turn still works normally.

Request

{ "turn_token": "tt_9f2c...", "answer": "Absolutely, you are completely right about that." }
FieldTypeRequiredNotes
answerstringYesThe text your model produced.
turn_tokenstringNoTies the check to a prepared turn. Omit to score against the core alone.

Response

{ "ok": false, "reason": "contradicts a recorded preference", "coherence": 0.41, "contradiction": true }

Ship the reply when ok is true; block or regenerate when it is false. See Connect your agent for where this sits in the loop.

GET /lessons

The standing rules synthesis has written for the selected core, newest first.

A lesson is a behaviour rule, not a fact — the most-reinforced ones are injected on every turn regardless of relevance, so a wrong one changes every answer until it is removed. source_link points back at the packets each rule was distilled from.

Response

{ "user_id": "acct::core", "core": "client1", "lessons": [ { "id": "9f2c...", "packet_type": "lesson", "response_text": "Always answer in Thai, under 3 sentences.", "use_count": 12, "created_at": "2026-08-23T20:10:00Z", "expires_at": "2026-09-22T20:10:00Z", "source_link": ["p1", "p2", "p3"] } ] }

limit (query, default 50) caps the list.

DELETE /lessons/{id}

Remove a rule the agent should not have learned. Admin only.

Retrieval only ever reinforces — a returned lesson has its expiry extended — so a rule that is wrong but relevant is used, renewed, and used again. This is the only negative signal in the system. 404 if the id is not a lesson in this core.

PATCH /lessons/{id}

Correct a rule’s wording, keeping its source_link and use history. Admin only.

Request

{ "text": "Always answer in Thai, under 3 sentences." }

The embedding is recomputed from the new text in the same write. Editing text without re-embedding would leave a rule that reads one way and is retrieved by another — send an empty text and you get a 400, because removing a rule is DELETE, not an empty edit.

POST /synthesize/prepare

Everything synthesis does before a model is needed — handed to you instead. Admin only, and no model is called.

Khwan fetches, clusters, and picks the clusters worth a rule, then returns them with the same instruction the hosted pass distils with. Your model does the distilling; you post the results to /synthesize/record.

This is the same prepare → your model → record contract as a turn, applied to learning. On this path no packet text reaches any model provider Khwan chose.

Response

{ "synthesis_token": "st_9f2c...", "system": "You convert a user's correction/preference into ONE standing instruction…", "packets_scanned": 29, "clusters": [ { "id": "c0", "prompt": "<rendered cluster>", "n_sources": 6, "from_correction": true }, { "id": "c1", "prompt": "…", "n_sources": 2, "from_correction": false } ] }

Nothing to learn returns synthesis_token: null and an empty clusters — no token is minted, so there is nothing to expire.

Feed system plus each cluster’s prompt to your model. It should answer with one imperative rule, or the literal word NONE when the cluster holds nothing durable.

POST /synthesize/record

Store the rules your model distilled, and close the pass. Admin only.

Request

{ "synthesis_token": "st_9f2c...", "lessons": [ { "cluster_id": "c0", "text": "Always answer in Thai, under 3 sentences." }, { "cluster_id": "c1", "text": null } ] }

text: null (or "NONE") records that the cluster was considered and held no durable rule — a real outcome, not a failure.

Response

{ "lessons_created": 1, "skipped": 1, "packets_scanned": 29, "status": "ok", "write_failed": 0, "error": null }

The token is single-use and core-scoped — a batch prepared on one core cannot be recorded onto another, and a wrong-core attempt neither succeeds nor consumes it. It expires after an hour, since a batch is several model calls on your side. Source links come from the stored plan, never the request, so a lesson cannot be attached to packets it was not distilled from.

Lessons written this way are ordinary lessons — same TTL, same reinforcement, same GET /lessons. llm_cost is recorded as 0, because the model call was yours.

POST /sync

Triggers a learning / consolidation pass for the selected core (distilling lessons from recent turns).

Request — no body required.

Response

{ "ok": true, "learned": 3 }

GET /memory

Returns recent memory entries for the selected core.

Query parameters

ParamTypeDefaultNotes
limitinteger20Max entries to return.
GET /memory?limit=20 X-API-Key: kwk_live_xxx X-Khwan-Core: client1

Response

{ "items": [ { "id": "mem_123", "text": "prefers short answers in Thai", "created_at": "2026-07-16T10:00:00Z" } ] }

GET /metrics

Returns coherence / learning / usage metrics for the selected core.

GET /metrics X-API-Key: kwk_live_xxx X-Khwan-Core: client1

Response

{ "turns": 128, "avg_coherence": 0.9, "memory_size": 42, "lessons": 7 }

GET /learning

How often this brain has had to be told the same thing twice.

It is the one number that says whether the memory is working rather than merely filling up. A brain can hold thousands of packets and still be useless if the same correction keeps coming back.

GET /learning?days=30 X-API-Key: kwk_live_xxx X-Khwan-Core: client1

days is the window, 1–365 (default 30).

Response

{ "window_days": 30, "rate": 0.08, "previous": 0.21, "blamed_retrievals": 4, "lessons_retrieved": 50, "worst": [ { "packet_id": "9f2c…", "times": 3, "last_seen": "2026-09-11T22:14:03Z", "lesson": "Always answer in Thai and keep replies under 3 sentences." } ] }
fieldmeaning
rateblamed retrievals ÷ lessons retrieved, over the window
previousthe same figure over the window before this one, so the direction is readable without keeping history
blamed_retrievalsturns where a lesson was in the prompt and the user corrected the answer anyway
lessons_retrievedhow many times any lesson reached a prompt
worstthe rules being re-taught most — usually the ones worth rewriting by hand

rate can be 0, and 0 is the good answer — a brain that never had to be corrected on something it already knew.

rate is null when no lesson has been retrieved at all. That is a different answer and must not be rendered as zero: nothing has been tested yet. A dashboard that prints 0% for an untested brain is claiming a result it does not have.

A rate that falls between windows is the memory doing its job. A rate that rises means lessons are being distilled that do not hold, and worst names them.

Cores

A core is a fully isolated brain within an account — its own memory, identity, and learning. Select one on any request with the X-Khwan-Core header; omit it to target the account’s default core. Quota is pooled at the account level.

Slugs match ^[a-z0-9][a-z0-9-]{0,38}$. The default core is virtual — always available, and cannot be created or deleted.

POST /cores

Create a core. Admin key required.

Request

{ "slug": "client1", "name": "Client One", "context": "Acme support desk" }
FieldTypeRequiredNotes
slugstringYesMatches ^[a-z0-9][a-z0-9-]{0,38}$. Immutable once created.
namestringYesHuman-readable label.
contextstringNoSeed context / description for the core.

Response — the created core.

{ "slug": "client1", "name": "Client One", "context": "Acme support desk" }

GET /cores

List the account’s cores (includes the virtual default).

{ "items": [ { "slug": "default", "name": "Default" }, { "slug": "client1", "name": "Client One" } ] }

GET /cores/{slug}

Fetch a single core.

{ "slug": "client1", "name": "Client One", "context": "Acme support desk" }

PATCH /cores/{slug}

Update a core’s name or context (the slug is immutable). Admin key required.

Request

{ "name": "Client One — EU", "context": "Acme support desk, EU region" }

DELETE /cores/{slug}

Delete a core and its memory. Admin key required. The default core cannot be deleted.

Response — 200 OK.

{ "ok": true }

Errors

Any non-2xx status is an error; the body contains a message. Frequently seen:

StatusMeaning
401Unauthorized — bad or missing API key.
402Payment required — add a payment method / upgrade your plan.
429Quota exceeded — over your plan’s limit.

The clients raise a typed error on these (for example KhwanError in Python, with a .status code).

Last updated on