CopilotKit

Headless Interrupts

Resolve agent interrupts from any UI, without a useInterrupt render slot.


"""MS Agent Framework 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. The MS Agent Framework does NOT have that primitive, so weadapt by delegating the time-picker interaction to a **frontend tool** that theagent calls 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 only defines the system prompt and advertisesno local `schedule_meeting` implementation — the agent's tool call is satisfiedentirely by the frontend.See `src/agents/agent.py` for the related `approval_mode="always_require"`pattern used elsewhere in this package."""from __future__ import annotationsfrom textwrap import dedentfrom agent_framework import Agent, BaseChatClientfrom agent_framework_ag_ui import AgentFrameworkAgentSYSTEM_PROMPT = dedent(    """    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.    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.    Keep responses short and friendly. After you finish executing tools,    always send a brief final assistant message summarizing what happened so    the message persists.    """.strip())def create_interrupt_agent(chat_client: BaseChatClient) -> AgentFrameworkAgent:    """Instantiate the scheduling-only agent used by the interrupt-adapted demos."""    base_agent = Agent(        client=chat_client,        name="scheduling_agent",        instructions=SYSTEM_PROMPT,        # No backend tools. `schedule_meeting` is registered on the frontend        # via `useFrontendTool` and dispatched through the CopilotKit runtime.        # When the agent calls `schedule_meeting`, the request is routed to        # the frontend handler, which returns a Promise that only resolves        # once the user picks a slot — equivalent to `interrupt()` in the        # LangGraph reference.        tools=[],    )    return AgentFrameworkAgent(        agent=base_agent,        name="CopilotKitMicrosoftAgentFrameworkInterruptAgent",        description=(            "Scheduling assistant for the interrupt-adapted demos. Delegates "            "the time-picker interaction to a frontend tool."        ),        require_confirmation=False,    )

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:

page.tsx
  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 MS Agent 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 resolve callback in a ref keyed by tool-call id, so concurrent tool calls don't trample each other's resolvers.
  • setPending is called from inside the handler so the app surface re-renders the picker as soon as the agent calls the tool, and again with null after the user interacts so the picker disappears.
  • render: () => null keeps 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, with enabled gating and handler preprocessing.