An Android chat client for pi. pi runs on your PC; this puts it in your pocket — read a long reply from the sofa, check on an agent that has been working for twenty minutes, or send the next instruction without walking back to the desk.
It is not a terminal mirror, and not a chat wrapper. A hub on the PC attaches to the
pi sessions already running there, and the phone shows the real thing: the same
transcript, the same thinking, the same tool calls, rendered properly and updating
live. Pair once over your LAN or a tailnet, and every pi you start afterwards shows
up in the list. The hub serves plain ws://, so prefer a tailnet on any network you do
not own — see docs/known-limits.md.
pc/ is Node + TypeScript, app/ is Flutter + Dart, and protocol/ holds the golden
fixtures both sides assert against.
- Reads and writes pi sessions from the phone. The transcript is a document: your messages, the agent's thinking, tool calls and their results, rendered as typed blocks rather than a wall of text.
- Find text in the transcript. A search button in the app bar matches text in the transcript rows already loaded and steps through the hits, tinting each matching row.
- Streams live. Replies arrive token by token and the reasoning streams into its own row above the reply. A content-free phase frame means a slow first token is never mislabelled as thinking.
- Renders tool calls by kind. A diff for
edit/write, a file range forread, a command and merged output forbash, grouped matches forgrep/find, a table forls, and a fallback for anything else. - A session menu — New, Fork, Tree, Compact, Rename, thinking level, and a model picker limited to the providers you actually have credentials for.
- Slash commands. Typing
/completes the session's real commands — prompt templates, extension commands, skills — and runs them in pi rather than sending the text to the model. - Start a session in a project folder. Browse the PC's home directory and start a
pithat runs in the chosen folder, so its session is saved under that project andpi --continuepicks it up. - Pairing that sticks. Scan a QR or type a code once; the phone keeps a token, and restarting the hub does not de-pair it.
- Images, both ways. Assistant and tool images render, and the composer can send one downscaled gallery image with a caption.
- Looks like pi. The palette is pi's own, light and dark, following the phone's setting — with measured contrast fixes where pi's terminal palette is sub-AA.
- Android niceties: predictive back, and notifications for the sessions you have open.
What is not built — and what is deliberately left out — is in the
issue tracker and
docs/known-limits.md.
You need pi and Node ≥ 22.19 on the PC. To
build the app you also need Flutter 3.47.5, the Android SDK, and a JDK; the exact
setup used here is in docs/development.md. The phone and the
PC have to be able to reach each other. A tailnet is preferred over a shared network:
the hub serves plain ws://, so anything that can read the connection's frames has the
pairing token and everything it grants.
git clone https://github.com/Tako88/pi-handset
cd pi-handset
pi install ./pc # load the bridge into every pi you start
(cd pc && npm link) # put `pi-handset` on PATH — once
pi-handset serve # start the hub (port 8787 by default)
pi-handset pair # print a pairing code and a QRnpm link is what makes pi-handset a command; without it the same two commands
are cd pc && npm run serve and npm run pair. Use the link rather than
npm install -g: Node refuses to strip types from a file under a real
node_modules, so an installed .ts bin cannot run — the link points at the
checkout instead. Tracked as issue #50.
For daily use, run the hub as a systemd user service so it starts at login and
outlives the terminal it was started from. It runs the same serve on the same
port:
systemctl --user link "$PWD/pc/systemd/pi-handset.service"
systemctl --user enable --now pi-handset.service
journalctl --user -u pi-handset -f # the hub's output
systemctl --user stop pi-handset # stop itDon't run both: the hub takes an exclusive lock, and the second one exits with
another supervisor is already running. pi-handset serve in a terminal stays
useful during development, where you want its output in front of you.
Then install the app. Every release carries a signed arm64 APK — Android 7.0 or later, and arm64 covers every phone from roughly 2017 on. Download it on the phone from the latest release, or fetch it on the PC and install it over adb:
curl -LO https://github.com/Tako88/pi-handset/releases/latest/download/app-arm64-v8a-release.apk
adb install app-arm64-v8a-release.apkOr build it yourself:
cd app
flutter build apk --profile
adb install -r build/app/outputs/flutter-apk/app-profile.apkOpen the app and scan the QR, or type the address and code. From then on, any pi you
start registers itself and its session appears in the list.
Notes for the first run:
-
The code is single-use, and dies after 5 minutes or 5 failed attempts.
pairtalks to the running hub over a0600Unix socket, so it only works on the same machine. -
The token is stored per phone, so pairing happens once. A changed signing key or an
adb uninstallloses it; a plainadb install -ror a hub restart does not. -
The QR encodes a
pihandset://pairURI carrying the code and every address the hub can advertise; with--no-lanit carries the code alone.kill -USR1 <main pid>still prints a code — under the service the pid issystemctl --user show -p MainPID --value pi-handset— butpairis the supported path. -
If pairing just spins, suspect the firewall. With
ufwon its defaultDROPinput policy the phone's connection to port 8787 over the LAN is silently dropped; Tailscale is not. Test it from the phone — the PC connecting to itself proves nothing:adb shell 'timeout 5 toybox nc 192.168.1.100 8787 < /dev/null; echo exit=$?' # exit=0 -> connected; exit=124 -> dropped (the firewall)
Then open the LAN path, scoped to the subnet rather than the world:
sudo ufw allow from 192.168.1.0/24 to any port 8787 proto tcp
Three pieces, and the phone only ever talks to the first:
| Piece | Runs | What it is |
|---|---|---|
| Hub | the PC | one process: two WebSocket listeners, the session registry, the relay, and the spawner for app-started sessions |
| Bridge | inside each running pi |
a pi extension that registers the session, normalizes pi's events, and runs commands |
| App | the phone | the Dart client and the Flutter UI |
phone ──WebSocket (viewer port 8787)──▶ hub ◀──WebSocket── bridge (inside pi)
- The hub listens on a viewer port (bound for phones) and an agent port (bound
to loopback only), and each listener advertises different capabilities. Pairing
codes are minted on demand over a
0600Unix control socket. - The agent port is ephemeral and written to a discovery file; the bridge finds it there, which is why a hub restart does not need the phone to re-pair.
- Every frame is a
{protocolVersion, type, payload}object, and both sides decode it from the same golden JSON fixtures inprotocol/, so a field-name drift is a test failure rather than a subtle runtime bug.
Deployment has three independent units — an APK rebuild, a pi /reload, and a hub
restart — and a change touches only the ones it has to. Which is which, and the
reasoning behind the design, are in docs/status.md and
docs/decisions.md.
Both sides are built and tested, and the whole path has been exercised for real: a real
pi, a real hub, the real Dart client, and the app on a phone driving a live model.
pc/ (Node + TypeScript) |
app/ (Flutter + Dart) |
|
|---|---|---|
| Suite | npm test |
flutter test |
| Static gate | tsc --noEmit clean |
flutter analyze clean |
| Product code | hub, protocol codec, pi bridge | protocol codec, client, UI |
What is built, what each change costs to deploy, and what the manual passes caught that
the suites could not: docs/status.md.
pc/ Node + TypeScript — supervisor, pi extension, protocol codec
app/ Flutter + Dart — the Android client
protocol/ shared golden JSON fixtures, asserted by BOTH suites
docs/ status, decisions, development, known limits
AGENTS.md rules and conventions for contributors and coding agents
Two first-class halves; neither owns the repo root. There is deliberately no manifest
at the root, so every command runs from inside its own side — npm test at the root
fails with ENOENT by design, not by accident.
cd pc && npm test && npm run lint && npm run typecheck # suite, lint, type gate
cd app && flutter test && flutter analyze # suite, analyzerBoth gates on both sides must be green before anything is done, and CI runs the same
five commands on every push to develop and every pull request against it
(.github/workflows/ci.yml). Toolchain setup, the
paid live-model test, the emulator notes and the deeper install details are in
docs/development.md; the rules are in
AGENTS.md.
docs/status.md— the engineering record: what is built, the deploy units, and what the manual passes found.docs/decisions.md— why the code looks the way it does.docs/development.md— toolchain, tests, install notes.docs/known-limits.md— what the app deliberately does not do, each with its reason.AGENTS.md— TDD method, test layout, definition of done.






