Skip to content

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 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.

TypeScript
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#

TriggerShapeFires
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
ActionShapeDoes
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#

MemberReturnsDoes
enabled()booleanWhether Reminders is on and the host keeps schedules. Never rejects.
create(input)ScheduleCreates 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 | nullOne schedule
update(id, patch)ScheduleChanges some fields. A new trigger, or turning it back on, counts its next fire from now.
remove(id)booleanRemoves a schedule and its fires. false when it was gone.
runNow(id)ScheduleFireRuns 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)DisposableRuns 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.

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)
})