Protocol
Every interactive front end (the terminal UI, the line mode, the web UI, the VS Code extension and the coming mobile apps) is a client of the daemon. They all speak one protocol: JSON messages over a WebSocket, plus a few REST endpoints. This page is a summary for client authors. The full specification, with every field, is docs/protocol.md in the repository; the Rust types are in crates/malac/src/protocol.rs, and crates/malac/src/client.rs is a typed Rust client.
Finding the daemon
Section titled “Finding the daemon”The running daemon describes itself in ~/.malac/daemon.json (mode 0600):
{ "url": "http://127.0.0.1:7700", "token": "…", "pid": 68119, "version": "1.0.0", "urls": ["http://127.0.0.1:7700/?token=…"], "started": "2026-10-25T18:33:10.271046Z"}The file can outlive a crashed daemon, so treat the daemon as running only when pid is alive and GET {url}/api/daemon answers. To start one, run malac daemon start and wait for the file.
- Local and remote. A request is local when it comes from a loopback address, names a loopback host (
127.0.0.1,localhostor[::1]) inHost, and carries no proxy header (Forwarded,X-Forwarded-For,X-Forwarded-Host,X-Real-IP,CF-Connecting-IP). Everything else is remote: other machines, tunnels, proxies, and pages that rebind their name to this machine. Without remote access, remote requests get403. - Tokens. Every
/apirequest needs a token, asAuthorization: Bearer <token>or atokenquery parameter. The master token fromdaemon.jsonworks on local requests only; it persists in~/.malac/daemon.token, and deleting that file rotates it. Other devices pair for a device token, which works local or remote. - Origin. WebSocket upgrades are accepted without an
Origin(apps, the CLI), from the page’s own origin, and from VS Code webviews.
Concepts
Section titled “Concepts”| Thing | Id | What it is |
|---|---|---|
| Workspace | UUIDv7 | A set of tabs. Saved in ~/.malac/workspaces/<id>.json. |
| Tab | UUIDv7 | A project folder with its own runtime and its top-level agents |
| Agent | UUIDv7 | A conversation, with a transcript in ~/.malac/sessions/<id>.jsonl. Subagents have a parent. |
| Job | 8 hex digits | A background process an agent started. Saved in ~/.malac/jobs/. |
| Permission request | UUIDv7 | A tool call waiting for approval |
Tabs load lazily: a tab’s runtime is built the first time an agent in it is prompted, started or opened. After a daemon restart, agents are saved sessions (live: false) until prompted.
The WebSocket
Section titled “The WebSocket”Connect to ws://127.0.0.1:<port>/api/ws?version=<your version> with Authorization: Bearer <token> (wss:// for a daemon behind TLS). Browsers, which cannot set headers on a WebSocket, pass token=<token> in the query instead. The first message is always a snapshot: the daemon’s state (daemon, workspaces, the connection’s workspace with its tabs and agents, pending approvals, jobs, update, control, models, default_model, remote and awake). After that, every message is a broadcast that all clients get, or a reply to one of your commands. Apply broadcasts to the snapshot to stay current; on {"type": "lagged"}, send snapshot again. A connection’s commands run in order, and the broadcasts a command causes reach it before the command’s reply.
When your version differs from the daemon’s, snapshot.daemon.version_mismatch is true, so you can suggest malac daemon restart.
Commands
Section titled “Commands”A command is a JSON object whose type names it. Add a client_ref to get a reply carrying the same value: the command’s own reply, or ack; failures come back as {"type": "error", "message": …}. One connection’s commands run in order.
{ "type": "prompt", "tab": "01a17453-…", "text": "fix the failing test", "client_ref": "c7" }| Group | Commands |
|---|---|
| Agents | prompt (to an agent, or a new agent in a tab; with images and attachments), agent_start, agent_open, agent_activate, agent_close, cancel, approve, mode, model, compact |
| Workspaces and tabs | launch (what the command line does: last, new or named workspace, a tab for a folder, a prompt), snapshot, workspace_list, workspace_create, workspace_open, workspace_rename, workspace_delete, tab_create, tab_close, tab_rename, tab_move, tab_activate |
| Jobs, control and the daemon | job_list, job_output, job_kill, control_permissions (with request to show the macOS prompts), keep_awake (busy, always or off, until the daemon restarts), ping |
Approvals. permission_requested reaches every client. Any of them may answer with approve (allow, allow_session or deny); the first answer wins and permission_resolved tells everyone. Requests still open when you connect are in snapshot.pending.
Project. A loaded tab carries project: where its folder sits in the repository (root, subdir, siblings), suggest_root when the folder is one part of a larger repository and the client should offer to open the root, and commands, the tab’s custom slash commands, for completion. Send those as ordinary prompt text; the agent expands them, and answers /context itself.
Attachments. prompt takes attachments: [{path}], resolved against the agent’s folder. Text files become a fenced block (cut at 256 KB), images image blocks (up to 10 MB), folders a listing, other files a note. A pasted image has no file the daemon could read, so it goes in images as base64, with a name to show it as an attachment.
Server messages
Section titled “Server messages”event: every agent event, tagged with itstabandworkspace. The envelope is the same as in JSON events.- Changes:
workspace_created,workspace_updated,workspace_deleted,tab_created,tab_updated,tab_closed,tabs_reordered,job_started,job_finished,control,mode,clients,remote(a tunnel or a paired device changed),awake(keep-awake changed),notice,reloaded,shutdown. - Replies:
ack,error,pong,launched,workspaces,workspace_opened,jobs,job_output,control,snapshot.
All under a token except GET /api/hello and POST /api/pair. JSON in and out.
| Endpoint | What it does |
|---|---|
GET /api/daemon |
Version, pid, clients, workspaces, loaded tabs, running agents and jobs, pending approvals, idle timeout, remote access, keep-awake |
POST /api/daemon/stop |
Stop the daemon; 409 while agents or jobs run, unless {"force": true} |
GET /api/snapshot |
The snapshot, as on the WebSocket |
POST /api/command |
Any command above; returns its reply |
GET /api/workspaces, GET /api/jobs, GET /api/jobs/{id}/output |
Listings and a job’s log tail |
GET /api/fs?path=&files= |
A folder’s subfolders, and with files=true its files, for the pickers; below daemon.browse_root |
GET /api/sessions?cwd=&limit=&subagents= |
Saved sessions, newest first, so a client on another machine can list recents |
GET /api/sessions/{id}, DELETE /api/sessions/{id} |
A transcript, or delete it |
GET /api/usage?days= |
Tokens and cost from the session transcripts: today, the week, the window, per model and per day (default 30 days) |
/api/settings, /api/models, /api/tools, /api/memory, /api/update |
Settings, as the web UI uses them; GET /api/settings includes the tab’s context report |
Pairing and devices
Section titled “Pairing and devices”| Endpoint | What it does |
|---|---|
GET /api/hello |
No token. {app: "malac", version, remote, pairing}, so a client can tell it found a daemon |
POST /api/pair {code, name, kind?} |
No token. Trades a pairing code for a device token: {token, device, version}. 403 for a wrong, used or expired code; 429 after ten wrong codes. |
POST /api/pair/codes |
A new code, valid ten minutes for one device, with a pairing link and QR code (SVG) per address |
GET /api/devices, DELETE /api/devices/{id} |
The paired devices (yours marked current), or revoke one; its sockets close |
GET /api/remote |
Remote access: on or off, the bound address, the addresses others can use, the tunnel, the number of devices |
POST /api/tunnel {provider}, DELETE /api/tunnel |
Start or stop a tunnel |
An app pairs in three calls: read the base address and code from the QR code’s link (<base>/#pair=<code>), check GET <base>/api/hello, and POST <base>/api/pair. It keeps the token in the platform’s secure storage and sends it as Authorization: Bearer from then on. A 401 means the device was revoked.
Trying it with curl
Section titled “Trying it with curl”URL=$(jq -r .url ~/.malac/daemon.json)TOKEN=$(jq -r .token ~/.malac/daemon.json)
# what the daemon is doingcurl -s -H "Authorization: Bearer $TOKEN" "$URL/api/daemon" | jq
# a prompt to a new agent in the active tab of the last workspace; the reply is an ackcurl -s -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"type": "prompt", "text": "summarize the README"}' "$URL/api/command"The agent’s output then streams to every WebSocket client, so open the terminal UI or the web UI to watch it. For a client of your own, read the full protocol or use the Rust client.
malac is created by lobirus and released under the MIT license. About malac