Skip to content

Capability and Topology Registry

Nick edited this page Jun 20, 2026 · 4 revisions

Capability and Topology Registry (v0.1.53)

CW keeps two registries so a capability or a topology is declared in ONE place and reaches every surface, with no hand-wiring across 4+ files. Capabilities are declared as data — one row per capability in a single table that both front doors read — so each command appears on the CLI and as an MCP tool at once. Topologies self-register and auto-appear in topology list, topology validate, and topology apply.

BSD discipline: mechanism (one table / one Map) separate from policy (the rows / the entries). Fail-closed on unknown ids.

Capability Registry

One declared table

Every runtime capability is one row in a single declarative table — BUILTIN_CAPABILITIES: CapabilityDescriptor[] in src/capability-registry.ts. There is no runtime "register a handler" step for capabilities and no per-surface wiring: the table is the mechanism, and the CLI command plus the MCP tool are two renderings of the same row.

export interface CapabilityDescriptor {
  capability: string;            // canonical dot-namespaced id, e.g. "worker.list"
  summary: string;
  entry: string;                 // the ONE shared core entry both surfaces run
  surface: "both" | "cli-only" | "mcp-only";
  cli?: { path: string[]; jsonMode: "default" | "flag" | "human" };
  mcp?: { tool: string };        // e.g. "cw_worker_list"
  payloadIdentical?: boolean;    // cw <cmd> --json === cw_<tool> result
  reason?: string;               // required when surface !== "both" or payload diverges
}

Adding a new capability

Add one row to the table. The row names the shared core entry both surfaces route through, plus its cli and mcp bindings:

// src/capability-registry.ts — BUILTIN_CAPABILITIES
{
  capability: "my.new.tool",
  summary: "Does something.",
  entry: "myNewTool",            // a shared core entry in capability-core.ts
  surface: "both",
  cli: { path: ["my", "new-tool"], jsonMode: "default" },
  mcp: { tool: "cw_my_new_tool" },
  payloadIdentical: true,
}

No second registration call. Both front doors read this one table, so the capability appears on the CLI and as an MCP tool together, and the fail-closed parity gate (npm run parity:check) blocks the release if the two ever drift.

Routing flow

Each front door routes to the row's shared core entry with its own explicit switch (there is no runtime dispatcher; the table is what the switches are validated against):

CLI: cw my new-tool --json
     -> cli.ts switch matches "my new-tool" -> the "my.new.tool" row's shared core `entry`
     -> render terse text (or --json)

MCP: cw_my_new_tool
     -> mcp-server.ts switch matches "cw_my_new_tool" -> the SAME shared core `entry`
     -> render structured JSON

Why one table

  • One source of truth: 199 capabilities, 186 MCP tools, each declared once.
  • Both surfaces run the row's single shared entry — no logic is stranded in cli.ts or mcp-server.ts.
  • The parity gate fails closed on any drift: a peer present on one surface but missing from the other, an undeclared live tool, a reasonless surface-only capability, or a payload divergence on a payloadIdentical row each block the release.

Topology Registry

Interface

interface MultiAgentTopologyDefinition {
  schemaVersion: 1;
  id: string;              // open string (was "map-reduce" | "debate" | "judge-panel")
  title: string;
  summary: string;
  roles: TopologyRoleSpec[];
  groups: Array<{ id: string; title: string; roleIds: string[] }>;
  blackboardTopics: Array<{ id: string; title: string; description: string }>;
  phases: TopologyPhaseSpec[];
  fanoutStrategy: string;
  faninStrategy: string;
  requiredEvidence: string[];
  coordinatorDecisions: CoordinatorDecisionKind[];
  candidateExpectations: string[];
  verifierGates: string[];
}

interface TopologyRoleSpec {
  id: string;
  title: string;
  responsibilities: string[];
  count?: number;         // NEW: materialize N instances
  requiredEvidence: string[];
  expectedArtifacts: string[];
  faninObligations: string[];
}

Adding a new topology

import { registerTopology } from "./topology";

registerTopology({
  schemaVersion: 1,
  id: "swarm",
  title: "Swarm",
  summary: "Parallel swarm agents with consensus voting.",
  roles: [
    { id: "swarm-agent", title: "Swarm Agent",
      responsibilities: ["Produce shard result with evidence."],
      requiredEvidence: ["swarm output artifact"],
      expectedArtifacts: ["swarm result"],
      faninObligations: ["indexed swarm artifact"],
      count: 5 }
  ],
  groups: [{ id: "swarm", title: "Swarm Group", roleIds: ["swarm-agent"] }],
  blackboardTopics: [
    { id: "swarm-outputs", title: "Swarm Outputs", description: "..." }
  ],
  phases: [
    { id: "execute", title: "Execute", roleIds: ["swarm-agent"],
      fanout: true, fanin: false,
      requiredEvidence: ["swarm output artifact"],
      coordinatorDecisionKinds: ["artifact-index"] },
    { id: "consensus", title: "Consensus", roleIds: ["synthesizer"],
      fanout: false, fanin: true,
      requiredEvidence: ["all swarm evidence"],
      coordinatorDecisionKinds: ["candidate-synthesis"] }
  ],
  fanoutStrategy: "one membership per swarm agent role",
  faninStrategy: "consensus requires all swarm agent evidence",
  requiredEvidence: ["swarm output artifact", "consensus synthesis"],
  coordinatorDecisions: ["artifact-index", "candidate-synthesis"],
  candidateExpectations: ["Synthesis cites swarm agent provenance."],
  verifierGates: ["Swarm fanin must be ready before commit."]
});

Data-driven role expansion

Before v0.1.53, materializedRoles() hardcoded:

if (role.id === "mapper") { ... expand N times ... }
else if (role.id === "judge") { ... expand N times ... }
else { single instance }

Now it checks role.count:

  • role.count > 1: creates role-1, role-2, ..., role-N
  • role.count undefined or 1: single role instance
  • Backward compat: mapperCount/judgeCount overrides still work for official topologies

Key Design Decisions

  1. Mechanism over policy, per layer. Capabilities are a static declared table (BUILTIN_CAPABILITIES, read at build/check time — no register() at runtime). The topology registry is the open runtime Map — a Map<string, MultiAgentTopologyDefinition> with registerTopology() and getTopologyDefinition(). No DI container, no lazy loading.

  2. Policy is declaration at import time. What exists is what was registered when modules loaded. No runtime config file that can silently change behavior.

  3. Fail closed. Unknown capability id → CapabilityError("not-found"). Unknown topology id → validation error "unknown-topology". Never silent fallback.

  4. One source of truth. All 199 capabilities and 3 official topologies are declared once. (v0.1.53 also shipped a dynamic capability dispatcher meant to route handlers at runtime; it had zero call sites and was removed as dead code in v0.1.81 (#131). Capabilities are declared, not runtime-dispatched.)

  5. Single import surface. capability-registry.ts is the one place the declared capability table and its parity helpers live; cli.ts and mcp-server.ts are validated against it. Consumers import from one place.

See Also

Clone this wiki locally