Agentz #ai #mcp #a2a #plugin-architecture

One module definition, three protocols: the Agentz module SDK

For four months, Agentz specialists — the sub-agents that handle memory, graphs, documents, code, stocks — existed in exactly one form: wired into one Electron app. Good specialists, unportable. This week that changed: each capability is now a module, a package built on a small SDK, and one module definition can run three ways — embedded in Agentz, served as an MCP server, or standing alone as an A2A agent with its own agent card.

The contract

A module is a single defineModule() call — the function is just a typed identity, which is the correct amount of framework. Here's the knowledge graph module, trimmed:

export function createGraphModule(deps: GraphHandlerDeps) {
  const handlers = createGraphHandlers(deps);
  const tools = GRAPH_TOOL_SCHEMAS.map(schema => ({
    schema,
    handler: handlers.find(h => h.name === schema.name)!,
  }));
  return defineModule({
    id: 'graph',
    name: 'Graph Specialist',
    version: '0.1.0',
    description: 'Manages the knowledge graph...',
    portable: true,
    systemPrompt: `You are a Knowledge Graph Specialist...`,
    orchestratorGuidance: 'Use the Graph Specialist when...',
    defaultModelId: null,
    tools,
    databases: [{
      slot: 'graphDb',
      initialize: initializeGraphDb,
      description: 'Knowledge graph nodes and edges',
    }],
    dependencies: [],
    mcpServers: [],
  });
}

The declaration covers five concerns, and the list is worth reading as a theory of what an agent capability is:

  • Tools: each pairs a JSON Schema (what the LLM sees) with a handler (what runs). The two travel together, so a module can never drift into advertising tools it doesn't implement.
  • Prompts, twice: a systemPrompt for when the module runs as an autonomous agent, and orchestratorGuidance for injection into a parent orchestrator's prompt. A capability needs to know how to be the agent and how to be described to one.
  • Databases: declared as named slots, not paths (more below).
  • Dependencies: on other modules — with a required flag, a reason, and optionally which specific tools it needs from the peer.
  • Lifecycle and config: initialize/shutdown hooks, a config schema, defaults.

Database slots and the throwing stub

Modules never open databases. They declare which of the seven well-known slots they need — graphDb, semanticDb, documentsDb, and so on, the same seven-database layout every Agentz agent already has — plus a schema initializer for each. The host supplies a DatabaseProvider that maps (agentId, slot) to an actual handle.

My favourite detail: slots a module didn't request are filled with stubs that throw on contact. The alternative — passing undefined and hoping — converts an access-control violation into a TypeError three frames later. The stub converts it into an immediate, named error: this module declared it doesn't use ledgerDb, and it just tried to. Declarations you don't enforce are documentation; these are enforced.

The same slot system is what makes the modules honest about portability. Six are fully self-contained (portable: true). Three — stock, portfolio, strategy — are bridge modules: they declare external MCP servers, and at initialization the runtime discovers those servers' tools and registers forwarding handlers. The module format absorbs the difference; a consumer can't tell a native tool from a bridged one.

Module-to-module calls

Modules can talk to each other, but only through the front door. A connection is declared as {from, to, transport, allowedTools?}; the runtime hands the caller a handle whose callTool enforces the allowlist. If no connections are declared, they're inferred from the dependency declarations — the common case costs nothing. Only the direct (in-process) transport is implemented; mcp and a2a transports are named in the type as a bet on where this goes, which I'm noting publicly so future me can be graded on it.

Three adapters

The payoff for all this declaration is that hosting becomes adaptation:

Embedded (the Agentz adapter). moduleToSpecialistConfig() converts a module into the app's existing specialist format, and the app now builds its specialists from the module packages — ten modules, plus two app-coupled specialists (scheduling, planning) that stay native, giving the same twelve specialists as before. The migration had one hard requirement: byte-identical LLM behavior. Prompts are overridden from the previous hardcoded configs while modules become canonical for tools, databases, and MCP bindings — and a verification script diffs all twelve against the old definitions to prove nothing moved.

MCP server. createMcpServerFromModule() wraps a module runtime as a Model Context Protocol server: tools/list from the module's schemas, tools/call into its handlers. One deliberate choice: it uses the MCP SDK's low-level Server class rather than the ergonomic McpServer, because modules already carry raw JSON Schema and round-tripping through Zod would be conversion for its own sake. A CLI serves any module over stdio — so run-module-mcp graph makes the Agentz knowledge graph a tool provider for Claude Code, or any other MCP client, with no Electron in sight.

A2A agent. The newest adapter serves a module as an Agent-to-Agent endpoint: an agent card at /.well-known/agent-card.json, a JSON-RPC endpoint handling message/send and task queries, and a pluggable task store. Each module becomes an agent with one chat skill; incoming messages run the module's full agentic loop — system prompt, tools, up to its declared iteration cap. The adapter is honest about its scope: execution is synchronous, streaming and push notifications are explicitly declared off in the card. A2A is young; this is a working v1, not a claim.

What the week proved

The real test of a plugin contract isn't whether new things can be built on it — it's whether the existing thing can be rebuilt on it without anyone noticing. The app passing its own specialist-equivalence check was the moment this stopped being a speculative SDK and became the actual architecture.

There's a lesson here about sequencing, too. This extraction was only possible because of the month that preceded it: SQL into repositories, any types eliminated, the main process decomposed, the agent core pulled into a library. Each step looked like housekeeping. Stacked, they were the runway. The module SDK took five days to build — and four months to make buildable.