How a client talks to a phux server. The code in crates/phux-protocol is the definition; this page is the map. wire-schema.json lists every frame and field and is generated from the codec, so it cannot be stale.
If you are writing a client in Rust, use phux-client-core and
phux-client-runtime rather than this. If you are writing one in another
language, clients/phux-web is a complete
non-native implementation to read alongside.
Transports
A Unix socket for local clients. QUIC (one bidirectional stream per
connection, more with QUIC_STREAMS), WebSocket (one binary message per
frame), or WebTransport for remote ones, all under TLS. Manual
phux stdio-bridge carries frames over SSH stdio; this is not the normal
attach --ssh bootstrap path. The frame format
is the same on every transport.
Framing
length: u32 big-endian (1 ..= 16 MiB)
type: u8
body: length - 1 bytes
A body is a sequence of tagged fields:
field_id: varint
wire_type: u8 (always 4: length-delimited bytes)
length: varint
value: bytes
Decoders match fields by id in any order and skip ids they do not know, so
an older peer skips additive optional fields. Most nested
records are positional and big-endian, with string leaves encoded as
u32 length || bytes; top-level UTF-8 field values are raw bytes. A
record that is not last in its field (list rows, input events,
arrangement nodes) carries a u32 length; readers skip bytes past the
fields they know, so any record can grow at its tail. Ids and other
leaves are frozen; a new shape is a new tag.
Organization bodies/records and selected extensible event bodies instead
use nested tagged fields: see the organization codec.
Do not infer a nested grammar from a type name in the schema. Field ids
are append-only; a retired id is never reused, and the schema lists them.
FRAME_COMPRESSED (0x9A) wraps one deflated frame. A server uses it only
if the client’s HELLO offered compression, only for bodies of 4 KiB or
more, and never for a frame that settles a request.
Handshake
- The client sends
HELLO(0x01): its name, protocolmajor.minor.patch, andClientCapabilities(color support, image and keyboard protocols, which bootstrap profiles it can consume, compression). - The server checks the client’s major against its own. Any minor or
patch is admitted; another major gets
ERRORwithVERSION_INCOMPATIBLEand a close. - The server authorizes the connection (owner, delegated, or scoped remote authority) and picks a bootstrap profile the client offered.
- The server replies
HELLO_OK(0x80): its version,ServerCapabilities(two words of feature bits,featuresandfeatures_ext), a server instance id, the selected profile, and size limits.
Before HELLO_OK, only HELLO and PING are accepted. Clients accept a
HELLO_OK on their own major with any minor or patch, older or newer, and
keep the server’s version beside its feature bits; only another major is
refused.
A client sends PING (0x7F); the server replies with PONG (0xFF)
carrying the same nonce. PONG is server-to-client only; the classifier
denies a client-originated PONG.
Compatibility
The protocol is 1.0. Any 1.x client works with any 1.x server, older or
newer, and servers may be far older than clients. Minor and patch are
informational. New behavior is a feature bit in HELLO_OK or a field or
record tail an old peer skips; a client checks the bit and degrades that
one feature. A peer answers a value it does not know with UNSUPPORTED and
stays connected; only unreadable bytes close. A new major is a deliberate,
announced break. Compatibility has the full rules.
The feature names, their bits, and which commands each gates are in the
generated kinds reference. phux status --json
prints the running server’s.
Attach and output
ATTACH (0x02) names a session or pane and the client’s viewport. The
server answers ATTACHED (0x81), then for each pane streams a bootstrap
(BOOTSTRAP_BEGIN, BOOTSTRAP_CHUNK…, BOOTSTRAP_READY) that brings the
client’s replica up to the current screen, then ATTACH_READY (0x83).
From there, RESOURCE_OUTPUT (0x90) frames carry each pane’s bytes with
a per-stream sequence number. For a terminal, the bytes are the PTY’s VT
output, and the client feeds them to its own emulator. RESOURCE_CLOSED
ends a stream. DETACH (0x03) gets DETACHED.
Which bootstrap you get depends on the profile the server selected:
- Native state: a libghostty checkpoint, for clients that run
libghostty themselves. Exact, and the only profile that supports history
paging (
HISTORY_REQUEST/HISTORY_PAGE). - Synthesized VT, raw: a VT sequence that repaints the screen, then the raw PTY bytes. Works with any emulator.
- Synthesized VT, state sync: the server keeps a per-client reference
screen and sends deltas against it, acknowledged with
FRAME_ACK.
A fresh successful SPAWN_RESOURCE subscribes its connection to the new
Terminal, even without a session ATTACH: output and terminal-scoped
fanout reach it, and INPUT_* passes the subscription gate. This does not
bypass input leases, authorization, or acknowledged-input checks.
A client that only wants text does not attach: it sends GET_SCREEN
through COMMAND and gets the server’s rendering.
Generations, publication, and history
An attach cuts one terminal actor’s state at an inclusive base_seq.
A generation has nonzero application stream_id and bootstrap_id;
bootstrap chunks are contiguous, and live output begins at base_seq + 1.
Native checkpoint bytes belong to the engine: transport and UI layers must
not rewrite them. Decode into invisible staging. Publish atomically only
when both the matching engine decode and protocol BOOTSTRAP_READY are
ready; ATTACH_READY is not a substitute for either.
A tombstone retires that generation even if late chunks/output arrive.
Keep the last published view display-only until the replacement is ready.
On gaps, overflow, resize or codec discontinuity, fence and recut; never
silently skip bytes or replay them into an obsolete replica. Consumer
mailboxes, stale-output recovery and resync attempts are bounded; failure
ends in an explicit error rather than unbounded backlog or a frozen view.
Only StateSync live output gets a cumulative, generation-bound FRAME_ACK,
after application to the replica; raw output and bootstrap never do.
Normal reliable StateSync advances its server reference on emit: an ACK is
not proof of a paint or application consumption, nor a lossy-delivery promise.
Native history is a bounded, pull-based lease on retained engine history,
not durable scrollback and not part of the attach’s eager work. Follow the
engine cursor and authenticate each page before requesting its successor.
The final page must authenticate the engine’s FINISH; no row-count guess
or protocol cursor alone substitutes for it, and trailing bytes after
FINISH are rejected.
A rejected page/request and an invalidated cursor are distinct outcomes;
a history tombstone invalidates that history, not the live replica or input.
Pruning or history decode failure must not retire a healthy live generation.
The kernel, its
generation/ACK/history tests
and the server pump own these transitions.
QUIC stream routing
With negotiated QUIC_STREAMS, keep connection control on the control
stream and terminal traffic on client-opened bidirectional terminal streams.
Each begins with STREAM_BIND; its application stream id is not the QUIC
stream number. Binding requires authorization for that terminal and frame
provenance must match the binding. A control-stream viewport update must
cross the ordering barrier before dependent terminal-stream work; ordering
within two separate QUIC streams does not provide that barrier for you.
See server stream admission
and the runtime stream router.
The viewport barrier uses a
control PING/matching PONG while retaining preceding control frames.
Without the capability, use the single-stream path.
Input
Input is structured. INPUT_KEY (0x10) carries a key event in
libghostty’s vocabulary: action, physical key, modifiers, text,
unshifted codepoint. INPUT_MOUSE, INPUT_FOCUS, INPUT_PASTE, and
VIEWPORT_RESIZE (0x20) likewise. The server encodes them to bytes for
the PTY according to the terminal’s current modes (application cursor
keys, kitty keyboard protocol, bracketed paste…), so clients never
generate escape sequences.
Terminal queries
The canonical server terminal answers each child query once. Replica
emulator replies are not another source of PTY input. INPUT_TERMINAL_REPLY
remains for compatibility but the server discards it.
New clients suppress replica replies; never resend them as paste or raw input.
Engine tests
pin this single reply.
Receipts, retries, and backpressure
With ACKNOWLEDGED_INPUT, one logical batch uses APPLY_INPUT. Retry an
unresolved request with the same random operation id and unchanged semantic
payload, not its connection-local request_id. This is safe only against
the same HELLO_OK server incarnation within the dedupe retention horizon
(currently ten minutes from admission). Keyed spawn/session creates and
supervisory operations have the same bounded incarnation rule. Saturation
refuses new ids rather than evicting live receipts. After a changed
incarnation or expired horizon, read current state and reconcile; never
blindly replay, even with the old key. A cached create result may name a
resource that has since closed.
Input OK means the PTY writer completed write_all/flush, not child
consumption, shell completion, or model admission. A provably not-written
refusal differs from INPUT_DELIVERY_UNKNOWN, which may already have
written bytes: report/reconcile unknown, never resubmit under a fresh id.
Looking up the retained outcome under the original id is not permission to
execute it again. Legacy INPUT_*/ROUTE_INPUT are ordered and bounded by
connected backpressure, not reconnect receipts. Do not fragment one logical
command into fire-and-forget paste events. See the shared
dedupe store,
acknowledged lane
and incarnation recovery tests.
Events are bounded in-memory wakeups over level state. On a journal gap,
reread authoritative state rather than inferring the missing transitions.
For delayed cleanup, KILL_RESOURCE_IF binds the allocation instance token
(not HELLO_OK.server_id) and attachment/use conditions atomically. A
refusal leaves the resource alone; never fall back to unconditional kill.
Commands, events, resources
Everything that is not attach, input, or output is a COMMAND (0x31)
with a sub-tag and a COMMAND_RESULT, or an EVENT (0xB3) the client
subscribed to. Commands include listing sessions, spawning and killing
panes, GET_SCREEN, tags, organization moves, agent session streams,
approvals. Events include pane lifecycle, agent state changes, asks,
bells, and command boundaries from shell integration.
PERF_CONTROL (command tag 0x39) is gated on
features_ext.PERF_CONTROL (0x80). Its positional body is one op byte:
trace start (0), trace stop (1), heap accounting on (2), or off (3).
Undefined ops decode and receive INVALID_COMMAND. The ordinary owner
socket alone can use it: traces write files on the server host and heap
accounting affects every allocation. Delegated and remote grants cannot
enable it. Older servers do not advertise the bit; clients check before
sending. Reports remain schema 1, with optional rss_bytes and alloc
JSON fields that old readers ignore.
Panes are resources with a kind: Terminal or AgentSession so
far, each with its own methods and events. A client tolerates kinds it
does not know. The kind catalog in phux-protocol::kinds lists every
method with the authorization verb it needs; the generated
kinds reference renders it.
GET_METADATA / SET_METADATA / SUBSCRIBE_METADATA (0x50-0x54)
attach small keyed values to resources. Some keys are plain storage; some
are read by the server (phux.agent/v1 is the agent record).
ORGANIZATION gates typed server-owned organization commands;
phux.tui.layout/v1 is a legacy layout adapter, not a competing source of
membership or arrangement authority. See organization.
An AgentSession has an immutable, one-level Terminal parent. Purging the
parent closes its children atomically; closing a child never kills its
parent. Process exit under Retain is not purge: see
activations and exit policy.
File transfer
PUT_FILE uses a live Terminal to select the destination host, not to grant
arbitrary filesystem writes. The server chooses the sandbox root and name;
offsets and overlapping bytes continue the same principal’s immutable
upload. Only whole-file digest verification, sync and rename publish a final
path. Digest mismatch discards the partial file and sidecar: restart rather
than trying to repair retained corrupt bytes. Completed artifacts belong to
the user and are not silently TTL-deleted; quotas refuse with a remedy.
Keep payloads and completed paths out of routine telemetry. Transfer files
through this lane, not fragmented PTY input. The upload implementation and
tests own continuation and
quota behavior; CLI and
environment own syntax and limits.
GET_FILE is the pull, behind features_ext.FILE_DOWNLOAD. It addresses a
machine, not a pane: an optional host names a satellite of the serving hub,
and no host reads the server itself, the way PATH_QUERY browses. The
client names an absolute path and the server reads any regular file its own
user can read, in chunks of at most 8 MiB. That is deliberately no narrower
than the server grants already: a consumer holding INPUT on this server can
type cat into any of its shells, so the command is classified INPUT on
the server. A hub relays a named host only when that satellite advertises
FILE_DOWNLOAD; otherwise it refuses UNSUPPORTED_SATELLITE_ROUTE and
forwards nothing, so an older satellite never sees an unknown tag. Each
chunk is one stateless request that returns the file’s current size, the
bytes, and, on the chunk that reaches the end, a whole-file SHA-256; a
client resumes from its own offset and must not expose a file whose digest
differs. There is no size cap and no sandbox on the reading side; the
destination disk is the client’s. The disk read runs in the bulk command
lane beside PUT_FILE, off the connection dispatcher. The
download implementation and
the client helper own the read and
the exclusive, verify-then-publish write;
phux download owns syntax.
Checkout methods
CHECKOUT (command tag 0x3c, behind features_ext.CHECKOUT = 0x400)
reads and stages the Git checkout a Terminal is in: status with per-file
line counts, one file’s diff, a page of history, branches, a directory
listing, the start of a file, the branch’s pull request and checks (through
the host’s gh), and stage or unstage. It names a Terminal and one typed
request; the server that owns the Terminal resolves the checkout from the
Terminal’s live working directory, so a client never names a host path.
Paths in requests and answers are relative to the checkout’s top level; an
absolute path, .., anything under .git, or a path crossing a symbolic
link is refused. The command is classified INPUT on the named Terminal
(not on the server, as GET_FILE is), since an INPUT holder can already
run git diff or git add there. The reach is the whole checkout: a
Terminal whose working directory is inside a large repository, such as
dotfiles tracked at $HOME, lets its INPUT holders read every file in that
repository outside .git. Index writes that overrun their deadline are
stopped with SIGTERM before SIGKILL, so Git can remove index.lock.
The request and its answer cross the wire as bounded JSON inside the command
envelope (requests at most 64 KiB, answers at most 4 MiB, text at most
1 MiB and cut flagged truncated). Answers skip unknown fields and fold
unknown enum values to unknown, so a newer server can add detail. A
refusal is a typed answer (not_a_repository, invalid_path, timeout,
gh_unavailable, no_pull_request, …), not a command error; a missing
Terminal or a denied grant stays a command error. Git and gh run off the
dispatcher under a deadline and an output cap, with
GIT_OPTIONAL_LOCKS=0, GIT_TERMINAL_PROMPT=0 and GIT_LITERAL_PATHSPECS=1.
A hub relays a satellite Terminal’s checkout only to a satellite that
advertises CHECKOUT; otherwise it refuses UNSUPPORTED_SATELLITE_ROUTE.
The request and answer types, the
server handler, and the
shared phux-git module own the
details.
Minimal client
- Framing and tagged fields, including skipping unknown ids.
HELLOoffering the synthesized raw profile and no compression; checkHELLO_OK.ATTACH; handleATTACHED, the bootstrap frames,ATTACH_READY,BOOTSTRAP_TOMBSTONEand replacement,RESOURCE_OUTPUTwith exact generation/sequence checks,RESOURCE_CLOSED,ERROR. Publish only at the readiness boundary above; raw/bootstrap never sendFRAME_ACK.- Send
PINGfor liveness and handlePONG. SendINPUT_KEYandVIEWPORT_RESIZE.DETACHto leave. - Feed terminal bytes to an emulator. Tolerate unknown kinds, events, and close reasons.
Changing the wire
Native onboarding is additive inside NATIVE_AGENT, gated by its own
NATIVE_AGENT_LAUNCH bit; see
ACP onboarding.
- Allocate type bytes in
wire/frame/mod.rs, sub-tags inwire/frame/*tags.rs; duplicates fail to compile. - Add field ids in
wire/field/, append-only. - Gate new behavior on a feature bit in
caps/server_features.rs. Do not bump the protocol version for an additive change. A shipped capability bit is never withdrawn or repointed. - Grow a positional record only at its tail; never reorder, retype, or remove a field. A non-final composite record needs a length prefix and a mid-record optional a presence byte (compatibility).
- Classify any new command in
kinds.rs, or dispatch denies it. - Regenerate the schema (
PHUX_UPDATE_WIRE_SCHEMA=1 cargo nextest run -p phux-protocol wire_schema_matches), fill in any?types, andjust docs-genfor the kinds page. - Add roundtrip and snapshot tests next to the existing ones in
crates/phux-protocol/tests/it/. - Say in the PR what an older client sees, and what an older server does with the new request.