# Scheduled tasks

> Run a job on a timetable: a schedule sends a signed request to an endpoint on your Rootsy computer, which verifies it and does the work. Manage schedules from the workspace, the rootsy command or MCP.

This page covers schedules: how a delivery looks, how to build and route the endpoint that receives it, and how to create, test and manage schedules. A schedule sends an HTTP request to a path on your computer at the times you choose. The request wakes the computer if it's asleep. An endpoint you build verifies it and runs the job. Rootsy doesn't run commands on a timetable itself, and cron or systemd timers inside the computer can't wake it, so a schedule is how recurring jobs run.

## What a delivery looks like

Each scheduled time produces one request to your computer:

| Part | Value |
| --- | --- |
| Method | `GET` or `POST`, as set on the schedule |
| Path | The schedule's path, such as `/jobs/report`. No query string. |
| `Host` | The computer's schedule hostname, stored as `originHostname` in `/etc/iwmwn-schedules/schedule-identity.json`. It is neither your address nor a custom domain. |
| Body | For `POST`, the schedule's JSON payload if it has one, with `Content-Type: application/json`. Otherwise empty. `GET` has no body. |
| Signature headers | `X-Rootsy-Schedule-Id`, `X-Rootsy-Occurrence-Id`, `X-Rootsy-Signed-At` (Unix seconds), `X-Rootsy-Signature` (`v1=<hex>`) |

Any 2xx response counts as delivered. It means your endpoint accepted the work. Whether the job finished is for your endpoint to record. Return `202 Accepted` once the job is safely queued and running. Rootsy waits up to 90 seconds for the response status. It doesn't follow redirects and never retries.

Your nginx gives up after 60 seconds unless the server block sets `proxy_read_timeout 90s;`. When it gives up, the request shows as Error 503. A 502 or 504 from your endpoint also shows as Error 503.

## Build the endpoint

The endpoint checks the signature before trusting anything in the request. It records the occurrence so the same request can't start the job twice. It starts the work in a way that keeps the computer awake until the work is done.

The computer ships a verifier at `/opt/platform/box-runtime/schedule-verify.js`. `readScheduleVerificationConfig()` loads this computer's identity and key from `/etc/iwmwn-schedules/`. `verifyScheduleRequest()` checks a request's method, exact path, `Host`, signature and age against the raw body bytes. It rejects requests signed more than 120 seconds ago or more than 5 seconds in the future.

This server is a complete endpoint with no dependencies. Save it as `/home/dev/jobs/server.mjs`:

```js
import http from "node:http";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, linkSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { readScheduleVerificationConfig, verifyScheduleRequest } from "/opt/platform/box-runtime/schedule-verify.js";

const QUEUE = "/home/dev/jobs/queue";
mkdirSync(QUEUE, { recursive: true });
const config = await readScheduleVerificationConfig();
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

http.createServer(async (req, res) => {
  try {
    if (req.url !== "/jobs/report") return res.writeHead(404).end();

    // Read the exact body bytes before parsing anything. Payloads are at most 64 KiB.
    const chunks = [];
    let size = 0;
    for await (const chunk of req) {
      size += chunk.length;
      if (size > 65536) return res.writeHead(413).end();
      chunks.push(chunk);
    }
    const body = Buffer.concat(chunks);

    const headers = {};
    for (const [name, value] of Object.entries(req.headers)) {
      if (Array.isArray(value)) return res.writeHead(401).end();
      headers[name] = value;
    }
    const verified = verifyScheduleRequest(
      { method: req.method, path: req.url, host: req.headers.host ?? "", headers, body },
      { ...config, nowSeconds: Math.floor(Date.now() / 1000) },
    );
    if (!verified.ok) return res.writeHead(401).end();

    // One file per occurrence. linkSync fails if the file exists, so a repeated
    // request can't queue the job twice.
    const job = `${QUEUE}/${verified.value.occurrenceId}.json`;
    const tmp = `${job}.${randomUUID()}.tmp`;
    writeFileSync(tmp, body.length ? body : "{}", { flush: true });
    try {
      linkSync(tmp, job);
    } catch (err) {
      if (err.code === "EEXIST") return res.writeHead(202).end();
      throw err;
    } finally {
      unlinkSync(tmp);
    }

    // Start the worker under keep-awake and wait until it's running.
    spawn("/usr/local/bin/rootsy", ["keep-awake", "--", "/usr/bin/node", "/home/dev/jobs/worker.mjs", job], {
      detached: true,
      stdio: "ignore",
    })
      // A spawn that fails writes no .started file, so the wait below answers 503.
      .on("error", () => {})
      .unref();
    for (let i = 0; i < 100 && !existsSync(`${job}.started`); i++) await wait(100);
    if (!existsSync(`${job}.started`)) return res.writeHead(503).end();

    res.writeHead(202, { "content-type": "application/json" }).end('{"accepted":true}');
  } catch {
    if (!res.headersSent) res.writeHead(503).end();
  }
}).listen(3001, "127.0.0.1");
```

It listens on port 3001. Pick a port nothing else on the computer uses.

The worker does the job. Save it as `/home/dev/jobs/worker.mjs` and replace the marked line with your own work:

```js
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";

const job = process.argv[2];
writeFileSync(`${job}.started`, new Date().toISOString());
if (existsSync(`${job}.done`)) process.exit(0);

const payload = JSON.parse(readFileSync(job, "utf8"));
// Your job goes here. This example appends a line to a log.
appendFileSync("/home/dev/jobs/report.log", `${new Date().toISOString()} ${JSON.stringify(payload)}\n`);

writeFileSync(`${job}.done`, new Date().toISOString());
```

`rootsy keep-awake` requests the wake hold before it starts the worker. Once `${job}.started` exists, the computer stays awake until the worker exits. Answering 202 any earlier would let the computer sleep with the job queued and nothing running. A queue file without a matching `.done` file is a job that didn't finish. Run `node /home/dev/jobs/worker.mjs <queue file>` to finish it.

Keep the occurrence record on disk, as here, or in a database with a unique constraint on the occurrence ID. A list in process memory is lost on a restart. A repeat delivery of the same occurrence would then run the job again.

Run the server as a service so it's there after a restart:

```sh
sudo tee /etc/systemd/system/jobs-endpoint.service > /dev/null <<'EOF'
[Unit]
Description=Scheduled job endpoint
After=network.target

[Service]
User=dev
ExecStart=/usr/bin/node /home/dev/jobs/server.mjs
Restart=always
Slice=customer.slice

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now jobs-endpoint
```

Restarting this service also stops any worker it started. Unfinished jobs keep their queue file and can be run again.

## Route deliveries to the endpoint

Deliveries carry the schedule hostname in the `Host` header. Visitors' requests carry your address or a custom domain. Give the schedule hostname its own nginx server block that proxies to the endpoint. Pass the `Host` header through unchanged, because the signature covers it. First read the hostname from the identity file without printing it:

```sh
ORIGIN_HOST=$(node -p "require('/etc/iwmwn-schedules/schedule-identity.json').originHostname")
test -n "$ORIGIN_HOST" && echo set
```

If it prints `set`, write the server block. An empty variable writes a broken config. If `nginx -t` fails, delete the file you wrote.

```sh
sudo tee /etc/nginx/sites-iwmwn/schedules.conf > /dev/null <<EOF
server {
  listen 8090;
  server_name $ORIGIN_HOST;
  location / {
    proxy_pass http://127.0.0.1:3001;
    proxy_set_header Host \$host;
    proxy_read_timeout 90s;
  }
}
EOF
sudo nginx -t && sudo nginx -s reload
```

With this block, the endpoint isn't served at your address at all. To put the endpoint inside an app that also serves visitors, add the schedule hostname to that app's allowed hosts and keep the path unchanged when proxying. Don't redirect the schedule hostname anywhere. A redirect shows up as Redirected and the job doesn't run. Anyone can call a path your address serves, so verify the signature there too.

Never print or serve `/etc/iwmwn-schedules/schedule-delivery.key`. It's what makes a request authentic.

## Create the schedule

Check the timing first. `preview` shows the next three times in the timezone you give:

```sh
rootsy schedules preview --timezone America/New_York --json '{"sourceText":"weekdays 9am"}'
```

Then create it. `--timezone` is required, and the JSON needs every field. [The rootsy command](https://rootsy.io/docs/reference/cli.md) describes each one.

```sh
rootsy schedules create --timezone America/New_York --json '{"name":"Daily report","sourceText":"daily at 2 am","method":"POST","path":"/jobs/report","payloadText":"{\"region\":\"east\"}","enabled":true}'
```

`payloadText` is a JSON document as a string, or `null` for no body. `GET` schedules need `null`. `--file schedule.json` or `--stdin` can replace `--json`. Saving a schedule doesn't send a request or check that the endpoint exists.

You can also use the Scheduled tasks window. Open **Schedules** in the workspace and choose **New schedule** (or **+**). Enter a name, a When value, a timezone and the endpoint path, then choose **Create schedule**. **Save paused** saves it switched off.

## Choose when it runs

The When value (`sourceText`) is either one of these English forms or a five-field cron expression:

```text
every hour
every hour at :15
every 15 minutes
every 2 hours
daily at 2 am
every day at 14:30
weekdays 9am
weekends at noon
every day at 9am and 5pm
mondays at 7 am
every Tuesday and Thursday at 9am
1st of month
15th of month at 6pm
0 9 * * MON-FRI
```

`every N minutes` needs N to divide 60, and `every N hours` needs N to divide 24. Two times in one phrase must share the same minute. Other phrasings, such as `every other day` or `every morning`, are rejected. Use cron for those. Cron fields (minute, hour, day of month, month, day of week) take ranges, steps, lists and three-letter names.

Times are in the schedule's IANA timezone (`UTC` or a name such as `Europe/Berlin`), never the computer's clock. When a daylight-saving change skips a time, that run is skipped. When a time repeats, it runs once.

## Test it

To send a request now with the saved configuration, run:

```sh
rootsy schedules run "$SCHEDULE_ID"
rootsy schedules history "$SCHEDULE_ID" --limit 20
```

`SCHEDULE_ID` is the `id` from `rootsy schedules list`. Run now works while a schedule is paused and doesn't change its timetable. `run` prints the request it sent, and `history` shows how each request went.

## Manage schedules

```sh
rootsy schedules list
rootsy schedules get "$SCHEDULE_ID"
rootsy schedules pause "$SCHEDULE_ID" --if-match "$VERSION"
rootsy schedules resume "$SCHEDULE_ID" --if-match "$VERSION"
rootsy schedules update "$SCHEDULE_ID" --if-match "$VERSION" --json '{"enabled":false}'
rootsy schedules delete "$SCHEDULE_ID"
```

`VERSION` is the schedule's `mutationVersion` from `list` or `get`. If the schedule changed since you read it, the command fails and nothing changes. Read it again and retry. `update` takes `enabled`, a full `draft`, or both. Resume starts at the next matching time. It doesn't send the runs you paused through.

Every command prints one JSON object on stdout. Any process on the computer can manage that computer's schedules, and only that computer's. Listing, previewing, saving and reading history don't wake the computer.

## Manage schedules over MCP

An agent connected over MCP manages schedules on any computer you can reach. Every tool takes `account_id` and `computer_id`. [Connect with MCP](https://rootsy.io/docs/reference/mcp.md) covers setup.

| Tool | Inputs besides the computer | What it does |
| --- | --- | --- |
| `list_schedules` | none | Lists the computer's schedules |
| `get_schedule` | `schedule_id` | Reads one schedule, its `mutation_version` and its latest delivery |
| `preview_schedule` | `source_text`, `timezone` | Shows the next three times |
| `create_schedule` | `request_id`, `draft`, `enabled` | Creates a schedule |
| `update_schedule` | `schedule_id`, `expected_version`, `draft` | Replaces the draft and keeps the schedule's on or off state |
| `set_schedule_enabled` | `schedule_id`, `expected_version`, `enabled` | Pauses or resumes |
| `delete_schedule` | `schedule_id`, `confirm_delete: true` | Deletes the schedule |
| `run_schedule` | `schedule_id`, `request_id` | Sends a request now |
| `list_schedule_requests` | `schedule_id`, optional `limit` (1–100) and one of `cursor`, `request_id`, `occurrence_id` | Reads delivery history |

A `draft` has `name`, `source_text`, `timezone`, `method`, `path` and `payload_text`, with the same rules as the CLI fields. `expected_version` is the `mutation_version` string from `get_schedule` or `list_schedules`. `request_id` is a UUID you generate. If a `create_schedule` response is lost, repeat it with the same `request_id` and identical input within 24 hours. If a `run_schedule` response is lost, look it up with `list_schedule_requests` and the same `request_id`. Don't send it again with a new one.

Pausing MCP access doesn't stop schedules or requests already sent.

## Read the history

| Status | Meaning |
| --- | --- |
| Delivered | Your endpoint answered 2xx. The job may still be running or may fail later. |
| Redirected | Your endpoint answered 3xx. Rootsy didn't follow it. |
| Error | Your endpoint answered 4xx or 5xx. The status code is shown. |
| Couldn't connect | Rootsy couldn't confirm the request reached the computer. |
| Timed out | No response within 90 seconds. The job may have started. |
| Outcome unknown | Rootsy couldn't confirm the result. The job may have started. |
| Missed | The request wasn't sent within 5 minutes of its time. When several runs are overdue, only the latest is sent. |
| Skipped | The request wasn't sent because it was no longer eligible: the schedule was paused, changed or deleted, the account had no Runtime Credits left, or the computer was over its storage limit or unavailable. The reason is in the request's details. |

Rootsy doesn't retry any of these. After Timed out or Outcome unknown, look for the occurrence in your own records before running the job again. History holds no job output. Write that to disk yourself.

## Recover a lost response

`create` and `run` print a recovery record on stderr before they contact Rootsy, for example `{"type":"schedule_recovery","requestId":"…","operation":"create"}`. Capture both streams. If a `create` response never arrives, repeat the command with `--request-id "$REQUEST_ID"` and identical input within 24 hours. You get the schedule the first attempt created, and no second schedule.

For a lost `run`, look it up in history by request ID. Don't send another run with a new ID, because the first one may already have reached your endpoint:

```sh
rootsy schedules history "$SCHEDULE_ID" --request-id "$REQUEST_ID"
```

## After Reset or restore

Reset and restoring a backup pause every schedule on the computer. Reset keeps only `/home`, `/root` and `/srv`, so it also removes your nginx file and the service unit. A restore can roll back your queue files. Check that the endpoint works, then resume each schedule. Pausing, deleting, Reset and restore don't cancel requests already sent or jobs already accepted. See [Backups and Reset](https://rootsy.io/docs/reference/backups-and-reset.md).

## Limits

| What | Limit |
| --- | --- |
| Schedules per computer | 20 |
| Name | 100 characters |
| When value | 500 characters |
| Path | 512 characters: letters, digits, `.`, `_`, `~`, `-`, `/`. No `//`, `.` or `..` segments. Can't be `/_iw` or start with `/_iw/`. |
| JSON payload | 64 KiB |
| Response deadline | 90 seconds. Your nginx stops at 60 unless the server block sets `proxy_read_timeout 90s;`. |
| Manual runs per computer | 1 per minute, 10 per hour |
| Schedule changes per computer | 60 per minute |
| History | 30 days, 10,000 requests per schedule |
| Request ID reuse window | 24 hours |

A request that wakes the computer, and the work it starts, spend Runtime Credits like any other awake time. See [Billing](https://rootsy.io/docs/reference/billing.md) and [Sleep and wake](https://rootsy.io/docs/reference/sleep.md).

---
Page: https://rootsy.io/docs/reference/scheduled-tasks/ · Updated: 2026-10-21 · All docs: https://rootsy.io/llms.txt
