Human-in-the-loop
Your agents pause for human approval and resume the moment any client responds. AI Transport carries the pending request in the durable session, so the user approves from any device, on any timeline.
Human-in-the-loop uses the tool-calling primitives to create approval gates. The agent requests approval, the run suspends, and any connected client approves or rejects. Because the session is durable, the approval request reaches the user even after a reconnect or device switch.
How it works
The pattern builds on tool calling. The agent defines a tool that requires human approval. When the LLM invokes that tool, the agent calls run.suspend() instead of run.end() so the run stays live, and the pending tool call is published to the session. The client presents the approval request to the user. When the user approves or rejects, the client publishes a tool-approval-response input addressed to the suspended assistant, and a continuation invocation resumes the run under the same runId.
The flow:
- The agent streams a response that includes a tool call requiring approval.
- The LLM's stream finishes with
finishReason: 'tool-calls'. The agent callsrun.suspend()and the pending tool call is visible on the session. - Any connected client renders the pending approval.
- The user approves or rejects. The client publishes a
tool-approval-responseinput addressed to the pending message, then POSTs a continuation invocation to the agent. - A continuation invocation enters the same
runIdand the agent picks up the approval result and proceeds.
Define an approval tool
On the server, define a tool with an execute function and a needsApproval gate. The AI SDK calls needsApproval per tool call: while it returns true, the SDK emits an approval request instead of running execute, so the tool pauses for the user. Make the gate per call rather than per tool name, so a call that has already been approved does not ask again on the continuation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// True once this specific tool call has an approved response in the
// conversation. `streamText` passes the model-message list, where an approval
// shows up as a `tool-approval-request` on an assistant message paired with a
// `tool-approval-response` on a later tool message, correlated by `approvalId`.
const isApprovedToolCall = (toolCallId, messages) => {
const approvalIdToToolCallId = new Map();
for (const message of messages) {
if (message.role !== 'assistant' || typeof message.content === 'string') continue;
for (const part of message.content) {
if (part.type === 'tool-approval-request') {
approvalIdToToolCallId.set(part.approvalId, part.toolCallId);
}
}
}
for (const message of messages) {
if (message.role !== 'tool') continue;
for (const part of message.content) {
if (part.type !== 'tool-approval-response' || !part.approved) continue;
if (approvalIdToToolCallId.get(part.approvalId) === toolCallId) return true;
}
}
return false;
};
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: conversationHistory,
tools: {
executeTransfer: {
description: 'Execute a bank transfer. Requires user approval before running.',
inputSchema: z.object({ amount: z.number(), recipient: z.string() }),
needsApproval: (_input, { toolCallId, messages }) => !isApprovedToolCall(toolCallId, messages),
execute: async ({ amount, recipient }) => {
return await processTransfer(amount, recipient);
},
},
},
abortSignal: run.abortSignal,
});
const pipeResult = await run.pipe(result.toUIMessageStream());
const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
if (outcome.reason === 'suspend') {
await run.suspend();
} else {
await run.end(outcome);
}When the LLM invokes executeTransfer and needsApproval returns true, streamText finishes with finishReason: 'tool-calls'; vercelRunOutcome translates that to 'suspend', so the agent calls run.suspend() and the pending tool call stays on the session for any connected client to act on. On the continuation, the approval response is in the conversation, needsApproval returns false, and execute runs.
Handle approval on the client
On the client, detect pending approval requests and present them to the user. A statically-declared tool arrives as a tool-${name} part rather than dynamic-tool, so match both representations and read the name with the AI SDK's getToolName:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { getToolName } from 'ai';
const { messages, runOf, send } = useView();
const isToolPart = (p) => p.type === 'dynamic-tool' || p.type.startsWith('tool-');
const pending = messages.find(({ message }) =>
message.parts?.some(
(p) => isToolPart(p) && getToolName(p) === 'executeTransfer' && p.state === 'approval-requested',
),
);
const pendingApproval = pending?.message.parts?.find(
(p) => isToolPart(p) && p.state === 'approval-requested',
);
if (pending && pendingApproval) {
const { amount, recipient } = pendingApproval.input;
const runId = runOf(pending.codecMessageId).runId;
const respond = async (approved) => {
const run = await send(
{
kind: 'tool-approval-response',
codecMessageId: pending.codecMessageId,
payload: { toolCallId: pendingApproval.toolCallId, approved },
},
{ runId },
);
// Wake the agent so it picks up the response and resumes.
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify(run.toInvocation().toJSON()),
});
};
return (
<ApprovalDialog
amount={amount}
recipient={recipient}
onApprove={() => respond(true)}
onReject={() => respond(false)}
/>
);
}The response is addressed to the suspended assistant message by codecMessageId, and reusing the original runId keeps the resume on the same run. The agent sees the approval as the tool result and proceeds.
Approve from any device
The session is a shared Ably channel, so the approval request is visible on every connected device. Any device submits the approval; the first response wins.
A user starts a conversation on a laptop, steps away, and approves the request on a phone. The agent does not know or care which device approved it. The continuation turn starts as soon as any client submits the result.
Durable approval requests
Approval requests survive disconnections. If the user is offline when the agent requests approval, the pending tool call persists in the channel history. On reconnect, the view loads the conversation including the pending request, and the approval UI appears.
The agent's turn has already ended, so no connection or timeout is at risk. The continuation turn starts only when the user submits their response, minutes, hours, or days later.
Edge cases and unhappy paths
- Two devices submitting at the same time race. The first response wins; the second submits to an already-resolved tool call and the agent ignores it. Guard against double-submit at the application layer if both devices need to see a consistent decision.
- A user who rejects must trigger an agent path that handles rejection. The LLM only sees the response you supply; an empty rejection is ambiguous.
- A pending approval that never receives a response stays pending forever. Add an explicit timeout in your application if you need one; AI Transport does not impose one.
- The resume runs as a fresh agent invocation. Make sure your server endpoint hydrates the conversation history correctly so the LLM sees the approval result in context.
FAQ
How is this different from a regular tool call?
A regular client-executed tool has no execute function, so its call sits in the input-available state and the client runs it and returns a tool-result as soon as it receives the call. An approval tool has an execute function gated by needsApproval, so its call sits in the approval-requested state until a human returns a tool-approval-response; the tool then runs on the server. Both suspend the run and resume on a continuation, but the approval path waits for a person.
Can the agent see who approved it?
Yes. Each Ably message carries the publisher's clientId. Pass approver identity in the output payload if the LLM needs it inline.
What if the user closes the app before approving?
The pending approval stays on the session. The user sees it when they next open the app on any device, within the channel's history retention window.
How do I chase an unanswered approval?
Set a server-side timer or scheduled job that checks for stale pending tool calls and sends a notification. Use push notifications to reach the user when the app is closed.
Can a non-human submit the approval?
Yes. Any client with publish capability can submit. The mechanism is generic; "human-in-the-loop" is the common use case.
Related features
- Tool calling: the underlying mechanism for human-in-the-loop.
- Multi-device sessions: approval from any connected device.
- Reconnection and recovery: approval requests survive disconnections.