AG-UI: Human-in-the-loop
Human-in-the-loop (HITL) pauses an agent's workflow before it performs a sensitive operation, gives the user control over the decision, and then resumes execution based on the user's choice.
This article uses an online-shopping example to demonstrate the complete flow: the agent generates a shopping list and random prices, then must receive user confirmation before simulating an order.
Complete Flow
This example does not access a real store or call a payment API.
Backend: Random Price Tool
The backend tool generates a random unit price between 1.50 and 12.00 for each item and calculates the total:
@tool
def get_item_prices(items: list[str], currency: str = "USD") -> str:
priced_items = []
total = 0.0
for item in items:
unit_price = round(random.uniform(1.50, 12.00), 2)
total += unit_price
priced_items.append({"item": item, "unit_price": unit_price, "currency": currency})
return json.dumps({"items": priced_items, "total": round(total, 2), "currency": currency})
Add it and CopilotKitMiddleware() to create_agent. The system prompt should clearly require every shopping request to call get_item_prices first and then the frontend purchase_online tool; the agent must not merely return a textual confirmation.
Frontend: Confirmation UI
import { useHumanInTheLoop } from "@copilotkit/react-core/v2";
import { z } from "zod";
useHumanInTheLoop({
agentId: "default",
name: "purchase_online",
description: "Show a shopping list and ask the user to approve it.",
parameters: z.object({
items: z.array(z.object({
item: z.string(),
unit_price: z.number(),
currency: z.string(),
})),
total: z.number(),
currency: z.string(),
}),
render: ({ args, status, respond }) => (
<PurchaseCard
items={args.items}
total={args.total}
currency={args.currency}
disabled={status !== "executing"}
onConfirm={() => respond?.({ approved: true })}
onCancel={() => respond?.({ approved: false })}
/>
),
});
respond is the key to resuming the workflow. Both the confirmation and cancellation branches must call it; otherwise, the agent run will remain waiting indefinitely.
if approval.get("approved") is not True:
return "Purchase cancelled. No order was placed."
return "Purchase confirmed. Simulated order SIM-ORDER-2026-001 was placed."
In a real project, irreversible operations such as payments, placing orders, and deleting data should happen only after user confirmation. Before confirmation, the agent can calculate prices, check inventory, and prepare the list, but it should not produce external side effects.
Runtime Configuration
CopilotKit v2's Next.js App Router uses a catch-all route so it can handle subpaths such as /info and agent runs:
src/app/api/copilotkit/[[...slug]]/route.ts
import { CopilotRuntime, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
import { LangGraphHttpAgent } from "@copilotkit/runtime/langgraph";
const runtime = new CopilotRuntime({
agents: {
default: new LangGraphHttpAgent({
url: process.env.AGENT_URL || "http://localhost:8300/agent",
}),
},
});
const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" });
export const GET = handler;
export const POST = handler;
Configure the provider with multi-route transport:
<CopilotKit runtimeUrl="/api/copilotkit" useSingleEndpoint={false}>
{children}
</CopilotKit>
If the confirmation card does not appear, check that the tool name, agentId, CopilotKitMiddleware(), and system prompt are consistent, then restart both the frontend and backend services. Do not use the deprecated EmptyAdapter or copilotRuntimeNextJSAppRouterEndpoint.