Page actions

ChatInterruptPanelComponent

ChatInterruptPanelComponent is a composition that renders an inline card whenever the agent is waiting on a human decision. It reads the pending interrupt off the agent, prints a summary of the payload, and offers four buttons: Accept, Edit, Respond, and Ignore. It emits which one was clicked and nothing else, so the resume payload stays yours to choose. The running example is a flight booking that pauses before it completes, and this page walks the four files behind it.

Selector: chat-interrupt-panel

What the demo does

The Run tab shows the prebuilt <chat> composition beside a sidebar holding the panel and the agent status. Two welcome suggestions set up the same pause on two different paths. "Book a flight (you confirm)" asks the agent to book UA123, and "Book a flight (you cancel)" asks it to book AA404.

Either request reaches the book_flight tool, which stops mid-call. The panel appears in the sidebar with the booking payload and the four buttons.

Accept resumes the run with confirm, the tool returns a booking confirmation, and the agent relays it in the transcript. Ignore resumes with cancel, and the tool returns "Booking cancelled." instead. Edit and Respond render, because the panel always renders all four, but this example leaves them unhandled: a single-decision booking has nothing to edit.

How it is built

Four files carry the feature: a Python tool that pauses, the graph that runs it, an application config that registers the agent, and an Angular component that places the panel and maps its buttons onto resume payloads. Open the Code tab to read them in place.

The tool that pauses

interrupt() freezes the run and streams its argument to the client, and that argument is exactly what the panel renders. Here the tool looks the flight up first, builds a one-line summary, and interrupts with a dictionary carrying the summary and the flight record. When the client resumes, the same interrupt() call returns the resume value, so the decision is read as an ordinary return value on the next line.

graph.py — the booking tool
@tool
async def book_flight(flight_number: str) -> str:
    """Book a flight by flight number. Pauses for human confirmation.
 
    Use this tool when the user explicitly asks to book a specific flight.
    The tool raises a LangGraph interrupt so the UI can render an
    approval card; on resume, it returns a confirmation or cancellation
    message.
 
    Args:
        flight_number: Flight number like 'AA123' or 'UA456'.
 
    Returns:
        A short booking confirmation or cancellation message.
    """
    flight = await lookup_flight.ainvoke({"flight_number": flight_number})
    if "error" in flight:
        return f"Cannot book {flight_number}: {flight['error']}."
 
    summary = (
        f"Book {flight['airline']} {flight['flight_number']} from "
        f"{flight['from']} to {flight['to']} "
        f"(departs {flight['depart_local']}, {flight['aircraft']})?"
    )
    response = interrupt({
        "type": "approval_request",
        "summary": summary,
        "flight": flight,
    })
 
    decision = str(response).strip().lower()
    if decision.startswith("confirm"):
        return (
            f"Booked {flight['airline']} {flight['flight_number']} from "
            f"{flight['from']} to {flight['to']} "
            f"(departs {flight['depart_local']})."
        )
    return "Booking cancelled."

The tool accepts any resume value that starts with confirm and treats everything else as a cancellation, which is why the client can answer with a bare string.

Warning: A resumed tool runs again from the top

LangGraph resumes by running the interrupting task again, and interrupt() returns the resume value rather than pausing a second time. The flight lookup above therefore runs twice. Keep that stretch free of side effects: reading data is safe, charging a card there is not.

The graph around it

The graph is an ordinary agent and tool loop. book_flight is registered next to the read-only aviation tools, and nothing in the wiring is interrupt-specific, because the pause happens inside the tool rather than in the topology.

graph.py — the agent and tool loop
def build_interrupts_graph():
    """Agent ↔ ToolNode loop with aviation read tools + book_flight (interrupt)."""
    tools = [book_flight, find_routes, lookup_flight, get_airport_info]
    llm = ChatOpenAI(model=MODEL, streaming=True).bind_tools(tools)
 
    async def agent(state: MessagesState) -> dict:
        system_prompt = (PROMPTS_DIR / "interrupts.md").read_text()
        messages = [SystemMessage(content=system_prompt)] + state["messages"]
        response = await llm.ainvoke(messages)
        return {"messages": [response]}
 
    def should_continue(state: MessagesState) -> str:
        last = state["messages"][-1]
        if hasattr(last, "tool_calls") and last.tool_calls:
            return "tools"
        return END
 
    graph = StateGraph(MessagesState)
    graph.add_node("agent", agent)
    graph.add_node("tools", ToolNode(tools))
    graph.add_node("generate_title", generate_title)
    graph.set_entry_point("agent")
    graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: "generate_title"})
    graph.add_edge("tools", "agent")
    graph.add_edge("generate_title", END)
    return graph.compile()

compile() is called with no checkpointer here, because this graph is served by the LangGraph API server, which supplies persistence itself.

The agent provider

provideAgent() registers the agent once for the whole application, and it is the only provider the <chat> composition requires. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo.

app.config.ts
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent(() => {
      const connection = injectCockpitRuntimeConnection();
      if (connection.adapter !== 'langgraph') {
        throw new Error('incompatible runtime');
      }
      return {
        apiUrl: connection.apiUrl,
        assistantId: connection.assistantId,
        clientOptions: connection.clientOptions,
      };
    }),
  ],
};

Your own application does not need the factory. Pass the values directly:

provideAgent({
  apiUrl: 'https://your-deployment.langgraph.app',
  assistantId: 'c-interrupts',
});

assistantId must match the graph name in langgraph.json.

The panel in the sidebar

The panel takes one input, the agent, and renders nothing at all while no interrupt is pending, so it is safe to leave mounted permanently. This demo puts it in the sidebar next to the agent status rather than over the transcript. There is no interrupted status: a pending interrupt is a separate signal, and the panel is what reads it.

interrupts.component.ts — the sidebar
<div sidebar class="panel">
  <h3 class="cap">Interrupt Panel</h3>
  <chat-interrupt-panel [agent]="agent" (action)="onInterruptAction($event)" />
  <div>
    <h4 class="cap">Stream Status</h4>
    <p class="metric-value">{{ streamStatus() }}</p>
  </div>
</div>

The status line beside it reads agent.status(), which is idle, running, or error.

Resuming with the decision

The panel emits an InterruptAction, and the component turns the two actions it cares about into resume payloads. The graph expects a string, so accept submits 'confirm' and ignore submits 'cancel'.

interrupts.component.ts — resuming the run
protected onInterruptAction(action: InterruptAction): void {
  if (action === 'accept') {
    this.agent.submit({ resume: 'confirm' });
  } else if (action === 'ignore') {
    this.agent.submit({ resume: 'cancel' });
  }
  // 'edit' and 'respond' are intentionally unhandled for the booking flow.
}

submit({ resume }) continues the paused run instead of starting a new one, and whatever you put in resume is exactly what interrupt() returns on the server.

Note: The panel never resumes for you

chat-interrupt-panel emits the action and stops there. Leaving an action unhandled, as this example leaves Edit and Respond, means the run stays paused and the panel stays on screen. All four buttons always render; a wrapper does not hide individual actions. For a two-button UI, build custom controls with the lower level primitive below.

A complete panel component

This complete standalone Angular component uses the LangGraph provider configured above. The output binding is (action). Accept and Ignore submit the flight tool's strings; Edit and Respond display an explanation and leave the run paused. The component hides the panel while submitting and displays rejected operations and agent errors.

import { Component, signal } from '@angular/core';
import { ChatInterruptPanelComponent, type InterruptAction } from '@threadplane/chat';
import { injectAgent } from '@threadplane/langgraph';
 
@Component({
  selector: 'app-flight-decision',
  standalone: true,
  imports: [ChatInterruptPanelComponent],
  template: `
    @if (busy() || agent.isLoading()) {
      <p role="status">Working…</p>
    } @else {
      <chat-interrupt-panel [agent]="agent" (action)="onAction($event)" />
    }
    @if (notice()) { <p>{{ notice() }}</p> }
    @if (error() || agent.error()?.message; as message) {
      <p role="alert">{{ message }}</p>
    }
  `,
})
export class FlightDecisionComponent {
  readonly agent = injectAgent();
  readonly busy = signal(false);
  readonly error = signal('');
  readonly notice = signal('');
 
  async onAction(action: InterruptAction): Promise<void> {
    if (this.busy() || this.agent.isLoading()) return;
    this.notice.set('');
    if (action === 'edit' || action === 'respond') {
      this.notice.set('This example supports Accept and Ignore only.');
      return;
    }
    this.busy.set(true);
    this.error.set('');
    try {
      if (action === 'accept') {
        await this.agent.submit({ resume: 'confirm' });
      } else if (action === 'ignore') {
        await this.agent.submit({ resume: 'cancel' });
      }
    } catch (error) {
      this.error.set(error instanceof Error ? error.message : 'Decision failed');
    } finally {
      this.busy.set(false);
    }
  }
}

confirm and cancel belong to this flight tool's backend contract. A refund or Mastra tool may instead expect { approved: true } or { approved: false }; the button label does not define the resume payload. A protocol-native AG-UI cancelled entry has no payload and must identify the pending interrupt.

The panel accepts the runtime-neutral Agent, but it does not coordinate batches, retain a rendered generation, hydrate storage, or reconcile uncertain outcomes. Applications using AG-UI's interruptSession(), interruptGeneration, ready, or reconcileInterrupt() need adapter-aware controls. LangGraph restores pending interrupts from its server checkpoint. In either adapter, disappearance of the panel alone is not evidence that the backend completed the decision.

Import

import { ChatInterruptPanelComponent } from '@threadplane/chat';
import type { InterruptAction } from '@threadplane/chat';

The component is standalone, so add it to a component's imports array.

Inputs

InputTypeDefaultDescription
agentAgentRequiredThe agent whose pending interrupt the panel renders.

Outputs

OutputTypeDescription
actionInterruptActionEmits the action the user selected. The panel takes no other step.

The InterruptAction union

type InterruptAction = 'accept' | 'edit' | 'respond' | 'ignore';
ActionButtonTypical use
'accept'AcceptApprove the proposed action and resume the run.
'edit'EditOpen your own editor, then resume with the edited value.
'respond'RespondCollect free text, then resume with it.
'ignore'IgnoreReject or dismiss. The run stays paused until you resume it.

All four buttons always render. The union is the whole of the component's contract with your code.

How the payload is rendered

The panel derives one string from the interrupt value and prints it, preserving line breaks:

  • An object with a string reason field renders that field alone.
  • A string value renders directly.
  • Anything else is rendered as indented JSON.

The example takes the third path, since its payload is a dictionary of type, summary, and flight with no reason field, so the card shows the raw booking record.

Tip: Add a reason field for prose

Interrupt with a reason string alongside your structured fields and the panel shows the sentence rather than the JSON dump, with no change on the Angular side.

Theme variables

The panel reads these from the chat theme:

VariableApplied to
--tplane-chat-surfaceCard background
--tplane-chat-separatorCard border and the Edit and Respond button borders
--tplane-chat-radius-cardCard corner radius
--tplane-chat-warning-textThe "Agent paused — review needed" eyebrow and its dot
--tplane-chat-textThe card's text color, the payload text, and the Edit and Respond button labels
--tplane-chat-font-size-smThe card's base font size, inherited by the payload text
--tplane-chat-primaryAccept button background
--tplane-chat-on-primaryAccept button label
--tplane-chat-radius-buttonButton corner radius
--tplane-chat-text-mutedIgnore button label

The card carries role="alert", so a screen reader announces the pause when it appears.

The lower level primitive

When the four buttons are the wrong set, ChatInterruptComponent gives you the pause detection without the chrome. It renders its own heading and projects an ng-template whose implicit value is the pending interrupt, so the body and every control are yours.

<chat-interrupt [agent]="agent">
  <ng-template let-interrupt>
    <p>{{ summaryOf(interrupt) }}</p>
    <button (click)="confirm()">Confirm</button>
  </ng-template>
</chat-interrupt>

value is typed unknown, so narrow it in the component rather than in the template:

summaryOf(interrupt: AgentInterrupt): string {
  const value = interrupt.value as { summary?: string };
  return value.summary ?? '';
}

The primitive's module exports the same read as a standalone function:

import { getInterrupt } from '@threadplane/chat';
 
const pending = getInterrupt(agent); // AgentInterrupt | undefined

An AgentInterrupt carries id, the opaque value, and resumable, which is true when the runtime supports submit({ resume }).

What's Next

Looking for something specific?