Agent blocks can emit more than answer text while they run: provider-exposed thinking (or reasoning summaries) and a tool-call lifecycle (name + status). Sim delivers those as typed events on an opt-in stream protocol.
Sim does not invent thinking for providers that do not stream it. Bedrock Converse, many OpenAI-compat models, and non-reasoning chat models stay text-only (plus tools when a live tool loop is wired).
Two independent switches
Agent events are governed by two things that do not depend on each other.
Policy decides which frames exist. includeThinking turns on thinking frames, includeToolCalls turns on tool frames, and both default to off.
| Surface | Policy source |
|---|---|
Deployed chat (/api/chat/{identifier}) | The chat deployment's Thinking and Tool calls toggles |
Workflow API (/api/workflows/{id}/execute) | Per-request includeThinking / includeToolCalls in the body |
The header declares the protocol version. Sending it says the client understands v1 framing:
X-Sim-Stream-Protocol: agent-events-v1It does two things. It switches answer text to live token-by-token chunk frames that chunk_reset can retract, and it is required for any thinking or tool frame — a client that never declared a version has no contract for their shape, so it keeps the text-only stream it already understands.
Omitting the header is always valid and always safe: you get settled final-turn text and no agent-event frames, which is what every pre-existing integration receives. The response echoes the header back when the protocol was negotiated.
The header alone exposes nothing, so a chat with both policies off still streams token by token.
On the workflow API, setting includeThinking or includeToolCalls without the header is rejected with 400. The flags would otherwise be a silent no-op, which is the failure mode this protocol exists to avoid. Deployed chat degrades instead of rejecting, because there the policy comes from the deployment rather than the request.
Workflow API
curl -N https://sim.ai/api/workflows/{id}/execute \
-H "X-API-Key: $SIM_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Sim-Stream-Protocol: agent-events-v1" \
-d '{
"stream": true,
"selectedOutputs": ["agent_1.content"],
"includeThinking": true,
"includeToolCalls": true
}'Both flags default to false, so an existing integration receives exactly the frames it does today. The header is required whenever either flag is set; omit it and the request is rejected with 400 rather than silently downgraded.
Two differences from deployed chat are worth knowing. Tool frames carry the name and status only on both surfaces, but the API's terminal final envelope keeps tool arguments and results — public chat redacts them. And thinking is delivered only as thinking frames; it is stripped from providerTiming.timeSegments in the envelope on every surface, so enabling the policy is the only way to receive it.
Simple SSE frame shapes
Answer text stays on chunk. Thinking and tools never reuse chunk (so older clients that append every chunk into the answer cannot leak thinking).
| Frame | Meaning |
|---|---|
{ "blockId", "chunk": "…" } | Answer text. Legacy clients receive settled final-turn text in one piece; opted-in clients receive it live as the model generates (see below) |
{ "blockId", "event": "chunk_reset" } | Opted-in only: discard the block’s streamed answer text — it belonged to a turn that resolved to tool calls |
{ "blockId", "event": "thinking", "data": "…" } | Thinking / reasoning summary delta |
{ "blockId", "event": "tool", "phase": "start"|"end", "id", "name", "status?" } | Tool lifecycle (no args / results) |
{ "event": "final", "data": … } | Terminal result envelope for a settled execution. data.success may be false with data.error when the workflow itself failed |
{ "event": "error", "error": "…" } | Terminal stream failure (timeout, client abort, processing error) — followed by [DONE], never by final |
{ "event": "stream_error", "blockId?", "error" } | Non-terminal mid-block read issue; the stream keeps going |
data: "[DONE]" | Stream closed (JSON-encoded sentinel; always follows the terminal final or error frame) |
Live answer text and intermediate turns
During a live tool loop, the model can’t be classified mid-turn: text it emits may turn out to be the final answer or preamble before a tool call (the stop reason arrives only at turn end).
- Clients sending the protocol header (no event policy required) receive answer text as
chunkframes live, token by token. If the turn then resolves to tool calls, achunk_resetframe tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Appendchunk, honorchunk_reset, and the displayed answer always converges to the block’s final content. - Clients without the header never see provisional text: only settled final-turn text is emitted as
chunk, delivered in one piece when the turn completes. Honoringchunk_resetis what buys live cadence, so send the header if you want it.
Logs, memory, and the block’s content output always contain final-turn text only — intermediate preamble is never persisted.
Abort
Client disconnect or Stop aborts the provider stream. In-flight tools settle as cancelled. Cancel is distinct from execution timeout.
Reconnect
Canvas execution-events stream:chunk, stream:chunk_reset, stream:thinking, and stream:tool are live-only (not buffered for reconnect replay), same as answer chunks. Guaranteed seq replay is out of scope.
Canvas (draft Run)
When you click Run in the builder, the execution-events SSE path forwards the same sink. The canvas is always opted in — it does not send (or need) the X-Sim-Stream-Protocol header, and the policy switches do not apply to it:
stream:thinking—{ blockId, text }stream:tool—{ blockId, phase, id, name, status? }stream:chunk— answer text, live on agent-events runsstream:chunk_reset—{ blockId }; discard the block’s streamed text (intermediate turn)
The terminal output panel shows Thinking / Tools chrome above the block output when those events arrive. PII redaction on block output still disables live forwarding (executor rule).
Chat deployment toggles
In Deploy → Chat, enable Include thinking for provider-exposed thinking and Include tool calls for tool names and lifecycle status. The switches are independent of each other, and both still require the client to send the protocol header. Neither switch affects answer-text cadence.
Tool arguments and results are never exposed to a public chat — not in lifecycle frames, and not through the terminal final envelope, where the block's own tool calls are reduced to the same name-and-lifecycle shape. The authenticated workflow API still returns full tool results. Redeploy or update the chat after changing Agent models or tools.
Capability honesty (high level)
Per-model support is generated from the model registry on the Agent block page; the table below summarizes by provider family.
| Family | Thinking | Live tools |
|---|---|---|
| Anthropic / Azure Anthropic | Yes (incl. redacted blocks in traces). The newest Claude generations omit full thinking; Sim requests summarized thinking for them on streaming runs | Yes |
| Gemini / Vertex | Yes when a thinking level is set (thought summaries requested on agent-events runs) | Yes |
| OpenAI Responses | Reasoning summaries when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Yes |
| OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams reasoning / reasoning_content | Live loop where wired (e.g. Groq, DeepSeek) |
| Bedrock | Not invented | Yes when streaming tool loop is used |
Consuming the stream
A conforming client owes the stream four things:
- Discriminate before appending. Only a frame with no
eventfield is answer text. Checkingevent === undefinedrather than "has achunkfield" is what keeps future frame types from leaking into the answer. - Accumulate per
blockId. A workflow can stream more than one block; frames interleave. - Honor
chunk_resetif you sent the protocol header. Clear that block's accumulated text — it belonged to a turn that resolved to tool calls, and the final turn re-streams. - Stop at the terminal frame. Exactly one of
finalorerrorarrives, followed by the literal"[DONE]"sentinel.stream_erroris not terminal.
Reference client
type Frame = Record<string, unknown>
async function consume(response: Response) {
const reader = response.body!.getReader()
const decoder = new TextDecoder()
const answers = new Map<string, string>()
const thinking = new Map<string, string>()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// SSE frames are newline-delimited; keep the trailing partial line.
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
const frame = JSON.parse(payload) as Frame | string
if (frame === '[DONE]') return { answers, thinking }
const { blockId, event } = frame as { blockId?: string; event?: string }
if (event === undefined && typeof frame.chunk === 'string') {
answers.set(blockId!, (answers.get(blockId!) ?? '') + frame.chunk)
} else if (event === 'chunk_reset') {
answers.set(blockId!, '')
} else if (event === 'thinking') {
thinking.set(blockId!, (thinking.get(blockId!) ?? '') + String(frame.data))
} else if (event === 'tool') {
// frame.phase is 'start' | 'end'; frame.status is set on 'end'.
renderToolChip(frame)
} else if (event === 'final') {
// Terminal. frame.data.success may be false with frame.data.error.
} else if (event === 'error') {
throw new Error(String(frame.error))
} else if (event === 'stream_error') {
// Non-terminal: log and keep reading.
}
}
}
}Unknown event values should be ignored rather than treated as errors — that is what lets new frame types ship without breaking existing clients.
Example (public chat)
curl -N -X POST 'http://localhost:3000/api/chat/your-slug' \
-H 'Content-Type: application/json' \
-H 'X-Sim-Stream-Protocol: agent-events-v1' \
-d '{"input":"Think briefly, then say hi"}'See also Chat deployment for access control and the event settings.