# Human-in-the-loop Your agents can 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. > Human-in-the-loop features need a durable session, because the conversation tree holds a pending approval until someone answers it. ![Diagram showing an agent suspending mid-run for a human approval and resuming when any client responds](https://raw.githubusercontent.com/ably/docs/main/src/images/content/diagrams/ait-human-in-the-loop.png) ## How it works The pattern builds on [tool calling](https://ably.com/docs/ai-transport/durable-sessions/tool-calling.md). The agent defines a tool that requires human approval. When the model emits that tool call, 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: 1. The agent streams a response that includes a tool call requiring approval. 2. The LLM's stream finishes with `finishReason: 'tool-calls'`. The agent calls `run.suspend()` and the pending tool call is visible on the session. 3. Any connected client renders the pending approval. 4. The user approves or rejects. The client publishes a `tool-approval-response` input addressed to the pending message, then POSTs a continuation invocation to the agent. 5. A continuation invocation enters the same `runId` and 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: ### Javascript ``` // 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`](https://ably.com/docs/ai-transport/api/javascript/vercel/run-outcome.md) 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`: ### Javascript ``` 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 ( respond(true)} onReject={() => respond(false)} /> ); } ``` The response is addressed to the suspended assistant message by `codecMessageId`, and reusing the original `runId` re-enters the suspended run under a fresh invocation, which the agent publishes as `ai-run-resume`. An approval response behaves differently from a client tool result here. A [client tool result forks](https://ably.com/docs/ai-transport/durable-sessions/tool-calling.md#client-executed) into its own reply run, because two clients answering one tool call have to stay apart. An approval response carries a decision rather than content the model consumes, so it re-enters the run the approval was requested on, and the tool then executes on the agent as normal. ## Approve from any device The session is a shared Ably channel, so the approval request is visible on every connected device. Any device can submit the approval, and the last approval on the channel is the one every client renders. A user starts a conversation on a laptop, steps away, and approves the request on a phone. The agent reads the approving device's `clientId` off the input that resumed the run. 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, so your UI can render the approval again. The agent's invocation has already returned, so the suspended run waits in the session while the user decides. 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. Both responses apply to the same tool call in channel order, so every client renders the last one, and both continuation invocations reach the agent. Guard against double-submit at the application layer if both devices need to see a consistent decision. - Your agent needs a path that handles a 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 your client can run it and return 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. A client tool result forks into its own reply run, and an approval response waits for a person before it re-enters the suspended run. ### 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](https://ably.com/docs/ai-transport/channel/push-notifications.md) 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](https://ably.com/docs/ai-transport/durable-sessions/tool-calling.md): the underlying mechanism for human-in-the-loop. - [Multi-device and fan-out](https://ably.com/docs/ai-transport/streaming/multi-device.md): approval from any connected device. - [Reconnection and recovery](https://ably.com/docs/ai-transport/streaming/reconnection-and-recovery.md): approval requests survive disconnections. ## Related Topics - [Overview](https://ably.com/docs/ai-transport/durable-sessions.md): A drop-in durable session layer for AI applications. AI Transport holds the conversation and the message state your UI renders, including branching, and you render from its React hooks. - [Sessions](https://ably.com/docs/ai-transport/durable-sessions/sessions.md): Understand sessions in AI Transport: persistent, shared conversation state that exists independently of any connection, and the ClientSession and AgentSession objects that attach to it. - [Conversation tree](https://ably.com/docs/ai-transport/durable-sessions/conversation-tree.md): Understand how AI Transport organises messages into a branching conversation tree, and how views give each client its own linear path through it. - [Optimistic updates](https://ably.com/docs/ai-transport/durable-sessions/optimistic-updates.md): User messages appear instantly in Ably AI Transport. Optimistic insertion with automatic reconciliation when the server confirms. - [Branching, edit, and regenerate](https://ably.com/docs/ai-transport/durable-sessions/branching.md): Edit user messages, regenerate AI responses, and navigate branches with Ably AI Transport. The full history is preserved in the conversation tree. - [Tool calling](https://ably.com/docs/ai-transport/durable-sessions/tool-calling.md): Stream tool invocations and results through Ably AI Transport. Server-executed and client-executed tools with persistent state. - [Database hydration](https://ably.com/docs/ai-transport/durable-sessions/database-hydration.md): Hydrate an AI conversation from your own database with AI Transport and reconcile it with the live Ably channel, with no gaps and no duplicate messages. - [Migrate from Streaming](https://ably.com/docs/ai-transport/durable-sessions/move-up-from-streaming.md): What changes in an application already streaming with Ably AI Transport when it moves the conversation into a durable session, and what stays exactly as it is. ## Documentation Index To discover additional Ably documentation: 1. Fetch [llms.txt](https://ably.com/llms.txt) for the canonical list of available pages. 2. Identify relevant URLs from that index. 3. Fetch target pages as needed. Avoid using assumed or outdated documentation paths.