---
title: Scheduling and Claude cloud routines
description: 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.
sidebar:
  order: 7
---

## 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](https://code.claude.com/docs/en/web-scheduled-tasks), 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](https://code.claude.com/docs/en/web-scheduled-tasks), 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](https://code.claude.com/docs/en/web-scheduled-tasks), 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](/routine-kinds/code)). 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](https://code.claude.com/docs/en/web-scheduled-tasks), 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:

1. Network access: the level and allowed domains.
2. Network secrets.
3. Setup script.
4. Environment variables.

RoutineOS records the answers in `[environment]` in `app.toml`, with variable names only, never values:

```toml
[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:

```toml
[[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).

1. `list` first. If a task with the app's name exists, ask whether to update it or create another. Never create a duplicate silently.
2. Create the schedule trigger with the approved cron, repo, prompt, environment and connector subset.
3. API and GitHub triggers are added by the person in the web UI. Record that the trigger exists, never the token.
4. Read back with `list` and 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:

```toml
[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.

## Related

- [Connectors and MCP](/infrastructure/connectors-and-mcp)
- [Compute sandboxes](/infrastructure/compute-sandboxes)
- [Gates and approvals](/concepts/gates-and-approvals)
- [routine.toml and app.toml](/reference/routine-toml)
