Design: Agent-Facing MCP Tool Set
Context
Switchboard vends each agent a scoped endpoint whose whole purpose is to let the agent do work: drain the durable todo queue and stand up the ingestion sources that feed it. SPEC-0006 realizes ADR-0012 (agents self-manage their own webhooks within a human-vended ceiling) and the contract shape of ADR-0005. It depends on the shared event-history contract SPEC-0005 for structured-output/error conventions and on the trust model SPEC-0003 that switchboard enforces on every ingested event regardless of who created the webhook.
The surface is implemented in internal/agentapi/agentapi.go, mounted under /agent on the shared
chi router (internal/server/server.go). Each request carries a bearer credential resolved by
store.EndpointByCredHash to a store.AuthEndpoint { AgentID, AgentName, OwnerHumanID, ScopeQueues, ScopeVerbs }. The handler enforces hasVerb (verb in ScopeVerbs) and inScope (queue in
ScopeQueues) before any transition. Todo transitions delegate to the store (ClaimTodo,
CompleteTodo, FailTodo), the acting owner is recorded as agent:<AgentID>, and expired leases are
requeued by a background reaper (server.reaper, 30s ticker) so a crashed agent never strands work.
Newly-verified todos are fanned out as a lossy doorbell — the durable todos table
(internal/db/migrations/0001_init.sql, states pending|claimed|done|failed) is always the ledger.
As shipped (ADR-0017; SPEC-0014) this doorbell is delivered to attached
MCP sessions as notifications/claude/channel on the Streamable-HTTP notification stream, driven by
the store's committed-transition doorbell hook (store.SetTodoDoorbellHook → mcp.PublishTodoReady).
The original bespoke /agent/stream SSE and its internal/agentapi Hub are retired; the ingest
accept-path keeps a small in-process ingest.Hub only as a test-observable new-todo seam.
The webhook self-management verbs (create_webhook, list_webhooks, rotate_webhook,
delete_webhook) extend this same scoped endpoint with ceiling-bounded ingestion-source management.
Goals / Non-Goals
Goals
- Give agents an autonomous, scoped work surface: claim/complete/fail todos and self-serve their ingestion sources without a human in the loop for routine ops.
- Enforce least privilege at the boundary: verb allowlist + queue grant + webhook ceiling.
- Keep switchboard the owner of secrets, verification, and idempotency — the agent handles URLs only.
- Guarantee crash safety: leases + reaper mean no work is stranded by a dead agent.
- Keep the fan-out stream a best-effort doorbell that never gates correctness.
Non-Goals
- The read-only event-history tools (list/get/replay) — those are SPEC-0005.
- The friending / A2A vend flow and the human web UI (separate capabilities/ADRs).
- Defining the ceiling storage fields (owned by the accounts/endpoints capability); this consumes them.
- Choosing the concrete MCP transport wiring; auth-by-default and scope enforcement are mandated regardless.
Decisions
Enforce scope at the boundary, before any mutation
Choice: Check hasVerb and queue-inScope in the handler before delegating to the store.
Rationale: An agent's scope is immutable per request; failing closed at the boundary keeps the
store logic simple and makes forbidden decisions attributable and uniform.
Alternatives considered:
- Enforce in the store: entangles authz with persistence and risks inconsistent checks per verb.
Leases + background reaper for crash safety
Choice: claim acquires a time-bounded lease; a 30s reaper requeues expired-lease todos (or
dead-letters when attempts are exhausted).
Rationale: An agent can crash mid-work; the durable queue plus lease expiry guarantees the todo
becomes claimable again without human intervention (SQS-style visibility).
Alternatives considered:
- No lease (claim = own forever): a crashed agent strands the todo permanently.
The lease contract is stated, not discovered
Choice: The session instructions and the claim, claim_next and heartbeat descriptions
spell out the lease: 300 seconds by default, at most 86,400 via lease_ttl_seconds, heartbeat
before it runs out and before any irreversible step, a lapse requeues the todo, and conflict
means stop. The numbers are built from defaultLeaseTTL and maxLeaseTTL; the
lease_ttl_seconds schema text is a struct tag, so a test pins it to them.
Rationale: The instructions used to say only that a claim "sets a lease". On 2026-10-01 a
Qwen3.8-27B one-shot worker claimed a review todo with claim_next and the default lease, never
called heartbeat, and was reaped 23 seconds after the lease lapsed while it was still reviewing.
It then approved and merged the pull request, and learned it had lost the todo only from the
conflict its complete returned. For about nine minutes another worker could have claimed the
requeued todo and acted on the same pull request. A worker that does not know the contract cannot
keep it, and the instructions are the one text every session reads before its first call.
Alternatives rejected: a longer default TTL only moves the lapse later, and strands a crashed
worker's todo for longer; tailoring the text to each endpoint's granted verbs makes every session's
instructions different for one sentence's gain, so the text instead says what to do without
heartbeat.
Each endpoint's default lease, read per call
Choice: claim, claim_next and heartbeat resolve their lease with one function
(internal/lease.TTL): the per-call lease_ttl_seconds, clamped to 86400, else the endpoint's
default_lease_ttl_seconds, else 300. The default is read from the endpoint row on each call that
passes no lease_ttl_seconds, not taken from the session's snapshot. The instructions and the three
descriptions are built from the endpoint's default when the session starts; an edit re-registers
the three tools on the endpoint's live sessions (tools/list_changed). get_default_lease and
set_default_lease are allowlisted, default-granted, and not friend-grantable
(ADR-0043).
Rationale: the text from #552 helps only a worker that reads and obeys it. A worker whose jobs
run an hour forgot lease_ttl_seconds and lost the todo, and a bare heartbeat cut a long lease
back to 300 seconds. The endpoint's human knows the job length, so the default lives on the endpoint
and applies when the call says nothing. Reading it per call makes an edit take effect on the next
claim without a reconnect, at the cost of one primary-key read.
Alternatives rejected: a server-wide default (one number for every worker); a per-queue default (queues are a shared namespace); using the session's snapshot (an edit would wait for every worker to reconnect); self verbs outside the allowlist, as presence uses (the human could not withhold the write).
The payload ships once, at claim
Choice: list_todos, complete, fail and heartbeat return compact rows (payload_size
in place of payload, no routing trace); only claim and claim_next return the full todo.
Rationale: The payload is the one field whose size Switchboard does not control. A forge
webhook body is ~15–20 KB, so a 200-row listing could be several megabytes, and every ack echoed
the payload the worker already held. On 2026-09-25 two Qwen3.8-27B workers (196,608-token window)
came back from a five-hour model outage to 57 pending todos; a house-rule list_todos call
(state = pending, limit = 200) returned 1.07 MB, and both sessions stuck on
ContextWindowExceededError, because every retry resent the same oversized history. Filtering
harder on the client cannot fix that: the rule already asked for pending rows in one queue. This
mirrors the event-history surface (SPEC-0005), whose list_webhook_events
returns payload_size and whose get_webhook_event returns the body.
Alternatives rejected: an opt-in include_payload flag on list_todos puts the overflow back
within one argument's reach; truncating payloads in rows gives a worker a body it cannot trust.
Lossy doorbell stream, durable queue as ledger
Choice: Hub.Publish never blocks; a full subscriber buffer drops the notification. The todo
stays in the queue.
Rationale: Push is a wake-up optimization; correctness must come from claiming off the durable
queue, so the stream can be best-effort without losing work.
Alternatives considered:
- Blocking/guaranteed delivery: a slow consumer would back-pressure producers and couple ingestion to agent liveness.
Switchboard mints and holds the secret; reveals it once; verifies per SPEC-0003
Choice: On create_webhook/rotate_webhook, switchboard mints the signing secret and holds the
plaintext server-side (the signing_secret column). For a signed-type webhook it reveals that secret
to the agent exactly once in the result — the agent pastes it into the producer (GitHub/Stripe/
Slack) — and every later read (list_webhooks) returns only the ingest URL. On delivery, the
/webhooks/w/{token} receiver recomputes the provider HMAC over the raw body against the held secret
in constant time (verifyGitHub/verifyStripe/verifySlack, internal/ingest) and persists verified=true/trust_mode=signed on a valid signature, failing closed
(401, nothing persisted) otherwise.
Rationale: Self-management changes who created a webhook, not how it is verified. Provider
HMAC verification needs the plaintext secret at both ends — the producer (to sign) and switchboard (to
recompute) — so switchboard must hold a recoverable secret, not a one-way hash, and hand it to the
agent once to configure the producer. Switchboard still owns the trust mode (no agent downgrade) and
never marks a delivery verified it did not verify, so a self-created signed webhook is exactly as
trustworthy as a human-configured one.
Alternatives considered:
- Store only the hash: precludes recomputing the HMAC, so the secret could never reach the verifier; signed deliveries could not be verified at all.
- Reveal the secret on every read: needless additional exposure of key material; one-time reveal at create/rotate is sufficient for the agent to configure the producer.
At-rest encryption (optional hardening): because the secret must be recoverable
(not hashed), the plaintext-in-signing_secret design leaves it readable to anyone with DB access.
As optional hardening, setting SWITCHBOARD_SECRET_ENCRYPTION_KEY (a 32-byte AES-256 key, base64 or
hex, held outside PostgreSQL via env/deploy config) makes create_webhook/rotate_webhook
persist the secret as AES-256-GCM ciphertext (enc:v1:<base64(nonce‖ct)>) instead of plaintext, and
the /webhooks/w/{token} delivery path decrypts it transparently to recompute the HMAC — no
behavior change, only a stronger at-rest posture. The key is deliberately not co-located with the
ciphertext, so a DB-only compromise yields ciphertext, not live signing secrets. It is off by default
(empty key = legacy plaintext), and a store with a key configured still reads legacy plaintext rows
unchanged (values without the enc:v1: marker pass through), so enabling it strands no existing rows.
The envelope primitive lives in internal/cred (SecretBox) alongside credential hashing; the store
seals on write and opens on read in internal/store/webhooks.go. Governing: SPEC-0006 REQ
"Switchboard Owns Secrets, Verification, and Idempotency", ADR-0002 (PostgreSQL is the trust boundary).
Architecture
The agent endpoint is one component in the switchboard process, sharing the router, store, and hub
with the ingestion and UI surfaces. The sequence below traces the two core flows this capability
owns: a ceiling-checked create_webhook (which then produces todos on delivery) and the
claim/complete drain loop over the durable queue.
Risks / Trade-offs
- Lossy doorbell can miss a wake-up → Mitigated: the todo is durable; agents also
list_todosto reconcile, so a dropped push only delays, never loses, work. - Agents can churn webhooks noisily within the ceiling → Mitigated by the count cap, per-endpoint rate limits, and the fact that all such actions are logged and attributable to the owning human.
- Boundary authz must be applied uniformly across every verb → Mitigated by a shared
transitionhelper that centralizes verb-check, queue-scope-check, decode, and error-mapping for claim/complete/fail; new verbs reuse it. - Reaper cadence (30s) bounds how quickly a crashed agent's todo is reclaimed → Accepted for the MVP; the lease TTL and reaper interval are tunable.
Migration Plan
Greenfield as a spec artifact — the agent surface, store transitions, hub, and reaper already exist
(internal/agentapi/agentapi.go, internal/server/server.go, internal/store,
internal/db/migrations/0001_init.sql). The webhook self-management verbs formalize behavior on the
same vended endpoint; no data migration is required beyond the ceiling fields owned by the
accounts/endpoints capability. The old prose contract (docs/specs/agent-mcp-tools.md) is superseded
by this spec/design pair.
Open Questions
- Resolved (2026-07):
heartbeatand the four webhook self-management verbs are implemented on the vended surface (internal/mcp/tools.go,internal/mcp/webhooks.go). The optionalreleaseverb (a MAY under SPEC-0006 "Lease Lifecycle and Crash Safety") is consciously deferred, not an oversight: an agent that wants to hand a todo back can stop heartbeating and let the lease expire — the reaper requeues it, losing nothing — and the operator board already exposes a human release action (POST /todos/{id}/release, SPEC-0013). Revisit only if lease TTLs grow long enough that expiry-as-release costs real latency. - Whether the friending verbs (
send_friend_request,approve/deny,revoke) live on this same endpoint or a distinct capability is out of scope here; they are noted in the old contract and belong to the A2A/vending ADRs. - The concrete storage of the webhook ceiling (max count, allowed source types, allowed queues) is owned by the accounts-and-endpoints capability; this spec consumes those fields.