Skip to content

Architecture

malac is a Cargo workspace with four crates:

Crate Role
malac-core The runtime: providers, tools, agent loop, permissions, memory, sessions, MCP client wiring. No UI.
malac The malac binary and library: CLI, the daemon, its protocol and Rust client, the terminal UI, the line interface, the web server.
malac-control Computer and browser control, served over MCP (stdio or WebSocket). Also a library.
malac-mcp A small Model Context Protocol implementation (client and server) shared by the others.

Front ends never talk to models or tools directly. Either they create a Runtime in their own process, spawn agents on it and render the event stream (-p, --json and piped runs), or they are clients of the daemon, which holds the runtimes for them (the terminal UI, the line mode, the web UI and the editor extension).

Runtime (crates/malac-core/src/runtime.rs) holds what every agent shares:

  • the merged Config,
  • a provider cache (provider/), built lazily per provider name,
  • the tool list: built-in tools plus tools from connected MCP servers,
  • Memory, the permission Policy and the current Approver,
  • the project context (context/): instruction files, the docs map, the project index and .malac/MALAC.md,
  • the enabled plugins (plugins/) with their hooks, and the agent types, skills and slash commands found on disk and in plugins (catalog.rs, commands.rs),
  • the EventBus and the registry of live agents
  • the job table (jobs.rs)

Several runtimes can share a Host: one sequence counter for their event buses, one job table, and one MCP pool that keys connections by server configuration, so identical servers (the control service included) start once per process. The daemon’s tabs are such runtimes.

An agent (agent.rs) is one conversation owned by a tokio task. Callers hold an AgentHandle and send it commands (prompt, send, cancel, set_model, compact); the task processes them one at a time, so an agent’s history is never shared or locked.

A turn:

  1. Attach recalled memories (vector search on the prompt) and append the user message.
  2. Compact the conversation if the last request used more than 80% of the model’s context window.
  3. Stream a completion from the provider, publishing text, reasoning and tool-call deltas. Failed requests are retried with backoff when the error is retryable and nothing has streamed yet.
  4. For each tool call, ask the Policy; if it says “ask”, the Approver decides. Consecutive tools of kind Safe run in parallel; others run one at a time, in order.
  5. Append the tool results and loop until the model answers without tool calls.

Subagents are ordinary agents with a parent and a depth. The agent_spawn, agent_wait, agent_send and agent_list tools create and steer them; a semaphore limits how many run at once, and agents at max_depth (default 3) do not get the delegation tools. Agents that may delegate see an “Available models” section in their system prompt, with each model’s description, so they can give each subagent a model that fits its task. Cancelling an agent cancels its whole subtree.

Prompts can carry attachments (attach.rs): text files become a fenced block, images image blocks, folders a listing, other files a note.

context/ gathers what a session knows about its project when its runtime is built: instruction files in every form other agents use (instructions.rs, deduplicated and fitted into a budget by priority), a map of the Markdown docs (docs.rs), the structural index in .malac/index.md (index.rs: a gitignore-aware walk, per-language symbol patterns, incremental by modification time, refreshed in a background thread when stale), the knowledge file .malac/MALAC.md (knowledge.rs), and where the folder sits in its repository. prompt.rs turns it into sections of the system prompt, and the same report backs /context and malac context.

plugins/ reads the Claude Code and Agent Plugins formats (manifest.rs), catalogs (marketplace.rs), sources through git (source.rs) and the state files (store.rs); manage.rs installs, enables and updates plugins, hooks.rs runs hooks, and builtin.rs holds the plugins compiled into the binary. When a runtime is built, the enabled plugins’ skills, commands and agents join the catalog, their approved MCP servers join the configuration, and their approved hooks go to the agents. In a turn, prompt hooks run before the message is sent, and PreToolUse hooks run before the permission check: they may deny a call, change its input, force a question or skip it, and deny rules still win.

bash with background: true starts a job: a shell in its own process group whose output goes to ~/.malac/jobs/<id>.log, with a record next to it. Jobs outlive agents and the process that started them; job_list, job_output and job_kill control them. When the daemon starts, it restores the records, marks jobs whose processes are gone as unknown, watches live ones, and deletes finished jobs older than a week.

Every agent writes an append-only JSONL transcript to ~/.malac/sessions/<id>.jsonl (metadata, messages, usage, compaction summaries). Resuming replays it from the last compaction.

Everything an agent does is an Event on the broadcast EventBus, wrapped in an Envelope with the agent id, a sequence number and a timestamp. The terminal renderer, the --json printer and every web UI connection are independent subscribers. This is what makes subagents visible: their events flow through the same bus and every front end can show them. The event types are listed in JSON events.

Provider::complete takes a provider-neutral Request (system prompt, Messages made of Blocks, tool specs) and returns the assembled message plus usage. Two wire formats are implemented:

  • OpenAI-compatible chat completions (provider/openai.rs): OpenAI, LiteLLM, vLLM, Ollama, OpenRouter, Together and others. Reasoning arrives as reasoning_content or reasoning. Images returned by tools are re-sent in a user message, since tool messages cannot carry images.
  • Anthropic Messages (provider/anthropic.rs), with prompt caching on the system prompt, tools and the latest turn, and signed thinking blocks replayed as required.

For models marked vision = false, images are replaced by descriptions written by vision_model before the request is sent.

Each tool has a kind: Safe (never asks), Edit or Execute. The mode decides the default (ask, edits, autopilot); allow and deny rules (tool or tool(glob)) refine it. Shell commands are split on &&, ||, ;, | and newlines, and every segment must match an allow rule. Deny rules are checked first and apply in every mode, autopilot included. See Permissions and autopilot.

Markdown files are the source of truth: ~/.malac/memory/*.md (global) and <project>/.malac/memory/*.md (project), each with name and description frontmatter (project ones also a kind), plus a generated index: MEMORY.md for global memories, .malac/MALAC.md for the project’s, grouped by kind. The indexes are part of every system prompt.

When [memory.vector] and [memory.embeddings] are configured, saving a memory also upserts its embedding into a Milvus collection (through the Milvus REST API, so self-hosted Milvus and Zilliz Cloud both work). Search returns the matching files’ current text, so the index can never serve stale content. With auto_recall, every prompt is embedded and close matches are attached to it. malac memory reindex rebuilds the collection from the files.

malac-control exposes the desktop and browsers as MCP tools. malac connects to it like any other MCP server, in this order:

  1. [control] url, if set,
  2. a running service announced in ~/.malac/control.json (written by malac-control serve),
  3. a spawned malac-control stdio, found next to the malac binary or on PATH.

Its tools are added without a server prefix (browser_click, computer_screenshot). Read-only tools are annotated so they never need approval.

malac daemon is one long-lived process per user that holds every workspace, tab and agent, so agents outlive the terminal that started them and several clients share them.

  • Process. malac daemon start (and any client that needs it) runs malac daemon run in its own session, logging to ~/.malac/daemon.log. It takes ~/.malac/daemon.lock, listens on web.port on loopback (or on all interfaces with web.remote), and writes ~/.malac/daemon.json with its address, token, pid and version.
  • Hub. The state: workspaces (saved to ~/.malac/workspaces/), one runtime per loaded tab, built lazily from the tab’s folder and options on a shared Host, the pending approvals, and a broadcast of changes. Commands from every transport go through one entry point.
  • Approvals. Every tab runtime uses the hub’s approver. Requests reach all clients and wait until one answers; the first answer wins.
  • Restarts. On start the hub loads the saved workspaces and restores jobs. A tab loads its runtime when used, and a saved agent resumes from its transcript when prompted.
  • Idle exit. With no clients, running agents, jobs or pending approvals for daemon.idle_minutes (default 120), the daemon exits.
  • Versions. The daemon keeps the version it started with. Clients send theirs, and the snapshot flags a mismatch so they can suggest malac daemon restart.
  • Control service. The daemon connects the control service once at start through the shared MCP pool and reports its state and macOS permissions in the snapshot.
  • Remote access (daemon/access.rs, daemon/tunnel.rs). Each request is local (loopback peer, loopback Host, no proxy headers) or remote, and remote ones are refused unless web.remote is on. The master token works only locally; other devices pair with a one-time code for a device token, hashed in ~/.malac/devices.json and revocable, which closes their sockets. A tunnel tool runs as the daemon’s child, and its public address joins the pairing links.
  • Keep-awake (daemon/awake.rs). A task follows the running agents and jobs and holds caffeinate (macOS) or systemd-inhibit (Linux) while there is work, per daemon.keep_awake.
Command Runs in
malac -p, --json, piped input, no terminal this process; never starts the daemon
malac (full-screen UI) a daemon client, starting the daemon if needed
malac --plain (or ui.tui = false) a daemon client, starting the daemon if needed; in this process when it cannot be reached
malac web, the editor extension, the web UI daemon clients
malac -C <name> a client of the daemon a saved connection names (connections.rs); never started from here

The protocol is summarized in Protocol.

The daemon serves a single-page UI from the binary; malac web starts the daemon if needed and prints or opens its URL. API requests need a token: the master token on local requests, a device token from pairing otherwise. Remote requests are refused unless remote access is on, which also blocks DNS rebinding; without a token, the page shows a pairing form. The VS Code extension connects to the daemon in ~/.malac/daemon.json, or starts one with malac web --port 0 --json-ready --cwd <folder>. It holds one WebSocket in the extension host, shared by its chat, trees, status bar and notifications; the chat is a webview that talks only to the extension host. Open Full UI embeds the web UI in a panel.

update.rs reads the latest release’s SHA256SUMS, downloads the archive for the current target, verifies it, unpacks it next to the installed binaries and renames them into place, malac-control first. A rename never disturbs a running process, so agents keep running on the old binary and the next start picks up the new one.

malac is created by lobirus and released under the MIT license. About malac