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-stdproto: COM-205/COM-206 (socket server/client, sniffing), COM-207 (NDJSON verbs), then the toy daemonvenom-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:
- The
--socket <path>flag (accepted byserveand by every client verb). - 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. - 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/orvenom-<uid>/) MUST be created with mode0700. Before binding or connecting, both daemon and client MUST check, withlstat, 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, withdenied.insecure_path(exit 4). - The daemon MUST create the socket with mode
0600(setumask(0177)aroundbind, thenchmod, so there is no window where it is wider). - On every accepted connection the daemon MUST read the peer credentials
(
SO_PEERCREDon Linux,getpeereidon 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’smax_frame(default 1 MiB = 1048576 bytes, negotiable inhello). A receiver that sees a longer line MUST replyprotocol.too_largeand 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":{…}}} |
idis 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: nullappears only in errors about a message whoseidcould not be read.verbis the verb name from the tool’s verb schema (RFC-0004), for examplepane.splitorecho. Names are^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$.paramsis an object. It MAY be omitted, which means{}.okis the verb’s result: any JSON value, includingnull. A response has exactly one ofokanderror.itemcarries 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 examplevenomx.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:
HELLOMUST be the first frame. Anything else isprotocol.hello_requiredand closes the connection. - NDJSON connections: client libraries MUST send
hellofirst. This covers venom-std’sprotoclient, the generated CLI and the generated MCP server. Only a human typing intosocator similar is expected to skip it. - Implicit
hello, pinned to version 1. If the first request on an NDJSON connection is nothello, the server behaves as if it had received{"proto":[1]}: version 1, no capabilities, defaultmax_frame. This meaning is permanent: in every future protocol version, a connection withouthellois 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 withprotocol.version_mismatchanddata.supported, then close. So a client that forgetshelloeither gets version 1, which is what a human typing JSON is writing, or a clear error; it is never silently misread. - A
helloafter any other request, or a secondhello, isprotocol.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:
- The first-byte table entries
{= NDJSON and0xF5= envelope v1. - NDJSON framing: one UTF-8 JSON object per line, ending in
\n. - The envelope v1 header (5.1) and core frame types
0x0001HELLO,0x0002HELLO_OKand0x0003ERROR. - The
hellorequest having aprotoarray of integers, and theprotocol.version_mismatcherror carryingdata.supported, an array of the integers the server speaks. - An NDJSON connection without
hellomeans protocol version 1 (6.4). - The error object’s
codeandmessagemembers (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, sossh host venomx bridgegives a remote client a byte-exact connection. Because peer credentials are then the SSH user’s, identity comes fromhello.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 namebridge. 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). --jsonand exit codes: defines the stable exit-code table (7.2) and what--jsonprints on success and failure (7.3). Every generated CLI follows it.- Events: no new event types. Defines that
eventsresults travel asitemmessages; the envelope and catalog are RFC-0003. skill,doctor,config: no change to the verbs themselves.doctorSHOULD check the runtime directory permissions (2.4), a stale or foreign socket, path length (2.3) and aschemamismatch between CLI and daemon (6.2).- Capability grants:
helloandpingneed no grant.cancelmay 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 defineshello.auth. Error classdenied(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,codestring →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
socator piped throughjq. Binary connections already get length-prefixed JSON throughCONTROLframes. - 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 63is 99). Host byte order, as tmux’simsgand 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.
flockis 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
- 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). - Header byte order. Resolved (2026-11-06): big-endian, decided by the maintainer on PR #2. See 5.1 and Alternatives.
- Implicit
helloon NDJSON (6.4). Resolved (2026-11-06), decided by the maintainer on PR #2: kept forsocatuse, permanently pinned to version 1. Client libraries MUST sendhello. A server without version 1 refuses a connection that skipshellowithprotocol.version_mismatch. - Multiple instances per user. Should there be a standard way to run
two daemons of one tool (for example
venomx@work.sock, selected by--instance), or is--socketenough? Resolved (2026-11-06), by maintainer acceptance:--socketis enough for v1. - 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.
- 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).