External Chat API
A public, versioned, tenant-aware REST + SSE API for building real chat apps on top of TurboLLM — with persistence, history, editing, and (adapter-permitting) branching built in. An integrating app implements exactly two things — send a message and consume an SSE stream — and never writes a conversation schema of its own.
This page documents the design ahead of launch — it's final and has been through extensive internal review, but it hasn't shipped in a TurboLLM release yet. api.ext.enabled won't exist in your config.json schema until it does. Watch the GitHub releases for when it lands.
What this is
TurboLLM already has a private, undocumented chat store behind its own web UI: conversations, messages, editing, branching, folders. This API promotes that capability into a stable, versioned, public surface at /api/ext/v1, so any application can be a chat app without first building and operating its own conversation database — schema, migrations, message ordering, attachments, token accounting.
Persistence & history
Chats and messages are durably stored before generation starts, and paginated with stable cursors — never offset-based, never a page that shifts under you.
Editing
Messages carry an optimistic-concurrency version, so a client can edit safely without last-write-wins data loss.
Resumable generation
A generation is a Run resource, not just a stream — disconnect, reconnect, or poll, and the run is still the source of truth. See Resumption.
Bring your own database
Storage is a documented interface, not a fixed backend. See Bring your own database.
This is not the gateway
TurboLLM also serves OpenAI- and Anthropic-compatible endpoints at /v1. That gateway is stateless — you send the full message history on every request and TurboLLM remembers nothing. Pick whichever fits:
Gateway (/v1) | External Chat API (/api/ext/v1) | |
|---|---|---|
| State | Stateless — you send full history each call | Stateful — TurboLLM stores chats and messages |
| Shape | OpenAI / Anthropic wire-compatible | TurboLLM's own versioned REST + SSE contract |
| Best for | Drop-in for an existing OpenAI/Anthropic client or SDK | Building a real chat product: threads, history, resumable generation |
| Storage | None — the caller owns it | Pluggable — SQLite by default, or your own database |
Enable it
The external API is off by default — it exposes tenant data, so turning it on is an explicit, deliberate step, never something a version bump switches on.
Turn it on
Set
api.ext.enabled: trueinconfig.json. While off, every route under/api/ext/v1answers a plain404— there's no reason to confirm the surface exists to someone who can't use it.{ "api": { "ext": { "enabled": true, "maxInFlightPerTenant": 4, "requestsPerMinutePerTenant": 120 } } }Mint a tenant key
Every request is scoped to a tenant — the integrating application — resolved from the presented key and never from anything in the request itself. A key belongs to exactly one tenant and carries coarse scopes (
chats:read,chats:write,runs:write). There's no dedicated key-minting UI for this yet, so add one directly toconfig.json'sapiKeysarray — the daemon only ever stores a key's SHA-256 hash, never the raw value:// Compute the hash for a secret you generate yourself, e.g.: node -e "console.log(require('crypto').createHash('sha256').update('YOUR-SECRET-HERE').digest('hex'))"{ "apiKeys": [{ "id": "...", "name": "my-integration", "prefix": "tllm-ext-...", "hash": "<sha256 hex from above>", "tenant": "my-integration", "scopes": ["chats:read", "chats:write", "runs:write"], "createdAt": "2026-08-18T00:00:00.000Z", "lastUsedAt": null }] }Point your app at the base URL
Same host and port as everything else TurboLLM serves:
$http://localhost:6996/api/ext/v1
A tenant key is a credential for your whole application, not one end user — shipping it in browser JavaScript leaks it to every visitor who opens dev tools. Keep it in your backend and proxy requests from there; use owner (see below) to distinguish your own end users.
Five-minute quickstart
Every mutating call is scoped by owner — your integration's own end-user id, opaque to TurboLLM (defaults to "default" if you have only one). Here's create a chat, send a message, and consume the stream — TypeScript client and raw curl side by side.
import { TurboLLMChat } from "@turbollm/chat-client";
const client = new TurboLLMChat({
baseUrl: "http://localhost:6996/api/ext/v1",
apiKey: process.env.TURBOLLM_TENANT_KEY!,
});
const chat = await client.chats.create({ title: "Support thread", owner: "user_4821" });
const stream = client.send(chat.id, {
owner: "user_4821",
content: "Summarize the attached report.",
});
for await (const ev of stream) {
if (ev.event === "delta") process.stdout.write(ev.data.content);
}
// stream.outcome is 'complete' | 'failed' | 'aborted' | 'unknown' once the loop ends —
// see Resumption below for what 'unknown' means and why it's not an error.
console.log("\n\noutcome:", stream.outcome, "last event_seq:", stream.lastEventSeq);# 1. Create a chat
curl -s http://localhost:6996/api/ext/v1/chats \
-H "Authorization: Bearer $TURBOLLM_TENANT_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Support thread","owner":"user_4821"}'
# -> { "id": "chat_01J8...", "owner": "user_4821", "title": "Support thread", ... }
# 2. Send a message and stream the reply
curl -N http://localhost:6996/api/ext/v1/chats/chat_01J8.../messages \
-H "Authorization: Bearer $TURBOLLM_TENANT_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Accept: text/event-stream" \
-H "Content-Type: application/json" \
-d '{"role":"user","content":"Summarize the attached report.","owner":"user_4821","generate":true}'
# -> event: run / event: delta (repeated) / event: usage / event: doneThe TypeScript client's send()/resume() surface implements the reconciliation rule in the next section automatically. If you write your own SSE client, you need to implement it yourself — it's the one rule that actually matters here.
Resumption: the one rule that matters
The resume token is event_seq — a single counter across every event kind (delta, reasoning, tool_call, usage), sent as the SSE frame's id: field. Reattach to a run with ?after=<event_seq>:
GET /api/ext/v1/runs/run_01J8.../stream?after=411afterwithin the run's replay buffer (4 MB or 5,000 events, whichever is smaller) → exact replay from that point, then live, no gap and no duplicate.afterolder than the buffer's oldest event →409 replay_window_exceeded. Re-read the message viaGET /messages/{id}to resynchronize, then reattach at the currentevent_seqfromGET /runs/{id}.
Once SSE headers are sent, the HTTP status is fixed — so a stream can only report failure inside the stream, via a terminal event: done frame carrying the final status (complete | failed | aborted). If the connection closes without a done event, the outcome is unknown — not failed. The run itself may still be generating, or may have finished with content you haven't seen yet. Never treat a dropped connection as a failed generation.
The correct client behavior, worked out:
Track the last
event_seqyou sawEvery SSE frame carries an
id:— that's your resume cursor. The TypeScript client exposes this asstream.lastEventSeqautomatically.If the stream ends without
done, ask the runCall
GET /runs/{id}— the run resource is the source of truth, independent of whatever happened to the transport. Itsstatustells you what actually happened.const run = await client.runs.get(runId); if (run.status === "streaming") { // still going — reattach const resumed = await client.resume(runId, stream.lastEventSeq); } else { // complete / failed / aborted — read the final message, done }Reattach with
?after=, or letresume()do itThe client's
resume(runId, afterSeq)wrapsruns.streamand automatically falls back to re-reading the message on areplay_window_exceededresponse, so this reconciliation dance isn't something you write by hand.
Errors
Every error shares one envelope, extending TurboLLM's existing { error: { code, message } } shape:
{
"error": {
"type": "capacity",
"code": "rate_limited",
"message": "Too many requests for this tenant. Slow down and retry shortly.",
"request_id": "req_01J8...",
"retryable": true,
"retry_after_ms": 60000
}
}type is the coarse class you switch on — frozen at nine values, never extended without a major version:
invalid_request | auth | not_found | conflict | capacity | engine | storage | unsupported | internalcode is the precise, open reason — adding a new one is non-breaking, so don't switch on strings you don't recognize; fall back to handling by type.
| Situation | HTTP | type | code | Retryable |
|---|---|---|---|---|
| Missing or invalid key | 401 | auth | unauthorized | no |
| Key valid, but lacks the required scope | 403 | auth | insufficient_scope | no |
| Key valid, another tenant's or owner's resource | 404 | not_found | not_found | no |
| Unknown chat / message / run id | 404 | not_found | not_found | no |
| Empty content and no attachment | 400 | invalid_request | invalid_input | no |
| Body, attachment, or metadata over limit | 413 | invalid_request | payload_too_large | no |
| Capability not implemented by the store | 501 | unsupported | not_supported | no |
if_version mismatch | 409 | conflict | version_conflict | yes, after re-read |
| No model loaded | 409 | conflict | model_not_loaded | after load |
| A run is already active for this chat | 409 | conflict | generation_in_flight | yes |
| Mutating a chat/message with an active run | 409 | conflict | run_active | yes |
| Reattach past the replay buffer | 409 | conflict | replay_window_exceeded | no |
| Idempotency-Key replay whose original run has since been pruned | 409 | conflict | idempotency_replay_expired | no |
| Cancelling a run that has already ended | 409 | conflict | not_active | no |
| Tenant over its in-flight or request-rate cap | 429 | capacity | rate_limited | yes |
| History exceeds the model's context window | 409 | engine | context_overflow | no |
| Engine failure mid-generation (crash, OOM, bad response) | — (SSE) | engine | engine_error | yes — send a new message |
| Daemon restarted while a run was streaming | — (SSE) | engine | daemon_restarted | yes — send a new message |
| Custom store returned malformed data (including an undecodable pagination cursor) | 500 | storage | storage_contract_violation | no |
| Anything else — including a custom adapter that's unreachable or times out | 500 | internal | internal | no |
A 403 would confirm the resource exists, letting one tenant enumerate another's ids by watching status codes. Not negotiable. The one 403 on this surface is insufficient_scope — that's about what the key itself is allowed to do, never about whether a particular resource exists, so it gives nothing away.
context_overflow is real, not theoreticalA long chat can exceed a smaller loaded model's context window. TurboLLM does not silently truncate history to make it fit — that would mean the model answering from a history you believe you sent. It fails loudly with context_overflow and leaves the decision (start a new chat, load a bigger-context model) to you.
engine_error and daemon_restarted never arrive as an HTTP statusBoth only happen after generation is already underway — the response headers are already committed, JSON or SSE — so they show up as an event: error frame and in the run's own error field (GET /runs/{id}), never as a direct response status. That also means they carry only type/code/message, not the fuller envelope above — no retryable, request_id, or param.
Audit log
GET /api/ext/v1/audit is the tenant's own record of every mutation on this surface — who did what, when, and what it returned. It requires the chats:read scope, and like every other read here it's scoped to tenant and owner: one owner can never see another owner's history, even within the same tenant.
curl -s "http://localhost:6996/api/ext/v1/audit?owner=user_4821&limit=5" \
-H "Authorization: Bearer $TURBOLLM_TENANT_KEY"{
"data": [
{
"id": "5f1e2a90-...",
"owner": "user_4821",
"action": "message.create",
"target_id": "msg_01J8...",
"request_id": "req_01J8...",
"status": 201,
"key_prefix": "tllm-ext",
"at": "2026-08-18T00:03:12.441Z"
}
]
}Only mutations are recorded. action is one of chat.create, chat.update, chat.delete, message.create, message.update, message.delete, run.start, or run.cancel — plus the sentinel request.rate_limited for a request the blanket per-tenant budget refused before any route ran at all. Reads, including GET /audit itself, are never audited: at read-heavy traffic volumes they'd dwarf the mutations without adding any accountability. status is the real HTTP status the caller received, so a refused mutation — a 404, a scope-denied 403, a 409 run_active — is itself part of the trail, not just the successes. key_prefix is the first 8 characters of the presented key: enough to tell keys apart in a log, never enough to reconstruct one.
Filter with ?owner= (defaults the same way every other route does) and ?since=<ISO-8601>. ?limit= defaults to 200 and, unlike every other list endpoint on this surface, isn't clamped to a maximum. Rows come back newest first, and there's no cursor here — for anything larger than one page, set since to the oldest row's at and keep paging backward in time.
There is no content column in the audit table, by design — an audit trail that carried what was said would just be a second, unscoped copy of every conversation, defeating the tenancy boundary it exists to police. A dedicated test asserts no row can ever contain the substring content.
Entries are pruned automatically after 30 days (a fixed default today, not yet configurable), on the same 30-second background tick that reaps orphaned runs and expired idempotency keys.
Bring your own database
Storage behind this API is a documented interface — ChatStore — not a fixed backend. TurboLLM ships SqliteChatStore as the default, but any integrator can implement the interface over their own database and configure the daemon to load it:
// config.json
{
"chatStore": { "kind": "sqlite" } // default
// { "kind": "module", "specifier": "./my-store.mjs", "options": {} }
}The interface is deliberately small: 13 required methods — create/get/list/update/delete for chats and messages, plus health() and close() — over a typed core of scope, id, and ordering fields, plus one opaque JSON document for everything else TurboLLM stores today or adds later. Two optional capability groups (branching, folders) and two single-method ones (search, batch) are declared, not assumed — a client can discover what's supported at GET /capabilities before it ever calls a gated endpoint, and an unsupported call answers 501 not_supported rather than failing in a surprising way.
Scope on every call
tenant and owner are positional arguments on every method — never optional filters — so an unscoped query is impossible to write by omission.
Optimistic concurrency
Every chat and message carries a version. Writers pass ifVersion; a mismatch is a 409 with the current resource, never a silent overwrite.
Atomic seq
Message ordering is the adapter's job: seq must be allocated atomically per chat, gapless and distinct even under concurrent appends.
Fail loud, not quiet
If your adapter fails to load, fails to export the right factory shape, or fails its health check, the daemon refuses to start — never a silent fallback to SQLite that would write your users' data to the wrong database.
Every adapter — including the built-in SQLite one — is proven against the same conformance suite: scope isolation (adversarially — a query in one tenant never returns another's rows), atomic seq allocation under concurrency, counter maintenance, optimistic-version semantics, blob round-trip fidelity, capability honesty, and error mapping. There's a full worked example — schema, adapter, and a passing run of this exact suite — for Postgres:
examples/postgres-chat-store in the TurboLLM repo — a complete, non-SQLite ChatStore implementing all 13 methods over plain pg, with the schema, the adapter, and instructions to run the real conformance suite against it yourself.
Limits
Bounded on purpose — on a single-GPU box, an unbounded queue is a memory leak that presents to your users as a hang.
| Limit | Value |
|---|---|
Max page size (?limit=) | 200 (default 50) |
| Max message/chat body size | 1 MiB (1,048,576 bytes) — over-limit writes get 413 payload_too_large |
| Max attachments per message | 4 |
| Default max in-flight generations per tenant | 4 (api.ext.maxInFlightPerTenant) |
| Default request rate per tenant | 120/min across the whole surface, reads included (api.ext.requestsPerMinutePerTenant) |
Check current values live at any time:
curl http://localhost:6996/api/ext/v1/capabilities \
-H "Authorization: Bearer $TURBOLLM_TENANT_KEY"
# -> { "capabilities": {...}, "limits": { "max_page_size": 200, "max_body_bytes": 1048576, "max_attachments": 4 } }Exceeding either cap returns 429 rate_limited with a Retry-After header — this surface has one capacity signal today, not a separate per-tenant-vs-whole-box split. A generation that fails after it's already started because the daemon itself is unavailable (GPU contention, an engine hiccup) surfaces as engine_error over SSE instead, not as a direct HTTP status — see the Errors section above.