Skip to content

Commit ab181fc

Browse files
committed
improvement(agent-streaming): add way to opt in for workflow executions
1 parent f43b52c commit ab181fc

24 files changed

Lines changed: 402 additions & 128 deletions

File tree

apps/docs/content/docs/en/workflows/deployment/agent-events.mdx

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,51 @@ Agent blocks can emit more than answer text while they run: **provider-exposed t
1212
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).
1313
</Callout>
1414

15-
## Protocol opt-in vs. event gates (public chat / simple SSE)
15+
## Two independent switches
1616

17-
Sending the header is a statement about the **client**: it understands v1 framing, so answer text can stream live and be retracted with `chunk_reset`. That alone does not expose anything — it only changes cadence, and it applies even when both event policies are off.
17+
Agent events are governed by two things that do not depend on each other.
1818

19-
Thinking and tool frames need the header **plus** their own deployment policy:
19+
**Policy decides which frames exist.** `includeThinking` turns on `thinking` frames, `includeToolCalls` turns on `tool` frames, and both default to **off**.
2020

21-
1. Thinking frames require chat `includeThinking` (default **off**).
22-
2. Tool lifecycle frames require chat `includeToolCalls` (default **off**).
23-
3. Both require the request to opt in with:
21+
| Surface | Policy source |
22+
|---------|---------------|
23+
| Deployed chat (`/api/chat/{identifier}`) | The chat deployment's **Thinking** and **Tool calls** toggles |
24+
| Workflow API (`/api/workflows/{id}/execute`) | Per-request `includeThinking` / `includeToolCalls` in the body |
25+
26+
**The header declares the protocol version.** Sending it says the client understands v1 framing:
2427

2528
```http
2629
X-Sim-Stream-Protocol: agent-events-v1
2730
```
2831

29-
Legacy clients that omit the header keep today’s text-only SSE even if either deployment policy is enabled. The hosted chat UI always sends the header for its own deployments, so hosted chats stream token by token regardless of the toggles.
32+
It 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.
33+
34+
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.
35+
36+
The header alone exposes nothing, so a chat with both policies off still streams token by token.
37+
38+
<Callout type="warn">
39+
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.
40+
</Callout>
41+
42+
### Workflow API
43+
44+
```bash
45+
curl -N https://sim.ai/api/workflows/{id}/execute \
46+
-H "X-API-Key: $SIM_API_KEY" \
47+
-H "Content-Type: application/json" \
48+
-H "X-Sim-Stream-Protocol: agent-events-v1" \
49+
-d '{
50+
"stream": true,
51+
"selectedOutputs": ["agent_1.content"],
52+
"includeThinking": true,
53+
"includeToolCalls": true
54+
}'
55+
```
56+
57+
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.
58+
59+
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.
3060

3161
## Simple SSE frame shapes
3262

@@ -47,8 +77,8 @@ Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older c
4777

4878
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).
4979

50-
- **Opted-in clients** (protocol header; no event policy required) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content.
51-
- **Legacy clients** (no header) never see provisional text: only settled final-turn text is emitted as `chunk`, delivered in one piece when the turn completes. Honoring `chunk_reset` is what buys live cadence, so send the header if you want it.
80+
- **Clients sending the protocol header** (no event policy required) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content.
81+
- **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. Honoring `chunk_reset` is what buys live cadence, so send the header if you want it.
5282

5383
Logs, memory, and the block’s `content` output always contain final-turn text only — intermediate preamble is never persisted.
5484

@@ -62,7 +92,7 @@ Canvas execution-events `stream:chunk`, `stream:chunk_reset`, `stream:thinking`,
6292

6393
## Canvas (draft Run)
6494

65-
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; the dual gate applies only to the public chat / simple SSE surface:
95+
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:
6696

6797
- `stream:thinking``{ blockId, text }`
6898
- `stream:tool``{ blockId, phase, id, name, status? }`
@@ -73,7 +103,7 @@ The terminal output panel shows Thinking / Tools chrome above the block output w
73103

74104
## Chat deployment toggles
75105

76-
In **Deploy → Chat**, enable **Include thinking** for provider-exposed thinking and **Include tool calls** for tool names and lifecycle status. The switches are independent, and both event types still require the protocol header. Neither switch affects answer-text cadence.
106+
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.
77107

78108
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.
79109

@@ -89,6 +119,68 @@ Per-model support is generated from the model registry on the [Agent block page]
89119
| OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams `reasoning` / `reasoning_content` | Live loop where wired (e.g. Groq, DeepSeek) |
90120
| Bedrock | Not invented | Yes when streaming tool loop is used |
91121

122+
## Consuming the stream
123+
124+
A conforming client owes the stream four things:
125+
126+
1. **Discriminate before appending.** Only a frame with **no** `event` field is answer text. Checking `event === undefined` rather than "has a `chunk` field" is what keeps future frame types from leaking into the answer.
127+
2. **Accumulate per `blockId`.** A workflow can stream more than one block; frames interleave.
128+
3. **Honor `chunk_reset`** if 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.
129+
4. **Stop at the terminal frame.** Exactly one of `final` or `error` arrives, followed by the literal `"[DONE]"` sentinel. `stream_error` is *not* terminal.
130+
131+
### Reference client
132+
133+
```ts
134+
type Frame = Record<string, unknown>
135+
136+
async function consume(response: Response) {
137+
const reader = response.body!.getReader()
138+
const decoder = new TextDecoder()
139+
const answers = new Map<string, string>()
140+
const thinking = new Map<string, string>()
141+
let buffer = ''
142+
143+
while (true) {
144+
const { done, value } = await reader.read()
145+
if (done) break
146+
buffer += decoder.decode(value, { stream: true })
147+
148+
// SSE frames are newline-delimited; keep the trailing partial line.
149+
const lines = buffer.split('\n')
150+
buffer = lines.pop() ?? ''
151+
152+
for (const line of lines) {
153+
if (!line.startsWith('data: ')) continue
154+
const payload = line.slice(6)
155+
156+
const frame = JSON.parse(payload) as Frame | string
157+
if (frame === '[DONE]') return { answers, thinking }
158+
159+
const { blockId, event } = frame as { blockId?: string; event?: string }
160+
161+
if (event === undefined && typeof frame.chunk === 'string') {
162+
answers.set(blockId!, (answers.get(blockId!) ?? '') + frame.chunk)
163+
} else if (event === 'chunk_reset') {
164+
answers.set(blockId!, '')
165+
} else if (event === 'thinking') {
166+
thinking.set(blockId!, (thinking.get(blockId!) ?? '') + String(frame.data))
167+
} else if (event === 'tool') {
168+
// frame.phase is 'start' | 'end'; frame.status is set on 'end'.
169+
renderToolChip(frame)
170+
} else if (event === 'final') {
171+
// Terminal. frame.data.success may be false with frame.data.error.
172+
} else if (event === 'error') {
173+
throw new Error(String(frame.error))
174+
} else if (event === 'stream_error') {
175+
// Non-terminal: log and keep reading.
176+
}
177+
}
178+
}
179+
}
180+
```
181+
182+
Unknown `event` values should be ignored rather than treated as errors — that is what lets new frame types ship without breaking existing clients.
183+
92184
## Example (public chat)
93185

94186
<Tabs items={['cURL']}>

apps/docs/content/docs/en/workflows/deployment/api.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,28 @@ while (true) {
237237
</Tab>
238238
</Tabs>
239239

240+
#### Streaming agent thinking and tool calls
241+
242+
By default a streaming run carries answer text only. To also receive the Agent block's reasoning and its tool-call lifecycle, set `includeThinking` / `includeToolCalls`:
243+
244+
```bash
245+
curl -N -X POST https://sim.ai/api/workflows/{workflow-id}/execute \
246+
-H "Content-Type: application/json" \
247+
-H "x-api-key: $SIM_API_KEY" \
248+
-H "X-Sim-Stream-Protocol: agent-events-v1" \
249+
-d '{
250+
"input": "Research this topic",
251+
"stream": true,
252+
"selectedOutputs": ["agent_1.content"],
253+
"includeThinking": true,
254+
"includeToolCalls": true
255+
}'
256+
```
257+
258+
The `X-Sim-Stream-Protocol` header is **required** whenever either flag is set — it declares that your client understands agent-event framing. Setting a flag without it returns `400` rather than silently ignoring the flag. The header also switches answer text to live token-by-token delivery that `chunk_reset` can retract.
259+
260+
Both flags default to `false` and the header is optional on its own, so existing integrations keep the exact frames they receive today. Tool frames carry the tool name and status only; arguments and results arrive in the terminal `final` envelope. Not every model exposes thinking. See [Agent stream events](/docs/workflows/deployment/agent-events) for the frame shapes, a reference client, and per-provider support.
261+
240262
#### Oversized outputs
241263

242264
Workflow execution responses are capped by platform request and response limits. When an internal output, log field, streamed field, or async status payload contains a value that is too large to inline, Sim may replace that nested value with a versioned reference:
@@ -378,6 +400,7 @@ For detailed rate limit information and the logs/webhooks API, see [External API
378400
{ question: "Can I deploy the same workflow as both an API and a chat?", answer: "Yes. A workflow can be simultaneously deployed as an API, chat, MCP tool, and more. Each deployment type runs against the same active snapshot." },
379401
{ question: "How do I choose between sync, streaming, and async?", answer: "Use sync for quick workflows that finish in seconds. Use streaming when you want to show progressive output to users as it's generated. Use async for long-running workflows where holding a connection open isn't practical." },
380402
{ question: "How do I select multiple outputs for streaming?", answer: "Open the Select outputs dropdown in the API tab and check each output field you want to stream. You can choose fields from multiple blocks. The selected fields are reflected as an array in the selectedOutputs request body parameter." },
403+
{ question: "Can I stream an Agent block's thinking and tool calls over the API?", answer: "Yes. Set includeThinking and/or includeToolCalls to true in the request body and send the X-Sim-Stream-Protocol: agent-events-v1 header, which declares that your client understands agent-event framing. Setting a flag without the header returns 400 rather than silently ignoring it. Both flags default to false, so existing integrations are unaffected. Thinking arrives as thinking frames and tool lifecycle as tool frames carrying name and status only — tool arguments and results stay in the final envelope." },
381404
{ question: "How does Promote to live work?", answer: "Promote to live sets an older version as the active deployment without creating a new version. Subsequent API calls immediately run against the promoted snapshot. This is the fastest way to roll back to a previous state." },
382405
{ question: "How long are async job results available?", answer: "Completed and failed job results are retained for 24 hours. After that, the status endpoint returns 404. Retrieve and store results on your end if you need them longer." },
383406
{ question: "What happens if my API key is compromised?", answer: "Revoke the key immediately in Settings → Sim Keys and generate a new one. Revoked keys stop working instantly." },

apps/sim/app/api/chat/[identifier]/otp/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export const PUT = withRouteHandler(
212212
authType: deployment.authType,
213213
outputConfigs: deployment.outputConfigs,
214214
includeThinking: deployment.includeThinking ?? false,
215-
includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false,
215+
includeToolCalls: deployment.includeToolCalls ?? false,
216216
})
217217
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
218218

apps/sim/app/api/chat/[identifier]/route.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,11 @@ describe('Chat Identifier API Route', () => {
413413
)
414414
}, 10000)
415415

416-
it('preserves the legacy tool policy when includeToolCalls is null', async () => {
416+
/**
417+
* A row predating the column has no tool policy, so it has not opted in.
418+
* Thinking must not drag tool frames along with it.
419+
*/
420+
it('reads a null tool policy as off rather than inheriting thinking', async () => {
417421
const thinkingChatResult = [
418422
{ ...mockChatResult[0], includeThinking: true, includeToolCalls: null },
419423
]
@@ -447,7 +451,7 @@ describe('Chat Identifier API Route', () => {
447451
const options = vi.mocked(createStreamingResponse).mock.calls[0][0]
448452
expect(options.streamConfig).toMatchObject({
449453
includeThinking: true,
450-
includeToolCalls: true,
454+
includeToolCalls: false,
451455
})
452456

453457
await options.executeFn({
@@ -458,7 +462,7 @@ describe('Chat Identifier API Route', () => {
458462
const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4]
459463
expect(executeOptions).toMatchObject({
460464
includeThinking: true,
461-
includeToolCalls: true,
465+
includeToolCalls: false,
462466
agentEvents: true,
463467
})
464468
}, 10000)
@@ -513,7 +517,11 @@ describe('Chat Identifier API Route', () => {
513517
})
514518
}, 10000)
515519

516-
it('keeps agent events off when the protocol header is missing, even with policy on', async () => {
520+
/**
521+
* Chat degrades rather than rejecting: the policy comes from the
522+
* deployment, so an un-negotiated client made no bad request.
523+
*/
524+
it('keeps agent events off without the protocol header, even with policy on', async () => {
517525
const thinkingChatResult = [
518526
{ ...mockChatResult[0], includeThinking: true, includeToolCalls: false },
519527
]

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function toChatConfigResponse(deployment: ChatConfigSource) {
4040
authType: deployment.authType,
4141
outputConfigs: deployment.outputConfigs,
4242
includeThinking: deployment.includeThinking ?? false,
43-
includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false,
43+
includeToolCalls: deployment.includeToolCalls ?? false,
4444
}
4545
}
4646

@@ -281,7 +281,7 @@ export const POST = withRouteHandler(
281281
}
282282

283283
const includeThinking = deployment.includeThinking ?? false
284-
const includeToolCalls = deployment.includeToolCalls ?? includeThinking
284+
const includeToolCalls = deployment.includeToolCalls ?? false
285285
const agentEvents = shouldEmitAgentStreamEvents({
286286
includeThinking,
287287
includeToolCalls,

apps/sim/app/api/chat/manage/[id]/route.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ describe('Chat Edit API Route', () => {
160160
expect(data.title).toBe('Test Chat')
161161
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
162162
expect(data.hasPassword).toBe(true)
163-
expect(data.includeToolCalls).toBe(true)
163+
// Stored null is not an opt-in.
164+
expect(data.includeToolCalls).toBe(false)
164165
})
165166
})
166167

@@ -227,19 +228,17 @@ describe('Chat Edit API Route', () => {
227228

228229
expect(response.status).toBe(200)
229230
expect(dbChainMockFns.update).toHaveBeenCalled()
231+
// An unrelated field update materializes the stored null as false.
230232
expect(dbChainMockFns.set).toHaveBeenCalledWith(
231-
expect.objectContaining({ includeToolCalls: true })
233+
expect.objectContaining({ includeToolCalls: false })
232234
)
233235
const data = await response.json()
234236
expect(data.id).toBe('chat-123')
235237
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
236238
expect(data.message).toBe('Chat deployment updated successfully')
237239
})
238240

239-
it('turns grandfathered tool calls off when the same update disables thinking', async () => {
240-
// Before includeToolCalls existed, includeThinking gated tool frames too.
241-
// Resolving the fallback against the stored value would materialize the
242-
// stale `true` and silently leave tool frames on.
241+
it('leaves tool calls off when a row without a tool policy disables thinking', async () => {
243242
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } })
244243

245244
mockCheckChatAccess.mockResolvedValue({

0 commit comments

Comments
 (0)