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/jsonfor 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:
| Header | Required | Purpose |
|---|---|---|
X-API-Key | Yes | Your Khwan API key (kwk_live_...). Authenticates the account. |
X-Khwan-User | Optional | End-user id → an isolated per-user sub-brain (account::@user). Omit for one shared brain. Paid — the free plan includes a few. |
X-Khwan-Core | Optional | Core 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/jsonX-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"
}| Field | Type | Required | Notes |
|---|---|---|---|
input | string | Yes | The end user’s message. |
model | string | No | Session model hint; may be overridden by dashboard settings. |
constitution | string | No | Named 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."
}| Field | Type | Required | Notes |
|---|---|---|---|
turn_token | string | Yes | The token from the matching /prepare. |
answer | string | Yes | The 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."
}| Field | Type | Required | Notes |
|---|---|---|---|
answer | string | Yes | The text your model produced. |
turn_token | string | No | Ties 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
| Param | Type | Default | Notes |
|---|---|---|---|
limit | integer | 20 | Max entries to return. |
GET /memory?limit=20
X-API-Key: kwk_live_xxx
X-Khwan-Core: client1Response
{
"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: client1Response
{
"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" }| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | Yes | Matches ^[a-z0-9][a-z0-9-]{0,38}$. Immutable once created. |
name | string | Yes | Human-readable label. |
context | string | No | Seed 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:
| Status | Meaning |
|---|---|
401 | Unauthorized — bad or missing API key. |
402 | Payment required — add a payment method / upgrade your plan. |
429 | Quota exceeded — over your plan’s limit. |
The clients raise a typed error on these (for example KhwanError in Python, with
a .status code).