Run Claude Code and Codex on a Schedule: Auth, Limits, Results
Contents
To run Claude Code or Codex on a schedule, call its non-interactive mode, claude -p or codex exec, from a wrapper that supplies an API key from a secret store, grants only the permissions the task needs, and bounds the run's turns, spend, and wall-clock time. Then judge the run by its output, not its exit code. Both CLIs exit non-zero when a run fails outright, but both exit 0 when the agent finished without doing the work: Claude Code carries on after a tool call is denied, and neither checks whether anything changed. Have the agent end with a structured verdict, compare it with the working tree, and report the run to a monitor with the numbers attached.
This guide covers Claude Code 2.1.314, the current release on npm (checked October 30, 2026), and Codex CLI 0.166.0, the current release on npm (checked October 30, 2026), plus anthropics/claude-code-action v1.0.265, which installs Claude Code 2.1.314 (run.ts). Jobs that call a model API directly, and the ways they fail with an HTTP 200, are in monitoring scheduled AI agent runs; having an agent add monitoring code to a repository is AI agent cron monitoring setup.
The Two CLIs Side by Side#
| Claude Code 2.1.314 | Codex CLI 0.166.0 | |
|---|---|---|
| Run without a session | claude -p "<prompt>" |
codex exec "<prompt>" |
| Key for unattended runs | ANTHROPIC_API_KEY |
CODEX_API_KEY |
| No permission flags given | Manual mode: calls that need approval are denied | Read-only sandbox, approval policy never |
| Let it edit files | --allowedTools "Edit" or --permission-mode acceptEdits |
--sandbox workspace-write |
| Cap turns and spend | --max-turns, --max-budget-usd |
No flag for either |
| Machine-readable output | --output-format json |
--json (JSON Lines events) |
| Final answer in a schema | --json-schema '<schema>' |
--output-schema <file> |
| Failed run | Non-zero exit | Exit 1 |
Sources: Claude Code's headless mode and CLI reference; Codex's non-interactive mode guide and exec CLI definition. Each row is covered below.
Claude Code: claude -p#
Authenticate Without a Browser#
Use an API key from the Claude Console in ANTHROPIC_API_KEY. In non-interactive mode, Claude Code always uses the key when it's present, and the key outranks CLAUDE_CODE_OAUTH_TOKEN and a /login subscription; cloud provider credentials and an ANTHROPIC_AUTH_TOKEN for an LLM gateway rank above it (authentication precedence).
A subscription works too. claude setup-token runs the browser sign-in once and prints a one-year OAuth token for CLAUDE_CODE_OAUTH_TOKEN, on Pro, Max, Ultra, Team, or Enterprise plans (long-lived token). It's a weaker fit for scheduled work: --bare, the recommended mode for scripts, doesn't read it; it's tied to the subscription of whoever generated it, which is why Anthropic recommends an API key for any secret shared across repositories (set up for an organization); and advertised Pro, Max, and Ultra usage limits "assume ordinary, individual usage of Claude Code and the Agent SDK" (legal and compliance). If you use one anyway, put its renewal date on a calendar.
Start From a Known Configuration With --bare#
Without --bare, claude -p loads what an interactive session would: hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md, from the working directory and ~/.claude. It runs the hooks in the project's .claude/settings.json and connects the servers in its .mcp.json "even in a folder you've never trusted", with no trust dialog (bare mode). On a schedule, that means a commit to the repository's Claude configuration changes what next week's run does.
--bare skips all of that. Claude gets the Bash, file read, and file edit tools, and anything else is passed explicitly: --append-system-prompt-file for instructions such as your CLAUDE.md, --settings for settings, --mcp-config for MCP servers. It reads no OAuth credentials or keychain entries, so it authenticates with ANTHROPIC_API_KEY or an apiKeyHelper. It also turns background tasks off. A normal -p run can stay open after Claude's last turn waiting for background work it started, up to 10 minutes of idle waiting by default (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS), or until a background command it started exits or reaches its time limit (background tasks at exit). Anthropic calls --bare "the recommended mode for scripted and SDK calls" and says it will become the default for -p.
Two more settings keep runs repeatable:
- Pin the CLI. Native installs update themselves in the background. Install a fixed version with
curl -fsSL https://claude.ai/install.sh | bash -s 2.1.314and setDISABLE_AUTOUPDATER=1in the job's environment (install a specific version, disable auto-updates). - Pin the model. Aliases such as
sonnet"point to the recommended version for your provider and update over time" (model aliases). Pass a full model name instead, as in the CLI reference's--model claude-sonnet-5.
Permissions for a Run Nobody Watches#
Since 2.1.302, a claude -p run with no permission mode set starts in Manual mode (default). File reads in the working directories and a built-in set of read-only commands run; any call that needs approval is denied unless an allow rule, a hook, or --permission-prompt-tool approves it (non-interactive runs). Choose the mode on purpose:
| Mode | Flags | What runs without a person | Use it for |
|---|---|---|---|
dontAsk with an allowlist |
--permission-mode dontAsk --allowedTools "Read,Edit,Bash(npm test *)" |
Reads in the working directories, read-only commands, and what the list names | Most scheduled jobs |
auto |
--permission-mode auto --permission-prompts none |
Reads and edits in the working directory, plus actions a classifier model approves | Open-ended tasks you can't enumerate |
bypassPermissions |
--dangerously-skip-permissions |
Everything | Only in a container or VM, as a non-root user |
Sources: common setups, dontAsk, how the classifier evaluates actions, unattended runs.
- Allowlist syntax.
Bash(npm test *)matchesnpm testalone and any command that starts withnpm test; the space before*is part of the rule (wildcard patterns). A compound command such asnpm test && curl ...runs only if every part is allowed (compound commands). - Auto mode can fall back. When
--permission-mode autoasks for auto mode but it isn't available, because a settings file disables it, the model doesn't support it, or Anthropic has turned it off server-side, the session starts in Manual mode instead (which mode a session starts in). And after the classifier blocks an action 3 times in a row or 20 times in a session, a-prun doesn't stop: "the action doesn't run and Claude keeps working" (fallback).--permission-prompts none(2.1.259 and later) also tells Claude not to retry denied requests. bypassPermissionshas no brakes. It "offers no protection against prompt injection or unintended actions", and Claude Code refuses it when running as root or undersudoon Linux and macOS (bypassPermissions).
In every mode, a denied call doesn't end the run, so a run can finish as success after some of its calls were denied. That's why the wrapper below counts denials.
Limit What the Agent Can Reach#
Two settings narrow what Claude's shell commands can see:
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1removes credentials such asANTHROPIC_API_KEYfrom the environments of the Bash commands, hooks, and stdio MCP servers Claude Code starts, while Claude Code keeps them for its own API calls. The docs describe it as reducing "what a prompt injection attack can read". It recognizes credentials by variable name or value shape and leavesGITHUB_TOKENandGH_TOKENin place (what the scrub removes).permissions.blockReadsOutsideWorkingDirectories.cat,head,grep, and the rest of the read-only command set run without approval in every mode, including on paths outside the repository (read-only commands). Setting this totrue(2.1.257 and later) makes Claude's file tools refuse reads outside the working directories (setting) and makes recognized file-reading shell commands on those paths prompt in every mode (actions no mode auto-approves), which in a-prun with no one to answer means a denial.
Neither setting reaches a program that opens files itself. Claude Code's file rules cover its own tools and the file commands it recognizes in Bash, not "arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself" (what Read and Edit rules cover), and an allowed npm test runs the test script from package.json (npm test), a file the agent can edit.
So keep the job's secrets file outside the checkout and hand each key only to the command that uses it, as the wrapper below does, but treat that as keeping keys out of the agent's environment, not out of its reach. The agent runs as the job's account, and a command it starts can read whatever that account can, including the secrets file, and use that account's Git credentials. To close that gap, run the agent CLI as a second account that can read neither.
Turn, Spend, and Time Limits#
--max-turns Nlimits agentic turns in print mode. There's no limit by default, and the run "exits with an error when the limit is reached" (CLI flags), with the result subtypeerror_max_turns.--max-budget-usd Nstops the run once estimated spend reaches N, with the subtypeerror_max_budget_usd. The estimate is computed client-side and can differ from your bill, subagent spend counts toward it, and the response that crosses the cap still completes, so spend can pass the cap by up to that response's cost, plus whatever subagents still running at that moment spend before they stop (budget headroom).- No wall-clock flag. Wrap the command in
timeout. On SIGTERM, whichtimeoutsends by default, Claude Code exits with code 143 and records no result for the turn in progress (SIGTERM);timeoutitself exits 124 when the command times out, or 137 when the command ortimeoutreceives KILL, which is what--kill-aftersends to a command still running after TERM (GNU coreutils). Cron job timeouts covers choosing the limit.
Output and Exit Codes#
Claude Code "exits with code 0 on success and a non-zero code when the run fails". An invalid flag is reported on stderr before the run starts; a failure inside the run, "such as missing authentication", is printed as the result on stdout (basic usage).
--output-format json prints the run's result as a JSON object with session metadata. These are the fields a scheduled job reads (handle the result, SDKResultMessage):
| Field | Meaning |
|---|---|
subtype |
success, error_max_turns, error_max_budget_usd, error_during_execution, or error_max_structured_output_retries |
is_error |
true when the run errored; claude-code-action treats subtype: "success" with is_error: true as a failure |
num_turns, total_cost_usd |
Turns taken and estimated cost in USD, on every subtype |
permission_denials |
One entry per denied tool call, with the tool's name and input |
result |
The final text, on success only |
structured_output |
The final answer as JSON matching --json-schema, when the run produced one |
claude-code-action's rule, from run-claude-sdk.ts, is the one to copy: a run succeeded only if subtype is success and is_error is false, because, as its source comment puts it, a success subtype with is_error:true "means the run errored without producing a real result". For --json-schema, "if a run ends without valid structured output, for example because Claude declined the request, the response has no structured_output field", and a schema that isn't valid JSON Schema makes claude exit with Error: --json-schema is not a valid JSON Schema (structured output).
Codex CLI: codex exec#
Authenticate With CODEX_API_KEY#
codex exec reuses the CLI's saved login by default. For automation, OpenAI's guidance is to set CODEX_API_KEY "only for the Codex invocation that needs it", to make sure no untrusted code runs in the same process environment, and never to set OPENAI_API_KEY or CODEX_API_KEY as a job-level variable in a workflow that checks out or runs repository code (authenticate in automation):
CODEX_API_KEY="$CODEX_API_KEY" codex exec --json "triage open bug reports"To store a key instead, pipe it in with printenv OPENAI_API_KEY | codex login --with-api-key (authentication); codex login status exits 0 when credentials are present, which makes a cheap preflight check (developer commands). With an API key, Codex uses standard API pricing instead of ChatGPT plan credits (authentication). Running CI as a ChatGPT account is documented as an advanced path: ~/.codex/auth.json holds access tokens, has to be refreshed in place and persisted between runs, and isn't meant for public or open-source repositories (authenticate in automation).
Pin the CLI with npm install -g @openai/[email protected]; npm is one of the documented install methods (Codex CLI).
Sandbox and Approvals#
- Read-only by default.
codex execruns in a read-only sandbox unless you pass--sandbox workspace-write, which lets it edit within the workspace. Usedanger-full-access"only in a controlled environment", such as an isolated CI runner or container (permissions and safety). - No network, no commits. In
workspace-write, network access stays off unlesssandbox_workspace_write.network_accessis set (network access), and.gitis protected as read-only (protected paths). Install dependencies before Codex starts, and commit from the wrapper. - Nothing waits for approval.
execsets the approval policy toneverunless your configuration selects automatic review (lib.rs), and it rejects any approval request that still arrives, with errors such as "command execution approval is not supported in exec mode" (lib.rs). - Old and dangerous flags.
--full-autois no longer supported (permissions and safety).--approve-for-meroutes approval requests through automatic review with the workspace-write sandbox, and--dangerously-bypass-approvals-and-sandbox(alias--yolo) is "EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed" (shared_options.rs). - Ignore the host's configuration.
--ignore-user-configskips$CODEX_HOME/config.toml(auth still usesCODEX_HOME),--ignore-rulesskips user and project execpolicy.rulesfiles, and--ephemeralwrites no session files (cli.rs). With them, a run uses the flags you pass rather than whatever the host's config file says. - Git repository required. Outside one,
execprints "Not inside a trusted directory and --skip-git-repo-check was not specified." and exits 1 (lib.rs). An enabled MCP server markedrequired = truethat fails to initialize also makescodex execexit with an error instead of continuing without it (permissions and safety).
Limits#
codex exec 0.166.0 has no turn cap and no spend cap: neither its own options nor the shared ones define such a flag (cli.rs, shared_options.rs). Bound it with timeout, and watch spend through the token usage in its turn.completed event.
Output and Exit Codes#
Codex streams progress to stderr and prints only the final agent message to stdout. With --json, stdout becomes JSON Lines, one event per line (machine-readable output). In 0.166.0 the events are thread.started, turn.started, turn.completed with token usage, turn.failed with an error, item.started, item.updated, item.completed, and error (exec_events.rs). Items include agent messages, file changes, and command executions with their exit_code and a status such as failed or declined (exec_events.rs). --output-schema <file> requests a final response that conforms to a JSON Schema, and -o/--output-last-message <file> writes the final message to a file.
exec exits 1 when the turn fails or is interrupted, or when Codex reports an error it won't retry; a turn that completes exits 0 (lib.rs), whatever the agent did or didn't change.
Exit 0 Doesn't Mean the Work Happened#
| What happened | Claude Code shows | Codex CLI shows | Exit code |
|---|---|---|---|
| Hit the turn or spend cap | subtype error_max_turns or error_max_budget_usd |
No such caps | Non-zero |
| Authentication or API failure | The failure printed as the result | turn.failed with the error message |
Non-zero (Codex: 1) |
| Stopped by your time limit | No result for the interrupted turn | No turn.completed |
124 from timeout (137 after KILL) |
| Tool calls denied | Entries in permission_denials; the run keeps going |
Rejected approval requests; command_execution items with a failed or declined status |
Often 0 |
| No valid verdict, such as after a refusal | No structured_output, or error_max_structured_output_retries |
-o file missing or not the expected JSON |
0 or non-zero |
| Agent says it finished, but nothing changed | Verdict and git status disagree |
Same | 0 |
| Genuinely nothing to do | Verdict nothing_to_do, clean tree |
Same | 0 |
The exit code separates the first three rows from the rest. For the others, ask the agent for a verdict in a fixed shape, then check it against what's on disk. One schema works for both CLIs, written the way Codex's own example writes them, with every field required and additionalProperties false:
{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["done", "nothing_to_do", "blocked"] },
"summary": { "type": "string" }
},
"required": ["status", "summary"],
"additionalProperties": false
}Claude Code takes it inline, as --json-schema "$(cat result-schema.json)"; Codex takes the path. The prompt says what each status means:
Fix the ESLint warnings that `npm run lint` reports under src/. Don't change
behavior, public APIs, tests, or anything outside src/. Run `npm run lint` and
`npm test` before you finish.
End with status "done" if you changed files and both commands pass,
"nothing_to_do" if there were no warnings, or "blocked" if you couldn't finish,
plus a short summary.Then the wrapper believes the verdict only when the working tree agrees. git status --porcelain --untracked-files=all prints one line per modified, staged, or untracked file (git-status), so done with zero lines, or nothing_to_do with some, is a failure. That catches the run scheduled job silent success warns about, one where every command exited 0 and nothing useful happened.
A Wrapper for cron and systemd Timers#
There's no CronRadar SDK for shell scripts, and HTTP check-ins don't discover jobs automatically, so the wrapper reports each run itself: start, then complete with outcome values, or fail with a short message. Check-ins use HTTP Basic auth with the key as the username and an empty password, so the key stays out of URLs and the crontab.
#!/usr/bin/env bash
# /opt/agent-jobs/lint-fix.sh: run a coding agent unattended, check what it did,
# push its branch for review, and report the run to CronRadar.
set -u
umask 077
PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin
KEY=weekly-lint-fix
SCHEDULE='30 4 * * 1' # Mondays 04:30 UTC, the same time as the crontab line
GRACE=900
JOBS=/opt/agent-jobs
REPO=/srv/agent-jobs/webapp # a clone used only by this job
URL="https://cron.life/api/v1/ping/$KEY"
RUN_ID="$KEY-$(date +%s)-$$"
# ANTHROPIC_API_KEY=... and CRONRADAR_API_KEY=... lines; mode 0600, owned by the
# job's account. Sourced without exporting, so each key is in the environment of
# only the command it's passed to below.
. /etc/agent-jobs/secrets.env || exit 1
checkin() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=$SCHEDULE" --data-urlencode "gracePeriod=$GRACE" \
--data-urlencode "run_id=$RUN_ID" "$@" "$URL/$event" || true
}
fail() {
echo "$KEY: $1" >&2
checkin fail --data-urlencode "message=$1"
exit 1
}
run_agent() { # sets rc, status, and extra (this tool's outcome values)
timeout --kill-after=60 45m env \
ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 \
DISABLE_AUTOUPDATER=1 \
claude --bare -p "$(cat "$JOBS/lint-fix.md")" \
--model claude-sonnet-5 \
--output-format json \
--json-schema "$(cat "$JOBS/result-schema.json")" \
--permission-mode dontAsk \
--allowedTools "Read,Edit,Bash(npm run lint *),Bash(npm test *)" \
--settings '{"permissions":{"blockReadsOutsideWorkingDirectories":true}}' \
--max-turns 40 \
--max-budget-usd 5 \
> "$work/result.json" 2> "$work/stderr.log"
rc=$?
read -r subtype is_error status denials turns cost <<< "$(jq -r '[.subtype, .is_error,
(.structured_output.status // "none"), (.permission_denials | length), .num_turns,
((.total_cost_usd // 0) * 100 | floor)] | map(tostring) | join(" ")' \
"$work/result.json" 2> /dev/null)"
# Success means subtype "success" and is_error false, as claude-code-action checks.
if [ "$rc" -eq 0 ] && { [ "$subtype" != success ] || [ "$is_error" != false ]; }; then
rc=1
fi
extra="\"permission_denials\":${denials:-0},\"turns\":${turns:-0},\"cost_cents\":${cost:-0}"
}
checkin start
work="/var/log/agent-jobs/$RUN_ID" # this run's output, kept for debugging
mkdir -p "$work" || fail "cannot create $work"
cd "$REPO" || fail "cannot enter $REPO"
{ git fetch --quiet origin && git checkout --quiet --force --detach origin/main &&
git clean -fdq; } || fail "could not reset the checkout to origin/main"
npm ci --silent || fail "npm ci failed" # dependencies come before the agent starts
run_agent
case $rc in
0) ;;
124) fail "agent stopped at the 45-minute limit" ;;
137) fail "agent killed with KILL, by the 45-minute limit or another process" ;;
*) fail "agent run failed with exit code $rc" ;;
esac
case $status in
done | nothing_to_do | blocked) ;;
*) fail "agent returned no valid verdict" ;;
esac
files_changed=$(($(git status --porcelain --untracked-files=all | wc -l)))
case "$status:$((files_changed > 0))" in
done:0) fail "agent reported done but changed no files" ;;
nothing_to_do:1) fail "agent reported nothing to do but changed files" ;;
esac
if [ "$status" = done ]; then
branch="agent/$KEY-$(date -u +%Y%m%d)"
{ git switch --quiet --create "$branch" && git add --all; } ||
fail "could not stage the changes on $branch"
# Refuse to push changes that contain one of this job's keys as plain text.
for secret in "${ANTHROPIC_API_KEY:-}" "${CODEX_API_KEY:-}" "$CRONRADAR_API_KEY"; do
if [ -n "$secret" ] && git diff --cached --text | grep -qF -- "$secret"; then
fail "the agent's changes contain an API key; nothing was pushed"
fi
done
{ git commit --quiet --message "Fix lint warnings (scheduled agent run)" &&
git push --quiet origin "$branch"; } || fail "could not push $branch"
fi
blocked=false
[ "$status" != blocked ] || blocked=true
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --user "$CRONRADAR_API_KEY:" --header "Content-Type: application/json" \
--data "{\"outcomes\":{\"files_changed\":$files_changed,\"blocked\":$blocked,$extra}}" \
"$URL/complete?run_id=$RUN_ID" || trueFor Codex, keep everything else, put CODEX_API_KEY=... in the secrets file, and replace run_agent:
run_agent() { # Codex CLI 0.166.0: same contract as the Claude Code version
timeout --kill-after=60 45m env CODEX_API_KEY="$CODEX_API_KEY" \
codex exec --json --ephemeral --ignore-user-config --ignore-rules \
--sandbox workspace-write \
--output-schema "$JOBS/result-schema.json" \
--output-last-message "$work/final.json" \
"$(cat "$JOBS/lint-fix.md")" \
> "$work/events.jsonl" 2> "$work/stderr.log"
rc=$?
status=$(jq -r '.status' "$work/final.json" 2> /dev/null) || status=none
tokens=$(jq -s '[.[] | select(.type == "turn.completed")] | last | .usage.output_tokens // 0' \
"$work/events.jsonl" 2> /dev/null) || tokens=0
extra="\"output_tokens\":$tokens"
}Schedule it from the job account's crontab, on a host whose clock is UTC (cron time zones covers the alternatives):
30 4 * * 1 /opt/agent-jobs/lint-fix.sh >> /var/log/agent-jobs/lint-fix.log 2>&1Or with a systemd timer. OnCalendar= accepts the literal zone UTC (systemd.time(7)), and because a Type=oneshot service has no start timeout by default (systemd.service(5)), TimeoutStartSec= is a backstop above the wrapper's own limit. systemd timers covers the rest of the options:
# /etc/systemd/system/weekly-lint-fix.service
[Service]
Type=oneshot
User=agentjobs
ExecStart=/opt/agent-jobs/lint-fix.sh
TimeoutStartSec=1h
# /etc/systemd/system/weekly-lint-fix.timer
[Timer]
OnCalendar=Mon *-*-* 04:30:00 UTC
[Install]
WantedBy=timers.targetWhat the wrapper does, and why:
startcomes first, so a broken checkout or a failednpm cireportsfailinstead of looking like a missed run. A missing secrets file exits before any check-in, and CronRadar alerts on the run that never arrived:warningonce it's late,criticalonce the grace period passes.- The check-ins work as in GitHub Actions scheduled workflows: they can't break the job, the
startcarries the schedule so the first run creates the monitor, and a run that dies afterstartis flagged as stuck only once the next scheduled time plus grace passes, which for a weekly job is next Monday. CronRadar evaluates the schedule as five-field cron in UTC. Thetimeoutturns a hung agent into afailat 45 minutes, which opens an incident immediately. - Keys stay out of the agent's environment, not out of its reach.
ANTHROPIC_API_KEYis passed only toclaude, andCRONRADAR_API_KEYonly to the check-in requests, but the agent runs as the job's account, which can read the secrets file; the second account described above closes that. - The verdict has to match the tree. A run that claims work it didn't do, or does work it didn't report, fails; a
blockedrun completes withblocked: true, which the outcome checks below turn into an incident. - Only the wrapper pushes. The Claude Code allowlist names no
gitcommand, and Codex'sworkspace-writesandbox keeps.gitread-only and the network off. The wrapper pushes only adonerun, to a new branch, for a person to review, and refuses when the staged changes contain one of the job's keys as plain text. That check misses an encoded key, and the push credentials belong to the account the agent runs as, so the second account is the real boundary. For schedules frequent enough to overlap, add a lock as in preventing overlapping cron jobs.
A Scheduled GitHub Actions Workflow#
With a prompt input, anthropics/claude-code-action runs in automation mode on any event, including schedule. Claude has no shell or GitHub API access until you grant tools, and results appear in the workflow run log (automation mode, run on a schedule). Three details matter for scheduled runs:
- The bot check applies. The action rejects a bot actor unless it's listed in
allowed_bots, and GitHub attributes a scheduled run to a repository user, "usually the one who last changed the workflow'scronschedule". If a bot made that change, scheduled runs fail (who can trigger runs). - Claude inherits the step's environment. The action builds Claude's environment from a copy of the whole process environment, minus the OIDC token request variables and the serialized action inputs (parse-sdk-options.ts). Give
CRONRADAR_API_KEYto the report steps only, never at job level. - The outputs say what happened.
execution_fileis the path of a JSON array of every message from the run, including the final result (action.yml, execution-file.ts).conclusionissuccesswhen the run succeeds; on a failed run the action fails the step without setting it, althoughaction.ymldescribes it assuccessorfailure(run.ts), so test forsuccess, as the report step below does. With--json-schemainclaude_args, a run that returns no structured output fails the step, and by default the log shows only a sanitized summary of the result (run-claude-sdk.ts); theshow_full_outputinput warns that full output "may contain secrets" (action.yml).
To avoid storing an Anthropic key at all, the action can exchange the workflow's GitHub OIDC token through workload identity federation, with the anthropic_federation_rule_id and anthropic_organization_id inputs and id-token: write (set up for an organization). This workflow uses a repository secret:
# .github/workflows/daily-report.yml
name: Daily report
on:
schedule:
- cron: '17 9 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
jobs:
report:
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
issues: read
id-token: write
defaults:
run:
shell: bash
env:
PING_URL: https://cron.life/api/v1/ping/daily-repo-report
MONITOR_SCHEDULE: '17 9 * * *' # keep in step with on.schedule
GRACE_SECONDS: '3600'
steps:
- name: Report start to CronRadar
timeout-minutes: 1
env:
CRONRADAR_API_KEY: ${{ secrets.CRONRADAR_API_KEY }}
run: |
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=$MONITOR_SCHEDULE" \
--data-urlencode "gracePeriod=$GRACE_SECONDS" \
--data-urlencode "run_id=$GITHUB_RUN_ID" \
"$PING_URL/start" || true
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@6d5ae526d25ce88daa2200be073e1a373167f064 # v1.0.265
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
Summarize yesterday's commits and the issues opened yesterday.
End with status "done" and the summary, "nothing_to_do" if there
were no commits or new issues, or "blocked" if you couldn't read them.
claude_args: |
--model claude-sonnet-5
--allowedTools "mcp__github__list_commits,mcp__github__list_issues"
--max-turns 20
--max-budget-usd 2
--json-schema '{"type":"object","properties":{"status":{"type":"string","enum":["done","nothing_to_do","blocked"]},"summary":{"type":"string"}},"required":["status","summary"],"additionalProperties":false}'
- name: Report result to CronRadar
if: always()
timeout-minutes: 1
env:
CRONRADAR_API_KEY: ${{ secrets.CRONRADAR_API_KEY }}
CONCLUSION: ${{ steps.claude.outputs.conclusion }}
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
run: |
body=
if [ "$CONCLUSION" = success ]; then
body=$(jq -c '[.[] | select(.type == "result")] | last | {outcomes: {
blocked: (.structured_output.status == "blocked"),
permission_denials: (.permission_denials | length),
turns: .num_turns,
cost_cents: ((.total_cost_usd // 0) * 100 | floor)}}' "$EXECUTION_FILE") || body=
fi
if [ -n "$body" ]; then
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --user "$CRONRADAR_API_KEY:" \
--header "Content-Type: application/json" --data "$body" \
"$PING_URL/complete?run_id=$GITHUB_RUN_ID" || true
else
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "run_id=$GITHUB_RUN_ID" \
--data-urlencode "message=Claude Code run did not succeed: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
"$PING_URL/fail" || true
fiThe allowed tools and the id-token permission follow Anthropic's own scheduled example, which reads commits and issues through two GitHub MCP tools, so it needs no checkout (run on a schedule). The action pin is the v1.0.265 commit, which also fixes the Claude Code version. jq is preinstalled on the ubuntu-24.04 image (runner image). The report step runs after a failure, a cancellation, or the job's timeout, and anything but a successful run reports fail. GitHub's own schedule can start runs late or drop them; GitHub Actions scheduled workflows covers that and why the grace here is an hour.
For Codex, OpenAI recommends openai/codex-action@v1 over installing and authenticating the CLI yourself (non-interactive mode). It starts a Responses API proxy for the key, runs codex exec, removes sudo before running Codex by default (safety-strategy: drop-sudo), takes extra flags such as --output-schema through codex-args, and returns the last message in its final-message output (Codex GitHub Action). The same start and result steps fit around it, with the verdict read from final-message instead of the execution file.
Outcome Checks That Catch Quiet Runs#
Set expectations on the reported values under the monitor's Outcome checks, and a complete that misses one marks the run outcome-failed and opens an incident; scheduled job silent success covers the rules for expectations and the complete body. For the two jobs above:
| Outcome check | Job | Catches |
|---|---|---|
blocked is false |
Every job | The agent couldn't finish and said so |
permission_denials = 0 |
Claude Code jobs | Calls the run's permissions didn't allow, including auto mode falling back or blocking |
turns <= 30 |
Weekly lint fix | A task taking far more steps than usual, before --max-turns 40 stops it |
cost_cents <= 300 |
Weekly lint fix | Spend creeping toward the --max-budget-usd 5 cap |
files_changed <= 40 |
Weekly lint fix | An agent that touched far more files than a lint fix should |
For the daily report, use turns <= 15 and cost_cents <= 100 under its caps of 20 turns and $2; for the Codex variant, put a ceiling on output_tokens taken from your own runs. Take thresholds from a few weeks of history rather than guessing (choosing thresholds).
CronRadar rejects a body it can't accept, such as a sixth value or a string, with a 400 and records no completion, and since the next start still arrives on schedule, that can go unnoticed. Send one completion by hand with curl --fail-with-body before relying on it. CronRadar also rejects a fail message longer than 1,000 characters with a 400 and records no failure. Keep prompts, summaries, and diffs out of check-ins, as monitoring scheduled AI agent runs explains. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.
Frequently Asked Questions#
How do I run Claude Code from a cron job?#
Call claude --bare -p from a wrapper script that loads ANTHROPIC_API_KEY from a file readable only by the job's account, sets --permission-mode dontAsk with an --allowedTools list, caps the run with --max-turns and --max-budget-usd, and wraps it in timeout. Use --output-format json and check that subtype is success and is_error is false, not just the exit code.
How does Claude Code authenticate without a browser?#
With an API key in ANTHROPIC_API_KEY, which non-interactive runs always use when it's set. For a subscription, claude setup-token prints a one-year OAuth token for CLAUDE_CODE_OAUTH_TOKEN, but --bare doesn't read it and it's tied to one person's subscription, so an API key suits shared, scheduled jobs better.
How do I run Codex CLI non-interactively?#
Use codex exec "<prompt>" inside a Git repository, with CODEX_API_KEY set only for that command. It runs read-only unless you pass --sandbox workspace-write, never waits for approvals, and exits 1 when its turn fails. Add --json for an event stream and --output-schema with -o for a final answer your script can parse.
How do I stop a scheduled agent run from running too long or costing too much?#
Claude Code has --max-turns and --max-budget-usd; the budget is a client-side estimate that one response, plus any subagents still running, can overshoot, so leave headroom. Codex 0.166.0 has neither flag. For both, timeout bounds wall-clock time and exits 124 when it stops the agent, or 137 if it had to send KILL, and outcome checks on turns or cost alert well before the hard stop.
Why did my scheduled agent run exit 0 but change nothing?#
The agent finished its turn without doing the work: its tool calls were denied, auto mode fell back or kept blocking, it decided there was nothing to do, or it described changes it never made. Have it return a verdict in a JSON schema, compare that with git status, and count permission_denials on Claude Code.
How do I get alerted when a scheduled agent run fails or does nothing?#
Send a start check-in before the agent runs and complete or fail after, so a missed, failed, or hung run alerts. Attach counts such as files changed, denials, turns, and cost, plus a blocked flag, to the completion, and configure outcome checks on them, so a run that exits 0 without doing its job still opens an incident.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.