CopilotKit

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: &ldquo;Delete the README; it&rsquo;s outdated.&rdquo; 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.

PatternWho decides to pause?Backend surface
useHumanInTheLoopThe LLM, by calling a registered client-side toolA frontend-only tool description (Zod schema + render)
useInterruptThe graph, by calling interrupt(...) during a nodeA 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.

page.tsx
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:

hitl-hook-and-time-slots.snippet.tsx
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.