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.

Coming soonNot yet in a published release

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)
StateStateless — you send full history each callStateful — TurboLLM stores chats and messages
ShapeOpenAI / Anthropic wire-compatibleTurboLLM's own versioned REST + SSE contract
Best forDrop-in for an existing OpenAI/Anthropic client or SDKBuilding a real chat product: threads, history, resumable generation
StorageNone — the caller owns itPluggable — 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.

  1. Turn it on

    Set api.ext.enabled: true in config.json. While off, every route under /api/ext/v1 answers a plain 404 — there's no reason to confirm the surface exists to someone who can't use it.

    {
      "api": {
        "ext": {
          "enabled": true,
          "maxInFlightPerTenant": 4,
          "requestsPerMinutePerTenant": 120
        }
      }
    }
  2. 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 to config.json's apiKeys array — 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
      }]
    }
  3. Point your app at the base URL

    Same host and port as everything else TurboLLM serves:

    $http://localhost:6996/api/ext/v1
Server-side only

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: done

The 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=411
A stream that just stops is UNKNOWN, not failed

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:

  1. Track the last event_seq you saw

    Every SSE frame carries an id: — that's your resume cursor. The TypeScript client exposes this as stream.lastEventSeq automatically.

  2. If the stream ends without done, ask the run

    Call GET /runs/{id} — the run resource is the source of truth, independent of whatever happened to the transport. Its status tells 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
    }
  3. Reattach with ?after=, or let resume() do it

    The client's resume(runId, afterSeq) wraps runs.stream and automatically falls back to re-reading the message on a replay_window_exceeded response, 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 | internal

code 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.

SituationHTTPtypecodeRetryable
Missing or invalid key401authunauthorizedno
Key valid, but lacks the required scope403authinsufficient_scopeno
Key valid, another tenant's or owner's resource404not_foundnot_foundno
Unknown chat / message / run id404not_foundnot_foundno
Empty content and no attachment400invalid_requestinvalid_inputno
Body, attachment, or metadata over limit413invalid_requestpayload_too_largeno
Capability not implemented by the store501unsupportednot_supportedno
if_version mismatch409conflictversion_conflictyes, after re-read
No model loaded409conflictmodel_not_loadedafter load
A run is already active for this chat409conflictgeneration_in_flightyes
Mutating a chat/message with an active run409conflictrun_activeyes
Reattach past the replay buffer409conflictreplay_window_exceededno
Idempotency-Key replay whose original run has since been pruned409conflictidempotency_replay_expiredno
Cancelling a run that has already ended409conflictnot_activeno
Tenant over its in-flight or request-rate cap429capacityrate_limitedyes
History exceeds the model's context window409enginecontext_overflowno
Engine failure mid-generation (crash, OOM, bad response)— (SSE)engineengine_erroryes — send a new message
Daemon restarted while a run was streaming— (SSE)enginedaemon_restartedyes — send a new message
Custom store returned malformed data (including an undecodable pagination cursor)500storagestorage_contract_violationno
Anything else — including a custom adapter that's unreachable or times out500internalinternalno
Cross-tenant access returns 404, not 403

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 theoretical

A 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 status

Both 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.

Never message content

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:

Worked example: 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.

LimitValue
Max page size (?limit=)200 (default 50)
Max message/chat body size1 MiB (1,048,576 bytes) — over-limit writes get 413 payload_too_large
Max attachments per message4
Default max in-flight generations per tenant4 (api.ext.maxInFlightPerTenant)
Default request rate per tenant120/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.