Streaming
The Chat API streams a turn with Server-Sent Events (SSE) to deliver the agent's response incrementally. POST /chat/stream starts a turn and streams it in one request. In a stateful session, POST /chat/stream/messages sends a message or an approval to the conversation at any time, running turn or not, and GET /chat/stream/{message_id} streams the turn that reads it (see Sending to a Conversation).
SSE Event Format
Each event has the following structure:
event: new_message
id: {message_id}:{event_index}
data: {"sender": "bot", "content": "...", "content_parts": [...], ...}
retry: 15000
| Field | Description |
|---|---|
event | new_message for message updates; stream_status and error are control events (see below) |
id | Composite ID: {message_id}:{event_index} — the event_index is a sequential integer starting at 0 |
data | A complete ChatMessage JSON object |
retry | Reconnection interval hint in milliseconds |
Incremental Content Delivery
By default each SSE event carries the complete message so far, not a delta (delta streaming is opt-in). As the agent generates tokens:
- Early events have partial
contentand noevidences. - Intermediate events may contain
content_partswithtype: "tool"showing tool execution state. - The final event contains the fully formed response: complete
content,evidences, andcontent_parts.
This design means reconnecting clients can resume from the latest event without needing to reassemble deltas.
Basic Streaming Client
import json
import os
import requests
import sseclient
TENANT = "zetaalpha"
url = f"https://api.zeta-alpha.com/v0/service/chat/stream?tenant={TENANT}"
response = requests.post(
url,
headers={
"accept": "text/event-stream",
"Content-Type": "application/json",
"X-Auth": os.getenv("ZETA_ALPHA_API_KEY"),
},
json={
"agent_identifier": "custom_agent",
"conversation": [
{"sender": "user", "content": "What is RAG?"}
],
},
stream=True,
)
response.raise_for_status()
client = sseclient.SSEClient(response)
for event in client.events():
message = json.loads(event.data)
# Check for tool execution status
for part in message.get("content_parts") or []:
if part["type"] == "tool":
tool = part["tool"]
print(f"[{tool['status']}] {tool['name']}: {tool.get('display_text', '')}")
# Check for dynamically retrieved context
for part in message.get("content_parts") or []:
if part["type"] == "context" and part.get("context"):
print(f"Agent found context: {part['context']}")
# Final message
print(message["content"])
print(message.get("evidences"))
Delta Streaming
Add delta=true to POST /chat/stream or GET /chat/stream/{message_id} to receive Open Responses events instead of full snapshots: the first event carries the whole message so far, later events carry only what changed. Without the parameter the stream is unchanged.
| Event | Carries |
|---|---|
response.created | The first event of the connection: the whole message so far as a response |
response.output_item.added | A new content part, as an output item |
response.output_text.delta | Text appended to a text part |
response.reasoning_summary_text.delta | Text appended to the reasoning summary |
zetaalpha:output_item.updated | A part that changed other than by appended text (for example a tool part moving from running to completed) |
zetaalpha:message.updated | Message fields that changed (set) or disappeared (unset), such as evidences |
zetaalpha:snapshot | The whole message again, when parts were removed or reordered |
response.output_text.done, response.output_item.done, response.completed | The end of the turn, with the final message |
response.incomplete, response.failed | Instead of response.completed, at the end of a replayed turn whose producer died (incomplete, followed by stream_status dead) or that failed (failed, followed by stream_status errored) |
Each output item maps to one content part, in order, after a reasoning item at output_index 0. Text parts are message items, tool parts are function_call items with the full tool part under zetaalpha:tool, and other parts are zetaalpha:<type> items with the part under zetaalpha:part. The message fields that are not content (message_id, session_id, evidences, ...) travel under zetaalpha:message. A reconnect with start_index starts a new connection, so its first event is again response.created with the whole message.
Sending to a Conversation
In a stateful session, everything the user sends goes to the conversation's inbox: the first message, messages typed while the agent works, and answers to approval requests. When the agent has queued messages on (queued_messages_provider_configuration and its mid_turn_queued_messages_source_configuration, both off by default), the turn reads its inbox before each model call, so a message sent while the agent is busy reaches it between two tool calls. Otherwise the message waits and starts the next turn; several messages sent during one turn start it together.
POST /chat/stream/messages?tenant={tenant}
{
"session_id": "the-session-id",
"agent_identifier": "custom_agent",
"message": {
"sender": "user",
"content": "Only papers from 2025, please.",
"message_id": "client-chosen-id"
},
"conversation_context": {},
"bot_params": {}
}
The response is 202 with the turn that reads what you sent:
{ "message_id": "turn-id", "started": true }
| Outcome | Response | Do |
|---|---|---|
| No turn was running | started: true and the new turn's message_id. The other request fields (agent_identifier, bot_params, conversation_context, user_agent_id) configure it. | Follow it with GET /chat/stream/{message_id}?session_id={session_id}. |
| A turn is running | started: false and the running turn's message_id | Keep following that turn; with queued messages on it reads the message before its next model call. Otherwise the message waits: when the turn ends, send {} to start a turn that answers it. |
| Nothing was sent and nothing waits | No message_id | Nothing to do. |
message is a full chat message: text, images (content_parts of type image), and attached documents (content_parts of type context) reach the agent as they would at the start of a turn. A message read mid-turn appears in the streamed answer as a content part of type user_message, at the point where the agent read it, with the message's text and the message_id you sent it with, so a client can tell which of its messages were read.
A message that arrives while the agent writes its final answer is answered in the same turn. A message the turn never read (it ended in between) stays in the inbox: the next turn starts with it. Send the request with neither message nor tool_decisions to start that turn without sending anything new.
Until a turn reads it, a sent message can be taken back out of the inbox by the message_id you sent it with:
DELETE /chat/stream/messages/{message_id}?tenant={tenant}&session_id={session_id}
It answers 204 when the message was removed and 409 Conflict when it is no longer in the inbox, because a turn already read it.
POST /chat/stream with stateful: true goes through the same inbox: the turn starts with the request's messages and with anything an earlier turn left unread.
Approving Tool Calls
A tool can require the user's approval before it runs. When the agent calls one, the streamed answer shows a confirmation card and the turn waits for the decision: a tool content part whose response.status is confirmation_required, with status: "completed" and display_text: "Waiting for your confirmation".
| Card field | Description |
|---|---|
tool_call_id, name, params | The call waiting for approval |
response.app, response.icon_url, response.title | How to present the tool, when its provider describes it |
response.parameters_spec | The JSON schema of the call's inputs, for a client that lets the user edit them |
response.allow_session | Whether to offer allow_session. false when the agent's configuration requires approval of every call to this tool |
Answer with tool_decisions on POST /chat/stream/messages, one entry per card; several cards can be answered in one request:
{
"session_id": "the-session-id",
"agent_identifier": "custom_agent",
"tool_decisions": [
{ "tool_call_id": "call_send_1", "decision": "allow_once", "params": { "to": "b@x.com" } }
]
}
decision | Effect |
|---|---|
allow_once | The call runs. params, when set, replaces the call's inputs (the full inputs, not a patch), and the agent is told what ran. |
allow_session | The call runs, and later calls to the same tool in this conversation run without asking. On a card with response.allow_session: false it acts as allow_once. |
deny | The call does not run; the agent is told the user declined. |
The card is replaced in the same answer, under the same tool_call_id, by the call's progress and result, or by a part with response.status confirmation_denied and display_text: "Declined". A call is also declined when the user sends a message instead of deciding, and when no decision arrives within 10 minutes. Where nobody can be asked (a non-streaming or stateless request), the call is declined at once with response.status confirmation_unavailable, and the agent reports that the action needs confirmation in a chat.
Calls made by a delegated agent (the task and delegate tools) follow the delegation's setting: ask the user in this chat (their cards appear in the answer like the agent's own), decline, or run.
Rewinding a conversation drops what its inbox still holds and the allow_session approvals together with the discarded messages.
Discovering the current turn
When a client lands on or reopens a conversation without already following its stream, probe the conversation's slot to find out what — if anything — is happening before deciding what to do:
GET /chat/stream/status?session_id={session_id}&tenant={tenant}
| Response | Meaning | Do |
|---|---|---|
200 · status: "running" | A turn is being produced right now (possibly by another tab or server instance). | Attach: follow GET /chat/stream/{message_id} (see Reconnection). |
200 · status: "dead" | A turn was in flight but its producer stopped. | Resume it (stateful) or resend (stateless) — see Resuming an Interrupted Turn. |
200 · status: "errored" | The last turn failed. | Surface the failure; to try again, send a new turn (not resume=true). |
200 · status: "done" | The last turn finished and is persisted. | Load the transcript with GET /conversations/{session_id}. |
404 | No turn in flight and nothing recent to replay. | Load the conversation; start a new turn when the user sends one. |
Starting a turn while one is already running is refused: POST /chat/stream returns 409 Conflict with the occupying turn in the body (detail.message_id, which may be null if it has not been assigned yet). Attach to that turn instead of starting a second one; to give it the message, send it with POST /chat/stream/messages.
Only dead is resumable. errored and gone are terminal — for errored the turn failed (retry with a new turn); for gone (see Stream Outcome) it succeeded (read it from the conversation). Never send resume=true for either.
Reconnection
If a client disconnects mid-stream (network issue, timeout), it can reconnect and resume from where it left off using the GET /chat/stream/{message_id} endpoint:
GET /chat/stream/{message_id}?tenant={tenant}&start_index={last_event_index + 1}
| Parameter | Description |
|---|---|
message_id | The message_id from the SSE event id field (the part before the colon) |
start_index | The event index to resume from (0-based). Pass last_received_index + 1 to avoid duplicates. |
Reconnection works across server instances: when the original in-memory buffer is gone, the stream is replayed from a durable recording of the turn — following it live while the turn is still being produced. Completed turns stay replayable for a limited time after they finish; once the recording expires, the endpoint returns 404.
Extracting the message_id
The SSE event id field has the format {message_id}:{event_index}. Parse the message_id from the first event:
event_id = event.id # e.g. "abc123:0"
message_id, event_index = event_id.rsplit(":", 1)
Reconnection Example
import json
import os
import requests
import sseclient
TENANT = "zetaalpha"
BASE_URL = "https://api.zeta-alpha.com/v0/service/chat/stream"
headers = {
"accept": "text/event-stream",
"Content-Type": "application/json",
"X-Auth": os.getenv("ZETA_ALPHA_API_KEY"),
}
# Resume from event index 5
message_id = "previously-received-message-id"
start_index = 5
response = requests.get(
f"{BASE_URL}/{message_id}?tenant={TENANT}&start_index={start_index}",
headers=headers,
stream=True,
)
response.raise_for_status()
client = sseclient.SSEClient(response)
for event in client.events():
message = json.loads(event.data)
print(message["content"])
Stream Outcome
A reconnected stream served from the recording ends with a final stream_status event telling the client how the turn ended:
event: stream_status
data: {"reason": "done"}
| Reason | Meaning | Client action |
|---|---|---|
done | The turn completed; the last new_message event carried the full response. | Render and finish. |
errored | The agent failed mid-turn; the events received so far are all there is. The turn is terminal — not resumable. | Surface the failure. To try again, send a new turn (a fresh POST /chat/stream), not resume=true. |
dead | The server instance producing the turn stopped mid-flight; the turn may be recoverable. | Resume the turn (stateful) or resend the request (stateless). |
gone | The turn finished and its recording was superseded or expired — it cannot be replayed from here. | Refetch the conversation (GET /conversations/{session_id}) to read the final answer. Do not resume. |
Streams served live — the original POST, or a GET that attaches to the live buffer — do not emit stream_status: the connection closing after the final new_message already means the turn is done.
A 404 from GET /chat/stream/{message_id} carries the same meaning as dead when the turn was recently in flight: the turn cannot be reattached because the producing instance died, the recording expired, or the message_id is unknown. A 410 carries the same meaning as gone: the turn finished and its slot was superseded or reaped — refetch the conversation (GET /conversations/{session_id}) instead of reconnecting, and never resume.
To display a dead turn's partial answer instead of resuming it (for example on a page reload, where the user should see what the agent produced before deciding anything), add partial=true to the reconnect: GET /chat/stream/{message_id}?partial=true&session_id={session_id}. The recorded content is replayed and the stream ends with stream_status reason dead, marking the answer as incomplete. This is read-only; to finish the turn, resume it.
Resuming an Interrupted Turn
In a stateful session, a turn whose producer died — stream_status with reason dead, or 404 on reconnect — can be resumed rather than restarted. POST to the streaming endpoint with resume=true and the turn's message_id in the query, and the session in the body:
POST /chat/stream?tenant={tenant}&resume=true&message_id={message_id}
{
"agent_identifier": "custom_agent",
"stateful": true,
"session_id": "the-session-id",
"conversation": []
}
The service takes the turn over under the same message_id — fencing the previous producer in case it is still running — and re-drives it from the session's last checkpoint: agent work completed before the crash (tool calls, retrieved context, generated text) is restored rather than recomputed. The body conversation is ignored: the user's message was stored when the turn started, so a turn that crashed before its first checkpoint starts again from the stored conversation. Messages sent to the conversation since are read as well.
The response is a normal SSE stream under the same message_id, so the reconnection and cancellation endpoints keep working unchanged.
import os
import requests
TENANT = "zetaalpha"
BASE_URL = "https://api.zeta-alpha.com/v0/service/chat/stream"
response = requests.post(
f"{BASE_URL}?tenant={TENANT}&resume=true&message_id={message_id}",
headers={
"accept": "text/event-stream",
"Content-Type": "application/json",
"X-Auth": os.getenv("ZETA_ALPHA_API_KEY"),
},
json={
"agent_identifier": "custom_agent",
"stateful": True,
"session_id": session_id,
"conversation": [],
},
stream=True,
)
In stateless mode there is nothing to resume from — the service holds no state for the conversation. Resend the original request instead; it runs as a new turn with a new message_id.
Cancellation
To cancel a running stream before the agent finishes:
DELETE /chat/stream/{message_id}?tenant={tenant}
This stops the agent's generation and cleans up server-side resources. The endpoint returns 204 No Content on success.
import os
import requests
TENANT = "zetaalpha"
message_id = "the-message-id-from-stream"
requests.delete(
f"https://api.zeta-alpha.com/v0/service/chat/stream/{message_id}?tenant={TENANT}",
headers={"X-Auth": os.getenv("ZETA_ALPHA_API_KEY")},
)
Turn lifetime
A turn runs to completion on the server even if no client is connected — disconnecting or closing the tab does not cancel it. Reconnect at any time (see Reconnection, or GET /chat/stream/status to discover the conversation's current turn) to keep watching. To stop a turn before it finishes, use the cancellation endpoint.
Error Handling
If the turn or the connection serving it fails, an error event is sent with a code and a message:
event: error
data: {"code": "RATE_LIMITED", "message": "..."}
code | Meaning |
|---|---|
RATE_LIMITED | The model provider's rate limit was reached; try again later. |
CONTENT_FILTERED | The model provider's content filter refused the request or the answer. Rephrasing may help; retrying the same input will not. |
AGENTS_STORAGE_NOT_CONFIGURED | A stateful request on a tenant without agent storage. |
FORBIDDEN | The caller may not use this agent. The turn stores nothing of what was sent; do not reconnect. |
INTERNAL | Any other failure, including an agent that does not exist. Such a turn stores nothing of what was sent. |
On INTERNAL, close the connection and reconnect via GET /chat/stream/{message_id}: the turn may still be producing on the server. Note the difference from stream_status: error reports a failure seen by this connection, while stream_status reports the outcome of the turn itself.