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.

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.

Response200 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 }

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.

Response200 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