HITL Overview
Allow your agent and users to collaborate on complex tasks.
/** * LangGraph TypeScript agent backing the In-Chat HITL (useHumanInTheLoop) demo. * * The `book_call` tool is defined on the frontend via `useHumanInTheLoop`, * so there is no backend tool here. CopilotKit forwards the frontend tool * schemas to the agent at runtime via `state.copilotkit.actions`; the agent * binds them when invoking the model so the frontend-rendered time-picker * can resolve the call. */import { RunnableConfig } from "@langchain/core/runnables";import { SystemMessage } from "@langchain/core/messages";import { MemorySaver, START, StateGraph } from "@langchain/langgraph";import { ChatOpenAI } from "@langchain/openai";import { convertActionsToDynamicStructuredTools, CopilotKitStateAnnotation,} from "@copilotkit/sdk-js/langgraph";const AgentStateAnnotation = CopilotKitStateAnnotation;export type AgentState = typeof AgentStateAnnotation.State;const SYSTEM_PROMPT = "You help users book an onboarding call with the sales team. " + "When they ask to book a call, call the frontend-provided " + "`book_call` tool with a short topic and the user's name. " + "Keep any chat reply to one short sentence.";async function chatNode(state: AgentState, config: RunnableConfig) { const model = new ChatOpenAI({ temperature: 0, model: "gpt-4o-mini" }); const modelWithTools = model.bindTools!([ ...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []), ]); const response = await modelWithTools.invoke( [new SystemMessage({ content: SYSTEM_PROMPT }), ...state.messages], config, ); return { messages: response };}const workflow = new StateGraph(AgentStateAnnotation) .addNode("chat_node", chatNode) .addEdge(START, "chat_node") .addEdge("chat_node", "__end__");const memory = new MemorySaver();export const graph = workflow.compile({ checkpointer: memory,});What is this?#
Human-in-the-loop (HITL) lets an agent pause mid-run to collect input, confirmation, or a choice from the user, then resume with that answer folded back into its reasoning. It's what turns an autonomous workflow into a collaborative one: the agent keeps its context, the user keeps the steering wheel.
When should I use this?#
Use HITL when you need:
- Quality control — a human gate at high-stakes decision points
- Edge cases — graceful fallbacks when the agent's confidence is low
- Expert input — lean on the user for domain knowledge the model lacks
- Reliability — a more robust loop for real-world, production traffic
Two patterns for HITL in CopilotKit#
CopilotKit ships two complementary ways to pause an agent turn and ask the human something. They look similar from the outside (the chat pauses, a custom component appears, the user answers, the run resumes) but they're wired differently on the backend, and each has its own niche.
| Pattern | Who decides to pause? | Backend surface |
|---|---|---|
useHumanInTheLoop | The LLM, by calling a registered client-side tool | A frontend-only tool description (Zod schema + render) |
useInterrupt | The graph, by calling interrupt(...) during a node | A server-side interrupt() call in your LangGraph agent |
Pick useHumanInTheLoop when the pause is an agent-initiated
decision — the model chose to ask the user — and you want the picker UI
inlined into the normal tool-call flow.
Pick useInterrupt when the pause is a graph-enforced checkpoint —
the code path deterministically requires a human answer — and you want
langgraph.interrupt() as the server-side contract.
Pattern 1 — useHumanInTheLoop (tool-based)#
The agent registers a HITL tool on the client with useHumanInTheLoop.
When the LLM calls that tool, CopilotKit routes the call through your
render function, which shows a custom component and calls respond
with the user's answer. The agent sees the answer as the tool result and
continues from there.
import React from "react";import { CopilotKit, CopilotChat, useHumanInTheLoop, useConfigureSuggestions,} from "@copilotkit/react-core/v2";import { z } from "zod";import { TimePickerCard, TimeSlot } from "./time-picker-card";const DEFAULT_SLOTS: TimeSlot[] = [ { label: "Tomorrow 10:00 AM", iso: "2026-04-19T10:00:00-07:00" }, { label: "Tomorrow 2:00 PM", iso: "2026-04-19T14:00:00-07:00" }, { label: "Monday 9:00 AM", iso: "2026-04-21T09:00:00-07:00" }, { label: "Monday 3:30 PM", iso: "2026-04-21T15:30:00-07:00" },];export default function HitlInChatDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="hitl-in-chat"> <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> <Chat /> </div> </div> </CopilotKit> );}function Chat() { useConfigureSuggestions({ suggestions: [ { title: "Book a call with sales", message: "Please book an intro call with the sales team to discuss pricing.", }, { title: "Schedule a 1:1 with Alice", message: "Schedule a 1:1 with Alice next week to review Q2 goals.", }, ], available: "always", }); useHumanInTheLoop({ agentId: "hitl-in-chat", name: "book_call", description: "Ask the user to pick a time slot for a call. The picker UI presents fixed candidate slots; the user's choice is returned to the agent.", parameters: z.object({ topic: z .string() .describe("What the call is about (e.g. 'Intro with sales')"), attendee: z .string() .describe("Who the call is with (e.g. 'Alice from Sales')"), }), render: ({ args, status, respond }: any) => ( <TimePickerCard topic={args?.topic ?? "a call"} attendee={args?.attendee} slots={DEFAULT_SLOTS} status={status} onSubmit={(result) => respond?.(result)} /> ), });The picker UI is fed a static list of candidate slots — this is just data the demo page owns, so you can swap in real availability, a calendar API, or anything else:
import React from "react";import { CopilotKit, CopilotChat, useHumanInTheLoop, useConfigureSuggestions,} from "@copilotkit/react-core/v2";import { z } from "zod";import { TimePickerCard, TimeSlot } from "./time-picker-card";const DEFAULT_SLOTS: TimeSlot[] = [ { label: "Tomorrow 10:00 AM", iso: "2026-04-19T10:00:00-07:00" }, { label: "Tomorrow 2:00 PM", iso: "2026-04-19T14:00:00-07:00" }, { label: "Monday 9:00 AM", iso: "2026-04-21T09:00:00-07:00" }, { label: "Monday 3:30 PM", iso: "2026-04-21T15:30:00-07:00" },];Pattern 2 — useInterrupt (graph-paused)#
With LangGraph's interrupt() the pause is enforced by the graph
itself: a node calls interrupt({...}), the run suspends, the client
receives the payload, renders a UI, and resumes the run with the user's
answer. CopilotKit's useInterrupt hook is the render contract.
See the useInterrupt deep dive for
the full walkthrough, including the backend tool and render-prop wiring.
/** * LangGraph TypeScript agent for the Interrupt-based Generative UI demos. * * Defines a backend tool `schedule_meeting(topic, attendee)` that uses * langgraph's `interrupt()` primitive to pause the run and surface the * meeting context to the frontend. The frontend `useInterrupt` renderer * shows a time picker and resolves with `{chosen_time, chosen_label}` or * `{cancelled: true}`, which this tool turns into a human-readable result. * * Ported from `src/agents/interrupt_agent.py` in the langgraph-python package. */import { z } from "zod";import type { RunnableConfig } from "@langchain/core/runnables";import { tool } from "@langchain/core/tools";import { ToolNode } from "@langchain/langgraph/prebuilt";import type { AIMessage } from "@langchain/core/messages";import { SystemMessage } from "@langchain/core/messages";import { MemorySaver, START, StateGraph, Annotation, interrupt,} from "@langchain/langgraph";import { ChatOpenAI } from "@langchain/openai";import { convertActionsToDynamicStructuredTools, CopilotKitStateAnnotation,} from "@copilotkit/sdk-js/langgraph";// Demo-only fixed timezone offset (Pacific = UTC-7 in PDT, UTC-8 in PST).// A real app would use the user's calendar + locale; we hardcode Pacific so// screenshots are stable. Mirrors interrupt_agent.py.const DEMO_TZ_OFFSET_HOURS = -7; // PDTinterface TimeSlot { label: string; iso: string;}function candidateSlots(): TimeSlot[] { const now = new Date(); // "Tomorrow" const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); // "Monday" — at least 2 days away to avoid collisions with "Tomorrow" const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat let daysToMonday = (1 - dayOfWeek + 7) % 7; if (daysToMonday <= 1) daysToMonday += 7; const nextMonday = new Date(now); nextMonday.setDate(now.getDate() + daysToMonday); const fmt = (d: Date, hour: number, minute = 0): string => { // Build an ISO-like string with the demo timezone offset const sign = DEMO_TZ_OFFSET_HOURS >= 0 ? "+" : "-"; const absOffset = Math.abs(DEMO_TZ_OFFSET_HOURS); const hh = String(absOffset).padStart(2, "0"); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00${sign}${hh}:00`; }; return [ { label: "Tomorrow 10:00 AM", iso: fmt(tomorrow, 10) }, { label: "Tomorrow 2:00 PM", iso: fmt(tomorrow, 14) }, { label: "Monday 9:00 AM", iso: fmt(nextMonday, 9) }, { label: "Monday 3:30 PM", iso: fmt(nextMonday, 15, 30) }, ];}const SYSTEM_PROMPT = "You are a scheduling assistant. Whenever the user asks you to book a " + "call / schedule a meeting, you MUST call the `schedule_meeting` tool. " + "Pass a short `topic` describing the purpose and `attendee` describing " + "who the meeting is with. After the tool returns, confirm briefly " + "whether the meeting was scheduled and at what time, or that the user " + "cancelled.";const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec,});export type AgentState = typeof AgentStateAnnotation.State;const scheduleMeeting = tool( async ({ topic, attendee }: { topic: string; attendee?: string | null }) => { // langgraph's `interrupt()` pauses execution and forwards the payload to // the client. The frontend v2 `useInterrupt` hook renders the picker and // calls `resolve(...)` with the user's selection, which comes back here. const response: unknown = interrupt({ topic, attendee: attendee ?? null, slots: candidateSlots(), }); if (response && typeof response === "object") { const resp = response as Record<string, unknown>; if (resp.cancelled) { return `User cancelled. Meeting NOT scheduled: ${topic}`; } const chosenLabel = (resp.chosen_label as string | undefined) ?? (resp.chosen_time as string | undefined); if (chosenLabel) { return `Meeting scheduled for ${chosenLabel}: ${topic}`; } } return `User did not pick a time. Meeting NOT scheduled: ${topic}`; }, { name: "schedule_meeting", description: "Ask the user to pick a time slot for a call, via an in-chat picker.", schema: z.object({ topic: z .string() .describe("Short human-readable description of the call's purpose."), attendee: z .string() .nullable() .optional() .describe("Who the call is with (optional)."), }), },);const tools = [scheduleMeeting];async function chatNode(state: AgentState, config: RunnableConfig) { const model = new ChatOpenAI({ temperature: 0, model: "gpt-4o-mini" }); const modelWithTools = model.bindTools!([ ...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []), ...tools, ]); const systemMessage = new SystemMessage({ content: SYSTEM_PROMPT }); const response = await modelWithTools.invoke( [systemMessage, ...state.messages], config, ); return { messages: response };}function shouldContinue({ messages, copilotkit }: AgentState) { const lastMessage = messages[messages.length - 1] as AIMessage; if (lastMessage.tool_calls?.length) { const actions = copilotkit?.actions; const toolCallName = lastMessage.tool_calls![0].name; if (!actions || actions.every((action) => action.name !== toolCallName)) { return "tool_node"; } } return "__end__";}const workflow = new StateGraph(AgentStateAnnotation) .addNode("chat_node", chatNode) .addNode("tool_node", new ToolNode(tools)) .addEdge(START, "chat_node") .addEdge("tool_node", "chat_node") .addConditionalEdges("chat_node", shouldContinue as any);const memory = new MemorySaver();export const graph = workflow.compile({ checkpointer: memory,});Going headless#
Both patterns above ship with a render prop — CopilotKit handles the
"when to show the picker" logic for you. If you want to drive
interrupt resolution from a custom UI that lives anywhere in the tree
(not necessarily inside a chat), see the
headless interrupts guide — it shows
how to compose useAgent, agent.subscribe, and copilotkit.runAgent
to build your own useInterrupt equivalent.
