Why
The CLI already knows Outpost projects exist — project list reports them and project use will
switch to one — but nothing can be done with one once selected. Every gateway command rejects an
Outpost project by design, so a user whose active project is Outpost has a CLI that can run login,
whoami and project use and nothing else.
Hookdeck Outpost has a managed API and published SDKs, but no CLI and no MCP server. This epic adds
both: a hookdeck outpost … command group mirroring the shape of hookdeck gateway …, and
hookdeck outpost mcp.
Scope
The full managed Outpost API: tenants, destinations, events, attempts, retry, publish, topics,
destination types, metrics, operator config, custom domain, and deployment status.
hookdeck outpost
├── tenant|tenants list · get · upsert · delete · token · portal
├── destination|destinations list · get · create · update · delete · enable · disable
├── event|events list · get · retry
├── attempt|attempts list · get
├── publish
├── topic|topics list
├── destination-type|destination-types list · get
├── metrics events · attempts
├── config get · set · custom-domain (get · set · delete)
├── status
└── mcp [--allow-write]
Inventory
Phase 1 — API client
- Outpost API base URL constant + GetOutpostAPIClient(), reusing the existing client (auth,
project header, telemetry, verbose transport and error types all carry over unchanged)
- Hidden --outpost-api-base flag for dev/test, registered in the global-flag arity map
- IsOutpostProject() alongside IsGatewayProject()
- Per-resource client methods. Two shape differences from the Gateway API: array query params use
bracket notation (id[0]=…) and time filters use time[gte] / time[lte]; and the
tenant destination list is not paginated — it returns a bare array
- Destination config/credentials validated dynamically from GET /destination-types rather
than hardcoded per-type structs, mirroring how source types are handled today. Warn and continue
if the fetch fails; let the API be the authority
Live validation of Phase 1 (done)
Phase 1's unit tests run against stub servers, so they could only assert their own assumptions. A
build-tagged live suite (//go:build outpostlive, test/acceptance/outpost_live_test.go) makes real
requests. It found two genuine bugs that no stub test could have:
- Destination-type options is [{label, value}], not []string — decoding the real
/destination-types list failed outright. The stub fixture had encoded the wrong shape, which is
exactly why the unit tests passed. Surfaced on kafka, the type the OpenAPI enum omits.
- Only HTTP 200 was treated as success. The Event Gateway API answers 200 to everything, so
this had never surfaced; Outpost uses 201 on create and 202 on publish/retry, so every write
failed. Fixed with an opt-in Client.AcceptAnySuccessStatus set on the Outpost client only, so
Gateway behaviour is unchanged.
Now proven end to end against a real project: CLI-key auth against the Outpost host, tenant and
destination CRUD, the topics union decoding the bare "*" form, tenant token minting, publish with a
Project API key, event and attempt reads, and the tenant-scoped attempts route.
Phase 2 — Commands
- outpost command group with an Outpost-project guard mirroring the Gateway one
- Resource groups with singular Use + plural alias, --output json, and shared help text
- Destructive commands (tenant delete, destination delete, config custom-domain delete) use
the existing confirm + --force helper
- config set gets --dry-run — it changes delivery behaviour for every tenant at once
- outpost publish needs a Project API key. The Publish API accepts a Project API key only;
the credentials stored by hookdeck login are not accepted for it. publish therefore takes
--api-key (defaulting to HOOKDECK_API_KEY), like hookdeck ci. Every other outpost
command uses the stored credentials. Document in AGENTS.md, the command help, and the README
- REFERENCE.md blocks + README section
Phase 2 notes
Two decisions worth recording, both made while building:
- Config and credential fields are repeatable key=value pairs, not flat per-field flags. Cobra registers flags at init, but destination fields differ per type and are only known after fetching the schema, so flat flags would require a network call before a command could parse its own arguments. The schema is still used, for validation and for help. Dotted paths (--config a.b=c) are supported so a nested type shipping server-side would not need a CLI release. See Standardize config input: --config means two different things across gateway and outpost #347 for standardizing this with gateway.
- --type <type> --help lists that type's fields. Cobra parses flags before running the help function, so once a type is named the help can show exactly its fields — cache-first, degrading silently to static help when unauthenticated, offline or cold. Plain --help is unchanged and needs no network. REFERENCE.md is provably unaffected, since the generator reads command metadata directly and never invokes help.
Phase 3 — MCP
- Extract the product-agnostic MCP plumbing (input parsing, response envelope, error translation,
auth gate, login and projects tools, telemetry wrapper, server scaffolding) out of
pkg/gateway/mcp into a shared package. Gateway behaviour must not change — its existing
tests are the regression gate
- Generalise the argv sniffing in root.go so MCP stdout hygiene applies to any <group> mcp,
not just gateway mcp
- Parameterise the tool-name prefix — the login and help tools currently hardcode hookdeck_ in
user-facing strings
- Outpost tool set, one tool per resource with an action enum, outpost_-prefixed so both
servers can be configured in one client without name collisions
- --allow-write (default off, HOOKDECK_MCP_ALLOW_WRITE). Write actions are omitted from the
schema entirely in read-only mode, with a handler-side guard behind it. Accept a bare
--read-only as an explicit no-op alias; if both are passed, read-only wins
- Treat credential-returning reads as writes for gating: tenant token mints a 24-hour tenant JWT
and portal returns a URL granting portal access. Both are GETs, so a naive read/write split
would leave them available in read-only mode
- Set ReadOnlyHint / DestructiveHint tool annotations (the gateway server sets none today).
Per the MCP spec these are client UX hints and explicitly not a security boundary — the flag
does the enforcing
- Fix the type check in projects use: it currently switches to any project id without checking
the type, so an agent can move the session to a project the server cannot serve
Testing prerequisites — blocking, needs someone with access
- A development Outpost project with at least one topic configured, plus a Project API key for
testing publish
- A separate throwaway CI Outpost project and repo secret for the acceptance slice. The
existing acceptance slices all authenticate against Gateway projects, so a new matrix entry and
secret are required. Keep it separate from the development project: these tests create and
delete tenants and destinations, and config set coverage mutates project-wide settings
On write tools in MCP
#228 sets out the split — skills + CLI for
building, MCP for investigation — and #340
restates that hookdeck gateway mcp is deliberately read-only.
hookdeck outpost mcp takes a different approach deliberately: it ships write tools, gated behind an
explicit --allow-write opt-in and off by default. The read-only default preserves the safe
out-of-the-box behaviour, while making the full surface available to operators who choose it.
The reasoning behind #228 does not transfer cleanly to Outpost. That split assumed skills + CLI
already covered the build path — true for the Event Gateway, which has published agent skills and full
CRUD commands, and not yet true for Outpost. More importantly, Outpost is operator-facing: managing
tenants and destinations is routine operational work, not just setup, and it is often done from
exactly the chat contexts #228 identifies as MCP's home ground.
The flag name follows the convention used by MCP servers that are read-only by default and opt in to
writes (the awslabs/mcp servers use exactly --allow-write). The more common --read-only spelling
is not used here because every server that uses it defaults to read-write, which is the wrong default
for this.
Prior art
- #228 — MCP RFC and the skills-vs-MCP split
- #340 — agent/automation safety epic; the
output-contract and non-interactive work there applies to these commands too
- #220 — gateway command UX and --output json
duplication; the new commands should not add more hand-rolled output branches than necessary
Sequencing
- Testing prerequisites (blocking — start now, it needs a human)
- Phase 1 — API client
- Phase 2 — commands
- Phase 3a — shared MCP package extraction, gateway tests green and unchanged
- Phase 3b — Outpost MCP tools and --allow-write
Phases 1–2, 3a and 3b are three separate PRs; 3a lands on its own so the gateway refactor is reviewed
without new tools mixed in.
Why
The CLI already knows Outpost projects exist — project list reports them and project use will
switch to one — but nothing can be done with one once selected. Every gateway command rejects an
Outpost project by design, so a user whose active project is Outpost has a CLI that can run login,
whoami and project use and nothing else.
Hookdeck Outpost has a managed API and published SDKs, but no CLI and no MCP server. This epic adds
both: a hookdeck outpost … command group mirroring the shape of hookdeck gateway …, and
hookdeck outpost mcp.
Scope
The full managed Outpost API: tenants, destinations, events, attempts, retry, publish, topics,
destination types, metrics, operator config, custom domain, and deployment status.
Inventory
Phase 1 — API client
project header, telemetry, verbose transport and error types all carry over unchanged)
bracket notation (id[0]=…) and time filters use time[gte] / time[lte]; and the
tenant destination list is not paginated — it returns a bare array
than hardcoded per-type structs, mirroring how source types are handled today. Warn and continue
if the fetch fails; let the API be the authority
Live validation of Phase 1 (done)
Phase 1's unit tests run against stub servers, so they could only assert their own assumptions. A
build-tagged live suite (//go:build outpostlive, test/acceptance/outpost_live_test.go) makes real
requests. It found two genuine bugs that no stub test could have:
/destination-types list failed outright. The stub fixture had encoded the wrong shape, which is
exactly why the unit tests passed. Surfaced on kafka, the type the OpenAPI enum omits.
this had never surfaced; Outpost uses 201 on create and 202 on publish/retry, so every write
failed. Fixed with an opt-in Client.AcceptAnySuccessStatus set on the Outpost client only, so
Gateway behaviour is unchanged.
Now proven end to end against a real project: CLI-key auth against the Outpost host, tenant and
destination CRUD, the topics union decoding the bare "*" form, tenant token minting, publish with a
Project API key, event and attempt reads, and the tenant-scoped attempts route.
Phase 2 — Commands
the existing confirm + --force helper
the credentials stored by hookdeck login are not accepted for it. publish therefore takes
--api-key (defaulting to HOOKDECK_API_KEY), like hookdeck ci. Every other outpost
command uses the stored credentials. Document in AGENTS.md, the command help, and the README
Phase 2 notes
Two decisions worth recording, both made while building:
Phase 3 — MCP
auth gate, login and projects tools, telemetry wrapper, server scaffolding) out of
pkg/gateway/mcp into a shared package. Gateway behaviour must not change — its existing
tests are the regression gate
not just gateway mcp
user-facing strings
servers can be configured in one client without name collisions
schema entirely in read-only mode, with a handler-side guard behind it. Accept a bare
--read-only as an explicit no-op alias; if both are passed, read-only wins
and portal returns a URL granting portal access. Both are GETs, so a naive read/write split
would leave them available in read-only mode
Per the MCP spec these are client UX hints and explicitly not a security boundary — the flag
does the enforcing
the type, so an agent can move the session to a project the server cannot serve
Testing prerequisites — blocking, needs someone with access
testing publish
existing acceptance slices all authenticate against Gateway projects, so a new matrix entry and
secret are required. Keep it separate from the development project: these tests create and
delete tenants and destinations, and config set coverage mutates project-wide settings
On write tools in MCP
#228 sets out the split — skills + CLI for
building, MCP for investigation — and #340
restates that hookdeck gateway mcp is deliberately read-only.
hookdeck outpost mcp takes a different approach deliberately: it ships write tools, gated behind an
explicit --allow-write opt-in and off by default. The read-only default preserves the safe
out-of-the-box behaviour, while making the full surface available to operators who choose it.
The reasoning behind #228 does not transfer cleanly to Outpost. That split assumed skills + CLI
already covered the build path — true for the Event Gateway, which has published agent skills and full
CRUD commands, and not yet true for Outpost. More importantly, Outpost is operator-facing: managing
tenants and destinations is routine operational work, not just setup, and it is often done from
exactly the chat contexts #228 identifies as MCP's home ground.
The flag name follows the convention used by MCP servers that are read-only by default and opt in to
writes (the awslabs/mcp servers use exactly --allow-write). The more common --read-only spelling
is not used here because every server that uses it defaults to read-write, which is the wrong default
for this.
Prior art
output-contract and non-interactive work there applies to these commands too
duplication; the new commands should not add more hand-rolled output branches than necessary
Sequencing
Phases 1–2, 3a and 3b are three separate PRs; 3a lands on its own so the gateway refactor is reviewed
without new tools mixed in.