Running Claude Code from My Phone, Nothing Exposed to the Internet
Claude (Opus 5.5)
Written by Claude (Opus 5.5) and checked by Claude (Fable 5.2), from the repo, its notes and the session transcripts. Earlier Claude sessions built the hub and the app; dested asked for it, made the calls and uses it. The first person below is dested's voice.
My two machines hold 9.4 GB of Claude Code transcripts. The words in them, every prompt I typed and every reply Claude wrote, come to 26 MB. The rest is what my index skips: tool output, compaction summaries, messages from other sessions.
That ratio is why I can search every session I've ever run from my phone, and get an answer in 4 to 35 ms.
One page for 30 terminals
I run a lot of sessions at once, across WSL and Windows. The hard part was finding the one tab stuck on "Allow Bash?".
So I built Wheelhouse, a hub that runs on my desktop and holds every session. On 2026-10-29 the first test proved that questions and permission prompts can be answered from outside the terminal. Two days later it was on my iPhone. My whole brief to the agent was:
I want it on my phone somehow. Expo whatever. It has to be fucking secure.
The phone's Queue tab holds every open question, permission ask and plan approval. A session's chat lets me read, send, withdraw a queued message and Stop.
A permission prompt races the terminal's dialog
Each session links to the hub through a Claude Code plugin I already run everywhere. It pushes a snapshot of the session every 1.5 s while something changes, and long-polls the hub's inbox for commands.
When Claude asks something, the terminal's dialog and the hub race. Whichever answer comes first wins, and the dialog closes either way. So I can answer at the desk or on the phone, and neither gets out of sync.
Sending a prompt is the harder half. Claude only takes a new prompt when it's idle, and a prompt queued inside Claude can't be pulled back. So the hub holds it.
The phone only talks to the hub on my home network
The first design had a relay on a server I run, end-to-end encrypted so the relay would only see ciphertext. I approved it and parked it the same day: I didn't want anything exposed to the internet yet.
What shipped instead:
- Pairing at the desk. The hub shows a QR code (its public key and a one-time secret, valid 2 minutes). The phone makes its own key and the two pin each other.
- A Noise channel.
Noise_IK_25519_ChaChaPoly_SHA256on @noble, pure TypeScript so it runs in Bun and in Hermes. Its 71 tests include a relay that tampers, replays, reorders and swaps, and caught all 16 bugs planted on purpose. - Face ID on the key. The phone's key is a keychain item that needs Face ID to read, asked again after 5 minutes in the background.
- An allowlist at the hub. Only listed tRPC procedures work from a phone. Terminals, shell input, env writes and file edits outside docs are off.
- Pushes with no content. The hub is written to call APNs itself, and a push says the session name and "Allow Bash?", "Approve the plan?", "Has a question" or "Finished". Never Claude's words. I have not yet confirmed one arriving on the phone.
Searching 2,257 sessions is a Postgres query
The indexer runs inside the hub. Each round it stats every transcript, reads only the bytes appended since last time, and stores prompts and replies as 4,000-character chunks with a tsvector. It held 2,151 sessions when I built it on 2026-10-30. Today it holds 2,257 from 3,170 files; most of the other files have no prompt anyone typed.
| Value (2026-11-10) | |
|---|---|
| Sessions on Windows / WSL | 1,564 / 693 |
| Largest transcript | 540 MB |
| Median transcript | 305 KB |
| Transcripts over 100 MB | 11 |
| Text chunks indexed | 63,299 |
| Full re-index after a format change | about 3.5 min |
Claude wrote about four fifths of what's in there.
What broke on the way
| Symptom | Cause | Fix |
|---|---|---|
| One session polled the hub 961 times a second | Two terminals had resumed the same session 2 to 4 s apart, and each new poll ended the other's | A new poll no longer ends a waiting one, and a second resume of a running session is refused. After: 0.13 polls a second |
| First install died in dyld at launch | SDK 58's precompiled Expo modules were built against a newer core than my lockfile held | Upgraded Expo; the build script now fails before export when a symbol is missing |
| App crashed once sessions loaded | toSorted doesn't exist on Hermes |
Stopped calling it |
| Phone couldn't connect at all | Windows' Hyper-V firewall dropped inbound connections | Added a firewall rule |
| Taps landed on the wrong row | Pull-to-refresh spun on every background poll and pushed the list down 60 px | It spins only for a real pull |
| The pairing code came out wrong in the Simulator | Typing a 175-character code dropped characters | A wheelhouse://pair link fills the field |
The storm alone, before the fix, had the hub using 26% of a core and 714 MB.
What still doesn't work
- Away from home, nothing. No relay, so the phone works only on my network.
- No Face ID per action. The plan asked for it on Allow and Stop. It isn't built.
- Every change is a rebuild. No EAS and no over-the-air updates. The Mac mini builds and installs it, signed the way I described in signing iOS builds over SSH.
- Some switches stay at the desk. Fast mode and permission mode can't be changed over the wire yet.
- "Finished" pushes live in memory. A hub restart drops the ones waiting.
Build the minimum version
Find the transcripts. Every main session is a .jsonl file directly in ~/.claude/projects/<folder>/, named by its session id. Subagent transcripts live deeper; skip them. Each extra CLAUDE_CONFIG_DIR has its own projects folder.
Keep only the words. From type: "user" records, skip any with isMeta, isSidechain, isCompactSummary or isVisibleInTranscriptOnly, and text starting with <, Caveat:, [Request interrupted or This session is being continued. From type: "assistant" records, keep the text blocks. Skip tool results, and don't parse a line over 16 MB.
Read incrementally. Store each file's size, mtime and byte offset, and read from the offset to the last full line. If a file is shorter than its offset, start over. Strip \u0000: Postgres text can't hold it.
Search with one query.
CREATE TABLE chunk (session_id text, role text, text text, tsv tsvector);
CREATE INDEX ON chunk USING gin (tsv);
-- insert with to_tsvector('english', text)
SELECT DISTINCT ON (session_id) session_id, ts_rank_cd(tsv, q) AS rank
FROM chunk, websearch_to_tsquery('english', 'firewall rule') q
WHERE tsv @@ q
ORDER BY session_id, rank DESC;Resume from a hit. Every record carries the session's cwd. Open a terminal there and run claude --resume <session-id>.
Search and resume need nothing inside Claude. Answering a prompt or sending into a session that's still running does: that's the part the plugin is for.