# Extension schedules

> Run an extension's handler or send a prompt at a set time, once or on a repeat, with ctx.schedules.

`ctx.schedules` sets [reminders](https://zidane.si/docs/reminders.md) from an extension. A schedule fires once, on a repeat, or on a cron expression. When it fires, it either sends a prompt to a session or calls a handler of the extension. What the extension creates is tagged `createdBy: { kind: 'extension', id }`, and the apps show it as set by the extension.

Schedules run on the chat host: the desktop app's, the daemon's or a server's. Every call rejects while the person has Reminders off (Settings › General › Sessions › Reminders), and on a host without a chat host. `ctx.schedules.enabled()` says whether they work, and never rejects.

## A daily nudge

Register the handler in `setup`, then create the schedule once. A cron trigger takes five fields and an IANA time zone.

```ts
ctx.schedules.onFire('stretch', async ({ payload }) => {
  const { sessionId } = payload as { sessionId: string }
  await ctx.sessions.note(sessionId, [{ text: 'Time to stretch.' }])
})

ctx.commands.register({
  id: 'stretch',
  title: 'Remind me to stretch on weekdays',
  run: async ({ sessionId }) => {
    if (!sessionId || !(await ctx.schedules.enabled()))
      return
    await ctx.schedules.create({
      name: 'Stretch',
      trigger: { kind: 'cron', expr: '0 15 * * 1-5', tz: 'Europe/Paris' },
      action: { kind: 'extension', extensionId: ctx.id, handler: 'stretch', payload: { sessionId } },
    })
  },
})
```

A schedule outlives the extension's process: it is stored on the host. Before you create one in `setup`, `list` the existing ones, or you get a new copy on each load.

## Triggers and actions

| Trigger | Shape | Fires |
| - | - | - |
| `at` | `{ kind: 'at', at }` | Once, at `at` (epoch ms) |
| `every` | `{ kind: 'every', everyMs, anchor }` | Every `everyMs`, counted from `anchor` (epoch ms) |
| `cron` | `{ kind: 'cron', expr, tz }` | On a five-field cron expression, read in time zone `tz` |

| Action | Shape | Does |
| - | - | - |
| Prompt | `{ kind: 'prompt', sessionId, text }` | Sends `text` to the session. It waits behind the current run. |
| New session | `{ kind: 'prompt', newSession: { cwd, agentId? }, text }` | Creates a session in `cwd` on each fire and sends `text` to it. `cwd: ''` is a chat with no folder. |
| Handler | `{ kind: 'extension', extensionId, handler, payload? }` | Calls the handler this extension registered with `onFire`, with `payload` |

`catchUp` says what to do with fires the host missed while it was down. `'once'` fires once, late. `'skip'` skips them. The default is `'once'` for `at` and `'skip'` for repeats.

A repeat is at least one minute apart. A host keeps at most 500 schedules. A prompt is at most 16 KB, and a name at most 120 characters. A bad input rejects with every problem named.

## The domain

| Member | Returns | Does |
| - | - | - |
| `enabled()` | `boolean` | Whether Reminders is on and the host keeps schedules. Never rejects. |
| `create(input)` | `Schedule` | Creates a schedule: `name?`, `trigger`, `action`, `catchUp?`, `enabled?` |
| `list(filter?)` | `Schedule[]` | Every schedule on the host, from any creator, oldest first. `{ sessionId }` keeps the ones whose prompt goes to that session. |
| `get(id)` | `Schedule \| null` | One schedule |
| `update(id, patch)` | `Schedule` | Changes some fields. A new trigger, or turning it back on, counts its next fire from now. |
| `remove(id)` | `boolean` | Removes a schedule and its fires. `false` when it was gone. |
| `runNow(id)` | `ScheduleFire` | Runs the action now. The next fire does not move. |
| `fires(id, limit?)` | `ScheduleFire[]` | The schedule's fires, newest first. 20 by default, at most 50. |
| `onFire(handler, fn)` | `Disposable` | Runs `fn` when a schedule with this extension's `handler` fires |

A `Schedule` carries its `id`, `trigger`, `action`, `enabled`, `createdBy`, `nextFireAt` (`null` when it will not fire again), `lastFire`, and `disabledReason` when the host turned it off. A `ScheduleFire` has `dueAt`, `firedAt`, `outcome` (`sent`, `queued`, `merged`, `handled`, `skipped` or `failed`), `late`, `sessionId` and `error`.

## Handlers

The handler gets the `schedule` as it was when it fired, `dueAt`, `late` (the host was down past `dueAt`), `manual` (it runs from `runNow`) and the action's `payload`.

Register handlers during `setup`. A fire that finds no handler is recorded as failed and skipped, not held for later. Unloading the extension removes its handlers. When the person removes the extension, the host turns its schedules off, with the reason in `disabledReason`.

## Test it

`createTestHost` runs `ctx.schedules` on an in-memory scheduler with a clock the test moves. `host.schedules.advance(ms)` moves the clock and runs every fire that came due, handlers included, then resolves. A prompt does not run a session: it goes to `host.schedules.prompts`. See [testing extensions](https://zidane.si/docs/extensions-testing.md#schedules).

```ts title="nudge.test.ts"
import type { TestHost } from 'zidane/extensions/testing'
import { afterEach, expect, it } from 'bun:test'
import { defineExtension } from 'zidane'
import { createTestHost } from 'zidane/extensions/testing'

const ticks: boolean[] = []
const nudge = defineExtension({
  apiVersion: 1,
  id: 'nudge',
  setup(ctx) {
    ctx.schedules.onFire('tick', ({ manual }) => {
      ticks.push(manual)
    })
  },
})

let host: TestHost | undefined
afterEach(async () => {
  await host?.dispose()
  host = undefined
})

it('fires on the test clock', async () => {
  host = await createTestHost({ extensions: [nudge] })
  const ctx = host.ctx('nudge')
  await ctx.schedules.create({
    trigger: { kind: 'at', at: host.schedules.now() + 20 * 60_000 },
    action: { kind: 'extension', extensionId: 'nudge', handler: 'tick' },
  })
  await ctx.schedules.create({
    trigger: { kind: 'at', at: host.schedules.now() + 30 * 60_000 },
    action: { kind: 'prompt', sessionId: 's1', text: 'Check the deploy' },
  })

  await host.schedules.advance(30 * 60_000)
  expect(ticks).toEqual([false])
  expect(host.schedules.prompts.map(p => p.text)).toEqual(['Check the deploy'])

  host.schedules.setEnabled(false)
  expect(await ctx.schedules.enabled()).toBe(false)
})
```

## Related

- [Reminders](https://zidane.si/docs/reminders.md): the time words, the apps, the agent's tool and where schedules run
- [Sessions](https://zidane.si/docs/extensions-sessions.md): `ctx.sessions`, to read and drive the sessions a schedule sends to
- [Testing extensions](https://zidane.si/docs/extensions-testing.md)
