feat(outpost): Outpost API client and command tree - #348
Draft
leggetter wants to merge 21 commits into
Draft
Conversation
First phase of Outpost support (#346): the API client layer that the `hookdeck outpost` commands and MCP server will be built on. No user-facing commands yet. Client: - Outpost API base URL, a separate client instance, and config resolution including a hidden --outpost-api-base for dev - IsOutpostProject alongside IsGatewayProject - Per-resource methods for tenants, destinations, events, attempts, retry, publish, topics, destination types, metrics, managed config, custom domain and status - Destination type schemas fetched and cached per API host and project, so --type validation follows the API rather than a hardcoded list Two shapes worth calling out. The `topics` field is a union — either "*" or an array — so it decodes through a dedicated type rather than []string. Publish takes a Project API key as a bearer token, which the stored CLI key cannot satisfy, so it sends through a clone with no stored credential. Live tests (build tag `outpostlive`) exercise the client against a real project and found two bugs that the stub-based unit tests could not: - destination-type `options` is [{label, value}], not []string; the stub fixture had encoded the wrong shape, which is why the unit tests passed - only HTTP 200 was treated as success. The Event Gateway API answers 200 to everything, so this never surfaced, but 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 Docs: README gains a key capability matrix and a way to tell which credential you hold; AGENTS.md gains the same diagnosis for agents plus the acceptance key table. The config field named api_key holds a CLI client key regardless of origin, which is easy to misread. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds the `hookdeck outpost` group with an Outpost-project gate mirroring the Gateway one, plus the tenant command tree: list, get, upsert, delete, token and portal. The gate matters for the error message rather than for safety. Pointing an outpost command at a Gateway project otherwise returns a 404, which reads as "no such tenant" instead of "you are on the wrong project"; it now says which type the project is and how to switch. Tenants are created through upsert because their IDs are chosen by the caller rather than generated. Delete names the destination count in its prompt, since that is the part most likely to have been forgotten. `--id` joins the empty-value guard list. It is a filter rather than an identifier, but the failure is worse: an empty value drops the filter, so `--id "$UNSET"` silently widens the query to everything rather than narrowing it. Verified against a real project, along with the no-terminal delete path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds `hookdeck outpost destination` — list, get, create, update, delete, enable and disable — with --tenant-id persistent across the group, since every destination endpoint is tenant-scoped. Deviation from the plan worth noting. The plan called for flat per-field flags (--config-url, --credential-secret). That is not implementable here: Cobra registers flags at init, but destination fields differ per type and are only known after fetching the schema, so declaring them would mean a network call before every command could parse its own arguments. Config and credentials are repeatable key=value pairs instead (--config url=https://example.com), with --config-file and --credentials-file as escape hatches. The schema is still used, for validation rather than flag registration: unknown keys, missing required fields, values outside a declared option set and values failing a declared pattern are all rejected before the request, naming the exact flag to fix and pointing at `destination-type get <type>` for the field list. Per AGENTS.md, a schema that cannot be fetched warns and continues rather than blocking a valid command. Update reads the existing destination to recover its type, so callers do not have to repeat --type just to get their config validated, and refuses an update with no fields rather than silently succeeding. Verified against a real project: create, list, get, update, enable, disable, schema validation, unknown type, missing tenant, and the no-terminal delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds `hookdeck outpost destination-type list|get`, and makes `destination create --type <type> --help` list that type's fields. Dynamic help is the answer to the discoverability cost of key=value config flags: `--config` alone cannot say which keys are valid, because the fields belong to the Outpost deployment rather than the CLI. Cobra parses flags before running the help function, so once a user has named a --type we can show exactly the fields it accepts, sourced from the same schema used for validation. Three properties this holds to: - Plain `--help` is untouched and needs no network or credentials. It only gains a line saying how to get per-type detail. - Cache first. The schema cache is already per host and project with a 24h TTL, so the warm path is a local file read. A cold cache allows one request bounded at 2s, and only when credentials exist; unauthenticated, offline and cold-cache runs all fall back to static help rather than erroring or hanging. - REFERENCE.md cannot be affected. The generator reads Long and the flag definitions directly and never invokes help, so generated docs stay identical whatever is cached locally. Verified with warm and cold caches, and pinned by a test asserting help never rewrites Long or flag usage. One non-obvious detail: Cobra returns flag.ErrHelp before running the cobra.OnInitialize hooks, so on the help path the config is not loaded yet. Without initialising it the client has no base URL or project and the cache — keyed on both — is never found. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`--config a.b=c` now builds a nested object. Flat keys are unchanged, so this is a no-op for every destination type that exists today. It is added now because of what the key=value design is for. Outpost's destination types are defined by the deployment rather than the CLI, which is why fields are not hardcoded — but that cuts both ways: a nested type could ship server-side just as easily as a new flat one. Flat-only parsing would leave such a type impossible to create until we shipped a CLI fix, which is precisely the failure the design exists to avoid. Paths cost nothing today and remove that cliff. The syntax follows Helm's --set (a.b.c=v, with a file as the escape hatch) rather than being invented here. A literal dot can be escaped as `a\.b`; no field key in either product contains one today, so that exists to avoid a corner rather than to solve a present problem. Validation now skips nested values instead of rejecting them. The schema describes flat fields, so it cannot say whether a nested shape is valid, and per AGENTS.md a client-side guess must not block a command the API would accept. Checked against the live API while deciding this: all 9 destination types are flat and every value is a string on the wire. The /destination-types endpoint reports some fields as key_value_map or checkbox, but those are form-rendering hints — sending custom_headers as an object returns it normalised to a JSON-encoded string, identical to sending a string. Context in #347. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…d status commands Completes the outpost command tree. - event list/get/retry, attempt list/get — the debugging surface. Attempts carry the response code the destination returned, which is what you actually need when delivery is failing. - publish — the one command with different auth. The publish API takes a Project API key as a bearer token and does not accept the credentials `hookdeck login` stores, so it has its own --api-key defaulting to HOOKDECK_API_KEY. Without one it fails with an actionableError explaining why, rather than surfacing a bare 401 that the generic handler would rewrite into "your API key is invalid or expired" — true but useless, since the stored key is never valid here. - topic list — reports the fix when no topics are configured, since an empty list leaves the project unable to deliver anything. - metrics events/attempts — reports when results were truncated at the row limit, so a partial answer is not mistaken for a complete one. - config get/set and config custom-domain — set takes KEY=VALUE arguments with --unset to restore a default, and --dry-run showing before/after per key. These settings apply to every tenant in the project, so the diff matters. - status — the first thing to check when configuration changes have not taken effect yet. Attempt list uses the tenant-scoped route when exactly one tenant and one destination are given, and the general one otherwise; results are identical either way. Verified against a real project: publish end to end with matched destinations, retry recorded as a manual second attempt, dry-run confirmed not to apply, pagination, and the missing-key error path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds test/acceptance/outpost_test.go behind the `outpost` build tag, covering tenant and destination lifecycles, destination types, publish and inspect, metrics, config, and the validation error paths. The suite needs its own project. Every `hookdeck outpost` command requires an Outpost project, so the Gateway keys the existing slices use would be rejected by the project gate before any request is made. NewOutpostCLIRunner reads HOOKDECK_CLI_OUTPOST_TESTING_API_KEY, which is a Project API key doing double duty: exchanged via `hookdeck ci` for the CLI credentials most commands use, and passed directly to `outpost publish`, which does not accept CLI credentials. The Gateway-rejection test lives in the gateway slice rather than this one, because asserting that a Gateway project is refused needs a Gateway project. Two things worth noting for anyone extending this: - Error assertions read stdout, not stderr. The CLI prints errors to stdout today (see #340, which tracks moving them); `go run` writes its own "exit status 1" to stderr, so asserting there passes vacuously. The tests are commented so this fails loudly if the contract changes rather than silently checking the wrong stream. - Tenants are uniquely named per run and removed in t.Cleanup. The project is shared between local runs and CI, and a failed run can leave data behind, so nothing assumes it starts empty. Both suites were run locally against the real project before committing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds the generated REFERENCE.md block for the outpost command tree, a README section, and the publish key exception to AGENTS.md. The generator's table of contents is a hand-maintained list rather than being derived from headings, so Outpost was added there — along with Metrics, which had been missing since it was introduced. Both docs lead with the two things that are genuinely surprising: config and credential fields are key=value pairs because they belong to the Outpost deployment rather than the CLI, and publish needs a Project API key because it is the one command that does not accept the credentials `hookdeck login` stores. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Raises CLI-level acceptance coverage from 22/30 to 26/30 leaf commands. All four reuse data the existing tests already create, so they add coverage without adding setup. The tenant token assertion checks shape rather than contents — three JWT segments, and that the raw tenant id is not readable in it. The token is a real credential, so a test should not print or match on its payload. The four commands still uncovered are the tenant portal and its custom domain. They are not omitted casually: `custom-domain set` configures a real DNS-verified hostname on the shared project, and `tenant portal` returns 404 until one exists. Covering them safely needs a dedicated throwaway domain. They are the least proven surface and should be called out as such in beta release notes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The MCP server scaffolding in pkg/gateway/mcp was written for one product but
almost none of it is Gateway-specific. Move the shared parts into a new
pkg/mcpcore so a second Hookdeck MCP server can reuse them instead of forking
them: input parsing, the data/meta response envelope, API error translation,
the auth guard, the JSON Schema helpers, project display resolution, the login
and projects tools, and the server/telemetry scaffolding.
Each product supplies its own identity, tool-name prefix, API client and tool
list through mcpcore.Options. Everything the login and projects tools say about
"the login tool" or "the projects tool" now comes from that prefix, so a second
server cannot tell an agent to call a tool that does not exist in its session.
Help topic normalisation takes the prefix as a parameter for the same reason.
Also adds two things the second server needs, kept here so there is only one
implementation of each:
- TranslateAPIError handles 403 distinctly from 401. "Check your API key" is
the wrong advice when the credential is valid but not permitted.
- RequireWrite(enabled, action) guards a write action on a server started in
read-only mode.
And an option the Gateway does not use: Options.ProjectFilter restricts which
project types the projects tool lists and will switch to, so a server cannot be
pointed at a project it has no API for. Gateway leaves it unset and keeps its
current behaviour.
Gateway behaviour is unchanged: same tool names, descriptions, schemas and
response shapes. pkg/gateway/mcp now holds only its tool definitions and
resource handlers. Unit tests for the moved code moved with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`hookdeck outpost mcp` exposes Outpost as MCP tools: tenants, their
destinations, published events, delivery attempts, topics, destination type
schemas, metrics, project configuration and deployment status. Tools are
prefixed outpost_ so this server and `hookdeck gateway mcp` can be configured
in the same client.
The server starts read-only. The gate is the schema rather than a runtime
check: in read-only mode the write actions are absent from each tool's action
enum and from its description, so an agent is never told about an action it
cannot use, and a tool whose every action is a write is not registered at all
rather than registered to always fail. A guard in each handler backs that up
for a client that calls one anyway. --allow-write enables the rest, and is also
read from HOOKDECK_MCP_ALLOW_WRITE, with the flag winning. A bare --read-only
is accepted for the many users who type it out of habit; it wins over
--allow-write.
Two actions that only read are gated with the writes: `outpost_tenants token`
mints a tenant-scoped access token and `outpost_tenants portal` returns a URL
granting access to a tenant's portal. Both hand back a reusable credential, so
a read/write split drawn on HTTP methods alone would leave a read-only session
able to produce them at will. outpost_help says so, along with the current mode
and how to change it.
Publishing needs a Hookdeck Project API key, which the credentials stored by
`hookdeck login` cannot substitute for. Without one the publish tool is not
registered, and outpost_help explains why.
Notes on wiring:
- The server is built on the Outpost API client and mutates that one, so
`outpost_projects use` moves the client the later calls actually go
through. Listing projects and validating credentials are account-level
requests that the Outpost host does not serve, so those go through a
separate account client, which is kept in step on a project switch or a
login. mcpcore gained an AccountClient option for this.
- `outpost_projects` only lists, and only switches to, Outpost projects. A
Gateway project would leave every later call failing.
- The MCP stdout hygiene and authentication fallback in root.go now apply to
any `<group> mcp` command, and name the login tool that exists in that
session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…ry server Login and project switching are Hookdeck platform operations, not Gateway or Outpost ones. You log in to Hookdeck; you switch a Hookdeck project. So both servers now expose hookdeck_login and hookdeck_projects, while product tools keep their own prefix: outpost_tenants, hookdeck_connections. Outpost previously named these outpost_login and outpost_projects. The original reasoning was collision avoidance when both servers are configured in one client, which does not hold up: it is the same operation, clients namespace by server, and one consistent name for it is a feature rather than a clash. Gateway is unchanged, verified over stdio. Outpost is unreleased, so this costs nothing now and would be a breaking rename later. Two things this surfaced: - HelpTopic prepended the product prefix unconditionally, so a platform topic became outpost_hookdeck_projects and missed. It now tries the exact tool name first, which is what a caller passing a name from tools/list will send. - A test asserted the Outpost error must not mention hookdeck_login, on the grounds that the gateway tool does not exist in that session. That premise is now deliberately false. Rewritten to assert the error names a tool the session actually registers, which is the property worth holding. Note this does not address Gateway's own inconsistency: its product tools are also hookdeck_-prefixed, which needs a rename and a major bump (#352). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
10 tasks
Three changes from driving the Outpost MCP for real. **Project name and org were always empty.** Every MCP response carries active_project_name and active_project_org, but resolution went through ListProjects, which a project-scoped key from `hookdeck ci` cannot call. It failed, returned early, and left callers with a bare project id to show. Now it validates the key first, which works for any credential and returns the name of the key's own project, and only lists projects when the active one differs. `hookdeck whoami` has always done it this way. Fixes the Gateway server too, which had the identical hole. **The publish credential is now publish-specific**: --publish-api-key and HOOKDECK_OUTPOST_PUBLISH_API_KEY, and the MCP server no longer reads HOOKDECK_API_KEY. That variable means "exchange this for CLI credentials" for `hookdeck ci` and `listen`, and the CLI encourages exporting it for CI. Reading it here gave one name two meanings, and worse, let an ambient variable exported for something else silently register the one tool whose effects cannot be undone: publishing sends real events to real customer destinations. Enabling that should be something you typed. The `outpost publish` CLI command is unchanged and still accepts --api-key / HOOKDECK_API_KEY, because that is an explicit one-shot action rather than an unattended server. **Help text** now says switching project affects the session only, unlike `hookdeck project use`, so an agent can answer honestly when asked whether the user's CLI was repointed. Signing in does persist, because the user asked for it. Tool descriptions also tell the model to identify destinations by type and target rather than by id — Outpost destinations have no name field, so an id is all a model has unless told otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Missed in the previous commit. Caught by generate-reference --check, which is the point of the check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Found by driving the MCP server against real projects.
**Publish followed the credential, not the active project, and said nothing.**
The publish credential is fixed when the server starts; the active project moves
with hookdeck_projects use. When they disagreed, publishing for a tenant that
existed in the active project was accepted with a 202 and an event id, matched
nothing, was never delivered, and did not appear in any event list. The response
looked like a success and reported the active project in its meta, which read as
confirmation the event landed where the caller was looking. It had not.
Publishing now checks the tenant first, using the publish credential, so the
lookup resolves to the same project the event would go to. That also catches a
mistyped or unprovisioned tenant, which the API otherwise accepts rather than
rejects.
One subtlety worth recording: the check must not send the project header.
Publishing resolves the project from the credential alone, but resource reads
also honour the header — so leaving it set checks a different project from the
one being published to, and returns a 401 that hides the answer entirely.
**Validation errors carried no detail.** The API returns
{"message":"validation error","data":["topic is invalid"]}, but ErrorResponse
parsed only the message, so every 422 surfaced as a bare "validation error" with
nothing to act on. The data array is now appended, which improves every command,
not just publish.
**A publish that matches nothing now says so.** Zero matched destinations means
the event is not delivered and never appears in the events list, so there is no
artifact to inspect afterwards. The result now carries a warning rather than
looking like an ordinary success.
Not addressed here, both API-side rather than CLI: publishing for a
non-existent tenant returns 202 rather than an error, and an event matching no
destinations is not persisted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
The unit tests were updated when login and projects moved to the hookdeck_ prefix; this acceptance test was missed and still asserted outpost_login. It now also asserts the product-prefixed names are absent, so the rule is pinned from both directions rather than only one. Caught by running the tagged suite locally, which is the point of doing so before pushing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…/outpost-api-client
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Outpost support to the CLI: the API client layer and the full
hookdeck outpostcommand tree. Part of #346. MCP (Phase 3) is not in this PR — it follows separately so the shared-plumbing refactor can be reviewed on its own.Draft: the commands work and are tested, but have not been exercised by real users. The intent is to cut a
v2.6.0-beta.1from this branch, test in the real world, then merge.What's here
Plus the client layer (
pkg/hookdeck/outpost_*.go), a destination-type schema cache, anoutpostacceptance slice, and docs.Three things worth a reviewer's attention
1. Config and credentials are
key=valuepairs, not flat flags.This departs from the flat-flag convention (
--url,--bearer-token) used by gateway commands. Cobra registers flags at init, but destination fields differ per type and are only known after fetching the schema — declaring them would mean a network call before a command could parse its own arguments. The schema is still used, for validation and help. Dotted paths (--config a.b=c) are supported so a nested type shipping server-side would not need a CLI release.This creates a genuine inconsistency:
--configmeans "JSON object" in gateway commands and "key=value" here. Tracked in #347 with a proposed resolution (accept both formats in gateway — additive, so a minor).2.
--type <type> --helplists that type's fields.Since
--configalone cannot say which keys are valid, help does. Cobra parses flags before running the help function, so once a type is named it shows exactly that type's fields — cache-first, degrading silently to static help when unauthenticated, offline, or cold. Plain--helpis unchanged and needs no network or credentials.REFERENCE.md is provably unaffected: the generator reads command metadata directly and never invokes help. Verified with warm and cold caches, and pinned by a test asserting help never rewrites
Longor flag usage.3.
outpost publishneeds a Project API key.It is the one command that does not accept the credentials
hookdeck loginstores, so it has its own--api-keydefaulting toHOOKDECK_API_KEY, in the same shape ashookdeck ci. Without one it fails with its own guidance rather than a bare 401. Documented in README and AGENTS.md.Two bugs the live tests caught
Both were invisible to unit tests, which is why a build-tagged live suite exists (
test/acceptance/outpost_live_test.go,outpostlive):optionsis[{label, value}], not[]string. Decoding the real endpoint failed outright. The stub fixture had encoded the wrong shape, which is exactly why the unit tests passed. Surfaced onkafka— a type missing from the published OpenAPI enum but live in the API.Client.AcceptAnySuccessStatus, set on the Outpost client only, so gateway behaviour is unchanged.Testing
go build,go vet,go test ./...greenoutpostacceptance slice run locally against a real Outpost project before committing, plus the gateway slice for the project-gate rejection testtopicsunion decoding the bare"*"form, publish, retry, event and attempt readsHOOKDECK_CLI_OUTPOST_TESTING_API_KEY— this PR is its first runNotes for review
pkg/hookdeck/*.gofiles show as modified but are gofmt-only — they were already unformatted onmain