# Run Claude Code and Codex on a Schedule: Auth, Limits, Results

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/scheduled-coding-agent-runs

> Run claude -p and codex exec from cron, systemd, or GitHub Actions: headless auth, permissions, turn and spend caps, exit codes, and catching no-op runs.

**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](https://www.npmjs.com/package/@anthropic-ai/claude-code/v/2.1.314) (checked October 30, 2026), and Codex CLI 0.166.0, the current release on [npm](https://www.npmjs.com/package/@openai/codex/v/0.166.0) (checked October 30, 2026), plus `anthropics/claude-code-action` [v1.0.265](https://github.com/anthropics/claude-code-action/releases/tag/v1.0.265), which installs Claude Code 2.1.314 ([run.ts](https://github.com/anthropics/claude-code-action/blob/v1.0.265/src/entrypoints/run.ts#L80)). Jobs that call a model API directly, and the ways they fail with an HTTP 200, are in [monitoring scheduled AI agent runs](https://cronradar.com/blog/monitoring-scheduled-ai-agent-runs); having an agent add monitoring code to a repository is [AI agent cron monitoring setup](https://cronradar.com/blog/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](https://code.claude.com/docs/en/headless) and [CLI reference](https://code.claude.com/docs/en/cli-reference); Codex's [non-interactive mode](https://learn.chatgpt.com/docs/non-interactive-mode) guide and [exec CLI definition](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/cli.rs). 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](https://code.claude.com/docs/en/authentication#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](https://code.claude.com/docs/en/authentication#generate-a-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](https://code.claude.com/docs/en/github-actions#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](https://code.claude.com/docs/en/legal-and-compliance#acceptable-use)). 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](https://code.claude.com/docs/en/headless#start-faster-with-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](https://code.claude.com/docs/en/headless#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.314` and set `DISABLE_AUTOUPDATER=1` in the job's environment ([install a specific version](https://code.claude.com/docs/en/setup#install-a-specific-version), [disable auto-updates](https://code.claude.com/docs/en/setup#disable-auto-updates)).
- **Pin the model.** Aliases such as `sonnet` "point to the recommended version for your provider and update over time" ([model aliases](https://code.claude.com/docs/en/model-config#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](https://code.claude.com/docs/en/permission-modes#which-mode-a-non-interactive-run-starts-in)). 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](https://code.claude.com/docs/en/permission-modes#common-setups), [dontAsk](https://code.claude.com/docs/en/permission-modes#allow-only-pre-approved-tools-with-dontask-mode), [how the classifier evaluates actions](https://code.claude.com/docs/en/permission-modes#how-the-classifier-evaluates-actions), [unattended runs](https://code.claude.com/docs/en/headless#turn-off-permission-prompts-in-unattended-runs).

- **Allowlist syntax.** `Bash(npm test *)` matches `npm test` alone and any command that starts with `npm test `; the space before `*` is part of the rule ([wildcard patterns](https://code.claude.com/docs/en/permissions#wildcard-patterns)). A compound command such as `npm test && curl ...` runs only if every part is allowed ([compound commands](https://code.claude.com/docs/en/permissions#compound-commands)).
- **Auto mode can fall back.** When `--permission-mode auto` asks 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](https://code.claude.com/docs/en/permission-modes#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 `-p` run doesn't stop: "the action doesn't run and Claude keeps working" ([fallback](https://code.claude.com/docs/en/permission-modes#when-auto-mode-falls-back)). `--permission-prompts none` (2.1.259 and later) also tells Claude not to retry denied requests.
- **`bypassPermissions` has no brakes.** It "offers no protection against prompt injection or unintended actions", and Claude Code refuses it when running as root or under `sudo` on Linux and macOS ([bypassPermissions](https://code.claude.com/docs/en/permission-modes#skip-all-checks-with-bypasspermissions-mode)).

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=1`** removes credentials such as `ANTHROPIC_API_KEY` from 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 leaves `GITHUB_TOKEN` and `GH_TOKEN` in place ([what the scrub removes](https://code.claude.com/docs/en/env-vars#what-the-subprocess-environment-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](https://code.claude.com/docs/en/permissions#read-only-commands)). Setting this to `true` (2.1.257 and later) makes Claude's file tools refuse reads outside the working directories ([setting](https://code.claude.com/docs/en/settings-reference#permissions-blockreadsoutsideworkingdirectories)) and makes recognized file-reading shell commands on those paths prompt in every mode ([actions no mode auto-approves](https://code.claude.com/docs/en/permission-modes#actions-no-mode-auto-approves)), which in a `-p` run 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](https://code.claude.com/docs/en/permissions#what-read-and-edit-rules-cover)), and an allowed `npm test` runs the `test` script from `package.json` ([npm test](https://docs.npmjs.com/cli/v11/commands/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 N`** limits 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](https://code.claude.com/docs/en/cli-reference#cli-flags)), with the result subtype `error_max_turns`.
- **`--max-budget-usd N`** stops the run once estimated spend reaches N, with the subtype `error_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](https://code.claude.com/docs/en/agent-sdk/agent-loop#budget-headroom)).
- **No wall-clock flag.** Wrap the command in `timeout`. On SIGTERM, which `timeout` sends by default, Claude Code exits with code 143 and records no result for the turn in progress ([SIGTERM](https://code.claude.com/docs/en/headless#stop-a-run-with-sigterm)); `timeout` itself exits 124 when the command times out, or 137 when the command or `timeout` receives KILL, which is what `--kill-after` sends to a command still running after TERM ([GNU coreutils](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html)). [Cron job timeouts](https://cronradar.com/blog/cron-job-timeout) 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](https://code.claude.com/docs/en/headless#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](https://code.claude.com/docs/en/agent-sdk/agent-loop#handle-the-result), [`SDKResultMessage`](https://code.claude.com/docs/en/agent-sdk/typescript#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](https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/run-claude-sdk.ts#L252-L256), 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](https://code.claude.com/docs/en/headless#get-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](https://learn.chatgpt.com/docs/non-interactive-mode#authenticate-in-automation)):

```bash
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](https://learn.chatgpt.com/docs/auth)); `codex login status` exits 0 when credentials are present, which makes a cheap preflight check ([developer commands](https://learn.chatgpt.com/docs/developer-commands)). With an API key, Codex uses standard API pricing instead of ChatGPT plan credits ([authentication](https://learn.chatgpt.com/docs/auth)). 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](https://learn.chatgpt.com/docs/non-interactive-mode#authenticate-in-automation)).

Pin the CLI with `npm install -g @openai/codex@0.166.0`; npm is one of the documented install methods ([Codex CLI](https://learn.chatgpt.com/docs/codex/cli)).

### Sandbox and Approvals

- **Read-only by default.** `codex exec` runs in a read-only sandbox unless you pass `--sandbox workspace-write`, which lets it edit within the workspace. Use `danger-full-access` "only in a controlled environment", such as an isolated CI runner or container ([permissions and safety](https://learn.chatgpt.com/docs/non-interactive-mode#permissions-and-safety)).
- **No network, no commits.** In `workspace-write`, network access stays off unless `sandbox_workspace_write.network_access` is set ([network access](https://learn.chatgpt.com/docs/agent-approvals-security#network-access)), and `.git` is protected as read-only ([protected paths](https://learn.chatgpt.com/docs/agent-approvals-security#protected-paths-in-writable-roots)). Install dependencies before Codex starts, and commit from the wrapper.
- **Nothing waits for approval.** `exec` sets the approval policy to `never` unless your configuration selects automatic review ([lib.rs](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L595-L597)), and it rejects any approval request that still arrives, with errors such as "command execution approval is not supported in exec mode" ([lib.rs](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L2166-L2177)).
- **Old and dangerous flags.** `--full-auto` is no longer supported ([permissions and safety](https://learn.chatgpt.com/docs/non-interactive-mode#permissions-and-safety)). `--approve-for-me` routes 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](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/utils/cli/src/shared_options.rs#L43-L59)).
- **Ignore the host's configuration.** `--ignore-user-config` skips `$CODEX_HOME/config.toml` (auth still uses `CODEX_HOME`), `--ignore-rules` skips user and project execpolicy `.rules` files, and `--ephemeral` writes no session files ([cli.rs](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/cli.rs#L36-L50)). With them, a run uses the flags you pass rather than whatever the host's config file says.
- **Git repository required.** Outside one, `exec` prints "Not inside a trusted directory and --skip-git-repo-check was not specified." and exits 1 ([lib.rs](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L1017-L1025)). An enabled MCP server marked `required = true` that fails to initialize also makes `codex exec` exit with an error instead of continuing without it ([permissions and safety](https://learn.chatgpt.com/docs/non-interactive-mode#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](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/cli.rs), [shared_options.rs](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/utils/cli/src/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](https://learn.chatgpt.com/docs/non-interactive-mode#make-output-machine-readable)). 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](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/exec_events.rs#L11-L57)). Items include agent messages, file changes, and command executions with their `exit_code` and a `status` such as `failed` or `declined` ([exec_events.rs](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/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](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L1307-L1416)), 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:

```json
{
  "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:

```markdown
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](https://git-scm.com/docs/git-status)), so `done` with zero lines, or `nothing_to_do` with some, is a failure. That catches the run [scheduled job silent success](https://cronradar.com/blog/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.

```bash
#!/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" || true
```

For Codex, keep everything else, put `CODEX_API_KEY=...` in the secrets file, and replace `run_agent`:

```bash
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](https://cronradar.com/blog/cron-timezone) covers the alternatives):

```text
30 4 * * 1 /opt/agent-jobs/lint-fix.sh >> /var/log/agent-jobs/lint-fix.log 2>&1
```

Or with a systemd timer. `OnCalendar=` accepts the literal zone `UTC` ([systemd.time(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html)), and because a `Type=oneshot` service has no start timeout by default ([systemd.service(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)), `TimeoutStartSec=` is a backstop above the wrapper's own limit. [systemd timers](https://cronradar.com/blog/systemd-timers) covers the rest of the options:

```ini
# /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.target
```

What the wrapper does, and why:

- **`start` comes first**, so a broken checkout or a failed `npm ci` reports `fail` instead of looking like a missed run. A missing secrets file exits before any check-in, and CronRadar alerts on the run that never arrived: `warning` once it's late, `critical` once the grace period passes.
- **The check-ins work as in [GitHub Actions scheduled workflows](https://cronradar.com/blog/github-actions-scheduled-workflows):** they can't break the job, the `start` carries the schedule so the first run creates the monitor, and a run that dies after `start` is 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. The `timeout` turns a hung agent into a `fail` at 45 minutes, which opens an incident immediately.
- **Keys stay out of the agent's environment, not out of its reach.** `ANTHROPIC_API_KEY` is passed only to `claude`, and `CRONRADAR_API_KEY` only 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](#limit-what-the-agent-can-reach) 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 `blocked` run completes with `blocked: true`, which the outcome checks below turn into an incident.
- **Only the wrapper pushes.** The Claude Code allowlist names no `git` command, and Codex's `workspace-write` sandbox keeps `.git` read-only and the network off. The wrapper pushes only a `done` run, 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](https://cronradar.com/blog/prevent-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](https://code.claude.com/docs/en/github-actions#interactive-and-automation-modes), [run on a schedule](https://code.claude.com/docs/en/github-actions#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's `cron` schedule". If a bot made that change, scheduled runs fail ([who can trigger runs](https://code.claude.com/docs/en/github-actions#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](https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/parse-sdk-options.ts#L279-L294)). Give `CRONRADAR_API_KEY` to the report steps only, never at job level.
- **The outputs say what happened.** `execution_file` is the path of a JSON array of every message from the run, including the final result ([action.yml](https://github.com/anthropics/claude-code-action/blob/v1.0.265/action.yml#L168-L174), [execution-file.ts](https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/execution-file.ts)). `conclusion` is `success` when the run succeeds; on a failed run the action fails the step without setting it, although `action.yml` describes it as `success` or `failure` ([run.ts](https://github.com/anthropics/claude-code-action/blob/v1.0.265/src/entrypoints/run.ts#L294-L324)), so test for `success`, as the report step below does. With `--json-schema` in `claude_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](https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/run-claude-sdk.ts)); the `show_full_output` input warns that full output "may contain secrets" ([action.yml](https://github.com/anthropics/claude-code-action/blob/v1.0.265/action.yml#L156-L157)).

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](https://code.claude.com/docs/en/github-actions#set-up-for-an-organization)). This workflow uses a repository secret:

```yaml
# .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
          fi
```

The 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](https://code.claude.com/docs/en/github-actions#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](https://github.com/actions/runner-images/blob/ubuntu24/20261026.354/images/ubuntu/Ubuntu2404-Readme.md)). 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](https://cronradar.com/blog/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](https://learn.chatgpt.com/docs/non-interactive-mode#authenticate-in-automation)). 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](https://learn.chatgpt.com/docs/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](https://cronradar.com/blog/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](https://cronradar.com/blog/scheduled-job-silent-success)).

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](https://cronradar.com/blog/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.
