Headless Interrupts
Resolve agent interrupts from any UI, without a useInterrupt render slot.
"""LlamaIndex scheduling agent -- interrupt-adapted.This agent powers two demos (gen-ui-interrupt, interrupt-headless) that in theLangGraph showcase rely on the native ``interrupt()`` primitive withcheckpoint/resume. LlamaIndex does NOT have that primitive, so we adapt bydelegating the time-picker interaction to a **frontend tool** that the agentcalls by name (``schedule_meeting``). The frontend registers the tool via``useFrontendTool`` with an async handler; that handler renders the interactivepicker, waits for the user to choose a slot (or cancel), and resolves the toolcall with the result.The backend provides a stub ``schedule_meeting`` tool so the LlamaIndexAGUIChatWorkflow emits the proper AG-UI TOOL_CALL_CHUNK events. Actualexecution happens on the frontend; the stub is never invoked becauseCopilotKit intercepts the tool call before the backend can process the result.See ``src/agents/hitl_in_chat_agent.py`` for the related ``book_call`` patternused by the HITL-in-chat demos in this package."""from __future__ import annotationsimport osfrom llama_index.core.tools import FunctionToolfrom llama_index.llms.openai import OpenAIfrom llama_index.protocols.ag_ui.router import get_ag_ui_workflow_routerfrom agents.hitl_in_chat_agent import FixedAGUIChatWorkflow_openai_kwargs = {}if os.environ.get("OPENAI_BASE_URL"): _openai_kwargs["api_base"] = os.environ["OPENAI_BASE_URL"]SYSTEM_PROMPT = ( "You are a scheduling assistant. Whenever the user asks you to book a call " "or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a " "short `topic` describing the purpose of the meeting and, if known, an " "`attendee` describing who the meeting is with.\n\n" "The `schedule_meeting` tool is implemented on the client: it surfaces a " "time-picker UI to the user and returns the user's selection. After the " "tool returns, briefly confirm whether the meeting was scheduled and at " "what time, or note that the user cancelled. Do NOT ask for approval " "yourself -- always call the tool and let the picker handle the decision.\n\n" "Keep responses short and friendly. After you finish executing tools, " "always send a brief final assistant message summarizing what happened so " "the message persists.")def _schedule_meeting_stub(topic: str, attendee: str = "") -> str: """Ask the user to pick a time slot for a meeting. The picker UI presents fixed candidate slots; the user's choice is returned to the agent. """ # Frontend-only tool -- CopilotKit intercepts the call and renders the # TimePickerCard. This stub satisfies the AGUIChatWorkflow tool registry # so the proper AG-UI events are emitted. return ""_schedule_meeting_tool = FunctionTool.from_defaults( fn=_schedule_meeting_stub, name="schedule_meeting", description=( "Ask the user to pick a time slot for a meeting. Pass a short " "`topic` and optional `attendee`. The picker UI presents fixed " "candidate slots; the user's choice is returned to the agent." ),)async def _workflow_factory(): return FixedAGUIChatWorkflow( llm=OpenAI(model="gpt-4o-mini", **_openai_kwargs), frontend_tools=[_schedule_meeting_tool], backend_tools=[], system_prompt=SYSTEM_PROMPT, initial_state={}, )interrupt_router = get_ag_ui_workflow_router( workflow_factory=_workflow_factory,)What is this?#
useInterrupt's render callback is the 80% path: it keeps the UI
glued to a <CopilotChat> transcript and handles "when to show the
picker" logic for you. This page covers the escape hatch: a
render-less interrupt resolver you assemble from the same
primitives useInterrupt uses internally — a pattern that lives
anywhere in your React tree, takes any shape you like (button grid,
form, modal, keyboard shortcut), and resolves the interrupt without
mounting a chat at all.
On Microsoft Agent Framework there's no native interrupt primitive,
so the headless variant uses useFrontendTool with a Promise-based
handler. The handler exposes its pending payload via React state — so
a separate "app surface" can render the picker outside the chat — and
resolves the Promise once the user interacts. Same UX, different
mechanism.
When should I use this?#
- Testing / Playwright fixtures — a deterministic, chat-less button grid is easier to drive than a chat surface where the picker only appears after an LLM call.
- Non-chat UIs — dashboards, side panels, inspector surfaces, or any place where you want the agent's interrupt without the chat transcript.
- Custom flow control — when you need to know exactly when the interrupt arrived (e.g. to gate other UI) and when it was resolved.
- Research / debugging — when you want to observe the raw AG-UI custom events without the abstraction layer.
If you just want "a picker in chat", just use
useInterrupt.
The primitives#
The render callback intentionally returns null — the picker UI lives
in the app surface, not in the chat transcript. The handler's pending
state drives whether the picker is shown:
useFrontendTool({ name: "schedule_meeting", description: "Ask the user to pick a time slot for a meeting via a picker popup " + "that appears outside the chat. 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: sets the pending payload so the popup renders, then // returns a Promise that only resolves once the user interacts with the // popup. This is the LlamaIndex shim for the LangGraph headless // interrupt `resume` flow. handler: async ({ topic, attendee, }: { topic: string; attendee?: string; }): Promise<string> => { setPending({ topic, attendee }); const result = await new Promise<PickerResult>((resolve) => { resolverRef.current = resolve; }); setPending(null); 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 nothing inside the chat — the UI lives in the app surface. render: () => null, });A few things this pattern is careful about:
- The handler stages its
resolvecallback in a ref keyed by tool-call id, so concurrent tool calls don't trample each other's resolvers. setPendingis called from inside the handler so the app surface re-renders the picker as soon as the agent calls the tool, and again withnullafter the user interacts so the picker disappears.render: () => nullkeeps the chat transcript clean — the headless variant deliberately bypasses inline rendering.
Going further#
- Tool-based HITL with
useHumanInTheLoop— for LLM-initiated pauses where the model decides on the fly to ask the user, rather than the runtime forcing the pause itself. useInterrupt— the render-prop version of this page, withenabledgating andhandlerpreprocessing.
