Claude Code and coding agents
Running Claude Code headless: the system around claude -p
In short
Treat every run as a job. Each claude -p runs in a fresh container with the repo cloned, gets a JSON schema for its result and the tools its kind of job needs, and saves its session outside the container. State lives in a database, Haiku picks the next job for under a cent (code checks the pick), and the task ends in a merge request a person reviews.
I run Claude Code with nobody at the keyboard: I give it a task and get back a merge request, which I review before it’s merged. The current setup went live on November 3, 2026. In its first week it built 21 of the 29 merge requests merged into its own repo, and 3 of 5 in the repo of the Shibui Finance MCP server.
I call the setup the factory, which is a loose use of the word: by StrongDM’s rules a software factory means code that no human writes or reviews, and mine ends in my review on purpose. It’s the second version. The first, a three-stage pipeline from April 2026, ended up unused. The code is in a private repo; the excerpts below come from it.
The claude -p call itself is short, and most of the work went into the parts around it: where a run happens, what
survives it, how a program gets its result, who decides what runs next, and where I still look at the work.
What claude -p gives you, and what’s missing
One claude -p call is one run. The process takes a prompt, runs the agent loop, returns a result and exits, and its
flags cover what that single run needs. This is how my worker starts an implementation job (simplified):
claude -p \
--session-id "$SESSION_ID" \
--model claude-opus-5-5 --effort xhigh \
--output-format stream-json --verbose --include-partial-messages \
--json-schema "$RESULT_SCHEMA" \
--permission-mode acceptEdits \
--allowedTools Bash \
--disallowedTools 'Bash(*gh pr merge*)' 'Bash(*git push*--force*)' \
--max-turns 400 \
--settings '{"disableAllHooks": true}' \
--strict-mcp-config \
< prompt.md
What claude -p doesn’t cover is anything across runs: where a run happens and what it leaves behind, what happens to
the session when its machine goes away, how a program reads the result, what runs next and who stops it, and who
checks the work. For one nightly job, cron and a shell script answer all of that (the
headless mode docs cover it). My runs chain into real changes and review
each other, and for that I needed a system around them.
One container per job
The idea is that a job’s behaviour depends on three things only: the repo at a commit, the job’s prompt and the files the prompt names. A small starter process watches the job queue and starts one container per waiting job (at most three at once on my workstation). Each container:
- makes a partial clone of the repo at the job’s ref, at the same path as on my workstation, so that I can later pull
the job’s session and open it locally with
claude -r; - runs with Claude Code’s auto memory off (
CLAUDE_CODE_DISABLE_AUTO_MEMORY=1), without the repo’s hooks (disableAllHooks; the repo’s CLAUDE.md still loads), and with only the MCP servers its kind of job gets (--strict-mcp-configkeeps a repo’s.mcp.jsonout); - runs one job and exits.
The first pipeline did the opposite: its containers mounted my ~/.claude/projects and a shared directory. That ties
a job to one machine, and to whatever earlier jobs and my own sessions left lying around. Now nothing is shared, and
anything a job should know has to be in the repo or in its prompt. The cost of that rule is that knowledge from my
Claude Code memory has to move into the repos’ documents first.
Tools and permissions per kind of job
Each kind of job is a template: a Markdown prompt with front matter that sets the job’s model, session, tools and limits. This is the implementer’s, trimmed:
model: claude-opus-5-5
effort: xhigh
session: {resume: [plan]}
tools:
permission_mode: acceptEdits
allowed_tools: [Bash]
disallowed_tools:
- 'Bash(*glab mr merge*)'
- 'Bash(*gh pr merge*)'
- 'Bash(*git push*--force*)'
- 'Bash(*git push*master*)'
limits:
timeout_s: 10800
max_turns: 400
The implementer may edit files and run commands, but it may not merge, approve or force-push. A deny list like this one only matches the commands it names, and a job with Bash and a token can find another way. This is why the jobs that shouldn’t change anything (writing the spec, planning, reviewing) run in the default permission mode with read-only tools, and get neither the GitLab nor the GitHub token.
A JSON schema for every result
Each run ends with an object that matches the JSON schema of its kind of job. With --json-schema, Claude Code adds a
StructuredOutput tool whose input is your schema. The model ends the run by calling that tool, and the final
result event carries the object as structured_output. The schema’s field descriptions reach the model as well, so
they work as instructions too.
A review job returns a list of findings, each with file, line, kind (code, test, docs or wording), summary and
failure_scenario. The fix job after it answers every finding with a disposition that code can branch on: fix, reject
with evidence, leave as a follow-up, or ask.
The wrapper fails a job in two cases. One is a run that ends without structured_output, whatever its subtype says.
The other is any subtype other than success (error_max_turns, error_max_budget_usd, error_during_execution),
and then the subtype is the reason the job failed. (The first pipeline collected results through a small MCP server
with a report_result tool; --json-schema replaced it.)
Sessions that outlive their container
The worker chooses the session ID before the run and keeps the transcript outside the container. The flags for this
are --session-id <uuid> to start a session with that ID, --resume <id> to continue it, and
--resume <id> --fork-session --session-id <uuid> to copy it under a new ID and leave the original as it was. After
each run the worker uploads the session’s .jsonl file to S3. To resume the session in a new container, it downloads
the file into ~/.claude/projects/<encoded working directory>/, and in my tests that was enough (Claude Code 2.1.316,
November 2026).
Jobs chain through sessions. In a software change, the plan, the implementation and every fix round share one session, so the implementer still knows why the plan looks the way it does. The reviewer always starts a new session, so it reads the code without that background. When I want to know why a job did something, I pull its transcript to my workstation and open it.
I pin the Claude Code version in the image and turn auto-update off, since the transcript format is internal and I wouldn’t count on a session resuming under a different version.
State in Postgres, not in the messages
Postgres holds every task, job, result, message and router decision. RabbitMQ only moves work, and S3 holds sessions and artifacts. A message carries IDs and what its receiver needs for one step; the task’s history stays in Postgres.
The first pipeline put every earlier stage’s result into each message, and I dropped that in the rebuild. A message waiting in a queue is a stale copy, so a cancel or a reply sent in the meantime doesn’t reach it. There are also two records of where a task stands, and every job gets everything earlier jobs produced, which grows with each round.
Acknowledging a job message only when the job ends causes duplicate runs. The reason is that RabbitMQ requeues an
unacknowledged message once its delivery acknowledgement timeout passes
(30 minutes by default) or the connection drops, and then a second worker starts the same job while the first is still
running. So the worker acknowledges at the start: it publishes started, waits for the broker to confirm it, then
acknowledges the job message. While the job runs, the worker sends a heartbeat every minute, and the orchestrator
keeps a lease of five heartbeats. A job whose lease runs out counts as lost, and it’s retried once if its template
allows a retry.
A cheap model picks the next job, code checks the pick
After every job result, reply or approval, the orchestrator asks Haiku what to do next:
claude -p --model haiku --tools "" --json-schema <decision>, with no session and no container. I call this the
router. Its input is the task’s spec, a compact history of its jobs and earlier decisions, the new event, and the
playbook. A playbook is a folder that describes one kind of work (its job templates, its limits and approvals, and
rules for the router in prose). The router answers with one of four actions, always with a reason: start a job (a
template and its parameters), ask me, complete, or fail. A decision costs less than a cent.
Whatever the router decides has to fit the playbook. Guards in code check every decision against it: which templates exist, each template’s tools, model and session mode, the limits per task (for a software change: 40 jobs, $100, five review rounds), my approvals, and the rule that nothing is ever merged. A decision that breaks a guard goes back to the router once, with the reason, and if the second decision breaks a guard too, the task waits for me.
Keeping the decision in the router and the checks in code means that the orchestrator’s code knows no workflow. A new
kind of work, say a data backfill, is a new playbook folder, and the orchestrator stays as it is. Every decision is
also stored with its input, so “why did it start this job?” is one row. Before a playbook change goes live,
srt replay (in the factory’s CLI) makes a task’s recorded decisions again with the changed playbook.
Where I still come in
My part is to approve the spec and the plan, and to review and merge the merge request. Everything between the plan
and the merge request runs on its own: the implementation, the repo’s checks, the push, then a review loop. In each
round of that loop, a fresh reviewer runs Claude Code’s built-in /code-review over the round’s changes, and a fix job
sorts every finding. The task also stops and asks me when a job has a question, the checks fail, a limit is reached,
or the router breaks the guards twice in a row. I keep both approvals while the factory is new. They live in the
playbook, so a variant without the plan approval is one more folder.
Tasks come from a terminal UI, a CLI, or a Linear issue delegated to the factory’s Linear app. Linear’s webhook is the only part of the factory that is reachable from the internet. It’s one path, published through a tunnel, and it accepts only signed deliveries less than a minute old; a timer asks Linear for any prompt a delivery missed.
What didn’t work
The first pipeline sat unused, and the rebuild replaced each of its parts named above: the message that carried everything, the acknowledgement at the end, the shared mounts and memory, and the results through an MCP server.
The rebuild’s first runs, from November 3 to 5, found more:
- Costs looked 2.5 times too high, and tasks reached the $100 cap early. The reason was that the cost Claude Code reports for a resumed run is the session’s running total (not the run’s own). The orchestrator now stores each job’s own cost.
- The review loop didn’t converge. In the first two tasks, 47 of 50 findings were sorted “fix”, because “fix” meant “the finding holds”. Fixing true but minor points added code in which the next fresh reviewer found new points, and each review of the same code picks different edge cases. Now a fix round fixes only what breaks something written down (the spec, the repo’s docs, a check, what the merge request says it does) and leaves the rest as follow-ups.
- The router’s input grew to 96,000 characters (the orchestrator’s cap is 100,000 characters), and the task’s text was in it twice. The orchestrator now puts it in once.
- Jobs wrote their own loops to wait for the CI pipeline. One of these loops never matched and ran for 10 minutes, so the report job now reads the pipeline once.
I also found some Claude Code behaviour only by running it. Unless noted, it’s from version 2.1.316 in November 2026, so check it against yours:
| What happened | What the wrapper does |
|---|---|
Options that take a list (--disallowedTools, --tools) swallow a prompt that follows them |
the prompt goes on stdin |
A schema the model can’t satisfy ends with success and no structured_output |
a missing result is a failed job |
The built-in /code-review runs in a forked subagent, which sees the session’s StructuredOutput tool; in one of five runs I tried, it called the tool itself and the findings never reached the main result |
the review job reads the findings from the skill’s result, with the transcript as fallback |
In jobs with an --allowedTools list, Grep and Glob were offered only when the list named them |
the read-only jobs name them |
ANTHROPIC_API_KEY, when set, takes precedence over the OAuth token |
the container sets one or the other |
CLAUDE_CODE_EFFORT_LEVEL, set in the image’s settings (env), outranks --effort |
the effort goes in both |
SIGINT ends the turn with error_during_execution and exits 0; the session can be resumed |
a cancel is a SIGINT |
A claude process outlived its parent when a container died (March 2026, 2.1.81) |
containers run with an init process (docker run --init) |
| Claude Code refused a long Bash command it couldn’t parse, with the merge request note inside it | the note goes to a file and is posted from stdin |
What an unattended change costs
In the first runs, from November 3 to 5, the cheapest software change task cost $5.64: seven jobs, whose eleven router decisions cost $0.0566 together. The others cost $13 to $41 each, counted after the cost fix above. These are Claude Code’s own figures, at API prices.
What’s next
Next I’ll move the workers from Docker Compose to Kubernetes, where KEDA starts one job per queued message. After that the factory gets its first data-heavy work, the pipeline behind Shibui Finance. There, jobs need a database and files beyond the repo, and two jobs must not write the same data at once.
All posts on claude code and coding agents
- Why my Claude Code review loop didn't converge: My Claude Code review loop fixed 47 of 50 findings and still ran to its cap. The stricter rule for what to fix, and how it did over 28 tasks.
Questions
Can Claude Code run without asking for permission?
Yes. In claude -p you set a permission mode and the tools per kind of job: acceptEdits plus Bash for a job that changes code, the default mode with a short list of allowed tools for one that only reads. I don’t use --dangerously-skip-permissions, since every kind of job gets the tools it needs named. A job with Bash and a git token can find a way around a deny list, so a job that shouldn’t push doesn’t get the token.
How do I limit what an unattended Claude Code run costs?
For a single run there are --max-turns, --max-budget-usd (an estimate that can overshoot) and a wall-clock limit in the script that starts the run. A task is many runs, so the cap per task has to be in your own code. Also watch the cost reported for a resumed session: it’s the session’s running total, so subtract what the session had cost before.
Does headless Claude Code load CLAUDE.md and memory?
The repo’s CLAUDE.md loads in claude -p, even with the repo’s hooks turned off through --settings '{"disableAllHooks": true}'. Auto memory turns off with CLAUDE_CODE_DISABLE_AUTO_MEMORY=1. I keep it off in every job, so that a job depends only on the repo, its prompt and the files the prompt names. This means any rule a job needs has to be in the repo’s documents.
Which Claude model should each job use?
In my setup Opus writes the spec, plans, implements, reviews and fixes, at high or extra-high effort. Research and follow-up questions about earlier jobs go to Sonnet, and Haiku runs the router and posts the final note on the merge request. Changing the model per kind of job is cheap, since it’s one line in the job’s template.
Do I need a queue and a database to run Claude Code unattended?
Not for one scheduled job: cron and claude -p with a result schema are enough for that. A queue and a database start to pay off when runs chain (plan, implement, review), when a run takes longer than a message broker waits for an acknowledgement, when several run at once, or when a run waits halfway for a person’s answer.
