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.

Architecture: the TUI, a web UI and Linear talk to a backend API, which writes to Postgres and sends commands through RabbitMQ to an orchestrator with a Haiku router and playbooks. The orchestrator publishes job specs; a starter spawns one worker container per job, which clones the repo from GitLab, stores its session and artifacts in S3, and reports started, heartbeat and end back through RabbitMQ.
The parts and the messages between them (click for full size). The web UI is planned; the rest runs.

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-config keeps a repo’s .mcp.json out);
  • 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

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.