Scheduling and Claude cloud routines
How a routine runs on Claude scheduled tasks. Triggers, cron in UTC, the hourly limits, environments, network access, secrets, setup scripts, creation with RemoteTrigger and the first report-only run.
What it is
Every RoutineOS routine runs as a Claude scheduled task. Anthropic’s docs now call these “scheduled tasks, formerly routines”, and the old name still appears in places such as the URL claude.ai/code/routines and the /routines alias for /schedule (docs, fetched 2026-10-30). A scheduled task is a saved Claude Code configuration (a prompt, one or more repositories and a set of connectors) that runs on Anthropic-managed cloud infrastructure, so it keeps working when your laptop is closed.
Scheduled tasks are a research preview: “Behavior, limits, and the API surface may change.” They are available on Pro, Max, Team and Enterprise plans (same source).
RoutineOS keeps the word “routine” for its own unit. A RoutineOS routine is the folder, the Desk and the rules; the scheduled task is where it runs.
Why it exists
A watch that only runs when someone remembers to run it is not a watch. Scheduled tasks give a routine a clock, a clean machine each time, and connectors, without anyone hosting a server.
How it works
Triggers
Per the docs (fetched 2026-10-30), a task can have one or more triggers:
| Trigger | What it does | How RoutineOS uses it |
|---|---|---|
| Schedule | A recurring cadence or one future time. Custom cron is allowed; expressions that run more often than once per hour are rejected | The routine’s schedule |
| API | An HTTP POST to the task’s own endpoint with a bearer token, under the beta header experimental-cc-routine-2026-04-01 |
Optional; added in the web UI by the person |
| GitHub | Repository events such as pull_request.opened or releases |
Not used by the shipped templates |
The registry records the API trigger as api-trigger-fire (docs-stated): text passed in a fire is delivered as untrusted data, and the saved prompt must opt in to act on it (fire-payload-untrusted). A fire is never approval or consent.
Cron is UTC
When a cron expression is five fields alone, its hours are UTC, so 0 9 * * 1-5 runs at 9:00 UTC on weekdays (docs, fetched 2026-10-30). A run scheduled exactly on the hour can start several minutes late; the docs suggest a few minutes past the hour.
RoutineOS stores both: schedule (in routine.toml, repeated on the [[routine]] entry in app.toml) is the UTC cron, and timezone on the [[routine]] entry is the person’s IANA zone, for example Australia/Melbourne. The permission screen shows the schedule in local time and in UTC with runs per day. lint refuses a schedule whose runs are not at least 1 hour apart (fixture tests/fixtures/fast_schedule, */30 * * * *).
Limits
Separately from subscription usage, each way of starting a run has an hourly limit (docs, fetched 2026-10-30):
| Start | Limit | Counted per |
|---|---|---|
| Scheduled runs, including one-off runs | 100 per hour | Account |
| Run now, API fires and re-running a one-off task | 30 per hour | Each task, one count shared by all three |
| Run now and re-running a one-off task | 100 per hour | Account |
| API fires | 100 per hour | Account, counted separately from Run now |
None of these has overage. GitHub events have their own per-task and per-account hourly caps, and events over the cap are dropped. RoutineOS adds its own daily ceiling, [budget] max_runs_per_day, checked in the run by budget.py decide (prompt-enforced, not guaranteed) (see Code routines). The registry records that no automatic pause on cost overrun is known (auto-pause-on-cost, unverified) and that the maximum run time is not known (max-run-time, unverified).
What a run is
Runs are full Claude Code cloud sessions: shell commands, skills and any included connectors. Skills committed to the repository’s .claude/skills/ load; skills only in ~/.claude/skills/ on a machine do not (repo-skills-carry-over, docs-stated). Plugins a repository turns on are not installed in a cloud session (repo-plugins-not-installed-in-cloud, docs-stated).
A run starts in auto mode unless it is unavailable. It still stops and waits when an action matches an ask rule, or when blocks reach auto mode’s repeated-block thresholds (docs, fetched 2026-10-30). An unattended run that waits is a stalled run, so RoutineOS designs routines to avoid prompts: report-only first runs, gates that resume in a later run, and a tools list on each routine.
Tasks belong to one claude.ai account and are not shared with teammates. Anything a task does through GitHub or connectors “appears as you” (same source).
Environments and secrets
An environment controls a run’s network access, its environment variables and its setup script. The docs describe a Default environment with Trusted network access, which allows a default allowlist of package registries, cloud provider APIs and common development domains. In a pilot, the Default environment’s dialog showed Limited with no domains. The registry records this as a conflict (default-environment-network), so RoutineOS never assumes the level.
RoutineOS cannot read an environment’s settings. RemoteTrigger returns only an environment id and an empty variables object (environment-settings-not-readable-via-api, pilot-verified). So before the permission screen, the person opens the environment dialog on claude.ai and reads out its four controls:
- Network access: the level and allowed domains.
- Network secrets.
- Setup script.
- Environment variables.
RoutineOS records the answers in [environment] in app.toml, with variable names only, never values:
[environment]
name = "hiring-signals"
network = "limited" # none, trusted, limited, full or custom
allowed_domains = []
setup_script = false
env_vars = [] # names only
lint refuses a value in env_vars: “names only, never values; remove the value”. A Default environment (or shared = true) gets the shared-environment warning: the Default cannot be renamed or archived and is shared by every routine on it, so a change to it affects all of them (default-environment-shared, pilot-verified).
The secrets ladder, best first:
| Rung | Where the key lives |
|---|---|
| 1 | A connector. No key reaches the run |
| 2 | A network secret in a per-app environment named for the app |
| 3 | A network secret in a shared environment, with a loud warning |
| never | A plain environment variable, a file, or the prompt |
Network secrets attach the key through Anthropic’s proxy outside the VM, never as environment variables or files (network-secrets, docs-stated). Plain environment variables are readable by anyone who uses the environment (env-vars-readable, docs-stated). Environments are created in the web UI only (environments-ui-only). RoutineOS never asks for a key in chat; if one is pasted, it warns at once to rotate it and does not use or echo it.
Each secret is declared in app.toml with the single host it may reach:
[[secret]]
name = "jobs-board-key"
where = "network-secret" # connector or network-secret, never env-var
host = "api.example.com"
Setup scripts
A setup script is a Bash script that runs when a new cloud session starts, before Claude Code launches. Per the registry record setup-script-rules (docs-stated): up to 64 KB, it must exit zero, it is cached only if it ends in about 5 minutes, the environment’s variables are not set yet, and the cache keeps files only. Runs without one log “No setup script configured” (pilot).
Network reachability
A run’s network behaves like an allowlist. In a pilot, api.cloudflare.com, gateway.ai.cloudflare.com, developers.cloudflare.com and huggingface.co were blocked with a proxy 403 while some package hosts answered (sandbox-allowlist, pilot-verified). Connector traffic goes through Anthropic’s servers, not the environment’s network, so a connector does not depend on the level (connector-traffic-bypasses-sandbox-network, docs-stated).
Probe before you design. Reachability from a run is the only proof. Before a design depends on a host, run one report-only probe from a run in the same environment: fetch one URL, record the status and any x-deny-reason, and write the result to risks.md.
Creating the task
RoutineOS creates tasks with /schedule and the RemoteTrigger tool (actions list, get, create, update, run, list_runs, get_run_log), in the person’s interactive session with a claude.ai login (remotetrigger-manage-tasks, docs-stated). The tool is absent inside a run, so a run can never fire or edit routines (run-cannot-fire-routines, pilot-verified).
listfirst. If a task with the app’s name exists, ask whether to update it or create another. Never create a duplicate silently.- Create the schedule trigger with the approved cron, repo, prompt, environment and connector subset.
- API and GitHub triggers are added by the person in the web UI. Record that the trigger exists, never the token.
- Read back with
listand confirm the id, schedule, repo, environment and connectors match the permission screen. Store the id in[deployed].
Pilot findings on the request body (remotetrigger-body-shape, pilot-verified): give repos as session_context.sources: [{"git_repository": {"url": ...}}]. The shape {"type": "git_repository", "url": ...} at create is dropped without an error. An update replaces job_config.ccr whole, so resend events and sources with every update or the prompt is wiped. Read back after every create and every update.
The first run
Every run before state/verified exists is report-only. RoutineOS runs the task once immediately, reads the run log and the Desk, and reports exactly what happened: items found, written, skipped and why. The person decides whether it is what they wanted, and only their word writes state/verified. Moving to sending, posting or deleting is a separate step with its own permission screen.
Worked example
Hiring Signals runs Monday and Thursday:
[routine]
type = "watch"
schedule = "0 22 * * 1,4"
first_run = "report-only"
The cron is UTC: 22:00 UTC on Monday and Thursday, which is Tuesday and Friday morning in eastern Australia (the exact local hour depends on daylight saving). That is about eight runs a month, far under every hourly limit. With [budget] max_runs_per_day = 1 in its app.toml, the prompt header has the run call budget.py decide, which returns "allow": false for a second run on the same day whatever the session says. A pilot saw a run exceed its cap when a message in the session told it to (run-obeys-in-session-cap-override), which is why the count lives in a script. The run still has to call the script, so the cap is prompt-enforced, not guaranteed.
The person uses a new environment named hiring-signals with no network secrets, because the routine needs only web search and connectors.
Reference
| Registry id | Status | Claim, short |
|---|---|---|
schedule-limits |
docs-stated | 1 hour minimum, 100 scheduled per hour per account, 30 per hour per task |
run-vm-spec |
docs-stated | Fresh VM per run, repo cloned each run |
environment-settings-not-readable-via-api |
pilot-verified | RoutineOS cannot read the environment |
default-environment-network |
conflict | Default network level |
default-environment-shared |
pilot-verified | Default is shared and cannot be renamed |
setup-script-rules |
docs-stated | 64 KB, exit zero, about 5 minutes |
remotetrigger-body-shape |
pilot-verified | Repo shape and whole-replace updates |
max-run-time |
unverified | Not known |
auto-pause-on-cost |
unverified | Not known |
Common pitfalls
- Writing local hours in the cron. Five-field cron is UTC.
- Scheduling exactly on the hour. It can start several minutes late.
- Changing the Default environment. Every routine on it changes too. Say so before changing it.
- An update that drops
sources. The prompt is wiped. Always read back. - A key in an environment variable. Anyone who uses the environment can read it. Use a network secret.