From 032eeb5b4b4d224a52f38f1af8f7ade6ec555b76 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 17:43:09 -0700 Subject: [PATCH 01/21] =?UTF-8?q?docs(plans):=20LangGraph=20pilot=20(PR=20?= =?UTF-8?q?2)=20=E2=80=94=20eight=20pages=20teach=20through=20their=20runn?= =?UTF-8?q?ing=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...9-06-docs-example-first-langgraph-pilot.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-docs-example-first-langgraph-pilot.md diff --git a/docs/superpowers/plans/2026-09-06-docs-example-first-langgraph-pilot.md b/docs/superpowers/plans/2026-09-06-docs-example-first-langgraph-pilot.md new file mode 100644 index 000000000..c68c43806 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-docs-example-first-langgraph-pilot.md @@ -0,0 +1,282 @@ +# Example-first docs — LangGraph pilot (PR 2) 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:** Rewrite the eight LangGraph docs pages that embed a runnable example so each teaches through that example via ``, absorb and delete their eight walkthroughs, and prove the rendered include end to end with a browser test. + +**Architecture:** PR 1 (#1025) shipped `` (`apps/website/src/components/docs/mdx/ExampleCode.tsx`) bound per page from the registry's asset paths, and the guard `apps/website/src/lib/docs-example-code.spec.ts` with a `PENDING_PAGES` list. This PR is content work under that contract: every page follows one shape (spec §3), pulls code from the example instead of hand-typed copies, adds `#region` markers to long example files, and leaves `PENDING_PAGES` minus its eight entries. + +**Tech Stack:** MDX under `apps/website/content/docs/langgraph/guides/`, example sources under `cockpit/langgraph//{python,angular}/src/`, Playwright (`apps/website/e2e`), Vitest guards. + +**Spec:** `docs/superpowers/specs/2026-09-05-docs-example-first-content-design.md` (§3 page shape, §5 "PR 2, LangGraph pilot"). + +**Branch:** `blove/docs-example-first-langgraph`, already created from `origin/main` at a3507380. + +**Decisions fixed for this PR** +- `CodeGroup` does not tab `ExampleCode` blocks (spec §1). Use sequential `` blocks with short prose between them; never wrap them in `` or ``. +- Prose register: 2026 voice per `docs/gtm/voice.md`: **no contractions** in any rewritten or new sentence (existing untouched sentences may keep theirs, but every sentence you write or edit uses full forms). Warm peer, opinions flagged ("For me…"). +- The word "cockpit" must not appear in page prose (guard `apps/website/src/lib/cockpit-retirement.spec.ts`); say "the running example", "the Run tab", "the Code tab". Never name competitor products. +- Walkthrough `` blocks are dropped (spec Non-goals). ``/``/`` content is absorbed only when the page does not already say it. +- Frontmatter: every page keeps or gains a `description:` under 180 characters, one sentence, no trailing period needed. + +**Commands** +- Website unit: `npx nx test website --skip-nx-cache` (run from the repo root; `cockpit-retirement.spec.ts` is cwd-coupled, so do not run vitest from inside `apps/website` for the full suite). Single specs: `cd apps/website && npx vitest run docs-example-code public-copy cockpit-retirement docs-search`. +- Generators after ANY edit under `cockpit/langgraph/**`: `npx tsx scripts/generate-shared-deployment-config.ts && npx tsx scripts/generate-ag-ui-deployment-config.ts`, then `git status --short deployments` and commit whatever changed (post-merge drift gates in `deploy-langgraph.yml` / `deploy-ag-ui.yml` run `git diff --exit-code`). +- Prod build: `rm -rf apps/website/.next dist/apps/website/.next && GROWTH_FORM_POLICY=growth_v1 npx nx build website` (output in `dist/apps/website/.next`). +- E2E (local servers): `npx nx e2e website --skip-nx-cache`; one spec: `cd apps/website && npx playwright test e2e/example-code.spec.ts`. + +--- + +## The page procedure (used by Tasks 2–9) + +Each page task names: the page file, the walkthrough file, the example files with their line counts, and a target outline. Do these steps in order for that page. + +- [ ] **P1. Read everything first.** The current page, the walkthrough, every example file listed, and `docs/gtm/voice.md` lines 1–60. Note which hand-written snippets on the page duplicate example code (same API calls, same shape) and which teach something the example does not contain (other stream modes, other checkpointers, patterns the demo does not exercise). The first kind is replaced by ``; the second kind stays as ordinary fences. + +- [ ] **P2. Add regions to long example files.** For any example file over ~60 lines, add named marker pairs around the parts the page will discuss, using the language's form: `// #region name` … `// #endregion` (TypeScript), `# region name` … `# endregion` (Python). Names are kebab-case and describe the concept (`provider`, `submit`, `approve`, `checkpointer`, `fork`). Regions may nest. Do not change any executable line; markers are comments only. Keep a region under ~40 lines where the discussion is line-level. Whole files under ~60 lines need no regions. + +- [ ] **P3. Rewrite the page to the outline.** Shape (spec §3): + 1. Title, one-paragraph lead (what the reader gets), then `## What the demo does`: two to four sentences on what the Run tab shows and one or two things to try (draw on the example's welcome suggestions or prompt file where present). + 2. `## How it is built`: walk the example in build order under `###` headings: the backend graph first (`graph.py`, one or more regions), then `app.config.ts` (whole file; explain the provider factory and that the runtime connection is how the running example is wired; a reader's app passes `apiUrl`/`assistantId` directly), then the component (regions), then the template if it is a separate file. Each `` gets one to three sentences before it saying what to look at, and at most one sentence after it. Use `title="…"` only when the basename is not self-explanatory (for a region, a title like `graph.py — checkpointer` is good). + 3. `## Concepts` (or the page's own conceptual headings, kept): the explanatory material the current page already carries that the example does not show, trimmed of snippets the example now covers. Keep hand-written fences only for variants the example lacks. + 4. `## What's Next`: keep the existing `CardGroup`. + Absorb from the walkthrough: any ``/``/`` whose point the page does not already make becomes a ``. Drop its ``, ``, `` (the steps are the "How it is built" walk), and `` (the page's own What's Next covers it; the walkthrough's links point at retired routes). + +- [ ] **P4. Delete the walkthrough** (`git rm cockpit/langgraph//python/docs/guide.md`). If the `docs/` directory is then empty, git removes it. + +- [ ] **P5. Remove the page from `PENDING_PAGES`** in `apps/website/src/lib/docs-example-code.spec.ts` (one line). + +- [ ] **P6. Regenerate deployments** if you touched anything under `cockpit/` (region markers count): run both generators and stage `deployments/`. + +- [ ] **P7. Verify.** + ```bash + cd apps/website && npx vitest run docs-example-code public-copy cockpit-retirement docs-search docs.spec && cd ../.. + npx nx test website --skip-nx-cache + grep -n "cockpit" apps/website/content/docs/langgraph/guides/.mdx # expect nothing + grep -nE "\b(don't|doesn't|isn't|it's|you'll|you're|we're|can't|won't|that's|there's|let's|I'm|they're|didn't|wasn't|aren't|hasn't|haven't|shouldn't|wouldn't|couldn't)\b" apps/website/content/docs/langgraph/guides/.mdx # expect nothing in lines you wrote + ``` + Then a prod build (`GROWTH_FORM_POLICY=growth_v1 npx nx build website`) and confirm with `grep -c 'data-example-file=' dist/apps/website/.next/server/app/docs/langgraph/guides/.html` that the count equals the number of `` tags on the page. Then `rm -rf apps/website/.next dist/apps/website/.next`. + +- [ ] **P8. Commit** the page, the example files, the deleted walkthrough, the guard spec, and any regenerated deployments in ONE commit: `docs(langgraph): teaches through the running example`. End the message with a blank line and `Co-Authored-By: Claude Fable 5.1 `. + +--- + +### Task 1: Browser proof of a rendered `ExampleCode` block + +**Files:** +- Create: `apps/website/e2e/example-code.spec.ts` + +The streaming page already renders `` (PR 1), so this spec is green before any page rewrite and guards the component for every later page. + +- [ ] **Step 1: Write the spec** + +```ts +import { test, expect } from '@playwright/test'; + +/** + * `` renders a docs page's example file through the docs code + * pipeline. jsdom proves the element tree; only a browser proves that the + * fence was highlighted, that the title bar is visible, and that the copy + * button copies the example source rather than the title or the markers. + */ +test.describe('ExampleCode on a docs page', () => { + const route = '/docs/langgraph/guides/streaming'; + const file = 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts'; + + test('renders a highlighted, titled, copyable block from the example file', async ({ + page, + context, + }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto(route); + + const block = page.locator(`.mdx-example-code[data-example-file="${file}"]`).first(); + await expect(block).toBeVisible(); + await expect(block.locator('.mdx-example-code-title')).toHaveText('streaming.component.ts'); + await expect(block).toHaveAttribute('role', 'group'); + + // Highlighted: shiki emits per-token spans with inline colour. + const pre = block.locator('pre').first(); + await expect(pre).toBeVisible(); + expect(await pre.locator('span[style*="color"]').count()).toBeGreaterThan(10); + await expect(pre).toContainText('export class StreamingComponent'); + + // Copy: the button copies the code, not the title bar. + await block.locator('button[aria-label="Copy code"]').click(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain('export class StreamingComponent'); + expect(copied).not.toContain('streaming.component.ts\n'); + }); +}); +``` + +- [ ] **Step 2: Run it against the local dev server** + +Run: `cd apps/website && npx playwright test e2e/example-code.spec.ts` +Expected: 1 passed. If the copy assertion fails because clipboard access is denied in headless mode, look at how `apps/website/e2e/home-hero.spec.ts` grants and reads the clipboard and match it exactly. + +- [ ] **Step 3: Confirm it also runs in the production-verify mode** + +The spec talks only to the page, so it must NOT be added to `fixtureDrivenSpecs` in `apps/website/playwright.config.ts`. Run: `BASE_URL=https://threadplane.ai npx nx e2e website --skip-nx-cache -- --grep "ExampleCode"` and expect 1 passed (PR 1 is live). + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/e2e/example-code.spec.ts +git commit -m "test(website): browser proof that ExampleCode renders, highlights, and copies example source" +``` + +--- + +### Task 2: Streaming + +**Inputs** +- Page: `apps/website/content/docs/langgraph/guides/streaming.mdx` (276 lines) +- Walkthrough: `cockpit/langgraph/streaming/python/docs/guide.md` (126 lines) +- Example: `cockpit/langgraph/streaming/python/src/graph.py` (53), `cockpit/langgraph/streaming/angular/src/app/app.config.ts` (22), `streaming.component.ts` (57). All whole-file; no regions needed. + +**Outline** +1. Lead + `## What the demo does` (tokens arrive incrementally into the prebuilt ``; try the two welcome suggestions). +2. `## How it is built`: `### The graph` → `` (one node, `MessagesState`, `streaming=True`, system prompt read from the prompts file); `### The provider` → ``; `### The component` → `` (typed agent ref, ``, `submit`). This replaces the current "How streaming works" tab group AND the PR 1 "### The running example" section; the `agent.py` stream-mode commentary in that tab group moves to Concepts as a short fence showing only the three `astream` calls. +3. Concepts kept: `## Stream status`, `## Stream modes` (tabs stay), `## Error handling` (keep, but its `chat.component.ts` fence shrinks to the `hasError`/`retry()` lines), `## Throttle configuration`. +4. `## What's Next` unchanged. +5. Absorb from the walkthrough: the `` about never exposing the LangSmith API key client-side (as a `warning` callout in the provider section, if the page does not already say it). + +- [ ] Run the page procedure P1–P8 for `streaming`. + +--- + +### Task 3: Persistence + +**Inputs** +- Page: `persistence.mdx` (416 lines). Walkthrough: `cockpit/langgraph/persistence/python/docs/guide.md` (169). +- Example: `graph.py` (40, whole), `app.config.ts` (10, whole), `persistence.component.ts` (205 → regions: `provider`/`agent`, `thread-list`, `switch-thread`, `new-thread`, plus whatever the file's own structure suggests). + +**Outline** +1. Lead + `## What the demo does` (threads survive reloads; switch threads in the sidebar; start a new one). +2. `## How it is built`: `### The graph and its checkpointer` → `graph.py`; `### The provider` → `app.config.ts`; `### Listing and switching threads` → component regions in the order the user meets them (list, switch, new). +3. Concepts kept and trimmed: `## Python: Checkpointer Setup` becomes `## Choosing a checkpointer` (keep the MemorySaver/SQLite/Postgres comparison fences; drop any fence that duplicates the example graph), `## Thread IDs in graph invocation`, `## Reactive Thread Switching`, `## Manual Thread Switching`, `## Checkpoint Recovery`, `## Thread Lifecycle`. The two current `## Angular: …` sections (basic persistence, thread-list component) are replaced by the example walk; keep only fragments that show a pattern the demo does not (say so in one sentence). +4. `## What's Next` unchanged. +5. Absorb: walkthrough ``/`` items not already on the page. + +- [ ] Run the page procedure P1–P8 for `persistence`. + +--- + +### Task 4: Interrupts + +**Inputs** +- Page: `interrupts.mdx` (588 lines). Walkthrough: `cockpit/langgraph/interrupts/python/docs/guide.md` (147). +- Example: `graph.py` (120 → regions: `state`, `plan`, `approve` (the `interrupt()` call), `execute`, `graph`), `app.config.ts` (21, whole), `interrupts.component.ts` (157 → regions: `agent`, `interrupt-view`, `approve`, `reject`). + +**Outline** +1. Lead + `## What the demo does` (the agent proposes, pauses, and waits; approve or reject in the panel). +2. `## How it is built`: `### Pausing the graph` → `graph.py` regions `approve` then `graph`; `### The provider` → `app.config.ts`; `### Surfacing the interrupt` → component regions `interrupt-view`, `approve`, `reject`. +3. Concepts kept and trimmed: `## The Interrupt Lifecycle` (keep, it is the mental model), `## Multi-Step Approval Pattern` (keep only what the demo does not do; if the demo is single-step, keep the section but shorten its fences to the resume call), `## Typed Interrupt Payloads with BagTemplate`, `## Timeout Handling`. The current `## Python: Pausing With An Interrupt` and `## Angular: Building an Approval Component` are replaced by the example walk. +4. `## What's Next` unchanged. + +- [ ] Run the page procedure P1–P8 for `interrupts`. + +--- + +### Task 5: Memory + +**Inputs** +- Page: `memory.mdx` (442 lines). Walkthrough: `cockpit/langgraph/memory/python/docs/guide.md` (165). +- Example: `graph.py` (115 → regions: `state`, `extract-memory`, `respond`, `graph`), `app.config.ts` (21, whole), `memory.component.ts` (81 → regions: `memory-signal`, `template` or whole if the file reads well as one block). + +**Outline** +1. Lead + `## What the demo does` (tell the agent a preference, watch the memory sidebar fill, see it used later in the thread). +2. `## How it is built`: `### Extracting memory in the graph` → `graph.py` regions `state`, `extract-memory`; `### The provider` → `app.config.ts`; `### Reading memory with value()` → component. +3. Concepts kept and trimmed: `## Agent State with Custom Memory Fields` (shrink to the parts the example does not show), `## Short-Term Memory (Thread-Scoped)`, `## Long-Term Memory (Cross-Thread) with the Store API` (keep; the demo is thread-scoped), `## Semantic Memory with Vector Search` (keep), `## Memory Best Practices`. `## Surfacing Memory in Angular with value()` is replaced by the example walk. +4. `## What's Next` unchanged. + +- [ ] Run the page procedure P1–P8 for `memory`. + +--- + +### Task 6: Durable execution + +**Inputs** +- Page: `durable-execution.mdx` (10-line stub with a description). Walkthrough: `cockpit/langgraph/durable-execution/python/docs/guide.md` (158) — this is the page's main source of narrative. +- Example: `graph.py` (94 → regions per node, e.g. `fetch`, `analyze`, `summarize`, `graph`), `app.config.ts` (21, whole), `durable-execution.component.ts` (215 → regions: `status-badge`, `data-received`, `retry`, `agent`). + +**Outline** +1. Keep the stub's lead paragraph (it is good). `## What the demo does` (a multi-node run you can interrupt and retry; the status badge and data-received indicator). +2. `## How it is built`: `### A multi-node graph` → `graph.py` regions in node order, then `graph`; `### The provider` → `app.config.ts`; `### Status, progress, and retry` → component regions `status-badge`, `data-received`, `retry`. +3. `## Concepts`: from the walkthrough's Steps prose and its Tips/Warnings: what durability guarantees and does not, why `submit()` with no input resumes rather than restarts (link to the streaming page's error-handling section), and that the checkpointer is configured as in the Persistence guide (keep the stub's link). +4. `## What's Next`: a `CardGroup` linking Persistence, Time Travel, Interrupts (use the same `Card` markup as the streaming page). + +- [ ] Run the page procedure P1–P8 for `durable-execution`. + +--- + +### Task 7: Subgraphs + +**Inputs** +- Page: `subgraphs.mdx` (422 lines). Walkthrough: `cockpit/langgraph/subgraphs/python/docs/guide.md` (186). +- Example: `graph.py` (176 → regions: `research-subgraph`, `analysis-subgraph`, `orchestrator`, `graph`), `agent-ref.ts` (23, whole), `subgraphs.component.ts` (217 → regions: `agent`, `subagents`, `progress`, `transcript`), and `app.config.ts` is NOT an asset for this capability (its assets are `agent-ref.ts` and the component) — do not reference it with ``. + +**Outline** +1. Lead + `## What the demo does` (one request fans out to research and analysis children; watch the subagent cards). +2. `## How it is built`: `### Two children and one orchestrator` → `graph.py` regions in that order; `### Typed state for the parent` → `agent-ref.ts`; `### Rendering delegated work` → component regions `subagents`, `progress`, `transcript`. +3. Concepts kept and trimmed: `## How subgraph composition works` (shrink its fence to what the example does not show, or drop the fence), `## Giving the child its own state`, `## Tracking delegated subagent execution`, `## Subagent stream details`, `## How child streams get matched to tool calls`, `## Orchestrator pattern`, `## Child messages and the parent transcript`, `## Error handling per subagent`, `## When to use subagents vs a single agent`. `## Subagent progress UI` is replaced by the example walk. +4. `## What's Next` unchanged. + +- [ ] Run the page procedure P1–P8 for `subgraphs`. + +--- + +### Task 8: Time travel + +**Inputs** +- Page: `time-travel.mdx` (315 lines). Walkthrough: `cockpit/langgraph/time-travel/python/docs/guide.md` (146). +- Example: `graph.py` (45, whole), `app.config.ts` (21, whole), `time-travel.component.ts` (232 → regions: `agent`, `history`, `fork`, `branches`). + +**Outline** +1. Lead + `## What the demo does` (send a few messages, open the history sidebar, fork from an earlier checkpoint, navigate branches). +2. `## How it is built`: `### A checkpointed graph` → `graph.py`; `### The provider` → `app.config.ts`; `### History, forking, and branches` → component regions in that order. +3. Concepts kept and trimmed: `## How checkpointing works` (keep the server-side `get_state_history` fence; drop parts the example graph shows), `## Browsing execution history`, `## Forking from a checkpoint`, `## Branch navigation` (each shrinks to what the component regions do not already show), `## Comparing checkpoints`, `## Replaying with modified input`. `## Building a history UI` is replaced by the example walk. +4. `## What's Next` unchanged. + +- [ ] Run the page procedure P1–P8 for `time-travel`. + +--- + +### Task 9: Deployment + +**Inputs** +- Page: `deployment.mdx` (438 lines). Walkthrough: `cockpit/langgraph/deployment-runtime/python/docs/guide.md` (188). +- Example: `graph.py` (48, whole), `app.config.ts` (21, whole), `deployment-runtime.component.ts` (25, whole). + +This page is mostly operational guidance the example cannot show (CI, auth, CORS, monitoring). The example's job here is the deployable unit: a graph, a provider, a component. Keep the operational sections. + +**Outline** +1. Lead + `## What the demo does` (the smallest deployable pair: a graph on LangGraph Platform and an Angular component reading it through the configured runtime). +2. `## How it is built`: `### The graph you deploy` → `graph.py`; `### Pointing the app at a deployment` → `app.config.ts` (explain that a real app sets `apiUrl` to the deployment URL and `assistantId` to the graph name in `langgraph.json`); `### The component` → `deployment-runtime.component.ts`. +3. Concepts kept: `## Python: LangGraph Cloud deployment` (rename `## Deploying the graph`; keep `### Agent entry point` only if it shows something `graph.py` does not, else drop; keep `### Push and deploy`), `## LangSmith deployment walkthrough`, `## Angular: environment configuration` (rename `## Environment configuration`), `## Authentication`, `## CORS configuration`, `## Error boundaries`, `## Stream recovery`, `## CI/CD pipeline`, `## Monitoring`, `## Deployment checklist`. +4. `## What's Next` unchanged. +5. Absorb: the walkthrough's Vercel-hosting step and its CI tip only where the page lacks them. + +- [ ] Run the page procedure P1–P8 for `deployment` (walkthrough directory is `deployment-runtime`). + +--- + +### Task 10: Close out + +- [ ] **Step 1: Guard state.** `PENDING_PAGES` in `apps/website/src/lib/docs-example-code.spec.ts` has exactly 32 entries and none start with `/docs/langgraph/`. `ls cockpit/langgraph/*/python/docs/guide.md` lists only `client-tools` (it maps to `/docs/chat/guides/client-tools`, the chat product's PR). + +- [ ] **Step 2: Whole-tree verification.** +```bash +npx nx run-many -t test,lint --projects=website,cockpit-registry,cockpit-shell,scripts --skip-nx-cache +npx tsx scripts/generate-shared-deployment-config.ts && npx tsx scripts/generate-ag-ui-deployment-config.ts && git status --short deployments # expect empty +rm -rf apps/website/.next dist/apps/website/.next && GROWTH_FORM_POLICY=growth_v1 npx nx build website +for s in streaming persistence interrupts memory durable-execution subgraphs time-travel deployment; do echo "$s: $(grep -c 'data-example-file=' dist/apps/website/.next/server/app/docs/langgraph/guides/$s.html)"; done +rm -rf apps/website/.next dist/apps/website/.next +npx nx e2e website --skip-nx-cache +``` +Expected: all green; every page count ≥ 3; the e2e suite includes the new `example-code.spec.ts`. + +- [ ] **Step 3: Read every page once as a reader** (`cat` each), checking: no "cockpit", no contractions in new prose, each `` has a sentence before it, the What-the-demo-does section describes what the Run tab actually shows (compare with the component's welcome suggestions / prompt file), and no heading promises something the example does not deliver. + +- [ ] **Step 4: PR.** Push and open the PR with `gh pr create` (title `docs(langgraph): eight guides teach through their running examples`; body lists the pages, the deleted walkthroughs, the region markers added, the e2e spec, and verification; end with the Claude Code footer). Wait for the Website preview lane and spot-check two pages on the aliased preview. From f3e289d9ac0a02bec7d86143d9748d5ae2725a44 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 17:45:08 -0700 Subject: [PATCH 02/21] test(website): browser proof that ExampleCode renders, highlights, and copies example source Co-Authored-By: Claude Fable 5.1 --- apps/website/e2e/example-code.spec.ts | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/website/e2e/example-code.spec.ts diff --git a/apps/website/e2e/example-code.spec.ts b/apps/website/e2e/example-code.spec.ts new file mode 100644 index 000000000..82aa1be76 --- /dev/null +++ b/apps/website/e2e/example-code.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +/** + * `` renders a docs page's example file through the docs code + * pipeline. jsdom proves the element tree; only a browser proves that the + * fence was highlighted, that the title bar is visible, and that the copy + * button copies the example source rather than the title or the markers. + */ +test.describe('ExampleCode on a docs page', () => { + const route = '/docs/langgraph/guides/streaming'; + const file = + 'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts'; + + test('renders a highlighted, titled, copyable block from the example file', async ({ + page, + context, + }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto(route); + + const block = page + .locator(`.mdx-example-code[data-example-file="${file}"]`) + .first(); + await expect(block).toBeVisible(); + await expect(block.locator('.mdx-example-code-title')).toHaveText( + 'streaming.component.ts' + ); + await expect(block).toHaveAttribute('role', 'group'); + + // Highlighted: shiki emits per-token spans with inline colour. + const pre = block.locator('pre').first(); + await expect(pre).toBeVisible(); + expect(await pre.locator('span[style*="color"]').count()).toBeGreaterThan( + 10 + ); + await expect(pre).toContainText('export class StreamingComponent'); + + // Copy: the button copies the code, not the title bar. + await block.locator('button[aria-label="Copy code"]').click(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain('export class StreamingComponent'); + expect(copied).not.toContain('streaming.component.ts\n'); + }); +}); From 014a9d21f3df12f4a30439fc12f63516021abaed Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 17:48:14 -0700 Subject: [PATCH 03/21] docs(langgraph): streaming teaches through the running example Rewrite the streaming guide around the example it embeds: what the demo does, then the graph, provider, and component pulled in with ExampleCode instead of hand-typed copies. The walkthrough is absorbed and deleted. Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/streaming.mdx | 155 +++++++----------- .../langgraph/streaming/python/docs/guide.md | 125 -------------- 2 files changed, 58 insertions(+), 222 deletions(-) delete mode 100644 cockpit/langgraph/streaming/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/streaming.mdx b/apps/website/content/docs/langgraph/guides/streaming.mdx index 760fcdc99..d72bc643b 100644 --- a/apps/website/content/docs/langgraph/guides/streaming.mdx +++ b/apps/website/content/docs/langgraph/guides/streaming.mdx @@ -1,108 +1,65 @@ +--- +description: How the streaming example wires a LangGraph graph to Angular signals, plus stream modes, status, error handling, and throttling +--- + # Streaming Agent streams token-by-token from LangGraph agents over Server-Sent Events (SSE). Every update lands directly in Angular Signals — no subscriptions, no manual change detection. -Make sure you've completed the Installation guide first. +Make sure you have completed the Installation guide first. -## How streaming works +## What the demo does -Streaming starts on the agent side. LangGraph's `astream()` method controls what data is sent over the SSE connection. On the Angular side, `injectAgent()` consumes those events and maps them to Signals. +The Run tab hosts one agent and the prebuilt `` composition. Send a message and the answer fills in token by token while the typing indicator runs. The composition owns message rendering, the input, and error display, so the demo component itself stays under twenty lines. - - +Two welcome suggestions are wired up. "Stream a long answer" asks for a 200-word explanation of LangGraph checkpointing, which is long enough to watch the tokens land one at a time. "How agents pick tools" asks the agent to explain tool selection, and it is a good jumping-off point for the tool-calls guide. -```python -from langgraph.graph import END, START, MessagesState, StateGraph -from langchain_openai import ChatOpenAI +## How it is built -llm = ChatOpenAI(model="gpt-5-mini", streaming=True) +Three files carry the whole integration: the graph that streams, the provider that points Angular at it, and the component that renders it. Open the Code tab to read them in place. -def call_model(state: MessagesState) -> dict: - response = llm.invoke(state["messages"]) - return {"messages": [response]} +### The streaming graph -builder = StateGraph(MessagesState) -builder.add_node("call_model", call_model) -builder.add_edge(START, "call_model") -builder.add_edge("call_model", END) +The backend is a single node. `MessagesState` gives the LangGraph SDK a message list it already understands, the model is constructed with `streaming=True`, and the node prepends a system prompt read from the capability's prompt file before it awaits the model. The compiled graph is exported as `graph`, which is the symbol `langgraph.json` points at. -graph = builder.compile() + -# Stream modes control what SSE chunks contain. LangGraph Platform accepts -# one mode or a list of modes, depending on the API you call: +Notice that nothing in the graph opts into streaming per request. What arrives on the wire is decided by the modes the client asks for, which is the subject of "Stream modes" below. -# "values" — full state snapshot after each node -async for chunk in graph.astream( - {"messages": [("user", "Hello")]}, - stream_mode="values", -): - print(chunk) +### The agent provider -# "messages" — individual message tokens as generated -async for chunk in graph.astream( - {"messages": [("user", "Hello")]}, - stream_mode="messages", -): - print(chunk) +`provideAgent()` registers the agent once for the whole application, keyed by the typed ref declared alongside it in `agent-ref.ts`. The example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them. -# "events" — raw run events (on_chain_start, on_llm_stream, etc.) -async for event in graph.astream_events( - {"messages": [("user", "Hello")]}, - version="v2", -): - print(event["event"], event.get("data")) -``` + - - +Your own application does not need the factory. Pass the two values directly: ```typescript -import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; - -@Component({ - selector: 'app-chat', - templateUrl: './chat.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ChatComponent { - protected readonly chat = injectAgent(); - - readonly isStreaming = computed(() => this.chat.isLoading()); - - send(text: string) { - this.chat.submit({ message: text }); - } -} +provideAgent(STREAMING_AGENT, { + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'streaming', +}); ``` - - +`assistantId` must match the graph name in `langgraph.json`. -```html - - -@for (msg of chat.messages(); track $index) { -

- {{ msg.content }} -

-} + +Never expose a LangSmith API key in client-side code. Point `apiUrl` at a deployment that authenticates the browser another way, or proxy the requests through your own server and attach the key there. + -@if (chat.status() === 'error') { -

{{ chat.error()?.message }}

-} -``` +### The chat component -
-
+`injectAgent(STREAMING_AGENT)` returns the agent registered above, typed by that ref. The component hands it to `` and does little else: it renders the two welcome suggestions and forwards the selected one into `submit()`. -### The running example + -The demo in the Run tab is the smallest real integration of this pattern. The snippet above sketches the pieces by hand; the real component uses the prebuilt `` composition instead. It injects the agent configured in `app.config.ts` and hands it to ``, which owns message rendering, input, and the typing indicator. +`submit({ message })` opens the stream, and every signal the composition reads updates as chunks arrive. There is no service layer to write — the agent owns the connection lifecycle, the state, and error recovery. - + +`injectAgent()` must run inside an Angular injection context: a field initializer, as it is here, or a constructor body. + ## Stream status @@ -122,9 +79,25 @@ The connection was interrupted or the agent returned an error. Inspect `error()` ## Stream modes -By default, `injectAgent()` asks LangGraph Platform for the stream modes it needs to populate its public signals: `values`, `messages-tuple`, `updates`, and `custom`. It also enables `streamSubgraphs` so namespaced subgraph events can reach the client. +On the server, `astream()` decides what a run emits. The three modes you will meet most often: -Override `streamMode` per run when you need a narrower stream. It's a submit option, not a `provideAgent()` option. +```python +# "values" — full state snapshot after each node +async for chunk in graph.astream({"messages": [("user", "Hello")]}, stream_mode="values"): + print(chunk) + +# "messages" — individual message tokens as they are generated +async for chunk in graph.astream({"messages": [("user", "Hello")]}, stream_mode="messages"): + print(chunk) + +# "events" — raw run events (on_chain_start, on_llm_stream, etc.) +async for event in graph.astream_events({"messages": [("user", "Hello")]}, version="v2"): + print(event["event"], event.get("data")) +``` + +You rarely call those directly from an Angular app. By default, `injectAgent()` asks LangGraph Platform for the stream modes it needs to populate its public signals: `values`, `messages-tuple`, `updates`, and `custom`. It also enables `streamSubgraphs` so namespaced subgraph events can reach the client. + +Override `streamMode` per run when you need a narrower stream. It is a submit option, not a `provideAgent()` option. @@ -179,29 +152,17 @@ Use the default modes for most chat UIs. They keep `messages()`, `state()`, `too ## Error handling -If the SSE connection drops or the agent throws, `status()` flips to `'error'` and `error()` is populated. Use these signals to render a fallback UI and retry. +If the SSE connection drops or the agent throws, `status()` flips to `'error'` and `error()` is populated. The prebuilt `` composition renders the failure for you; a hand-built UI reads the same two signals. ```typescript -import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; - -@Component({ - selector: 'app-chat', - templateUrl: './chat.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ChatComponent { - protected readonly chat = injectAgent(); - - readonly hasError = computed(() => this.chat.status() === 'error'); - - retry() { - // Re-stream using the same thread so context is preserved - this.chat.submit(); - } +readonly hasError = computed(() => this.chat.status() === 'error'); + +retry() { + // Re-stream using the same thread so context is preserved + this.chat.submit(); } ``` @@ -245,7 +206,7 @@ provideAgent({ The value is in milliseconds. Pass `false` or `0` to disable batching and forward state updates immediately. Token message updates are not throttled, so live markdown and typing indicators still receive every token emission. -For me, the 16 ms default is the right starting point — it tracks a 60 fps render and you'll rarely feel it. The tradeoff of raising it is straightforward: a larger window costs you a touch of perceived latency on state signals to save renders, so only reach for it when profiling shows state updates are the bottleneck. +For me, the 16 ms default is the right starting point — it tracks a 60 fps render and you will rarely feel it. The tradeoff of raising it is straightforward: a larger window costs you a touch of perceived latency on state signals to save renders, so only reach for it when profiling shows state updates are the bottleneck. | Use case | Recommended throttle | |---|---| diff --git a/cockpit/langgraph/streaming/python/docs/guide.md b/cockpit/langgraph/streaming/python/docs/guide.md deleted file mode 100644 index 9abf4445b..000000000 --- a/cockpit/langgraph/streaming/python/docs/guide.md +++ /dev/null @@ -1,125 +0,0 @@ -# Streaming with Angular - - -Build a real-time streaming chat interface using `provideAgent()` and -`injectAgent()` from `@threadplane/langgraph` connected to a LangGraph backend -on LangSmith Cloud. - - - -Add real-time LLM streaming to this Angular component using `@threadplane/langgraph`. Configure `provideAgent({ apiUrl })` in the app config, call `injectAgent()` in the component, then call `stream.submit()` to send messages. Bind `stream.messages()` in the template using `@for` - all Signals, no subscriptions needed. - - - - - -Set up `provideAgent()` in your app config with the LangGraph Cloud URL: - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'https://your-deployment.langgraph.app', - assistantId: 'streaming', - }), - ], -}; -``` - -This makes the configured agent available to all `injectAgent()` calls in your app. - - - - -In your component, call `injectAgent()` in a field initializer (injection context required): - -```typescript -// streaming.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -export class StreamingComponent { - protected readonly stream = injectAgent(); -} -``` - - -`injectAgent()` must be called within an Angular injection context - a component field initializer or constructor body. - - - - - -Use Angular's control flow to render messages reactively: - -```html -@for (msg of stream.messages(); track $index) { -
- {{ msg.content }} -
-} -``` - -The template re-renders automatically as tokens arrive - no manual subscriptions or change detection needed. - -
- - -Call `stream.submit()` with a LangGraph message payload: - -```typescript -// streaming.component.ts -send(): void { - const text = this.prompt().trim(); - if (!text || this.stream.isLoading()) return; - this.prompt.set(''); - void this.stream.submit({ message: text }); -} -``` - -The submit call opens a streaming connection to the LangGraph backend. As tokens arrive, `stream.messages()` updates reactively. - - - - -The backend is a LangGraph `StateGraph` deployed to LangSmith Cloud: - -```python -# graph.py -from langgraph.graph import StateGraph, END -from langchain_openai import ChatOpenAI - -def build_streaming_graph(): - llm = ChatOpenAI(model="gpt-5-mini", streaming=True) - - async def generate(state): - response = await llm.ainvoke(state["messages"]) - return {"messages": [response]} - - graph = StateGraph(dict) - graph.add_node("generate", generate) - graph.set_entry_point("generate") - graph.add_edge("generate", END) - return graph.compile() -``` - -Deploy with `langgraph deploy` from `langgraph-cli`. The `assistantId` in your Angular code must match the graph name in `langgraph.json`. - - -
- - -No service layer needed - `injectAgent()` replaces wrapper services entirely. It handles connection lifecycle, state management, and error recovery. - - - -Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. - - - -- [Chat Messages](/chat/core-capabilities/messages/overview/python) - Learn how ChatMessageListComponent renders messages -- [Chat Input](/chat/core-capabilities/input/overview/python) - Explore ChatInputComponent for message submission - From e7f6c3a947f62ff2a02766fe925b8f0bc3b60401 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 17:54:21 -0700 Subject: [PATCH 04/21] docs(langgraph): persistence teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/persistence.mdx | 367 +++++------------- .../website/src/lib/docs-example-code.spec.ts | 1 - .../angular/src/app/persistence.component.ts | 8 + .../persistence/python/docs/guide.md | 168 -------- 4 files changed, 115 insertions(+), 429 deletions(-) delete mode 100644 cockpit/langgraph/persistence/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/persistence.mdx b/apps/website/content/docs/langgraph/guides/persistence.mdx index acf97125e..f977e37e6 100644 --- a/apps/website/content/docs/langgraph/guides/persistence.mdx +++ b/apps/website/content/docs/langgraph/guides/persistence.mdx @@ -1,21 +1,35 @@ +--- +description: How the persistence example lists, switches, and starts threads with onThreadId and switchThread, plus checkpointer choices and thread lifecycle +--- + # Persistence -Thread persistence keeps conversations alive across page refreshes, browser restarts, and server deployments. This guide covers configuring checkpointers on the Python side and wiring up thread management in your Angular components with `injectAgent()`. +Thread persistence keeps conversations alive across page refreshes, browser restarts, and server deployments. LangGraph checkpoints agent state at every super-step, keyed by a thread ID, and `injectAgent()` connects to those checkpoints for you. The running example is a chat with a thread sidebar, and this guide walks the four files that make it work. - -LangGraph checkpoints agent state at every super-step. Each checkpoint is keyed by a thread ID. `injectAgent()` connects to these checkpoints automatically, so your users resume exactly where they left off — even if your server restarted between sessions. + +Make sure you have completed the Installation guide first. - -"Restore a prior thread's messages when the user switches to it" is a behavior the `@threadplane/langgraph` adapter implements because the LangGraph protocol exposes per-thread checkpoint history. The runtime-neutral `Agent` contract in `@threadplane/chat` doesn't require this — adapters built on event-stream protocols (like `@threadplane/ag-ui`) typically can't offer it. If you're writing your own adapter, the [Writing an Adapter guide](/docs/chat/guides/writing-an-adapter#hydrating-from-a-server-stored-thread) covers the design choice. - +## What the demo does + +The Run tab shows the prebuilt `` composition next to a thread sidebar. Send a message and the backend assigns a thread ID, which appears in the sidebar as "Thread 1". Click "+ New Thread" and send another message, and you have two conversations you can move between. + +Switching back to an earlier thread replays its stored history: the messages come back from the server checkpoint, not from anything the browser kept. The welcome suggestion, "Start a saved thread", asks the agent to draft a project brief you can revisit, which gives each thread enough content to recognise in the sidebar. + +## How it is built + +Four pieces carry the whole feature: a graph that leaves checkpointing to the platform, an application config that deliberately does not register the agent, a component-scoped provider that captures thread IDs, and a sidebar that switches between them. Open the Code tab to read them in place. -## Python: Checkpointer Setup +### The graph and its checkpointer -Where the checkpointer comes from depends on how the graph is served, and getting this wrong is the fastest way to a server that won't boot. +The backend is a single node. `MessagesState` gives the LangGraph SDK a message list it already understands, the node prepends a system prompt read from the capability's prompt file, and the compiled graph is exported as `graph`, which is the symbol `langgraph.json` points at. + + + +Look at the last line of `build_persistence_graph`: it calls `compile()` on the `StateGraph` with no checkpointer at all. Persistence here comes entirely from the LangGraph API server, which is the case whenever you serve a graph with `langgraph dev` or on LangGraph Platform. -The platform provides persistence itself, and it rejects a graph that brings its own. Compile with `builder.compile()` and no argument. +The platform provides persistence itself, and it rejects a graph that brings its own. Call `compile()` on the `StateGraph` with no argument, as the example does. Passing one is not a soft warning — `langgraph dev` fails to load the graph and exits: @@ -29,27 +43,71 @@ Application startup failed. Exiting. To point the platform at your own database, set the `POSTGRES_URI` environment variable rather than constructing a saver in code. -The checkpointers below apply when you **embed** the graph in your own process — a FastAPI app calling `graph.ainvoke()`, a worker, a script, or an AG-UI server built with `ag-ui-langgraph` (which needs a checkpointer to read state via `aget_state`). +### Why the app config is almost empty + +Most of the LangGraph examples register their agent in `app.config.ts`. This one does not, and the comment in the file says why. + + + +The `onThreadId` callback is per-instance state, so the agent is provided at the component instead. `provideChat({})` is all that stays at the application root. + +### The thread bookkeeping the sidebar reads + +Two signals hold everything the sidebar needs: the list of threads the user has created and the ID of the active one. A counter supplies the human-readable labels, because a thread ID from the server is a long opaque string. + + + +Module scope works here because the demo bootstraps exactly one component instance. In an application that mounts several, move these into a service and inject it. + +### Capturing thread IDs with onThreadId + +`provideAgent()` sits in the component's `providers` array, so the agent and its callback are created with the component. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo; your own application passes `apiUrl` and `assistantId` directly. + + + +`onThreadId` fires whenever the backend assigns a thread ID. The callback records it as the active thread and appends it to the list if it is new, which is the only bookkeeping the sidebar needs. + + +Never generate a thread ID client-side. Always use the value handed to `onThreadId`, or a value the LangGraph Threads API returned earlier. + + + +Never expose a LangSmith API key in client-side code. Point `apiUrl` at a deployment that authenticates the browser another way, or proxy the requests through your own server and attach the key there. + + +### The sidebar + +The `` composition owns message rendering, the input, loading states, and error display, so the template only has to add the picker. The sidebar renders one button per tracked thread, marks the active one, and ends with a "+ New Thread" button in the footer. + + + +### Switching and starting threads + +The class itself is small. It injects the agent registered above, forwards a welcome suggestion into `submit()`, and exposes the two thread actions the sidebar calls. + + + +`switchThread(id)` loads that thread's latest checkpoint and repopulates every signal the composition reads. `switchThread(null)` clears the conversation; the backend assigns a new ID on the next submit, and `onThreadId` adds it to the sidebar. + + +`injectAgent()` must run inside an Angular injection context: a field initializer, as it is here, or a constructor body. + + + +"Restore a prior thread's messages when the user switches to it" is a behavior the `@threadplane/langgraph` adapter implements because the LangGraph protocol exposes per-thread checkpoint history. The runtime-neutral `Agent` contract in `@threadplane/chat` does not require this — adapters built on event-stream protocols (like `@threadplane/ag-ui`) typically cannot offer it. If you are writing your own adapter, the [Writing an Adapter guide](/docs/chat/guides/writing-an-adapter#hydrating-from-a-server-stored-thread) covers the design choice. + + +## Choosing a checkpointer + +The example leaves checkpointing to the platform, and that is the right default. The checkpointers below apply when you **embed** the graph in your own process — a FastAPI app calling `graph.ainvoke()`, a worker, a script, or an AG-UI server built with `ag-ui-langgraph` (which needs a checkpointer to read state via `aget_state`). -`@threadplane/langgraph` connects to a LangGraph *server*, so if you're following the [Quick Start](/docs/langgraph/getting-started/quickstart) and running `langgraph dev`, you're in the first case and can skip this section. +`@threadplane/langgraph` connects to a LangGraph *server*, so if you are following the [Quick Start](/docs/langgraph/getting-started/quickstart) and running `langgraph dev`, you are in the first case and can skip this section. ```python from langgraph.checkpoint.memory import MemorySaver -from langgraph.graph import START, END, MessagesState, StateGraph -from langchain_openai import ChatOpenAI - -llm = ChatOpenAI(model="gpt-5-mini") - -def call_model(state: MessagesState) -> dict: - return {"messages": [llm.invoke(state["messages"])]} - -builder = StateGraph(MessagesState) -builder.add_node("model", call_model) -builder.add_edge(START, "model") -builder.add_edge("model", END) # MemorySaver stores checkpoints in-process memory # Fast for development — lost when the process restarts @@ -61,15 +119,9 @@ graph = builder.compile(checkpointer=MemorySaver()) ```python from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver -from langgraph.graph import START, END, MessagesState, StateGraph # Persists to a local file — survives restarts, zero infrastructure async with AsyncSqliteSaver.from_conn_string("checkpoints.db") as checkpointer: - builder = StateGraph(MessagesState) - builder.add_node("model", call_model) - builder.add_edge(START, "model") - builder.add_edge("model", END) - graph = builder.compile(checkpointer=checkpointer) ``` @@ -78,7 +130,6 @@ async with AsyncSqliteSaver.from_conn_string("checkpoints.db") as checkpointer: ```python from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver -from langgraph.graph import START, END, MessagesState, StateGraph DATABASE_URL = "postgresql://user:pass@localhost:5432/myapp" @@ -86,11 +137,6 @@ async with AsyncPostgresSaver.from_conn_string(DATABASE_URL) as checkpointer: # Run migrations once on startup await checkpointer.setup() - builder = StateGraph(MessagesState) - builder.add_node("model", call_model) - builder.add_edge(START, "model") - builder.add_edge("model", END) - graph = builder.compile(checkpointer=checkpointer) ``` @@ -101,9 +147,9 @@ async with AsyncPostgresSaver.from_conn_string(DATABASE_URL) as checkpointer: MemorySaver is for development only — all state vanishes when the process exits. For anything users depend on, use PostgresSaver. SqliteSaver is a middle ground for prototypes and single-server deployments where you need persistence without a database. -## Python: Thread IDs in Graph Invocation +## Thread IDs in graph invocation -The thread ID is how LangGraph associates a conversation with its checkpoint history. Pass it in the `configurable` dict every time you invoke the graph: +When you invoke an embedded graph yourself, the thread ID is how LangGraph associates a conversation with its checkpoint history. Pass it in the `configurable` dict on every call: ```python # First message creates the thread @@ -124,246 +170,47 @@ result = graph.invoke( Use stable, user-scoped identifiers for thread IDs. A common pattern is `f"{user_id}_{session_id}"` — this prevents cross-user data leaks and lets one user have multiple conversations. -## Angular: Basic Thread Persistence - -Save the thread ID to localStorage so conversations survive page refreshes. `injectAgent()` handles thread creation and restoration automatically; the configuration lives in your root `provideAgent({...})` call. - - - - -```typescript -import { signal } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'http://localhost:2024', - assistantId: 'chat', - // Restore thread from localStorage on app start - threadId: signal(localStorage.getItem('threadId')), - // Persist thread ID whenever a new thread is created - onThreadId: (id) => localStorage.setItem('threadId', id), - }), - ], -}; -``` - - - - -```typescript -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; - -@Component({ - selector: 'app-chat', - templateUrl: './chat.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ChatComponent { - protected readonly chat = injectAgent(); - - send(text: string) { - this.chat.submit({ message: text }); - } -} -``` - - - - -```html - -@for (msg of chat.messages(); track $index) { -
-

{{ msg.content }}

-
-} - -@if (chat.isLoading()) { -
Agent is thinking...
-} -``` - -
-
- -## Angular: Thread-List Component - -A real chat application needs a sidebar showing all conversations. Here's a full thread-list component that manages multiple threads alongside your chat singleton. The active-thread signal lives in shared state and is wired into `provideAgent({...})` at bootstrap; the component reads back through `injectAgent()`. - - - - -```typescript -// thread-state.ts — shared signals injected by app.config.ts -import { signal } from '@angular/core'; - -export const activeThreadId = signal(null); -``` - - - - -```typescript -import { provideAgent } from '@threadplane/langgraph'; -import { activeThreadId } from './thread-state'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'http://localhost:2024', - assistantId: 'chat', - threadId: activeThreadId, - onThreadId: (id) => activeThreadId.set(id), - }), - ], -}; -``` - - - - -```typescript -import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import { activeThreadId } from './thread-state'; - -interface Thread { - id: string; - title: string; - updatedAt: Date; -} - -@Component({ - selector: 'app-thread-list', - templateUrl: './thread-list.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ThreadListComponent { - threads = signal(this.loadThreads()); - protected readonly activeThreadId = activeThreadId; - - protected readonly chat = injectAgent(); - - activeThread = computed(() => - this.threads().find((t) => t.id === this.activeThreadId()) - ); - - selectThread(id: string) { - this.activeThreadId.set(id); - } - - newConversation() { - this.chat.switchThread(null); - // A new thread ID is assigned on the next submit - } - - private addThread(id: string, title: string) { - this.threads.update((list) => [ - { id, title, updatedAt: new Date() }, - ...list.filter((t) => t.id !== id), - ]); - this.saveThreads(); - } - - private loadThreads(): Thread[] { - return JSON.parse(localStorage.getItem('threads') ?? '[]'); - } - - private saveThreads() { - localStorage.setItem('threads', JSON.stringify(this.threads())); - } -} -``` - - - - -```html - - -
- @if (chat.isThreadLoading()) { -
Loading conversation...
- } @else { - @for (msg of chat.messages(); track $index) { -
{{ msg.content }}
- } - } -
-``` - -
-
- - -`LANGGRAPH_CLIENT` is the DI token that holds the shared LangGraph SDK `Client` used by the threads adapter (`LangGraphThreadsAdapter`). The adapter injects it optionally and, when no client is provided, constructs one via `createLangGraphClient(apiUrl)`. Provide your own client through this token to share a single SDK instance — or to inject an explicit client in tests. - - -## Reactive Thread Switching +## Surviving a full page reload -When you pass a Signal as `threadId` to `provideAgent({...})`, `injectAgent()` reacts to every change. Set the signal and the conversation switches automatically — no imperative calls needed. +The example keeps its thread list in memory, so a browser refresh starts it over. An application that should survive a reload writes the IDs somewhere durable and reads them back at startup: ```typescript -// In app.config.ts: provideAgent({ - apiUrl: '...', - assistantId: 'chat', - threadId: activeThreadId, // Signal — switches reactively - onThreadId: (id) => activeThreadId.set(id), + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'persistence', + // Restore the last thread on app start + threadId: signal(localStorage.getItem('threadId')), + // Persist the ID whenever the backend assigns one + onThreadId: (id) => localStorage.setItem('threadId', id), }); - -// Anywhere in the app — clicking a thread in the sidebar triggers a reactive switch -selectThread(id: string) { - activeThreadId.set(id); - // injectAgent() detects the signal change, fetches the thread's - // checkpoint from the server, and updates all derived signals -} ``` +`threadId` accepts a Signal, which is the other way to change threads: set the signal and `injectAgent()` reacts to it, fetches that thread's checkpoint, and updates every derived signal. The example calls `switchThread()` instead because it wants the switch to happen exactly when the button is clicked. + Use the `isThreadLoading()` signal to show a skeleton UI while `injectAgent()` fetches checkpoint state from the server. This avoids a flash of empty content when switching threads. -## Manual Thread Switching - -Use `switchThread()` for imperative thread changes. It's useful when you want to control exactly when the switch happens — for example, after an animation completes or a modal closes. + +`LANGGRAPH_CLIENT` is the DI token that holds the shared LangGraph SDK `Client` used by the threads adapter (`LangGraphThreadsAdapter`). The adapter injects it optionally and, when no client is provided, constructs one via `createLangGraphClient(apiUrl)`. Provide your own client through this token to share a single SDK instance — or to inject an explicit client in tests. + -```typescript -// Start a fresh conversation (null = new thread on next submit) -newConversation() { - this.chat.switchThread(null); -} +## Forking a conversation -// Jump to a specific thread -loadConversation(threadId: string) { - this.chat.switchThread(threadId); -} +`switchThread()` also composes into a fork, which the example does not do: start a fresh thread and resubmit the messages you already have. -// Fork a conversation — create a new thread from current state +```typescript forkConversation() { - this.chat.switchThread(null); - this.chat.submit({ - messages: this.chat.messages(), + this.agent.switchThread(null); + this.agent.submit({ + messages: this.agent.messages(), }); } ``` -## Checkpoint Recovery +For a fork that branches from an earlier checkpoint rather than from the end of the conversation, see the [Time Travel guide](/docs/langgraph/guides/time-travel). + +## Checkpoint recovery When a connection drops mid-stream, `joinStream()` reconnects to an in-progress run without restarting the agent. That prevents duplicate work and lost tokens. @@ -377,7 +224,7 @@ await chat.joinStream(runId, lastEventId); In most cases `injectAgent()` handles reconnection internally. Use `joinStream()` directly only when you need explicit control — for example, when restoring a run ID from a URL parameter after a full page reload. -## Thread Lifecycle +## Thread lifecycle @@ -390,7 +237,7 @@ If `threadId` is null, `injectAgent()` creates a new thread via the LangGraph AP Each super-step is checkpointed server-side. The `messages()` signal updates in real time as events arrive. -Setting the `threadId` signal (or calling `switchThread()`) loads the target thread's latest checkpoint. All signals update to reflect the restored state. +Setting the `threadId` signal (or calling `switchThread()`, as the example does) loads the target thread's latest checkpoint. All signals update to reflect the restored state. `joinStream()` reconnects to the in-progress run. The agent does not restart — streaming resumes from the last received event. diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 7f5a8cc8b..a2b87f992 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -49,7 +49,6 @@ const PENDING_PAGES = new Set([ '/docs/langgraph/guides/durable-execution', '/docs/langgraph/guides/interrupts', '/docs/langgraph/guides/memory', - '/docs/langgraph/guides/persistence', '/docs/langgraph/guides/subgraphs', '/docs/langgraph/guides/time-travel', '/docs/render/api/provide-render', diff --git a/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts b/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts index 10783954b..4c4b11334 100644 --- a/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts +++ b/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts @@ -17,6 +17,7 @@ interface Thread { label: string; } +// #region thread-state // Per-instance thread bookkeeping shared between the component-scoped // provideAgent() config (which owns the onThreadId callback) and the // component itself. Module scope is safe here: each demo app bootstraps a @@ -24,6 +25,7 @@ interface Thread { const threadsState = signal([]); const activeThreadIdState = signal(null); let threadCounter = 0; +// #endregion /** * PersistenceComponent demonstrates thread persistence with `injectAgent()`. @@ -40,6 +42,7 @@ let threadCounter = 0; selector: 'app-persistence', standalone: true, imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], + // #region agent-provider // Scoped agent: the onThreadId callback tracks new thread ids into the // module-scoped signals the sidebar reads. Provided at the component (Option // B) because the config is genuinely per-instance. @@ -69,6 +72,7 @@ let threadCounter = 0; }; }), ], + // #endregion styles: ` .sidebar { display: flex; @@ -150,6 +154,7 @@ let threadCounter = 0;
+ + `, }) @@ -178,6 +184,7 @@ export class PersistenceComponent { protected readonly activeThreadId = activeThreadIdState; protected readonly suggestions = WELCOME_SUGGESTIONS; + // #region thread-actions /** * The streaming resource with thread persistence. * @@ -202,4 +209,5 @@ export class PersistenceComponent { this.activeThreadId.set(null); this.agent.switchThread(null); } + // #endregion } diff --git a/cockpit/langgraph/persistence/python/docs/guide.md b/cockpit/langgraph/persistence/python/docs/guide.md deleted file mode 100644 index 8ae81f373..000000000 --- a/cockpit/langgraph/persistence/python/docs/guide.md +++ /dev/null @@ -1,168 +0,0 @@ -# Thread Persistence with Angular - - -Build a chat interface with thread persistence using `provideAgent()` and -`injectAgent()` from `@threadplane/langgraph`. Conversations survive browser refreshes and -can be resumed using `stream.switchThread(id)`. - - - -Add thread persistence to this Angular component using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. Use the `onThreadId` callback to capture thread IDs, `stream.switchThread(id)` to resume conversations, and `stream.switchThread(null)` to start fresh. Bind `stream.messages()` in the template beside the `` component from `@threadplane/chat`. - - - - - -Set up `provideAgent()` in your app config with the LangGraph API URL: - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'https://your-deployment.langgraph.app', - }), - ], -}; -``` - -This makes the LangGraph API URL available to all `injectAgent()` calls in your app. - - - - -Because the `onThreadId` callback is per-instance, configure `provideAgent()` in -the component's `providers: []` and capture thread IDs into module-scoped signals -the template can read. Then call `injectAgent()` to retrieve the configured agent: - -```typescript -// persistence.component.ts -import { Component, signal } from '@angular/core'; -import { injectAgent, provideAgent } from '@threadplane/langgraph'; - -// Per-instance thread bookkeeping shared between the component-scoped -// provideAgent() config and the component itself. -const threadIdsState = signal([]); -const currentThreadIdState = signal(''); - -@Component({ - // ... - providers: [ - provideAgent({ - assistantId: 'persistence', - onThreadId: (id: string) => { - currentThreadIdState.set(id); - if (!threadIdsState().includes(id)) { - threadIdsState.set([...threadIdsState(), id]); - } - }, - }), - ], -}) -export class PersistenceComponent { - protected readonly stream = injectAgent(); - protected readonly threadIds = threadIdsState; - protected readonly currentThreadId = currentThreadIdState; -} -``` - -The `onThreadId` callback fires whenever a new thread is created by the backend. Store the IDs to build a thread picker UI. - - -Store thread IDs in `localStorage` to survive full page reloads. On app init, read them back and call `stream.switchThread(id)` to restore the last active thread. - - - - - -Use the `` component from `@threadplane/chat` and render a sibling sidebar: - -```html - - - -``` - -The sibling panel gives you a thread picker alongside the conversation. - - - - -Add methods to switch between threads and start new conversations: - -```typescript -selectThread(id: string): void { - this.currentThreadId.set(id); - this.stream.switchThread(id); -} - -newThread(): void { - this.currentThreadId.set(''); - this.stream.switchThread(null); -} -``` - -Calling `switchThread(id)` loads the full message history for that thread. Calling `switchThread(null)` clears the conversation and starts fresh. - - -Thread IDs are assigned by the backend. Never generate them client-side. Always use the ID provided by the `onThreadId` callback. - - - - - -The backend uses `MemorySaver` to persist thread state in memory during development: - -```python -# graph.py -from langgraph.graph import StateGraph, MessagesState, END -from langgraph.checkpoint.memory import MemorySaver - -checkpointer = MemorySaver() - -def build_persistence_graph(): - llm = ChatOpenAI(model="gpt-5-mini", streaming=True) - - async def generate(state: MessagesState) -> dict: - response = await llm.ainvoke(state["messages"]) - return {"messages": [response]} - - graph = StateGraph(MessagesState) - graph.add_node("generate", generate) - graph.set_entry_point("generate") - graph.add_edge("generate", END) - return graph.compile(checkpointer=checkpointer) -``` - -The `checkpointer` argument on `graph.compile()` enables persistence. Each thread's conversation is stored and can be resumed with the same `thread_id`. - - -For production, replace `MemorySaver` with `PostgresCheckpointer` for durable persistence across server restarts. - - - - - - -The `` component handles message rendering, input, loading states, and error display. Focus your component on thread management logic. - - - -Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. - - - -- [Chat Threads](/chat/core-capabilities/threads/overview/python) — Learn how ChatThreadsComponent manages conversation threads -- [Chat Timeline](/chat/core-capabilities/timeline/overview/python) — Explore ChatTimelineComponent for visualizing thread history - From ee9ba3b8eb6461a348bba66b14cda094cd4d44fa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 17:55:36 -0700 Subject: [PATCH 05/21] test(website): ExampleCode e2e asserts the copy source exactly Co-Authored-By: Claude Fable 5.1 --- apps/website/e2e/example-code.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/website/e2e/example-code.spec.ts b/apps/website/e2e/example-code.spec.ts index 82aa1be76..c04911719 100644 --- a/apps/website/e2e/example-code.spec.ts +++ b/apps/website/e2e/example-code.spec.ts @@ -28,6 +28,11 @@ test.describe('ExampleCode on a docs page', () => { await expect(block).toHaveAttribute('role', 'group'); // Highlighted: shiki emits per-token spans with inline colour. + // + // rehype-pretty-code is configured with a single theme (tokyo-night, see + // mdx-options.ts), which emits inline `color:` per token. A light/dark + // theme pair would switch to `--shiki-*` custom properties instead, and + // this count would then need `span[style*="--shiki"]`. const pre = block.locator('pre').first(); await expect(pre).toBeVisible(); expect(await pre.locator('span[style*="color"]').count()).toBeGreaterThan( @@ -39,6 +44,6 @@ test.describe('ExampleCode on a docs page', () => { await block.locator('button[aria-label="Copy code"]').click(); const copied = await page.evaluate(() => navigator.clipboard.readText()); expect(copied).toContain('export class StreamingComponent'); - expect(copied).not.toContain('streaming.component.ts\n'); + expect(copied).toBe(await pre.textContent()); }); }); From 0af4e9ceea462a50590e9c3d67b01c3fe54ef968 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 17:56:49 -0700 Subject: [PATCH 06/21] =?UTF-8?q?docs(langgraph):=20streaming=20page=20?= =?UTF-8?q?=E2=80=94=20facts=20checked=20against=20the=20example,=20one=20?= =?UTF-8?q?API=20style=20throughout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/streaming.mdx | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/streaming.mdx b/apps/website/content/docs/langgraph/guides/streaming.mdx index d72bc643b..ff255a6c2 100644 --- a/apps/website/content/docs/langgraph/guides/streaming.mdx +++ b/apps/website/content/docs/langgraph/guides/streaming.mdx @@ -12,13 +12,13 @@ Make sure you have completed the Installation guide first. ## What the demo does -The Run tab hosts one agent and the prebuilt `` composition. Send a message and the answer fills in token by token while the typing indicator runs. The composition owns message rendering, the input, and error display, so the demo component itself stays under twenty lines. +The Run tab hosts one agent and the prebuilt `` composition. Send a message and the answer fills in token by token while the typing indicator runs. The composition owns message rendering, the input, and error display, so the demo component is a template plus three fields. -Two welcome suggestions are wired up. "Stream a long answer" asks for a 200-word explanation of LangGraph checkpointing, which is long enough to watch the tokens land one at a time. "How agents pick tools" asks the agent to explain tool selection, and it is a good jumping-off point for the tool-calls guide. +Two welcome suggestions are wired up. "Stream a long answer" asks for a 200-word explanation of LangGraph checkpointing, which is long enough to watch the tokens land one at a time. "How agents pick tools" asks the agent to explain tool selection, and the [chat-tool-calls component guide](/docs/chat/components/chat-tool-calls) covers how those calls are rendered. ## How it is built -Three files carry the whole integration: the graph that streams, the provider that points Angular at it, and the component that renders it. Open the Code tab to read them in place. +Three files carry the visible integration: the graph that streams, the provider that points Angular at it, and the component that renders it. A fourth, two-line file declares the typed agent ref they share. Open the Code tab to read the three in place. ### The streaming graph @@ -26,11 +26,19 @@ The backend is a single node. `MessagesState` gives the LangGraph SDK a message -Notice that nothing in the graph opts into streaming per request. What arrives on the wire is decided by the modes the client asks for, which is the subject of "Stream modes" below. +The node awaits a single `ainvoke` call. `streaming=True` is what lets the model emit tokens through LangGraph's callbacks, and which of those reach the browser is decided by the stream modes the client asks for. ### The agent provider -`provideAgent()` registers the agent once for the whole application, keyed by the typed ref declared alongside it in `agent-ref.ts`. The example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them. +`provideAgent()` registers the agent once for the whole application, keyed by the typed ref declared in `agent-ref.ts`. That file is two lines of code: + +```typescript +import { createAgentRef } from '@threadplane/chat'; + +export const STREAMING_AGENT = createAgentRef('streaming'); +``` + +The example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them. It sits alongside `provideChat({})`, which supplies the chat composition's own providers. @@ -40,7 +48,8 @@ Your own application does not need the factory. Pass the two values directly: provideAgent(STREAMING_AGENT, { apiUrl: 'https://your-deployment.langgraph.app', assistantId: 'streaming', -}); +}), +provideChat({}), ``` `assistantId` must match the graph name in `langgraph.json`. @@ -79,22 +88,25 @@ The connection was interrupted or the agent returned an error. Inspect `error()` ## Stream modes -On the server, `astream()` decides what a run emits. The three modes you will meet most often: +On the server, `astream()` — and `astream_events()` for raw run events — decides what a run emits. The three modes you will meet most often: ```python -# "values" — full state snapshot after each node -async for chunk in graph.astream({"messages": [("user", "Hello")]}, stream_mode="values"): - print(chunk) - -# "messages" — individual message tokens as they are generated -async for chunk in graph.astream({"messages": [("user", "Hello")]}, stream_mode="messages"): - print(chunk) - -# "events" — raw run events (on_chain_start, on_llm_stream, etc.) -async for event in graph.astream_events({"messages": [("user", "Hello")]}, version="v2"): - print(event["event"], event.get("data")) +async def main(): + # "values" — full state snapshot after each node + async for chunk in graph.astream({"messages": [("user", "Hello")]}, stream_mode="values"): + print(chunk) + + # "messages" — individual message tokens as they are generated + async for chunk in graph.astream({"messages": [("user", "Hello")]}, stream_mode="messages"): + print(chunk) + + # "events" — raw run events (on_chain_start, on_llm_stream, etc.) + async for event in graph.astream_events({"messages": [("user", "Hello")]}, version="v2"): + print(event["event"], event.get("data")) ``` +The client-side name for the `messages` mode is `messages-tuple`, which is how it appears in the `streamMode` lists below. + You rarely call those directly from an Angular app. By default, `injectAgent()` asks LangGraph Platform for the stream modes it needs to populate its public signals: `values`, `messages-tuple`, `updates`, and `custom`. It also enables `streamSubgraphs` so namespaced subgraph events can reach the client. Override `streamMode` per run when you need a narrower stream. It is a submit option, not a `provideAgent()` option. @@ -105,7 +117,7 @@ Override `streamMode` per run when you need a narrower stream. It is a submit op ```typescript // Receives the full agent state after every node execution. // Best when you only need state snapshots. -const chat = injectAgent(); +const chat = injectAgent(STREAMING_AGENT); await chat.submit( { message: 'Summarize this thread.' }, @@ -121,7 +133,7 @@ await chat.submit( ```typescript // Streams individual message tokens as they are generated. // Best for token-by-token rendering with lowest perceived latency. -const chat = injectAgent(); +const chat = injectAgent(STREAMING_AGENT); await chat.submit( { message: 'Draft a reply.' }, @@ -135,7 +147,7 @@ await chat.submit( ```typescript // Emits raw LangGraph run events (on_chain_start, on_llm_stream, etc.). // Best for advanced observability or custom progress indicators. -const chat = injectAgent(); +const chat = injectAgent(STREAMING_AGENT); await chat.submit( { message: 'Trace this run.' }, @@ -158,18 +170,20 @@ If the SSE connection drops or the agent throws, `status()` flips to `'error'` a ```typescript -readonly hasError = computed(() => this.chat.status() === 'error'); +import { computed } from '@angular/core'; +import { injectAgent } from '@threadplane/langgraph'; -retry() { - // Re-stream using the same thread so context is preserved - this.chat.submit(); -} -``` +export class ChatComponent { + protected readonly chat = injectAgent(STREAMING_AGENT); - - + readonly hasError = computed(() => this.chat.status() === 'error'); -Calling `submit()` with no input opens a fresh stream against the current thread state without adding a new user message — the server resumes the run from where it left off, which is how you recover after an error. Pass `submit({ message })` only when you have new input to send. If you instead want to replay the exact last input you submitted, call `chat.reload()`. + retry() { + // Re-stream using the same thread so context is preserved + this.chat.submit(); + } +} +``` @@ -186,6 +200,8 @@ Calling `submit()` with no input opens a fresh stream against the current thread
+Calling `submit()` with no input opens a fresh stream against the current thread state without adding a new user message — the server resumes the run from where it left off, which is how you recover after an error. Pass `submit({ message })` only when you have new input to send. If you instead want to replay the exact last input you submitted, call `chat.reload()`. + `error()` surfaces both transport-level failures (lost connection, 5xx) and application-level errors returned by the agent graph. Check `error().cause` for the underlying HTTP status when you need to distinguish them. @@ -196,9 +212,9 @@ By default Agent coalesces state-like signal updates every 16 ms. That is close ```typescript // app.config.ts -provideAgent({ +provideAgent(STREAMING_AGENT, { apiUrl: '...', - assistantId: 'chat', + assistantId: 'streaming', // Batch incoming chunks and flush at most once every 50 ms throttle: 50, }); From 76f5c774e7fe880810d7ea0020c73e1d9fa88f8d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:02:40 -0700 Subject: [PATCH 07/21] docs(langgraph): interrupts teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/interrupts.mdx | 602 +++++------------- .../website/src/lib/docs-example-code.spec.ts | 1 - .../angular/src/app/interrupts.component.ts | 4 + .../langgraph/interrupts/python/docs/guide.md | 146 ----- .../langgraph/interrupts/python/src/graph.py | 8 + 5 files changed, 157 insertions(+), 604 deletions(-) delete mode 100644 cockpit/langgraph/interrupts/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/interrupts.mdx b/apps/website/content/docs/langgraph/guides/interrupts.mdx index 485461cf7..e516ebff0 100644 --- a/apps/website/content/docs/langgraph/guides/interrupts.mdx +++ b/apps/website/content/docs/langgraph/guides/interrupts.mdx @@ -1,522 +1,213 @@ +--- +description: How the interrupts example drafts a refund, pauses at interrupt(), and resumes from an approval card, plus typed payloads and timeout strategies +--- + # Interrupts -Interrupts let your LangGraph agent pause mid-execution and hand control to a human. The agent proposes an action, the graph freezes, your Angular UI shows an approval dialog, the user decides, and the agent resumes with the human's decision. `injectAgent()` surfaces interrupts as Angular Signals, so building approval flows, confirmation dialogs, and multi-step review experiences requires no manual event wiring. +Interrupts let a LangGraph agent pause mid-execution and hand control to a human. The agent proposes an action, the graph freezes, your Angular UI shows an approval card, the person decides, and the agent resumes with that decision. `injectAgent()` surfaces the pending interrupt as an Angular Signal, so an approval flow needs no manual event wiring. The running example is a refund authorization, and this guide walks the three files that make it work. -Use interrupts when an agent action is irreversible (sending an email, placing an order, deleting data), when the agent needs a human decision it cannot make on its own, or when compliance requires explicit approval before execution. +Use interrupts when an agent action is irreversible (sending an email, placing an order, issuing a refund), when the agent needs a human decision it cannot make on its own, or when compliance requires explicit approval before execution. -## The Interrupt Lifecycle +## What the demo does -Before diving into code, let's walk the five-stage lifecycle that every interrupt follows: +The Run tab shows the prebuilt `` composition in front of a refund graph. Ask for a refund and the agent acknowledges the draft in the transcript, and then the run stops: a modal card appears with the amount, the customer identifier, and the reason the agent extracted from your request, above three buttons — Cancel, Edit, and Approve. - - -The agent reasons about the user's request and determines an action that requires human approval. It builds a structured payload describing what it wants to do. - - -The agent node calls `interrupt({...})`, which freezes the graph. The interrupt payload is persisted in the checkpoint and streamed to the client. - - -`injectAgent()` updates the `interrupt()` signal. Your Angular template detects the change through OnPush change detection and renders an approval dialog with the interrupt payload. - - -The user reviews the proposed action and clicks Approve or Reject. Your component calls `agent.submit()` with a resume payload containing the decision. - - -LangGraph resumes the graph from the interrupted checkpoint. The next node receives the human's decision and either executes or aborts the action. - - +Two welcome suggestions set it up. "Refund a duplicate charge" asks for $47.50 back to customer `cus_a8x2k`, and "Refund a chargeback" asks for $129.00 with a different justification, so you can watch the same pause fire on two different payloads. -## Python: Pausing With An Interrupt +Approve resumes the graph, which issues a stand-in refund and posts the refund ID into the transcript. Cancel resumes it with a rejection, and the graph says so and issues nothing. Edit is the interesting one: the card stays open, an amount field appears, and saving resumes with an amount the operator chose rather than the one the agent proposed. -An interrupt is created inside any graph node by calling `interrupt({...})`. The value can be any JSON-serializable object — it becomes the payload your Angular component displays. When the UI resumes the run, the resume payload becomes the return value of the `interrupt()` call. +## How it is built - - +Three files carry the whole feature: a graph that stops in the middle of a run, an application config that registers the agent, and a component that maps the card's three buttons onto resume payloads. Open the Code tab to read them in place. -```python -from langgraph.graph import END, START, StateGraph -from langgraph.types import interrupt -from langchain_openai import ChatOpenAI -from typing_extensions import TypedDict, Annotated -from operator import add - -llm = ChatOpenAI(model="gpt-5-mini") - -class State(TypedDict): - messages: Annotated[list, add] - proposed_action: dict - approval_result: dict - -def plan_action(state: State) -> dict: - """Agent analyzes the request and proposes an action.""" - response = llm.invoke([ - {"role": "system", "content": ( - "Analyze the user's request. If it requires sending " - "an email, modifying data, or any irreversible action, " - "return a JSON action plan with keys: action, target, " - "description, risk_level." - )}, - *state["messages"] - ]) - action = parse_json(response.content) - return { - "proposed_action": action, - "messages": [response], - } - -def request_approval(state: State) -> dict: - """Pause the graph and ask the human for approval.""" - action = state["proposed_action"] - approval = interrupt({ - "action": action["action"], - "target": action["target"], - "description": action["description"], - "risk_level": action.get("risk_level", "medium"), - }) - return {"approval_result": approval} - -def execute_action(state: State) -> dict: - """Run the approved action or explain the rejection.""" - result = state.get("approval_result", {}) - if result.get("approved"): - # Execute the real action - outcome = perform_action(state["proposed_action"]) - return { - "messages": [{"role": "assistant", "content": ( - f"Done. {outcome}" - )}] - } - else: - reason = result.get("reason", "No reason given") - return { - "messages": [{"role": "assistant", "content": ( - f"Action cancelled. Reason: {reason}" - )}] - } - -# Build the graph: plan → approve → execute -builder = StateGraph(State) -builder.add_node("plan", plan_action) -builder.add_node("approve", request_approval) -builder.add_node("execute", execute_action) -builder.add_edge(START, "plan") -builder.add_edge("plan", "approve") -builder.add_edge("approve", "execute") -builder.add_edge("execute", END) - -graph = builder.compile() -``` +### The state behind the approval card - - - -```json -{ - "dependencies": ["."], - "graphs": { - "approval_agent": "./src/approval_agent/agent.py:graph" - }, - "env": ".env", - "python_version": "3.12" -} -``` +The graph tracks more than a message list. A Pydantic model describes the fields the agent must extract from the conversation, and the state adds the operator's decision and the resulting refund ID to them. - - + - -Place the `interrupt()` call in its own dedicated node. This gives you a clean three-node pattern (plan, approve, execute) where the interrupt sits between reasoning and action. When a run resumes, LangGraph re-executes the node containing `interrupt()`, so any side effects before that call must be idempotent. - +`decision_approved` is the field the router reads after the pause, which is why the state carries it rather than deriving it later. -## Angular: Building an Approval Component +### Drafting the refund -When the agent raises an interrupt, `injectAgent()` populates the `interrupt()` signal with the interrupt payload. Your component reads this signal to render a dialog and calls `submit()` to resume. +The first node makes two model calls. One is a structured-output extraction that fills `customer_id`, `amount`, and `reason` — the three values the approval card renders. The other is a streaming call that writes a human acknowledgement into the transcript while the operator reads the card. - - + -```typescript -import { - Component, - computed, - signal, - ChangeDetectionStrategy, -} from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import { createAgentRef } from '@threadplane/chat'; -import type { BaseMessage } from '@langchain/core/messages'; +Splitting extraction from narration is deliberate: the card needs typed fields, and the transcript needs prose, and one call cannot do both well. -interface ApprovalPayload { - action: string; - target: string; - description: string; - risk_level: 'low' | 'medium' | 'high'; -} +### Pausing at interrupt() -interface AgentState { - messages: BaseMessage[]; - proposed_action: ApprovalPayload; - approval_result: { approved: boolean; reason?: string }; -} +`interrupt()` freezes the graph and streams its argument to the client. The argument is any JSON-serializable value, and it becomes the payload your component renders. When the client resumes, that same call returns the resume value, so the node reads like a straight-line function even though a human answered in the middle of it. -export const APPROVAL_AGENT = createAgentRef('approval_agent'); + -// Configure in app.config.ts: -// provideAgent(APPROVAL_AGENT, { apiUrl: '...' }); +Notice the `kind` field on the payload. It is not required by LangGraph; it is how the frontend tells this interrupt apart from any other one the graph might raise. Notice too that the node validates what came back: a resume value that is not a dictionary, or one without `approved`, is treated as a rejection. -@Component({ - selector: 'app-approval', - templateUrl: './approval.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ApprovalComponent { - protected readonly agent = injectAgent(APPROVAL_AGENT); + +LangGraph resumes by running the interrupting node again, and `interrupt()` returns the resume value instead of pausing a second time. Every line above the `interrupt()` call therefore runs twice, so keep that stretch free of side effects. Reading state, as the example does, is safe; charging a card there is not. + - messages = computed(() => this.agent.messages()); - pendingApproval = computed(() => this.agent.interrupt()); - isLoading = computed(() => this.agent.isLoading()); +### Routing on the decision - rejectionReason = signal(''); +After the pause the graph branches. A conditional edge sends an approved refund to the node that issues it and sends everything else to the end, which is why the rejection message is written by the interrupting node itself. - riskClass = computed(() => { - const interrupt = this.pendingApproval(); - if (!interrupt) return ''; - const level = interrupt.value?.risk_level ?? 'medium'; - return `risk-${level}`; - }); + - send(input: string) { - this.agent.submit({ message: input }); - } - - approve() { - this.agent.submit({ - resume: { approved: true }, - }); - } - - reject() { - this.agent.submit({ - resume: { - approved: false, - reason: this.rejectionReason() || 'User rejected', - }, - }); - this.rejectionReason.set(''); - } -} -``` +`compile()` is called with no checkpointer, because the LangGraph API server provides persistence and rejects a graph that brings its own. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you embed in your own process. - - - -```html - -
- @for (msg of messages(); track msg) { -
{{ msg.content }}
- } - - @if (isLoading()) { -
Agent is working...
- } -
- - -@if (pendingApproval(); as approval) { -
-

Agent Needs Approval

- -
-
Action
-
{{ approval.value.action }}
- -
Target
-
{{ approval.value.target }}
- -
Description
-
{{ approval.value.description }}
- -
Risk Level
-
- - {{ approval.value.risk_level | titlecase }} - -
-
- -
- - -
- -
- - -
-
-} + +A paused graph is a stored checkpoint. Serving through `langgraph dev` or LangGraph Platform gives you that for free, and passing your own saver there is an error. Embedding the graph in your own process is the case where you must supply one yourself, or `interrupt()` has nowhere to save the pause. + - -@if (!pendingApproval()) { -
- - -
-} +### The agent provider + +`provideAgent()` registers the agent once for the whole application, and `provideChat({})` registers the configuration the `` composition reads, here left at its defaults. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo. + + + +Your own application does not need the factory. Pass the two values directly: + +```typescript +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'interrupts', +}); ``` -
-
+`assistantId` must match the graph name in `langgraph.json`. -## Multi-Step Approval Pattern + +Never expose a LangSmith API key in client-side code. Point `apiUrl` at a deployment that authenticates the browser another way, or proxy the requests through your own server and attach the key there. + -Some workflows need multiple approvals in sequence. An agent that plans a multi-step deployment might need a sign-off at each stage. Each node in the graph can raise its own interrupt. +### The approval card - - +`` is a prebuilt composition. Give it the agent and it watches `interrupt()` for you: when a payload arrives it opens a native modal dialog, and when the interrupt resolves it closes again. `matchKind` compares the payload's `kind` field, so the card ignores any interrupt that is not a refund approval, and `showEdit` adds the third button. -```python -from langgraph.graph import END, START, StateGraph -from langgraph.types import interrupt -from typing_extensions import TypedDict, Annotated -from operator import add - -class DeployState(TypedDict): - messages: Annotated[list, add] - plan: list[dict] - current_step: int - completed_steps: list[str] - approval_result: dict - -def create_plan(state: DeployState) -> dict: - """Generate a multi-step deployment plan.""" - plan = [ - {"step": "backup", "description": "Back up current database"}, - {"step": "migrate", "description": "Run schema migrations"}, - {"step": "deploy", "description": "Deploy new application version"}, - ] - return {"plan": plan, "current_step": 0} + -def approve_step(state: DeployState) -> dict: - """Interrupt for each step that needs approval.""" - step_index = state["current_step"] - step = state["plan"][step_index] - decision = interrupt({ - "step_number": step_index + 1, - "total_steps": len(state["plan"]), - "step": step["step"], - "description": step["description"], - "completed": state.get("completed_steps", []), - }) - return {"approval_result": decision} +The card owns the chrome and the buttons; the `#body` template owns the content. The payload arrives as the template's implicit value, so the demo renders the amount through the currency pipe and reveals the edit form inline rather than in a second dialog. -def execute_step(state: DeployState) -> dict: - """Execute the approved step and advance.""" - decision = state.get("approval_result", {}) - if not decision.get("approved"): - return { - "current_step": len(state["plan"]), - "messages": [{"role": "assistant", "content": "Deployment aborted."}], - } +### Resuming with the decision - step = state["plan"][state["current_step"]] - # ... perform the actual deployment step ... - return { - "completed_steps": [step["step"]], - "current_step": state["current_step"] + 1, - "messages": [{"role": "assistant", "content": ( - f"Completed: {step['description']}" - )}], - } +The card emits an action rather than resuming by itself, which leaves the resume payload entirely up to the component. Approve and Cancel are terminal, so the card closes and the handler submits the matching decision. Edit is not: it flips a signal, the body template grows an amount field, and the Save button submits the edited value. -def should_continue(state: DeployState) -> str: - if state["current_step"] < len(state["plan"]): - return "approve_step" - return END - -builder = StateGraph(DeployState) -builder.add_node("create_plan", create_plan) -builder.add_node("approve_step", approve_step) -builder.add_node("execute_step", execute_step) -builder.add_edge(START, "create_plan") -builder.add_edge("create_plan", "approve_step") -builder.add_edge("approve_step", "execute_step") -builder.add_conditional_edges("execute_step", should_continue) + -graph = builder.compile() -``` +`submit({ resume })` continues the paused run instead of starting a new one, and whatever you put in `resume` is exactly what `interrupt()` returns on the server. - - + +`injectAgent()` must run inside an Angular injection context: a field initializer, as it is here, or a constructor body. + -```typescript -import { - Component, - computed, - ChangeDetectionStrategy, -} from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import { createAgentRef } from '@threadplane/chat'; -import type { BaseMessage } from '@langchain/core/messages'; - -interface StepApproval { - step_number: number; - total_steps: number; - step: string; - description: string; - completed: string[]; -} +## The interrupt lifecycle -type DeployState = { - messages: BaseMessage[]; - plan: { step: string; description: string }[]; - current_step: number; - completed_steps: string[]; - approval_result: { approved: boolean; reason?: string }; -}; +Five stages, and the example passes through all of them on every refund: -export const DEPLOY_AGENT = createAgentRef('approval_agent'); + + +A node reasons about the request and produces the structured payload that describes what it wants to do. In the example that is the draft node and its extraction call. + + +A node calls `interrupt({...})`, which freezes the graph. The payload is persisted in the checkpoint and streamed to the client. + + +`injectAgent()` updates the `interrupt()` signal. The approval card reads it, matches on `kind`, and opens its dialog. + + +Approve, Edit, or Cancel. The component calls `agent.submit({ resume })` with a payload carrying the decision. + + +LangGraph re-runs the interrupting node, `interrupt()` returns the resume value, and the graph routes on it — issuing the refund or ending the run. + + -// Configure in app.config.ts: -// provideAgent(DEPLOY_AGENT, { apiUrl: '...' }); +## Approving more than once -@Component({ - selector: 'app-deploy-approval', - templateUrl: './approval.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DeployApprovalComponent { - protected readonly agent = injectAgent(DEPLOY_AGENT); +The example pauses once per run. A workflow that needs a sign-off at each stage of a plan uses the same primitive in a loop: the approving node interrupts on the current step, the executing node advances the counter, and a conditional edge sends the run back for the next approval until the plan is exhausted. - currentStep = computed(() => { - const interrupt = this.agent.interrupt(); - return interrupt?.value as StepApproval | null; - }); +```python +def approve_step(state: DeployState) -> dict: + step = state["plan"][state["current_step"]] + decision = interrupt({ + "kind": "deploy_step", + "step_number": state["current_step"] + 1, + "total_steps": len(state["plan"]), + "description": step["description"], + }) + return {"approval_result": decision} - progress = computed(() => { - const step = this.currentStep(); - if (!step) return 0; - return (step.completed.length / step.total_steps) * 100; - }); - allInterrupts = computed(() => this.agent.langGraphInterrupts()); +def should_continue(state: DeployState) -> str: + return "approve_step" if state["current_step"] < len(state["plan"]) else END - approveStep() { - this.agent.submit({ resume: { approved: true } }); - } - abortDeploy() { - this.agent.submit({ - resume: { approved: false, reason: 'Deployment aborted by user' }, - }); - } -} +builder.add_edge("approve_step", "execute_step") +builder.add_conditional_edges("execute_step", should_continue) ``` - - - -```html -@if (currentStep(); as step) { -
-

Step {{ step.step_number }} of {{ step.total_steps }}

- - -
-
-
- - - @if (step.completed.length) { -
    - @for (done of step.completed; track done) { -
  • {{ done }}
  • - } -
- } - - -
- {{ step.step }} -

{{ step.description }}

-
- -
- - -
-
-} -``` +Nothing changes on the client. Each pause raises one interrupt, the card matches on `kind` exactly as before, and the same `submit({ resume })` call answers it. A card that should show progress reads the counters straight out of the payload, which is the reason to put `step_number` and `total_steps` in there rather than deriving them. -
-
+ +`interrupt()` gives you the one interrupt a runtime-neutral UI needs. When a run pauses in several branches at once, `langGraphInterrupts()` returns the raw LangGraph array instead. + -## Typed Interrupt Payloads with BagTemplate +## Typed interrupt payloads with BagTemplate -By default, `interrupt()` returns an untyped value. The `BagTemplate` generic parameter on `injectAgent()` lets you define the exact shape of your interrupt payloads, giving you full TypeScript safety throughout your component. +By default an interrupt payload is untyped: the normalized `interrupt()` signal exposes `value` as `unknown`, and the example casts at the template boundary. The `BagTemplate` generic gives the raw signal a real type instead. -`BagTemplate` is an SDK type with an `InterruptType` slot. When you specify that slot, the raw `langGraphInterrupts()` signal is typed. The normalized runtime-neutral `interrupt()` signal still exposes `value` as `unknown`, so cast that payload at the UI boundary if you use the neutral signal. +Typing the bag means naming the agent, so this variation declares a ref with `createAgentRef()` and passes it to both `provideAgent()` and `injectAgent()`, where the example uses the unnamed form. ```typescript -import { injectAgent, BagTemplate } from '@threadplane/langgraph'; +import { injectAgent, type BagTemplate } from '@threadplane/langgraph'; import { createAgentRef } from '@threadplane/chat'; -// Define the exact shape of your interrupt payload -interface DeployApproval { - step_number: number; - total_steps: number; - step: string; - description: string; - completed: string[]; +// The exact shape the graph passes to interrupt() +interface RefundApproval { + kind: 'refund_approval'; + amount: number; + customer_id: string; + reason: string; } -type DeployBag = BagTemplate & { - InterruptType: DeployApproval; -}; +interface RefundState { + amount: number | null; + customer_id: string | null; + decision_approved: boolean | null; +} -// The ref carries the state shape; pass the BagTemplate at the inject site. -export const TYPED_DEPLOY_AGENT = createAgentRef('approval_agent'); +type RefundBag = BagTemplate & { + InterruptType: RefundApproval; +}; -const agent = injectAgent(TYPED_DEPLOY_AGENT); +// The ref carries the state shape; the bag is passed at the inject site. +export const REFUND_AGENT = createAgentRef('interrupts'); -const raw = agent.langGraphInterrupts(); -// ^? Interrupt[] +export class TypedApprovalComponent { + private readonly agent = injectAgent(REFUND_AGENT); -const step = agent.interrupt()?.value as DeployApproval | undefined; + readonly pending = this.agent.langGraphInterrupts(); + // ^? Interrupt[] -// TypeScript catches errors at compile time -const num = step?.step_number; // number — correct -const bad = step?.nonexistent; // Error — property doesn't exist + readonly amount = this.pending[0]?.value?.amount; // number — correct + readonly bad = this.pending[0]?.value?.nonexistent; // Error — property does not exist +} ``` -Define your interrupt payload interfaces alongside your Python state schema. This creates a contract between your agent and your UI. When the Python payload shape changes, the TypeScript interface should change too. Consider generating types from a shared schema to keep them in sync. +Define the payload interface next to your Python state schema. It is the contract between graph and UI, and when the Python payload changes the TypeScript interface has to change with it. Generating both from one schema is worth it once the payloads stop being trivial. -## Timeout Handling +## Timeout handling -Interrupts pause graph execution indefinitely by default — the agent waits until a human responds. In production, you often need to handle the case where no one responds within a reasonable time. There are two strategies for managing interrupt timeouts. +An interrupt pauses execution indefinitely by default: the agent waits until a human responds. In production you usually need a fallback for the case where nobody does. For me, the server-side timeout is the safer default. It costs you a background job to run and maintain, but it fires even if the user closed the tab — which is exactly when you most need it to. -**Server-side timeout with a background task:** Schedule a background job that checks for stale interrupts and resumes them with a default decision. +**Server-side timeout with a background task:** schedule a job that looks for stale interrupts and resumes them with a default decision. ```python async def check_stale_interrupts(): @@ -530,7 +221,7 @@ async def check_stale_interrupts(): if (now() - created).total_seconds() > 3600: # 1 hour timeout await client.runs.create( thread["thread_id"], - assistant_id="approval_agent", + assistant_id="interrupts", input=None, command={"resume": { "approved": False, @@ -539,34 +230,31 @@ async def check_stale_interrupts(): ) ``` -**Client-side timeout in Angular:** Use a timer in your component to auto-reject if the user does not act. +**Client-side timeout in Angular:** run a timer in the component and reject if the operator does not act. ```typescript import { effect } from '@angular/core'; import { timer } from 'rxjs'; // Watch for interrupts and start a timeout -effect(() => { - const interrupt = this.agent.interrupt(); - if (interrupt) { - const sub = timer(5 * 60 * 1000).subscribe(() => { - // Auto-reject after 5 minutes of inaction - this.agent.submit({ - resume: { approved: false, reason: 'Approval timeout' }, - }); - }); - // Clean up if user responds before timeout - return () => sub.unsubscribe(); - } +effect((onCleanup) => { + const pending = this.agent.interrupt(); + if (!pending) return; + const sub = timer(5 * 60 * 1000).subscribe(() => { + // Auto-reject after 5 minutes of inaction + void this.agent.submit({ resume: { approved: false } }); + }); + // Clean up if the operator responds before the timeout + onCleanup(() => sub.unsubscribe()); }); ``` -Avoid running both server-side and client-side timeouts simultaneously. If both fire, the second resume call will fail because the graph already moved past the interrupt. Choose server-side timeouts for reliability (works even if the browser closes) or client-side timeouts for immediacy. +Avoid running server-side and client-side timeouts together. If both fire, the second resume call fails because the graph already moved past the interrupt. Choose the server side for reliability, since it works even when the browser is closed, or the client side for immediacy. -Because interrupts are checkpointed, the user can close their browser, come back hours later, and still approve or reject the pending action. The graph state is frozen in the checkpoint store, not in browser memory. +Because interrupts are checkpointed, the operator can close the browser, come back hours later, and still approve or reject the pending action. The graph state is frozen in the checkpoint store, not in browser memory. ## What's Next diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index a2b87f992..33126d020 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -47,7 +47,6 @@ const PENDING_PAGES = new Set([ '/docs/deep-agents/capabilities/subagents', '/docs/langgraph/guides/deployment', '/docs/langgraph/guides/durable-execution', - '/docs/langgraph/guides/interrupts', '/docs/langgraph/guides/memory', '/docs/langgraph/guides/subgraphs', '/docs/langgraph/guides/time-travel', diff --git a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts index 288f227a6..2efc4d8de 100644 --- a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts +++ b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts @@ -93,6 +93,7 @@ const WELCOME_SUGGESTIONS = [
+ + `, }) export class InterruptsComponent { protected readonly suggestions = WELCOME_SUGGESTIONS; + // #region resume protected readonly editing = signal(false); protected readonly editAmount = signal(null); @@ -154,4 +157,5 @@ export class InterruptsComponent { this.editing.set(false); this.editAmount.set(null); } + // #endregion } diff --git a/cockpit/langgraph/interrupts/python/docs/guide.md b/cockpit/langgraph/interrupts/python/docs/guide.md deleted file mode 100644 index 362e2dede..000000000 --- a/cockpit/langgraph/interrupts/python/docs/guide.md +++ /dev/null @@ -1,146 +0,0 @@ -# Human-in-the-Loop Interrupts with Angular - - -Build a chat interface with human-in-the-loop approval using `provideAgent()` and -`injectAgent()` from `@threadplane/langgraph`. The LangGraph backend pauses execution for approval, -and the frontend resumes it with `stream.submit()`. - - - -Add human-in-the-loop approval to this Angular component using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. Use `stream.interrupt()` to display pending approvals, `stream.submit({ resume: true })` to approve and resume execution, and `stream.submit({ resume: false })` to reject. Bind `stream.messages()` in the template via the `` component from `@threadplane/chat`. - - - - - -Set up `provideAgent()` in your app config with the LangGraph API URL: - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'https://your-deployment.langgraph.app', - assistantId: 'interrupts', - }), - ], -}; -``` - -This makes the configured agent available to all `injectAgent()` calls in your app. - - - - -In your component, call `injectAgent()` to retrieve the configured interrupts agent: - -```typescript -// interrupts.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -export class InterruptsComponent { - protected readonly stream = injectAgent(); -} -``` - -The resource automatically handles streaming, interrupt detection, and state management. - - - - -Use `stream.interrupt()` to conditionally show a pending approval in the sidebar: - -```html - - -@if (stream.interrupt(); as interrupt) { - -} @else { -

No pending approvals

-} -``` - -When the graph pauses, `stream.interrupt()` returns the interrupt payload. When no interrupt is active, it returns a falsy value. - -
- - -Add methods that resume graph execution with the user's decision: - -```typescript -approve(): void { - this.stream.submit({ resume: true }); -} - -reject(): void { - this.stream.submit({ resume: false }); -} -``` - -Submitting a `resume` payload continues past an interrupt. Submitting `{ resume: false }` signals rejection so the graph can handle it accordingly. - - -You can extend this pattern to pass structured data back to the graph. For example, `stream.submit({ resume: true, edits: { ... } })` lets the user modify the response before approving. - - - - - -The backend uses `interrupt()` from `langgraph.types` to pause execution for human approval: - -```python -# graph.py -from langgraph.graph import StateGraph, MessagesState, END -from langgraph.checkpoint.memory import MemorySaver -from langgraph.types import interrupt - -checkpointer = MemorySaver() - -def build_interrupts_graph(): - llm = ChatOpenAI(model="gpt-5-mini", streaming=True) - - async def generate(state: MessagesState) -> dict: - response = await llm.ainvoke(state["messages"]) - return {"messages": [response]} - - async def check_approval(state: MessagesState) -> dict: - last_msg = state["messages"][-1] - interrupt(f"The assistant wants to respond: {last_msg.content[:100]}...") - return state - - graph = StateGraph(MessagesState) - graph.add_node("generate", generate) - graph.add_node("check_approval", check_approval) - graph.set_entry_point("generate") - graph.add_edge("generate", "check_approval") - graph.add_edge("check_approval", END) - return graph.compile(checkpointer=checkpointer) -``` - -The `interrupt()` call pauses the graph and sends the message string to the client. Execution resumes when the client calls `stream.submit()`. - - -A checkpointer is required for interrupts to work. Without it, the graph cannot save its state while paused. Use `MemorySaver` for development and `PostgresCheckpointer` for production. - - - -
- - -The `` component handles message rendering, input, loading states, and error display. Focus your component on interrupt handling logic. - - - -Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. - - - -- [Chat Interrupts](/chat/core-capabilities/interrupts/overview/python) — Learn how ChatInterruptsComponent handles human-in-the-loop approval flows - diff --git a/cockpit/langgraph/interrupts/python/src/graph.py b/cockpit/langgraph/interrupts/python/src/graph.py index 4f75bf405..6ee614b75 100644 --- a/cockpit/langgraph/interrupts/python/src/graph.py +++ b/cockpit/langgraph/interrupts/python/src/graph.py @@ -21,6 +21,7 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# region state class RefundDraft(BaseModel): """Structured fields the agent extracts from the refund request.""" @@ -36,9 +37,11 @@ class RefundState(TypedDict): reason: Optional[str] decision_approved: Optional[bool] refund_id: Optional[str] +# endregion def build_interrupts_graph(): + # region draft llm = ChatOpenAI(model="gpt-5-mini", streaming=True) extractor = ChatOpenAI(model="gpt-5-mini").with_structured_output(RefundDraft) @@ -65,7 +68,9 @@ async def draft_refund(state: RefundState) -> dict: "amount": draft.amount, "reason": draft.reason, } + # endregion + # region request-approval def request_approval(state: RefundState) -> dict: """Pause for human approval. Resume value is { approved: bool, amount?: number }.""" amount = state.get("amount") or 0.0 @@ -91,7 +96,9 @@ def request_approval(state: RefundState) -> dict: "decision_approved": True, "amount": final_amount, } + # endregion + # region graph def issue_refund(state: RefundState) -> dict: """Stand-in for the real Stripe call. Logs a fake refund ID.""" customer_id = state.get("customer_id") or "anon" @@ -115,6 +122,7 @@ def route_after_approval(state: RefundState) -> str: graph.add_edge("issue", END) return graph.compile() + # endregion graph = build_interrupts_graph() From 72485094d5002783c69bbcd98e9a28b85a1570e4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:04:13 -0700 Subject: [PATCH 08/21] =?UTF-8?q?docs(langgraph):=20persistence=20page=20?= =?UTF-8?q?=E2=80=94=20drop=20the=20broken=20fork=20snippet;=20one=20recei?= =?UTF-8?q?ver=20name;=20US=20spelling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/persistence.mdx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/persistence.mdx b/apps/website/content/docs/langgraph/guides/persistence.mdx index f977e37e6..769d45c63 100644 --- a/apps/website/content/docs/langgraph/guides/persistence.mdx +++ b/apps/website/content/docs/langgraph/guides/persistence.mdx @@ -14,7 +14,7 @@ Make sure you have completed the Installation guide first. The Run tab shows the prebuilt `` composition next to a thread sidebar. Send a message and the backend assigns a thread ID, which appears in the sidebar as "Thread 1". Click "+ New Thread" and send another message, and you have two conversations you can move between. -Switching back to an earlier thread replays its stored history: the messages come back from the server checkpoint, not from anything the browser kept. The welcome suggestion, "Start a saved thread", asks the agent to draft a project brief you can revisit, which gives each thread enough content to recognise in the sidebar. +Switching back to an earlier thread replays its stored history: the messages come back from the server checkpoint, not from anything the browser kept. The welcome suggestion, "Start a saved thread", asks the agent to draft a project brief you can revisit, which gives each thread enough content to recognize in the sidebar. ## How it is built @@ -65,7 +65,7 @@ Module scope works here because the demo bootstraps exactly one component instan -`onThreadId` fires whenever the backend assigns a thread ID. The callback records it as the active thread and appends it to the list if it is new, which is the only bookkeeping the sidebar needs. +`onThreadId` fires when the backend creates a thread and reports its ID, which is why the callback checks whether it already knows the ID before adding it. The callback records it as the active thread and appends it to the list if it is new, which is the only bookkeeping the sidebar needs. Never generate a thread ID client-side. Always use the value handed to `onThreadId`, or a value the LangGraph Threads API returned earlier. @@ -197,18 +197,7 @@ Use the `isThreadLoading()` signal to show a skeleton UI while `injectAgent()` f ## Forking a conversation -`switchThread()` also composes into a fork, which the example does not do: start a fresh thread and resubmit the messages you already have. - -```typescript -forkConversation() { - this.agent.switchThread(null); - this.agent.submit({ - messages: this.agent.messages(), - }); -} -``` - -For a fork that branches from an earlier checkpoint rather than from the end of the conversation, see the [Time Travel guide](/docs/langgraph/guides/time-travel). +To fork, capture `agent.messages()` first, start a fresh thread with `switchThread(null)`, and resubmit the captured history as a `state` patch. For a fork that branches from an earlier checkpoint, use the [Time Travel guide](/docs/langgraph/guides/time-travel). ## Checkpoint recovery @@ -216,7 +205,7 @@ When a connection drops mid-stream, `joinStream()` reconnects to an in-progress ```typescript // Rejoin a running stream after a network interruption -await chat.joinStream(runId, lastEventId); +await this.agent.joinStream(runId, lastEventId); // Picks up from the last event — no duplicate agent execution ``` From f2d1c03d06a1305b10c7004635caa49cdf66f431 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:09:30 -0700 Subject: [PATCH 09/21] docs(langgraph): memory teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../content/docs/langgraph/guides/memory.mdx | 413 ++++++------------ .../website/src/lib/docs-example-code.spec.ts | 1 - .../angular/src/app/memory.component.ts | 4 + cockpit/langgraph/memory/python/docs/guide.md | 164 ------- cockpit/langgraph/memory/python/src/graph.py | 8 + 5 files changed, 142 insertions(+), 448 deletions(-) delete mode 100644 cockpit/langgraph/memory/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/memory.mdx b/apps/website/content/docs/langgraph/guides/memory.mdx index 5db86cf8f..e4b3f9618 100644 --- a/apps/website/content/docs/langgraph/guides/memory.mdx +++ b/apps/website/content/docs/langgraph/guides/memory.mdx @@ -1,248 +1,176 @@ +--- +description: How the memory example keeps learned facts in graph state, extracts them after every reply, and renders them in a live sidebar with value() +--- + # Memory -Memory gives your LangGraph agent the ability to recall past interactions, user preferences, and learned facts. There are two distinct kinds: short-term memory scoped to a single thread (conversation), and long-term memory that persists across threads using the LangGraph Store API. `injectAgent()` surfaces both through Angular Signals so your components stay reactive without manual state wiring. +Memory gives your LangGraph agent the ability to recall past interactions, user preferences, and learned facts. There are two distinct kinds: short-term memory scoped to a single thread (conversation), and long-term memory that persists across threads using the LangGraph Store API. `injectAgent()` surfaces graph state through Angular Signals, so a memory panel needs no manual state wiring. The running example is a chat that learns facts about you as you talk, and this guide walks the three files that make it work. Short-term memory lives within a thread — it is the conversation history plus any custom state fields your agent accumulates during a run. Long-term memory lives in the LangGraph Store and survives across threads, users, and sessions. Think of short-term as "what happened in this conversation" and long-term as "what the agent knows about this user." -## Agent State with Custom Memory Fields +## What the demo does -Every LangGraph agent has a state schema. You control what the agent remembers by adding fields to that schema. Messages accumulate automatically, but you can define any additional fields the agent should track. +The Run tab shows the prebuilt `` composition beside a sidebar titled Learned Facts, which starts out saying that no facts have been learned yet. Tell the agent something about yourself — your name, where you live, the framework you work in — and the reply streams in first, then the sidebar fills with the facts the agent decided were worth keeping, one key and value per row. - - +Two things are worth trying. Keep going for a few turns and watch the list grow rather than reset, because each pass merges new facts into the ones already known. Then ask the agent what it remembers about you: the facts in the sidebar are injected into the system prompt of the next reply, so it answers from the same list you are looking at. -```python -import json -from typing_extensions import TypedDict, Annotated -from operator import add -from langgraph.graph import END, START, StateGraph -from langchain_openai import ChatOpenAI +The memory in this demo lives in the thread's state, which makes it thread-scoped. Memory that has to follow a user from one conversation to the next is the Store API, further down this page. -llm = ChatOpenAI(model="gpt-5-mini") +## How it is built -def parse_json(text: str) -> dict: - """Your own JSON-extraction helper — here, a plain json.loads.""" - return json.loads(text) +Three files carry the whole feature: a graph that answers and then extracts, an application config that registers the agent, and a component that derives a signal from the state the graph returns. Open the Code tab to read them in place. -class State(TypedDict): - messages: Annotated[list, add] - user_preferences: dict # Accumulated user preferences - conversation_summary: str # Rolling summary of past context - mentioned_topics: list[str] # Topics the user has brought up - -def call_model(state: State) -> dict: - system = "You are a helpful assistant." - if state.get("conversation_summary"): - system += f"\n\nPrevious context: {state['conversation_summary']}" - if state.get("user_preferences"): - system += f"\n\nUser preferences: {state['user_preferences']}" +### The state the graph carries - response = llm.invoke([ - {"role": "system", "content": system}, - *state["messages"] - ]) - return {"messages": [response]} +The state is the mechanism. Alongside the message list the graph keeps a plain `memory` dict, and because it is a state field it is checkpointed with the thread and streamed to the browser on every update. -def update_memory(state: State) -> dict: - """Extract preferences and topics from the latest exchange.""" - extraction = llm.invoke([ - {"role": "system", "content": ( - "Extract any user preferences and topics from " - "this conversation. Return JSON with keys: " - "preferences (dict), topics (list[str]), summary (str)." - )}, - *state["messages"][-4:] # Last two exchanges - ]) - parsed = parse_json(extraction.content) - return { - "user_preferences": { - **state.get("user_preferences", {}), - **parsed.get("preferences", {}), - }, - "mentioned_topics": parsed.get("topics", []), - "conversation_summary": parsed.get("summary", ""), - } - -builder = StateGraph(State) -builder.add_node("model", call_model) -builder.add_node("update_memory", update_memory) -builder.add_edge(START, "model") -builder.add_edge("model", "update_memory") -builder.add_edge("update_memory", END) + -graph = builder.compile() -``` +The values are ordinary JSON, which is what lets the Angular side render them without knowing anything about their shape. - - +### Answering with what is already known -```typescript -import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import { createAgentRef } from '@threadplane/chat'; -import type { BaseMessage } from '@langchain/core/messages'; - -interface AgentState { - messages: BaseMessage[]; - user_preferences: Record; - conversation_summary: string; - mentioned_topics: string[]; -} - -export const MEMORY_AGENT = createAgentRef('memory_agent'); - -// Configure in app.config.ts: -// provideAgent(MEMORY_AGENT, { -// apiUrl: '...', -// threadId: signal(localStorage.getItem('memory-thread')), -// onThreadId: (id) => localStorage.setItem('memory-thread', id), -// }); - -@Component({ - selector: 'app-memory-chat', - templateUrl: './memory.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MemoryChatComponent { - protected readonly agent = injectAgent(MEMORY_AGENT); - - // Reactive memory signals derived from agent state - preferences = computed(() => this.agent.value()?.user_preferences ?? {}); - summary = computed(() => this.agent.value()?.conversation_summary ?? ''); - topics = computed(() => this.agent.value()?.mentioned_topics ?? []); - messages = computed(() => this.agent.messages()); - - send(input: string) { - this.agent.submit({ message: input }); - } -} -``` +The first node reads the current `memory` dict and folds it into the system prompt as a bulleted list of facts before it calls the model. That is the entire recall mechanism: no retrieval step, no vector index, just state read back into the prompt. + + - - - -```html -
- @for (msg of messages(); track msg) { -
{{ msg.content }}
- } - - @if (agent.isLoading()) { -
Agent is thinking...
- } -
- - - +The model is constructed with `streaming=True`, so the answer arrives token by token while the extraction pass has not yet run. + +### Extracting new facts + +The second node runs after the reply. It builds a transcript from the last few messages, asks a non-streaming model to return only a JSON object of new or updated facts, parses that defensively, and merges the result over what was already known. + + + +Two details are load-bearing. The extraction prompt is given the current facts and told not to repeat them, so each pass returns a small delta. And the node returns `{}` when it learns nothing, which leaves the existing `memory` value untouched. + + +A node's return value replaces the state key it names unless that field declares a reducer. `extract_memory` therefore builds `{**current_memory, **new_facts}` by hand rather than returning only the new facts, which would drop everything learned earlier. + + +### Wiring the two nodes + +The graph is a straight line: generate, then extract, then end. Putting extraction after the reply keeps it off the path the user waits on. + + + +`compile()` is called with no checkpointer, because the LangGraph API server provides persistence and rejects a graph that brings its own. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you embed in your own process. + +### The agent provider + +`provideAgent()` registers the agent once for the whole application, and `provideChat({})` registers the configuration the `` composition reads, here left at its defaults. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo. + + + +Your own application does not need the factory. Pass the two values directly: + +```typescript +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'memory', +}); ``` -
-
+`assistantId` must match the graph name in `langgraph.json`. - -When `update_memory` returns `user_preferences`, the dict is merged into the existing state. For list fields using the `Annotated[list, add]` reducer, new items are appended. Design your state schema with these merge semantics in mind. + +Never expose a LangSmith API key in client-side code. Point `apiUrl` at a deployment that authenticates the browser another way, or proxy the requests through your own server and attach the key there. -## Short-Term Memory (Thread-Scoped) +### Reading memory with value() + +`injectAgent()` returns the agent registered above, and `value()` is the full graph state as a signal — messages, `memory`, and any other field the state schema declares. The component derives the sidebar rows from it with `computed()`, casting the state to the shape it expects and returning an empty list until a `memory` object exists. + + -Short-term memory is the simplest form: the conversation history and any accumulated state fields within a single thread. Every message, tool call, and state update is automatically checkpointed. When a user reconnects with the same `threadId`, the full history is restored. +No extra request is involved. The `memory` dict rides along with the state updates the run is already streaming, so `memoryEntries` recomputes the moment the extraction node commits. -Let's see it in Python first, then on the Angular side. +### Rendering the facts + +The template is the prebuilt `` in the main slot and the fact list in the sidebar slot. Both read signals directly, so nothing subscribes and nothing is torn down by hand. + + + + +`injectAgent()` must run inside an Angular injection context: a field initializer, as it is here, or a constructor body. + + +## Designing the state schema + +The example keeps one memory field because one is enough to show the mechanism. A larger agent usually splits what it remembers into fields with different lifetimes: a rolling summary that is rewritten every turn, preferences that accumulate, topics that append. ```python -from langgraph.checkpoint.postgres import PostgresSaver +from operator import add +from typing import Annotated, TypedDict + +from langchain_core.messages import BaseMessage + + +class State(TypedDict): + messages: Annotated[list[BaseMessage], add] + user_preferences: dict # Merged by the node that writes it + conversation_summary: str # Replaced on every write + mentioned_topics: Annotated[list[str], add] # Appended by the reducer +``` + +The `Annotated[..., add]` fields append on every write; the plain fields are replaced by whatever a node returns for them. Choosing the reducer per field is how you decide, once, whether a node adds to memory or overwrites it. -checkpointer = PostgresSaver.from_connection_string(DATABASE_URL) -graph = builder.compile(checkpointer=checkpointer) +## Short-term memory (thread-scoped) -# Every invocation within the same thread accumulates state +Short-term memory is the kind the demo uses: the conversation history plus the state fields accumulated inside one thread. Every message and state update is checkpointed, so reconnecting with the same `threadId` restores both the transcript and the custom fields. + +```python +# Same thread, two invocations — the second one sees what the first learned result = graph.invoke( {"messages": [{"role": "user", "content": "I prefer dark mode"}]}, - config={"configurable": {"thread_id": "user_42_session"}} + config={"configurable": {"thread_id": "user_42_session"}}, ) -# Later invocation — same thread, memory intact result = graph.invoke( {"messages": [{"role": "user", "content": "What theme do I like?"}]}, - config={"configurable": {"thread_id": "user_42_session"}} + config={"configurable": {"thread_id": "user_42_session"}}, ) -# Agent responds: "You mentioned you prefer dark mode." ``` -On the Angular side, thread-scoped memory requires no extra code. The `threadId` signal handles it — configure it once in `provideAgent({...})`: +On the Angular side this takes no extra code beyond naming the thread. Pass a `threadId` to `provideAgent()`, as a signal when it changes at runtime: ```typescript -// agent.ts (shared) -// export const MEMORY_AGENT = createAgentRef('memory_agent'); +const threadId = signal(localStorage.getItem('memory-thread')); -// app.config.ts -provideAgent(MEMORY_AGENT, { - apiUrl: '...', - threadId: signal(userId()), // Same user = same thread = same memory +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'memory', + threadId, + onThreadId: (id) => localStorage.setItem('memory-thread', id), }); - -// component.ts -const chat = injectAgent(MEMORY_AGENT); - -// chat.messages() restores full history on reconnect -// chat.value() restores all custom state fields ``` -## Long-Term Memory (Cross-Thread) with the Store API +`agent.messages()` restores the full history on reconnect and `agent.value()` restores every custom state field with it. The [Persistence guide](/docs/langgraph/guides/persistence) covers thread switching and checkpointer configuration in full. -Short-term memory disappears when you start a new thread. For knowledge that should persist across conversations — user preferences, learned facts, project context — use the LangGraph Store API. The Store is a key-value layer that any node can read from and write to, independent of the current thread. +## Long-term memory (cross-thread) with the Store API - - +Thread state disappears when you start a new thread. For knowledge that should outlive a conversation — preferences, learned facts, project context — use the LangGraph Store API. The Store is a key-value layer that any node can read from and write to, independent of the current thread, and a node opts into it by declaring a `store` parameter. ```python import json from uuid import uuid4 + from langgraph.graph import END, START, StateGraph, MessagesState from langgraph.store.base import BaseStore from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-5-mini") -def parse_json(text: str) -> dict: - """Your own JSON-extraction helper — here, a plain json.loads.""" - return json.loads(text) def recall_memories(state: MessagesState, *, store: BaseStore, config) -> dict: """Load long-term memories for this user before responding.""" user_id = config["configurable"]["user_id"] - # Fetch all memories in this user's namespace + # Fetch every memory in this user's namespace memories = store.search(("memories", user_id)) - memory_text = "\n".join( - f"- {m.value['content']}" for m in memories - ) + memory_text = "\n".join(f"- {m.value['content']}" for m in memories) system = ( "You are a helpful assistant with long-term memory.\n\n" @@ -250,10 +178,11 @@ def recall_memories(state: MessagesState, *, store: BaseStore, config) -> dict: ) response = llm.invoke([ {"role": "system", "content": system}, - *state["messages"] + *state["messages"], ]) return {"messages": [response]} + def save_memories(state: MessagesState, *, store: BaseStore, config) -> dict: """Extract and persist new facts to the Store.""" user_id = config["configurable"]["user_id"] @@ -264,19 +193,15 @@ def save_memories(state: MessagesState, *, store: BaseStore, config) -> dict: "exchange. Return a JSON list of strings. " "Return [] if nothing new." )}, - *state["messages"][-4:] + *state["messages"][-4:], ]) - facts = parse_json(extraction.content) - for fact in facts: - store.put( - ("memories", user_id), - key=str(uuid4()), - value={"content": fact}, - ) + for fact in json.loads(extraction.content): + store.put(("memories", user_id), key=str(uuid4()), value={"content": fact}) return {} + builder = StateGraph(MessagesState) builder.add_node("recall", recall_memories) builder.add_node("save", save_memories) @@ -287,88 +212,32 @@ builder.add_edge("save", END) graph = builder.compile() ``` - - +The identity the nodes namespace by has to come from the client. Pass it per run through `submit()`, which forwards a `config` to LangGraph: ```typescript -import { Component, computed, signal, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import type { BaseMessage } from '@langchain/core/messages'; - -// Configure in app.config.ts so each conversation gets a new thread, -// but the agent remembers the user across all of them via the Store: -// provideAgent({ -// apiUrl: '...', -// assistantId: 'memory_agent', -// config: { configurable: { user_id: 'user_42' } }, -// }); - -@Component({ - selector: 'app-longterm-chat', - templateUrl: './memory.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class LongTermChatComponent { - protected readonly agent = injectAgent(); - - messages = computed(() => this.agent.messages()); - - send(input: string) { - this.agent.submit({ message: input }); - } -} -``` - - - - -```html -
- @for (msg of messages(); track msg) { -
{{ msg.content }}
- } - - @if (agent.isLoading()) { -
Thinking...
- } - -
- - -
-
+agent.submit( + { message: text }, + { config: { configurable: { user_id: 'user_42' } } }, +); ``` -
-
- -The checkpointer saves thread state (short-term memory). The Store saves cross-thread knowledge (long-term memory). They serve different purposes and you will typically use both. The checkpointer is configured at compile time; the Store is injected into nodes that declare a `store` parameter. +The checkpointer saves thread state, which is the short-term memory the example uses. The Store saves cross-thread knowledge. They serve different purposes and you will typically use both. The checkpointer is configured where the graph is served; the Store is injected into nodes that declare a `store` parameter. -## Semantic Memory with Vector Search +## Semantic memory with vector search -For agents that accumulate hundreds or thousands of memories, keyword matching isn't enough. The Store API supports semantic search with embeddings, so your agent can retrieve the most relevant memories for any given context. +For agents that accumulate hundreds or thousands of memories, keyword matching is not enough. The Store API supports semantic search with embeddings, so your agent can retrieve the most relevant memories for any given context. ```python -from langchain_openai import OpenAIEmbeddings -from langgraph.store.base import BaseStore - def recall_relevant(state: MessagesState, *, store: BaseStore, config) -> dict: """Retrieve memories semantically related to the current question.""" user_id = config["configurable"]["user_id"] query = state["messages"][-1].content # Vector search — returns memories ranked by cosine similarity - results = store.search( - ("memories", user_id), - query=query, - limit=5, - ) - - memory_text = "\n".join( - f"- [{r.score:.2f}] {r.value['content']}" for r in results - ) + results = store.search(("memories", user_id), query=query, limit=5) + memory_text = "\n".join(f"- [{r.score:.2f}] {r.value['content']}" for r in results) response = llm.invoke([ {"role": "system", "content": ( @@ -376,43 +245,21 @@ def recall_relevant(state: MessagesState, *, store: BaseStore, config) -> dict: f"{memory_text}\n\n" "Use these memories to personalize your response." )}, - *state["messages"] + *state["messages"], ]) return {"messages": [response]} ``` -The `store.search()` call accepts a `query` string and returns results ranked by vector similarity. You control how many results to retrieve with the `limit` parameter. Each result includes a `score` field (0 to 1) indicating how relevant the memory is to the query. +The `store.search()` call accepts a `query` string and returns results ranked by vector similarity. You control how many results to retrieve with the `limit` parameter. Each result includes a `score` field indicating how relevant the memory is to the query. Semantic search requires an embedding model configured on the Store. LangGraph Platform handles this configuration in `langgraph.json`. When running locally, pass the embeddings provider when constructing your Store instance. -## Surfacing Memory in Angular with value() - -The `value()` signal is the primary way memory surfaces in your Angular components. It contains the full agent state object, including all custom memory fields. Because it's a Signal, your template re-renders automatically through OnPush change detection whenever the agent state changes. - -```typescript -// The value() signal contains everything the agent knows -const state = agent.value(); - -// Access specific memory fields -const prefs = state?.user_preferences; -const summary = state?.conversation_summary; -const topics = state?.mentioned_topics; - -// Compose derived signals for template binding -const hasMemory = computed(() => { - const val = agent.value(); - return val?.conversation_summary || val?.mentioned_topics?.length; -}); -``` - -For long-term memory stored in the Store, the agent must explicitly include retrieved memories in its response or state output. The Store lives server-side; your Angular app only sees what the agent puts into the thread state. - -## Memory Best Practices +## Memory best practices -Every field in your state schema is persisted by the checkpointer. Only include fields the agent actively uses. Avoid dumping raw LLM outputs into state — extract structured data instead. +Every field in your state schema is persisted by the checkpointer. Only include fields the agent actively uses. Avoid dumping raw LLM outputs into state — extract structured data instead, as the example does when it insists on a JSON object of key-value facts. diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 33126d020..5755807c1 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -47,7 +47,6 @@ const PENDING_PAGES = new Set([ '/docs/deep-agents/capabilities/subagents', '/docs/langgraph/guides/deployment', '/docs/langgraph/guides/durable-execution', - '/docs/langgraph/guides/memory', '/docs/langgraph/guides/subgraphs', '/docs/langgraph/guides/time-travel', '/docs/render/api/provide-render', diff --git a/cockpit/langgraph/memory/angular/src/app/memory.component.ts b/cockpit/langgraph/memory/angular/src/app/memory.component.ts index c6dc5b6a7..06157da62 100644 --- a/cockpit/langgraph/memory/angular/src/app/memory.component.ts +++ b/cockpit/langgraph/memory/angular/src/app/memory.component.ts @@ -42,6 +42,7 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; template: ` +

Learned Facts

@if (memoryEntries().length === 0) { @@ -54,10 +55,12 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
} +
`, }) export class MemoryComponent { + // #region memory-signal /** * The streaming resource connected to the memory graph. * @@ -78,4 +81,5 @@ export class MemoryComponent { if (!mem || typeof mem !== 'object') return []; return Object.entries(mem as Record); }); + // #endregion } diff --git a/cockpit/langgraph/memory/python/docs/guide.md b/cockpit/langgraph/memory/python/docs/guide.md deleted file mode 100644 index eef21a7f0..000000000 --- a/cockpit/langgraph/memory/python/docs/guide.md +++ /dev/null @@ -1,164 +0,0 @@ -# Cross-Thread Persistent Memory with Angular - - -Build a chat interface where the agent actively learns and remembers facts about the user. -The LangGraph backend maintains a `memory` dict in graph state, updated by an `extract_memory` -node after each exchange. The Angular component reads the `memory` field from `stream.value()` -and displays it in a live sidebar. - - - -Add persistent agent memory to this Angular component using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. Use `stream.value()` to access the `memory` field in graph state, derive a reactive `memoryEntries` signal with Angular's `computed()`, and render the facts in a sidebar panel beside the `` component from `@threadplane/chat`. - - - - - -Set up `provideAgent()` in your app config with the LangGraph API URL: - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'https://your-deployment.langgraph.app', - assistantId: 'memory', - }), - ], -}; -``` - - - - -In your component, call `injectAgent()` to retrieve the configured `memory` assistant: - -```typescript -// memory.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -export class MemoryComponent { - protected readonly stream = injectAgent(); -} -``` - - - - -Use Angular's `computed()` to derive a reactive list of key-value pairs from the -`memory` field in the graph state returned by `stream.value()`: - -```typescript -import { computed } from '@angular/core'; - -protected readonly memoryEntries = computed(() => { - const state = this.stream.value() as { memory?: Record } | null; - const memory = state?.memory ?? {}; - return Object.entries(memory).map(([key, value]) => ({ - key, - value: typeof value === 'string' ? value : JSON.stringify(value), - })); -}); -``` - -`stream.value()` exposes the full graph state on every update event, so `memoryEntries` -updates reactively as the agent learns new facts mid-conversation. - - -Cast the return type of `stream.value()` to your state shape to get proper type inference. - - - - - -Use the `` component from `@threadplane/chat` and render a sibling memory panel: - -```html - - - -``` - -Facts appear in the sidebar in real time as the agent learns them. - - - - -Define a custom `MemoryState` that extends messages with a `memory` dict, then -wire two nodes - `generate` and `extract_memory` - in sequence: - -```python -# graph.py -from typing import TypedDict -from langgraph.graph import StateGraph, END -from langgraph.checkpoint.memory import MemorySaver - -class MemoryState(TypedDict): - messages: list - memory: dict # {"user_name": "Alice", "location": "NYC", ...} - -checkpointer = MemorySaver() - -def build_memory_graph(): - llm = ChatOpenAI(model="gpt-5-mini", streaming=True) - - async def generate(state: MemoryState) -> dict: - memory = state.get("memory", {}) - # Inject known facts into the system prompt - ... - response = await llm.ainvoke(messages) - return {"messages": [response]} - - async def extract_memory(state: MemoryState) -> dict: - # Ask the LLM to extract new facts as JSON - ... - return {"memory": updated_memory} - - graph = StateGraph(MemoryState) - graph.add_node("generate", generate) - graph.add_node("extract_memory", extract_memory) - graph.set_entry_point("generate") - graph.add_edge("generate", "extract_memory") - graph.add_edge("extract_memory", END) - return graph.compile(checkpointer=checkpointer) -``` - -The `extract_memory` node runs after every `generate` call, keeping the memory -dict fresh without adding latency to the user-facing reply. - - -For production, replace `MemorySaver` with `PostgresCheckpointer` so memory -survives server restarts and scales across workers. - - - - - - -The `memory` dict is part of graph state and is streamed back to the client on -every state update. There is no separate API call needed - just read `stream.value()`. - - - -Never expose your LangSmith API key in client-side code. Use server-side environment -variables or a proxy. - - - -- [Chat Messages](/chat/core-capabilities/messages/overview/python) - Learn how ChatMessageListComponent renders messages -- [Chat Threads](/chat/core-capabilities/threads/overview/python) - Learn how ChatThreadsComponent manages conversation threads - diff --git a/cockpit/langgraph/memory/python/src/graph.py b/cockpit/langgraph/memory/python/src/graph.py index ad09d9896..7c7b898ff 100644 --- a/cockpit/langgraph/memory/python/src/graph.py +++ b/cockpit/langgraph/memory/python/src/graph.py @@ -22,9 +22,11 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# region state class MemoryState(TypedDict): messages: list[BaseMessage] memory: dict # {"user_name": "Alice", "preferences": {...}, ...} +# endregion def build_memory_graph(): @@ -40,6 +42,7 @@ def build_memory_graph(): llm = ChatOpenAI(model="gpt-5-mini", streaming=True) extractor_llm = ChatOpenAI(model="gpt-5-mini", streaming=False) + # region generate async def generate(state: MemoryState) -> dict: """Generate a response using current messages and known memory.""" system_prompt = (PROMPTS_DIR / "memory.md").read_text() @@ -53,7 +56,9 @@ async def generate(state: MemoryState) -> dict: messages = [SystemMessage(content=system_prompt)] + list(state["messages"]) response = await llm.ainvoke(messages) return {"messages": [response]} + # endregion + # region extract-memory async def extract_memory(state: MemoryState) -> dict: """ Scan the latest exchange for facts worth remembering. @@ -101,7 +106,9 @@ async def extract_memory(state: MemoryState) -> dict: updated_memory = {**current_memory, **new_facts} return {"memory": updated_memory} + # endregion + # region graph graph = StateGraph(MemoryState) graph.add_node("generate", generate) graph.add_node("extract_memory", extract_memory) @@ -109,6 +116,7 @@ async def extract_memory(state: MemoryState) -> dict: graph.add_edge("generate", "extract_memory") graph.add_edge("extract_memory", END) return graph.compile() + # endregion # The graph instance — referenced by langgraph.json From 6411c13ed4a9ed3b3904f8fc7a8a5493aefe4f4e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:11:12 -0700 Subject: [PATCH 10/21] =?UTF-8?q?docs(langgraph):=20streaming=20page=20?= =?UTF-8?q?=E2=80=94=20compile-safe=20retry=20snippet,=20honest=20ref-file?= =?UTF-8?q?=20note,=20stream-mode=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../content/docs/langgraph/guides/streaming.mdx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/streaming.mdx b/apps/website/content/docs/langgraph/guides/streaming.mdx index ff255a6c2..80f2e5307 100644 --- a/apps/website/content/docs/langgraph/guides/streaming.mdx +++ b/apps/website/content/docs/langgraph/guides/streaming.mdx @@ -18,7 +18,7 @@ Two welcome suggestions are wired up. "Stream a long answer" asks for a 200-word ## How it is built -Three files carry the visible integration: the graph that streams, the provider that points Angular at it, and the component that renders it. A fourth, two-line file declares the typed agent ref they share. Open the Code tab to read the three in place. +Three files carry the visible integration: the graph that streams, the provider that points Angular at it, and the component that renders it. A fourth, small file declares the typed agent ref they share. Open the Code tab to read the three in place. ### The streaming graph @@ -30,11 +30,15 @@ The node awaits a single `ainvoke` call. `streaming=True` is what lets the model ### The agent provider -`provideAgent()` registers the agent once for the whole application, keyed by the typed ref declared in `agent-ref.ts`. That file is two lines of code: +`provideAgent()` registers the agent once for the whole application, keyed by the typed ref declared in `agent-ref.ts`. The ref itself is one line, typed by a state interface declared beside it: ```typescript import { createAgentRef } from '@threadplane/chat'; +export interface StreamingState { + messages: unknown[]; +} + export const STREAMING_AGENT = createAgentRef('streaming'); ``` @@ -88,7 +92,7 @@ The connection was interrupted or the agent returned an error. Inspect `error()` ## Stream modes -On the server, `astream()` — and `astream_events()` for raw run events — decides what a run emits. The three modes you will meet most often: +On the server, `astream()` decides what a run emits, and `astream_events()` adds raw run events. The three modes you will meet most often: ```python async def main(): @@ -105,7 +109,7 @@ async def main(): print(event["event"], event.get("data")) ``` -The client-side name for the `messages` mode is `messages-tuple`, which is how it appears in the `streamMode` lists below. +The client asks for `messages-tuple` to receive the token tuples that `stream_mode="messages"` produces on the server; `messages` is a separate client mode, so the lists below name `messages-tuple`. You rarely call those directly from an Angular app. By default, `injectAgent()` asks LangGraph Platform for the stream modes it needs to populate its public signals: `values`, `messages-tuple`, `updates`, and `custom`. It also enables `streamSubgraphs` so namespaced subgraph events can reach the client. @@ -172,6 +176,7 @@ If the SSE connection drops or the agent throws, `status()` flips to `'error'` a ```typescript import { computed } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { STREAMING_AGENT } from './agent-ref'; export class ChatComponent { protected readonly chat = injectAgent(STREAMING_AGENT); @@ -180,7 +185,7 @@ export class ChatComponent { retry() { // Re-stream using the same thread so context is preserved - this.chat.submit(); + this.chat.submit(null); } } ``` @@ -200,7 +205,7 @@ export class ChatComponent { -Calling `submit()` with no input opens a fresh stream against the current thread state without adding a new user message — the server resumes the run from where it left off, which is how you recover after an error. Pass `submit({ message })` only when you have new input to send. If you instead want to replay the exact last input you submitted, call `chat.reload()`. +Calling `submit(null)` opens a fresh stream against the current thread state without adding a new user message. That resumes the run only when the thread still has pending work, such as an interrupted or failed run; after a run that finished there is nothing left to execute and the call is a no-op, so `chat.reload()` is the reliable retry. Pass `submit({ message })` only when you have new input to send. `error()` surfaces both transport-level failures (lost connection, 5xx) and application-level errors returned by the agent graph. Check `error().cause` for the underlying HTTP status when you need to distinguish them. From 9de0add9f470a142306b5dce2cb42ee8c940a01c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:12:22 -0700 Subject: [PATCH 11/21] =?UTF-8?q?docs(langgraph):=20interrupts=20page=20?= =?UTF-8?q?=E2=80=94=20checkpointer=20facts=20per=20platform=20variant;=20?= =?UTF-8?q?narrower=20resume=20region?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../content/docs/langgraph/guides/interrupts.mdx | 10 +++++----- .../interrupts/angular/src/app/interrupts.component.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/interrupts.mdx b/apps/website/content/docs/langgraph/guides/interrupts.mdx index e516ebff0..f1773d427 100644 --- a/apps/website/content/docs/langgraph/guides/interrupts.mdx +++ b/apps/website/content/docs/langgraph/guides/interrupts.mdx @@ -44,7 +44,7 @@ Splitting extraction from narration is deliberate: the card needs typed fields, -Notice the `kind` field on the payload. It is not required by LangGraph; it is how the frontend tells this interrupt apart from any other one the graph might raise. Notice too that the node validates what came back: a resume value that is not a dictionary, or one without `approved`, is treated as a rejection. +Notice the `kind` field on the payload. It is not required by LangGraph; it is how the frontend tells this interrupt apart from any other one the graph might raise. Notice too that the node validates what came back: a resume value that is not a dictionary, or one whose `approved` is missing or false, is treated as a rejection. LangGraph resumes by running the interrupting node again, and `interrupt()` returns the resume value instead of pausing a second time. Every line above the `interrupt()` call therefore runs twice, so keep that stretch free of side effects. Reading state, as the example does, is safe; charging a card there is not. @@ -56,10 +56,10 @@ After the pause the graph branches. A conditional edge sends an approved refund -`compile()` is called with no checkpointer, because the LangGraph API server provides persistence and rejects a graph that brings its own. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you embed in your own process. +`compile()` is called with no checkpointer, because the LangGraph API server provides persistence and either rejects or ignores a graph that brings its own. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you embed in your own process. -A paused graph is a stored checkpoint. Serving through `langgraph dev` or LangGraph Platform gives you that for free, and passing your own saver there is an error. Embedding the graph in your own process is the case where you must supply one yourself, or `interrupt()` has nowhere to save the pause. +A paused graph is a stored checkpoint. Serving through `langgraph dev` or LangGraph Platform gives you that for free. `langgraph dev` refuses to load a graph that compiles its own saver, and a deployment ignores it, so leave it off in both cases. ### The agent provider @@ -157,7 +157,7 @@ Nothing changes on the client. Each pause raises one interrupt, the card matches ## Typed interrupt payloads with BagTemplate -By default an interrupt payload is untyped: the normalized `interrupt()` signal exposes `value` as `unknown`, and the example casts at the template boundary. The `BagTemplate` generic gives the raw signal a real type instead. +By default an interrupt payload is untyped: the normalized `interrupt()` signal exposes `value` as `unknown`, and the example lets it reach the template untyped, giving the payload a shape only where `submitEdit()` declares its parameter. The `BagTemplate` generic gives the raw signal a real type instead. Typing the bag means naming the agent, so this variation declares a ref with `createAgentRef()` and passes it to both `provideAgent()` and `injectAgent()`, where the example uses the unnamed form. @@ -217,7 +217,7 @@ async def check_stale_interrupts(): metadata={"interrupt_type": "approval"}, ) for thread in threads: - created = thread.updated_at + created = thread["updated_at"] if (now() - created).total_seconds() > 3600: # 1 hour timeout await client.runs.create( thread["thread_id"], diff --git a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts index 2efc4d8de..fa374da0e 100644 --- a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts +++ b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts @@ -125,7 +125,6 @@ const WELCOME_SUGGESTIONS = [ }) export class InterruptsComponent { protected readonly suggestions = WELCOME_SUGGESTIONS; - // #region resume protected readonly editing = signal(false); protected readonly editAmount = signal(null); @@ -135,6 +134,7 @@ export class InterruptsComponent { void this.agent.submit({ message: text }); } + // #region resume protected onAction(action: ChatApprovalAction): void { if (action === 'approve') { void this.agent.submit({ resume: { approved: true } }); From 4d50b54ebf8c4ba088fffb41661f27f875f6c2d1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:17:01 -0700 Subject: [PATCH 12/21] docs(langgraph): durable execution teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../langgraph/guides/durable-execution.mdx | 136 ++++++++++++++- .../website/src/lib/docs-example-code.spec.ts | 1 - .../src/app/durable-execution.component.ts | 6 + .../durable-execution/python/docs/guide.md | 157 ------------------ .../durable-execution/python/src/graph.py | 8 + 5 files changed, 149 insertions(+), 159 deletions(-) delete mode 100644 cockpit/langgraph/durable-execution/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/durable-execution.mdx b/apps/website/content/docs/langgraph/guides/durable-execution.mdx index a8680a176..d4e461448 100644 --- a/apps/website/content/docs/langgraph/guides/durable-execution.mdx +++ b/apps/website/content/docs/langgraph/guides/durable-execution.mdx @@ -6,4 +6,138 @@ description: Resume a LangGraph run after a crash, restart, or redeploy from its Persistence keeps a thread's history; durable execution keeps a _run_ alive through failure. When a step crashes, the process restarts, or a deployment rolls, the checkpointer lets the run resume from its last completed super-step instead of starting over, and the UI can offer a retry rather than a blank transcript. -This page is the live example for durable execution over LangGraph. Use **Run** to interrupt and retry a run, **Code** to read the Angular and Python sources, and **API** for the extracted reference. The checkpointer configuration it relies on is written up in the [Persistence guide](/docs/langgraph/guides/persistence). +This page is the live example for durable execution over LangGraph. Use **Run** to watch one request move through a three-node graph that commits its state after every node, **Code** to read the Angular and Python sources, and **API** for the extracted reference. The checkpointer configuration it relies on is written up in the [Persistence guide](/docs/langgraph/guides/persistence). + +## What the demo does + +The Run tab shows the prebuilt `` composition beside a sidebar titled Pipeline, listing three steps: Analyze, Plan, and Generate. Send any question and the sidebar tracks the run. Each step turns from a hollow circle to a spinner while its node is executing and to a green check once the run has moved past it, because the graph writes the name of the node it just finished into state and the component reads that field back. + +Two things are worth watching. The first is how long each step holds: the graph makes a separate model call per node, so the answer you eventually read is the third of three passes rather than one long generation. The second is where the sidebar stops. The final step stays marked active rather than complete when the run ends, because `generate` is the last name the graph ever writes and there is no fourth step for the indicator to advance to. + +Nothing in the demo fails on purpose. What durability buys you here is visible in the shape of the run rather than in a staged crash: three separately committed steps instead of one all-or-nothing call. + +## How it is built + +Three files carry the feature: a graph that splits one answer into three checkpointed nodes, an application config that registers the agent, and a component that derives the sidebar from graph state. Open the Code tab to read them in place. + +### The state the run carries + +The state schema is short on purpose. Alongside the message list it keeps a single `step` string, which is the entire progress protocol between the graph and the browser. + + + +Neither field declares a reducer, so whatever a node returns for a key replaces that key outright. + +### One node of the pipeline + +Each node loads the shared system prompt, appends a line telling the model which phase it is in, calls the model, and returns the conversation with its own response appended plus the name of the step it just completed. `analyze` is the first of the three and `plan` is the same code with a different instruction. + + + +The return value is the commit: LangGraph saves the state a node returns before the next node starts, which is what makes the run recoverable partway through. + +### The node that ends the run + +`generate` is where the shape changes. The first two nodes append to the message list they received, so the intermediate analysis and outline accumulate in state. The last node returns only two messages — the user's question and the final answer — and because `messages` has no reducer, that return replaces the list. + + + +The scratch work exists for exactly as long as the run needs it and is not part of the thread the reader is left with. + +### Wiring the three nodes + +The graph is a straight line. Three nodes, three edges, and an entry point. + + + +`compile()` is called with no checkpointer. That is not a gap: the LangGraph API server provides persistence for every thread it serves and rejects a graph that brings its own. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you run inside your own process. + +### The agent provider + +`provideAgent()` registers the agent once for the whole application, and `provideChat({})` registers the configuration the `` composition reads, here left at its defaults. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo. + + + +Your own application does not need the factory. Pass the two values directly: + +```typescript +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'durable-execution', +}); +``` + +`assistantId` must match the graph name in `langgraph.json`. + + +Never expose a LangSmith API key in client-side code. Point `apiUrl` at a deployment that authenticates the browser another way, or proxy the requests through your own server and attach the key there. + + +### Deriving the pipeline from state + +The component holds the node order and the labels it renders for them. This list is the contract with the graph: the strings are the node names the Python side writes into `state.step`. + + + +`injectAgent()` returns the agent registered above, and `value()` is the graph state as a signal. The `steps` computed reads `step` out of it, finds that name in the ordered list, and marks everything before it complete, that entry active, and everything after it pending. An unknown or missing `step` leaves every entry pending, which is the state before the first submission. + + + +No polling and no subscription: the `step` field arrives with the state updates the run is already streaming, so the sidebar recomputes as each node commits. + +### The layout + +`` takes the agent in the main slot and renders the conversation, the input, the loading state, and errors. The sidebar slot is the pipeline, rendered straight from the `steps` signal with a spinner, a check, or a hollow circle per entry. + + + +The `views` and `store` inputs register a generative-UI catalog with the chat composition; this graph emits no view specs, so the catalog goes unused here and the [Generative UI guide](/docs/chat/guides/generative-ui) is where it earns its place. + +## What durability covers + +Splitting one answer across three nodes is what makes the run durable. Each node's return is committed before the next one starts, so a failure in `generate` does not throw away the analysis and the outline, and the thread you retry against is not empty. + +What it does not cover is the request in the browser. A dropped connection, a restarted server, or an error from the model ends the run in the client with an error, and something has to start a new one. That is the UI's job, and it is one call. + +### Retrying a failed run + +The `` composition already renders the retry. Its error banner shows a Retry button whenever the error it received is marked retryable — dropped streams and connection failures are — and the button calls `agent.retry()`. + +`retry()` clears the error and re-submits the last payload the agent sent, with the same options, against the same thread. Two guards matter when you call it yourself: + +- It returns immediately while a run is in flight, so a double click cannot start a second run. +- It does nothing when there is no last payload: before the first submission, and after `switchThread()`, which clears it. A Retry button on a thread the user just switched to is a button that does nothing. + +`reload()` performs the same re-submission without clearing the error first. Reach for `retry()` in a UI and `reload()` when you want the previous error to stay on screen while the new attempt runs. + + +Re-submitting starts a fresh run against the thread, and the thread still holds everything the failed attempt committed. That is the point — the completed work survives — but it also means a retry does not undo those messages. When a failed attempt leaves state the next one should not see, `regenerate(index)` is the call that rolls back first: it removes the assistant message at that index and everything after it, then re-runs against the trimmed thread. + + +### submit(null) resumes; it does not retry + +`submit(null)` starts a run with no new input. LangGraph advances whatever the thread still has pending, which is why it is the resume call for a graph paused at an `interrupt()` — see the [Interrupts guide](/docs/langgraph/guides/interrupts). + +It is not the retry. Once a run has reached the end of the graph the thread has nothing pending, and `submit(null)` does nothing at all: no run starts and no error is raised. `regenerate()` is the one path that resumes a finished thread this way, and it can only do so because it first repositions the thread to the entry node — LangGraph's `as_node` parameter, applied through the transport's `updateState` — before submitting null. + + +Both `retry()` and `reload()` are silent no-ops when the agent has nothing to re-submit. Render the button from `error()` rather than unconditionally, as `` does, so the user is never offered a control that cannot do anything. + + +### Where checkpointing comes from + +Nothing in this example configures durability. The graph compiles without a checkpointer and the component asks for no recovery options, because the LangGraph API server checkpoints every thread it runs. Move the same graph into a process you host and the checkpointer becomes yours to choose — an in-memory saver for tests, Postgres for anything that has to survive a restart. The [Persistence guide](/docs/langgraph/guides/persistence) covers the choice and the warning that comes with it: a graph served by `langgraph dev` or LangGraph Platform must not compile a checkpointer of its own. + +## What's Next + + + + Choose a checkpointer and resume conversations across page reloads using thread IDs. + + + Browse the checkpoints a run leaves behind and fork the thread from any of them. + + + Pause a run for human input and resume it with `submit(null, { resume })`. + + diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 5755807c1..809286f88 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -46,7 +46,6 @@ const PENDING_PAGES = new Set([ '/docs/deep-agents/capabilities/skills', '/docs/deep-agents/capabilities/subagents', '/docs/langgraph/guides/deployment', - '/docs/langgraph/guides/durable-execution', '/docs/langgraph/guides/subgraphs', '/docs/langgraph/guides/time-travel', '/docs/render/api/provide-render', diff --git a/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts b/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts index c124e4bcb..7f2e4486b 100644 --- a/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts +++ b/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts @@ -5,6 +5,7 @@ import { signalStateStore } from '@threadplane/render'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { StepPipelineComponent } from './views/step-pipeline.component'; +// #region steps-model /** * Pipeline step definition for the vertical progress indicator. */ @@ -23,6 +24,7 @@ const STEP_LABELS: Record = { plan: 'Plan', generate: 'Generate', }; +// #endregion /** * DurableExecutionComponent demonstrates fault-tolerant multi-step execution @@ -138,6 +140,7 @@ const STEP_LABELS: Record = { `, template: ` +

Pipeline

@@ -186,6 +189,7 @@ const STEP_LABELS: Record = { }
+
`, }) @@ -193,6 +197,7 @@ export class DurableExecutionComponent { readonly ui = views({ 'step-pipeline': StepPipelineComponent }); readonly uiStore = signalStateStore({}); + // #region agent protected readonly agent = injectAgent(); /** @@ -212,4 +217,5 @@ export class DurableExecutionComponent { status: activeIndex < 0 ? 'pending' : i < activeIndex ? 'complete' : i === activeIndex ? 'active' : 'pending', })); }); + // #endregion } diff --git a/cockpit/langgraph/durable-execution/python/docs/guide.md b/cockpit/langgraph/durable-execution/python/docs/guide.md deleted file mode 100644 index 1c466d4f3..000000000 --- a/cockpit/langgraph/durable-execution/python/docs/guide.md +++ /dev/null @@ -1,157 +0,0 @@ -# Durable Execution with Angular - - -Build a fault-tolerant chat interface using `provideAgent()` and -`injectAgent()` from `@threadplane/langgraph`. The backend graph checkpoints state after -each node, enabling resume-on-failure. The sidebar monitors execution -status in real time and exposes a "Retry" button when errors occur. - - - -Add a durable multi-step execution workflow to this Angular component using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. Display `stream.status()` as a colour-coded badge, show a `stream.hasValue()` indicator, and render a "Retry" button that calls `stream.reload()` when `stream.error()` is set. Bind the conversation with `` from `@threadplane/chat`. - - - - - -Set up `provideAgent()` in your app config with the LangGraph API URL: - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'https://your-deployment.langgraph.app', - assistantId: 'durable-execution', - }), - ], -}; -``` - -This makes the configured agent available to all `injectAgent()` calls in your app. - - - - -In your component, call `injectAgent()` to retrieve the configured durable-execution agent: - -```typescript -// durable-execution.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -export class DurableExecutionComponent { - protected readonly stream = injectAgent(); - - send(text: string): void { - void this.stream.submit({ message: text }); - } -} -``` - - - - -Use `stream.status()` to display real-time execution state. The signal returns `'idle'`, `'running'`, or `'error'`: - -```html - - {{ stream.status() }} - -``` - -```typescript -statusBadgeColor(): string { - switch (this.stream.status()) { - case 'running': return '#2563eb'; - case 'idle': return '#16a34a'; - case 'error': return '#dc2626'; - default: return '#6b7280'; - } -} -``` - - - - -Use `stream.hasValue()` to indicate whether the graph has returned any data yet: - -```html - - -{{ stream.hasValue() ? 'Yes' : 'No' }} -``` - -`hasValue()` becomes `true` as soon as the first value or message arrives from the stream. - - - - -Render a "Retry" button when `stream.error()` is set. Call `stream.reload()` to re-submit the last input: - -```html -@if (stream.error()) { - -} -``` - -`reload()` re-submits the previous input without requiring the user to retype their message. Because the graph checkpoints after each node, a retry resumes from the last successful checkpoint rather than restarting the whole run. - - -`stream.reload()` is a no-op if there is no previous submission. Guard against calling it on an idle stream. - - - - - -The backend uses a three-node graph with `MemorySaver` checkpointing: - -```python -# graph.py -from typing import TypedDict -from langgraph.graph import StateGraph, END -from langgraph.checkpoint.memory import MemorySaver - -class DurableState(TypedDict): - messages: list - step: str # Current execution step name - -checkpointer = MemorySaver() - -def build_durable_execution_graph(): - llm = ChatOpenAI(model="gpt-5-mini", streaming=True) - - async def analyze(state): ... # Node 1 - async def plan(state): ... # Node 2 - async def generate(state): ... # Node 3 - - graph = StateGraph(DurableState) - graph.add_node("analyze", analyze) - graph.add_node("plan", plan) - graph.add_node("generate", generate) - graph.set_entry_point("analyze") - graph.add_edge("analyze", "plan") - graph.add_edge("plan", "generate") - graph.add_edge("generate", END) - return graph.compile(checkpointer=checkpointer) -``` - -Each node updates `state.step` so the UI (or LangSmith traces) can show which stage the graph is currently in. - - -For production, replace `MemorySaver` with `PostgresCheckpointer` for durable persistence across server restarts. - - - - - - -The `` component handles message rendering, input, loading states, and error display. Keep your component focused on status monitoring and retry logic. - - - -Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. - diff --git a/cockpit/langgraph/durable-execution/python/src/graph.py b/cockpit/langgraph/durable-execution/python/src/graph.py index b1f9d5e96..aea1ff516 100644 --- a/cockpit/langgraph/durable-execution/python/src/graph.py +++ b/cockpit/langgraph/durable-execution/python/src/graph.py @@ -19,9 +19,11 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# region state class DurableState(TypedDict): messages: list step: str # Current execution step name +# endregion def build_durable_execution_graph(): @@ -35,6 +37,7 @@ def build_durable_execution_graph(): llm = ChatOpenAI(model="gpt-5-mini", streaming=True) system_prompt = (PROMPTS_DIR / "durable-execution.md").read_text() + # region analyze async def analyze(state: DurableState) -> dict: """ Node 1: Analyze the user request. @@ -48,6 +51,7 @@ async def analyze(state: DurableState) -> dict: "messages": state["messages"] + [response], "step": "analyze", } + # endregion async def plan(state: DurableState) -> dict: """ @@ -63,6 +67,7 @@ async def plan(state: DurableState) -> dict: "step": "plan", } + # region generate async def generate(state: DurableState) -> dict: """ Node 3: Generate the final response. @@ -76,7 +81,9 @@ async def generate(state: DurableState) -> dict: "messages": [HumanMessage(content=state["messages"][-1].content if hasattr(state["messages"][-1], "content") else ""), response], "step": "generate", } + # endregion + # region graph graph = StateGraph(DurableState) graph.add_node("analyze", analyze) graph.add_node("plan", plan) @@ -88,6 +95,7 @@ async def generate(state: DurableState) -> dict: graph.add_edge("generate", END) return graph.compile() + # endregion # The graph instance — referenced by langgraph.json From 27f8b6fa108bd3496307bc13fdf9ed91f7bd3cb5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:18:48 -0700 Subject: [PATCH 13/21] =?UTF-8?q?docs(langgraph):=20memory=20page=20?= =?UTF-8?q?=E2=80=94=20the=20demo's=20transcript=20does=20not=20accumulate?= =?UTF-8?q?;=20sidebar=20prose=20matches=20its=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- apps/website/content/docs/langgraph/guides/memory.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/memory.mdx b/apps/website/content/docs/langgraph/guides/memory.mdx index e4b3f9618..0ea269989 100644 --- a/apps/website/content/docs/langgraph/guides/memory.mdx +++ b/apps/website/content/docs/langgraph/guides/memory.mdx @@ -89,7 +89,7 @@ No extra request is involved. The `memory` dict rides along with the state updat ### Rendering the facts -The template is the prebuilt `` in the main slot and the fact list in the sidebar slot. Both read signals directly, so nothing subscribes and nothing is torn down by hand. +Beside the prebuilt `` in the layout's main slot, the sidebar slot holds the fact list. It reads `memoryEntries()` directly, so nothing subscribes and nothing is torn down by hand. @@ -119,7 +119,7 @@ The `Annotated[..., add]` fields append on every write; the plain fields are rep ## Short-term memory (thread-scoped) -Short-term memory is the kind the demo uses: the conversation history plus the state fields accumulated inside one thread. Every message and state update is checkpointed, so reconnecting with the same `threadId` restores both the transcript and the custom fields. +Short-term memory is the kind the demo uses: state fields accumulated inside one thread. State updates are checkpointed, so reconnecting with the same `threadId` restores the fields the graph wrote, the demo's `memory` dict among them. Declaring `messages` with the `add_messages` reducer is what makes the transcript accumulate the same way. ```python # Same thread, two invocations — the second one sees what the first learned From 8e8ddeb8d8168e2dbf80d1d09dc37ddb360cb609 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:26:19 -0700 Subject: [PATCH 14/21] docs(langgraph): subgraphs teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/subgraphs.mdx | 367 ++++++------------ .../website/src/lib/docs-example-code.spec.ts | 1 - .../angular/src/app/subgraphs.component.ts | 4 + .../langgraph/subgraphs/python/docs/guide.md | 185 --------- .../langgraph/subgraphs/python/src/graph.py | 10 + 5 files changed, 130 insertions(+), 437 deletions(-) delete mode 100644 cockpit/langgraph/subgraphs/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/subgraphs.mdx b/apps/website/content/docs/langgraph/guides/subgraphs.mdx index 6bf5256c0..d87116d10 100644 --- a/apps/website/content/docs/langgraph/guides/subgraphs.mdx +++ b/apps/website/content/docs/langgraph/guides/subgraphs.mdx @@ -1,212 +1,153 @@ +--- +description: How the subgraphs example routes a turn into a compiled child graph, keeps the child's state and tokens out of the transcript, and surfaces the child as a stream +--- + # Subgraphs -Subgraphs let you compose larger agents from smaller, focused units. `injectAgent()` streams their output through the same message, state, tool-call, and custom-event signals as the parent graph. +Subgraphs let you compose larger agents from smaller, focused units. A compiled `StateGraph` becomes a node in a parent graph, and `injectAgent()` streams the whole composition through the same message, state, tool-call, and custom-event signals as a single graph. The running example is a research orchestrator: the parent decides per turn whether to enter the child at all, and this guide walks the three files that make it work. LangGraph subgraphs are graph nodes. Deep Agents-style subagents are delegated tool calls. `injectAgent()` requests subgraph streams by default, and every namespaced child run appears in the `subagents()` signal — tool-dispatched children under their tool-call id (matched via `subagentToolNames` + `subagent_type`), plain subgraph nodes under their namespace key, named by node. A child's tokens live on its stream and never merge into the parent transcript. -## How subgraph composition works +## What the demo does -Subgraph composition starts on the agent side. Each subgraph is a fully compiled `StateGraph` that can be added as a node in a parent graph. +The Run tab shows the prebuilt `` composition beside a sidebar that reports which branch the parent took. Ask a factual question and the sidebar switches to "Nested — research subgraph ran", printing the topic the parent handed to the child and the brief the child handed back, followed by the child's own stream and its status. Ask a greeting and the sidebar reports "Direct — subgraph skipped" instead, because the parent answered without entering the child. - - +Two welcome suggestions set both branches up. "Ask something that needs research" sends a question about LangGraph checkpointing, which routes through the child. "Ask something that does not" sends a greeting, which does not. In both cases the answer in the transcript is written by the parent: the brief renders in the sidebar and nowhere else. -```python -from langgraph.graph import END, START, MessagesState, StateGraph -from langchain_openai import ChatOpenAI - -llm = ChatOpenAI(model="gpt-5-mini") - -# --- Research subgraph --- -def search_web(state: MessagesState) -> dict: - query = state["messages"][-1].content - results = web_search(query) - return {"messages": [{"role": "assistant", "content": results}]} - -def summarize_results(state: MessagesState) -> dict: - response = llm.invoke(state["messages"]) - return {"messages": [response]} - -research_builder = StateGraph(MessagesState) -research_builder.add_node("search", search_web) -research_builder.add_node("summarize", summarize_results) -research_builder.add_edge(START, "search") -research_builder.add_edge("search", "summarize") -research_builder.add_edge("summarize", END) - -research_subgraph = research_builder.compile() - -# --- Analysis subgraph --- -def analyze_data(state: MessagesState) -> dict: - response = llm.invoke([ - {"role": "system", "content": "Analyze the data and provide insights."}, - *state["messages"], - ]) - return {"messages": [response]} - -analysis_builder = StateGraph(MessagesState) -analysis_builder.add_node("analyze", analyze_data) -analysis_builder.add_edge(START, "analyze") -analysis_builder.add_edge("analyze", END) - -analysis_subgraph = analysis_builder.compile() - -# --- Parent orchestrator --- -def route_task(state: MessagesState) -> str: - last = state["messages"][-1].content.lower() - if "research" in last or "search" in last: - return "research" - return "analyze" - -builder = StateGraph(MessagesState) -builder.add_node("research", research_subgraph) -builder.add_node("analyze", analysis_subgraph) -builder.add_conditional_edges(START, route_task) -builder.add_edge("research", END) -builder.add_edge("analyze", END) - -graph = builder.compile() -``` +## How it is built + +Three files carry the feature: a graph whose child is a separately compiled graph, a one-file typed agent ref, and a component that reads the two keys the parent and child share. Open the Code tab to read them in place. + +### The state boundary + +Adding a compiled graph as a node does not isolate state by itself. The boundary is designed, and it is designed in the two state schemas: LangGraph wires a subgraph node through the keys the two schemas have in common. + + + +`ResearchState` has no `messages` key, so the child can neither read the transcript nor append to it: the two keys it does share are the entire interface, a topic in and a brief out. + +### The child graph + +The child is an ordinary graph. One node, one model call, and a `compile()` at the end that produces the object the parent will mount. + + + +Its system prompt restates the structural fact in words, telling the researcher that it cannot see the chat transcript and that its output is an internal brief for the parent to use. + +### Deciding whether to delegate + +The parent's first node classifies the turn with a structured-output call and writes a topic when research is warranted. Writing that topic is the delegation: the conditional edge routes on nothing else. + + + +Both shared keys are reset on every turn, so a topic left over from an earlier turn in the same thread cannot re-trigger the child. + +### Writing the answer - - +One node writes to the transcript. It reads the brief out of state when there is one, folds it into the system context, and streams the user-facing turn. + + + +The brief arrives here as context rather than as a chat message, so what the user reads is the parent's own prose. + +### Adding the child as a node + +The compiled child graph is passed straight to `add_node`. There is no wrapper function, and that is what makes it a subgraph rather than an inline helper call. + + + +The child runs as its own graph with its own step sequence, and LangGraph emits its stream events under a `research:` namespace rather than flattening them into the parent's. + +### The typed agent ref + +The Angular side declares the parent's state once and hands it to a ref that both the provider and the component use. Because `SubgraphsState` names the two shared keys, `agent.value()` is typed at every read site. + + + +The application config registers that ref with `provideAgent()` and adds `provideChat({})` for the chat composition. In your own application the two connection values are literals: ```typescript -import { Component, computed, inject, effect, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import { createAgentRef } from '@threadplane/chat'; - -export interface OrchestratorState { - messages: BaseMessage[]; - // add your subgraph output fields here -} - -export const ORCHESTRATOR = createAgentRef('orchestrator'); - -@Component({ - selector: 'app-orchestrator', - templateUrl: './orchestrator.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class OrchestratorComponent { - protected readonly orchestrator = injectAgent(ORCHESTRATOR); - - readonly messages = computed(() => this.orchestrator.messages()); - readonly isRunning = computed(() => this.orchestrator.isLoading()); - - send(text: string) { - this.orchestrator.submit({ message: text }); - } -} +provideAgent(SUBGRAPHS_AGENT, { + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'subgraphs', + transcriptNodeNames: ['answer'], +}), +provideChat({}), ``` - - - - -A child's streamed tokens never merge into the parent transcript — they land on the child's own stream in `subagents()`, keyed by the `research:` namespace. What the transcript shows at settle is decided by state: because both graphs above share `MessagesState`, the child's message enters the parent's message list and arrives with the final `values` sync. Give the child its own schema (below) and it never does. +`assistantId` must match the graph name in `langgraph.json`. `transcriptNodeNames` whitelists the *top-level* nodes whose message tokens belong in the chat transcript, which here means the `answer` node alone. The parent's other node runs a structured-output call of its own, and the whitelist is what keeps that traffic out of the chat. -Streamed chunks from *top-level* side-effect nodes — a router, a title generator — are a separate concern: whitelist your conversational nodes with [`transcriptNodeNames`](/docs/langgraph/api/provide-agent). + +Never expose a LangSmith API key in client-side code. Point `apiUrl` at a deployment that authenticates the browser another way, or proxy the requests through your own server and attach the key there. -## Giving the child its own state +### Reading the boundary from Angular -Adding a compiled graph as a node does not isolate state. If you want a real boundary, design one: give the child its own state schema and share only the keys you want crossing it. LangGraph passes a subgraph node through the keys the two schemas have in common. +`injectAgent(SUBGRAPHS_AGENT)` returns `LangGraphAgent`, so `agent.value()` is a typed signal over the parent graph's live state as its `values` events arrive. The two shared keys are read straight off it, and a non-empty topic doubles as the "did the run nest?" signal because it is exactly what the conditional edge routes on. -```python -from typing import Annotated, TypedDict -from langgraph.graph import END, START, StateGraph -from langgraph.graph.message import add_messages - -class ResearchState(TypedDict): - """Child state — deliberately has no `messages` key.""" - research_topic: str - research_brief: str - -class OrchestratorState(TypedDict): - """Parent state — the transcript plus the shared channel.""" - messages: Annotated[list, add_messages] - research_topic: str - research_brief: str - -async def research_node(state: ResearchState) -> dict: - # Receives a topic, returns a brief. No transcript access. - brief = await researcher.ainvoke(f"Topic: {state['research_topic']}") - return {"research_brief": brief.content} - -research_graph = StateGraph(ResearchState) -research_graph.add_node("research", research_node) -research_graph.add_edge(START, "research") -research_graph.add_edge("research", END) -compiled_research = research_graph.compile() - -def route_after_orchestrate(state: OrchestratorState) -> str: - # Writing a topic is what triggers delegation. - return "research" if state.get("research_topic") else "answer" - -parent = StateGraph(OrchestratorState) -parent.add_node("orchestrate", orchestrate_node) -parent.add_node("research", compiled_research) # the compiled graph IS the node -parent.add_node("answer", answer_node) # the only node that writes messages -parent.add_edge(START, "orchestrate") -parent.add_conditional_edges( - "orchestrate", route_after_orchestrate, {"research": "research", "answer": "answer"} -) -parent.add_edge("research", "answer") -parent.add_edge("answer", END) -graph = parent.compile() -``` + + +The sidebar renders `topic()` and `brief()` under the route line, so watching those two fields is watching the state boundary itself. + +### The same child, seen as a stream -Because `ResearchState` has no `messages` key, the child cannot read the transcript or append to it — its brief reaches the parent through `research_brief` and never becomes a chat message. Pair it with `transcriptNodeNames: ['answer']` so only the parent's answering node streams into `messages()`. +`agent.value()` is one view of the child. `agent.subagents()` is the other: a Map of every namespaced child run, which for a plain subgraph node is keyed by the namespace segment and named by the node. No configuration turns it on — the entry appears on the child's first streamed event and settles with the run. - -`OrchestratorState` and `PipelineState` below are placeholders for your own graph's state schema — the shape your subgraph's `StateGraph` produces. They mirror the Python state the same way `ChatState` does on the [State Management](/docs/langgraph/concepts/state-management) page. Use `createAgentRef('your-assistant-id')` to create a typed ref, then pass it to both `provideAgent()` and `injectAgent()`. + + +`status()` and `name` come off the `SubagentStreamRef`, which is why the mapped objects carry a called signal rather than the ref itself. + + +`injectAgent()` must run inside an Angular injection context: a field initializer, as it is here, or a constructor body. ## Tracking delegated subagent execution -The `subagents()` signal contains a Map of active child streams. Tool-dispatched children — Deep Agents' default `task` tool or your own delegation tools — are keyed by tool-call id and named by their `subagent_type`. Plain subgraph nodes are keyed by their namespace segment and named by node; they register on their first streamed event and settle with the run. +The `subagents()` signal contains a Map of active child streams. Tool-dispatched children — Deep Agents' default `task` tool or your own delegation tools — are keyed by tool-call id and named by their `subagent_type`. Plain subgraph nodes, as in the example above, are keyed by their namespace segment and named by node; they register on their first streamed event and settle with the run. + +Nested delegation — a subagent that itself dispatches a delegation tool — surfaces as its own entry too, keyed by its namespace path (truncated at the innermost delegation segment) and treated like a plain subgraph stream. Each level of delegation gets its own stream; the map stays flat, so there is no parent/child linking between the entries. -Nested delegation — a subagent that itself dispatches a delegation tool — surfaces as its own entry too, keyed by its namespace path (truncated at the innermost delegation segment) and treated like a plain subgraph stream. Each level of delegation gets its own stream; the map stays flat, so there's no parent/child linking between the entries. +Tool-dispatched children need one option the example does not set, because the example has no delegation tool: ```typescript -// In a shared file (e.g. agent.ts): -// import { createAgentRef } from '@threadplane/chat'; -// export const ORCHESTRATOR = createAgentRef('orchestrator'); +// app.config.ts +provideAgent(ORCHESTRATOR, { + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'orchestrator', + subagentToolNames: ['task', 'delegate_to_researcher'], +}), +``` -// Configure in app.config.ts: -// provideAgent(ORCHESTRATOR, { -// apiUrl: '...', -// subagentToolNames: ['task', 'delegate_to_researcher'], -// }); +With that in place, the same lookups work on either flavor: +```typescript const orchestrator = injectAgent(ORCHESTRATOR); -// All subagent streams (active and completed) -const subagents = computed(() => orchestrator.subagents()); - -// Only active ones +// Only the active ones const running = computed(() => - [...orchestrator.subagents().values()].filter((subagent) => - subagent.status() === 'pending' || subagent.status() === 'running' + [...orchestrator.subagents().values()].filter( + (subagent) => subagent.status() === 'pending' || subagent.status() === 'running' ) ); -const runningCount = computed(() => running().length); // Lookup helpers for common UI paths const specific = computed(() => orchestrator.getSubagent('research-tool-call-id')); -const researchers = computed(() => - orchestrator.getSubagentsByType('researcher') -); +const researchers = computed(() => orchestrator.getSubagentsByType('researcher')); // React to count changes effect(() => { - console.log(`${runningCount()} subagents currently running`); + console.log(`${running().length} subagents currently running`); }); ``` + +Set `subagentToolNames` to the tool names that spawn subagents. `injectAgent()` uses this to identify tool calls that create subagent streams. + +Registration is skipped silently unless the tool call also carries a valid `subagent_type` argument: a string of 3-50 characters, starting with a letter, containing only letters, digits, `_`, or `-`. A value like `qa` (too short) or `2nd_pass` (leading digit) produces no subagent and no error, so `subagents()` stays empty with nothing in the console to explain it. + + ## Subagent stream details Each `SubagentStreamRef` exposes its own reactive signals — status, messages, and state — so you can surface granular progress in your UI. @@ -236,7 +177,7 @@ A child graph invoked inside a `@tool` body streams under a `tools:` names 2. **The description ladder**: the child's first human message is compared against each pending tool call's `description` argument — exact match, then substring in either direction 3. **A positional fallback** that fires only when *exactly one* tool child is outstanding — with several in flight, arrival order is not dispatch order, and guessing would cross-wire the cards, so ambiguous streams stay buffered instead -Tier 3 covers the common sequential shape (one dispatch per assistant turn). For parallel fan-out, or a delegation tool whose argument isn't named `description`, announce the binding from the server — the tool body is the one place both halves are known: +Tier 3 covers the common sequential shape (one dispatch per assistant turn). For parallel fan-out, or a delegation tool whose argument is not named `description`, announce the binding from the server — the tool body is the one place both halves are known: ```python from typing import Annotated @@ -245,15 +186,16 @@ from langchain_core.runnables import RunnableConfig from langchain_core.tools import InjectedToolCallId, tool from threadplane.middleware.langgraph import announce_subagent + @tool async def task( description: str, tool_call_id: Annotated[str, InjectedToolCallId] = None, config: RunnableConfig = None, ) -> str: - announce_subagent(config, tool_call_id) # one line — before invoking the child - result = await child_graph.ainvoke({...}) - ... + announce_subagent(config, tool_call_id) # one line — before invoking the child + result = await child_graph.ainvoke({"messages": [("user", description)]}) + return result["messages"][-1].content ``` `announce_subagent` (threadplane-middleware ≥ 0.0.2) emits one custom event pairing the config's `checkpoint_ns` with the injected tool-call id. `injectAgent()` consumes it, attributes the stream exactly — replaying any chunks that arrived before the announcement — and never lets it override an established mapping. It returns `False` instead of raising when anything it needs is unavailable (outside a run, no namespace, no id), so it needs no guarding. @@ -264,25 +206,14 @@ It costs one line, upgrades attribution from heuristic to exact, and your graph ## Orchestrator pattern -The orchestrator pattern delegates specialised work to subagents and merges their results. Each subagent runs its own graph independently while the parent coordinates the whole. +The example delegates one kind of work to one child. The same shape scales to several: each child runs its own graph while the parent coordinates, and a derived summary over `subagents()` gives you the fan-out at a glance. ```typescript -// In a shared file (e.g. agent.ts): -// import { createAgentRef } from '@threadplane/chat'; -// export const PIPELINE = createAgentRef('pipeline-orchestrator'); - -// Configure in app.config.ts: -// provideAgent(PIPELINE, { -// apiUrl: '...', -// subagentToolNames: ['task'], -// }); - const pipeline = injectAgent(PIPELINE); // Derive a summary of all subagent states const pipelineStatus = computed(() => { - const agents = pipeline.subagents(); - const entries = [...agents.entries()]; + const entries = [...pipeline.subagents().entries()]; return { total: entries.length, @@ -294,79 +225,13 @@ const pipelineStatus = computed(() => { }); ``` -## Subagent progress UI - -Render live progress for each subagent using the signals above. - - - -```typescript -import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import { ORCHESTRATOR } from './agent'; // createAgentRef('orchestrator') - -@Component({ - selector: 'app-subagent-progress', - templateUrl: './progress-panel.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SubagentProgressComponent { - protected readonly orchestrator = injectAgent(ORCHESTRATOR); - - subagentEntries = computed(() => - [...this.orchestrator.subagents().entries()] - ); -} -``` - - -```html -@for (entry of subagentEntries(); track entry[0]) { -
- {{ entry[0] }} - - {{ entry[1].status() }} - - - @if (entry[1].status() === 'running') { - - } - - @if (entry[1].status() === 'error') { -

Subagent failed

- } -
-} -``` -
-
- ## Child messages and the parent transcript -Child messages never appear in the parent's `messages()` signal — a namespaced stream belongs to its child, and `messages()` is the parent's transcript. Render a child's live output from its own stream: - -```typescript -// In a shared file (e.g. agent.ts): -// import { createAgentRef } from '@threadplane/chat'; -// export const ORCHESTRATOR = createAgentRef('orchestrator'); - -// Configure in app.config.ts: -// provideAgent(ORCHESTRATOR, { -// apiUrl: '...', -// subagentToolNames: ['task'], -// }); +Child messages never appear in the parent's `messages()` signal — a namespaced stream belongs to its child, and `messages()` is the parent's transcript. That is a classification rule, not a heuristic: any event carrying a namespace is child content. -const orchestrator = injectAgent(ORCHESTRATOR); - -// The parent's transcript — child chatter is structurally absent -const parentMessages = computed(() => orchestrator.messages()); -``` - - -Set `subagentToolNames` to the tool names that spawn subagents. `injectAgent()` uses this to identify tool calls that create subagent streams. +What the transcript shows once the run settles is decided by state instead. A child that shares the parent's `messages` key writes into the parent's message list, and that list arrives with the authoritative `values` sync at the end of the run. The example's child has no `messages` key, so nothing it produces can ever land there. Render a child's live output from its own stream, as the example does with `subagents()`. -Registration is skipped silently unless the tool call also carries a valid `subagent_type` argument: a string of 3-50 characters, starting with a letter, containing only letters, digits, `_`, or `-`. A value like `qa` (too short) or `2nd_pass` (leading digit) produces no subagent and no error, so `subagents()` stays empty with nothing in the console to explain it. - +Streamed chunks from *top-level* side-effect nodes — a router, a title generator — are a separate concern, and the one `transcriptNodeNames` exists for. ## Error handling per subagent @@ -400,7 +265,7 @@ Always check `failedAgents()` before presenting final results. A completed orche Use **subagents** when tasks are independent and can run in parallel, when each task needs its own context window, or when you want isolated error boundaries. Use a **single agent** for sequential reasoning, tasks that share tightly coupled state, or when latency from spawning subagents outweighs the parallelism benefit. -None of those three come from compiling a child graph. A narrow context window follows from what you pass into the child, an error boundary from how the parent handles a failed delegation, and state isolation from giving the child its own schema. Compiling buys you nested execution and a namespace; the rest is yours to design. See the [decision matrix](/docs/langgraph/concepts/agent-architecture). +None of those three come from compiling a child graph. A narrow context window follows from what you pass into the child, an error boundary from how the parent handles a failed delegation, and state isolation from giving the child its own schema, as the example does. Compiling buys you nested execution and a namespace; the rest is yours to design. See the [decision matrix](/docs/langgraph/concepts/agent-architecture). ## What's Next diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 809286f88..c53e27e66 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -46,7 +46,6 @@ const PENDING_PAGES = new Set([ '/docs/deep-agents/capabilities/skills', '/docs/deep-agents/capabilities/subagents', '/docs/langgraph/guides/deployment', - '/docs/langgraph/guides/subgraphs', '/docs/langgraph/guides/time-travel', '/docs/render/api/provide-render', '/docs/render/api/render-spec-component', diff --git a/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts b/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts index f616ae233..ca7f685b6 100644 --- a/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts +++ b/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts @@ -180,6 +180,7 @@ const WELCOME_SUGGESTIONS = [ export class SubgraphsComponent { protected readonly suggestions = WELCOME_SUGGESTIONS; + // #region agent /** * Typed agent — `agent.value()` is `Signal`, the parent * graph's live state as LangGraph streams `values` events. @@ -197,7 +198,9 @@ export class SubgraphsComponent { * so it doubles as the UI's "did we nest?" signal. */ protected readonly delegated = computed(() => this.topic().length > 0); + // #endregion + // #region child-streams /** * The same child, seen as a stream. Plain subgraph children appear in * `subagents()` keyed by their namespace segment; `name` is the node name @@ -210,6 +213,7 @@ export class SubgraphsComponent { status: ref.status(), })), ); + // #endregion protected send(text: string): void { void this.agent.submit({ message: text }); diff --git a/cockpit/langgraph/subgraphs/python/docs/guide.md b/cockpit/langgraph/subgraphs/python/docs/guide.md deleted file mode 100644 index 29aef572c..000000000 --- a/cockpit/langgraph/subgraphs/python/docs/guide.md +++ /dev/null @@ -1,185 +0,0 @@ -# Nested Graph Composition with Subgraphs and Angular - - -Build a chat interface over a LangGraph parent graph that composes a compiled child -graph as a node, using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. -The parent orchestrator decides per turn whether to enter the child graph, and the sidebar -reads the parent's own state through `agent.value()` to show which branch ran and what the -child returned. - - - -Add a parent/child LangGraph composition to this Angular app using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. The parent should route conditionally into a compiled child graph whose state has no `messages` key, and the component should read `agent.value()` for the shared `research_topic` / `research_brief` keys. Set `transcriptNodeNames` so only the parent's answer node reaches the chat transcript. - - - -Every namespaced child run appears in `agent.subagents()`. A subgraph added as a plain -node emits a `research:` namespace and registers under that key, named by node — -no configuration needed. Delegation *tool calls* (`subagentToolNames` + `subagent_type`) -appear under their tool-call id instead, carrying the arguments a richer UI can render; -for that pattern see [Chat Subagents](/chat/core-capabilities/subagents/overview/python). -Either way, the child's tokens stay on its stream and never merge into the transcript. - - - - - -The parent state carries the transcript plus the keys it shares with the child: - -```typescript -// agent-ref.ts -import { createAgentRef } from '@threadplane/chat'; - -export interface SubgraphsState { - messages: unknown[]; - research_topic: string; - research_brief: string; -} - -export const SUBGRAPHS_AGENT = createAgentRef('subgraphs'); -``` - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; -import { SUBGRAPHS_AGENT } from './agent-ref'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent(SUBGRAPHS_AGENT, { - apiUrl: 'https://your-deployment.langgraph.app', - assistantId: 'subgraphs', - transcriptNodeNames: ['answer'], - }), - ], -}; -``` - -`transcriptNodeNames` whitelists the graph nodes whose message tokens belong in the chat -transcript. It matters more than it looks: a child subgraph's `research:` namespace is -not a subagent (`tools:`) namespace, so without this option the child's tokens merge into the -transcript as they stream and its internal brief briefly renders as its own chat bubble. The -parent's final `values` event corrects the message list afterwards, so the symptom is a -mid-stream flash rather than a wrong end state. - - - - -`injectAgent(SUBGRAPHS_AGENT)` gives you `LangGraphAgent`, so -`agent.value()` is typed: - -```typescript -// subgraphs.component.ts -import { Component, computed } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; -import { SUBGRAPHS_AGENT } from './agent-ref'; - -export class SubgraphsComponent { - protected readonly agent = injectAgent(SUBGRAPHS_AGENT); - - protected readonly topic = computed(() => this.agent.value()?.research_topic ?? ''); - protected readonly brief = computed(() => this.agent.value()?.research_brief ?? ''); - - /** A non-empty topic is what the parent's conditional edge routes on. */ - protected readonly delegated = computed(() => this.topic().length > 0); -} -``` - -`agent.value()` is a `Signal` fed by LangGraph's `values` stream mode. It -updates as the parent graph advances, so the sidebar reflects the branch that actually ran. - - - - -Use `` from `@threadplane/chat` and render a sibling sidebar off the same signals: - -```html - - - -``` - - -The brief renders here and nowhere else. Because the child's state has no `messages` key, -its output never enters the transcript — the parent decides what, if anything, to say -about it. - - - - - -The parent adds the *compiled* child graph as a node and routes into it conditionally: - -```python -# graph.py -from typing import Annotated, TypedDict -from langgraph.graph import StateGraph, START, END -from langgraph.graph.message import add_messages - -class ResearchState(TypedDict): # child — no `messages` key - research_topic: str - research_brief: str - -class OrchestratorState(TypedDict): # parent — transcript + shared keys - messages: Annotated[list, add_messages] - research_topic: str - research_brief: str - -research_graph = StateGraph(ResearchState) -research_graph.add_node("research", research_node) -research_graph.add_edge(START, "research") -research_graph.add_edge("research", END) - -def route_after_orchestrate(state: OrchestratorState) -> str: - return "research" if state.get("research_topic") else "answer" - -parent_graph = StateGraph(OrchestratorState) -parent_graph.add_node("orchestrate", orchestrate_node) -parent_graph.add_node("research", research_graph.compile()) # subgraph AS a node -parent_graph.add_node("answer", answer_node) -parent_graph.add_edge(START, "orchestrate") -parent_graph.add_conditional_edges( - "orchestrate", route_after_orchestrate, {"research": "research", "answer": "answer"} -) -parent_graph.add_edge("research", "answer") -parent_graph.add_edge("answer", END) -graph = parent_graph.compile() -``` - -LangGraph wires a subgraph node through the keys the two state schemas **share**. Here that -is `research_topic` and `research_brief`: the child receives a topic, returns a brief, and -can neither read nor append to the parent's `messages`. The child runs as its own graph with -its own step sequence, and its stream events arrive under a `research:` namespace -rather than flattened into the parent's. - - -Child subgraphs can have their own state, checkpointers, and tools. Reach for this pattern -when you want a reusable unit of graph logic with a narrow, explicit interface to its caller. - - - - - - -The `` component handles message rendering, input, loading states, and error display. -Focus your component on reading the shared state keys. - - - -Never expose your LangSmith API key in client-side code. Use server-side environment -variables or a proxy. - - - -- [Chat Subagents](/chat/core-capabilities/subagents/overview/python) — tool-call delegation, the named-and-argumented flavor of `subagents()` - diff --git a/cockpit/langgraph/subgraphs/python/src/graph.py b/cockpit/langgraph/subgraphs/python/src/graph.py index 8a23d3d8c..0eddb2477 100644 --- a/cockpit/langgraph/subgraphs/python/src/graph.py +++ b/cockpit/langgraph/subgraphs/python/src/graph.py @@ -73,6 +73,7 @@ class DelegationDecision(BaseModel): ) +# region state class ResearchState(TypedDict): """Child graph state — deliberately has no `messages` key. @@ -90,6 +91,7 @@ class OrchestratorState(TypedDict): messages: Annotated[list, add_messages] research_topic: str research_brief: str +# endregion def build_subgraphs_graph(): @@ -98,6 +100,7 @@ def build_subgraphs_graph(): router = ChatOpenAI(model="gpt-5-mini").with_structured_output(DelegationDecision) researcher = ChatOpenAI(model="gpt-5-mini") + # region research-subgraph # ── Child: research subgraph ────────────────────────────────────────────── async def research_node(state: ResearchState) -> dict: @@ -118,9 +121,11 @@ async def research_node(state: ResearchState) -> dict: research_graph.add_edge(START, "research") research_graph.add_edge("research", END) compiled_research = research_graph.compile() + # endregion # ── Parent: orchestrator graph ──────────────────────────────────────────── + # region orchestrate async def orchestrate_node(state: OrchestratorState) -> dict: """Classify the request. Writing a topic is what triggers delegation. @@ -136,7 +141,9 @@ async def orchestrate_node(state: OrchestratorState) -> dict: def route_after_orchestrate(state: OrchestratorState) -> str: """The parent's decision: enter the child graph, or skip it.""" return "research" if state.get("research_topic") else "answer" + # endregion + # region answer async def answer_node(state: OrchestratorState) -> dict: """The only node that writes to the transcript. @@ -154,7 +161,9 @@ async def answer_node(state: OrchestratorState) -> dict: [SystemMessage(content=system_prompt), *context, *state["messages"]] ) return {"messages": [response]} + # endregion + # region graph parent_graph = StateGraph(OrchestratorState) parent_graph.add_node("orchestrate", orchestrate_node) # The compiled child graph IS the node — no wrapper function. This is what @@ -170,6 +179,7 @@ async def answer_node(state: OrchestratorState) -> dict: parent_graph.add_edge("research", "answer") parent_graph.add_edge("answer", END) return parent_graph.compile() + # endregion # The graph instance — referenced by langgraph.json From 75263314715505237ac2cf2e8c144edbe2ffba3f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:28:13 -0700 Subject: [PATCH 15/21] =?UTF-8?q?docs(langgraph):=20durable=20execution=20?= =?UTF-8?q?page=20=E2=80=94=20what=20the=20last=20node=20really=20returns;?= =?UTF-8?q?=20the=20pipeline=20indicator=20trails=20by=20one=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/durable-execution.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/durable-execution.mdx b/apps/website/content/docs/langgraph/guides/durable-execution.mdx index d4e461448..1c0286732 100644 --- a/apps/website/content/docs/langgraph/guides/durable-execution.mdx +++ b/apps/website/content/docs/langgraph/guides/durable-execution.mdx @@ -10,7 +10,7 @@ This page is the live example for durable execution over LangGraph. Use **Run** ## What the demo does -The Run tab shows the prebuilt `` composition beside a sidebar titled Pipeline, listing three steps: Analyze, Plan, and Generate. Send any question and the sidebar tracks the run. Each step turns from a hollow circle to a spinner while its node is executing and to a green check once the run has moved past it, because the graph writes the name of the node it just finished into state and the component reads that field back. +The Run tab shows the prebuilt `` composition beside a sidebar titled Pipeline, listing three steps: Analyze, Plan, and Generate. Send any question and the sidebar tracks the run. Each step shows the spinner once its own node has committed and the next one is running, and turns green when a later node commits. The graph writes the name of the node it just finished, so the indicator trails execution by one step, and nothing spins while the first node runs. The component reads that field back out of state. Two things are worth watching. The first is how long each step holds: the graph makes a separate model call per node, so the answer you eventually read is the third of three passes rather than one long generation. The second is where the sidebar stops. The final step stays marked active rather than complete when the run ends, because `generate` is the last name the graph ever writes and there is no fourth step for the indicator to advance to. @@ -38,11 +38,11 @@ The return value is the commit: LangGraph saves the state a node returns before ### The node that ends the run -`generate` is where the shape changes. The first two nodes append to the message list they received, so the intermediate analysis and outline accumulate in state. The last node returns only two messages — the user's question and the final answer — and because `messages` has no reducer, that return replaces the list. +`generate` is where the shape changes. The first two nodes append to the message list they received, so the intermediate analysis and outline accumulate in state. The last node returns two messages, and because `messages` has no reducer that return replaces the list. Note what it takes as the first of the two: `state["messages"][-1]` is the plan node's response, not the original question, so the thread the reader is left with is the outline re-labeled as a user turn plus the final answer. -The scratch work exists for exactly as long as the run needs it and is not part of the thread the reader is left with. +The scratch work exists for exactly as long as the run needs it: once `generate` commits, the thread holds those two messages and nothing else. ### Wiring the three nodes @@ -79,7 +79,7 @@ The component holds the node order and the labels it renders for them. This list -`injectAgent()` returns the agent registered above, and `value()` is the graph state as a signal. The `steps` computed reads `step` out of it, finds that name in the ordered list, and marks everything before it complete, that entry active, and everything after it pending. An unknown or missing `step` leaves every entry pending, which is the state before the first submission. +`injectAgent()` returns the agent registered above, and `value()` is the graph state as a signal. The `steps` computed reads `step` out of it, finds that name in the ordered list, and marks everything before it complete, that entry active, and everything after it pending. An unknown or missing `step` leaves every entry pending, which is the state before the first submission, and a second question in the same thread starts from the stale `generate` value, because nothing clears the field. @@ -87,7 +87,7 @@ No polling and no subscription: the `step` field arrives with the state updates ### The layout -`` takes the agent in the main slot and renders the conversation, the input, the loading state, and errors. The sidebar slot is the pipeline, rendered straight from the `steps` signal with a spinner, a check, or a hollow circle per entry. +`` takes the agent in the main slot and renders the conversation, the input, the loading state, and errors. The sidebar slot is the pipeline, rendered straight from the `steps` signal with a spinner, a check, or a pending circle per entry. @@ -111,14 +111,14 @@ The `` composition already renders the retry. Its error banner shows a Ret `reload()` performs the same re-submission without clearing the error first. Reach for `retry()` in a UI and `reload()` when you want the previous error to stay on screen while the new attempt runs. -Re-submitting starts a fresh run against the thread, and the thread still holds everything the failed attempt committed. That is the point — the completed work survives — but it also means a retry does not undo those messages. When a failed attempt leaves state the next one should not see, `regenerate(index)` is the call that rolls back first: it removes the assistant message at that index and everything after it, then re-runs against the trimmed thread. +Re-submitting starts a fresh run against the thread, and the thread still holds everything the failed attempt committed. That is the point — the completed work survives — but it also means a retry does not undo those messages. When a failed attempt leaves state the next one should not see, `regenerate(index)` is the call that rolls back first: it removes everything after the user message that preceded that index — the assistant turn and any tool messages with it — then re-runs against the trimmed thread; it throws rather than no-ops while a run is loading or when the index is not an assistant message. ### submit(null) resumes; it does not retry `submit(null)` starts a run with no new input. LangGraph advances whatever the thread still has pending, which is why it is the resume call for a graph paused at an `interrupt()` — see the [Interrupts guide](/docs/langgraph/guides/interrupts). -It is not the retry. Once a run has reached the end of the graph the thread has nothing pending, and `submit(null)` does nothing at all: no run starts and no error is raised. `regenerate()` is the one path that resumes a finished thread this way, and it can only do so because it first repositions the thread to the entry node — LangGraph's `as_node` parameter, applied through the transport's `updateState` — before submitting null. +It is not the retry. Once a run has reached the end of the graph the thread has nothing pending, and `submit(null)` does nothing at all: the run completes immediately without executing a node, the thread is unchanged and no error is raised. `regenerate()` is the one path that resumes a finished thread this way, and it can only do so because it first repositions the thread to the entry node — LangGraph's `as_node` parameter, applied through the transport's `updateState` — before submitting null. Both `retry()` and `reload()` are silent no-ops when the agent has nothing to re-submit. Render the button from `error()` rather than unconditionally, as `` does, so the user is never offered a control that cannot do anything. From 3de4fb469f8bba711baa6fc1e3854cb60524dd05 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:33:09 -0700 Subject: [PATCH 16/21] docs(langgraph): time travel teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/time-travel.mdx | 317 +++++------------- .../website/src/lib/docs-example-code.spec.ts | 1 - .../angular/src/app/time-travel.component.ts | 6 + .../time-travel/python/docs/guide.md | 145 -------- 4 files changed, 92 insertions(+), 377 deletions(-) delete mode 100644 cockpit/langgraph/time-travel/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/time-travel.mdx b/apps/website/content/docs/langgraph/guides/time-travel.mdx index c05ff0915..cf45dce3a 100644 --- a/apps/website/content/docs/langgraph/guides/time-travel.mdx +++ b/apps/website/content/docs/langgraph/guides/time-travel.mdx @@ -1,296 +1,151 @@ +--- +description: How the time travel example checkpoints every turn, lists the checkpoints in a timeline sidebar, and selects one, plus the history signals and the branch tree +--- + # Time Travel -Time travel lets you inspect earlier states and replay alternate execution paths. `injectAgent()` exposes the full checkpoint history and branch navigation through Angular Signals. Use it to debug agent decisions, explore alternate paths, and build undo/redo experiences. +Time travel is the ability to look at what an agent's state was a few steps ago, and to start a run from there instead of from the end of the conversation. LangGraph writes a checkpoint after every step of a run, and `injectAgent()` exposes that checkpoint history as Angular Signals. The running example is a chat with a checkpoint timeline beside it, and this guide walks the three files that make it work. Debug agent decisions, explore alternate paths, and build undo/redo experiences for your users. Time travel works with any LangGraph agent that persists checkpoints to a thread. -## How checkpointing works +## What the demo does -Time travel depends on checkpointing on the agent side. LangGraph automatically saves a checkpoint after every node execution when you compile your graph with a checkpointer. +The Run tab shows the prebuilt `` composition next to a Timeline sidebar. Before you send anything, the sidebar reads "No checkpoints yet. Send a message to begin." Ask the assistant a question and the sidebar fills with numbered rows, each carrying a label, the full checkpoint identifier, and two buttons, Replay and Fork. - - +Send two or three messages so the timeline has entries to move between, then click Replay or Fork on an earlier row: the row highlights, and the component records that checkpoint identifier as the active branch. -```python -from langgraph.graph import END, START, MessagesState, StateGraph -from langgraph.checkpoint.memory import MemorySaver -from langchain_openai import ChatOpenAI +## How it is built -llm = ChatOpenAI(model="gpt-5-mini") +Three files carry the feature: a graph that leaves checkpointing to the platform, an application config that registers the agent, and a component that renders the checkpoint history. Open the Code tab to read them in place. -def call_model(state: MessagesState) -> dict: - response = llm.invoke(state["messages"]) - return {"messages": [response]} +### A graph the platform checkpoints -builder = StateGraph(MessagesState) -builder.add_node("call_model", call_model) -builder.add_edge(START, "call_model") -builder.add_edge("call_model", END) +The backend is a single streaming node over `MessagesState`, with a system prompt read from the capability's prompt file. Nothing in it mentions time travel, because the checkpoints come from the server that runs it. -# Compile with a checkpointer to enable time travel -checkpointer = MemorySaver() -graph = builder.compile(checkpointer=checkpointer) + -# Run the graph with a thread ID -config = {"configurable": {"thread_id": "user_123"}} -result = graph.invoke( - {"messages": [("user", "What is LangGraph?")]}, - config=config, -) +`compile()` is called with no checkpointer, since the LangGraph API server provides persistence itself; the [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you embed in your own process. -# Browse checkpoint history server-side -for state in graph.get_state_history(config): - print(f"Step: {state.metadata.get('step', '?')}") - print(f" Checkpoint: {state.config['configurable']['checkpoint_id']}") - print(f" Messages: {len(state.values.get('messages', []))}") - -# Replay from a specific checkpoint -past_config = { - "configurable": { - "thread_id": "user_123", - "checkpoint_id": "", - } -} -past_state = graph.get_state(past_config) -``` +### The provider - - +The example resolves its connection details at runtime from the host that serves it, which is why `provideAgent()` takes a factory. Your own application passes `apiUrl` and `assistantId` directly. -```typescript -import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; - -@Component({ - selector: 'app-history-viewer', - templateUrl: './history-viewer.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class HistoryViewerComponent { - protected readonly agent = injectAgent(); - - readonly checkpoints = computed(() => this.agent.history()); - readonly rawCheckpoints = computed(() => this.agent.langGraphHistory()); - readonly checkpointCount = computed(() => this.agent.history().length); - - readonly activeIndex = computed(() => - this.checkpoints().length - 1 - ); + - fork(index: number) { - const checkpoint = this.rawCheckpoints()[index]?.checkpoint; - if (!checkpoint) return; - this.agent.submit( - { message: 'Try a different approach' }, - { checkpoint } - ); - } - - formatTime(isoString: string): string { - return new Date(isoString).toLocaleTimeString(); - } -} -``` +### Reading the checkpoint history - - +The component injects the agent and derives the timeline from it. `langGraphHistory()` is LangGraph's own view of the thread: an array of `ThreadState` snapshots. `selectedIndex` is local interface state, holding the row the user clicked last. -## Browsing execution history + -The `history()` signal contains runtime-neutral `AgentCheckpoint` entries for the thread. For LangGraph-specific checkpoint metadata, `langGraphHistory()` exposes the raw `ThreadState[]`. The framework loads this history with `threads.getHistory()` when a thread is selected and refreshes it after a run completes. +Nothing in the component fetches that history: the framework loads it when a thread is selected and refreshes it when a run completes. -```typescript -// agent.ts (shared): -// import { createAgentRef } from '@threadplane/chat'; -// export const MY_AGENT = createAgentRef('my-agent'); +### The timeline sidebar + +The `` composition owns the transcript, the input, and the loading and error states, so the template only adds the sidebar. Each row renders its position in the history, a label, the checkpoint identifier, and the two action buttons. -// Configured globally in app.config.ts via provideAgent(MY_AGENT, { ... threadId }). -const agent = injectAgent(MY_AGENT); + -// Runtime-neutral execution timeline +`checkpoint_id` is optional on a `ThreadState`, which is why the identifier line and both handlers guard on it. + +### Selecting a checkpoint + +Replay and Fork run the same two statements: record the clicked row so the sidebar can highlight it, then hand the checkpoint identifier to `setBranch()`. + + + + +`setBranch(id)` writes the identifier into the `branch()` signal, and that is all it does. No run starts, and the next `submit()` continues from the tip of the thread as usual. To run from a past checkpoint, pass that checkpoint to `submit()` yourself, as [Running from a past checkpoint](#running-from-a-past-checkpoint) shows. + + +## Reading the history two ways + +`history()` and `langGraphHistory()` describe the same thread at two levels. The first is the runtime-neutral shape every adapter that keeps checkpoints can produce; the second is LangGraph's own. + +```typescript +const agent = injectAgent(); + +// Runtime-neutral: AgentCheckpoint[], each with id, label, and values. const checkpoints = computed(() => agent.history()); -const checkpointCount = computed(() => agent.history().length); -// Raw LangGraph checkpoints +// Raw LangGraph: ThreadState[], each with checkpoint, parent_checkpoint, +// metadata, created_at, next, tasks, and the full values snapshot. const rawCheckpoints = computed(() => agent.langGraphHistory()); - -// Access a specific checkpoint -const latestCheckpoint = computed(() => { - const history = agent.history(); - return history[history.length - 1]; -}); ``` -Each runtime-neutral checkpoint exposes `id`, `label`, and `values`. Each raw `ThreadState` entry exposes `checkpoint`, `parent_checkpoint`, `metadata`, `created_at`, and the full `values` snapshot, giving you complete visibility into every step of execution. +The example reads the raw signal because it renders LangGraph's `checkpoint` object directly; `history()` would hand it the same identifier as an adapter-opaque `id`. -## Forking from a checkpoint +## Running from a past checkpoint -Submit with a specific checkpoint to branch execution from an earlier state. That creates a new branch in the thread graph while leaving the original path intact. +`submit()` accepts LangGraph's run options, and two of them address a checkpoint: `checkpoint` takes a `ThreadState.checkpoint` object, and `checkpointId` takes only its identifier. Passing either starts the run from that point in the thread rather than from its tip, which is what creates a second path out of one checkpoint. ```typescript -forkFromCheckpoint(index: number) { +protected forkFrom(index: number, message: string): void { const checkpoint = this.agent.langGraphHistory()[index]?.checkpoint; if (!checkpoint) return; - this.agent.submit( - { message: 'Try a different approach' }, - { checkpoint } - ); -} - -// Fork with a completely different input -retryWithAlternative(index: number, newInput: string) { - const checkpoint = this.agent.langGraphHistory()[index]?.checkpoint; - if (!checkpoint) return; - this.agent.submit( - { message: newInput }, - { checkpoint } - ); + void this.agent.submit({ message }, { checkpoint }); } ``` -## Branch navigation +The original path is left intact, so the earlier checkpoints stay in the history and the new run hangs off the one you named. -Use `branch()`, `setBranch()`, and `experimentalBranchTree()` to navigate between execution branches. Branches are automatically created when you fork from a checkpoint, and the branch tree is derived from raw `ThreadState.parent_checkpoint` relationships. +## Navigating branches + +`branch()` holds the identifier the application last set, and `setBranch()` writes it. `experimentalBranchTree()` turns the flat history into a tree by linking each `ThreadState` to its `parent_checkpoint`, so a checkpoint with two children becomes a fork with one sequence per alternate path. ```typescript -// Current branch identifier +// The identifier the application last selected. const activeBranch = computed(() => agent.branch()); -// Full branch tree for custom time-travel UIs +// A sequence of nodes and forks, for a custom time-travel interface. const branchTree = computed(() => agent.experimentalBranchTree()); - -// Switch to a different branch -selectBranch(branchId: string) { - agent.setBranch(branchId); -} ``` -## Building a history UI +An `AgentBranchTree` is a `sequence` whose items are either a `node`, carrying one `ThreadState` and the path that reached it, or a `fork`, carrying a list of alternate sequences. The signal is named `experimental` deliberately: treat its shape as unsettled. -Expose checkpoint history directly in your component to let users scrub through execution steps or rewind to any earlier state. +## Reading history on the server - - -```typescript -import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; - -@Component({ - selector: 'app-history-viewer', - templateUrl: './history-viewer.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class HistoryViewerComponent { - protected readonly agent = injectAgent(); - - readonly checkpoints = computed(() => this.agent.history()); - readonly rawCheckpoints = computed(() => this.agent.langGraphHistory()); - readonly activeIndex = computed(() => - this.checkpoints().length - 1 - ); +The signals above are the client's view. When you embed the graph in your own process rather than serving it through LangGraph Platform, the compiled graph exposes the same history in Python, and the [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer that case needs. - fork(index: number) { - const checkpoint = this.rawCheckpoints()[index]?.checkpoint; - if (!checkpoint) return; - this.agent.submit( - { message: 'Try a different approach' }, - { checkpoint } - ); - } - - formatTime(isoString: string): string { - return new Date(isoString).toLocaleTimeString(); - } -} -``` - - -```html -
    - @for (cp of checkpoints(); track cp.id; let i = $index) { -
  • - Step {{ i + 1 }} - {{ cp.label ?? cp.id }} - -
  • - } -
+```python +config = {"configurable": {"thread_id": "user_123"}} + +# Every checkpoint recorded for the thread. +for state in graph.get_state_history(config): + print(state.config["configurable"]["checkpoint_id"], state.next) + +# Read one past checkpoint by id. +past_state = graph.get_state( + { + "configurable": { + "thread_id": "user_123", + "checkpoint_id": "", + } + } +) ``` -
-
## Comparing checkpoints -Diff two checkpoints to understand exactly what changed between execution steps. This is useful for understanding tool call results, message additions, or state mutations. +Two checkpoints and a diff tell you what a step actually changed, which is often faster than reading the transcript for it. The runtime-neutral `history()` is enough here, because `values` is the whole state snapshot. ```typescript -compareCheckpoints(indexA: number, indexB: number) { +protected compare(indexA: number, indexB: number) { const history = this.agent.history(); - const stateA = history[indexA]?.values; - const stateB = history[indexB]?.values; + const before = history[indexA]?.values; + const after = history[indexB]?.values; + if (!before || !after) return null; - if (!stateA || !stateB) return null; - - // Compare message counts - const messagesAdded = (stateB.messages?.length ?? 0) - - (stateA.messages?.length ?? 0); - - // Identify changed keys - const changedKeys = Object.keys({ ...stateA, ...stateB }).filter( - key => JSON.stringify(stateA[key]) !== JSON.stringify(stateB[key]) + const changedKeys = Object.keys({ ...before, ...after }).filter( + (key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]), ); - - return { messagesAdded, changedKeys }; + return { changedKeys }; } ``` -Use the comparison result to render a diff view, highlight changed fields in your UI, or log what the agent touched during a specific step. - -## Replaying with modified input - -Combine forking with new input to explore how the agent would have responded differently. This is the core of the undo/redo experience. - -```typescript -// agent.ts (shared): -// import { createAgentRef } from '@threadplane/chat'; -// export const MY_AGENT = createAgentRef('my-agent'); - -@Component({ - selector: 'app-replay', - templateUrl: './replay.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ReplayComponent { - protected readonly agent = injectAgent(MY_AGENT); - - readonly history = computed(() => this.agent.history()); - readonly rawHistory = computed(() => this.agent.langGraphHistory()); - readonly canUndo = computed(() => this.history().length > 1); - - undo() { - const history = this.history(); - if (history.length < 2) return; - - // Go back one step - const previousCheckpoint = this.rawHistory()[history.length - 2]?.checkpoint; - if (!previousCheckpoint) return; - this.agent.submit({}, { - checkpoint: previousCheckpoint, - }); - } - - replayWith(index: number, newMessage: string) { - const checkpoint = this.rawHistory()[index]?.checkpoint; - if (!checkpoint) return; - this.agent.submit( - { message: newMessage }, - { checkpoint } - ); - } -} -``` +Use the result to render a diff view, highlight changed fields, or log what the agent touched during a step. Time travel is most useful during development. Inspect why an agent chose a particular path by comparing adjacent checkpoints, then fork to test alternatives without restarting the conversation. Combine `history()` with Angular DevTools to watch checkpoint arrays update in real time as the agent streams. diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index c53e27e66..1e7311a36 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -46,7 +46,6 @@ const PENDING_PAGES = new Set([ '/docs/deep-agents/capabilities/skills', '/docs/deep-agents/capabilities/subagents', '/docs/langgraph/guides/deployment', - '/docs/langgraph/guides/time-travel', '/docs/render/api/provide-render', '/docs/render/api/render-spec-component', '/docs/render/guides/registry', diff --git a/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts b/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts index 6ecaa5524..e202a5f36 100644 --- a/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts +++ b/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts @@ -141,6 +141,7 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; +
@@ -189,10 +190,12 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; }
+ `, }) export class TimeTravelComponent { + // #region history protected readonly agent = injectAgent(); /** Index of the currently selected checkpoint in the sidebar. */ @@ -202,6 +205,7 @@ export class TimeTravelComponent { protected readonly checkpoints = computed( (): ThreadState[] => this.agent.langGraphHistory(), ); + // #endregion /** Display label for a checkpoint entry. */ protected checkpointLabel( @@ -214,6 +218,7 @@ export class TimeTravelComponent { return `State ${index + 1}`; } + // #region branch /** Replay the conversation from the given checkpoint. */ protected replay(state: ThreadState, index: number): void { if (state.checkpoint?.checkpoint_id) { @@ -229,4 +234,5 @@ export class TimeTravelComponent { this.agent.setBranch(state.checkpoint.checkpoint_id); } } + // #endregion } diff --git a/cockpit/langgraph/time-travel/python/docs/guide.md b/cockpit/langgraph/time-travel/python/docs/guide.md deleted file mode 100644 index 8db1449b3..000000000 --- a/cockpit/langgraph/time-travel/python/docs/guide.md +++ /dev/null @@ -1,145 +0,0 @@ -# Time Travel with Angular - - -Build a chat interface with time travel using `provideAgent()` and -`injectAgent()` from `@threadplane/langgraph`. Browse the checkpoint history via `stream.history()`, -see the active branch via `stream.branch()`, and fork the conversation from any -past state with `stream.setBranch(checkpointId)`. - - - -Add time travel to this Angular component using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. Display checkpoint history from `stream.history()` in the sidebar. Highlight the active branch using `stream.branch()`. Call `stream.setBranch(id)` when the user clicks a checkpoint to fork the conversation from that point. - - - - - -Set up `provideAgent()` in your app config with the LangGraph API URL: - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: 'https://your-deployment.langgraph.app', - assistantId: 'time-travel', - }), - ], -}; -``` - - - - -In your component, call `injectAgent()`. The history and branch signals are -available automatically - no extra config needed: - -```typescript -// time-travel.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -export class TimeTravelComponent { - protected readonly stream = injectAgent(); -} -``` - - - - -Use `stream.history()` to render checkpoints and `stream.branch()` to highlight -the active one: - -```html - - - -``` - -Each entry in `stream.history()` is a `ThreadState` snapshot with a -`checkpoint_id` and `created_at` timestamp. - - - - -Call `stream.setBranch(checkpointId)` to set the active branch. The next -`stream.submit()` will fork the conversation from that checkpoint: - -```typescript -selectCheckpoint(state: { checkpoint_id?: string }): void { - if (state.checkpoint_id) { - this.stream.setBranch(state.checkpoint_id); - } -} - -send(text: string): void { - void this.stream.submit({ message: text }); -} -``` - - -After branching, the conversation diverges from the selected checkpoint. -The original timeline remains accessible in the history sidebar. - - - - - -The backend uses `MemorySaver` to persist checkpoint history. Time travel is a -client-side feature - the graph itself requires only the checkpointer: - -```python -# graph.py -from langgraph.graph import StateGraph, MessagesState, END -from langgraph.checkpoint.memory import MemorySaver - -checkpointer = MemorySaver() - -def build_time_travel_graph(): - llm = ChatOpenAI(model="gpt-5-mini", streaming=True) - - async def generate(state: MessagesState) -> dict: - response = await llm.ainvoke(state["messages"]) - return {"messages": [response]} - - graph = StateGraph(MessagesState) - graph.add_node("generate", generate) - graph.set_entry_point("generate") - graph.add_edge("generate", END) - return graph.compile(checkpointer=checkpointer) -``` - - -For production, replace `MemorySaver` with `PostgresCheckpointer` for durable -checkpoint history across server restarts. - - - - - - -The `stream.history()` signal updates after each successful submission. -The list grows as the conversation progresses, giving you a full audit trail. - - - -`stream.setBranch()` sets a client-side branch pointer. The branch only takes -effect on the next `stream.submit()` call. Calling `setBranch` without -submitting does not modify the thread state. - - - -- [Chat Timeline](/chat/core-capabilities/timeline/overview/python) - Explore ChatTimelineComponent for visualizing thread history -- [Chat Debug](/chat/core-capabilities/debug/overview/python) - Learn how ChatDebugComponent aids in debugging agent behavior - From ab9aefe5a82d99993ab6fcf6e77744807084332f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:36:03 -0700 Subject: [PATCH 17/21] =?UTF-8?q?docs(langgraph):=20subgraphs=20page=20?= =?UTF-8?q?=E2=80=94=20correct=20subagent=20types,=20explain=20the=20provi?= =?UTF-8?q?der=20factory,=20no=20retired=20brand=20in=20the=20rendered=20r?= =?UTF-8?q?ef=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../content/docs/langgraph/guides/subgraphs.mdx | 12 ++++++++---- .../langgraph/subgraphs/angular/src/app/agent-ref.ts | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/subgraphs.mdx b/apps/website/content/docs/langgraph/guides/subgraphs.mdx index d87116d10..e7c4c7d4c 100644 --- a/apps/website/content/docs/langgraph/guides/subgraphs.mdx +++ b/apps/website/content/docs/langgraph/guides/subgraphs.mdx @@ -34,7 +34,7 @@ The child is an ordinary graph. One node, one model call, and a `compile()` at t -Its system prompt restates the structural fact in words, telling the researcher that it cannot see the chat transcript and that its output is an internal brief for the parent to use. +The module-level `RESEARCH_PROMPT` (not shown) restates the structural fact in words, telling the researcher that it cannot see the chat transcript and that its output is an internal brief for the parent to use. ### Deciding whether to delegate @@ -66,7 +66,9 @@ The Angular side declares the parent's state once and hands it to a ref that bot -The application config registers that ref with `provideAgent()` and adds `provideChat({})` for the chat composition. In your own application the two connection values are literals: +The application config registers that ref with `provideAgent()` and adds `provideChat({})` for the chat composition. + +The running example registers the provider through a factory that reads its connection from the host that serves the demo, which is how the same build runs locally and in production. Your own application passes the two connection values as literals, and keeps `transcriptNodeNames` exactly as the example sets it: ```typescript provideAgent(SUBGRAPHS_AGENT, { @@ -97,7 +99,7 @@ The sidebar renders `topic()` and `brief()` under the route line, so watching th -`status()` and `name` come off the `SubagentStreamRef`, which is why the mapped objects carry a called signal rather than the ref itself. +`status()` and `name` come off each `Subagent` entry in the `subagents()` map, which is why the mapped objects carry a called signal rather than the entry itself. `injectAgent()` must run inside an Angular injection context: a field initializer, as it is here, or a constructor body. @@ -120,6 +122,8 @@ provideAgent(ORCHESTRATOR, { }), ``` +`ORCHESTRATOR` and `PIPELINE` here stand for agent refs created the same way as `SUBGRAPHS_AGENT`. + With that in place, the same lookups work on either flavor: ```typescript @@ -150,7 +154,7 @@ Registration is skipped silently unless the tool call also carries a valid `suba ## Subagent stream details -Each `SubagentStreamRef` exposes its own reactive signals — status, messages, and state — so you can surface granular progress in your UI. +Each `SubagentStreamRef` exposes its own reactive signals — status, messages, and values — so you can surface granular progress in your UI. ```typescript // Access a specific subagent by its tool call ID diff --git a/cockpit/langgraph/subgraphs/angular/src/app/agent-ref.ts b/cockpit/langgraph/subgraphs/angular/src/app/agent-ref.ts index 03282d56b..eb07e5033 100644 --- a/cockpit/langgraph/subgraphs/angular/src/app/agent-ref.ts +++ b/cockpit/langgraph/subgraphs/angular/src/app/agent-ref.ts @@ -1,7 +1,7 @@ import { createAgentRef } from '@threadplane/chat'; /** - * Parent graph state for the subgraphs cockpit. + * Parent graph state for the subgraphs example. * * `research_topic` and `research_brief` are the two keys the parent shares * with the compiled child graph — writing a topic is what routes execution From 4cda0bacbb5250b8518e398326422de06eae02bc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:40:18 -0700 Subject: [PATCH 18/21] docs(langgraph): deployment teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../docs/langgraph/guides/deployment.mdx | 243 +++++++++++------- .../website/src/lib/docs-example-code.spec.ts | 1 - .../deployment-runtime/python/docs/guide.md | 187 -------------- 3 files changed, 147 insertions(+), 284 deletions(-) delete mode 100644 cockpit/langgraph/deployment-runtime/python/docs/guide.md diff --git a/apps/website/content/docs/langgraph/guides/deployment.mdx b/apps/website/content/docs/langgraph/guides/deployment.mdx index 7600f0ba5..8f6cff2b0 100644 --- a/apps/website/content/docs/langgraph/guides/deployment.mdx +++ b/apps/website/content/docs/langgraph/guides/deployment.mdx @@ -1,41 +1,83 @@ +--- +description: How the deployment example packages a graph and points an Angular app at it, plus authentication, CORS, error handling, CI, and monitoring +--- + # Deployment -Deploy your LangGraph agent to the cloud and ship your Angular frontend to production with environment-based configuration, authentication, error handling, and observability. +A deployed agent is two independent artifacts: a graph running on LangGraph Platform, and an Angular application that connects to it. The running example is the smallest pair that works — one graph, one provider, one component — and the rest of this guide covers the operational parts the example cannot show: authentication, CORS, error handling, continuous delivery, and monitoring. + + +Make sure you have completed the Installation guide first. + + +## What the demo does + +The Run tab shows the prebuilt `` composition and nothing else. Send a message and the answer streams back from the graph, exactly as it would from a local server. + +That is the point of this example. Nothing about the deployment is visible in the component: the graph name lives in `langgraph.json`, and the API URL and assistant ID live in the provider. Change those values and the same component talks to a local server, a staging deployment, or production. + +## How it is built + +Three files carry the deployable unit: the graph packaged for the platform, the provider that holds the connection, and a component that knows neither. Open the Code tab to read them in place. + +### The graph you deploy -## Python: LangGraph Cloud deployment +The backend is a single node. `MessagesState` gives the LangGraph SDK a message list it already understands, the model is constructed with `streaming=True`, and the node prepends a system prompt read from the capability's prompt file before it awaits the model. -Your agent code needs a `langgraph.json` manifest at the project root. This file tells LangGraph Cloud how to build and serve your agent. + -Let's start there. +The last line matters more than the graph does: `graph = build_deployment_runtime_graph()` is the module-level symbol `langgraph.json` points at, and `compile()` is called with no checkpointer because the platform provides persistence itself. + +### Pointing the app at a deployment + +`provideAgent()` registers the agent once for the whole application, which is where every deployment-specific value belongs. The example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them. It sits alongside `provideChat({})`, which supplies the chat composition's own providers. + + + +Your own application does not need the factory. Pass the values directly: + +```typescript +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'deployment-runtime', +}), +provideChat({}), +``` + +`clientOptions` is the third value the example forwards. It tunes the LangGraph SDK client behind the default transport, and it accepts two keys: `apiKey`, and `maxRetries` for how many times a failed request is retried with exponential backoff before the error reaches your UI. + + +The `assistantId` you pass to `provideAgent()` must exactly match a key in the `graphs` map of `langgraph.json`. A mismatch is not a build error: the application starts normally and the first message fails against the deployment. + + +### The component + +`injectAgent()` returns the agent registered above and hands it to ``. The class is one field. + + + +No URL, no assistant ID, no environment check: the same component ships to every environment. + +## Deploying the graph + +Your agent needs a `langgraph.json` manifest at the project root. This is the example's, and it is the whole file: ```json { - "dependencies": ["."], "graphs": { - "chat": "./agent/graph.py:graph" + "deployment-runtime": "./src/graph.py:graph" }, + "dependencies": ["."], + "python_version": "3.12", "env": ".env" } ``` -The `graphs` key maps an assistant ID (configured via `provideAgent({ assistantId: '...' })` on the Angular side) to the Python module path and graph variable. The `env` key points to a file with secrets like `OPENAI_API_KEY` that will be injected at runtime. +The `graphs` key maps an assistant ID to a module path and the graph variable inside it. `dependencies` names the packages to install into the image, and `env` points at a file with secrets like `OPENAI_API_KEY` that are injected at runtime. -### Agent entry point - -```python -from langchain_openai import ChatOpenAI -from langgraph.graph import StateGraph, MessagesState - -llm = ChatOpenAI(model="gpt-5-mini") - -def call_model(state: MessagesState): - return {"messages": [llm.invoke(state["messages"])]} - -graph = StateGraph(MessagesState) -graph.add_node("model", call_model) -graph.set_entry_point("model") -graph = graph.compile() -``` + +`langgraph dev` serves the same manifest on your machine and speaks the same API a deployment does, so you can point a development build at it before you deploy anything. It refuses a graph that compiles its own checkpointer; a deployment ignores one. Compile with no checkpointer, as the example does. + ### Push and deploy @@ -78,7 +120,7 @@ Click **Deploy**. Once the build succeeds, you will see a deployment URL like `h
-## Angular: environment configuration +## Environment configuration Angular uses file-based environment replacement at build time rather than `process.env`. Create separate environment files for development and production. @@ -115,6 +157,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideAgent({ apiUrl: environment.langgraphUrl, + assistantId: 'deployment-runtime', }), ], }; @@ -126,30 +169,12 @@ Angular CLI replaces `environment.ts` with `environment.prod.ts` during `ng buil ### Keep LangGraph credentials server-side -Do not ship LangSmith or LangGraph API keys in an Angular bundle. The default `FetchStreamTransport` uses the LangGraph SDK client directly, not Angular `HttpClient`, so Angular HTTP interceptors do not attach headers to `injectAgent()` requests. +Do not ship LangSmith or LangGraph API keys in an Angular bundle. The default `FetchStreamTransport` uses the LangGraph SDK client directly, not Angular `HttpClient`, so Angular HTTP interceptors do not attach headers to `injectAgent()` requests. `clientOptions.apiKey` does reach the SDK client, but a value passed there is bundled into the browser build like any other constant. -For production, put a same-origin backend route, edge function, or API gateway in front of LangGraph. The browser calls your relative URL, and that server-side layer adds the deployment credentials. +For production, put a same-origin backend route, edge function, or API gateway in front of LangGraph. The browser calls your relative URL, and that server-side layer adds the deployment credentials. Set `apiUrl` to that relative path, as the production environment file above does. For me, the same-origin proxy is worth the extra hop. You give up the simplicity of pointing Angular straight at the deployment, but your keys never leave the server, and HTTP-only cookies just work. -```typescript -// environment.prod.ts -export const environment = { - production: true, - langgraphUrl: '/api/langgraph', -}; -``` - -```typescript -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: environment.langgraphUrl, - }), - ], -}; -``` - Environment files that are bundled into Angular are public. Store LangGraph deployment credentials in your server-side proxy, hosting provider secret store, or CI/CD environment, not in `environment.prod.ts`. @@ -168,7 +193,7 @@ In `langgraph.json`, add an `http` section: { "dependencies": ["."], "graphs": { - "chat": "./agent/graph.py:graph" + "deployment-runtime": "./src/graph.py:graph" }, "http": { "cors": { @@ -185,23 +210,14 @@ In `langgraph.json`, add an `http` section: During local development with `langgraph dev`, CORS is permissive by default. You only need explicit CORS configuration for production deployments. -## Error boundaries +## Error handling -Production apps need graceful error handling. Let's build a reactive error boundary using `injectAgent()` signals. - -The transport runs on the LangGraph SDK client, not Angular's `HttpClient`, so `error()` won't be an `HttpErrorResponse`. To branch on HTTP status, read `error()?.cause` — the SDK attaches the underlying status there (see [Streaming](/docs/langgraph/guides/streaming#stream-status)). +The demo leans on ``, which renders failures for you. A hand-built UI reads the same two signals the composition does: `status()` flips to `'error'`, and `error()` holds an `AgentError` with a classified `kind`, a `retryable` flag, an optional HTTP `status`, and the original failure on `cause`. ```typescript import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; -/** Pull an HTTP status off the error's `cause`, if present. */ -function httpStatus(err: unknown): number | undefined { - const cause = (err as { cause?: unknown } | null | undefined)?.cause; - const status = (cause as { status?: unknown } | undefined)?.status; - return typeof status === 'number' ? status : undefined; -} - @Component({ selector: 'app-chat', changeDetection: ChangeDetectionStrategy.OnPush, @@ -209,65 +225,80 @@ function httpStatus(err: unknown): number | undefined { @if (hasError()) {

{{ errorMessage() }}

- + @if (canRetry()) { + + }
} `, }) export class ChatComponent { - // injectAgent() without a ref returns the default-typed agent (state/value are Record). - // This component only uses status() and error(), so the no-arg form is sufficient. - protected readonly chat = injectAgent(); - - hasError = computed(() => this.chat.status() === 'error'); - - errorMessage = computed(() => { - const err = this.chat.error(); - switch (httpStatus(err)) { - case 401: return 'Authentication failed. Please check your API key.'; - case 429: return 'Rate limit exceeded. Please wait a moment.'; - case 503: return 'Agent is starting up. Please try again shortly.'; + protected readonly agent = injectAgent(); + + protected readonly hasError = computed(() => this.agent.status() === 'error'); + protected readonly canRetry = computed( + () => this.agent.error()?.retryable === true, + ); + + protected readonly errorMessage = computed(() => { + const failure = this.agent.error(); + if (!failure) return ''; + switch (failure.kind) { + case 'auth': + return 'The deployment rejected the request. Check the credentials on your proxy.'; + case 'connection': + return 'The deployment is unreachable. Check your connection and try again.'; + case 'interrupted': + return 'The response was interrupted. Try again.'; + default: + return failure.message; } - return err instanceof Error ? err.message : 'An unexpected error occurred.'; }); - retry(): void { - this.chat.reload(); + protected retry(): void { + void this.agent.retry(); } } ``` +Branching on `kind` covers the cases that need different copy without reading HTTP status codes; `failure.status` is there when you want the exact code. + ### Retry with exponential backoff -For automated retries (network blips, transient 5xx errors), wrap `.submit()` with a backoff utility: +`submit()` resolves whether the run succeeded or failed — a failure lands on `error()` rather than rejecting the promise — so an automated retry reads the signal between attempts and uses `retry()`, which re-runs the last input and clears the error first. ```typescript import type { LangGraphAgent } from '@threadplane/langgraph'; -export async function retrySubmit( - chat: LangGraphAgent, +export async function submitWithBackoff( + agent: LangGraphAgent, input: Record, maxAttempts = 3, ): Promise { - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - await chat.submit(input); - return; - } catch { - if (attempt === maxAttempts - 1) throw new Error('Max retries exceeded'); - await new Promise(r => setTimeout(r, 1000 * 2 ** attempt)); - } + await agent.submit(input); + + for (let attempt = 1; attempt < maxAttempts; attempt++) { + const failure = agent.error(); + if (!failure) return; + if (!failure.retryable) throw failure; + await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** (attempt - 1))); + await agent.retry(); } + + const failure = agent.error(); + if (failure) throw failure; } ``` +The `retryable` check is what keeps a wrong API key from being retried three times. + ## Stream recovery Use `joinStream()` to reconnect to a running agent execution after a network interruption, page refresh, or navigation event. ```typescript // Store the run ID when LangGraph creates the run -await this.chat.submit( +await this.agent.submit( { message: input }, { streamResumable: true, @@ -283,21 +314,34 @@ if (savedRunId) { // The second arg is an optional last-event id. Omit it (or pass // undefined) to replay the run from the start; pass a stored id to // resume only the events after it. - await this.chat.joinStream(savedRunId); + await this.agent.joinStream(savedRunId); } ``` -`joinStream(runId, lastEventId?)` replays any events the client missed, then switches to live streaming. The second argument is an optional last-event id: omit it (as above) and LangGraph replays from the start of the run; pass a stored id to resume only the events that came after it. Replaying from the start is the safe default — the run's full state is on the Platform, so the client just re-renders from scratch. - -This works because all state lives on the LangGraph Platform, and the SSE endpoint supports event ID-based resumption. +`joinStream(runId, lastEventId?)` replays any events the client missed, then switches to live streaming. Replaying from the start is the safe default — the run's full state is on the Platform, so the client just re-renders from scratch. `injectAgent()` is a stateless client. All state lives on the LangGraph Platform. This means your Angular app can be deployed anywhere (CDN, edge, SSR) without state management concerns. Scale your frontend independently of your agent infrastructure. +## Hosting the Angular app + +A built Angular app is static files, so any static host will serve it. Two rules apply: rewrite unknown paths to `index.html` so client-side routing works, and proxy the LangGraph calls through the same origin so the deployment credentials stay on the server. On Vercel both are one file: + +```json +{ + "rewrites": [ + { "source": "/api/langgraph/(.*)", "destination": "https://your-deployment.langgraph.app/$1" }, + { "source": "/(.*)", "destination": "/index.html" } + ] +} +``` + +That is the server-side layer the Authentication section describes, and it is why the production environment file sets `langgraphUrl` to `/api/langgraph`. + ## CI/CD pipeline -A typical pipeline deploys the Python agent and Angular frontend in parallel since they are independent artifacts. +The Python agent and the Angular frontend are independent artifacts, so a pipeline can build them in parallel. ```yaml name: Deploy @@ -342,6 +386,10 @@ jobs: echo "Deploy dist/ to your hosting platform" ``` + +Parallel is right when the two artifacts do not depend on each other. When a release changes the graph and the UI together, add `needs: deploy-agent` to the frontend job so the new API is live before any traffic reaches it. + + ## Monitoring ### LangSmith observability @@ -363,14 +411,14 @@ Key metrics to track in production: Track stream health from your Angular app: ```typescript -const status = this.chat.status(); // 'idle' | 'running' | 'error' -const isStreaming = this.chat.isLoading(); +const status = this.agent.status(); // 'idle' | 'running' | 'error' +const isStreaming = this.agent.isLoading(); // Log stream lifecycle for your APM tool effect(() => { - const s = this.chat.status(); - if (s === 'error') { - this.analytics.trackError('stream_error', this.chat.error()); + const failure = this.agent.error(); + if (failure) { + this.analytics.trackError('stream_error', failure.kind); } }); ``` @@ -381,6 +429,9 @@ effect(() => { Point `provideAgent({ apiUrl })` to your same-origin LangGraph proxy, or to a direct LangGraph URL only when that deployment is intentionally public. + +Pass the `graphs` key from `langgraph.json` as `assistantId`. A mismatch fails on the first message, not at build time. + Route browser traffic through a server-side proxy or gateway that adds LangGraph credentials outside the Angular bundle. @@ -388,13 +439,13 @@ Route browser traffic through a server-side proxy or gateway that adds LangGraph Add your Angular app's origin to the `allow_origins` list in `langgraph.json`. -Show user-friendly error messages for 401, 429, 503, and network failures. Provide retry buttons. +Branch on `error().kind` for legible copy, and show a retry button only when `error().retryable` is true. Store `runId` and use `joinStream()` to reconnect after network interruptions. -Store `threadId` in `localStorage` or a backend so users can resume conversations across sessions. +Store the server-issued `threadId` in `localStorage` or a backend so users can resume conversations across sessions. Set the `throttle` option if token-by-token updates are too frequent for your UI rendering. diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 1e7311a36..e5b6e0c5c 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -45,7 +45,6 @@ const PENDING_PAGES = new Set([ '/docs/deep-agents/capabilities/planning', '/docs/deep-agents/capabilities/skills', '/docs/deep-agents/capabilities/subagents', - '/docs/langgraph/guides/deployment', '/docs/render/api/provide-render', '/docs/render/api/render-spec-component', '/docs/render/guides/registry', diff --git a/cockpit/langgraph/deployment-runtime/python/docs/guide.md b/cockpit/langgraph/deployment-runtime/python/docs/guide.md deleted file mode 100644 index c83316c98..000000000 --- a/cockpit/langgraph/deployment-runtime/python/docs/guide.md +++ /dev/null @@ -1,187 +0,0 @@ -# Production Deployment with LangGraph Cloud - - -Deploy a LangGraph graph to LangGraph Cloud and connect an Angular app using -`provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. This tutorial covers -`langgraph deploy`, environment configuration, Vercel hosting for Angular, and -CI automation. - - - -Build a production-ready Angular chat app that connects to a LangGraph Cloud deployment using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. Configure the `apiUrl` to point to your deployed LangGraph Cloud endpoint and set `assistantId` to match the graph name in `langgraph.json`. Display the deployment status via `stream.status()` and show the thread ID from the `onThreadId` callback. - - - - - -Create a `langgraph.json` in your Python project root that maps graph names to their entry points: - -```json -{ - "graphs": { - "deployment-runtime": "./src/graph.py:graph" - }, - "dependencies": ["./pyproject.toml"], - "env": ".env" -} -``` - -The key `"deployment-runtime"` becomes the `assistantId` your Angular app uses. - - - - -Install the LangGraph CLI and authenticate with LangSmith, then deploy: - -```bash -pip install langgraph-cli -langgraph deploy -``` - -The CLI packages your graph, pushes it to LangGraph Cloud, and returns a deployment URL of the form `https://.langgraph.app`. - - -Run `langgraph dev` first to test locally before deploying. It starts a local server on port 8123 compatible with the production API. - - - - - -Set the deployment URL in your Angular environment files: - -```typescript -// environment.ts (production) -export const environment = { - production: true, - langGraphApiUrl: 'https://your-deployment.langgraph.app', - deploymentRuntimeAssistantId: 'deployment-runtime', -}; -``` - -```typescript -// environment.development.ts -export const environment = { - production: false, - langGraphApiUrl: 'http://localhost:4307/api', - deploymentRuntimeAssistantId: 'deployment-runtime', -}; -``` - -Angular's file replacement swaps the environment at build time. - - - - -Configure `provideAgent()` with the deployment URL and assistant ID, then -retrieve it with `injectAgent()`. Because the `onThreadId` callback is -per-instance, provide the agent in the component's `providers: []` and capture -the thread ID into a module-scoped signal: - -```typescript -// deployment-runtime.component.ts -import { Component, signal } from '@angular/core'; -import { injectAgent, provideAgent } from '@threadplane/langgraph'; -import { environment } from '../environments/environment'; - -const currentThreadIdState = signal(''); - -@Component({ - // ... - providers: [ - provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.deploymentRuntimeAssistantId, - onThreadId: (id: string) => { - currentThreadIdState.set(id); - }, - }), - ], -}) -export class DeploymentRuntimeComponent { - protected readonly stream = injectAgent(); - protected readonly currentThreadId = currentThreadIdState; - - send(text: string): void { - void this.stream.submit({ message: text }); - } -} -``` - -Use `stream.status()` to render a live connection badge in the sidebar. - - - - -Add a `vercel.json` to your Angular project to configure SPA routing and the API proxy: - -```json -{ - "rewrites": [ - { "source": "/api/(.*)", "destination": "https://your-deployment.langgraph.app/$1" }, - { "source": "/(.*)", "destination": "/index.html" } - ] -} -``` - -Deploy with the Vercel CLI: - -```bash -npm install -g vercel -vercel --prod -``` - -The `/api` rewrite proxies LangGraph requests through Vercel, avoiding CORS issues and keeping your LangSmith API key server-side. - - -Never expose your LangSmith API key in client-side code. Use Vercel environment variables and the server-side proxy pattern shown above. - - - - - -Add a GitHub Actions workflow to deploy on every push to `main`: - -```yaml -# .github/workflows/deploy.yml -name: Deploy - -on: - push: - branches: [main] - -jobs: - deploy-graph: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: pip install langgraph-cli - - run: langgraph deploy - env: - LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} - - deploy-frontend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm ci - - run: npx nx build cockpit-langgraph-deployment-runtime-angular - - run: vercel --prod --token ${{ secrets.VERCEL_TOKEN }} -``` - - -Deploy the graph before the frontend so the new API is live before traffic reaches it. - - - - - - -The `stream.status()` signal reflects the live connection state: `idle`, `streaming`, or `error`. Bind it to a status badge in your sidebar to give users instant feedback on the deployment health. - - - -The `assistantId` in your Angular component must exactly match the graph key in `langgraph.json`. A mismatch results in a 404 from the LangGraph Cloud API. - From 5c1337b11a50d07a99776f249eadf38a78907036 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:42:31 -0700 Subject: [PATCH 19/21] =?UTF-8?q?docs(langgraph):=20streaming=20page=20?= =?UTF-8?q?=E2=80=94=20error=20fields=20and=20retry()=20per=20the=20agent?= =?UTF-8?q?=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../content/docs/langgraph/guides/streaming.mdx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/streaming.mdx b/apps/website/content/docs/langgraph/guides/streaming.mdx index 80f2e5307..835ce18cf 100644 --- a/apps/website/content/docs/langgraph/guides/streaming.mdx +++ b/apps/website/content/docs/langgraph/guides/streaming.mdx @@ -184,8 +184,8 @@ export class ChatComponent { readonly hasError = computed(() => this.chat.status() === 'error'); retry() { - // Re-stream using the same thread so context is preserved - this.chat.submit(null); + // Clears the error and re-submits the last input on the same thread + this.chat.retry(); } } ``` @@ -197,7 +197,9 @@ export class ChatComponent { @if (hasError()) {

{{ chat.error()?.message }}

- + @if (chat.error()?.retryable) { + + }
} ``` @@ -205,10 +207,10 @@ export class ChatComponent { -Calling `submit(null)` opens a fresh stream against the current thread state without adding a new user message. That resumes the run only when the thread still has pending work, such as an interrupted or failed run; after a run that finished there is nothing left to execute and the call is a no-op, so `chat.reload()` is the reliable retry. Pass `submit({ message })` only when you have new input to send. +`retry()` clears the error, then re-runs the last submission on the same thread; it is a no-op while a run is already in flight or when there is nothing to retry. `reload()` re-runs the last submission the same way but leaves the error signal alone, which is useful when you want to keep showing the failure state while the retry attempt is in progress. `submit(null)` opens a stream against the current thread state without a new user message and resumes only pending work, such as an interrupted or failed run; otherwise it is a no-op. Pass `submit({ message })` only when you have new input to send. -`error()` surfaces both transport-level failures (lost connection, 5xx) and application-level errors returned by the agent graph. Check `error().cause` for the underlying HTTP status when you need to distinguish them. +`error()` returns an `AgentError` whose `kind` distinguishes the failure class — `'connection'`, `'auth'`, `'server'`, `'interrupted'`, or `'aborted'` — whose `status` carries the HTTP status code when the failure came from an HTTP response, whose `retryable` says whether attempting the request again could plausibly succeed, and whose `cause` preserves the original raw error for debugging or telemetry. ## Throttle configuration From c92007fdf5e9fc5efa53d28823285f30fbe067b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:42:53 -0700 Subject: [PATCH 20/21] =?UTF-8?q?docs(langgraph):=20time=20travel=20?= =?UTF-8?q?=E2=80=94=20the=20example's=20docstring=20and=20prompt=20stop?= =?UTF-8?q?=20promising=20setBranch=20forks;=20page=20precision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../content/docs/langgraph/guides/time-travel.mdx | 6 +++--- .../time-travel/python/prompts/time-travel.md | 12 ++++++------ cockpit/langgraph/time-travel/python/src/graph.py | 7 +++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/time-travel.mdx b/apps/website/content/docs/langgraph/guides/time-travel.mdx index cf45dce3a..d96738121 100644 --- a/apps/website/content/docs/langgraph/guides/time-travel.mdx +++ b/apps/website/content/docs/langgraph/guides/time-travel.mdx @@ -22,7 +22,7 @@ Three files carry the feature: a graph that leaves checkpointing to the platform ### A graph the platform checkpoints -The backend is a single streaming node over `MessagesState`, with a system prompt read from the capability's prompt file. Nothing in it mentions time travel, because the checkpoints come from the server that runs it. +The backend is a single streaming node over `MessagesState`, with a system prompt read from the capability's prompt file. No executable line in it concerns time travel, because the checkpoints come from the server that runs it. @@ -48,11 +48,11 @@ The `` composition owns the transcript, the input, and the loading and err -`checkpoint_id` is optional on a `ThreadState`, which is why the identifier line and both handlers guard on it. +`checkpoint_id` is optional on the `Checkpoint` a `ThreadState` carries, which is why the identifier line and both handlers guard on it. ### Selecting a checkpoint -Replay and Fork run the same two statements: record the clicked row so the sidebar can highlight it, then hand the checkpoint identifier to `setBranch()`. +Replay and Fork run the same two statements: record the clicked row so the sidebar can highlight it, then hand the checkpoint identifier to `setBranch()`, so the Fork button in the running example does not yet start a forked run; [the section below](#running-from-a-past-checkpoint) shows the call that does. diff --git a/cockpit/langgraph/time-travel/python/prompts/time-travel.md b/cockpit/langgraph/time-travel/python/prompts/time-travel.md index c221af586..b7c4d287f 100644 --- a/cockpit/langgraph/time-travel/python/prompts/time-travel.md +++ b/cockpit/langgraph/time-travel/python/prompts/time-travel.md @@ -2,14 +2,14 @@ You are a helpful assistant that is aware of LangGraph's time travel capability. -Every response you give is saved as a checkpoint snapshot. The user can inspect -the conversation history and branch the conversation from any previous checkpoint -using `stream.setBranch(checkpointId)`. This creates an alternate timeline from -that point forward. +Every response you give is saved as a checkpoint snapshot. The conversation +history is visible in the timeline sidebar, and selecting a checkpoint there +highlights it. A new run is started from a past checkpoint by submitting with +that checkpoint, which creates an alternate timeline from that point forward. Mention this capability naturally when relevant — for example, when a user explores different approaches, you can note that they can return to an earlier checkpoint and try a different path. -You are demonstrating LangGraph's checkpoint-based time travel feature, -powered by MemorySaver checkpointing. +You are demonstrating LangGraph's checkpoint-based time travel feature, with +checkpointing provided by the LangGraph server. diff --git a/cockpit/langgraph/time-travel/python/src/graph.py b/cockpit/langgraph/time-travel/python/src/graph.py index 9eee3f5b8..a502a05bd 100644 --- a/cockpit/langgraph/time-travel/python/src/graph.py +++ b/cockpit/langgraph/time-travel/python/src/graph.py @@ -2,9 +2,8 @@ LangGraph Time Travel Graph Demonstrates checkpoint-based time travel. Each message exchange is saved as -a checkpoint snapshot. The client can read checkpoint history via the LangGraph -SDK's thread history API and branch the conversation from any past state by -calling `setBranch(checkpointId)` before the next submit. +a checkpoint snapshot. The client reads the thread's checkpoint history and can +start a new run from any past checkpoint by submitting with that checkpoint. The LangGraph API server provides checkpointing automatically. """ @@ -19,7 +18,7 @@ def build_time_travel_graph(): """ - Constructs a StateGraph with checkpointing enabled for time travel. + Constructs a StateGraph that the LangGraph API server checkpoints. The LangGraph API checkpointer saves a snapshot after each node execution, producing a history of ThreadState objects that the client can replay or From 213da216710022ea2df7add66a5374e030de6970 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 18:51:55 -0700 Subject: [PATCH 21/21] docs(langgraph): one checkpointer story across the pilot pages; resume and submit(null) wording aligned Co-Authored-By: Claude Fable 5.1 --- .../content/docs/langgraph/guides/durable-execution.mdx | 4 ++-- apps/website/content/docs/langgraph/guides/memory.mdx | 2 +- apps/website/content/docs/langgraph/guides/persistence.mdx | 2 +- apps/website/content/docs/langgraph/guides/streaming.mdx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/durable-execution.mdx b/apps/website/content/docs/langgraph/guides/durable-execution.mdx index 1c0286732..3b1a18ebc 100644 --- a/apps/website/content/docs/langgraph/guides/durable-execution.mdx +++ b/apps/website/content/docs/langgraph/guides/durable-execution.mdx @@ -50,7 +50,7 @@ The graph is a straight line. Three nodes, three edges, and an entry point. -`compile()` is called with no checkpointer. That is not a gap: the LangGraph API server provides persistence for every thread it serves and rejects a graph that brings its own. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you run inside your own process. +`compile()` is called with no checkpointer. That is not a gap: the LangGraph API server provides persistence for every thread it serves and either rejects or ignores a graph that brings its own, depending on whether it is `langgraph dev` or a deployment. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you run inside your own process. ### The agent provider @@ -138,6 +138,6 @@ Nothing in this example configures durability. The graph compiles without a chec Browse the checkpoints a run leaves behind and fork the thread from any of them. - Pause a run for human input and resume it with `submit(null, { resume })`. + Pause a run for human input and resume it with `submit({ resume })`. diff --git a/apps/website/content/docs/langgraph/guides/memory.mdx b/apps/website/content/docs/langgraph/guides/memory.mdx index 0ea269989..be0d28b73 100644 --- a/apps/website/content/docs/langgraph/guides/memory.mdx +++ b/apps/website/content/docs/langgraph/guides/memory.mdx @@ -56,7 +56,7 @@ The graph is a straight line: generate, then extract, then end. Putting extracti -`compile()` is called with no checkpointer, because the LangGraph API server provides persistence and rejects a graph that brings its own. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you embed in your own process. +`compile()` is called with no checkpointer, because the LangGraph API server provides persistence and either rejects or ignores a graph that brings its own, depending on whether it is `langgraph dev` or a deployment. The [Persistence guide](/docs/langgraph/guides/persistence) covers the checkpointer choices for a graph you embed in your own process. ### The agent provider diff --git a/apps/website/content/docs/langgraph/guides/persistence.mdx b/apps/website/content/docs/langgraph/guides/persistence.mdx index 769d45c63..7a02de1b4 100644 --- a/apps/website/content/docs/langgraph/guides/persistence.mdx +++ b/apps/website/content/docs/langgraph/guides/persistence.mdx @@ -29,7 +29,7 @@ The backend is a single node. `MessagesState` gives the LangGraph SDK a message Look at the last line of `build_persistence_graph`: it calls `compile()` on the `StateGraph` with no checkpointer at all. Persistence here comes entirely from the LangGraph API server, which is the case whenever you serve a graph with `langgraph dev` or on LangGraph Platform. -The platform provides persistence itself, and it rejects a graph that brings its own. Call `compile()` on the `StateGraph` with no argument, as the example does. +The platform provides persistence itself. `langgraph dev` refuses to load a graph that compiles its own saver, and a deployment ignores one, so leave it off in both cases. Call `compile()` on the `StateGraph` with no argument, as the example does. Passing one is not a soft warning — `langgraph dev` fails to load the graph and exits: diff --git a/apps/website/content/docs/langgraph/guides/streaming.mdx b/apps/website/content/docs/langgraph/guides/streaming.mdx index 835ce18cf..3d23ea9f6 100644 --- a/apps/website/content/docs/langgraph/guides/streaming.mdx +++ b/apps/website/content/docs/langgraph/guides/streaming.mdx @@ -207,7 +207,7 @@ export class ChatComponent { -`retry()` clears the error, then re-runs the last submission on the same thread; it is a no-op while a run is already in flight or when there is nothing to retry. `reload()` re-runs the last submission the same way but leaves the error signal alone, which is useful when you want to keep showing the failure state while the retry attempt is in progress. `submit(null)` opens a stream against the current thread state without a new user message and resumes only pending work, such as an interrupted or failed run; otherwise it is a no-op. Pass `submit({ message })` only when you have new input to send. +`retry()` clears the error, then re-runs the last submission on the same thread; it is a no-op while a run is already in flight or when there is nothing to retry. `reload()` re-runs the last submission the same way but leaves the error signal alone, which is useful when you want to keep showing the failure state while the retry attempt is in progress. `submit(null)` opens a stream against the current thread state without a new user message and resumes only pending work, such as an interrupted run or a failed run that left tasks pending; otherwise it is a no-op. Pass `submit({ message })` only when you have new input to send. `error()` returns an `AgentError` whose `kind` distinguishes the failure class — `'connection'`, `'auth'`, `'server'`, `'interrupted'`, or `'aborted'` — whose `status` carries the HTTP status code when the failure came from an HTTP response, whose `retryable` says whether attempting the request again could plausibly succeed, and whose `cause` preserves the original raw error for debugging or telemetry.