Internals
How ttym works inside
ttym keeps each terminal's PTY in its own process, outside the server, and keeps the terminal's screen in the server. The rest follows from that split: a server restart does not close a terminal, an upgrade replaces the server under running sessions, and an idle agent can be stopped and restarted while its screen stays.
01
A holder keeps the PTY, the server keeps the screen
Each terminal has a holder: a small Rust process that owns the PTY and a ring of the raw bytes it produced. The server runs a headless xterm per session, so it knows the screen, the scrollback and where each command started. Every viewer talks to the server; only the server talks to holders, over a unix socket.
Why this way
Emulation in the server
The holder moves bytes and never parses them, so it has no screen model that must stay in step with the server's. A new server can parse the screen differently without touching running holders, and the holder stays small enough to run for weeks.
One hub for every viewer
The CLI, the browser, the desktop app and a phone all read the same xterm through the same protocol, so they cannot disagree about what is on screen. A holder has exactly one client, the server.
The CLI as a control plane
ttym attach is a viewer. The other verbs (new, split, send, await) are plain HTTP calls, which is what lets scripts and agents drive terminals without a terminal of their own.
Limit. Viewing needs the server. While it is down the terminals keep running, but nothing can attach to them until a server is back.
Source: README · How it works · docs/architecture.md
02
Recovery is a question of byte offsets
The holder counts every byte it reads from the PTY. The server saves, per session, a rendered screen together with the offset it covers. After a restart the server loads that screen and asks the holder only for the bytes after the offset.
A new server reconnecting to a running terminal
- loads the checkpoint: a rendered screen and the byte offset it covers
- connects to the holder's socket
STATE: the holder's generation and the offsets it still holdsACQUIRE: claims the single controller slotACQUIREDDUMP_SINCE(offset)REPLAY: the bytes after the offset- a viewer attaches and gets the caught-up screen
Why this way
A rendered checkpoint, then the delta
A full 3,000-row screen rendered as ANSI takes about 571 KB; the holder's raw ring covers roughly a third of that history. Starting from the rendered screen restores more than replaying the ring would. Checkpoints are written after 2 s of quiet and at most 30 s apart, so the delta stays short.
The same holder, or no checkpoint
An offset only means something within one holder's byte stream. The checkpoint records the holder's generation, and a checkpoint from a different generation is not used; the server replays the ring instead.
One controller per holder
Two servers driving one PTY would interleave input and each build its own screen. The holder hands out one lease. A second server gets DENIED, leaves the session to the first and keeps its record on disk, so the terminal stays reachable.
Upgrades by rename
ttym upgrade puts the new build beside the old one and swaps the directories by rename. Processes that are running keep the files they opened (POSIX), so only the restarted server loads new code, and a failed health check renames back.
Limit. If the requested offset has already left the ring, the holder answers gap=true. The server records the gap instead of presenting the screen as complete.
Source: docs/architecture.md · holder ↔ server · packages/server/src/session.ts
03
One number per viewer: the seq
Each output frame the server sends carries a seq, and each viewer reports the seq it has parsed. From that number the server knows how far behind a viewer is, when to hold output back from it, which bytes it can discard, and what to send when the viewer reconnects.
- bytes into the holder's ring; its offset advances
DATA_OUT- stamped with the next seq and fed to the headless xterm
DATA(seq)ACK(seq)once the viewer's terminal has parsed it
ATTACH{fromSeq}: the last seq this viewer parsed- missing bytes up to 64 KB: replay them; more, or a seq the server does not have: snapshot
DATAdeltas, or oneSNAPSHOT(seq)of the whole screenDATA(seq)from there on
- key bytes, as typed; input frames carry no seq
DATA_IN- written to the PTY
Why this way
Acknowledge parsing, not delivery
The ack is sent after the viewer's xterm has parsed the bytes, not when they arrive. When a viewer has more than 256 KB unacknowledged, the server stops sending to it and resumes below 16 KB; the wide gap keeps the stream from stuttering at the boundary. Only that viewer waits. The PTY and other viewers keep going.
Discard only what everyone has
The server trims its ring to the lowest ack across viewers. Acks are checked (whole numbers, never decreasing, never past what was sent) because a single ack from the future once flushed the ring for every viewer.
Snapshot over a long replay
A pane hidden for a long time can owe the whole ring. Replaying it is a slow visible pour, so above 64 KB the server sends one snapshot instead, as tmux does on attach. After a server restart seqs start again at 1, so a viewer's old seq can be ahead of the server; that case also gets a snapshot.
Snapshots in one write
The snapshot carries the seq it was rendered at, so the viewer resumes from it instead of asking again. It is applied as a reset plus the screen in a single xterm write, which is parsed atomically: the reset and the repaint land in one frame, with no blank frame between.
Note. Frames are decoded by direction because only server-to-viewer data carries a seq. A shared decoder once read the first 4 bytes of longer input frames as a seq, and Korean text committed by an IME in one frame lost characters; a test now runs that case through a real PTY.
Source: docs/architecture.md · wire protocol · packages/server/src/server.ts
04
Waiting for "done" from the program itself
ttym await sends input to a terminal and returns when the program in it says it has finished, not when the screen goes quiet. An agent says so through its Stop hook. A shell with ttym's integration says so with the marks it writes around every command. await uses whichever signal the pane has shown.
# an agent: returns this turn's answer
ttym await :helper --json -- "Why does this stack trace happen?"
# a shell: returns the exit code and that command's output
ttym await :build -- "make test"
- prompt
- puts an xterm marker at the cursor
- prompt + Enter
- the turn runs, however long it pauses
- the agent fires its Stop hook
- the hook reports the stop
- takes this turn's answer from the agent's transcript, or the screen from the marker down
- the answer
- prompt
- prompt + Enter
- the turn errors or the session ends
- the hook reports it
- the failure, at once instead of at the timeout
- command
- command + Enter
- mark: output starts (
OSC 133;C) - mark: finished, with exit code (
OSC 133;D;n) - exit code + the output between the two marks
Why this way
Quiet is not done
An agent can think for minutes without printing, and a build can pause between steps. Watching the screen would either end the wait early or need a long guessed timeout. The program's own signal needs neither.
Markers, not row numbers
Once the scrollback moves, a row number still points inside the buffer but at someone else's output. An xterm marker moves with its line, so the answer is cut from where the prompt actually went.
Marks come from the shell
With one line in ~/.zshrc, the shell writes OSC 133/633 marks into its own output (the FinalTerm and VS Code convention). The server indexes them per session, which gives ttym commands its list and ttym output --cmd N the exact output of one command, with no prompt scraping.
Limits. If the marker has scrolled out of the buffer the answer is null, not the wrong rows. A timeout does not cancel anything: the call returns 202 with a location, and the same request can be picked up later. Marks are not validated; a shell that never writes them just has an empty command list.
Source: docs/architecture.md · interaction · packages/server/src/command-index.ts
05
Stop the process, keep the screen
The server already holds every session's screen, and an agent's conversation is saved by the agent under a session id. So an idle agent's process can be stopped while viewers keep its last screen, and restarted later with the same session id. An idle Claude Code process holds 200 to 600 MB; the default is to stop one after 30 minutes without input or output.
- idle long enough, and nothing says the agent is busy
- freezes the screen for viewers and saves it to disk
- Ctrl-C three times: the agent's own way out
- the agent exits; the shell and the session stay
- a keystroke,
sendorawait - holds the input in a queue
- types the resume command with the agent's original flags
- the agent starts on the same session id
SessionStarthook- ready once output has been quiet for 500 ms
- one snapshot replaces the frozen screen
- the queued input, in order
Why this way
Ctrl-C, not /exit
/exit is a command, so it would be written into the conversation. Ctrl-C three times is the agent's own exit path and leaves the transcript as it was.
Ready means started and quiet
The start hook says the agent is up, but it keeps drawing for a moment after. Waiting for 500 ms of quiet output before the snapshot and the queued input means the input lands in a prompt that is ready for it. Measured: 1.7 s from keystroke to ready.
Busy, asked in order of authority
First the agent's own status file (waiting on a permission prompt or dialog, or busy), then a turn opened by a prompt and not yet stopped, then the background tasks and scheduled wakeups the last Stop hook reported, and last, for older versions, a command still running under the agent.
Limits. An agent waiting on a permission prompt is never put to sleep, because a restarted agent could not answer that prompt. Codex has no prompt hook and reports its start only on its first turn, so its turn and its readiness are read from recent output and from the process, and waking takes about 4 s.
Source: packages/server/src/agent-sleep.ts · CLAUDE.md · agent sleep
06
Local is trusted, everything else passes a gate
The server listens on 127.0.0.1. The CLI, agent hooks and the web UI on this machine need no login, so the first question for every request is whether it came from this machine. Everything else, whether it arrives through tailscale serve, a tunnel or the LAN, passes the same steps.
-
Did it come from this machine?
localloopback peer, loopback Host, no proxy headersno loginlocal-proxya proxy on this machine for a browser on this machineno loginremoteanything elsecontinue ↓ Is the Host allowed?
Only names on the allow-list pass. This also stops DNS rebinding, where a hostile page's name resolves to 127.0.0.1.
Is there a login cookie?
HttpOnly, SameSite=Lax, 30 days, Secure over HTTPS. If yes, the request passes.
-
Can the proxy's identity be verified?
TailscaleThe login header is treated as a claim. The last forwarded hop must be a tailnet address, andtailscale whoisfor that address must return the same, trusted login.Cloudflare AccessThe JWT's signature is checked against the team's keys, with issuer, app and expiry pinned, and its email must be on ttym's own list.If yes, the usual cookie is issued, so one login to Tailscale or Access is enough.
Otherwise, a one-time link
Valid for 10 minutes and one use. The token sits in the URL fragment, which browsers do not send to the server, so it does not appear in server or proxy logs.
Why this way
A header is not an identity
tailscale serve overwrites a forged login header, but the same header could reach the server another way, for example through a local nginx. Asking tailscaled who owns the address closes that path.
ttym keeps its own list
An Access policy loosened by mistake still lets no other email in, because ttym checks the email itself. With Access removed there is no JWT, and only the link works.
Nothing to steal on disk
The allow-list, trusted identities and signed-in browsers are in ~/.ttym/remote.json, mode 0600, with tokens stored as hashes. Only local callers can change them.
Limit. Paths that carry no identity (the LAN, a local nginx, a device on another tailnet account) always need the one-time link. Writes and WebSocket upgrades from other origins are refused, and the server sends no CORS headers.
07
Where things live in the repo
The packages are layered so that a lower layer survives a redesign of the ones above it. A defect is fixed at the lowest layer that has it, and the surface changes last.
- @ttym/web
- browser app: dashboard, splits, agent state
- @ttym/desktop
- Tauri shell around the same contract
- any shell
- the CLI is the compatibility boundary
- @ttym/cli
- headless surface: new · split · send · await · screen
- @ttym/protocol
- wire format, one implementation for both ends
- @ttym/api
- HTTP client shared by the apps
- @ttym/server
- authority on terminal state: cell grid, scrollback, markers, sessions, workspaces, interactions
- @ttym/vt
- framework-free client core: WS mux, local echo, ANSI
- @ttym/ui
- React terminal host + layout views
- @ttym/shared
- domain rules both ends must agree on (layout tree)
- ttym-holder
- Rust, one per session, detached, owns the PTY. Talks to the server over a unix socket only.
Where to start reading
- holder/src/main.rs
- the PTY, the ring, offsets and the lease
- packages/server/src/session.ts
- one session: the headless xterm, checkpoints, reconnecting to a holder
- packages/server/src/server.ts
- the WebSocket side: seq, acks, backpressure, replay or snapshot
- packages/protocol
- the frame format, shared by server and clients
Tests. The recovery and protocol tests spawn real holders and drive a real PTY through the same WebSocket protocol, so reconnection is tested the way it happens in use.
Source: docs/architecture.md · layers