All interactions within a session are recorded as events and delivered in real time via Server-Sent Events (SSE) when subscribed to via the API.
Server-Sent Events
While an agent processes messages, the server pushes events of the following types:
| type | Description |
|---|---|
message | Assistant-generated message output |
tool_call / tool_call_output | Built-in tool invocation requests and their execution results |
mcp_call / mcp_call_output | MCP tool invocation requests and their execution results |
tool_approval_request | A tool approval request, emitted once when a tool requires approval. Its data carries batch_id, call_id, name, arguments, and tool_type (builtin or mcp; MCP additionally includes server_label). Whether approval is required is determined by this event |
tool_approval_response | This event is bidirectional: the client can send it upstream to submit a decision, and the server can also emit it downstream. When the session is in requires_action and you send a bare interrupt, the server emits one downstream event with role=user, type=tool_approval_response, result=deny for every call in the current batch that has not yet executed — including calls already decided but not yet run because the batch was not complete — paired with the corresponding tool_call_output / mcp_call_output (is_error=true), so the client can move the approval card to a terminal state |
session_status | Session status change, including stop_reason (see Managing Sessions) |
error | A runtime error. Error details are carried in the top-level error object and are available as error.code and error.message, not in content[].data. Examples include pending_tool_approval_unresolved and invalid_tool_approval. These errors are not synchronous 4xx responses from POST /sessions/{session_id}/events; observe them through the event stream or GET /events?types=error |
Client-Sent Events
Send events to a session using POST /sessions/{session_id}/events.
| type | Description |
|---|---|
message | Sends a user message, triggering the agent to transition into the running state |
tool_approval_response | Responds to an approval: after receiving a tool_approval_request, reference the call via batch_id + call_id and provide the decision (allow / deny). This type is also a server-side downstream event (see the table above) |
interrupt | Interrupts the current processing; used to end the entire current batch of pending calls. Sending a bare interrupt in requires_action causes the server to emit deny for every call in the batch that has not yet executed, including already-decided calls that never ran (see Tool Approval) |
message event initiates agent processing. For full parameter details, see the Session API.
SSE Subscription
Sending events and subscribing to the stream are two separate endpoints: POST /sessions/{session_id}/events only accepts input and returns once the events are enqueued, while GET /sessions/{session_id}/events/stream is the SSE endpoint. Adding Accept: text/event-stream to the POST does not turn it into a long-lived connection — the SSE stream is always the GET request shown below.
This ordering matters for the approval flow: a tool_call approval request is emitted only once, at the moment it is produced. If you POST the message first and open the stream afterwards, a fast task may already be paused before your subscription is established, and you will miss the request. The recommended sequence is:
- Open the SSE stream first with
GET /events/streamon one connection. - Then
POSTthe user event on a separate connection. - If the stream drops or you miss an event, first
GET /sessions/{session_id}to readstop_reason.pending_batch_idandpending_call_ids, then pull events withGET /events?types=tool_approval_request&order=descin descending order; filter bybatch_id, align bycall_id, and stop oncepending_call_idsis fully matched — this rebuilds the approval cards. If the first page does not match all of them, readnext_pagefrom the response; when it is non-empty, treat it as an opaque string, URL-encode it, and pass it as thepage=<next_page>parameter of the next request, keeping the sametypes=tool_approval_request,order=desc, andlimit— Event List defaults toorder=ascwith 20 items per page; without descending order and pagination, a long history means the current batch may never appear. Whether approval is required is authoritative instop_reason: as long as the Session still returnsrequires_action, pending calls exist. If a state change has just occurred and the Session snapshot or the filtered event list does not yet reflect the latest state (a just-produced event may be briefly delayed), retry within a bounded window — do not treat a single empty result as "no approval needed".
tool_approval_request events, and the Session returns pending_batch_id=response_xxx:9f2c..., pending_call_ids=["call_3"]. Pull in descending order:
batch_id=response_xxx:9f2c... and align by call_id until pending_call_ids is fully matched. If the first page is not enough, read next_page from the response, URL-encode it, and request the second page with it as the page parameter:
data: <JSON> lines. A call that requires approval produces three frames in order — tool_call, tool_approval_request, and session_status (idle + requires_action):
- Approval cards come from
tool_approval_request, whosedata.batch_id+data.call_idtogether identify one approval. Group pending calls by this event — do not infer them from the number of historicaltool_callor request/response events. - The current pending set is authoritative in
session_status'sstop_reason.pending_call_ids. After deciding calls one at a time (partial), the status staysrequires_actionandpending_call_idsshrinks to the remaining calls; only once the whole batch is decided do these pending (always_ask) tools run and the model resume (always_allowtools are not subject to this barrier — see "Batch and partial decisions" below). - Stop when
session_statusisterminated; the turn is genuinely over whenstop_reasonisend_turn/retries_exhausted.
message + tool_approval_response → pending_tool_approval_unresolved) — with pending_call_ids equal to ["call_xxx"], mixed in one POST:
pending_tool_approval_unresolved, and GET /sessions/{session_id} still returns requires_action with pending_call_ids still ["call_xxx"] (identical to before submission).
Contract case 2 (interrupt + tool_approval_response → invalid_tool_approval) — with pending_call_ids equal to ["call_xxx"], mixed in one POST:
invalid_tool_approval; the decision does not take effect, and GET /sessions/{session_id} still returns requires_action with pending_call_ids still ["call_xxx"].
Delta streaming
By default, each event is delivered as a single complete Message (status: "completed") after generation finishes. To stream text-type events (message, reasoning) chunk by chunk during generation, enable delta streaming for specific types with the event_deltas[] query parameter when subscribing:
type=event_start frame (event.id is the id of the final complete Message), then several type=event_delta frames in generation order (delta.content is the newly added ContentBlock fragment), and finally the aggregated complete Message (status: "completed"). The client accumulates the delta fragments in order by event_id + delta.index; the complete Message can be used for verification. Event types not declared are still delivered as a single complete Message. For the full frame structure and an example stream, see the Session Event Stream (SSE) API.
Tool Approval
When a tool is set to Always ask (see Agent Tool Configuration), the agent pauses and waits for confirmation before calling it. You will receive a tool_approval_request event — whether approval is required is determined by this event, not inferred from the tool_call metadata. The client must send a tool_approval_response event to the session, referencing the call via batch_id + call_id and providing the decision.
Example of a tool_approval_request event (the JSON inside an SSE data: frame):
data fields:
| Field | Type | Description |
|---|---|---|
batch_id | string | The batch this approval belongs to; multiple pending calls in one turn share the same batch_id |
call_id | string | The ID of the tool call awaiting approval |
name | string | The tool name |
arguments | string | The frozen call arguments (JSON string) |
tool_type | string | builtin or mcp; the mcp type additionally includes server_label |
tool_approval_response whose data fields are:
| Field | Type | Required | Description |
|---|---|---|---|
batch_id | string | Yes | The batch_id from the tool_approval_request; together with call_id it identifies one approval — you cannot match on call_id alone |
call_id | string | Yes | The ID of the tool call being decided |
result | string | Yes | The decision. One of allow (approve execution) or deny (reject execution) |
deny_message | string | No | Used only when result is deny; returned to the model as the tool output |
batch_id. You may submit multiple tool_approval_response entries in a single POST, or across several requests.
The approval barrier covers only the always_ask set — it is not a transaction boundary over the whole turn. Do not treat approval as an atomic barrier over every tool in the turn:
always_allowcalls run normally and are not blocked by approval — even if analways_askcall comes earlier in the model's output order, analways_allowcall may execute before approval completes.always_askcalls do not run until the entire batch is decided, and the session produces no further replies until then.- Once all
askdecisions are collected, only thealways_askcalls are processed among themselves in their original call order:allowexecutes,denydoes not and returns a paired tool output (tool_call_outputormcp_call_output) withis_errorset totrue, withdeny_messageas the error content returned to the model. The agent then continues processing.
call_1 (always_ask), call_2 (always_allow), call_3 (always_ask):
call_2(auto) is not blocked by the barrier and may finish executing while approvals forcall_1/call_3are still outstanding.call_1andcall_3enter pending approval, withpending_call_idsequal to["call_1","call_3"]; neither runs until both are decided.- Once decided, only these two ask calls are processed in original order —
call_1first, thencall_3— each per itsallow/deny, after which the agent continues processing.
| Error code | Trigger | Decisions consumed? |
|---|---|---|
pending_tool_approval_unresolved | While approvals are pending, sending a plain message alone or message + tool_approval_response in the same request; reported as a later type=error event/terminal state | No — pending_call_ids unchanged |
invalid_tool_approval | interrupt + tool_approval_response in the same request (control-message conflict); or the submission hits no call in the current batch, or the payload/batch combination is invalid. Idempotency semantics below | This submission adds no newly effective decision. If the current batch still exists, pending_call_ids stays unchanged; if the batch has ended, expired, or does not exist, later state may no longer carry pending items — trust the latest session_status or Session query |
tool_approval_service_unavailable | The approval service is temporarily unavailable; the related tools do not execute | — |
malformed_model_tool_call | The tool call identifier is invalid; the related tools do not execute | — |
invalid_tool_approval (the key to safe retries):
- First decision wins, scoped to
(batch_id, call_id): an approval is identified by the compound identity(batch_id, call_id)—call_idis unique only within a batch. Within a givenbatch_id, the first decision for a(batch_id, call_id)is the one that takes effect; later decisions for the same(batch_id, call_id)do not override it — duplicate submissions and timeout retries never roll back a decision that already took effect. Do not deduplicate at session level bycall_idalone: different batches may legally reuse the samecall_id. - Partial hit: within one correct
batch_id, if a submission mixes a validcall_id(still pending) with an unknown or already-decidedcall_id, the valid one still takes effect, and this request does not returninvalid_tool_approval. An already-decided item that still belongs to the current batch is accepted idempotently (the first effective decision is preserved — it is not treated as a miss); only unknown items (call_ids not part of the current batch) are ignored as misses. - When it is returned: only when a non-empty submission misses entirely (hits no call in the current batch: the batch has ended or does not exist, the
batch_iddoes not match, or none of thecall_ids belong to the current batch — resubmitting an already-decided item within the current batch is accepted idempotently, not counted as a miss), or the payload itself is malformed, or the batch combination is invalid. - It says nothing about tool execution: receiving
invalid_tool_approvaltells you nothing about the execution state of earlier requests or tools — it neither proves an earlier tool did not execute nor that it has started or finished. Clients must not treat this error as a tool-completion signal, and must not undo any decision that already took effect; determine tool results from the pairedtool_call_output/mcp_call_output(top-levelis_error) instead.
call_id reused across batches): the first batch (batch_id response_a:1f2e...) and the second batch (batch_id response_b:3c4d...) both contain a pending call whose call_id is call_xxx. After the first batch is fully decided, the second batch's (response_b:3c4d..., call_xxx) can still be decided normally — first-decision-wins applies only within the same (batch_id, call_id), so the reused call_id is not treated as a duplicate decision.
Retrying the same decision: as long as the approval batch still exists, submitting a duplicate or opposite decision for the same (batch_id, call_id) leaves the first effective decision unchanged and is treated idempotently — it does not return invalid_tool_approval; if the same request also contains valid pending items, those items continue to be applied. Only when a non-empty submission hits no call in the current batch (for example, the batch has ended or does not exist, the batch_id does not match, or none of the call_ids belong to the current batch) does it return invalid_tool_approval. This error cannot be used to infer the execution state of earlier requests or tools; determine tool results from the paired tool_call_output / mcp_call_output instead.
Example: pending + already-decided items. With pending_call_ids equal to ["call_b"] (call_a was decided earlier), one POST carries both. A tool_approval_response may hold only one data block (maxItems: 1), so batch decisions must be sent as multiple separate events:
call_b's deny takes effect; call_a was already decided and still belongs to the current batch, so it is accepted idempotently (its first decision is preserved), and this request does not return invalid_tool_approval. Once the batch is complete, call_b is processed as deny and pending_call_ids is cleared. A response may hold only one data block; use multiple events for batch decisions.
Server-side downstream events after interrupt
When you send a bare interrupt in requires_action, the server emits the interrupted terminal state for every call in the current batch that has not yet executed — including calls that were already decided but had not run because the batch was not yet complete. Each such call receives one downstream tool_approval_response (role=user, result=deny) paired with the corresponding tool_call_output / mcp_call_output (is_error=true). The client uses these to move the corresponding approval card to a terminal state. A strict parser should recognize the downstream tool_approval_response; otherwise it drops the event and the card may remain stuck in a pending state.
Contract case (interrupt after a partial decision): a batch has two pending calls call_1 and call_2; you submit allow for call_1 first (call_2 still pending), then send a bare interrupt. Because the batch was not complete, call_1 was decided but never executed; the server emits the interrupted terminal state for both call_1 and call_2 — each receives a downstream tool_approval_response (result=deny) and the paired tool output (is_error=true). The client must also move call_1's approval card to the terminal state — do not keep it as approved-but-not-yet-run.
The downstream tool_approval_response's data carries deny_message; on a tool-output event, is_error is at the Message top level (not inside content[].data) and data is a ToolCallOutput / McpCallOutput carrying at least call_id and output. On a user interrupt, the server writes the fixed text The tool call has been interrupted by the user. into both the downstream approval response's deny_message and the paired tool output's output. To reconcile, read the top-level is_error for success/failure and data.output for the interrupted output.
With pending_call_ids equal to ["call_xxx"] (a builtin tool), after sending {"input":[{"role":"user","type":"interrupt"}]} the stream shows (the real interrupt sequence):
pending_call_ids equal to ["mcp_call_xxx"]), the post-interrupt tool-output frame uses mcp_call_output with the same shape (top-level is_error, data with call_id and output) and the same text:
Tool approval is supported only for the main agent; it is not supported inside subagents.
Event Filtering
In the Console's Events panel, use the dropdown menu in the top-left corner to filter events by type. Supported filters include: All events, User, Agent, Tool, Tool_output, Error, Model, and System.
Next Steps
- Managing Sessions: Learn about the session state machine and tool invocation flow.
- Session API: Detailed documentation for the SSE event stream API.