Routines
Run actions automatically on a schedule.
Agent-readable version: Markdown · llms.txt
A routine ties a trigger to an action: when the trigger fires, the daemon calls a named action with a fixed set of arguments. Routines are how an app makes something happen on its own — on a schedule, or in response to a domain event published on the event bus.
You create routines from app code by calling the platform system:create_routine
action through appContext.runAction. The trigger shapes are exported from
@rome-os/app-runtime as the Trigger union. system:create_routine accepts three
of them: ScheduleTrigger, EventBusTrigger, and ManualTrigger. This page
covers schedule and manual triggers; for routines that fire on a
published domain event, see
Events.
The
Triggerunion also declaresWebhookTriggerandPollTriggervariants. They are type-level placeholders:system:create_routinerejects them, so you can't create webhook- or poll-triggered routines today.
Scheduled and event-driven runs carry no memory of the run that set them up. The action a routine fires must stand fully on its own — every input it needs must live in
argsor be worked out again inside the action. Don't assume a scheduled run remembers files, variables, or choices from setup.
Creating routines
system:create_routine is a platform built-in. Call it via
appContext.runAction with a name, a trigger, the actionName to call, and
one args object. actionName must be a canonical <app-id>:<local-name> ID,
including when the routine calls an action from your own app. There is no
confirmation card — the routine is registered right away.
await appContext.runAction("system:create_routine", {
name: "daily-dream",
trigger: {
type: "schedule",
tzid: "UTC", // IANA tz id, NOT "+00:00"
localTime: "03:00", // "HH:mm" in tzid's local time
tzMode: "fixed",
rrule: "FREQ=DAILY", // iCal RRULE; recurring
},
actionName: "dream:dream", // canonical action ID to execute at trigger time
args: {}, // single arg object passed to the action
});system:create_routine returns { status: "error", error } for problems you can fix
(a bad localTime, a date/rrule clash, an array args, an actionName
that isn't in the action registry, a one-off date already in the past, or
args containing the reserved __triggerPayload key), so check status
and show the message instead of assuming it worked.
Input fields
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | A name a person can read. Use it to avoid duplicates (see below). |
key | string | no | Stable unique identity for idempotency, distinct from the display name. Creation is rejected if a routine already uses this key. |
trigger.type | "schedule" | "event-bus" | "manual" | yes | Schedule, watched-event, or run-on-demand. |
actionName | string | yes | Canonical <app-id>:<local-name> action ID called when the trigger fires. |
args | Record<string, unknown> | no | One args object (not an array); defaults to {}. To run several, create one routine per set of args. |
enabled | boolean | no | Whether the routine is live on creation (default true). |
Schedule triggers
A ScheduleTrigger describes a clock time and how often it repeats:
interface ScheduleTrigger {
type: "schedule";
tzid: string; // IANA timezone, e.g. "UTC", "America/Los_Angeles"
localTime: string; // "HH:mm" local time in tzid
tzMode: "fixed" | "floating";
date?: string; // "YYYY-MM-DD" — a true one-off; mutually exclusive with rrule
rrule?: string; // iCal RRULE — recurring; mutually exclusive with date
}| Field | Required | Meaning |
|---|---|---|
tzid | yes | IANA timezone id (e.g. "UTC", "America/Los_Angeles"). Not a UTC offset like "+00:00". |
localTime | yes | Local clock time, "HH:mm", read in tzid. |
tzMode | yes | How tzid is tied down. fixed locks the schedule to the exact tzid (a set zone). floating reads localTime against the guardian's current timezone, so the routine moves with them when they change it. |
rrule | recurring | iCal RRULE, e.g. FREQ=DAILY or FREQ=WEEKLY;BYDAY=MO,WE,FR. Can't be used together with date. See the constraints below. |
date | one-off | A set calendar date, "YYYY-MM-DD". Can't be used together with rrule. |
tzMode is required — every schedule must say how its timezone is tied down.
The RRULE checks are strict: FREQ=MONTHLY must set BYMONTHDAY=N,
FREQ=WEEKLY must set BYDAY=..., and FREQ=YEARLY must set both BYMONTH=M
and BYMONTHDAY=D. Clauses the scheduler doesn't support — COUNT, UNTIL,
ordinal BYDAY values like 1MO, or a repeated clause — are rejected rather
than silently ignored.
One-off vs recurring
The choice comes down to date vs rrule, and you can use only one of them:
- Repeating — set
rrule(iCal RRULE syntax). Fires on every matching time. - One-off on a specific date — set
date("YYYY-MM-DD"). Fires once atlocalTimeon that calendar date. A dated one-off is always pinned totzMode: "fixed"at creation, even if you passfloating. - One-off at the next matching time — set neither
datenorrrule. Fires once at the nextlocalTime.
A one-off reminder at a specific local time and date:
await appContext.runAction("system:create_routine", {
name: "send-launch-reminder",
trigger: {
type: "schedule",
tzid: "America/Los_Angeles",
localTime: "09:00",
tzMode: "fixed",
date: "2026-06-01", // one-off on this calendar date
},
actionName: "system:send_message",
args: { channel: "telegram", threadId: "123", text: "Launch today!" },
});Manual triggers
A ManualTrigger has no firing condition at all — the routine never runs on
its own. It runs only through the out-of-band "run now" path: the Run-now
button on the dashboard's Routines page (POST /routines/:id/run). Use it for
a saved playbook the guardian kicks off on demand:
await appContext.runAction("system:create_routine", {
name: "refresh-all-feeds",
trigger: { type: "manual" }, // no config — runs only via Run now
actionName: "feeds:refresh_feeds",
args: {},
});Avoiding duplicates with listRoutines
system:create_routine does not dedupe by name — calling it twice registers two
routines. For a routine your app owns and might re-create, the strongest guard
is the key field: pass a stable key and the second creation is rejected
instead of registering a duplicate.
When you can't use a key (e.g. matching routines you didn't create), guard
with appContext.listRoutines() (which returns every Routine already
scheduled) and match on name alone:
const existing = await appContext.listRoutines();
const alreadyScheduled = existing.some((r) => r.name === "daily-dream");
if (!alreadyScheduled) {
await appContext.runAction("system:create_routine", {
/* ...as above */
});
}Match on name only. Don't also match on actionName — that would block valid
sibling routines that just happen to call the same action (e.g. "weekly-dream"
alongside "daily-dream").
Each Routine returned by listRoutines() has its id, name, enabled
flag, trigger, actionName, args, createdAt, and (when known)
lastFiredAt / nextRunAt. It may also carry a key (the stable dedup
identity above) and managedBy — the owning app's id. Routines your app
creates are stamped managedBy: <your appId> automatically, and a managed
routine can't be deleted by users, only by the managing app.
This same duplicate guard applies to event-driven routines — see Events.