SPEC-0006: Agent-Facing MCP Tool Set
SPEC · SPEC-0006 · Status · implemented · Date · 2026-07-06 · Implements · ADR-0012, ADR-0005 · Requires · SPEC-0005, SPEC-0003
Overview
Switchboard vends a scoped MCP/HTTP endpoint to each agent
(ADR-0012,
ADR-0005). This capability defines the
agent-facing work surface exposed on that endpoint — the verbs an agent actually uses to do its
job: draining the durable todo queue (list_todos, claim, complete, fail, plus lease
heartbeat), self-managing its own ingestion sources within a human-vended ceiling
(create_webhook, list_webhooks, rotate_webhook, delete_webhook), and deciding which
endpoints those sources deliver to (add_webhook_route, list_webhook_routes,
remove_webhook_route), and routing each delivery with jq rules (list_webhook_rules,
set_webhook_rules, add_webhook_rule, update_webhook_rule, move_webhook_rule,
remove_webhook_rule, test_webhook_rules; SPEC-0020).
Every request is authenticated by a bearer credential that resolves to an endpoint and its immutable
scope (internal/agentapi/agentapi.go). Scope is enforced at the boundary: a verb outside the
endpoint's verbs allowlist, or a queue outside its queues grant, is refused with forbidden
before any state changes. Webhook self-management operates strictly within a per-endpoint ceiling
(max count, allowed source types, allowed target queues); switchboard mints and holds the signing
secret (server-side, so it can HMAC-verify inbound deliveries per SPEC-0003) and owns verification and
idempotency. For a signed-type webhook the secret is revealed to the agent exactly once, at
create/rotate, for the agent to configure the producer; every later read returns only the ingest URL,
never the secret. This surface is distinct from, but complements, the shared read-only event-history contract
of SPEC-0005; it depends on that contract's shapes/errors and on the trust
model of SPEC-0003.
Requirements
Requirement: Bearer-Scoped Endpoint Authentication
Every request to the vended agent surface MUST carry a bearer credential in the Authorization
header. A missing credential MUST be rejected with unauthenticated (HTTP 401). A credential that
does not resolve to a live endpoint (revoked or unknown) MUST be rejected with unauthenticated. The
resolved endpoint's scope (queues, verbs) MUST be treated as immutable for the life of the
request; the agent MUST NOT be able to widen its own scope through any verb.
Scenario: Missing credential is rejected
- WHEN a request arrives with no bearer credential
- THEN the server MUST respond
unauthenticatedand MUST NOT perform any read or mutation
Scenario: Revoked credential is rejected
- WHEN a request presents a credential whose endpoint has been revoked
- THEN the server MUST respond
unauthenticated
Requirement: Scope Enforcement at the Boundary
Before executing any verb, the server MUST verify the verb is in the endpoint's allowlist and, for
todo-targeting verbs, that the todo's queue is in the endpoint's granted queues. A verb outside the
allowlist MUST be refused with forbidden. A queue filter or a target todo whose queue is outside
the grant MUST be refused with forbidden. These checks MUST occur before any state-changing
operation.
Scenario: Verb not in allowlist
- WHEN an endpoint whose allowlist lacks
completecallscomplete - THEN the server MUST respond
forbiddenand MUST NOT transition the todo
Scenario: Todo outside granted queues
- WHEN an endpoint calls
claimon a todo whose queue is not in the endpoint's granted queues - THEN the server MUST respond
forbiddenand MUST NOT claim the todo
Requirement: Todo Drain Verbs
The surface MUST expose list_todos, claim, complete, and fail. list_todos MUST return
compact todo rows filtered to the endpoint's granted queues (optionally narrowed by a queue and/or
state filter within scope) with a bounded limit (default 50). claim MUST atomically transition
a pending todo to claimed, acquiring a lease with a caller-supplied or default TTL, and MUST fail
with conflict if the todo is not in the expected claimable state. complete MUST transition a
todo the endpoint holds to done. fail MUST park a leased todo in failed with a scheduled
backoff retry window if attempts remain — it re-enters pending when the window elapses, per
SPEC-0003 Bounded Retries — otherwise dead-letter it to failed with no
window. The todo owner MUST be recorded as the acting agent
identity (agent:<agent_id>). A todo returned or transitioned MUST carry at minimum id, queue,
state, and attempt.
Scenario: Claim races resolve to one winner
- WHEN two endpoints call
claimon the samependingtodo concurrently - THEN exactly one MUST succeed (todo →
claimed) and the other MUST receiveconflict
Scenario: Fail retries until attempts are exhausted
- WHEN
failis called on a leased todo and retry attempts remain - THEN the todo MUST park in
failedwith a scheduled retry window (next_retry_at) and return topendingonce the window elapses; when no attempts remain it MUST transition tofailedwith no retry window
Scenario: List is confined to granted queues
- WHEN
list_todosis called with noqueuefilter - THEN it MUST return only todos in the endpoint's granted queues
Requirement: Lease Lifecycle and Crash Safety
claim MUST acquire a time-bounded lease. The surface SHOULD expose heartbeat to extend a lease
the endpoint holds and MAY expose release to voluntarily requeue. A todo whose lease expires
without completion MUST be reclaimable — a background reaper MUST requeue expired-lease todos (or
dead-letter them when attempts are exhausted) so that a crashed agent does not strand work. Lease
extension and completion MUST be permitted only to the endpoint that holds the lease.
Scenario: Expired lease is requeued
- WHEN an agent claims a todo and then crashes without completing it, and the lease TTL elapses
- THEN the reaper MUST return the todo to
pending(orfailedif attempts are exhausted) so another agent can claim it
Scenario: Heartbeat extends only the holder's lease
- WHEN an endpoint that does not hold a todo's lease calls
heartbeaton it - THEN the server MUST refuse it (
forbiddenorconflict) and MUST NOT extend the lease
Requirement: Webhook Self-Management Within a Vended Ceiling
The surface MUST expose create_webhook, list_webhooks, rotate_webhook, and delete_webhook,
bounded by the endpoint's ceiling: maximum webhook count, allowed source types, and allowed target
queues (ADR-0012). create_webhook MUST be
refused with ceiling_exceeded when it would exceed the max count, forbidden_source_type for a
disallowed source type, and forbidden for a target queue outside the grant. create_webhook and
rotate_webhook MUST return the ingest URL (and trust_mode); for a signed-type webhook they MUST
also reveal the minted signing secret exactly once (for the agent to configure the producer), and
MUST NOT return it on any later call. list_webhooks MUST return webhook metadata plus the ceiling
(max, allowed source types, allowed queues, used) and MUST NOT return secret values. delete_webhook
MUST tear down the webhook.
The trust mode MUST be derived by switchboard from the source type, never supplied by the agent.
github, gitea, stripe, slack, and cairn are signed: switchboard mints and holds an HMAC
secret, and cairn deliveries additionally carry signed replay defenses
(SPEC-0001 REQ "Cairn Signed Deliveries"). generic is token.
Scenario: Create beyond the count ceiling is refused
- WHEN an endpoint at its max webhook count calls
create_webhook - THEN the server MUST respond
ceiling_exceededand MUST NOT create a webhook
Scenario: Disallowed source type is refused
- WHEN an endpoint whose ceiling allows only
github/genericcallscreate_webhookwith source typestripe - THEN the server MUST respond
forbidden_source_typeand MUST NOT create a webhook
Requirement: Webhook Route Fan-Out Under Ownership and Friendship
A webhook's deliveries always mint a todo owned by the webhook's owning endpoint; a route adds
further target endpoints, and the receiver mints one endpoint-owned todo per target
(ADR-0022,
SPEC-0001 REQ "Deterministic Route Fan-Out (Token-Free)"). The
surface MUST expose add_webhook_route, list_webhook_routes, and remove_webhook_route to manage
that target set. Routing MUST name an explicit target endpoint id — never a queue name — so two
tenants sharing a queue string can never acquire visibility into each other's work.
All three verbs MUST require that the caller owns the webhook: the webhook's owning endpoint MUST
belong to the calling endpoint's human (agents.owner_human_id). A webhook id that is unknown,
malformed, or owned by another human MUST be refused with not_found, and the refusals MUST be
indistinguishable from one another, so a routing verb cannot confirm the existence of another human's
webhook.
add_webhook_route MUST additionally authorize the target:
- A target endpoint owned by the caller's own human MUST be allowed with no further condition.
- A target endpoint owned by another human MUST require an approved friend edge in the
delivering direction —
friend_edgeswithfrom_human= the caller's human,to_human= the target's human, andstate = 'approved'(ADR-0010, SPEC-0010 REQ "Per-Direction, Revocable, Non-Transitive Edges"). This is the same directioncreate_forrelies on: the target human's approval is what consents to receiving the requester's work. An edge that is absent,pending,denied,revoked, or recorded only in the opposite direction MUST NOT authorize the route; honoring the opposite direction would be a privilege escalation. - A target endpoint that is unknown, malformed, or revoked MUST be refused.
Every target-authorization failure — unknown id, revoked endpoint, or an unfriended human — MUST
return the same forbidden code and message, so the verb cannot be used to enumerate which
endpoint ids exist or which humans the caller is friends with.
add_webhook_route and remove_webhook_route MUST be idempotent: they state an end condition, so a
repeat call MUST succeed rather than return conflict, and MUST leave exactly one (or zero) route.
The webhook's owning endpoint is an implicit, unremovable target — remove_webhook_route against it
MUST be a no-op, and list_webhook_routes MUST report it alongside the explicit routes so the
reported fan-out set matches where deliveries actually land. list_webhook_routes and
remove_webhook_route MUST require webhook ownership only, not target authorization, so a route
whose friendship was later revoked remains visible and removable by the webhook's owner.
Scenario: Agent routes its own webhook to its own second endpoint
- WHEN an agent calls
add_webhook_routenaming a webhook its human owns and a target endpoint the same human owns - THEN the route MUST be recorded with no friend edge required,
list_webhook_routesMUST report it alongside the owning endpoint, and a subsequent delivery MUST mint one todo per target, each pinned to its own endpoint
Scenario: Routing to another human's endpoint without an approved edge is refused
- WHEN an agent calls
add_webhook_routetargeting an endpoint owned by another human, and noapprovedfriend edge runs from the caller's human to that human - THEN the server MUST respond
forbiddenand MUST NOT record a route — whether the edge is absent,pending,denied, orrevoked
Scenario: A friend edge in the opposite direction does not authorize delivery
- WHEN human B holds an
approvededge authorizing B to hand work to human A, and A callsadd_webhook_routetargeting an endpoint owned by B - THEN the server MUST respond
forbiddenand MUST NOT record a route; only an edge from A to B authorizes A's deliveries into B
Scenario: Routing a webhook the caller does not own leaks nothing
- WHEN an agent calls any routing verb naming a webhook owned by another human, and separately names a webhook id that does not exist
- THEN both MUST be refused with an identical
not_foundresponse, and no route MUST be recorded
Scenario: Adding the same route twice is idempotent
- WHEN
add_webhook_routeis called twice with the same webhook and target - THEN both calls MUST succeed and exactly one route MUST exist; the resolved target set MUST contain the target once
Requirement: Webhook Routing Rules
The surface MUST expose these verbs as members of the webhook verb family, so they are enumerated by the vend and consent screens with the rest of that family:
list_webhook_rulesset_webhook_rulesadd_webhook_ruleupdate_webhook_rulemove_webhook_ruleremove_webhook_ruletest_webhook_rules
Each verb MUST be gated by the endpoint's verb allowlist (forbidden outside it) and by human
ownership of the webhook. The ownership check follows the route verbs: unknown, malformed, and
another human's webhook ids MUST all return an identical not_found.
Saving rules MUST validate the whole list against the webhook's grant: its target queue, its owning
endpoint's allowed webhook queues, and its live delivery targets. A failed validation MUST leave the
previous list in force. A rule MUST only ever narrow a delivery to targets the webhook already has;
adding a target remains add_webhook_route's job.
test_webhook_rules MUST NOT persist anything. It MUST reach stored events only on the named
webhook.
Rule semantics, error codes, and the dry-run contract are specified in SPEC-0020 REQ "Rule Management Tools" and REQ "Routing Dry-Run".
Scenario: Rule verbs outside the allowlist are refused
- WHEN an endpoint whose allowlist lacks
set_webhook_rulescalls it - THEN the server MUST respond
forbiddenand MUST NOT change any rule
Scenario: A rule cannot reach another human's endpoint
- WHEN an agent saves a rule on its own webhook whose action names an endpoint owned by another human
- THEN the server MUST respond
forbiddenand MUST NOT change the rule list
Requirement: Switchboard Owns Secrets, Verification, and Idempotency
For any self-created webhook, switchboard MUST mint the signing secret and hold it server-side in
PostgreSQL — the plaintext, not a hash, because switchboard must recompute the provider HMAC over each
inbound body to verify it. Switchboard reveals the secret to the agent exactly once, in the
create_webhook/rotate_webhook result for a signed-type webhook, so the agent can configure the
producer; it MUST NOT return the secret on any later call (e.g. list_webhooks). A self-created
signed-type webhook MUST be verified exactly as
SPEC-0003 mandates: switchboard
recomputes the provider HMAC-SHA256 over the raw body against the held secret in constant time, and on
a valid signature persists the delivery as verified=true under trust_mode=signed — identical to a
human-configured signed webhook — while a missing or invalid signature is rejected and nothing is
persisted. Switchboard — not the agent — owns the trust mode: the agent MUST NOT be able to downgrade
the trust mode, disable signature checks, or otherwise alter how a delivery is verified, and
switchboard MUST NOT report a delivery as verified unless it verified the body signature per
SPEC-0003. Duplicate deliveries to a self-created webhook MUST dedup into a single todo on the
idempotency key. rotate_webhook MUST mint a new secret and retire the old one.
Scenario: Secret is revealed exactly once to the agent
- WHEN an agent calls
create_webhookorrotate_webhookfor a signed-type webhook - THEN the response MUST contain the ingest URL and the minted signing secret revealed once; the secret MUST be held server-side and MUST NOT be returned again on any later call
Scenario: Self-created signed webhook is verified per SPEC-0003
- WHEN a delivery arrives at a self-created signed-type webhook signed with the minted secret
- THEN switchboard MUST recompute the provider HMAC over the raw body against the held secret and,
on a valid signature, persist the delivery
verified=trueundertrust_mode=signed; an invalid or missing signature MUST be rejected with nothing persisted
Scenario: Trust mode cannot be downgraded by the agent
- WHEN an agent attempts to create or alter a
signed-type webhook to weaken its verification - THEN switchboard MUST derive and enforce the trust mode itself, MUST verify the webhook per SPEC-0003 regardless, and MUST NOT honor any agent-supplied trust downgrade
Requirement: Structured Output and Stable Error Shape
Every verb MUST accept a declared input shape and return structured output. Errors MUST use a stable
machine code plus a human message that MUST NOT contain secret material. The defined codes are:
unauthenticated (missing/invalid credential), forbidden (verb not in allowlist, or queue/target
outside grant), conflict (lost claim race / wrong state), not_found (unknown todo/webhook id),
invalid_argument (bad input), ceiling_exceeded / forbidden_source_type (webhook ceiling), and
internal (unexpected server-side failure).
Scenario: Unknown todo id raises not_found
- WHEN a transition verb targets a todo id that does not exist
- THEN the server MUST respond
not_found
Requirement: Error Handling Standards
Errors crossing a boundary (store, outbound HTTP, request decode) MUST be wrapped with context and
MUST NOT be silently swallowed. Domain failures MUST be surfaced as sentinel errors (e.g. not-found,
conflict) and mapped to the stable error codes above; unexpected failures MUST map to internal and
be logged with structured context. A store or transport failure MUST NOT leak internal detail or
secret material into the client-visible message.
Scenario: Store failure maps to internal and is logged
- WHEN a store operation returns an unexpected error during a verb
- THEN the server MUST respond
internal, MUST log the underlying error with context, and MUST NOT expose the underlying error text to the client
Requirement: Concurrency Safety
The endpoint MUST propagate the request context for cancellation and timeout into all store and outbound operations. The new-todo fan-out stream MUST be a best-effort doorbell that never blocks a producer and never gates correctness — the durable todo queue remains the ledger. The background lease reaper MUST have an explicit lifecycle (clean startup, graceful shutdown on context cancellation) and MUST be race-safe. Concurrent claims MUST be resolved atomically by the store, not by application-level locking.
Scenario: Slow stream consumer never blocks producers
- WHEN a subscribed agent's stream buffer is full while new todos are created
- THEN the server MUST drop the pushed notification for that subscriber rather than block, and the todo MUST remain durably in the queue for the agent to claim
Scenario: Graceful shutdown stops the reaper
- WHEN the process receives a shutdown signal (request context cancelled)
- THEN the reaper MUST stop cleanly without leaking goroutines
Requirement: Database Operation Standards
Multi-step atomic mutations (claim-with-lease, complete, fail-with-retry-or-dead-letter) MUST execute under a transaction or an equivalent atomic conditional update so a todo cannot be left in an inconsistent state. All queries MUST be parameterized; string interpolation of user-supplied values into SQL MUST NOT be used. Connections MUST have explicit lifecycle and timeouts via the propagated context.
Scenario: Claim is atomic
- WHEN
claimtransitions a todo frompendingtoclaimed - THEN the state change and lease assignment MUST be applied atomically so no partial state is observable
Security Requirements
Authentication
Every endpoint on this surface is served over HTTP and MUST require authentication by default via a bearer credential resolved to a scoped endpoint. There are no public verbs on this surface.
Transport superseded (ADR-0017; SPEC-0014). These verbs now ship as MCP tools over Streamable HTTP at
/mcp/{endpoint}—list_todos/claim/complete/fail/heartbeatplus the webhook self-management verbs, with the new-todo doorbell delivered asnotifications/claude/channelon the MCP notification stream. The bespoke/agent/*REST routes and the/agent/streamSSE below are retired and no longer served; the table is kept as the record of the verb set, auth model, and scope semantics that SPEC-0014 carries over MCP.
| Endpoint (retired REST shape) | Auth | Justification |
|---|---|---|
GET /agent/whoami | Required | Returns the caller's identity + scope. |
GET /agent/todos (list_todos) | Required | Exposes queue contents. |
POST /agent/todos/{id}/claim (claim) | Required | State-changing lease acquisition. |
POST /agent/todos/{id}/complete (complete) | Required | Terminal state transition. |
POST /agent/todos/{id}/fail (fail) | Required | State transition / dead-letter. |
GET /agent/stream (new-todo doorbell) | Required | Streams queue activity in scope. |
create_webhook / list_webhooks / rotate_webhook / delete_webhook | Required | Manages ingestion sources within the vended ceiling. |
Rate Limiting
Webhook self-management verbs (create_webhook, rotate_webhook, delete_webhook) MUST be
rate-limited per endpoint to prevent noisy churn within the ceiling. Todo drain verbs SHOULD be
rate-limited per endpoint to bound store load. A per-endpoint token bucket is RECOMMENDED; the ceiling
count cap is a hard secondary bound on webhook creation.
Security Headers
All HTTP responses on this surface MUST include:
Content-Security-Policy:default-src 'none'(JSON/SSE API, not browser assets)X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-origin
Request Body Size Limits
All verbs accepting request bodies (claim/complete/fail, webhook create/rotate) MUST bound them with
http.MaxBytesReader. Limit: 1 MiB per request — the MCP-wide cap applied to every /mcp/*
request before JSON-RPC parsing (maxBodyBytes in internal/mcp/mcp.go), which these verbs ride.
Oversized bodies MUST be rejected before decoding.
CSRF Protection
This surface is a machine-to-machine bearer-authenticated JSON/SSE API, not a cookie-authenticated browser session; classic form CSRF does not apply. State-changing verbs MUST authenticate via the bearer credential and MUST NOT be reachable through ambient cookie authentication. No HTML forms are served here.
Redirect Validation
Agents supply webhook target_queue and source_type, not outbound redirect targets — this surface
issues no user-supplied outbound redirects. The ingest_url returned by create_webhook is minted by
switchboard, not supplied by the agent, so there is no open-redirect or SSRF vector on this surface.
(The read-only event-history surface's replay target validation lives in
SPEC-0005.)
Cross-References
- Self-management split & ceiling: ADR-0012.
- Contract shape / structured output / errors: ADR-0005, SPEC-0005.
- Trust model switchboard enforces regardless of who created the webhook: SPEC-0003 / ADR-0003.
- Design & architecture for this capability: design.md.