CopilotKit

Headless Interrupts

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


using System.Text.Json;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using OpenAI;using System.ClientModel;// =================// Interrupt Agent Factory// =================//// Adaptation note: the Microsoft Agent Framework (.NET) does not have a// LangGraph-equivalent `interrupt()` primitive that can pause execution// mid-tool and resume with a caller-supplied value. The scheduling demos// use a frontend-provided `schedule_meeting` tool; AG-UI forwards that tool// definition to the model, then the client renders the picker and resolves// the tool call with the user's selected slot.//// This factory reuses the existing SharedStateAgent pattern for// consistency with the rest of the showcase, even though state-sync isn't// the primary concern for interrupt demos. The agent's system prompt// instructs it to always call `schedule_meeting` whenever the user asks// to book a call or schedule a meeting.public sealed class InterruptAgentFactory{    private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";    private readonly IConfiguration _configuration;    private readonly OpenAIClient _openAiClient;    private readonly ILogger _logger;    private readonly ILoggerFactory _loggerFactory;    private readonly JsonSerializerOptions _jsonSerializerOptions;    public InterruptAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory, JsonSerializerOptions jsonSerializerOptions)    {        _configuration = configuration;        _loggerFactory = loggerFactory;        _logger = loggerFactory.CreateLogger<InterruptAgentFactory>();        _jsonSerializerOptions = jsonSerializerOptions;        var githubToken = _configuration["GitHubToken"]            ?? throw new InvalidOperationException(                "GitHubToken not found in configuration. " +                "Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +                "or get it using: gh auth token");        var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");        var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;        _logger.LogInformation(            "InterruptAgentFactory using OpenAI endpoint: {Endpoint} (from OPENAI_BASE_URL: {HasEnv})",            endpoint,            !string.IsNullOrEmpty(endpointEnv));        _openAiClient = new(            new ApiKeyCredential(githubToken),            AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));    }    public AIAgent CreateInterruptAgent()    {        var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();        // No backend fallback tool is registered. If the frontend tool is        // missing, the demo should fail visibly instead of bypassing the        // picker with a server-side response.        var chatClientAgent = new ChatClientAgent(            chatClient,            name: "InterruptAgent",            description: @"You are a scheduling assistant. Whenever the user asks you to book a callor schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a short `topic`describing the purpose and `attendee` describing who the meeting is with. After the toolreturns, confirm briefly whether the meeting was scheduled and at what time, or that theuser cancelled.",            tools: []);        return new SharedStateAgent(chatClientAgent, _jsonSerializerOptions, _loggerFactory.CreateLogger<SharedStateAgent>());    }}

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.