The server is plain node:http. Locally it listens on http://127.0.0.1:8787 (host and port in skillhook.json); publicly it is whatever your tunnel maps to it (exposure.md). Every route is available on both.
Conventions:
- JSON responses are pretty-printed,
content-type: application/json; charset=utf-8, withcache-control: no-storeandx-content-type-options: nosniff.GET /andGET /hooks/<skill>returntext/plain. - Errors are
{"ok": false, "error": "<code>", "message": "<text>"}; codes are listed at the end. - Timeouts: keep-alive 65 s, headers 70 s, whole request
max(300 s, max_wait_seconds + 30 s). - Every request counts against the per-IP limit
rate_limit.requests_per_minute(120); beyond it the answer is429 rate_limited. GET /eventsandGET /jobs/<id>/eventsanswertext/event-streamand stay open; every other route is one JSON (or text) response.
Related: security.md (authentication), skills.md (filters, dedupe, placeholders), operations.md (job files).
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/ |
none | Banner: skillhook <version> plus a hint. |
GET |
/health |
none; admin for details | Liveness. Public callers get {ok, version}; admin callers also get uptime_seconds, queue and schedules. |
GET |
/health/checks |
admin | The grouped health report (skillhook health), cached; ?deep=0, ?network=1, ?refresh=1. |
GET |
/doctor |
admin | The quick report (skillhook doctor), cached; ?network=0, ?refresh=1. |
GET |
/runners |
admin | Is each runner installed and logged in (what a job checks before it starts); ?refresh=1. |
GET |
/stats |
admin | Numbers over jobs and deliveries: by status, outcome, runner, failure kind; durations, cost, tokens; per skill (?since=24h, ?until=, ?skill=). |
GET, PATCH |
/config |
admin | The live skillhook.json (defaults applied) with what applies live and what needs a restart; change keys in one validated write. |
POST |
/config/reload |
admin | Re-read skillhook.json (after editing it by hand). |
POST |
/control/restart |
admin | Stop, let running jobs finish, exit; launchd / systemd starts the server again. 409 when not run as the service. |
GET |
/service |
admin | The launchd / systemd service status. |
GET |
/logs |
admin | The last lines of the service log (?lines=200). |
POST |
/update |
admin | Ask npm for a newer skillhook; {"install": true} installs it (the server does not restart itself). |
GET, HEAD |
/hooks/<skill> |
none | 200 text when the skill exists, is enabled and has a webhook, 404 otherwise (schedule_only for a webhook: false skill). Lets providers "test" the URL. |
POST, PUT |
/hooks/<skill> |
the skill's auth |
Deliver a webhook. 404 schedule_only for a skill with webhook: false. |
GET |
/skills |
admin | Every skill with its effective settings. |
POST |
/skills/<skill>/run |
admin | Run a skill with an arbitrary payload, bypassing webhook auth. |
POST |
/skills/test |
admin | Run a SKILL.md that is not installed (the document travels in the body). |
GET |
/jobs |
admin | Recent jobs (?waiting=1: only those waiting for a person). |
GET |
/jobs/<id> |
admin | One job, optionally with artifacts. |
POST |
/jobs/<id>/cancel |
admin | Cancel a queued or running job. |
GET |
/jobs/<id>/progress |
admin | What the agent reported: current state, pending question, answer, timeline. |
POST |
/jobs/<id>/answer |
admin | A person's answer: delivered live to a waiting job, or a new job continues the session. |
GET |
/jobs/<id>/artifacts/<name> |
admin | One artifact file as it is on disk (?tail=<bytes> for its end). |
GET |
/jobs/<id>/events |
admin | Server-sent events for one job: status snapshots, stdout/stderr as they are written, end. |
GET |
/events |
admin | Server-sent events for the whole server: delivery.received, job.*, schedule.*, skill.changed, server.* (?types= to filter). |
GET |
/deliveries |
admin | Every webhook received, newest first, whatever became of it. |
GET |
/deliveries/<id> |
admin | One delivery, optionally with its body. |
POST |
/deliveries/<id>/replay |
admin | Run a recorded delivery again, as a new job. |
POST |
/jobs/<id>/replay |
admin | Run the request an earlier job received again, as a new job. |
Anything else is 404 not_found; another method on /hooks/<skill> is 405 method_not_allowed.
Processing order:
- Rate limit per client IP.
- Skill lookup: unknown, disabled or malformed name ->
404 unknown_skill; a schedule-only skill (webhook: false) ->404 schedule_only; aSKILL.mdthat fails to parse ->500 invalid_skill(details in the server log). - Body read: a
Content-Lengthor streamed size abovemax_body_bytes(1 MiB) ->413 payload_too_large. - Authentication per the skill's
auth:403 ip_not_allowed,401 <code>, or503 skill_not_configuredwhen the secret env var is missing. Afterrate_limit.auth_failures_per_minute(10) failures from one IP within a minute the answer becomes429 too_many_failures. - Body parsing by content type (JSON, form, text, binary; see skills.md).
- Slack skills only:
{"type":"url_verification","challenge":"…"}->200 {"challenge":"…"}, no job. - Delivery id (provider header,
dedupe.header,dedupe.path); a repeat withinjobs.dedupe_window_seconds->200withduplicate: trueand the originaljob_id. whenfilters ->200withskipped: truewhen they do not match.- In-flight check (
dedupe.in_flight, defaultjobs.dedupe_in_flight=true): a payload and query string identical to a job of this skill that is still queued or running ->200withduplicate: true,in_flight: trueand that job'sjob_id; with?wait=the response waits for that job instead. - The job is written to disk and queued; the response is sent.
Whatever the step it stopped at, every request to /hooks/<skill> is recorded in the delivery log with its outcome, the status it was answered with and the reason (GET /deliveries); a refused delivery keeps its body so it can be inspected and replayed.
Asynchronous (default): 202 Accepted
{
"ok": true,
"job_id": "20260916T025442Z-r1wn6g",
"status": "queued",
"skill": "hello",
"status_url": "/jobs/20260916T025442Z-r1wn6g"
}Synchronous: add ?wait=<seconds> or send a Prefer: wait=<seconds> header (clamped to max_wait_seconds, default 120). When the job finishes in time the answer is 200; ok reflects the job status, and the body also carries outcome and response (whether the task was done and what the agent reported, null when it reported nothing; see skills.md):
{
"ok": true,
"job_id": "20260916T025442Z-e634on",
"status": "succeeded",
"result": "Summarized the payload and wrote hello.md.",
"error": null,
"job": {
"id": "20260916T025442Z-e634on",
"skill": "hello",
"status": "succeeded",
"trigger": "webhook",
"runner": "claude",
"created_at": "2026-09-16T02:54:42.452Z",
"started_at": "2026-09-16T02:54:42.497Z",
"finished_at": "2026-09-16T02:54:42.547Z",
"duration_ms": 50,
"cwd": "/Users/me/.skillhook/skills/hello",
"session_id": "6f1c…",
"resume_command": "cd /Users/me/.skillhook/skills/hello && claude --resume 6f1c…",
"exit_code": 0,
"signal": null,
"cost_usd": 0.0123,
"usage": { "input_tokens": 10, "output_tokens": 5 },
"num_turns": 1,
"result": "Summarized the payload and wrote hello.md.",
"source": { "ip": "203.0.113.7", "method": "POST", "path": "/hooks/hello", "content_type": "application/json", "user_agent": "curl/8.7.1" }
}
}A job that ended as failed, timed_out or cancelled is still 200, with "ok": false, the status, and error set. When the wait elapses first the answer is 202:
{ "ok": true, "job_id": "20260916T025442Z-e634on", "status": "running", "status_url": "/jobs/20260916T025442Z-e634on", "note": "still running after 30s" }Duplicate delivery: 200
{ "ok": true, "duplicate": true, "job_id": "20260916T025442Z-w0un7d", "status_url": "/jobs/20260916T025442Z-w0un7d" }Identical delivery still in flight: 200
{ "ok": true, "duplicate": true, "in_flight": true, "job_id": "20260916T025442Z-w0un7d", "status": "running", "status_url": "/jobs/20260916T025442Z-w0un7d" }Filtered out: 200
{ "ok": true, "skipped": true, "reason": "payload.action equals \"created\": expected \"created\"" }Duplicates and skips are 2xx on purpose: providers treat them as delivered and stop retrying.
Bearer (the default auth):
curl -sS -X POST "https://mac.tail1234.ts.net/hooks/hello?wait=60" \
-H "Authorization: Bearer $SKILLHOOK_SECRET_HELLO" \
-H "Content-Type: application/json" \
-d '{"name":"world"}'GitHub-style HMAC, signed by hand:
body='{"action":"opened","issue":{"number":1}}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$GH_WEBHOOK_SECRET" | sed 's/^.* //')
curl -sS -X POST https://mac.tail1234.ts.net/hooks/github-issue-triage \
-H "X-Hub-Signature-256: sha256=$sig" \
-H "X-GitHub-Delivery: $(uuidgen)" \
-H "X-GitHub-Event: issues" \
-H "Content-Type: application/json" \
-d "$body"For any scheme (Standard Webhooks, Stripe, Slack, …) skillhook send <skill> --payload … [--public] builds the right headers from the stored secret, and the MCP tool send_test_webhook does the same.
Poll an asynchronous job:
curl -sS -H "Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN" https://mac.tail1234.ts.net/jobs/20260916T025442Z-r1wn6g?include=resultAdmin routes accept Authorization: Bearer <SKILLHOOK_ADMIN_TOKEN>. Without a token they are allowed only for direct loopback connections that carry no proxy header (X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Real-IP, CF-Connecting-IP, Forwarded, Via, Tailscale-User-Login, ngrok-trace-id); that is how the CLI and the MCP server talk to the local server. Through a tunnel the token is mandatory. If no token is configured the response is 401 unauthorized with the message admin endpoints need SKILLHOOK_ADMIN_TOKEN (run: skillhook secret generate admin); with a token configured but absent or wrong it is admin token required.
Public: {"ok": true, "version": "0.1.0"}. Admin or direct local: adds "cloud" (the Skillhook Cloud link: {state, reason?, mode, enabled, url, machine_id, last_sync_at, last_error, connected_since, syncs, outbox_depth, dropped_total, ingress_urls, …}, or null for a server without one), "uptime_seconds", "queue": {"running": 0, "queued": 0, "running_ids": []}, "deliveries": {"total": 412, "last_received_at": "2026-09-28T10:00:02.000Z"} (the delivery log) and "schedules", one entry per skill or hook with a schedule::
{ "skill": "weekly-review", "cron": "0 16 * * 5", "timezone": "America/New_York", "catch_up": "latest", "overlap": "skip", "enabled": true, "webhook": false, "next_due": "2026-09-25T20:00:00.000Z", "last_slot": "2026-09-18T20:00:00.000Z", "last_fired_at": "2026-09-18T20:00:09.120Z", "last_job": "20260918T200009Z-k3x9q2", "last_status": "succeeded", "skipped": 0 }The CLI and MCP server use this route to detect a running server, and skillhook schedules list prefers its live schedules over the state file.
The report of skillhook health: {checks, ok, summary, groups, generated_at, duration_ms, deep, network, cached, public_url?, server?}. Each check is {name, status: ok|warn|fail|skip, detail, hint?, group: system|skillhook|runners|tools|skills|exposure, data?}. The server keeps one report per flavour for health.cache_seconds (60) and answers from it (cached: true); ?refresh=1 probes again, ?deep=0 leaves out the slow probes (MCP servers, plugins, codex doctor, last runs), and ?network=1 also asks the npm registry for a newer version and probes the public URL (off by default: the server makes no outbound request unless asked). The server check describes this very process (uptime, queue). Concurrent callers share one probe run.
The quick flavour, as skillhook doctor prints it: GET /health/checks?deep=0 with network on by default (?network=0 to turn it off).
{runners: [{runner, found, path?, version?, authenticated, method?, detail, hint?, ready, checked_at}], default_runner} for claude, codex and shell: whether each is installed and logged in or given an API key, as the queue checks before every job (runners.md). Answers are cached for health.readiness_cache_seconds; ?refresh=1 probes again.
{
"skills": [
{
"name": "hello",
"description": "Smoke-test skill…",
"enabled": true,
"runner": "claude",
"model": null,
"effort": null,
"cwd": "/Users/me/.skillhook/skills/hello",
"timeout_seconds": 300,
"path": "/hooks/hello",
"auth": {
"type": "bearer",
"secret_env": "SKILLHOOK_SECRET_HELLO",
"configured": true,
"how": "Authorization: Bearer <$SKILLHOOK_SECRET_HELLO>"
},
"when": ["payload.action equals \"created\""],
"webhook": true,
"schedule": null,
"dir": "/Users/me/.skillhook/skills/hello",
"file": "/Users/me/.skillhook/skills/hello/SKILL.md",
"source": { "type": "home" }
},
{
"name": "pull-after-merge",
"description": "Fast-forward this checkout when a pull request merges.",
"enabled": true,
"runner": "shell",
"model": null,
"effort": null,
"cwd": "/Users/me/dev/api",
"timeout_seconds": 900,
"path": "/hooks/pull-after-merge",
"auth": { "type": "hmac", "secret_env": "GITHUB_WEBHOOK_SECRET", "configured": true, "how": "github HMAC-SHA256 of the body in x-hub-signature-256 (prefix sha256=), secret $GITHUB_WEBHOOK_SECRET" },
"when": ["header x-github-event equals \"pull_request\"", "payload.action equals \"closed\"", "payload.pull_request.merged equals true"],
"webhook": true,
"schedule": null,
"dir": "/Users/me/dev/api",
"file": "/Users/me/dev/api/skillhook.yaml",
"source": { "type": "project", "dir": "/Users/me/dev/api", "file": "/Users/me/dev/api/skillhook.yaml", "kind": "run" }
}
],
"errors": [
{ "dir": "/Users/me/.skillhook/skills/broken", "name": "broken", "error": "Invalid SKILL.md frontmatter: …" }
]
}runner, model, effort, cwd and timeout_seconds are effective values after defaults. auth.type is the normalized type: github, sentry and linear appear as hmac, granola and svix as standard-webhooks; auth.how spells out the preset. auth.configured says whether the secret is present. webhook is false for a schedule-only skill; schedule is null or {"cron", "timezone", "catch_up", "overlap", "next_run_at"} (schedules.md). source says where the skill is defined: {"type": "home"} for <home>/skills/<name>, or {"type": "project", "dir", "file", "kind"} for a hook of a linked repository's skillhook.yaml (kind is run, skill or prompt; see projects.md). This call rescans the skills directory and every linked repository, so new directories and hooks appear immediately.
Body: a JSON object (anything else is 400 bad_request).
| Field | Type | Meaning |
|---|---|---|
payload |
any | What the skill receives (default {}). A string is delivered as a text body. |
headers |
object | Simulated request headers, for when: header: filters and {{headers.*}}. |
runner |
claude | codex | shell |
Override for this run. |
model, effort |
string | Overrides for this run. |
wait |
number | Seconds to wait for the result (also accepted as ?wait=); clamped to max_wait_seconds. |
The job is recorded with trigger: "api" and answered with the same shapes as a webhook. No webhook signature is checked and no when filter or dedupe applies. This is what skillhook run (via the MCP run_skill tool) uses when a server is running.
curl -sS -X POST http://127.0.0.1:8787/skills/hello/run \
-H "Content-Type: application/json" \
-d '{"payload":{"name":"Dee"},"wait":60,"model":"sonnet"}'Runs a SKILL.md that is not installed: the document is validated like any skill file, written to jobs/<id>/skill/<name>/SKILL.md (the server writes nothing outside the jobs directory) and run from there, with trigger: "test", adhoc: true, skill_file pointing at that copy and source.method: "TEST". Nothing is added to <home>/skills, and the job's default working directory is the copy's own directory unless the document or cwd says otherwise.
| Field | Type | Meaning |
|---|---|---|
skill_md |
string | The whole SKILL.md text, frontmatter included. Its name must be a valid skill name; the frontmatter and skillhook: block are validated as usual. |
payload, headers, runner, model, effort, wait |
As in POST /skills/<skill>/run. |
|
cwd |
string | Working directory for the run. |
Responses are the webhook shapes plus adhoc: true. An invalid document is 400 invalid_skill_document with the validation message; a missing skill_md is 400 bad_request. This is what skillhook run --file / --stdin and the MCP test_skill tool use when a server is running.
curl -sS -X POST -H "Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "$(jq -n --rawfile md draft/SKILL.md '{skill_md: $md, payload: {name: "Dee"}, wait: 120}')" http://127.0.0.1:8787/skills/testQuery: skill=<name>, status=<queued|running|succeeded|failed|timed_out|cancelled|interrupted>, outcome=<completed|partial|needs_human|nothing_to_do|failed|unknown> (derived for jobs recorded before outcomes existed; queued and running jobs never match), trigger=<webhook|api|cli|mcp|schedule|replay|test|resume>, failure=<auth|usage_limit|rate_limit|budget|max_turns|not_found|timeout|crash|unknown> (jobs that failed that way, see runners.md), waiting=1 (only jobs waiting for a person: an unanswered question, or a finished job with outcome needs_human that nobody answered or resumed yet), since=<ISO-8601> (created at or after; whole seconds), after=<job id> (only older jobs: the next_after of the previous page), limit=<n> (default 50, at most 500). Newest first. An unknown status, outcome, trigger or since value is 400 bad_request.
{
"jobs": [ { "id": "20260916T025443Z-z1y3m4", "skill": "hello", "status": "succeeded", "…": "…" } ],
"queue": { "running": 0, "queued": 0, "running_ids": [] },
"next_after": "20260916T025443Z-z1y3m4"
}next_after is the last id of a full page (pass it as after for the next one) and null when the page was not full.
?include=result,stdout,stderr,prompt,payload,event,response adds an artifacts object with file contents (each capped to its last 512 KiB and prefixed with … [N bytes omitted] when truncated; a file the job did not write is left out).
curl -sS "http://127.0.0.1:8787/jobs/20260916T025442Z-r1wn6g?include=result,prompt"{
"job": { "id": "20260916T025442Z-r1wn6g", "skill": "hello", "status": "succeeded", "…": "…" },
"artifacts": {
"result": "Summarized the payload and wrote hello.md.\n",
"prompt": "# Skill: hello\n\n# hello\n\nYou received a webhook…"
}
}Ids that do not exist (or do not look like YYYYMMDDTHHMMSSZ-xxxxxx) are 404 unknown_job.
200 {"ok": true, "job_id": "…", "status": "…"} when the job was queued (it becomes cancelled at once) or running (SIGTERM now, SIGKILL after 10 s, then cancelled). 409 {"ok": false, "job_id": "…", "status": "succeeded"} when it had already finished.
What the running (or finished) agent reported through the job API (skills.md): {job_id, status, outcome, waiting, progress?, question?, answer?, timeline}. progress is the current state ({state: working|blocked|waiting_human|done, message, percent?, step?, updated_at}), question the pending or last question ({id, text, options?, context?, asked_at, wait_until?, answered_at?}), answer the person's answer ({question_id?, text, option?, by?, at}) and timeline the entries of progress.jsonl, oldest first (?limit= keeps the last N, default 200). All of it is also on the job record.
A person answers a job. Body:
| Field | Type | Meaning |
|---|---|---|
answer |
string | The answer (required). |
option |
string | One of the question's options, when it had any. |
by |
string | Who answered, for the record and the agent. |
resume |
auto | never |
For a job that already ended: auto (default) starts a new job that continues the session, never only records the answer. |
wait |
number | Seconds to wait for the resume job (also ?wait=); clamped to max_wait_seconds. |
Response: {ok, job_id, delivered, answer, resume_job_id, resume_job?, job} with delivered one of live (the job is running and waiting; the agent's ask call returns the answer), resumed (resume_job is the new job with trigger: "resume" and resume_of; the original gets resolved_by) or recorded. 409 not_waiting when the job is not waiting for a person (nothing asked, already answered, already resumed, still queued); 404 unknown_job; 400 bad_request without answer or with another resume value.
curl -sS -X POST -H "Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN" -H "content-type: application/json" \
-d '{"answer":"Go with the smaller change","option":"A","by":"ada","wait":120}' \
http://127.0.0.1:8787/jobs/20260916T025442Z-r1wn6g/answer<name> is one of stdout, stderr, prompt, result, payload, event, response. The body is the file as written, with no JSON envelope: application/json for event and for a payload that was parsed as JSON, text/plain otherwise. x-artifact-bytes carries the file's full size. ?tail=<bytes> returns only the last <bytes> bytes and adds x-artifact-truncated: true. A name outside the list, or a file the job has not written yet, is 404 unknown_artifact.
curl -sS -H "Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN" "http://127.0.0.1:8787/jobs/20260916T025442Z-r1wn6g/artifacts/result"A text/event-stream that follows one job. Messages, in order:
event: status,data:the job record: once at connect, then after each change (running,pid/session_idcaptured, cancel requested);event: stdout/event: stderr,data:a JSON string with the new bytes, sent as the files grow (?streams=stdout,stderr; defaultstdout; a file already larger than 512 KiB starts at its tail);event: end,data:the final record, after which the server closes the stream.
A job that has already finished gets status, the whole output and end at once. A comment line (: ping) every 15 s keeps proxies from closing an idle stream. skillhook jobs logs <id> -f uses this route when a server is running, and reads the file otherwise.
A text/event-stream of the server's event bus. Each message carries id (the event's seq, increasing by one per event in this server process), event (the type) and data (the whole event, {"seq", "type", "at", "data"}). ?types=job.finished,schedule.fired limits it to those types; an unknown type is 400 bad_request. Events that happened before the connection, or while it was down, are not replayed: a consumer that reconnects should reconcile through /jobs and /health.
| Type | data |
|---|---|
server.started, server.stopping |
{state} (the server.json record) and {reason, running} |
job.queued, job.started, job.finished |
{job} |
job.updated |
{job, fields}: pid, session_id, resume_command captured while running; runner, runner_requested, runner_reason when a fallback runner takes over; attempts when a run is repeated |
job.cancelled |
{job, state} with state queued or running; job.finished follows |
job.progress |
{job, entry}: the agent reported progress, a note or its outcome (entry is the progress.jsonl line) |
job.waiting_human |
{job, question}: the agent asked a person and waits |
job.answered |
{job, answer, delivered, resume_job_id?} with delivered live, resumed or recorded |
schedule.registered |
{skill, cron, timezone, next_due} |
schedule.fired |
{skill, slot, job, caught_up} |
schedule.skipped |
{skill, slot, reason}: in_flight, caught_up, too_old or duplicate |
skill.changed |
{name, action, source} with action added, changed or removed, noticed when a lookup or listing reads the changed file |
health.changed |
{report, changed}: a fresh health report whose checks differ from the previous one of the same flavour (changed lists {name, from, to}; the first report of a flavour has from: null) |
runners.changed |
{runner, readiness, previous?}: a runner became usable or stopped being so (installed, logged in), as the readiness check sees it |
config.changed |
{changed, applied, restart_required, pending_restart, config}: skillhook.json was re-read; applied took effect now, restart_required at the next start |
curl -sN -H "Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN" "http://127.0.0.1:8787/events?types=job.finished,schedule.fired"The delivery log: one record per request to /hooks/<skill>, newest first, whatever became of it. Query: skill=<name>, outcome=<accepted|duplicate|in_flight|skipped|rejected|challenge|error>, since=<ISO-8601>, after=<delivery id> (the next_after of the previous page), limit=<n> (default 50, at most 500).
{
"deliveries": [
{ "id": "20260928T100002Z-q7m2ka", "skill": "gh", "received_at": "2026-09-28T10:00:02.418Z", "outcome": "rejected", "http_status": 401, "code": "invalid_signature", "reason": "signature mismatch", "ip": "140.82.115.6", "method": "POST", "path": "/hooks/gh", "query": {}, "headers": { "content-type": "application/json", "x-github-event": "pull_request", "x-github-delivery": "b3e4…" }, "user_agent": "GitHub-Hookshot/abc", "content_type": "application/json", "bytes": 9412, "body_stored": true, "duration_ms": 2 },
{ "id": "20260928T095910Z-x1p0ll", "skill": "hello", "received_at": "2026-09-28T09:59:10.101Z", "outcome": "accepted", "http_status": 202, "delivery_id": null, "job_id": "20260928T095910Z-k3x9q2", "ip": "127.0.0.1", "method": "POST", "path": "/hooks/hello", "query": {}, "headers": { "content-type": "application/json", "user-agent": "skillhook-send" }, "user_agent": "skillhook-send", "content_type": "application/json", "bytes": 15, "body_kind": "json", "body_stored": false, "duration_ms": 4 }
],
"next_after": null
}The log lives in jobs/.delivery-log/ and keeps the newest deliveries.max (2000) records. It is the answer to "why did that webhook not run": a rejected record carries the error code and message the sender got, a skipped one the when condition that did not match, a duplicate or in_flight one the job it was folded into. Rate-limited requests to a hook are recorded too (429 rate_limited). CLI: skillhook deliveries list|show; MCP: list_deliveries, get_delivery.
{"delivery": {…}}; ?include=body adds "body": {"encoding": "utf8" | "base64", "text": "…", "truncated": false, "source": "log" | "job"}: the body the log kept for a refused delivery (skipped, rejected, error; at most deliveries.body_max_bytes, 64 KiB, and only while deliveries.store_bodies is on), or the payload of the job an accepted delivery created; null when neither exists. An unknown id is 404 unknown_delivery.
Runs a recorded delivery again through the skill as it is now: the original payload, headers (redacted, plus x-skillhook-replay-of: <delivery id>), query string and sender IP, as a new job with trigger: "replay", source.method: "REPLAY" and replay_of: {"delivery": "<id>", "job": "<original job id>"} (the job is present when the delivery had been accepted; its event.json and body.bin are then what is replayed). The signature is not checked again, when filters apply unless skipped, and nothing is de-duplicated: a replay never counts as a duplicate and is never folded into a job still in flight, and it carries no delivery_id itself.
Body: a JSON object, all fields optional.
| Field | Type | Meaning |
|---|---|---|
force |
boolean | Replay a delivery that was rejected or error, whose body was therefore never verified. Without it the answer is 409 replay_needs_force. |
skip_filters |
boolean | Run even when the skill's when conditions do not match; otherwise a non-match answers 200 {"ok": true, "skipped": true, "reason", "replay_of"}. |
runner, model, effort |
string | Overrides for this run, as in POST /skills/<skill>/run. |
wait |
number | Seconds to wait for the result (also ?wait=); clamped to max_wait_seconds. |
Responses are the webhook shapes (202 queued, 200 finished when waiting) plus replay_of. Errors: 404 unknown_delivery, 404 unknown_skill (the skill is gone or disabled), 409 replay_needs_force, 409 no_body (the body was not kept: deliveries.store_bodies was off, it was cut at deliveries.body_max_bytes, or the record was compacted away), 400 bad_request (not a JSON object, or an unknown runner).
curl -sS -X POST -H "Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"skip_filters": true, "wait": 60}' "http://127.0.0.1:8787/deliveries/20260928T100002Z-q7m2ka/replay"The same for an earlier job, whatever its trigger: its event.json (payload, redacted headers, query) is run again as a new job with trigger: "replay" and replay_of: {"job": "<id>"}. The body takes skip_filters, runner, model, effort and wait as above (force is not needed: a job's request was accepted). 404 unknown_job / 404 unknown_skill.
GET answers {config, file, exists, hot_keys, restart_keys, pending_restart}: the live configuration with defaults applied, which top-level keys the running server applies on reload (hot_keys, everything but host and port) and which wait for a restart (restart_keys), and the restart-only keys whose file value differs from what this process started with (pending_restart).
PATCH takes {"set": {"<dotted key>": value, …}, "unset": ["<dotted key>", …]} (at least one of them), writes the file once after validating the whole result, re-reads it into the live config and answers {ok, applied, restart_required, restart_required_keys, pending_restart, config, …}. applied lists the top-level keys that changed and took effect now; restart_required_keys those written for the next start. Errors: 400 config_invalid (the result would not validate: nothing is written), 400 config_key_not_allowed ($schema, __proto__ and friends), 400 bad_request (shape).
curl -sS -X PATCH -H "Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN" -H "content-type: application/json" \
-d '{"set":{"concurrency":4,"defaults.model":"sonnet"},"unset":["defaults.effort"]}' http://127.0.0.1:8787/configPOST /config/reload re-reads a file edited by hand (the server also notices a changed file within five seconds on its own) and answers the same shape; 400 config_invalid when the file does not parse, in which case the running settings are kept. Every reload that changed something is a config.changed event.
Body {"force"?: boolean, "wait_seconds"?: number} (default 30, at most 600). The server answers 202 {ok, restarting: true, force, wait_seconds, running}, stops accepting requests, lets running jobs finish for up to wait_seconds (terminates them at once with force, or when the wait runs out), removes server.json and exits 0; launchd (KeepAlive) or systemd (Restart=always) starts it again, and queued jobs are re-queued at start. 409 not_a_service when this server is not the one the service supervises (a skillhook serve in a terminal), since nothing would start it again.
/service: {service: {platform, installed, running, pid, file, logFile, detail?}, this_pid, supervised} (supervised is what /control/restart checks). /logs?lines=200 (at most 2000): {file, exists, lines} from <home>/logs/service.log.
Body {"install"?: boolean}. Answers what skillhook update prints: {ok, current, latest, available, checked_at, registry, cached, install: {method, command}, release_notes, installed, service_restarted, service_note, error?}. With install: true and a newer version, the package manager that installed skillhook runs the upgrade; the server itself keeps running the old code until POST /control/restart (it never restarts itself from this route), and service_note says so. ok: false with error when the registry did not answer, when skillhook runs from a source checkout, or when the install failed.
Query: since=<24h|7d|2w|ISO-8601> (default: everything on disk, newest 5000 jobs and deliveries), until=<ISO-8601>, skill=<name>. Response:
{
"window": { "since": "2026-09-27T12:00:00.000Z", "until": null, "skill": null },
"jobs": { "total": 42, "finished": 40, "queued": 1, "running": 1, "by_status": { "succeeded": 37, "failed": 2, "timed_out": 1, "…": 0 }, "by_outcome": { "completed": 30, "partial": 2, "needs_human": 3, "nothing_to_do": 2, "failed": 3, "unknown": 0 }, "by_trigger": { "webhook": 38, "…": 0 }, "by_runner": { "claude": 40, "codex": 2, "shell": 0 }, "by_failure_kind": { "rate_limit": 1, "auth": 1, "…": 0 }, "success_rate": 0.925, "completion_rate": 0.865, "duration_ms": { "count": 40, "p50": 42000, "p95": 190000, "avg": 61000, "max": 300000 }, "queue_wait_ms": { "count": 41, "p50": 200, "p95": 4000, "avg": 700, "max": 9000 }, "cost_usd": 1.2345, "tokens": { "input": 120000, "output": 30000, "cached_input": 80000 }, "waiting_for_human": 2 },
"deliveries": { "total": 60, "by_outcome": { "accepted": 42, "duplicate": 3, "in_flight": 0, "skipped": 5, "rejected": 10, "challenge": 0, "error": 0 }, "by_http_status": { "202": 42, "200": 8, "401": 9, "413": 1 }, "accepted_rate": 0.7, "last_received_at": "2026-09-28T11:58:00.000Z" },
"skills": { "hello": { "jobs": 30, "by_status": { "…": 0 }, "by_outcome": { "…": 0 }, "success_rate": 0.97, "cost_usd": 0.9, "tokens": { "…": 0 }, "duration_ms": { "…": 0 }, "deliveries": 41, "last_job": { "id": "20260928T115800Z-k3x9q2", "status": "succeeded", "outcome": "completed", "created_at": "2026-09-28T11:58:00.000Z" } } },
"generated_at": "2026-09-28T12:00:00.000Z"
}success_rate is succeeded over finished jobs; completion_rate is completed plus nothing_to_do over finished jobs with a reported outcome; percentiles are nearest-rank over finished jobs (queue_wait_ms: creation to start). Tokens add Claude's input_tokens / output_tokens / cache_read_input_tokens and Codex's input_tokens / output_tokens / cached_input_tokens. 400 bad_request for a since or until that does not parse.
| Field | Type | Notes |
|---|---|---|
id |
string | Same format as job ids; the delivery log's own id, distinct from the provider's delivery_id. |
skill |
string | The name in the URL, as requested, also when no such skill exists. |
received_at |
ISO-8601 | |
outcome |
string | accepted (a job was created), duplicate (delivery id seen before), in_flight (folded into a queued or running job), skipped (a when filter), rejected (any error answer: 401, 403, 404, 413, 429, 500, 503), challenge (Slack URL verification), error (unexpected server error). |
http_status |
number | What the sender was answered at decision time; a ?wait= request may have ended as 200 with the result instead of 202. |
code, reason |
string, optional | The error code and message of a rejected delivery; duplicate, in_flight, skipped (with the condition as reason), challenge or internal_error otherwise. Absent when accepted. |
delivery_id |
string, optional | Provider delivery id or dedupe value, when one was found. |
job_id |
string, optional | The job created, or the one the delivery was folded into. |
ip, method, path, query |
The request (token and wait removed from query). |
|
headers |
object | Redacted like event.json (no authorization, signature, token or cookie headers); values over 512 characters are shortened. |
via, ingress_id |
string, optional | via: "ingress" and the cloud's id for a webhook that arrived at a hosted URL and was handed over by the cloud link (cloud.md). |
user_agent, content_type, bytes, body_kind |
The body as received (body_kind is only known once the body was parsed). |
|
body_stored, body_truncated |
boolean | Whether the log kept the body, and whether it was cut at deliveries.body_max_bytes. |
duration_ms |
number | From arrival to the decision (a ?wait= is not counted). |
| Field | Type | Notes |
|---|---|---|
id |
string | YYYYMMDDTHHMMSSZ-<6 chars>, UTC, sortable; also the directory name under jobs/. |
skill |
string | |
status |
string | queued, running, succeeded, failed, timed_out, cancelled, interrupted. |
trigger |
string | webhook, api, cli, mcp, schedule (fired by a schedule:), replay (an operator replayed a delivery or job), test (a SKILL.md supplied with the request), resume (a person answered an earlier job; this run continues it). |
runner |
string | claude, codex, shell. |
model, effort |
string, optional | Resolved values when set. |
created_at, started_at, finished_at |
ISO-8601 | |
duration_ms |
number, optional | Start (or creation) to finish. |
cwd |
string, optional | Working directory used. |
pid |
number, optional | Only while running. |
exit_code, signal |
number / string / null | Process exit. |
session_id, resume_command |
string, optional | Claude session or Codex thread, and the shell command that resumes it. |
cost_usd, usage, num_turns |
optional | As reported by the runner (Claude reports all three, Codex usage only). |
result |
string, optional | Final agent message, truncated to 20 000 characters here; complete in result.md. |
error |
string, optional | Failure reason. |
outcome |
string, optional | Whether the task was done, set when the job ends: completed, partial, needs_human, nothing_to_do, failed (also every status other than succeeded) or unknown (the agent reported nothing). See skills.md. |
response |
object, optional | What the agent reported: {"outcome", "summary", "links"?, "data"?} (data is capped at 64 KiB here; complete in response.json). |
replay_of |
object, optional | For trigger: replay: {"delivery"?: "<delivery-log id>", "job"?: "<original job id>"}. |
progress |
object, optional | What the agent last reported: {"state": "working"|"blocked"|"waiting_human"|"done", "message", "percent"?, "step"?, "updated_at"}. |
question |
object, optional | The question the agent asked a person: {"id", "text", "options"?, "context"?, "asked_at", "wait_until"?, "answered_at"?}; pending until answered_at is set. |
answer |
object, optional | The person's answer: {"question_id"?, "text", "option"?, "by"?, "at"}. |
resume_of, resume |
optional | For trigger: resume: the job whose answer this run carries, and {"session_id", "runner"} when that job's session is continued (absent when the skill had to run afresh; runner_reason then says why). |
resolved_by |
string, optional | The resume job an answer to this job started. |
runner_reason |
string, optional | Why the run differs from what was asked: a resume without a session, or a fallback runner (fallback: claude not logged in, fallback: claude failed (rate_limit)). |
runner_requested |
string, optional | The runner the skill asked for, when runner is a fallback that took over. |
failure |
object, optional | For failed and timed_out jobs: {"kind", "code"?, "retryable", "message"?} with kind one of auth, usage_limit, rate_limit, budget, max_turns, not_found, timeout, crash, unknown (runners.md). |
attempts |
array, optional | Earlier runs of this job that a retry: or fallback: repeated: [{runner, started_at, finished_at, status, error?, failure?}]; the record itself is the last attempt. |
adhoc |
true, optional |
The SKILL.md came with the request (POST /skills/test, skillhook run --file) and lives in jobs/<id>/skill/<name>/. |
skill_file |
string, optional | The SKILL.md (or skillhook.yaml) the job ran from. |
delivery_id |
string, optional | Provider delivery id when known; schedule:<wall-clock slot> for scheduled runs. |
fingerprint |
string, optional | SHA-256 of the payload and query string of a webhook delivery; what the in-flight duplicate check compares. |
source |
object | ip, method (POST, PUT, LOCAL for CLI/MCP runs, SCHEDULE for scheduled runs, REPLAY for replays, whose ip is the original sender's, TEST for ad-hoc runs, RESUME for resumed runs, CLOUD for runs started from Skillhook Cloud), path, content_type, user_agent. |
job.json on disk also contains command (the exact argv); API responses omit it.
| HTTP | error |
Meaning |
|---|---|---|
| 200 | — | Result available, duplicate, skipped, Slack challenge, admin reads, successful cancel. |
| 202 | — | Job queued (or still running after wait). |
| 400 | bad_request |
/skills/<skill>/run body is not a JSON object; unknown ?types= (/events), ?streams= (/jobs/<id>/events), ?status=/?trigger= (/jobs), ?outcome= (/deliveries) or malformed ?since= value; /skills/test without skill_md; /jobs/<id>/answer without answer or with a resume other than auto/never; an unknown ?failure= kind (/jobs). |
| 400 | invalid_skill_document |
/skills/test: the SKILL.md does not validate (the message says why). |
| 400 | config_invalid, config_key_not_allowed |
PATCH /config would not validate (nothing written) or names $schema / a prototype key; POST /config/reload found an unparsable file. |
| 401 | missing_token, invalid_token, missing_credentials, invalid_credentials, missing_signature, invalid_signature, missing_timestamp, invalid_timestamp, stale_timestamp |
Webhook authentication failed. |
| 401 | unauthorized |
Admin route without a valid token. |
| 403 | ip_not_allowed |
Client IP not in the skill's allow_ips. |
| 404 | unknown_skill, unknown_job, unknown_artifact, unknown_delivery, not_found |
|
| 404 | schedule_only |
The skill has webhook: false; it runs only on its schedule:. |
| 405 | method_not_allowed |
|
| 409 | — (ok: false) |
Cancel on a finished job. |
| 409 | replay_needs_force, no_body |
Replaying a rejected delivery without force; a delivery whose body was not kept. |
| 409 | not_waiting, unknown_skill |
Answering a job that is not waiting for a person; the skill of the job to resume no longer exists. |
| 409 | not_a_service |
POST /control/restart on a server that launchd / systemd would not start again. |
| 413 | payload_too_large |
Body over max_body_bytes. |
| 429 | rate_limited, too_many_failures |
Per-IP limits. |
| 500 | invalid_skill, internal_error |
SKILL.md failed to parse; unexpected error (see the server log). |
| 503 | skill_not_configured |
The skill's secret env var is not set. |