Skip to main content

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
FieldDescription
eventnew_message for message updates; stream_status and error are control events (see below)
idComposite ID: {message_id}:{event_index} — the event_index is a sequential integer starting at 0
dataA complete ChatMessage JSON object
retryReconnection 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:

  1. Early events have partial content and no evidences.
  2. Intermediate events may contain content_parts with type: "tool" showing tool execution state.
  3. The final event contains the fully formed response: complete content, evidences, and content_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.

EventCarries
response.createdThe first event of the connection: the whole message so far as a response
response.output_item.addedA new content part, as an output item
response.output_text.deltaText appended to a text part
response.reasoning_summary_text.deltaText appended to the reasoning summary
zetaalpha:output_item.updatedA part that changed other than by appended text (for example a tool part moving from running to completed)
zetaalpha:message.updatedMessage fields that changed (set) or disappeared (unset), such as evidences
zetaalpha:snapshotThe whole message again, when parts were removed or reordered
response.output_text.done, response.output_item.done, response.completedThe end of the turn, with the final message
response.incomplete, response.failedInstead 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 }
OutcomeResponseDo
No turn was runningstarted: 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 runningstarted: false and the running turn's message_idKeep 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 waitsNo message_idNothing 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 fieldDescription
tool_call_id, name, paramsThe call waiting for approval
response.app, response.icon_url, response.titleHow to present the tool, when its provider describes it
response.parameters_specThe JSON schema of the call's inputs, for a client that lets the user edit them
response.allow_sessionWhether 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" } }
]
}
decisionEffect
allow_onceThe call runs. params, when set, replaces the call's inputs (the full inputs, not a patch), and the agent is told what ran.
allow_sessionThe 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.
denyThe 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}
ResponseMeaningDo
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}.
404No 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}
ParameterDescription
message_idThe message_id from the SSE event id field (the part before the colon)
start_indexThe 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"}
ReasonMeaningClient action
doneThe turn completed; the last new_message event carried the full response.Render and finish.
erroredThe 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.
deadThe server instance producing the turn stopped mid-flight; the turn may be recoverable.Resume the turn (stateful) or resend the request (stateless).
goneThe 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": "..."}
codeMeaning
RATE_LIMITEDThe model provider's rate limit was reached; try again later.
CONTENT_FILTEREDThe model provider's content filter refused the request or the answer. Rephrasing may help; retrying the same input will not.
AGENTS_STORAGE_NOT_CONFIGUREDA stateful request on a tenant without agent storage.
FORBIDDENThe caller may not use this agent. The turn stores nothing of what was sent; do not reconnect.
INTERNALAny 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.