Skip to content

Commit 21dee71

Browse files
garethxclaude
andcommitted
docs: rewrite the README, move the reference material into docs/
The README now opens with what the plugin is for and how it differs from OpenClaw's built-in webhook support, and stops at the point most readers stop. The nine reference sections it used to carry move to docs/, unchanged in substance, linked from a Documentation table: getting started, configuration, dispatch modes, transport, the response contract, durability, agent tools, security, and limitations. Three claims in the draft did not match the code and are corrected: - The quickstart configured `plugins."@hookdeck/openclaw"`. The host reads `plugins.entries.hookdeck.config`, so as written nobody could have configured the plugin at all. Verified by booting a Gateway from the README's own JSON and delivering a signed event to it. - Issue #9130 was cited as closed-not-planned alongside #4977 and #5868. It was closed as completed on 2026-02-13 — per-agent routing did land, via webhook mappings — so only the auth and agentId requests support the argument being made. - "Action tools, each requiring explicit confirmation" overstated it. Only bulk replay and issue dismissal gate on `confirm`; setup is dry-run by default and pause always schedules an auto-resume. Each row now names its actual rail, and hookdeck_issues gains the dismiss action it supports. Checked the rest against the source rather than trusting it: every documented default matches config-parse, the tool list matches the manifest contract exactly in both directions, the CLI floor matches MIN_CLI_VERSION, and every relative link resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d2cc28d commit 21dee71

10 files changed

Lines changed: 487 additions & 347 deletions

README.md

Lines changed: 122 additions & 347 deletions
Large diffs are not rendered by default.

docs/agent-tools.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Agent tools
2+
3+
The eight tools an agent can call, what each returns, and the rails on the ones that change something.
4+
5+
Eight tools. Five are the operator verbs — `setup`, `status`, `pause`/`resume`, `replay`, `doctor` — plus three an agent host benefits from more than a CLI does. Two of those correlate what Hookdeck saw with what we did (`hookdeck_recent_deliveries`, `hookdeck_inspect_event`); the third, `hookdeck_issues`, is the dead-letter queue's own lifecycle.
6+
7+
| Tool | Answers |
8+
|---|---|
9+
| `hookdeck_status` | "Are webhooks working?" — routes, capacity, ledger persistence, dead-letter count, open issues, transport state, config warnings |
10+
| `hookdeck_recent_deliveries` | "Did anything break overnight?" — open Hookdeck Issues, plus failures Hookdeck cannot see |
11+
| `hookdeck_inspect_event` | "Why did *this* one fail?" — our row and reason beside Hookdeck's status and full attempt history; payload on request |
12+
| `hookdeck_doctor` | What's misconfigured, including whether each connection's retry rule still covers every status we emit |
13+
| `hookdeck_setup` | Provisions connections. Dry run by default |
14+
| `hookdeck_pause` | Pause/resume a connection. Auto-resumes within an hour |
15+
| `hookdeck_replay` | **Retries** specific events (`eventIds`), or runs a scoped bulk **replay** of requests (`routeId` + `sinceMinutes`). Dry run unless `confirm: true`. Caps at 100 ids per call and says what it dropped |
16+
| `hookdeck_issues` | The dead-letter queue's lifecycle: list, acknowledge, resolve, ignore, dismiss. Replays nothing, and says so |
17+
18+
`tools.allowMutations: false` reduces this to the five read tools — `hookdeck_issues` stays, able to list and inspect but not acknowledge, resolve or dismiss — for an agent that can diagnose but not act.
19+
20+
Four safety rails are deliberate. **`hookdeck_setup` defaults to a dry run**, so an agent has to mean it. **`hookdeck_replay` refuses a bulk replay without `confirm: true`**, because replaying an unscoped window costs real money. **`hookdeck_pause` always schedules an auto-resume**, clamped to an hour, because an agent that pauses and then loses the thread must not stop the pipeline indefinitely. And **every `hookdeck_issues` mutation states that it replayed nothing** — "resolved" reads like "fixed", and an agent that resolves without replaying has tidied the dashboard and left the work undone.
21+
22+
Deliberately absent: `disable`, any delete, raw source/destination CRUD, transformation overwrite. Their failure mode is irrecoverable event loss and an agent cannot judge the blast radius.
23+
24+
`hookdeck_setup`'s dry run returns a summary rather than the raw connection spec, because that spec carries `source.config.auth` and `destination.config.auth` — a provider webhook secret must not be echoed into a model's context just because someone asked what would change.
25+
26+
**The tools read the plugin's state files, not just the running service.** When a tool call lands in the Gateway process it uses the live service; otherwise it opens the same JSONL state read-only from the state directory. That matters because a tool call is not reliably in the Gateway process — OpenClaw loads the plugin in the CLI process too, and `register()` runs more than once per turn. Depending on the in-memory runtime meant every tool answered "the service is not running" however healthy the deployment was.
27+
28+
Each result carries `source: "live" | "disk"`. On a disk view, in-flight capacity and transport state are reported as `null` rather than zero, because they exist only in the service's memory and a zero would be a lie rather than a gap. Reads are strictly read-only — including suppressing the compaction that loading would otherwise perform — since the Gateway owns those files.
29+
30+
> Two host requirements will silently produce a plugin with no tool surface, and neither throws:
31+
>
32+
> 1. **`contracts.tools` in the manifest**, listing every tool name. Without it the host logs `plugin must declare contracts.tools` and registers nothing.
33+
> 2. **The `AgentTool` contract**: a required `label`, an `execute(toolCallId, params, …)` signature, and an `AgentToolResult` return (use `jsonResult` from `openclaw/plugin-sdk/core`). Get any of these wrong and the host accepts the registration while the agent never sees the tool.
34+
>
35+
> Neither failure is visible to a typecheck or to handler-level tests, so `test/tool-wiring.test.ts` asserts the manifest matches the code, every tool has a label, the execute arity is right, and the return is an `AgentToolResult`.
36+
37+
## Retry and replay are different operations
38+
39+
Hookdeck distinguishes them, so this plugin does too:
40+
41+
- **Retry** (`POST /events/{id}/retry`) makes a new delivery attempt for an existing event. The event id is unchanged and the attempt count goes up.
42+
- **Replay** (`POST /bulk/requests/replay`) re-ingests the original *requests* through the pipeline, producing **new events with new ids**. The originals are untouched.
43+
44+
Almost everything here is a retry: crash recovery re-queuing interrupted work, an agent run asking for another delivery, and `hookdeck_replay` when given explicit `eventIds`. Only catch-up after an outage is a true replay, because the events it needs never existed — the requests arrived while no CLI session was attached, so Hookdeck discarded them rather than creating events to retry.
45+
46+
That distinction decides whether deduplication can protect you:
47+
48+
| | Ledger sees | Suppressed? |
49+
|---|---|---|
50+
| Retry | Same event id, higher attempt | Admitted by the attempt rule, and a duplicate of an already-handled attempt is rejected |
51+
| Replay | A brand-new event id | Admitted as a first delivery — **the ledger has no way to know it is related to anything** |
52+
53+
So a replay of requests that already ran successfully **will run the work again**. That is why every replay path here is scoped to requests that produced no event at all (`cli_events_count: 0`, `ignored_count >= 1`) rather than to a bare time window, and why the tool insists on `confirm: true`. If you need protection against a broader replay, `route.dedupe.idPath` keys deduplication on a provider-native id in the payload, which survives re-ingestion.
54+
55+
## Hookdeck Issues are the dead-letter queue
56+
57+
This plugin does not reimplement one. A delivery Issue with `strategy: "final_attempt"` means exactly "this event is not coming back", and it carries notifications, an acknowledge/resolve lifecycle and a dashboard that a local file never will. `hookdeck_recent_deliveries` therefore leads with open Issues.
58+
59+
The local log holds only the residue Hookdeck is structurally blind to, created by our own choice to acknowledge early:
60+
61+
- an agent run that failed **after** we returned `202`, once its retry budget is spent;
62+
- work interrupted by a crash between the acknowledgement and completion.
63+
64+
In both cases Hookdeck recorded a *successful* delivery, so no Issue will ever open and nothing else knows they happened. Those come back as `unreportedFailures`.
65+
66+
Pre-acknowledgement rejections — a cancelled retry, a final failed attempt — are mirrored locally only as a convenience where Issues are unreachable, and are returned separately as `locallyRecorded` so a reader knows to prefer the Issue. Two cases make that mirror worth keeping: deployments with no API key, and **CLI destinations, which support no issue triggers at all** — so in local development the local log is the only record there is.
67+
68+
## What else we let Hookdeck do
69+
70+
Deliberately not reimplemented, listed because the temptation is real:
71+
72+
- **Provider signature verification** (Stripe, GitHub, Shopify, ~145 others) happens at the Hookdeck Source via `verification.provider` + `credentials`. An unverified request is rejected at the Request layer, so no event is created and nothing reaches the agent. `signingSecret` is a different thing entirely — Hookdeck's own secret for signing deliveries *to us*.
73+
- **Retries and backoff** are the connection's retry rule. We only choose the status code that decides what it does next.
74+
- **Concurrency limiting** is pushed into the destination as `rate_limit_period: "concurrent"` in HTTP mode, because Hookdeck paces delivery where our local admission control has to answer `503` — spending one of the event's finite attempts to say "not now". The local limit stays as a backstop, and is the *only* control under CLI transport, where destinations carry no `rate_limit` field.
75+
- **Payload deduplication** of a double-firing provider is the connection's `deduplicate` rule. Our ledger solves a different problem — deciding whether an incoming *attempt* is a legitimate redelivery or a duplicate — which no server-side rule can answer for us.
76+
- **Holding events during a restart** is `PUT /connections/{id}/pause`; **catch-up** is bulk replay. Both are API calls, not local queues.
77+
78+
Route `filters` are the one deliberate overlap. Hookdeck can filter server-side and doing it there is better — a filtered event never reaches the agent and costs nothing — so the local ones exist only for decisions a connection cannot express.
79+
80+
## What reaches the model
81+
82+
Payload text from a webhook is third-party input, and the tools treat it that way:
83+
84+
- Signature, `Authorization`, cookie and token headers are redacted before an inspected event's headers are returned.
85+
- The delivered body is **opt-in** (`includeBody`), truncated at 4,000 characters, and labelled as data rather than presented as something addressed to the reader.
86+
- The `hookdeck listen` child's output is scrubbed of the API key as it is captured, not as it is read — that output is surfaced by `hookdeck_status` and we do not write it, so a future CLI version echoing a key into a banner would otherwise land it in a model's context with nothing here having changed.
87+
- A test asserts that no configured secret appears in *any* tool's result, so the next tool added inherits the check.

docs/configuration.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Configuration reference
2+
3+
Every setting, its default, and what it changes.
4+
5+
| Key | Default | Notes |
6+
|---|---|---|
7+
| `headerPrefix` | `x-hookdeck` | Hookdeck's header prefix is white-labelable per project. Set it if yours differs. |
8+
| `signingSecret` || Inline string or a secretRef `{source, provider, id}`. Routes may override. Re-resolved on every request, so rotation needs no restart. |
9+
| `apiKey` || Optional. Needed for provisioning, pause/resume, replay, issue management and re-queuing interrupted work. Without it the plugin runs ingress-only. |
10+
| `storage.enabled` | `true` | Persist the ledger and dead-letter log. Off means memory-only — see [Durability](#durability-and-recovery). |
11+
| `storage.deadLetterMaxEntries` | `500` | Dead-letter entries kept before the oldest are dropped. |
12+
| `recovery.enabled` | `true` | Re-queue work interrupted by a crash on the next start. Needs `apiKey`. |
13+
| `recovery.maxEvents` | `50` | Caps a crash loop from storming the API. Oldest events recovered first. |
14+
| `ingress.basePath` | `/hookdeck` | Gateway route prefix. May not be `/`. |
15+
| `maxConcurrent` | `4` | Local admission control. In CLI transport this is the **only** limit — CLI destinations carry no `rate_limit` field. |
16+
| `busyRetryAfterSeconds` | `10` | `Retry-After` sent when deferring at capacity. |
17+
| `deferAttemptLimit` | `5` | Deferrals of the same event before the short `Retry-After` is dropped and exponential backoff takes over. Capacity that has not recovered after this many attempts is not the transient condition a short interval assumes. |
18+
| `pause.onShutdown` | `true` | Pause the connection before stopping the listener, so events are held rather than discarded. |
19+
| `pause.shutdownTimeoutMs` | `5000` | Budget for pausing connections at shutdown. Stopping the CLI children is bounded separately, by a SIGTERM grace before SIGKILL. |
20+
| `catchUp.enabled` | `true` | After a reconnect, replay requests that arrived while nothing was listening. |
21+
| `catchUp.minGapSeconds` | `30` | Below this, an outage is not worth a bulk replay. |
22+
| `dedupe.ttlHours` | `168` | Ledger retention, matching Hookdeck's one-week retry ceiling. Raise it if you extend retries beyond a week. |
23+
| `safety.allowRetryCancel` | `false` | See [Retry cancellation](#retry-cancellation). |
24+
| `routes.<id>.source` || **Required.** Hookdeck source name. |
25+
| `routes.<id>.path` | `/<id>` | Appended to `ingress.basePath`. Matched as a prefix — see below. |
26+
| `routes.<id>.verification` || Provider signature verification at the Hookdeck source: `{provider: "STRIPE", credentials: {webhook_secret_key: …}}`. See below. |
27+
| `routes.<id>.connectionId` || Only needed when provisioning is off. Pause-on-shutdown and catch-up act on a connection id, so without one they are silently inert; the plugin warns at startup if that applies. |
28+
| `routes.<id>.dispatch.sessionKey` || **Required.** Session the event is enqueued against. |
29+
| `routes.<id>.dispatch.text` | `Webhook received from {source}` | Placeholders: `{source}`, `{eventId}`, `{routeId}`. |
30+
| `routes.<id>.dispatch.wakeMode` | `now` | `now` also requests an immediate heartbeat; `next-heartbeat` only enqueues. |
31+
32+
`provider` is required on a secretRef — OpenClaw's own secret-input schema marks all three fields required and rejects unknown keys.
33+
34+
## Two different secrets
35+
36+
These are easy to conflate and they come from different parties:
37+
38+
- **`signingSecret`** is **Hookdeck's own**, project-level, from Settings → Project → Secrets. Hookdeck signs its deliveries *to you* with it, and this plugin verifies that signature. Without it, every delivery is rejected with a retryable `503`.
39+
- **`routes.<id>.verification.credentials`** is the **provider's** — Stripe's `whsec_…`, GitHub's webhook secret. It goes on the Hookdeck *source*, and **Hookdeck** uses it to verify the provider's own signature at ingest. This plugin never sees it. Verification failure is rejected at Hookdeck's request layer, so no event is created and nothing reaches your agent.
40+
41+
Not reimplementing ~145 provider schemes is the point of the integration. Configure verification and Hookdeck does it; leave it out and Hookdeck accepts anything posted to the source URL, which the plugin warns about at startup.
42+
43+
**Route paths match as a prefix, longest first.** Hookdeck appends the source request's path to the destination path unless `path_forwarding_disabled` is set, which is not the default — so a provider posting to `<source-url>/events` arrives at `/hookdeck/stripe/events`. Exact matching would reject perfectly good traffic. A route named `stripe` will not swallow `/hookdeck/stripe-test`; only a further path segment counts.

docs/dispatch-modes.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Dispatch modes
2+
3+
What a verified event actually does when it arrives: wake a session, apply a TaskFlow action, or start an agent turn.
4+
5+
Each route picks one.
6+
7+
| Mode | What it does | Use it for |
8+
|---|---|---|
9+
| `wake` | Enqueues a system event and, by default, requests an immediate heartbeat. | "Something happened, look at it." Cheapest option. |
10+
| `taskflow` | Body is a TaskFlow action envelope (`create_flow`, `run_task`, `finish_flow`, …), applied against a bound session. | Automation sources that already speak OpenClaw's vocabulary — n8n, Zapier, CI. |
11+
| `agent` | Renders the payload into a prompt and runs an isolated turn. | Raw provider webhooks. A Stripe body is not a TaskFlow envelope and never will be, so this is the mode that works with any of Hookdeck's ~145 verified providers on day one. |
12+
13+
## TaskFlow semantics
14+
15+
The status taxonomy mirrors the built-in Webhooks plugin, with two entries worth knowing:
16+
17+
- **`revision_conflict` cancels retries.** `expectedRevision` is baked into the stored request and TaskFlow revisions only ever increase, so a retry of that exact envelope can never succeed. The current revision comes back in the body so the caller can re-read and re-send.
18+
- **`not_found` does not cancel.** The flow may simply not exist yet — an envelope can race ahead of the creation that produces it — and Hookdeck's backoff resolves that for free.
19+
20+
## Agent turns
21+
22+
The prompt template's own text is trusted; everything substituted into it is not. See [Trust boundary](#trust-boundary).
23+
24+
Turns are started through TaskFlow `run_task` rather than `subagent.run`, and that is not a preference. **A plugin-registered HTTP route with `auth: "plugin"` is given `scopes: []` unconditionally** — the Gateway's `createPluginRouteRuntimeScope` reads `route.auth !== "gateway" ? [] : …`, and `gatewayRuntimeScopeSurface` only applies on the `"gateway"` branch. Since this plugin authenticates with Hookdeck's signature rather than the Gateway's own credentials, the `operator.write` scope is structurally unreachable and `subagent.run` answers `missing scope: operator.write`.
25+
26+
`run_task` has no such requirement, and it is the better fit anyway: the run becomes durable flow state rather than a bare run id, so it survives a restart and stays inspectable.
27+
28+
The consequence is honest rather than hidden: **TaskFlow exposes flow state, not a completion promise**, so this transport cannot observe when a run finishes. The route acknowledges `202` once the task is created and settles the ledger there. `ackMode: "sync"` and `maxAgentRetries` need completion observability and therefore have no effect on this transport — they are wired and tested for hosts where the route does carry operator scopes.
29+
30+
## Route filters
31+
32+
Filters are matched against the parsed payload; all must pass. A non-match is answered `200` with `{"ignored": true}`, because the drop is deliberate and a `2xx` correctly retires the event. Nothing is written to the ledger for a filtered event.
33+
34+
```json
35+
"filters": [{ "path": "type", "equals": "invoice.paid" }]
36+
```
37+
38+
`equals`, `in` and `exists` are supported. Prefer filtering at the Hookdeck connection where you can — an event filtered there never reaches the agent and costs nothing.

0 commit comments

Comments
 (0)