The Workflow pattern: pipeline, parallel, verify
A Workflow is a plain script — agent(), parallel() and pipeline() calls deciding who does what and in what order — not a plan written in prose
11 min read
Every course module up to this one has been about a single agent doing a single job well. This one is about what changes when a task needs several agents, and — more importantly — when it needs several agents checking each other, because that second thing is where most of the real risk in AI-assisted work actually lives. Everything below is drawn from workflow scripts that this repository's own sessions actually ran, kept at knowledge/workflows/scripts/ rather than left to age out of a chat transcript. That folder's own README states the reason plainly: "Every file is a real, complete workflow script that actually ran against this codebase — not a template, not a sanitized example." This lesson reads several of them.
What a Workflow script actually is
A Workflow is not a natural-language plan that an agent interprets loosely. It is a plain JavaScript file — no TypeScript syntax, no filesystem access, an async script body that runs top to bottom — built around five hooks: agent(), parallel(), pipeline(), phase(), and log(). Every script must open with a literal export const meta = { name, description, phases } object naming what the script does and, optionally, the phases it runs through. knowledge/workflows/README.md puts the underlying idea in one sentence: "an agent is a prompt, and an orchestrator is a script that decides which prompts run, in what order, and what happens to their output." The intelligence is in the agents; the structure — sequencing, branching, deciding what one agent's output becomes as the input to the next — is in the deterministic code around them.
agent(prompt, opts) spawns one subagent and returns its result — a string by default, or, when opts.schema is a JSON Schema, a validated object. The mechanism matters: with a schema, "the subagent is forced to call a StructuredOutput tool and agent() returns the validated object — no parsing needed," and a schema mismatch triggers a retry at the tool-call layer rather than a downstream JSON.parse crash. llm-council-protocol-review.js shows this in its FINDING_SCHEMA:
const FINDING_SCHEMA = {
type: 'object',
properties: {
findings: {
type: 'array',
items: {
type: 'object',
properties: {
file: { type: 'string' },
claim: { type: 'string' },
problem: { type: 'string' },
correction: { type: 'string' },
severity: { type: 'string', enum: ['critical', 'major', 'minor'] },
},
required: ['file', 'claim', 'problem', 'correction', 'severity'],
},
},
},
required: ['findings'],
}
Nothing downstream has to guess whether a finding has a severity field or parse it out of a paragraph — the schema makes the model's own output the data structure the rest of the script runs on. This is not an abstract mechanism to take on faith, either: this lesson's own text was produced by exactly this pattern, one subagent call inside a larger Workflow, its final answer returned through the same structured-output tool call it is now describing.
Phases: the script narrating its own progress
phase(title) marks the start of a stage; every agent() call after it is grouped under that title in the run's live progress display. meta.phases has to use the exact same titles — the tool matches them literally. llm-council-protocol-review.js declares three: Council, Verify, Synthesise, and its body calls phase('Council'), phase('Verify'), phase('Synthesise') in that order, so anyone watching the run — or reading the transcript afterward — sees the same three-stage shape the script's author designed, not just an undifferentiated stream of agent calls. meridian-payments-security-audit.js uses two, Find and Verify; meridian-porting-fixes-and-content-quality.js uses three: Fix Porting Failures, Content Quality, Glossary Consolidation. Phases are bookkeeping, not logic — but they're the difference between a workflow you can debug by reading its own progress tree and one where every agent call looks identical from the outside.
Why pipeline() is the default
pipeline(items, stage1, stage2, ...) runs every item through all the stages, but — this is the part worth sitting with — with no barrier between stages. Item A can be in stage 3 while item B is still in stage 1. parallel(thunks) runs its tasks concurrently too, but it is a hard barrier: it awaits every thunk before returning anything to the caller.
verify-production-deploy.js is the cleanest real instance of the choice. It checks five separate things about the live production site — homepage SEO, corporate-page canonicals, corporate JSON-LD, crawl infrastructure, and the Potentia/Vault access gate — and each one needs the same two steps: an agent probes it with curl, then a second, independent agent re-fetches and confirms or disputes the first agent's verdict. The script runs this as one pipeline() call over the five dimensions, (d) => agent(d.prompt, {...}) for Probe, then (probe, d) => agent(...) for Verify. Nothing about the five dimensions is equally expensive to check: crawl-infra is a handful of curl calls against /sitemap.xml and /robots.txt; corporate-jsonld has to fetch three separate pages and parse the <script type="application/ld+json"> block out of each one. With pipeline(), the moment the cheap dimension's Probe finishes, its Verify starts immediately — it does not sit idle waiting for the expensive dimension's Probe to also finish first.
That idling is the actual cost of an unwarranted barrier, and it is a wall-clock cost, not a code-cleanliness one. If those same five dimensions had instead gone through two parallel() calls — probe all five, wait for all five, then verify all five — total run time becomes the slowest Probe, plus the slowest Verify, and every fast dimension pays the full delay of the slowest one twice. The tool's own documentation states the general version of that arithmetic directly: "if 5 finders run and the slowest takes 3× the fastest, a barrier wastes 2/3 of the fast finders' idle time." pipeline()'s wall-clock is instead bounded by the slowest single item's own Probe-then-Verify chain — never by the sum of everyone else's slowest stage. None of the five dimensions in verify-production-deploy.js depends on what any of the other dimensions found; a barrier there would buy nothing and cost real time, which is exactly why the script doesn't use one.
parallel(): the one place a barrier earns its cost
A barrier is justified only when a later stage genuinely needs every earlier result at once — not because the code is easier to write that way. meridian-porting-fixes-and-content-quality.js shows the real version of this. Its Content Quality phase runs a content audit across 27 separate Meridian lesson files:
const auditResults = await parallel(
AUDIT_FILES.map((file) => () => agent(/* audit this one file */, { schema: CANDIDATE_TERM_SCHEMA, phase: 'Content Quality' })),
)
Each of those 27 agents also scans its own lesson for jargon that should be in the shared glossary but isn't, and is explicitly told not to add it itself — the prompt says so directly: "a separate consolidation step adds these afterward so 27 agents aren't all racing to edit the same shared glossary file concurrently." The barrier exists because the next stage, Glossary Consolidation, has to write to one shared file — content/meridian/glossary.ts — exactly once, after collecting every audit's candidate terms, deduplicated, in a single pass: const allCandidates = auditResults.filter(Boolean).flatMap((r) => r.candidateGlossaryTerms || []). Twenty-seven agents editing the same file concurrently is a real collision risk, not a hypothetical one; a single write after a barrier is the fix. The same block also shows the other legitimate reason for a barrier — skipping work outright when the count comes back at zero: if (allCandidates.length > 0) { glossaryResult = await agent(...) }. No candidates, no consolidation agent spawned at all.
Adversarial verify: the pattern that matters most
The single biggest risk in delegating real work to an AI agent isn't that it produces nothing — a blank result is obviously wrong and gets caught immediately. It's that it produces something plausible: a security finding that reads convincingly but doesn't actually exploit anything, a "correction" to a fact that was already correct, a bug report against code that already handles the case it claims is unhandled. A model asked to re-read its own claim and confirm it tends to agree with it — the same blind spot that produced the finding is available to grade it. The fix used across every review-shaped script in this repo is to never let the agent that found something be the one that decides it's real.
The canonical shape, N independent skeptics voting on a single claim, majority-refute kills it:
const votes = await parallel(Array.from({ length: 3 }, () => () =>
agent(`Try to refute: ${claim}. Default to refuted=true if uncertain.`, { schema: VERDICT })))
const survives = votes.filter(Boolean).filter((v) => !v.refuted).length >= 2
The real scripts in this repo tune that vote rule to the cost of being wrong in either direction, and the differences are informative on their own. meridian-payments-security-audit.js — auditing checkout, Stripe webhooks, and access control on a paid course about to go live with real money — runs each finding past exactly two skeptics and keeps it if at least one fails to refute it: const confirmed = votes.filter(Boolean).filter((v) => !v.refuted).length >= 1. That's deliberately lopsided toward keeping a finding alive, because for a pre-launch payment system a missed real vulnerability is far more expensive than one extra false positive an engineer has to dismiss. meridian-review-council.js, checking a paid course's own answer keys, goes the other way: exactly one independent verifier per finding, instructed to "Default to refuted when uncertain — acting on a wrong finding makes the course worse than leaving it alone," and told to re-derive the answer itself — with python3 or node for anything arithmetic — before comparing, rather than just re-reading the first agent's reasoning. There, a false correction is nearly as costly as a missed one, so the bar is independent reproduction, not a vote. verify-production-deploy.js takes a third variant again: its skeptic doesn't re-judge the first agent's report at all, it's told to "re-run the key curl checks YOURSELF from scratch" against the live URLs — verification means re-executing the underlying check, because the thing being verified is a fact about the world, not a piece of the first agent's reasoning.
What all three share is the instruction to default toward disbelief. That phrase isn't decoration — it's a direct countermeasure to a named, specific failure mode: a model asked "is this finding correct?" tends toward agreement unless explicitly told which way to lean when it's unsure. This project's own retrospective on 23 of these runs, knowledge/research/agent-orchestration.md, is blunt about why the extra step is worth its cost: "'Find security bugs' produces a list a single skeptical read would have thrown out half of. 'Find security bugs, then have two independent agents each try to refute every finding, and drop anything a majority can't defend' produces a list worth acting on." The mechanism, the same document argues, is that the verify prompt is "built around trying to prove the finding is wrong, not confirming it's right" — a different question than the one that produced the finding in the first place, asked by an agent with no stake in having found anything.
What adversarial verify doesn't fix
It's worth being honest about the edges of this, because the failure modes it doesn't touch are the ones that will actually bite someone relying on it. Verification catches a claim that's wrong given the material everyone involved can see — it does nothing for an error baked into a source every reviewer trusts equally, or a citation that's individually accurate but doesn't say what it's being used to support. It also doesn't defend against an agent producing no result at all: agent() "returns null if the user skips the agent mid-run or the subagent dies on a terminal API error after retries," which is why every real script in this set filters with .filter(Boolean) before touching results — a silent null is a different failure than a wrong verdict, and a script that forgets the filter will crash on it rather than notice it. agent-orchestration.md's account of port-official-sat-questions.js is the sharpest example on record: three of twenty-nine porting agents failed outright, two on a hard 64,000-output-token ceiling and a third by doing real work and then returning nothing. No amount of adversarial verification would have caught that, because there was no finding to verify — the fix that actually worked was telling agents to write their output incrementally and never paste generated content back into their own final response, an instruction about how an agent reports, not about how carefully anyone checks its claim afterward.
And verification isn't free. Every skeptic is a full extra agent() call, and calls queue against a real concurrency cap (documented at 16 or fewer running at once, depending on the machine) and a hard ceiling of 1,000 agent calls in a workflow's lifetime. Running three skeptics per finding on a review that surfaces forty findings is 120 extra calls before synthesis even starts. That's usually the right trade for a paid course's answer key or a payment system's access control. It is not automatically the right trade for everything, and deciding when it is — rather than reaching for it by reflex — is as much a part of this pattern as the mechanism itself. This site's own /skills page lists exactly this trade-off under agent-orchestration, built, as its own entry says, "from 23 real workflow runs against this codebase, kept and indexed rather than left to age out of a session transcript" — the same source this lesson has been reading from directly.
Up next
Building a real course with Claude
Three artifacts already sitting in this codebase — a typed lesson schema, one pilot lesson, and a production incident with a real PR number — teach more about this than a tutorial would
9 min