Skip to content
Draft
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
90 changes: 90 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e '.[dev]'
- run: ruff check --output-format=github .

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The floor is `requires-python`; the ceiling is whatever is current.
# Hermes decides which of these a real install runs on, so the plugin
# should not be the thing that narrows it.
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e '.[dev]'
- run: python -m pytest -q

package:
# A plugin that installs without its manifest, dashboard bundle or skill
# registers nothing, and the failure is silent — the platform simply never
# appears. Building the artifact and looking inside it is the only way that
# gets caught before a release.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
- run: twine check dist/*
- name: The wheel must carry the non-Python half of the plugin
run: |
set -euo pipefail
wheel=$(ls dist/*.whl)
echo "inspecting $wheel"
contents=$(python -m zipfile --list "$wheel")
for required in \
hookdeck/plugin.yaml \
hookdeck/dashboard/manifest.json \
hookdeck/dashboard/dist/index.js \
hookdeck/skills/triage-webhook-failures/SKILL.md
do
if ! grep -qF "$required" <<<"$contents"; then
echo "::error::$required is missing from the wheel"
exit 1
fi
echo " ✓ $required"
done
- name: The entry point Hermes discovers the plugin by must be declared
run: |
set -euo pipefail
pip install dist/*.whl
python - <<'PY'
from importlib.metadata import entry_points
found = entry_points(group="hermes_agent.plugins")
names = {e.name: e.value for e in found}
assert names.get("hookdeck") == "hookdeck", names
print("✓ hermes_agent.plugins entry point:", names)
PY
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
99 changes: 99 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Tag-driven release to PyPI.
#
# 1. bump `__version__` in hookdeck/__init__.py (pyproject reads it from there)
# 2. git tag v0.2.0 && git push --tags
#
# Release notes are generated from the commits in the range, so the commit
# messages are the changelog.
#
# Publishing uses PyPI Trusted Publishing (OIDC), so there is no API token in
# the repository to leak or rotate. It needs a one-time setup on PyPI:
# Project → Publishing → add a GitHub publisher for hookdeck/hermes-hookdeck,
# workflow `release.yml`, environment `pypi`.
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e '.[dev]' build twine

- name: The tag and the package version must agree
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -euo pipefail
tagged="${GITHUB_REF_NAME#v}"
declared=$(python -c 'import hookdeck; print(hookdeck.__version__)')
if [ "$tagged" != "$declared" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match hookdeck.__version__ ($declared)"
exit 1
fi
echo "✓ releasing $declared"

# A release that cannot pass its own test suite is not a release.
- run: ruff check .
- run: python -m pytest -q

- run: python -m build
- run: twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/hermes-hookdeck
permissions:
# The OIDC token pypa/gh-action-pypi-publish exchanges for an upload
# token. Nothing else in this workflow needs it, which is why it is
# scoped to this job rather than the file.
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1

github-release:
needs: publish
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
# v0.1.0rc1 and friends are pre-releases. Saying so keeps them off
# the repository's "latest release", which otherwise points people at
# a release candidate.
prerelease=""
case "$GITHUB_REF_NAME" in
*a[0-9]*|*b[0-9]*|*rc[0-9]*) prerelease="--prerelease" ;;
esac
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
--generate-notes $prerelease
37 changes: 37 additions & 0 deletions .github/workflows/upstream-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# The test suite runs against tests/hermes_stub.py, because the plugin lives
# outside the Hermes tree and there is no other way to exercise the ingest path
# without a Hermes checkout. The blind spot that buys is real: the stub cannot
# notice when the thing it stands in for changes.
#
# So this asks upstream directly, on a schedule rather than on every PR — the
# answer changes when Hermes changes, not when this repo does. A failure here
# is a heads-up, not a broken build.
name: Upstream contract

on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
push:
paths:
- "scripts/check_upstream_contract.py"
- "tests/hermes_stub.py"
- ".github/workflows/upstream-contract.yml"

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Fetch the parts of Hermes this plugin borrows from
run: |
git clone --depth 1 --filter=blob:none --sparse \
https://github.com/NousResearch/hermes-agent.git upstream
git -C upstream sparse-checkout set gateway agent
- run: python scripts/check_upstream_contract.py upstream
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ dist/
# file outside it (see README).
*.env
.env*
# The Hookdeck CLI writes a session here when run with `--local`, and the
# gateway's own session lives under HERMES_HOME. Both hold credentials.
.hookdeck/
.coverage
htmlcov/
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

**A durable, verified queue in front of your Hermes agent, so a webhook can trigger an agent run without the usual ways that goes wrong.**

Agent runs are not ordinary webhook handlers. They take seconds to minutes, cost money per execution, and must not run twice for the same event. Hermes's built-in webhook platform is fine for trying things out, but in production it drops bursts over 30/min and forgets duplicates after a restart. Any run that fails after the 202 is sent is simply lost. This plugin replaces that ingestion path with [Hookdeck](https://hookdeck.com), plus a local ledger that tracks the outcomes Hookdeck can't see.
Agent runs are not ordinary webhook handlers. They take seconds to minutes, cost money per execution, and must not run twice for the same event. Hermes's built-in webhook platform is fine for trying things out, but in production it drops bursts over 30/min and forgets duplicates after a restart. Any run that fails after the 202 is sent is simply lost. This plugin replaces that ingestion path with the [Hookdeck Event Gateway](https://hookdeck.com/docs), plus a local ledger that tracks the outcomes Hookdeck can't see.

> Inbound only. This is the Event Gateway — third-party events arriving at your agent. It is not [Outpost](https://hookdeck.com/docs/outpost), which points the other way, and nothing here helps Hermes publish webhooks.

## Why

Expand Down Expand Up @@ -31,11 +33,11 @@ pip install hermes-hookdeck && hermes plugins enable hookdeck
git clone https://github.com/hookdeck/hermes-hookdeck ~/.hermes/plugins/hermes-hookdeck
```

Configure two environment variables from your Hookdeck dashboard (Project Settings > Secrets):
Configure two environment variables from your Hookdeck dashboard (Project Settings > Secrets). They are prefixed `HOOKDECK_EG_` for the Event Gateway, since Hookdeck's platform is more than one product:

```bash
export HOOKDECK_API_KEY=... # provisions connections
export HOOKDECK_WEBHOOK_SECRET=... # verifies deliveries
export HOOKDECK_EG_API_KEY=... # provisions connections
export HOOKDECK_EG_WEBHOOK_SECRET=... # verifies deliveries
```

Then create a route and check the setup:
Expand Down Expand Up @@ -95,6 +97,8 @@ A bundled `triage-webhook-failures` skill teaches the agent to group failures by

## Documentation

- [How it fits together](docs/architecture.md) — where each piece runs, the
delivery pipeline, and the three different things called "CLI"
- [How the reliability works](docs/reliability.md) — verification, the run
ledger and its idempotency rule, backpressure, ack modes, and why retry
rather than replay
Expand Down
126 changes: 126 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# How it fits together

Where each piece runs, and what crosses your network boundary.

```mermaid
flowchart LR
P["<b>Provider</b><br/>GitHub · Stripe · Shopify · …"]

subgraph HD["Hookdeck Event Gateway — hosted"]
direction TB
SRC["<b>Source</b><br/>verifies the provider's<br/>own signature"]
RULES["<b>Connection rules</b><br/>filter · deduplicate · retry"]
Q[("<b>Event queue</b><br/>holds what is not yet<br/>delivered, within retention")]
SRC --> RULES --> Q
end

subgraph GW["Your machine — one hermes gateway process"]
direction TB
AD["<b>hookdeck adapter</b><br/>verifies x-hookdeck-signature<br/>deduplicates · admission control"]
LED[("<b>Run ledger</b><br/>SQLite, survives restarts")]
RUN["<b>Agent run</b><br/>prompt → tools → response"]
AD <--> LED
AD --> RUN
end

P -->|"POST, signed by the provider"| SRC
Q -->|"<b>cli mode</b><br/>hookdeck listen holds an outbound<br/>connection — no public URL"| AD
Q -->|"<b>push mode</b><br/>HTTPS to your reachable URL"| AD

style HD fill:#f4f7ff,stroke:#4571d1,color:#26324d
style GW fill:#f3faf1,stroke:#3f8f3c,color:#1f3d1e
```

Two signatures, two different jobs. Hookdeck checks the *provider's* signature
at the edge — Stripe's, Shopify's, Twilio's, ~140 schemes — then signs its own
delivery. The adapter checks only that one, which is the whole point: Hermes
implements one verifier instead of one per provider.

## The delivery that has to come back

The diagram above is only the path in. What makes this more than a webhook
listener is the arrow it does not show — the adapter telling Hookdeck a run
failed, so the event returns instead of being forgotten:

```mermaid
sequenceDiagram
autonumber
participant H as Hookdeck
participant A as Adapter
participant L as Ledger
participant R as Agent run

H->>A: deliver — event id, attempt 1, x-hookdeck-signature
A->>A: verify · route · parse · filter
A->>L: is this new work?
L-->>A: yes — attempt 1 beats nothing seen
A-->>H: 202 accepted
Note over A,H: The ack goes out before the run finishes.<br/>Recoverable in both directions, which is what lets<br/>Hookdeck be the queue instead of the plugin owning one.
A->>R: dispatch
R-->>A: failed
A->>L: mark failed
A->>H: POST /events/{id}/retry
H->>A: deliver — same event id, attempt 2
Note over L: attempt 2 > attempt 1, so this is a retry, not a duplicate.<br/>A repeat of attempt 1 would be refused.
```

The attempt counter is what lets deduplication and retry coexist rather than
cancelling out. It is also how a gateway that dies at step 7 recovers: the
ledger row is still `running` at the next start, which by then can only be an
orphan, so the adapter asks for the same redelivery at step 9. See
[reliability](reliability.md) for the ack modes.

## Two ways in

Both modes run the same adapter and the same reliability machinery. They differ
only in how an event crosses your network boundary.

| | `mode: cli` (default) | `mode: push` |
|---|---|---|
| Reachability | None needed — the connection is outbound | A public HTTPS URL |
| Suits | A laptop, a homelab box, anything behind NAT | A VPS, a container, anything with an address |
| Extra process | One `hookdeck listen` per route | None |
| Gateway-side throttling | Not available — CLI destinations have no rate limit | Delivery rate limits, delivery groups, issue triggers, alerting |
| Buffering while you are down | Only if you **pause** first | Yes; failed deliveries stay queued and retry |

In `cli` mode the listener binds loopback only and is not reachable from the
network at all. In `push` mode it binds whatever `host` you configure, and the
signature check is the only thing in front of it.

## Three things here are called "CLI"

Worth separating once, because the quickstarts use all three:

- **The Hookdeck CLI** (`hookdeck`) — a binary you install from Hookdeck, and
what makes `cli` mode work. You do not run it by hand: the adapter spawns
`hookdeck listen` itself, one process per route, and supervises it —
restarting with capped backoff if it dies, piping its output into the gateway
log. What you do need is version 2.3.2 or later. The adapter authenticates a
CLI session of its own; see [operations](operations.md).
- **`hermes hookdeck …`** — the operator commands this plugin adds: `setup`,
`status`, `pause`, `resume`, `retry`, `doctor`. These call the Hookdeck REST
API rather than the binary above, and work in both modes.
- **`hermes`** — Hermes' own CLI, which hosts all of the above. `hermes gateway`
runs the process the adapter lives in.

## What the adapter does with one delivery

The order is deliberate, and the security-relevant part is that nothing reads
the payload before the signature is checked:

1. **Verify** `x-hookdeck-signature` against the raw bytes, in constant time.
2. **Route** — by the path segment Hookdeck was told to deliver to, else by
source name. No match is a 404, never a guess.
3. **Parse** as strict UTF-8 JSON or form encoding. Anything else is a 400 that
no retry can fix.
4. **Filter** — the route's `events` list, payload filters and route script. An
event the route does not want gets a 200, not an error.
5. **Deduplicate** against the ledger, *before* considering capacity, so a
repeat arriving at a busy moment is not deferred and then mistaken for new
work when it comes back.
6. **Admit or defer** — over `max_concurrent`, the answer is 503 with
`Retry-After` and nothing is written down.
7. **Dispatch**, then answer according to `ack_mode`, and record the run's real
outcome when it finishes.

Each step either produces a response and stops, or hands the delivery on.
Loading
Loading