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
systemPromptfor when the module runs as an autonomous agent, andorchestratorGuidancefor 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.