New! Jouzu — our CLI agentic coding harness, built for Japan. Connect in 60 seconds.Learn More

Orchestrated multi-agent development

Running a team of agents on one codebase.

How Jouzu's subagent system works, a worked example run end to end, and the four things that broke on the way — including two that are not in the documentation.

The model

A main agent that orchestrates, child agents that work, and a judge that cannot write.

The main session holds the plan and the conversation. It does not write code. Instead it gets a subagent tool and uses it to launch child runs, each bound to a role: a saved definition with its own model, thinking level, tool set, instructions and limits.

Looking for the right shape rather than the mechanics? Eight orchestration patterns → catalogues the topologies — blind review panels, concern-sharded sweeps, competing drafts — with the failure each one guards against.

Three properties make this more than parallel prompting:

  • Per-role models. The hard problem gets the strongest model; formatting gets a cheap one. Cost follows difficulty.
  • A judging role. judging: true gives a child a fresh context and read-only tools. It reviews without having been involved in writing.
  • A workspace write lock. Jouzu serialises writers per working directory, so two agents cannot clobber each other — and two writers in one workspace never run at the same time, however disjoint their files. See Flow control, tasks and loops.
main plans audit-scanner qwen3.7-max · high · scan.py audit-resolver qwen3.7-max · high · resolve.py audit-reviewer judging: true · reads only write lock one at a time workspace ~/webaudit
The four roles from the worked example below. Dashed means no write tools — but the judge’s path still runs through the same lock, which is why launching it alongside a writer gains nothing.

Defining roles

Roles live in a single JSON file. Each entry:

FieldNotes
idUnique, lowercase, [a-z][a-z0-9_-]*
model, thinkingExact selector e.g. shisa/qwen3.7-max; thinking off minimal low medium high xhigh max
placementmain, child or both
judgingRequired on every role, boolean. Child-only, read tools only
toolsread grep find ls write edit bash powershell
instructionsUp to 32,000 characters
timeoutSeconds, maxTurnsDefault 7,200s and 500 turns

Plus a top-level maxConcurrent (1–8, default 2) and a queue that holds up to 32 waiting tasks.

judging: false judging: true placement: main placement: child orchestrator plans and delegates; writes nothing all 8 tools not allowed judging requires placement: child coder implements one assigned change read grep find ls write edit bash reviewer fresh context, no part in writing read grep find ls
Jouzu’s three built-in roles are exactly one per legal cell. A role may also declare placement: both, which simply passes both gates; nothing reads it at run time, and both with judging: true is rejected when the file is saved. Everything else — model, thinking, instructions, limits, what you call it — is configuration, not a different kind of role.
Jouzu listing four custom audit roles with their models, tools, turn limits and timeouts
Four roles loaded: three child workers and one read-only judge, each on a deliberately chosen model.

Five traps

Getting roles to load at all took four attempts. Three of these five are silent — they change what you get without telling you.

1. agents.json goes in configDir, not agentDir. The documentation says "Jouzu's configuration directory", and keybindings.json sits in …\Jouzu\agent\ — so that looks like the place. It is not. AgentRoleStore does join(paths.configDir, "agents.json"), and on Windows configDir is %APPDATA%\Jouzu while agentDir is %APPDATA%\Jouzu\agent. One level up. %APPDATA%\Jouzu\ ├─ agents.json └─ agent\ ├─ agents.json └─ keybindings.json roles load from here — paths.configDir silently ignored — Jouzu falls back to its built-in roles the file that makes this folder look like the right one
Windows   %APPDATA%\Jouzu\agents.json
macOS     ~/Library/Application Support/Jouzu/agents.json
Linux     ${XDG_CONFIG_HOME:-~/.config}/jouzu/agents.json
2. judging is required on every role. The validator tests typeof role.judging !== "boolean", so omitting it on a worker throws — and Jouzu then falls back to its built-in roles silently. There is no error in the UI. If you see the defaults when you expected your own, this is the first thing to check.
3. Roles are read at session start. Editing agents.json while a session is open has no effect. Restart.
4. The built-in roles may not run on your account. Jouzu's defaults — orchestrator, coder, reviewer — reference gpt-6-astra and glm-5.3-flash. Neither is in the Shisa catalogue, and resolveAgentModel throws unless a selector matches exactly one available model. On a Shisa-only account, define your own roles before delegating.
5. thinking is validated against the global list, not against your model. The role validator only checks the value is one of off minimal low medium high xhigh max. Whether the model offers that level is a separate question, answered by supportedThinkingLevels in the catalogue — and catalogThinkingMap maps an unsupported level to null rather than raising. You get no error and not the level you asked for.

The levels are not uniform across the catalogue. qwen3.7-max, -plus and -flash offer only high and off. qwen3.8-max and qwen3.8-flash offer low medium off xhigh — no high. qwen3.8-27b offers only medium and xhigh. Only kimi-k3 and glm-5.2 reach max, and nothing offers minimal.

off minimal low medium high xhigh max glm-5.2 kimi-k3 qwen3.7-flash qwen3.7-max qwen3.7-plus qwen3.8-27b qwen3.8-flash qwen3.8-max qwen38-flash-next-nvfp4 offered the model’s default resolves to null, silently

This caught the run below: audit-reviewer is configured shisa/qwen3.7-max at xhigh, which that model does not offer. The judge did not run at the level its definition claims.

Delegating

The main agent drives everything through one tool:

{"op":"roles"}                                 // check availability first
{"op":"launch","role":"coder","task":"…"}      // returns a run ID immediately
{"op":"list"}                                  // up to 20 runs, paginated
{"op":"read","id":"<run-id>","offset":0}       // page the run's output
{"op":"steer","id":"<run-id>","task":"…"}      // redirect without restarting
{"op":"stop","id":"<run-id>"}
{"op":"resume","id":"<run-id>","task":"…"}     // keeps that run's exact model
{"op":"acknowledge","batchId":"…"}             // "No reply needed"
agent_wait {"on":[…],"mode":"all","deadline":"30m"}   // or "any" to take the first

agent_wait takes 1–64 run IDs, so you can join on a chosen subset; deadline is mandatory and clamped to 8 hours. launch returns at once; completions arrive later as a batch. A child can be pointed at a different workspace than the parent. The agent cannot override a role's model — only you can, through Workflow.

launch queued a slot frees running completes reported acknowledge acknowledged steer stop resume stopped read · list at any point, paginated
resume keeps the exact model and role definition the run started with, which is why it is a transition back into running rather than a fresh launch.

Flow control, tasks and loops

Orchestration does not sit beside Jouzu’s autonomy machinery. It rides on it.

The four containers on the reference page — tasks, goals, loops, schedules — are not a separate feature from delegation. A fan-out is driven by the same layer, and three of the four are how you keep a long one coherent. These are the places they meet.

Flow control is what makes launch asynchronous

launch returns a run ID and the turn carries on. Children run on their own clock, and their completions arrive as a batch after the current reply rather than cutting into it — the same batching that delivers a background job. The orchestrator never polls. It parks on agent_wait, a join barrier, which is a flow wait source in exactly the way a job or a schedule is.

main audit-scanner audit-resolver flow control launch × 2 — returns at once, with run IDs agent_wait · mode all turn N turn N+1 queued · the workspace is taken completion batch time
Two children launched together and the orchestrator parked on one barrier rather than polling. They did not overlap: both held bash in one workspace, so the scanner was queued until the resolver finished — drawn here as it actually ran. Each result waits in the batch rather than interrupting.

Which means two things in practice. A child finishing does not wake the orchestrator mid-reply, so you never get a half-written plan built on a result that arrived during it. And when a batch lands that owes no reply, the turn ends with agent_no_reply instead of padding one out — the delegation-side equivalent being {"op":"acknowledge","batchId":"…"}.

Tasks are the plan; agents are how items get done

The punchlist and the fan-out are two views of one run, and the Session Line carries both at once — Tasks · 1 running · 2 open · 2 done beside Agents · 2 open. Because a task can wait on another job, “launch the reporter once the resolver reports” is a dependency you declare rather than a sequence you hand-drive.

The task list is also what keeps a fan-out affordable. It holds the mission, so the orchestrator’s own context stays small even while five children come and go. /goal allow-open-tasks <on|off> decides whether an objective may finish with items still open — worth setting off for an orchestration, where an open item usually means a child never reported.

Loops put a metric on the fan-out

This is the real reason to reach for /multiloop over /goal: use a goal for an objective, a loop when the work has a metric and a verify command. For the audit below that is literally the finding count:

/multiloop  …verify: python -m src ./web --json

Each iteration can re-fan-out, and the run record keeps a tally of iterations, keeps, reverts, logs, crashes and blocks. Two properties matter specifically here:

  • Each loop owns a lane with independent state, so running two orchestrations at once means naming the lane in every tool call.
  • Resume prompts are compaction-aware. A fan-out is long — the run below was about 35 minutes across five children — and a hand-driven orchestration does not survive the transcript being compacted mid-run. A loop does.

A /goal --tokens 50k cap is worth setting for the same reason: per-role models mean a fan-out spends fast and unevenly, and the cap pauses the run rather than stopping at a bad moment.

Five things that will bite you

1. The write lock makes some launch orders pointless. Writers serialise per workspace — where writer means any role holding write, edit, bash or powershell, so a role that only runs tests still counts — and a reader waits behind the writer in the same parent session — so agent_wait mode all over a writer and a judge does not buy parallel review. The judge sits blocked, holding one of your maxConcurrent slots, with its own timeoutSeconds counting down the whole time. Launch judges after the writers report, not alongside them. same wall clock ① launched together resolver judge blocked on the write lock timeout has been running since launch ② judge launched after the writer reports resolver judge slot stayed free · full timeout available
2. Do not cancel a run because the files are not there yet. The worst mistake of the run below. I checked the working tree, saw nothing, and declared the reporter had failed. run.json said cancelled and events.jsonl showed five writes and two edits inside the window — I had killed it about thirty seconds early. Read the run record, not the directory listing.
3. Flow control holds input it cannot prove was delivered. /flow reports it as “Sent, but whether the model received it is not known”, and nothing is replayed automatically. /flow resolve <attempt> retry|discard settles it — where retry may repeat a request the model already answered. With children in flight that is a live risk of a duplicate launch, so check {"op":"list"} before retrying one.
4. Branching is not symmetric, and your children have already written to disk. Returning to a branch you recently left resumes its unfinished work, including tasks waiting on jobs. Rewinding to an earlier point starts a new branch and does not resume. In neither case are file changes undone — so a rewind leaves you with the children’s output in the tree and no runs attached to it.
5. A schedule is not a join barrier. schedule_prompt registers the prompt, but what flow control waits on is the schedule’s first trigger — not the completion of the work it fires. Use it to start an orchestration overnight; use agent_wait to wait for one to finish.

A worked example

Build a broken-link auditor, where the engineering problem is precision rather than crawling.

Project staged at ~/webaudit. It audits this repo's web/ tree read-only, and writes only inside its own directory.

Why this task

A naive checker — regex every href/src, resolve against the containing folder, test existence — reports 1,063 broken references across 749 pages. Almost all are false:

CauseWhy it is not broken
Template literals${esc(item.link.href)} inside <script> is JavaScript
Slide sourcesA slide is inlined into <deck>/<deck>.html, so ../_brand/… resolves against the deck directory, not the slide's own
Stray files… - Copy.html, test-*.html

Handle those three and it falls to 67. That is the metric — and it is a precision problem, not a crawling one, which is exactly what a judging role is for. A number near 67 proves nothing on its own: you can reach it by suppressing real findings as easily as by resolving correctly.

The role split

RoleModelOwns
audit-resolverqwen3.7-max · highsrc/resolve.py — the hard problem
audit-scannerqwen3.7-max · highsrc/scan.py — parsing traps
audit-reporterqwen3.8-flash · mediumsrc/report.py, __main__.py, __init__.py
audit-reviewerqwen3.7-max · xhighnothing — judging: true
One file per role is deliberate — but not for the reason stated here originally. Disjoint file ownership stops two agents clobbering each other and makes every gap attributable to a named role. It buys no concurrency: the admission check in manager.js compares cwd, not files, so these three writers queued behind one another regardless. Concurrency needs separate workspaces — see disjoint-workspace fan-out.

The run

Two children launched together, with the parent blocked on a join barrier rather than polling. Agents · 2 open counts runs that exist, not runs that are executing:

Two subagents launched in parallel with an agent_wait barrier on both
agent_wait with mode all, two unmet conditions and a deadline. The session line shows Agents · 2 open.

Both workers completed and produced their modules and tests. Then it got interesting:

A ledger of four subagent runs with models, statuses and durations
Scanner done in ~2 min, resolver in ~6. The reporter shows ~7 min and running; it was cancelled a minute later, prematurely. The reviewer never started — blocked by the write lock.

What the run taught

The orchestration worked. The spec and the review step were where it went wrong — including twice by the author.

The read-only reviewer was blocked by the writer

On the first pass maxConcurrent was 3 and only one agent was running, yet audit-reviewer sat in queued and never started. That is the workspace lock: the reporter held write, edit and bash, and within a parent session readers wait behind the writer. A review-only role gets no exemption. Plan for it — a reviewer queued behind a slow writer looks identical to a reviewer that is broken.

Do not cancel a run because the files are not there yet

The reporter looked stalled: seven minutes, heavy token use, no output files. It was cancelled. The run record then showed status cancelled, not failed — and the files were written, between 09:12 and 09:15. The last check happened at 09:13 and the cancel at 09:16. It was killed roughly thirty seconds before it would have reported.

Read the run record, not the filesystem. run.json carries status, token usage and a result string; events.jsonl carries every tool call with a timestamp. Those showed 5 writes and 2 edits that a directory listing taken a minute earlier could not.

A fixture that passes for the wrong reason is worse than none

The first fixture claimed to test slide-root resolution with ../assets/logo.png. It did not: that path resolves to an existing file whether you use the slide's directory or the deck's. A resolver with no slide handling at all passed the fixtures and then reported 1,017 findings against the real tree instead of 67.

fixture  ../assets/logo.png
   against slide dir : EXISTS     naive answer correct by luck
   against deck dir  : MISSING
real     ../_brand/assets/shisa-logo-white.png
   against slide dir : MISSING    naive answer WRONG
   against deck dir  : EXISTS

The discriminating case has to resolve above the deck. Adding ../_brand/assets/mark.png to the fixture made it fail honestly, and re-running audit-resolver against the corrected fixture took the real tree from 1,017 to 170.

The orchestrator relaunching the resolver against the corrected fixture with a four-step plan
Second pass: the orchestrator states a four-step plan and blocks on a single agent_wait until the resolver reports.

170 is still not 67. The remainder was one missed clause: the resolver applied the deck rule to slides/ but not to slides-ja/ and slides-en/, although both the spec and the role instructions said "same for slides-<lang>/". Every one of the 170 sat in a translation directory.

The judge earned its place — and showed its limit

Launched blind, with no hint about the suspected cause, audit-reviewer returned a structured verdict against the acceptance criteria.

The reviewer's verdict: five criteria pass or cannot verify, one fail, two medium and two low findings
Claim, evidence, severity — and two criteria it declined to judge.

It found two real defects the author had missed, both verified afterwards:

  • scan.py writes scan-results.json into the audited tree (line 141), violating the spec's "never writes to the audited tree, read-only, always". The stale file it left behind in fixtures/ was the evidence.
  • Acceptance criterion 1 specifies python -m webaudit. Only python -m src works — there is no packaging config. A genuine FAIL against the spec, glossed over by everyone who had only ever run it the working way.

It also confirmed all five known traps pass and that no path-specific suppression was present — the thing it was principally hired to check.

The limitation is structural. A judging role gets read, grep, find and ls — no bash. So it cannot run the program it is judging. The two criteria that mattered most, the real-tree count and the performance budget, came back CANNOT VERIFY. That is the correct answer, and it is an argument for pairing a read-only judge with a separate role that can execute, rather than expecting one agent to do both.

Where it actually got to

Two passes, five child runs, roughly 35 minutes of wall clock:

src/scan.py       4,860 b    audit-scanner
src/resolve.py    8,873 b    audit-resolver, twice
src/report.py                audit-reporter
src/__main__.py              audit-reporter
tests/           44 tests    8 + 36
fixtures   2 findings default, 3 strict    exactly as specified
real tree  1,017 -> 170                     baseline 67

The fixtures pass precisely. The real tree does not: 170 against a baseline of 67, all of the remainder sitting in slides-<lang>/ directories the resolver still does not treat as slides. Two spec violations are open — the read-only write and the python -m webaudit entry point — both raised by the judge, neither yet fixed.

That is a partial result, reported as one. It is also the argument for the structure: every remaining gap is attributable to a named role, and the role that found the gaps was the one allowed to do nothing but look.

If you run this yourself

  1. Put agents.json in configDir, and give every role an explicit judging boolean.
  2. Verify with subagent {"op":"roles"} before delegating. If you see orchestrator/coder/reviewer, your file was rejected.
  3. Give each writer a disjoint set of files so nothing is clobbered and every gap is attributable — and a disjoint workspace if you want them to actually run at the same time.
  4. Put your strongest model on the role with the genuinely hard problem, and do not economise on the judge.
  5. Expect to steer. steer redirects a run in place; stop and resume keep the run's original model and definition.
main session child runs orchestrator judging: false qwen3.8-max medium all 8 tools worker × N judging: false qwen3.7-max high + bash judge judging: true kimi-k3 max no bash verifier judging: false qwen3.8-flash medium + bash a judging role cannot run the thing it is judging
The verifier is the role the built-in set has no equivalent for. In the run below the judge returned CANNOT VERIFY on the two acceptance criteria that needed the program executed — correct, and an argument for pairing a read-only judge with a role that can run things. Model picks are reasoned from context, thinking ceiling and family, not from benchmarks; kimi-k3 judges because it is the only model that reaches max and is a different family from the qwen workers.
Review-only is not a sandbox. Jouzu's own documentation is explicit that it is an assignment policy, not an OS security boundary. It restricts a role's built-in tools; it does not contain the process.

Provenance

This page was corrected once. Its first version said audit-reporter had failed to write its files. The run record said otherwise — the files existed, and the run was cancelled prematurely by the author. The reviewer's two findings were each verified against the source before being repeated here.

Run live on 2026-10-06 against jouzu 0.1.18 / pi 0.87.1, Windows x64, signed in to the Shisa API. Every screenshot is from that run. The four traps were each hit in sequence before the roles would load; the trap-1 and trap-2 causes were read from dist/subagents/roles.js and dist/paths.js in the installed build, not from documentation.

Project files: ~/webaudit — SPEC.md, fixtures/EXPECTED.md, agents.example.json, RUNBOOK.md.