TUTORIAL · June 4, 2026 · 8 min read

Human-in-the-Loop AG-UI Agents in Angular

Build a human-in-the-loop AG-UI agent in Angular — the same chat-approval-card composition from the LangGraph version, wired to an AG-UI-fronted LangGraph backend via @threadplane/ag-ui.

Brian Love · Founder, Threadplane

This is how to pause an AG-UI agent in Angular for human approval before it runs a high-stakes tool, using a CUSTOM on_interrupt event and the <chat-approval-card> composition from @threadplane/chat. The example is the same refund agent from Human-in-the-Loop LangGraph Agents in Angular — wired through the AG-UI adapter instead. The Angular component is byte-identical except the import.

Everything below is running code from the cockpit example at cockpit/ag-ui/interrupts. Clone the repo, run nx serve cockpit-ag-ui-interrupts-angular, and follow along.

#Goals

  • Wire the same refund-approval gate over the AG-UI protocol instead of the LangGraph SDK.
  • See how a single CUSTOM event named on_interrupt becomes agent.interrupt() in Angular.
  • Swap adapters without touching the component — the parity proof.

#The parity proof

The Angular component file is byte-identical to the LangGraph version from the precedent post except for the injectAgent import:

- import { injectAgent } from '@threadplane/langgraph';
+ import { injectAgent } from '@threadplane/ag-ui';

The app.config.ts adapter swap is one line in the providers array:

- provideAgent({ apiUrl: '/api', assistantId: 'interrupts' }),
+ provideAgent({ url: '/agent' }),

That's the whole client-side delta. The rest of the file — the template binding <chat-approval-card>, the (action) handler, the approve / edit / cancel branches, the submit({ resume }) call — is unchanged.

<chat-approval-card> reads agent.interrupt() (a Signal<AgentInterrupt | undefined>), and submit({ resume }) is part of the runtime-neutral Agent contract declared in @threadplane/chat. Both adapters populate the signal and forward the resume; the chat surface above doesn't see the wire format.

#When to use an interrupt

Most tool calls don't need approval. Reads, searches, and lookups can run unattended. Reach for an interrupt when a tool does something the operator wouldn't want to undo by hand: moves money, sends a customer-facing message, deletes a record, or triggers a deploy.

Two practical reasons hold up: it caps the cost of a misfiring agent looping over a write API, and it gives the operator a checkpoint to catch a wrong action before it lands.

The AG-UI angle adds a third. Because AG-UI normalizes the wire across many backends, the same operator-approval checkpoint plugs into LangGraph, CrewAI, Mastra, Pydantic AI, or anything else that speaks AG-UI — with no UI rewrite. If your shop runs more than one agent backend, that's a real benefit. The <chat-approval-card> you build today survives the backend you swap in next year.

#The architecture

Three pieces:

  • AG-UI-fronted LangGraph backend. The same compiled graph as the LangGraph post, duplicated under cockpit/ag-ui/interrupts/python because cockpit examples are standalone. Wrapped with ag-ui-langgraph's LangGraphAgent(name, graph) + add_langgraph_fastapi_endpoint(app, agent, path='/agent') and served by uvicorn. When interrupt() fires inside the graph, ag-ui-langgraph emits a CUSTOM AG-UI event with name: 'on_interrupt' and a value carrying the interrupt payload — serialized as a JSON string via dump_json_safe. The uvicorn server needs a MemorySaver checkpointer (langgraph dev and LangGraph Platform inject one automatically; plain uvicorn does not, and aget_state raises "No checkpointer set" without it).
  • @threadplane/ag-ui adapter. The reducer recognizes the CUSTOM/on_interrupt event, JSON-parses the string value so consumers see the structured object, and sets agent.interrupt() to { id, value, resumable: true }. agent.submit({ resume }) short-circuits the message-append path and calls source.runAgent({ forwardedProps: { command: { resume } } }). The server reads forwarded_props.command.resume. The adapter exposes the same Agent contract as @threadplane/langgraph.
  • @threadplane/chat UI. <chat-approval-card matchKind="refund_approval"> reads agent.interrupt(), opens a <dialog>-backed modal, and emits 'approve' | 'edit' | 'cancel'. Resume actions call agent.submit({ resume: { approved, amount? } }). Reject calls submit({ resume: { approved: false } }). The component doesn't know which adapter is wired. That's the point.

The data flow on resume:

<chat-approval-card> (action: approve)
  → agent.submit({ resume: { approved: true, amount: 99.00 } })
    → source.runAgent({ forwardedProps: { command: { resume: { approved, amount } } } })
      → POST /agent (body carries forwarded_props.command.resume)
        → ag-ui-langgraph: Command(resume=value) → graph continues

On the LangGraph adapter, the same submit({ resume }) becomes a native Client.submit(thread, command={resume:…}) call. Different wire, same Angular surface.

The cockpit ag-ui/interrupts welcome screen showing two suggestion chips: 'Refund a duplicate charge' and 'Refund a chargeback.'
The cockpit refund example on the AG-UI adapter.

#Scaffold

Four steps.

1
The LangGraph node

A structured-output call populates the fields the approval card displays. Then request_approval pauses with interrupt():

# graph.py — cockpit/ag-ui/interrupts/python/src/graph.py
class RefundDraft(BaseModel):
    """Structured fields the agent extracts from the refund request."""
 
    customer_id: str = Field(description="The customer identifier, e.g. cus_a8x2k. Use 'unknown' if not stated.")
    amount: float = Field(description="The refund amount in USD. Use 0 if not stated.")
    reason: str = Field(description="One sentence describing why the refund is justified.")
 
llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
extractor = ChatOpenAI(model="gpt-5-mini").with_structured_output(RefundDraft)
 
async def draft_refund(state: RefundState) -> dict:
    draft = await extractor.ainvoke(
        [
            SystemMessage(content="Extract the refund fields from the conversation."),
            *state["messages"],
        ]
    )
    response = await llm.ainvoke([SystemMessage(content=system_prompt)] + state["messages"])
    return {
        "messages": [response],
        "customer_id": draft.customer_id,
        "amount": draft.amount,
        "reason": draft.reason,
    }
 
def request_approval(state: RefundState) -> dict:
    """Pause for human approval. Resume value is { approved: bool, amount?: number }."""
    amount = state.get("amount") or 0.0
    customer_id = state.get("customer_id") or "unknown"
    reason = state.get("reason") or ""
 
    decision = interrupt({
        "kind": "refund_approval",
        "amount": amount,
        "customer_id": customer_id,
        "reason": reason,
    })
 
    if not isinstance(decision, dict) or not decision.get("approved"):
        return {
            "decision_approved": False,
            "messages": [AIMessage(content="Refund cancelled by operator. No charge issued.")],
        }
 
    edited_amount = decision.get("amount")
    final_amount = float(edited_amount) if edited_amount is not None else amount
    return {
        "decision_approved": True,
        "amount": final_amount,
    }

This file is duplicated into cockpit/ag-ui/interrupts/python/src/graph.py per the cockpit standalone-examples convention — copy, don't import across examples. The graph itself doesn't know it'll be served over AG-UI.

2
Wrap the graph with ag-ui-langgraph + uvicorn

ag-ui-langgraph translates LangGraph runtime events into AG-UI protocol events and mounts a FastAPI endpoint. We add a /ok route for the e2e harness's readiness check:

# server.py — cockpit/ag-ui/interrupts/python/src/server.py
# SPDX-License-Identifier: MIT
from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph
 
agent = LangGraphAgent(name="interrupts", graph=graph)
app = FastAPI(title="cockpit-ag-ui-interrupts")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")
 
 
@app.get("/ok")
def ok() -> dict:
    return {"ok": True}

Run with uv run uvicorn src.server:app --port 5320.

Two details worth knowing:

  • MemorySaver is mandatory here. ag-ui-langgraph calls graph.aget_state(config) to read the post-stream interrupt state. langgraph dev and LangGraph Platform inject a checkpointer; plain uvicorn does not. Without one, aget_state raises "No checkpointer set" and the run never surfaces the interrupt.
  • The dump_json_safe quirk. When interrupt({…}) fires, ag-ui-langgraph serializes value to a JSON string before placing it on the wire so arbitrary Python objects survive JSON-encoding. The @threadplane/ag-ui reducer parses it back to an object for you, so the Angular side never sees the string.
3
Wire the adapter in app.config.ts
// app.config.ts — cockpit/ag-ui/interrupts/angular/src/app/app.config.ts
// SPDX-License-Identifier: MIT
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';
import { provideChat } from '@threadplane/chat';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent({ url: '/agent' }),
    provideChat({}),
  ],
};

The Angular dev server proxies /agent to the uvicorn port from cockpit/ports.mjs:

// proxy.conf.mjs — cockpit/ag-ui/interrupts/angular/proxy.conf.mjs
import { portsFor } from '../../../../cockpit/ports.mjs';
const { langgraph: backend } = portsFor('cockpit-ag-ui-interrupts-angular');
export default {
  '/agent': { target: `http://localhost:${backend}`, secure: false, changeOrigin: true, ws: true },
};

AG-UI's provideAgent({ url }) and LangGraph's provideAgent({ apiUrl, assistantId }) share the symmetric provider name but take different config shapes because the wire protocols differ. The provider+inject names are deliberately symmetric across both adapters — that's what makes the component code unchanged.

4
The Angular component
// interrupts.component.ts — cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts
// SPDX-License-Identifier: MIT
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { ChatComponent, ChatApprovalCardComponent, ChatWelcomeSuggestionComponent, type ChatApprovalAction } from '@threadplane/chat';
import { injectAgent } from '@threadplane/ag-ui';
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
import { CurrencyPipe } from '@angular/common';
 
const WELCOME_SUGGESTIONS = [
  { label: 'Refund a duplicate charge', value: 'Refund $47.50 to customer cus_a8x2k — they were charged twice for the same order.' },
  { label: 'Refund a chargeback', value: 'Refund $129.00 to customer cus_z19fp who opened a chargeback for unrecognized activity.' },
] as const;
 
/**
 * Refund authorization cockpit example.
 *
 * The LangGraph backend acknowledges the refund draft, then pauses at
 * `request_approval` with a structured interrupt payload of the form
 * `{ kind: 'refund_approval', amount, customer_id, reason }`.
 *
 * The frontend uses `ChatApprovalCardComponent` to render the native-dialog
 * modal and emit a `ChatApprovalAction` ('approve' | 'edit' | 'cancel').
 * The handler maps each action to a structured resume payload back to the
 * graph.
 *
 * The agent is wired in `app.config.ts` via `provideAgent({...})` and
 * retrieved here with `injectAgent()`.
 */
@Component({
  selector: 'app-interrupts',
  standalone: true,
  imports: [
    ChatComponent,
    ChatApprovalCardComponent,
    ChatWelcomeSuggestionComponent,
    ExampleChatLayoutComponent,
    CurrencyPipe,
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <example-chat-layout>
      <div main class="flex flex-col h-full">
        <chat [agent]="agent" class="flex-1 min-w-0">
          <div chatWelcomeSuggestions>
            @for (s of suggestions; track s.value) {
              <chat-welcome-suggestion
                [label]="s.label"
                [value]="s.value"
                (selected)="send($event)"
              />
            }
          </div>
        </chat>
 
        <chat-approval-card
          [agent]="agent"
          matchKind="refund_approval"
          title="Refund approval required"
          [showEdit]="true"
          (action)="onAction($event)"
        >
          <ng-template #body let-payload>
            <div style="display:flex; flex-direction:column; gap:6px;">
              <div><span style="color:var(--ngaf-chat-text-muted); margin-right:6px;">Amount</span><strong>{{ payload.amount | currency }}</strong></div>
              <div><span style="color:var(--ngaf-chat-text-muted); margin-right:6px;">Customer</span><code>{{ payload.customer_id }}</code></div>
              @if (payload.reason) {
                <div style="font-style:italic; color:var(--ngaf-chat-text-muted); margin-top:4px;">{{ payload.reason }}</div>
              }
              @if (editing()) {
                <div style="margin-top:10px; display:flex; gap:6px; align-items:center;">
                  <label style="color:var(--ngaf-chat-text-muted); font-size:12px;">Edit amount</label>
                  <input type="number" step="0.01" [value]="editAmount() ?? payload.amount" (input)="editAmount.set(+($any($event.target).value))" style="padding:4px 8px; border:1px solid var(--ngaf-chat-separator); border-radius:6px; width:120px;" />
                  <button type="button" (click)="submitEdit(payload)" style="padding:4px 10px; background:var(--ngaf-chat-primary); color:var(--ngaf-chat-on-primary); border:0; border-radius:6px; font-size:12px; cursor:pointer;">Save</button>
                </div>
              }
            </div>
          </ng-template>
        </chat-approval-card>
      </div>
    </example-chat-layout>
  `,
})
export class InterruptsComponent {
  protected readonly suggestions = WELCOME_SUGGESTIONS;
  protected readonly editing = signal(false);
  protected readonly editAmount = signal<number | null>(null);
 
  protected readonly agent = injectAgent();
 
  protected send(text: string): void {
    void this.agent.submit({ message: text });
  }
 
  protected onAction(action: ChatApprovalAction): void {
    if (action === 'approve') {
      void this.agent.submit({ resume: { approved: true } });
      this.resetEdit();
    } else if (action === 'cancel') {
      void this.agent.submit({ resume: { approved: false } });
      this.resetEdit();
    } else if (action === 'edit') {
      this.editing.set(true);
    }
  }
 
  protected submitEdit(payload: { amount: number }): void {
    const next = this.editAmount() ?? payload.amount;
    void this.agent.submit({ resume: { approved: true, amount: next } });
    this.resetEdit();
  }
 
  private resetEdit(): void {
    this.editing.set(false);
    this.editAmount.set(null);
  }
}

This is the same file as cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts from the LangGraph post — byte-identical except for the injectAgent import. The template binds <chat-approval-card matchKind="refund_approval"> to the agent. When agent.interrupt() becomes non-undefined, the dialog opens. The (action) event fires 'approve' | 'edit' | 'cancel'; the handler calls agent.submit({ resume: { approved, amount? } }). The { approved, amount? } shape couples back to whatever the LangGraph node will read from Command(resume=…) — keep them in sync.

The matchKind input is the discriminator pattern that keeps the dialog component reusable across interrupt kinds. If your graph emits interrupt({ kind: 'deploy_approval', … }), a separate <chat-approval-card matchKind="deploy_approval"> instance picks that up — same component, different match.

#Walk the run

#Streaming start → the draft

The user clicks "Refund a duplicate charge." A run starts; token-level TEXT_MESSAGE_CONTENT events stream the assistant's draft and messages() updates incrementally. For more on the AG-UI streaming model, see Build Fullstack Agentic Angular Apps Using AG-UI.

#The interrupt arrives

The structured-output call finishes, the graph hits request_approval, and interrupt({ kind: 'refund_approval', … }) fires. ag-ui-langgraph emits the CUSTOM event on the SSE stream:

// AG-UI event on the wire
{
  "type": "CUSTOM",
  "name": "on_interrupt",
  "value": "{\"kind\":\"refund_approval\",\"amount\":99.0,\"customer_id\":\"cus_a8x2k\",\"reason\":\"Duplicate charge on 2024-12-01.\"}"
}

value is a JSON string — that's the dump_json_safe quirk. The @threadplane/ag-ui reducer parses it and sets:

agent.interrupt() // → { id: 'r3w…', value: { kind, amount, customer_id, reason }, resumable: true }
The approval card dialog open over the chat, showing the structured refund payload (customer id, amount, reason).
The approval card with structured payload fields.

<chat-approval-card matchKind="refund_approval"> is bound to agent.interrupt(). The moment the signal becomes non-undefined, the dialog opens with the structured payload. The run has already finished (RUN_FINISHED arrived); the graph is parked at its checkpoint until something resumes it.

#Approve, edit, or cancel → resume

When the operator clicks Approve:

this.agent.submit({ resume: { approved: true, amount: this.editAmount() ?? payload.amount } });

The adapter clears agent.interrupt() immediately for snappy UX, then forwards the resume:

source.runAgent({ forwardedProps: { command: { resume: { approved: true, amount: 99 } } } });

A new POST /agent request fires. ag-ui-langgraph reads forwarded_props.command.resume, constructs a Command(resume=…), and continues the graph from the checkpoint. Token-level streaming resumes; the assistant confirms the refund issued. Reject ({ approved: false }) takes the alternate branch in the graph; edit-then-approve carries a new amount through Command(resume=…).

On the langgraph adapter the same submit({ resume }) becomes a native Client.submit(thread, command={resume:…}) call. Different wire, same Angular surface.

The chat history after the refund was approved and the run completed, showing the assistant's confirmation.
Run resumed and finished after Approve.

#Closing

The runtime-neutral Agent contract isn't a marketing line; it's the reason this post existed without rewriting the component. <chat-approval-card>, agent.interrupt(), and submit({ resume }) are the stable surface. on_interrupt and forwardedProps.command.resume are the AG-UI-specific wire details the adapter hides. Pick the adapter that matches your backend — LangGraph SDK direct → @threadplane/langgraph; anything AG-UI-fronted, including LangGraph-via-ag-ui-langgraph@threadplane/ag-ui. Your chat surface doesn't pick.

Pointers: