API reference
Streaming
The same turn as /v1/chat, delivered as server-sent events. Same tools, same prompts, same
staged actions on the completion frame.
POST /v1/chat/stream
The request body and headers are identical to /v1/chat, including
X-Tether-Customer-Token for a verified customer; a token that
does not verify is refused with 401 invalid_customer_token and its reason before any frame is
sent, and X-Tether-Upstream-Token for the signed-in person's own API token,
refused as malformed with 400 invalid_upstream_token before any frame is sent. The response is text/event-stream with Cache-Control: no-cache and
X-Accel-Buffering: no.
Frames
Every frame is a single data: line containing JSON, terminated by a blank line. There are no
named SSE event types — branch on the type field.
data: {"type":"task_start","task":"load_context","label":"Loading data"}
data: {"type":"chunk","text":"Seven tickets are"}
data: {"type":"chunk","text":" still open."}
data: {"type":"complete","raw":"{…}","sessionId":"0f2c…","requestId":"7d1e…","response_id":"resp_9f1c…","pendingActions":[]}
type | Fields | Meaning |
|---|---|---|
chunk | text | A fragment of the answer as it generates. Concatenate in order. |
complete | raw, sessionId, requestId, response_id, pendingActions | The terminal frame. raw is the full block document as a JSON string, including handoff when the turn was handed to a person. |
error | message, optional code, optional retryAfterSeconds | Terminal. No complete follows. |
agent_start / agent_complete | agent, label, latencyMs | Progress. Safe to ignore. |
task_start / task_complete | task, label, latencyMs | Progress. Safe to ignore. |
message | task, label | Progress. Safe to ignore. |
The completion frame
raw is the string form of the same document /v1/chat returns, so it needs a second parse:
const es = new EventSource('/api/assistant/stream') // your backend proxies to Tether
let text = ''
es.onmessage = (e) => {
const frame = JSON.parse(e.data)
switch (frame.type) {
case 'chunk':
text += frame.text
renderPartial(text) // optimistic prose while the turn runs
break
case 'complete': {
const doc = JSON.parse(frame.raw) // the block document
renderBlocks(doc.blocks) // authoritative — replaces the partial text
setSession(frame.sessionId)
renderPendingActions(frame.pendingActions)
es.close()
break
}
case 'error':
renderError(frame)
es.close()
break
}
}
Note the field naming: the streaming frame uses sessionId and pendingActions (camelCase),
where the buffered response uses session_id and pending_actions. This is an inconsistency,
not a subtlety — the buffered document inside raw keeps its snake_case fields.
Chunks are prose, blocks are the answer
The chunk stream is the model's raw output on its way to becoming the block document. Render
it if you want the typing effect, but treat the complete frame as authoritative: validation,
repair and fallback all happen at the end, and the final blocks may not match the streamed text
verbatim.
If you only need the answer, ignore chunk entirely and render on complete.
Error frames
{ "type": "error", "code": "RATE_LIMITED", "message": "The AI service is handling too many requests right now. Please try again in a few seconds.", "retryAfterSeconds": 3 }
code | Retryable | Meaning |
|---|---|---|
RATE_LIMITED | yes, after retryAfterSeconds | The upstream provider throttled the turn |
ORG_DAILY_BUDGET_EXCEEDED | at the next UTC day | This tenant's daily spend cap |
ORG_MONTHLY_BUDGET_EXCEEDED | at the next UTC month | This tenant's monthly spend cap |
GLOBAL_DAILY_BUDGET_EXCEEDED | operator decision | Platform-wide daily cap |
GLOBAL_MONTHLY_BUDGET_EXCEEDED | operator decision | Platform-wide monthly cap |
| (no code) | no | Out of scope, or an unclassified provider failure |
An error frame is terminal. The HTTP status is already 200 — the stream opened before the
failure was known — so a client that only inspects status codes will report success on a
failed turn.
A sessionId this guest may not continue is refused before the stream opens: 403 with a JSON
body, { "error": { "code": "session_not_yours", "reason": … } }, exactly as on
/v1/chat.
Parity with the buffered path
Same routing, same ceilings, same tool loop, same validation, same ledger row. A turn that
stages a write on /v1/chat stages it identically here and returns it in pendingActions on
the completion frame.
The only differences are transport-level: the frame shapes above, and the fact that an error
arrives inside a 200 response.
Proxying
Terminate the stream on your own backend and forward frames to the browser. Two reasons: the
API key must never reach a browser, and buffering proxies between you and the client will
otherwise defeat the streaming entirely. X-Accel-Buffering: no is set on Tether's response —
set the equivalent on yours.
Related
- Chat — the buffered turn
- Rendering blocks
- Errors