Teaching your AI agent to drive Serial Tether
Audience: humans who want Claude Code / Codex / Cursor / etc. to drive an embedded board's serial console via Serial Tether.
Already-an-agent? You don't need this file. Run
tether agents(no daemon needed) or read AGENT_USAGE.md instead — that's the cookbook the agent itself should consume.
This doc covers four things:
- The 30-second on-ramp — one line, no paste-and-go required.
- How to pin it permanently into your project (
AGENTS.md/CLAUDE.md), for when you want more than the on-ramp gives you for free. - A verification script you (or the agent) can run to prove it works.
- Failure modes you should know about, so you can unstick the agent fast.
1. The 30-second on-ramp
If the daemon is already running on the host the agent can reach:
tetherd -D /dev/ttyUSB0 -b 115200 # local
# or
tetherd -D /dev/ttyUSB0 --tcp --auth-token MYSECRET # remote-reachable…paste this verbatim into your agent's chat window:
You have access to
tether, the CLI for Serial Tether — a daemon that owns a serial port and lets multiple clients share it. The daemon is already running. Runexport TETHER_NONINTERACTIVE=1, thentether agents— it prints the full cookbook (built into the binary, no network needed, always matches the CLI you're running). Read it before your first real command.First steps:
tether --json statusto confirm the daemon and device, thentether -d <id> exec "<cmd>"for anything at a shell prompt (busybox, dash, bash, U-Boot hush). For a raw console with no shell — bootloader mid-boot, vendor monitor — usetether -d <id> run "<cmd>" -u "<prompt-regex>" --literal --newline crinstead.
That's enough for a competent coding agent to take it from there — tether agents is self-contained and covers exec vs run, exit codes, the -d discipline, and common pitfalls in one place.
For remote daemons add:
The daemon is reachable at
tcp://<host>:5557with tokenMYSECRET. Pass-s tcp://<host>:5557and either--auth-token MYSECRETor setTETHER_AUTH_TOKEN=MYSECRETin the environment.
2. Pin it permanently in AGENTS.md / CLAUDE.md
The on-ramp above (tell the agent to run tether agents) is enough for most projects — it re-reads the current cookbook fresh every session, so there's nothing to keep in sync. Pin a block into your project instead when you want something the live command can't give you: a fixed device id baked in, a non-default socket/TCP endpoint, or non-interactive mode without relying on the agent remembering to export it.
Add this to whichever file your runner auto-loads (CLAUDE.md, AGENTS.md, GEMINI.md, etc.) — it's intentionally short, because tether agents carries the rest:
## Serial console
Board `<id>` is on Serial Tether. The daemon (`tetherd`) is already running;
you connect with the `tether` CLI.
export TETHER_NONINTERACTIVE=1 # once per session — never prompt
tether -d <id> exec "<cmd>" # shell console: output + real exit code
tether -d <id> run "<cmd>" -u "<prompt-regex>" --literal --newline cr --json
# raw console (bootloader, U-Boot w/o hush)
- Always pass `-d <id>` explicitly — never rely on the daemon's default device.
- Prefer `exec` for anything sitting at a shell prompt. Fall back to `run`
only for consoles with no shell to run `echo`/`$?`.
- `--json` whenever you need to parse the result; read `result.output`
(ANSI/echo already stripped), not `result.before`.
- Exit codes: `0` ok, `4` device disconnected, `6` lock contention (another
session is writing), `7` unauthorized (TCP), `8` `exec` ran but the shell
gave no numeric status (non-POSIX console), `124` timeout.
- Full cookbook: run `tether agents`, or read
https://github.com/hulryung/serial-tether/blob/main/docs/AGENT_USAGE.mdIf your project sometimes targets a non-default daemon (different socket path, TCP, multiple boards), add a line alongside:
- Daemon socket: `tcp://lab-host.local:5557`, token in `${TETHER_AUTH_TOKEN}`.
- Or, multiple local boards: pass `--name board0` / `--name board1` to every
`tether` call. (Operator started each daemon with the matching `--name`.)3. Verifying the agent can drive the board
Ask the agent to run this loop. It's exec-first — no prompt detection needed for the common case:
1. tether --json status
→ expect: "device": {"connected": true, "path": "<the path you expected>", ...}
2. tether -d <id> exec "echo READY && uname -a"
→ expect: prints READY + kernel/version info, exits 0.
This works whether the shell is bash/dash/busybox or hush-enabled
U-Boot — no prompt regex to write.If the device has no shell at all (bootloader mid-boot, vendor monitor, a U-Boot built without hush), step 2 times out instead — fall back to:
3. tether -d <id> sync --idle-ms 500 --timeout-ms 3000
→ expect: a non-empty prompt candidate string. Save it.
4. tether -d <id> --json run "version" -u "<PROMPT>" --literal --newline cr --timeout-ms 3000
→ expect: matched=true, output containing the board's version banner.If step 1 fails: the daemon isn't running on a path the agent can reach. If step 2 times out: not a POSIX-ish shell — either it's a raw console (use steps 3-4), or the device needs shell=uboot/shell=none registered (see tetherd --help) so exec knows what it's talking to instead of guessing.
4. Failure modes the agent will hit
Tell the agent to recognize these and self-correct rather than retrying blindly:
- Hangs instead of erroring — a multi-device daemon with no
-d, under a harness-allocated PTY, showing an interactive device picker that will never get a keypress. Fix:TETHER_NONINTERACTIVE=1must be set (or--no-interactivepassed) — always, not just when it seems to be needed. exit 124(timeout) — forexec, usually means the device isn't at a shell prompt (see §3 fallback). Forrun, first suspect--newline(embedded consoles usually wantcr), then a wrong-upattern, then raise--timeout-msif the command is genuinely slow.exit 4(device disconnected) — USB unplug, board reset, or driver hiccup. The agent can calltether -d <id> reconnect --timeout-ms 5000and retry, or add--auto-reconnectto ride out a transient one.exit 6(lock contention) — another session holds the writer lock (likely mid-flash).tetherprintsdevice is locked by another session (flashing?) — try again after it unlocksto stderr. Pass--preempt queue(default) to wait, or--preempt forceonly if the agent is the authoritative caller and knows it's safe to abort the other session.exit 8/exit_code: null(execonly) — the command ran and its output was captured, but the device shell didn't report a numeric$?(a non-POSIX console, classically U-Boot without hush). Not a failure of the command itself — see EXEC_NONPOSIX_SHELLS.md. Registershell=ubootor fall back torun.- U-Boot runs every command twice —
--newline crlfwas sent to it (CR runs the line, the trailing LF repeats it). Usecr, or register the device once withshell=uboot, which enforces CR-only framing forexec/runautomatically from then on. - Garbled bytes / random characters — wrong baud. The agent should call
tether -d <id> statusto read the configured baud live, thentether -d <id> config --baud <N>if it has authorization to change it. - stderr says "attaching as a client — no new daemon spawned" — not an error, but a signal: a daemon was already running for this device, so the agent's
tether -D <PATH>got auto-redirected to it. From this point on the agent should drop-Dand just usetether -d <id> <subcommand>(the redirect already populated the right id). Don't try to "kill" the existing daemon; the user's interactive session may be on it.
For a longer treatment, the agent should read AGENT_USAGE.md §Common pitfalls and §Connecting when a daemon may already be running.
5. Tips per agent runner
These are not requirements — Serial Tether doesn't care which model is calling it — but they're worth knowing. In every case, set TETHER_NONINTERACTIVE=1 in the runner's environment (not just in the pasted prompt) so a stray interactive picker can never block the session.
Claude Code / Codex (terminal agents): they shell out to tether directly. Export TETHER_NONINTERACTIVE=1 in the shell profile or session env the agent inherits. The --json mode produces stable schema output that maps cleanly to their tool-reasoning. They'll run tether agents on their own once told to, and remember it for the session.
Cursor / IDE agents with shell access: same as above, but you may need to ensure the tether binary is in PATH for the IDE's spawned shell (brew install hulryung/tap/serial-tether or cargo install serial-tether puts it in a standard location), and that TETHER_NONINTERACTIVE=1 is in whatever env the IDE passes to its shell — IDE-spawned shells don't always inherit your interactive shell's exports.
Agents without shell access: use the wire protocol directly over a TCP socket — see PROTOCOL.md. Every CLI command corresponds to a JSON-RPC method (run, send, status, …); exec is the one exception built entirely client-side (a wrapper around run), so a raw protocol client should reimplement it as send + expect on begin/end markers, or just use run directly with its own prompt detection.
TL;DR
- Run
tetherd -D /dev/ttyUSB0. - Tell the agent:
export TETHER_NONINTERACTIVE=1 && tether agents— read that, then go. (Pin the §2 block intoAGENTS.md/CLAUDE.mdonly if you need more than that.) - Ask the agent to run the two-step verification from §3 (
status+exec). - Hand it the actual task.
That's the whole setup.