Skip to main content

Session Event Stream (SSE)

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:
typeDescription
messageAssistant-generated message output
tool_call / tool_call_outputBuilt-in tool invocation requests and their execution results
mcp_call / mcp_call_outputMCP tool invocation requests and their execution results
tool_approval_requestA 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_responseThis 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_statusSession status change, including stop_reason (see Managing Sessions)
errorA 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.
typeDescription
messageSends a user message, triggering the agent to transition into the running state
tool_approval_responseResponds 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)
interruptInterrupts 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)
Sending a message event initiates agent processing. For full parameter details, see the Session API.
curl -X POST "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sessions/sesn_xxx/events" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": [
      {
        "role": "user",
        "type": "message",
        "content": [
          {"type": "text", "text": "Analyze Q3 sales trends in /mnt/session/uploads/sales.csv"}
        ]
      }
    ]
  }'

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:
  1. Open the SSE stream first with GET /events/stream on one connection.
  2. Then POST the user event on a separate connection.
  3. If the stream drops or you miss an event, first GET /sessions/{session_id} to read stop_reason.pending_batch_id and pending_call_ids, then pull events with GET /events?types=tool_approval_request&order=desc in descending order; filter by batch_id, align by call_id, and stop once pending_call_ids is fully matched — this rebuilds the approval cards. If the first page does not match all of them, read next_page from the response; when it is non-empty, treat it as an opaque string, URL-encode it, and pass it as the page=<next_page> parameter of the next request, keeping the same types=tool_approval_request, order=desc, and limit — Event List defaults to order=asc with 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 in stop_reason: as long as the Session still returns requires_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".
Recovery example (history beyond one page): the session history already holds 30 tool_approval_request events, and the Session returns pending_batch_id=response_xxx:9f2c..., pending_call_ids=["call_3"]. Pull in descending order:
curl "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sessions/sesn_xxx/events?types=tool_approval_request&order=desc&limit=20" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY"
The newest events appear first; filter by 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:
curl "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sessions/sesn_xxx/events?types=tool_approval_request&order=desc&limit=20&page={URL-encoded-next_page}" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY"
See the Event List API for details. Open the event stream:
curl -N "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sessions/sesn_xxx/events/stream" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" \
  -H "Accept: text/event-stream"
Events are pushed as data: <JSON> lines. A call that requires approval produces three frames in order — tool_call, tool_approval_request, and session_status (idle + requires_action):
event: message
data: {"type":"tool_call","role":"assistant","content":[{"type":"data","data":{"call_id":"call_xxx","name":"bash","arguments":"{\"command\": \"ls /workspace\"}"}}]}

event: message
data: {"type":"tool_approval_request","role":"assistant","content":[{"type":"data","data":{"batch_id":"response_xxx:9f2c...","call_id":"call_xxx","name":"bash","arguments":"{\"command\": \"ls /workspace\"}","tool_type":"builtin"}}]}

event: message
data: {"type":"session_status","content":[{"type":"data","data":{"session_status":"idle","stop_reason":{"type":"requires_action","pending_batch_id":"response_xxx:9f2c...","pending_call_ids":["call_xxx"]}}}]}
Handling notes:
  • Approval cards come from tool_approval_request, whose data.batch_id + data.call_id together identify one approval. Group pending calls by this event — do not infer them from the number of historical tool_call or request/response events.
  • The current pending set is authoritative in session_status's stop_reason.pending_call_ids. After deciding calls one at a time (partial), the status stays requires_action and pending_call_ids shrinks to the remaining calls; only once the whole batch is decided do these pending (always_ask) tools run and the model resume (always_allow tools are not subject to this barrier — see "Batch and partial decisions" below).
  • Stop when session_status is terminated; the turn is genuinely over when stop_reason is end_turn / retries_exhausted.
POST /events is asynchronous. An HTTP 200 only means the events were enqueued and accepted — it does not mean the agent has started processing or that a decision has taken effect. The errors below are event/terminal states (type=error) produced later by that run, not a synchronous 4xx from this POST; observe them via the SSE stream or GET /events?types=error.While pending approvals exist (requires_action), two kinds of mixed input resolve with different error codes; in both, the decisions are not consumed and pending_call_ids stays unchanged:
  • Sending a plain message alone, or message + tool_approval_response in the same request: accepted first (200), then the run reports pending_tool_approval_unresolved — the current batch's approvals are still not complete.
  • interrupt + tool_approval_response in the same request: rejected as a control-message conflict, with error code invalid_tool_approval; the decision does not take effect, the current batch still exists, and pending_call_ids stays unchanged.
  • Allowed combination: interrupt + a plain Message may be mixed in the same request; the semantics are to end the current approval batch first, then chat.
Multiple tool_approval_response entries in one request must all belong to the same batch_id. So once you see requires_action, finish the current batch of approvals (tool_approval_response), or send an interrupt alone to end the batch (do not mix it with a tool_approval_response), before chatting again.
Contract case 1 (message + tool_approval_response → pending_tool_approval_unresolved) — with pending_call_ids equal to ["call_xxx"], mixed in one POST:
{
  "input": [
    {"role": "user", "type": "tool_approval_response", "content": [
      {"type": "data", "data": {"batch_id": "response_xxx:9f2c...", "call_id": "call_xxx", "result": "allow"}}
    ]},
    {"role": "user", "type": "message", "content": [
      {"type": "text", "text": "Also, take a look at the logs"}
    ]}
  ]
}
The decision is not consumed; the run then reports 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:
{
  "input": [
    {"role": "user", "type": "interrupt"},
    {"role": "user", "type": "tool_approval_response", "content": [
      {"type": "data", "data": {"batch_id": "response_xxx:9f2c...", "call_id": "call_xxx", "result": "allow"}}
    ]}
  ]
}
Because of the control-message conflict, the request is rejected with error code 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:
curl -N --no-buffer --get \
  "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sessions/sesn_xxx/events/stream" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" \
  --data-urlencode 'event_deltas[]=message' \
  --data-urlencode 'event_deltas[]=reasoning'
Once enabled, an event of a declared type first emits a 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):
{
  "object": "message",
  "type": "tool_approval_request",
  "id": "msg_approval_xxx",
  "role": "assistant",
  "status": "completed",
  "content": [
    {"type": "data", "data": {"batch_id": "response_xxx:9f2c...", "call_id": "call_xxx", "name": "bash", "arguments": "{\"command\": \"rm -rf /tmp/cache\"}", "tool_type": "builtin"}}
  ]
}
data fields:
FieldTypeDescription
batch_idstringThe batch this approval belongs to; multiple pending calls in one turn share the same batch_id
call_idstringThe ID of the tool call awaiting approval
namestringThe tool name
argumentsstringThe frozen call arguments (JSON string)
tool_typestringbuiltin or mcp; the mcp type additionally includes server_label
To respond, send a tool_approval_response whose data fields are:
FieldTypeRequiredDescription
batch_idstringYesThe batch_id from the tool_approval_request; together with call_id it identifies one approval — you cannot match on call_id alone
call_idstringYesThe ID of the tool call being decided
resultstringYesThe decision. One of allow (approve execution) or deny (reject execution)
deny_messagestringNoUsed only when result is deny; returned to the model as the tool output
Approve execution:
curl -X POST "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sessions/sesn_xxx/events" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": [
      {
        "role": "user",
        "type": "tool_approval_response",
        "content": [
          {"type": "data", "data": {"batch_id": "response_xxx:9f2c...", "call_id": "call_xxx", "result": "allow"}}
        ]
      }
    ]
  }'
Reject execution:
curl -X POST "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sessions/sesn_xxx/events" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": [
      {
        "role": "user",
        "type": "tool_approval_response",
        "content": [
          {"type": "data", "data": {"batch_id": "response_xxx:9f2c...", "call_id": "call_xxx", "result": "deny", "deny_message": "This action is not authorized"}}
        ]
      }
    ]
  }'
Batch and partial decisions: Calls in the same batch share one 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_allow calls run normally and are not blocked by approval — even if an always_ask call comes earlier in the model's output order, an always_allow call may execute before approval completes.
  • always_ask calls do not run until the entire batch is decided, and the session produces no further replies until then.
  • Once all ask decisions are collected, only the always_ask calls are processed among themselves in their original call order: allow executes, deny does not and returns a paired tool output (tool_call_output or mcp_call_output) with is_error set to true, with deny_message as the error content returned to the model. The agent then continues processing.
Timing example (one turn: 1 auto + 2 ask). The model emits three tool calls in the order 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 for call_1 / call_3 are still outstanding.
  • call_1 and call_3 enter pending approval, with pending_call_ids equal to ["call_1","call_3"]; neither runs until both are decided.
  • Once decided, only these two ask calls are processed in original order — call_1 first, then call_3 — each per its allow/deny, after which the agent continues processing.
Common error codes:
Error codeTriggerDecisions consumed?
pending_tool_approval_unresolvedWhile 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 stateNo — pending_call_ids unchanged
invalid_tool_approvalinterrupt + 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 belowThis 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_unavailableThe approval service is temporarily unavailable; the related tools do not execute—
malformed_model_tool_callThe tool call identifier is invalid; the related tools do not execute—
Idempotency semantics of 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_id is unique only within a batch. Within a given batch_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 by call_id alone: different batches may legally reuse the same call_id.
  • Partial hit: within one correct batch_id, if a submission mixes a valid call_id (still pending) with an unknown or already-decided call_id, the valid one still takes effect, and this request does not return invalid_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_id does not match, or none of the call_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_approval tells 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 paired tool_call_output / mcp_call_output (top-level is_error) instead.
Contract case (the same 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:
{
  "input": [
    {"role": "user", "type": "tool_approval_response", "content": [
      {"type": "data", "data": {"batch_id": "response_xxx:9f2c...", "call_id": "call_a", "result": "allow"}}
    ]},
    {"role": "user", "type": "tool_approval_response", "content": [
      {"type": "data", "data": {"batch_id": "response_xxx:9f2c...", "call_id": "call_b", "result": "deny", "deny_message": "Not authorized"}}
    ]}
  ]
}
This request passes OpenAPI validation (two events, each with a single data block). The valid item 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):
event: message
data: {"type":"tool_approval_response","role":"user","content":[{"type":"data","data":{"batch_id":"response_xxx:9f2c...","call_id":"call_xxx","result":"deny","deny_message":"The tool call has been interrupted by the user."}}]}

event: message
data: {"type":"tool_call_output","role":"tool","is_error":true,"content":[{"type":"data","data":{"call_id":"call_xxx","output":"The tool call has been interrupted by the user."}}]}

event: message
data: {"type":"session_status","content":[{"type":"data","data":{"session_status":"idle","stop_reason":{"type":"end_turn"}}}]}
If the pending call is an MCP tool (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:
event: message
data: {"type":"tool_approval_response","role":"user","content":[{"type":"data","data":{"batch_id":"response_xxx:9f2c...","call_id":"mcp_call_xxx","result":"deny","deny_message":"The tool call has been interrupted by the user."}}]}

event: message
data: {"type":"mcp_call_output","role":"tool","is_error":true,"content":[{"type":"data","data":{"call_id":"mcp_call_xxx","output":"The tool call has been interrupted by the user."}}]}

event: message
data: {"type":"session_status","content":[{"type":"data","data":{"session_status":"idle","stop_reason":{"type":"end_turn"}}}]}
This request / response pairing is for display and audit only, and is best-effort. The pending set is always authoritative in the Session's pending_call_ids; do not compute the remaining pending calls by subtracting the counts of historical tool_approval_request and tool_approval_response events.
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.
Agent Development
Agent Context
Agent Governance & Evolution
Store
Application Marketplace