Skip to content

Wire protocol

This page describes the protocol between the phone and Tealhand Host as implemented in tealhand-proto, tealhand-host (src/ctl.rs, src/pairing.rs, src/files.rs), tealhand-files (src/serve.rs) and tealhand-core (src/link.rs, src/files.rs, src/transfer.rs). Capability version: 12.

Since version 7 the client side is a phone or one of the owner’s computers (decision 52): a computer’s own client is tealhand-core in computer mode inside the Tealhand app’s process, signs with the computer’s endpoint key, dials through the Host’s own iroh endpoint, and says tealhand-computer/<version> in its Hello. Below, “phone” in a direction means either client unless the text says a phone only.

  • iroh 1.x QUIC between two endpoint keys (Ed25519). iroh proves each peer’s key in the TLS handshake.
  • The ALPN is chosen once per connection: one connection per pair of devices and ALPN.
  • tealhand/pair/1: accepted from anyone, but only while the computer’s connection door is open (a QR is showing) and fewer than 4 pair/1 connections are live. Otherwise the connection is closed with NotAllowed.
  • tealhand/ctl/1, tealhand/files/1 and every other ALPN: only for current, unexpired roster members, in both directions. Otherwise closed with NotOnRoster.
  • tealhand/files/1 carries the shared-folder protocol (The files/1 session).
  • tealhand/ssh/1 carries a terminal, for phones and computers on the device list while SSH access is on: each stream starts with a signed shell.open that the computer checks before it reads any SSH byte (tealhand-ssh).
  • Reserved, no messages yet: tealhand/blobs/1, tealhand/llm/1, tealhand/cam/1.

Each frame is a 4-byte big-endian length followed by that many bytes: the postcard encoding of an Envelope.

  • MAX_FRAME is 65,536 bytes. A reader refuses a larger length before reading the body; a writer refuses to write one. The stream is then closed.
  • Decoding is strict: bytes left over after the envelope are an error.
  • read_frame and write_frame are not cancel-safe: read from a dedicated loop and close the stream on any error.
Envelope {
v: u32 capability version of the sender (ctl/1: the negotiated version after Hello)
ty: MsgType
id: u64 request id; answers carry the id of what they answer; unasked events use 0
body: bytes postcard of the struct that `ty` names
}

postcard encodes an enum variant as its position in the declaration, as a varint. So the variant order of every wire enum is the wire format: new variants go at the end, nothing is reordered or removed. Unknown variants are a hard decode error.

Value MsgType Body Direction
0 Hello Hello { version: u32, min_version: u32, app: String } Both, first on ctl/1
1 Request Request { command: bytes } (an encoded SignedCommand) Phone or computer to computer
2 Response Response { result: Result<bytes, ErrorCode> } (Ok: an encoded ActionOutput) Computer to phone
3 Subscribe Subscribe { command: bytes } (a signed device.status) Phone or computer to computer
4 StatusPush StatusPush { status: bytes, tasks: bytes } (postcard DeviceStatus and Vec<TaskInfo>) Computer to phone
5 Event Event { at: u64, kind: EventKind } Computer to phone
6 Close (none read) Either
7 PairHello see the pair/1 session Phone to computer
8 PairReply see the pair/1 session Computer to phone
9 PairConfirm PairConfirm { roster: bytes } (an encoded SignedRoster) Phone to computer
10 PairResult PairResult { ok: bool, error: Option<ErrorCode> } Computer to phone
11 RosterPush RosterPush { roster: bytes } (an encoded SignedRoster) Phone to computer (from a computer: NotAllowed)
12 SessionRequest SessionRequest { action: bytes } (a postcard Action, unsigned), on files/1 only, after the signed opener Phone to computer
13 ApprovalAsk ApprovalAsk { command: bytes } (an encoded SignedCommand: shell.open, or shell.run_key of a saved command marked for the fingerprint), version 9 Computer to computer
14 ApprovalAnswer ApprovalAnswer { id: u64, grant: Option<bytes> } (an encoded SignedApproval; None declines), version 9 Phone to computer
15 PutResume PutResume { command: bytes, key: [u8; 16] } (a signed files.put that does not replace), on files/1 only, version 10 Phone or computer to computer
16 PutEnd PutEnd { sha256: [u8; 32] } (the whole file), after a resumable upload’s bytes, version 10 Phone or computer to computer
17 PutDiscard PutDiscard { key: [u8; 16] } (drop a paused upload), version 10 Phone or computer to computer

CAPABILITY_VERSION = 12 and MIN_PEER_VERSION = 6. negotiate(peer) refuses the peer with VersionTooOld when peer.version < MIN_PEER_VERSION or peer.min_version > CAPABILITY_VERSION; otherwise the session version is the lower of the two versions. The receiver checks the minimum itself, whatever the sender claims.

The history, one integer per protocol change: 1 is the first version (2026-10-16); 2 adds RosterPush, the power and rule events, the NoGpu, TooManyRules and TooLate codes and CancelledBy; 3 adds the files/1 ALPN, SessionRequest, the error codes 17 to 25, the download and run events, the field wake in PairReply, and the new actions and outputs below; 4 adds RunInfo.output, the end of a finished saved command’s output in shell.list; 5 adds download pages (DownloadState::Page, DownloadInfo.links and the DownloadPage event); 6 makes download.start name its folder (into), adds the Default change to files.share and adds commands.add and commands.remove; 7 lets a computer on the device list command the owner’s other computers and adds the PeerChanged event and CancelledBy::Computer. The minimum stays 6: a Host sends neither addition to a peer that negotiated 6 (it tells CancelledBy::Computer as ThisPc), so a version-6 phone keeps working. 8 adds os and wake to StatusPush (an older peer gets the version-7 body). 9 adds the owner’s phone approval of a computer’s terminal or fingerprint command (decision 54): ApprovalAsk, ApprovalAnswer, the ApprovalRequest and ApprovalClosed events, ApprovalWhat, and the codes 26 to 28; a Host sends the events only to phones that negotiated 9 and the codes only in answer to an ApprovalAsk. 10 adds files.move and files.hash, the messages 15 to 17 (resumable uploads), the outputs PutFrom and Hash and the code 29 Mismatch (decisions 57 and 58); a client sends them only to a Host that negotiated 10 (an older Host gets a plain files.put and no check at the end, and the phone says to update it before a move). 11 adds lans to StatusPush: up to 4 opaque 8-byte LAN tags (decision 56, wakers), sent only to a peer that negotiated 11 (versions 8 to 10 get the version-8 body). 12 adds a coding agent on the computer itself asking the phone (decision 55): ApprovalWhat::AgentTool and the AgentNotice event, sent only to phones that negotiated 12. 18 adds the code Disconnected (a computer disconnected one phone, also the QUIC close code; decision 59). 19 adds member phones and the recovery code (decisions 60 and 61): device lists in format v2, PairReply.managed, PairJoin, PairResult.roster, ApprovalWhat::AddPhone, the Roster event, RecoverAsk, RecoverBase and RecoverConfirm; a Host sends them only to a peer that negotiated 19, and the owner phone signs a v2 list only when every computer on it negotiated 19. A build refuses a peer below 6, so update both ends.

One QUIC connection per client (phone or computer) and computer, and one long-lived bidirectional stream, opened by the client.

  1. Hello. The phone sends Hello within 10 seconds, with the envelope’s v equal to hello.version (otherwise Malformed). The computer answers with its own Hello (app = tealhand-host; the phone sends tealhand-phone/<version>, a computer’s own client tealhand-computer/<version>). When the versions do not overlap, the computer closes the connection with VersionTooOld and sends no Hello of its own; the phone reads that close code. From then on every frame must carry v = the negotiated version, or the connection is closed with Malformed. The app string is capped and only logged at debug level.
  2. Request and Response. Request { command } is answered by a Response with the same id.
  3. Subscribe and StatusPush. Subscribe { command }, where the command is a signed device.status, is checked like any request. On success the computer sends a StatusPush at once and then every 5 seconds, all with the Subscribe’s id, and re-checks the phone’s roster membership before each push (closing with NotOnRoster when it is gone). On failure it answers with a Response carrying the error, and there is no subscription. One subscription per connection; a second one gets Busy. When the computer cannot read its own status, it still sends a push with nothing known, so the phone does not mistake silence for a dead link.
  4. RosterPush. RosterPush { roster } is answered by a Response: Ok with an empty body, or the error. Only a phone delivers the device list: from a computer it is NotAllowed and a journal line. The roster must be signed by the stored owner. The same roster again (same hash) is answered Ok and changes nothing, so a push whose answer was lost can be repeated. Any higher version of the same owner is accepted, without a prev_hash match, so a computer that missed versions catches up; a lower or equal version with a different hash is Rollback, and another owner is refused. A higher version may remove, rename or expire devices and add computers, but it may not add a phone, change the kind or the keys of a device already listed, or give a new computer keys (NotAllowed, the list stays). If it is a valid higher version that no longer lists this computer, the computer wipes its saved connection, answers Ok, and closes all connections.
  5. Event. The computer sends Event frames with id 0, unasked. 5a. Approval (version 9). A computer sends ApprovalAsk { command } before a shell.open, or a shell.run_key of a saved command marked for the fingerprint, on another computer. The Host verifies the command like a Request, checks every other gate (remote control, SSH access, the elevated Host), answers NoPhone at once when no phone with a presence key is connected at version 9, else sends ApprovalRequest to its phones and answers the ApprovalAsk when one of them did, or after 60 s with NoAnswer; other frames go on meanwhile. A phone answers ApprovalAnswer { id, grant }: None declines (Declined for the computer); a grant is checked against that phone’s presence key (see Signed payloads) and leaves a one-time ticket (30 s): the computer’s next matching command counts as having presence.
  6. Close. Either side may send Close. A second Hello, a message type only the computer sends, or a body that does not decode closes the connection with Malformed. A frame that is too large or broken just ends the session, with no close code.

Limits on the computer: 30 requests (Request, Subscribe, RosterPush, ApprovalAsk or ApprovalAnswer) per 10 seconds per connection, at most 2 approvals waiting per computer and 8 per Host, more get Busy; at most 4 connections per phone; 120 seconds of silence from a phone without a subscription closes the connection (Expired); a write that takes over 10 seconds closes it (Expired). The phone treats 25 seconds of silence from a subscribed computer as a lost link and reconnects.

Value EventKind Meaning
0 RuleFired { rule_id: u64, deadline: u64, then: PowerAction } A rule’s shutdown or sleep notice started; deadline in Unix seconds; then is Shutdown 0 or Sleep 1
1 PowerCancelled { by: CancelledBy } The notice was cancelled; by is Phone 0, ThisPc 1 (the person at the computer, or the Host itself), RosterChange 2 (the phone that asked left the list) or Computer 3 (another computer on the list, version 7). No text crosses the wire: the phone words it
2 RuleRefused { rule_id: u64, reason: u8 } A rule was refused: 0 another user signed in, 1 task not found, 2 remote control off
3 RuleCancelled { rule_id: u64 } A rule was cancelled
4 PowerPending { deadline: u64 } A power.shutdown (not a rule) notice started
5 RuleNotify { rule_id: u64 } A notify rule fired
6 PowerFailed { rule_id: Option<u64> } A shutdown or sleep did not happen (the helper failed, or a power.shutdown was refused at its deadline); None without a rule
7 DownloadProgress { id: u64, received: u64, total: Option<u64> } A download made progress, at most once a second
8 DownloadDone { id: u64, size: u64 } A download finished
9 DownloadFailed { id: u64, code: ErrorCode } A download stopped for good
10 RunDone { session: u64, exit_code: Option<i32>, tail: String } A saved command finished, with the end of its output (at most 2 KiB, cleaned). The phone notifies you when a run ends
11 DownloadPage { id: u64 } A download’s link answered with a web page, not a file (version 5): download.list has the file links found on it
12 PeerChanged { by: [u8; 32], action: String, detail: Option<String> } Version 7: another computer on the list changed something here (anything but a read). by is its endpoint key (the phone names it from its list), action the action’s id such as files.delete, detail a short name the Host chose (a file or folder name, a shared folder, a download’s host, a saved command’s key; at most 64 characters), never a path, a link, text or file content. Sent only to phones that negotiated 7, never to computers. The phone shows “Studio PC deleted a.txt on Cloud PC”
13 ApprovalRequest { id: u64, by: [u8; 32], what: ApprovalWhat, nonce: [u8; 16], expires_at: u64 } Version 9: the computer by asks to do what here (ShellOpen 0, or RunKey { key } 1), or, version 12, a coding agent on this computer itself (by is this computer) asks to use a tool (AgentTool { agent, tool, summary, folder } 2; answered like the others, and an approval leaves no ticket) and waits until expires_at. Phones only. The phone shows “Studio PC wants to open a terminal on Cloud PC” with Decline and Approve
14 ApprovalClosed { id: u64 } Version 9: that request ended (answered, declined, timed out); the phone takes it away
15 AgentNotice { agent: String, notice: AgentNoticeKind, folder: String } Version 12: a coding agent on this computer needs the person (NeedsInput 0) or finished (Finished 1) in the project folder. Phones only. The phone shows “Claude finished in ~/app on Lab PC”

When the work came back during a rule’s notice, the computer sends PowerCancelled { by: ThisPc }.

One bidirectional stream, opened by the phone. There is no Hello: every envelope’s v must equal CAPABILITY_VERSION, otherwise VersionTooOld.

  1. Phone: PairHello { endpoint: [u8; 32], name: String, device_key: bytes, presence_key: bytes, proof: [u8; 32] }. The phone opens the stream within 10 seconds and sends the hello within 30 seconds. Each key field is the 33-byte compressed key followed by a 64-byte signature of the bind message (97 bytes).
  2. Computer: PairReply { endpoint: [u8; 32], name: String, os: String, proof: [u8; 32], wake: Vec<[u8; 6]> }, with the hello’s id. wake is the computer’s MAC addresses for Wake-on-LAN, at most 4, and the transcript covers it. The Host sends an empty list until wake-on-LAN is built.
  3. Phone: PairConfirm { roster }, the new roster signed by the device key, within 2 minutes.
  4. Computer, after the person clicks Connect or Cancel: PairResult { ok, error } with the confirm’s id. A timeout gives Expired; a refusal NotAllowed. When the hello or the confirm is refused, the computer sends PairResult with the error code at once and closes.

The QR text is tealhand:pair?v=1&p=<64 hex: computer key>&a=<hex: address bytes>&s=<32 hex: secret>&e=<expiry, decimal Unix seconds>, lowercase hex, exactly this order, at most 2208 bytes, with an address of 1 to 1024 bytes. The address bytes are the postcard of { version: u8 = 1, id: [u8; 32], relay: Option<String> (https, at most 256 bytes), direct: list of at most 12 IPv4 or IPv6 socket addresses }. Transcripts, proofs and the check code: How commands are signed.

One QUIC connection per phone and computer on tealhand/files/1, with at most 2 per phone. The phone opens every stream. The full stream layout is the header comment of crates/tealhand-files/src/serve.rs; in short:

  1. Control stream, the first bidirectional stream. Hello both ways within 10 seconds, negotiated as on ctl/1; from then on every frame carries the negotiated v. Then the opener: a Request with a signed files.list, answered by a Response (Ok(Roots | Listing) or an error). After a good opener the session is open, and SessionRequest { action } with an unsigned files.list or files.stat is answered by a Response. Another opener on the same connection replaces the session; a refused one ends it.
  2. Transfer streams, one per transfer, opened after the computer’s Hello, at most 8 at once (more get Response(Err(Busy))):
    • Range read: SessionRequest with an unsigned files.get { path, offset, len }. The answer is Response(Ok(FileData { size, modified, len })) with len cut at the end of the file, then exactly len raw bytes in pieces of at most FILE_CHUNK (64 KiB), then the computer finishes the stream. “To the end” is len = u64::MAX - offset. The phone may stop early.
    • Upload: a Request with a signed files.put { path, size, on_conflict }; after every check the answer is Response(Ok(empty)), then exactly size raw bytes, then the phone finishes its side, then Response(Ok(Entry)). A phone that resets its send side abandons the upload.
    • Resumable upload (version 10): PutResume { command, key } with a signed files.put that does not replace; after every check the answer is Response(Ok(PutFrom { offset })), the bytes the computer already holds for that sender and key. Then exactly size - offset raw bytes, then PutEnd { sha256 } with the SHA-256 of the whole file, then the sender finishes its side, then Response(Ok(Entry)), or Mismatch when the hash differs (the temporary file is gone). A stream that breaks, stalls or is reset leaves the upload paused at its last synced byte; the next PutResume with the same key, path, size and conflict rule goes on from there. PutDiscard { key } drops a paused upload (Response(Ok(empty))).
    • Hash (version 10): SessionRequest with an unsigned files.hash { path } answers Response(Ok(Hash { size, modified, sha256 })): how a copy to the phone or another computer is checked at its end.
    • Blob: a Request with a signed screen.shot answers Response(Ok(Shot { .. })) and len raw bytes. The Host does not serve it yet.
  3. Closed with Malformed: a frame that does not decode, a wrong v, a second Hello, a SessionRequest before the opener, an unsigned action other than the allowed reads, a signed action that does not belong on files/1, or an upload whose byte count is not size.
  4. Checked on every request: the phone is still a member and remote control is on (else the connection is closed with that code), the folder is still shared, and the path is resolved again.

Limits: Hello within 10 seconds; a session idle for 10 minutes is closed (Expired); 100 session requests and 30 signed requests per 10 seconds per connection (more get Busy); a transfer with no progress for 60 seconds is reset; a frame write over 10 seconds closes the connection. The jail rules: Files.

SignedCommand { body: bytes, sig_device: [u8; 64], sig_presence: Option<[u8; 64]> }
CommandBody { issuer: [u8; 32], target: [u8; 32], action: bytes, nonce: [u8; 16],
issued_at: u64, expires_at: u64 }
signed message: "tealhand-cmd-v1" || body
SignedRoster { body: bytes, sig: [u8; 64] }
Roster { version: u64, prev_hash: [u8; 32], owner: [u8; 33], issued_at: u64,
devices: Vec<DeviceEntry> }
DeviceEntry { endpoint: [u8; 32], name: String, kind: DeviceKind (Phone 0, Computer 1),
device_key: Option<[u8; 33]>, presence_key: Option<[u8; 33]>,
added_at: u64, expires_at: Option<u64> }
signed message: "tealhand-roster-v1" || body; hash = SHA-256(signed message)
SignedApproval { body: bytes, sig_presence: [u8; 64] }
ApprovalBody { approver: [u8; 32], requester: [u8; 32], target: [u8; 32], what: ApprovalWhat,
nonce: [u8; 16], issued_at: u64, expires_at: u64 }
signed message: "tealhand-approve-v1" || body (the phone's presence key only; version 9)

A grant is accepted only from the phone it names (approver is the connection’s peer, a phone on the roster), signed by that phone’s presence_key, for exactly the request (requester, target, what, the Host’s nonce), at most 90 s long, in time, and once.

sig_device is the issuer’s signature of the signed message: a phone’s ECDSA P-256 signature by its device_key, or a computer’s Ed25519 signature by its endpoint key (checked strictly; version 7). The key tried is fixed by the roster entry of the connection’s peer. A computer’s command never carries sig_presence.

Endpoint keys and nonces are fixed-size arrays (no length prefix). Hardware keys (33 bytes, compressed) and signatures (64 bytes, raw r then s) are length-prefixed byte strings. Decoding is strict, and a decoded body must re-encode to the same bytes, so every message has exactly one encoding.

CommandBody.action carries a postcard Action. Its variant order is not the order of the action table:

Value Action
0 DeviceStatus
1 DeviceTasks
2 RuleList
3 RuleAdd(RuleSpec)
4 RuleCancel { rule_id }
5 PowerSleep
6 PowerShutdown
7 PowerCancel
8 DeviceRename { device, name }
9 DeviceRemove { device }
10 RemoteSet { enabled }
11 DeviceForget
12 FilesList { path: Option<FilePath>, cursor: u32 } (None: the shared folders)
13 FilesStat { path }
14 FilesGet { path, offset, len }
15 FilesPut { path, size, on_conflict }
16 FilesMkdir { path }
17 FilesRename { path, to, replace }
18 FilesDelete { path }
19 FilesShare(ShareChange): Add { path, name, read_only, downloads }, Remove { root } or Default { root: Option<RootId> } (version 6; None clears the default for downloads)
20 DownloadStart { url, name, into } (into: the shared folder to save in, any one that is not read only; None: the default for downloads)
21 DownloadCancel { id }
22 DownloadList
23 ShellOpen { cols, rows }
24 ShellRunKey { key, session }
25 ShellClose { session }
26 ShellList
27 ClipGet
28 ClipSet { text }
29 PowerWake { device, macs }
30 ScreenShot
31 CommandsAdd { key, label, line, cwd, needs_presence } (version 6; only while SSH access is on)
32 CommandsRemove { key } (version 6; only while SSH access is on)
33 FilesMove { from, to_folder, on_conflict } (version 10)
34 FilesHash { path } (version 10; unsigned, in a files/1 session)

A path is FilePath { root: RootId(u32), rel: Vec<String> }: a shared folder’s number and the names below it. A Response’s Ok bytes carry a postcard ActionOutput:

Value ActionOutput
0 Status(DeviceStatus)
1 Tasks(Vec<TaskInfo>)
2 Rules(Vec<RuleInfo>)
3 RuleAdded(RuleId)
4 Ok
5 Roots(Vec<RootInfo>)
6 Listing(Listing)
7 Entry(FileEntry)
8 FileData { size, modified, len } (len raw bytes follow)
9 Downloads(Vec<DownloadInfo>)
10 DownloadStarted(DownloadId)
11 ShellOpened(SessionId)
12 Shell(ShellInfo)
13 RunStarted(SessionId)
14 Clip(String)
15 Shot { width, height, len }
16 PutFrom { offset } (version 10)
17 Hash { size, modified, sha256 } (version 10)

Other wire enums: Trigger (ProcessEnds { name } 0, GpuIdle { minutes: u16 } 1), Then (Shutdown 0, Sleep 1, Notify 2), RuleState (Waiting 0, Firing { deadline } 1, Done { at } 2, Refused { reason } 3, Cancelled 4), RefuseReason (OtherUserSignedIn 0, TaskNotFound 1, RemoteControlOff 2). DeviceStatus is { cpu_pct, gpu_pct: Option<f32>, mem_free, mem_total: Option<u64>, battery_pct: Option<u8>, uptime_s: Option<u64>, remote_control: bool }; TaskInfo is { pid: u32, name: String, cpu_pct, gpu_pct: Option<f32> }.

In JSON (the planner and the schema) an action is {"action": "<id>", "params": {...}}, with no params for actions without parameters, and a device as 64 lowercase hex digits.

ErrorCode values double as QUIC application close codes (0 is no error). In a postcard Response or PairResult, the code is encoded as its position, which is the number minus 1.

Close code ErrorCode
1 Malformed
2 VersionTooOld
3 NotOnRoster
4 BadSignature
5 Expired
6 Replay
7 PresenceRequired
8 RemoteControlOff
9 OtherUserSignedIn
10 TaskNotFound
11 NotAllowed
12 Busy
13 Internal
14 NoGpu
15 TooManyRules
16 TooLate
17 NotFound
18 Exists
19 PathRefused
20 TooLarge
21 NoSpace
22 AddressRefused
23 FetchFailed
24 NotSetUp
25 Unsupported
26 Declined (version 9)
27 NoPhone (version 9)
28 NoAnswer (version 9)

What each means and what the phone says: Error codes.