Protocol
Argus has two reference clients: the Go client, which the TUI and the CLI use, and the mobile app.
Timeouts and intervals marked client default are the Go client's values. A new client can use different ones. The node or the gateway fixes all other values.
Overview
Components
| Component | What it does |
|---|---|
| node | Runs on each machine. Discovers agent sessions, drives tmux, and serves the API. |
| gateway | Listens on HTTP and relays traffic between clients and nodes. It can run with a node or alone. |
| client | The TUI, the mobile app, or the CLI. It talks to a node directly or to nodes through a gateway. |
A client reaches a node in one of two ways:
local: client ──unix socket──▶ node
gateway: client ──WebSocket /client──▶ gateway ◀──WebSocket /node── nodeOn the gateway path, the node dials out to the gateway. This connection is the node uplink. The gateway never dials a node.
The gateway does not read session data, and it does not merge results from several nodes. The client opens one relay channel per node and merges the results itself.
What a client implements
A client that uses the gateway path must implement these parts:
- Line framing over a WebSocket byte stream. See Framing.
- JSON-RPC 2.0 requests, responses, and notifications in both directions.
- The roster: the
node.eventstream from the gateway. - One relay channel per online node. See Relay channels.
- Noise IK and sealed records, if the client supports E2EE mode. See End-to-end encryption.
- Composite ids, and routing of each call to the correct node. See Aggregation.
- The feature methods that the client needs. See Features.
- Trust log verification, if the client supports locked mode. See Locked mode.
Versions
The protocol has no version number and no version negotiation. The version field in server.info and node.identify is the Argus build version.
A client can compare it with a minimum version that the client supports.
Wire format
Transports
Unix socket. Each node listens on a unix socket. The default path is argus.sock in the runtime directory (configuration key socket). The CLI and the TUI on the same machine use this socket. Only the owner of the socket file can connect. The socket has no token authentication. It is the only transport that serves the lock.* methods.
A gateway also listens on argus-gateway.sock, in the same directory as the node socket. This socket serves only server.info (with no nodes), server.checkUpgrade, server.upgrade, server.restart, and server.stop.
WebSocket. The gateway serves plain HTTP on :8443 by default (--listen-addr). It has two WebSocket routes and no other HTTP routes:
| Route | Who connects | Token accepted |
|---|---|---|
/node | nodes | the master token only |
/client | clients | the master token or a client token |
The gateway does not terminate TLS. For wss://, put a tunnel, an SSH uplink, or a reverse proxy in front of the gateway. See Multi Machine. The gateway does not verify the Origin header, because the token controls access. The read limit is 16 MiB per WebSocket message.
Gateway URLs. A gateway URL is a base URL. The client appends the route.
ws://host[:port][/base-path]orwss://.... The Go client appends/nodeor/clientafter the base path. The reverse proxy must remove the base path, because the gateway serves the routes at the root.ssh://[user@]host[:sshport][?port=N]. The Go client runsssh -Wto127.0.0.1:Non the gateway host and speaks WebSocket through that tunnel.portis8443by default. This form takes no path.
The Flutter app accepts only ws:// and wss:// URLs without a path.
Framing
Every frame is one JSON object on one line, followed by \n. The maximum line length is 16 MiB. A receiver skips empty lines. If a line is longer than 16 MiB, the receiver closes the connection.
On a WebSocket, treat the text messages as one byte stream. A WebSocket message does not always hold exactly one frame. For example, a large frame can arrive as one message, and its \n can arrive in the next message. A client must buffer the input and split it on \n.
If a line is not valid JSON, the receiver sends back an error response with code -32700, message "parse error", and no id. Then it continues with the next line.
Envelope
The envelope is JSON-RPC 2.0 with two extra fields, route and body:
{
jsonrpc: "2.0"
id?: number // requests and responses
method?: string // requests and notifications
route?: RouteHeader // relay frames only
body?: string // relay frames only (base64)
params?: any // direct frames only
result?: any // direct frames only
error?: { code: number, message: string }
}A frame belongs to one of these classes:
| Class | Rule |
|---|---|
| request | has method and id |
| notification | has method and no id |
| response | has id and no method |
| relay frame | has route, of any shape |
A relay frame never reaches the normal dispatch path. It carries its payload in body, not in params or result. See Relay channels.
Both sides of a connection can send requests and notifications.
Request ids
Each side picks its own request ids. Use integers. A server echoes the id exactly as it received it, but the Go peer matches responses only as integers. On relay frames, use one counter for all channels of a connection, so that each id is unique on the connection.
Errors
| Code | Name | Use |
|---|---|---|
-32700 | parse error | The line is not valid JSON. |
-32600 | invalid request | A refused operation. Some handlers also use it for bad params. |
-32601 | method not found | The receiver does not serve the method on this connection. |
-32603 | internal error | Any other handler failure. |
410 | push target gone | push.test hit a dead push subscription. |
A handler error that is not a JSON-RPC error becomes -32603 with the error text as message. Many handlers return plain errors, so bad params and a missing field often arrive as -32603. The code and the text also differ between handlers. For example, sessions.capture returns -32603 unknown session: <id>, but sessions.kill returns -32600 unknown session <id>. A client must not depend on the code to tell these errors apart. The message text is the only detail.
Keepalive
ping is a request with no params and a null result. The peer answers it directly, before any dispatch rule. As a result, a ping always works, even on a connection that serves no other method.
The gateway does not ping clients. A client that must detect a dead connection must send its own pings. The client default is a ping every 15 seconds with a 5-second timeout, and a close after two failed pings in a row. Any reply counts as a success, including an error reply. The app uses a 20-second interval and a 10-second timeout, and closes after one failure.
Slow readers
If the gateway cannot write a frame to a client in time, it closes the whole connection, because a partial frame corrupts the stream. A client must read its connection continuously.
Connecting a client
Authentication
The client sends its token in the WebSocket handshake:
Authorization: Bearer <token>If the header is absent, or if it does not start with the exact text Bearer , the gateway reads the token query parameter. Browsers need this fallback because they cannot set headers on a WebSocket handshake.
If the token is wrong, the gateway answers 401 with the body unauthorized and does not upgrade the connection.
There are two kinds of token:
- Master token. This is the
--tokenvalue ($ARGUS_TOKEN). Nodes must use it on/node. A client that uses it on/clientis an admin connection. If--tokenis empty,/nodeaccepts any token. - Client token. The gateway issues it during pairing. It is exactly 64 lowercase hex characters. It works only on
/clientand never grants admin rights.
Admin connections can call clients.* and pushport.setToken. Other connections get -32600 with the message unauthorized: admin token required.
Connection sequence
The Go client connects in this order:
- Open the WebSocket to
<gateway>/clientwith the token. - Receive the roster. The gateway sends one
node.eventof typeaddedfor each node in the roster, then onenode.eventfor each roster change. - For each node with
online: true, callrelay.open. Skip a node withoutidentity_pubkeyif you use E2EE mode. - In E2EE mode, do the Noise handshake on the channel. See Handshake.
- Call
node.identifyon the channel. In locked mode, record thetip. - Receive the session snapshot. The node sends one
session.eventof typeaddedfor each current session, then live events. - Send feature calls.
The session snapshot starts when the channel exists. In plaintext mode, that is when the first frame arrives at the node. As a result, session.event notifications can arrive before the response to your first request. The snapshot has no end marker. To get a complete list at a known time, call sessions.list.
On the unix socket, the node starts the same session.event stream as soon as the client connects. There is no roster and no relay channel. Send requests as direct frames.
Roster events
node.event has the type added, online, offline, removed, or trust-changed:
- A new node produces
added. - A node that reconnects within 30 seconds of a disconnect produces
online, notadded. - A node that disconnects produces
offline. After 30 seconds, the gateway removes it and sendsremoved. trust-changedtells the client to sync the trust log. Itsnodefield is an empty descriptor.
The initial snapshot also sends added for offline nodes that are in the 30-second window, with online: false. relay.open fails for these nodes.
A second uplink with the same node id replaces the first. The gateway subscribes the client before it sends the snapshot, so a client can see one node event twice. If the event buffer of a client (64 events) is full, the gateway drops the event for that client.
Reconnect
The client default is to dial again after a disconnect. The wait starts at 500 ms and doubles up to 15 seconds. After a successful connection, the wait goes back to 500 ms. The app waits from 1 second up to 30 seconds.
After a reconnect, repeat the full connection sequence. Old channel ids are not valid on a new connection.
Pairing a new device
A client token is 32 random bytes, written as 64 lowercase hex characters. The pairing flow is:
An admin client calls
clients.pairStart. The gateway returns a new token and its public URL. The token stays pending in memory for 60 seconds.urlcan be empty if the gateway has no public URL.The admin client shows a QR code with this URI:
argus://pair?url=<ws|wss base URL>&token=<token>The Go generator keeps a reverse-proxy base path in
url. The app also accepts an optional&e2e=true, which selects E2EE mode. Nothing else on the wire tells a client which mode to use.The admin client calls
clients.pairAwaitwith the token. The call blocks until a device connects with the token, or until 60 seconds pass.The new device connects to
/clientwith the token. The gateway stores the token and wakes the waitingclients.pairAwait.
clients.pairAwait returns connected: false for an unknown token, for a second await on the same token, and after the timeout. It does not return an error in these cases.
The gateway identifies each stored token by its SHA-256 hash. clients.list returns this id in the field token, not the raw token. clients.remove takes that id.
Relay channels
A relay channel connects one client to one node through the gateway. Every request that a client sends to a node travels on a relay channel. The gateway forwards relay frames by channel id and does not decode body.
Opening a channel
The client sends a normal request to the gateway:
{"jsonrpc":"2.0","id":7,"method":"relay.open","params":{"node_id":"home"}}The gateway answers with a channel id:
{"jsonrpc":"2.0","id":7,"result":{"chan_id":"c12"}}Channel ids come from one counter for the whole gateway. They start again at c1 when the gateway restarts. The gateway does not tell the node about the new channel. The node learns about it from the first frame that arrives.
| Error | Cause |
|---|---|
-32600 invalid params: ... | The params do not decode. |
-32600 unknown node: <id> | No live uplink for this node id. |
-32600 too many open channels for this client | The client already holds 64 channels. |
Route header
RouteHeader {
chan_id: string // the routing key
node_id?: string // set on client-to-node requests
sub_id?: string // reserved; current nodes do not set it
term_id?: string // reserved; current nodes do not set it
}The gateway forwards a frame only if its chan_id exists and the sender owns that side of the channel. It drops other relay frames without a reply.
Frame shapes
A relay frame keeps id and method in cleartext. The payload goes in body as a JSON string with standard base64 (with padding).
| Frame | Cleartext fields | body holds |
|---|---|---|
| request | id, method, route | seal(params) |
| response | id, route | seal({"result": ...}) or seal({"error": ...}) |
| notification | method, route | seal(params) |
A request:
{"jsonrpc":"2.0","id":42,"method":"sessions.list","route":{"chan_id":"c12","node_id":"home"},"body":"<base64>"}The response to it:
{"jsonrpc":"2.0","id":42,"route":{"chan_id":"c12"},"body":"<base64>"}The error detail of a response is inside body. The gateway cannot read it. If a request has no params, seal gets an empty input.
seal depends on the mode:
- Plaintext mode.
sealdoes nothing.bodyis the base64 of the inner JSON. - E2EE mode.
sealencrypts with the Noise session of the channel. See Sealed records.
Plaintext mode
This mode is the default. There is no handshake. The client sends requests directly after relay.open. If a frame arrives for an unknown chan_id, the node creates the channel and starts the session.event stream on it. The Go client sends node.identify as its first frame.
Order and delivery
- The node serves requests concurrently. Responses can arrive in a different order than the requests.
- Notifications on one channel arrive in the order that the node sent them.
- The node acts only on requests. It ignores notifications and responses from the client on a channel.
- If the node cannot open the
bodyof a request, it drops the frame without a reply. The client sees only its own timeout.
The client default timeout for a call on a relay channel is 30 seconds. server.upgrade is the exception: its client default is 6 minutes. Calls to the gateway itself have no client timeout.
Mode mismatch
The client and the node must use the same mode. There is no error for a mismatch:
- A node in E2EE mode drops frames for an unknown
chan_idthat are note2e.handshake. A plaintext client sees only timeouts. - A node in plaintext mode also accepts
e2e.handshake.
Limits and teardown
The gateway keeps a queue of 64 frames per channel and direction. If a queue is full, the gateway closes the channel. It never drops one frame, because a missing frame breaks the Noise nonce order.
The gateway closes a channel in these cases:
- The client calls
relay.closewith thechan_id. - The client disconnects.
- The node uplink closes.
- A frame write to either side fails.
- A queue overflows.
The gateway sends the node a relay.closed notification with the chan_id, and the node frees the channel's state, including its transcript subscriptions. The node also frees every channel when its uplink closes. The gateway sends the client no teardown frame. The client drops a channel when it gets a node.event of type offline or removed. It opens a new channel when the node is online again.
relay.close returns null, even for a channel that the caller does not own.
End-to-end encryption
In E2EE mode, each relay channel is a Noise session between the client and the node. The gateway relays the handshake and the sealed frames, but it cannot read them. Set e2ee.enabled: true on nodes and clients to use this mode. See End-to-End Encryption.
Keys
| Key | Type | Lifetime |
|---|---|---|
| node identity | Curve25519 | stored by the node |
| client identity | Curve25519 | Go client: stored in locked mode, else one key per process. App: stored. |
| node signer | Ed25519 | stored by the node (locked mode) |
Curve25519 keys are 32 bytes.
The node reports its identity public key in node.identify as identity_pubkey (base64). The gateway copies it to the roster (nodes.list, node.event). The client reads the key from the roster.
The gateway can replace a roster key. If it does, the Noise handshake fails against the real node. In locked mode, the client also requires that the trust log authorizes the key.
Noise parameters
| Item | Value |
|---|---|
| Protocol | Noise_IK_25519_ChaChaPoly_BLAKE2s |
| Initiator | the client |
| Payloads | Both handshake messages have an empty payload. |
The prologue is:
argus-e2e/v1|<node_id>|<chan_id>The prologue binds the session to the node id and the channel id that the gateway assigned. For example, node home and channel c12 give the prologue argus-e2e/v1|home|c12. Both sides must build it from the same values.
Both sides follow the Noise specification:
- The ChaCha20-Poly1305 nonce is 4 zero bytes, then the 64-bit message counter in little-endian order.
- After the handshake, the initiator encrypts with the first cipher state and decrypts with the second. The responder uses the reverse.
- Message 1 is 96 bytes: ephemeral key (32), encrypted static key (48), and the tag of the empty payload (16).
- Message 2 is 48 bytes: ephemeral key (32) and the tag of the empty payload (16).
Handshake
The client calls
relay.openand gets achan_id.The client builds Noise message 1 and sends it as a relay notification.
bodyis the base64 of the raw Noise message. It is not sealed.json{"jsonrpc":"2.0","method":"e2e.handshake","route":{"chan_id":"c12"},"body":"<base64(msg1)>"}The node reads the static key of the client from message 1. The node drops the frame and sends nothing in these cases:
- The node is quarantined.
- Locked mode is on and the trust log does not authorize the key. See Enforcement.
- A channel with this
chan_idalready exists.
Otherwise, the node sends message 2 in the same frame shape.
The node starts the
session.eventstream on the channel.The client completes the handshake.
A refused client gets no error frame. It sees only its own timeout. The client default timeout for message 2 is 10 seconds. The app uses 15 seconds.
Sealed records
seal turns one message into one or more records:
record = uint16_be(len(ciphertext)) || ciphertext- Each record holds at most 65519 bytes of plaintext.
- The AEAD associated data of each record is 5 bytes: the 0-based record index as
uint32_be, then0x01for the last record or0x00for the others. - An empty message produces one empty final record.
- A receiver rejects an empty blob and a blob with a missing last record.
The Noise cipher state supplies the nonces. Each side must seal and open messages in wire order. There is no rekey.
Each record that opens correctly advances the receive nonce. If the first record of a blob fails, the nonce does not change and the receiver drops the frame. If a later record fails, the earlier records already advanced the nonce. The channel is then out of sync and cannot recover. Close it and open a new channel.
Methods on a channel
The node serves every method on a channel except lock.*. It returns -32601 for lock.*. It acts only on sealed requests, that is, frames with both id and method.
Aggregation
A client that talks to several nodes must merge their data and route each call to one node. The gateway does not do this work.
Composite ids
A node uses local ids. A client that talks to several nodes turns them into composite ids:
<node_id>:<local_id>The separator is the first :. A node id must not contain :. A local id can contain :. For example, node home and session default:%3 give home:default:%3.
Local session ids have these forms:
| Form | Use |
|---|---|
<tmux_server>:<pane_id> | sessions in tmux. The servers are default and argus (sessions that Argus spawned). |
<agent>:<agent_session_id> | headless agents (opencode, codex) |
The client makes these ids composite:
idandworkspace_idof each session, insessions.list,sessions.refresh, andsession.eventidof each project and workspace inproject.listsession_idin the results ofsessions.spawnandsessions.resumeworkspace_idin the result ofworkspace.createsession_idintasks.changedidandworkspace_idof each terminal interminal.listand in the result ofterminal.create
The client adds node_id and node_label to sessions, projects, terminals, and history items. History items are not composite. A node accepts a composite id with its own prefix in sessions.focus.
Routing
The client picks the node for each call:
| Rule | Methods |
|---|---|
| All nodes; merge results | sessions.list, sessions.refresh, sessions.historyProjects, project.list, terminal.list |
| All nodes; success if one node succeeds | push.register, push.unregister, push.test, push.setPause |
Node from composite session_id | sessions.transcriptView, sessions.toolDetail, sessions.capture, sessions.input, sessions.key, sessions.respond, sessions.kill, sessions.focus, session.rename, session.prompt.complete, sessions.tasks, sessions.changedFiles, sessions.fileDiff, sessions.commits, sessions.commitFiles, transcript.subscribe, terminal.open with session_id |
Node from composite terminal_id | terminal.kill, terminal.rename, terminal.open with terminal_id |
Node from composite workspace_id | workspace.changedFiles, workspace.diff, workspace.listDir, workspace.readFile, workspace.commits, workspace.commitFiles, workspace.remove, workspace.setTarget, workspace.runSetup, workspace.setupLog |
Node from composite project_id | workspace.create, project.rename, project.setHidden, project.setPinned, project.forget, project.branches, project.prs, project.issues |
Node from node_id param | sessions.spawn, sessions.resume, agents.list, agent.model.list, sessions.exportBundle, sessions.historySessions, sessions.historyTranscript, sessions.historyToolDetail, terminal.create, host.info, host.setWakelock, host.completeDir |
Node that owns the sub_id or term_id | transcript.unsubscribe, terminal.input, terminal.resize, terminal.close |
Node from node_id param, or the gateway when node_id is empty | server.checkUpgrade, server.upgrade, server.restart |
| The gateway | ping, server.info, nodes.list, relay.open, relay.close, trustlog.sync, push.vapidKey, clients.*, pushport.setToken |
The client removes the node prefix before it sends an id to a node. The Go client uses these error texts:
| Error | Cause |
|---|---|
-32600 session id is not gateway-qualified: <id> | The id has no node prefix. The same text exists for workspace id, project id, and terminal id. |
-32600 <method> requires node_id | node_id is empty and more than one node is connected. |
-32600 <method> requires a handle id | sub_id or term_id is empty. |
-32600 <method>: unknown handle <id> | The client did not open this sub_id or term_id. |
If node_id is empty and exactly one node is connected, the client uses that node. If no node channel exists, the push.* fan-out goes to the gateway, which returns -32601.
sub_id and term_id are local to one connection on the node. The client must remember which node owns each handle.
Offline nodes
When a node goes offline, the Go client sends updated events with offline: true for the sessions of that node. After 30 seconds, or when the node is removed, it sends removed events. The next real session.event from the node sets offline back to false. While a node is offline, calls to it fail in the client without a request.
Features
Sessions
session.event is a notification from the node:
{ type: "added" | "updated" | "removed", session: Session }updated carries the full session, not a patch. If the event buffer of a subscriber (64 events) is full, the node drops the event for that subscriber. The node subscribes before it sends the snapshot, so one event can arrive twice. To repair the list, call sessions.list or sessions.refresh.
A session with a tmux pane accepts pane methods: sessions.capture, sessions.key, and sessions.focus. For a session without a pane, these methods fail with session has no terminal control. For an unknown id, they fail with unknown session: <id>.
sessions.input uses the channel in the input_mode field of the session:
pane: the node types the text into the tmux pane.api: the node sends the text through the agent itself. This applies to opencode, codex, and Claude Code while the Argus plugin is connected. A working Claude Code session with a tmux pane can read text during the running turn, except text that starts with/, which runs after the turn.- No
input_mode: the call fails withsession has no terminal control.
sessions.kill kills the tmux pane of the session. For a headless agent (opencode, codex), it also removes the session, with or without a viewer pane. For Claude Code without a pane, Claude Code stops its running turn and exits.
session.rename renames a session through the agent's own rename feature (opencode's session title, codex's thread name, Claude's rename), so the new name shows in the agent's own UI as well as in argus. It needs no tmux pane. The node trims the name and returns the trimmed name. These errors are -32600:
session_id and name are required, also for a blank name.name must not contain control characters.unknown session <id>.rename unsupported for <agent> sessions, for an agent without a rename feature.
The result's applied is true once the agent has taken the name, and the session update follows. While a Claude Code turn is running, Claude Code takes the name only after the turn, so applied is false and the session update arrives once it has. Claude Code renames only while the Argus plugin is connected; otherwise the call fails with rename needs the Argus plugin connection. While the plugin is connected, a rename typed in Claude Code reaches argus too.
Answering an interaction. When a session waits for input, its interaction field describes the request. Answer it with sessions.respond:
- If the agent waits on the node for the decision, for example Claude Code with the Argus plugin, the node turns the structured fields (
behavior,reason,answers,question_action,set_mode,option_value) into the decision. - Otherwise, if the agent is headless, the node passes the answer to the agent's service (opencode, the codex app-server daemon).
- In all other cases, the node drops the answer and returns success.
For option_value, the node maps deny to a deny, allow to an allow, and any other value to an allow that also sets that permission mode. The node ignores kind, option_index, and text.
An interaction with a request_id is one of several requests the agent may have open (codex). Echo it in the respond's request_id: the answer then goes to that request, and fails if the request was already answered or withdrawn. Without it, the answer goes to the oldest open request. The codex service also rejects a respond that does not fit its request: one whose kind differs from the request's, answers or a question_action for an approval, an option_value the request did not offer, or an approval with no decision.
A question with allow_notes also accepts a free-text note in notes, keyed by question text like answers. Agents without note support ignore it.
For a question with cancel_interrupts, question_action: "cancel" interrupts the asking thread's turn, which also ends its other open requests, and question_action: "chat" is not accepted. A question with allow_unanswered accepts a respond whose answers omits some or all questions; the omitted ones get empty answers.
Transcripts
- The client picks a
sub_idand callstranscript.subscribewith the number of entries it already holds (have_entries). - The result is a
TranscriptDeltathat brings the client up to date. - The node reads the transcript every second. If it changed, the node sends a
transcript.deltanotification. - The client calls
transcript.unsubscribewith thesub_idto stop.
To apply a delta, the client cuts its entry list to from_index, then appends entries. Filter deltas by sub_id.
For a Claude Code session transcript, the first result also carries queue: the prompts waiting in the agent's queue, oldest first. A later delta carries queue only when the queue changed, and then replaces it; a delta that changes only the queue has no entries and a from_index equal to the length of the client list. A prompt usually leaves the queue in the same delta that adds it to entries. Subagent transcripts never carry queue.
- The first result starts at the first entry of the turn that holds the last cached entry, because entries of an unfinished turn can still change. A
turn_end,system,compact, orshellentry ends a turn. If the last cached entry ends a turn, the first result starts at that entry (have_entries - 1). - The node does not verify the cached entries of the client. The cache must come from the same session and the same
agent_id. from_indexcan be less than the length of the client list, because the node can change the last entries.- A delta can look identical on the client, because the node compares fields that it does not send.
- A second subscribe with the same
sub_idreplaces the first. All subscriptions end when the connection closes. - Set
agent_idto follow a subagent transcript. - If the agent moves the session to a new transcript file, for example after
/resumeor/clearin Claude Code, the node follows it. The node sends a delta withfrom_index: 0and every entry of the new file. This applies only to subscriptions withoutagent_id.
Errors: sub_id required, unknown session: <id>, session has no transcript: <id>, and unknown subagent: <id>.
Transcript entries do not carry toolInput and result. To get these fields, the client calls sessions.toolDetail.
The node emits a turn_end entry only for a finished turn. Entry ids are stable as the transcript grows.
The node sends tasks.changed on the same subscription when the task count in the transcript goes up. The client then calls sessions.tasks. The node sends it only for subscriptions without agent_id, only for agents with task support (today, Claude Code), and never at subscribe time.
Terminals
- The client picks a
term_idand callsterminal.openwith the size and either asession_idor aterminal_id. - The node sends
terminal.outputnotifications. - The client sends keystrokes with
terminal.input. - The client sends
terminal.resizewhen its size changes. - The client calls
terminal.closeto stop.
The terminal shows only the pane of the session. The first output is a full redraw of the screen. There is no separate snapshot.
datais standard base64 in both directions. One output chunk holds at most 32 KiB. A chunk can end inside a UTF-8 sequence.- The terminal size also changes the size of the real tmux window of the agent.
- The default size is 80×24. The maximum is 1000 in each direction. The node ignores a resize with a size of 0 or less.
- For a session without a pane, the node first creates a viewer pane.
- A second
terminal.openwith the sameterm_idreplaces the first. - Mouse input is SGR mouse reports in
terminal.input. The node passes button and wheel events only to a program that turned on mouse reporting. terminal.closedoes not sendterminal.exited.
The node sends terminal.exited when the terminal ends:
reason | Meaning |
|---|---|
exited | The process ended, or the session left the registry. An empty reason means the same. |
evicted | A newer terminal.open for the same session, from any connection. The last opener wins. |
If the client sets client_pane and that pane shares a tmux window with the session, the node refuses the open.
Errors: term_id required, unknown term_id: <id>, bad base64, and terminal not available for this session.
Persistent terminals
A node with capabilities.terminal runs persistent shells. A terminal id is opaque.
terminal.createstarts the default shell in the node user's home directory. With a node-localworkspace_id, it starts the shell in the directory of that workspace, and the terminal carries thatworkspace_id. If the workspace directory no longer exists, the call fails withworkspace directory is gone: <dir>.terminal.listreturns the terminals in creation order.cwdshows the node user's home as~.nameis empty until a rename. A node withoutcapabilities.terminalreturns an empty list.- A shell stays alive after
terminal.closeand after a node restart. terminal.killstops the shell. An open attach then getsterminal.exitedwithexited.- The node sends
terminal.changedafter a create, kill, or rename, and when an attach to a terminal starts or ends. A shell that exits with no attach stays in the list until the nextterminal.list. - A terminal has one viewer at a time, with the same eviction rule as sessions.
- Over a shell without mouse reporting, a wheel-up enters tmux copy mode, which scrolls the terminal history.
- For a terminal, the node refuses
client_panewhen that pane is in the terminal's window.
Errors: terminals not supported on this node, unknown terminal: <id>, name required, set session_id or terminal_id, not both, session_id or terminal_id required, and this terminal holds your client; use it directly.
Projects and workspaces
project.changed is a notification with empty params. It tells the client to call project.list again.
project.forgetforgets the project and keeps its files. It fails if the project has live sessions.workspace.removefails for the main worktree and for a workspace with live sessions. Withoutforce, it fails for uncommitted changes and for a teardown script failure. Withforce, it removes the workspace and returns a teardown failure aswarning.against: "target"has an effect only onworkspace.changedFiles.workspace.commitsalways lists commits since the merge base with the target branch. It returns an empty list if the workspace has no target.path: ""is the root only forworkspace.listDir.workspace.diffandworkspace.readFilefail withpath is required.
Push notifications
Nodes send push notifications to mobile devices. The gateway delivers them but cannot read them. The device side of the flow is:
- Call
push.vapidKeyon the gateway to get the VAPID public key. An emptykeymeans that Web Push is not available. - Subscribe with a push distributor (UnifiedPush or PushPort).
- Call
push.registerwith a stabledevice_id, the endpoint, and the subscription keys. The client sends this call to every node.p256dhandauthare base64url without padding. - Decrypt each push message (RFC 8291,
aes128gcm).
The node sends a push when a session changes to awaiting_input. It does not send pushes for events in the initial snapshot. It skips a device whose paused_until is in the future.
The cleartext payload is:
{"id":"<32 hex>","title":"...","body":"...","data":{"session_id":"...","node_id":"..."}}id is random for each delivery. The device uses it to drop duplicates. session_id is the local id. Join it with node_id to get the composite id. A push.test payload has data: {"test": "1"} and no session_id.
If the push service reports that a subscription is gone, the node deletes the device record. push.test then returns error 410.
Locked mode
Locked mode uses a signed, append-only trust log. The log controls which client devices can open E2EE channels to a node, and which nodes a client trusts. The design follows Tailscale tailnet lock. See End-to-End Encryption for the user guide.
A client that supports locked mode must decode and verify the log, pick one chain, and refuse nodes that the log does not authorize.
Entry encoding
All integers are big-endian. A field is a uint32 length followed by the bytes. A zero-length field decodes as empty.
| Kind value | Kind | Payload |
|---|---|---|
1 | genesis | Signers, Disablements |
2 | add-signer | Key = signer public key |
3 | remove-signer | Key = signer public key |
4 | authorize-device | Key = device public key |
5 | revoke-device | Key = device public key |
6 | disable | Key = disablement secret |
7 | revoke-signer | Signers = revoked keys, Replaces, CoSigns |
The signed bytes are:
kind(1 byte)
field(Prev)
uint32(count) field(Signers[i])...
uint32(count) field(Disablements[i])...
field(Key)
field(Signer)
[kind 7 only] uint32(count) field(Replaces[i])...The wire form of an entry is:
signed bytes
field(Sig)
[kind 7 only] uint32(count) (field(CoSign.Signer) field(CoSign.Sig))...Co-signs are sorted by signer, then by signature. A decoder rejects trailing bytes.
| Item | Value |
|---|---|
| Hash | BLAKE2s-256 over the wire form of the entry |
| Signature | Ed25519 over the signed bytes. Signer is a 32-byte key. |
| Link | Prev is the hash of the previous entry. It is empty for genesis. |
| Genesis hash | the hash of entry 0. It identifies the log. |
A revoke-signer entry has an empty Signer and an empty Sig. Its co-signs are its only authentication. Each co-sign is an Ed25519 signature over the signed bytes. A client that fills Signer or Sig computes a different hash.
A chain (in files and in chain assembly) is uint32(count), then field(entry wire form) for each entry. The decoder limits are 1 MiB per field, 4096 items per list, and 65536 entries per chain.
Chain rules
- A genesis must have an empty
Prev, at least one signer, no duplicate signers, and aSignerfrom its ownSigners. - Every other entry must extend the current tip, and the log must not be disabled.
- A decoder rejects an unknown kind.
add-signer,remove-signer,authorize-device, andrevoke-deviceneed a non-emptyKeyand a signature from a trusted signer.authorize-devicefails if the device is already authorized.remove-signerfails if the key is not a signer or if it is the last signer.- A
disableentry is valid if the Argon2id commitment of its secret is in the genesisDisablements. It needs a valid signature, but any key can sign it. A disable is permanent. - When a signer is removed or revoked, the devices it authorized are also removed.
A revoke-signer entry has these extra rules:
Signers(the revoked keys) andReplacesmust not overlap.- The number of distinct trusted co-signers must be more than the number of revoked keys.
- A co-sign from a revoked signer counts only if
Replacesis not empty. - At least one signer must remain, including the keys in
Replaces. Previs the fork point: the parent of the first entry of the revoked signer, or a chosen fork point.
The disablement commitment is Argon2id with the salt argus-trustlog-disablement-v1, time 1, memory 64 MiB, 4 threads, and a 32-byte output. A secret is 32 random bytes.
Fork choice
When two chains have the same genesis, pick one with these rules, in order:
- A disabled chain beats a chain that is not disabled.
- If one chain is a prefix of the other, the longer chain wins.
- Otherwise, compare only the first different entry of each chain. The entry with more weight wins. The weight of a
revoke-signerentry is its number of valid co-signs from signers that were trusted at the fork. The weight of any other entry is1if its signer was trusted at the fork. - On a tie, a removal (
remove-signerorrevoke-signer) wins. - On a second tie, the lower entry hash wins.
Distribution
The gateway keeps the entries in memory. It removes duplicates by hash. It does not verify signatures, but it cannot forge or reorder entries, because every entry is signed and linked by hash.
trustlog.sync is available to nodes and clients:
- The caller sends
known, the hashes of all entries it holds. If the list is longer than 4096 hashes, the caller cuts it and setstruncated. - The gateway returns every entry that is not in
known, parents first. wantlists hashes inknownthat the gateway does not hold. A node answers withtrustlog.push. A client ignoreswant.disjointis true ifknownis not empty, not truncated, and shares no hash with the gateway.
To build chains from the result, merge the new entries with the entries that you hold. Find each entry that no other entry names as Prev. Walk back from each of these entries to the genesis, and drop incomplete branches. Then apply fork choice to each complete chain.
The client default is to sync on connect, every 5 minutes, and after each node.event of type trust-changed. The app syncs every 30 seconds.
Enforcement
A node verifies the client key at the end of each Noise handshake. It refuses the channel in these cases:
- The node is quarantined and not locally disabled.
- Locked mode is on, the node is not locally disabled, the log is not disabled, and the log does not authorize the client key.
An empty log authorizes no device. When the log changes, the node closes channels whose client is no longer authorized.
The client does the same check in the other direction. It opens a channel only to nodes whose identity key the log authorizes, unless the log is disabled. When the log changes, it closes channels to nodes that are no longer authorized.
A node that runs lock.localDisable accepts channels from any device. It reports lock_disabled: true in node.identify, and the gateway copies the flag to the roster. The Go client opens a channel to such a node even when the log does not authorize its own device.
The signer_pubkey and lock_disabled fields in the roster come from the gateway. Use them as hints only. For a trust decision, read the values from node.identify on an authenticated channel.
Equivocation
A gateway can show different chains to different parties. The client detects this with the node tips:
- After each trust sync, call
node.identifyon each channel and recordtip. - Look up each tip in the set of all entry hashes of the chosen chain.
- If the same tip value is missing for two checks in a row, report equivocation. A changed tip starts the count again.
The client skips nodes that were connected and are now offline. The Go client keeps the flag until it exits. It reports the flag but does not close channels.
Pinning and quarantine
A node and a client can pin the genesis hash. See Pin Devices. A node that runs lock.init pins its new genesis.
If a node without a log sees a valid chain on the gateway, or if its disabled chain is superseded by another root, it becomes quarantined. A quarantined node refuses all channels until an operator pins a genesis with lock.pin or runs lock.localDisable. The Go client applies the same rules to itself.
The app does not use a pin configuration and has no quarantine. It pins the first valid chain that it receives, and it enforces locked mode after it has a log.
Keys in text form use a prefix and 64 hex characters: sigpub:, devpub:, gen:, dis:, and tip:.
Method reference
In the tables, Served by shows which component handles the method. The type notation uses ? for a field that can be absent. Byte arrays (bytes) are standard base64 strings in JSON.
Connection and nodes
| Method | Kind | Served by | Params | Result |
|---|---|---|---|---|
ping | request | all | none | null |
node.identify | request | node | none | IdentifyResult |
server.info | request | gateway, node | none | ServerInfo |
server.checkUpgrade | request | gateway, node | { node_id?: string } | { current: string, latest: string } |
server.upgrade | request | gateway, node | { node_id?: string } | { from: string, to: string } |
server.restart | request | gateway, node | { node_id?: string } | null |
server.stop | request | gateway, node | none | null |
nodes.list | request | gateway | none | { nodes: NodeDescriptor[] } |
node.event | notification | gateway → client | NodeEvent | — |
relay.open | request | gateway | { node_id: string } | { chan_id: string } |
relay.close | request | gateway | { chan_id: string } | null |
relay.closed | notification | gateway → node | { chan_id: string } | — |
e2e.handshake | relay notification | client ↔ node | Noise message in body | — |
IdentifyResult {
id: string // stable node id; composite-id prefix
label: string
version: string
capabilities: Capabilities
identity_pubkey?: string // base64 Curve25519
signer_pubkey?: string // base64 Ed25519 (locked mode)
tip?: bytes // trust-log tip (locked mode)
lock_disabled?: boolean // the node accepts any device
}
Capabilities {
spawn_session: boolean // the node can spawn sessions (tmux present)
terminal: boolean // the node can run persistent terminals
host_wakelock: boolean // the node serves host.setWakelock
}
ServerInfo {
version: string
started_at?: string // RFC3339; server.restart changes it
nodes: { id, label, version, capabilities }[]
pushPortConfigured: boolean
}
NodeDescriptor {
id, label, version: string
capabilities: Capabilities
identity_pubkey?: string
signer_pubkey?: string // from the gateway; discovery only
lock_disabled?: boolean // from the gateway; a hint only
online: boolean
}
NodeEvent {
type: "added" | "online" | "offline" | "removed" | "trust-changed"
node: NodeDescriptor // empty for trust-changed
}A gateway relays capability keys that it does not know unchanged, so a client can read the capabilities of a node that is newer than the gateway.
On a node's unix socket, server.info returns one entry in nodes, with an empty id.
server.upgrade downloads the latest release, checks its SHA-256 against the .sha256 release asset, and replaces the binary. It does not restart. If the binary on disk already matches the checksum, it skips the download. If to equals from, the target already runs the latest version. server.restart replies first, then shuts down and executes the binary on disk with the same arguments and the same PID. The shutdown can close the connection before the reply arrives, so only an error reply means that the restart failed. The new process reports a new started_at. A target that does not support these methods replies -32601.
server.stop replies first, then shuts the server down. A node accepts it only on its unix socket, and a gateway accepts it only on argus-gateway.sock. Relay channels and the gateway WebSocket routes reply -32601.
server.upgrade and server.restart fail with an upgrade or restart is already in progress while another one runs. server.upgrade fails with upgrade can only be run with the argus binary if the target runs from a binary with a different name.
Host
Both methods are served by the node.
| Method | Params | Result |
|---|---|---|
host.info | { node_id? } | HostInfo |
host.setWakelock | { node_id?, until? } | HostWakelock |
HostInfo {
os?: string // for example "macOS 26.0.1 (arm64)"
uptime_seconds: number
battery?: { percent: number, state: "charging" | "discharging" | "full" | "not_charging" | "unknown" }
wakelock: HostWakelock
}
HostWakelock { until?: string } // RFC 3339A wakelock keeps the machine of the node awake until until. An empty until turns the wakelock off. 9999-12-31T23:59:59Z keeps it on until a client turns it off. The wakelock stays on after a node restart. battery is absent if the machine has no battery.
host.setWakelock fails with -32600 for an invalid until, for an until in the past, and on a node without wakelock support. Use capabilities.host_wakelock to check support before the call. Other failures are -32603.
Sessions
All methods in this table are served by the node. The node serves them on the unix socket and on relay channels.
| Method | Kind | Params | Result |
|---|---|---|---|
sessions.list | request | none | Session[] |
sessions.refresh | request | none | Session[] (after a rescan) |
session.event | notification | { type, session: Session } | — |
sessions.capture | request | SessionRef | { screen: string } |
sessions.input | request | InputParams | null |
sessions.key | request | { session_id, keys: string[] } | null |
sessions.respond | request | RespondParams | null |
sessions.spawn | request | SpawnParams | { session_id, pane_id } |
sessions.resume | request | ResumeParams | { session_id } |
sessions.kill | request | SessionRef | null |
sessions.focus | request | SessionRef | null |
session.rename | request | { session_id, name } | { name, applied } |
agents.list | request | { node_id? } | { agents: AgentInfo[] } |
agent.model.list | request | { node_id?, agent } | { models: ModelInfo[] } |
session.prompt.complete | request | { session_id, trigger } | { items: PromptCompletion[] } |
host.completeDir | request | { node_id?, path } | { home, paths: string[] } |
sessions.tasks | request | SessionRef | { tasks: Task[] } |
tasks.changed | notification | { sub_id, session_id } | — |
sessions.exportBundle | request | ExportBundleParams | { filename, data: bytes } |
For a headless agent (opencode, codex), pane_id in the sessions.spawn result is an empty string. For sessions.spawn, a cwd of ~ or one that starts with ~/ is resolved against the node user's home directory. sessions.spawn and sessions.resume reject (-32600) a cwd that is relative, does not exist, is not a directory, or cannot be entered on the node. sessions.resume also rejects an empty cwd with cannot resume: session working directory is unknown.
terminal.open with the session_id of a session without a pane first creates a viewer pane. It applies the same cwd checks to the directory of the session, and fails with cannot open terminal: session working directory is unknown if the directory is unknown.
agent.model.list lists the models agent can be spawned on, in the agent's order. An agent without a model choice returns no models. An unknown agent or a failed listing is rejected (-32600). A ModelInfo.id is opaque: a client shows name and passes id back unchanged as model in sessions.spawn. sessions.spawn rejects (-32600) a model given with a command, or for an agent without a model choice.
session.prompt.complete lists the commands and skills the session's agent accepts after trigger (/, $, or @), as { name, description }. name has no trigger character. An agent without a list for the trigger returns no items. An unknown session or a failed listing is rejected (-32600).
host.completeDir returns the node user's home directory as home. paths holds the subdirectories of path's parent whose names start with path's last segment, sorted, at most 200. Each is path's parent as given, then the name and a /. Names that start with . are included only when the last segment also starts with .. A leading ~ in path names the home directory, and an empty path lists the home directory. A relative or unreadable path gives no paths.
SessionRef { session_id: string }
InputParams {
session_id: string
text: string
submit: boolean // send Enter after the text
prepare: boolean // leave copy mode and vim normal mode first
}For an api session, the node ignores submit and prepare and always submits the text. The call fails with session has no terminal control if text is empty. A session's can_prompt says whether it takes the text now. For opencode and Codex, the call fails with session is busy if the session is not idle. Claude Code takes the text while it works and queues it: it can read the text during the running turn when the session has a tmux pane, or after the turn otherwise. A slash command always runs after the turn. Every agent fails the call with session is busy while a permission, question, or plan is pending. For Claude Code, text that starts with / runs as a slash command. Claude Code takes no prompt that starts with /, so other such text fails. Codex and opencode run a prompt that is only /compact as a compaction. opencode runs /name args as its command name when name is in its command list. Codex attaches a skill input item for each $skill word, and opencode a skill part for each @skill word, that names a listed skill.
RespondParams {
session_id: string
behavior?: "allow" | "deny"
reason?: string // deny message
answers?: { [question: string]: string | string[] }
question_action?: "" | "chat" | "cancel"
notes?: { [question: string]: string } // one note per question; only for allow_notes questions
set_mode?: "acceptEdits" | "default" | "auto"
option_value?: string // echo of DecisionOption.value
request_id?: string // echo of Interaction.request_id
kind?: string // interaction kind; ignored by the node, checked by the codex service
option_index?: number // ignored by the node
text?: string // ignored by the node
}
SpawnParams {
node_id?: string // routing only
name?: string // tmux session name
cwd?: string
agent?: string // default agent if empty
command?: string // overrides agent
prompt?: string
model?: string // agent's default if empty
}
ResumeParams {
node_id?: string
agent: string
agent_session_id: string
cwd: string
}
AgentInfo { id, name, color: string, spawnable: boolean }
ModelInfo { id, name: string }
PromptCompletion { name, description: string }
Task {
id, subject: string
description?: string
active_form?: string
status: "pending" | "in_progress" | "completed"
blocks?: string[]
blocked_by?: string[]
}
ExportBundleParams {
node_id?: string
agent: string
transcript_path: string
metadata: BundleMetadata // written into the bundle manifest
}Transcripts and history
| Method | Kind | Params | Result |
|---|---|---|---|
sessions.transcriptView | request | SessionRef | { entries: Entry[] } |
sessions.toolDetail | request | { session_id, agent_id?, tool_id } | ToolDetail |
transcript.subscribe | request | TranscriptSubscribeParams | TranscriptDelta |
transcript.unsubscribe | request | { sub_id } | null |
transcript.delta | notification | TranscriptDelta | — |
sessions.historyProjects | request | none | HistoryProject[] |
sessions.historySessions | request | HistorySessionsParams | HistorySessionPage |
sessions.historyTranscript | request | { node_id?, agent?, transcript_path, agent_id? } | { entries: Entry[] } |
sessions.historyToolDetail | request | { node_id?, agent?, transcript_path, agent_id?, tool_id } | ToolDetail |
agent_id selects a subagent trace. History methods use the transcript path on the node, because past sessions have no live session id.
TranscriptSubscribeParams {
sub_id: string
session_id: string
agent_id?: string
have_entries: number
}
TranscriptDelta { sub_id: string, from_index: number, entries: Entry[], queue?: QueuedPrompt[] }
QueuedPrompt { text: string }
ToolDetail { toolInput?: string, result?: string, resultIsError?: boolean }
HistorySessionsParams {
node_id?: string
project_dir: string
limit?: number // 0 or less returns all from offset
offset?: number
}Terminals
| Method | Kind | Params | Result |
|---|---|---|---|
terminal.open | request | { term_id, session_id?, terminal_id?, cols, rows, client_pane? } | null |
terminal.input | request | { term_id, data } | null |
terminal.resize | request | { term_id, cols, rows } | null |
terminal.close | request | { term_id } | null |
terminal.output | notification | { term_id, data } | — |
terminal.exited | notification | { term_id, reason? } | — |
terminal.list | request | none | { terminals: Terminal[] } |
terminal.create | request | { node_id?, workspace_id? } | Terminal |
terminal.kill | request | { terminal_id } | null |
terminal.rename | request | { terminal_id, name } | null |
terminal.changed | notification | empty; a gateway client adds { node_id } | — |
terminal.open takes exactly one of session_id and terminal_id.
The workspace_id of terminal.create is node-local. A gateway client removes the node prefix and sends the node id in node_id.
Terminal {
id: string
name?: string
cwd: string
command: string
attached?: boolean
workspace_id?: string // the workspace it was created in
node_id?: string
node_label?: string
}Changes and commits
| Method | Params | Result |
|---|---|---|
sessions.changedFiles | SessionRef | ChangedFilesResult |
sessions.fileDiff | { session_id, path, orig_path?, rev? } | FileDiffResult |
sessions.commits | SessionRef | CommitsResult |
sessions.commitFiles | { session_id, sha } | ChangedFilesResult |
ChangedFilesResult {
root?: string
files: {
path: string
orig_path?: string
change: "added" | "modified" | "deleted" | "renamed" | "untracked"
staged: boolean
unstaged?: boolean
}[]
}
FileDiffResult {
path: string
old_content?: string // HEAD, or the side before rev
new_content?: string // working tree, or the side after rev
not_shown?: boolean // binary or too large
}
CommitsResult {
commits: { sha, short, subject, author: string, unix_sec: number }[]
unpushed?: boolean
}Projects and workspaces
| Method | Params | Result |
|---|---|---|
project.list | none | { projects: ProjectNode[] } |
project.changed | notification, empty params | — |
project.rename | { project_id, name } | { name } (as stored) |
project.setHidden | { project_id, value } | null |
project.setPinned | { project_id, value } | null |
project.forget | { project_id } | null |
project.branches | { project_id } | { branches: BranchInfo[] } |
project.prs | { project_id } | { prs: PRInfo[], truncated? } |
project.issues | { project_id } | { issues: IssueInfo[], truncated? } |
workspace.create | WorkspaceCreateParams | WorkspaceCreateResult |
workspace.remove | { workspace_id, force? } | { warning? } |
workspace.setTarget | { workspace_id, target_branch } | { target_branch } (as stored) |
workspace.runSetup | WorkspaceRef | ScriptRun |
workspace.setupLog | WorkspaceRef | { output: string } |
workspace.changedFiles | WorkspaceRef | ChangedFilesResult |
workspace.diff | WorkspaceFileParams | { path, diff?, not_shown? } |
workspace.listDir | WorkspaceFileParams | ListDirResult |
workspace.readFile | WorkspaceFileParams | { path, content?, not_shown? } |
workspace.commits | WorkspaceRef | CommitsResult |
workspace.commitFiles | { workspace_id, sha } | ChangedFilesResult |
See Projects and workspaces for the rules of each method.
ProjectNode {
id, name: string
kind: "git" | "plain"
dir: string
root?: string // empty for a bare repository
default_branch?: string
is_gone?, hidden?, pinned?: boolean
error?: string // git failed; workspaces are the last known
created_at?, last_seen_at?: string // RFC 3339
scripts?: { setup?: string, teardown?: string }
workspaces: WorkspaceNode[]
node_id?, node_label?: string // added by the client
}
WorkspaceNode {
id, dir: string
is_main?, is_gone?: boolean
branch?: string // empty for a detached HEAD
head?: string
target_branch?: string
created_at?, last_seen_at?: string
setup?: ScriptRun
}
ScriptRun {
state: "running" | "ok" | "failed"
command: string
exit_code?: number
started_at?, ended_at?: string
output_tail?: string
}
WorkspaceRef {
workspace_id: string
against?: "" | "target" // workspace.changedFiles only
}
WorkspaceFileParams {
workspace_id: string
path: string // repository-relative; "" is the root for listDir
against?: "" | "target" // diff only
orig_path?: string // diff only
rev?: string // diff only; a commit sha
}
WorkspaceCreateParams {
project_id: string
source?: "new" | "branch" | "pr" | "issue" // default "new"
branch?: string
number?: number // pr, issue
target_branch?: string
}
WorkspaceCreateResult {
workspace_id, dir: string
warning?: string
prompt?: string // issue text, for an agent spawn
setup?: string // setup command that started
setup_run?: ScriptRun // the setup's state when the reply was sent
branch?: string
target_branch?: string // absent: the repository's default branch
}
ListDirResult {
root?, path?: string
entries: { name, path: string, is_dir?, symlink?: boolean, target?: string }[]
}
BranchInfo { name: string, remote?, local?, checked_out?: boolean }
PRInfo { number, title, author?, head_branch, base_branch, url? }
IssueInfo { number, title, author?, url? }Push
| Method | Served by | Params | Result |
|---|---|---|---|
push.vapidKey | gateway | none | { key?: string } |
push.register | node | PushRegisterParams | null |
push.unregister | node | { device_id } | null |
push.test | node | { device_id } | null, or error 410 |
push.setPause | node | { device_id, paused_until? } | null |
pushport.setToken | gateway (admin) | { token } | null |
PushRegisterParams {
device_id: string // stable key; a new register replaces the old one
endpoint: string
p256dh?: string // base64url, no padding
auth?: string // base64url, no padding
paused_until?: string // RFC 3339; empty means enabled
}Client tokens
All methods in this table are served by the gateway and need an admin connection.
| Method | Params | Result |
|---|---|---|
clients.pairStart | none | { token, url } |
clients.pairAwait | { token } | { connected: boolean } |
clients.list | none | { token, created_at }[], newest first |
clients.remove | { token } | null |
In clients.list and clients.remove, token is the SHA-256 id of the token.
Trust log distribution
| Method | Kind | Direction | Params | Result |
|---|---|---|---|---|
trustlog.sync | request | node or client → gateway | { known?: bytes[], truncated? } | { entries?: bytes[], want?: bytes[], disjoint? } |
trustlog.push | request | node → gateway | { entries?: bytes[] } | null |
trustlog.changed | notification | gateway → node | { heads?: bytes[] } | — |
Each item in entries is one entry in wire form. Entries are in order, parents first.
Locked mode control
These methods are served on the unix socket only. On any other path, the node returns -32601.
| Method | Params | Result |
|---|---|---|
lock.init | { signers?, devices?, gen_disablements? } | { tip, signer_count, disablement_secrets? } |
lock.sign | { device } | { tip, changed } |
lock.revoke | { device } | { tip, changed } |
lock.addSigner | { signer } | { tip, changed } |
lock.removeSigner | { signer } | { tip, changed } |
lock.disable | { secret } | { tip, disabled } |
lock.pin | { genesis } | null |
lock.unpin | none | null |
lock.localDisable | none | null |
lock.status | none | LockStatusResult |
lock.log | none | { entries: LockLogEntry[], tip?, signers? } |
lock.revokeSignerStart | { revoked, replaces?, fork_from? } | { blob } |
lock.revokeSignerCosign | { blob } | { blob } |
lock.revokeSignerFinish | { blob } | { tip } |
gen_disablementsis a number: the count of disablement secrets to create. All other keys, hashes, secrets, and blobs arebytes.lock.initneeds the signer key of this node insigners. Signer and device keys must be 32 bytes. It returns the disablement secrets one time only.changedisfalseif the device or signer was already in the requested state.lock.disabledoes not need a trusted signer, but the node must have a signer key.
A revoke-signer blob is the wire form of a revoke-signer entry. It holds the fork point, the revoked keys, the replacements, and the co-signs so far. lock.revokeSignerStart adds the co-sign of this node. Each other signer adds a co-sign with lock.revokeSignerCosign. lock.revokeSignerFinish needs enough co-signs and appends the entry unchanged.
LockStatusResult {
enabled: boolean
tip?: bytes
signers?: bytes[]
device_count: number
devices?: bytes[]
length?: number
signer_trusted: boolean
authorized: boolean
signer_pubkey?: bytes
identity_pubkey?: bytes
disabled?, local_disabled?, pinned?, quarantined?: boolean
pin_genesis?: bytes
seen_genesis?: bytes // genesis that caused the quarantine
pin_source?: "config" | "file"
}
LockLogEntry {
index: number
kind: "genesis" | "add-signer" | "remove-signer" | "authorize-device"
| "revoke-device" | "revoke-signer" | "disable"
hash?: bytes
target?: bytes
signers?: bytes[] // genesis
revoked?: bytes[] // revoke-signer
replaces?: bytes[] // revoke-signer
cosign_count?: number // revoke-signer
}Data types
Session and API types use snake_case field names. Transcript types use camelCase field names. ServerInfo.pushPortConfigured and the fields of ToolDetail are also camelCase.
Session
Session {
id: string
agent: string // for example "claude"
agent_session_id?: string
name?: string
tmux: {
server: "default" | "argus"
pane_id: string // for example "%3"
session_name: string
window_index: number
current_path: string
}
cwd?: string
transcript_path?: string
status: "discovered" | "starting" | "working" | "awaiting_input" | "idle" | "dead"
status_label?: string // "awaiting" for awaiting_input, else the status
status_since?: string // RFC 3339; when status last changed
source: "discovered" | "spawned" | "hooked"
frontend?: "tmux" | "vscode" | "external"
input_mode?: "pane" | "api"
can_open_terminal?: boolean
can_prompt?: boolean // takes a prompt now; see sessions.input
repo?: string
branch?: string // short sha for a detached HEAD
workspace_id?: string
summary?: Summary
interaction?: Interaction // present only while the session waits for input
subagents?: RunningSubagent[] // its subagents running now
shells?: RunningShell[] // its background shells running now
node_id?, node_label?: string // added by the client
offline?: boolean // added by the client
}
RunningSubagent {
id: string
type?, name?, desc?: string
since?: string // RFC 3339 UTC; when the node first saw it running
}
RunningShell {
id: string // the agent's background task id
tool_use_id?: string // the call that started it; see sessions.toolDetail
command?, desc?: string
since?: string // RFC 3339 UTC; when the node first saw it running
}
Summary {
model_name?, model_color?: string
effort?: string // reasoning effort, e.g. "high"
has_context?: boolean
context_pct?: number // 0 to 100
tokens?: number
task?: string
last_activity?: string // RFC 3339
}
Interaction {
kind: "permission" | "question" | "plan" | "idle"
message?: string
tool_name?: string
tool_input?: string
questions?: {
header?, question?: string
multi_select?: boolean
options?: string[]
option_descriptions?: string[] // same order as options
option_previews?: string[] // same order as options
allow_notes?: boolean // the question accepts one free-text note (Codex)
}[]
plan?: string
options?: { label, value: string, reject?: boolean, placeholder?: string }[]
cancel_interrupts?: boolean // questions cannot be declined: cancel interrupts the turn, no "chat" (Codex)
allow_unanswered?: boolean // questions may be submitted with some or all unanswered (Codex)
request_id?: string // the pending request this shows; echo it in RespondParams.request_id (Codex)
}subagents is the session's top-level subagents that are running, as its agent last reported them: each report replaces the set, and the set ends with the session. A subagent keeps its since across reports.
shells is the same for the background shells that the session's main loop started. A subagent's shells are not listed.
History
HistoryProject {
project_dir, cwd: string
repo?: string
label: string
session_count: number
last_activity: string
node_id?, node_label?: string
missing?: boolean // cwd no longer exists on the node
}
HistorySessionPage {
items: {
session_id: string
agent?: string
resumable?: boolean
title?, first_message?: string
transcript_path: string
model_name?, model_color?: string
last_activity: string
tokens?, turn_count?, duration_ms?: number
node_id?, node_label?: string
}[]
has_more: boolean
}Transcript
Entry {
id: string
kind: "user" | "thinking" | "text" | "tool" | "subagent" | "skill"
| "turn_end" | "system" | "compact" | "shell"
timestamp?, text?: string
signature?: boolean
toolName?, toolId?: string
inputPreview?: string
resultIsError?: boolean
subagents?: {
id: string
name?, type?, desc?, status?, color?: string
isTeammate?, idle?, hasTrace?: boolean
trace?: Entry[] // history only
}[]
// turn_end only: emitted once a turn has finished
modelName?, modelColor?: string
usage?: { input?, output?, cacheRead?, cacheCreation?: number }
stopReason?: string
durationMs?: number
thinking?, toolCount?: number
interrupted?: boolean
hasContext?: boolean
contextPct?, contextFirstPct?: number
contextDeltaTokens?: number
// system / compact / shell
summary?, label?, detail?: string
isError?: boolean
}Entry never carries toolInput or result on the wire. Use sessions.toolDetail or sessions.historyToolDetail to get them.
The numbers on turn_end come from the agent's own logs; the node does not count tokens.
usage.outputis the output the turn generated, summed over its API calls, reasoning included.usage.input,usage.cacheRead, andusage.cacheCreationare from the turn's last API call, so they describe the current context.durationMsis how long the turn ran.- A field the agent does not record is omitted. Antigravity records no usage or duration.
Bundle metadata
BundleMetadata {
title?, model_name?, model_color?, cwd?, repo?: string
git_user_name?, git_user_email?, first_message?: string
tokens?, turn_count?, duration_ms?: number
last_activity?: string
}