Claude Code headless: running claude -p in a production worker
How to run Claude Code headless with claude -p on a small VPS: OAuth token auth, systemd env, tool permissions, review runs, and real wall-clock times.
To run Claude Code headless, call it with -p (print mode). It reads your prompt, does the work, writes the result to stdout and exits, with no interactive UI. On a server, the three things to get right are auth (a long-lived token from claude setup-token), the environment (no stray ANTHROPIC_API_KEY), and tool permissions (an explicit allow list, because nobody is there to click "yes").
This post covers how I set that up for a worker on my own server: how the token reaches the process, which tools each kind of run gets, and how long runs took on that box. Examples are plain bash and ini.
What claude code headless mode actually is#
"Headless" here means non-interactive. claude -p "your prompt" runs one task and returns. You can call it from cron, a systemd service, a queue worker or a shell script. The official Claude Code docs cover the full flag list, and I'd check them for anything I don't mention here.
A minimal call looks like this:
claude -p "Summarize what this repo does in five lines" --output-format json
The --output-format json flag gives you a structured result your worker can parse instead of free text. Everything else in this post is about making that call safe to run unattended.
Headless mode is not a headless browser#
Searches like "claude code headless browser" and "claude code headless chrome" mix up two different things. claude -p has nothing to do with Chrome. If your task needs a browser with no display, that is a separate tool (for example Playwright, which can run Chromium headless), and you would let the agent call it through an allowed Bash command. The headless in claude -p only means there is no terminal UI and no human in the loop.
Claude code headless auth and login on a server#
You can't sit through an interactive browser login on a server, and you don't want a login that expires while the worker is running. My approach is a long-lived OAuth token generated by claude setup-token.
The flow:
- On a machine where you can log in interactively, run
claude setup-tokenand complete the login. - Copy the token it prints into a file on the server.
- Load that file into the worker's environment as
CLAUDE_CODE_OAUTH_TOKEN.
This also answers the "claude code headless subscription" question. The token ties the headless run to your subscription login, so claude -p uses that instead of an API key.
Keep the token out of argv and logs#
I load the token through a systemd EnvironmentFile with mode 0440. The token never goes on the command line, because argv is visible in process listings, and it never gets printed to logs.
# /etc/coremesh/claude.env (chmod 0440, readable only by root and the service group)
CLAUDE_CODE_OAUTH_TOKEN=paste-token-here
# /etc/systemd/system/worker.service
[Service]
EnvironmentFile=/etc/coremesh/claude.env
UnsetEnvironment=ANTHROPIC_API_KEY
ExecStart=/opt/worker/run.sh
See the systemd.exec documentation for how EnvironmentFile and UnsetEnvironment behave. The point is that the secret lives in one file with tight permissions and reaches the process only as an environment variable.
When you write the worker itself, don't echo the environment or dump it in an error handler. A crash report that includes env output puts the token in your logs.
Strip ANTHROPIC_API_KEY so it uses the subscription#
I strip ANTHROPIC_API_KEY from the worker environment. If an API key is present, claude -p can use it instead of the subscription login, and you get billed on the API without any visible sign. Removing the variable makes the subscription token the only credential the process can find.
Two ways to do it:
- In systemd,
UnsetEnvironment=ANTHROPIC_API_KEYas shown above. - In a shell wrapper,
env -u ANTHROPIC_API_KEY claude -p ....
I use both when a script might also be run by hand from a shell that has the key exported. It costs nothing, and the failure it prevents is quiet.
Tools and permissions for unattended runs#
In headless mode nobody can approve a permission prompt, so you decide up front what the run may do. I split runs into two kinds.
Build runs#
Build runs change files, so they get --permission-mode acceptEdits and an allow list of Read, Glob, Grep, Edit, Write and Bash:
claude -p "$PROMPT" \
--model "$BUILD_MODEL" \
--permission-mode acceptEdits \
--allowedTools "Read,Glob,Grep,Edit,Write,Bash" \
--output-format json
acceptEdits lets file edits go through without a prompt. The allow list names the tools the run may use without asking. Anything outside the list can't be approved, so the run can't do it. Bash is the broadest tool on that list, so I'd run the worker as an unprivileged user on a box where the working directory is the only thing it can damage.
Review runs#
Review runs get read-only tools only:
claude -p "$REVIEW_PROMPT" \
--model "$REVIEW_MODEL" \
--allowedTools "Read,Glob,Grep" \
--output-format json
A reviewer has no reason to edit anything. With only Read, Glob and Grep, it can look at the code and report, but it can't change what it is judging. I also use a different model for the review than the one that did the build, so the reviewer isn't checking its own work with the same blind spots.
Limits: wall time and resources on a small box#
Here is what I observed on my server:
| Run type | Observed wall time |
|---|---|
| Build | 35 seconds to 5 minutes |
| Review | 13 seconds to 1 minute |
The spread within each row is wide. The same worker will sometimes finish a build in about half a minute and sometimes take five minutes. So I don't treat any single number as normal.
What that means for your own worker:
- Set the timeout on the calling side (systemd, your queue, or
timeoutin the wrapper) with room above the slowest case you've seen. A timeout tuned to the average will kill healthy runs. - Run one job at a time per box unless you've measured otherwise. On a small server I'd rather queue work than overlap it.
- Record the duration of every run. Those numbers are the only way to know what normal looks like on your hardware.
The docs describe flags for capping a run, such as turn limits. Check the current CLI reference for exact names before relying on them. A process-level timeout works either way, because it doesn't depend on the CLI's own behavior.
What I'd do differently and what can go wrong#
The main risk is billing. Without stripping ANTHROPIC_API_KEY, the run can use that key rather than the subscription, and nothing in the output warns you. I now treat that variable as something to remove explicitly on every worker, not something to hope is absent.
The second is the token itself. A long-lived token is convenient because it doesn't expire mid-job, but it also stays valid if it leaks. That is why it sits in a 0440 file, stays out of argv and logs, and is only ever read through the service's environment. If you suspect it leaked, generate a new one with claude setup-token and replace the file.
The third is scope. If I were starting over, I'd write the read-only review profile first and the build profile second. Starting from the smallest tool set and adding tools only when a run fails for lack of one is safer than starting wide and trimming later.
Checklist for a headless Claude Code worker#
- Generate a token with
claude setup-tokenon a machine where you can log in. - Store it in an
EnvironmentFilewith mode 0440 and load it asCLAUDE_CODE_OAUTH_TOKEN. - Never put the token in argv, shell history or logs.
- Remove
ANTHROPIC_API_KEYfrom the worker environment (UnsetEnvironment=orenv -u). - Give build runs
--permission-mode acceptEditsand an explicit--allowedToolslist. - Give review runs read-only tools (Read, Glob, Grep) and a different model than the builder.
- Use
--output-format jsonso the worker can parse results. - Put a timeout on the caller, sized above your slowest observed run.
- Log the duration of every run.
- Run the worker as an unprivileged user, since Bash is on the allow list.
Quick answers
What does claude code headless mean?
It means running Claude Code non-interactively with claude -p. The command takes a prompt, does the task, prints the result and exits, so you can call it from scripts, cron or systemd.
How do I log in to Claude Code headless on a server?
Run claude setup-token on a machine where you can log in, then give the resulting token to the server as the CLAUDE_CODE_OAUTH_TOKEN environment variable. I load it from a systemd EnvironmentFile with mode 0440.
Can claude code headless use my subscription instead of an API key?
Yes, with the OAuth token from claude setup-token. Remove ANTHROPIC_API_KEY from the environment as well, so the run can't use an API key instead.
Is claude code headless the same as a headless browser or headless Chrome?
No. Headless in claude -p only means no interactive UI. If a task needs a browser without a display, use a separate tool such as Playwright and allow the agent to run it.
Next note