Skip to main content
Human-in-the-loop (HITL) workflows allow you to pause agent execution and wait for user approval before performing critical actions. This pattern is essential for applications where user control and confirmation are important.

Overview

Human-in-the-loop workflows in CopilotKit enable:
  • User approval before executing critical actions
  • Action review with custom UI components
  • Rejection handling to stop unwanted operations
  • Interactive confirmations with visual feedback
  • Multi-step approvals for complex operations

What You’ll Learn

This guide teaches you how to:
  • Implement renderAndWait for approval workflows
  • Set up agent interrupts with LangGraph
  • Build custom approval UI components
  • Handle approval and rejection responses
  • Create form-filling assistants with HITL
  • Test and debug HITL workflows

Example: Form Filling Assistant

The Form Filling example demonstrates a security incident report form that uses AI to fill fields through conversation, with the user maintaining full control. Form filling demo Live Demo: https://copilotkit.ai/examples/form-filling-copilot Source Code: GitHub

Prerequisites

Quick Start

1

Clone the Repository

git clone https://github.com/CopilotKit/CopilotKit.git
cd CopilotKit/examples/v1/form-filling
2

Install Dependencies

pnpm install
3

Configure API Key

Create a .env file:
NEXT_PUBLIC_COPILOT_PUBLIC_API_KEY=your_api_key
4

Start Development Server

pnpm dev
5

Open Application

Core Concepts

1. Basic HITL Pattern

The simplest human-in-the-loop implementation:
import { useHumanInTheLoop } from "@copilotkit/react";
import { z } from "zod";

useHumanInTheLoop({
  name: "performCriticalAction",
  description: "Performs a critical action that requires approval",
  parameters: z.object({
    actionData: z.object({}).describe("Data for the action"),
  }),
  render: ({ args, respond, status }) => {
    if (status === "complete") {
      return <div>Action approved!</div>;
    }

    return (
      <div className="approval-ui">
        <h3>Approve Action?</h3>
        <pre>{JSON.stringify(args.actionData, null, 2)}</pre>
        
        <div className="buttons">
          <button onClick={() => respond({ approved: true, data: args.actionData })}>
            Approve
          </button>
          <button onClick={() => respond({ approved: false })}>
            Reject
          </button>
        </div>
      </div>
    );
  },
});
Key points:
  • render displays UI and waits for user interaction
  • respond({ approved: true, data }) approves and continues with data
  • respond({ approved: false }) rejects and cancels the action
  • status tracks completion state

2. Form Filling Implementation

File: components/IncidentReportForm.tsx
import { useFrontendTool, useAgentContext } from "@copilotkit/react";
import { z } from "zod";
import { useForm } from "react-hook-form";

export function IncidentReportForm() {
  const form = useForm<FormData>();

  // Make form state readable to AI
  useAgentContext({
    description: "The security incident form fields and their current values",
    value: form.watch(),
  });

  // Action to fill the form
  useFrontendTool({
    name: "fillIncidentReportForm",
    description: "Fill out the incident report form based on user information",
    parameters: z.object({
      fullName: z.string().describe("The full name of the person reporting the incident"),
      email: z.string().describe("Email address"),
      incidentDescription: z.string().describe("Detailed description of the incident"),
      date: z.string().describe("When the incident occurred (ISO date string)"),
      incidentLevel: z.enum(["low", "medium", "high", "critical"]).describe("Severity level"),
      incidentType: z.string().describe("Type of incident"),
    }),
    execute: async (action) => {
      // Fill form fields
      form.setValue("name", action.fullName);
      form.setValue("email", action.email);
      form.setValue("description", action.incidentDescription);
      form.setValue("date", new Date(action.date));
      form.setValue("impactLevel", action.incidentLevel);
      form.setValue("incidentType", action.incidentType);
    },
  });

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* Form fields */}
    </form>
  );
}
File: app/page.tsx
import { useAgentContext } from "@copilotkit/react";

export default function Home() {
  // Provide user context to AI
  useAgentContext({
    description: "The current user information",
    value: retrieveUserInfo(), // { name: "John Doe", email: "john@example.com" }
  });

  return (
    <div>
      <IncidentReportForm />
      <CopilotSidebar />
    </div>
  );
}
How it works:
  1. User says: “I need to report a security incident from yesterday”
  2. AI reads user context (name, email) from useAgentContext
  3. AI calls fillIncidentReportForm with extracted information
  4. Form fields are automatically populated
  5. User reviews and can edit before submitting

3. Trip Approval with Custom UI

From the Travel Planner example - more complex HITL with custom approval components: File: lib/hooks/use-trips.tsx
import { useHumanInTheLoop } from "@copilotkit/react";
import { z } from "zod";
import { AddTrips, EditTrips, DeleteTrips } from "@/components/humanInTheLoop";

export function useTripsActions(state, setState) {
  // Add trips with approval
  useHumanInTheLoop({
    name: "add_trips",
    description: "Add new trips to the itinerary",
    parameters: z.object({
      trips: z.array(z.object({
        name: z.string(),
        location: z.string(),
        dates: z.string(),
      })).describe("Array of trips to add"),
    }),
    render: AddTrips,
  });

  // Update trips with approval
  useHumanInTheLoop({
    name: "update_trips",
    description: "Update existing trips",
    parameters: z.object({
      trips: z.array(z.object({
        id: z.string(),
        name: z.string(),
        location: z.string(),
        dates: z.string(),
      })).describe("Array of trips to update"),
    }),
    render: (props) =>
      EditTrips({
        ...props,
        trips: state.trips,
        selectedTripId: state.selected_trip_id,
      }),
  });

  // Delete trips with confirmation
  useHumanInTheLoop({
    name: "delete_trips",
    description: "Delete trips from the itinerary",
    parameters: z.object({
      trip_ids: z.array(z.string()).describe("IDs of trips to delete"),
    }),
    render: (props) => 
      DeleteTrips({ ...props, trips: state.trips }),
  });
}
File: components/humanInTheLoop/EditTrips.tsx
import { ToolCallStatus } from "@copilotkit/react";
import { Trip } from "@/lib/types";
import { motion } from "framer-motion";

interface EditTripsProps {
  args: { trips: Trip[] };
  respond: (result: any) => void;
  status: ToolCallStatus;
  trips: Trip[];
  selectedTripId: string;
}

export function EditTrips({ args, respond, status, trips, selectedTripId }: EditTripsProps) {
  if (status === ToolCallStatus.Complete) {
    return (
      <div className="bg-green-50 p-3 rounded-lg border border-green-200">
        <p className="text-green-800 text-sm">Trips updated successfully!</p>
      </div>
    );
  }

  const updatedTrips = args.trips || [];

  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      className="bg-white p-4 rounded-lg border shadow-md"
    >
      <h3 className="text-lg font-semibold mb-3">Review Trip Changes</h3>

      <div className="space-y-3 mb-4">
        {updatedTrips.map((updatedTrip, index) => {
          const originalTrip = trips.find(t => t.id === updatedTrip.id);
          
          return (
            <div key={index} className="border-l-4 border-blue-500 pl-3">
              <p className="font-medium">{updatedTrip.name}</p>
              
              {originalTrip && (
                <div className="text-sm text-gray-600 mt-1">
                  <div className="grid grid-cols-2 gap-2">
                    <div>
                      <span className="text-gray-400">Before:</span>
                      <p>{originalTrip.location}</p>
                      <p>{originalTrip.dates}</p>
                    </div>
                    <div>
                      <span className="text-gray-400">After:</span>
                      <p>{updatedTrip.location}</p>
                      <p>{updatedTrip.dates}</p>
                    </div>
                  </div>
                </div>
              )}
            </div>
          );
        })}
      </div>

      <div className="flex gap-2">
        <button
          className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
          onClick={() => respond({ approved: true, trips: updatedTrips })}
        >
          Approve Changes
        </button>
        <button
          className="flex-1 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition"
          onClick={() => respond({ approved: false })}
        >
          Cancel
        </button>
      </div>
    </motion.div>
  );
}

4. Agent-Side Interrupts with LangGraph

For multi-agent systems, you need to configure interrupts on the agent side: File: agent/src/agent.py
from langgraph.graph import StateGraph, END, START
from langgraph.checkpoint.memory import MemorySaver

# Build your agent graph
graph_builder = StateGraph(AgentState)

# Add nodes
graph_builder.add_node("chat_node", chat_node)
graph_builder.add_node("trips_node", trips_node)
graph_builder.add_node("perform_trips_node", perform_trips_node)

# Add edges
graph_builder.add_edge(START, "chat_node")
graph_builder.add_edge("trips_node", "perform_trips_node")
graph_builder.add_edge("perform_trips_node", "chat_node")

# Compile with interrupt configuration
graph = graph_builder.compile(
    interrupt_after=["trips_node"],  # Pause after trips_node for approval
    checkpointer=MemorySaver(),      # Required for interrupts
)
How it works:
  1. Agent executes until trips_node completes
  2. Execution pauses (interrupt)
  3. Frontend receives tool call and shows approval UI via render
  4. User approves or rejects
  5. Response sent back to agent
  6. Agent continues execution or stops based on response

Advanced Patterns

Multi-Step Approval

For complex workflows with multiple approval steps:
useHumanInTheLoop({
  name: "complexWorkflow",
  description: "A workflow with multiple approval steps",
  parameters: z.object({
    // define parameters
  }),
  render: ({ args, respond, status }) => {
    const [step, setStep] = useState(1);

    if (status === "complete") {
      return <CompletionMessage />;
    }

    if (step === 1) {
      return (
        <StepOne
          data={args}
          onApprove={(data) => setStep(2)}
          onReject={() => respond({ approved: false })}
        />
      );
    }

    if (step === 2) {
      return (
        <StepTwo
          data={args}
          onApprove={(finalData) => respond({ approved: true, data: finalData })}
          onReject={() => setStep(1)}
        />
      );
    }
  },
});

Conditional Approval

Only require approval for certain conditions:
useHumanInTheLoop({
  name: "updateData",
  description: "Update data with conditional approval",
  parameters: z.object({
    amount: z.number(),
  }),
  render: ({ args, respond, status }) => {
    const requiresApproval = args.amount > 1000;

    if (!requiresApproval) {
      // Auto-approve small amounts
      useEffect(() => {
        respond({ approved: true, data: args });
      }, []);
      return <div>Processing...</div>;
    }

    // Show approval UI for large amounts
    return (
      <ApprovalUI
        amount={args.amount}
        onApprove={() => respond({ approved: true, data: args })}
        onReject={() => respond({ approved: false })}
      />
    );
  },
});

Approval with Modifications

Allow users to modify data before approval:
useHumanInTheLoop({
  name: "createOrder",
  description: "Create an order with user review",
  parameters: z.object({
    quantity: z.number(),
  }),
  render: ({ args, respond, status }) => {
    const [editedData, setEditedData] = useState(args);

    if (status === "complete") {
      return <div>Order created!</div>;
    }

    return (
      <div>
        <h3>Review Order</h3>
        
        <input
          value={editedData.quantity}
          onChange={(e) => setEditedData({
            ...editedData,
            quantity: parseInt(e.target.value)
          })}
        />
        
        <button onClick={() => respond({ approved: true, data: editedData })}>
          Approve
        </button>
        <button onClick={() => respond({ approved: false })}>
          Cancel
        </button>
      </div>
    );
  },
});

Best Practices

1. Clear Visual Feedback

Always show clear approval/rejection states:
{status === ToolCallStatus.Complete && (
  <div className="bg-green-50 border border-green-200 p-3 rounded">
    <CheckCircle className="text-green-600" />
    <span>Approved!</span>
  </div>
)}

2. Show Context

Display all relevant information for informed decisions:
<div className="approval-card">
  <h3>Approve Trip Addition?</h3>
  
  {/* Show what's being added */}
  <div className="details">
    <p><strong>Destination:</strong> {args.location}</p>
    <p><strong>Dates:</strong> {args.startDate} - {args.endDate}</p>
    <p><strong>Budget:</strong> ${args.budget}</p>
  </div>
  
  {/* Show impact */}
  <div className="impact">
    <p>This will add 1 trip to your itinerary</p>
    <p>Total trips: {currentTrips.length + 1}</p>
  </div>
</div>

3. Handle Errors Gracefully

render: ({ args, respond, status }) => {
  const [error, setError] = useState<string | null>(null);

  const handleApproval = async () => {
    try {
      // Validate before approving
      if (!args.email.includes('@')) {
        setError('Invalid email address');
        return;
      }
      
      respond({ approved: true, data: args });
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <div>
      {error && <ErrorMessage message={error} />}
      <button onClick={handleApproval}>Approve</button>
    </div>
  );
}

4. Provide Keyboard Shortcuts

useEffect(() => {
  const handleKeyPress = (e: KeyboardEvent) => {
    if (e.key === 'Enter' && e.metaKey) {
      respond({ approved: true, data: args }); // Cmd+Enter to approve
    }
    if (e.key === 'Escape') {
      respond({ approved: false }); // Escape to reject
    }
  };

  window.addEventListener('keydown', handleKeyPress);
  return () => window.removeEventListener('keydown', handleKeyPress);
}, []);

Testing HITL Workflows

Unit Testing

import { render, screen, fireEvent } from '@testing-library/react';
import { AddTrips } from './AddTrips';

test('approves trip addition', () => {
  const respond = jest.fn();
  const args = { trips: [{ name: 'Paris', location: 'France' }] };
  
  render(<AddTrips args={args} respond={respond} status={ToolCallStatus.Executing} />);
  
  fireEvent.click(screen.getByText('Approve'));
  
  expect(respond).toHaveBeenCalledWith({ approved: true, trips: args.trips });
});

test('rejects trip addition', () => {
  const respond = jest.fn();
  
  render(<AddTrips args={args} respond={respond} status={ToolCallStatus.Executing} />);
  
  fireEvent.click(screen.getByText('Reject'));
  
  expect(respond).toHaveBeenCalledWith({ approved: false });
});

Integration Testing

Test the full flow with Playwright:
import { test, expect } from '@playwright/test';

test('form filling with approval', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  // Type message
  await page.fill('[data-testid="chat-input"]', 
    'Report an incident from yesterday');
  await page.click('[data-testid="send-button"]');
  
  // Wait for form to be filled
  await expect(page.locator('[name="description"]'))
    .not.toBeEmpty();
  
  // User can review and submit
  await page.click('[type="submit"]');
  
  await expect(page.locator('.success-message')).toBeVisible();
});

Troubleshooting

Check:
  1. You’re using useHumanInTheLoop with a render function
  2. The agent has interrupt_after configured (for LangGraph)
  3. The action name matches between frontend and agent
  4. The checkpointer is enabled in your agent
Verify:
  1. You’re calling respond({ approved: true, data }) to approve
  2. You’re calling respond({ approved: false }) to reject
  3. The respond function is being called in response to user action
  4. You’re not calling respond during render (use onClick, etc.)
Ensure:
  1. You’re checking status === ToolCallStatus.Complete to hide approval UI
  2. The component re-renders when status changes
  3. You’re not preventing re-renders with React.memo incorrectly

Next Steps

Generative UI

Build custom UI components for approvals

Multi-Agent

Implement HITL in multi-agent systems

useHumanInTheLoop

Complete API reference

LangGraph Interrupts

LangGraph documentation on interrupts