Animated Octomind icon — purple pixel-art octopus

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

toml
entry           = "test"    # where execution starts
max_transitions = 6         # hard cap on total node executions
toml
[[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 arrow

That'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:

  1. Edges are tested in declaration order. First match wins.
  2. A conditional edge reads the completed node's own output — unless when.output names a different available output.
  3. Every node needs exactly one unconditional edge, and it must be last. There is no "fell through all the conditions and now what" state.
  4. $end is 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:

toml
max_transitions = 6      # counts node executions, including repeat visits
max_cost        = 2.00   # USD across the whole workflow

max_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:

text
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 $end

Two 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.

toml
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:

python
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:

bash
octomind workflow green.toml --dry-run
text
workflow: 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  default

Then run it:

bash
echo "make the duration parser tests green" | octomind workflow green.toml
text
workflow · 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 ✗1

Three 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:

python
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 only PASS. A bare contains = "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.

toml
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:

bash
echo "add a --json flag to the CLI" | octomind workflow config-templates/workflow-graph.toml

The 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:

text
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 $end

The 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:

text
workflow output '{{spec}}' is unavailable on the current route

rather 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:

  1. --dry-run every edit. The routes: block is your diagram. If it doesn't read the way you intended, you've found the bug for free.
  2. Set both bounds. max_transitions for structure, max_cost for money. A cheap infinite loop and an expensive short one are different failures.
  3. Cheap model on the routing node. The step whose output an edge matches usually just needs to classify. developer:brief, or a per-step model override.
  4. 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.
  5. 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 $end by 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:

bash
export DO_NOT_TRACK=1           # the cross-tool standard, honoured first
export OCTOMIND_TELEMETRY=0     # per-run
# or set `telemetry = false` in config.toml

We 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

bash
# Homebrew (macOS / Linux)
brew install muvon/tap/octomind

# or from crates.io
cargo install octomind

Prebuilt 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.