Skip to content

Architecture

Kairox is a local-first AI agent workbench. Two user interfaces (a ratatui terminal app and a Tauri 2 + Vue 3 desktop app), a headless eval CLI, and an embeddable SDK share the same Rust runtime contracts. Everything below the consumers is a Rust workspace of small crates connected through narrow traits. There is no server, no cloud control plane, and no shared mutable singletons — the whole stack runs on your machine and persists state to a local SQLite database.

This page is the canonical map. It explains the layered diagram, the dependency rule that keeps the layers honest, every crate that belongs to a layer, how state moves through the system as events, and the design decisions behind the bigger choices.

Layered architecture

The product splits into four practical layers: user-facing consumers, a runtime/composition layer, a core contract crate, and focused domain crates.

Read the diagram top-down. A click in the GUI or a key in the TUI reaches a runtime object that implements AppFacade. agent-core owns that interface and the language used to describe what happens inside the app (DomainEvent, EventPayload, typed IDs, projections). agent-runtime implements the contract and composes the domain crates. Domain crates do not depend on UI crates or consumer crates.

The dependency rule

The single rule that keeps the architecture stable: domain crates never depend on consumers or the runtime.

From layerMay depend onMay not depend on
Contractnothing in this workspaceruntime, domain crates, consumers
Domainagent-core and other domain crates in orderagent-runtime, UIs, eval, SDK
Runtime/compositioncontract + domain cratesUI frontend state or view modules
Consumer Rust cratesruntime + needed domain cratesVue internals or unrelated clients

agent-core has zero workspace dependencies. Some small domain crates are also leaf crates (agent-lsp, agent-skills, agent-plugins) because they model standalone data or protocol concepts. A new domain crate that needs to call a UI surface is a mistake; emit an event instead and let the consumer react. A new UI feature that needs runtime behavior should go through the facade or a typed Tauri command backed by the runtime.

In code, the rule is enforced by Cargo edges: no domain crate imports agent-runtime, agent-tui, agent-gui-tauri, agent-eval, or agent-sdk. In review, the rule shows up in PR scope: a feature that touches agent-runtime plus the GUI is normal; a feature that makes a domain crate reach up into a consumer is not.

Crates, layer by layer

Facade layer — agent-core

agent-core is small on purpose. It contains exactly:

  • AppFacade — the async trait that the runtime implements and that both UIs call. Every user-visible operation goes through one of its methods.
  • Domain eventsDomainEvent, EventPayload (an enum of every payload variant), MemoryMarker, MemoryScope, PermissionDecision.
  • Typed identifiersSessionId, WorkspaceId, TaskId, AgentId, MessageId, TrajectoryId, AutonomousTaskId, all newtypes around UUIDs with serde support.
  • ProjectionsTaskSnapshot, TaskGraphSnapshot, TaskState, AgentRole.
  • Review and trajectory DTOsTrajectoryStep, TrajectoryOutcome, AdvisorMode, AdvisorVerdict, and autonomous task snapshots.
  • Build infoBuildInfo (version, git SHA, build date) so both UIs render the same banner.

agent-core exports a specta feature that the GUI's Tauri backend enables to derive specta::Type for the types crossing the IPC boundary. That feature is the only asymmetry — the Rust core itself has no opinion about Tauri.

Domain layer

CrateRoleKey types
agent-runtimeOrchestrates the agent loop, session-actor execution runtime, context budgets, race-free turn-end compaction, model switching, configurable agent settings, multi-agent strategies, advisor review, autonomous checkpoints, trajectory capture, MCP lifecycle, permissions.LocalRuntime<S, M>, SessionActor, SessionExecutionRuntime, PlannerAgent, WorkerAgent, ReviewerAgent, AgentStrategy, DagExecutor, TaskGraph, McpServerManager.
agent-modelsModel provider abstraction (OpenAI-compatible, Anthropic, Ollama, Fake) with metadata and context-window registry.ModelClient trait, ModelRequest, ModelRouter, ModelProfile, ModelRegistry.
agent-toolsTool registry, orthogonal Approval × Sandbox policy engine, built-in tools (shell.exec, fs.read, fs.write, fs.list, patch.apply, search.ripgrep, monitor.start/list/stop, browser.action, browser.batch, computer.use), MCP-tool adapter.ToolRegistry, PolicyEngine, ApprovalPolicy, SandboxPolicy, PolicyDecision, PolicyRisk, Tool trait, ToolRisk, McpToolAdapter, MonitorRegistry, BrowserTool, ComputerUseTool.
agent-mcpMCP (Model Context Protocol) client, stdio + SSE + Streamable HTTP transports, server lifecycle, discovery cache, marketplace catalog (built-in + remote sources).McpClient, Transport trait, StdioTransport, SseTransport, StreamableHttpTransport, ServerLifecycle, McpServerDef, CatalogEntry.
agent-lspLSP and DAP client implementations, JSON-RPC transport, server lifecycle management for code intelligence and debugging.LspClient, DapClient, LspServerDef, DapServerDef, LspServerLifecycle, DapServerLifecycle, ServerStatus.
agent-skillsNative skills system — reusable prompt/tool/workflow capabilities, frontmatter parsing, registry, GUI settings.SkillRegistry, SkillDef, SkillFrontmatter, SkillScope, SkillSettings.
agent-pluginsPlugin manifest and inventory for plugin-provided skills, tools, hooks, and MCP servers.PluginManifest, plugin inventory helpers.
agent-memoryDurable, user-, workspace-, and session-scoped memory, context assembly with tiktoken budgets, multimodal image pruning, prompt compaction.MemoryStore trait, SqliteMemoryStore, ContextAssembler, MemoryMarker, ContextCompactor, ImagePruningStrategy.
agent-storeAppend-only SQLite event store plus metadata tables for workspace/session tracking and trajectory persistence.EventStore trait, SqliteEventStore, SessionMeta, TrajectoryStore, SqliteTrajectoryStore.
agent-configTOML config loading, model profile discovery, API key resolution from env, .kairox/ project discovery, advisor policy, skills config, instructions config.ProfileDef, AdvisorConfig, load_from_str, build_router.

agent-runtime is the composition crate that fans out to all domain crates. The rest stay narrow: agent-memory depends on model metadata but not tools or storage, agent-tools depends on MCP/LSP protocol types to adapt external tools, and agent-config depends on model/MCP/LSP definitions to parse TOML. When a runtime feature needs several capabilities, the runtime composes them.

UI layer

CrateRoleKey types
agent-tuiThree-panel ratatui app (sessions, chat, trace). Build-info banner, permission modal, model-switch UI.App, ChatPanel, SessionsPanel, TracePanel, PermissionModal.
agent-guiTauri 2 backend (Rust) + Vue 3 frontend. Persistent sessions, task graph, trajectory viewer, autonomous settings, MCP & memory UI, marketplace, model/agent/plugin/hook/instructions/skills settings.Rust: commands.rs, GuiState, event_forwarder.rs, specta.rs. Vue: stores (session, taskGraph, agents, autonomous, mcp, memory, catalog, skills), components (ChatPanel.vue, TaskSteps.vue, TrajectoryViewer.vue, …)

Both UIs implement the same interaction model — start a session, send a prompt, watch a trace, approve permissions — over the same facade. The TUI is the simplest possible reference client; the GUI adds persistence, multi-session, marketplace, and settings management.

Event-sourced state

State changes in Kairox are events, not mutations. Every meaningful thing that happens inside the runtime — a message arrives, a tool is invoked, a permission is decided, a task starts or completes, a model switches, memory is proposed — is recorded as a DomainEvent and appended to the event store. UIs render from event streams, not from mutable state owned by some "session manager".

A few properties fall out of this design:

  • Replay is free. Restarting the GUI re-reads events from SQLite and rebuilds task snapshots, chat history, and trace timelines. There is no "rebuild from cache" path.
  • UIs are subscribers, not owners. The GUI's event_forwarder calls LocalRuntime::subscribe_all() and forwards every DomainEvent to the renderer via Tauri's emit. The TUI does the same in-process. Both filter by the currently focused SessionId.
  • Persistence is bounded. agent-store writes envelopes only. Privacy defaults (see the Permissions & Tools page) constrain what payload content is persisted in production.
  • Auditing is a side effect of the design. Because every decision is an event, the trace panel does not need a parallel audit log; it is the audit log.

The event taxonomy itself — every variant of EventPayload and what emits it — is documented on the Runtime & Sessions page.

Trait boundaries

Trait boundaries are not decoration. Every cross-crate dependency in Kairox goes through a trait so that tests can substitute fakes and so that adapters (a new model provider, a new transport, a new event store backend) plug in without touching the runtime.

TraitDefined inUsed inNotes
AppFacadeagent-coreagent-runtime, agent-tui, agent-guiThe integration point between UIs and the runtime.
EventStoreagent-storeagent-runtime and consumers at wiring boundariesImplemented by SqliteEventStore; tests use in-memory SQLite (:memory:).
MemoryStoreagent-memoryagent-runtime, agent-gui (read-only)Implemented by SqliteMemoryStore.
ModelClientagent-modelsagent-runtimeImplemented by OpenAI-compatible, Anthropic, Ollama, and Fake clients; the router multiplexes.
Tool / ToolProvideragent-toolsagent-runtime, agent-mcp (via adapter)Built-in tools and MCP-exposed tools both implement Tool.
Transportagent-mcpagent-mcp internalImplemented by StdioTransport, SseTransport, StreamableHttpTransport.
AgentStrategyagent-runtimeagent-runtimePlanner / Worker / Reviewer roles. Strategies compose; they do not subclass.

The reason for the discipline is concrete: crates/agent-runtime/tests/full_stack.rs exercises a real LocalRuntime<SqliteEventStore, FakeModelClient> end-to-end without a model API key, an MCP server, or a GUI window — because every collaborator is a trait and a fake is one cargo test away.

Crate dependency graph

The dependency rule above translates into a real DAG. The graph below is the shape cargo enforces — read it as who knows about whom, not what calls what at runtime.

agent-tui, agent-gui-tauri, agent-eval, and agent-sdk are consumer crates that depend on agent-runtime. Nothing in the domain layer depends back on them. That asymmetry is the point: a new model provider, tool, MCP transport, or skill source can be added in its owning domain crate and then composed by the runtime without teaching storage, memory, or protocol crates about UI state.

Decision log

A few of the larger choices are worth recording. They were not obvious from the start, and they shape every page of this site.

Why a facade instead of direct domain calls

The GUI used to call agent-runtime directly. Adding the TUI revealed how much GUI-shaped assumption had leaked across — Tauri-flavored error types, GUI-specific event shapes. Pulling those types up into agent-core and forcing both UIs through AppFacade made the runtime free to refactor (the entire agent-runtime module layout was split apart in #532) without breaking either UI. The cost is one extra trait dispatch per call. That cost is irrelevant for an LLM-bound workflow.

Why event sourcing instead of a state struct

Three forces pushed the runtime toward events: (1) the GUI and TUI need to render the same trace from the same source, (2) the desktop app needs to recover state cleanly after a crash or restart, and (3) the audit and observability story for permission decisions is much cleaner when every decision is a row, not a method return value. Once events were in place, the compaction and model-switching features (see #531 and #533) became "append a few more event variants" instead of "thread new state through every consumer".

Why split agent-runtime into focused modules

agent-runtime grew large. Splitting it into agent_loop, agents, dag_executor, event_emitter, facade_runtime, mcp_manager, memory_handler, permission, session, and task_graph made each module testable in isolation and made the dependency lines inside the crate visible. The pattern is the same as the workspace pattern, recursively: small modules, narrow interfaces, no module reaching back into another. See #532 for the queue-the-actor refactor that came out of that split.

Why SQLite (and not a custom file format)

SQLite gives transactional appends, deterministic recovery, indexed reads for the task graph and memory queries, and zero external services. It is widely available and battle-tested. The event store is append-only and the memory store is small; there is no schema-migration treadmill to fear.

Why two UIs instead of one

The TUI is a deliberate forcing function for facade quality. If a feature is implementable in the TUI, the facade is probably right; if it is not, the facade has GUI-specific concerns leaking into it. The GUI is the product surface for end users; the TUI is the reference client for developers and the test bench for facade refactors. Maintaining both is cheap because both consume the same events.

Why Bun and not npm/pnpm

The repository pins packageManager to Bun and the project's lint-staged, husky, and just recipes assume bun. Bun's install speed and built-in bun test keep the GUI loop fast on a developer laptop. The trade-off — yet-another package manager in a team's toolbox — is documented in Installation and Troubleshooting & FAQ.

What this page does not cover

This page maps the system. It does not explain the runtime's per-turn behavior, the memory protocol, the Approval × Sandbox policy axes, or the extensibility surfaces. Each of those has its own page in this section. Start with Runtime & Sessions for what happens on every prompt.

Released under the Apache-2.0 License.