|
| 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. |
0 commit comments