← Blog

Approve Claude Code from your phone without opening ports

A walkthrough of how Pilothouse gets a Claude Code permission request from your laptop to your phone and back — hooks, presence, Tailscale Serve, web push and signed decisions — with no port forwarding and no account.

4 min readThe Pilothouse maintainers

You start a long Claude Code task, walk away, and twenty minutes later it’s sitting at “Do you want to run git push origin main?”. You want that question on your phone, and you want your tap to go back to the agent.

The usual answers involve forwarding a port, running a tunnel to the public internet, or creating an account with someone’s relay. This post shows how Pilothouse does it with none of those — and why each piece is there.

1. Catching the question: Claude Code hooks

Claude Code has hooks: commands it runs at points in its lifecycle, with JSON on stdin and a decision on stdout. Two matter here:

  • PreToolUse runs before every tool call. Pilothouse uses it for your rules.
  • PermissionRequest runs when Claude Code is about to ask you for permission. A hook can answer it: allow or deny, with a message.

pilothouse setup adds a small shim — pilothouse hook claude — to both, merged into your existing ~/.claude/settings.json (backed up first). The shim forwards the event to the Pilothouse daemon over a Unix socket and prints whatever the daemon decides.

The PermissionRequest hook is given a 600-second timeout. That number matters later.

2. Deciding where the question goes: presence

If the shim always waited for your phone, sitting at your desk would get worse: the agent’s own dialog wouldn’t appear until the phone answered. So Pilothouse asks one question first: are you at the keyboard?

It reads the OS idle time. Under a minute (configurable), you’re present: the daemon immediately answers “no opinion”, Claude Code shows its normal dialog, and nothing is different from life without Pilothouse.

Idle longer than that, you’re away. Now the daemon holds the request and notifies your devices. While it waits, three things can happen:

  1. You answer on a device. First answer wins.
  2. You come back to the keyboard. The daemon lets go and the dialog appears in the terminal.
  3. Nobody answers. After the hold limit — 9 minutes by default, always kept below the hook’s 600-second timeout — the daemon lets go and the terminal takes over, so Claude Code never sees a timed-out hook.

You can force either mode: pilothouse away before a meeting, pilothouse here when you want the terminal regardless.

3. Reaching the phone: a web app on loopback, served by Tailscale

The phone side is a small web app (a PWA) served by the daemon itself — but only on 127.0.0.1:7517. Nothing on your network, let alone the internet, can reach it directly.

Tailscale Serve bridges the gap. pilothouse remote tailscale runs tailscale serve to publish that loopback port as https://<machine>.<tailnet>.ts.net, with a real certificate, reachable only by devices logged in to your tailnet. No ports are opened on your router or firewall, and no Pilothouse server is involved.

Why insist on HTTPS? Because phones do. iOS only delivers web push to home-screen web apps on secure origins, and WebCrypto keys — which we’re about to rely on — need a secure context too. A plain http://192.168.x.x address can’t do either.

4. Getting attention: web push (and friends)

When you pair a phone and allow notifications, its browser gives Pilothouse a push subscription. Pilothouse signs pushes with its own VAPID key and sends them to your phone vendor’s push service, which wakes the phone. Tap the notification and you’re looking at the request: agent, repo, tool, the full command, and the rule that sent it to you.

If you’d rather use ntfy, Pilothouse sends notifications with Allow and Deny buttons. Slack and Discord webhooks work too, as alerts with a link back to the app.

5. Making “Allow” trustworthy: device keys and signatures

A remote “Allow” runs a command on your machine, so it deserves more than a cookie.

When you pair (pilothouse pair shows a QR code with a one-time code that expires in 10 minutes), the phone’s browser generates an ECDSA P-256 key pair whose private key can’t be exported, and registers the public key with your daemon. Every decision is signed over the request ID, the decision, its scope, a timestamp and a single-use nonce. The daemon rejects anything unsigned, anything older than two minutes, and any nonce it has seen before.

So a leaked cookie can’t approve anything, and a captured decision can’t be replayed or pointed at a different request. You can revoke a device at any time with pilothouse devices.

6. Back to the agent

The daemon turns your decision into a PermissionRequest hook response — allow, or deny with your message — and the shim prints it. Claude Code carries on. If you chose Allow for session, the daemon also records a grant, so the next git push in that session goes through without asking (deny rules still win over grants).

The whole round trip is: hook → Unix socket → daemon → web push → your phone → signed POST over your tailnet → daemon → hook response.

Setting it up

curl -fsSL https://pilothouse.dev/install.sh | sh
pilothouse setup              # hooks + background service
pilothouse remote tailscale   # HTTPS on your tailnet
pilothouse pair               # scan the QR on your phone

On iPhone, add the page to your home screen and allow notifications from there. Then pilothouse away, give Claude Code something that needs permission, and watch your phone. The phone setup guide has the details and the security model has the fine print.

No account was created during this process, and no port was opened.