Why both brains share one verb catalog, how it evolved from 9 to 227 verbs, and when the engine runs its own tools instead
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).
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.
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.
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.
| Phase | Verbs | What arrived |
|---|---|---|
| ADR 0008 origin | 9 | First Gate seam (hermes-only) |
| ADR 0010 expansion | ~60–80 | Google Workspace, Lark, WhatsApp, memory, people, wiki — both brains |
| Post-ADR 0010 (May 2026) | 114 | Sheet, Drive, Docs, Slides, browser (ADR 0016) |
| ADR 0029 generated | +82 composio | GitHub, Linear, Notion, Slack |
| Present (July 2026) | 227 total | 145 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.
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.
The executor is a 6-stage pipeline. Every verb, from calendar.create to message.reply to composio.execute, goes through exactly the same steps:
ParamSpec. Malformed args are rejected before dispatch.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.minRole against the principal's resolved access role (blocked → unknown → allowlisted → member → admin). A member cannot call admin-only verbs.HttpApiBackend (Bella API route), ComposioBackend (managed integration), or dispatch() (in-process, e.g. Surface dispatcher for message.reply).activity_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).
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.
| Surface | Adapter | Transport |
|---|---|---|
org-bella-whatsapp | WhatsAppOrgBellaSurface | WhatsApp Bridge |
personal-whatsapp | WhatsAppPersonalSurface (same impl, different session) | WhatsApp Bridge |
lark | LarkSurface | Lark API (markdown → post rendering) |
web | WebSurface | SSE + 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.
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)
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.
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.
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 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.
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:
composio.search(query) — semantic discovery of ~1,000 toolkits, returning action slugs + JSON schemas.composio.execute(toolkit, action, args) — audited execution with runtime JSON-Schema validation before dispatch.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.
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:
trusted opt-out for known-local agents.No catalog can cover every possible HTTP endpoint. The Gateway has two audited escape hatches (the combined http.request was retired in BEL-90):
http.get — GET-only, for idempotent reads.http.send — body-bearing methods (POST/PUT/PATCH/DELETE).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.
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.