- Go 100%
| cmd/termd | ||
| internal | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
termd
A headless terminal emulator daemon built for LLMs to drive. An agent talks to it over a plain HTTP API: spawn shell sessions in real PTYs, send keystrokes and terminal mouse events, and read the rendered screen back as clean, style-free text. A human can manage sessions from the CLI and attach to any session live, tmux-style.
LLM / curl ──HTTP──▶ termd daemon ──PTY──▶ bash, vim, htop, ...
│
└─ vt emulator models the screen; GET /screen returns it as text
human ──termd CLI──▶ same API human ──termd attach──▶ live websocket mirror
Build & run
go build -o termd ./cmd/termd
./termd serve # listens on 127.0.0.1:7070
./termd serve -addr 127.0.0.1:9000
./termd serve -unix /run/user/1000/termd.sock # note: socket paths max ~108 chars
./termd serve -token s3cret # require "Authorization: Bearer s3cret"
Quickstart (LLM side, plain curl)
# create a session
curl -s -XPOST localhost:7070/sessions -d'{"command":["bash"],"cols":100,"rows":30}'
# → {"id":"a1b2c3","pid":1234,...}
# type a command and press Enter
curl -s -XPOST localhost:7070/sessions/a1b2c3/input \
-d'{"input":[{"text":"vim notes.txt"},{"key":"Enter"}]}'
# read the screen
curl -s localhost:7070/sessions/a1b2c3/screen
# → {"lines":["...","..."],"cursor":{"x":0,"y":0,"visible":true},
# "modes":{"mouse":"off",...},"alt_screen":true,...}
# click at column 10, row 3 (0-based cells)
curl -s -XPOST localhost:7070/sessions/a1b2c3/mouse \
-d'{"type":"click","button":"left","x":10,"y":3}'
Quickstart (human side)
export TERMD_ADDR=127.0.0.1:7070 # or pass -addr; -token/$TERMD_TOKEN likewise
termd new bash # prints the session id
termd new -cols 120 -rows 40 -cwd ~/src -env FOO=bar htop
termd ls
termd send a1b2c3 'echo hi' Enter # args matching key names are keys, rest is text
termd send -l a1b2c3 Enter # -l: everything literal
termd screen a1b2c3 # plain text; -json full state; -raw styled ANSI
termd resize a1b2c3 120x40
termd mouse a1b2c3 click 10,3 # click|press|release|move|drag|scroll
termd attach a1b2c3 # live read-only view; detach with C-]
termd attach -write a1b2c3 # also forward your keystrokes to the session
termd attach -resize a1b2c3 # push your terminal size too (and SIGWINCH)
termd kill a1b2c3
HTTP API
Every error is {"error":"..."} with a real status code. All coordinates are
0-based screen cells.
| Method & path | Body / params | Returns |
|---|---|---|
POST /sessions |
{"command":["bash"],"cwd":"...","env":{"K":"V"},"cols":80,"rows":24,"term":"xterm-256color"} — all optional; command defaults to $SHELL |
201 session info |
GET /sessions |
— | {"sessions":[...]} |
GET /sessions/{id} |
— | {"id","pid","command","cols","rows","title","exited","exit_code"} |
DELETE /sessions/{id} |
?signal=HUP default (HUP, TERM, KILL, INT, QUIT, USR1, USR2); SIGKILL escalation after 3s. HUP because interactive shells ignore TERM |
204 |
POST /sessions/{id}/resize |
{"cols":120,"rows":40} |
200 |
POST /sessions/{id}/input |
see below | {"written":N} (items delivered) |
POST /sessions/{id}/mouse |
see below | 200, or 409 if the app can't receive it |
GET /sessions/{id}/screen |
?format=text (default) or raw (adds styled ANSI in "raw") |
screen state, below |
GET /sessions/{id}/attach |
WebSocket upgrade | binary = terminal bytes, text = JSON control |
Input
An ordered array; each element is exactly one of:
{"input":[
{"text":"ls -la"}, // literal text, no interpretation
{"key":"Enter"}, // named key, encoded mode-aware (see key names)
{"raw":"[200~x[201~"} // escape hatch: bytes straight to the pty
]}
The batch is validated up front and delivered all-or-nothing; an unknown key
name is a 400 naming the bad element. Input to an exited session is a 409.
Screen
{
"lines": ["$ ls", "notes.txt", ""], // one string per row, right-trimmed
"cols": 80, "rows": 24,
"cursor": {"x": 2, "y": 1, "visible": true},
"alt_screen": false,
"title": "bash",
"modes": {
"mouse": "off | x10 | normal | button_event | any_event",
"mouse_encoding": "default | sgr",
"app_cursor_keys": false,
"bracketed_paste": true
},
"exited": false, "exit_code": null // screen stays readable after exit, until DELETE
}
modes is the application's actual DECSET state, tracked live — check
modes.mouse before clicking.
Mouse
{"type":"click","button":"left","x":10,"y":5,"modifiers":["ctrl"]}
type:press,release,click(= press+release),move,drag,scrollbutton:left,middle,right,wheel-up,wheel-down(scroll only)- Encoded as SGR (
CSI < b;x+1;y+1 M/m) when the app enabled?1006, legacy X10 bytes otherwise.
Events the application cannot receive are rejected with 409, never
silently dropped — the body explains why and includes the current modes:
no tracking mode at all; drag when only ?1000 is on (needs ?1002/?1003);
move without ?1003; anything but press under X10 (?9).
Key names
tmux-style: a named key or single character, with stackable C- (Ctrl),
M- (Alt), S- (Shift) prefixes in any order.
| Names | Notes |
|---|---|
Enter Escape/Esc Tab BTab Space Backspace/BSpace |
BTab = Shift-Tab (CSI Z) |
Up Down Left Right |
CSI A..D, or SS3 A..D when the app set DECCKM (vim, htop...) — handled automatically |
Home End Insert/IC Delete/DC PgUp/PPage PgDn/NPage |
|
F1–F12 |
|
single characters: a Z % - ... |
sent literally |
C-a…C-z, C-Space, C-[ C-\ C-] C-^ C-_ |
control characters 0x00–0x1f |
M-<anything> |
ESC-prefix (e.g. M-x = ESC x) |
S-a → A |
shift is only meaningful on letters |
modified specials: C-Up S-F5 C-M-Delete ... |
xterm CSI 1;m<ch> / CSI n;m~, m = 1+Shift(1)+Alt(2)+Ctrl(4) |
Combinations with no real escape sequence (C-Enter, C-1) are a 400, not
a guess.
Attach
termd attach <id> mirrors the session into your terminal: instant repaint on
join (the emulator re-renders its full state — no scrollback replay needed),
then the live byte stream. Attach is read-only by default — watch without
the risk of typing into an agent's session. With -write, your keystrokes go
straight to the PTY and interleave with API input. Detach with C-] either
way (configurable byte in the client). The session size is API-authoritative;
-resize makes your terminal the authority instead (sent on connect and every
SIGWINCH, last writer wins).
If your terminal is larger than the session, attach automatically switches
to render mode: instead of the raw stream (whose wrap/scroll behavior encodes
the session's geometry and breaks on a bigger screen), the server streams
debounced full frames rendered from the emulator — the session drawn as a
top-left box, every line cleared to your screen edge with default styling, the
area below blanked, cursor placed to match. -stream forces raw passthrough
if you want it anyway.
Wire protocol (if you want your own client): websocket at
GET /sessions/{id}/attach; binary frames are raw terminal bytes both ways;
text frames are JSON control — client sends {"resize":{"cols":N,"rows":N}},
server sends {"hello":{...session info...}} first and {"exited":{"code":N}}
when the process dies. With ?readonly=1 the server drops incoming binary
frames — read-only is enforced server-side, not just client courtesy. With
?view=render binary frames are emulator-rendered repaints (25ms debounce)
instead of the raw byte stream.
Testing
go test -race ./...
Integration-first: the suite boots the real HTTP server with real PTYs and the
real emulator, and drives it through the CLI command functions, so one test
crosses CLI parsing → REST client → routing → session locking → key encoding →
PTY → emulator → screen snapshot. The application inside the PTY is the
"probe": this same test binary re-executed (TERMD_TEST_PROBE=1), which sets
its tty raw, enables whatever DEC modes the test asks for, and echoes every
byte it receives back as hex on its screen — so tests assert the exact bytes
an application would see. Unit tests exist only where integration can't pin
behavior: the keymap table, and vtpin_test.go, which pins the input-encoding
and mode-callback behavior of the unversioned charmbracelet/x/vt dependency
so an upstream change fails loudly.
Architecture notes
- One goroutine reads the PTY and feeds the
charmbracelet/x/vtemulator under the session mutex; the same raw bytes broadcast to attach subscribers. Mode callbacks (DECSET/DECRST, alt screen, title) fire synchronously insideem.Writeand record state used formodesand mouse gating. - Input sent through the emulator's encoders (
SendKey/SendText/SendMouse) is mode-aware for free (DECCKM arrows, SGR vs X10 mouse). Its synchronous input pipe is decoupled from the PTY by an unbounded queue so a child that stops reading stdin can never deadlock a session. - Attach clients' bytes bypass the encoders — their terminal already encoded them — and go straight to the PTY, like tmux.
- A slow attach client is disconnected rather than allowed to stall the
session; a dead process keeps its screen readable until
DELETE.