HITL Overview
Allow your agent and users to collaborate on complex tasks.
"use client";import { CopilotKitProvider, CopilotChat, useHumanInTheLoop,} from "@copilotkit/react-core/v2";import { z } from "zod";export default function HITL() { return ( <CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint> <Demo /> </CopilotKitProvider> );}function Demo() { useHumanInTheLoop({ name: "approveAction", description: "Ask the user to approve a sensitive action before running it.", parameters: z.object({ action: z.string().describe("Short name of the action to approve"), reason: z.string().describe("Why the agent wants to do this"), }), render: ApprovalCard, }); return ( <main className="p-8"> <h1 className="text-2xl font-semibold mb-4">In-Chat Human in the Loop</h1> <p className="text-sm opacity-70 mb-6"> Try: “Delete the README; it’s outdated.” The agent will ask you to approve the action inline. </p> <CopilotChat /> </main> );}// eslint-disable-next-line @typescript-eslint/no-explicit-anyfunction ApprovalCard(props: any) { const { status, args, respond, result } = props; const action = args?.action ?? "(pending)"; const reason = args?.reason ?? ""; if (status === "InProgress") { return ( <div className="border rounded p-3 my-2 opacity-70"> <div className="font-medium">Preparing approval — {action}</div> {reason ? <div className="text-sm">{reason}</div> : null} </div> ); } if (status === "Executing" && respond) { return ( <div className="border rounded p-3 my-2"> <div className="font-medium">Approve action: {action}</div> <div className="text-sm opacity-70">{reason}</div> <div className="mt-2 flex gap-2"> <button type="button" className="px-3 py-1 bg-green-600 text-white rounded" onClick={() => respond({ approved: true })} > Approve </button> <button type="button" className="px-3 py-1 bg-red-600 text-white rounded" onClick={() => respond({ approved: false })} > Reject </button> </div> </div> ); } // Complete return ( <div className="border rounded p-3 my-2 opacity-70"> <div className="font-medium">Decision recorded — {action}</div> <div className="text-sm"> {typeof result === "string" ? result : JSON.stringify(result)} </div> </div> );}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 { CopilotKitProvider, CopilotChat, useHumanInTheLoop,} from "@copilotkit/react-core/v2";import { z } from "zod";export default function HITL() { return ( <CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint> <Demo /> </CopilotKitProvider> );}function Demo() { useHumanInTheLoop({ name: "approveAction", description: "Ask the user to approve a sensitive action before running it.", parameters: z.object({ action: z.string().describe("Short name of the action to approve"), reason: z.string().describe("Why the agent wants to do this"), }), render: ApprovalCard, });import { useHumanInTheLoop } from "@copilotkit/react-core/v2";import { z } from "zod";// Stand-in for the locally-authored picker UI. In a real page, this// lives at `./time-picker-card.tsx` and exports `TimePickerCard` plus// the `TimeSlot` type.type TimeSlot = { label: string; iso: string };declare const TimePickerCard: React.ComponentType<{ topic: string; attendee?: string; slots: TimeSlot[]; status: string; onSubmit: (result: unknown) => void;}>;type BookCallRenderProps = { args?: { topic?: string; attendee?: string }; status: string; respond?: (result: unknown) => void;};const DEFAULT_SLOTS: TimeSlot[] = [ { label: "Tomorrow 10:00 AM", iso: "2026-04-30T10:00:00-07:00" }, { label: "Tomorrow 2:00 PM", iso: "2026-04-30T14:00:00-07:00" }, { label: "Monday 9:00 AM", iso: "2026-05-04T09:00:00-07:00" }, { label: "Monday 3:30 PM", iso: "2026-05-04T15:30:00-07:00" },];export function HitlBookingHook() { useHumanInTheLoop({ 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 }: BookCallRenderProps) => ( <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 { useHumanInTheLoop } from "@copilotkit/react-core/v2";import { z } from "zod";// Stand-in for the locally-authored picker UI. In a real page, this// lives at `./time-picker-card.tsx` and exports `TimePickerCard` plus// the `TimeSlot` type.type TimeSlot = { label: string; iso: string };declare const TimePickerCard: React.ComponentType<{ topic: string; attendee?: string; slots: TimeSlot[]; status: string; onSubmit: (result: unknown) => void;}>;type BookCallRenderProps = { args?: { topic?: string; attendee?: string }; status: string; respond?: (result: unknown) => void;};const DEFAULT_SLOTS: TimeSlot[] = [ { label: "Tomorrow 10:00 AM", iso: "2026-04-30T10:00:00-07:00" }, { label: "Tomorrow 2:00 PM", iso: "2026-04-30T14:00:00-07:00" }, { label: "Monday 9:00 AM", iso: "2026-05-04T09:00:00-07:00" }, { label: "Monday 3:30 PM", iso: "2026-05-04T15: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.
"use client";// Gen UI Interrupt demo (Built-in Agent port).//// The LangGraph version of this demo uses `useInterrupt` with LangGraph's// native `interrupt()` primitive — the backend pauses the run and surfaces// a payload that the frontend renders into the chat via the `useInterrupt`// hook. The built-in agent (TanStack AI) does NOT have an equivalent// interrupt primitive, so we adapt the demo by registering a frontend tool// with `useFrontendTool`. The handler returns a Promise that only resolves// once the user picks a time (or cancels), which produces the same UX: the// picker appears inline in the chat and the agent's tool call blocks until// the user decides.import React, { useRef } from "react";import { CopilotKitProvider, CopilotChat, useConfigureSuggestions, useFrontendTool,} 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-25T10:00:00-07:00" }, { label: "Tomorrow 2:00 PM", iso: "2026-04-25T14:00:00-07:00" }, { label: "Monday 9:00 AM", iso: "2026-04-28T09:00:00-07:00" }, { label: "Monday 3:30 PM", iso: "2026-04-28T15:30:00-07:00" },];type PickerResult = | { chosen_time: string; chosen_label: string } | { cancelled: true };export default function GenUiInterruptDemo() { return ( <CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint> <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> <Chat /> </div> </div> </CopilotKitProvider> );}function Chat() { // Pending-resolver ref: set by the async handler, called by the render // prop when the user clicks a slot or cancels. This is the built-in agent // adaptation of the LangGraph `resolve(...)` callback. const resolverRef = useRef<((result: PickerResult) => void) | null>(null); useConfigureSuggestions({ suggestions: [ { title: "Book a call with sales", message: "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", }); useFrontendTool({ name: "schedule_meeting", description: "Ask the user to pick a time slot for a meeting via an in-chat " + "picker. Blocks until the user chooses a slot or cancels.", parameters: z.object({ topic: z .string() .describe("Short human-readable description of the meeting."), attendee: z .string() .optional() .describe("Who the meeting is with (optional)."), }), // Async handler: returns a Promise that resolves only once the user // acts on the picker. This is the built-in agent shim for LangGraph's // `interrupt()`/`resolve()` pair. handler: async (): Promise<string> => { const result = await new Promise<PickerResult>((resolve) => { resolverRef.current = resolve; }); if ("cancelled" in result && result.cancelled) { return "User cancelled. Meeting NOT scheduled."; } if ("chosen_label" in result) { return `Meeting scheduled for ${result.chosen_label}.`; } return "User did not pick a time. Meeting NOT scheduled."; }, render: ({ args, status }) => { if (status === "complete") return null; const topic = (args as { topic?: string } | undefined)?.topic ?? "a meeting"; const attendee = (args as { attendee?: string } | undefined)?.attendee; return ( <TimePickerCard topic={topic} attendee={attendee} slots={DEFAULT_SLOTS} onSubmit={(result) => { const fn = resolverRef.current; resolverRef.current = null; fn?.(result); }} /> ); }, }); return <CopilotChat className="h-full rounded-2xl" />;}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.
