Architecture
30 Jul 2026 — describes the implementation as of today; this document tracks the code, not the roadmap
The shape of it
┌──────────────────────────────┐
shell ── PTY ────────┤ termwired │
vim ── PTY ────────┤ per-user session runtime │
agent ── PTY ────────┤ │
│ owns: PTYs, processes, │
│ screen state, scrollback, │
│ (later: workspaces, leases) │
└──────┬────────────┬──────────┘
control plane data plane
(JSON, one (framed in /
socket) raw out, one
│ socket per session)
│ │
┌─────────┴──┬─────────┴─┬───────────┐
│ tw (CLI) │ browser │ desktop │
│ today │ next │ later │
└────────────┴───────────┴───────────┘
One daemon per user. Applications keep believing they talk to an ordinary terminal — they do: an ordinary PTY whose master side is held by the runtime instead of by a window. Clients are interchangeable views that attach and detach at will; the session's lifetime belongs to the runtime alone.
Terminology
The traditional stack had comfortable names — terminal emulator, multiplexer, shell — that stop describing anything precisely once the ownership moves. Email solved this with role names (MUA, MTA, MDA) rather than program names, and that is the right instinct: these are roles, and one program may play several.
| term | role |
|---|---|
| application | what runs on the PTY slave side and believes it owns a terminal: bash, vim, htop, ssh. (What people loosely call a "terminal app".) |
| PTY | the kernel pseudo-terminal pair, unchanged since the eighties. |
| engine | the VT interpreter that turns an output byte stream into screen state (ghostty-vt here). Every legacy emulator has one buried inside; TermWire hoists it into the runtime. |
| session | one PTY + its application + its canonical screen state and scrollback. The unit of persistence. Owned by the runtime, never by a window. |
| runtime | termwired: the per-user daemon that owns every session. The PipeWire of this story. |
| view | anything that renders a session and gathers input for it: tw in an outer terminal, a browser tab, a native window, a phone. Views are disposable; sessions are not. |
| controller / observer | a view that may write to the session vs. one that only watches. (Today every view is a controller; the lease is on the roadmap.) |
| bridge | a protocol adapter between a view's transport and the runtime's sockets: termwire-webd for browsers; others may follow. |
| workspace | a semantic group of sessions belonging to one task (planned). |
In these terms, a classic terminal emulator is an engine and a view fused into one process that also owns the PTY; a multiplexer is a second engine wedged into the byte stream. Under TermWire, xterm or Ghostty can shed both jobs and be what they are best at: a view.
Components
| Path | What it is |
|---|---|
daemon/ | termwired — the runtime: a single-threaded poll loop owning all sessions, both socket planes, and the VT state |
protocol/ | wire-format types and socket helpers shared by daemon and clients |
cli/ | tw — CLI client: new, ls, attach, kill |
webd/ | termwire-webd — web bridge: serves the built web client and bridges WebSockets to the daemon's Unix sockets |
web/ | browser client: Vue 3 + Motion + xterm.js, built with Vite |
third_party/ghostty | vendored Ghostty; the daemon imports its ghostty-vt Zig module (libghostty-vt) for terminal emulation |
third_party/xterm.js | vendored xterm.js 6.0.0 for the browser client |
Runtime directory
Everything lives under $XDG_RUNTIME_DIR/termwire/
(fallback /tmp/termwire-<uid>/, mode 0700):
$XDG_RUNTIME_DIR/termwire/
control.sock control plane (one per user)
session-1.sock data plane (one per session)
session-2.sock
...
Control plane
Newline-delimited JSON over control.sock: one request
per line, one response per line, connection stays open. Structured,
out-of-band, never tunneled through the terminal stream.
$ python3 -c '
import socket, json, os
s = socket.socket(socket.AF_UNIX)
s.connect(os.environ["XDG_RUNTIME_DIR"] + "/termwire/control.sock")
s.sendall(b"{\"op\":\"create_session\",\"cols\":120,\"rows\":40}\n")
print(s.recv(4096).decode())'
{"ok":true,"session":{"id":1,"pid":184532,"cols":120,"rows":40,
"socket_path":"/run/user/1000/termwire/session-1.sock"}}
Current operations:
| op | fields | effect |
|---|---|---|
create_session | cols, rows (optional) | spawn $SHELL on a fresh runtime-owned PTY; returns the session and its data socket path |
list_sessions | — | enumerate live sessions |
kill_session | id | SIGHUP the session's process group; the session is reaped when the child exits |
Workspaces, attach leases, permissions, and agent operations will be further ops on this same plane.
Data plane
One Unix socket per session. Deliberately dumb — this is the path every output byte takes, so no JSON, no policy, no parsing beyond a 5-byte frame header (type byte + u32le payload length), used in both directions.
| type | direction | payload |
|---|---|---|
data (0) | client → daemon | keyboard/stdin bytes, written verbatim to the PTY (max 4096 per frame; chunk larger writes) |
resize (1) | client → daemon | 4 bytes: cols u16le, rows u16le → TIOCSWINSZ and the runtime's terminal state |
snapshot (2) | daemon → client | sent once on attach: a VT byte sequence reconstructing the session's current screen — palette, modes, content, styles, cursor — generated from the runtime's canonical terminal state |
output (3) | daemon → client | raw PTY output |
Attach semantics. Every PTY byte flows through
the runtime's VT engine before being broadcast, so the runtime
always holds the canonical screen. On attach the daemon serializes
that screen (using ghostty-vt's TerminalFormatter, VT
format) into one snapshot frame, then streams output frames. The
daemon is single-threaded, so the snapshot is atomic with respect to
the stream: output that arrives during snapshot generation is
applied and broadcast after it, and every attached view of
a session sees the same bytes in the same order — views cannot
drift. The snapshot payload is itself VT, so any VT-speaking view
replays it with the parser it already has; a structured binary
snapshot for state-aware views can be added as a new frame type
without disturbing this one.
Multiple clients may be attached; today all of them receive output and all of them may write (the one-controller lease is on the roadmap, not in the code).
The web stack
The browser client is deliberately not inside the daemon.
termwire-webd is a separate binary that serves the built
web UI and translates between WebSockets and the daemon's Unix
sockets; the daemon never learns HTTP, and the browser never learns
Unix sockets. Kill the bridge and every session keeps running —
which is the whole point.
browser (Vue + xterm.js)
│ ws://127.0.0.1:7181/ws/control JSON, one per text frame
│ ws://127.0.0.1:7181/ws/session/{id} binary in/out
▼
termwire-webd ── unix sockets ──► termwired
| endpoint | behavior |
|---|---|
GET / | the web client (static files from --root, default web/dist) |
/ws/control | each text frame is one control request; each response line comes back as one text frame |
/ws/session/{id} | binary frames in = raw keyboard bytes; text frames in = {"resize":{"cols":N,"rows":N}}; binary frames out = raw PTY output. The bridge builds the data-plane framing, so the browser stays protocol-ignorant. |
The client itself is Vue 3 with Motion for animation and xterm.js
(pinned to the same 6.0.0 as third_party/xterm.js) for
rendering: a session sidebar with live sizes and ages, create/kill,
and a terminal pane that follows the browser layout and syncs the
PTY size on attach and resize. termwire-webd binds
127.0.0.1 only and has no authentication yet — do not expose it.
The VT engine
The daemon links Ghostty's terminal core, consumed as the
ghostty-vt Zig module straight from the vendored tree —
the same VT emulation Ghostty ships to its users, not a
reimplementation. This is what makes the runtime the canonical owner
of screen state: every session's PTY output flows through
ghostty.TerminalStream into a per-session
ghostty.Terminal, and attach snapshots are serialized
from that state with ghostty's TerminalFormatter. Any
client — a reattaching CLI, a browser tab, a phone — is brought
current from the runtime's copy instead of keeping its own.
Building
Requires Zig 0.16.0 — exactly. Zig build-system APIs shift between minor versions, and the vendored Ghostty is pinned to match; the two move together (see the README for the current pins).
$ git clone https://github.com/toppk/termwire
$ cd termwire
$ git submodule update --init --depth 1
$ zig build
$ zig build test
Day-to-day tasks go through just (bare
just lists the recipes; each echoes the commands it
runs, and build-graph work delegates to zig build). The
web client additionally needs node/npm, only at build time:
$ just serve # npm build + bridge on http://127.0.0.1:7181
Try it:
$ zig-out/bin/termwired # terminal 1: the runtime
$ zig-out/bin/tw new # terminal 2: create + attach
$ MARCO=polo # ... do some work ...
# Ctrl-\ detaches; close the window
$ zig-out/bin/tw ls # later, any terminal:
ID PID SIZE SOCKET
1 184532 120x40 /run/user/1000/termwire/session-1.sock
$ zig-out/bin/tw attach 1 # your shell, exactly as you left it
$ echo $MARCO
polo
Honest limitations, today
- Attach replays the current screen, not scrollback history — that needs runtime-owned scrollback serialization.
- No backpressure: a stuck client can stall the loop on write.
- No single-instance lock; a second daemon stomps the first's control socket.
- No controller lease: every attached client can type, and every client's resize is applied — with two differently-sized views on one session, the last resize wins.
- No authentication on the web bridge; it binds localhost only.
- Daemon crash takes sessions with it (see the manifesto FAQ).
Milestones
- Done: runtime daemon, PTY ownership, control plane, data plane, CLI attach/detach, ghostty-vt linked and tested.
- Done: browser client —
termwire-webdbridge plus the Vue/xterm.js UI inweb/. - Done: runtime-owned screen state — every session's output flows through a ghostty-vt Terminal in the daemon, and attach replays a snapshot of it. Next: extend the snapshot with scrollback history.
- Workspace model; read-only observers; controller leases.
- Native desktop client — informed by the web client, and likely thinner: less an emulator, more a window manager for sessions ("bring this terminal to the foreground").
- Mobile, collaboration, and the agent API.