You wanted three agents to hand work to each other. A planner that turns a vague request into a spec. A builder that writes the code. A tester that checks it against the spec and sends it back if it's wrong.
So a year ago, you opened your config. You wrote a [[workflows]] block. You bound it to a role with workflow = "dev_flow". You flipped enable_layers = true. You added a [[pipelines]] section to gather context before the model ran. You typed /workflow in the session and watched a wall of output scroll by, with no idea which step you were in or what it cost.
It half-worked. And six months later you couldn't read your own config.
That whole system is gone in Octomind 0.30. Not deprecated-with-a-warning. Deleted. The [[workflows]] section, the /workflow command, role-bound workflows, role-bound pipelines, the enable_layers layer-chaining — all of it already cut from the codebase. In its place is one thing that does the same job and almost nothing the old system did wrong:
echo "build a JSON-to-CSV CLI in Rust" | octomind workflow dev.tomlA portable file. A real CLI command. Orchestration that sits above your sessions instead of tangled inside them. This post is the story of why we threw the old one out, and exactly how the new one works.
The Old World: Orchestration Trapped Inside the Session
The original design made an understandable mistake. It treated "run several AI steps in sequence" as a session feature. So it lived in session config, bound to roles, and ran inside the session loop.
That had four problems, and they compounded.
It was role-bound. A workflow was attached to a role via workflow = "dev_flow". To run a different flow you switched roles. To share a flow you exported the role and its workflow and its pipeline and the model config they all depended on. The unit of reuse was "your entire config," which means nothing was reusable.
It ran first-message-only. The in-session workflow and its [[pipelines]] pre-processing fired on the first message of a session and then got out of the way. That's a strange contract: orchestration that only exists for one turn, living inside a thing whose whole point is to persist across many.
It was invisible. Steps ran inside one session's output stream. No per-step cost. No per-step timing. No "you are here." When a five-step flow ran hot, you found out from your bill, not from your terminal.
It tangled two completely different jobs into one config soup. "Gather context with a shell script" (deterministic, no model, cheap) and "reason about the architecture" (agentic, expensive, non-deterministic) were both stuffed into the same role's config — pipelines for the first, workflows for the second, layers gluing it together. They have nothing in common except that they happen before or around a model call. Putting them in the same place made both harder to understand.
The old [[pipelines]] docs actually drew the line correctly — pipelines handle deterministic work, workflows handle agentic work — and then undermined it by binding both to a role and running both inside the session. The right idea, at the wrong altitude.
We Deleted All Of It
The precise list matters, because "we cleaned up workflows" is the kind of sentence that hides breaking changes. If you're upgrading, this is what's gone:
-
[[workflows]]config section — removed. You can't define a workflow indefault.tomlanymore. -
/workflowsession command — removed. There's no in-session workflow runner. - Role-bound workflows (
workflow = "...") — removed. Drop the field from your roles.octomind config --upgradewill do it for you. -
[[pipelines]]+pipeline = "..."— removed. The standalone, role-bound, multi-step pipeline feature no longer exists in the codebase. -
enable_layers— removed. The legacy automatic layer-chaining that quietly stitched roles together inside a session is gone. (Layers still exist as explicit,/run-invocable ACP units — they're just no longer an implicit orchestration mechanism.)
The migration is short. If you had workflow = "dev_flow" on a role, delete it and rewrite the flow as an external workflow TOML (the rest of this post shows you how). If you leaned on [[pipelines]] to enrich input before the model, that job moved somewhere better — we'll get there at the end.
Everything multi-step now goes through a single command.
The New World: octomind workflow file.toml
A workflow is now an external orchestrator. It chains multiple independent octomind run invocations into a multi-step process. Each step is its own subprocess — its own session, its own role, its own model, its own tools. Outputs flow between steps by name.
stdin ─► octomind workflow file.toml ─► (watch it run on stderr)
│
├── step "spec" → octomind run (subprocess)
├── step "developer" → octomind run (subprocess) ─┐
└── step "tester" → octomind run (subprocess) ─┘ loop
│
▼
stderr: per-step
progress, cost,
tokens, totalsUnder the hood, each step runs octomind run --format jsonl. The orchestrator streams that JSONL event log, accumulates the assistant text and the cost/token totals from the cost events, then hands the captured output to the next step. That's the whole machine.
Three things changed:
The flow is a file, not config. A workflow file is a portable TOML document. It touches nothing in default.toml and binds to no role. You can commit it next to your code, hand it to a teammate, run it in CI, or keep ten of them in a folder. The unit of reuse is the flow itself.
You can see it run. Every step prints its rendered response, its duration, its cost, its token count, and its tool-call count to stderr as it completes — then a grand total at the end. No more guessing.
It composes the model you actually want per step. Each step names a role, and you can override the model per step. Cheap model for the classifier, expensive model for the hard reasoning, in the same flow.
The CLI
# Run a flow — stdin is the input
echo "build a JSON-to-CSV CLI in Rust" | octomind workflow dev.toml
# Validate + print the execution plan without spawning anything
octomind workflow dev.toml --dry-runTwo rules:
- stdin is the input. It's required (unless
--dry-run); empty stdin is a hard error. There's no--var key=value. The flow's single entry point is whatever you pipe in. - Everything streams to stderr; the command produces no stdout. Per-step assistant messages (rendered as markdown when enabled), progress lines, per-step stats, warnings, the final total — all stderr. You watch a workflow; you don't pipe its result into the next shell command. (
--dry-runis the one exception: it prints the resolved plan to stdout so you can inspect it.)
The Mental Model: Above vs. Inside
The old design got this split wrong. The new one gets it right:
Workflows sit above sessions. Pipes sit inside one.
A workflow orchestrates many octomind run invocations. It's a level up from any single session — a conductor standing over the orchestra. It belongs in a file, runs from the CLI, and is the same whether you run it once or a thousand times.
A pipe transforms the input of a single session before the model sees it. It's a level down, inside one run. It belongs in your project's policy file (.agents/guardrails.toml, as a [[pipe]] section — we cover it in the guardrails post), runs every turn, and never spawns a subprocess.
The old [[pipelines]] feature tried to be both — deterministic pre-processing that lived in session config but conceptually wanted to be orchestration. Splitting it in two made each half obvious. Deterministic input shaping is a pipe. Multi-step agentic orchestration is a workflow. Nothing is both.
Anatomy of a Workflow File
A workflow file has a name, an optional description, and a list of steps. There are four step types; below is one of each.
name = "my-workflow"
description = "Optional human description"
# ── Sequential step (the default) ─────────────────────────────────────
[[steps]]
name = "spec"
role = "developer:general" # any installed role or tap-agent tag
prompt = """
User request:
{{input}}
Write a tight implementation spec.
"""
session = "fresh" # "fresh" (default) | "continue"
timeout = 0 # seconds; 0 = no timeout (default)
retries = 0 # extra attempts on failure (default 0)
# model = "anthropic:claude-sonnet-4-6" # optional per-step model override
# ── Parallel block — sub-steps run concurrently ───────────────────────
[[steps]]
name = "review"
parallel = true
[[steps.run]]
name = "security"
role = "security:owasp"
prompt = "Security review of:\n{{spec}}"
[[steps.run]]
name = "performance"
role = "developer:general"
prompt = "Performance review of:\n{{spec}}"
# ── Loop block — generator/evaluator refine pattern ───────────────────
[[steps]]
name = "refine"
loop = true
max_iterations = 3 # default 10
exit_when = { output = "tester", contains = "NO ISSUES" }
[[steps.run]]
name = "developer"
role = "developer:general"
session = "continue"
prompt = "Implement:\n{{spec}}"
[[steps.run]]
name = "tester"
role = "developer:brief"
session = "continue"
prompt = "Verify against spec:\n{{spec}}\n\nCode:\n{{developer}}"
# ── Conditional block — branch on a pattern match ─────────────────────
[[steps]]
name = "route"
conditional = true
condition = { output = "spec", contains = "security" }
on_match = ["deep-dive"]
on_no_match = ["quick-summary"]
[[steps.run]]
name = "deep-dive"
role = "security:owasp"
prompt = "Deep analysis:\n{{spec}}"
[[steps.run]]
name = "quick-summary"
role = "developer:general"
prompt = "One-line summary:\n{{spec}}"Variables: how output flows
Anywhere in a prompt, {{name}} is substituted:
| Variable | Value |
|---|---|
{{input}} | The raw stdin content |
{{step_name}} | The full text output of a previously completed step (by name) |
{{input}} is the same everywhere; {{spec}} is whatever the step named spec produced. That's the entire data model — text in, text out, named and threaded by hand. There's no shared mutable state, no hidden context bleed. A step sees exactly what you wire into its prompt.
The orchestrator rejects forward references before anything runs: a step can't read {{later}} from a step that completes after it. Step names must be unique across the whole file, including sub-steps. input is reserved — you can't name a step input.
The four step types
Sequential (default). Any [[steps]] table with no parallel/loop/conditional = true runs octomind run once with the resolved prompt. This is the workhorse.
Parallel (parallel = true). Sub-steps run concurrently. The next top-level step waits until every sub-step finishes. Sub-steps can't reference each other — only the outer scope. Needs at least two sub-steps. Use it for independent reviews of the same artifact, like the security + performance pass above.
Loop (loop = true). Sub-steps run in sequence each iteration. Between iterations, exit_when is checked against a named step's output:
-
exit_when = { output = "tester", contains = "NO ISSUES" }— substring match -
exit_when = { output = "tester", matches = "^PASS" }— Rust regex match - omit
outputto test the most recent step's output
If max_iterations is hit without exit, the loop ends with the last iteration's outputs and a warning to stderr. It does not fail the workflow — a loop that didn't converge still hands its best attempt downstream. Always set max_iterations (default 10) to bound spend.
Conditional (conditional = true). condition tests a prior step's output (same shape as exit_when). On a match, the step names in on_match run; otherwise on_no_match. Skipped sub-step names resolve to empty strings in later substitutions, so downstream prompts don't break.
Session modes: the GAN trick
This is the most interesting field, and the one that makes refine loops actually work.
| Mode | Behaviour |
|---|---|
session = "fresh" (default) | Brand-new session every invocation. No state persists. |
session = "continue" | First run: new session, ID remembered. Subsequent runs (loop iteration 2+, or a retry): the same session resumes, and /done is sent first to compress prior context. Ephemeral to this one workflow invocation. |
The clever part is the prompt-replacement rule. On the first run of a continue session, the templated prompt is sent as written. On subsequent runs, the templated prompt is replaced with the most recent prior step's raw output — because the session already holds the full history; it just needs the latest signal to react to.
That's what makes a generator↔tester GAN work without re-feeding the entire spec every iteration. The developer keeps its own running session; the tester keeps its own. Iteration 2's developer doesn't re-read the spec — it reads the tester's last critique and revises. Each step accumulates an independent history. Cheap, and it converges.
Retries and timeouts
-
retries = N— up to N additional attempts on failure (default 0 = one attempt). - A step "fails" when the subprocess exits non-zero or produces no assistant output.
-
timeout = S— seconds before the subprocess is killed (default 0 = no timeout). A timeout counts as a failure for retry logic. - All retries exhausted → the workflow exits non-zero with
step '<name>' failed after <N> attempts.
Set timeout on any step that touches an external dependency that might hang. Set retries on steps that occasionally flake.
A Real Flow: develop — Driving a Task to a Finished Version
This is what a workflow is actually for. It isn't speed, and it isn't parallelism. It's that a single octomind run hands you a first draft and calls it done — the exact premature-"done" failure mode that guardrails fight one turn at a time. A workflow attacks the same problem from above: it refuses to stop at the first draft. It keeps a task moving until something other than the author agrees it's finished.
That's the entire point of the develop workflow that ships as a starter template in 0.30. Pipe it a spec and you don't get back the developer's first attempt — you get the version that survived a reviewer and an evaluator gate.
name = "develop"
# 1. Orient — pin down where to start before writing anything
[[steps]]
name = "context"
role = "developer:context"
session = "fresh"
retries = 1
prompt = """
Take the user spec and try to gather most important context for beginning of development.
<spec>
{{input}}
</spec>
Produce pinpoints of files and lines to look at first place to gather right info to start for developer.
"""
# 2. Develop ⇄ review ⇄ evaluate — loop until the evaluator says ALL GOOD
[[steps]]
name = "development"
loop = true
max_iterations = 3
exit_when = { output = "evaluator", contains = "ALL GOOD" }
[[steps.run]]
name = "developer"
role = "developer:general"
session = "continue"
retries = 1
prompt = """
We are working on the following spec:
<spec>
{{input}}
</spec>
Take hints from initial harness in context:
<context>
{{context}}
</context>
Proceed with development fully autonomous.
"""
[[steps.run]]
name = "brief"
role = "developer:brief"
session = "fresh"
retries = 1
prompt = """
Look at original spec and unstaged changes to provide a brief.
<spec>
{{input}}
</spec>
Developer response:
<response>
{{developer}}
</response>
"""
[[steps.run]]
name = "evaluator"
role = "developer:general"
session = "fresh"
prompt = """
Evaluate this code generation run.
User spec provided:
<spec>
{{input}}
</spec>
Final developer response:
<developer>
{{developer}}
</developer>
Briefing:
<brief>
{{brief}}
</brief>
If anything worth to improve, change, fix or missing in spec. Provide detailed response with everything to fix.
Make sure your response is detailed and concise in that case, it will go directly to the developer with all original context.
If everything is good and there are no critical issues, respond ONLY with:
ALL GOOD
exactly as requested
"""
# 3. Brief what actually landed in the working tree
[[steps]]
name = "summary"
role = "developer:brief"
session = "fresh"
prompt = """
Look on user spec and our implementation in current unstaged git changes and provide concise briefing.
User spec request:
<spec>
{{input}}
</spec>
"""Read it as a story:
-
context— a cheap orientation pass. It reads the spec and pins down the handful of files and lines worth looking at first, so the developer doesn't start cold. -
development— the loop, up to three rounds, and the heart of the flow:-
developer(session = "continue") implements the spec autonomously, using the context hints. Because it's a continue session, round two doesn't re-send this prompt — the executor replaces it with the most recent prior step's output, which is the evaluator's verdict from the previous round. The critique literally becomes the developer's next instruction. -
brief(session = "fresh") is a reviewer with fresh eyes. It reads the original spec and the actual unstaged git changes and summarizes what was really done — grounded in the diff, not in the developer's self-report. This is the part that catches "I refactored the module" when the diff says otherwise. -
evaluator(session = "fresh") is the gate. It compares the spec, the developer's output, and the brief, and does exactly one of two things: emit a detailed, concrete fix-list — written to go straight back to the developer — or, only if nothing critical remains, reply with exactlyALL GOOD. -
exit_when = { output = "evaluator", contains = "ALL GOOD" }— the loop ends the moment the evaluator is satisfied.
-
-
summary— once the gate passes (or three rounds elapse), a final briefing comparing the spec to the implementation now sitting in your git changes.
Two design choices make this work, and both are deliberate:
The author never declares victory. The developer doesn't decide it's done — it just keeps implementing and fixing. The evaluator decides, and only after the brief has grounded the claim in the real diff. That's the structural version of the lesson from the guardrails post: don't trust the model's "done"; make something external check it.
Fresh eyes every round. brief and evaluator run as session = "fresh", so they re-judge from scratch each iteration against the unchanging spec — no anchoring on their own earlier verdicts. Only the developer carries state across rounds, because only the developer needs to remember what it built.
And the failure mode is honest. If three rounds pass without an ALL GOOD, the loop doesn't error — it hands you the best version it reached plus the evaluator's outstanding fix-list and a warning on stderr. You either get a validated artifact, or you get the exact list of what's still wrong. You never get a confident "Done" hiding an unfinished task.
echo "add a --json flag to the export command" | octomind workflow develop.tomlThe roles are split on purpose — and notice every one carries a colon. developer:context, developer:general, and developer:brief are tap agents (a domain:variant tag served by a tap), not local roles; a local [[roles]] entry never has a colon. Orientation runs on developer:context, the real implementation and the evaluator gate on developer:general, the cheap grounded summaries on developer:brief. To keep the expensive reasoning where it earns its cost, override the model per tag in [taps]:
[taps]
"developer:general" = "anthropic:claude-sonnet-4-6" # the work + the gate
"developer:context" = "openrouter:openai/gpt-4o" # quick orientation
"developer:brief" = "ollama:glm-5" # cheap summariesThat override applies only to :-tagged tap agents; a plain local role takes its model from its own [[roles]] entry instead. Need to pin a model for one step only? Set model on that step in the workflow file — it's forwarded as --model and wins outright. The full order is CLI --model > [taps] > role model > config default.
Watching it run
Progress is what the old system never gave you. A develop run prints this shape on stderr:
╭ workflow: develop
► context running...
✓ context 2.1s $0.0042 1240 tok
► development [1/3] developer running...
✓ development [1/3] developer 18.4s $0.0156 8208 tok
✓ development [1/3] brief 3.2s $0.0048 1450 tok
✓ development [1/3] evaluator 4.1s $0.0061 1890 tok
► development [2/3] developer running...
✓ development [2/3] developer 9.7s $0.0098 4870 tok
✓ development [2/3] brief 2.9s $0.0041 1280 tok
✓ development [2/3] evaluator 3.6s $0.0052 1610 tok
✓ exit condition matched at iteration 2
► summary running...
✓ summary 1.8s $0.0029 890 tok
╰ Total: 49.8s $0.0489 23438 tokPer-step cost, per-step tokens, which loop iteration you're in, when the exit condition fired, and a grand total. The stats come straight from the cost events in each subprocess's JSONL stream. If a flow runs hot, the breakdown shows you exactly which step to blame.
Validate before you spend
octomind workflow dev.toml --dry-run--dry-run validates the file, resolves the execution graph, and prints the plan to stdout — without spawning a single octomind run or even reading stdin. The pre-flight checks are strict, and all of them hard-fail before any step runs:
- File exists and is valid TOML.
- Step names are unique across the whole file.
-
inputisn't used as a step name. - Every
{{var}}referencesinputor a step that completes earlier. - A
parallelstep has ≥2 sub-steps; aloophas ≥1 sub-step plusexit_when; aconditionalhas aconditionand at least one ofon_match/on_no_match. - Every
matchesregex compiles. - Any
modelyou set is non-empty.
Run it before every change. It's free, and it catches the unresolved-variable typo that would otherwise cost you three steps of tokens before failing.
Where the Old Pipelines Went
The deterministic half of the old system — "run a script to gather context before the model" — didn't disappear. It moved to where it belongs: a [[pipe]] section in .agents/guardrails.toml.
A pipe runs a script on the raw user input before the model sees it. The script gets the message on stdin; its stdout replaces the message. It's the new home for everything [[pipelines]] used to do — detect relevant files, inject git context, validate input format, refuse a malformed request — except it lives in the policy file, runs on every message (not just the first), and isn't shackled to a role.
[[pipe]]
name = "context-enricher"
command = "./scripts/add-context.sh"
when = "first"
roles = ["developer"]The difference in philosophy is the whole point of this release: deterministic input shaping is a session concern, so it's a pipe inside the session. Multi-step agentic orchestration is an above-session concern, so it's a workflow over many sessions. The old design crammed both into role config and made each one harder to see. We pulled them apart. Pipes are covered in full in the guardrails post and reference docs.
What Workflows Deliberately Don't Do
Honest boundaries, because a tool that pretends to do everything is a tool you can't reason about. These are intentionally out of scope — use shell composition or call octomind run directly instead:
- No
--var key=valueinjection. stdin is the only input. One entry point, no hidden parameters. - No workflow definitions in
default.toml. External file only. (This is the lesson of the old system, enforced.) - No named-workflow lookup. You pass an explicit file path, not a short name.
- No cross-invocation session persistence.
continuesessions are ephemeral to oneoctomind workflowrun. - No step artifacts on disk. Outputs flow in memory between steps by name.
- No stdout / no structured output from the command itself. You watch it on stderr; you don't pipe its result onward.
If your flow needs any of those, you're describing a shell script that calls octomind workflow and octomind run — and that's the right tool for it. The workflow command stays small on purpose.
Why This Matters
The old workflow system wasn't wrong about the goal. Chaining a planner, a builder, and a tester is exactly what you want. It was wrong about the altitude. Orchestration isn't a property of a session; it's a thing that stands over sessions. Bind it to a role and you can't reuse it. Bury it in config and you can't read it. Run it inside one output stream and you can't see it.
Moving it up a level fixed all three at once. A workflow is now a file you can commit, share, dry-run, and watch — built out of the same octomind run you already trust, composed the way you'd compose any Unix tool: small pieces, named outputs, one input. The deterministic pre-processing that used to be tangled in went down a level to where it belongs, as a pipe inside the session.
Two tools, two altitudes, nothing that's both. That's the version of orchestration we wished we'd shipped the first time. It's the one that shipped in 0.30.
Try It
0.30 is out, and the cut is already made: the old in-session system is gone from the tree, and the octomind workflow command and the develop template are in. We run workflows against Octomind itself. Drop a .toml next to your code and pipe it a task. No global config, no daemon, no role wiring — just a file and stdin.



