Serial Tether — Guide for AI Agents
One page. Read once. You can drive an embedded serial console after this. The same cookbook is available offline straight from the CLI:
tether agents.Want the why before the how? Read OVERVIEW.md first for the mental model and architecture. This file is a script-level cookbook.
You are an AI agent that has been given access to tether, a CLI for talking to a serial device through the tetherd daemon. The daemon owns the port; you attach either over a Unix socket (local) or directly via TCP with a token (remote — see "Remote daemons" below).
Rules — read these first
- Target the device explicitly with
-d <id>. Multi-device daemons require it (they answer-32015 ambiguous_deviceotherwise). Discover ids withtether list-devices --json. Single-device daemons let you omit it. - Stay non-interactive:
export TETHER_NONINTERACTIVE=1(or pass--no-interactive). When a device/port is ambiguous and tether detects a terminal, it may show an interactive picker — which would hang an agent that runs under a PTY. With this set it fails fast with the normal error instead. Plain pipes/scripts are already non-interactive. - Always
--jsonfor scripted calls. Never parse human-readable output. - Prefer
execfor shell commands,runfor raw consoles (next section).
Two canonical commands
Device at a POSIX shell prompt → exec
Busybox / Linux / dash / U-Boot hush sitting at a prompt:
tether -d <id> exec "<command>" # stdout = the command's output
tether -d <id> exec "<command>" --json # {output, exit_code, duration_ms}exec wraps the command so the device shell brackets its output with unique markers, returns only the bytes the command produced (echoed command stripped, even when the terminal wraps a long line), and exits with the device command's own status, like ssh:
if tether -d board0 exec "test -f /etc/os-release"; then echo present; fiNo prompt to detect, no --until to craft. This is the 90% case once a shell is up. Default line ending is cr, which suits both Linux shells and U-Boot; pass --newline lf if a Linux shell needs it.
The wrapper is a single line — echo "<BEG>"; <cmd>; echo "<END>=$?" — with no temp variable, so it runs unchanged on POSIX shells and hush-enabled U-Boot (hush expands $?). If the device shell can't report a numeric status (a non-POSIX console), exec still prints the captured output but reports the status as unknown: exit_code: null in --json, exit code 8 at the process level, plus a one-line stderr hint. It never fabricates a 0.
U-Boot consoles
Register the device as U-Boot once and both exec and run just work:
tetherd -D board=/dev/ttyUSB0,shell=uboot,prompt='=> '
tether -d board exec "mdio list" # unified wrapper, CR-only framing
tether -d board run "printenv" # -u defaults to the device prompt=shell=uboot forces a CR-only line terminator. Never send crlf to U-Boot — its CLI runs the command on CR and then repeats it on the trailing LF (double execution). exec on a shell=uboot device enforces CR for you and warns if you pass --newline crlf/lf. A U-Boot built without the hush parser can't run exec at all; fall back to tether -d board run "<cmd>" -u "=> " --newline cr.
Raw / non-shell console → run
Bootloader mid-boot, login prompt, vendor MCU monitor — anything without a shell to run echo/$? (register these shell=none, which makes exec refuse immediately with this recipe instead of timing out):
tether -d <id> --json run "<COMMAND>" --newline cr -u "<PROMPT_REGEX>" --timeout-ms <T>run is a single daemon-side transaction: it holds the writer lock, sends <COMMAND> plus the newline you choose, and waits for <PROMPT_REGEX> with a hard timeout — race-free, no interleaving from other clients. Serial consoles usually want --newline cr; --newline lf for Unix endings, --newline none if the command already contains its terminator. When --newline/-u are omitted they default to the device's configured newline=/prompt=. Add --literal to treat the pattern as a fixed string instead of a regex (prompts often contain ., $, >).
Stable JSON fields
exec --json:
{ "output": "...", "exit_code": 0, "duration_ms": 12, "truncated": false }
// exit_code is null (never fabricated 0) when the device shell reported no
// numeric status — a non-POSIX console. See docs/EXEC_NONPOSIX_SHELLS.md.run --json / expect --json:
{
"matched": true, // bool — whether the prompt was found
"match": "ASAD SOC => ", // the matched substring
"output": "...response text...", // UTF-8 decoded; ANSI/echo already stripped
"duration_ms": 404, // how long the run took
"truncated": false, // true if `output` was capped
"original_bytes": 4622, // pre-truncation length (when truncated)
"match_seq": 12450, // ring-buffer offset (debug only)
"end_seq": 12462,
"before": "..." // base64 raw — prefer `output` instead
}Use output for everything LLM-related. It is UTF-8 text with ANSI escapes removed and the echoed command line stripped from the front. before is the raw base64 source — only use it when you need exact bytes (binary protocols, non-UTF-8 boards).
Exit codes
0 ok / matched
2 protocol/RPC error (other JSON-RPC failures)
3 connection error (couldn't dial the socket — daemon down?)
4 device disconnected (USB unplugged, etc.)
5 buffer overflow (--max-bytes hit without a match)
6 lock contention (--preempt=fail and someone else holds the lock)
7 unauthorized (TCP transport: --auth-token missing or wrong)
8 exec ran, status unknown (non-POSIX console; exit_code:null — see EXEC_NONPOSIX_SHELLS.md)
124 timeout (no match within --timeout-ms)exec additionally passes through the device command's own exit status (like ssh) — so a non-zero exit from exec usually means your device command failed, not tether. Branch on it directly:
if out=$(tether -d board0 exec "cat /proc/uptime" --json); then
echo "$out" | jq -r .output
else
case $? in
124) echo "timeout — device alive but unresponsive";;
4) echo "device gone — abort";;
*) echo "device command failed (rc=$?)";;
esac
fiStderr always carries a one-line context message on failure (timeout, etc.).
First-attach procedure
When you first connect to an unfamiliar setup, do this exact dance:
# 1. What's connected? ids, paths, connected state.
tether --json list-devices
# 2. Confirm the device + baud for the one you want.
tether -d <id> --json status | jq '{path: .device.path, baud: .device.baud}'
# 3. Is it at a shell? Just try exec.
tether -d <id> exec "echo READY && id"
# If exec times out or the device is a raw console, detect the prompt and use run:
PROMPT=$(tether -d <id> sync --idle-ms 500 --timeout-ms 3000)
tether -d <id> --json run "version" --newline crlf -u "$PROMPT" --literal --timeout-ms 3000Don't guess the prompt for run. Boards differ. sync is cheap.
Race-free patterns
- For shells use
exec; for raw consoles userun. Both are single transactions —runholds the writer lock, captures the seq before the write, and matches from there;execis built on the same primitive. - Don't hand-compose
send+expectunless you have a reason. When multiple clients are attached (human + agent), interleaving bytes from separate sends can corrupt either client's matching. - Set
--timeout-msexplicitly every time. Default is 3000ms (exec: 5000ms). For boot, flash, or memory dumps, raise it. Never passnull/0 expecting "wait forever" — use a generous concrete bound like60000.
Length safety
- Default output cap is
--max-output-bytes 8192forrun/expect,65536forexec. Iftruncated: trueyou got the trailing N bytes (the prompt / end-marker is at the end, so you usually want this). - Set lower (e.g.
1024) when you only care about pass/fail. - For commands that print megabytes (
md,ext4ls,cat /var/log/...), filter on the device side (| tail,| grep) rather than letting the host swallow it.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
-32015 ambiguous_device | Multi-device daemon, no -d | Add -d <id> (list-devices to find it) |
| Agent hangs on a menu | PTY + ambiguous target | Set TETHER_NONINTERACTIVE=1 and pass -d <id> |
run times out, no match | Forgot --newline | Add --newline cr (serial), or lf/crlf |
exec times out | Device isn't at a shell | Register shell=uboot/shell=none, or use run/send/expect |
exec exit 8 / exit_code: null | Non-POSIX console: no numeric $? | See docs/EXEC_NONPOSIX_SHELLS.md |
| U-Boot runs every command twice | Sent --newline crlf | Use cr (or register shell=uboot) — never crlf to U-Boot |
| Output begins with the command itself | run --strip-echo disabled | Re-enable (on by default); exec strips it always |
| Match found in previous output | expect without anchoring | Use run (or exec) instead |
| Garbled / random bytes | Wrong baud rate | tether -d <id> status shows the configured baud |
| Connection error | Daemon not running, or wrong -s | Check ls /tmp/tetherd.sock; start a daemon |
What NOT to do
- Don't use stdin streaming / interactive shell mode. That's for humans. Every command should be one
exec(orrun) call. - Don't omit
-d <id>on a multi-device daemon. You'll get-32015, or — under a PTY withoutTETHER_NONINTERACTIVE— an interactive menu that stalls. - Don't omit
--timeout-ms. A wrong default will hang your loop. - Don't strip the prompt/marker manually from
output. It's already separated —matchholds the prompt,outputthe response;execreturns output only. - Don't parse
--versionor human-readable output. Always use--json. - Don't open multiple writer-lock sessions in parallel. For concurrent reads, run
tether -d <id> tailin one connection andexec/runin another. Don't race two parallelrun/execwrites.
Connecting when a daemon may already be running
The normal case: a long-lived tetherd is already up (someone ran it, or a human has a tether shell open). Just attach as a client — don't spawn a competing daemon for the same port.
# Is anyone home? Either succeeding means a daemon is running.
tether --json list-devices # ids/paths it manages
tether -d <id> --json status # the device you care about
# Then drive it as a client (no daemon management on your side):
tether -d <id> exec "<cmd>"Notes for automation:
tether -D <PATH>/tether <PATH>are "lazy spawn". They probe/tmp/tetherd*.sockfirst; if an existing daemon already owns that path they attach to it as a client (a short "attaching as a client" notice on stderr) instead of starting a second daemon that would scramble the port. You don't need a separatepgrep tetherdcheck.- Bare
tetherwith no daemon will, in an interactive terminal, offer a port picker and start a throwaway session daemon. Agents should not rely on that: withTETHER_NONINTERACTIVE=1there's no prompt, so be explicit — start a daemon (tetherd -D /dev/ttyUSB0 -b 115200 &) or use the standalone form (tether -D /dev/ttyUSB0 exec "...").
Detecting what kind of device you're talking to
PROMPT=$(tether -d <id> sync --idle-ms 500)
case "$PROMPT" in
*"=> ") echo "U-Boot or similar bootloader";;
*"# ") echo "root shell (busybox/Linux as root)";;
*"$ ") echo "user shell";;
*">"|*">>"|*"]>") echo "MCU REPL or vendor monitor";;
*) echo "unknown — try 'help' or '?' or '\\r'";;
esacA starting heuristic, not gospel. Shells (#, $) → use exec; bootloaders / monitors (=>, >) → use run. Verify with version, uname, etc.
Multiple boards on one host
Two patterns. Prefer B (one daemon, many devices) — fewer moving parts and no port double-open.
A. One daemon per board (--name selects which daemon):
tetherd -D /dev/ttyUSB0 --name board0 &
tetherd -D /dev/ttyUSB1 --name board1 &
tether --name board0 status
tether --name board1 exec "uname -a"--name X is shorthand for -s /tmp/tetherd-X.sock.
B. One daemon, multiple devices (-d / --device selects which device-in-daemon):
# Each -D is `[id=]path[,key=value,...]`. Per-device baud / parity / data-bits
# / stop-bits / flow override the global flags.
tetherd \
-D 'board0=/dev/ttyUSB0' \
-D 'board1=/dev/ttyUSB1,baud=921600' &
# Address devices by id.
tether -d board0 status
tether -d board0 exec "uname -a"
tether -d board1 sync
tether list-devices # daemon-wide; no -d neededMulti-device daemons answer -32015 ambiguous_device if -d is missing and
1 device is managed. Single-device daemons fall back to the only device, so
-dis optional there.
Tio-style line / break / modem control
tether -d <id> break --duration-ms 250 # send a BREAK pulse
tether -d <id> dtr on # assert DTR
tether -d <id> dtr off # deassert
tether -d <id> rts on # assert RTS
tether -d <id> lines # → CTS=1 DSR=0 RI=0 DCD=1
tether -d <id> disconnect # explicit close (parks port)
tether -d <id> connect # release the hold, reopen
tether -d <id> config # show live baud/framing/flow
tether -d <id> config --baud 921600 # change it liveThe Fd backend (PTYs, pipes) returns -32007 unsupported_serial_op for break / dtr / rts / lines.
Remote daemons (over a network)
Native TCP transport with token auth:
# Daemon host (board operator):
tetherd -D /dev/tty.usbserial-XXXX --tcp --auth-token MYSECRET
# (bare `--tcp` defaults to 0.0.0.0:5557; omit --auth-token to auto-generate)
# Agent host (you):
export TETHER_AUTH_TOKEN=MYSECRET
export TETHER_NONINTERACTIVE=1
tether -s tcp://daemon-host:5557 -d <id> status
tether -s tcp://daemon-host:5557 -d <id> exec "uname -a"UDS still works for local connections (OS-level auth via file permissions). A single daemon can listen on both transports at once.
If you're stuck with a pre-TCP daemon you cannot upgrade, fall back to SSH forwarding the UDS:
ssh -N -L /tmp/tetherd-remote.sock:/tmp/tetherd.sock user@daemon-host &
tether -s /tmp/tetherd-remote.sock -d <id> statusDon't waste effort on socat bridges; native TCP is simpler and the wire protocol is identical.
Worked example A — Linux shell (use exec)
Goal: read a value, act on it, check a file, all by exit code.
#!/usr/bin/env bash
set -e
D=board0
tether -d $D exec "uname -a"
UP=$(tether -d $D exec "cut -d. -f1 /proc/uptime" --json | jq -r .output)
echo "uptime ${UP}s"
if tether -d $D exec "test -e /dev/mmcblk1"; then
echo "eMMC present"
else
echo "no eMMC — exit $?"
fiWorked example B — U-Boot (raw console, use run)
Goal: read U-Boot environment, set a variable, save it, verify.
#!/usr/bin/env bash
set -e
D=board0
PROMPT=$(tether -d $D sync --idle-ms 500 --timeout-ms 3000)
ub() {
tether -d $D --json run "$1" --newline crlf -u "$PROMPT" --literal --timeout-ms 5000 \
| jq -r .output
}
echo "--- before ---"; ub "printenv bootdelay"
ub "setenv bootdelay 5"
ub "saveenv"
echo "--- after ---"; ub "printenv bootdelay"sync once at the start, run per command with the same prompt, parse output.
Reference cheatsheet
tether agents # this guide, offline
tether list-devices --json # ids / paths / connected
tether -d <id> status # daemon + device info
tether -d <id> exec "<cmd>" # shell: output + exit code
tether -d <id> exec "<cmd>" --json # {output, exit_code, duration_ms}
tether -d <id> run "<cmd>" -u "<prompt>" --literal --newline crlf # raw console
tether -d <id> sync --idle-ms 500 # detect prompt
tether -d <id> tail # stream output (read-only follow)
tether -d <id> send "any-bytes" --newline crlf # fire-and-forget
tether -d <id> expect "<regex>" --timeout-ms <T> # rare; prefer run/execSet TETHER_NONINTERACTIVE=1 once in your environment. That's it. You're ready.