The eight tools an agent can call, what each returns, and the rails on the ones that change something.
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.
| Tool | Answers |
|---|---|
hookdeck_status |
"Are webhooks working?" — routes, capacity, ledger persistence, dead-letter count, open issues, transport state, config warnings |
hookdeck_recent_deliveries |
"Did anything break overnight?" — open Hookdeck Issues, plus failures Hookdeck cannot see |
hookdeck_inspect_event |
"Why did this one fail?" — our row and reason beside Hookdeck's status and full attempt history; payload on request |
hookdeck_doctor |
What's misconfigured, including whether each connection's retry rule still covers every status we emit |
hookdeck_setup |
Provisions connections. Dry run by default |
hookdeck_pause |
Pause/resume a connection. Auto-resumes within an hour |
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 |
hookdeck_issues |
The dead-letter queue's lifecycle: list, acknowledge, resolve, ignore, dismiss. Replays nothing, and says so |
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.
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.
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.
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.
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.
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.
Two host requirements will silently produce a plugin with no tool surface, and neither throws:
contracts.toolsin the manifest, listing every tool name. Without it the host logsplugin must declare contracts.toolsand registers nothing.- The
AgentToolcontract: a requiredlabel, anexecute(toolCallId, params, …)signature, and anAgentToolResultreturn (usejsonResultfromopenclaw/plugin-sdk/core). Get any of these wrong and the host accepts the registration while the agent never sees the tool.Neither failure is visible to a typecheck or to handler-level tests, so
test/tool-wiring.test.tsasserts the manifest matches the code, every tool has a label, the execute arity is right, and the return is anAgentToolResult.
Hookdeck distinguishes them, so this plugin does too:
- 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. - Replay (
POST /bulk/requests/replay) re-ingests the original requests through the pipeline, producing new events with new ids. The originals are untouched.
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.
That distinction decides whether deduplication can protect you:
| Ledger sees | Suppressed? | |
|---|---|---|
| Retry | Same event id, higher attempt | Admitted by the attempt rule, and a duplicate of an already-handled attempt is rejected |
| Replay | A brand-new event id | Admitted as a first delivery — the ledger has no way to know it is related to anything |
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.
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.
The local log holds only the residue Hookdeck is structurally blind to, created by our own choice to acknowledge early:
- an agent run that failed after we returned
202, once its retry budget is spent; - work interrupted by a crash between the acknowledgement and completion.
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.
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.
Deliberately not reimplemented, listed because the temptation is real:
- 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.signingSecretis a different thing entirely — Hookdeck's own secret for signing deliveries to us. - Retries and backoff are the connection's retry rule. We only choose the status code that decides what it does next.
- 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 answer503— 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 norate_limitfield. - Payload deduplication of a double-firing provider is the connection's
deduplicaterule. 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. - Holding events during a restart is
PUT /connections/{id}/pause; catch-up is bulk replay. Both are API calls, not local queues.
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.
Payload text from a webhook is third-party input, and the tools treat it that way:
- Signature,
Authorization, cookie and token headers are redacted before an inspected event's headers are returned. - 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. - The
hookdeck listenchild's output is scrubbed of the API key as it is captured, not as it is read — that output is surfaced byhookdeck_statusand 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. - A test asserts that no configured secret appears in any tool's result, so the next tool added inherits the check.
A status tool that returns a page and lets the reader infer a total is worse than one that says nothing: a model asked "what needs attention?" will report what it can see as though it were everything.
hookdeck_status.openIssuesandhookdeck_issues'totalcome from Hookdeck's count endpoint, not from the length of a page.hookdeck_recent_deliveriesreturnsopenIssuesTotalbeside the page it shows, and anopenIssuesTruncatednote whenever the two differ. The local records get the same treatment vialocalTruncated.hookdeck_status.deadLettersis the local log's true size, but the log evicts oldest-first at its cap — so once it is full,deadLettersIsAtLeast: truesays the number is a floor. A floor reported as a floor beats a ceiling reported as a total.
Beyond the obvious config validation:
- Provider verification is actually in force. Setting a source's type to STRIPE or GITHUB does not enable signature verification — the provider's signing secret has to be set on the source as well. A source with one is byte-identical to a source without it over the API, because the secret is never returned. So the only evidence is whether the requests that arrived were verified, and that is what this check reads.
- The retry rule still covers every status the plugin emits. A rule narrower than the emitted codes turns admission control into silent data loss.
- The CLI and the API key point at the same project. See Transport.
- The burst each route can absorb, from
maxConcurrentand the connection's retry count.