System Design · Lesson 2 · Updated July 6, 2026

Action Gateway — Bella's Typed Action Surface

Why both brains share one verb catalog, how it evolved from 9 to 227 verbs, and when the engine runs its own tools instead

Reference

This lesson is grounded in the latest public snapshot: MetatechID/bella-enterprise main at 5b524a0. The key ADRs are ADR 0010 (genesis), ADR 0014 (replies), ADR 0016 (browser), ADR 0023 (engine parity), ADR 0029 (Composio), and ADR 0047 (external agents).

The problem that created the Gateway

Before the Gateway, Bella's brains touched the outside world through two different surfaces. When AGENT_BACKEND=bella, the brain called a api-modules.ts curl-documentation system — ~1,150 lines of prose describing HTTP endpoints that the LLM turned into shell commands. When AGENT_BACKEND=hermes, the brain used a much smaller typed verb catalog inside the Gate seam created by ADR 0008.

This asymmetry had a concrete cost: stateful invertibility. If an org ran Bella for six months, accumulated memory, skills, and history, then flipped to Hermes, the accumulated state was trapped in Bella's Knowledge Base — inaccessible to Hermes. Flipping back left Hermes-side memory invisible to Bella. The AGENT_BACKEND dropdown was technically reversible, but the data was not.

ADR 0010 resolved this by converging both brains onto one shared typed surface — the Bella Action Gateway — and one shared memory store (Hindsight). The dropdown became a genuine, state-preserving switch.

flowchart TB subgraph Brains["Brains (engines)"] BE["bella
(Claude Agent SDK)"] HE["hermes
(native gateway)"] NC["nullclaw / pi
claude-code"] end subgraph Gateway["Action Gateway
packages/worker/src/agent/gateway/"] EX["Executor
validate → authz → dispatch → audit"] CA["catalog.ts
227 verbs"] MC["MCP server
loopback"] end subgraph Backends["Backends"] HA["HttpApiBackend
Bella API routes"] CB["ComposioBackend
managed integrations"] SD["Surface dispatcher
message.reply"] end subgraph Audit["Observability"] AL["activity_logs
per-verb audit row"] VS["Verb Span
side-effect, redacted I/O"] SE["skill_executions
per-call trace"] end Brains --> MC MC --> EX EX --> CA EX --> HA EX --> CB EX --> SD EX --> Audit style Gateway fill:#fef9c3,stroke:#ca8a04 style Audit fill:#faf5ff,stroke:#9333ea style Backends fill:#dbeafe,stroke:#2563eb

Every engine reaches the Gateway through an MCP loopback. The executor validates, authorises, builds the backend request, dispatches, and writes the audit row — all before the brain sees the result.

The verb catalog: from 9 to 227

ADR 0008 originally carved out a 9-verb Gate seam. ADR 0010 recognised that 9 verbs were far too few for both brains to share — the goal was ~60–80. What actually happened is instructive: the catalog kept growing as each integration surface was modelled.

PhaseVerbsWhat arrived
ADR 0008 origin9First Gate seam (hermes-only)
ADR 0010 expansion~60–80Google Workspace, Lark, WhatsApp, memory, people, wiki — both brains
Post-ADR 0010 (May 2026)114Sheet, Drive, Docs, Slides, browser (ADR 0016)
ADR 0029 generated+82 composioGitHub, Linear, Notion, Slack
Present (July 2026)227 total145 inline + 82 generated (catalog.ts line count: 4,721)

The catalog is organised by namespace with ~30 groups. The largest clusters are Composio (15 dynamic), Lark (13), memory (12), calendar (10), and dozens of single-verb namespaces. Every verb declares its parameters, minRole, sideEffect, auditSlug, and dispatch backend in a single VerbSpec record — the source of truth for the brain-facing tool schema, the validation pipeline, and the audit surface.

Architectural chokepoint: the VerbSpec

The design that makes the Gateway powerful is also what makes it hard to change. Every verb lives in one type definition:

export interface VerbSpec {
  name: string;          // dotted verb name, e.g. 'calendar.create'
  category: string;      // grouping namespace
  sideEffect: 'read' | 'write';
  summary: string;       // brain-facing tool description
  params: ParamSpec[];   // typed parameters with schema
  minRole: AccessRole;   // minimum principal role
  auditSlug: string;     // greppable audit identifier
  requiresTurn?: boolean;
  requiresConfirmation?: boolean;
  destructive?: boolean;
  build: (args) => GatewayHttpRequest;
  dispatch?: (args, ctx) => Promise<Result>;  // in-process verbs
}

This one shape feeds the MCP tool schema (brain-side), the validation gate (executor-side), the Piperole gate, the recipient authorizer, the PII redactor whitelist, and the activity_logs row. If a verb is wrong in the catalog, it is wrong everywhere.

What happens when the brain calls a verb

The executor is a 6-stage pipeline. Every verb, from calendar.create to message.reply to composio.execute, goes through exactly the same steps:

  1. Schema validation — against the verb's declared ParamSpec. Malformed args are rejected before dispatch.
  2. requiresTurn check — verbs that need turn context (message.reply, vision.analyze) fail with a turn_required nudge if called proactively. message.reply has a special proactive-capture path for scheduled runs.
  3. Role gateminRole against the principal's resolved access role (blocked → unknown → allowlisted → member → admin). A member cannot call admin-only verbs.
  4. Recipient authorization (BEL-71) — for send verbs, the executor checks the target is allowed BEFORE building the request. Fail-closed on probe error.
  5. Backend dispatch — either HttpApiBackend (Bella API route), ComposioBackend (managed integration), or dispatch() (in-process, e.g. Surface dispatcher for message.reply).
  6. Audit writeactivity_logs row + Verb Span (PII-redacted) + skill_executions trace. Always written, success or failure.

The requiresConfirmation flag is observed, not enforced — the Gateway emits it in the Span so a post-Turn verifier can compute confirmation_gate_held. Actual confirmation stays prompt-driven inside skills (ADR 0008 decision).

Evolution 1: Inbound Surface and message.reply (ADR 0014)

The Gateway originally had per-channel reply verbs (whatsapp.send, lark.sendToChat, etc.). Skills had to know which channel they were on to pick the right verb — which defeated the point of channel-agnostic skill prose. More concretely, every handler duplicated an ~80% identical inbound-turn pipeline.

ADR 0014 introduced the Inbound Surface abstraction and one Gateway verb above it: message.reply. The brain emits replies without knowing the channel; the runtime dispatches on turn.surface.

SurfaceAdapterTransport
org-bella-whatsappWhatsAppOrgBellaSurfaceWhatsApp Bridge
personal-whatsappWhatsAppPersonalSurface (same impl, different session)WhatsApp Bridge
larkLarkSurfaceLark API (markdown → post rendering)
webWebSurfaceSSE + sub-token

The win: skills become channel-agnostic. A skill written for Lark replies works on WhatsApp without change. Adding a 4th Surface (Slack, Telegram) requires one new adapter, one slug, and one mediumOf() line — zero handler changes. Transcript persistence concentrates from 8 ad-hoc write sites to 2 helpers.

Auto-wrap with explicit override

To avoid rewriting every existing skill prompt to teach message.reply, the handler auto-wraps the brain's final output when repliesEmitted === 0. This means old text-only skills work unmodified. Skills that need attachments, multi-part deliveries, or mid-turn updates opt into explicit message.reply calls.

post-turn:
  if ctx.turn.repliesEmitted === 0 and result.output is non-empty:
    Surface.reply(turn, { text: result.output })
  else:
    no auto-wrap (brain delivered explicitly)

Web push: SSE + sub-tokens

Before ADR 0014, Web replies returned synchronously in the POST /chat HTTP response. After, the Web Surface returns 202 Accepted immediately and pushes results over EventSource. Sub-tokens (60s, single-use) keep long-lived JWTs out of URLs and access logs. Multi-tab fan-out and SSE Last-Event-ID replay complete the architecture.

Evolution 2: Browser as Gateway verbs (ADR 0016)

ADR 0010 left one open follow-up: browser ops were still an engine-internal concern. A flight-checkin skill on Bella produced zero activity_logs rows for its browser work — the same skill on Hermes would have produced Hermes-side records. Stateful invertibility held for memory but not for browser.

ADR 0016 lifted 6 BrowserService methods to typed verbs: browser.navigate, browser.click, browser.type, browser.screenshot, browser.extract, browser.close. All ride the same pipeline — role gate, audit, Verb Span, PII redaction. Screenshot bytes are base64 in the response; extracted text is PII-redacted by default (the whitelist exposes only byteCount).

This is the pattern for everything that goes on the Gateway: you get the entire verification-and-audit envelope for free just by adding a VerbSpec entry. The cost is scope — you only model the interactive core, not the long tail of evaluate, selectOption, or HITL flows.

Evolution 3: Engine parity (ADR 0023) — Gateway demoted

Every design decision has a revert. Six months after ADR 0008 created the Gate seam and ADR 0010 converged both brains, a deeper question surfaced: what if an engine is powerful enough to run its own tools? Specifically, claude-code (and soon fully-provisioned nullclaw and pi) has the Google Workspace MCP, native bash/curl, per-org credentials, and a real persona. Making it go through a ~114-verb proxy was artificially restricting it.

ADR 0023 partially supersedes ADR 0010

ADR 0010 said "the Gateway is the single typed tool surface." ADR 0023 says "the Gateway is one optional MCP server among the fleet" for fully-provisioned engines. The gateway still handles cross-cutting concerns (message.reply routeing, audited writes, recipient authz). But a fully-provisioned engine runs its own Google Workspace MCP, its own creds, its own browser.

The key insight is provisioning level, not engine name, becomes the gate. pi and nullclaw-with-gateway-kill-switch remain thin-client; fully-provisioned nullclaw and claude-code get the full fleet. The Gateway message.reply verb is still available to both — it stays the authoritative path for inbound-surface replies because only Bella knows the turn context.

This is a rare example of a design going from "chokepoint" to "optional path" as capabilities matured. The Gateway's actual audit surface — activity_logs rows with skillName: 'gateway-browser-navigate' — is preserved via a Lean Activity Log streamed back from the engine, even when the engine runs its own tools.

Evolution 4: Composio backend (ADR 0029)

Adding a new integration (GitHub, Linear, Slack, Notion) used to mean a new @vpa/*-api package, OAuth client, and verb cluster — ~2 weeks of engineering. ADR 0029 put Composio behind the Gateway as a new backend type, reachable via two meta-verbs:

The tool-surface cost is flat: ~2 advertised verbs reach 1,000 toolkits. Typed verbs become a promote-by-frequency optimization — verbs that see heavy use get code-generated entries in catalog.ts, inheriting per-verb PII redaction and param-level doc.

Composio sits behind the Gateway, never in front — so ctxArgs org-scoping, the Resource Map leak guard, activity_logs, Verb Spans, and PII redaction all survive. WhatsApp/Lark/Channels are structurally off-limits (Composio has no personal WhatsApp, no inbound triggers, no Lark toolkit). Internal verbs (memory, people, wiki, team) stay on HttpApiBackend.

Evolution 5: External agent delegation (ADR 0047)

The most recent Gateway extension: agent.invoke — a single verb that delegates a scoped sub-task to an external agent (openai-responses, mcp-as-agent, future a2a). The result re-enters Bella's turn as a child Execution, preserving the Turn-tree for /debug.

Key design constraints:

The escape hatch: http.get and http.send

No catalog can cover every possible HTTP endpoint. The Gateway has two audited escape hatches (the combined http.request was retired in BEL-90):

Both log to activity_logs like every other verb, but skip schema validation. The SSRF guard (BEL-109 C1) blocks non-HTTPS, loopback, and private IPs. When audit shows a recurring URL pattern, it gets promoted to a typed verb — the standing ADR 0010 rule.

What's next

Autonomous AI agents course: how Bella's engines (claude-code, nullclaw, hermes, pi) differ in provisioning, tool access, and the Gateway's evolving role. Lesson 3 should start with engine differences and the parity work in ADR 0023.

Which ADR introduced the Inbound Surface abstraction and message.reply verb?
ADR 0010 — Stateful invertibility and Gateway convergence
ADR 0014 — Inbound Surface adapter + message.reply Gateway verb
ADR 0008 — Hermes-as-Brain (the Gate seam)
ADR 0023 — Engine parity full provisioning
What changed in ADR 0023 that partially superseded ADR 0010?
The Gateway was removed entirely
All engines were restricted to the thin-client Gateway model
Fully-provisioned engines can run their own tools; the Gateway becomes one MCP server among the fleet
Composio replaced the Gateway as the single action surface