Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
---
title: "Human-in-the-Loop LangGraph Agents in Angular"
description: "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."
date: 2026-05-25
tags: [tutorial, langgraph, angular, agents, hitl, interrupts]
author: brian
featured: false
---

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*.

<figure>
<img src="/blog/2026-05-25-human-in-the-loop-langgraph-agents-in-angular/1.png" alt="The refund agent's welcome screen with two suggestion chips: 'Refund a duplicate charge' and 'Refund a chargeback.'" width="1280" height="800" />
<figcaption>The cockpit refund example. Two suggestions seed a refund request.</figcaption>
</figure>

## 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.

<Steps>
<Step title="The LangGraph node">

```python
# graph.py — from cockpit/langgraph/interrupts/python/src/graph.py
from langgraph.types import interrupt
from pydantic import BaseModel, Field

class RefundDraft(BaseModel):
customer_id: str = Field(description="Customer id, e.g. cus_a8x2k. 'unknown' if absent.")
amount: float = Field(description="Refund amount in USD. 0 if not stated.")
reason: str = Field(description="One sentence: why the refund is justified.")

# Extract structured fields so the approval card shows real values.
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."), *state["messages"]]
)
ack = await llm.ainvoke([SystemMessage(content=system_prompt)] + state["messages"])
return {"messages": [ack], "customer_id": draft.customer_id,
"amount": draft.amount, "reason": draft.reason}

def request_approval(state: RefundState) -> dict:
decision = interrupt({
"kind": "refund_approval",
"amount": state["amount"],
"customer_id": state["customer_id"],
"reason": state["reason"],
})
if not isinstance(decision, dict) or not decision.get("approved"):
return {"decision_approved": False,
"messages": [AIMessage(content="Refund cancelled by operator.")]}
edited = decision.get("amount")
return {"decision_approved": True,
"amount": float(edited) if edited is not None else state["amount"]}
```

`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.

</Step>
<Step title="Wire the providers">

```ts
// app.config.ts
import { provideAgent } from '@threadplane/langgraph';
import { provideChat } from '@threadplane/chat';

export const appConfig: ApplicationConfig = {
providers: [
provideAgent({ apiUrl: environment.langGraphApiUrl }),
provideChat({}),
],
};
```

Same wiring as any other LangGraph agent. The adapter discovers interrupts at runtime from the thread state.

</Step>
<Step title="The component">

```ts
// interrupts.component.ts — from cockpit/langgraph/interrupts/angular
import { ChatComponent, ChatApprovalCardComponent, type ChatApprovalAction } from '@threadplane/chat';
import { agent } from '@threadplane/langgraph';

@Component({
selector: 'app-interrupts',
standalone: true,
imports: [ChatComponent, ChatApprovalCardComponent, CurrencyPipe],
template: `
<chat [agent]="agent" />

<chat-approval-card
[agent]="agent"
matchKind="refund_approval"
title="Refund approval required"
[showEdit]="true"
(action)="onAction($event)"
>
<ng-template #body let-payload>
<div>Amount <strong>{{ payload.amount | currency }}</strong></div>
<div>Customer <code>{{ payload.customer_id }}</code></div>
@if (payload.reason) { <div><em>{{ payload.reason }}</em></div> }
@if (editing()) {
<input type="number" [value]="editAmount() ?? payload.amount"
(input)="editAmount.set(+$any($event.target).value)" />
<button (click)="submitEdit(payload)">Save</button>
}
</ng-template>
</chat-approval-card>
`,
})
export class InterruptsComponent {
protected readonly agent = agent({ assistantId: 'interrupts' });
protected readonly editing = signal(false);
protected readonly editAmount = signal<number | null>(null);

protected onAction(action: ChatApprovalAction): void {
if (action === 'approve') this.agent.submit({ resume: { approved: true } });
else if (action === 'cancel') this.agent.submit({ resume: { approved: false } });
else if (action === 'edit') this.editing.set(true); // reveal the inline editor
}

protected submitEdit(payload: { amount: number }): void {
const next = this.editAmount() ?? payload.amount;
this.agent.submit({ resume: { approved: true, amount: next } });
this.editing.set(false);
}
}
```

`<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.

</Step>
</Steps>

<figure>
<img src="/blog/2026-05-25-human-in-the-loop-langgraph-agents-in-angular/2.png" alt="The approval dialog, centered over a blurred chat, showing Amount $47.50, Customer cus_a8x2k, a reason line, and Cancel / Edit / Approve buttons." width="1280" height="800" />
<figcaption>The native &lt;dialog&gt; modal halts the conversation. The structured payload — amount, customer, reason — renders through the body template slot.</figcaption>
</figure>

## What's happening under the hood

Let's trace one full run.

1. User: "Refund $47.50 to customer cus_a8x2k — they were charged twice."
2. `draft_refund` runs a structured-output extraction → `state.amount = 47.5`, `customer_id = cus_a8x2k`, `reason = …`. It also posts a short acknowledgement to the chat.
3. `request_approval` calls `interrupt({ kind: 'refund_approval', … })`. Graph pauses. Checkpointer persists the pending interrupt.
4. The adapter exposes it on `agent.interrupt()`.
5. `<chat-approval-card>` matches the `kind`, calls `dialog.showModal()`. The conversation behind goes blurred and dimmed.
6. Operator clicks Approve.
7. The handler runs `agent.submit({ resume: { approved: true } })`.
8. The adapter posts the resume. `request_approval` re-runs — `interrupt()` returns `{ approved: true }` this time instead of pausing.
9. 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. 💚

<figure>
<img src="/blog/2026-05-25-human-in-the-loop-langgraph-agents-in-angular/3.png" alt="The chat after approval, showing the agent's draft summary and a confirmation: 'Refund of $47.50 issued to cus_a8x2k. Refund ID: re_demo__a8x2k.'" width="1280" height="800" />
<figcaption>After Approve, the run continues into issue_refund and posts confirmation back into the chat.</figcaption>
</figure>

## 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:

```ts
protected async onAction(action: ChatApprovalAction): Promise<void> {
if (action === 'approve') {
await this.audit.record({
actor: this.currentUser(),
decision: 'approved',
payload: this.agent.interrupt()?.value,
});
this.agent.submit({ resume: { approved: true } });
}
}
```

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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading