# Routines
URL: /docs/building-apps/capabilities/routines
Markdown: /docs/building-apps/capabilities/routines.md
Description: Run actions automatically on a schedule.

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](/docs/building-apps/capabilities/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 [#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.

```ts
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 [#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 [#schedule-triggers]

A `ScheduleTrigger` describes a clock time and how often it repeats:

```ts
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 [#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:

```ts
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 [#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:

```ts
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` [#avoiding-duplicates-with-listroutines]

`system:create_routine` &#x2A;*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:

```ts
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](/docs/building-apps/capabilities/events).