Skip to content

Companion apps on your local network

A companion app is a phone or a second computer driving your gateway — the same dashboard, rendered on a different screen. The only hard problem is the first thirty seconds: the other device has to learn where this machine is, and then be allowed in.

Those are two separate problems, and PersonalClaw keeps them separate:

QuestionAnswered byOptional?
Where is the gateway?you type the LAN address — or LAN discovery finds it for youdiscovery is optional and off by default
May this device in?the token link, or a single-use pairing codenever optional

Discovery only ever answers the first question. It grants nothing, it carries no credential, and turning it off never costs you access — it costs you typing. If you only ever use PersonalClaw on the machine it runs on, you can stop reading here.

Reaching your gateway from outside your home network is a different guide: Reaching your dashboard from outside your home network. Nothing here exposes anything to the internet.


The path that always works: type the address

Section titled “The path that always works: type the address”

This needs no setup beyond letting the gateway listen on your LAN.

  1. Let the gateway off loopback. By default it binds 127.0.0.1, which no other device can reach. Bind it wider — the token stays on; it is your front door:
    Terminal window
    PERSONALCLAW_BIND_HOST=0.0.0.0 personalclaw gateway --port 10000
  2. Find this machine’s address.
    Terminal window
    personalclaw doctor # prints the LAN/tailnet address it can see
  3. Mint a signed-in link and open it on the other device.
    Terminal window
    personalclaw token # prints http://localhost:10000?token=…
    On the phone, open the same path with the LAN host substituted: http://192.168.1.37:10000?token=…

That URL is also what a QR code encodes, so scanning and typing are the same mechanism — one is just kinder to your thumbs.

Use a fresh token for each device. A token becomes bound to the first address that uses it, so a link you already opened on the machine will be refused from the phone, which is told the link “has already signed in another device”. Run personalclaw token again and open that link on the phone first.

A durable session instead of a long URL. A token link is fine once; for a device you will use every day, pair it. On the machine:

Terminal window
personalclaw auth enroll # prints a single-use 8-character code, valid 5 minutes

The code redeems for a session lasting auth.session_ttl. It is worth exactly one session and nothing else — single-use, short-lived, hashed at rest, and at most five outstanding at a time. That bound is the point: an 8-character string read off one screen and typed into another should not be usable twice, and cannot be.

Known rough edge. Redeeming that code from a browser you reached by IP address is currently refused with CSRF check failed: request origin not allowed — the gateway’s allowed-origin set covers its loopback names and its hostname, not its LAN address. Reading the dashboard over the LAN works; it is only this state-changing call that is rejected. Until that is settled, reach the gateway by hostname (http://<machine-name>:PORT) for the redeem step, or use the token link.


The convenience: LAN discovery (optional, off by default)

Section titled “The convenience: LAN discovery (optional, off by default)”

With discovery on, the gateway announces itself on your local network as a standard mDNS/DNS-SD service — the same mechanism that makes printers and speakers appear by name. A companion app then lists it instead of asking you to type an address.

It ships off. Announcing a service on a network is a posture choice: on your own Wi-Fi it is unremarkable, in a café or an office it tells every other device on that network that this machine is here. That should be a decision, not a default.

Settings → Companion apps → LAN discovery, or:

Terminal window
personalclaw config set companion.discovery_enabled true
personalclaw config set companion.instance_name "Living room Mac"

instance_name is just the label other devices show. Leave it empty and your machine’s hostname is used. It takes effect immediately — no restart.

Terminal window
personalclaw discover
Found 1 gateway:
Living room Mac
url: http://192.168.1.37:10166
pairing: required — run `personalclaw auth enroll` on that machine
for a single-use code, then redeem it from this device.

--json prints the same thing machine-readably. Any DNS-SD browser sees it too, which is a useful way to check what your network is actually being told:

Terminal window
dns-sd -B _personalclaw._tcp # macOS
avahi-browse -r _personalclaw._tcp # Linux

Four fields, and that is the complete list:

FieldExampleWhy it is there
nameLiving room Macthe label you chose, so you can tell two gateways apart
port10166so a client can build a URL
requires_pairing1so a client can say “you’ll need a code” before it knocks
schema1so a future client knows what it is reading

Plus the service address, which is how any network service is reachable at all.

No token, no session, no content — ever. A discovery record is a broadcast: unauthenticated, readable by every device on the network, and cached by some of them. So the code that builds it works from a fixed list of four keys and cannot express a fifth; a test asserts that the serialized packet contains none of the gateway’s credentials. The dashboard shows you the record verbatim under Settings → Companion apps — what you see there is what your network receives.


Worth being precise, because “discovery” sounds like a security feature and is not one.

  • It is not access control. Discovery says where; the token or the pairing code decides whether. Turning discovery on does not make your gateway more reachable — a gateway bound to your LAN was already reachable at that address by anything that scanned for it. Turning discovery off does not make it less reachable, either.
  • It tells the network you exist. That is its whole job. On a network you do not control, the honest mitigation is to leave it off — which is the default.
  • It is unauthenticated in both directions. Anything on the network can claim to be a PersonalClaw gateway. A client that finds one still has to pair with it, and pairing requires a code minted on the real machine — so a lookalike gets you a failed pairing, not a session. Check the URL a client offers you before you type a code into it.
  • It does not cross networks. Multicast stops at your subnet. From outside your home network you want remote access, not discovery.

Discovery is a convenience layer over a path that always works, so every failure below has the same fallback: type the address.

What you seeWhat is happening
Settings says “bound to loopback only”The gateway is on 127.0.0.1, so nothing on your network could reach it, and advertising an unreachable address would be a lie. Nothing is announced. Start the gateway with PERSONALCLAW_BIND_HOST=0.0.0.0.
Settings says “no local-network address”The machine has no LAN address to publish — usually no network, or an interface that is up but unaddressed.
personalclaw discover finds nothingDiscovery may be off on the other machine; or the network filters multicast. Guest and corporate Wi-Fi very often do, and client isolation blocks device-to-device traffic entirely. Nothing is broken — type the address.
It worked, then stopped after a restartGive it a few seconds. A stopping gateway withdraws its record immediately, and a starting one re-announces a small burst.
The page says the link “has already signed in another device”That token was already used from another address. Mint a fresh one with personalclaw token and open it on this device first.
CSRF check failed: request origin not allowed while redeeming a codeSee the rough edge above — reach the gateway by hostname for that step.

Native wrappers live in their own plans (desktop, mobile); this is the contract they share.

  1. Find a gateway, or accept a typed URL. Both, always — discovery is never a precondition. personalclaw.companion.discovery.resolve() is the Python implementation of the client half if you are building in-process; over the wire it is a plain DNS-SD browse for _personalclaw._tcp.local.
  2. Pair once. Ask the user for the code from personalclaw auth enroll and redeem it for a durable session. Do not invent a second credential type.
  3. Render the served dashboard. There is no separate companion API — a companion loads the same SPA from the gateway it paired with. Keeping one frontend is why the phone never lags behind the desktop by a release.

The rest of this guide is the precise version of that contract — the part a desktop shell and a phone shell must not answer differently.


Two wrappers around one product will drift if either of them gets to decide something the other also decides. So this section decides it once.

It is deliberately small, and for a good reason: almost everything a companion needs is already settled by the fact that it renders the gateway’s own dashboard. The SPA brings its own routing, its own caching and its own reconnect behaviour. The genuinely new state — the only state a wrapper owns — is the list of gateways it has paired with.

A companion may hold more than one paired gateway. A work brain and a personal brain is the common case, and they are unrelated machines that merely happen to sit in one app’s list. That list is the only sanctioned multi-instance mechanism in PersonalClaw. Nothing is shared between the gateways in it — see No hub, ever below, which is a permanent boundary and not a current limitation.

A client stores exactly one thing: a list of endpoints plus a pointer at the active one.

{
"active": "home-laptop", // the id of the endpoint currently loaded
"endpoints": [
{ "id": "home-laptop", // stable, client-minted; never sent to a gateway
"label": "Home laptop", // what the switcher shows
"base_url": "http://claw.local:10000",
"kind": "local", // "local" | "remote"
"device_session_ref": "" } // reserved; always empty, never handed out
]
}

Four notes, each of which someone would otherwise get wrong:

  • id is the client’s own. It exists so the shell has a stable key to namespace state under, and it survives the user relabelling or re-pairing an endpoint. No gateway ever sees it or needs to.
  • base_url is an origin to navigate to, not a prefix to prepend. More on this below — it is the single most important thing in this section.
  • kind is local for a gateway on your own network and remote for one reached through the remote access path. It changes nothing about authentication; it exists so a shell can label a connection honestly.
  • label defaults to the gateway’s own companion.instance_name (the same field the discovery record publishes, above), falling back to its hostname. Let the gateway name itself — a user who renames “Living room Mac” once should not have to rename it again in every wrapper.

Where the registry lives, and why the dashboard cannot hold it

Section titled “Where the registry lives, and why the dashboard cannot hold it”

The registry belongs to the shell, in the shell’s own storage, outside any gateway’s origin. This is not a preference. The served dashboard is structurally incapable of holding it:

  • The dashboard is per-gateway by construction. A shell loads the SPA from a gateway: desktop/main.js:1461 is a bare wc.loadURL(localGatewayUrl), and navigateToEndpoint does the same with a paired gateway’s origin. One shell window is looking at one gateway at a time, always. (localGatewayUrl is resolved from the spawned gateway’s READY line at desktop/main.js:367; activeUrl is what the WebView is currently pointed at. They are separate variables on purpose — see What the desktop shell narrows.)
  • The SPA has no base-URL concept at all. Its API client speaks root-relative /api paths on the same origin (web/src/lib/api.ts:1-3), and the WebSocket is built from location.host (connect in web/src/lib/useChatSocket.ts). There is no variable to re-point. A bundle served by gateway A can only ever talk to gateway A.

So a registry kept inside the dashboard would be a registry each gateway held a separate copy of, listing itself — which is not a registry.

The good news first. Because the SPA is origin-bound, browser origin isolation already partitions everything it stores. Two gateways on two origins get two separate localStorage and sessionStorage buckets for free — including the cache:-prefixed sessionStorage mirror at web/src/lib/data/store.ts:50, and the other 47 non-test files under web/src that touch web storage. A wrapper does not need to namespace, wrap, or patch any of that, and should not try.

The bad news, and the whole point of this subsection: the shell’s own storage is a single scope spanning all N gateways. desktop/main.js declares no partition, so the default session applies. That single scope is the one and only place two brains can bleed into each other.

The rule: everything the shell itself persists is keyed by endpoint id. Window bounds and zoom, the last route, notification state, badge counts, cached avatars, any wrapper-side prefs — all of it goes under the endpoint’s id, never under a global key. The registry itself (active plus the list) is the sole exception, because it is what the ids belong to.

If you find yourself writing a global key in a wrapper, you have found a state-bleed bug before it shipped.

Switching is two steps, in this order:

  1. Re-point active to the target endpoint’s id.
  2. Load that endpoint’s base_url. Not a re-configuration — a navigation to a different origin.

What the user sees: the switcher shows every paired gateway with the active one marked; they pick another; the dashboard reloads as that gateway’s dashboard, already signed in, because the device session for it is already held. It should feel like switching accounts in a mail app, not like reconnecting.

What must not happen, in any wrapper:

  • No aggregation. No merged inbox, no combined search, no unified notification count, no “all gateways” view. One gateway is in view at a time.
  • No cross-endpoint carry-over. Do not hand the target endpoint state read from the previous one — that is exactly the bleed the namespacing rule exists to prevent.
  • No re-pairing on switch. An endpoint already in the list is already paired. If it is not, that is a revocation to report, not a pairing flow to re-enter.

Device sessions are per-gateway and never federate

Section titled “Device sessions are per-gateway and never federate”

A device session is an httponly cookie scoped to that gateway’s own origin (pc_token_<port>, set by _set_session_cookie in dashboard/handlers/auth.py) — a browser sends it back only to the gateway that set it, which is why sessions never federate. There is no shared identity across gateways, and no gateway knows the others exist.

The consequence a wrapper must get right: revoking a device session breaks exactly one entry. The other endpoints keep working, untouched. A shell that reacts to one endpoint’s refusal by clearing its whole registry has turned one revocation into a full re-pair of every gateway. Surface it on that row, and leave the rest alone. The gateway’s refusal carries X-Auth-Required: true and a sentence saying why and how to sign back in (error.message, see API overview) — show that rather than a wording of your own. The desktop app watches its page’s own responses for that header and puts the gateway’s sentence on the row (desktop/connectMode.js::sessionRefusal).

Carry a device session as the session cookie — never through the ?token= query parameter. This one is measured, not stylistic:

  • The query-param path binds the token to the first client IP it sees (bind_token_ip in dashboard/token_auth.py, called by token_auth_middleware on the first query-param use) and then refuses on mismatch — session_required, reason link_used (check_token_ip, enforced by the same middleware).
  • Cookie-borne requests skip that check entirely — “the credential itself is the proof, and IP validation behind a proxy is unreliable” (token_auth_middleware). So does a session carried in an Authorization: Bearer header: the binding belongs to the entry link alone.

A phone changes IP every time it moves between cell and Wi-Fi. A query-param device session would therefore die on every network change, while a cookie-borne one is untouched. Pairing completion sets the cookie on the response, and a native shell — Electron or a Capacitor WebView — holds it exactly like any browser does. There is nothing to implement here beyond not reaching for the query parameter because it was the shape you saw in the token link.

A remote endpoint is your own gateway reached through the remote access tunnel. The tunnel terminates TLS, so the socket is wss:// rather than ws://.

Nothing is in the middle. The connection is your client to your gateway and stops there. There is no relay, no broker, no account, and no cloud tier that sees your traffic — the tunnel is the owner’s own, and the gateway is the only server in the path. That is a property of the design, not a setting: no first-party code anywhere knows how to forward a companion’s request to a third host.

Two different jobs, and mixing them up is the common bug:

  • A WebView shell loads the served dashboard from base_url. The SPA then opens its own socket origin-relative (${proto}://${location.host}/api/ws), so the shell must not build a URL at all. This is why the rule below says to load base_url as an origin and never prepend it to an API path.
  • A native client — one that speaks to the gateway directly instead of hosting the SPA — has no document and therefore no location to be relative to. It has only the registry row, so it must build the URL, and it should use the shared helper rather than its own string work:
import { endpointSocket, endpointSocketUrl } from './lib/endpoints'
endpointSocketUrl('https://pc.example.com') // 'wss://pc.example.com/api/ws'
endpointSocketUrl('http://claw.local:10000') // 'ws://claw.local:10000/api/ws'
endpointSocket(activeEndpoint(registry)) // same, from a registry row

The scheme is derived from the endpoint’s own URL, so a remote row behind TLS gets wss:// without anything having to remember that “remote implies TLS”. Nothing consults kind — that field says whether your shell spawned the gateway, which is a lifecycle fact, not a transport one. An unparseable base_url, a bare host, or a scheme that is not http/https returns undefined rather than a guess: show “this endpoint is misconfigured” on that row, because a guessed scheme dials a socket somewhere you did not intend.

The device session rides as the session cookie, exactly as on the LAN — see the query-param warning above, which matters more here because a phone changes IP whenever it moves between cell and Wi-Fi. The URL carries no credential.

The part worth knowing, because it is the one place a native client is treated differently from a browser: a native client sends no Origin header, since it has no document origin to send. The gateway admits an Origin-less /api/ws upgrade only when the session that authorized it is a paired device session. Concretely:

ClientOriginResult
Native shell, paired device sessionabsentupgrade proceeds
Any client, ordinary browser sessionabsentrefused
Any client, any sessionpresent but not in the allowlistrefused

Two things follow, and a wrapper author should not try to work around either:

  • The allowed-origin list is unchanged. Pairing a device does not add an origin, so it buys no help presenting one. If your client does send an Origin, it must be in the list like everyone else.
  • Pairing is the opt-in. There is no config flag to enable this and nothing to turn on. A session the owner deliberately paired is what vouches for the connection, which is also why revoking that device from Settings → Devices closes it immediately and closes nothing else.

Known rough edge: a WebView over the tunnel needs dashboard.url too

Section titled “Known rough edge: a WebView over the tunnel needs dashboard.url too”

If your shell loads the dashboard in a WebView through the tunnel, the page’s origin is your public URL, and dashboard.public_url alone does not admit it: it adds wss://<host> to the Content-Security-Policy but does not add https://<host> to the CSRF/WebSocket origin allowlist. The dashboard then renders and never receives an event, and state-changing requests return 403.

Until that is reconciled, set both fields to the same public URL:

{ "dashboard": { "url": "https://pc.example.com",
"public_url": "https://pc.example.com" } }

dashboard.url is the field that widens the origin allowlist (and it refuses to do so unless token auth is active). A native client that sends no Origin is unaffected by this and needs only public_url.

Reconnecting: reuse the contract, do not write one

Section titled “Reconnecting: reuse the contract, do not write one”

A wrapper inherits reconnect behaviour by loading the served dashboard, and that behaviour is already specified in code:

  • The socket reconnects with capped exponential backoff — retry climbs to a ceiling of 6 and the next attempt is scheduled at 250 * 2 ** retry ms (the socket’s onclose in web/src/lib/useChatSocket.ts).
  • Every page that keeps state current from the socket’s frames re-reads it when the socket comes back: the catch-up callback (onReconnect) is a required argument, null only for a consumer that keeps nothing a lost frame leaves stale. It fires only after a real connection existed, guarded by sawOpen, so a first-load failure is not reported as a dropped connection.
  • Degraded UI is whatever the dashboard already renders in that state. It is the same contract the rest of the product uses; a companion is not a special case.

Do not add a wrapper-side retry loop. A second timer racing the SPA’s own produces duplicated catch-up fetches and a connection indicator that disagrees with the page. A shell’s legitimate job here is narrower: notice that the machine is unreachable at all (DNS or TCP failure against base_url), and say so on that endpoint’s row in the switcher.

If you open the socket yourself: two things a dying tunnel does

Section titled “If you open the socket yourself: two things a dying tunnel does”

A shell that hosts the SPA inherits all of the above and can stop reading here. A shell that opens the socket itself has no SPA to inherit from, and two behaviours were measured against a real TLS tunnel being killed under a live session (tests/test_wss_tunnel_e2e.py):

  • The drop arrives in one of two shapes, and you must handle both. The read either returns a close/error message or it raises a connection-reset error — the latter when the client’s autoping tries to answer a ping on the transport that has just died. Handling only the message shape turns an ordinary tunnel restart into an unhandled error. Neither shape is slow; if neither arrives, you are looking at the hang this contract exists to prevent, not a quiet link.
  • Your device session survives the drop — keep it. Reconnecting does not mean re-pairing. The session is cookie-borne, and only the entry link is IP-bound (token_auth_middleware in src/personalclaw/dashboard/token_auth.py reads if from_query and not check_token_ip(...)), so the same session still authenticates after a tunnel restart has moved your apparent address — which is exactly what happens when a phone changes network. This is the concrete reason the guide forbids ?token= for a companion: that path is IP-bound, so a shell that authenticates with it will find each reconnect refused as a link already used elsewhere.

This is a standing decision, quoted rather than summarised, because it is the rule most likely to be re-litigated by someone adding “just one” convenience:

No hub in core, ever. No gateway-to-gateway anything. Gateways never discover, sync with, or proxy for each other; no shared identity, no cross-gateway search, no aggregated inbox in core or in the shells. A future “hub” could only ever be a third-party app running against gateways the user pairs it with — explicitly out of first-party scope.

Read it as a design boundary, not a missing feature. The N gateways in a client’s registry are N independent machines; the client is the only thing that knows they are related, and it knows it only as a list. Multi-gateway support is a client affordance from end to end, which is why it costs the gateway nothing.

A desktop or mobile author can work from this list without deciding anything else:

  1. Persist the registry — {active, endpoints[]} in the shell’s own storage, in the shape above.
  2. Add an endpoint by discovery or a typed URL, then pair once for a device session (cookie-borne). Leave device_session_ref empty and do not go looking for it — it is reserved, and no route hands it out.
  3. Key every shell-side value by endpoint id. No global keys except the registry itself.
  4. Load base_url as an origin. Never prepend it to an API path; there is nothing in the SPA to prepend it to.
  5. Ship a switcher that lists all endpoints, marks the active one, and on selection re-points active and navigates. No aggregate view.
  6. Show per-endpoint health — reachable, unreachable, or needs-pairing-again — on the switcher rows. One endpoint’s failure never touches another’s entry.
  7. Inherit reconnect from the SPA. Add no retry loop of your own. If you open the socket yourself rather than hosting the SPA, build its URL with endpointSocketUrl — never by string-concatenating a scheme.
  8. Label endpoints from companion.instance_name, falling back to the hostname, and let the user override locally.

If a wrapper needs something not on this list, that is a change to this document first.


Two shells exist today: the desktop app (desktop/main.js) and the phone, which installs the served SPA as a PWA. Neither of them is a port. That is the whole point, and it is the reason a third platform is a small job rather than a new product.

The recipe is two steps, and there is no third:

  1. Wrap the served UI. Load an endpoint’s base_url as an origin in whatever the platform calls a web view, and stop. No forked UI, no per-platform screens, no second API. If you find yourself designing a view, you are on the wrong side of the line — that view belongs in web/ where every platform gets it at once, including the two that already shipped.
  2. Implement the client contract above. All eight items, unchanged. The registry shape, the per-endpoint namespacing, the switcher, the health rows, the socket-URL rule. A new platform is not an occasion to re-decide any of them; if you disagree with one, argue with this document, not with your own shell.

What is legitimately yours, per platform: packaging and signing, the notification-permission prompt, safe areas and the hardware back button, and a secure place to keep the registry. That list is short on purpose. Everything not on it is either the SPA’s job or the gateway’s.

What the gateway owes you is equally fixed: the pairing routes, the Devices registry, the discovery config, and a content-free push payload. It grows no per-platform backend — no platform-shaped endpoint, no server-side branch on your user agent, no fan-out service. A platform that cannot be brought up without one has found a gap in this contract; report it as a gap rather than closing it privately on the server.

The gate: nothing platform-specific ships before the platform is cleared

Section titled “The gate: nothing platform-specific ships before the platform is cleared”

Which platforms are supported at all is a separate call, not this contract’s. Until a platform is cleared, no code for that platform lands in this repository — and that includes the well-meant kind:

  • no platform SDK dependency, and no platform SDK identifier in src/personalclaw or web/src (those two are the surfaces every platform shares; a native symbol appearing there means a shell has leaked into the shared half);
  • no if (platform === …) branch reserving a slot for a shell that does not exist;
  • no empty directory, no scaffold, no stub “so the wiring is ready”.

This is the clean-break tenet applied to a platform: unused platform code is dead code, and dead code that names a platform is worse than dead — it reads as a promise. Between now and the gate clearing, a prospective platform’s entire footprint here is this recipe. That is sufficient, because a shell that follows it needs nothing from us that is not already written down.

When the gate does clear, the shell arrives as its own tree with its own build, and it earns its place by passing the eight-item list — not by being wired in ahead of time.