Repository navigation
Protocol
A summary of the malac daemon's WebSocket and HTTP API, for people writing clients.
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.
Note
The protocol is new with the daemon. Fields may still be added; parse defensively and ignore message types you do not know.
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.
| 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.
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.
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.
-
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 |
| 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.
URL=$(jq -r .url ~/.malac/daemon.json)
TOKEN=$(jq -r .token ~/.malac/daemon.json)
# what the daemon is doing
curl -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 ack
curl -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.
Getting started
Tutorials
- Tutorials
- Tutorial 1: Your first task
- Tutorial 2: Delegating to subagents
- Tutorial 3: Automating a browser
- Tutorial 4: Long-term memory with Milvus
- Tutorial 5: Running malac unattended
- Tutorial 6: Customizing malac
- Tutorial 7: Running malac on a server
Using malac
- Workspaces, tabs and the daemon
- The terminal UI
- Plain mode and scripting
- Sessions
- Jobs
- Permissions and autopilot
- Subagents
- Memory
- Project context
- Plugins and marketplaces
- Computer and browser control
- Web UI
- VS Code
- Remote access
- Mobile (preview)
- MCP servers
- Configure malac by asking
- Updating
Reference
- CLI
- Slash commands
- Configuration
- Tools
- Control service tools
- JSON events
- Protocol
- Environment variables
Guides
- Using an LLM gateway
- Running models locally
- Vector memory with Milvus
- Controlling a remote machine
- Writing agent types, skills and commands
- Security model
- Things to know
Project