Scheduled hooks: schedule: on a skill or hook, fired by the server - #9
Merged
Merged
Conversation
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
enabled auto-merge (squash)
September 23, 2026 21:12
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: falsemakes a scheduled hook schedule-only:POST /hooks/<name>answers404 schedule_onlyand no secret is required.src/schedule.ts: five-field cron (lists, ranges, steps, names,7= Sunday, Vixie day semantics) and the@hourly…@yearlyaliases;nextRun/previousRunin an IANA zone by stepping wall-clock minutes withIntl.DateTimeFormat. No new dependency.src/scheduler.ts: a 15 s wall-clock tick (monotonic timers stop while a machine sleeps) that fires due slots throughcreateManualJob+ 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 injobs/.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.skillhook schedules list | next <name> | run <name>, MCPlist_schedules,schedulesinGET /health(admin),webhook+schedule(withnext_run_at) inGET /skills,skills show/list,doctor(schedulescheck;sleepcheck on macOS viapmset; schedule-only hooks are not asked for a secret),skills validatelikewise.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
scheduleandwebhookare ordinary block fields normalized byresolveSchedule(likenormalizeAuth), so hooks inherit them from a SKILL.md and can override them;schedule: falsecancels 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.registry.list()on every tick, so agit pullthat adds or changes a schedule is picked up without a restart, like every other hook change.createManualJobnow takesPick<Ops, "config" | "store">plus optionaldeliveryId/sourceMethod, so the scheduler reuses the server's store and queue (a secondJobStorewould clobber.deliveries.json).docs/schedules.mdsays to upgrade every linked machine first.Security
server.ts: awebhook: falseskill is unreachable over HTTP (404 schedule_onlyon GET/HEAD/POST/PUT, indistinguishable from an unknown skill to an outsider except for the code). AdminPOST /skills/<name>/runstill works (admin only, as before)./healthexposesschedulesonly to admin/local callers, likequeue.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.doctorrunspmset -g custom(read-only) on macOS when schedules exist.Tests
npm run checkgreen (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_uplatest/all/none incl. the 24-slot cap;overlapskip vs queue; duplicate detection across a lost state file and the repeated fall-back hour; disabled and schedule-only skills; static payload merge; askillhook.yamlchange picked up without restart), plus cases inskills.test.ts,projects.test.ts,server.test.ts(schedule_only,/health.schedules,/skillsfields) andcli.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 realservewithin seconds of the minute, with the slot in its payload and state injobs/.schedules.json.🤖 Generated with Claude Code