↓ Skip to main content

Architecture

Documented version: v0.1.8

Everything flows one way, through one loop:

agent ──hook JSON──▶ vults-hook ──protocol line──▶ ipc ──┐
GitHub (gh) ─────────────▶ connectors runtime ─────────────────┤
island clicks (Allow, open, jump…) ────────────────────────────┤
                                                                ▼
                                        core::reduce(state, input, now) ──▶ effects
                                                                │            (answer the hook,
                                                    State::view ▼             open a URL, jump)
                                                         island renders

Layers
#

The crates form three layers over a small base. A crate depends only on its own layer or a lower one; scripts/check-layers.sh checks it in CI (tests may reach further).

LayerCratesWhat it owns
Experienceapp, platform, ui/Every surface: the island, the widget, settings, the tray, Zeca and the flock
Connectconnectors, chat, voice, media, zeca, link, relay, evals, spikeWhat reaches past the agents: GitHub, the chat CLIs and APIs, the microphone, what is playing, a paired phone
Corecore, protocol, peer, ipc, hook, agents, agent-config, storeSessions, events, approvals and decisions: what the agents are doing and what the human said
basebrand, secretsThe name, the keyring

Only app wires the layers together: Connect never calls into Experience, and Core knows neither. That keeps a new surface cheap (it draws State::view and sends intents, ADR 0021) and keeps Zeca optional (ADR 0018): he uses Chat, Voice and Connect, and none of them knows about him. His body is in Experience; his brain is the zeca crate in Connect, which reads the flock but never answers a permission (ADR 0018).

CrateRoleMust not use
brandThe app’s name, slug and bundle id; generates ui/src/brand.tsanything
protocolVersioned hook ↔ app messages, limits, socket and pipe namestokio, Tauri
peerSame-user checks on both ends of the connectiontokio, Tauri
hookReads the agent’s hook JSON, forwards it, prints the agent’s decision formattokio, HTTP
ipcAsync server: connection limits, ack-then-decide, routing to the appTauri
corePure domain: sessions, approvals (each card an offer in the ledger: bound to its exact request, answered once, only in time), action classes, the audit lines, alerts, the view, whose TypeScript types it generates into ui/src/view.gen.ts (a test checks it is fresh); clock injectedIO, async, Tauri
agentsThe registry of every agent (id, name, wire, setup, how it runs, what it can do; ui/src/agents.gen.ts is its twin) and, per agent (Claude Code, Codex, Antigravity, OpenCode, Qwen Code, Copilot CLI, Cursor, Factory Droid, Kimi Code, any other): event names, tool → activity, install entries, Codex trust; the AgentDriver trait for an agent the app runs itself (no runtime)Tauri, tokio
agent-configSafe edits of agent configs (JSON, TOML with its comments kept, whole plugin files): strict read, diff, fingerprint, backup, atomic writeTauri
storeThe local database: one vults.sqlite (bundled SQLite), numbered migrations, the history of turns, the append-only audit log, the consents (never deleted), traces, Zeca’s conversations (with what each read from outside and its frozen context), the paired phones and the highest counter each signed with, Zeca’s journal and daily summaries with their full-text and vector recall, the memory proposals waiting for a yes, his nights, the user’s tasks, and what the agents cost by day and roostTauri, async, the network
chatChat through the claude and codex CLIs, with permission requests, or an API: Anthropic or an OpenAI-compatible cloud with the user’s key, or a local Ollama / LM StudioTauri
zecaZeca’s brain (ADR 0018): a read-only view of the flock, his memory as a git repository of small Markdown files of facts, each with a hard limit (each write a commit)core (so nothing in it can build an Intent), Tauri
linkA paired phone’s link (ADR 0004): pairing by QR, envelopes sealed with X25519 and XChaCha20-Poly1305 (a relay sees only ciphertext), answers signed by the phone’s Keystore keys and checked before core’s ledger redeems themIO, the network, Tauri
relayThe phone’s relay (ADR 0004): an axum server that stores and forwards sealed envelopes by box, capped and dropped after 10 minutes, and the client both ends use, each request through the app’s network gate; it depends only on linkTauri, core
evalsEvals with cassettes (dev only, never in the app): policy classes, redaction, the prompts sent to models, recorded streams replayed for injectionbeing linked into the app
spikeThe spike kit (dev only, never in the app): a fake model, a fake app on the hook’s socket and a terminal for TUIs, to record a real agent’s hooks without an account; scripts/spike.sh runs it per agentbeing linked into the app
secretsThe OS keyring (Secret Service, Credential Manager), keyed by the bundle idTauri, files
connectorsVults Connect: the Connector trait, the polling runtime, GitHubTauri, core
voicePush-to-talk: the microphone into memory (cpal), whisper.cpp on this computer, model downloads checked by SHA-256Tauri, core
mediaWhat is playing (MPRIS over the session bus, by its signals) and play/pause/skipTauri, core
platformLinux surface placement (layer-shell, an input region per window), the tray item (a StatusNotifierItem), global shortcuts (the desktop portal) and jump-to-terminalTauri, core
appThe Tauri shell: the runtime loop, effects, commands, tray, settings—

The UI (ui/) is TypeScript with no framework. src/bridge.ts is the only file that talks to Tauri; src/island/ renders the island from the view, src/character/ draws the birds from sprite data, and src/surfaces/ holds every other surface, one folder each (widget/, settings/, nest/) with its main.ts entry and its stylesheet. Code more than one surface uses sits at the root of src/; the exceptions are the chat panel (src/island/chat.ts, with its Markdown and icons) and the GitHub card (src/island/board.ts), which the Nest draws too, and the settings pages’ look (surfaces/settings/pages.css), which the Nest’s Week uses inside a shadow root.

Meaning in core, look in the surface
#

The view says what things mean, so every surface agrees (ADR 0021). Each session carries its attention, an ordered level (quiet < info < done < failed < needs-you), and the view carries the most of them; card marks the session whose card is first in line and still waits on it. The island only chooses the look: which sound a level makes, and how long a state must hold (1.5 s, 3 s for done) before it is news.

The runtime broadcasts the view, when it changed, to every window at once (publish_view), and keeps the last one for a page that loads later (current_view). Events meant for the island alone (pointer, shortcuts, chat) are sent to its label. A plain listen hears every target, so a new surface subscribes to those through window-scoped helpers in bridge.ts (getCurrentWebviewWindow().listen) to stay out of them.

The Nest’s window holds two webviews, the Nest’s and Settings’, one shown at a time under the same header (ADR 0021). Commands check the label of the webview that called: the calls only Settings may make are refused from the Nest’s, which shows agents’ text.

ended lists the cards that left the line most recently, each with its outcome: answered here (allowed, denied, answered), released (sent to the terminal from here), terminal (the agent moved on), expired or rule (an Always on an identical card). The island reads it to say what became of the card it showed. An outcome is information only: it is recorded where a card leaves the line and never answers one.

front is the session in front, by one rule: the session whose card waits, else the one the user put in front (focus, set by Intent::Focus from a click on its row, forgotten when the session leaves), else the first. Work alone never moves it. Sessions come in the order they arrived, the order every surface draws them in, and the one Intent::FocusNext and FocusPrevious (global shortcuts) walk, wrapping. The chat works in the folder of the session in front.

Why the hook waits for an acknowledgement
#

A permission request keeps its connection open. The server only waits for a human once the app’s loop has taken the request and core has queued its card (Effect::AckPermission). If the app is stuck or not listening, the agent gets its answer (silence, so it asks in the terminal) within 800 ms instead of two minutes. The island opens on a queued card and stays open until it is answered; no presence mode may hide it once acknowledged (ADR 0021).

Why only core produces a decision
#

core::reduce turns a PermissionRequested event into pending state, and only a human’s click turns pending state into a RespondPermission effect: Intent::Decide (Allow, Deny) or Intent::DecideAlways, which also saves a rule. A saved rule answers only a request that matches it exactly: the same agent, folder, tool and target (ADR 0004). A question (QuestionAsked) waits the same way, and only an Intent::Answer that fits its questions turns it into an AnswerQuestion effect. A test feeds every other input in every order and checks none of them answers.

The island on Wayland
#

The island is a layer-shell surface mapped once, at a fixed size, and never resized or hidden: KWin stops showing a layer surface resized from the webview. The UI draws the island inside it and reports its rectangle, which becomes the only part that takes the mouse (the input region); everything else falls through to the windows below. WebKit pauses requestAnimationFrame while it thinks the page is hidden, so the UI measures the DOM synchronously and animates with timers.

The platform code is per window: each surface has a LayerSpec (namespace, fixed size, the edges it hangs from, margin, keyboard) and its own input region, kept by window label. A page’s layout and keyboard requests act on the window that sent them, and only if it is a surface (SURFACES in app/src/lib.rs). A new surface gets a spec, its label in the windows of app/capabilities/default.json and, for its own page, a Vite entry.

Trying the island without an agent
#

cargo run -p vults-hook --example replay sends a recorded session through the real hook into the running app: reading, searching, the web, an edit, a permission (it waits for your answer, as an agent would), a command and the end. Pass your own JSONL file (one hook JSON per line) and --delay-ms or --agent codex to change it. ui/lab/ (npm run dev, then /lab/) shows every clip and the island with made-up states, without the app at all.

Decisions
#

Why the app is built this way (Rust only, the safety rules, one core for every surface, Zeca optional, rules before models) is in the decision records.

Documentation
#

docs/ is the source for the docs on the website (rogeriojunior31.github.io/en/docs/vults), published on each release tag; docs/site.json holds the name and summary the website shows. Pages start with a # H1, use relative links, and keep images in docs/assets/. docs/adr/ holds the decision records. docs/dev/ is internal and not published; docs/pt-br/ holds the Brazilian Portuguese translation (see CONTRIBUTING). node tests/visual/docs-shots.mjs retakes the README’s and the guides’ island images from the lab; run it before each release.