A daemon and CLI that lets AI agents and humans share a single serial device.
Site: https://hulryung.github.io/serial-tether/ · Demo: https://hulryung.github.io/serial-tether/#demo
Docs:
docs/OVERVIEW.md— what it is, why, and how it works (read this first)docs/AI_AGENT_GUIDE.md— how to teach Claude Code / Codex / Cursor to use it (paste-and-go AGENTS.md block + verification script)docs/AGENT_USAGE.md— one-page command cookbook the AI agent itself readsdocs/PROTOCOL.md— JSON-RPC 2.0 / NDJSON wire spec (v1)CHANGELOG.md— release-by-release notesexamples/— five working Bash automation scripts
tetherd owns the serial port; multiple clients (tether, user scripts, the future tether-tui) connect over a Unix socket / Named Pipe / TCP and read and write concurrently. The agent-facing CLI (tether) is transactional, structured, and bounded by design: JSON-RPC responses carry decoded text, exit codes follow shell conventions, the run primitive is race-free at the daemon level, and output truncation guards LLM context budgets.
Embedded development is a lot of staring at a serial console — kicking a
bootloader, reading kernel logs, exercising firmware against a corner case.
Increasingly an AI coding agent wants to do that staring too: react to a
stack trace, set a U-Boot env var, drive a board through a regression suite,
read sensor output, retry after a flash. The naïve loop — "agent describes
a command in chat → human copies it into tio → human pastes the output
back" — is slow, brittle, and pointless.
Serial Tether's job is to hand that loop directly to the agent without
elbowing the human out. The daemon takes ownership of /dev/ttyUSB0
once. From there, three audiences share the same port at the same time:
- AI agents drive the board through a JSON-RPC CLI that is transactional,
structured, and bounded by design. Race-free
run, ANSI-stripped and echo-strippedoutput, standard exit codes, configurable length truncation so the LLM context never blows up — the things that turn flaky scripted automation into reliable scripted automation. - Humans stay in full control on the same port: drop into a
tio-style raw-mode interactive shell withtether, tail every byte the agent is sending and receiving withtether tail, override or interrupt at will. No "agent mode" that locks the operator out — quite the opposite, the human gets a god's-eye view of what every other client is doing. - CI and shell scripts ride the same wire.
if tether run … ; then …; case $? in 124) … esacis just a few lines, and it works the same way whether the daemon is on this machine, behind SSH, or on a VM across the room.
The whole thing is meant to be agnostic about what sits on the other end of the serial link — U-Boot, Linux console, busybox login, vendor monitors, RTOS REPLs, raw MCU debug streams — because the daemon just shuttles bytes and surfaces them with race-free framing. Intelligence about prompts, escape sequences, and command grammars belongs in the client (or the agent driving it), where it's easy to evolve.
In one line: modern, AI-friendly, multi-tenant access to the serial port across the whole spectrum of embedded development, without taking the port away from the engineers who have always lived inside it.
Two humans, one serial console — every byte the device emits is broadcast
to every attached session. Think screen -x for a U-Boot prompt.
More flavors — same idea, different right-pane (click to expand)
Human in a tether shell on the left; on the right, a scripter
running one-shot tether run / ports / config from another terminal.
The scripter's commands echo live into the human's pane.
Same setup, but the right pane is an LLM/agent calling tether --json run
for transactional RPCs (with a live tether config --baud toward the end).
Multiple boards on one daemon (v0.8) — different concept, click to expand
One tetherd owns N serial ports. Clients address each by an
operator-chosen id (tether -d board0, tether -d board1). Per-device
baud / parity / etc. inline in -D. Buffers, locks, and event broadcast
are all per-device — traffic streams stay isolated.
Higher-fidelity (asciinema-player, click to seek between all four): https://hulryung.github.io/serial-tether/#demo
The serial-tether package ships two binaries:
tetherd— daemon. Owns the serial port; fans a single ring buffer out to every attached session.tether— non-interactive CLI.send/expect/run/status/tail/sync.
Plus a supporting library:
tether-protocol— wire-protocol types and NDJSON codec (shared between daemon and client).tether-tui(planned) — interactive TUI client for human use.
Every option below installs both tetherd and tether. Pick the one you prefer.
Homebrew (recommended on macOS — no Rust toolchain needed):
brew install hulryung/tether/serial-tethercargo install (with a Rust toolchain — works on any platform Rust supports):
cargo install serial-tetherPre-built binaries via curl (no dependencies):
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/hulryung/serial-tether/releases/download/v0.9.3/serial-tether-installer.sh | shOr build from source:
git clone https://github.com/hulryung/serial-tether
cd serial-tether
cargo build --workspace --release
# binaries land in ./target/release/{tetherd,tether}# `tio`-style one-liner — pass the device path as the first argument.
# tether brings up its own private daemon, drops you into an interactive
# shell, and shuts everything down on exit.
tether /dev/tty.usbserial-XXXX # Ctrl-A then Q to quit
# Same thing with an explicit baud:
tether -b 9600 -D /dev/tty.usbserial-XXXX
# Multi-client — start a long-lived daemon, attach as many clients as you
# want from any terminal (or remote, with --tcp).
# Terminal 1:
tetherd -D /dev/tty.usbserial-XXXX -b 115200
# Terminal 2 — interactive shell:
tether
# Terminal 3 — agent / scripted:
tether status
tether run "version" -u "# " --literal --timeout-ms 3000 --json
tether tailIf the daemon isn't running, tether prints exactly how to start it.
When the device is sitting at a POSIX-ish shell (busybox, dash, bash, U-Boot
hush), tether exec runs a command and returns only its output — no prompt
pattern to guess, no BEGIN/END scaffolding to write yourself. It brackets the
command with unique markers on the wire, strips the echoed command line (even
when the device terminal wraps it), and exits with the device command's
status, like ssh:
tether exec "uname -a" # output to stdout, exit = remote status
tether exec "test -f /etc/os-release" # use the exit code in a script
tether exec "cat /proc/uptime" --json # {output, exit_code, duration_ms}
# Composes like any shell command:
if tether exec "grep -q ok /tmp/state"; then echo "ready"; fiThe wrapper is a single line (echo "<BEG>"; <cmd>; echo "<END>=$?") with no
temp variable, so it works on POSIX shells and hush-enabled U-Boot. exec
defaults to a CR line terminator, which suits serial shells and U-Boot alike.
U-Boot: register the device shell=uboot (see below) so exec/run force
CR-only framing — never send crlf to U-Boot, whose CLI runs a command on
CR and then repeats it on the trailing LF (double execution). If a device shell
can't report a numeric status (a non-POSIX console), exec still prints the
output but reports the status as unknown — exit_code: null in --json and
exit code 8 — never a fabricated 0. See
docs/EXEC_NONPOSIX_SHELLS.md.
For a truly raw / non-shell console (bootloaders mid-boot, custom firmware
prompts), register it shell=none — exec then refuses immediately with the
right recipe — and use send + expect or the server-side atomic run.
Attach a shell personality to a device in the -D spec (daemon or standalone):
tetherd -D board=/dev/ttyUSB0,shell=uboot,prompt='=> '
# shell=posix|uboot|none (default posix); prompt=<regex>; newline=lf|cr|crlf|noneshell=ubootforces CR-only framing forexec/runand defaultsnewline=cr.shell=nonemakesexecrefuse at once (raw console → use run/send/expect).prompt=becomes the default-uforrun/sync, sotether -d board run "printenv"needs no-u.newline=sets the default line terminator.
These show up in tether list-devices --json and tether status (shell,
prompt, newline fields).
Other serial tools can't open a port tetherd already holds. Instead, give
each tool its own virtual serial port — a client-side PTY bridged to the
device, with a full copy of the stream (its own ring-buffer cursor):
tether -d a35 pty -- minicom -D {} # port lives exactly as long as minicom
tether -d a35 pty -- python3 flash.py {} # {} = the port path; also in $TETHER_PTY
tether -d a35 pty --link /tmp/a35.pty # or: print the path, run until Ctrl-C
tether -d a35 pty --read-only # observation-only portRun as many as you like — one per tool. (Two tools must not share one
virtual port: the kernel splits the byte stream between simultaneous readers.)
Works over TCP too: tether -s tcp://lab:5557 -d a35 pty -- minicom -D {}
turns a board on a lab host into a local port on your laptop.
For flashing, take the device's writer lock so nothing else can interleave bytes, and drive the board reset on the real port (a PTY carries no DTR/RTS):
tether -d a35 reset --seq "dtr=0 rts=1 wait=100 dtr=1 rts=0 wait=50 dtr=0"
tether -d a35 pty --lock -- flasher --port {} --no-reset ...
# while --lock is held, other sessions' writes fail with lock_contentionCaveats: a baud rate the tool sets on the virtual port is a no-op (the real
rate is the daemon's config — change it with tether config --baud), and
DTR/RTS toggles can't traverse a PTY (OS limitation) — use tether reset.
There's also a daemon-side always-on variant (-D 'a35=...,pty' →
/tmp/tether-a35.pty) for a single permanent consumer on the daemon host.
To drive a board attached to one host from another machine, start the daemon with TCP listening:
# On the daemon host:
tetherd -D /dev/tty.usbserial-XXXX -b 115200 --tcp
# Banner prints the auto-generated token and every reachable IP. Pin the
# token explicitly with --auth-token MYSECRET if you want it stable across
# restarts. Use --tcp 127.0.0.1:5557 for loopback only.
# On the agent host:
TETHER_AUTH_TOKEN=MYSECRET tether -s tcp://daemon-host:5557 status
TETHER_AUTH_TOKEN=MYSECRET tether -s tcp://daemon-host:5557 run "version" \
--newline crlf -u "# " --literal --timeout-ms 3000 --jsonUDS connections are authenticated by the OS (file permissions); TCP
connections always require a token. Run with both -s /tmp/tetherd.sock and
--tcp ... to expose the daemon on both transports simultaneously.
If you just want to open a shell on your Mac and let an AI agent (or a
colleague) attach over TCP for the duration of that session, the client
itself can spin up an embedded daemon with a TCP listener — no separate
tetherd step:
# On the Mac (or wherever the USB serial is)
tether /dev/tty.usbserial-XXXX --tcp # bare --tcp = 0.0.0.0:5557
tether /dev/tty.usbserial-XXXX --tcp --auth-token MYSECRET
tether /dev/tty.usbserial-XXXX --tcp=127.0.0.1:6666 # custom bind needs =Before the shell drops in, stderr prints the listener + auto-generated token so any remote agent can attach:
tether: also listening on tcp://0.0.0.0:5557
tether: auth token: a3f9...d2c4
tether: remote clients:
tether: TETHER_AUTH_TOKEN=a3f9...d2c4 \
tether: tether -s tcp://<this-host>:5557 status
tether: (this daemon shuts down when you quit — Ctrl-A Q)
The TCP listener is bound to the ephemeral daemon's lifespan: when
you exit the shell, the daemon stops and any remote clients see their
connection drop. For a long-lived shared service that survives your
session ending, run tetherd -D /dev/ttyUSB0 --tcp explicitly.
There are two ways to run several boards. Pick whichever suits the setup.
One daemon per board (process-level isolation, simplest):
tetherd -D /dev/tty.usbserial-A --name board0 # in one terminal
tetherd -D /dev/tty.usbserial-B --name board1 # in another
tether --name board0 status # talk to board0
tether --name board1 run "version" -u "# " --literal --timeout-ms 3000--name defaults each daemon's UDS to /tmp/tetherd-<NAME>.sock so
they don't collide.
One daemon, multiple devices (single endpoint, single auth token — since v0.8):
# Repeat -D / --device for each port. Each spec is `[id=]path[,key=value...]`.
tetherd \
-D 'board0=/dev/tty.usbserial-A' \
-D 'board1=/dev/tty.usbserial-B,baud=921600,parity=odd'
# Clients address devices by id with -d / --device.
tether -d board0 status
tether -d board1 run "version" -u "# " --literal --timeout-ms 3000
tether list-devicesPer-device options inside -D: baud, data-bits, parity, stop-bits,
flow. Anything omitted falls through to the global --baud / --parity /
etc. flags. If a client omits --device against a multi-device daemon
the call returns -32015 ambiguous_device — pick a specific one.
The plain tetherd -D /dev/ttyX and tether <cmd> (no flags) still use
/tmp/tetherd.sock and the only device — single-board setups don't change.
tether --json run "$cmd" -u "$prompt" --literal --timeout-ms 5000
# → { matched, match, output (decoded text), truncated, duration_ms, ... }
# → exit 0 (ok) / 124 (timeout) / 2 (protocol) / 3 (connect) / 4 (device) / 5 (overflow) / 6 (lock) / 7 (unauthorized) / 8 (exec: non-numeric status)Agent-friendly defaults are baked in: --strip-ansi, --strip-echo, --max-output-bytes 8192. The --json payload includes a decoded output field so an LLM never has to deal with base64.
If you want Claude Code / Codex / Cursor to drive the console, see
docs/AI_AGENT_GUIDE.md — a paste-and-go
AGENTS.md / CLAUDE.md block plus a four-step verification script you
can ask the agent to run before handing it the actual task.
docs/PROTOCOL.md — JSON-RPC 2.0 over NDJSON. The same wire format works on UDS, Named Pipe, or TCP.
A virtual serial pair smoke test (no socat required, only Python 3 and the built binaries):
bash tools/smoke_test.shShipped through v0.9.0:
- ✅ Stabilization toward 1.0:
cargo clippy --workspaceclean, 7-test PTY-based integration suite (cargo test --test integration), protocol stability commitment indocs/PROTOCOL.md§10, formal CHANGELOG.md (v0.9.0) - ✅ Tio-style quick-start:
tether /dev/ttyUSB0drops into an interactive shell with no daemon to set up — bare path as the first arg auto-spawns a privatetetherd(v0.8.2) - ✅ Multi-device daemon: one
tetherdowns N ports, addressed by--device <id>(v0.8.0) - ✅
list_devicesRPC +tether list-devicesCLI (v0.8.0) - ✅ Per-device startup config:
-D 'id=path,baud=N,parity=...'(v0.8.0) - ✅ Tio-style line / break / modem control:
send_break/set_dtr/set_rts/read_modem_status+ shell escapes Ctrl-A B/D/R/L (v0.8.0) - ✅ Operator-driven port hold:
disconnect_device/connect_device(v0.8.0)
Through v0.7.x:
- ✅
list_ports/set_device/ livetether config --baudetc. (v0.7.0) - ✅ Shell escapes Ctrl-A C (config) / V (ports) (v0.7.0)
- ✅
--name <NAME>for running multiple daemons side-by-side (v0.7.1)
Through v0.6.0:
- ✅
hello/attach/detach/send/expect/run/status - ✅ writer lock with
preemptpolicy (queue / fail / force) - ✅
strip_ansi/strip_echo/max_output_bytes(with truncation marker) - ✅ standard exit codes; decoded
outputfield in--json - ✅
sync(send CR, wait until idle, surface a prompt candidate) - ✅ ring-buffer fan-out with separate consumer / notify cursors per session
- ✅ TCP transport with token auth (
--tcp [HOST:PORT] --auth-token …) - ✅ Single daemon can listen on UDS and TCP simultaneously
- ✅ Startup banner enumerating reachable IPs and the auth token
- ✅
tether shell— interactive raw-mode client (Ctrl-A then Q to quit) - ✅
tether(no subcommand) drops into the shell - ✅ Friendly error when the daemon isn't running (with the command to start one)
- ✅ Auto-reconnect on the daemon side when the device disappears (USB unplug, etc.)
- ✅
tether reconnectRPC +--auto-reconnectclient flag for retry-on-disconnect - ✅
devicenotifications (disconnected / reconnected) shown intailandshell - ✅ Standalone mode:
tether -D /dev/ttyUSB0auto-spawns a private daemon
Not yet:
- Windows Named Pipe backend
- 30-second session resume after disconnect
cancelmethod- TLS for TCP (use SSH/WireGuard for untrusted networks for now)
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/license/mit)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.





