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:
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:
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:
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:
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.
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:
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 describes each one.
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:
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:
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
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 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:
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.
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 and Sleep and wake.