Remote control for a local AI agent creates an awkward setup problem. The phone needs enough information to reach and authenticate to the computer, but a QR code is a poor place for a long-lived API key. QR payloads can end up in screenshots, camera history, debugging output, or a copied deep-link URL.
I build Hermes Mobile, an Android and iOS client for controlling a Hermes agent running on your own computer. The pairing path in the current code uses a short-lived, opaque pairing code instead of embedding the gateway key in the deep link. This is the implementation pattern, the tradeoffs, and a reproducible way to inspect it.
The client and gateway are different roles
The phone runs the mobile client. The Mac or other computer runs the Hermes gateway on port 8642. Wi-Fi and Tailscale only provide a network route between them:
phone client -> private Wi-Fi or Tailscale -> computer gateway :8642
That distinction matters during discovery. A computer picker should list machines that answer as Hermes gateways, not every device visible on the tailnet. The handset's own Tailscale address is intentionally filtered so the app does not offer to connect back to itself.
Put a one-time code in the link, not the gateway key
The setup link carries two pieces of pairing information:
hermes://setup?pairCode=HTYH6PSW&pairServer=http%3A%2F%2F100.x.y.z%3A8765
The pairCode is an opaque one-time value. pairServer identifies the private endpoint that can exchange it. The long-lived gateway credential is not a query parameter in this path.
On the phone, the exchange is deliberately small. Reduced to its essential operation, it is:
const response = await fetch(
`${pairServer}/pair-exchange?code=${encodeURIComponent(pairCode)}`
);
if (!response.ok) return null;
const gatewayConfig = await response.json();
After a successful exchange, Hermes Mobile saves the returned key through platform secure storage backed by Android Keystore or iOS Keychain. The phone then uses the returned gateway URL and key for authenticated health checks and sessions.
Make replay fail
The computer keeps the pairing-code map in memory with an expiration time and a consumed flag. A valid exchange consumes the code. A second request with the same code fails. Unknown and expired codes fail too.
The current pairing page displays codes with a twenty-minute maximum lifetime so a user has time to move between screens, but the page refreshes the displayed code every minute. The important property is not merely that the code is short. It is that replay is rejected after the first successful exchange.
This approach does not magically make an untrusted network safe. The exchange endpoint still needs to be reachable only through a route you control, such as the same private Wi-Fi network or your Tailscale tailnet. If the pair server is exposed publicly, or if the computer itself is compromised, an opaque code is not a complete defense.
A practical verification sequence
You can test the pattern without printing the real credential:
- Start the computer gateway and pairing server.
- Confirm the generated setup link contains
pairCodeandpairServerbut nokeyorapiKeyfield. - Exchange the code once and verify the server returns HTTP 200.
- Repeat the exact exchange and verify it is rejected.
- Mint another code, wait beyond its configured TTL, and verify that exchange is rejected.
- On the phone, confirm an authenticated gateway health check succeeds after pairing.
- Turn off the active network route and confirm the app reports the connection loss instead of claiming it is still connected.
Hermes includes deterministic tests for self-peer filtering, single-use rejection, unknown-code rejection, and expiration. Those tests are useful because a green connection indicator alone cannot prove that a credential stayed out of the QR payload or that replay protection works.
Where this pattern helps
The same design applies to local development tools, home-lab dashboards, self-hosted assistants, and device-control apps. Keep discovery and authorization separate: discovering an IP address does not authorize it, and seeing a device on Tailscale does not prove that it runs the service you need.
Pair with an opaque, expiring capability; exchange it once over a private route; validate the service and credential; then store the resulting secret in the platform credential store. It is a small protocol, but it removes a surprisingly common credential leak from the setup flow.
If you want to use this workflow rather than build the mobile client yourself, install Hermes Mobile from Google Play on Android or the App Store on iPhone and iPad. Both store listings are paid upfront.
Top comments (0)