Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ is `skillhook`. User docs: `README.md`, `docs/`, `llms.txt`.
| `src/projects.ts` | `skillhook.yaml` (a repository's hooks): the zod schema (`HookSchema` = the `skillhook:` block + `run`/`skill`/`prompt`), loading, compiling hooks into `Skill`s, the starter template. |
| `src/registry.ts` | `SkillRegistry`: `<home>/skills` first, then linked projects from `projects` in `skillhook.json`; mtime caches for SKILL.md, skillhook.yaml and the config file, so nothing needs a restart. |
| `src/prompt.ts` | Placeholders, event block, unattended-run guardrails. |
| `src/schedule.ts`, `src/scheduler.ts` | Cron parsing and next/previous occurrence in an IANA zone (pure, no deps); the scheduler that fires `schedule:` hooks from `serve` (wall-clock tick, `catch_up` / `overlap`, exactly-once slots via the delivery index, state in `jobs/.schedules.json`). `src/commands/schedules.ts` is the CLI. |
| `src/jobs.ts`, `src/queue.ts`, `src/run.ts` | Job directories on disk, the concurrency queue, invocation preparation. |
| `src/runners/` | `claude.ts`, `codex.ts`, `shell.ts`: build argv, parse output; `env.ts` is the env allow-list. |
| `src/ops.ts` | Shared operations (create skill, run locally, sign+send, resolve URLs). CLI and MCP both call this; do not duplicate logic in either. |
Expand All @@ -40,14 +41,14 @@ is `skillhook`. User docs: `README.md`, `docs/`, `llms.txt`.
| `test/fixtures/` | `fake-claude.mjs` / `fake-codex.mjs` emulate the real CLIs' output formats. |

Runtime state lives outside the repo in `~/.skillhook` (`SKILLHOOK_HOME`):
`skillhook.json`, `.env` (mode 600), `skills/`, `jobs/`, `logs/`, `server.json`.
`skillhook.json`, `.env` (mode 600), `skills/`, `jobs/` (including `.deliveries.json` and `.schedules.json`), `logs/`, `server.json`.

## Hard rules

- **Runtime dependencies stay at three**: `@modelcontextprotocol/server`, `yaml`, `zod`. Everything else is `node:` built-ins. Node >= 22, ESM, TypeScript strict, imports end in `.js`.
- **Security is not optional.** The server binds `127.0.0.1` by default; TLS and public exposure are Tailscale's job. Every webhook goes through `verifyRequest`; every admin route through `requireAdmin`. Compare secrets only with `safeEqual`. A skill without `auth` gets a bearer token (`SKILLHOOK_SECRET_<NAME>`); `auth: none` must be explicit and is warned about. Secret values are never logged, never returned by an API/tool except once at generation, and never written into job files (`redactHeaders`). The agent's environment is an allow-list (`src/runners/env.ts`); `SKILLHOOK_ADMIN_TOKEN` and `SKILLHOOK_SECRET_*` are never forwarded implicitly.
- **Payloads are data.** Anything that reaches the prompt from a webhook is wrapped in `<webhook_payload>` and the guardrails say so. Never build a prompt by concatenating payload text outside those blocks.
- **Skills are Agent Skills.** Standard frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`) plus a `skillhook:` block. `name` must equal the directory name. New fields: add to the zod schema in `src/skills.ts`, to `docs/skills.md`, to `skills/skillhook-authoring/SKILL.md`, and cover them in `src/skills.test.ts` — in the same PR. A hook in `skillhook.yaml` is the same block plus exactly one of `run` / `skill` / `prompt` (`HookSchema` in `src/projects.ts` extends `SkillhookBlockSchema`, so new block fields reach hooks automatically); hook-only fields go in `src/projects.ts`, `docs/projects.md`, `npm run schema` and `src/projects.test.ts`. A compiled hook is an ordinary `Skill` (with `source.type === "project"`); never special-case hooks in the server, queue or runners.
- **Skills are Agent Skills.** Standard frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`) plus a `skillhook:` block. `name` must equal the directory name. New fields: add to the zod schema in `src/skills.ts`, to `docs/skills.md`, to `skills/skillhook-authoring/SKILL.md`, and cover them in `src/skills.test.ts` — in the same PR. `schedule` and `webhook` are block fields like any other (normalized by `resolveSchedule`, documented in `docs/schedules.md`). A hook in `skillhook.yaml` is the same block plus exactly one of `run` / `skill` / `prompt` (`HookSchema` in `src/projects.ts` extends `SkillhookBlockSchema`, so new block fields reach hooks automatically); hook-only fields go in `src/projects.ts`, `docs/projects.md`, `npm run schema` and `src/projects.test.ts`. A compiled hook is an ordinary `Skill` (with `source.type === "project"`); never special-case hooks in the server, queue or runners.
- **Config changes** go in `src/config.ts` (zod, `.prefault({})` for nested objects so defaults apply), then `npm run schema`, then `docs/operations.md`. `projects` is the one key the server re-reads without a restart (`configProjects` in `src/registry.ts`); keep it that way.
- **Runners never shell-interpolate.** Argv arrays only; the prompt travels on stdin; parse the CLI's structured output (`stream-json`, JSONL). When Claude Code or Codex change flags, update the runner, `test/fixtures/`, `docs/runners.md` and the version note in `README.md` together.
- **Jobs are directories.** `job.json` is the record; artifacts sit next to it; nothing outside `~/.skillhook/jobs` is written by the server. Statuses: `queued running succeeded failed timed_out cancelled interrupted`.
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang

## Unreleased

- Scheduled hooks. 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: a five-field expression or an alias
(`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`), read in an IANA `timezone` (default UTC), with
`catch_up` for slots missed while the server was stopped or the machine asleep (`latest` by default,
`all` up to 24, or `none`), `overlap` for a slot that comes due while the previous run is still going
(`skip` by default, or `queue`), and a static `payload`. `webhook: false` makes a scheduled hook
schedule-only: `POST /hooks/<name>` answers `404 schedule_only` and no secret is required. A slot is
identified by its wall-clock minute in the hook's zone and recorded 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. A new schedule waits for its next slot.
- Scheduled jobs carry `trigger: schedule`, `source.method: SCHEDULE`, `delivery_id: schedule:<slot>`
and the payload `{scheduled_for, schedule: {cron, timezone, slot, fired_at, caught_up, manual}, …}`;
the guardrails say the run was started by a schedule and has no external sender. State lives in
`jobs/.schedules.json`; the server logs `schedule registered`, `schedule fired` and
`schedule slots skipped`.
- `skillhook schedules list | next <name> [--count N] | run <name> [--wait S]`, the MCP tool
`list_schedules`, `schedules` in `GET /health` (admin), `webhook` and `schedule` (with `next_run_at`)
in `GET /skills`, `skills show` and the `skills list` URL column (`(schedule <cron>)` for
schedule-only hooks). `doctor` gains a `schedules` check and, on macOS, a `sleep` check that warns
when a machine with schedules is allowed to sleep; `doctor` and `skills validate` no longer ask
schedule-only hooks for a secret.
- `skillhook.yaml` and `SKILL.md` files that use `schedule` or `webhook` are rejected by older
servers (unknown keys have always been errors), so upgrade every linked machine before merging one.
Reference: `docs/schedules.md`.

## 0.2.0 (2026-09-17)

- Version-controlled hooks: a repository can declare its webhooks in a `skillhook.yaml` at its root.
Expand Down
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A scheduled task polls. It wakes up every N minutes, looks for work, usually fin
| Cost | tokens on empty runs | one run per event; retries and identical deliveries are de-duplicated |
| Where | wherever the scheduler runs | your machine: your logins, your checkouts, your MCP servers, your `CLAUDE.md` |

Keep schedules for digests and clean-ups; give everything that has a trigger a webhook. Anything that can call a URL can start a skill: SaaS webhooks (Granola, Sentry, GitHub, Linear, Stripe, Slack, Standard Webhooks), Zapier and Make, iOS Shortcuts, `curl` from a cron job, another agent.
Give everything that has a trigger a webhook. Anything that can call a URL can start a skill: SaaS webhooks (Granola, Sentry, GitHub, Linear, Stripe, Slack, Standard Webhooks), Zapier and Make, iOS Shortcuts, `curl`, another agent. The work that has no trigger (a sweep of whatever is overdue, a weekday digest, a weekly report, a nightly clean-up) gets a [`schedule:`](#scheduled-hooks) on the same skill or hook, version-controlled next to the webhooks: skillhook fires it on time in the zone you name, catches up slots missed while the machine slept, and never runs one twice.

## How it works

Expand All @@ -37,6 +37,7 @@ Keep schedules for digests and clean-ups; give everything that has a trigger a w

- A skill is a directory `~/.skillhook/skills/<name>/SKILL.md`: standard Agent Skills frontmatter plus a `skillhook:` block that sets the runner, model, authentication, filters and working directory. Edits apply to the next delivery without a restart.
- A repository can carry its own hooks in a version-controlled `skillhook.yaml` (webhook name → a shell command, a `SKILL.md` in the repository, or inline instructions); `skillhook link <dir>` serves them. See [Version-controlled hooks](#version-controlled-hooks-in-a-repository).
- Any skill or hook can also carry a `schedule:` (a cron expression, a time zone, and what to do about missed slots); the server fires it without a webhook. See [Scheduled hooks](#scheduled-hooks).
- The runner is the real `claude` or `codex` CLI on the machine, so subscriptions, MCP servers, `CLAUDE.md`/`AGENTS.md` files and tool permissions apply as usual.
- Responses are immediate (`202` with a job id) or synchronous with `?wait=N` (or `Prefer: wait=N`); the agent's final message becomes the job result.
- Developed against Claude Code 2.1.270, Codex CLI 0.153.4 and Tailscale 1.102.3. skillhook drives the CLIs through their headless flags (`claude -p --output-format stream-json …`, `codex exec --json …`); `skillhook run <skill> --dry-run` shows the exact command line.
Expand Down Expand Up @@ -185,6 +186,40 @@ skillhook projects # which hook runs what, from which repository,

`skillhook skills list` shows repository hooks next to local skills with their source, `skillhook unlink <dir>` stops serving them, and a `git pull` that changes the file is enough to deploy the change. Names in `~/.skillhook/skills` win over repositories, and a name defined twice is reported instead of guessed. Details: [docs/projects.md](docs/projects.md).

## Scheduled hooks

Not everything has a trigger. The sweep that applies defaults to overdue items, the weekday digest, the Friday report and the nightly clean-up run on time instead, from the same file:

```yaml
hooks:
overdue-sweep:
run: node tools/sweep.mjs
schedule: "*/30 * * * *" # cron, read in UTC
webhook: false # schedule-only: no URL, no secret

weekly-review:
skill: .claude/skills/weekly-review
model: opus
webhook: false
schedule:
cron: "0 16 * * 5"
timezone: America/New_York
catch_up: latest # slots missed while asleep: latest (default) | all | none
overlap: skip # previous run still going at the next slot: skip (default) | queue
```

A slot is identified by its wall-clock minute in the hook's zone, so a restart, a second check or the repeated hour of a fall-back night never fires it twice; a slot the machine slept through is caught up at wake according to `catch_up`. The job is an ordinary job with `trigger: schedule` and a payload that says which slot fired (`{{payload.scheduled_for}}`). The same key works in a `SKILL.md`'s `skillhook:` block.

```bash
skillhook schedules list # cron, zone, next due, last run and its status, for every schedule
```

```bash
skillhook schedules run weekly-review --wait 60 # fire one now, with a scheduled payload
```

`skillhook doctor` lists the schedules and warns when a scheduled Mac is allowed to sleep. Details: [docs/schedules.md](docs/schedules.md).

## Choosing runner and model

| | `claude` | `codex` | `shell` |
Expand Down Expand Up @@ -296,6 +331,7 @@ Agents reading this repository should start with [`AGENTS.md`](AGENTS.md) (layou
| `skillhook config show\|get <key>\|set <key> <value>\|unset <key>\|path` | Read and edit `skillhook.json`. |
| `skillhook link [dir] [--no-secret]` / `skillhook unlink <dir>` | Serve the hooks a repository declares in its `skillhook.yaml` (default `.`); stop serving them. |
| `skillhook projects [list]` / `skillhook projects init [dir] [--force]` | List linked repositories and their hooks; write a starter `skillhook.yaml` and link it. |
| `skillhook schedules [list]` · `schedules next <name> [--count N]` · `schedules run <name> [--wait S]` | Every skill or hook with a `schedule:`, its next and last runs; preview occurrences; fire one now. |
| `skillhook update [--install]` | Check npm for a newer skillhook; `--install` upgrades with the package manager that installed it and restarts the background service when it is idle. |

Global options: `--dir <path>` (default `$SKILLHOOK_HOME` or `~/.skillhook`), `--json` (machine-readable output for every command), `--help`, `--version`. Exit codes: 0 success, 1 failure, 2 usage error. Environment: `SKILLHOOK_HOME`, `SKILLHOOK_NO_UPDATE_CHECK=1` (or `CI`) to silence the daily update check, `SKILLHOOK_NPM_REGISTRY` for a mirror, `SKILLHOOK_DEBUG=1` for stack traces. HTTP API: [docs/api.md](docs/api.md). Service, logs, jobs, config and troubleshooting: [docs/operations.md](docs/operations.md).
Expand All @@ -309,7 +345,8 @@ Global options: `--dir <path>` (default `$SKILLHOOK_HOME` or `~/.skillhook`), `-
├── server.json pid/host/port while `serve` runs; removed on shutdown
├── skills/<name>/SKILL.md one directory per skill (plus any files the skill needs)
├── jobs/<id>/ job.json, payload.json, event.json, prompt.md, stdout.log, stderr.log, result.md
├── jobs/.deliveries.json replay-protection index
├── jobs/.deliveries.json replay-protection index (also the slots the scheduler has fired)
├── jobs/.schedules.json per schedule: last slot handled, last job and its status
├── update-check.json what npm said at the last daily update check
└── logs/service.log server output when run by launchd / systemd
```
Expand All @@ -328,6 +365,8 @@ Override the location with `SKILLHOOK_HOME=<path>` or `--dir <path>`.

**Can I run a plain script instead of an agent?** Yes. In a repository's `skillhook.yaml`, `run: ./scripts/deploy.sh` (or any command) runs it in the repository with the payload on stdin and the `SKILLHOOK_*` variables set; in a `SKILL.md`, `runner: shell` with `shell.command` does the same. Exit code 0 is success, stdout is the result. See [docs/projects.md](docs/projects.md) and [docs/runners.md](docs/runners.md#shell-runner).

**Can a skill run on a schedule instead of a webhook?** Yes. Add `schedule: "*/30 * * * *"` (or `{ cron, timezone, catch_up, overlap }`) to a skill's `skillhook:` block or a hook in `skillhook.yaml`, and `webhook: false` when it should have no URL at all. The running server fires it on time, catches up slots missed while the machine slept (`catch_up`), and never fires one slot twice. `skillhook schedules list` shows what will run when. See [docs/schedules.md](docs/schedules.md).

**Can several skills run at once?** Two jobs globally by default (`concurrency` in `skillhook.json`) and one per skill (`skillhook.concurrency` in `SKILL.md`); the rest wait in a FIFO queue that survives restarts. The same webhook firing twice with the same payload while the first run is still queued or running does not start a second job; the sender gets the first job's id (`duplicate: true, in_flight: true`).

**How do I update?** skillhook asks npm once a day (in the background, cached in `~/.skillhook/update-check.json`) and mentions a newer version after a command, in `skillhook doctor` and in the server log. `skillhook update` checks right now; `skillhook update --install` upgrades with whatever installed it (npm, pnpm, bun, yarn) and restarts the background service if no job is running. Opt out with `SKILLHOOK_NO_UPDATE_CHECK=1` or `"update_check": false` in `skillhook.json`. Releases and notes: [GitHub releases](https://github.com/MeterApp/skillhook/releases).
Expand Down
Loading
Loading