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.
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
CUSTOMevent namedon_interruptbecomesagent.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:
The app.config.ts adapter swap is one line in the providers array:
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/pythonbecause cockpit examples are standalone. Wrapped withag-ui-langgraph'sLangGraphAgent(name, graph)+add_langgraph_fastapi_endpoint(app, agent, path='/agent')and served byuvicorn. Wheninterrupt()fires inside the graph,ag-ui-langgraphemits aCUSTOMAG-UI event withname: 'on_interrupt'and avaluecarrying the interrupt payload — serialized as a JSON string viadump_json_safe. The uvicorn server needs aMemorySavercheckpointer (langgraph devand LangGraph Platform inject one automatically; plain uvicorn does not, andaget_stateraises "No checkpointer set" without it). @threadplane/ag-uiadapter. The reducer recognizes theCUSTOM/on_interruptevent, JSON-parses the stringvalueso consumers see the structured object, and setsagent.interrupt()to{ id, value, resumable: true }.agent.submit({ resume })short-circuits the message-append path and callssource.runAgent({ forwardedProps: { command: { resume } } }). The server readsforwarded_props.command.resume. The adapter exposes the sameAgentcontract as@threadplane/langgraph.@threadplane/chatUI.<chat-approval-card matchKind="refund_approval">readsagent.interrupt(), opens a<dialog>-backed modal, and emits'approve' | 'edit' | 'cancel'. Resume actions callagent.submit({ resume: { approved, amount? } }). Reject callssubmit({ resume: { approved: false } }). The component doesn't know which adapter is wired. That's the point.
The data flow on resume:
On the LangGraph adapter, the same submit({ resume }) becomes a native Client.submit(thread, command={resume:…}) call. Different wire, same Angular surface.

#Scaffold
Four steps.
A structured-output call populates the fields the approval card displays. Then request_approval pauses with interrupt():
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.
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:
Run with uv run uvicorn src.server:app --port 5320.
Two details worth knowing:
MemorySaveris mandatory here.ag-ui-langgraphcallsgraph.aget_state(config)to read the post-stream interrupt state.langgraph devand LangGraph Platform inject a checkpointer; plain uvicorn does not. Without one,aget_stateraises "No checkpointer set" and the run never surfaces the interrupt.- The
dump_json_safequirk. Wheninterrupt({…})fires,ag-ui-langgraphserializesvalueto a JSON string before placing it on the wire so arbitrary Python objects survive JSON-encoding. The@threadplane/ag-uireducer parses it back to an object for you, so the Angular side never sees the string.
The Angular dev server proxies /agent to the uvicorn port from cockpit/ports.mjs:
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.
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:
value is a JSON string — that's the dump_json_safe quirk. The @threadplane/ag-ui reducer parses it and sets:

<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:
The adapter clears agent.interrupt() immediately for snappy UX, then forwards the resume:
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.

#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:
- The working example:
cockpit/ag-ui/interrupts— Angular + Python, e2e-tested. - The cross-adapter parity rendered in docs: Choosing an adapter.
- The AG-UI interrupts guide for protocol-level detail: /docs/ag-ui/guides/interrupts.
- The langgraph counterpart, if you want both sides side-by-side: Human-in-the-Loop LangGraph Agents in Angular.