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.
@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.
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.
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.
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.
<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'.
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.
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
| Input | Type | Default | Description |
|---|---|---|---|
agent | Agent | Required | The agent whose pending interrupt the panel renders. |
Outputs
| Output | Type | Description |
|---|---|---|
action | InterruptAction | Emits the action the user selected. The panel takes no other step. |
The InterruptAction union
type InterruptAction = 'accept' | 'edit' | 'respond' | 'ignore';| Action | Button | Typical use |
|---|---|---|
'accept' | Accept | Approve the proposed action and resume the run. |
'edit' | Edit | Open your own editor, then resume with the edited value. |
'respond' | Respond | Collect free text, then resume with it. |
'ignore' | Ignore | Reject 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
reasonfield 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.
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:
| Variable | Applied to |
|---|---|
--tplane-chat-surface | Card background |
--tplane-chat-separator | Card border and the Edit and Respond button borders |
--tplane-chat-radius-card | Card corner radius |
--tplane-chat-warning-text | The "Agent paused — review needed" eyebrow and its dot |
--tplane-chat-text | The card's text color, the payload text, and the Edit and Respond button labels |
--tplane-chat-font-size-sm | The card's base font size, inherited by the payload text |
--tplane-chat-primary | Accept button background |
--tplane-chat-on-primary | Accept button label |
--tplane-chat-radius-button | Button corner radius |
--tplane-chat-text-muted | Ignore 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 | undefinedAn AgentInterrupt carries id, the opaque value, and resumable, which is true when the runtime supports submit({ resume }).