| 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";
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.
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 idle
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.
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.
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.
Two pieces on the client:
- UI for the pending tool call — render when the tool part is in
input-availablestate, i.e. the LLM has called the tool but there's no output yet. - Auto-send on resolution — use
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCallsso answering kicks off the next turn without the user having to hit "send."
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.
Two ways to detect "this turn paused for user input" vs "this turn finished normally":
The AI SDK's finish reason is surfaced on every onTurnComplete event. If the model stopped on tool calls, it's "tool-calls":
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
}
};If you need more nuance (e.g. which specific tool is pending), use chat.history.getPendingToolCalls():
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.
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:
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.
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:
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.
onTurnComplete: async ({ chatId, uiMessages }) => {
await db.chat.update({
where: { id: chatId },
data: { messages: uiMessages },
});
},Use this unless you specifically need an audit trail.
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:
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.
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.
- Don't set an
executefunction on the HITL tool. If it has one,streamTextwill call it immediately instead of handing control back. - The frontend must use
sendAutomaticallyWhen. Without it, the user has to press Enter after answering —addToolOutputupdates local state but doesn't fire a new turn by itself. - Don't mutate
responseMessageinonTurnComplete. It's the captured snapshot. To add custom parts, usechat.response.append()inonBeforeTurnComplete(while the stream is open). - Stop handling. If the user stops the run while a pause is active (
chat.stop()on the transport),onTurnCompletefires withstopped: trueandfinishReasonreflecting the last successful step. Treat stopped paused turns the same as stopped normal turns.