Architecture
Architecture
Mentu is four components working together. Each has a single responsibility and a clear boundary.
Components
1. mentu CLI (Swift binary)
The user-facing tool. Everything you do with mentu starts here: running scripts, querying CIR, managing vault secrets, firing temporals. It is a single statically-linked binary installed at ~/.local/bin/mentu.
The CLI is the authority. Every SDK method, every MCP tool call, every daemon route ultimately shells out to the CLI or calls its libraries. This is intentional — one binary, one trust boundary.
2. mentu-mcp (TypeScript, Node.js)
The intelligence layer. This is an MCP server that exposes mentu's capabilities to AI models and hosts the Scripting SDK's runtime.
Responsibilities:
- MCP server — registers tools like
mcp_doandmcp_compilefor model access - Script runner — executes TypeScript scripts in a V8 sandbox
- MCP child manager — lazy-spawns and pools connections to external MCP servers for script access via
servers.*
3. mentud (Swift daemon)
A background platform root that runs on a Unix domain socket (~/.mentu/mentud.sock). It provides:
- HTTP API for local clients (auth, temporal status, evolve operations)
- Temporal scheduler — fires cron-scheduled tasks via launchd integration
- System tray presence via the menu bar toolbar
The daemon is local-only. It never listens on a network port. The Unix socket restricts access to the current user by filesystem permissions.
4. api.mentu.ai (Hummingbird on Fly.io)
The cloud API. Handles authentication (registration, API key issuance), usage metering and quota enforcement, and future sync capabilities. The CLI talks to it during mentu auth login and mentu auth status.
How scripts flow through the stack
When you run mentu script run hello --var NAME=world:
mentu (Swift CLI)
│
├─ Resolves script path: ~/.mentu/scripts/hello.ts
├─ Spawns Node.js: node script-runner.js hello.ts --var NAME=world
│
└─ script-runner.js (mentu-mcp)
│
├─ Reads script source from disk
├─ Builds mentu SDK object (shell-out wrappers)
├─ Loads .mcp.json for MCP server config
├─ Creates V8 sandbox with { mentu, servers, sleep, console }
├─ Executes script in sandbox
│
└─ When script calls mentu.cir.query():
│
├─ SDK wrapper calls: execFileSync('mentu', ['cir', 'query', ...])
├─ mentu CLI processes the command
├─ Returns JSON to the SDK wrapper
└─ Wrapper parses and returns to scriptEvery SDK method follows this pattern. The script never touches the filesystem, network, or process directly. It asks the SDK, the SDK shells out to the CLI, the CLI does the work.
Data flow: mentu.cir.query()
A concrete trace through all four layers:
- Script calls
mentu.cir.query({ type: 'observation', limit: 10 }) - SDK wrapper (in script-runner.js) translates to
execFileSync('mentu', ['cir', 'query', '--type', 'observation', '--limit', '10', '--format', 'json']) - mentu CLI (Swift) receives the command, opens the CIR SQLite database, executes the query, returns JSON to stdout
- SDK wrapper parses the JSON, returns a
CIRSignal[]array to the script - Script receives the array and continues execution
The cloud API (api.mentu.ai) is not involved in CIR queries — CIR is entirely local. The daemon (mentud) is not involved either, unless the script was triggered by a temporal that the daemon fired.
Key design decisions
Why shell-out?
The SDK could embed CIR access directly, but that would create a second code path for every operation. Shell-out means:
- Single trust boundary — all authorization and validation happens in the CLI
- No new attack surface — scripts can only do what the CLI already allows
- Consistent behavior — a script calling
mentu.cir.query()gets exactly the same result asmentu cir queryin a terminal
The cost is ~30ms per call for process spawn. For scripts making dozens of SDK calls, this is acceptable. For scripts making thousands, use cir.query() with filters to batch.
Why V8?
Scripts are TypeScript because that is what the ecosystem writes. V8's sandbox (vm.createContext) provides deny-all isolation without a container or VM overhead. Frozen prototypes prevent prototype pollution. Disabled code generation prevents eval-based escapes.
Why Unix socket?
The daemon needs to be reachable by local clients (CLI, scripts, menu bar) but never by the network. A Unix socket:
- Is restricted to the current user by filesystem permissions (0700 on the socket directory)
- Has no TCP stack to attack
- Has no port to scan
- Works with standard HTTP semantics (the daemon speaks HTTP over the socket)
Why local-first?
CIR, vault, temporals, and scripts all run locally. The cloud API handles only authentication and metering. This means:
- No latency — CIR queries hit a local SQLite database
- No data exfiltration — your knowledge substrate never leaves your machine
- Offline capable — everything works without internet except
mentu auth login