---
title: "Human-in-the-loop"
sidebarTitle: "Human-in-the-loop"
description: "Pause the agent mid-response to ask the user a clarifying question, then resume with their answer."
---

import RcBanner from "/snippets/ai-chat-rc-banner.mdx";

<RcBanner />

Some turns need to stop and ask the user something before they can finish — picking between options, confirming a destructive action, or clarifying an ambiguous request. The AI SDK calls this **human-in-the-loop** (HITL), and the building block is a tool with no `execute` function.

When the LLM calls a tool that has no `execute`, `streamText` ends with the tool call still pending. The turn completes cleanly, the frontend renders UI to collect the answer, and when the user responds, a new turn resumes with the answer merged into the same assistant message.

## How it works

```
Turn N:
  User message → run()
  LLM streams text → calls askUser tool (no execute)
  streamText ends with tool-call in `input-available` state
  onTurnComplete fires (finishReason = "tool-calls")
  Agent suspends (compute freed) — maxDuration does not tick while paused

Frontend:
  Renders question + option buttons from tool input
  User clicks → addToolOutput({ tool, toolCallId, output })
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls
  → sendMessage() fires next turn

Turn N+1:
  hydrateMessages / accumulator sees the updated assistant message
  run() is called, LLM continues from the tool result
  onTurnComplete fires (finishReason = "stop", responseMessage is the FULL merged message)
```

The AI SDK's `toUIMessageStream` automatically reuses the assistant message ID across the pause (we pass `originalMessages` internally), so `responseMessage` in the post-resume `onTurnComplete` is the **full merged message** — the original text, the completed tool call, and any follow-up content — not just the new parts.

## Duration and cost while paused

A pause doesn't hold compute. After the model calls a no-execute tool, the turn finishes and the run stays warm for `idleTimeoutInSeconds` (default 30s), then **suspends** and frees its compute, the same way [`wait.for`](/wait-for) does. The user's `addToolOutput` wakes it back up.

Because the run is suspended while it waits, the human's thinking time is not billed and does **not** count against [`maxDuration`](/runs/max-duration). `maxDuration` measures active CPU time and excludes suspended waitpoint time, exactly like `wait.for`, so a user can take minutes, hours, or days to answer without the run hitting `maxDuration`. The only time that counts is each turn's actual compute plus the short warm window before each suspend.

You don't need to raise `maxDuration` or end the run to support long human waits. How long a single suspended pause stays open is governed by the run's suspend timeout, not `maxDuration`; if a wait outlives it the run ends, and the next `addToolOutput` boots a fresh continuation that picks up the resolved tool result.

## Backend: define the tool

A HITL tool has an `inputSchema` describing what the model can ask, but **no `execute` function**. When the LLM calls it, `streamText` returns control to your agent.

```ts trigger/my-chat.ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const askUser = tool({
  description:
    "Ask the user a clarifying question when you need their input. " +
    "Present 2-4 options for them to pick from.",
  inputSchema: z.object({
    question: z.string(),
    options: z
      .array(
        z.object({
          id: z.string(),
          label: z.string(),
          description: z.string().optional(),
        })
      )
      .min(2)
      .max(4),
  }),
  // No execute function — streamText ends, the frontend supplies the output
  // via addToolOutput, and the next turn continues from the result.
});

export const myChat = chat.agent({
  id: "my-chat",
  tools: { askUser },
  run: async ({ messages, tools, signal }) => {
    return streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      tools,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

Declaring `tools` on the config (and reading them back from the payload) is the recommended shape for any agent with tools. See [Tools](/ai-chat/tools).

## Frontend: render the question and collect the answer

Two pieces on the client:

1. **UI for the pending tool call** — render when the tool part is in `input-available` state, i.e. the LLM has called the tool but there's no output yet.
2. **Auto-send on resolution** — use `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` so answering kicks off the next turn without the user having to hit "send."

```tsx
import { useChat, lastAssistantMessageIsCompleteWithToolCalls } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";

function ChatView({ chatId }: { chatId: string }) {
  const transport = useTriggerChatTransport({
    task: "my-chat",
    accessToken: ({ chatId }) => mintChatAccessToken(chatId),
    startSession: ({ chatId, clientData }) =>
      startChatSession({ chatId, clientData }),
  });
  const { messages, sendMessage, addToolOutput } = useChat({
    id: chatId,
    transport,
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
  });

  return (
    <>
      {messages.map((m) =>
        m.parts.map((part, i) => {
          if (part.type === "tool-askUser" && part.state === "input-available") {
            return (
              <AskUserCard
                key={i}
                question={part.input.question}
                options={part.input.options}
                onAnswer={(opt) =>
                  addToolOutput({
                    tool: "askUser",
                    toolCallId: part.toolCallId,
                    output: { optionId: opt.id, label: opt.label },
                  })
                }
              />
            );
          }
          if (part.type === "text") return <Markdown key={i}>{part.text}</Markdown>;
          return null;
        })
      )}
    </>
  );
}
```

`addToolOutput` patches the assistant message locally with `state: "output-available"` and fills in `output`. `lastAssistantMessageIsCompleteWithToolCalls` detects that every pending tool call now has a result, and `useChat` fires a new `sendMessage` — the backend picks it up as the next turn.

## Detecting a paused turn in `onTurnComplete`

Two ways to detect "this turn paused for user input" vs "this turn finished normally":

### Via `finishReason` (recommended)

The AI SDK's finish reason is surfaced on every `onTurnComplete` event. If the model stopped on tool calls, it's `"tool-calls"`:

```ts
onTurnComplete: async ({ finishReason, responseMessage }) => {
  if (finishReason === "tool-calls") {
    // Turn paused — assistant message has pending tool call(s)
    const pending = responseMessage?.parts.filter(
      (p) => p.type.startsWith("tool-") && p.state === "input-available"
    );
    // Persist as a checkpoint / partial turn
  } else {
    // finishReason === "stop" — normal completion
    // Persist as a completed turn
  }
};
```

<Note>
  `finishReason` is only undefined for manual `chat.pipe()` flows or aborted streams. For the common `run() → return streamText(...)` pattern it's always populated.
</Note>

### Via response parts

If you need more nuance (e.g. which specific tool is pending), use `chat.history.getPendingToolCalls()`:

```ts
const pending = chat.history.getPendingToolCalls();
// [{ toolCallId, toolName, messageId }]
```

The result reflects the most recent assistant message: the one waiting on `addToolOutput`. Use it from `onAction` to gate fresh user turns ("can't send a new message while a HITL is open"), or from `onTurnComplete` to decide what to persist.

Both `finishReason === "tool-calls"` and `chat.history.getPendingToolCalls().length > 0` are equivalent in practice. Use `finishReason` for dispatch, the helper for detail.

### Acting once per net-new tool result

When the user's `addToolOutput` round-trips a tool answer back to the agent, the wire message carries the resolved tool part. If you want to fire side-effects (audit log, billing, notifications) exactly once per resolved tool call, do it in `hydrateMessages` before the runtime merges. `chat.history.extractNewToolResults(message)` returns only the parts whose `toolCallId` isn't already resolved on the chain:

```ts
hydrateMessages: async ({ incomingMessages }) => {
  for (const msg of incomingMessages) {
    if (msg.role !== "assistant") continue;
    for (const r of chat.history.extractNewToolResults(msg)) {
      await auditLog.record({
        toolCallId: r.toolCallId,
        toolName: r.toolName,
        output: r.output,
        errorText: r.errorText, // set only for output-error parts
      });
    }
  }
  return incomingMessages;
},
```

`extractNewToolResults` compares against the current `chat.history`. By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so the helper returns `[]` there. Use it where the message is from outside the accumulator: `hydrateMessages`, `onAction` if the action carries a message, or any custom pre-merge code path.

## Persistence: one message vs one record per pause

Because the AI SDK reuses the assistant message ID across the pause, the "same turn" from the user's perspective maps to **two `onTurnComplete` firings** on the server — but both receive a `responseMessage` with the **same `id`**, and the second firing's `responseMessage` contains the fully merged content.

Two common persistence patterns:

### Overwrite on every turn (simplest)

Just store the latest `uiMessages` array on every `onTurnComplete`. The paused-turn write is overwritten by the resume-turn write; the final DB state has the full merged message.

```ts
onTurnComplete: async ({ chatId, uiMessages }) => {
  await db.chat.update({
    where: { id: chatId },
    data: { messages: uiMessages },
  });
},
```

Use this unless you specifically need an audit trail.

### Checkpoint nodes (immutable history)

For apps that want every pause point recorded as its own immutable snapshot (branching, replay, diff review), save a checkpoint when paused and a sibling when complete:

```ts
onTurnComplete: async ({ chatId, responseMessage, finishReason, uiMessages }) => {
  if (!responseMessage) return;

  if (finishReason === "tool-calls") {
    // Paused — save a checkpoint
    await db.turnCheckpoint.create({
      data: {
        chatId,
        messageId: responseMessage.id,
        parts: responseMessage.parts,
        kind: "partial",
      },
    });
  } else {
    // Completed — save a sibling with the merged full message
    await db.turnCheckpoint.create({
      data: {
        chatId,
        messageId: responseMessage.id,
        parts: responseMessage.parts,
        kind: "final",
      },
    });
  }

  // Always update the canonical chat record for `hydrateMessages` to load
  await db.chat.update({
    where: { id: chatId },
    data: { messages: uiMessages },
  });
};
```

Both writes see `responseMessage.id` as the same value — they're checkpoints of the same logical message. Grouping by `messageId` + ordering by `createdAt` gives you the progression.

## Multi-pause turns

A single logical turn can pause more than once — the LLM asks question A, gets the answer, thinks, then asks question B before finishing. Each pause fires its own `onTurnComplete` with `finishReason === "tool-calls"`; only the last firing has `finishReason === "stop"`. The checkpoint pattern above handles this naturally — each pause adds a new checkpoint sharing the same `responseMessage.id`.

## Gotchas

- **Don't set an `execute` function on the HITL tool.** If it has one, `streamText` will call it immediately instead of handing control back.
- **The frontend must use `sendAutomaticallyWhen`.** Without it, the user has to press Enter after answering — `addToolOutput` updates local state but doesn't fire a new turn by itself.
- **Don't mutate `responseMessage` in `onTurnComplete`.** It's the captured snapshot. To add custom parts, use `chat.response.append()` in `onBeforeTurnComplete` (while the stream is open).
- **Stop handling.** If the user stops the run while a pause is active (`chat.stop()` on the transport), `onTurnComplete` fires with `stopped: true` and `finishReason` reflecting the last successful step. Treat stopped paused turns the same as stopped normal turns.
