The Android Companion contract

Edit on GitHub

The Companion is a phone app that holds an iroh connection to your laptop and shows the Board in a web view. It lives outside this repository. This page is the contract it is built against, and the Board's side of it is built to the same words: it fixes what the app dials, what it reads from a pairing link, what it may rely on, and what it must never do. companion-vectors.json holds the test vectors; the app's tests copy it.

Status. Three kinds of statement share this page, and each says which it is:

The words must, must not and may bind the app or the Board as the sentence says. The contract is OS-neutral, but only Android is in scope: iOS is deferred.

The wire

What the app dials

The app is an iroh endpoint with a key of its own, made on first run. Its public key is the identity the Board sees. It dials the laptop's endpoint ID from the link, 32 bytes written as 64 lowercase hexadecimal characters, with the ALPN hand-board (ten bytes, no version suffix). If the link carries a relay URL, the app gives it to iroh as a relay hint for that endpoint. The link never carries IP addresses, and the app must not store or send any it learns.

The app holds one connection to a laptop at a time and opens every stream on it. A laptop admits 16 connections by default and 4 from one source (one direct IPv4 address or IPv6 /64, or one relayed endpoint ID), so a second connection from the same phone is a cost, not a speed-up.

Streams

What the sidecar writes toward the Board

For each accepted stream, the sidecar opens one TCP connection to the peer listener and writes one line before any byte of the app:

HAND-PEER 1 <endpoint id of the app> <secret from board.peer>\n

It does not wait for an answer, and the Board never sends one: the first byte the app receives on a stream is the first byte of the Board's HTTP response. The app never sees the preface or the secret. After the line, the sidecar copies bytes both ways. It writes nothing else of its own to a stream.

If the sidecar cannot connect to the Board, or cannot do it within the idle time, it resets the stream with code 3. If the Board refuses the preface (a wrong secret, which only a misconfigured laptop causes), it closes without an answer: the app sees the stream end with no byte, or a reset with code 5.

Ending a stream

EventWhat the sidecar doesWhat the app must do
The app ends its send side (the web view half-closed)Shuts down the write half of the Board connection and keeps copying the answer; the Board answers the request as it would with the send side open, a held request such as a long poll included. The /events stream, and /api/v1/events where the peer listener serves the API, are the exception: it has no answer to wait for, so the Board ends itKeep reading until the stream ends
The Board closes its write halfEnds its send side after the last byteDeliver every byte, then shut down the write half of the web view's socket
The Board closes the connection while the app is still sendingDelivers the answer, then stops the app's send side with code 5Stop sending without an error if an answer arrived; the web view sees a normal response
The web view closes its socket at onceNothing: the app resets its send side with any code, and the sidecar closes the Board connection. The Board cancels the request when that close is a reset or a read error, and cancels an /events stream, or an /api/v1/events stream where the peer listener serves the API, on any close, plain or half; any other request that sees a close that looks like a half-close finishes and ends the connection at the answerReset the stream, and do not retry it

Close and reset codes

Codes 1 to 5 exist today. Every other code is unassigned.

CodeNameWhereWhen the sidecar sends itWhat the app doesWhat the user sees
1fullConnection closeThe handshake finished and no connection slot was freeWaits and dials again with backoff (below)"Your laptop is busy. Trying again."
2idleConnection closeNo byte moved on any stream for the idle timeNothing at once. Dials again when the web view next opens a streamNothing
3board_unreachableStream reset and stopThe sidecar could not reach the peer listenerCloses the web view's socket. The connection stays openAfter 2 in a row: "Your laptop answers, but the Board is not running. Start it on the laptop."
4stream_idleStream reset and stopNo byte moved on this stream for the idle timeCloses the web view's socketNothing: the page reconnects
5board_errorStream reset or stopThe Board's TCP connection brokeCloses the web view's socket. Keeps any answer already deliveredNothing
othern/aEitherNever todayTreats it as a lost connection: backoff, three tries, then the message of a lost connection"Your laptop closed the connection. If you removed this phone there, pair it again."

Refusals. A dialer is also refused before the handshake finishes, with no application code, when 16 connections are open, when 8 handshakes are running, or when 4 connections from its source are open. A refusal arrives at once, before any handshake, and the app treats it as code 1. A handshake that does not finish in 3 seconds is dropped with no code at all: the app cannot tell that drop from a laptop that is off, so it keeps dialing until its own 20-second deadline and then says the laptop does not answer, as below.

Backoff. Start at 1 second, double each time up to 30 seconds, add up to 25 percent of random jitter, and reset after a connection has carried a stream for 10 seconds. The app must not retry faster, because every attempt costs the laptop a handshake.

Caps the app must respect

CapValueWhat happens past it
Open streams per connection12A 13th open_bi waits until one ends
New streams per second per connection20, after a burst of 12The sidecar accepts them later, at that rate: they are delayed, not refused
Unidirectional streams0They cannot be opened
Connections per laptop16 by default; the operator may set 1 to 1024Refused, or closed with code 1
Connections from one source4Refused
Handshakes at once8, each dropped after 3 secondsRefused
Idle, stream60 seconds by default; the operator may set 1 to 86400Reset with code 4
Idle, connectionThe sameClosed with code 2

Only a byte moves the idle clock. QUIC keepalive packets, opening a stream and ending one do not. The app cannot learn the laptop's setting, so it must assume 60 seconds and treat any connection or stream as one that may end. The Board's live pages send a heartbeat every 15 seconds, which keeps a page's event stream under the default.

The Board also bounds what an unpaired connection may do; see What the Board does with a paired phone.

The link is what the laptop's QR code and its text form carry. It is not the Phone page's ADDRESS/<fleet id>/pair#CODE link, which a browser opens.

Grammar

link     = "handboard://c#" fragment
fragment = "v=1" *( "&" key "=" value )
key      = %x61-7A *15( %x61-7A / DIGIT / "_" )
value    = 1*( ALPHA / DIGIT / "." / "-" / "_" / "~" / "%" )

The link is printable ASCII (bytes 0x21 to 0x7E) and at most 213 bytes, the most the Board's QR encoder holds. The scheme and host are lowercase and compared exactly. Keys may come in any order after v, and none may appear twice.

KeyRequiredValue
vyes, and firstThe contract version, the text 1
eyesThe laptop's endpoint ID: 64 lowercase hexadecimal characters
fyesThe fleet ID the code pairs with: f and 12 lowercase hexadecimal characters
cyesThe one-time code: 10 characters of 0123456789ABCDEFGHJKMNPQRSTVWXYZ, uppercase, no hyphen
rnoThe laptop's relay URL, percent-encoded as below. Absent when the laptop uses the public relays of the iroh project, which the app finds through its own defaults

Any other key is ignored, so that a later version can add one.

A canonical link is v, e, f, c, then r: handboard://c#v=1&e=ID&f=FLEET&c=CODE&r=RELAY. The Board emits only that order. The app accepts any order.

Why a fragment, and what must not be in the query. Everything after # stays on the phone: an operating system hands a custom-scheme link to the app whole, and no browser or server that a link might pass through reads it. The code is a credential and must be in the fragment. A link that has a ? before its # is rejected, so that nothing secret is ever put where a log or a preview could keep it, and a link with no # is rejected even if its query carries every field.

The relay URL. It is https://, a lowercase DNS host (an optional final dot is allowed), an optional port from 1 to 65535 with no leading zero, and an optional single /. A host whose last label is a number, decimal or 0x hexadecimal, is read as an IPv4 address by URL parsers, not as a DNS host, and is rejected, so no IP address is ever in a link. There is no user information, path, query or fragment. It is percent-encoded byte by byte: A-Z a-z 0-9 - . _ ~ stay as they are and every other byte becomes % and two uppercase hexadecimal digits. A decoder rejects a lowercase escape, a truncated escape, and an escape of a byte that needed none, so that each URL has one spelling. Example: https://relay.example.test:7842/ is https%3A%2F%2Frelay.example.test%3A7842%2F. The longest relay the 213 bytes allow is 97 encoded characters.

How the app checks it

The app trims surrounding whitespace that a scanner or a paste adds, and then parses the exact text. It applies these checks in this order and stops at the first that fails. The reason is a stable name that the vectors use and the app may log (never the link).

#ReasonThe link is rejected when
1too_longIt is over 213 bytes
2charactersA byte is outside 0x21 to 0x7E, which includes a space, a line break and any non-ASCII character
3schemeIt does not start with handboard://
4no_fragmentIt has no #
5queryThe text before the first # has a ?
6targetThe text before the first # is not exactly handboard://c: another host, a path, a port, user information
7version_missingThe fragment does not start with v=
8version_unsupportedThe value of v is not exactly 1
9fragmentAny pair is not key=value, a key or a value breaks the grammar above (including a second #, an empty value, a key in capitals, a relay with a raw : or /)
10duplicate_keyA key appears twice
11missing_keye, f or c is absent
12endpoint_ide is not 64 lowercase hexadecimal characters
13fleetf is not f and 12 lowercase hexadecimal characters
14codec is not 10 characters of the alphabet above
15relayr is present and is not a relay URL in its one spelling

Check 8 comes before every check of a field, so that a later version may change anything after v= and an old app still says "needs a newer app" and not "invalid". An app also rejects, as endpoint_id, an ID its iroh library will not turn into a public key: the reference parser checks only the shape.

The app shows the user the first four and last four characters of the laptop's ID and asks for confirmation before it dials: any other app may open a custom-scheme link, and a link must not pair the phone with a stranger's laptop on one tap. A link that arrives while a pairing is open replaces nothing until the user confirms.

What the app opens

After the user confirms, the app dials, starts its loopback proxy, and loads this in its web view:

http://localhost:PORT/<fleet id>/pair#<code>

with PORT the proxy's port. The host is the name localhost, never an IP address. On a real phone, an Android web view refused cleartext HTTP to http://127.0.0.1:PORT, and loaded the same page from http://localhost:PORT once the app's network security config allowed cleartext to localhost.

The fragment keeps the code out of the request: the Board's pair page reads it with a script, puts it in the form's code field, and removes it from the address bar. The user then types a name for the phone and taps the button. So the web view must have JavaScript on, and the app must not submit the form for the user.

The app may instead redeem the code itself with POST /<fleet id>/api/v1/pair and {code, name} as JSON (Pairing a phone), over the same stream and with the same limits. The answer holds the cookie name and value the app keeps as its device secret. The cookie_value is the device's number in digits with no leading zero, a dot, and the secret as 64 lowercase hexadecimal characters (^[1-9][0-9]*\.[0-9a-f]{64}$, the cookie_value of the pair vectors); an app accepts a stored value only in that shape, and cookie_name is hand_phone_<fleet id>. The web form is still the user's: the app must not submit it, and must not call this endpoint with a code or a name the user did not give.

The loopback proxy

An alert can open an item in the app. The link is handboard://open/<fleet id>/<page>[#<report ref>], and Hand puts it in HAND_NOTIFY_APP_URL for the notify command (Alerts that open the app) and prints it as app with hand open --print. <page> is the Board's own path for the item.

ItemLink
taskhandboard://open/f0123456789ab/task/t3
decisionhandboard://open/f0123456789ab/decision/d3
attempthandboard://open/f0123456789ab/attempt/a7
reporthandboard://open/f0123456789ab/task/t3#r5
supervisor, fleet pagehandboard://open/f0123456789ab/

Grammar. The scheme handboard and the host open, so it never collides with the pairing link, whose host is c. Then the fleet, which matches ^f[0-9a-f]{12}$; then / and nothing, task/tN, decision/dN or attempt/aN, with N one to nine digits and no leading zero; then optionally #rN, only after task/tN, with the same rule for N. Everything is lowercase ASCII. No query, userinfo, port, percent escape or trailing slash is valid. Any other text is not an open link, and the parser gives no reason: it rejects.

The laptop. The desktop window takes the same link as hand-desktop --open LINK, and the Linux alert's Open runs it. go test ./internal/desktop runs its parser over the same vectors, and a link with a #rN opens the report rN there.

What it holds. No token, no pairing code and no endpoint ID, because an alert service reads it and the app has one laptop per install. It names the fleet and an item, nothing more.

What the app does. The app must treat it as a request to show one read page through the proxy it already has, never as a pairing. It must not clear cookies, change the laptop, replace a pending pairing or pair a phone. With a laptop paired, it dials, sets up the loopback proxy and loads http://localhost:PORT/<fleet id>/<page> (with #rN kept as the fragment). With no laptop it shows "Pair this phone with your laptop first." and drops the link. When the laptop is unreachable it shows the unreachable screen, and tapping the alert again retries. A phone that is not paired for the fleet gets the Board's own refusal. A hostile link from another app can open one read page of an already paired laptop, and a change on the Board needs a POST with the form token, so the link grants nothing.

Vectors. companion-vectors.json holds 6 valid and 61 invalid open links under the key open. Valid entries carry the parsed fleet, page, report and the path to open, and every one is a link Hand emits (canonical); an emitter must reproduce its link from fleet, page and report. Invalid entries carry only the link; they are the app's main reject table, and the reference parser's anchored fleet check also rejects the app's extra right-length fleet cases, which only internal/companion/companion_test.go (the reference parser test) holds, not the vectors file. go test ./internal/companion runs the reference parser over both lists, and go test ./internal/cli checks that Hand's emitter reproduces every valid link. The app runs its parser over them as written.

Versions. A new link with a new host needs no new version: an old app has no filter for the host open, so it never receives the link. The vectors are new entries, and no earlier vector changes.

What the Board does with a paired phone

Today

Step 3

The alert feed

Today, from schema 22. The app reads the alerts the watcher raises from the Board, over the connection it already holds, instead of from a push service. The route is on the fleet's prefix, so a phone reads only the fleets whose hand_phone_<fleet id> cookie the web view holds. companion-vectors.json holds the numbers, the link grammar and five exchanges under the key alerts; go test ./internal/board runs every exchange against the Board's handler, and go test ./internal/companion reads the numbers back out of the code.

The request

GET /<fleet id>/alerts/feed?after=<seq>&wait=<seconds>

The app sends it as any other request through the sidecar: Host: localhost, the cookie hand_phone_<fleet id>, and Connection: close, so the answer ends at the end of its body. The answer always has a Content-Length, never chunked encoding, so the app reads the headers, then that many bytes, then stops. It is a GET: nothing in it is a form, so it carries no CSRF token.

The answer

200, application/json; charset=utf-8, Cache-Control: no-store, always under 64 KiB:

{"version":1,"fleet":{"id":"f0123456789ab","name":"hand"},"cursor":57,"more":false,
 "alerts":[{"seq":57,"at":"2026-10-22T12:00:00Z","kind":"decision.asked","ref":"d3",
  "text":"t3 decision: Keep the old cookie name?","link":"handboard://open/f0123456789ab/decision/d3"}]}
FieldMeaning
version1 for this contract
fleetthe fleet's id and name
cursorthe number to send as after next: the last seq in alerts, or after itself when there are none
moretrue when more than 50 alerts were waiting: ask again at once, without wait
alertsat most 50, oldest first, each with seq (a number that only grows and is never reused), at (UTC, to the second), kind, ref, text and link

Receipts

The Board records two times per alert, for the reliability trial: fetched_at, when the alert is written to an answer, and acked_at, when the next request arrives with after at or above its seq. That next request is the acknowledgment: the app sends after only after it has posted its notifications, which makes delivery at-least-once, and a repeat is harmless when the app tags a notification by <fleet id>/<ref>. The receipts are per fleet, not per phone. Each request also counts as the phone being seen on the Phone page. The Board lists the week, with the delay from raised to acknowledged, at /<fleet id>/alerts.

Refusals

The route answers as every other Board page does. An unpaired phone, a cookie of another fleet and a cookie of another endpoint ID get 403. A phone revoked while its request is held gets 403 with the X-Hand-Error of "no longer paired" at the next check, within about a second, and the app stops polling that fleet. A 429 or a 5xx, a reset with code 3, 4 or 5, or no answer within 35 seconds is a failed poll: the app backs off as it does for any lost connection and tries again.

Errors and what the user should see

The app's own screens say the left column. A page the Board serves appears in the web view as it is, and the app does not read or rewrite it.

CaseHow the app knowsWhat the user sees
The link is rejectedA reason from the table aboveFor version_unsupported: "This code needs a newer Hand app. Update the app." For every other reason: "This is not a Hand pairing code. Show a new one on your laptop." The app never shows the reason code to the user, the link or the code
The camera is refusedThe OS"Allow the camera to scan the code, or paste the link."
The laptop does not answerNo handshake within the app's 20-second deadline"Can't reach your laptop. Check that it is on, that Hand's Board is running, and that this phone has internet."
The laptop is full or refusesCode 1, or a refusal"Your laptop is busy. Trying again."
The Board is not runningCode 3 twice in a row"Your laptop answers, but the Board is not running. Start it on the laptop."
The connection is lostAny other close"Reconnecting…", and after three tries "Can't reach your laptop."
A wrong codeThe pair page's 403, or wrong_code"That code is not right. Check it against the one on your laptop, or show a new code there."
An expired codeThe pair page's 403, or code_expired"That code has expired. A code works for 5 minutes. Show a new code on your laptop."
A used codeThe pair page's 403, or code_used"That code was already used. A code works once. Show a new code on your laptop."
Too many wrong codes (all cancelled)The pair page's 403, or codes_cancelled"Too many wrong codes. Every code was cancelled. Show a new code on your laptop."
Too many wrong codes from this phoneThe pair page's 429, or rate_limited"Too many wrong codes from this phone. Wait a minute, then try again."
The phone is still paired in another fleet, and this fleet's pairing was removed on the laptopThe Board's 403 "no longer paired", with X-Hand-ErrorThe page, and the app adds two actions: Pair again and Forget this laptop
The phone's only paired fleet was removed on the laptopThe Board's 403 "Pair this phone first", with no X-Hand-ErrorThe page, which says how to pair again. The app adds no actions
Too much asked before pairingThe Board's 429The page; the web view reconnects, which opens a new stream

A name the Board refuses is bad_name: the app limits the field to 40 characters and no control character, so the user never sees it. The pair page's texts on the right are the Board's, and the API's message carries the same text; the app may show them in its own words but must keep their meaning, and must not add the code.

Rules for the app

Test vectors

companion-vectors.json holds 13 valid and 73 invalid links, each with the result a conforming parser gives: the fields and the path to open, or the reason. It also holds the numbers of the wire and of the Board's pairing rules, under the keys wire and board. Under pair it holds the request, the answer and each refusal of POST /api/v1/pair, which go test ./internal/board replays against a Board. go test ./internal/companion runs a reference parser against the file, and reads the numbers back out of iroh/src/ and internal/board/, so a change of code and a stale file cannot both pass.

The app copies the file into its own tests and runs its parser over every vector as written: trimming whitespace is the caller's job and happens before the parser, so a vector with a leading space is rejected as characters. A valid vector whose canonical is true is also what a Board emits: an emitter must reproduce its link from its parsed fields. The reference parser checks shape only: an ID of 64 hexadecimal characters is valid in it even where the app's iroh library refuses it as a key.

Versions

The contract has one whole-number version, in the link as v and on the wire as the ALPN: version 1 is v=1 and hand-board. The link, the ALPN and the rules on this page change together.

iOS is deferred

There is no iOS app and none is planned until the Android app has been used on a real phone. Nothing on this page is a promise about iOS. Open facts about it are untested: whether WKWebView keeps an HttpOnly cookie set from http://localhost, what App Transport Security allows for localhost, how long a backgrounded app keeps its sockets, and how an iOS app claims a link. The link grammar and the wire do not depend on any of them.