Tether
DocsGitHub

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":[]}
typeFieldsMeaning
chunktextA fragment of the answer as it generates. Concatenate in order.
completeraw, sessionId, requestId, response_id, pendingActionsThe terminal frame. raw is the full block document as a JSON string, including handoff when the turn was handed to a person.
errormessage, optional code, optional retryAfterSecondsTerminal. No complete follows.
agent_start / agent_completeagent, label, latencyMsProgress. Safe to ignore.
task_start / task_completetask, label, latencyMsProgress. Safe to ignore.
messagetask, labelProgress. 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 }
codeRetryableMeaning
RATE_LIMITEDyes, after retryAfterSecondsThe upstream provider throttled the turn
ORG_DAILY_BUDGET_EXCEEDEDat the next UTC dayThis tenant's daily spend cap
ORG_MONTHLY_BUDGET_EXCEEDEDat the next UTC monthThis tenant's monthly spend cap
GLOBAL_DAILY_BUDGET_EXCEEDEDoperator decisionPlatform-wide daily cap
GLOBAL_MONTHLY_BUDGET_EXCEEDEDoperator decisionPlatform-wide monthly cap
(no code)noOut 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.