![]()
Every orchestration tool you've used draws the same picture: boxes, arrows, left to right. Airflow does it. Dagster does it. The letter that makes them work is the A in DAG — acyclic. No arrow may point back.
That constraint was correct for data pipelines, where a step that runs twice is a bug. It is exactly wrong for agents, where a step that runs twice is the entire point. Write code, test it, and if the test fails you do not proceed to the next box — you go back to the previous one. The loop is not an edge case in agent orchestration. The loop is the shape of the work.
This is the direction the whole category has been moving — LangGraph's entire reason to exist is supporting cycles that DAG orchestrators structurally cannot express, and every serious agent harness eventually grows a while loop somewhere. Octomind's workflows, until this release, had loops too — but as a syntax: a loop = true block with sub-steps nested inside it. That works right up to the moment your cycle needs to span two different step types, at which point you're nesting blocks inside blocks and reading your own TOML like a puzzle.
0.39.0 makes the workflow graph explicit. Steps stay flat. Control flow moves into [[edges]]. Any node can route to any node, including backwards, and a single number bounds the whole thing.
The four things you declare
entry = "test" # where execution starts
max_transitions = 6 # hard cap on total node executions[[edges]]
from = "test"
to = "$end" # reserved terminal target
when = { matches = "(?m)^PASS\\s*$" }
[[edges]]
from = "test"
to = "fix" # required default route, declared last
[[edges]]
from = "fix"
to = "test" # the backwards arrowThat's the feature. entry, [[edges]], $end, max_transitions. Declare entry or any edge and the workflow runs in graph mode; declare neither and your existing files run in declaration order exactly as before. Nothing you already have changes.
Four rules govern routing, and they are all deliberately boring:
- Edges are tested in declaration order. First match wins.
- A conditional edge reads the completed node's own output — unless
when.outputnames a different available output. - Every node needs exactly one unconditional edge, and it must be last. There is no "fell through all the conditions and now what" state.
$endis reserved. It is the only way out, and the validator makes sure at least one route reaches it.
Loop engineering: bound it twice
A cyclic graph can run forever, so loop engineering starts with the brakes. Octomind gives you two independent ones, and you should use both:
max_transitions = 6 # counts node executions, including repeat visits
max_cost = 2.00 # USD across the whole workflowmax_transitions is the structural bound — it counts every node execution, so a cycle of three nodes visited twice is six. max_cost is the economic bound, checked between steps. These are not redundant: a graph can burn budget in two expensive nodes or spin cheaply through twenty. Bounding one does not bound the other.
Here's the bound firing, for real. A one-node graph that loops to itself and never says the magic word, with max_transitions = 2:
workflow · bounded
╭ tick
╰ ✓ tick 10.1s · $0.0119 · 8426 tok · ⚒0
NOPE
· route: tick -> tick
╭ tick
╰ ✓ tick 17.4s · $0.0118 · 8422 tok · ⚒0
NOPE
· route: tick -> tick
Error: graph exceeded max_transitions (2) before reaching $endTwo executions, then a hard error. Not a warning, not a silent stop — a non-zero exit, because a workflow that ran out of transitions did not succeed and your CI should know that.
A real loop: run the tests, fix what fails, run them again
Here is the whole thing, small enough to paste. Two steps, three edges.
name = "green"
description = "Run the tests, fix what fails, run them again — until green"
entry = "test"
max_transitions = 6
max_cost = 2.00
[[steps]]
name = "test"
role = "developer:brief"
prompt = """
Run `python3 test_duration.py` in {{CWD}} and report the result.
If it printed ALL TESTS PASSED, reply with exactly:
PASS
Otherwise reply with the failing assertion and the traceback. Do not edit any file.
"""
[[steps]]
name = "fix"
role = "developer:general"
session = "continue"
prompt = """
The test run failed:
{{test}}
Fix duration.py so the tests pass. Do not modify test_duration.py.
"""
[[edges]]
from = "test"
to = "$end"
when = { matches = "(?m)^PASS\\s*$" }
[[edges]]
from = "test"
to = "fix"
[[edges]]
from = "fix"
to = "test"I pointed it at a deliberately broken duration parser — one that matched only the first <number><unit> pair, so parse_duration("1h30m") returned 3600 instead of 5400:
def parse_duration(text):
m = re.match(r"(\d+)([smhd])", text)
if not m:
raise ValueError(f"bad duration: {text!r}")
return int(m.group(1)) * UNITS[m.group(2)]Check the plan first — this costs nothing and spawns no subprocess:
octomind workflow green.toml --dry-runworkflow: green
description: Run the tests, fix what fails, run them again — until green
max_cost: $2.0000
mode: graph
entry: test
max_transitions: 6
1. test [sequential] role: developer:brief session: Fresh
2. fix [sequential] role: developer:general session: Continue
routes:
test -> $end when matches=Some("(?m)^PASS\\s*$")
test -> fix default
fix -> test defaultThen run it:
echo "make the duration parser tests green" | octomind workflow green.tomlworkflow · green
╭ test
│ ▸ shell · octofs
╰ ✓ test 29.2s · $0.0522 · 36125 tok · ⚒2 ✗1
Exit code was 1 (command failed). The failing assertion is
parse_duration("1h30m") == 5400 at test_duration.py:5 — the function
did not return 5400 for the input "1h30m".
· route: test -> fix
╭ fix
│ ▸ view · octofs
│ ▸ text_editor · octofs
│ ▸ shell · octofs
╰ ✓ fix 31.0s · $0.0881 · 61640 tok · ⚒4
Fixed parse_duration to sum all <number><unit> pairs via re.findall (was
matching only the first pair), with a whole-string check so invalid input
still raises ValueError.
• Check: python3 test_duration.py → ALL TESTS PASSED
· route: fix -> test
╭ test
╰ ✓ test 15.5s · $0.0244 · 17238 tok · ⚒1
PASS
· route: test -> $end
total · 1m16s · $0.1648 · 115003 tok · ⚒7 ✗1Three node executions out of a budget of six. Sixteen cents. The route: lines are the new part — every transition is printed as it is taken, so watching a graph run tells you which way it actually went, not which way you hoped it would.
And the fix it landed:
def parse_duration(text):
matches = re.findall(r"(\d+)([smhd])", text)
if not matches or "".join(n + u for n, u in matches) != text:
raise ValueError(f"bad duration: {text!r}")
return sum(int(n) * UNITS[u] for n, u in matches)Note the two roles. test runs on developer:brief and is told not to edit anything; fix runs on developer:general with session = "continue". The observer is cheap and powerless, the worker is capable and keeps its context across iterations. Cycles make that split matter — the same node runs repeatedly, so its cost multiplies.
The condition is a text match, so make the output matchable
The one design constraint worth internalizing: an edge condition matches against a step's text output. There is no typed state object. That is a deliberate trade — every node is an independent octomind run subprocess, and text is the only thing that reliably crosses that boundary.
It means the routing prompt is part of your control flow. "reply with exactly PASS" is not politeness, it's a wire format. Two practices follow:
- Anchor your regex.
matches = "(?m)^PASS\\s*$"matches a line that is onlyPASS. A barecontains = "PASS"also fires on "this does not PASS", and you will find that out three iterations in. - Put the sentinel on its own line and ask for it explicitly. Models are good at emitting a token on command and bad at not adding a preamble.
Everything is already a node
Graph mode did not introduce a new step type. Sequential steps, parallel blocks, loop blocks, and conditional blocks are all just nodes — so composition happens through edges rather than through nesting. A parallel block can route into a conditional; a conditional can route back into the parallel block.
The shipped template at config-templates/workflow-graph.toml does exactly that: implement once, review from two angles concurrently, judge, and cycle the fixes back through both reviewers.
entry = "implement"
max_transitions = 12
max_cost = 5.00
[[steps]]
name = "implement"
role = "developer:general"
session = "continue"
prompt = "Implement this request completely and verify the result:\n\n{{input}}"
[[steps]]
name = "reviews"
parallel = true
min_success = 2
max_parallel = 2
[[steps.run]]
name = "correctness"
role = "developer:brief"
prompt = "Review the current worktree for correctness against:\n\n{{input}}"
[[steps.run]]
name = "maintainability"
role = "developer:brief"
prompt = "Review the current worktree for maintainability and unnecessary complexity. Request:\n\n{{input}}"
[[steps]]
name = "verdict"
role = "developer:brief"
prompt = "Return PASS only if both reviews have no actionable issue. Otherwise give a concise fix list:\n\n{{reviews}}"
[[steps]]
name = "fix"
role = "developer:general"
session = "continue"
retries = 1
prompt = "Apply every valid issue from this verdict, then verify the result:\n\n{{verdict}}"
[[edges]]
from = "implement"
to = "reviews"
[[edges]]
from = "reviews"
to = "verdict"
[[edges]]
from = "verdict"
to = "$end"
when = { matches = "(?m)^PASS\\s*$" }
[[edges]]
from = "verdict"
to = "fix"
[[edges]]
from = "fix"
to = "reviews"Read the edges and the topology is right there: a diamond that fans out to two reviewers, converges on a verdict, and either terminates or loops the fix back to the top of the diamond. Try expressing that with nested blocks. The steps are flat; only the arrows are interesting.
Run it against a real request:
echo "add a --json flag to the CLI" | octomind workflow config-templates/workflow-graph.tomlThe validator refuses your bad graph before it costs anything
Cyclic graphs fail in ways linear pipelines can't, and finding out at step nine is expensive. So every structural check runs pre-flight — before a single subprocess spawns, on --dry-run and on a real run alike. All four of these are actual output:
Error: graph node 'a' must have exactly one unconditional default edge (found 0)
Error: graph has unreachable top-level steps: c
Error: graph has no reachable route to $end
Error: graph exceeded max_transitions (2) before reaching $endThe first three cost nothing — no API call is made. Only the last one is a runtime failure, and by definition it can't be caught statically.
There's one more check that only graph mode can have. In an ordered workflow, {{spec}} is guaranteed available if spec is declared earlier. In a graph, availability depends on the route taken — so if a node references an output that the chosen path hasn't produced yet, you get:
workflow output '{{spec}}' is unavailable on the current routerather than a prompt with a literal {{spec}} in it silently going to a model.
Related, and worth knowing if you use dynamic fan-out: a match parallel block must now name its source explicitly. It used to be inferred from position, which stops being meaningful the moment there is no fixed position.
Practical rules for cycles
Five things I'd tell anyone writing their first graph:
--dry-runevery edit. Theroutes:block is your diagram. If it doesn't read the way you intended, you've found the bug for free.- Set both bounds.
max_transitionsfor structure,max_costfor money. A cheap infinite loop and an expensive short one are different failures. - Cheap model on the routing node. The step whose output an edge matches usually just needs to classify.
developer:brief, or a per-stepmodeloverride. session = "continue"on the worker in a cycle. The node that keeps getting re-entered should remember its previous attempts; otherwise iteration two repeats iteration one.- The observer must not be able to mutate. If the node that decides "are we done" can also edit the code, it can route itself to
$endby fixing the symptom. Give it a restricted role.
Also in 0.39.0
/login inside a session. The RFC 8628 device-authorization flow from 0.38 is now a slash command — no exiting your session to authenticate, and the resulting key is persisted to .env for you. The account and credential logic moved into its own module along the way.
Anonymous telemetry, on by default. Which commands, tools and models get used, plus timings, token counts, and error kinds. Never your code, prompts, file paths, tool arguments, or environment values. It prints nothing at startup, and opting out is local and instant — no request is made to announce it:
export DO_NOT_TRACK=1 # the cross-tool standard, honoured first
export OCTOMIND_TELEMETRY=0 # per-run
# or set `telemetry = false` in config.tomlWe built it because the alternative is prioritizing features on vibes. If that trade isn't for you, one env var and it's gone.
Hot paths stopped recompiling regexes. Markdown rendering, system-tag stripping, and file-reference parsing were each compiling their patterns on every call; they're LazyLock statics now. The release build also moved from opt-level = "z" to "s" — z disables vectorization and often runs slower for no real size win. Plus dependency bumps across octolib, agent-client-protocol and base64, and an expanded test sweep over core that turned up several real bugs, now fixed.
Upgrading
# Homebrew (macOS / Linux)
brew install muvon/tap/octomind
# or from crates.io
cargo install octomindPrebuilt binaries for every platform are on the releases page. Graph mode is purely additive: no entry, no [[edges]], no change. Every workflow you already wrote runs in declaration order exactly as it did yesterday.
The model hub is open to everyone — octomind login, or /login from inside a session, and you're routing.
The chain was always a simplification. Real work goes: try, check, and when the check fails, go back. 0.39.0 just lets you say that out loud.
![Octomind 0.39.0 — workflows gain a graph execution mode: an entry node, ordered [[edges]] with first-match conditional routing, a reserved $end terminal target, and max_transitions as a hard bound on total node executions, so a test/fix cycle can route backwards until it goes green; every existing step type (sequential, parallel, loop, conditional) is a graph node, the validator rejects unreachable nodes, missing default edges and graphs with no route to $end before any token is spent, and the release also ships an RFC 8628 device-authorization login available as /login inside a session plus anonymous telemetry honouring DO_NOT_TRACK](/blog/octomind-0-39-0-release/index.webp)


