Skip to main content

ADR-003: Kaori Agent Architecture — MCP Tools + Character File, Defer the Framework

FieldValue
StatusAccepted (incremental path)
DateAugust 2026
DecidersWes Huber
Supersedes
RelatedADR-002: Kaori Fallback Chain

Context

Kaori's "brain" today is a custom Node.js function (kaori-ai-response.js) that calls OpenRouter (google/gemini-3.5-flash) with a hand-rolled tool-calling loop. It has:

  • 8 tools (kaori-tools.js) that hit MongoDB directly: search_spots, search_trickipedia, get_user_tricklists, create_tricklist, add_trick_to_list, update_trick_status, lookup_boardsport_knowledge, remember_user_info.
  • A lightweight RAG step (kaori-rag/) + a JSON knowledge file.
  • Per-user memory in the companion_profiles Mongo collection (relationship stage, traits, memory.userName/knownFacts).
  • An ElevenLabs voice sidecar (Kith) that streams TTS for the 3D avatar.

Three problems motivated this review:

  1. The persona lived in a ~60-line inline system-prompt string — hard to edit, impossible to reuse, "sloppy."
  2. The tools were hardwired into the brain — no other client (a future web-Kaori, Claude Desktop, a different orchestrator) could reuse them.
  3. Open question: should we adopt a framework — LangGraph, ElizaOS, or "Milady" — or keep the custom loop?

We ran a deep, multi-source, adversarially-verified research pass (22 sources, 25 claims verified, 23 confirmed / 2 refuted) to answer #3 honestly. Note our own history here: per ADR-002, we already ran ElizaOS as Kaori's primary and it was unstable (34 PM2 restarts), which is why the OpenRouter loop exists as the reliable path.

Decision

Take the incremental path. Specifically:

  1. Build a Node/TypeScript-compatible MCP server (mcp/trickbook-mcp.js) that wraps the existing tools. This is the highest-leverage, framework-agnostic move — every candidate brain consumes MCP.
  2. Decompose the inline prompt into a structured character file (kaori-character.json), composed back into the system prompt at load time. Brain-agnostic; fixes the "sloppy" prompt.
  3. Keep the custom OpenRouter loop as the brain for now. Do not adopt LangGraph, ElizaOS, or Milady wholesale.
  4. Defer the orchestration-framework question until a concrete need (durable/branching/human-in-the-loop flows) appears. If/when it does, prefer Mastra (stays in Node) over a polyglot Python LangGraph service, unless durable checkpointing specifically justifies the latter.

Target shape

Clients

in-process

optional

Custom OpenRouter loop
current brain

Future brain
Mastra / LangGraph

Claude Desktop /
MCP Inspector

kaori-tools.js
defs + handlers

trickbook-mcp
Streamable HTTP, stateless

MongoDB
TrickList2

kaori-character.json

The current loop still calls tools in-process (no added latency); the MCP server is an additive external interface over the same handlers — one source of truth, no duplicated schema mapping.

Research summary & decision matrix

OptionLangMemory / persistenceMCPMigration costVerdict
MCP server (tools)TS/JSn/ais the layerLow✅ Do now — every brain consumes it
Character file (persona)anyn/an/aLow✅ Do now — brain-agnostic
Keep custom loopNodeexisting Mongo docsvia MCPnone✅ Fine as the brain for now
MastraTS-nativelibSQL/Postgres + RAGnativeLow (in-Node)🟡 Best framework option if/when
Vercel AI SDKTSyou build ityesLowest🟡 Lightest; DIY persistence
LangGraph (Python)Pythonbest-in-class checkpointer + Storevia langchain-mcp-adaptersHigh (polyglot)🟠 Only if durable/branching is a real need
ElizaOS / MiladyTSpluginplugin❌ Borrow patterns only

The honest LangGraph case

For: genuine durable checkpointing (crash → resume from the exact node, time-travel replay) + a two-tier memory model (per-thread checkpointer + cross-thread Store for user facts) — a real fit for a "remembers you" companion.

Against (for us, now):

  • Kaori is single-agent, no branching, no human-in-the-loop — the graph abstraction is largely unused; the value collapses to checkpointer + Store, which we already approximate with companion_profiles.
  • It forces a polyglot Python service (we're all-Node). Research refuted the two comforting myths — that migration is "just swap the loop" and that "tools/prompt/model stay identical."
  • Idempotency landmine: LangGraph node-resume re-runs the interrupted node. Our add_trick_to_list / update_trick_status / create_tricklist are side-effecting writes — a resume could double-write.

"Milady" and ElizaOS

  • "Milady" is not a framework — it's milady-ai/milady, an app built on ElizaOS (a local-first "AI waifu" with a VRM avatar + voice — conceptually adjacent to Kaori) but with crypto/DeFi baked into its core (PancakeSwap trading, auto-generated EVM/Solana wallets). Nothing to borrow beyond what Eliza offers; the crypto defaults are undesirable.
  • ElizaOS — genuine TS agent framework, but crypto-social origins, and its token was declared "dead" / foundation winding down (Aug 2026). Combined with our own ADR-002 instability history: borrow the character-file pattern, don't adopt the framework.

Implementation (what shipped in this pass)

1. Character file — Backend/kaori-character.json

The inline KAORI_SYSTEM_PROMPT was decomposed into typed fields: intro, voice[], messageExamples[], identityNotes[], knows[], toolGuidance{intro,routes,rules}, dont[], laugh, vibe, stageDemo. kaori-ai-response.js now composes the system prompt via buildSystemPrompt(character)behavior-equivalent to the old string (same sections, order, wording), just sourced from editable data. The load-bearing persona (voice + messageExamples, corpus-mined and adversarially verified in prior work) was preserved verbatim.

2. MCP server — Backend/mcp/trickbook-mcp.js

  • Official @modelcontextprotocol/sdk (v1.30), plain CommonJS (matches the Backend).
  • Reuses TOOL_DEFINITIONS (their JSON schemas are valid MCP inputSchema) + executeToolCall — zero duplication.
  • Streamable HTTP, stateless (fresh Server + transport per request, sessionIdGenerator: undefined) — sidesteps the stateful-session/horizontal-scaling gap flagged in the 2026 MCP roadmap.
  • Per-request user context via the x-trickbook-user-id header (MCP_DEV_USER_ID for local dev). Runs inside the trusted backend network.
  • npm run mcp (default port 9101); GET /health reports the tool count.

Verification: mcp/smoke-test.js connects over Streamable HTTP, lists all 8 tools, and calls a read-only tool (lookup_boardsport_knowledge) returning live data. ✅ green.

3. Brain — unchanged

The custom OpenRouter loop remains the brain; it loads the character file and (for now) calls tools in-process.

Consequences

Positive

  • Persona is now editable structured data, reusable across brain + voice.
  • Tools are decoupled and reusable by any MCP client — a future brain swap is cheap.
  • No framework lock-in; no new language/service to operate.
  • Every door (Mastra, LangGraph) stays open at low future cost.

Negative / caveats

  • The MCP server is currently additive — the brain doesn't consume it yet (avoids added latency). Wiring the brain through MCP is a later step if we want a single call path.
  • Remote MCP at scale has known stateful-session/scaling caveats (mitigated here by stateless mode) and the spec is moving fast (a non-backward-compatible 2026-07-28 revision).
  • If we later adopt LangGraph, the side-effecting tools must be made idempotent first.

Future work

  1. (Optional) Point the loop at trickbook-mcp for a single tool path.
  2. Register trickbook-mcp with Claude Desktop / MCP Inspector for manual tool testing.
  3. Revisit Mastra vs LangGraph only when a durable/branching/human-in-the-loop flow (e.g. confirm-before-add-trick) becomes a real requirement.

Sources

  • MCP TypeScript SDK — github.com/modelcontextprotocol/typescript-sdk
  • langchain-ai/langchain-mcp-adapters (how a future LangGraph brain consumes the same MCP server)
  • Mastra — durable TypeScript agents (developersdigest.tech/blog/mastra-durable-typescript-agents)
  • 2026 MCP roadmap — blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/
  • ElizaOS character-file pattern — github.com/elizaOS/characterfile, docs.elizaos.ai/agents/character-interface