Serial Tether Wire Protocol — v1 (draft)
The protocol spoken between tetherd (daemon) and clients (tether, the future tether-tui, ad-hoc user scripts).
0. Conceptual model
- The daemon owns a single serial port. All bytes flow through one ring buffer.
- Many clients can attach simultaneously. A "session" is not an isolated I/O stream; it is the bundle of:
- identity — who attached (used for logging and audit)
- read cursor — offset into the shared ring buffer
- mode —
rworro - writer-lock candidacy — the right to enter the critical section during a
runtransaction
- Serial output is fanned out to every attached session (each session has its own queue).
1. Transport
| Environment | Transport | Example path |
|---|---|---|
| Local Linux/macOS | Unix domain socket | /run/tetherd.sock, ~/.tether/sock |
| Local Windows | Named Pipe | \\.\pipe\tetherd |
| Remote | TCP + token auth | tcp://host:5557 |
Every transport is treated as a bidirectional byte stream. Authentication and authorization are handled at the transport layer; the wire format is identical above it.
2. Framing
- NDJSON: UTF-8, one JSON object per line, terminated by
\n(LF). - Newlines inside a payload are JSON-string-escaped (
\n), so framing never collides with content. - Recommended maximum line length: 1 MiB. The server closes the connection when this is exceeded.
3. Message shape — JSON-RPC 2.0
Three kinds of messages flow on the wire:
// Request — has an id, expects a response
{"jsonrpc":"2.0","id":1,"method":"<name>","params":{...}}
// Response — id matches the request
{"jsonrpc":"2.0","id":1,"result":{...}}
{"jsonrpc":"2.0","id":1,"error":{"code":N,"message":"...","data":{...}}}
// Notification — no id, no response (server-push, or client→server fire-and-forget)
{"jsonrpc":"2.0","method":"<name>","params":{...}}idis a client-issued integer or string. The server echoes it verbatim.- Unknown method →
-32601error. - Unknown param fields → silently ignored (additive evolution).
- Unknown notifications → silently dropped.
4. Data-type conventions
- session_id: UUIDv7 string (time-sortable). Issued by the server.
- seq: byte offset into the ring buffer (u64). Monotonically increasing; resets to 0 when the daemon restarts.
- bytes: base64 (RFC 4648 standard). Text payloads in
send/expectare encoded the same way for consistency.- For convenience some methods also accept
data_text(UTF-8 only) as an alternative to base64.
- For convenience some methods also accept
- timestamp: RFC 3339 with microsecond precision, UTC. Filled in by the server.
- timeout_ms: u32 milliseconds.
0or omitted does not mean "wait forever" — pass an explicitnullfor that.
5. Connection lifecycle
[client connects]
↓
hello ─────────────────→
←────────────── hello result (server info)
↓
attach ────────────────→ (may be called multiple times to hold N sessions)
←────────────── attach result (session_id)
↓
send / expect / run / status / data notif / ...
↓
detach (optional) or connection closeWhen a connection drops, every session it owned is automatically detached. Session IDs are retained for 30 seconds so the same client can reconnect with attach { session_id } to resume.
6. Method catalogue
6.1 hello (required, must be the first message)
// Request
{"id":1,"method":"hello","params":{
"protocol_version":"1",
"client":{
"name":"tether",
"version":"0.1.0",
"kind":"agent" // "human" | "agent" | "logger"
},
"auth_token":"..." // required on TCP transport
}}
// Response
{"id":1,"result":{
"server_version":"0.1.0",
"protocol_version":"1",
"device":{"path":"/dev/ttyUSB0","baud":115200,"data_bits":8,"parity":"none","stop_bits":1},
"buffer":{"capacity_bytes":65536,"head_seq":12345,"tail_seq":3201}
}}- A
protocol_versionmajor mismatch is rejected with-32010 unsupported_protocol. - Calling any method before
helloreturns-32011 not_initialized.
6.2 attach
{"method":"attach","params":{
"session_id": null, // null for new; a string attempts to resume
"mode":"rw", // "rw" | "ro"
"replay":{"from":"now"}, // "start" | "now" | {"seq": N}
"label":"agent-claude", // display-only, optional
"flow_control":"drop_oldest" // "drop_oldest" | "disconnect"
}}
→ {"result":{
"session_id":"01HV...",
"cursor_seq":12345,
"restored":false // true when an existing session was resumed
}}Errors:
-32012 session_not_found— the requested session id has expired or never existed.-32013 mode_conflict— refused by policy (e.g. policy of "one rw at a time" already satisfied).
6.3 detach
{"method":"detach","params":{"session_id":"..."}}
→ {"result":{}}6.4 send
Non-atomic write. Does not acquire the writer lock; may interleave with sends from other sessions.
{"method":"send","params":{
"session_id":"...",
"data":"dmVyc2lvbgo=", // base64
// or
"data_text":"version\n", // UTF-8 plain text (one of the two)
"eat_echo":false // when true, advances the cursor past the echoed bytes
}}
→ {"result":{"bytes_written":8,"sent_at_seq":12389}}sent_at_seq is the buffer's head_seq immediately before the write. The client can then call expect(from_seq=sent_at_seq) to match in a race-free way.
6.5 expect
{"method":"expect","params":{
"session_id":"...",
"pattern":"# $",
"regex":true, // false → literal substring match
"timeout_ms":3000, // null → wait forever
"strip_ansi":true, // strip ANSI escapes before matching
"strip_echo":"version\n", // optional: strip the echoed command line from `before`
"from_seq":12389, // omitted → uses the session's current cursor
"max_bytes":65536, // accumulating beyond this raises buffer_overflow
"max_output_bytes":8192 // truncate `before` to the trailing N bytes (matching window unaffected)
}}
// Success
→ {"result":{
"matched":true,
"match":"# ",
"before":"dmVyc2lvbi4uLg==", // raw bytes up to the match (base64)
"match_seq":12450, // seq where the match began
"end_seq":12452, // seq just past the match (next cursor candidate)
"truncated":false, // true when before was capped by max_output_bytes
"original_bytes":null // pre-truncation length (when truncated)
}}
// Failure (timeout)
→ {"error":{
"code":-32001,
"message":"timeout",
"data":{"buffered":"...","buffered_seq_range":[12389,12440]}
}}On match (or timeout), the session's cursor advances to end_seq.
6.6 run
Atomic send + expect. Holds the writer lock for the duration.
{"method":"run","params":{
"session_id":"...",
"data_text":"version\n",
"until":{"pattern":"# $","regex":true,"strip_ansi":true},
"timeout_ms":3000,
"preempt":"queue", // "queue" | "fail" | "force"
"strip_echo":true, // remove the echoed command line from `before`
"max_output_bytes":8192
}}
→ {"result":{
"matched":true,
"match":"# ",
"before":"...", // base64; same field name as expect for symmetry
"match_seq":12450,
"end_seq":12462,
"truncated":false,
"duration_ms":42
}}Meaning of preempt:
queue(default) — if another session holds the lock, queue and run when it releases.fail— return-32004 lock_contentionimmediately.force— abort the current lock holder'srunand seize the lock. Servers may restrict this to clients ofkind:"human".
6.7 cancel
{"method":"cancel","params":{"id":7}}
→ {"result":{"cancelled":true}}The targeted request responds with -32800 cancelled. If the request has already completed or never existed, cancelled:false.
6.8 status
{"method":"status","params":{}}
→ {"result":{
"device":{
"path":"/dev/ttyUSB0",
"baud":115200,
"data_bits":8,
"parity":"none",
"stop_bits":1,
"flow_control":"none",
"connected":true
},
"buffer":{"head_seq":12345,"tail_seq":3201,"capacity":65536},
"lock":{"holder_session_id":"01HV...","acquired_at":"2026-04-29T..."},
"sessions":[
{"id":"01HV...","label":"human-dkkang","mode":"rw","cursor_seq":12300,"lag_bytes":45},
{"id":"01HW...","label":"agent-claude","mode":"rw","cursor_seq":12345,"lag_bytes":0}
]
}}device.parity is one of "none" | "odd" | "even". device.flow_control is one of "none" | "software" | "hardware".
6.9 list_ports (since v0.7)
Enumerate the serial ports the daemon's host machine knows about. Useful for picking a device to connect when you don't know the path. Returns an empty array on platforms or environments where enumeration is unavailable (a warning is logged server-side).
{"method":"list_ports","params":{}}
→ {"result":{"ports":[
{
"path":"/dev/ttyUSB0",
"kind":"usb", // "usb" | "pci" | "bluetooth" | "unknown"
"manufacturer":"FTDI",
"product":"FT232R USB UART",
"serial_number":"A50285BI",
"vid":"0403", // lowercase 4-hex
"pid":"6001"
},
{"path":"/dev/ttyS0","kind":"unknown"}
]}}6.10 list_devices (since v0.8)
Enumerate the devices currently managed by the daemon. Daemon-wide RPC (no device_id). Used by clients to discover ids before issuing device-targeted RPCs.
{"method":"list_devices","params":{}}
→ {"result":{
"devices":[
{
"id":"board0", "path":"/dev/ttyUSB0",
"baud":115200, "data_bits":8, "parity":"none",
"stop_bits":1, "flow_control":"none",
"connected":true, "explicitly_disconnected":false,
"shell":"uboot", // "posix" | "uboot" | "none" (default "posix")
"prompt":"=> ", // optional default -u regex for run/sync
"newline":"cr" // optional default line terminator
},
{"id":"board1", "path":"/dev/ttyUSB1", "baud":9600, ...}
],
"default_device":"board0"
}}shell/prompt/newline are the device's console personality (set via the -D shell=|prompt=|newline= spec). shell defaults to "posix" and is always present; prompt/newline are omitted when unset. These fields also appear on device in status / hello. default_device is the id selected when a client omits device_id. Multi-device daemons return AmbiguousDevice for any device-targeted RPC that doesn't pass device_id; single-device daemons silently fall through to the only device for backwards compat.
6.11 set_device (since v0.7)
Apply a partial update to the live serial settings. Every field is optional; absent fields keep their current value. The change is applied to the open SerialStream in place — the device handle is not dropped, in-flight reads/writes are not interrupted.
{"method":"set_device","params":{
"baud":921600, // optional
"data_bits":8, // optional, 5..=8
"parity":"none", // optional, "none" | "odd" | "even"
"stop_bits":1, // optional, 1 or 2
"flow_control":"none" // optional, "none" | "software" | "hardware"
}}
→ {"result":{"device":{...}}} // device shape from §6.8A successful apply updates the daemon's stored config (so the same settings survive an auto-reconnect) and broadcasts a device notification with kind:"config_changed" to every attached client (§7.5).
Errors:
-32007 unsupported_serial_op— the device's backend can't accept termios changes (e.g. PTYs, pipes).-32008 invalid_serial_setting— value out of range, unknown parity/flow string, or hardware refused.-32602 invalid_params— no fields supplied.-32005 device_disconnected— set during a reconnect attempt; the new settings are remembered and applied on the next successful open.
6.12 send_break / set_dtr / set_rts / read_modem_status (since v0.8)
Tio-style line / break / modem control. All four take an optional device_id. Hardware-only — the Fd backend (PTYs, pipes) returns -32007 unsupported_serial_op.
{"method":"send_break","params":{"duration_ms":250}}
→ {"result":{"ok":true}}
{"method":"set_dtr","params":{"on":true}}
→ {"result":{"ok":true}}
{"method":"set_rts","params":{"on":false}}
→ {"result":{"ok":true}}
{"method":"read_modem_status","params":{}}
→ {"result":{"cts":true, "dsr":false, "ri":false, "dcd":true}}6.13 disconnect_device / connect_device (since v0.8)
Operator-driven port management. disconnect_device closes the open port and pauses the auto-reconnect loop. The device remains parked in explicitly_disconnected:true state; pending writes get -32005 device_disconnected. connect_device clears the flag and forces an immediate reopen.
{"method":"disconnect_device","params":{"device_id":"board0"}}
→ {"result":{"device":{...,"connected":false}}}
{"method":"connect_device","params":{"device_id":"board0"}}
→ {"result":{"device":{...,"connected":true}, "connected":true}}connect_device waits up to 2s for the reopen to complete; the connected field reflects the final state.
6.14 lock / unlock (since v0.11)
Explicit, session-held possession of a device's writer lock — distinct from the transient hold run takes internally for the duration of one transaction (§6.6). An exclusive lock additionally gates plain send (§6.4) from every other session, and it persists until unlock, the session detaches, or its connection closes — not just for one transaction. Meant for a session that's about to flash the device out-of-band (bootloader over UART, a pty= virtual port, etc.) and needs the wire to itself for longer than a single run.
{"method":"lock","params":{
"session_id":"...",
"device_id":"board0", // optional; validated against the session's device if given
"preempt":"queue" // "queue" | "fail" | "force" — same semantics as run's preempt
}}
→ {"result":{"locked":true}}
{"method":"unlock","params":{"session_id":"...","device_id":"board0"}}
→ {"result":{"unlocked":true}}- A session that already holds the lock (e.g. via a
runin flight) maylockit too — this upgrades the existing hold to exclusive rather than queuing behind itself. unlockis idempotent when nobody holds the lock. Unlocking a lock a different session holds returns-32004 lock_contention("not the lock holder") instead of silently releasing it.- If the holding session detaches or its connection closes, the daemon releases the lock automatically, so a crashed flashing client can't strand the device locked forever.
- While an exclusive lock is held,
sendfrom any other session fails with-32004 lock_contention("device locked by another session (flashing?); try again after unlock").run's own internal hold is never exclusive, so arunin progress doesn't trigger this for other sessions'sends — it only serialises against otherrun/lockcallers viapreempt(queue/fail/force), same as today. - A
pty=virtual serial port bridging a non-tether tool onto the same device also backs off while an exclusive lock is held: it drops bytes written by the host tool rather than interleaving them with the locking session's writes.
7. Server → client notifications
All notifications gained an optional
device_idfield in v0.8 so multi-device daemons can route them. Old single-device clients keep working — they ignore unknown fields.
7.1 data — serial output
{"method":"data","params":{
"session_id":"01HV...", // from the receiving session's perspective
"seq":12390, // first seq of this chunk
"data":"Li4uIGRvbmUK" // base64
}}- Chunking is at the server's discretion (network/buffer boundaries). No semantic-unit guarantee.
- When the same data is fanned out to N sessions, each notification carries that session's cursor-aligned seq (which is identical to the global seq).
7.2 lag — backpressure caused data drops
{"method":"lag","params":{
"session_id":"...",
"dropped_bytes":4096,
"dropped_range":[12100,12300],
"resume_seq":12300
}}Only emitted for sessions configured with flow_control:"drop_oldest". Tells the client "you missed some data".
7.3 lock
{"method":"lock","params":{
"kind":"acquired", // "acquired" | "released" | "queued" | "preempted"
"holder_session_id":"01HV...",
"queue_depth":2
}}7.4 session
{"method":"session","params":{
"id":"01HV...",
"kind":"detached", // "attached" | "detached" | "preempted"
"reason":"client_disconnect"
}}7.5 device
{"method":"device","params":{
"kind":"disconnected", // "disconnected" | "reconnected" | "config_changed"
"detail":"USB cable removed"
}}If the device drops, in-flight expect/run requests fail with -32005 device_disconnected.
8. Error codes
| Code | Meaning |
|---|---|
-32700 | parse error (JSON-RPC standard) |
-32600 | invalid request |
-32601 | method not found |
-32602 | invalid params |
-32603 | internal error |
-32001 | timeout |
-32002 | session not attached |
-32003 | mode violation (write on ro) |
-32004 | lock contention (preempt:fail) |
-32005 | device disconnected |
-32006 | buffer overflow (max_bytes exceeded without a match) |
-32007 | unsupported serial operation (backend can't apply termios) |
-32008 | invalid serial setting (out-of-range / unknown value) |
-32009 | device not found (unknown device_id) |
-32010 | unsupported protocol |
-32011 | not initialized (called before hello) |
-32012 | session not found |
-32013 | mode conflict (policy refused) |
-32014 | unauthorized (TCP token wrong) |
-32015 | ambiguous device — daemon serves >1, specify device_id |
-32800 | cancelled |
9. Race / ordering guarantees
- Within a connection the server may process requests out of order (e.g. an
expectblocks while another RPC runs in parallel). Responses are matched byid. - The
seqcarried bydatanotifications is globally monotonic. sent_at_seqfrom asendresponse is≤the seq of any echo or response generated by that send. So callingexpect(from_seq=sent_at_seq)immediately aftersendis race-free.- Requests on a single connection are dispatched in arrival order. Only
expect,run,reconnect, andlock(which may block waiting on a pattern, a device write, a reopen, or another session's release, respectively) are handled off the connection's main loop so a long-running one can't stall the rest; every other method — most importantlysend— is handled inline.sendpayloads therefore reach the device in exactly the order the client issued them, which matters for pasted multi-line input and rapidsendbursts.
10. Evolution rules
- Additive changes (new fields, new methods, new notifications): bump the server's minor version, keep
protocol_version. - Breaking changes: bump the major
protocol_version. The server may refuse to negotiate down athello. - Clients must ignore unknown response fields.
- The server ignores unknown request fields by default (a strict-validation mode is a separate option).
Stability commitment (since v1.0)
The serial-tether project follows semver on the crate version (serial-tether, tether-protocol on crates.io) with the following protocol-stability rules layered on top. Once 1.0.0 is cut:
protocol_version: "1"is frozen. Every change shipped under a 1.x crate version will be a strict superset of v1.0's wire format. v1.0 clients will continue to negotiate successfully against any 1.x daemon, and vice versa.- Method additions are minor bumps (
1.x.0). New methods may be added; old methods keep their parameter and result schemas. - Field additions are minor bumps, with two rules:
- New request fields are optional with a documented default. Servers accepting v1.0 clients must continue to honor that default.
- New response/notification fields are optional and clients ignore them if unrecognized.
- Error-code additions are minor bumps. Existing codes never change meaning. Clients must not crash on an unknown code — treat as generic protocol error and surface the message to the user.
- Patch bumps (
1.x.y) are reserved for bug fixes that don't change the wire schema or method semantics. - Breaking changes (semantic shifts, removed fields, removed methods) go in
protocol_version: "2"under a2.0.0crate release. Until then, anything that would force a breaking change ships as a new additive method instead.
The tested compatibility matrix is "any 1.x crate can talk to any other 1.x crate". CI runs the integration tests on every supported MSRV bump within a major.
11. Debugging
- Log level:
RUST_LOG=debug tetherd ...(standardtracing_subscriberfiltering; tracing goes to stdout, the human startup banner to stderr). - Talk to the daemon by hand:
- UDS —
socat - UNIX-CONNECT:/run/tetherd.sock(ornc -Uon Linux). - TCP —
nc daemon-host 5557(don't forget to send a validauth_tokenin the firsthello).
- UDS —
- A handy first message:
{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol_version":"1","client":{"name":"manual","version":"0","kind":"human"},"auth_token":"<TOKEN_IF_TCP>"}}
12. Open questions (to be resolved during v1.x)
- Raw passthrough mode: a separate connection mode where a human TUI streams keystrokes without wrapping each one in NDJSON. v0 finds plain
sendgood enough; we'll add this if throughput becomes an issue. - Hot-add / hot-remove devices: v0.8 settles the static multi-device model (all
-Dspecs at startup). Dynamic add/remove viaadd_device/remove_deviceRPC is on the roadmap but not committed for v1.0. - Recording / replay: a separate tool that replays session logs preserving timing (out of protocol scope).
- TLS for TCP: v0.4 ships TCP with token-based auth, plaintext on the wire. For untrusted networks, tunnel through SSH/WireGuard or wait for a future
--tls-cert/--tls-keyflag. - Compression: optional gzip framing for TCP remote mode. Not needed locally; might help on high-latency links once TLS is in place.
Resolved during v0.x:
Multiple devices: should a single daemon host more than one serial port?→ Yes, since v0.8.0. Each session carries adevice_id; ambiguous calls return-32015 ambiguous_device.
Status: v1.0 draft. Multi-device + tio parity shipped in v0.8.0; tio-style quick-start in v0.8.2. Stability commitment (§10) takes effect once 1.0.0 is tagged.