Write the chain down

Write the chain down

By the third time you have typed the same two steps, the workflow exists. It lives in your shell history, where nobody can review it, nobody else can run it, and a change to it is not a diff. Most agent tooling answers that with a YAML file and a templating language. Within a year it has if: and ${{ steps.x.outputs.y }}, and it is a programming language with none of the tools of one.

A combo flow is a Markdown file next to your agents, and it refuses to become that. It is built from a closed set of nodes, it runs no code of its own, it has no templating, and it is checked whole before anything is spawned. The moment a run needs something the nodes cannot say, it is TypeScript, and the file has done its job by making that boundary visible.

The problem worth a file

Documentation drifts from code. Every repository has a page that says a default is 4 when the code says 2. The reading is always the same: one agent reads the code, one reads the docs, one compares. Put it in .pi/flows/docs-drift.md:

---
name: docs-drift
description: One scout reads the code and one reads the guide on a topic, then one agent lists where they disagree
input: string

nodes:
  - id: read
    parallel:
      code:
        - id: in_code
          agent: scout
          reads: [input]
      guide:
        - id: in_guide
          agent: scout
          reads: [input]

  - id: compare
    agent: synthesiser
    reads: [input, read]
---

## in_code
Read the code under src/ only, and report what it does about the topic under
`input`: one claim per line, with file and line. Say plainly when you find
nothing there.

## in_guide
Read docs/guide/ only, and report every claim the guide makes about the topic
under `input`, quoted, with the page it comes from. Say plainly when you find
nothing there.

## compare
List every place where the guide and the code disagree about the topic under
`input`. The two reports are under `read`: `code` says what the code does,
`guide` what the guide claims. One line each, quoting the claim and naming the
file that contradicts it. Where they agree, say nothing. If they agree
everywhere, say so in one line.

Frontmatter carries the structure: which nodes, which agents, what each one reads, because that nests and YAML nests for free. The body carries the prose, one ## <id> section per agent turn, because a ten-line instruction inside a YAML block scalar is miserable to write and Markdown is what prose is for.

input: string says what the flow is started on: the text after its name. parallel runs its two branches at once. reads: is the whole data model: a turn is handed what it lists, each under its own heading, and nothing else. compare cannot read in_code directly, because a node inside a block is not visible outside it; it reads read, the block’s output, which holds each branch as it ended.

See it loaded

/flows
6 flows
build           project  ≤ 154 turns · ≤ 41h20m                      Locate the code, split the work, implement it in
                                                                     pairs, check and audit the whole
build-attended  project  ≤ 170 turns · ≤ 49h20m + a person's answer  Interview the user, confirm, build, then commit
                                                                     on the run's branch
docs-drift      project  ≤ 3 turns · ≤ 1h                            One scout reads the code and one reads the guide
                                                                     on a topic, then one agent lists where they
                                                                     disagree
explore         project  ≤ 8 turns · ≤ 2h                            Three scouts read the code in parallel, then one
                                                                     agent answers from what they found
interview       project  ≤ 14 turns · ≤ 7h + a person's answer       Ask the user one question at a time, then write a
                                                                     specification
split           project  ≤ 12 turns · ≤ 4h                           A planner splits a read-only question between a
                                                                     scout and a reviewer, then one agent answers

Yours sits beside the shipped ones, with its worst case: three turns, an hour at most, because each turn is bounded by a 30-minute default and the two scouts run at once. Nothing is estimated there; every loop and every map in a flow carries a max:, so the most a flow can cost is known before it runs. /flows docs-drift prints the plan that bound comes from:

docs-drift · .pi/flows/docs-drift.md · input string · ≤ 3 turns · ≤ 1h
○ read · parallel · ≤ 2 turns · ≤ 30m
  ○ read/code
    ○ read/code/in_code · agent scout (.pi/agents/scout.md) · reads input · timeout 30m by default · ≤ 1 turn · ≤ 30m
  ○ read/guide
    ○ read/guide/in_guide · agent scout (.pi/agents/scout.md) · reads input · timeout 30m by default ·
      ≤ 1 turn · ≤ 30m
○ compare · agent synthesiser (.pi/agents/synthesiser.md) · reads input, read · timeout 30m by default ·
  ≤ 1 turn · ≤ 30m

A file in .pi/flows/ is visible from this repository only; one in ~/.pi/agent/flows/ follows you everywhere. The same name in both, and the repository’s wins, so replacing a shipped flow is writing a file called build.md, not deleting anything.

Run it

/run docs-drift the default lifetime of a subagent, and who decides it

Two scouts, then the synthesiser, then the comparison lands in the conversation, prefixed with the flow that produced it exactly as explore was:

Result of the docs-drift flow, asked to: the default lifetime of a subagent, and who decides it.

They agree everywhere.

ok · runs/2026-09-23_22-18-00

✓ docs-drift · 4 visits · 33s · ↑38k ↓1.6k
✓ read · 2 branches · 27s · ↑37k ↓1.4k
✓ compare · synthesiser · 6s · ↑1.5k ↓252

One line, thirty-three seconds, 38k input tokens for the whole run. An anticlimax is the correct result of a drift check on a page that is right, and a flow that only ever finds something is one you should distrust.

What the last turn received is worth reading once, because it is the whole data model. It is the first message of compare/synthesiser.jsonl in the run directory:

List every place where the guide and the code disagree about the topic under
`input`. The two reports are under `read`: `code` says what the code does,
`guide` what the guide claims. One line each, quoting the claim and naming the
file that contradicts it. Where they agree, say nothing. If they agree
everywhere, say so in one line.

## input

the default lifetime of a subagent, and who decides it

## read

```json
{
  "code": {
    "ok": true,
    "output": "- `src/agent.ts:19-23`: The possible lifetimes are `\"task\"` (born and dies with each task), …\n- `src/subagent.ts:63`: The default lifetime is `\"task\"`.\n- `src/subagent.ts:166`: The lifetime is decided by the `lifetime` option passed to `spawn()`, falling back to the `lifetime` declared in the agent's frontmatter, and finally falling back to the `\"task\"` default."
  },
  "guide": {
    "ok": true,
    "output": "In `docs/guide/lifetime.md`:\n\n- \" `\"task\"` *(default)* \" (line 8)\n- \"Resolution order, always: the explicit argument, then the agent's frontmatter, then `\"task\"`. Persistence is asked for. It is never obtained by accident.\" (lines 14-15)"
  }
}
```

Its own section first, then each address it reads, under the address as its heading. Each branch comes with whether it ran. As written, a scout that fails ends the run; give both on-fail: continue, as explore does, and a failed one reaches the synthesiser as "ok": false in its slot instead. The prose names the headings it will find, input and read: a turn holding two reads has two sections, and “the reports below” would point at neither.

What a typo costs

Change one letter and run it again:

  - id: compare
    agent: synthesizer
/run docs-drift the default lifetime of a subagent
Error: run: `docs-drift` is refused
  .pi/flows/docs-drift.md compare.agent: `synthesizer` is unknown; did you mean `synthesiser`?

It stopped before spawning anything. The file was parsed, every node’s shape was checked, every section was matched to an agent node, every address and every agent name was resolved, all before a single session opened. A typo in the last node costs a second, not two scouts’ worth of real work followed by a failure. The fault names the file, the node and the key, compare.agent.

Now put the name back, delete the ## compare section, and run /flows:

5 flows, 1 file refused
…
docs-drift      project  broken
  .pi/flows/docs-drift.md compare: `compare` is an agent node and has no `## compare` section
…

The refused entry and its fault are the two lines drawn in the warning colour; the rest is the listing as before. A refused flow is never silently skipped. An agent file missing its name is dropped without a word, following pi, because agents are discovered; a flow is asked for by name, and answering “unknown flow” about a file sitting right there would be a lie. The most likely reason anyone runs /flows is that something did not load, so the refused ones are what the listing is for. /flows checks and spawns nothing, so it needs no model.

What the file cannot say

A flow can branch, repeat and fork: a choice takes the first case whose condition holds, a loop runs until its condition holds, a map runs its body once per item. What it cannot do is let a model or a string decide the shape of the run:

  • A model produces values, never the next node. A condition is a small subset of CEL that reads typed outputs, audit.output.approved, and is type-checked before the first spawn. An agent’s text is read whole and never into, so no condition can hang on the wording of a paragraph.

  • There is no templating. A node lists its reads:, and the runner hands it those values under their names. Nothing is interpolated into a prompt.

  • A flow runs no code. A check names a script of the project, and its exit code is the verdict.

  • Every loop and every map has a max:. Forever is not reachable by forgetting an argument, and /flows prints what the worst case costs.

  • model: at the top level puts every agent turn of the file on one model, and a --model on the command beats it.

When you need more than that, the same two reads and a comparison in TypeScript are a few lines, and every combinator is a function you can call. That is where Workflows picks up. The whole format, every node and every fault, is Flows.

The flow used our agents. The next page writes one of yours, and the first thing it decides is what the agent may not do.

Next: An agent that cannot do harm.