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 turn pauses, 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 turn ends with a pending tool call. The client presents the approval request to the user. When the user approves or rejects, the client submits the result via view.update(), which triggers a continuation turn.

The flow:

  1. The agent streams a response that includes a tool call requiring approval.
  2. The turn ends. The pending tool call is published to the channel.
  3. Any connected client renders the pending approval.
  4. The user approves or rejects. The client calls view.update() with the result.
  5. A continuation turn starts. The agent receives the approval result and proceeds.

Define an approval tool

On the server, define a tool without an execute function. The tool's description tells the LLM when to request approval:

JavaScript

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

const result = streamText({
  model: anthropic('claude-sonnet-4-20250514'),
  messages: conversationHistory,
  tools: {
    requestApproval: {
      description: 'Request user approval before executing a sensitive action',
      inputSchema: z.object({
        action: z.string().describe('Description of the action to approve'),
        details: z.string().describe('Additional context for the user'),
      }),
      // No execute function: requires client-side approval.
    },
    executeTransfer: {
      description: 'Execute a bank transfer',
      inputSchema: z.object({ amount: z.number(), recipient: z.string() }),
      execute: async ({ amount, recipient }) => {
        return await processTransfer(amount, recipient);
      },
    },
  },
  abortSignal: turn.abortSignal,
});

const { reason } = await turn.streamResponse(result.toUIMessageStream());
await turn.end(reason);

When the LLM decides an action needs approval, it invokes requestApproval. The turn ends with the tool call pending.

Handle approval on the client

On the client, detect pending approval requests and present them to the user:

JavaScript

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

const { nodes } = useView({ transport });

const pendingNode = nodes.find((n) =>
  n.message.parts?.some(
    (p) => p.type === 'dynamic-tool' && p.toolName === 'requestApproval' && p.state === 'input-available',
  ),
);

const pendingApproval = pendingNode?.message.parts?.find(
  (p) => p.type === 'dynamic-tool' && p.state === 'input-available',
);

if (pendingNode && pendingApproval) {
  const { action, details } = pendingApproval.args;

  return (
    <ApprovalDialog
      action={action}
      details={details}
      onApprove={() =>
        view.update(pendingNode.id, [{
          type: 'tool-output-available',
          toolCallId: pendingApproval.toolCallId,
          output: { approved: true },
        }])
      }
      onReject={() =>
        view.update(pendingNode.id, [{
          type: 'tool-output-available',
          toolCallId: pendingApproval.toolCallId,
          output: { approved: false, reason: 'User declined' },
        }])
      }
    />
  );
}

view.update() submits the result and triggers a continuation turn. The agent receives { approved: true } as the tool result and proceeds, or receives the rejection and adjusts.

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 view.update() 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 output you supply; an empty rejection result 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 continuation turn 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 runs as soon as the client receives the call. Human-in-the-loop blocks until a human submits the result. The mechanics are the same; the user experience is different.

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 channel. The user sees it when they next open the app on any device, within the channel's history retention window.

How do I escalate 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 for app-level escalation.

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.