Headless Interrupts
Resolve agent interrupts from any UI, without a useInterrupt render slot.
Open your coding agent in your project's folder, or in an empty folder for a new app.This runs in a coding agent on your computer.
"""The agent behind gen-ui-interrupt and interrupt-headless.``schedule_meeting`` is marked ``needs_approval``, so the SDK pauses the runbefore the tool executes and the adapter ends the AG-UI run with an interruptfor the call, its arguments on ``metadata.tool_input``. The page renders thetime picker from them and resolves the interrupt with ``{"approved": true,"chosen_time": ..., "chosen_label": ...}``, or ``{"approved": false}`` whenthe user cancels. The next request carries that answer as a ``resume`` entry,and the adapter runs an approved call before the model sees the conversationagain.Nothing is kept on the server between the two runs: the paused call lives inthe conversation the page holds."""from collections.abc import AsyncIteratorfrom contextlib import aclosingfrom contextvars import ContextVarfrom typing import Anyfrom ag_ui.core import BaseEvent, RunAgentInputfrom ag_ui_openai_agents import OpenAIAgentsAgentfrom agents import Agent, function_toolfrom agents.tool_context import ToolContextfrom demo_agents._common import MODEL, chat_settingsSYSTEM_PROMPT = """You are a scheduling assistant.Whenever the user asks you to book a call or schedule a meeting, you MUST callthe `schedule_meeting` tool. Pass a short `topic` describing the purpose and, ifknown, an `attendee` describing who the meeting is with.The tool shows the user a time picker and returns their choice. Then brieflyconfirm whether the meeting was scheduled and at what time, or note that theuser cancelled. Do not ask for approval yourself: always call the tool and letthe picker handle the decision. Keep responses short and friendly.Never claim a meeting is scheduled unless the tool result says so."""# This request's interrupt answers, by tool call id (see SchedulerAgent)._answers: ContextVar[dict[str, dict[str, Any]]] = ContextVar("meeting_answers")@function_tool(needs_approval=True)def schedule_meeting(ctx: ToolContext[Any], topic: str, attendee: str = "") -> str: """Ask the user to pick a meeting time, then confirm what was scheduled. Args: topic: Short description of the meeting purpose. attendee: Who the meeting is with, if known. """ # Runs once the user approved, with the time they picked beside it. answer = _answers.get({}).get(ctx.tool_call_id, {}) label = answer.get("chosen_label") or answer.get("chosen_time") if not label: return f"User did not pick a time. Meeting NOT scheduled: {topic}" return f"Meeting scheduled for {label}: {topic}"interrupt_agent = Agent( name="scheduler", instructions=SYSTEM_PROMPT, model=MODEL, model_settings=chat_settings(parallel_tool_calls=False), tools=[schedule_meeting],)class SchedulerAgent(OpenAIAgentsAgent): """Lets ``schedule_meeting`` read the time the user picked. The adapter runs an approved call with its arguments alone: an Agents SDK approval is a yes or a no, with nothing to hand the tool. The picker sends the time beside the approval, so this keeps each answer where the tool can read it. """ async def run_streamed(self, input: RunAgentInput) -> AsyncIterator[BaseEvent]: _answers.set( { entry.interrupt_id: entry.payload for entry in input.resume or [] if isinstance(entry.payload, dict) } ) async with aclosing(super().run_streamed(input)) as events: async for event in events: yield eventWhat 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.
Not available on this framework. Headless interrupts are built on top of
useInterrupt/useFrontendToolpatterns that require the runtime to expose either a nativeinterrupt(...)primitive (LangGraph) or a Promise-resolving frontend-tool path. For all other integrations, useuseHumanInTheLoopinstead — it's the standard hook for tool-call-based pause/resume flows and works on every framework that supports tool calls.
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.
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.