Skip to content

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 ​

ComponentWhat it does
nodeRuns on each machine. Discovers agent sessions, drives tmux, and serves the API.
gatewayListens on HTTP and relays traffic between clients and nodes. It can run with a node or alone.
clientThe 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── node

On 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:

  1. Line framing over a WebSocket byte stream. See Framing.
  2. JSON-RPC 2.0 requests, responses, and notifications in both directions.
  3. The roster: the node.event stream from the gateway.
  4. One relay channel per online node. See Relay channels.
  5. Noise IK and sealed records, if the client supports E2EE mode. See End-to-end encryption.
  6. Composite ids, and routing of each call to the correct node. See Aggregation.
  7. The feature methods that the client needs. See Features.
  8. 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:

RouteWho connectsToken accepted
/nodenodesthe master token only
/clientclientsthe 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] or wss://.... The Go client appends /node or /client after 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 runs ssh -W to 127.0.0.1:N on the gateway host and speaks WebSocket through that tunnel. port is 8443 by 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:

ts
{
  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:

ClassRule
requesthas method and id
notificationhas method and no id
responsehas id and no method
relay framehas 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 ​

CodeNameUse
-32700parse errorThe line is not valid JSON.
-32600invalid requestA refused operation. Some handlers also use it for bad params.
-32601method not foundThe receiver does not serve the method on this connection.
-32603internal errorAny other handler failure.
410push target gonepush.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:

http
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 --token value ($ARGUS_TOKEN). Nodes must use it on /node. A client that uses it on /client is an admin connection. If --token is empty, /node accepts any token.
  • Client token. The gateway issues it during pairing. It is exactly 64 lowercase hex characters. It works only on /client and 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:

  1. Open the WebSocket to <gateway>/client with the token.
  2. Receive the roster. The gateway sends one node.event of type added for each node in the roster, then one node.event for each roster change.
  3. For each node with online: true, call relay.open. Skip a node without identity_pubkey if you use E2EE mode.
  4. In E2EE mode, do the Noise handshake on the channel. See Handshake.
  5. Call node.identify on the channel. In locked mode, record the tip.
  6. Receive the session snapshot. The node sends one session.event of type added for each current session, then live events.
  7. 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, not added.
  • A node that disconnects produces offline. After 30 seconds, the gateway removes it and sends removed.
  • trust-changed tells the client to sync the trust log. Its node field 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:

  1. 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. url can be empty if the gateway has no public URL.

  2. 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.

  3. The admin client calls clients.pairAwait with the token. The call blocks until a device connects with the token, or until 60 seconds pass.

  4. The new device connects to /client with the token. The gateway stores the token and wakes the waiting clients.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:

json
{"jsonrpc":"2.0","id":7,"method":"relay.open","params":{"node_id":"home"}}

The gateway answers with a channel id:

json
{"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.

ErrorCause
-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 clientThe client already holds 64 channels.

Route header ​

ts
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).

FrameCleartext fieldsbody holds
requestid, method, routeseal(params)
responseid, routeseal({"result": ...}) or seal({"error": ...})
notificationmethod, routeseal(params)

A request:

json
{"jsonrpc":"2.0","id":42,"method":"sessions.list","route":{"chan_id":"c12","node_id":"home"},"body":"<base64>"}

The response to it:

json
{"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. seal does nothing. body is the base64 of the inner JSON.
  • E2EE mode. seal encrypts 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 body of 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_id that are not e2e.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.close with the chan_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 ​

KeyTypeLifetime
node identityCurve25519stored by the node
client identityCurve25519Go client: stored in locked mode, else one key per process. App: stored.
node signerEd25519stored 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 ​

ItemValue
ProtocolNoise_IK_25519_ChaChaPoly_BLAKE2s
Initiatorthe client
PayloadsBoth 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 ​

  1. The client calls relay.open and gets a chan_id.

  2. The client builds Noise message 1 and sends it as a relay notification. body is 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)>"}
  3. 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_id already exists.
  4. Otherwise, the node sends message 2 in the same frame shape.

  5. The node starts the session.event stream on the channel.

  6. 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, then 0x01 for the last record or 0x00 for 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:

FormUse
<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:

  • id and workspace_id of each session, in sessions.list, sessions.refresh, and session.event
  • id of each project and workspace in project.list
  • session_id in the results of sessions.spawn and sessions.resume
  • workspace_id in the result of workspace.create
  • session_id in tasks.changed
  • id and workspace_id of each terminal in terminal.list and in the result of terminal.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:

RuleMethods
All nodes; merge resultssessions.list, sessions.refresh, sessions.historyProjects, project.list, terminal.list
All nodes; success if one node succeedspush.register, push.unregister, push.test, push.setPause
Node from composite session_idsessions.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_idterminal.kill, terminal.rename, terminal.open with terminal_id
Node from composite workspace_idworkspace.changedFiles, workspace.diff, workspace.listDir, workspace.readFile, workspace.commits, workspace.commitFiles, workspace.remove, workspace.setTarget, workspace.runSetup, workspace.setupLog
Node from composite project_idworkspace.create, project.rename, project.setHidden, project.setPinned, project.forget, project.branches, project.prs, project.issues
Node from node_id paramsessions.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_idtranscript.unsubscribe, terminal.input, terminal.resize, terminal.close
Node from node_id param, or the gateway when node_id is emptyserver.checkUpgrade, server.upgrade, server.restart
The gatewayping, 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:

ErrorCause
-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_idnode_id is empty and more than one node is connected.
-32600 <method> requires a handle idsub_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:

ts
{ 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 with session 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 ​

  1. The client picks a sub_id and calls transcript.subscribe with the number of entries it already holds (have_entries).
  2. The result is a TranscriptDelta that brings the client up to date.
  3. The node reads the transcript every second. If it changed, the node sends a transcript.delta notification.
  4. The client calls transcript.unsubscribe with the sub_id to 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, or shell entry 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_index can 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_id replaces the first. All subscriptions end when the connection closes.
  • Set agent_id to follow a subagent transcript.
  • If the agent moves the session to a new transcript file, for example after /resume or /clear in Claude Code, the node follows it. The node sends a delta with from_index: 0 and every entry of the new file. This applies only to subscriptions without agent_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 ​

  1. The client picks a term_id and calls terminal.open with the size and either a session_id or a terminal_id.
  2. The node sends terminal.output notifications.
  3. The client sends keystrokes with terminal.input.
  4. The client sends terminal.resize when its size changes.
  5. The client calls terminal.close to 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.

  • data is 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.open with the same term_id replaces 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.close does not send terminal.exited.

The node sends terminal.exited when the terminal ends:

reasonMeaning
exitedThe process ended, or the session left the registry. An empty reason means the same.
evictedA 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.create starts the default shell in the node user's home directory. With a node-local workspace_id, it starts the shell in the directory of that workspace, and the terminal carries that workspace_id. If the workspace directory no longer exists, the call fails with workspace directory is gone: <dir>.
  • terminal.list returns the terminals in creation order. cwd shows the node user's home as ~. name is empty until a rename. A node without capabilities.terminal returns an empty list.
  • A shell stays alive after terminal.close and after a node restart.
  • terminal.kill stops the shell. An open attach then gets terminal.exited with exited.
  • The node sends terminal.changed after 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 next terminal.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_pane when 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.forget forgets the project and keeps its files. It fails if the project has live sessions.
  • workspace.remove fails for the main worktree and for a workspace with live sessions. Without force, it fails for uncommitted changes and for a teardown script failure. With force, it removes the workspace and returns a teardown failure as warning.
  • against: "target" has an effect only on workspace.changedFiles.
  • workspace.commits always 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 for workspace.listDir. workspace.diff and workspace.readFile fail with path 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:

  1. Call push.vapidKey on the gateway to get the VAPID public key. An empty key means that Web Push is not available.
  2. Subscribe with a push distributor (UnifiedPush or PushPort).
  3. Call push.register with a stable device_id, the endpoint, and the subscription keys. The client sends this call to every node. p256dh and auth are base64url without padding.
  4. 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:

json
{"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 valueKindPayload
1genesisSigners, Disablements
2add-signerKey = signer public key
3remove-signerKey = signer public key
4authorize-deviceKey = device public key
5revoke-deviceKey = device public key
6disableKey = disablement secret
7revoke-signerSigners = 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.

ItemValue
HashBLAKE2s-256 over the wire form of the entry
SignatureEd25519 over the signed bytes. Signer is a 32-byte key.
LinkPrev is the hash of the previous entry. It is empty for genesis.
Genesis hashthe 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 a Signer from its own Signers.
  • 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, and revoke-device need a non-empty Key and a signature from a trusted signer.
  • authorize-device fails if the device is already authorized.
  • remove-signer fails if the key is not a signer or if it is the last signer.
  • A disable entry is valid if the Argon2id commitment of its secret is in the genesis Disablements. 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) and Replaces must 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 Replaces is not empty.
  • At least one signer must remain, including the keys in Replaces.
  • Prev is 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:

  1. A disabled chain beats a chain that is not disabled.
  2. If one chain is a prefix of the other, the longer chain wins.
  3. Otherwise, compare only the first different entry of each chain. The entry with more weight wins. The weight of a revoke-signer entry is its number of valid co-signs from signers that were trusted at the fork. The weight of any other entry is 1 if its signer was trusted at the fork.
  4. On a tie, a removal (remove-signer or revoke-signer) wins.
  5. 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 sets truncated.
  • The gateway returns every entry that is not in known, parents first.
  • want lists hashes in known that the gateway does not hold. A node answers with trustlog.push. A client ignores want.
  • disjoint is true if known is 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:

  1. After each trust sync, call node.identify on each channel and record tip.
  2. Look up each tip in the set of all entry hashes of the chosen chain.
  3. 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 ​

MethodKindServed byParamsResult
pingrequestallnonenull
node.identifyrequestnodenoneIdentifyResult
server.inforequestgateway, nodenoneServerInfo
server.checkUpgraderequestgateway, node{ node_id?: string }{ current: string, latest: string }
server.upgraderequestgateway, node{ node_id?: string }{ from: string, to: string }
server.restartrequestgateway, node{ node_id?: string }null
server.stoprequestgateway, nodenonenull
nodes.listrequestgatewaynone{ nodes: NodeDescriptor[] }
node.eventnotificationgateway → clientNodeEvent—
relay.openrequestgateway{ node_id: string }{ chan_id: string }
relay.closerequestgateway{ chan_id: string }null
relay.closednotificationgateway → node{ chan_id: string }—
e2e.handshakerelay notificationclient ↔ nodeNoise message in body—
ts
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.

MethodParamsResult
host.info{ node_id? }HostInfo
host.setWakelock{ node_id?, until? }HostWakelock
ts
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 3339

A 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.

MethodKindParamsResult
sessions.listrequestnoneSession[]
sessions.refreshrequestnoneSession[] (after a rescan)
session.eventnotification{ type, session: Session }—
sessions.capturerequestSessionRef{ screen: string }
sessions.inputrequestInputParamsnull
sessions.keyrequest{ session_id, keys: string[] }null
sessions.respondrequestRespondParamsnull
sessions.spawnrequestSpawnParams{ session_id, pane_id }
sessions.resumerequestResumeParams{ session_id }
sessions.killrequestSessionRefnull
sessions.focusrequestSessionRefnull
session.renamerequest{ session_id, name }{ name, applied }
agents.listrequest{ node_id? }{ agents: AgentInfo[] }
agent.model.listrequest{ node_id?, agent }{ models: ModelInfo[] }
session.prompt.completerequest{ session_id, trigger }{ items: PromptCompletion[] }
host.completeDirrequest{ node_id?, path }{ home, paths: string[] }
sessions.tasksrequestSessionRef{ tasks: Task[] }
tasks.changednotification{ sub_id, session_id }—
sessions.exportBundlerequestExportBundleParams{ 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.

ts
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.

ts
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 ​

MethodKindParamsResult
sessions.transcriptViewrequestSessionRef{ entries: Entry[] }
sessions.toolDetailrequest{ session_id, agent_id?, tool_id }ToolDetail
transcript.subscriberequestTranscriptSubscribeParamsTranscriptDelta
transcript.unsubscriberequest{ sub_id }null
transcript.deltanotificationTranscriptDelta—
sessions.historyProjectsrequestnoneHistoryProject[]
sessions.historySessionsrequestHistorySessionsParamsHistorySessionPage
sessions.historyTranscriptrequest{ node_id?, agent?, transcript_path, agent_id? }{ entries: Entry[] }
sessions.historyToolDetailrequest{ 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.

ts
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 ​

MethodKindParamsResult
terminal.openrequest{ term_id, session_id?, terminal_id?, cols, rows, client_pane? }null
terminal.inputrequest{ term_id, data }null
terminal.resizerequest{ term_id, cols, rows }null
terminal.closerequest{ term_id }null
terminal.outputnotification{ term_id, data }—
terminal.exitednotification{ term_id, reason? }—
terminal.listrequestnone{ terminals: Terminal[] }
terminal.createrequest{ node_id?, workspace_id? }Terminal
terminal.killrequest{ terminal_id }null
terminal.renamerequest{ terminal_id, name }null
terminal.changednotificationempty; 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.

ts
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 ​

MethodParamsResult
sessions.changedFilesSessionRefChangedFilesResult
sessions.fileDiff{ session_id, path, orig_path?, rev? }FileDiffResult
sessions.commitsSessionRefCommitsResult
sessions.commitFiles{ session_id, sha }ChangedFilesResult
ts
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 ​

MethodParamsResult
project.listnone{ projects: ProjectNode[] }
project.changednotification, 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.createWorkspaceCreateParamsWorkspaceCreateResult
workspace.remove{ workspace_id, force? }{ warning? }
workspace.setTarget{ workspace_id, target_branch }{ target_branch } (as stored)
workspace.runSetupWorkspaceRefScriptRun
workspace.setupLogWorkspaceRef{ output: string }
workspace.changedFilesWorkspaceRefChangedFilesResult
workspace.diffWorkspaceFileParams{ path, diff?, not_shown? }
workspace.listDirWorkspaceFileParamsListDirResult
workspace.readFileWorkspaceFileParams{ path, content?, not_shown? }
workspace.commitsWorkspaceRefCommitsResult
workspace.commitFiles{ workspace_id, sha }ChangedFilesResult

See Projects and workspaces for the rules of each method.

ts
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 ​

MethodServed byParamsResult
push.vapidKeygatewaynone{ key?: string }
push.registernodePushRegisterParamsnull
push.unregisternode{ device_id }null
push.testnode{ device_id }null, or error 410
push.setPausenode{ device_id, paused_until? }null
pushport.setTokengateway (admin){ token }null
ts
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.

MethodParamsResult
clients.pairStartnone{ token, url }
clients.pairAwait{ token }{ connected: boolean }
clients.listnone{ 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 ​

MethodKindDirectionParamsResult
trustlog.syncrequestnode or client → gateway{ known?: bytes[], truncated? }{ entries?: bytes[], want?: bytes[], disjoint? }
trustlog.pushrequestnode → gateway{ entries?: bytes[] }null
trustlog.changednotificationgateway → 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.

MethodParamsResult
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.unpinnonenull
lock.localDisablenonenull
lock.statusnoneLockStatusResult
lock.lognone{ entries: LockLogEntry[], tip?, signers? }
lock.revokeSignerStart{ revoked, replaces?, fork_from? }{ blob }
lock.revokeSignerCosign{ blob }{ blob }
lock.revokeSignerFinish{ blob }{ tip }
  • gen_disablements is a number: the count of disablement secrets to create. All other keys, hashes, secrets, and blobs are bytes.
  • lock.init needs the signer key of this node in signers. Signer and device keys must be 32 bytes. It returns the disablement secrets one time only.
  • changed is false if the device or signer was already in the requested state.
  • lock.disable does 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.

ts
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 ​

ts
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 ​

ts
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 ​

ts
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.output is the output the turn generated, summed over its API calls, reasoning included.
  • usage.input, usage.cacheRead, and usage.cacheCreation are from the turn's last API call, so they describe the current context.
  • durationMs is how long the turn ran.
  • A field the agent does not record is omitted. Antigravity records no usage or duration.

Bundle metadata ​

ts
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
}