TUTORIAL · May 25, 2026 · 7 min read
Human-in-the-Loop LangGraph Agents in Angular
Build a human-in-the-loop LangGraph agent in Angular — pause runs before money moves with a structured approval dialog from @threadplane/chat and @threadplane/langgraph.
Let's build a human-in-the-loop LangGraph agent in Angular, with an approval dialog that pauses the run before money moves.
I learned this one the cheap way. The first time I let an agent call Stripe directly, it tried to refund the same customer twice in the same run. The second call failed because the first had already cleared. If I'd given it a slightly different prompt, it could have refunded ten times.
That's the moment I started reaching for interrupt().
Streaming made chat feel alive. Interrupts make tool calls feel safe. They're the difference between a demo your team enjoys and a system you trust to call your own APIs.
Everything below is running code. It's the cockpit example at cockpit/langgraph/interrupts in the ThreadPlane repo — every screenshot in this post came from that app. Clone it, nx serve cockpit-langgraph-interrupts-angular, and click along.
#Goals
- Understand why human-in-the-loop is the production-vs-demo line for tool calls.
- Wire a refund-approval gate using LangGraph's
interrupt()primitive. - Render the approval dialog in Angular with the
<chat-approval-card>composition. - Resume, reject, or edit-then-resume — with a real semantic difference between Approve, Edit, and Cancel.
- Have fun!
#Why interrupts matter
Streaming chat changed the conversation from "is it broken?" to "is this the answer I wanted?" Interrupts change a different question: "should this thing actually happen?"
Most tool calls don't need approval. A read against your data warehouse, a vector search, a stock-price lookup — let the agent rip. But the moment a tool moves money, sends a message a customer will see, deletes a row, or kicks off a build, you want a human in the loop.
Two reasons.
The cheap one is cost. An LLM in a loop with a write API is a slot machine where the house is your bank account. Interrupts cap the blast radius.
The deeper one is trust. The operator on the other side of the screen needs to feel like the agent is collaborating with them, not narrating a fait accompli. A pause for review tells them "you're still driving."
In my opinion, interrupts are what turn an agent from a demo into a teammate. They're not friction — they're consent.

#The architecture in three boxes
Let's look at the seams before we touch any code.
LangGraph backend. A node that, instead of calling Stripe directly, calls interrupt({ kind: 'refund_approval', amount, customer_id, reason }). The run pauses there. The thread checkpointer persists the pending interrupt until something resumes it.
@threadplane/langgraph adapter. Surfaces the pending interrupt on an agent.interrupt() signal. agent.submit({ resume: <any> }) writes a structured value back to the paused graph.
@threadplane/chat UI. The <chat-approval-card> composition reads the agent's pending interrupt, opens a native HTML <dialog> modal, and emits 'approve' | 'edit' | 'cancel' when the operator clicks a button.
The contract is narrow. The LangGraph node doesn't know how the UI renders. The Angular component doesn't know which graph it's paused inside. That separation is what lets you reuse one approval dialog across five different agents.
#Scaffold
Three files. Let's go.
interrupt() is a function call inside a node. When it runs, the graph pauses and persists the interrupt payload to the thread checkpointer. The graph stays paused until agent.submit({ resume: <value> }) is called against the same thread — and <value> is what interrupt() returns when the node re-executes. That's why request_approval can branch on decision["approved"] and pick up an edited amount.
No queues, no webhooks, no human-approval-service. The thread state is the queue.
Same wiring as any other LangGraph agent. The adapter discovers interrupts at runtime from the thread state.
<chat-approval-card> reads agent.interrupt(), matches the kind you give it via matchKind, opens a native <dialog> modal, and emits an action enum on each button click. The body is yours — write whatever Angular template fits the structured payload your graph emitted. The composition handles the modal shell.
One subtlety worth calling out: Approve and Cancel are terminal — they resolve the interrupt and close the dialog. Edit is not. Clicking Edit leaves the dialog open so you can reveal an inline editor (here, an amount field) and submit the resume yourself. That distinction lives in the composition, so every consumer gets it for free.

#What's happening under the hood
Let's trace one full run.
- User: "Refund $47.50 to customer cus_a8x2k — they were charged twice."
draft_refundruns a structured-output extraction →state.amount = 47.5,customer_id = cus_a8x2k,reason = …. It also posts a short acknowledgement to the chat.request_approvalcallsinterrupt({ kind: 'refund_approval', … }). Graph pauses. Checkpointer persists the pending interrupt.- The adapter exposes it on
agent.interrupt(). <chat-approval-card>matches thekind, callsdialog.showModal(). The conversation behind goes blurred and dimmed.- Operator clicks Approve.
- The handler runs
agent.submit({ resume: { approved: true } }). - The adapter posts the resume.
request_approvalre-runs —interrupt()returns{ approved: true }this time instead of pausing. - The graph continues to
issue_refund. Stripe is called (a fake refund id in this demo). The run finishes.
The whole thing is one thread, one persisted state. If the operator closes the tab and comes back tomorrow, the interrupt is still there. Pretty freakin' cool. 💚

#Production patterns
Three things to know before this ships to a real customer.
#Idempotency
interrupt() re-executes the node when the graph resumes. Any side effect before the interrupt() call has already run; anything after runs on resume. Put the write call (the Stripe refund.create) on the resumed side, never the planning side.
And use an idempotency key. Generate one in request_approval, pass it through state to issue_refund — so if the operator's network blips and they click Approve twice, Stripe deduplicates the second call.
#Audit trail
When the operator approves, log who approved, when, and what payload they saw. The cleanest place is in the action handler, before agent.submit fires:
Auditing is the difference between "the agent did a thing" and "I can prove who authorized it." Compliance teams care a lot about this.
#When NOT to interrupt
Resist the urge to interrupt on every tool call. A pause for an analytics query is friction with no upside. A pause for a customers.search is annoying.
The rule I use: interrupt on writes the operator wouldn't want to undo by hand.
For me, that's three categories — money movement, customer-facing communication, and destructive deletes. Everything else, let the agent run. If you can undo it with a script in under a minute, it doesn't need approval.
#Conclusion
Streaming made agents feel alive. Interrupts make them safe to ship.
The pattern is small — one interrupt() call in your LangGraph node, one <chat-approval-card> in your Angular component, one agent.submit({ resume }) from the action handler. The architecture is what's powerful: the thread state holds the pause, the adapter exposes it, the dialog renders it, and the operator can close the laptop and come back tomorrow.
The next post in this series wires the other half — durable threads — so the conversation (and the pending interrupt) survives a reload, a different device, or a different operator.
If you're building an agent that touches money, sends messages, or deletes data, I think you owe your users a pause button. Now you have one.