One Graph, One Thread: Copilot Agents vs. LangGraph

Comparing this repo's VS Code Copilot agent pipeline against its LangGraph port — what it means to make the topology the specification, and what a checkpointer retires.

Stay Learning now exists three times. This repo builds the course-authoring pipeline entirely from VS Code Copilot customization files — agents, prompts, skills and instructions under .github/, driven by a person in Copilot Chat. The Mastra port rebuilt it as TypeScript workflows and a command line, described in Copilot Agents vs. Mastra. The third, stay-learning-langgraph, rebuilds it again in Python on LangGraph, and its README puts the three side by side by what carries the sequencing:

VersionRuntimeWhat holds the order
this repoVS Code Copilot .github/an agent’s instructions
stay-learning-mastraMastra workflows + a CLIthree workflow graphs and two ledgers
stay-learning-langgraphLangGraph + a CLIone graph

The output contract is unchanged across all three: courses/<slug>/ as YAML and Markdown that git can diff and the Astro viewer can render, unmodified, from any of them. The instructional-design contract is unchanged too — backward design, Bloom-levelled outcomes, cognitive load budgets, described in What Makes a Good Course. This post is about the runtime underneath both, and specifically about the two things the LangGraph version has that neither predecessor did: a checkpointer, and a graph you can make assertions about before you spend a penny on a model.

The same nine stages, one graph

Nine stages, five exclusive and four in the wave: audience, curriculum, outcomes, assessment and lesson planning run one at a time; capstone, lesson content, exercises and quizzes run together. Both versions run exactly those. What changed is what holds the order.

flowchart TD
    O1[Orchestrator agent] --> S1[Five exclusive stages]
    S1 --> G1{Approve in chat?}
    G1 -->|yes| W1[Wave: 3N+1 subagent delegations]
    W1 --> M1[Orchestrator merges glossary, lands manifest]
    M1 --> V1[validate.py, PostToolUse hook]

This repo: the orchestrator reads course.yaml, computes the next stage from artefact statuses, and delegates. The order is a paragraph in the orchestrator’s instructions.

flowchart TD
    I2[intake] --> D2[moderate → scaffold]
    D2 --> S2[audience → curriculum → outcomes → assessment]
    S2 --> G2{{design gate: summary, ask, record}}
    G2 --> P2[plan_module self-loop]
    P2 --> G3{{plan gate}}
    G3 --> W2[Send: write_lesson × N + write_capstone]
    W2 --> B2[gather, defer=True]
    B2 --> L2[merge_glossary → land_manifest]
    L2 --> V2[validate]
    V2 --> R2[repair_round self-loop]

The LangGraph port: 28 nodes in one StateGraph. Four CLI commands enter the same spine at different points; the order is declared edges, not prose.

Orchestration: an agent that computes the next stage, or a topology that cannot skip one

This repo’s course-orchestrator.agent.md is the only user-invocable agent, and it is explicit that it does none of the work:

You coordinate course creation. You decide what runs next and delegate it. You do not do the work yourself.

It decides by reading state rather than by remembering position:

Compute the next required stage from the artefact statuses, not from what the user asked for. The manifest is the source of truth about where the course actually is.

That is a good design for a chat-driven pipeline, and it still depends on a model reading the instruction correctly, every time. The LangGraph port removes the dependency by removing the orchestrator. There is no supervisor node and no router model — build.py declares 28 nodes and the edges between them, and says why in its own docstring:

Why this file exists: in v1 the order of the stages was a paragraph in course-orchestrator.agent.md that a model had to read correctly every time. In v2 it became four workflow graphs plus two ledgers … LangGraph’s checkpointer removes that premise, so it is one graph. The topology is the specification. … Topology cannot be skipped.

One graph serves four commands. choose_entry picks where a run joins the spine from a single phase field:

def choose_entry(state: CourseState) -> str:
    return {"new": "intake", "build": "intake", "plan": "require_design",
            "write": "require_gates", "repair": "repair_seed"}[state.get("phase", "new")]

Every router lives in one file, deliberately: a node does work, a router only reads state and names an edge, so the whole control flow can be read at once rather than inferred from the returns of twenty-six functions.

State: a manifest on disk, or a thread whose id is the slug

This repo’s state is course.yaml and nothing else. Every artefact carries missing | current | stale, and each agent updates it as part of its write procedure. There is no run object, because there is no run — there is a conversation.

The LangGraph state is a TypedDict that describes the run, not the course, and its docstring is one line: “The run, not the course. The course is on disk.” The rule that only the slug travels between nodes survives from the Mastra port, and gains an argument that only applies once runs are durable:

Putting a loaded course in state would make staleness durable. Resuming a three-day-old thread would replay a three-day-old curriculum.

The rule is broken in exactly one place, and the break is engineered. A Send payload has to carry a lesson’s whole context, so write_lesson declares its own input_schema — the payload is a task input, delivered and consumed inside one superstep, rather than a channel every later checkpoint would carry forever. The three list channels that do persist use a hand-written reducer rather than the documented operator.add, for a reason that is pure thread-outlives-run:

def extend(left: list | None, right: list | None) -> list:
    """Append, with None meaning reset.

    `operator.add` is the documented fan-in reducer and it is enough for one run. It
    is not enough here, because a thread is a course: the second `course write` on the
    same slug would inherit the first one's file list and re-commit files it never
    touched."""

Gates: a live conversation, or three nodes and a signed ledger

In this repo a review happens inside the chat. The orchestrator stops twice — after assessment, and after every lesson plan is written — reports what was built and what it cost against the time budget, and waits. The instruction says why that second stop is where it is:

The wave is the expensive half of the course and the last point at which changing an outcome is still cheap.

The answer stays live in the session’s context because the conversation is the record. There is no approval artefact anywhere in this repo; the closest thing, .state/run-log.md, records writes and never decisions.

The LangGraph port gates with interrupt(), and the interesting part is not that it suspends — it is what suspension does to the shape of the code. A LangGraph interrupt replays its entire node on resume, so a gate cannot be one node:

interrupt() replays its whole node, top to bottom, when the run resumes. Everything a gate does other than ask therefore has to live in a different node — the summary above it, the recording below it.

v2 could put all three in one step because a Mastra suspend returns rather than replays. Ported naively, that means a full re-load and re-validation of the course on every resume, and record_gate sitting below the interrupt runs twice.

So each gate is exactly summary → ask → record, and the graph topology test asserts it stays that way. --yes is an answer rather than a bypass: it takes the same path and writes the same ledger entry, annotated auto-approved (--yes).

That ledger, .state/gates.json, stores the hash of every file the approval covered — the four design artefacts for the design gate, those plus every lesson plan for the plan gate. An approval stops counting three ways, and gates.py enumerates them: never answered, declined, or a covered file whose hash has moved since. The checkpointer could have held the decision, and deliberately does not:

the ledger records the hash of every file the gate covered, so a hand edit afterwards retires the approval rather than inheriting it; the ledger is in git beside the course, readable without this pipeline installed; and deleting .runs/ is a cache eviction, not a withdrawal of consent.

This repo has no equivalent failure mode, because there is no process boundary for an approval to fall across and no unattended run for one to be inherited into. That is a property of single-session orchestration, not a gap.

Staleness: a field every agent must keep honest, or a hash recomputed on demand

This repo stores status. The course-state skill makes each writing agent responsible for setting its own artefact current and everything downstream stale, and the cascade is the whole subtree: rewriting curriculum.yaml marks outcomes, assessment, the capstone, every plan, and every lesson’s content, exercises and quiz. The README is blunt about the cost — stale means regenerate, not review, so correcting two fields in a curriculum invalidates the entire course below it.

The LangGraph port stores no status at all. hashes.py records each artefact’s direct inputs by hash and recomputes the verdict when asked:

A stored flag can disagree with the disk in either direction, and a lost flip is invisible

  • Only direct inputs are recorded, so a regeneration that reproduces its input stops the cascade there…
  • A hand edit is visible. An artefact whose own content hash no longer matches was changed by someone other than this pipeline.

The dependency graph is deliberately non-transitive — a lesson’s content depends on its plan, and the plan depends on curriculum, outcomes and assessment, so a curriculum edit that leaves the regenerated outcomes byte-identical stops there instead of reaching four lessons. The status fields still appear in course.yaml, because the viewer and the diff want them, but they are written by a function named project_statuses whose docstring calls the file a projection rather than a source of truth. course adopt rebuilds the ledger from disk, which is how a course authored by this repo loads into that one without migration.

Hand-editing is the sharp end of the difference. Here, a hand edit leaves a stale flag saying current and nothing notices. There, the artefact’s own content hash stops matching and the edit is reported.

The wave: one batch of delegations, or Send plus a deferred barrier

Both pipelines exploit the same independence — once every lesson plan exists, content, exercises, quizzes and the capstone depend on nothing but the plans, so they can all run at once. Read more on why that’s safe.

This repo dispatches the wave as one batch of subagent delegations in a single chat turn: 3N + 1 for an N-lesson course, so the sample how-http-requests-travel landed thirteen. It is bounded by nothing but what the session can hold. The orchestrator reserves three files for itself — course.yaml, glossary.yaml and the run log — and the ordering afterwards is load-bearing, stated in the agent file: merge the glossary first, land the manifest second, “because the manifest claims the glossary is current, so merging second would mean saying so before it was true.”

The LangGraph version fans out with Send from a conditional edge, capstone beside the lessons rather than after them, and closes the wave with a scheduling primitive that has no equivalent in either predecessor:

# `defer=True` is the barrier. Without it this node runs once per arriving branch;
# with it, once, after every pending task in the superstep has finished. Everything
# below it writes files that more than one wave node would otherwise touch --
# course.yaml, glossary.yaml, the run log -- which is what the barrier protects.
g.add_node("gather", nodes.gather, defer=True)

The same merge-then-land order survives verbatim, one node each, below the barrier. Concurrency is capped at two levels rather than one, and the file explains why one is not enough: STAY_WAVE_CONCURRENCY (default 4) caps how wide the graph goes, but each lesson makes three model calls at once via asyncio.gather, so four lessons is twelve requests. A separate asyncio.Semaphore under STAY_MODEL_CONCURRENCY (default 8) caps calls in flight regardless of graph width. Wave results are sorted before writing, so two identical runs produce identical run logs and identical diffs — fan-in order is completion order, which is not stable.

Reference documents: a skill the model may load, or a list bound at import time

Both pipelines run on the same ten instructional-design documents — eight Markdown references plus the machine-readable bloom-verbs.yaml and house-style.yaml. What differs is who decides which sit in front of a stage, and when.

This repo actually uses three mechanisms at once, and it is worth being precise about them. The documents are packaged as skills under .github/skills/, and agent files ask for them in prose — outcomes-designer.agent.md says to load “the course-state skill for the schema and write procedure, and the instructional-design skill for outcome rules and Bloom levelling,” and that sentence is the whole mechanism. But four agents also hard-link a specific reference by relative path when a stage must not miss it: the project designer is told to read capstone-design.md before it starts, and the lesson writer to read house-style.yaml “before you write a word.” And .github/instructions/ applies files by glob with no agent involvement at all — course-content.instructions.md attaches to courses/**/*.md automatically, which the lesson writer notes are “not optional and they are machine-checked.”

The LangGraph port keeps only the fixed-list end of that range, and argues for the trade rather than pretending it is free:

v1 let the model decide at inference time which references to load, which is genuinely more flexible — adding one was dropping a file in a directory. It also meant a stage could decline to read the document that would have told it the rule it then broke. Naming them per stage costs that flexibility and buys the guarantee.

Each stage names its own set on one line — reference("blooms-taxonomy.md", "bloom-verbs.yaml") for outcomes, reference("house-style.yaml", "assessment-design.md", "blooms-taxonomy.md") for quizzes — interpolated into module-level constants, so the set is bound at import time and cannot vary between runs.

Two of the ten are shared with the validator: bloom-verbs.yaml and house-style.yaml exist byte-identically in both the prompt directory and the data directory that the checking code reads, so the verb table an outcomes designer is told to use and the table its output is checked against are the same table. That is the same argument this repo makes for keeping those two as data rather than prose. Worth noting for anyone reading that source: its docstring cites a test asserting the two copies have not drifted, and that test file is not in the tree. The copies are identical today; nothing enforces it.

Model choice: whatever backs your subscription, or a role you pin

No agent in this repo’s course pipeline names a model — all ten files carry only description, tools, and for the orchestrator agents and argument-hint. That is deliberate: Copilot Chat calls whatever backend your subscription points at, and pinning a vendor string would break portability for anyone on a different plan. The recommendation to run planning-heavy stages on a stronger model lives in the architecture writeup, where a person can override it.

The LangGraph port needs an explicit model string, and narrows the choice rather than avoiding it. One file maps a role to a model:

DEFAULTS: dict[Role, str] = {
    "analysis": "claude-sonnet-4-6",
    "planning": "claude-opus-4-7",
    "writing":  "claude-sonnet-4-6",
    "judging":  "claude-sonnet-4-6",
}
MAX_TOKENS = 16_000

Planning gets the strongest model because every later stage inherits its mistakes; writing gets a cheaper one because it runs once per lesson per artefact and is checked mechanically afterwards. STAY_MODEL_<ROLE> overrides one role, STAY_MODEL moves everything.

The MAX_TOKENS line is the sort of detail only a port discovers. Its comment calls the default output cap on a chat model “sized for a chat reply,” and names it the single most common cause of an artefact that fails schema validation for no visible reason: not malformed, cut off. A chat-driven pipeline never meets that failure, because a person watching a truncated lesson simply asks for the rest.

Validation: a hook that blocks the edit, or a node that routes to repair

This repo’s validator is 1,666 lines of Python at .github/skills/course-state/scripts/validate.py, run automatically after every file write by a PostToolUse hook, emitting PASS/WARN/FAIL per check across every course on disk. Mechanical Verification covers what it checks and why a model is not allowed to check its own work. Its most quietly important property is that it must never print a traceback, because an agent sees the exit code and the text, so a crash would read as “something is wrong with your edit.”

The LangGraph port ports it near-verbatim — same checks, same order, same codes, same message strings — and refuses to tidy it on the way:

That is also why the file is one module rather than the fourteen v2 split it into. A 1400-line hand-port that is restructured at the same time cannot be argued to be faithful, only hoped to be.

Then it proves the claim, which is the thing neither predecessor can do about itself. The original is vendored and frozen under tests/parity/, and the suite compares in tiers: defect multisets in both severity modes, then pass counts, because “a rule that never runs also never fails, and Tier 1 cannot see that.” Known divergences are enumerated by exact message rather than filtered by code, on the grounds that dropping all glo defects would also hide a genuine future glossary regression.

Invocation differs in a way that follows from having no hook. Here it fires on every write, so it is a guardrail on the agent’s hand. There it is a graph node placed after the manifest lands and routed to repair — and it deliberately does not run in strict mode, because “the wave may legitimately have left a course partway, and a transient state is not a defect until the run is over.” A python -m stay.validate entry point preserves this repo’s exit codes, so the same hook would work against it unchanged.

Repair: report and stop, or route on owner and check the count went down

This repo’s validator reports; nothing after that is automatic. A person, or the next turn, decides what to do.

The LangGraph port closes the loop for the four leaf artefacts — content, exercises, quizzes and the capstone — and refuses to touch the five that were approved at a gate, for the reason that follows directly from the ledger: silently editing one turns an approval into a signature on a document that has since changed. Repair stops on any of four conditions — nothing remaining, nothing actionable, a round that reduced no count, or the STAY_REPAIR_ROUNDS cap of two — and the report names which one stopped it rather than just the count.

Two details are genuine improvements on both predecessors. Routing is on the defect’s owner field, where the Mastra version matched path strings and left owner decorative. And retrieval defects — a term introduced and then never asked about again — became repairable by resolving the term through the glossary to the lesson that introduced it, and handing it to that lesson’s quiz. Both earlier versions could only report those.

Moderation and observability: a person watching, or a screen and a trace

Two capabilities exist in the LangGraph port for the same underlying reason, and its own source states the reason without claiming this repo was wrong:

Why this exists at all: v1 has no input moderation, and its argument for not needing one is sound — a person is watching every turn in a chat panel… This pipeline is built to run unattended, which retires that argument rather than answering it.

The screen runs before scaffold, the first node that creates anything, so a rejected brief leaves no directory behind, and it screens the whole brief as one message rather than field by field, because a topic and an audience that are each unremarkable can be a different thing together. It fails open — a screening error logs a warning and continues — on the grounds that a guardrail failing closed means a pipeline nobody can use when the endpoint is slow.

Observability is the other half. This repo’s record of a run is the chat transcript plus .state/run-log.md. The LangGraph port ships self-hosted Langfuse and pushes the validator’s counts onto every trace as scores, with the trace id minted before the run so post-run scores attach to it and the session id set to the slug, so a course’s whole history is one thread. The argument against an LLM judge is the same one this repo makes about its validator:

An LLM scorer gives an opinion that costs money and moves between runs. The validator gives a count that costs nothing and is reproducible to the defect.

One score exists purely to catch the failure the others cannot: validator_checks_run, because “a rule that stops running also stops failing.” Every owner gets a score including the clean ones, since a missing score reads as “not measured” and a zero reads as “measured, and clean.”

What only one version can do

Only in the LangGraph port:

  • Suspend at a gate and resume from a different process days later, because the checkpointer writes to SQLite after every superstep and the thread id is the course slug — no run id for a person to copy between commands.
  • Run the whole pipeline end to end unattended with course build, gates auto-answered and recorded rather than skipped.
  • Assert properties of the pipeline with no model, no API key and no course: that every node is reachable, that removing outcomes makes assessment unreachable, that every path into the wave passes a gate guard, that no interrupt node is reachable from inside the wave.
  • Prove its validator agrees with this repo’s, defect for defect and pass count for pass count, against a vendored frozen copy.
  • Retire an approval automatically when a file it covered is hand-edited afterwards.
  • Stop a staleness cascade at a regeneration that reproduced its input, and detect a hand edit as distinct from a stale one.
  • Cap concurrency at both graph width and model calls in flight, and bound repair rounds, from environment variables with no prompt to edit.
  • Auto-repair mechanically-detected defects in leaf artefacts, including retrieval defects routed through the glossary.
  • Refuse a brief at the door before the first node that touches disk.
  • Print its own specification with course graph, in two formats that deliberately disagree — the declared structure, and LangGraph’s compiled graph including the barrier’s scheduling edges.

Only in this repo’s Copilot version:

  • Start working the moment the repo is open in VS Code with Copilot Chat — no uv sync, no SQLite file, no API key, no Docker Compose for a tracing stack.
  • Stay genuinely provider-agnostic: the same agent files run against whatever model your Copilot subscription points at, with nothing to change if that changes.
  • Let a person redirect an agent mid-turn in full conversational context, rather than answering a gate with a CLI flag.
  • Let the model decide at inference time which reference documents a stage needs, and add a new one by dropping a Markdown file into a skill.
  • Attach reference material to files by glob, with no agent asking for it and no stage list to extend.
  • Validate on every single write rather than once per run, so a defect surfaces at the edit that caused it.
  • Change orchestration behaviour by editing Markdown — no build step, no graph to recompile, no second runtime to install.

Instructions you can edit, or a graph you can prove things about

All three versions still prompt models with natural-language instructions. Porting to LangGraph did not replace the lesson writer’s judgment with a schema; the reference documents are the same documents and the prose in them is unchanged. What moved is the scaffolding.

This repo’s scaffolding is Markdown, and its correctness is a property of a model reading it correctly: the stage order, the staleness cascade, the wave’s file ownership rules and the two approval stops are all sentences. They are also editable by anyone who can write English, which is not a small thing — the entire pipeline is legible without running it.

The LangGraph scaffolding is a compiled graph, and its correctness is a property you can test. The topology suite is the clearest expression of the difference: it makes claims about what the pipeline can and cannot do — you cannot reach the assessment stage without outcomes, you cannot reach the wave without passing both gate guards, you cannot reach a directory without passing the moderator — and it checks them in milliseconds, with no model in the loop. This repo’s equivalent claims are true, and it has no mechanism to demonstrate that they are.

Neither is the better architecture. They fail differently. This repo optimizes for a person steering every decision in one continuous session with nothing to install, and its worst failure is a status flag nobody flipped. The LangGraph port optimizes for a pipeline that survives without anyone watching — across processes, across days, across a crash in module four — and its worst failure is a stage that never got the flexibility to ask for something its fixed reference list did not name.

If you are still working out what a course-authoring pipeline should contain, or you want to sit in the loop for every judgment call, Copilot Chat driving Markdown agents remains the shortest path to a working system. If you need a run to survive a laptop closing, an approval that expires when the document under it changes, or a way to prove that a refactor did not quietly remove a check — that is what the graph is for.