Scheduled, unattended Claude Code runs — driven by the CLI you already have logged in, so no ANTHROPIC_API_KEY is involved.
One command scaffolds the whole thing: a prompt, a hardened runner script, and a real scheduler entry (launchd, systemd or cron).
npx claude-jobs init morning-report \
--skill ./playbooks/morning-report.md \
--at 09:30 \
--notify 'echo "$CLAUDE_JOB_MESSAGE" | mail -s "morning report" me@example.com'
npx claude-jobs run morning-report --dry-run # see exactly what will happen
npx claude-jobs install morning-report # hand it to the OS schedulerIf you have ever written the same claude -p wrapper script for the third time — the one with the explicit PATH, the random start delay, the summary file, the notifier — this is that script, generalized and tested.
Claude Code is a real product with a real login. On the machine where you are already signed in, claude -p is a legitimate, non-interactive way to run work on your subscription. What that leaves you missing is everything around the call: schedulers start with an empty environment, unattended agents must never stop to ask a question, and a run nobody watches needs to report its own outcome.
claude-jobs is the boring wrapper that gets those details right, so the interesting part stays in your prompt.
Non-goals: this does not proxy, resell or wrap your subscription as an API for other tools. It runs the official CLI, as you, on your own machine. See docs/policy.md.
Claude Code also schedules work by itself now, in three different places. Read Do you need this? before installing — for a good share of daily jobs the built-in answer is the better one.
npm install -g claude-jobs # or just use npx claude-jobs
claude-jobs doctor # safe-to-paste checks + a direct bug-report form linkRequires Node 18.17+, Claude Code installed and logged in (claude auth login) as the same user that will run the jobs.
Upgrading the package does not touch jobs you already created. run.sh is generated once, at init, and every job keeps the copy it was born with — so a fix that lands in the runner reaches new jobs only, until you say otherwise.
install regenerates it:
npm install -g claude-jobs@latest
claude-jobs list # the names you have
claude-jobs install <name> # rewrites run.sh from the current templateThat is the whole upgrade path, and it is safe to repeat — install is what you already run to register a job, and re-running it rewrites the runner and the scheduler unit from your existing job.json. Your prompt is not touched.
What it does not do is migrate job.json. A field added after your job was created is simply absent, and the runner falls back to that field's default. Concretely, for the one field where that is visible today: a job created before 0.1.6 has no recorded --log-max-bytes, so re-running install gives it the 5 MiB default and its log starts rotating. Keep the old append-forever behaviour with --log-max-bytes 0 at init, or by editing LOG_MAX_BYTES in the job's run.sh.
| Command | What it does |
|---|---|
init <name> |
Scaffold prompt + runner + scheduler unit |
list |
Every job and whether it is actually scheduled |
run <name> [--now|--dry-run] |
Run by hand — --dry-run prints the plan and the prompt |
install / uninstall <name> |
Register / unregister with launchd, systemd or cron |
logs <name> [--lines N] |
Tail the job log |
status <name> |
Schedule, paths, what the last run did, and the last summary the agent wrote with the time it was written |
doctor |
Safe-to-paste binary, login, API-key leakage and scheduler checks; account email is redacted and the last line opens the structured bug-report form |
Useful init flags: --skill, --task, --prompt-file, --at HH:MM, --jitter, --workdir, --scheduler, --model, --precheck, --notify, --permission-mode. Run claude-jobs help for the full list.
A job earns its place when the work repeats, needs judgement rather than just a command, and produces something short enough to read over coffee. The five that pay off fastest:
- Dependency and CVE triage. Run the audit, then separate what actually reaches your code from what sits in a devDependency nothing calls, and open a PR for the safe patch bumps. The sorting is the value — listing every advisory is what you already ignore.
- Morning error triage. Read overnight errors, group them, and say which ones deserve attention. A dashboard counts them; this one interprets them.
- CI flake triage. Read the day's failed runs, group by cause, name the tests that failed for reasons unrelated to the change. A week of this gives you a ranked flake list instead of a feeling.
- Docs drift. Compare the docs against the code and open a PR for what diverged — renamed flags, examples that no longer run. Nobody wants this job; an agent never gets bored of it.
- A digest with your taste in it. Read the sources you chose and keep only what touches what you are working on now. The filter is what makes it different from an RSS reader.
claude-jobs init dep-triage --skill ~/playbooks/deps.md --at 02:30 \
--workdir ~/src/api \
--precheck 'git -C ~/src/api fetch --quiet' \
--notify 'gh issue comment 42 --body "$CLAUDE_JOB_MESSAGE"'More ideas — release notes, issue triage, cost anomalies, backup verification, competitor watch — plus the jobs that are a bad fit and why: docs/use-case-ideas.md.
~/.claude-jobs/
├── jobs/<name>/job.json # the declaration
├── jobs/<name>/prompt.md # what the agent is told — edit this freely
├── jobs/<name>/run.sh # generated runner, yours to modify
├── logs/<name>.log # stream-json, every step as it happens
└── state/<name>-summary.md # the agent's own report, written last
Nothing is hidden in a database. Delete the directory and the job is gone.
These are the parts that are easy to get wrong once and then debug for a week:
- Explicit environment. Schedulers do not load your shell profile.
PATH,HOMEand the absolute path toclaudeare written into the script. - The prompt is a pointer. It names a skill or runbook file rather than embedding the logic, so behaviour changes without touching the scheduler.
- Unattended means unattended. The prompt tells the agent never to wait for an answer, and to park human decisions in writing instead of blocking.
--permission-mode bypassPermissions. A headless run cannot approve anything. Override with--permission-modeif you want a stricter mode.--output-format stream-json --verbose. Steps land in the log as they happen instead of buffering until the end.- Preconditions before spending a session.
--precheckruns a cheap command first; if it fails, the run is skipped and reported rather than started into a broken environment. - Random start jitter. Firing at the same second every day is a machine rhythm, and staggering also keeps several jobs off the same usage-limit cliff.
- Delivery is separate from logging. The agent writes a summary file as its last step; the runner delivers that file through
--notify. No summary means the run is reported as failed, with the exit code. - State lives in files. Each run is a fresh process; continuity comes from whatever the skill writes down.
Details and the reasoning behind each one: docs/design.md.
The same "subscription instead of API key" idea shows up in a few officially supported places — GitHub Actions with claude_code_oauth_token, the Agent SDK authenticating as your account, and chat gateways that execute turns through the local CLI. Where each one fits, and where the line is: docs/use-cases.md.
A scheduled job and a chat bot are the same substrate with a different trigger. OpenClaw is a self-hosted gateway that connects Zalo, Telegram, Slack and friends to an agent, and it can run every turn through your logged-in CLI — no API key in the config at all:
{
"agents": {
"defaults": {
"model": { "primary": "claude-cli/claude-sonnet-5" },
"cliBackends": { "claude-cli": { "command": "/opt/homebrew/bin/claude" } },
"agentRuntime": { "id": "claude-cli" }
}
}
}A ready-to-run script that merges this into an existing gateway config, with a backup and a dry run, is in examples/openclaw/:
node examples/openclaw/apply-claude-cli-backend.mjs # show the diff
node examples/openclaw/apply-claude-cli-backend.mjs --write # apply itThe full walkthrough — how a turn executes, why the model list must not contain a direct-API model, and what a long-running gateway does differently from a one-shot job — is in docs/openclaw.md.
Run both and you cover the two halves: the gateway answers when someone asks, claude-jobs acts when nobody does.
Often not. Claude Code ships three ways to schedule work, and they cover most of what people reach for a wrapper script to do. Checked against the docs on 2026-08-18:
| Option | Runs on | Needs | Reach for it when |
|---|---|---|---|
| Routines | Anthropic's cloud | Nothing of yours running | The work must happen whether or not your machine is on, or a GitHub event / API call is the trigger |
| Desktop scheduled tasks | Your machine | Claude Code Desktop open, computer awake | You want local files plus a UI: run history, a notification per fire, and a permission prompt you can answer later |
/loop |
Your machine | An open session | Polling something for the next few minutes or hours, inside the session you are already in |
claude-jobs (this repo) |
Your machine | An OS scheduler and a valid CLI login | The schedule has to be an OS-level unit and the outcome has to leave the machine on its own |
The differences that actually decide it:
- Cloud routines clone your repo fresh. No uncommitted work, no local database, no file outside the repo, no tool that only exists on your laptop. That rules out a whole class of jobs, and rules in every job that should survive a closed lid.
- A Desktop task fires only while the app is open and the machine is awake, and it can stall mid-run waiting for a permission you have not granted yet. Both are fine at a desk. Neither is fine on a box you SSH into.
claude-jobshas no UI at all, and that is the point: the schedule is a launchd/systemd/cron unit, the job is three files you can read and commit, and--notifyhands the agent's summary to any command —mail,gh issue comment, a Slack webhook — so the result reaches you without a screen to look at.- Routines draw on the same subscription and are capped per day by plan (5 on Pro, 15 on Max, 25 on Team/Enterprise at the time of writing). Jobs here are capped by nothing but your own usage limits, because they are ordinary CLI sessions.
If your job is "review yesterday's commits at 9am and tell me", start with a Desktop task — it is one form, it keeps its own history, and nothing here beats it. Come back when the job needs to run under an OS scheduler, report through a command, or live in version control next to the code it reads.
A job installed from the published package, scheduled at a fixed minute, delivering through gh gist create:
claude-jobs init ci-watch --at 09:40 --jitter 0 \
--task 'read the latest CI conclusion on main and the count of open external PRs for five repos, report one line each' \
--notify 'printf "%s\n" "$CLAUDE_JOB_MESSAGE" | gh gist create -p -f ci-watch.md -'
claude-jobs install ci-watchlaunchd fired it with nobody at the keyboard, and the log of that run reads:
[2026-08-18 09:40:05] === session start ===
[2026-08-18 09:41:00] === session end, exit=0 ===
✓ Created public gist ci-watch.md
https://gist.github.com/vinhnguyenthanhdn/09b7430e84ddf0fdc31acf3c578ed4b5
The gist is the whole point: 55 seconds after the unit fired, the agent's report was readable by someone who has no access to that machine. What this does not show is a host nobody has ever logged into — the run used the CLI login of the user who installed it, which is the dependency named under Limitations.
A scheduled job has a second failure shape that never raises anything: it does nothing. The precheck refuses every morning, the claude binary moved, the unit was never loaded. claude-jobs status <name> answers that directly — it reads the log the run already wrote and says what the last run did:
last run 2026-09-03 09:08:40 — spent no session: precheck failed: git -C /repo pull
the summary below is older than this run — that run wrote none, so it is not its report
The second line is the case worth naming: a summary from four days ago reads exactly like one from this morning, so the report shown carries the time it was written.
Failures that do raise something are sorted into two kinds, because they need different things from you.
A mistake in what you passed prints one line naming the fix and stops there — a missing job name, an unknown scheduler, a --skill path that is not there. There is nothing to report.
Anything else is a failure this tool did not anticipate, and those print the way out instead of a bare error string: run claude-jobs doctor, then paste its output into the bug-report form the message links. doctor is safe to paste — it redacts the account email and checks for API keys leaking into the environment. Add CLAUDE_JOBS_DEBUG=1 to get the stack trace along with it.
- One daily time per job.
--attakesHH:MMand schedules that job once a day. Several times a day, weekdays only, or a full cron expression means several jobs, or editing the generated scheduler unit by hand. - No retries and no catch-up. A failed run is reported, not repeated, and a run whose scheduled minute passed while the machine was asleep or off is handled by whatever the scheduler does — this tool adds no logic of its own on top.
- macOS, Linux, and any Unix with cron. The scheduler is launchd on macOS, systemd user timers on Linux, and cron where either is missing. Windows Task Scheduler is not supported. CI exercises launchd, systemd, and cron against the schedulers that own them rather than against the files this tool writes: a launchd job is registered in the user's GUI domain and read back with
launchctl, a systemd timer is registered, enabled, active and carrying the--atvalue, and a crontab line is read back fromcrontab -l— all gone again afteruninstall. - The login is the dependency. Jobs run through the Claude Code CLI as the user who installed them, so the machine needs an interactive login that is still valid. There is no API-key path by design, and no way to run this on a host nobody has ever logged into.
claude-jobs doctorchecks that before a schedule silently starts failing. bypassPermissionsby default. An unattended run cannot approve a tool call, so it starts with permissions bypassed in the working directory given to it. That is a trust decision about that directory, not a detail —--permission-modemakes it stricter.- The agent's report is the only outcome. Delivery is driven by the summary file the agent writes last. A run that ends without one is reported as failed, even if useful work happened before it stopped.
- One log per job, rotated once. Every run appends to
~/.claude-jobs/logs/<name>.log, and the session is written there instream-json, so a single short run is tens of kilobytes. At the start of a run the runner moves the log to<name>.log.1if it has reached 5 MiB (--log-max-bytes, default5242880), replacing any older.1. That bounds a job at two files and keeps the previous run available for a post-mortem; it is not a full log-rotation policy, and nothing prunes the.1.--log-max-bytes 0turns rotation off and restores append-forever behaviour. The value is written into the generated runner asLOG_MAX_BYTES, so an existing job is changed by editing that line in itsrun.sh, or by re-runningclaude-jobs install <name>— see Upgrading, including what that does to a job created before this option existed.
Two separate concerns around the same CLI; either works on its own.
| Tool | What it does |
|---|---|
claude-jobs (this repo) |
Runs Claude Code on a schedule, unattended, and reports the outcome |
claude-router |
Points Claude Code at a 9Router provider instead of Anthropic, per process in the terminal or per machine in VSCode (Windows) |
Issues and PRs are welcome — especially scheduler support beyond launchd/systemd/cron, notifier recipes, and prompt templates that survive real unattended use. Start with CONTRIBUTING.md.
If this saved you an afternoon, a ⭐ helps other people find it.
MIT