Skip to content

Scheduled hooks: schedule: on a skill or hook, fired by the server - #9

Merged
JOsacky merged 1 commit into
mainfrom
claude/schedules-a3cd3c
Sep 23, 2026
Merged

JOsacky merged 1 commit into
mainfrom
claude/schedules-a3cd3c

Conversation

@JOsacky

@JOsacky JOsacky commented Sep 23, 2026

Copy link
Copy Markdown
Member

What

A schedule: key on any skill (skillhook: block) or hook (skillhook.yaml) runs it on a cron schedule from the running server, without a webhook. webhook: false makes a scheduled hook schedule-only: POST /hooks/<name> answers 404 schedule_only and no secret is required.

hooks:
  overdue-sweep:
    run: node tools/sweep.mjs
    webhook: false
    schedule: "*/30 * * * *"                    # cron, read in UTC
  weekly-review:
    skill: .claude/skills/weekly-review
    webhook: false
    schedule: { cron: "0 16 * * 5", timezone: America/New_York, catch_up: latest, overlap: skip }
  • src/schedule.ts: five-field cron (lists, ranges, steps, names, 7 = Sunday, Vixie day semantics) and the @hourly…@yearly aliases; nextRun / previousRun in an IANA zone by stepping wall-clock minutes with Intl.DateTimeFormat. No new dependency.
  • src/scheduler.ts: a 15 s wall-clock tick (monotonic timers stop while a machine sleeps) that fires due slots through createManualJob + the server's own queue. A slot is identified by its wall-clock minute (delivery_id: schedule:2026-11-01T01:30) and remembered in the delivery index, so a restart, a second tick or the repeated hour of a fall-back night never runs it twice; a minute that does not exist on a spring-forward night is skipped. catch_up: latest | all (≤24) | none, overlap: skip | queue (one decision per tick, so a batch of caught-up slots queues behind itself). A schedule seen for the first time waits for its next slot. State in jobs/.schedules.json.
  • trigger: "schedule", source.method: SCHEDULE, payload {scheduled_for, schedule: {cron, timezone, slot, fired_at, caught_up, manual}, …static}; the guardrails say the run was started by a schedule and has no external sender.
  • Surface: skillhook schedules list | next <name> | run <name>, MCP list_schedules, schedules in GET /health (admin), webhook + schedule (with next_run_at) in GET /skills, skills show/list, doctor (schedules check; sleep check on macOS via pmset; schedule-only hooks are not asked for a secret), skills validate likewise.
  • Docs: docs/schedules.md (new), README pitch reworded plus a "Scheduled hooks" section, docs/{skills,projects,api,operations,mcp}.md, llms.txt, both plugin skills, AGENTS.md, CHANGELOG.

Design notes

  • schedule and webhook are ordinary block fields normalized by resolveSchedule (like normalizeAuth), so hooks inherit them from a SKILL.md and can override them; schedule: false cancels an inherited schedule so two hooks sharing one SKILL.md do not both fire. An unusable cron or zone stops the skill from loading rather than silently never firing.
  • The scheduler calls registry.list() on every tick, so a git pull that adds or changes a schedule is picked up without a restart, like every other hook change.
  • createManualJob now takes Pick<Ops, "config" | "store"> plus optional deliveryId / sourceMethod, so the scheduler reuses the server's store and queue (a second JobStore would clobber .deliveries.json).
  • Older servers reject a file that uses the new keys (unknown keys have always been errors); docs/schedules.md says to upgrade every linked machine first.

Security

  • server.ts: a webhook: false skill is unreachable over HTTP (404 schedule_only on GET/HEAD/POST/PUT, indistinguishable from an unknown skill to an outsider except for the code). Admin POST /skills/<name>/run still works (admin only, as before). /health exposes schedules only to admin/local callers, like queue.
  • prompt.ts: only the trigger sentence of the guardrails changes; payloads still arrive inside <webhook_payload> and the schedule payload is composed by skillhook, never by a sender.
  • No new outbound requests, no new secrets, no new dependency. doctor runs pmset -g custom (read-only) on macOS when schedules exist.

Tests

npm run check green (typecheck, 150 vitest tests in 22 files, build, schema check, release check). New: src/schedule.test.ts (parsing, aliases, errors, UTC and New York DST spring-forward/fall-back, half-hour zones), src/scheduler.test.ts (first sighting waits; exactly-once per slot; catch_up latest/all/none incl. the 24-slot cap; overlap skip vs queue; duplicate detection across a lost state file and the repeated fall-back hour; disabled and schedule-only skills; static payload merge; a skillhook.yaml change picked up without restart), plus cases in skills.test.ts, projects.test.ts, server.test.ts (schedule_only, /health.schedules, /skills fields) and cli.test.ts (schedules list|next|run, validate/list/show/doctor for a schedule-only hook). Smoke-tested the built CLI: a * * * * * shell hook fired from a real serve within seconds of the minute, with the slot in its payload and state in jobs/.schedules.json.

🤖 Generated with Claude Code

A `schedule:` key (cron expression, IANA timezone, catch_up, overlap, static
payload) on any skill or skillhook.yaml hook makes `serve` fire it on time
without a webhook; `webhook: false` makes it schedule-only (404 schedule_only,
no secret). Slots are identified by their wall-clock minute in the hook's zone
and recorded in the delivery index, so restarts, repeated ticks and the
repeated hour of a fall-back night never fire one twice; non-existent
spring-forward minutes are skipped. Missed slots follow catch_up (latest,
all up to 24, none), overlapping slots follow overlap (skip, queue).

Adds src/schedule.ts (pure cron parsing and next/previous occurrence with
Intl.DateTimeFormat, no deps), src/scheduler.ts (wall-clock tick, state in
jobs/.schedules.json), trigger "schedule" with guardrail wording, queue
inFlight(), schedules in /health and skillSummary, `skillhook schedules
list|next|run`, the MCP tool list_schedules, doctor `schedules` and macOS
`sleep` checks, docs/schedules.md, and the README/docs/plugin-skill updates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@JOsacky
JOsacky enabled auto-merge (squash) September 23, 2026 21:12
@JOsacky
JOsacky merged commit f2d753a into main Sep 23, 2026
6 checks passed
@JOsacky
JOsacky deleted the claude/schedules-a3cd3c branch September 23, 2026 21:13
@JOsacky JOsacky mentioned this pull request Sep 23, 2026
JOsacky added a commit that referenced this pull request Sep 23, 2026
## What

Version bump to 0.3.0 (`npm run release -- minor`): `package.json`,
`package-lock.json`, the three plugin manifests, and the Unreleased
changelog section moved under `## 0.3.0`. When this merges, the Release
workflow tags `v0.3.0` and dispatches Publish.

## Changes since 0.2.0

- Scheduled hooks (`schedule:` on any skill or hook; `webhook: false`
for schedule-only hooks), `skillhook schedules`, `list_schedules`,
`/health.schedules`, doctor checks. See #9 and `docs/schedules.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant