Internal architecture reference for the tool approval system in TanStack AI. Covers the full lifecycle from stream event to continuation, with emphasis on concurrency control and the chained approval mechanism.
The approval flow allows tools marked with needsApproval: true to pause execution until the user explicitly approves or denies the action. This creates a human-in-the-loop checkpoint for sensitive operations (sending emails, making purchases, deleting data).
The flow spans three layers:
flowchart TD
A[Server - TextEngine]
B[StreamProcessor]
C[ChatClient]
A -- "AG-UI stream · SSE / HTTP" --> B
B -- "events" --> C
| Layer | Responsibility |
|---|---|
| Server (TextEngine) | chat() detects needsApproval → emits CUSTOM approval-requested instead of executing the tool |
| StreamProcessor | Receives CUSTOM chunk → updateToolCallApproval() → fires onApprovalRequest callback |
| ChatClient | Exposes addToolApprovalResponse() → updates message state → triggers checkForContinuation() → sends new stream |
Framework hooks (useChat in React, Solid, Vue, Svelte) delegate to ChatClient, which owns all concurrency and continuation logic.
type ToolCallState =
| 'awaiting-input' // TOOL_CALL_START received, no arguments yet
| 'input-streaming' // Partial arguments being received
| 'input-complete' // All arguments received (TOOL_CALL_END)
| 'approval-requested' // Waiting for user approval
| 'approval-responded' // User has approved or deniedtype ToolCallState =
| 'awaiting-input' // TOOL_CALL_START received, no arguments yet
| 'input-streaming' // Partial arguments being received
| 'input-complete' // All arguments received (TOOL_CALL_END)
| 'approval-requested' // Waiting for user approval
| 'approval-responded' // User has approved or deniedinterface ToolCallPart {
type: 'tool-call'
id: string // Unique tool call ID
name: string // Tool name
arguments: string // JSON string of arguments
state: ToolCallState
approval?: {
id: string // Unique approval ID (NOT the toolCallId)
needsApproval: boolean // Always true when present
approved?: boolean // undefined until user responds
}
output?: any // Set after execution (client tools)
}interface ToolCallPart {
type: 'tool-call'
id: string // Unique tool call ID
name: string // Tool name
arguments: string // JSON string of arguments
state: ToolCallState
approval?: {
id: string // Unique approval ID (NOT the toolCallId)
needsApproval: boolean // Always true when present
approved?: boolean // undefined until user responds
}
output?: any // Set after execution (client tools)
}The approval.id is a separate identifier generated per approval request. All user-facing APIs (addToolApprovalResponse) use the approval ID, not the tool call ID. This allows the system to correlate approval responses even when multiple tools share similar call IDs across different messages.
flowchart TD
S1[awaiting-input]
S2[input-streaming]
S3[input-complete]
S4[approval-requested]
S5([execute directly])
S6[approval-responded]
S7([execute tool])
S8([cancelled])
S1 -- "TOOL_CALL_START" --> S2
S2 -- "TOOL_CALL_ARGS" --> S3
S3 -- "TOOL_CALL_END · needsApproval: true" --> S4
S3 -- "TOOL_CALL_END · needsApproval: false" --> S5
S4 -- "addToolApprovalResponse()" --> S6
S6 -- "approved: true" --> S7
S6 -- "approved: false" --> S8
A tool call is considered complete (and eligible for auto-continuation) when any of the following is true:
Step-by-step flow for a single tool requiring approval:
TOOL_CALL_START { toolCallId: "tc-1", toolName: "send_email" }
TOOL_CALL_ARGS { toolCallId: "tc-1", delta: '{"to":"..."}' }
TOOL_CALL_END { toolCallId: "tc-1" }
CUSTOM { name: "approval-requested", value: {
toolCallId: "tc-1",
toolName: "send_email",
input: { to: "..." },
approval: { id: "appr-1", needsApproval: true }
}}
RUN_FINISHED { finishReason: "tool_calls" }TOOL_CALL_START { toolCallId: "tc-1", toolName: "send_email" }
TOOL_CALL_ARGS { toolCallId: "tc-1", delta: '{"to":"..."}' }
TOOL_CALL_END { toolCallId: "tc-1" }
CUSTOM { name: "approval-requested", value: {
toolCallId: "tc-1",
toolName: "send_email",
input: { to: "..." },
approval: { id: "appr-1", needsApproval: true }
}}
RUN_FINISHED { finishReason: "tool_calls" }handleCustomEvent():
1. updateToolCallApproval(messages, messageId, "tc-1", "appr-1")
→ Sets part.state = "approval-requested"
→ Sets part.approval = { id: "appr-1", needsApproval: true }
2. emitMessagesChange()
3. fires onApprovalRequest({ toolCallId, toolName, input, approvalId })handleCustomEvent():
1. updateToolCallApproval(messages, messageId, "tc-1", "appr-1")
→ Sets part.state = "approval-requested"
→ Sets part.approval = { id: "appr-1", needsApproval: true }
2. emitMessagesChange()
3. fires onApprovalRequest({ toolCallId, toolName, input, approvalId })streamResponse() finally block:
1. setIsLoading(false)
2. drainPostStreamActions() → (nothing queued)
3. streamCompletedSuccessfully check:
lastPart is tool-call (not tool-result) → no auto-continue
→ Returns to caller (sendMessage resolves)streamResponse() finally block:
1. setIsLoading(false)
2. drainPostStreamActions() → (nothing queued)
3. streamCompletedSuccessfully check:
lastPart is tool-call (not tool-result) → no auto-continue
→ Returns to caller (sendMessage resolves)The conversation is now paused. The UI renders the approval prompt.
addToolApprovalResponse({ id: "appr-1", approved: true }):
1. processor.addToolApprovalResponse("appr-1", true)
→ updateToolCallApprovalResponse():
part.approval.approved = true
part.state = "approval-responded"
2. isLoading is false → call checkForContinuation() directlyaddToolApprovalResponse({ id: "appr-1", approved: true }):
1. processor.addToolApprovalResponse("appr-1", true)
→ updateToolCallApprovalResponse():
part.approval.approved = true
part.state = "approval-responded"
2. isLoading is false → call checkForContinuation() directlycheckForContinuation():
1. continuationPending = false, isLoading = false → proceed
2. shouldAutoSend() → areAllToolsComplete():
part.state === "approval-responded" → true
3. continuationPending = true
4. streamResponse() → new stream to server with approval in messages
5. Server sees approval, executes tool, returns result + LLM response
6. continuationPending = falsecheckForContinuation():
1. continuationPending = false, isLoading = false → proceed
2. shouldAutoSend() → areAllToolsComplete():
part.state === "approval-responded" → true
3. continuationPending = true
4. streamResponse() → new stream to server with approval in messages
5. Server sees approval, executes tool, returns result + LLM response
6. continuationPending = falseThe most complex scenario: a continuation stream produces another tool call that also needs approval, and the user responds to it while the stream is still active.
Timeline:
─────────────────────────────────────────────────────────────
1. User approves tool A
└─ checkForContinuation() sets continuationPending = true
└─ streamResponse() starts (stream 2)
2. Stream 2 produces tool B needing approval
└─ approval-requested chunk processed
└─ UI shows approval prompt for tool B
3. User approves tool B WHILE stream 2 is still active
└─ addToolApprovalResponse():
└─ processor state updated (approval-responded)
└─ isLoading is true → queues checkForContinuation
4. Stream 2 ends
└─ streamResponse() finally block:
└─ setIsLoading(false)
└─ drainPostStreamActions():
└─ Runs queued checkForContinuation()
└─ BUT continuationPending is STILL TRUE (from step 1)
└─ *** EARLY RETURN — approval swallowed ***
└─ Returns to step 1's checkForContinuation()
└─ continuationPending = false
5. Nobody re-checks → tool B's approval is lostTimeline:
─────────────────────────────────────────────────────────────
1. User approves tool A
└─ checkForContinuation() sets continuationPending = true
└─ streamResponse() starts (stream 2)
2. Stream 2 produces tool B needing approval
└─ approval-requested chunk processed
└─ UI shows approval prompt for tool B
3. User approves tool B WHILE stream 2 is still active
└─ addToolApprovalResponse():
└─ processor state updated (approval-responded)
└─ isLoading is true → queues checkForContinuation
4. Stream 2 ends
└─ streamResponse() finally block:
└─ setIsLoading(false)
└─ drainPostStreamActions():
└─ Runs queued checkForContinuation()
└─ BUT continuationPending is STILL TRUE (from step 1)
└─ *** EARLY RETURN — approval swallowed ***
└─ Returns to step 1's checkForContinuation()
└─ continuationPending = false
5. Nobody re-checks → tool B's approval is lostTwo flags work together to handle this:
continuationPending — prevents concurrent streamResponse() calls. Set to true when entering checkForContinuation's streaming path, cleared in the finally block.
continuationSkipped — set to true whenever checkForContinuation() returns early due to continuationPending or isLoading being true. Checked after continuationPending is cleared to trigger a re-evaluation.
private async checkForContinuation(): Promise<void> {
if (this.continuationPending || this.isLoading) {
this.continuationSkipped = true // ← Mark that a check was suppressed
return
}
if (this.shouldAutoSend()) {
this.continuationPending = true
this.continuationSkipped = false // ← Reset before entering stream
try {
await this.streamResponse()
} finally {
this.continuationPending = false
}
// If a check was skipped during the stream, re-evaluate now
if (this.continuationSkipped) {
this.continuationSkipped = false
await this.checkForContinuation() // ← Recurse safely
}
}
}private async checkForContinuation(): Promise<void> {
if (this.continuationPending || this.isLoading) {
this.continuationSkipped = true // ← Mark that a check was suppressed
return
}
if (this.shouldAutoSend()) {
this.continuationPending = true
this.continuationSkipped = false // ← Reset before entering stream
try {
await this.streamResponse()
} finally {
this.continuationPending = false
}
// If a check was skipped during the stream, re-evaluate now
if (this.continuationSkipped) {
this.continuationSkipped = false
await this.checkForContinuation() // ← Recurse safely
}
}
}The recursion terminates because:
continuationSkipped is only set when a real check was suppressed. After the final stream (e.g., a text-only response), no new approvals arrive, so continuationSkipped stays false and the recursion stops.
shouldAutoSend() returns false when tools are still pending approval. If a new approval arrives that hasn't been responded to yet, areAllToolsComplete() returns false and the method exits without streaming.
Each recursion level sets continuationPending = true, preventing any concurrent checks from entering the streaming path.
Timeline (with fix):
─────────────────────────────────────────────────────────────
1. User approves tool A
└─ checkForContinuation() [OUTER]
└─ continuationPending = true, continuationSkipped = false
└─ streamResponse() starts (stream 2)
2. Stream 2 produces tool B, user approves during stream
└─ Queues checkForContinuation as post-stream action
3. Stream 2 ends
└─ drainPostStreamActions():
└─ checkForContinuation(): continuationPending is true
└─ continuationSkipped = true ← MARKED
└─ returns early
└─ Back in OUTER: continuationPending = false
4. OUTER checks continuationSkipped → true
└─ continuationSkipped = false
└─ Recurses into checkForContinuation() [INNER]
└─ shouldAutoSend() → true (tool B is approval-responded)
└─ continuationPending = true
└─ streamResponse() → stream 3 (final text response)
└─ continuationPending = false
└─ continuationSkipped is false → no further recursion
5. Done. All three streams completed correctly.Timeline (with fix):
─────────────────────────────────────────────────────────────
1. User approves tool A
└─ checkForContinuation() [OUTER]
└─ continuationPending = true, continuationSkipped = false
└─ streamResponse() starts (stream 2)
2. Stream 2 produces tool B, user approves during stream
└─ Queues checkForContinuation as post-stream action
3. Stream 2 ends
└─ drainPostStreamActions():
└─ checkForContinuation(): continuationPending is true
└─ continuationSkipped = true ← MARKED
└─ returns early
└─ Back in OUTER: continuationPending = false
4. OUTER checks continuationSkipped → true
└─ continuationSkipped = false
└─ Recurses into checkForContinuation() [INNER]
└─ shouldAutoSend() → true (tool B is approval-responded)
└─ continuationPending = true
└─ streamResponse() → stream 3 (final text response)
└─ continuationPending = false
└─ continuationSkipped is false → no further recursion
5. Done. All three streams completed correctly.These are CUSTOM events emitted by the TextEngine, not by adapters directly.
Emitted when a tool with needsApproval: true has its arguments finalized.
{
type: 'CUSTOM',
name: 'approval-requested',
value: {
toolCallId: string, // ID of the tool call
toolName: string, // Name of the tool
input: any, // Parsed arguments
approval: {
id: string, // Unique approval ID
needsApproval: true
}
}
}{
type: 'CUSTOM',
name: 'approval-requested',
value: {
toolCallId: string, // ID of the tool call
toolName: string, // Name of the tool
input: any, // Parsed arguments
approval: {
id: string, // Unique approval ID
needsApproval: true
}
}
}Processor handling: handleCustomEvent() → updateToolCallApproval() → onApprovalRequest callback.
A complete approval tool call in the stream looks like:
TOOL_CALL_START → creates tool-call part (state: awaiting-input)
TOOL_CALL_ARGS* → accumulates arguments (state: input-streaming)
TOOL_CALL_END → finalizes arguments (state: input-complete)
CUSTOM → approval-requested (state: approval-requested)
RUN_FINISHED → finishReason: "tool_calls"TOOL_CALL_START → creates tool-call part (state: awaiting-input)
TOOL_CALL_ARGS* → accumulates arguments (state: input-streaming)
TOOL_CALL_END → finalizes arguments (state: input-complete)
CUSTOM → approval-requested (state: approval-requested)
RUN_FINISHED → finishReason: "tool_calls"After the stream ends and the user responds, the ChatClient:
| File | Role |
|---|---|
| packages/typescript/ai/src/types.ts | ToolCallState, ToolCallPart, tool approval types |
| packages/typescript/ai/src/activities/chat/stream/processor.ts | handleCustomEvent() (approval-requested), areAllToolsComplete(), addToolApprovalResponse() |
| packages/typescript/ai/src/activities/chat/stream/message-updaters.ts | updateToolCallApproval(), updateToolCallApprovalResponse() |
| packages/typescript/ai-client/src/chat-client.ts | addToolApprovalResponse(), checkForContinuation(), continuation flags |
| packages/typescript/ai-react/src/use-chat.ts | React hook: exposes addToolApprovalResponse |
| packages/typescript/ai-solid/src/use-chat.ts | Solid hook: exposes addToolApprovalResponse |
| packages/typescript/ai-vue/src/use-chat.ts | Vue composable: exposes addToolApprovalResponse |
| packages/typescript/ai-svelte/src/create-chat.svelte.ts | Svelte: exposes addToolApprovalResponse |
| packages/typescript/ai-client/tests/chat-client.test.ts | Chained approval test (describe('chained tool approvals')) |
| packages/typescript/ai/docs/chat-architecture.md | Internal stream processing architecture |