diff --git a/apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx b/apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx new file mode 100644 index 000000000..52747c5b2 --- /dev/null +++ b/apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx @@ -0,0 +1,223 @@ +--- +title: 'LangGraph Subgraphs: When to Split a Graph and When Not To' +description: 'On LangGraph, a subgraph buys you an observable boundary, not a state boundary. Why our own graphs got split, and what the frontend sees while a child runs.' +date: 2026-08-27 +tags: [langgraph, subgraphs, agents, streaming, angular] +author: brian +featured: false +draft: false +--- + +Most people reach for a LangGraph subgraph expecting a _state_ boundary, and what they actually get is an _observable_ one. + +If your question is "single agent, approval loop, or multi-agent?", that's an architecture question and the [decision matrix](/docs/langgraph/concepts/agent-architecture) already answers it. +This post is about the layer underneath: what a subgraph actually changes at runtime, why our own graphs got split, and what the frontend sees while a child is running. + +## What does a subgraph actually give you? + +Nested execution and namespaced stream events. That's the honest list. + +Let's start with the canonical pattern, which is small. +Compile a child `StateGraph`, then add the compiled graph as a node in the parent: + +```python +research_builder = StateGraph(MessagesState) +research_builder.add_node("search", search_web) +research_builder.add_edge(START, "search") +research_subgraph = research_builder.compile() + +builder = StateGraph(MessagesState) +builder.add_node("research", research_subgraph) # a compiled graph, used as a node +``` + +Two things change. +The child runs as its own graph, with its own nodes and its own step sequence rather than being flattened into the parent's. +And LangGraph emits the child's stream events under a namespace, so a consumer can tell parent output from child output. + +Here's the part I think gets assumed and shouldn't: state isolation isn't a third. + +If parent and child share `MessagesState`, the child appends to the same message list the parent is building. +Nothing about `add_node` fenced anything off. + +Isolation is something you design — give the child its own state schema, then map in at the boundary and map the result back out. +That's a decision you make and maintain, not a property `compile()` hands you. + +We ship one graph that does exactly that, and because it's a capability demo built to show the primitive, it's a clean look at the shape. +Its child state schema has no `messages` key at all. +Parent and child share exactly two keys, `research_topic` and `research_brief`, so the child is handed a topic and hands back a brief — it can't read the transcript, and it can't append to one. + +That boundary is real, and none of it came from `compile()`. +It came from writing two `TypedDict`s and being deliberate about what they share. + +### What about context windows and error boundaries? + +Those are real reasons to split — our own docs lean on them. +The [subgraphs guide](/docs/langgraph/guides/subgraphs) points at per-task context windows and failure containment as reasons to reach for subagents, and the docstring on our own research child's only node calls it "a focused contractor." + +But look at where each one actually comes from. +A narrow context window is a consequence of what you pass into the child's `ainvoke` — you get it by handing over a topic instead of a transcript. +An error boundary is a consequence of how the parent handles a failed child call, and a node-level retry wraps any node, plain function or compiled graph alike. +Reuse across parents is a consequence of the child being a value you can reference twice. + +You can have all three without ever compiling a child graph, and you can compile a child graph and get none of them. + +There is one more, and it's worth stating because it looks like a counterexample. +Wire the child in as a node under a parent that has a checkpointer, and the child's steps get checkpointed under its namespace — which is what lets you interrupt and resume at child granularity. +Notice that's the namespace again, doing a second job. + +## Why do people really split? + +In our own repo, the honest answer is: so the frontend can see the delegation. + +That's a claim about our own graphs, not a law of the framework — and one of them splits for a different reason entirely, which I'll get to. +But it's a natural experiment rather than a portfolio — nobody wrote these to prove a point about subgraphs, and the constraint that drove them, a frontend that renders per-child progress, isn't specific to us. + +Let's look at what we wrote down at the time. +Here's the comment sitting above the research subagent in our canonical `examples/chat` graph: + +```python +# Research subagent — a small compiled child graph the parent dispatches +# via the `research` @tool. Running it as an actual subgraph (vs. inline +# logic) is what causes LangGraph to emit stream events under namespace +# prefix `tools:` for the child run, which is what the @threadplane/langgraph +# SubagentTracker keys on to populate `agent.subagents()`. +``` + +That's not a state argument. It's a visibility argument. + +The design doc for that feature is blunter still. Here's the alternative it rejected: + +```text +Plain `@tool` returning a synthesized "subagent" payload — Simpler graph +code but does not exercise the SubagentTracker code path: no `tools:` +namespace events get emitted because no subgraph runs. The card would +render empty. Rejected. +``` + +Then there's the conversion. +Our `cockpit/chat/subagents` demo originally ran its three specialists as a flat in-process helper, and was rewritten to dispatch a real compiled child graph — because the flat version emitted no namespace events, so `subagents()` stayed empty and no card rendered. +A working feature was restructured so a UI card would appear. + +In both of those graphs the compiled child is invoked from inside a `@tool` body, not wired in as a plain node. +That's deliberate: the tool call is what the tracker registers, and our own docs are blunt that [plain subgraph nodes](/docs/langgraph/guides/subgraphs) don't show up in that map at all. + +Which cuts the other way from how it sounds — plain `add_node` subgraphs make the point sharper, not weaker. +Those still get a namespace, so they're still observable in the raw stream. +They just don't get a name, so nothing downstream can attribute them to anything. +The subgraph is what makes the events observable; the tool call is what gives them an identity. + +## What does the frontend see while a child runs? + +Namespaced events — and nearly everything interesting downstream follows from that one fact. + +### What the wire looks like + +Let's take it from the wire inward. +The event type carries the namespace after a pipe, so the base type is the part before it: + +```text +messages # parent +messages|tools:call-1 # child run dispatched by tool call "call-1" +``` + +Our transport requests those child streams by default — `streamSubgraphs` is `true` unless you turn it off. +That's the LangGraph JS SDK's own option name, passed straight through, and worth knowing if you're coming from the Python API, where the in-process `graph.stream()` equivalent is the `subgraphs=True` kwarg. + +### The terminal-event hazard + +A child graph terminates before the parent does, and a child's terminal event looks an awful lot like the parent's. + +Without a namespace guard, that child terminal marker gets read as "the run finished" and closes out the parent's still-streaming assistant message. +We guard it by refusing namespaced events as top-level terminal evidence, and there's a test that feeds a namespaced terminal marker in and asserts the parent message settles with outcome `interrupted` rather than success. + +If you ever write a transport against this stream yourself, that's the bug you'll hit, and it will look like truncation rather than a namespace bug. + +### Where child text goes + +Into your main transcript, by default. +Our `filterSubagentMessages` is off unless you set it, so a child's tokens flow into `messages()` alongside the parent's. + +That isn't a quirk of our config. +Any consumer reading a namespaced stream has to decide what a child's tokens mean, and "append them like everything else" is the path of least resistance — so unless something opts out, child text lands in the parent transcript and the same content renders twice. + +### How does a child get attributed? + +By id — and this is the part I find well-designed: the namespace segment _is_ the identifier. + +`tools:` carries the parent tool call id, so the tracker slices the prefix off and looks the id up directly against what it recorded when the tool call came through. +Marking a child running and routing its messages need no matching at all. + +There is also a description-comparison ladder — exact match on the tool call's `description` argument, then substring either direction, then a last-resort fallback to any unmapped subagent still pending or running. +It only runs for children whose state opens with a human message, and none of the graphs we ship reach it. +The ones dispatched through a tool call invoke the child with an empty message list, so the first message in child state is the AI response. +The one wired in as a plain node doesn't keep a `messages` key in child state at all. +Treat that path as untested rather than as the mechanism. + +The general point survives, though, and it's the one worth carrying to any protocol. +A consumer mapping child runs onto delegations is doing string matching unless the protocol gives it an id. +LangGraph gives it an id — which is why the ladder is vestigial here and would be load-bearing in a fan-out graph with look-alike children. + +One limit, though: only the _first_ `tools:` segment of a namespace is read. +A subagent that itself delegates will have its inner events attributed to the outer tool call. +Nothing in this repo exercises deeper nesting, so don't build on it. + +## When should you not split? + +When there's no observable boundary to draw and no genuinely divergent state. + +The cleanest evidence I have is a control group we didn't set out to build. +Our `cockpit/ag-ui/subagents` capability ships the same three-subagent feature as the LangGraph one — and it's a LangGraph `StateGraph` too, same framework, same orchestrator-plus-`task`-tool shape, same three roles, same cards in the UI — with no subgraph anywhere. +Its module docstring says so outright: + +```text +Mirrors cockpit/chat/subagents' orchestrator + `task` tool + `_run_subagent` +structure, but each dispatch emits `subagent_activity` CUSTOM events +``` + +The thing that differs is the transport: AG-UI's already carries a first-class delegation event. +So so the specialists stayed a flat `async` helper and progress reaches the frontend as a custom event dispatched from the tool body. + +The subgraph was never required by the feature. It was required by the transport. + +You could dispatch custom events from the LangGraph graph too — nothing stops you, and `adispatch_custom_event` is a LangChain primitive, not an AG-UI one. +What namespaces buy is that you don't have to. +The boundary emits its own identity for free, and a transport that reads it works against any graph rather than any graph that remembered to instrument itself. + +Staying flat wasn't free. +There's no separate state schema to isolate anything into, and no child step sequence — every specialist gets the parent's shape, one LLM call wide. +What it bought was one fewer graph for a feature that renders identically. + +That's the test I'd apply. +If your transport already has a way to say "a child is working right now," or your UI doesn't render per-child progress at all, then a subgraph is a boundary you now have to defend: an extra state schema, mapping at both edges, and one more place to look when a message goes missing. + +And splitting because a region of the graph _feels_ like a separate concern isn't a reason on its own. +A node is already a unit. + +### So when does a split earn itself? + +When the child really is a different graph — and the repo has exactly one of those, which is the case I owe you after arguing the other side this whole time. + +Our `examples/ag-ui` demo runs on that same AG-UI transport, and it emits the same `subagent_activity` events from the tool body. +So it isn't buying observability; it already had it. +It compiles a child graph anyway. + +Look at what the child is, though. +It has its own `agent → tools → agent` loop with conditional edges and an iteration cap — a different control flow from the parent's, not a slice of it. + +And here's the part that took me a second read to see. +A custom child state schema doesn't discriminate at all: the two graphs I just used as observability evidence _also_ define their own child `TypedDict`s. +But both of those children are one node and a straight line, so the schema is really just an argument list with a type on it. + +So it's the control flow, not the schema. +A child that carries a `topic` string is a function call wearing a graph costume. +A child that loops until it's satisfied is a graph. + +## Conclusion + +Split when something outside the graph needs to see the child run as its own thing — a card, a progress panel, per-child streaming. +Split when the child has its own control flow — a loop, a branch, a stopping condition the parent doesn't have — and you're willing to own the mapping at both edges. +Don't split for tidiness, and don't assume the split isolated state: wire a child in as a node on a shared `MessagesState` and it appends straight to the transcript the parent is building. + +The [architecture matrix](/docs/langgraph/concepts/agent-architecture) covers the tiering question, the [subgraphs guide](/docs/langgraph/guides/subgraphs) has the composition and `subagents()` wiring, and [What injectAgent() Actually Returns](/blog/what-inject-agent-returns) walks the signal surface those child streams land in. + +If you've split a graph for a third reason — not observability, and not a child that's genuinely its own graph — I'd like to hear it. Those are the two I've been able to justify; I doubt they're the only two that exist. diff --git a/docs/superpowers/HANDOFF-blog-sequence.md b/docs/superpowers/HANDOFF-blog-sequence.md new file mode 100644 index 000000000..021a3c43a --- /dev/null +++ b/docs/superpowers/HANDOFF-blog-sequence.md @@ -0,0 +1,93 @@ +# Handoff: GSC-driven blog sequence + +**Written:** 2026-08-27 +**Worktree:** `/Users/blove/repos/angular-agent-framework/.claude/worktrees/threadplane-a2ui-migration-94f7b6` +**Current branch:** `blove/langgraph-subgraphs-post` (3 commits ahead of `origin/main`, **not pushed**) + +--- + +## What this is + +Brian ranked 20 blog candidates against a Search Console pull and approved a four-post sequence running three strategic plays in order: convert existing traffic, grow search surface, then earn distribution. Two posts are live; the third is drafted and unpushed; the fourth is unstarted. + +The candidates doc lives at `docs/gtm/blog-topic-candidates.md` **on a different worktree branch** (`suspicious-ellis-644600`) and is NOT on main. Read it there if you need the full evidence table. + +## Sequence status + +| # | Post | Status | +|---|---|---| +| #11 | What `injectAgent()` Actually Returns | **Live** — [threadplane.ai/blog/what-inject-agent-returns](https://threadplane.ai/blog/what-inject-agent-returns), PR #836 | +| #9 | json-render vs A2UI: Choosing a Generative UI Contract | **Live** — [threadplane.ai/blog/json-render-vs-a2ui-choosing](https://threadplane.ai/blog/json-render-vs-a2ui-choosing), PR #837 | +| #1 | LangGraph Subgraphs: When to Split a Graph and When Not To | **Shipped** — see the section below for what the reviews changed | +| #12 | Testing Agents Deterministically: Fixture-Replay for LLM UIs | **Not started** | + +## What the reviews changed in post #1 + +Both reviews returned blocking findings and all were real. Recorded here because two of them are traps a future post could repeat. + +**Attribution is structural, not heuristic.** The `tools:` namespace segment *is* the parent tool call id (`extractToolCallIdFromNamespace` is `segment.slice(6)`; `resolveToolCallId` falls back to the namespace id itself). The description-comparison ladder exists but is unreachable in every shipped graph. Do not describe it as the mechanism. + +**`streamSubgraphs` is the LangGraph JS SDK's own option name** (`@langchain/langgraph-sdk` `types.d.ts:148,187,240`), not a Threadplane rename. The `subgraphs=True` kwarg is the Python in-process `graph.stream()` API. + +**The discriminator for an earned split is control flow, not state schema.** `examples/chat` and `cockpit/chat/subagents` both define custom child `TypedDict`s yet are single-node straight lines, so "the child has its own state schema" fails to separate an earned split from an observability split. Only `examples/ag-ui` — child with its own `agent → tools → agent` loop and iteration cap — earns it on the merits. + +**`examples/ag-ui` is a counterexample the first draft missed.** It compiles a child on a transport that already emits `subagent_activity`, so that split is not buying observability. The post now owns this rather than claiming no such case exists. + +**Checkpointing is a trap in both directions.** Both subgraph children compile bare (`.compile()`, no checkpointer) while the *flat* `cockpit/ag-ui/subagents` graph is the one using `MemorySaver`. So "compile() gives the child its own checkpoint lineage" is false, and so is listing "no independent checkpointing" as a cost of staying flat. An editorial reviewer proposed the first of those as a fix — verify reviewer suggestions in source before applying them. + +## Two live defects — FIXED + +Both were real, and both were fixed by #838 (`cockpit/langgraph/subgraphs` now routes conditionally and gives the child a state schema with no `messages` key). Kept here for the record: + +1. **`cockpit/langgraph/subgraphs/python/docs/guide.md:112` states a falsehood** — claims subgraph events surface through `stream.subagents()`. For that example they cannot: it adds a compiled subgraph as a plain node (`python/src/graph.py:55`), emitting namespace `research:`, but the tracker only routes `tools:`-prefixed namespaces (`libs/langgraph/src/lib/internals/subagent-tracker.ts:265-269`) and additionally requires `subagentToolNames` + `args.subagent_type` (:78-112). The Angular app sets no `subagentToolNames` and never has. Our published docs already say the opposite at `apps/website/content/docs/langgraph/guides/subgraphs.mdx:114`. +2. **The example doesn't demonstrate what it advertises** — the docstring at `python/src/graph.py:22` claims the orchestrator "decides when to delegate," but the edge at :57-58 is unconditional. The subagent sidebar (`angular/src/app/subgraphs.component.ts:112-119`) can never populate, and `e2e/manual/subgraphs.manual.ts:12` asserts `text=No active subagents` as its only assertion — a test passing vacuously. + +Post #1 was held until #838 landed, then re-checked against it — the fixed example is now cited in the post as first-hand evidence that state isolation is designed, not handed to you by `compile()`. + +## Post #12 — research was in flight, results lost + +A research pass was running when the session ended; its findings are gone. Re-run it before designing. What it was asked to establish (all read-only): + +- How the aimock harness works end to end: what it intercepts, record vs replay, fixture format, how a test selects a fixture set. +- **Fixture matching semantics** — the matching keys and, critically, the ordering constraint: a `hasToolResult: true` entry must precede the plain `userMessage` entry, or the continuation re-matches the tool call and loops forever. +- **What replay cannot catch** — replay is roughly atomic (one content snapshot), so streaming re-materialization warnings (NG0956, `@for` recreation) can't fire and console-guard e2e assertions false-pass. There's a live-LLM smoke-gate convention as the counterweight. +- Concrete war stories from specs and git history; scale facts (fixture count, which suites, runtime) to make "we run our entire e2e suite this way" checkable. +- What we should NOT publish. + +This is the only post with **no search evidence** — it's a bet on being genuinely differentiated and shareable. If the research comes back thin, say so rather than padding it. + +## The pipeline (used for all three posts; it works) + +1. **Design pass** — research the repo for first-hand material, present 2–3 angles with a recommendation, get Brian's approval. He engages with this and picks. +2. **Spec** → `docs/superpowers/specs/YYYY-MM-DD--design.md`, commit. +3. **Plan** → `docs/superpowers/plans/YYYY-MM-DD-.md`, commit. Include a "Verified facts" block with paths/lines you personally read. +4. **Implementer subagent** — give it the full task text, the verified facts, hard prohibitions, and the voice gate. Never make it read the plan file. +5. **Spec-compliance review subagent** — explicitly told not to trust the implementer's report; verifies claims against source. +6. **Editorial review subagent** — prose, argument, voice fidelity, MDX mechanics; told a separate agent handles spec compliance. +7. **Apply findings → re-review → validate → PR → auto-merge → verify production.** + +Run the two reviews in parallel; they don't conflict. + +## Hard-won conventions + +**Voice.** `docs/gtm/voice.md` with a 2026 technical override: no invented first-person anecdotes (Brian: "don't make up stories"), no emoji, trimmed rhetoric. Keep contractions, "Let's" transitions, 1–3-line paragraphs, H2-as-question answered in its first line, opinions flagged ("For me," "I think"), no hype, no marketing CTAs. Register references are the two shipped posts. + +**No licensing callout.** Brian removed it from post #11 as unnecessary. Don't reintroduce it. + +**Don't co-rank against our own docs.** Every post so far had a docs page targeting the same query. The post must answer the *decision* the searcher faces and link the docs page for mechanics. Check for verbatim runs against the docs page before shipping — reviewers caught three in post #9. + +**Verify against published tarballs, not just source.** Main routinely runs ahead of npm (releases fire only on a pushed tag). `npm pack @threadplane/@latest` into the scratchpad and grep the `.d.ts` for every public member the post names. Drop main-only members. + +**`nx test website` does not exist** — it fails silently-ish. Use `cd apps/website && npx vitest run --config vite.config.mts`. + +**5 pre-existing test failures**, unrelated to any of this work and red long enough to have drifted: `PostCard.spec.tsx` (expects a raw date the component now formats), `Differentiator.spec.tsx` (assertion drift), `thanks/page.spec.tsx` (3). Confirm the count is unchanged; don't fix them inside a content PR. Worth a separate cleanup — Brian hasn't decided. + +**Merge is not a deploy.** Only `Vercel – threadplane` gates merge. Vercel preview URLs are SSO-protected, so verify locally on `npx next dev -p 3111` and then poll the production URL until it returns 200 before claiming shipped. On post #9 the PR merged while two accuracy fixes were still in flight — "merged" wouldn't have proven the right content shipped, so re-check `origin/main` content and the live page. + +**Dev server side effect:** `next dev` modifies `apps/website/next-env.d.ts`. `git checkout` it before committing. + +## Gotchas that bit us + +- A Fable 5 subagent hit a usage limit mid-task and died **leaving its edits uncommitted in the working tree**. Don't assume a dead agent did nothing — check `git status` and verify each expected change before committing on its behalf. +- An editorial reviewer suggested wording that was itself a factual overreach ("a submit action *always* goes back"); its own re-review caught it. Validation checks can block a submit — `libs/chat/src/lib/a2ui/surface.component.ts:203`. Verify reviewer suggestions in source before applying them. +- Post #9's snippet needed real schema validation: `Card` has no `title` prop, and the A2UI envelopes were checked against `libs/a2ui/schemas/server_to_client.json` and round-tripped through the published parser. Plausible-looking JSON is not good enough. diff --git a/docs/superpowers/plans/2026-08-27-langgraph-subgraphs-post.md b/docs/superpowers/plans/2026-08-27-langgraph-subgraphs-post.md new file mode 100644 index 000000000..9b0eddb70 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-langgraph-subgraphs-post.md @@ -0,0 +1,154 @@ +# LangGraph Subgraphs Blog Post Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish a first-hand essay for the `langgraph subgraphs` query (41 impressions, position 32.3, nothing of ours competing) arguing that the boundary a subgraph really buys you is observability, not state. + +**Architecture:** One new MDX file in `apps/website/content/blog/`. No code changes. At most two short code blocks (Python is fine — audience is Python-first). + +**Tech Stack:** MDX blog content, Next.js website (`apps/website`), vitest for content validation. + +**Spec:** `docs/superpowers/specs/2026-08-27-langgraph-subgraphs-post-design.md` +**Branch:** `blove/langgraph-subgraphs-post` (off main at `d2e5ce73`) + +--- + +## Verified facts (I re-read each of these in source; drafter must still open them) + +**The observability motive, in our own code** — `examples/chat/python/src/graph.py:276-281`, verbatim: + +``` +# Research subagent — a small compiled child graph the parent dispatches +# via the `research` @tool. Running it as an actual subgraph (vs. inline +# logic) is what causes LangGraph to emit stream events under namespace +# prefix `tools:` for the child run, which is what the @threadplane/langgraph +# SubagentTracker keys on to populate `agent.subagents()`. +``` + +**Namespace gating** (`libs/langgraph/src/lib/internals/subagent-tracker.ts`): +- `isSubagentNamespace()` :265-269 — a segment must start with `tools:`. +- `extractToolCallIdFromNamespace()` :271-277 — returns the FIRST `tools:` segment only, so nested delegation attributes inner events to the outer call. +- `matchSubgraphToSubagent()` :130-175 — exact description match, then substring either direction, then the fallback at :165-169: any unmapped subagent whose status is `pending` or `running`. Heuristic, not structural. + +**Streaming defaults:** +- `streamSubgraphs: streamSubgraphs ?? true` — `libs/langgraph/src/lib/transport/fetch-stream.transport.ts:154`. Subgraph streaming is opt-OUT. The SDK option is `streamSubgraphs`, not `subgraphs: true`. +- `filterSubagentMessages?: boolean` — `libs/langgraph/src/lib/agent.types.ts:283`, doc comment "When true, subagent messages are filtered from the main messages signal." Optional, so default OFF: subagent chatter lands in the parent transcript unless you opt in. +- Events are `|`-delimited (`messages|tools:call-1`), parsed in `fetch-stream.transport.ts:191-198` and `stream-manager.bridge.ts:1311-1318`. +- Namespaced terminal events would close out the parent's streaming message without a guard — `stream-manager.bridge.ts:314-330`, with `stream-manager.bridge.spec.ts:474-505` asserting outcome `interrupted`. + +**The cross-transport A/B:** +- LangGraph side: `cockpit/chat/subagents` uses a real subgraph; converted from flat to subgraph in PR #718 (spec `docs/superpowers/specs/2026-06-19-cockpit-subagents-subgraph-design.md`) so a UI card would render. +- AG-UI side: `cockpit/ag-ui/subagents/python/src/graph.py` — flat `async def _run_subagent(...)` at :107 and `adispatch_custom_event("subagent_activity", {...})` at :166-168. No subgraph. Same feature. +- Rejected alternative on record: `docs/superpowers/specs/2026-05-08-*-subagents-design.md:23` — a plain `@tool` returning a synthesized payload was rejected because no `tools:` namespace events are emitted when no subgraph runs, so the card would render empty. + +**Docs to link, not restate:** `apps/website/content/docs/langgraph/concepts/agent-architecture.mdx:626-696` (three-tier breakdown + decision matrix) and `apps/website/content/docs/langgraph/guides/subgraphs.mdx` (note :114 — "Plain subgraph nodes do not appear in this map"). + +**Hard prohibitions** (from the spec): +- Do NOT claim `cockpit/langgraph/subgraphs` demonstrates delegation or populates the sidebar — it does neither (unconditional edge `orchestrate → research → END` at `python/src/graph.py:57-58`; no `subagentToolNames` in `angular/src/app/app.config.ts`). Two separate tasks are correcting that example; the post must not depend on or contradict their outcome. Cite `cockpit/chat/subagents` and `examples/chat` for working delegation instead. +- Do NOT speculate about split performance/latency, parallel subagent fan-out, or child-specific checkpointers/state schemas. Nothing in the repo measures or exercises those. + +--- + +### Task 1: Author the post + +**Files:** +- Create: `apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx` + +- [ ] **Step 1: Frontmatter** + +```yaml +--- +title: 'LangGraph Subgraphs: When to Split a Graph and When Not To' +description: 'When a LangGraph subgraph earns its complexity, how state crosses the boundary, and what your UI sees while a child graph runs.' +date: 2026-08-27 +tags: [langgraph, subgraphs, agents, streaming, angular] +author: brian +featured: false +draft: false +--- +``` + +Description is 127 chars (≤155). The drafter may sharpen the title toward the observability thesis but MUST keep "Subgraphs" early for the query, and must re-count the description if changed. No licensing callout. + +- [ ] **Step 2: Body** + +1. **Lede** (no header): restate the searcher's question, then land the thesis early — the boundary a subgraph really draws is usually observability, not state. +2. `## What does a subgraph actually give you?` — first line answers. The canonical pattern (compile a child, add it as a node). What genuinely changes: nested execution and namespaced stream events. What does NOT change automatically: state isolation — if parent and child share a `MessagesState`, the child appends to the parent's list. Link the architecture matrix for the tiering question rather than restating it. At most one short Python block here. +3. `## Why do people really split?` — the thesis, evidenced: quote or closely paraphrase the `examples/chat/python/src/graph.py:276-281` comment (attribute it as our own code), the rejected `@tool` alternative, and the flat→subgraph conversion done so a card would render. +4. `## What does the frontend see while a child runs?` — the differentiated section. Namespaced `|`-delimited events; `streamSubgraphs` defaults on; without namespace guards a child's terminal event closes the parent's message; `filterSubagentMessages` defaults off so child text lands in the parent transcript; attribution is heuristic (`matchSubgraphToSubagent` falls back to any unmapped pending/running subagent) and nested `tools:` collapses to the outer call — say plainly that deeper nesting is untested here. +5. `## When should you not split?` — the honest counterweight. If you don't need the observable boundary and don't have genuinely divergent state, a subgraph adds a boundary you then have to defend. Cite AG-UI doing the same feature flat because its transport has a first-class delegation event. +6. `## Conclusion` — the heuristic in a short paragraph; forward links to `/docs/langgraph/concepts/agent-architecture`, `/docs/langgraph/guides/subgraphs`, and `/blog/what-inject-agent-returns`. No CTA. + +- [ ] **Step 3: Voice pass** + +`docs/gtm/voice.md` with the 2026 technical override. Register references: `apps/website/content/blog/2026-08-26-what-inject-agent-returns.mdx` and `apps/website/content/blog/2026-08-26-json-render-vs-a2ui-choosing.mdx` — this post must read as the same author. Checklist: title-restating lede, no "Introduction" header, contractions, 1–3-line paragraphs, H2-as-question answered in its first line, ≥1 "Let's" per major section, opinions flagged ("I think"/"For me"), no invented anecdotes, no emoji, no hype, no CTAs. Don't copy sentences from our docs pages verbatim. + +- [ ] **Step 4: Accuracy pass** + +- Open every file cited in Verified Facts and confirm the claim before it ships. Cite behavior, not line numbers, in the prose — line numbers rot. +- Confirm the hard prohibitions above are respected. +- Published-release check: `npm pack @threadplane/langgraph@latest @threadplane/chat@latest` in the scratchpad; grep the `.d.ts` for any public member named (`subagents`, `filterSubagentMessages`, `subagentToolNames`, `streamSubgraphs`). Drop main-only members. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx +git commit -m "feat(website): add 'LangGraph Subgraphs' blog post" +``` + +--- + +### Task 2: Validate + +- [ ] **Step 1: Frontmatter + description length** + +```bash +cd apps/website && node -e " +const matter = require('/Users/blove/repos/angular-agent-framework/node_modules/gray-matter'); +const fs = require('fs'); +const f = matter(fs.readFileSync('content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx','utf8')); +console.log('desc length:', f.data.description.length); +if (f.data.description.length > 155) throw new Error('description too long'); +if (!f.data.title || !f.data.date || f.data.author !== 'brian') throw new Error('frontmatter incomplete'); +console.log('OK'); +" +``` + +- [ ] **Step 2: Test suite** (`nx test website` does not exist): + +```bash +cd apps/website && npx vitest run --config vite.config.mts +``` + +`blog.spec.ts` and `sitemap-dates.spec.ts` must pass. Known pre-existing failures, do NOT fix, confirm the count is unchanged at 5: `PostCard.spec.tsx` (1), `Differentiator.spec.tsx` (1), `thanks/page.spec.tsx` (3). + +- [ ] **Step 3: Render check** — `npx next dev -p 3111` from `apps/website` in the background, then curl: + - `/blog/langgraph-subgraphs-when-to-split` → 200, correct ``, meta description matches frontmatter, code blocks carry `data-language` with a theme + - `/blog` lists the post + + Kill the server, confirm port 3111 free, and `git checkout apps/website/next-env.d.ts` if the dev server modified it. + +- [ ] **Step 4: Commit fixes** (skip if none): + +```bash +git add -A apps/website/content/blog/ && git commit -m "fix(website): render fixes for subgraphs post" +``` + +--- + +### Task 3: PR and merge + +- [ ] **Step 1: Push and open PR** + +```bash +git push -u origin HEAD +gh pr create --title "feat(website): add 'LangGraph Subgraphs' blog post" --body "Third post of the GSC-driven blog sequence (spec: docs/superpowers/specs/2026-08-27-langgraph-subgraphs-post-design.md). + +Targets \`langgraph subgraphs\` — 41 impressions at position 32.3, the second-highest-impression query on the site with nothing of ours competing. Argues from first-hand repo evidence that a subgraph's real payoff on LangGraph is an observable boundary rather than a state boundary. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +- [ ] **Step 2: Merge on green, then verify production** + +Arm auto-merge (`gh pr merge <n> --squash --auto`); only `Vercel – threadplane` gates. After merge, confirm `origin/main` carries the final content, then poll `https://threadplane.ai/blog/langgraph-subgraphs-when-to-split` until it returns 200 and spot-check the live title and meta description. Do not report "shipped" before the production URL answers 200 — a merge is not a deploy. diff --git a/docs/superpowers/specs/2026-08-27-langgraph-subgraphs-post-design.md b/docs/superpowers/specs/2026-08-27-langgraph-subgraphs-post-design.md new file mode 100644 index 000000000..109d193c9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-langgraph-subgraphs-post-design.md @@ -0,0 +1,59 @@ +# Post #1 design: LangGraph subgraphs — the observability boundary + +**Date:** 2026-08-27 +**Status:** Approved (angle + scope approved in session; third post of the sequence in `docs/superpowers/specs/2026-08-26-blog-sequence-inject-agent-design.md`) + +## Intent and evidence + +`langgraph subgraphs` — 41 impressions, position 32.3, 0 clicks. Second-highest impression query on the site and we're effectively invisible for it. Nothing of ours competes. + +This is the "grow the search surface" play: the searcher is a LangGraph-core engineer, often Python-first, not necessarily looking for an Angular UI framework. Per the candidates doc, serving them well may mean content only incidentally about Threadplane. That's the deliberate choice here. + +**Slug:** `langgraph-subgraphs-when-to-split` +**File:** `apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx` +**Title:** working title "LangGraph Subgraphs: When to Split a Graph and When Not To" — the drafter may sharpen it toward the observability thesis, but must keep "subgraphs" early in the title for the query. +**Meta description (≤155 chars):** "When a LangGraph subgraph earns its complexity, how state crosses the boundary, and what your UI sees while a child graph runs." +**No licensing callout.** + +## The angle: the observability boundary + +**Rejected:** a generic "when to split" decision essay. `apps/website/content/docs/langgraph/concepts/agent-architecture.mdx:626-696` already carries a three-tier breakdown and a decision matrix; a generic post would co-rank against our own docs and lose the first-hand advantage. Link that section for the architecture question instead of restating it. + +**The thesis:** most people reach for a subgraph expecting a *state* boundary, but in practice — at least on LangGraph — the thing a subgraph actually buys you is an *observable* boundary. That is the claim only we can make, and the repo says it out loud. + +Supporting first-hand material (all verified; cite by path): + +1. **Our own code says the motive is observability.** `examples/chat/python/src/graph.py:277-281` states that running the work as a real subgraph "is what causes LangGraph to emit stream events under namespace prefix `tools:<id>`… which the SubagentTracker keys on." The subgraph exists for the UI. +2. **A controlled A/B across transports.** The same three-subagent feature exists twice: with a subgraph on LangGraph (`cockpit/chat/subagents`) and with no subgraph at all on AG-UI (`cockpit/ag-ui/subagents/python/src/graph.py` — flat `_run_subagent()` at :107-135 plus `adispatch_custom_event("subagent_activity", …)` at :167). Same feature, opposite structural answer, because AG-UI's transport has a first-class delegation event and LangGraph's doesn't. +3. **A rejected alternative on record.** `docs/superpowers/specs/2026-05-08-*-subagents-design.md:23` rejected a plain `@tool` returning a synthesized payload because "no `tools:` namespace events get emitted because no subgraph runs. The card would render empty." +4. **A reverse migration.** `cockpit/chat/subagents` was converted from flat to subgraph (spec `2026-06-19-cockpit-subagents-subgraph-design.md`, PR #718) purely so a UI card would render. +5. **Splitting has real streaming costs the docs don't cover.** Namespaced terminal events would close out the parent's streaming message without a guard (`libs/chat`… see `stream-manager.bridge.ts:314-330`, test at `:474-505` asserting outcome `interrupted`); subagent text lands in the parent transcript unless you opt into `filterSubagentMessages` (default off, `agent.types.ts:282-283`). +6. **Attribution across the boundary is heuristic, not structural.** `matchSubgraphToSubagent()` (`subagent-tracker.ts:130-175`) falls back to "any unmapped pending/running subagent" (:165-169), and `extractToolCallIdFromNamespace` takes only the first `tools:` segment (:271-277) — so a subagent that itself delegates attributes inner events to the outer call. Nested delegation is untested here; say so plainly. + +## Structure + +1. **Lede** (no header): restate the question the searcher has, then land the thesis early — the useful boundary a subgraph draws is usually observability, not state. +2. `## What does a subgraph actually give you?` — the canonical pattern (compiled child as a node), what genuinely changes (nested execution, namespaced events) and what doesn't (state isolation isn't automatic; a shared `MessagesState` means the child appends to the parent's list). Point to the docs' architecture matrix for the tiering question rather than restating it. +3. `## Why do people really split?` — the observability thesis with the evidence above: our code comment, the rejected `@tool` alternative, the reverse migration. +4. `## What does the frontend see while a child runs?` — the differentiated section. Namespaced events (`messages|tools:call-1`), `streamSubgraphs` defaulting on, what breaks without namespace guards, `filterSubagentMessages` defaulting off, heuristic attribution and its fallback. +5. `## When should you not split?` — the honest counterweight: if you don't need the observable boundary and don't need genuinely divergent state, nesting adds a boundary you have to defend. Cite AG-UI doing the same feature flat. +6. `## Conclusion` — the heuristic, plus forward links to `/docs/langgraph/concepts/agent-architecture`, `/docs/langgraph/guides/subgraphs`, and `/blog/what-inject-agent-returns` (the `subagents()` signal is part of that return surface). + +## Voice and register + +Same as posts #11 and #9: `docs/gtm/voice.md` with the 2026 technical override — H2-as-question answered in the first line, contractions, 1–3-line paragraphs, "Let's" transitions, opinions flagged, no anecdotes, no emoji, no hype, no CTAs. Register reference: the two shipped posts. + +Python code is fine here (the audience is Python-first); keep code to at most two short blocks. + +## Accuracy requirements (drafting gate) + +- Every claim cited to a real path/line, verified by reading the file — not from this spec's summary. +- **Do not claim the `cockpit/langgraph/subgraphs` example demonstrates delegation or populates the subagent sidebar.** It does neither (unconditional edge; no `subagentToolNames`; sidebar permanently empty). Two separate tasks are correcting the example and its guide; this post must not depend on or contradict their outcome — prefer citing `cockpit/chat/subagents` and `examples/chat` for working delegation. +- **Do not speculate about:** performance/latency of splitting (nothing measures it); parallel subagent fan-out (explicitly out of scope, e2e dispatches sequentially); child-specific checkpointers or state schemas (nothing exercises a separate checkpointer). +- Verify named public members against published 0.0.58 tarballs; drop main-only members. +- Frontmatter per existing posts; tags along the lines of [langgraph, subgraphs, agents, streaming, angular]. + +## Out of scope + +- Fixing the subgraphs example or its guide (routed as separate tasks). +- Post #12 (own pass later).