HITL Overview
Allow your agent and users to collaborate on complex tasks.
"use client";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)} /> ), }); return <CopilotChat agentId="hitl-in-chat" className="h-full rounded-2xl" />;}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 agent for the Human-in-the-Loop (Interrupt-based) booking demo.Defines a backend tool `schedule_meeting(topic, attendee)` that usesLangGraph's `interrupt()` primitive to pause the run and surface astructured booking payload to the frontend. The frontend `useInterrupt`renderer shows a time picker inline in the chat and resolves with`{chosen_time, chosen_label}` or `{cancelled: true}`, which this toolturns into a human-readable result the agent uses to confirm the booking."""from __future__ import annotationsfrom datetime import datetime, time, timedeltafrom typing import Any, List, Optionalfrom zoneinfo import ZoneInfofrom langchain.agents import create_agentfrom langchain_core.tools import toolfrom langchain_openai import ChatOpenAIfrom langgraph.types import interruptfrom copilotkit import CopilotKitMiddlewareSYSTEM_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.")# Demo-only fixed timezone. A real app would use the user's calendar +# locale (e.g. zoneinfo.ZoneInfo(user.timezone) and Google Calendar /# Outlook availability); we hardcode Pacific so screenshots are stable._DEMO_TZ = ZoneInfo("America/Los_Angeles")def _candidate_slots() -> List[dict]: """Upcoming candidate slots, relative to "now" so the picker never shows stale dates.""" now = datetime.now(_DEMO_TZ) tomorrow = (now + timedelta(days=1)).date() # Skip a week when the result would collide with `tomorrow` — i.e. # today is Mon (0 days away, picker would show two slots both # labelled "Monday") or Sun (1 day away, picker would show # "Tomorrow" and "Monday" both pointing at the same date). days_to_monday = (7 - now.weekday()) % 7 if days_to_monday <= 1: days_to_monday += 7 next_monday = (now + timedelta(days=days_to_monday)).date() candidates = [ ("Tomorrow 10:00 AM", tomorrow, time(10, 0)), ("Tomorrow 2:00 PM", tomorrow, time(14, 0)), ("Monday 9:00 AM", next_monday, time(9, 0)), ("Monday 3:30 PM", next_monday, time(15, 30)), ] return [ {"label": label, "iso": datetime.combine(d, t, _DEMO_TZ).isoformat()} for label, d, t in candidates ]@tooldef schedule_meeting(topic: str, attendee: Optional[str] = None) -> str: """Ask the user to pick a time slot for a call, via an in-chat picker. Args: topic: Short human-readable description of the call's purpose. attendee: Who the call is with (optional). Returns: Human-readable result string describing the chosen slot or indicating the user cancelled. """ # `interrupt()` pauses the LangGraph run and forwards a structured # payload to the client. The frontend v2 `useInterrupt` hook renders # the picker inline in the chat, then calls `resolve(...)` with the # user's selection — that value comes back here as `response`. response: Any = interrupt( { "topic": topic, "attendee": attendee, "slots": _candidate_slots(), } ) if isinstance(response, dict): if response.get("cancelled"): return f"User cancelled. Meeting NOT scheduled: {topic}" chosen_label = response.get("chosen_label") or response.get("chosen_time") if chosen_label: return f"Meeting scheduled for {chosen_label}: {topic}" return f"User did not pick a time. Meeting NOT scheduled: {topic}"model = ChatOpenAI(model="gpt-5.4")graph = create_agent( model=model, tools=[schedule_meeting], middleware=[CopilotKitMiddleware()], system_prompt=SYSTEM_PROMPT,)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.
