diff --git a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx new file mode 100644 index 00000000..014720cc --- /dev/null +++ b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx @@ -0,0 +1,402 @@ +--- +title: "Human-in-the-Loop AG-UI Agents in Angular" +description: "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." +date: 2026-06-04 +tags: [tutorial, ag-ui, angular, agents, hitl, interrupts] +author: brian +featured: true +--- + +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 `` composition from `@threadplane/chat`. The example is the same refund agent from [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-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. + +
+ + + The refund agent running in the cockpit. Walk the approve / edit / cancel flow yourself. + + + The exact graph.py, server.py, and Angular component from this post. + + +
+ +## 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](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular) except for the `injectAgent` import: + +```diff +- import { injectAgent } from '@threadplane/langgraph'; ++ import { injectAgent } from '@threadplane/ag-ui'; +``` + +The `app.config.ts` adapter swap is one line in the providers array: + +```diff +- provideAgent({ apiUrl: '/api', assistantId: 'interrupts' }), ++ provideAgent({ url: '/agent' }), +``` + +That's the whole client-side delta. The rest of the file — the template binding ``, the `(action)` handler, the approve / edit / cancel branches, the `submit({ resume })` call — is unchanged. + +`` reads `agent.interrupt()` (a `Signal`), 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 `` 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.** `` reads `agent.interrupt()`, opens a ``-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: + +``` + (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. + + + + +A structured-output call populates the fields the approval card displays. Then `request_approval` pauses with `interrupt()`: + +```python +# 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. + + + + +`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: + +```python +# 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. + + + + +```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`: + +```js +// 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. + + + + +```ts +// 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: ` + +
+ +
+ @for (s of suggestions; track s.value) { + + } +
+
+ + + +
+
Amount{{ payload.amount | currency }}
+
Customer{{ payload.customer_id }}
+ @if (payload.reason) { +
{{ payload.reason }}
+ } + @if (editing()) { +
+ + + +
+ } +
+
+
+
+
+ `, +}) +export class InterruptsComponent { + protected readonly suggestions = WELCOME_SUGGESTIONS; + protected readonly editing = signal(false); + protected readonly editAmount = signal(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 `` 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 `` 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](/blog/2026-05-21-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: + +```jsonc +// 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: + +```ts +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.
+
+ +`` 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: + +```ts +this.agent.submit({ resume: { approved: true, amount: this.editAmount() ?? payload.amount } }); +``` + +The adapter clears `agent.interrupt()` immediately for snappy UX, then forwards the resume: + +```ts +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. ``, `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`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/ag-ui/interrupts) — Angular + Python, e2e-tested. +- The cross-adapter parity rendered in docs: [Choosing an adapter](https://threadplane.ai/docs/choosing-an-adapter). +- The AG-UI interrupts guide for protocol-level detail: [/docs/ag-ui/guides/interrupts](https://threadplane.ai/docs/ag-ui/guides/interrupts). +- The langgraph counterpart, if you want both sides side-by-side: [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular). diff --git a/docs/superpowers/plans/2026-06-04-ag-ui-interrupts-blog-post.md b/docs/superpowers/plans/2026-06-04-ag-ui-interrupts-blog-post.md new file mode 100644 index 00000000..58321eff --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-ag-ui-interrupts-blog-post.md @@ -0,0 +1,560 @@ +# AG-UI Interrupts Blog Post — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Author and commit `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx` — a full standalone tutorial covering the new ag-ui interrupt support with the "same UI, two adapters" parity angle. + +**Architecture:** Single `.mdx` deliverable structured as eight sections (frontmatter + lede + CTAs / goals + parity hook / "when to use an interrupt" / three-piece architecture / four-step scaffold / walk-the-run / closing / image placeholders). Every code block must be verified character-accurate against `cockpit/ag-ui/interrupts` source. The "test" for prose tasks is grep/diff against source. Image PNG capture is a deliberate follow-up — the post ships with `
` placeholders and captions. + +**Tech Stack:** MDX (Next.js website), Markdown, bash grep/diff for source verification. + +**Spec:** `docs/superpowers/specs/2026-06-04-ag-ui-interrupts-blog-post-design.md` + +--- + +## Conventions + +- Run all commands from the repo root: `/Users/blove/repos/angular-agent-framework/.claude/worktrees/interesting-mccarthy-5d4ea0`. +- Commit after each task. Do NOT push. +- The deliverable file is `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx`. Subsequent tasks append to it; the first task creates it. +- Voice rule (from `feedback_blog_voice_no_anecdotes`): no anecdotes for Brian; no emojis; trimmed technical register; match the precedent post's voice exactly. + +--- + +## File Structure + +**Create:** `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx` + +**Read-only references the implementer will source-verify against:** + +| Reference | Purpose | +|---|---| +| `apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx` | Precedent post (structure, voice, CTA shape, figure conventions, frontmatter format) | +| `cockpit/ag-ui/interrupts/python/src/graph.py` | LangGraph node + `request_approval` interrupt — source for Step 1 code block | +| `cockpit/ag-ui/interrupts/python/src/server.py` | uvicorn FastAPI app + `ag-ui-langgraph` wiring + `/ok` health — source for Step 2 code block | +| `cockpit/ag-ui/interrupts/angular/src/app/app.config.ts` | ag-ui `provideAgent({ url })` — source for Step 3 code block | +| `cockpit/ag-ui/interrupts/angular/proxy.conf.mjs` | `/agent` → backend port wiring | +| `cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts` | Angular component — source for Step 4 code block + parity-diff claim | +| `cockpit/langgraph/interrupts/angular/src/app/app.config.ts` | LangGraph `provideAgent({ apiUrl, assistantId })` — counterpart for the parity diff | +| `cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts` | LangGraph counterpart for the byte-identical claim | +| `libs/ag-ui/src/lib/reducer.ts` | Confirm the `on_interrupt` handler + JSON-string parse in the adapter | +| `libs/chat/src/lib/agent/agent-interrupt.ts` | Confirm `AgentInterrupt = { id, value, resumable }` shape | + +**Image asset paths (referenced but not created in this plan):** + +- `apps/website/public/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular/1.png` +- `apps/website/public/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular/2.png` +- `apps/website/public/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular/3.png` + +--- + +## Task 1: Frontmatter + lede + CTAs + Goals + Parity Hook (Sections 1–2) + +**Files:** Create `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx` + +- [ ] **Step 1: Pre-verify the parity diff against real source** + +Run: +```bash +diff cockpit/langgraph/interrupts/angular/src/app/app.config.ts cockpit/ag-ui/interrupts/angular/src/app/app.config.ts +diff cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts +``` +Expected: the app.config.ts diff shows the provider swap (langgraph `provideAgent({ apiUrl, assistantId })` from `@threadplane/langgraph` → ag-ui `provideAgent({ url: '/agent' })` + `provideChat({})` from `@threadplane/ag-ui`). The component diff shows ONLY the `injectAgent` import line — that's the byte-identical-except-the-import claim. If the component diff shows other differences, those are content the post needs to acknowledge — STOP and report before writing. + +- [ ] **Step 2: Read the precedent post** + +Read `apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx` end-to-end. Note: frontmatter shape, CTA card MDX block, voice register (short sentences, no emojis, no anecdotes), figure conventions, link styles, code-fence language tags (`ts` vs `python` vs `diff` vs `jsonc`). + +- [ ] **Step 3: Author Section 1 (frontmatter + lede + CTAs)** + +Create the file with this content (verify each link target): + +```mdx +--- +title: "Human-in-the-Loop AG-UI Agents in Angular" +description: "Build a human-in-the-loop AG-UI agent in Angular — the same from the LangGraph version, wired to an AG-UI-fronted LangGraph backend via @threadplane/ag-ui." +date: 2026-06-04 +tags: [tutorial, ag-ui, angular, agents, hitl, interrupts] +author: brian +featured: true +--- + +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 `` composition from `@threadplane/chat`. The example is the same refund agent from [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-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. + +
+ + + The refund agent running in the cockpit. Walk the approve / edit / cancel flow yourself. + + + The exact graph.py, server.py, and Angular component from this post. + + +
+``` + +The description field intentionally drops the backtick chars from `` because YAML frontmatter doesn't render them as code. Match the precedent's pattern (read line 3 of the langgraph post — same convention). + +- [ ] **Step 4: Author Section 2 (Goals + parity hook)** + +Append to the file: + +````mdx +## 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](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular) except for the `injectAgent` import: + +```diff +- import { provideAgent, injectAgent } from '@threadplane/langgraph'; ++ import { provideAgent, injectAgent } from '@threadplane/ag-ui'; +``` + +The `app.config.ts` adapter swap is one line in the providers array: + +```diff +- provideAgent({ apiUrl: '/api', assistantId: 'interrupts' }), ++ provideAgent({ url: '/agent' }), +``` + +That's the whole client-side delta. The rest of the file — the template binding ``, the `(action)` handler, the approve / edit / cancel branches, the `submit({ resume })` call — is unchanged. + +`` reads `agent.interrupt()` (a `Signal`), 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. +```` + +(The langgraph-side `apiUrl`/`assistantId` strings in the diff above must match the precedent's `app.config.ts` snippet — verify by reading `cockpit/langgraph/interrupts/angular/src/app/app.config.ts` if the literal values differ.) + +- [ ] **Step 5: Verify** + +```bash +grep -n "byte-identical except the import" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +grep -n "CardGroup cols={2}" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +grep -n "^title:\|^date:\|^author: brian\|^featured: true" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +``` +Expected: the parity claim phrase present; CTA group present; frontmatter complete. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +git commit -m "docs(blog): start ag-ui interrupts post — frontmatter + lede + parity hook" +``` + +--- + +## Task 2: When to use an interrupt + Architecture (Sections 3–4) + +**Files:** Append to `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx` + +- [ ] **Step 1: Verify the AG-UI-specific architecture claims against source** + +```bash +# Confirm the on_interrupt branch + JSON parse exist in the adapter: +grep -n "on_interrupt\|JSON.parse\|safeParseJson" libs/ag-ui/src/lib/reducer.ts | head +# Confirm AgentInterrupt shape: +grep -nE "interface AgentInterrupt|id:|value:|resumable:" libs/chat/src/lib/agent/agent-interrupt.ts | head +# Confirm forwardedProps.command.resume path in to-agent.ts: +grep -n "forwardedProps\|command\|resume" libs/ag-ui/src/lib/to-agent.ts | head +# Confirm server.py mounts /agent + has /ok: +grep -nE "add_langgraph_fastapi_endpoint|path=|/ok" cockpit/ag-ui/interrupts/python/src/server.py | head +# Confirm MemorySaver is used: +grep -nE "MemorySaver|checkpointer" cockpit/ag-ui/interrupts/python/src/graph.py cockpit/ag-ui/interrupts/python/src/server.py | head +``` +Expected: every claim the post makes about the wire has a matching source line. If `MemorySaver` is NOT in graph.py/server.py, the post must not claim it's there — STOP and reconcile (the spec assumed PR #567's implementer added it). + +- [ ] **Step 2: Author Section 3 (When to use an interrupt — paraphrased, AG-UI-tilted)** + +Append to the file: + +```mdx +## 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 `` you build today survives the backend you swap in next year. +``` + +- [ ] **Step 3: Author Section 4 (The architecture — three pieces, AG-UI-tilted)** + +Append: + +````mdx +## 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.** `` reads `agent.interrupt()`, opens a ``-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: + +``` + (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. +```` + +- [ ] **Step 4: Verify** + +```bash +grep -nE "on_interrupt|dump_json_safe|MemorySaver|forwarded_props\.command\.resume" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx | head +``` +Expected: every load-bearing technical term present. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +git commit -m "docs(blog): ag-ui interrupts — when-to-use + architecture sections" +``` + +--- + +## Task 3: Scaffold — the four-step code walk (Section 5) + +**Files:** Append to `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx` + +This is the largest task. Each step's code block must be source-verified character-by-character against the cockpit example. + +- [ ] **Step 1: Verify all four code sources exist + extract** + +```bash +sed -n '20,60p' cockpit/ag-ui/interrupts/python/src/graph.py # the relevant graph node +cat cockpit/ag-ui/interrupts/python/src/server.py +cat cockpit/ag-ui/interrupts/angular/src/app/app.config.ts +cat cockpit/ag-ui/interrupts/angular/proxy.conf.mjs +sed -n '1,30p' cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts # imports + class decl for the parity claim +``` +Note the exact content of each. The Step 1 (graph) code block in the post need not be the full file — the precedent shows ~20 lines around `request_approval`; mirror that scope. + +- [ ] **Step 2: Author Section 5 opener + Step 1 (the LangGraph node)** + +Append to the file: + +````mdx +## Scaffold + +Four steps. + + + + +A structured-output call populates the fields the approval card displays. Then `request_approval` pauses with `interrupt()`: + +```python +# graph.py — cockpit/ag-ui/interrupts/python/src/graph.py +<<>> +``` + +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. + + +```` + +Replace the `<<>>` placeholder with the actual code from `cockpit/ag-ui/interrupts/python/src/graph.py`. Pick the slice that shows the `RefundDraft` model + `extractor = ChatOpenAI(...).with_structured_output(RefundDraft)` + `async def draft_refund(...)` + `async def request_approval(...)` ending at the `interrupt(...)` call. Keep imports out unless they're load-bearing for the snippet's readability. Match the precedent's `# graph.py — cockpit/langgraph/interrupts/python/src/graph.py` filename-as-comment convention. + +- [ ] **Step 3: Author Step 2 (Wrap with ag-ui-langgraph + uvicorn)** + +Append: + +````mdx + + +`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: + +```python +# server.py — cockpit/ag-ui/interrupts/python/src/server.py +<<>> +``` + +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. + + +```` + +Replace the placeholder with the verbatim content of `server.py`. Use the actual filename-as-comment format. + +- [ ] **Step 4: Author Step 3 (app.config.ts + proxy.conf.mjs)** + +Append: + +````mdx + + +```ts +// app.config.ts — cockpit/ag-ui/interrupts/angular/src/app/app.config.ts +<<>> +``` + +The Angular dev server proxies `/agent` to the uvicorn port from `cockpit/ports.mjs`: + +```js +// proxy.conf.mjs — cockpit/ag-ui/interrupts/angular/proxy.conf.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. + + +```` + +Replace placeholders with verbatim source content. + +- [ ] **Step 5: Author Step 4 (the Angular component — the parity payoff)** + +Append: + +````mdx + + +```ts +// interrupts.component.ts — cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts +<<>> +``` + +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 `` 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 `` instance picks that up — same component, different match. + + + +```` + +Replace the placeholder with the verbatim component source. (Note the closing `` tag — only this last step closes the wrapper.) + +- [ ] **Step 6: Verify all code blocks match source** + +```bash +# Each code block in the .mdx must match its source file. Spot-check by +# extracting one of the longer blocks and diffing structurally: +awk '/```python/,/```/' apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx | grep -c "interrupt(" +grep -nE "^# (server|graph|app\.config|interrupts\.component|proxy\.conf)\.(py|ts|mjs)" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +# No <<>> placeholders remain: +grep -n "<<>>` markers; parity claim present. + +- [ ] **Step 7: Commit** + +```bash +git add apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +git commit -m "docs(blog): ag-ui interrupts — scaffold (graph + server + app.config + component)" +``` + +--- + +## Task 4: Walk the run + Closing (Sections 6–7) + +**Files:** Append to `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx` + +- [ ] **Step 1: Verify the on-wire JSON example and the resume code against the adapter** + +```bash +# Confirm the reducer JSON-parses the value (i.e. the wire example having a string `value` is accurate): +grep -nE "JSON\.parse|safeParseJson|on_interrupt" libs/ag-ui/src/lib/reducer.ts | head +# Confirm submit({ resume }) uses forwardedProps.command.resume in to-agent.ts: +grep -nE "forwardedProps|command|resume" libs/ag-ui/src/lib/to-agent.ts | head +``` +Expected: reducer parses string `value`; to-agent.ts forwards `resume` via `forwardedProps.command.resume`. + +- [ ] **Step 2: Author Section 6 (Walk the run)** + +Append: + +````mdx +## 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](/blog/2026-05-21-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: + +```jsonc +// 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: + +```ts +agent.interrupt() // → { id: 'r3w…', value: { kind, amount, customer_id, reason }, resumable: true } +``` + +`` 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: + +```ts +this.agent.submit({ resume: { approved: true, amount: this.editAmount() ?? payload.amount } }); +``` + +The adapter clears `agent.interrupt()` immediately for snappy UX, then forwards the resume: + +```ts +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. +```` + +- [ ] **Step 3: Author Section 7 (Closing + pointers)** + +Append: + +```mdx +## Closing + +The runtime-neutral `Agent` contract isn't a marketing line; it's the reason this post existed without rewriting the component. ``, `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`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/ag-ui/interrupts) — Angular + Python, e2e-tested. +- The cross-adapter parity rendered in docs: [Choosing an adapter](https://threadplane.ai/docs/choosing-an-adapter). +- The AG-UI interrupts guide for protocol-level detail: [/docs/ag-ui/guides/interrupts](https://threadplane.ai/docs/ag-ui/guides/interrupts). +- The langgraph counterpart, if you want both sides side-by-side: [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular). +``` + +- [ ] **Step 4: Verify** + +```bash +grep -n "forwarded_props\.command\.resume" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +grep -n "Choosing an adapter\|/docs/ag-ui/guides/interrupts" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +grep -n "^## Closing\|^## Walk the run" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +``` +Expected: each section heading + the pointer links present. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +git commit -m "docs(blog): ag-ui interrupts — walk the run + closing" +``` + +--- + +## Task 5: Image figure placeholders + verify nav + build + +**Files:** Modify `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx`; possibly `apps/website/src/lib/blog-config.ts` or similar (verify auto-discovery). + +- [ ] **Step 1: Insert three `
` blocks at their beats** + +Read the precedent post and observe where its three figures sit. Insert three matching `
` blocks in the new post at parallel beats: + +1. After the architecture section closing one-liner (end of Section 4) — the welcome screen. +2. Inside "The interrupt arrives" (Section 6), after the `agent.interrupt()` code block — the approval card dialog open. +3. After "Approve, edit, or cancel" final paragraph (end of Section 6) — the completed run with refund confirmation. + +Use this MDX shape (mirror the precedent — width/height match the screenshot dimensions; caption voice matches the precedent's voice): + +```mdx +
+ 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.
+
+``` + +Vary `src`, `alt`, and `figcaption` per figure. The PNGs themselves DO NOT EXIST YET — capturing them is a follow-up step. The post can still ship (frontmatter `featured: true` + image asset MIA results in a broken-image placeholder; the alt text is enough for readability and the editor will fill in the screenshots before publish). + +Suggested `alt` text per figure: + +1. *"The cockpit ag-ui/interrupts welcome screen showing two suggestion chips."* +2. *"The approval card dialog open over the chat, showing the structured refund payload (customer, amount, reason)."* +3. *"The chat history after the refund was approved and the run completed, showing the assistant's confirmation."* + +Suggested `figcaption` per figure (terse, mirroring precedent voice): + +1. *"The cockpit refund example on the AG-UI adapter."* +2. *"The approval card with structured payload fields."* +3. *"Run resumed and finished after Approve."* + +- [ ] **Step 2: Verify nav registration** + +Read `apps/website/src/app/blog/page.tsx` (the blog index route) and `apps/website/src/app/blog/[slug]/page.tsx` to confirm whether posts are auto-discovered from `apps/website/content/blog/*.mdx` or registered in a config (search `grep -rln "human-in-the-loop-langgraph-agents-in-angular" apps/website/src/lib`). If a registry exists, add the new slug; if discovery is automatic, no edit. + +- [ ] **Step 3: Build the website** + +```bash +npx nx build website 2>&1 | tail -10 +``` +Expected outcomes: +- If the build succeeds, you're done. +- If the build fails ONLY for the pre-existing `posthog-node` missing-package error (unrelated, predates this branch), that's acceptable — note it in the report. +- If the build fails for a reason CAUSED by the new post (broken MDX syntax, unresolved ``/`` component, missing image route handler), FIX before committing. + +- [ ] **Step 4: Verify final state** + +```bash +wc -l apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +grep -c "^## " apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx # expect ~7 (Goals, parity proof, when to use, architecture, scaffold, walk the run, closing) +grep -c "
" apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx # expect 3 +grep -n "<<` blocks; no leftover placeholders. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx \ + apps/website/src 2>/dev/null # only if nav registration edit was needed; safely no-op if not +git commit -m "docs(blog): ag-ui interrupts — figure placeholders + nav verified" +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** + +- Spec §1 (Frontmatter + lede + CTAs) → T1. +- Spec §2 (Goals + parity hook) → T1. +- Spec §3 (When to use an interrupt — paraphrased) → T2. +- Spec §4 (Architecture — three pieces + data flow trace) → T2. +- Spec §5 (Scaffold — four steps) → T3. +- Spec §6 (Walk the run — three sub-sections) → T4. +- Spec §7 (Closing + pointers) → T4. +- Spec §8 (Images + nav registration) → T5. + +All 8 spec sections covered. + +**Placeholder scan:** Only `<<>>` markers in T3 are explicit pre-paste instructions, replaced before T3's commit; T3 Step 6 greps for any leftover markers. Image PNGs are explicitly OUT OF SCOPE per the spec, with descriptive `alt` text serving until they're captured. No `TBD`/`TODO` in executable steps. + +**Type/name consistency:** Identifiers used across tasks: `provideAgent`, `injectAgent`, `agent.interrupt()`, `submit({ resume })`, ``, `matchKind="refund_approval"`, `on_interrupt`, `forwardedProps.command.resume`, `forwarded_props.command.resume`, `dump_json_safe`, `MemorySaver`, `ag-ui-langgraph`. All used identically across tasks. + +## Risks / verify-as-you-go + +- **T1 Step 1 verification** (the component byte-identical claim) is load-bearing. If the live diff shows MORE than the `injectAgent` import line, the parity claim must be adjusted — either trim the prose claim or surface the additional diff in Section 2. +- **`MemorySaver` source check (T2 Step 1)** could surface that PR #567's implementer didn't add it to graph.py specifically. If so, the spec's claim "Needs a `MemorySaver` checkpointer" stays accurate (the implementer DID add it; just confirm the precise file). Adjust the prose to wherever it actually lives. +- **``/`` MDX components** are assumed available from the precedent post; if the website's MDX setup has changed, T5 Step 3's build catches it. diff --git a/docs/superpowers/specs/2026-06-04-ag-ui-interrupts-blog-post-design.md b/docs/superpowers/specs/2026-06-04-ag-ui-interrupts-blog-post-design.md new file mode 100644 index 00000000..d8c68427 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-ag-ui-interrupts-blog-post-design.md @@ -0,0 +1,283 @@ +# Blog Post: "Human-in-the-Loop AG-UI Agents in Angular" — Design + +**Date:** 2026-06-04 +**Status:** Approved (brainstorming) — pending implementation plan + +## Problem + +The AG-UI adapter's interrupt support (PR #567) shipped end-to-end with a working `cockpit/ag-ui/interrupts` example proving it. There's a clear distribution opportunity: the framework's first cross-adapter HITL story. Brian shipped the LangGraph-side counterpart a week ago (`2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx`). The new post should land as a standalone tutorial that distinguishes itself with the cross-adapter parity angle — not a rewrite of the precedent. + +## Goals + +- Land a publishable blog post at `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx`. +- Distinguish from the LangGraph HITL precedent via the **"same UI, two adapters"** angle: the Angular component is byte-identical except the adapter import. +- Document the AG-UI-specific HITL wire details once, in a discoverable post: `CUSTOM`/`on_interrupt`, `dump_json_safe` JSON-string `value`, `forwardedProps.command.resume`, the `ag-ui-langgraph` FastAPI + `MemorySaver` infra detail. +- Drive traffic to the working `cockpit/ag-ui/interrupts` demo and source. + +## Voice + +Voice ground truth: memory `feedback_blog_voice_no_anecdotes`. + +- No fabricated first-person stories or anecdotes for Brian. +- Trimmed technical register: no emojis, no rhetoric, no "fun" headers, no exclamation marks. +- Substance over framing. Short sentences. Code-heavy. +- The precedent post (`2026-05-28-…langgraph…`) is the local voice ground truth. Match its register exactly. + +Per the user: it is OK to **paraphrase** concepts from the precedent (when-to-use-an-interrupt framing, approval-card UX explanation) so this post stands alone. Do NOT copy paragraphs verbatim — re-author them in the AG-UI context. + +## Decisions (from brainstorming) + +- **Angle:** "Same UI, two adapters" — runtime-neutral parity. +- **Depth:** Full standalone walkthrough (~6–10k words) mirroring the precedent's structure. +- **Standalone vs. linked:** Stand on its own; paraphrase (don't verbatim-copy) shared concepts; still link the precedent + AG-UI tutorial + choosing-an-adapter docs from the close. +- **Images:** Re-capture three screenshots from `cockpit/ag-ui/interrupts` matching the precedent's beats. Asset capture is a follow-up step, NOT in this spec's scope (the post can ship with placeholder paths committed and screenshots filled in before publish). +- **Author/featured:** `brian` / `featured: true` (matches precedent). + +## In-scope vs. out-of-scope + +**In scope:** +- The full `.mdx` file content (frontmatter + 7 sections + closing pointers). +- Three figure placeholders with intended captions and `alt` text (so the post is structurally complete; PNGs land separately). + +**Out of scope:** +- Capturing the actual `.png` screenshots. +- Any code changes to `@threadplane/ag-ui`, `cockpit/ag-ui/interrupts`, or the framework. +- Reworking the precedent's HITL post. +- Generating an opengraph / social card image (the website's blog template likely handles this from frontmatter). + +--- + +## Design + +### Section 1: Frontmatter + lede + CTA cards + +**Frontmatter** (mirror the precedent exactly except for slug-specific fields): + +```yaml +--- +title: "Human-in-the-Loop AG-UI Agents in Angular" +description: "Build a human-in-the-loop AG-UI agent in Angular — the same `` from the LangGraph version, wired to an AG-UI-fronted LangGraph backend via @threadplane/ag-ui." +date: 2026-06-04 +tags: [tutorial, ag-ui, angular, agents, hitl, interrupts] +author: brian +featured: true +--- +``` + +**Lede** (two sentences): + +> 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 `` composition from `@threadplane/chat`. The example is the same refund agent from [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular) — wired through the AG-UI adapter instead. The Angular component is byte-identical except the import. + +**A short follow-up paragraph** stating where the running code lives: "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." + +**CTA card group** (same shape as precedent): + +```mdx +
+ + + The refund agent running in the cockpit. Walk the approve / edit / cancel flow yourself. + + + The exact graph.py, server.py, and Angular component from this post. + + +
+``` + +### Section 2: Goals + the parity hook + +**Goals (three bullets):** +- 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 hook** — place this *before* the architecture section as the post's distinguishing content. Show a side-by-side diff of `app.config.ts` (adapter swap) and the component import: + +```diff +- import { provideAgent, injectAgent } from '@threadplane/langgraph'; ++ import { provideAgent, injectAgent } from '@threadplane/ag-ui'; +``` + +```diff +- provideAgent({ apiUrl: '/api', assistantId: 'interrupts' }), ++ provideAgent({ url: '/agent' }), +``` + +**Why this works** (one paragraph): `` reads `agent.interrupt()` (a `Signal`), and `submit({ resume })` is part of the runtime-neutral `Agent` contract — `interrupt` is declared on the `Agent` interface in `@threadplane/chat`, both adapters populate it, the chat surface above doesn't see the wire format. + +### Section 3: When to use an interrupt (paraphrased, AG-UI-tilted) + +A fresh ~150-word section re-covering the precedent's "When to use an interrupt" framing in AG-UI-specific language. Tilt: + +- Same heuristic — interrupt when an action moves money, sends a customer-facing message, deletes a record, or triggers a deploy. +- AG-UI-specific addendum: because AG-UI normalizes the wire across many backends, the same operator-approval checkpoint plugs into LangGraph, CrewAI, Mastra, etc. with no UI rewrite. Worth calling out — it's a real benefit for shops running heterogeneous agent backends. + +### Section 4: The architecture (three pieces, AG-UI-tilted) + +Mirror the precedent's "three pieces" framing; each piece highlights AG-UI specifics. + +**1. AG-UI-fronted LangGraph backend.** +- Same compiled graph as the precedent, duplicated under `cockpit/ag-ui/interrupts/python` per the cockpit standalone-examples convention. +- Wrapped with `ag-ui-langgraph`'s `LangGraphAgent(name, graph)` + `add_langgraph_fastapi_endpoint(app, agent, path='/agent')`; mounted as a FastAPI app and served by `uvicorn`. +- When `interrupt()` fires, `ag-ui-langgraph` emits a `CUSTOM` AG-UI event with `name: 'on_interrupt'` and `value: `. +- Needs a `MemorySaver` checkpointer — `langgraph dev`/Platform inject one automatically; plain uvicorn does not, and `aget_state` raises "No checkpointer set" without it. + +**2. `@threadplane/ag-ui` adapter.** +- Reducer recognizes `CUSTOM`/`on_interrupt`, JSON-parses the string `value` so consumers see the structured object, 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`. +- Exposes the same `Agent` contract as `@threadplane/langgraph`. + +**3. `@threadplane/chat` UI — unchanged.** +- `` reads `agent.interrupt()` and renders the dialog (``-backed modal, emits `'approve' | 'edit' | 'cancel'`). +- Resume actions call `agent.submit({ resume: { approved, amount? } })`. Reject calls `submit({ resume: { approved: false } })`. +- Adapter-agnostic by design. + +**Data-flow trace on resume** (formatted as a code block, indented arrows): + +``` + (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 +``` + +Closing one-liner of the section: *On the LangGraph adapter, the same `submit({ resume })` becomes a native `Client.submit(thread, command={resume:…})` call. Different wire, same Angular surface.* + +### Section 5: Scaffold — the code walk + +Mirror the precedent's `` block. Four steps. + +**Step 1 — The LangGraph node.** Identical content to the precedent. Show the `RefundDraft` Pydantic model, `extractor` (`ChatOpenAI.with_structured_output(RefundDraft)`), and the `request_approval` node calling `interrupt({ "kind": "refund_approval", … })`. Add a one-line callout: the file is **duplicated** into `cockpit/ag-ui/interrupts/python/src/graph.py` per the cockpit examples convention — copy, don't import across examples. The graph itself doesn't know it'll be served over AG-UI. + +**Step 2 — Wrap with `ag-ui-langgraph` + uvicorn.** The piece the precedent doesn't have. Full `server.py`: + +```python +# server.py — cockpit/ag-ui/interrupts/python/src/server.py +from fastapi import FastAPI +from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint +from langgraph.checkpoint.memory import MemorySaver +from .graph import build_graph + +graph = build_graph(checkpointer=MemorySaver()) # uvicorn doesn't inject one +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 callouts: + +- **`MemorySaver` is mandatory.** Without it, `aget_state` raises "No checkpointer set" because plain uvicorn does not inject one — only `langgraph dev`/Platform do. +- **The `dump_json_safe` quirk.** `ag-ui-langgraph` serializes interrupt `value` to a JSON string on the wire (so arbitrary Python objects survive JSON-encoding). The `@threadplane/ag-ui` reducer parses it back to an object for you, so consumers never see the string. + +**Step 3 — `app.config.ts`.** The Angular DI swap: + +```ts +// app.config.ts — cockpit/ag-ui/interrupts/angular/src/app/app.config.ts +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({}), + ], +}; +``` + +Plus `proxy.conf.mjs` routing `/agent` → the uvicorn port: + +```js +// 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}`, changeOrigin: true, ws: true } }; +``` + +Brief contrast: AG-UI's `provideAgent({ url })` vs. LangGraph's `provideAgent({ apiUrl, assistantId })`. Same provider name (the symmetric surface from the agent→langgraph rename), different config shape because the backend protocols differ. + +**Step 4 — The Angular component (the parity payoff).** Show `interrupts.component.ts`. Then state explicitly: this file is **byte-identical** to `cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts` from the precedent post, except for the one-line `injectAgent` import. Don't re-walk the template mechanics — paraphrase a tight version of the precedent's approval-card section (~150 words) covering: the `matchKind` input, the dialog open/close lifecycle tied to the `agent.interrupt()` signal becoming non-undefined, the `(action)` event, and how the resume payload's `{ approved, amount? }` shape couples back to the graph's `interrupt(...)` payload. + +### Section 6: Walk the run — what the user sees + +Narrate observed behavior end-to-end with the wire calls woven in. Three sub-sections. + +**Streaming start → the draft.** User clicks "Refund a duplicate charge." Token-level `TEXT_MESSAGE_CONTENT` events stream the assistant's draft; `messages()` updates. One brief paragraph; assume streaming is familiar to readers (link the AG-UI tutorial post). + +**The interrupt arrives.** Show the AG-UI event (paraphrased — exact serialization is the adapter's concern): + +```jsonc +// 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.\"}" +} +``` + +Call out the JSON-string detail. Show how the reducer parses it and what the consumer sees: + +```ts +agent.interrupt() // → { id: 'r3w…', value: { kind, amount, customer_id, reason }, resumable: true } +``` + +`` is bound to `agent.interrupt()`, so the moment that signal becomes non-undefined the dialog opens. The run has already finished (`RUN_FINISHED` arrived); the graph is parked at its checkpoint until something resumes. + +**Approve / edit / cancel → resume.** When the operator clicks Approve: + +```ts +this.agent.submit({ resume: { approved: true, amount: this.editAmount() ?? payload.amount } }); +``` + +The adapter clears `agent.interrupt()` immediately (snappy UX), then: + +```ts +source.runAgent({ forwardedProps: { command: { resume: { approved: true, amount: 99 } } } }); +``` + +A new `POST /agent` fires; `ag-ui-langgraph` reads `forwarded_props.command.resume`, constructs a `Command(resume=…)`, and continues the graph. Token-level streaming resumes; the assistant confirms the refund issued. Reject (`{ approved: false }`) takes the alternate branch; edit-then-approve carries a new `amount`. + +Closing one-liner: *On the langgraph adapter the same `submit({ resume })` becomes a native `Client.submit(thread, command={resume:…})` call. Different wire, same Angular surface.* + +### Section 7: Closing + durable lesson + pointers + +Two paragraphs, no anecdote. + +**Paragraph 1 — durable lesson.** The runtime-neutral `Agent` contract isn't a marketing line; it's the reason this post existed without rewriting the component. ``, `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. + +**Paragraph 2 — pointers** (bullet list): +- The working example: `cockpit/ag-ui/interrupts` (Angular + Python). +- The cross-adapter parity rendered in docs: [Choosing an adapter](https://threadplane.ai/docs/choosing-an-adapter). +- The AG-UI interrupts guide for protocol-level detail: [/docs/ag-ui/guides/interrupts](https://threadplane.ai/docs/ag-ui/guides/interrupts). +- The langgraph counterpart, if you want both sides side-by-side: [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular). + +### Section 8: Images + nav registration + +**Images.** Three `
` blocks placed at the precedent post's matching beats: + +1. After the architecture section — the cockpit refund example's welcome screen (suggestion chips: "Refund a duplicate charge", "Refund a chargeback"). +2. Inside Step 4 or "The interrupt arrives" — the approval card dialog open, showing the refund payload. +3. After "Approve / edit / cancel" — the completed run with refund confirmation in the message list. + +All three live under `apps/website/public/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular/{1,2,3}.png`. The MDX references the paths with descriptive `alt` text + `
` matching the precedent's caption voice. The actual `.png` files are TBD (capture step is post-this-spec). + +**Nav registration.** The website's blog index is auto-generated from `apps/website/content/blog/*.mdx`. The implementation plan will verify (read `apps/website/src/app/blog/page.tsx` and the related `blog/[slug]/page.tsx` route) and, if there IS a manual register list, add the post; otherwise no nav edit. + +--- + +## Success criteria + +- `apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx` exists, frontmatter matches the precedent shape (right tags, `author: brian`, `featured: true`). +- The post stands alone (no required prerequisite reading) while still linking the precedent + tutorial + docs. +- Every code block is copy-pasteable and matches the `cockpit/ag-ui/interrupts` source (graph.py, server.py, app.config.ts, proxy.conf.mjs, interrupts.component.ts). The implementation plan will verify each block against the cockpit example. +- The parity claim is concrete: a literal diff for the import + config; an explicit "byte-identical except the import" callout for the component. +- AG-UI-specific details are accurate: `CUSTOM`/`on_interrupt`, `dump_json_safe` JSON-string `value`, `forwardedProps.command.resume`, `MemorySaver` mandatory for uvicorn. +- `nx build website` succeeds with the new post in place (pre-existing posthog-node failure is unrelated). +- Image placeholders + figcaptions present; capturing the PNGs is a follow-up step.