Venom
RFC-0002venom-spec

Wire protocol (socket, framing, NDJSON verbs, errors)

Field Value
Status Accepted
Authors commixon (commixon)
Created 2026-11-06
Accepted 2026-11-06, approved by the maintainer
Linear COM-201
Supersedes None
Superseded by None

Summary

This RFC defines the one local protocol that every venom daemon speaks: where its Unix socket lives and how it is protected, how a stale socket is detected, how the first byte of a connection selects between NDJSON control and binary frames, the request/response/error grammar of the NDJSON control protocol, the generic binary frame envelope, how frame types are allocated, how error codes map to CLI exit codes, and how peers negotiate versions and capabilities with hello.

It is implemented once, in venom-std’s proto module (COM-206, COM-207), and every product (venomx, venom, venom-desk, venom-mem, …) uses that module. Pane-stream semantics for venomx (snapshot → ready → stream → resync) are not defined here; they are RFC-0006 (COM-221), built on the envelope below. The event envelope is RFC-0003 (COM-202) and caller identity and grants are RFC-0005 (COM-204); both ride on this protocol.

Motivation

The agent contract (docs/ecosystem.md, section 2) says the daemon and its CLI are one binary and that every action is a verb. For that to work, the CLI, the generated MCP server, the TUI clients and other daemons all need to reach a daemon in the same way. Without one written protocol each repo would invent its own socket path, framing and error shape, and the generated CLI (RFC-0004, COM-203) could not map errors to stable exit codes.

Waiting on this RFC:

  • venom-std proto: COM-205/COM-206 (socket server/client, sniffing), COM-207 (NDJSON verbs), then the toy daemon venom-echo (COM-216), which is the P0 exit criterion.
  • RFC-0003 (events), RFC-0005 (grants), RFC-0006 (pane stream), which all extend it.

Goals, in order: easy to debug with socat and jq; one socket per daemon; a fast binary path for byte streams (PTY output); room for remote transport later without changing the bytes.

Design

1. Overview by example

A complete session against the toy daemon, with socat:

printf '%s\n' \
  '{"id":1,"verb":"hello","params":{"proto":[1],"client":{"name":"socat","version":"0"}}}' \
  '{"id":2,"verb":"echo","params":{"text":"hi"}}' \
  '{"id":3,"verb":"echo","params":{}}' \
  | socat - UNIX-CONNECT:"$XDG_RUNTIME_DIR/venom/venom-echo.sock"

Output (one JSON object per line; the server name and version are illustrative):

{"id":1,"ok":{"proto":1,"server":{"name":"venom-echo","version":"0.1.0"},"caps":["cancel"],"max_frame":1048576}}
{"id":2,"ok":{"text":"hi"}}
{"id":3,"error":{"code":"invalid_params.missing","message":"missing required param 'text'","data":{"param":"text"}}}

The same failure through the generated CLI:

$ venom-echo echo --json
{"error":{"code":"invalid_params.missing","message":"missing required param 'text'","data":{"param":"text"}}}
$ echo $?
2

2. Socket location

2.1 Tool names

Every daemon has a tool name matching ^[a-z][a-z0-9-]{0,31}$, for example venomx, venom, venom-mem, venom-echo. It is the binary’s name and is used in paths, environment variables, capability names and error codes.

2.2 Default path

$XDG_RUNTIME_DIR/venom/<tool>.sock      socket
$XDG_RUNTIME_DIR/venom/<tool>.lock      lock file (section 2.5)

If XDG_RUNTIME_DIR is unset or empty, the runtime directory is ${TMPDIR:-/tmp}/venom-<uid>/ instead (this is the usual case on macOS). <uid> is the decimal real user ID.

2.3 Overrides

The socket path is resolved in this order; the first one set wins:

  1. The --socket <path> flag (accepted by serve and by every client verb).
  2. The environment variable <TOOL>_SOCKET, where <TOOL> is the tool name upper-cased with - replaced by _: VENOMX_SOCKET, VENOM_SOCKET, VENOM_MEM_SOCKET, VENOM_ECHO_SOCKET.
  3. The default path from 2.2.

The lock file is always the socket path with its .sock suffix replaced by .lock (or with .lock appended if the override has no .sock suffix).

A resolved path that does not fit in sockaddr_un.sun_path (107 bytes on Linux, 103 on macOS, excluding the NUL) MUST fail with unavailable.path_too_long (exit 6) rather than being truncated. doctor SHOULD suggest a shorter override.

2.4 Permissions

  • The runtime directory (…/venom/ or venom-<uid>/) MUST be created with mode 0700. Before binding or connecting, both daemon and client MUST check, with lstat, that it is a directory (not a symlink), owned by the current UID, with no group or other permission bits. If not, the daemon MUST refuse to start and the client MUST refuse to connect, with denied.insecure_path (exit 4).
  • The daemon MUST create the socket with mode 0600 (set umask(0177) around bind, then chmod, so there is no window where it is wider).
  • On every accepted connection the daemon MUST read the peer credentials (SO_PEERCRED on Linux, getpeereid on BSD/macOS) and close the connection without a reply if the peer UID differs from its own. Finer identity (which pane or agent is calling) is RFC-0005.

2.5 Startup, stale sockets and shutdown

A socket file left by a daemon that crashed is stale. Staleness is decided by the lock file, never by guessing from the socket:

serve:
  1. mkdir -p runtime dir (0700) and verify it (2.4)
  2. open(<tool>.lock, O_RDWR|O_CREAT|O_CLOEXEC, 0600)
     flock(LOCK_EX|LOCK_NB)
       EWOULDBLOCK → another daemon is live → exit with conflict.already_running (5)
  3. holding the lock:
       if <tool>.sock exists:
         lstat it; if it is not a socket → refuse with conflict.not_a_socket (5)
         else it is stale → unlink it
  4. bind (umask 0177), chmod 0600, listen
  5. write own PID + "\n" into the lock file (informational, for doctor)
  6. serve until told to stop
shutdown (clean):
  7. stop accepting, close connections (binary: `CLOSE` frame first, 5.2)
  8. unlink <tool>.sock, then close the lock file (releases the flock)

The kernel releases the flock when the daemon dies for any reason, so a crash leaves a socket file but no lock, and the next serve cleans it up. Two serve processes racing are serialised by step 2.

Clients just connect. ENOENT and ECONNREFUSED both mean no daemon is running: unavailable.not_running (exit 6). Whether a CLI verb auto-starts the daemon is decided by RFC-0001 (see Open question 1), not here.

3. Connection and first-byte selection

A connection is a reliable, ordered byte stream. The client speaks first, and its first byte selects the protocol for the whole connection:

First byte Protocol Section
0x7B ({) NDJSON control 4
0xF5 Binary frames, envelope v1 5
0xF6–0xFF Reserved for future envelope versions 6.5
anything else Rejected —

0xF5–0xFF never occur in UTF-8 text, so a binary connection can never be mistaken for JSON. For NDJSON the { is not consumed: it is the first byte of the first JSON object. For binary the 0xF5 is consumed and frames follow.

The binary preamble byte is the envelope version. If the frame header layout ever changes, the new layout gets the next byte (0xF6), so a server can refuse an envelope it doesn’t know before it tries to parse a header.

On a rejected first byte the server MUST write one NDJSON line and close:

{"id":null,"error":{"code":"protocol.bad_preamble","message":"first byte must be '{' (NDJSON) or 0xF5 (binary)","data":{"byte":71,"supported":[123,245]}}}

data.supported lists every first byte the server accepts, so a client offering a newer envelope (for example 0xF6) learns which older one to fall back to.

The server SHOULD close a connection that sends no byte within 10 seconds of accept, and one that has not completed hello (when hello is required) within 10 seconds of its first byte.

Connections are independent. A client that needs both a byte stream and verbs MAY open two connections, or use CONTROL frames (5.2) on one binary connection.

4. NDJSON control protocol

4.1 Encoding

  • Each message is one JSON object (RFC 8259) encoded as UTF-8 without a BOM, followed by a single \n (0x0A). Senders MUST NOT put a raw newline inside a message (JSON escapes newlines in strings, so this only rules out pretty-printing). Senders MUST NOT send \r\n; receivers MUST ignore whitespace before the \n.
  • A message, including its \n, MUST NOT exceed the connection’s max_frame (default 1 MiB = 1048576 bytes, negotiable in hello). A receiver that sees a longer line MUST reply protocol.too_large and close.
  • Integers MUST lie within ±(2^53−1) so every JSON parser reads them exactly. Larger values (rare; byte offsets that large are not expected) are sent as decimal strings. Binary data inside JSON is standard padded base64, but bulk bytes belong on binary frames.
  • Unknown object members MUST be ignored by receivers. This is the main forward-compatibility rule.

4.2 Message kinds

The kind of a message is decided by its members:

Kind Direction Shape
Request client → server {"id":…, "verb":"…", "params":{…}}
Item server → client {"id":…, "item":…}
Response (success) server → client {"id":…, "ok":…}
Response (error) server → client {"id":…, "error":{"code":"…","message":"…","data":{…}}}
  • id is a JSON integer or a string of at most 64 bytes, chosen by the client. It MUST be unique among the client’s in-flight requests on that connection. The server echoes it unchanged. id: null appears only in errors about a message whose id could not be read.
  • verb is the verb name from the tool’s verb schema (RFC-0004), for example pane.split or echo. Names are ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$.
  • params is an object. It MAY be omitted, which means {}.
  • ok is the verb’s result: any JSON value, including null. A response has exactly one of ok and error.
  • item carries one element of a streamed result (4.4).

In v1 the server MUST NOT send requests to the client. A later version may add server → client requests (for example approvals) behind a capability.

4.3 Concurrency

A client MAY send several requests without waiting (pipelining). The server MAY process them concurrently and MAY respond out of order; clients match responses to requests by id. For any one request, its items are delivered in order and before its final response, and exactly one final response is sent.

A server MUST NOT let a slow reader stall other connections. If a connection’s unsent output exceeds a server-chosen limit (RECOMMENDED: 8 MiB), the server MAY close it; event subscribers recover with cursor replay (RFC-0003).

4.4 Streamed results and cancellation

A verb whose schema marks it as streaming (RFC-0004) sends zero or more item messages and then one final response. events (RFC-0003) and run --wait with output are the expected users. A subscription that never ends naturally ends when the client cancels it or closes the connection.

C→S {"id":7,"verb":"events","params":{"types":["pane.*"]}}
S→C {"id":7,"item":{"seq":41,"type":"pane.created","data":{"pane":"p3"}}}
S→C {"id":7,"item":{"seq":42,"type":"pane.exited","data":{"pane":"p3","status":0}}}
C→S {"id":8,"verb":"cancel","params":{"id":7}}
S→C {"id":7,"error":{"code":"cancelled","message":"cancelled by client"}}
S→C {"id":8,"ok":null}

(The item payload above is a placeholder; the event envelope is RFC-0003.)

4.5 Core verbs

Every daemon implements these three verbs in proto, outside its own verb schema. Every other verb, including events, skill, doctor and config …, is defined by RFC-0001, RFC-0003 or the tool.

Verb Params Result Notes
hello see 6.1 see 6.2 At most once per connection, and before any other request if sent
cancel {"id": <request id>} null Ends the target request with error cancelled. Cancelling an id that is not in flight returns not_found.request. Requires capability cancel.
ping {} {} Liveness check; never needs a grant

4.6 Malformed input

Input Server action
Line is not valid JSON, or not an object Reply {"id":null,"error":{"code":"protocol.parse",…}}, keep the connection
Object without a usable id Reply with id: null, code protocol.invalid_request, keep the connection
verb missing or not a string protocol.invalid_request, keep the connection
Verb not in the schema unsupported.verb
Params fail schema validation invalid_params or a refinement (invalid_params.missing, …)
Duplicate in-flight id protocol.duplicate_id for the new request; the old one is unaffected
Line longer than max_frame protocol.too_large, then close

Because messages are newline-delimited, a bad line never desynchronises the stream; the server resumes at the next \n.

5. Binary frames

5.1 Envelope

After the 0xF5 preamble byte, both directions are a sequence of frames. All integers are big-endian (network byte order), whatever the byte order of the host. Implementations MUST NOT send host-order integers: the same bytes have to work across machines once remote transport exists (section 8).

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-------------------------------+-------------------------------+
|          type (u16)           |        stream (u32) ...       |
+-------------------------------+-------------------------------+
|      ... stream (cont.)       |        length (u32) ...       |
+-------------------------------+-------------------------------+
|      ... length (cont.)       |   payload (length bytes) ...  |
+-------------------------------+-------------------------------+
Field Size Meaning
type u16 Frame type (5.2). 0x0000 is invalid.
stream u32 Stream the frame belongs to. 0 is the connection itself.
length u32 Payload length in bytes. MUST NOT exceed max_frame (default 1 MiB; hard ceiling 16 MiB).
payload length Type-specific.

The header is 10 bytes. There is no padding, checksum or flags field: the transport is reliable, and per-type flags belong in the payload.

Streams. A stream is a tool-defined sub-channel (for venomx, typically one pane). The tool’s RFC defines how a stream is opened and what flows on it. Stream IDs are allocated by the side that opens the stream: odd IDs by the client, even non-zero IDs by the server, so the two sides never collide. An ID is not reused on the same connection after its stream is closed.

5.2 Frame type allocation

Range Owner Rule
0x0000 — Invalid; receiving it is protocol.bad_frame
0x0001–0x00FF Core, this RFC Same meaning on every daemon
0x0100–0xEFFF Tool Meaning is scoped to one tool’s socket; each tool defines its types in an RFC and lists them in the registry below
0xF000–0xFFFF Experimental Never in a release build; MUST NOT be sent unless both peers advertised the same experimental capability

Because tool types are scoped to the tool’s socket, venomx and venom may both use 0x0100 with different meanings. A peer MUST NOT send a tool frame type unless the capability that defines it is in the negotiated set (6.3). Receiving an unknown core type, or a tool type outside the negotiated capabilities, is protocol.bad_frame and closes the connection.

Core frame types (v1):

Type Name Stream Payload
0x0001 HELLO 0 JSON: hello params (6.1). MUST be the client’s first frame.
0x0002 HELLO_OK 0 JSON: hello result (6.2)
0x0003 ERROR any JSON: an error object (7). On stream 0 the sender closes the connection after it; on stream n it closes stream n.
0x0004 PING 0 Opaque, ≤ 64 bytes
0x0005 PONG 0 Copy of the PING payload
0x0006 CLOSE any JSON, optional: {"reason":"…"}. Stream 0: the sender is closing the connection (graceful shutdown). Stream n: the sender will send nothing more on n.
0x0010 CONTROL 0 One NDJSON message (4.2) without the trailing \n. Gives a binary connection the full verb protocol.
0x0007–0x000F, 0x0011–0x00FF reserved Future core use

Tool frame type registry (updated by the RFC that defines each range):

Tool Types Capability Defined in
venomx 0x0100–0x0109 ATTACH … EXIT; 0x010A–0x01FF reserved venomx.pane_stream RFC-0006 (COM-221)
venom-echo 0x0100 ECHO_BYTES venom-echo.bytes COM-216 (conformance toy, illustrative below)

5.3 Byte trace: binary connection

Client connects, sends the preamble and HELLO:

# C→S preamble 0xF5 + HELLO (type 0x0001, stream 0, length 0x63 = 99)
00000000  f5 00 01 00 00 00 00 00 00 00 63 7b 22 70 72 6f  |..........c{"pro|
00000010  74 6f 22 3a 5b 31 5d 2c 22 63 6c 69 65 6e 74 22  |to":[1],"client"|
00000020  3a 7b 22 6e 61 6d 65 22 3a 22 76 65 6e 6f 6d 2d  |:{"name":"venom-|
00000030  65 63 68 6f 22 2c 22 76 65 72 73 69 6f 6e 22 3a  |echo","version":|
00000040  22 30 2e 31 2e 30 22 7d 2c 22 63 61 70 73 22 3a  |"0.1.0"},"caps":|
00000050  5b 22 63 61 6e 63 65 6c 22 2c 22 76 65 6e 6f 6d  |["cancel","venom|
00000060  2d 65 63 68 6f 2e 62 79 74 65 73 22 5d 7d        |-echo.bytes"]}|

Server replies HELLO_OK:

# S→C HELLO_OK (type 0x0002, stream 0, length 0x75 = 117)
00000000  00 02 00 00 00 00 00 00 00 75 7b 22 70 72 6f 74  |.........u{"prot|
00000010  6f 22 3a 31 2c 22 73 65 72 76 65 72 22 3a 7b 22  |o":1,"server":{"|
00000020  6e 61 6d 65 22 3a 22 76 65 6e 6f 6d 2d 65 63 68  |name":"venom-ech|
00000030  6f 22 2c 22 76 65 72 73 69 6f 6e 22 3a 22 30 2e  |o","version":"0.|
00000040  31 2e 30 22 7d 2c 22 63 61 70 73 22 3a 5b 22 63  |1.0"},"caps":["c|
00000050  61 6e 63 65 6c 22 2c 22 76 65 6e 6f 6d 2d 65 63  |ancel","venom-ec|
00000060  68 6f 2e 62 79 74 65 73 22 5d 2c 22 6d 61 78 5f  |ho.bytes"],"max_|
00000070  66 72 61 6d 65 22 3a 31 30 34 38 35 37 36 7d     |frame":1048576}|

Client sends a tool frame on stream 1 (opened by the client, so odd); the toy daemon echoes it back on the same stream:

# C→S ECHO_BYTES (type 0x0100, stream 1, length 3, payload "hi\n")
00000000  01 00 00 00 00 01 00 00 00 03 68 69 0a           |..........hi.|
# S→C ECHO_BYTES
00000000  01 00 00 00 00 01 00 00 00 03 68 69 0a           |..........hi.|

A verb over the same connection with CONTROL:

# C→S CONTROL (type 0x0010, stream 0, length 0x22 = 34)
00000000  00 10 00 00 00 00 00 00 00 22 7b 22 69 64 22 3a  |........."{"id":|
00000010  33 2c 22 76 65 72 62 22 3a 22 70 69 6e 67 22 2c  |3,"verb":"ping",|
00000020  22 70 61 72 61 6d 73 22 3a 7b 7d 7d              |"params":{}}|
# S→C CONTROL (length 0x10 = 16)
00000000  00 10 00 00 00 00 00 00 00 10 7b 22 69 64 22 3a  |..........{"id":|
00000010  33 2c 22 6f 6b 22 3a 7b 7d 7d                    |3,"ok":{}}|

5.4 Byte trace: NDJSON connection

For comparison, the start of an NDJSON connection. The first byte is {:

# C→S hello request (47 bytes including the trailing 0x0a)
00000000  7b 22 69 64 22 3a 31 2c 22 76 65 72 62 22 3a 22  |{"id":1,"verb":"|
00000010  68 65 6c 6c 6f 22 2c 22 70 61 72 61 6d 73 22 3a  |hello","params":|
00000020  7b 22 70 72 6f 74 6f 22 3a 5b 31 5d 7d 7d 0a     |{"proto":[1]}}.|

6. Versioning and capability negotiation (hello)

6.1 hello params

{
  "proto": [1],
  "client": {"name": "venomx", "version": "0.1.0"},
  "caps": ["cancel", "venomx.pane_stream"],
  "max_frame": 1048576,
  "auth": {}
}
Member Required Meaning
proto yes Protocol major versions the client speaks, as integers. This RFC is version 1.
client no Name and version of the client, for logs and doctor
caps no Capabilities the client wants to use (6.3)
max_frame no Largest message or frame the client accepts, 4096 to 16777216
auth no Opaque here; defined by RFC-0005 (for example a VENOM_TOKEN)

6.2 hello result

{
  "proto": 1,
  "server": {"name": "venom-echo", "version": "0.1.0"},
  "caps": ["cancel"],
  "max_frame": 1048576,
  "schema": "sha256:9f2c…"
}
Member Meaning
proto The chosen version: the highest version in both the client’s list and the server’s
server Tool name (2.1) and version of the running daemon
caps The negotiated capability set: the client’s request intersected with what the server supports
max_frame The effective limit for this connection, applying in both directions: the smaller of the two peers’ values
schema Optional digest of the daemon’s verb schema (format in RFC-0004). The CLI compares it with its own to spot a daemon that is still running an older binary after an upgrade, and doctor reports the mismatch.

If there is no common version, the server responds with protocol.version_mismatch, data: {"supported":[1]}, and closes the connection.

6.3 Capabilities

  • A capability is a string. Core capabilities are bare names; tool capabilities are <tool>.<name> (for example venomx.pane_stream). Unknown capabilities in a request are dropped from the result, never an error.
  • A peer MUST NOT use a feature gated by a capability that is not in the negotiated set. The server answers a gated verb used without its capability with unsupported.capability.
  • v1 core capabilities: cancel (4.4). Capabilities do not replace grants: a capability says what the connection can speak; a grant (RFC-0005) says what the caller is allowed to do.

6.4 When hello is required

  • Binary connections: HELLO MUST be the first frame. Anything else is protocol.hello_required and closes the connection.
  • NDJSON connections: client libraries MUST send hello first. This covers venom-std’s proto client, the generated CLI and the generated MCP server. Only a human typing into socat or similar is expected to skip it.
  • Implicit hello, pinned to version 1. If the first request on an NDJSON connection is not hello, the server behaves as if it had received {"proto":[1]}: version 1, no capabilities, default max_frame. This meaning is permanent: in every future protocol version, a connection without hello is a version 1 connection, never “the newest version”. A server that no longer speaks version 1 MUST answer the first request on such a connection with protocol.version_mismatch and data.supported, then close. So a client that forgets hello either gets version 1, which is what a human typing JSON is writing, or a clear error; it is never silently misread.
  • A hello after any other request, or a second hello, is protocol.hello_order.

6.5 Versioning

Version layers. Five things change at different speeds, and each has its own identifier, so none of them has to be bumped just because another one changed:

Layer Identifier Changes when How peers find out
Envelope Preamble byte (0xF5 = v1) The binary frame header layout changes First byte (section 3); unknown → protocol.bad_preamble
Protocol proto integer (this RFC = 1) An incompatible change to sections 4–7 hello (6.1, 6.2)
Features Capabilities A compatible addition: a core verb, frame type, message member hello caps intersection (6.3)
Verbs schema digest, plus the schema’s own version rules (RFC-0004) A tool adds or changes verbs hello schema; doctor
Daemon build server.version (SemVer) Every release hello server; logs and doctor only, never used for feature checks

There are deliberately no protocol minor versions. Anything a minor version would announce is a capability instead, so a peer checks for the exact feature it needs rather than inferring it from a number.

Frozen bootstrap. These parts never change in any protocol or envelope version, so that any two peers, however far apart their versions, can always discover that they don’t match and say so:

  1. The first-byte table entries { = NDJSON and 0xF5 = envelope v1.
  2. NDJSON framing: one UTF-8 JSON object per line, ending in \n.
  3. The envelope v1 header (5.1) and core frame types 0x0001 HELLO, 0x0002 HELLO_OK and 0x0003 ERROR.
  4. The hello request having a proto array of integers, and the protocol.version_mismatch error carrying data.supported, an array of the integers the server speaks.
  5. An NDJSON connection without hello means protocol version 1 (6.4).
  6. The error object’s code and message members (7.1).

Compatible changes (no new proto; a new capability where peers need to know):

  • New optional members in any message or result. Receivers already ignore unknown members (4.1).
  • New core verbs, capabilities, error details (<class>.<new_detail>), and tool frame types behind a capability.
  • New core frame types in the reserved range, only behind a new core capability, because an unknown core frame type is a fatal error (5.2).
  • New error classes, using exit codes from the reserved 12–63 range. Older clients treat an unknown class as failed (7.1), so they still exit non-zero.

Incompatible changes (need proto: 2 and a superseding RFC): removing or renaming anything, changing a member’s type or meaning, making an optional member required, changing an existing exit code, or changing the NDJSON grammar. A new frame header layout needs a new preamble byte instead.

Transition. A server that adds proto: 2 SHOULD keep speaking 1 for at least one release, and lists both in data.supported when it refuses a client. Clients list every version they speak in hello.proto; the server picks the highest common one, so list order doesn’t matter. The conformance suite (COM-216) MUST check that {"proto":[99]} gets protocol.version_mismatch with data.supported, and that unknown members in requests are ignored.

7. Errors and exit codes

7.1 Error object

{"code":"not_found.pane","message":"no pane with id 'p9'","data":{"pane":"p9"}}
Member Required Meaning
code yes <class> or <class>.<detail>. Both parts match [a-z][a-z0-9_]*. Programs match on code.
message yes One line of English for humans. Not stable; never parse it.
data no Object with structured details. Its shape is fixed per code and documented with the verb (RFC-0004).

The class (the part before the first .) is one of the fixed values below and determines the exit code. The detail refines it; tools add details freely (not_found.pane, conflict.session_exists), and a client that does not know a detail still knows the class. A client that receives an unknown class MUST treat it as failed.

7.2 Classes and exit codes

Class Exit Meaning
(success) 0 ok response
failed 1 The verb ran and the operation failed (for example a command started by run --wait failed to launch). Also the fallback for unknown classes.
invalid_params 2 Params are missing, of the wrong type or out of range. The CLI also uses 2 for its own usage errors.
not_found 3 The thing named in params (pane, session, thread, request) does not exist
denied 4 The caller lacks the grant (RFC-0005), or a path failed the permission checks in 2.4
conflict 5 The current state forbids it: already exists, already running, wrong state
unavailable 6 The daemon cannot be reached or is shutting down; also set by the client when connect fails
timeout 7 A deadline passed (a --timeout given to the verb, or a run --wait limit)
cancelled 8 Cancelled by cancel, by Ctrl-C in the CLI, or by the daemon shutting down
protocol 9 Malformed message, bad frame, version mismatch, hello misuse
unsupported 10 Unknown verb, or a capability not negotiated
internal 11 A bug in the daemon. message SHOULD say where to report it.

Exit codes 12–63 are reserved for future classes. A verb MAY, if its schema says so (RFC-0004), exit with a code taken from its result instead (for example run --wait exiting with the child’s status); such codes are part of that verb’s contract, not of this table.

7.3 CLI mapping

With --json, the CLI writes to stdout exactly one JSON value per final response: the ok value on success, or {"error":{…}} on failure. It exits with the code from 7.2. Streaming verbs write each item as one NDJSON line and, on failure, end with the {"error":…} line. Without --json the CLI prints human text to stdout and the error message to stderr; the exit code is the same.

$ venomx pane.kill p9 --json
{"error":{"code":"not_found.pane","message":"no pane with id 'p9'","data":{"pane":"p9"}}}
$ echo $?
3
$ VENOMX_SOCKET=/nonexistent/x.sock venomx ping --json
{"error":{"code":"unavailable.not_running","message":"no daemon at /nonexistent/x.sock","data":{"socket":"/nonexistent/x.sock"}}}
$ echo $?
6

(Verb names above are illustrative; venomx’s verbs come from its own schema.)

8. Remote transport (out of scope for v1)

v1 is local Unix sockets only. Nothing in sections 3–7 depends on the transport except peer credentials (2.4), so the same bytes can later travel over any reliable, ordered byte stream. The intended shape, not normative yet:

  • <tool> bridge (reserved verb name) connects its stdin/stdout to the local socket, so ssh host venomx bridge gives a remote client a byte-exact connection. Because peer credentials are then the SSH user’s, identity comes from hello.auth (RFC-0005), and the remote end MUST require it.
  • A direct TCP listener, if ever added, MUST use TLS and token auth, and needs its own RFC.
  • File-descriptor passing (SCM_RIGHTS) is not used in v1, so it cannot become a hidden dependency that breaks remote use.

Agent-contract impact

  • Verbs: adds three core verbs every daemon gets from proto: hello, cancel, ping. Reserves the name bridge. The verb schema (RFC-0004) must be able to mark a verb as streaming (4.4) and as passing through its own exit code (7.2).
  • --json and exit codes: defines the stable exit-code table (7.2) and what --json prints on success and failure (7.3). Every generated CLI follows it.
  • Events: no new event types. Defines that events results travel as item messages; the envelope and catalog are RFC-0003.
  • skill, doctor, config: no change to the verbs themselves. doctor SHOULD check the runtime directory permissions (2.4), a stale or foreign socket, path length (2.3) and a schema mismatch between CLI and daemon (6.2).
  • Capability grants: hello and ping need no grant. cancel may only cancel the caller’s own requests on the same connection, so it needs no grant either. Peer-UID checking (2.4) is the coarse outer gate; RFC-0005 adds per-caller grants and defines hello.auth. Error class denied (exit 4) is reserved for it.
  • Headless: the protocol has no TUI dependency; every client, including the TUIs, is just another connection.
  • OSC 7501 / OSC 133: no change; those are ingested by venomx and surfaced as events (RFC-0003).

Alternatives

  • JSON-RPC 2.0 (as used by MCP and LSP). Very close in shape. Rejected because its integer error codes don’t carry a class a client can map to an exit code without a lookup table, it requires the "jsonrpc":"2.0" member on every message, and its notification/batch features add cases we don’t need. The mapping to JSON-RPC is mechanical (verb→method, ok→result, code string → data.code), which the generated MCP server (RFC-0004) does anyway.
  • Two sockets per daemon (one NDJSON, one binary). Rejected: two paths, two permission checks, two stale-socket stories, and no way to send a verb on an already-open stream connection. The first-byte switch, taken from tuios, costs one byte.
  • Length-prefixed JSON everywhere instead of NDJSON. Rejected: it can’t be typed into socat or piped through jq. Binary connections already get length-prefixed JSON through CONTROL frames.
  • Protobuf, Cap’n Proto, MessagePack or CBOR for control. Rejected: a code generator or extra dependency in every Zig repo, and opaque to humans and agents reading a trace. Only bulk bytes benefit from binary, and they get raw frames.
  • Little-endian header. Equally cheap on every target we care about (macOS and Linux on arm64 and x86_64 are all little-endian; big-endian costs one byte-swap instruction per header field). Big-endian was chosen because it is the convention for wire formats and reads naturally in a hex dump (00 00 00 63 is 99). Host byte order, as tmux’s imsg and Wayland use, was rejected outright: it only works while both ends are the same machine, which rules out remote transport.
  • Detecting stale sockets by test-connecting instead of a lock file. Rejected: two daemons starting at once can both see “refused”, both unlink, and both bind. flock is atomic and released by the kernel on crash.
  • Do nothing and let each repo pick. Rejected: the generated CLI, MCP server and conformance suite (COM-216) need one protocol to target.

Open questions

  1. CLI auto-start. Should venomx <verb> start the daemon when it isn’t running (as tmux does), or fail with exit 6? This RFC only defines the error. Deferred to RFC-0001 (COM-199).
  2. Header byte order. Resolved (2026-11-06): big-endian, decided by the maintainer on PR #2. See 5.1 and Alternatives.
  3. Implicit hello on NDJSON (6.4). Resolved (2026-11-06), decided by the maintainer on PR #2: kept for socat use, permanently pinned to version 1. Client libraries MUST send hello. A server without version 1 refuses a connection that skips hello with protocol.version_mismatch.
  4. Multiple instances per user. Should there be a standard way to run two daemons of one tool (for example [email protected], selected by --instance), or is --socket enough? Resolved (2026-11-06), by maintainer acceptance: --socket is enough for v1.
  5. Flow control for streams. Binary streams have no credit-based flow control; a slow reader is closed (4.3). Is that enough for venomx pane streams, or does RFC-0006 need per-stream credits in the envelope? Resolved (2026-11-06) by RFC-0006 section 6.2: no credits and no new core frame type. Pane-stream frames are produced lazily from a per-pane ring, and a stream that falls behind is resynced with a fresh snapshot instead of buffered.
  6. Other users’ daemons. v1 rejects any peer with a different UID (2.4). Root-run agents or a shared multi-user venom-os would need RFC-0005 to relax that explicitly. Deferred to RFC-0005 (COM-204).