Running Claude Code headless on a schedule on a server
A systemd timer running claude -p with nobody logged in: lingering, an exact allow list, a sign-in that lasts, the plan's limits, timeouts and hearing back.
To run Claude Code on a schedule on a Linux server, a timer starts claude -p, the headless mode, and five things decide whether it works with nobody there. The timer has to run with you logged out: a systemd user timer needs lingering on (loginctl enable-linger), and both it and cron need the full path to claude. The run needs permissions that never wait for an answer: --permission-mode dontAsk with an exact --allowedTools list, so anything else is refused. It needs a sign-in that lasts, such as the one-year token from claude setup-token; on a plan it draws on the same five-hour and weekly limits as your own sessions, and when they run out it ends at once rather than waiting. It needs bounds: --max-turns, --max-budget-usd and an outside timeout, since there is no flag for time. And it needs a way to tell you what happened, including that it never ran.
The shape of a scheduled run
Claude Code’s scheduling options compares the ways to put Claude Code on a schedule; this page is the last of them, a timer on a machine that stays on, which runs in your real project with your own tools as often as every minute. A systemd user timer is the better fit than cron for most jobs: it takes a time zone of its own, catches up a run the machine was off for, and never starts a second run while the first is still going. Three small files make one: a script, a service that runs it, and a timer that starts the service.
The script, at ~/bin/daily-report.sh and made executable:
#!/bin/bash
set -euo pipefail
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin"
export CLAUDE_CODE_OAUTH_TOKEN="$(cat "$HOME/.config/claude/token")"
cd "$HOME/project"
timeout -s INT -k 2m 90m claude -p "$(cat prompts/daily-report.md)" \
--permission-mode dontAsk \
--permission-prompts none \
--allowedTools "Edit(reports/**)" "Write(reports/**)" "Bash(npm test *)" \
--max-turns 60 \
--max-budget-usd 10 \
--output-format json > "$HOME/runs/report-$(date +%F).json"The service, at ~/.config/systemd/user/daily-report.service:
[Unit]
Description=Daily report by Claude Code
[Service]
Type=oneshot
ExecStart=%h/bin/daily-report.shAnd the timer beside it, daily-report.timer:
[Unit]
Description=Daily report, weekdays at 07:30 London time
[Timer]
OnCalendar=Mon..Fri 07:30 Europe/London
Persistent=true
[Install]
WantedBy=timers.targetsystemctl --user daemon-reload
systemctl --user enable --now daily-report.timer
systemctl --user list-timers # when it runs next
systemd-analyze calendar "Mon..Fri 07:30 Europe/London" --iterations=3
systemctl --user start daily-report.service # one run now, the timer's wayEach line of the script is one of the sections below. The calendar time carries its own zone, so a London morning stays a London morning on a server set to another zone and follows London’s summer time. Persistent=true makes the timer run once on start-up if the machine was off at the scheduled minute. And starting the service by hand runs it exactly as the timer will, which is the test to make before the first scheduled run.
Running with nobody logged in
A user timer lives in your account’s own systemd instance, and that instance normally stops about ten seconds after your last session ends: close your SSH connection and the timer goes with it. Lingering keeps it. The loginctl manual: with lingering on, “a user manager is spawned for the user at boot and kept around after logouts. This allows users who are not logged in to run long-running services.”
loginctl enable-linger # for your own account
loginctl show-user "$USER" --property=Linger # Linger=yesThe setting is stored on disk, so it survives a restart. cron needs no lingering, since its daemon runs whether anyone is logged in or not, but it has two gaps a user timer does not: Ubuntu’s and Debian’s cron has no time zone per job, and it does not run a job the machine was off for.
The path. Claude Code’s installer puts claude in ~/.local/bin. A unit is not a login shell and does not read ~/.profile, and the service manual says a command outside the standard folders needs “an absolute path”. Finding claude is half of it: the run then starts commands of its own, so the tools it needs (npm, a version manager’s Node, anything else in your home) have to be on the PATH it inherits too. Setting PATH in the script, as above, covers both. cron’s PATH lines don’t expand $HOME or ~, so there too the script is the simpler place for it.
Reading what happened. systemctl --user status daily-report.service shows the last run and its most recent log lines, and journalctl --user -u daily-report.service the rest. On Ubuntu and Debian the journal keeps per-user logs only if /var/log/journal exists; if journalctl --user shows nothing, that is why.
Permissions for a run nobody can answer
An interactive session stops and asks before a command it has no rule for. A scheduled run has nobody to ask, and since Claude Code 2.1.302 a -p run that sets no mode starts in Manual mode, where, per the permission modes page, “calls needing approval are denied unless you approve them with an allow rule, hook, canUseTool callback, or --permission-prompt-tool.” So the question is which of three setups to give it:
| Setup | What runs | What it costs you |
|---|---|---|
--permission-mode dontAsk with --allowedTools | Reads in the working folder, read-only commands, and what the list allows; anything else is refused and the run carries on | Listing every command the job needs, exactly |
--permission-mode auto | Whatever a classifier judges safe; a blocked action is skipped and the run carries on | Anthropic: auto mode “reduces permission prompts but does not guarantee safety” |
--dangerously-skip-permissions | Everything, without checks | “No protection against prompt injection or unintended actions”; Anthropic says isolated containers and VMs only, and it refuses to start as root |
Add --permission-prompts none to whichever you choose (2.1.259 and later): it removes the tools that need a person, such as Claude asking you a question, and tells Claude that nobody can approve a refused request, so it doesn’t retry it. Running Claude Code and Codex without permission prompts compares the modes and the sandbox in depth.
An allow list takes some care, because a near-miss is refused with nobody to notice:
- Prefixes end in a space and a star.
Bash(npm test *)allowsnpm testwith any arguments;Bash(git diff*), with no space, would also allowgit diff-index. - A chained command is checked part by part. “A rule must match each subcommand independently”, across
&&,||,;, pipes and newlines, per the permissions page. So a joined line is refused whole when any part of it is not covered, and the run tries again another way, which costs a turn or two each time. - Read-only commands need no rule.
ls,cat,grep,find,headand the read-only forms ofgitrun in every mode. To keep a run out of them, deny them explicitly. - A path from the root starts with two slashes.
Write(//srv/reports/**)is the absolute path;Write(/reports/**)is read relative to where the rule was set, not from the root. - A broken settings file is dropped silently. In
-p, “settings files that fail validation are silently ignored”, so a typo in the file that holds a run’s rules leaves it with fewer permissions and no error. - The project folder is trusted without asking. A
-prun shows no trust dialog and runs a project’s hooks and MCP servers “even in a folder you’ve never trusted”. Point a schedule only at a folder whose.claude/and.mcp.jsonyou know.
How much to allow is your call, and three things about a job decide how much is at stake. Whether its actions can be undone: an edit on a branch can be thrown away, while a sent message, a deploy, a deleted file or a payment cannot. Whether it reads what strangers wrote: an inbox, issues, web pages or documents someone else supplied can carry instructions the agent may act on, which is the case Anthropic’s warning about skipping permissions names. What it can spend: tokens, and whatever the accounts its tools reach can buy or send. The controls match them: allow only the commands the job needs and deny what it must never run, keep its changes on a branch you merge, keep credentials it doesn’t need off the machine (securing a server that runs Claude Code covers the keys an agent holds), and bound its spend (below).
A sign-in that lasts
A scheduled run uses whatever sign-in the account it runs as has, and there are three, from Anthropic’s authentication page:
- The ordinary
/login. Kept in~/.claude/.credentials.json. Anthropic publishes no lifetime for it; Claude Code warns three days before it expires, at the start of a session, where nobody sees it when the session is a scheduled run. When it lapses, a-prun fails withFailed to authenticate: OAuth session expired and could not be refreshed. - A token from
claude setup-token. A one-year token for “CI pipelines, scripts, or other environments where interactive browser login isn’t available,” set asCLAUDE_CODE_OAUTH_TOKEN. It uses your Pro, Max, Team or Enterprise plan, can only make model requests, and is not read in--baremode. The setup-token guide has the details. - An API key in
ANTHROPIC_API_KEY, billed at API rates. In-pmode “the key is always used when present”, so a key left in the environment bills the API even when you meant the run to use your plan.
Keep a token or key out of the unit file: systemd’s manual says variables set for a unit “are exposed to unprivileged clients via D-Bus IPC”. A file only your account can read (chmod 600), read by the script, keeps it to you. claude auth status exits 1 when you are not signed in, which makes a cheap check at the top of a script.
Which allowance it uses. A run signed in with your plan, through /login or the token, draws on the plan’s five-hour and weekly limits, which are “shared across Claude and Claude Code”: your chats, your interactive sessions and your scheduled runs spend the same allowance. Max and Team plans also come with monthly API credits ($100 on Max 5x, $200 on Max 20x, $20 or $100 a Team seat, pooled and capped at $500 a team), but they cover claude -p only when run with an API key from the linked Console organization; signed in with your plan, claude -p “still draw[s] from your plan’s usage limits,” per the credits article.
When the plan’s limit runs out
In an interactive session Claude Code waits out a usage limit and carries on by itself after the reset. That is an interactive feature. As of October 2026, a -p run that meets the limit ends within seconds with an error such as You've hit your session limit · resets 11:20pm, and a run stopped mid-task does not pick up after the reset: it is simply over. The weekly limit stops a run the same way, and its reset can be days off.
Since the allowance is shared, a scheduled run competes with your own work. A run at 2 a.m. finds the allowance your evening left it, and a heavy day can take the weekly limit with it. What helps: schedule runs at hours you don’t usually work, check the run’s exit and its last line for the limit message and try again after the reset it names, or give the scheduled job its own API key so it never touches your plan.
Bounding a run
- Turns.
--max-turnscounts tool-use turns and ends the run with an error when it reaches the number. There is no limit by default. It doesn’t appear inclaude --help, but it is in the CLI reference, which notes that help “does not list every flag”. - Spend.
--max-budget-usdstops the run when Claude Code’s own estimate of the API cost reaches the figure. It is an estimate and can pass the cap by up to one response, plus what subagents still running spend before they stop, so leave room. On a plan nothing is charged in dollars, but the estimate still bounds how much of your allowance one run can use. - Time. There is no flag for it, so the bound comes from outside.
timeout 90min the script does it, orTimeoutStartSec=90minin the service. In aType=oneshotservice,RuntimeMaxSec=has no effect, and the manual says oneshot services have no start timeout by default, so without one a stuck run holds the service for good.
The signal matters. Both timeout and systemd send SIGTERM unless told otherwise, and Anthropic’s headless page says that on SIGTERM Claude Code exits with code 143, “leaves the turn that was in progress unfinished and records no result for it. To end the turn instead, send SIGINT.” That is why the script uses timeout -s INT -k 2m: SIGINT at 90 minutes, and a kill two minutes later if the run is still there. A oneshot service killed by its own timeout counts as failed, which matters for the next sections.
Long commands inside the run
The tools reference gives a command Claude runs two minutes by default and ten at most. When it hits that without finishing, Claude Code moves it to the background instead of stopping it. In a run that nobody watches, background commands have a time limit of their own, and in a -p run with a text prompt it is ten minutes, unless Claude gives the command a longer one, up to two hours. A command over the limit is stopped with the notice Background command "…" was stopped after reaching its background time limit, and Claude can start it again with a longer timeout.
That catches out real jobs: a test suite, a build, a paced send or a crawl that takes twenty minutes is stopped at ten unless the prompt tells Claude to give it a timeout. Two settings change the defaults: BASH_DEFAULT_TIMEOUT_MS above 600000 replaces the ten-minute limit, and BASH_MAX_TIMEOUT_MS above 7200000 raises the two-hour ceiling. A command stopped partway can leave its own state behind, such as a lock file, so a script the run calls should cope with being killed.
After its final reply the run stays open for a background command still running and takes one more turn with its outcome. Other background work, such as subagents, it waits for up to ten minutes (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS changes that).
Hearing from it, including when it didn’t run
Claude Code exits 0 on success and non-zero when the run fails, and --output-format json adds the result text, the number of turns, the estimated cost and a subtype that says how it ended (success, error_max_turns, error_max_budget_usd, error_during_execution). A run that stops can report itself. The failure that can’t is the run that never started: a timer that wasn’t enabled, an account’s systemd that stopped, a machine that was off. Nothing runs, so nothing says so.
Three ways to catch both, from simplest:
- A message from every run, success or not. Then silence is itself the signal: the day no message arrives is the day to look.
OnFailure=in the service’s[Unit]section starts another unit when the run fails, a timeout included, and that unit can send the alert. It does not cover a run that never started.- A heartbeat check outside the machine, a service that expects a ping every day and alerts you when one doesn’t come. It catches the machine itself being down.
For the message itself, sending mail straight from a cloud server mostly fails: providers block port 25, and some block 465 and 587 too. Claude Code notifications on your phone has the ports eight providers block and how each lifts the block, plus the routes that avoid mail ports altogether: an email service’s HTTPS API, a push service such as ntfy, a Slack or Discord webhook. Prove whichever you choose from the server itself before the first scheduled run, not from your laptop, where the ports are open.
Ideas that make a schedule easier to live with
None of these is required, and other setups work as well. Each is one way of doing it, with the reason behind it:
- One message per run, with the outcome in the subject. A glance at the inbox says whether the runs are happening and which need reading, and a missing message is the sign the scheduler stopped.
- Let the agent write the message. Ask it to end with what it would tell you in a chat: what it did, what needs your answer, what went wrong. The script adds a fixed message of its own only for the case where Claude never started.
- A dated record per run rather than one log appended to forever. A run that reads its predecessors’ records reads less, and one run’s mistake is less likely to become every later run’s habit.
- A branch per run, merged only after a check, so a run that does something wrong leaves it on a branch rather than in your main line.
- The first runs watched. Start the service by hand, read what it did, widen or narrow the allow list, and only then leave the timer to it.
Codex on a schedule
Codex’s headless command is codex exec, which OpenAI lists for “CI, pre-merge checks, scheduled jobs.” It never stops to ask: in exec a request that would need approval is refused, or with --approve-for-me sent to an automatic reviewer, so the sandbox is what bounds it. It starts read-only; --sandbox workspace-write lets it edit the project, with network access still off unless your configuration turns it on.
timeout -s INT -k 2m 90m codex exec --sandbox workspace-write \
-o "$HOME/runs/codex-$(date +%F).md" "$(cat prompts/daily-report.md)"-owrites Codex’s final message to a file, and--jsonturns its progress into JSON Lines on stdout. A failed or interrupted turn exits 1.- It wants a git repository;
--skip-git-repo-checkruns it elsewhere, and--ephemeralkeeps no session files. - For sign-in, OpenAI calls an API key in
CODEX_API_KEY“the right default for automation”. A ChatGPT sign-in works too: Codex refreshes it when it is more than about eight days old, and OpenAI says not to share one~/.codex/auth.jsonacross machines or jobs running at once. - It has no flag for turns, spend or time, so the outside timeout is the only bound.
Codex CLI on a Linux server covers the install and signing in without a browser.