Dev
My Claude Code workflows ran for days. Headless workers finished in minutes.
Dev.toUnited States · NORTH AMERICA
I run four projects with Claude Code. For a while, my long multi-agent jobs went through Claude Code's Workflow tool, and they kept running for days. Some never finished. Part of it was effort. Every...
I run four projects with Claude Code. For a while, my long multi-agent jobs went through Claude Code's Workflow tool, and they kept running for days. Some never finished.
Part of it was effort. Every agent in a workflow runs at the effort level of the session that started it, and a workflow script has no documented per-agent effort setting. So I kept switching model and effort by hand, trying to make the big jobs move. That became a job of its own.
The setup that worked
I split the roles.
- One interactive Claude Code session is the orchestrator. It plans the work, writes a narrow brief for each part, launches it, reads the report and decides what happens next.
-
Each part runs as a headless
claude -pworker in its own git worktree, with its own tool list, model, effort and budget. Workers run in parallel as separate processes and report back as JSON. - Nothing lands without gates and a human yes. Push and deploy stay mine.
A worker launch ends up looking like this:
claude -p --output-format stream-json --verbose \
--model claude-opus-5-5 --effort high \
--permission-mode dontAsk --permission-prompts none \
--setting-sources "" --strict-mcp-config --restricted \
--tools "Read,Edit,Write,Glob,Grep,Bash" \
--settings ./worker-settings.json \
--max-budget-usd 10 \
--session-id "$(uuidgen)" < brief.md
with a settings file that turns on the sandbox and spells out the allow and deny rules:
{
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"autoAllowBashIfSandboxed": false,
"allowUnsandboxedCommands": false,
"filesystem": { "denyWrite": ["/abs/path/to/repo/.git"] }
},
"permissions": {
"allow": ["Bash(pnpm test)", "Bash(pnpm typecheck)"],
"deny": ["Read(**/.env*)", "Read(**/*.pem)", "Read(~/.ssh/**)"]
}
}
Most of those flags are there because something went wrong without them. More on that below.
Before and after
Same work, both ways, from my own logs:
- 7 research tasks: a Workflow run had finished none of them after 73 minutes. As headless workers, all 7 finished within about 7 minutes.
- A batch that had dragged on for 16 hours without finishing: 6 workers, 45 minutes, 6 of 6 done.
This isn't a controlled benchmark. The Workflow run was stopped at the 73-minute mark, and 5 of the 7 headless workers started from digests of the stopped work. The two that didn't were still among the fastest.
It also seems to use less of my usage limits for the same work, probably because each role runs at the effort it needs instead of everything at max. I haven't measured that properly yet, and I'll post the numbers when I have them.
10 things that bit me
All of this is as of Claude Code 2.1.295.
1. --allowedTools doesn't restrict anything. It pre-approves. Tools that never ask for approval still run: with only --allowedTools set, my workers spawned Agent subagents and called Monitor and ToolSearch with zero denials. --tools sets which tools exist at all.
2. Prefix rules match the command as written. Bash(git push *) doesn't match git -C repo push. A curl deny does nothing against node -e "fetch(...)". And Bash(pnpm *) means "run any code", via pnpm exec or pnpm dlx. A denylist is a speed bump. The OS sandbox is the wall.
3. Turning on the sandbox auto-approves Bash. autoAllowBashIfSandboxed defaults to true, and an allow rule can approve an unsandboxed retry unless allowUnsandboxedCommands is false. Set both, plus failIfUnavailable, so a missing sandbox fails loudly.
4. Worktrees leave the shared .git writable. Code a worker runs inside a git worktree can write loose refs and files into the main repository's .git. sandbox.filesystem.denyWrite on that directory closes it, and git status, log and diff still work. Let your launcher commit, not the worker.
5. Workers inherit your settings unless you cut them off. Without --setting-sources "", a worker loads your user allow rules, plugin hooks, MCP servers, auto-memory and effort level. In an untrusted workspace, -p drops the project's allow rules with only a stderr warning, while all of that still loads. Cutting the sources has two side effects: effort falls to the CLI default (medium for Opus 5.5, not high), and your CLAUDE.md stops loading. Pass --effort every time, and inline your project rules into the brief.
6. Started from an editor, a worker looks like an editor session. It inherits the IDE's environment, registers as an interactive session and accepts messages from other sessions. Build the worker's environment from scratch.
7. A usage-limit stop looks like success. The result says subtype: "success", with is_error: true and exit code 1. Classify by api_error_status: 429 and terminal_reason instead. Pre-assign --session-id so you can --resume after the reset. A cold resume re-pays the context, though: one resume cost about $18 (API-equivalent) for 63 tool calls, against $9 for the first 79.
8. --output-format json can describe only the last turn. If the worker used a background task, every task notification starts a new turn, and the final JSON covers just that one. Duration and turn counts go wrong; cost stays cumulative. Use stream-json and keep every result event.
9. A stopped worker can hang forever. One worker hit the usage limit while holding a background next dev server, and its process never exited. I found it 1 hour 49 minutes later. Give each worker its own process group, a wall-clock and an idle timeout, and reap its children.
10. A smoke test has to prove a tool call. An old CLI on my PATH ran a smoke test on its default model and answered "OK" with a commit SHA it made up: 1.8 seconds, no tool call. The same CLI passed my haiku preflights, then failed every Opus run in 3 seconds. Preflight with the exact binary, model and flags your workers will use, and check that a real tool call happened.
One more: Claude Code's Bash checks refuse some command shapes no matter what your rules say, like cmd; echo $?, cd x && ... and sleep 60; .... Tell workers to use single, foreground commands.
Why I packaged it
For a while every project session wrote its own launch scripts: five sessions, five different setups for the same method, and the gotchas above kept coming back. So I turned it into a Claude Code plugin called arbab. The name means master, the one who gives the orders: your session is the arbab, and the workers do the work.
It's a skill that teaches your session the method, plus a runner with zero runtime dependencies that does the mechanics:
- role fences built from the flags and settings above;
- quota holds, and auto-resume of the same session after a usage limit;
- status per lane, measured from the stream and the transcript: turns, tool calls, refusals, effort, cost;
- integrate, verify (your gates, run inside the fence), review (lens reviewers plus skeptics for MEDIUM+ findings), a readiness report, and land. It never pushes.
I built arbab with arbab. Its first three milestones took 146 worker attempts and 5,091 tool calls, and only 1.3% of those calls were refused by the fence. My hand-made fences had refused 5.8 to 15%.
claude plugin marketplace add ozgursagiroglu/arbab
claude plugin install arbab@arbab
It needs Claude Code 2.1.290 or newer, Node 20 and git, and it's only tested on macOS so far. Keep in mind that N parallel workers use your quota about N times as fast as one.
The repo is at github.com/ozgursagiroglu/arbab. If you try it, I'd like to hear what breaks.