# Scheduling

One-off, daily and weekly schedule rules — each in its own timezone, to all devices, a group or one subscriber, in any environment — with quiet hours, at-most-once dispatch and a run history.

**Schedule rules** send notifications for you on a cadence. One rule is:

- **when** — a one-off on a date, 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/push/ai-notifications));
- **who** — all devices, a [group](/docs/push/groups) (including the always-on Lapsed / Active / New) or one subscriber, in one [environment](/docs/push/environments).

Rules live in the dashboard's **Push notifications → Scheduling** tab (with an on/off switch, the next send, the last run, and quiet hours), the API below, and the [MCP server](/docs/mcp-server). Every send goes through the ordinary [send pipeline](/docs/push/sending) — one [inbox](/docs/push/web-inbox) entry per device, analytics included.

## Cadence

| `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, and `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 and can no longer be changed (`409 rule_completed`); create a new one.
- **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.                                                   |
| `group`         | `audienceRef` = the group key     | Every device subscribed to a [group](/docs/push/groups) — a manual group, or one of the always-on `lapsed` / `active` / `new` groups. |
| `sub`           | `audienceRef` = the subscriber id | Every device registered under that one subscriber id.                                                                                 |

- **`skipIfActiveWithinDays`** (`1`–`365`) skips subscribers whose device was seen in the last N days — "they already opened the app".
- An audience that resolves to nobody sends nothing and records an `empty` run.

## 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. One-offs are held the same way; send-now pushes 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: the runner claims the occurrence in the database before anything is sent (a second runner, or the same one after a restart, finds it claimed and skips it), and each send carries the idempotency key `rule:<ruleId>:<occurrence>`, so no device gets a second inbox entry for one occurrence. A run still `sending` 30 minutes later shows `outcomeUnknown: true` and is never sent again; a one-off whose whole grace window passed while the scheduler was not running is recorded `missed`. A `blocked` or `failed` send is not retried — the rule 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 and analytics. |
| `held`    | Quiet hours held it; a later run records the send.                                                                                                |
| `empty`   | Nobody to send to — nothing sent.                                                                                                                 |
| `blocked` | A gate refused it: an expired trial, a group audience on the Premium plan, or a disabled environment. `detail` says which.                        |
| `error`   | The send failed — for example devices matched but no transport accepted the push (missing credentials).                                           |
| `failed`  | An AI-written rule's copy could not be written — nothing was sent.                                                                                |
| `missed`  | A one-off whose time passed while the scheduler was not running — nothing was sent.                                                               |
| `sending` | Claimed and in flight. `unknown: true` when it never settled.                                                                                     |

## API

These are account routes: a dashboard session or an [MCP access token](/docs/mcp-server) (`Authorization: Bearer nnmcp_…`) — an app token is refused with `403`. Any member of the app's team may manage rules.

```text
GET    /api/apps/:appId/schedules                — rules + quiet hours
GET    /api/apps/:appId/schedules/upcoming       — the next sends
POST   /api/apps/:appId/schedules                — create a rule
PATCH  /api/apps/:appId/schedules/:ruleId        — update / pause / resume
DELETE /api/apps/:appId/schedules/:ruleId        — delete a rule
GET    /api/apps/:appId/schedules/:ruleId/runs   — run history
GET    /api/apps/:appId/schedules/settings       — quiet hours
PUT    /api/apps/:appId/schedules/settings       — set quiet hours
POST   /api/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" }
}
```

| 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/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/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), `"group"` or `"sub"`.                                                     |
| `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`; skips subscribers seen in the last N days.                              |
| `pushData`                                               | no             | A JSON object (or JSON string) delivered with every send, up to 16 KiB.                      |
| `bigPictureURL`                                          | no             | An absolute `https://` image url, up to 2,000 characters.                                    |

The response is the saved `rule` — with its `id`, `cadence`, `nextFireIso`, `heldByQuietHours`, `effectiveFireIso`, `lastRun` and, for AI rules, its `ai` brief — plus a `timing` hint comparing the next send with the app's best send times.

`PATCH …/schedules/:ruleId` takes only what changes; every other field keeps its stored value, `{ "enabled": false }` pauses a rule, and `null` clears `pushData`, `bigPictureURL`, `skipIfActiveWithinDays` or `instructions`. `DELETE` removes the rule and its run history — what it already sent stays in inboxes and analytics.

### Errors

| Status | Code                                          | Meaning                                                               |
| ------ | --------------------------------------------- | --------------------------------------------------------------------- |
| `400`  | `bad_schedule`                                | A rule field is missing or invalid — the message names it.            |
| `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 app.      |
| `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.

Every route is also an [MCP tool](/docs/mcp-server) — `list_schedule_rules`, `create_schedule_rule`, `update_schedule_rule`, `delete_schedule_rule`, `set_quiet_hours`, `generate_ai_schedule_sample` and friends. The tools take snake\_case arguments and accept times like `"9:00 AM"`; `create_schedule_rule` requires `enabled` explicitly, because an enabled rule sends real pushes.
