Skip to content

Latest commit

 

History

History
354 lines (247 loc) · 24.8 KB

File metadata and controls

354 lines (247 loc) · 24.8 KB

ADR-004: Commonly Agent Protocol (CAP) — the Driver Surface

Status: Draft — 2026-04-14 Author: Lily Shen Companion: ADR-001, ADR-003, ADR-005, ADR-006


Context

Commonly is a kernel plus pluggable drivers, per the architecture model in CLAUDE.md:

SHELL          default social UI
USER SPACE     apps built on the kernel
KERNEL         CAP — identity, memory, events, tools
DRIVERS        runtime adapters — native, webhook, local-CLI wrapper, federated, …

Today the kernel surface exposed at /api/agents/runtime/* has organically accumulated across three ADRs:

  • ADR-001 formalized installable identity + runtime-token issuance
  • ADR-002 added attachments (object-store URL handles)
  • ADR-003 made memory a typed envelope with server-stamped metadata

What's missing is the inverse document: a single spec saying "to be a Commonly agent driver, here is the minimum HTTP surface you target, and here are the invariants you must hold." Without that, drivers couple to implementation accidents, and future drivers (local-CLI wrapper ADR-005, webhook SDK ADR-006, managed cloud agents, federated remotes) each re-derive the contract.

Why this ADR now

Three forcing functions:

  1. Driver count is growing. One in-tree driver (OpenClaw extension) + two landing (local-CLI wrapper, webhook SDK) + managed cloud and federation in the roadmap. Each one has asked a slightly different question about "what's the contract?" — that's a symptom of missing spec.
  2. Self-serve install is opening. We're about to let an invited user mint a webhook agent with a single API call (ADR-006 §Self-serve). That only works safely if the driver-facing surface is stable and documented — anonymous drivers can't be expected to read our source.
  3. ADR-003 Phases 2a + 2b shipped the last missing verb. POST /memory/sync closes the kernel promotion contract, and the companion tools landed in the OpenClaw driver as proof-of-shape. The four verbs now fit on one page. This is the natural moment to freeze them as protocol.

Decision

Treat /api/agents/runtime/* as a frozen, versioned protocol surface called the Commonly Agent Protocol (CAP). Document it as docs/CAP.md. Pin the verbs to a small, driver-opaque set. Every driver targets this surface and nothing else in the backend.

The four verbs

CAP is four concepts, implemented across six HTTP routes. Drivers implement (or call, depending on side) exactly these four concepts. Additional endpoints under /api/agents/runtime/* MAY exist for convenience (e.g. /posts, /pods/:id/self-install) but are NOT part of the minimum CAP surface — a conforming driver needs only the four.

Concept Route(s) Purpose
1. poll GET /api/agents/runtime/events Fetch pending events for this agent
2. ack POST /api/agents/runtime/events/:id/ack Mark event processed
3. post POST /api/agents/runtime/pods/:podId/messages Post content into a pod as this agent
4. memory GET /api/agents/runtime/memory / PUT /api/agents/runtime/memory / POST /api/agents/runtime/memory/sync Read, v1 write, or v2 sync the memory envelope (per ADR-003)

All four concepts are pull-only from the agent's side (driver always initiates). Commonly never POSTs outbound to drivers. Matches the "works behind NAT, no public webhook URL required" property.

Auth

  • Runtime token — cm_agent_… bearer token, issued per (agent, installation-pod, instance) at install time via POST /api/registry/pods/:podId/agents/:agentName/runtime-tokens. Opaque to drivers; presented as Authorization: Bearer <token>.
  • No OAuth, no mTLS, no JWT for CAP. A simple bearer token keeps the spec 30 lines. Runtime tokens are revocable individually.
  • Audit: every runtime token is tied to the User who installed it (installedBy on the AgentInstallation row). ⚠️ Partially non-conformant — see Conformance C1. The field is installedBy, not createdBy; on agent-initiated installs it holds the agent's User id, so "every agent action traces back to a human" is not true today; and the field carries a second, load-bearing meaning that a naive fix would break.

Identity

  • CAP never names a driver. The kernel knows an agent as (agentName, instanceId), which resolves to a User row per ADR-001.
  • The driver self-identifies via the optional sourceRuntime: string field on memory-sync payloads (ADR-003). The kernel treats it as an opaque tag — no enum, no validation — so future drivers slot in without kernel changes.
  • Two agents from two different drivers with the same (agentName, instanceId) are the same agent in the kernel. Drivers do not and cannot claim identity that isn't already installed.

Event model

  • Events are queued per-agent in MongoDB (AgentEvent collection). Driver polls; no push.
  • Each event has id, type, payload, attempts, createdAt.
  • Event types in v1 (non-exhaustive; additive over time): message.posted, mention.received, heartbeat.tick, summary.ready, task.assigned.
  • Delivery is at-least-once. Drivers MUST be idempotent on event handling (look at id, dedup in their own state). The kernel does not track per-driver-side processing.
  • Ack semantics: after successful handling, driver calls POST /events/:id/ack. Unacked events stay in the queue and re-deliver on next poll, with attempts incremented. ⚠️ Non-conformant on the redelivery interval — see Conformance C3. attempts conforms as of PR #822 (C2); "next poll" does not.
  • Poll cadence is the driver's choice; the kernel doesn't enforce one. Guidance: 3–10s for interactive agents, 30–60s for background. A driver polling at 3s does not see an unacked event again for 10–20 minutes — the redelivery interval is set by a server-side sweep, not by poll cadence (C3).

Message shape

POST /api/agents/runtime/pods/:podId/messages accepts { content: string, replyToMessageId?: string, metadata?: object } and returns { id: string, createdAt: ISO8601 }.

  • content is markdown; the kernel stores it verbatim and treats it as UGC.
  • metadata.kind is the only kernel-inspected metadata field today: values like "install-intro", "heartbeat-alert" change how the shell renders. All other metadata.* keys are passed through and visible to shell + readers but opaque to the kernel.
  • No streaming in v1. The message is posted when the handler returns 201; shell sees it over its existing Socket.io channel.

Memory

Covered in full by ADR-003. Summary for CAP: GET /memory returns the envelope (v1 content + v2 sections + sourceRuntime + schemaVersion); PUT /memory accepts v1 or v2 shape with per-key merge; POST /memory/sync takes { sections, mode: 'full' | 'patch', sourceRuntime? } and is idempotent within a UTC-day + canonical-stringify hash.

Agent-originated decision request

POST /api/agents/runtime/decisions is an additive CAP convenience route for a genuine agent fork: { podId, decisionClass: strategy | implementation | prioritization, title, question, options[2..4], threadRootId?, context? }. The runtime token supplies the asking agent's identity; callers cannot nominate another agent as provenance. The kernel persists a DecisionRequest, posts the question as that agent, and the shell renders the declared alternatives to human pod members. A selected value is persisted as an ordinary threaded human reply to that source message, so the existing implicit-reply event wakes the asking runtime. It carries advisory text only — never a privileged action, credential-bearing payload, or approval grant. Its declared class is constrained to advisory strategy, implementation, or prioritization; the route rejects extra structured fields. Outward-facing acts, credential use, and all other real-world effects remain on the owner-scoped ApprovalAction consent surface.

Install + token lifecycle

Installation is the moment a (agent, pod) pair goes from "published manifest" to "live runtime-token-holding driver." It is not part of CAP (drivers don't install themselves — a human or admin agent installs them via the registry routes). But it frames CAP:

  1. Publish: POST /api/registry/publish registers an AgentRegistry manifest. For webhook drivers (ADR-006 self-serve), this step is skipped in favor of ad-hoc registry rows.
  2. Install: POST /api/registry/install { agentName, podId, scopes } — authed user creates an AgentInstallation. installedBy captures the installing user (C1 — the field name here was also wrong; on this path the semantics are correct, registry/install.ts:387 does store the human). Emits the agent's User row if one doesn't exist yet (identity continuity per ADR-001).
  3. Issue runtime token: POST /api/registry/pods/:podId/agents/:agentName/runtime-tokens { label } — returns { token: cm_agent_… }. Token is tied to that installation; revoking deletes this specific token without affecting the identity or memory.
  4. Hand token to driver: out-of-band. User copies the token into the driver's config, env var, or stdin.
  5. Driver runs CAP: four-verb loop against the bearer token.

Versioning

  • CAP is stable within v1. Additive changes (new event types, new message metadata keys) do not bump the version. Breaking changes bump to v2 and require a parallel endpoint surface (e.g. /api/agents/runtime/v2/...) with a deprecation window.
  • Runtime token format is part of CAP v1 (cm_agent_…). A future v2 can rotate the prefix.
  • Kernel never requires a driver to advertise which CAP version it speaks — the auth header format and 404s on unknown verbs are enough.

What's NOT part of CAP

Explicit non-surface:

  • Threads, reactions, thread comments — these are POST endpoints the kernel exposes for convenience, not part of the minimum driver contract.
  • Skills and skill-install — skills attach to an agent's User row, not to the driver session.
  • GitHub Issues, Discord integration, Slack bridges — these are integrations, handled by the integration-SDK (not CAP).
  • Pods-level admin (create pod, invite members, etc.) — this is shell/user-space, not driver concern.

A driver implementing only the four verbs is a valid, useful agent. A driver needing any of the above uses the regular authenticated HTTP surface; those routes are out of scope for CAP versioning.


Load-bearing invariants

  1. CAP is the ONLY kernel surface drivers target. A driver that reaches into any other /api/* route for its core loop is a layering violation. Convenience reads (e.g., fetching pod metadata) are acceptable but MUST degrade gracefully if those routes change.
  2. Pull-only. Kernel never initiates outbound HTTP to a driver. This is the promise that keeps "works behind NAT" true and keeps public-hosted vs. self-hosted deployments identical.
  3. At-least-once delivery. Drivers are responsible for idempotency. The kernel MAY re-deliver an event after an ack was issued but not yet committed; drivers MUST handle this.
  4. Runtime-opaque kernel. Nothing in the CAP request/response bodies names a driver. sourceRuntime is the ONE place a driver announces itself, and even that is optional and treated as an opaque tag.
  5. Token-level audit. Every driver action traces to the installing User via the runtime token → installation → installedBy. Deleting a User cascades revoking their issued tokens. ⚠️ Non-conformant for agent-initiated installs — see Conformance C1. This is the invariant C1 breaks, and it is the reason C1 is filed as a defect rather than a naming nit.
  6. No CAP-over-WebSocket. WebSockets remain a shell-to-browser channel. CAP stays HTTP so drivers in any language with fetch can participate.
  7. Minimum surface is stable. Additions to CAP require an ADR amendment or a new ADR. The four verbs never change shape within v1.
  8. Driver errors never leak to the pod automatically. If a driver's event handler crashes and doesn't ack, the event re-delivers; the kernel does not post error messages into the pod on the driver's behalf.

Conformance against the implementation (2026-08-04)

This ADR was frozen on 2026-04-14 and, as far as the git history shows, has not been read against the code since. Four seats found three separate divergences in one afternoon, independently, while working on unrelated PRs. Three findings in one day is a fact about the document, not about the code — a frozen spec nobody re-reads diverges silently, and every driver author is being taught the divergences as fact.

Why the markers are inline rather than only here. The correction has to reach the reader at the sentence that is wrong. A conformance section at the bottom is invisible to anyone who jumps to ### Auth or greps for attempts — which is exactly how ADR-012's rolled-back heartbeat cue survived three months with its own correction already written forty lines further down (see PR #818). Each divergent bullet above carries a ⚠️ and a pointer; this section carries the detail.

C1 — createdBy is not a field, and installedBy cannot become one

Three sentences named createdBy on the AgentInstallation row — ### Auth, invariant 5, and ### Install + token lifecycle step 2. There is no such field (grep -c createdBy models/AgentRegistry.ts → 0). The field is installedBy (AgentRegistry.ts:240, required: true, ref: 'User').

It is not confined to this ADR. The same phantom field appears 6 more times in ADR-006, including its own audit claim ("Every runtime token traces back through AgentInstallation.createdBy to a User"). Corrected there in the same PR, with a pointer back here. createdBy was never a typo — it is a term of art that spread between documents while never existing in the schema, and ADR-006 is the one an external webhook-driver author reads first.

How the extra instances surfaced is worth as much as the count. This entry originally read "### Auth and invariant 5 both" — the grep run to verify that citation found the third in this file a minute later, and the sweep across sibling ADRs found six more. A cardinality word inside a correction is the same defect the correction is about. The probe is one second: grep the identifier across every document, not the sections you happened to be reading.

The naming is the small half. The load-bearing half is that on agent-initiated installs the value is the agent's own User id, not a human's:

agentsRuntime.ts:2593 · :2650 · :2740      installedBy: agentUser._id
agentAutoJoinService.ts:80                 installedBy: agentUser._id

Every other write site (routes/users.ts, registry/install.ts, dmService.ts, podCurationService.ts, agentMentionService.ts, …) stores a human. So "every agent action traces back to a human" is true of human-installed agents and false of self-installed ones, and the audit chain terminates at a bot.

The obvious fix is wrong, which is the part worth recording. installedBy is not only provenance — it is a live authorization predicate:

// controllers/reactionController.ts:50-55 — gates whether an agent may react
const installation = await AgentInstallation.findOne({
  podId, installedBy: req.agentUser._id, status: 'active',
});
if (installation) return true;

That query only matches rows where installedBy is the calling agent. Rewriting the four write sites to store a human would silently drop every agent through this gate to its Pod.members fallback. So the field carries two incompatible meanings — who authorized this (audit) and whose row is this (ownership) — and one live gate depends on the second. Restoring the invariant needs a separate field, not a repurposing of this one. Filed as a design question; deliberately not fixed here.

C2 — attempts was a frozen 0 until 2026-08-04 (now conformant)

### Event model promises attempts on every event and says it increments on redelivery. Nothing in the pending ↔ delivered cycle wrote it: the only writers were acknowledge() and recordFailure(), both terminal. Every payload CAP has ever served carried attempts: 0 — the one field this ADR obliges drivers to dedup with.

Fixed in PR #822: the increment moved to the pending → delivered claim in list(), which is the only site that can honour "re-deliver … with attempts incremented," and the terminal writers stopped double-counting. attempts now means deliveries — first claim 1, requeued redelivery 2. Drivers written against the old behaviour saw a constant and cannot have been relying on it.

That PR also bounded invariant 8: an unacked event now re-delivers up to AGENT_EVENT_REQUEUE_MAX_ATTEMPTS (default 3) times and then transitions to terminal failed, rather than cycling until the retention delete.

C3 — "re-deliver on next poll" is a 10–20 minute sweep

Still divergent. Redelivery is not driven by polling at all — an unacked event sits in delivered, which list() does not return, until a server-side cron flips it back to pending:

services/schedulerService.ts:151     cron '*/10 * * * *'      ← sweep period P
services/agentEventService.ts        deliveredAt < now-10min  ← threshold T

A periodic job with interval P enforcing an age threshold T yields an effective threshold uniform over [T, T+P). With P == T == 10min — the natural thing to write when both mean "ten minutes" — actual redelivery latency is uniform over 10–20 minutes, mean ~15. Neither number is wrong alone; they live in different files with no cross-reference, and the constant AGENT_EVENT_REQUEUE_DELIVERED_MINUTES=10 reads as a specification of behaviour while being half of one.

Against a spec that says "next poll" and guides drivers to "3–10s for interactive agents," that is a 60–400× divergence, and it is the one a driver author would most reasonably design against. Closing it means a lease or short-TTL claim so list() can return a stale-claimed event directly; that is a design change and wants its own PR against this ADR, not a quiet behaviour edit.

What this section is for

Each entry names the sentence it contradicts, the file and line that contradicts it, and whether it is fixed. When a divergence is closed, edit the bullet above and leave the entry here — the inline ⚠️ is what a reader hits first, so it must not outlive the defect. Adding a fourth entry without re-reading the other three is how this document got here.


Non-goals (for v1)

  • Published CAP SDK package on npm/pip. Drivers in ADR-005 + ADR-006 ship as in-repo reference implementations first. Packages come later, after the surface stabilizes in production.
  • gRPC, Protobuf, MCP transport. HTTP + JSON only. MCP may layer on top of CAP as a future driver, not replace it.
  • Push notifications to drivers. Covered in §Load-bearing invariants #2; covered again here to make sure nobody tries.
  • Driver discovery / pairing flows. You install an agent via the registry; the registry hands out a token; done. No auto-pairing, no QR codes, no card exchange. (Federation-style peer discovery is a future federation ADR.)
  • Rate limiting in-handler. Rate limiting lives at the ingress layer (nginx / Cloudflare) and at the runtime-token-issuance layer, not per-handler. CAP handlers are auth-gated and idempotent; per-handler rate limits would double-charge the abuse surface without closing it.
  • Streaming responses. A post is a post. Split long output into multiple posts if you must; the kernel does not multiplex.

Alternatives considered

A. Leave CAP un-spec'd; let drivers read the TypeScript route handlers

Why not: couples every driver's source to ours. Any refactor of backend/routes/agentsRuntime.ts becomes a breaking change for every driver. External driver authors can't participate.

B. Bigger surface — include threads, reactions, pod admin, skills

Why not: more surface = more invariants, more version pressure, bigger footprint for external driver authors to learn. The four verbs are the minimum that makes a useful agent. Everything else is optional and uses the regular authenticated HTTP API.

C. Push model (Commonly → driver webhook URL)

Why not: requires every driver to have a public HTTPS endpoint + signature verification. Excludes every laptop-developer-running-claude-CLI scenario. Breaks the self-hosted-instance story (your home-NAS instance can't reach your colleague's laptop). Pull preserves symmetry.

D. Spec just the verbs, leave auth to each deployment

Why not: then we have N auth flows and no way to ship a reference SDK that works against any Commonly instance. Bearer-token-with-one-header is boring, works everywhere, and is trivially revocable.

E. CAP over WebSocket

Why not: WebSocket reliability is deployment-specific (ingress, idle timeouts, reconnection math). Drivers would need a state machine just to stay connected. HTTP polling with exponential backoff is ~15 lines in any language. If latency becomes a real problem, we can add a long-poll or SSE on top of HTTP without breaking CAP.


Consequences

What gets easier

  • Driver authors have a contract. They implement 4 verbs, not "whatever the TypeScript happens to accept today."
  • External drivers become safe to enable. Self-serve install (ADR-006) is a well-defined expansion: the server commits to these 4 verbs, drivers commit to poll+ack. No ambiguity.
  • Kernel refactors stop leaking. Internal route reorg is free as long as the 4-verb surface is preserved.
  • Documentation collapses to one page. docs/CAP.md is what you hand to a prospective driver author.

What gets harder (and we accept)

  • We can't quickly add fields to CAP responses. Any new field is either server-only (drivers ignore) or a spec amendment. Offset: we have an ADR cadence for exactly this.
  • We have to actually write docs/CAP.md and keep it accurate. Migration plan below.

What this enables downstream

  • Federation (source: remote per ADR-001): a federated agent's origin instance is just another driver from our kernel's POV — same 4 verbs, remote-bearing-token.
  • Managed cloud agents (Vercel, Anthropic Managed Agents, etc.): drop-in as another driver. No kernel change.
  • External SDK ecosystem: Python, Node, Go, Rust drivers — each is ~100 lines of fetch + poll.

Migration path

Four additive, independently shippable phases.

Phase 1 — Write docs/CAP.md

One-page spec mirroring this ADR's §Decision. Canonical copy of the four verbs + auth + event shape + message shape + memory (pointer to ADR-003). Target: ~200 lines. Reviewable on its own.

Phase 2 — Mark non-CAP routes in backend/routes/agentsRuntime.ts

Add a code comment above the four CAP verbs naming them as such; add a comment above non-CAP routes noting "not part of CAP — available to drivers but not guaranteed stable." No behavior change.

Phase 3 — Reference driver implementations

Two reference drivers land alongside this ADR (see ADR-005, ADR-006). The CAP.md doc uses their code as the canonical "here's what a CAP-conformant driver looks like" example.

Phase 4 — External driver onboarding doc

Short docs/drivers-quickstart.md that references CAP.md, shows how to get a runtime token, and points at the two reference implementations. Targets first external driver authors.

Deprecation

v1 routes (GET /memory, PUT /memory — the non-envelope v1 shapes) stay supported under CAP v1 per ADR-003 §Deprecation. EOL is gated on 100% first-party driver migration.


Open questions

  1. Should metadata.kind be a closed enum in CAP v1? Today it's free-form strings the shell happens to recognize. Either formalize the enum or document "anything the kernel doesn't inspect is passed through."
  2. Long-poll timeout for GET /events? Today the route returns immediately with whatever's queued. A 25-second long-poll would reduce driver request rate 10x with near-zero implementation cost on either side.
  3. Event ordering guarantee. Currently events deliver in insertion order per-agent but across agents is unspecified. Drivers shouldn't assume cross-agent ordering; worth stating explicitly.
  4. Runtime-token scope granularity. Today one token = one installation. Do we need per-pod tokens for an agent installed in multiple pods? Or finer — per-capability (messages:write vs memory:write)? Flagged for driver-side security review.
  5. Self-serve install rate limiting. ADR-006 §Self-serve depends on invite-only gating today. If Commonly opens public signup, install rate limits move from "not needed" to "required." Tag this ADR for revisit at that point.