Reference / Scheduled tasks

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.

Updated 2026-10-21 · View as Markdown

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:

PartValue
MethodGET or POST, as set on the schedule
PathThe schedule’s path, such as /jobs/report. No query string.
HostThe computer’s schedule hostname, stored as originHostname in /etc/iwmwn-schedules/schedule-identity.json. It is neither your address nor a custom domain.
BodyFor POST, the schedule’s JSON payload if it has one, with Content-Type: application/json. Otherwise empty. GET has no body.
Signature headersX-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.

ToolInputs besides the computerWhat it does
list_schedulesnoneLists the computer’s schedules
get_scheduleschedule_idReads one schedule, its mutation_version and its latest delivery
preview_schedulesource_text, timezoneShows the next three times
create_schedulerequest_id, draft, enabledCreates a schedule
update_scheduleschedule_id, expected_version, draftReplaces the draft and keeps the schedule’s on or off state
set_schedule_enabledschedule_id, expected_version, enabledPauses or resumes
delete_scheduleschedule_id, confirm_delete: trueDeletes the schedule
run_scheduleschedule_id, request_idSends a request now
list_schedule_requestsschedule_id, optional limit (1–100) and one of cursor, request_id, occurrence_idReads 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

StatusMeaning
DeliveredYour endpoint answered 2xx. The job may still be running or may fail later.
RedirectedYour endpoint answered 3xx. Rootsy didn’t follow it.
ErrorYour endpoint answered 4xx or 5xx. The status code is shown.
Couldn’t connectRootsy couldn’t confirm the request reached the computer.
Timed outNo response within 90 seconds. The job may have started.
Outcome unknownRootsy couldn’t confirm the result. The job may have started.
MissedThe request wasn’t sent within 5 minutes of its time. When several runs are overdue, only the latest is sent.
SkippedThe 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

WhatLimit
Schedules per computer20
Name100 characters
When value500 characters
Path512 characters: letters, digits, ., _, ~, -, /. No //, . or .. segments. Can’t be /_iw or start with /_iw/.
JSON payload64 KiB
Response deadline90 seconds. Your nginx stops at 60 unless the server block sets proxy_read_timeout 90s;.
Manual runs per computer1 per minute, 10 per hour
Schedule changes per computer60 per minute
History30 days, 10,000 requests per schedule
Request ID reuse window24 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.