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
| Method | Path | Description | Auth | Rate Limit |
|---|---|---|---|---|
| GET | /ai/runs/:runId/stream | Stream events for a feature run | JWT + Entitlement | 20/min |
| GET | /ai/sessions/:sessionId/messages/:messageId/stream | Stream events for a chat message | JWT + Entitlement | 20/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.
curl -N https://api.chainabit.com/api/v1/ai/runs/$RUN_ID/stream \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream"// 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();
});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"}}| Field | Description |
|---|---|
id | Monotonically increasing event ID (use for reconnection) |
retry | Reconnection interval in milliseconds (3000ms) |
event | Event type name |
data | JSON envelope with full event details |
Data Envelope Fields
| Field | Type | Description |
|---|---|---|
eventId | number | Sequential event counter for this run |
type | string | Event type (see table below) |
runId | string | The AI run UUID |
stepId | string | undefined | Step ID (for workflow/tool events) |
timestamp | string | ISO 8601 event timestamp |
payload | object | Event-specific data |
Event Types
30+ event types organized by category:
Connection
| Event Type | Description | Terminal |
|---|---|---|
stream.connected | Emitted once per connection after headers flush. Carries { runId }. Not buffered — will not appear in event replay. | No |
Capability Routing
| Event Type | Description | Terminal |
|---|---|---|
capability.resolved | The requested AI capability was routed to an executable path | No |
Run Lifecycle
| Event Type | Description | Terminal |
|---|---|---|
run.started | Run processing has begun | No |
run.status.changed | Run status transition | No |
run.model.switched | Active model changed (model unavailable or provider failover) | No |
run.cancel_requested | Cancellation was requested | No |
run.cancelling | Cancellation in progress | No |
run.cancelled | Run cancelled successfully | Yes |
run.failed | Run failed with an error | Yes |
run.error | Runtime/timeout error | Yes |
run.model.switched Payload
{
"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 Type | Description | Terminal |
|---|---|---|
message.delta | Incremental token content (partial response) | No |
message.completed | Full message has been assembled | No |
message.suggestions | Follow-up suggestion cards for the completed assistant message | No |
message.partially_completed | Partial completion (e.g., mid-cancel) | No |
message.suggestions Payload
{
"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 Type | Description | Terminal |
|---|---|---|
tool.started | Tool call initiated | No |
tool.progress | Tool execution progress | No |
tool.completed | Tool call finished successfully | No |
tool.failed | Tool call failed (non-terminal — Chao handles errors gracefully) | No |
tool.degraded | Tool or capability returned a graceful fallback state | No |
tool.started Payload
{
"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
{
"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
{
"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 field | Preferred lookup |
|---|---|
toolKey | payload.toolKey ?? payload.key |
callId | payload.callId ?? payload.toolCallId ?? envelope.stepId |
input | payload.input ?? payload.args |
output | payload.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 Type | Description | Terminal |
|---|---|---|
tool.approval_required | Chao requires user approval before executing a write/destructive tool | No |
tool.approval_response | User approved or rejected the pending tool call | No |
tool.approval_timeout | Approval window (60 s) expired; tool was not executed | No |
tool.approval_required Payload
{
"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:
| Decision | Endpoint | Body |
|---|---|---|
| Approve | POST /ai/runs/:runId/tools/:toolCallId/approve | none |
| Reject | POST /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 Type | Description | Terminal |
|---|---|---|
tool.progress | Tool progress/activity update | No |
tool.approval_required | Approval activity card | No |
plan.step_added | Planned action summary | No |
capability.resolved | Capability routing summary | No |
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 Type | Description | Terminal |
|---|---|---|
workflow.step.started | Workflow step began | No |
workflow.step.completed | Workflow step finished | No |
workflow.step.failed | Workflow step failed | No |
workflow.completed | Entire workflow finished | Yes |
Settlement
| Event Type | Description | Terminal |
|---|---|---|
run.settlement.pending | Credit settlement is processing | No |
run.settlement.completed | Settlement done, run fully finished | Yes |
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:
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
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 Field | Type | Description |
|---|---|---|
error | string | Human-readable error message |
code | string | Error code — see table below |
| Code | Meaning |
|---|---|
execution_error | General error during LLM execution |
stream_timeout | Connection open for more than 5 minutes |
provider_error | Unhandled upstream provider failure |
empty_response | Provider 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
| Header | Value |
|---|---|
Content-Type | text/event-stream; charset=utf-8 |
Cache-Control | no-cache, no-transform |
Connection | keep-alive |
X-Accel-Buffering | no |