Skip to content

SSE Streaming

AI run results are streamed in real time via Server-Sent Events (SSE). Connect to a run's stream endpoint to receive incremental tokens, tool progress, workflow steps, and terminal events as they occur.

Stream Endpoints

MethodPathDescriptionAuthRate Limit
GET/ai/runs/:runId/streamStream events for a feature runJWT + Entitlement20/min
GET/ai/sessions/:sessionId/messages/:messageId/streamStream events for a chat messageJWT + Entitlement20/min

Connecting to a Stream

Open an SSE connection by sending a GET request with the Accept: text/event-stream header (or use the browser's EventSource API).

Use the id from POST /ai/features/:featureKey/runs as $RUN_ID.

bash
curl -N https://api.chainabit.com/api/v1/ai/runs/$RUN_ID/stream \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: text/event-stream"
javascript
// Using EventSource (browser)
const runId = process.env.RUN_ID; // from POST /ai/features/:featureKey/runs's data.id
const url = `${BASE_URL}/ai/runs/${runId}/stream`;
const eventSource = new EventSource(url, {
  headers: { Authorization: `Bearer ${TOKEN}` },
});

eventSource.addEventListener("message.delta", (e) => {
  const data = JSON.parse(e.data);
  process.stdout.write(data.payload.delta);
});

eventSource.addEventListener("run.settlement.completed", (e) => {
  console.log("Run complete");
  eventSource.close();
});

eventSource.addEventListener("run.error", (e) => {
  const data = JSON.parse(e.data);
  console.error("Error:", data.payload.error);
  eventSource.close();
});
python
import requests, os

run_id = os.environ["RUN_ID"]  # from POST /ai/features/:featureKey/runs's data["id"]

with requests.get(
    f"{BASE_URL}/ai/runs/{run_id}/stream",
    headers={"Authorization": f"Bearer {TOKEN}"},
    stream=True,
) as response:
    for line in response.iter_lines(decode_unicode=True):
        if line.startswith("data: "):
            import json
            event = json.loads(line[6:])
            print(event["type"], event["payload"])

Event Frame Format

Each event is sent as a standard SSE frame:

id: 1
retry: 3000
event: message.delta
data: {"eventId":1,"type":"message.delta","runId":"550e8400-...","timestamp":"2026-03-17T14:00:00.123Z","payload":{"delta":"Hello"}}
FieldDescription
idMonotonically increasing event ID (use for reconnection)
retryReconnection interval in milliseconds (3000ms)
eventEvent type name
dataJSON envelope with full event details

Data Envelope Fields

FieldTypeDescription
eventIdnumberSequential event counter for this run
typestringEvent type (see table below)
runIdstringThe AI run UUID
stepIdstring | undefinedStep ID (for workflow/tool events)
timestampstringISO 8601 event timestamp
payloadobjectEvent-specific data

Event Types

30+ event types organized by category:

Connection

Event TypeDescriptionTerminal
stream.connectedEmitted once per connection after headers flush. Carries { runId }. Not buffered — will not appear in event replay.No

Capability Routing

Event TypeDescriptionTerminal
capability.resolvedThe requested AI capability was routed to an executable pathNo

Run Lifecycle

Event TypeDescriptionTerminal
run.startedRun processing has begunNo
run.status.changedRun status transitionNo
run.model.switchedActive model changed (model unavailable or provider failover)No
run.cancel_requestedCancellation was requestedNo
run.cancellingCancellation in progressNo
run.cancelledRun cancelled successfullyYes
run.failedRun failed with an errorYes
run.errorRuntime/timeout errorYes

run.model.switched Payload

json
{
  "eventId": 3,
  "type": "run.model.switched",
  "runId": "550e8400-e29b-41d4-a716-446655440000",
  "timestamp": "2026-04-15T12:00:00.300Z",
  "payload": {
    "runId": "550e8400-e29b-41d4-a716-446655440000",
    "fromModelKey": "gemini-2.0-flash",
    "fromProvider": "google",
    "toModelKey": "gemini-2.5-flash",
    "toProvider": "google",
    "reason": "model_not_found"
  }
}

This event is informational — the run continues uninterrupted with the new model. Clients may surface it as a status indicator but do not need to take any action.

Message Streaming

Event TypeDescriptionTerminal
message.deltaIncremental token content (partial response)No
message.completedFull message has been assembledNo
message.suggestionsFollow-up suggestion cards for the completed assistant messageNo
message.partially_completedPartial completion (e.g., mid-cancel)No

message.suggestions Payload

json
{
  "eventId": 11,
  "type": "message.suggestions",
  "runId": "550e8400-e29b-41d4-a716-446655440000",
  "timestamp": "2026-04-15T12:00:01.000Z",
  "payload": {
    "messageId": "cm5msg002",
    "suggestions": [
      {
        "suggestion": "Can you make that more practical for this week?",
        "priority": 1
      },
      {
        "suggestion": "What would the first 10 minutes look like?",
        "priority": 2
      }
    ]
  }
}

message.suggestions is emitted immediately after message.completed (in the same tick) and before run.settlement.completed. Suggestion chips are produced by the same model call that wrote the assistant reply, so they arrive as soon as the reply is finalized. Clients should render these cards outside the assistant markdown so copying the answer does not include suggestion text.

Tool Calls

Tool events occur when Chao calls built-in tools (calendar lookups, bit queries, chainies/chains operations, memory, and write operations like creating or completing bits) during a conversation. Multiple tools may be called in parallel within a single turn.

Event TypeDescriptionTerminal
tool.startedTool call initiatedNo
tool.progressTool execution progressNo
tool.completedTool call finished successfullyNo
tool.failedTool call failed (non-terminal — Chao handles errors gracefully)No
tool.degradedTool or capability returned a graceful fallback stateNo

tool.started Payload

json
{
  "eventId": 4,
  "type": "tool.started",
  "runId": "550e8400-e29b-41d4-a716-446655440000",
  "stepId": "tc_abc123",
  "timestamp": "2026-04-12T10:00:01.100Z",
  "payload": {
    "toolKey": "calendar.today",
    "callId": "tc_abc123",
    "input": {},
    "activity": {
      "activityType": "tool_read",
      "origin": "chao",
      "label": "Reading calendar",
      "detail": "Starting tool execution.",
      "renderHint": "tool_card",
      "status": "active",
      "subjectType": "calendar",
      "subjectId": "tc_abc123"
    }
  }
}

tool.completed Payload

json
{
  "eventId": 5,
  "type": "tool.completed",
  "runId": "550e8400-e29b-41d4-a716-446655440000",
  "stepId": "tc_abc123",
  "timestamp": "2026-04-12T10:00:01.160Z",
  "payload": {
    "toolKey": "calendar.today",
    "callId": "tc_abc123",
    "executionMs": 58,
    "success": true,
    "data": { "items": [] },
    "activity": {
      "activityType": "tool_read",
      "origin": "chao",
      "label": "Calendar complete",
      "detail": "Tool execution completed.",
      "renderHint": "tool_card",
      "status": "completed",
      "subjectType": "calendar",
      "subjectId": "tc_abc123"
    }
  }
}

tool.failed Payload

json
{
  "eventId": 5,
  "type": "tool.failed",
  "runId": "550e8400-e29b-41d4-a716-446655440000",
  "stepId": "tc_abc123",
  "timestamp": "2026-04-12T10:00:06.105Z",
  "payload": {
    "toolKey": "bits.get",
    "callId": "tc_abc123",
    "executionMs": 5001,
    "success": false,
    "error": "Tool execution timed out",
    "activity": {
      "activityType": "tool_read",
      "origin": "chao",
      "label": "Tool failed",
      "detail": "Tool execution timed out",
      "renderHint": "tool_card",
      "status": "failed",
      "subjectType": "bits",
      "subjectId": "tc_abc123"
    }
  }
}

tool.failed is not a terminal event. Chao receives the error result and continues generating a response.

Treat payload.data as tool observation output. It can help render a card, but Chao's assistant message remains the user-facing synthesis.

Tool card normalization

Frontend and mobile clients should normalize tool-like payloads before rendering:

Normalized fieldPreferred lookup
toolKeypayload.toolKey ?? payload.key
callIdpayload.callId ?? payload.toolCallId ?? envelope.stepId
inputpayload.input ?? payload.args
outputpayload.data ?? payload.output

When payload.activity exists, render cards from activityType, origin, label, detail, renderHint, status, subjectType, and subjectId.

Approval Gates

When Chao needs to perform a write or destructive action in a session with mode: "approval" (the default), it pauses and waits for the user to approve or reject the tool call. Three events manage this flow:

Event TypeDescriptionTerminal
tool.approval_requiredChao requires user approval before executing a write/destructive toolNo
tool.approval_responseUser approved or rejected the pending tool callNo
tool.approval_timeoutApproval window (60 s) expired; tool was not executedNo

tool.approval_required Payload

json
{
  "eventId": 6,
  "type": "tool.approval_required",
  "runId": "550e8400-e29b-41d4-a716-446655440000",
  "stepId": "tc_abc123",
  "timestamp": "2026-04-12T10:00:01.500Z",
  "payload": {
    "runId": "550e8400-e29b-41d4-a716-446655440000",
    "toolCallId": "tc_abc123",
    "toolKey": "bits.create",
    "input": { "title": "Review Q2 metrics", "priority": "high" },
    "activity": {
      "activityType": "approval",
      "origin": "chao",
      "label": "Approval needed",
      "detail": "Review this action before Chao runs it.",
      "renderHint": "status_card",
      "status": "pending",
      "subjectType": "bits",
      "subjectId": "tc_abc123"
    }
  }
}

Submit the user's decision with one of these endpoints:

DecisionEndpointBody
ApprovePOST /ai/runs/:runId/tools/:toolCallId/approvenone
RejectPOST /ai/runs/:runId/tools/:toolCallId/reject{ "reason"?: string }

After approval, Chao resumes and emits tool.approval_response, tool.started, then tool.completed. If rejected or timed out, Chao continues and explains the result.

Reasoning and Activity Summaries

Public clients should show safe activity summaries, not raw private Chain-of-Thought. Tool, approval, planning, and capability events may include payload.activity; use that object for status cards and timelines.

Event TypeDescriptionTerminal
tool.progressTool progress/activity updateNo
tool.approval_requiredApproval activity cardNo
plan.step_addedPlanned action summaryNo
capability.resolvedCapability routing summaryNo

Diagnostic cot.* events may appear in some internal/debug sessions. They are not the public tool-card contract and should not be shown to end users by default.

Workflow Steps

Event TypeDescriptionTerminal
workflow.step.startedWorkflow step beganNo
workflow.step.completedWorkflow step finishedNo
workflow.step.failedWorkflow step failedNo
workflow.completedEntire workflow finishedYes

Settlement

Event TypeDescriptionTerminal
run.settlement.pendingCredit settlement is processingNo
run.settlement.completedSettlement done, run fully finishedYes

Terminal events cause the stream to close automatically after a short grace period.


Reconnection with Last-Event-ID

If the connection drops, reconnect using the Last-Event-ID header to resume from where you left off:

bash
curl -N https://api.chainabit.com/api/v1/ai/runs/$RUN_ID/stream \
  -H "Authorization: Bearer $TOKEN" \
  -H "Last-Event-ID: 5"

The server replays all events with eventId > 5 before switching to live delivery. Events are buffered for 15 minutes after the run completes.

JavaScript Reconnection Example

javascript
function connectStream(runId, lastEventId = null) {
  const url = new URL(`${BASE_URL}/ai/runs/${runId}/stream`);

  const headers = { Authorization: `Bearer ${TOKEN}` };
  if (lastEventId) {
    headers["Last-Event-ID"] = String(lastEventId);
  }

  const eventSource = new EventSource(url, { headers });
  let latestId = lastEventId || 0;

  eventSource.onmessage = (e) => {
    latestId = Number(e.lastEventId);
  };

  eventSource.onerror = () => {
    eventSource.close();
    // Reconnect after retry interval (3 seconds)
    setTimeout(() => connectStream(runId, latestId), 3000);
  };

  return eventSource;
}

Retry Behavior

The retry: 3000 field in every SSE frame instructs compliant clients (including browser EventSource) to automatically reconnect after 3 seconds if the connection is lost. The server tracks the Last-Event-ID header on reconnection to replay missed events.


Error Events

The run.error event signals a runtime error during streaming:

event: run.error
data: {"eventId":8,"type":"run.error","runId":"...","timestamp":"...","payload":{"error":"Stream timeout exceeded","code":"stream_timeout"}}
Payload FieldTypeDescription
errorstringHuman-readable error message
codestringError code — see table below
CodeMeaning
execution_errorGeneral error during LLM execution
stream_timeoutConnection open for more than 5 minutes
provider_errorUnhandled upstream provider failure
empty_responseProvider returned no text and no tool calls. The run fails; no automatic failover is attempted for this error code.

run.error is a terminal event -- the stream closes after it is sent.


Stream Timeout

Streams are automatically closed after 5 minutes of continuous connection. If the run has not completed by then, a run.error event is emitted with code: "stream_timeout". The client should reconnect with Last-Event-ID to continue receiving events.


Connection Lifecycle

Simple run (no tool calls):

Client                                  Server
  │                                       │
  │──── GET /ai/runs/:id/stream ────────►│
  │                                       │ Set headers (text/event-stream)
  │◄──── event: stream.connected ────────│
  │◄──── (replay missed events) ─────────│
  │                                       │
  │◄──── event: message.delta ───────────│  (real-time events)
  │◄──── event: message.delta ───────────│
  │◄──── event: message.completed ───────│
  │◄──── event: run.settlement.completed─│  (terminal)
  │                                       │
  │──── connection closed ───────────────│

Chao run with auto tool calls:

Client                                  Server
  │                                       │
  │──── GET /ai/runs/:id/stream ────────►│
  │◄──── event: stream.connected ────────│
  │◄──── event: run.started ─────────────│
  │◄──── event: tool.started ────────────│  (card opens with activity)
  │◄──── event: tool.progress ───────────│  (optional status update)
  │◄──── event: tool.completed ──────────│
  │◄──── event: message.delta ───────────│  (LLM writes response)
  │◄──── event: message.completed ───────│
  │◄──── event: run.settlement.completed─│  (terminal)
  │                                       │
  │──── connection closed ───────────────│

Chao run with approval gate (default mode):

Client                                  Server
  │                                       │
  │──── GET /ai/runs/:id/stream ────────►│
  │◄──── event: stream.connected ────────│
  │◄──── event: run.started ─────────────│
  │◄──── event: tool.approval_required ──│  (run pauses)
  │                                       │
  │──── POST /ai/runs/:id/tools/:toolCallId/approve ─►│
  │◄──── event: tool.approval_response ──│
  │◄──── event: tool.started ────────────│
  │◄──── event: tool.completed ──────────│
  │◄──── event: message.delta ───────────│
  │◄──── event: message.completed ───────│
  │◄──── event: run.settlement.completed─│  (terminal)
  │                                       │
  │──── connection closed ───────────────│

Keep-Alive

A heartbeat comment (: keep-alive) is sent every 15 seconds to prevent proxy timeouts.

Response Headers

HeaderValue
Content-Typetext/event-stream; charset=utf-8
Cache-Controlno-cache, no-transform
Connectionkeep-alive
X-Accel-Bufferingno

Built with purpose.