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 layer | May depend on | May not depend on |
|---|---|---|
| Contract | nothing in this workspace | runtime, domain crates, consumers |
| Domain | agent-core and other domain crates in order | agent-runtime, UIs, eval, SDK |
| Runtime/composition | contract + domain crates | UI frontend state or view modules |
| Consumer Rust crates | runtime + needed domain crates | Vue 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 events —
DomainEvent,EventPayload(an enum of every payload variant),MemoryMarker,MemoryScope,PermissionDecision. - Typed identifiers —
SessionId,WorkspaceId,TaskId,AgentId,MessageId,TrajectoryId,AutonomousTaskId, all newtypes around UUIDs withserdesupport. - Projections —
TaskSnapshot,TaskGraphSnapshot,TaskState,AgentRole. - Review and trajectory DTOs —
TrajectoryStep,TrajectoryOutcome,AdvisorMode,AdvisorVerdict, and autonomous task snapshots. - Build info —
BuildInfo(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
| Crate | Role | Key types |
|---|---|---|
| agent-runtime | Orchestrates 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-models | Model provider abstraction (OpenAI-compatible, Anthropic, Ollama, Fake) with metadata and context-window registry. | ModelClient trait, ModelRequest, ModelRouter, ModelProfile, ModelRegistry. |
| agent-tools | Tool 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-mcp | MCP (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-lsp | LSP and DAP client implementations, JSON-RPC transport, server lifecycle management for code intelligence and debugging. | LspClient, DapClient, LspServerDef, DapServerDef, LspServerLifecycle, DapServerLifecycle, ServerStatus. |
| agent-skills | Native skills system — reusable prompt/tool/workflow capabilities, frontmatter parsing, registry, GUI settings. | SkillRegistry, SkillDef, SkillFrontmatter, SkillScope, SkillSettings. |
| agent-plugins | Plugin manifest and inventory for plugin-provided skills, tools, hooks, and MCP servers. | PluginManifest, plugin inventory helpers. |
| agent-memory | Durable, user-, workspace-, and session-scoped memory, context assembly with tiktoken budgets, multimodal image pruning, prompt compaction. | MemoryStore trait, SqliteMemoryStore, ContextAssembler, MemoryMarker, ContextCompactor, ImagePruningStrategy. |
| agent-store | Append-only SQLite event store plus metadata tables for workspace/session tracking and trajectory persistence. | EventStore trait, SqliteEventStore, SessionMeta, TrajectoryStore, SqliteTrajectoryStore. |
| agent-config | TOML 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
| Crate | Role | Key types |
|---|---|---|
| agent-tui | Three-panel ratatui app (sessions, chat, trace). Build-info banner, permission modal, model-switch UI. | App, ChatPanel, SessionsPanel, TracePanel, PermissionModal. |
| agent-gui | Tauri 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_forwardercallsLocalRuntime::subscribe_all()and forwards everyDomainEventto the renderer via Tauri'semit. The TUI does the same in-process. Both filter by the currently focusedSessionId. - Persistence is bounded.
agent-storewrites 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.
| Trait | Defined in | Used in | Notes |
|---|---|---|---|
AppFacade | agent-core | agent-runtime, agent-tui, agent-gui | The integration point between UIs and the runtime. |
EventStore | agent-store | agent-runtime and consumers at wiring boundaries | Implemented by SqliteEventStore; tests use in-memory SQLite (:memory:). |
MemoryStore | agent-memory | agent-runtime, agent-gui (read-only) | Implemented by SqliteMemoryStore. |
ModelClient | agent-models | agent-runtime | Implemented by OpenAI-compatible, Anthropic, Ollama, and Fake clients; the router multiplexes. |
Tool / ToolProvider | agent-tools | agent-runtime, agent-mcp (via adapter) | Built-in tools and MCP-exposed tools both implement Tool. |
Transport | agent-mcp | agent-mcp internal | Implemented by StdioTransport, SseTransport, StreamableHttpTransport. |
AgentStrategy | agent-runtime | agent-runtime | Planner / 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.