MINARA

System Design

Core modules, their invariants, and a directory of every design doc in this section

👥 This section is for: research-oriented developers who want to understand why Minara is built the way it is, and maintainers who need the invariants and change-risk lines. Every page opens with a plain-language "why this exists" callout and closes with a See this in use link back to the user-facing feature. Contributor-only guidance is clearly marked.

Suggested reading order for newcomers: start with The Agent Loop (the heart of how a turn runs), then The Skill System (how capability is packaged), then jump to whichever subsystem the feature you care about depends on.

This section documents how Minara Agent is built. Start with the core-modules walkthrough below, then follow the directory to the deep-dive page for whichever subsystem you're touching.

Directory

Runtime

  • The Agent Loop — How a single turn flows from user message to final answer.
  • The Skill System — Routing, activation, risk gates, and the full catalog of available skills.
  • LLM Integration — Provider abstraction, prompt caching, model routing.
  • Scenario Classifier — L0.5 intent classification that injects procedural playbooks and preloads skills.

State & storage

  • Memory (subsection overview) — the four interlocking stores (session memory, personalization, role reflection, the learning system) and how they compose on a turn.
  • Workspace — the markdown identity and memory ground truth, plus the artifact and file stores for durable conversation content (charts, reports, uploads).

Safety & execution

  • Safety & Sandboxing — Sandbox, permission tiers, hook pipeline, and the 6-stage finance safety stack.

I/O & operations


Core Modules

The agent is a small number of modules that cooperate through explicit interfaces. This page walks through each one: what it owns, what it depends on, and the invariants it upholds.

core-modules diagram

app.ts: the composition root

apps/agent/src/app.ts is the only module in the repo that knows about every other module. It exports a single createApp() function that:

  1. Opens the SQLite database (WAL mode, FTS5 enabled) under $dataDir/.
  2. Builds the ToolRegistry with the permission-tier hook and the analysis → trade boundary hook installed.
  3. Instantiates all tool factories (createReadTools, createTradeTools, createMemoryTools, createFileTools, …) and registers them. Factories that depend on missing env vars return [] and never throw at boot.
  4. Builds the SkillRegistry from BUILTIN_SKILLS plus the output of buildExternalDomainSkills() for anything vendored under apps/agent/src/skills/external/.
  5. Wires the AgentLoop, injecting the LLM client, the registries, the sandbox resolver, and the audit-log writer.
  6. Returns the assembled App object for the gateway to drive.

Because createApp() is pure (given env vars and config), both the CLI REPL and the HTTP gateway share an identical runtime. If you ever wonder "where does X get hooked up," the answer is always app.ts.

core/tool-registry.ts: typed dispatch and permission tiers

The tool registry is the concrete representation of what the agent can do. Every tool is a ToolEntry:

interface ToolEntry {
  name: string;
  toolSet: string;
  schema: ToolSchema;          // JSON Schema for LLM tool-use
  handler: ToolHandler;        // async (args) => string
  permissionTier: PermissionTier;
  isAsync: boolean;
  description: string;
  checkFn?: () => boolean;     // optional runtime availability gate
}

Permission tiers

enum PermissionTier {
  READ_ONLY      = 1, // price, balance, trending, fear_greed, search
  CONFIRM_ONCE   = 2, // analyze, research, small swap, write_file
  ALWAYS_CONFIRM = 3, // perps, large swap, autopilot start/modify
  MANUAL_ONLY    = 4, // withdraw, external-address send, emergency stop
}

Tiers are enforced by BeforeToolCallHook. They are not advisory. When a hook throws ToolCallBlockedError, the agent loop catches it and returns the error to the LLM as a tool result. The LLM sees it as any other tool error; the audit log sees it distinctly.

Tool sets

BUILTIN_TOOL_SETS groups tools into named bundles (read, trade, perps, file, browser, …) with optional includes composition. Skills reference tools by name, but the registry and the gateway talk about tool sets: easier to reason about, easier to gate. Cron autopilot turns run with allowedToolSets: ["read", "memory", "web"] and nothing else.

Context propagation

The registry uses AsyncLocalStorage to thread ToolCallContext through async tool calls, including sub-agent calls via subagent and skill-executed tool sequences. Hooks read ToolRegistry.currentContext() to enforce per-turn invariants (analysis → trade boundary, allowedToolSets whitelist, emergency stop).

core/agent-loop.ts: plan → call → observe → decide

The agent loop is a vanilla while (iterations < max) orchestrator:

  1. Build the system prompt (via prompt-builder.ts) using the current SkillSession state.
  2. Call the LLM with the set of tools allowed by the currently active skills (intersected with the turn's allowedToolSets).
  3. For each tool call:
    • Validate the stated intent matches the actual tool call.
    • Run the registry's dispatch, which fires BeforeToolCallHook.
    • Persist the call plus result to the audit log.
  4. Feed results back as a new user message.
  5. Exit on stop_reason: "end_turn" or when max_iterations is hit.

Turn bookkeeping (the risk ceiling, the signal context) lives on a ToolCallContext object that the loop wraps in runInContext(ctx, fn) so every tool call sees the same state.

See The Agent Loop for a fuller walkthrough.

core/prompt-builder.ts: system prompt assembly

The system prompt is built from declared blocks rather than string concatenation:

system: [
  { type: "text", text: identityPrompt,     cache: true  },
  { type: "text", text: skillCatalog,       cache: true  },
  { type: "text", text: activeSkillPrompts, cache: false },
  { type: "text", text: signalContextBlock, cache: false },
]

Blocks marked cache: true are passed through to Anthropic's prompt cache (5-minute TTL, 10% cost) so identity plus catalog stay warm. Blocks that change every turn (active skills, signal context, pending confirmations) are not cached. This design is load-bearing: a poorly ordered prompt can turn what looks like "one Claude call" into three cache misses.

buildSystemPrompt() wraps buildSystemPromptBlocks() for callers that want a plain string (tests, the CLI's --dump-prompt mode).

skills/: the skill layer

A DomainSkill is a fully declarative unit:

{
  id: "minara.core",
  kind: "domain_skill",
  description: "Unified Minara API — markets, portfolio, spot/perps trading, sub-account management",
  prompt: "...≤ 800 tokens of instructions...",
  tool_names: [
    "get_price", "get_trending", "get_fear_greed", "search_tokens",
    "minara_account", "lookup_token", "minara_total_balance",
    "get_portfolio", "get_perps_positions", "minara_pnl",
    "swap_tokens", "buy_token", "sell_token", "transfer_token",
    "open_perps_position", "close_perps_position",
    "minara_perps_wallets_list", "minara_perps_wallet_sweep", /* ... */
  ],
  activation: "auto",
  priority: 50,
  routing: {
    lifecycle_stages: ["discover", "decide", "manage"],
    keywords: ["minara", "balance", "swap", "long", "short", "perp", "perps", "perps sub-account"],
    asset_classes: ["crypto_major", "crypto_alt", "crypto_meme", "stablecoin", "perps"],
  },
}

The pieces:

  • SkillRegistry (apps/agent/src/skills/registry.ts) holds the catalog, enforces requires_env gating at registration time, renders the unrouted and routed catalog blocks, concatenates prompt fragments for active ids, and collects tool whitelists.
  • SkillSession (apps/agent/src/skills/session.ts) is the per-turn mutable state: which skills are active, what risk ceiling applies, what pending confirmations exist. The registry is stateless; the session owns activation.
  • Router (apps/agent/src/skills/router.ts) runs the L0 deterministic pre-filter: keyword scoring, stage classification, asset-class detection, plus the L3 risk gate that short-circuits high-risk activations without user confirmation.
  • FinanceTaxonomy (apps/agent/src/skills/finance-taxonomy.ts) is the shared vocabulary (LifecycleStage, AssetClass, RiskTier) the router and skills speak.

See The Skill System for the routing algorithm and the activation flow end to end.

tools/_shared/sandbox.ts: the filesystem boundary

Every file tool calls resolveInSandbox(relativePath) which:

  1. Rejects absolute paths.
  2. Resolves the path under $dataDir/sandbox/files/.
  3. Walks each path segment, following symlinks, and verifies the resolved real path still has the sandbox root as a prefix.
  4. Returns the canonical absolute path or throws SandboxEscapeError.

Tool handlers never see raw user input as a fs.* argument. Even if a tool tries (which it should not), the resolver rejects the traversal attempt before any syscall touches the disk.

Do not write a parallel helper that opens files with a different resolver. The single-entry-point property is what makes the sandbox defensible.

tools/_shared/result.ts: the tool result envelope

All tool handlers return a string, but the string is shaped by ok({...}), err("..."), or errFromThrow(e) into a tagged envelope the LLM can parse reliably:

{"ok": true,  "data": {...}}
{"ok": false, "error": "..."}

This is boring on purpose. The LLM sees the same shape from every tool, so prompt logic like "if the result is an error, apologize and retry" compiles once instead of per-tool.

gateway/: two entrypoints, one runtime

  • cli.ts is a REPL that streams LLM output to stdout and routes tool calls through the same AgentLoop. It imports config/load-env.ts as the first line of execution so .env is populated before anything reads process.env.
  • server.ts exposes the HTTP API; see HTTP Gateway. It uses the same createApp() output, so there is no drift between CLI and HTTP behavior.
  • skills-cli.ts is the minara skills add/list/upgrade/remove subcommand. It vendors external SKILL.md packages into apps/agent/src/skills/external/ and surfaces license detection in its output.

config/load-env.ts: environment bootstrap

Two responsibilities and nothing else:

  1. Call Node 22's native process.loadEnvFile() on .env if it exists.
  2. Record which vars came from the file vs the shell, for startup logs.

It is imported purely as a side effect, as the very first import of every entrypoint. If you introduce a new entrypoint and forget this, you will get silent env-missing failures downstream.


The architectural rule: every module listed here has one job, and modules higher in the stack depend only on modules lower in the stack. The agent loop depends on the registries; the registries depend on the tool layer; the tool layer depends on the sandbox. If you find yourself reaching "up" (a tool handler importing from agent-loop.ts, for example), that's a signal to reshape the design rather than add a back edge.

On this page