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 Trigger union also declares WebhookTrigger and PollTrigger variants. They are type-level placeholders: system:create_routine rejects 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 args or 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

FieldTypeRequiredNotes
namestringyesA name a person can read. Use it to avoid duplicates (see below).
keystringnoStable 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"yesSchedule, watched-event, or run-on-demand.
actionNamestringyesCanonical <app-id>:<local-name> action ID called when the trigger fires.
argsRecord<string, unknown>noOne args object (not an array); defaults to {}. To run several, create one routine per set of args.
enabledbooleannoWhether 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
}
FieldRequiredMeaning
tzidyesIANA timezone id (e.g. "UTC", "America/Los_Angeles"). Not a UTC offset like "+00:00".
localTimeyesLocal clock time, "HH:mm", read in tzid.
tzModeyesHow 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.
rrulerecurringiCal RRULE, e.g. FREQ=DAILY or FREQ=WEEKLY;BYDAY=MO,WE,FR. Can't be used together with date. See the constraints below.
dateone-offA 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 at localTime on that calendar date. A dated one-off is always pinned to tzMode: "fixed" at creation, even if you pass floating.
  • One-off at the next matching time — set neither date nor rrule. Fires once at the next localTime.

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.

On this page