"Run my agent every morning" sounds like one feature. In Claude Code it's three, and they fail in different ways. Pick the wrong one and your "nightly" job quietly stops firing the first time you close the terminal — or a week later, when it silently expires. Here's the decision, and the handful of gotchas that bite unattended jobs specifically.

The three mechanisms#

There is no single scheduler. There are three, and the right question is what has to be true for this to keep running.

The one-line heuristic: /loop for quick polling inside a session, Routines for unattended work that must run with your laptop shut, Desktop tasks for local-file work that needs your own tools.

The scheduler you want is the one whose "requires" column you can actually guarantee at 3am. Everything else is a job that looks scheduled and isn't.

Driving /loop#

/loop is the fastest to reach for. Both the interval and the prompt are optional, and what you supply changes the behavior:

/loop 5m check if the deployment finished and tell me what happened

Give it an interval and a prompt and it runs on a fixed cadence — units are s, m, h, d, and the interval can lead as a bare 30m or trail as every 2 hours. Give it a prompt only and Claude picks the delay itself each iteration (short while a build is finishing, longer when things go quiet), printing the delay and its reasoning at the end of each pass. Give it nothing and it runs a built-in maintenance prompt — or your .claude/loop.md if you've written one.

You can also schedule a skill: /loop 20m /review-pr 1234 re-runs that skill each iteration. This pairs naturally with a forked skill — see how to run a Claude skill in the background — so the loop kicks off heavy, self-contained work and stays responsive.

The gotcha that eats scheduled skills#

Here's the one that produces a job which appears to run and accomplishes nothing. As of Claude Code v2.1.196, a scheduled fire only runs skills Claude is allowed to invoke on its own. Anything Claude can't auto-invoke reaches it as plain text instead of executing:

So /loop 30m /code-review doesn't run a review on a schedule — it hands Claude the literal string /code-review and lets it decide. If your scheduled skill quietly does nothing, check this first.

Cron, jitter, and the seven-day cliff#

Under the hood, natural-language scheduling compiles to CronCreate with a standard 5-field expression — minute hour day-of-month month day-of-week — supporting , single values, steps (/15), ranges (1-5), and lists. Two things surprise people:

All times are local. 0 9 is 9am wherever you run Claude Code*, not UTC. Extended syntax (L, W, name aliases like MON) isn't supported.

Jitter is deterministic and can be large. To avoid every session hammering the API at the same wall-clock second, the scheduler offsets each fire by an amount derived from the task ID. A recurring hourly job scheduled for :00 can fire anywhere up to :30; one-shots at the top or bottom of the hour fire up to 90 seconds early. If exact timing matters, schedule an odd minute — 3 9 , not 0 9 — and the one-shot jitter won't apply.

And the cliff: recurring tasks expire seven days after creation. They fire one final time, then delete themselves — a deliberate bound on forgotten loops. There's also no catch-up: a fire due while Claude is mid-request runs once when it goes idle, not once per missed interval.

Which to pick#

If the job must survive a closed laptop, it cannot be /loop or a Desktop task — use a Routine (or a GitHub Actions schedule trigger) and accept the fresh-clone, no-local-files constraint. If it needs your local files and tools, it's a Desktop task, and you own keeping the machine awake. If you just want to babysit a deploy or a PR for the next hour, it's /loop — and press Esc to stop it. Match the mechanism to the "requires" you can guarantee, not to the sentence "every morning," and your scheduled agent will still be firing next week.