Your phone: the gateway¶
The gateway (handler-gateway, in remote-gateway/) lets a phone or
tablet control shared agent sessions and mini apps. Handler never opens a port: it polls the gateway
outbound, and the phone talks to the same gateway. Everything relayed is end-to-end encrypted;
the gateway cannot read or forge it. How it is built and what a
compromised gateway can do: remote control design.
phone ──HTTPS──► gateway ◄──HTTPS (outbound long poll)── handlerd on your machine
The gateway does two jobs:
- a mailbox between Handler hosts and their paired devices (
/v1/...); - a web server for the remote client, the small web app the phone opens (
/).
Before you start¶
- HTTPS is required. Browsers only give the remote client WebCrypto on
https://pages (or localhost). Plainhttp://over the LAN does not work. - Where to run it. Any always-on machine that both the phone and your Handler machine can reach: a home server, a NAS, a VPS, or the Handler machine itself.
- Choose how it is reached (this guide covers both):
- Tailscale (recommended): reachable only from your own devices, TLS certificate handled for you.
- Public, behind a reverse proxy (Caddy on a VPS): reachable from anywhere. See Security first.
- Choose how it runs: a container (Podman; builds the remote client for you), or directly
with
uv(needsbunto build the client).
1. Install and run it¶
Option A: container (recommended)¶
Build from the repository root (the image also builds the remote client):
podman build -f remote-gateway/Containerfile -t handler-gateway .
Try it:
podman run -d --name handler-gateway -p 127.0.0.1:7431:7431 -v handler-gateway:/state handler-gateway
curl http://127.0.0.1:7431/v1/health
It listens on 127.0.0.1:7431 only; TLS comes in step 2. The handler-gateway volume holds its
state (/state/state.json and /state/state.queue.json).
To keep it running across reboots, replace the podman run with a Quadlet unit.
~/.config/containers/systemd/handler-gateway.container:
[Unit]
Description=handler remote-control gateway
[Container]
Image=localhost/handler-ai-gateway:latest
ContainerName=handler-gateway
PublishPort=127.0.0.1:7431:7431
Volume=handler-gateway:/state
[Service]
Restart=always
[Install]
WantedBy=default.target
podman rm -f handler-gateway # the test container, if still there
systemctl --user daemon-reload
systemctl --user start handler-gateway
loginctl enable-linger "$USER" # keep user services running when you are logged out
Option B: without a container¶
Build the remote client (from the repository root):
bun install
cd packages/remote-client && bun run build # → packages/remote-client/dist
Install and run the gateway (its own project, with its own virtualenv):
uv tool install ./remote-gateway # → ~/.local/bin/handler-gateway
handler-gateway serve --port 7431 --client "$PWD/packages/remote-client/dist"
As a systemd user service, ~/.config/systemd/user/handler-gateway.service:
[Unit]
Description=handler remote-control gateway
[Service]
ExecStart=%h/.local/bin/handler-gateway serve --port 7431
Environment=HANDLER_GATEWAY_CLIENT_DIR=/path/to/handler/packages/remote-client/dist
Restart=always
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now handler-gateway
loginctl enable-linger "$USER"
State lives in ~/.local/state/handler-gateway/ (set HANDLER_GATEWAY_STATE to move it).
2. Put HTTPS in front¶
Tailscale¶
Enable MagicDNS and HTTPS certificates for your tailnet (admin console → DNS), then on the gateway machine:
tailscale serve --bg --https=443 http://127.0.0.1:7431
The gateway is now at https://<machine>.<tailnet>.ts.net, for devices on your tailnet only.
Install Tailscale on the phone too. Use tailscale serve, not tailscale funnel (Funnel makes
it public).
Caddy (public)¶
Point a DNS name at the server, open ports 80 and 443, then in the Caddyfile:
gateway.example.com {
reverse_proxy 127.0.0.1:7431
}
Caddy gets the certificate by itself. Its defaults suit the gateway's long polls (held up to 25 s); if you use another proxy, allow at least 30 s of read timeout and 8 MiB request bodies.
Leave the gateway on 127.0.0.1; use --host 0.0.0.0 only when the thing in front terminating
TLS cannot reach localhost (the container image already does this inside the container, and the
port is published on 127.0.0.1 only).
Check:
curl https://<gateway>/v1/health
and open https://<gateway>/ on the phone: the remote client should load.
3. Register your Handler machine¶
On the gateway machine, add one host per Handler machine:
podman exec handler-gateway handler-gateway add-host laptop # container
handler-gateway add-host laptop # without a container
It prints the host token, shown once. The gateway only keeps its hash. Copy it now.
handler-gateway hosts # hosts and how many devices each has
handler-gateway remove-host h_… # a host and all its devices
4. Connect Handler and pair a phone¶
In the Handler web UI (handler ui) on your machine:
- Settings → Remote: the gateway URL (
https://<gateway>) and the host token. Saved in~/.config/handler-ai/remote.json(0600). - Settings → Remote → Pair a device: scan the QR code with the phone, then Pair. The key
travels in the link's
#fragment, which never reaches a server. Devices can only be paired from here, never from the gateway. - New device is just a label (
phone,alice's tablet). - For is the person the device belongs to. Leave it empty for your own devices (person
owner: sessions and apps). Enter a name (alice; lowercase letters, digits,-,_) for a family member's device: it reaches mini apps only, never a session. - In a session, turn on the Remote switch. The phone lists the session.
5. Use a mini app from the phone¶
Mini apps are reached through the same gateway, end-to-end encrypted like
sessions. The app still runs only on your machine: there is no URL of its own from outside
(http://<name>.localhost:7430/ works on the machine itself only).
- Deploy the app (Apps page, or
handler app deploy). - On the Apps page, next to Remote (devices of), tick the persons who may use it: you for your own devices, and each family member you paired a device for. Apps are shared with persons, not devices: every device of a ticked person gets the app, and a device paired later for that person gets it too. Nothing is ticked by default (local only).
- On the phone, open
https://<gateway>/: the shared apps are listed above the shared sessions. Tap one to open it.
When your machine is off, the phone shows the last copy of the app's UI it received; calls fail
with "host offline", except routes the app declares queued, which wait at the gateway (up to
7 days) and run when the machine is back.
Status lights (sidebar and each session's switch): 🟢 ok · ⚪ idle (nothing shared, Handler not polling) · 🔴 unreachable · 🟠 unauthorized (host token refused).
Operations¶
Update. Container: git pull, rebuild the image, systemctl --user restart handler-gateway
(or podman rm -f + podman run). Without a container: uv tool install --reinstall
./remote-gateway, rebuild the client, restart the service. A restart loses envelopes in transit
(in memory, 10 min at most); Handler and the phones resync from the session log on their own.
Envelopes queued for an offline host (state.queue.json, up to 7 days) survive.
Back up the state: the handler-gateway volume, or ~/.local/state/handler-gateway/. It only
holds token hashes and still-encrypted queued envelopes, nothing readable.
Lost host token. remove-host the old host (this removes its devices at the gateway too),
add-host a new one, and enter the new token in Settings → Remote. Pair the phones again if
they do not reconnect.
Revoke a phone from Handler (Settings → Remote). Handler deletes its key and removes it from the gateway; even a gateway that ignored the removal cannot get the phone's commands accepted.
Limits (built in): 20 requests/s per caller (burst 60), 256 KiB per command, 8 MiB per
upload, 500 waiting commands per host (then 503: commands are never dropped silently), 5000
waiting output envelopes per device (the oldest are dropped; the phone resyncs).
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
| The phone opens the page but says crypto is unavailable | not served over HTTPS |
| 🔴 unreachable in Handler | wrong URL, gateway down, or the Handler machine cannot reach it (e.g. not on the tailnet) |
| 🟠 unauthorized in Handler | wrong or removed host token: handler-gateway hosts, then add-host again |
| The phone shows the host offline | Handler only polls while something is shared: turn on a session's Remote switch, or share an app |
| The phone lists no apps | the app is not deployed, or not ticked for the device's person on the Apps page |
| The phone lists no sessions | no running session has Remote on (it resets to off when a session stops) |
| A command shows "expired" | the phone's or the host's clock is more than 5 min off |
/ returns 404 |
the client is not configured: --client / HANDLER_GATEWAY_CLIENT_DIR (the container sets it) |
Logs: journalctl --user -u handler-gateway (or podman logs handler-gateway).
Security¶
The design assumes the gateway can be compromised: it cannot read your prompts or output, send commands, or replay them. One exception in v1: the gateway also serves the remote client's JavaScript, so whoever controls the gateway could serve a modified client and take the key of a phone that opens it. Hence:
- prefer Tailscale, so the gateway is not on the public internet;
- on a public server, keep it patched, run nothing else sensitive on it, and use the container
(non-root user, only
/statewritable); - if you think the gateway was tampered with, revoke every device in Handler and pair again after redeploying.
Moving the client off the gateway is planned.