# Scheduling (Universal)

One-off, daily and weekly schedule rules for Agent Notify projects — each in its own timezone, to all devices, a segment, a group or one subscriber, in any environment — with quiet hours, at-most-once dispatch and a run history. Set up from the dashboard, the chat, the API or MCP.

Agent Notify projects schedule notifications with **schedule rules** — one rules engine behind the dashboard's **Scheduling** tab, the in-dashboard agent, the API and the [MCP server](/docs/mcp-server). A rule is:

- **when** — a one-off on a date, or every day, or one day a week, at a time in the rule's own timezone;
- **what** — a fixed title + body, or copy the AI writes fresh at every send ([AI-Written Notifications](/docs/universal-push/ai-notifications));
- **who** — all devices, a segment, a [universal group](/docs/universal-push/groups) or one subscriber, in one [environment](/docs/universal-push/environments).

Every send goes through the ordinary [universal pipeline](/docs/universal-push/sending) — one [inbox](/docs/universal-push/inbox-and-audience) entry per device and analytics included.

> **Classic Expo apps:**
>
> The same rules serve classic apps too, with daily and weekly rules to all devices or a segment — see [Scheduling](/docs/scheduling). One-offs, group and subscriber audiences, environments and images are for Agent Notify projects; AI-written copy works for every project.

## Three ways to set one up

- **Dashboard** — **Push Notifications → Scheduling**: **New rule** opens the form (fixed or AI-written copy, cadence, audience, environment; **Generate sample** previews AI copy); each rule has an on/off switch, its next send, its last run and a delete. The **Upcoming** card lists the next sends, and **Quiet hours** sets the hold window. Scheduling a send from the **Notifications** composer also creates a one-off rule, for any audience.
- **Chat** — ask the in-dashboard agent (*"Every Saturday at 8:30, tell our regulars the sourdough is out"*). It configures the rule, saves it **off**, and shows a card — **Turn on schedule** — so your click turns it on; an edit to a live rule shows **Save changes** instead. The cards are a chat-only safety step.
- **API and MCP** — the routes below, or the MCP tools `create_schedule_rule`, `update_schedule_rule` and friends. They act **immediately**, exactly like the Scheduling tab: a rule saved with `enabled: true` is live, and its next occurrence sends real pushes.

## One-off, daily and weekly

| `frequency` | Needs                                                     | Sends                            |
| ----------- | --------------------------------------------------------- | -------------------------------- |
| `once`      | `date` (`"YYYY-MM-DD"`) + `timeOfDay`                     | Once, on that date at that time. |
| `daily`     | `timeOfDay`                                               | Every day at that time.          |
| `weekly`    | `dayOfWeek` (`0` = Sunday … `6` = Saturday) + `timeOfDay` | Every week on that day.          |

- **Each rule keeps its own wall clock.** `timezone` is an IANA zone (`America/New_York`), DST included: a 09:00 rule sends at 09:00 local time in July and in January. `timeOfDay` is 24h `"HH:MM"`.
- **A one-off must be at least 2 minutes away** when it is created, moved or switched back on (`400 schedule_too_soon`). Once it has gone out — or was missed — it is complete (`completedAt`) and can no longer be changed (`409 rule_completed`); create a new one instead.
- **No surprise send today.** A daily or weekly rule created, resumed or rescheduled after today's time first sends at its next occurrence — `nextFireIso` shows exactly when.
- The runner ticks **every minute** and dispatches an occurrence on the tick that reaches it (or within a 15-minute grace window, if the server was briefly busy or restarting).

## Audiences

| `audienceKind`  | With                              | Reaches                                                                                                                                                                                                |
| --------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `all` (default) | —                                 | Every registered device in the rule's environment, with or without a subscriber id.                                                                                                                    |
| `segment`       | `segmentId`                       | The members of a dashboard [segment](/docs/segments) — universal subscriber ids — on every device registered under them.                                                                               |
| `group`         | `audienceRef` = the group key     | Every device subscribed to a [universal group](/docs/universal-push/groups). The group must exist when the rule is saved; a name like `Church Newsletter` is accepted for the key `church-newsletter`. |
| `sub`           | `audienceRef` = the subscriber id | Every device registered under that one subscriber id.                                                                                                                                                  |

- **Segments over 5,000 members are refused** at send time (a `blocked` run) — never sent in part. Split the segment, or send to all devices.
- **`skipIfActiveWithinDays`** (`1`–`365`, segment rules only) skips subscribers with a device seen in the last N days — "they already opened the app". A universal device is seen each time the app re-registers it.
- An audience that resolves to nobody sends nothing and records an `empty` run.

## Environments

`environment` is `production` (default), `staging` or `development`; a rule reaches only the devices registered in it. An environment that is switched off is refused when you save the rule (`400 environment_disabled`) — and if it is switched off later, the rule's sends are `blocked` until it is back on. `GET …/schedules` lists the choices under `environments`.

## Quiet hours — hold, don't drop

Quiet hours are one window per project. While they are on, an occurrence whose time falls inside the window — read in **that rule's** timezone — is **held** and sent the moment the window ends: a `held` run first, then the run that sends. One-offs are held the same way. Send-now pushes and [classic scheduled notifications](/docs/scheduling#one-off-scheduled-sends) are never held.

```json
{ "quietHoursEnabled": true, "quietHoursStart": "22:00", "quietHoursEnd": "07:00" }
```

The default window is `22:00`–`07:00`. It may cross midnight, and start and end must differ. A rule's `heldByQuietHours` and `effectiveFireIso` say when a held next send will actually go out.

## At most once

Every occurrence is sent at most one time — even across a restart:

1. The runner **claims** the occurrence in the database before anything is sent (a one-off is marked complete by the same claim), then writes a `sending` run.
2. A second runner, or the same one after a restart, finds it claimed and skips it. A run still `sending` 30 minutes later shows `outcomeUnknown: true` — the push may or may not have gone out, and it is never sent again.
3. Each send also carries the idempotency key `rule:<ruleId>:<occurrence>`, so no device gets a second inbox entry for one occurrence.
4. A one-off whose whole grace window passed while the scheduler was not running is recorded `missed` — nothing is sent; schedule it again.

Every outcome uses up its occurrence: a `blocked` or `failed` send is not retried — the rule simply continues at its next occurrence.

## Run statuses

`GET …/schedules/:ruleId/runs` returns a rule's 20 most recent runs, newest first.

| `status`  | Meaning                                                                                                                                                                                      |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sent`    | The push went out. `audienceCount` = devices targeted; `title` / `body` are the copy that was sent; `sendId` links it to the [inbox](/docs/universal-push/inbox-and-audience) and analytics. |
| `held`    | Quiet hours held it; a later run records the send.                                                                                                                                           |
| `empty`   | Nobody to send to (no members, no registered device, or everyone skipped) — nothing sent.                                                                                                    |
| `blocked` | A gate refused it: an expired trial or membership, a disabled environment, or a segment over 5,000 members. `detail` says which.                                                             |
| `error`   | The send failed — for example the segment was deleted, or devices matched but no transport accepted the push (missing credentials).                                                          |
| `failed`  | An AI-written rule's copy could not be written — nothing was sent. See [AI-Written Notifications](/docs/universal-push/ai-notifications).                                                    |
| `missed`  | A one-off whose time passed while the scheduler was not running — nothing was sent.                                                                                                          |
| `sending` | Claimed and in flight. `outcomeUnknown: true` when it never settled.                                                                                                                         |

## API

The routes are account routes: call them with a dashboard session or an [MCP access token](/docs/mcp-server) (`Authorization: Bearer nnmcp_…`) — an app token is refused with `403`. Every call re-checks that the account (or its team) can reach the app (`404 app_not_found` otherwise). Any member of the app's team may manage rules, the same as on the Scheduling tab.

```text
GET    https://app.nativenotify.com/api/agent/apps/:appId/schedules                — rules + quiet hours
GET    https://app.nativenotify.com/api/agent/apps/:appId/schedules/upcoming       — the next sends
POST   https://app.nativenotify.com/api/agent/apps/:appId/schedules                — create a rule
PATCH  https://app.nativenotify.com/api/agent/apps/:appId/schedules/:ruleId        — update / pause / resume
DELETE https://app.nativenotify.com/api/agent/apps/:appId/schedules/:ruleId        — delete a rule
GET    https://app.nativenotify.com/api/agent/apps/:appId/schedules/:ruleId/runs   — run history
GET    https://app.nativenotify.com/api/agent/apps/:appId/schedules/settings       — quiet hours
PUT    https://app.nativenotify.com/api/agent/apps/:appId/schedules/settings       — set quiet hours
POST   https://app.nativenotify.com/api/agent/apps/:appId/schedules/ai-sample      — preview AI copy
```

### Create a rule

A one-off to the church newsletter group, the Saturday before the bake sale:

```json
{
  "name": "Harvest bake sale",
  "enabled": true,
  "title": "Bake sale this Saturday",
  "body": "Pies, bread and cookies in the church hall from 9:00. Everything goes to the food pantry.",
  "frequency": "once",
  "date": "2026-10-03",
  "timeOfDay": "08:00",
  "timezone": "America/New_York",
  "audienceKind": "group",
  "audienceRef": "church-newsletter",
  "pushData": { "url": "/events/bake-sale" }
}
```

A weekly rule to a segment, skipping anyone who opened the app in the last 3 days, saved paused:

```json
{
  "name": "Saturday bread",
  "enabled": false,
  "title": "Fresh sourdough",
  "body": "Loaves come out of the oven at 8:30.",
  "frequency": "weekly",
  "dayOfWeek": 6,
  "timeOfDay": "08:30",
  "timezone": "America/Chicago",
  "audienceKind": "segment",
  "segmentId": 4,
  "skipIfActiveWithinDays": 3
}
```

| Field                                                    | Required       | Notes                                                                                                  |
| -------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------ |
| `name`                                                   | yes            | Up to 120 characters.                                                                                  |
| `enabled`                                                | no             | Default `true` — the rule is live unless you pass `false`.                                             |
| `contentMode`                                            | no             | `"fixed"` (default) or `"ai"` — see [AI-Written Notifications](/docs/universal-push/ai-notifications). |
| `title`, `body`                                          | fixed copy     | Up to 200 / 4,000 characters.                                                                          |
| `appDescription`, `instructions`, `language`, `examples` | AI copy        | The brief — see [AI-Written Notifications](/docs/universal-push/ai-notifications#the-brief).           |
| `frequency`                                              | yes            | `"once"`, `"daily"` or `"weekly"`.                                                                     |
| `date`                                                   | `once`         | `"YYYY-MM-DD"`, in the rule's timezone.                                                                |
| `dayOfWeek`                                              | `weekly`       | `0`–`6`, `0` = Sunday.                                                                                 |
| `timeOfDay`                                              | yes            | 24h `"HH:MM"`.                                                                                         |
| `timezone`                                               | yes            | IANA zone, up to 60 characters.                                                                        |
| `audienceKind`                                           | no             | `"all"` (default), `"segment"`, `"group"` or `"sub"`.                                                  |
| `segmentId`                                              | `segment`      | A segment of this project.                                                                             |
| `audienceRef`                                            | `group`, `sub` | The group key or the subscriber id, up to 256 characters.                                              |
| `environment`                                            | no             | `"production"` (default), `"staging"` or `"development"`.                                              |
| `skipIfActiveWithinDays`                                 | no             | `1`–`365` or `null`; segment rules only.                                                               |
| `pushData`                                               | no             | A JSON object (or JSON string) delivered with every send, up to 16 KiB.                                |
| `bigPictureURL`                                          | no             | An http(s) image url, up to 2,000 characters.                                                          |

Response — `201`: the saved `rule` plus a `timing` hint that compares its next send with the project's best send times (`best.dow` / `best.hour` are the UTC weekday and hour with the most notification opens in the last 90 days; `nearBest` is `true` when the next send lands on that weekday within two hours of it):

```json
{
  "ok": true,
  "timing": {
    "hasData": true,
    "totalOpens": 412,
    "best": { "dow": 6, "hour": 13, "opened": 37 },
    "nearBest": true,
    "cadenceChecked": { "frequency": "once", "dayOfWeek": null, "timeOfDay": "08:00" }
  },
  "rule": {
    "id": 42,
    "name": "Harvest bake sale",
    "enabled": true,
    "audienceKind": "group",
    "segmentId": null,
    "segmentName": null,
    "title": "Bake sale this Saturday",
    "body": "Pies, bread and cookies in the church hall from 9:00. Everything goes to the food pantry.",
    "pushData": { "url": "/events/bake-sale" },
    "frequency": "once",
    "dayOfWeek": null,
    "timeOfDay": "08:00",
    "timezone": "America/New_York",
    "skipIfActiveWithinDays": null,
    "cadence": "Once · Sat, Oct 3, 2026 · 08:00",
    "lastFiredAt": null,
    "lastFiredLocalDate": null,
    "pendingFireAt": null,
    "nextFireIso": "2026-10-03T12:00:00.000Z",
    "nextFireLocal": "Sat, Oct 3 · 08:00",
    "heldByQuietHours": false,
    "effectiveFireIso": "2026-10-03T12:00:00.000Z",
    "skipIfActiveApplies": false,
    "createdAt": "2026-09-25T16:20:00.000Z",
    "updatedAt": "2026-09-25T16:20:00.000Z",
    "lastRun": null,
    "contentMode": "fixed",
    "ai": null,
    "audienceRef": "church-newsletter",
    "audienceLabel": "Group “church-newsletter”",
    "onceDate": "2026-10-03",
    "completedAt": null,
    "environment": "production",
    "bigPictureURL": null
  }
}
```

- `id` is the rule's stable id for every other call.
- `nextFireIso` is the next send in UTC (`null` while paused or once complete); `nextFireLocal` is the same moment on the rule's clock.
- `lastRun` is the newest run (`status`, `firedAt`, `audienceCount`, `detail`, `title`, `body`, `sendId`).
- `completedAt` is set once a one-off has gone out or was missed.

### List, update, delete

- **`GET …/schedules`** — `{ ok, pipeline, environments, settings, rules }`: every rule in the shape above, the quiet-hours `settings`, `pipeline: "universal"` for an Agent Notify project, and `environments` — `{ default, choices: [{ key, label, devices }], showPicker }`: the enabled environments that have registered devices (production always, while it is enabled).
- **`GET …/schedules/upcoming`** — `{ ok, upcoming }`: the 10 soonest sends across enabled rules (`kind: "rule"` with `ruleId`, `whenIso`, `localText`, `frequency`, `contentMode`, `heldByQuietHours`, `effectiveFireIso`), merged with any pending classic scheduled notifications (`kind: "scheduled"`). An AI-written rule has no title here — it is written at send time.
- **`PATCH …/schedules/:ruleId`** — send only what changes; every other field keeps its stored value. `{ "enabled": false }` pauses a rule, `{ "enabled": true }` resumes it. `null` clears `pushData`, `bigPictureURL`, `skipIfActiveWithinDays` or `instructions`. Answers `{ ok, rule }`.
- **`DELETE …/schedules/:ruleId`** — `{ ok, deleted: { id, name } }`. The rule's run history is deleted with it; what it already sent stays in inboxes and analytics. Deleting a one-off that has not gone out cancels it.
- **`PUT …/schedules/settings`** — `{ quietHoursEnabled, quietHoursStart, quietHoursEnd }` (send only what changes); answers `{ ok, settings }`.

### Errors

Errors use the envelope `{ "error": { "code", "message", "field"? } }`:

| Status | Code                                          | Meaning                                                                                                                                                              |
| ------ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `bad_schedule`                                | A rule field is missing or invalid — the message names it (for example `timeOfDay must be a 24h time like "09:00"`, or `That group does not exist on this project`). |
| `400`  | `schedule_too_soon`                           | A one-off less than 2 minutes away.                                                                                                                                  |
| `400`  | `invalid_environment`, `environment_disabled` | Not one of the three environments, or switched off for this project.                                                                                                 |
| `400`  | `bad_app_id`, `bad_rule_id`, `bad_settings`   | A malformed id, or invalid quiet hours.                                                                                                                              |
| `404`  | `app_not_found`, `rule_not_found`             | The app is not one this account can access, or the rule is not on it.                                                                                                |
| `409`  | `rule_completed`                              | The one-off already went out (or was missed).                                                                                                                        |

An app token instead of an account credential answers `403` with a plain-text message.

## For agents

The [MCP server](/docs/mcp-server) wraps every route:

| Tool                            | Route                                                                   |
| ------------------------------- | ----------------------------------------------------------------------- |
| `list_schedule_rules`           | `GET …/schedules`                                                       |
| `list_upcoming_scheduled_sends` | `GET …/schedules/upcoming`                                              |
| `list_schedule_rule_runs`       | `GET …/schedules/:ruleId/runs`                                          |
| `create_schedule_rule`          | `POST …/schedules`                                                      |
| `update_schedule_rule`          | `PATCH …/schedules/:ruleId`                                             |
| `delete_schedule_rule`          | `DELETE …/schedules/:ruleId`                                            |
| `set_quiet_hours`               | `PUT …/schedules/settings`                                              |
| `generate_ai_schedule_sample`   | `POST …/schedules/ai-sample`                                            |
| `list_segments`                 | `GET /api/agent/apps/:appId/segments` — the ids for `segment` audiences |

The tools take snake\_case arguments (`time_of_day`, `audience_ref`, …), accept a weekday name for `day_of_week` and times like `"9:00 AM"`, and `create_schedule_rule` requires `enabled` — there is no default, because an enabled rule sends real pushes. Agents are instructed to confirm the copy, audience and timing with you before they turn a rule on.

## Related

- [AI-Written Notifications](/docs/universal-push/ai-notifications) — fresh copy at every send, and the sample endpoint.
- [Inbox & Audience](/docs/universal-push/inbox-and-audience) — what each send reached, and who is registered.
- [Send Notifications](/docs/universal-push/sending) — the same audiences, sent now.
- [Groups](/docs/universal-push/groups) and [Environments](/docs/universal-push/environments).
