Skip to content

Reaching your dashboard from outside your home network

By default PersonalClaw is a local program: it binds to your machine, and you get in with a token link (personalclaw token). That is the safest arrangement, and if you only ever use it at home you should stop reading here — nothing in this guide makes a local install better.

This guide is for the other case: you are away from home — on your phone, on cell data — and want to reach your own dashboard. There are two shapes of answer, and you almost certainly want the first one:

  1. A private network (a tailnet). Your phone and your gateway join the same encrypted overlay network and talk directly. Nothing is exposed to the public internet; there is no TLS certificate to obtain and no port to open. This is the recommended path for personal use.
  2. A public tunnel. You deliberately publish the dashboard to the internet behind TLS and a password. More moving parts, a larger attack surface — the right choice only when you cannot put both devices on a tailnet.

Section titled “The simplest path: a tailnet (recommended)”

A tailnet (Tailscale is the common one) is a private, end-to-end-encrypted network your devices join. Once your phone and your gateway are both on it, the phone reaches the gateway at a stable 100.x.y.z address as if they were on the same LAN — over the tailnet’s own encryption, with nothing published to the public internet.

This works today, unchanged, with the default AUTH_MODE=local_token. You do not set dashboard.public_url, you do not obtain a TLS certificate, and you do not port-forward anything.

your phone ──(tailnet, encrypted)──▶ your gateway at 100.x.y.z:PORT
both devices are members of the same tailnet; no public exposure

Steps

  1. Put the gateway on the tailnet. Install Tailscale on the machine running PersonalClaw and bring it up:
    Terminal window
    tailscale up
    tailscale ip -4 # prints this machine's tailnet address, e.g. 100.101.102.103
  2. Put your phone on the same tailnet. Install the Tailscale app, sign in to the same account, and confirm both devices appear in your device list.
  3. Let the gateway listen on the tailnet interface. By default the gateway binds loopback only. Bind it so the tailnet can reach it (token auth stays on — the token is the front door):
    Terminal window
    PERSONALCLAW_BIND_HOST=0.0.0.0 personalclaw gateway --port 10000
  4. Open it on your phone with the signed-in link. On the machine, mint a token URL and rewrite the host to the tailnet address:
    Terminal window
    personalclaw token # prints http://localhost:10000?token=…
    On the phone, open http://100.101.102.103:10000?token=… (same path and token, tailnet host).

personalclaw doctor does this detection for you. It now finds the tailnet interface and prints the phone-usable base URL directly:

Remote access
remote: ✅ tailnet 100.101.102.103 — open http://100.101.102.103:10000 on your phone
(run: personalclaw token, for the signed-in link)

If instead it says local-only, your gateway isn’t on a tailnet yet — walk back through the steps above. If it says the bind is exposed beyond loopback with auth OFF, fix that first (see the anti-patterns below) before going any further.

Why this is the safe default. The tailnet is already a private encrypted network, so the two risks the public-tunnel path spends effort mitigating — cleartext on the wire, and a stranger finding your URL — simply do not arise. There is no public listener to find. The token link is still your authentication, and everything under What this does not protect you from still applies.


The other path: expose to the internet (public tunnel)

Section titled “The other path: expose to the internet (public tunnel)”

Use this only when you cannot put both devices on a tailnet. It publishes the dashboard to the public internet, so it needs TLS, a password, and the hardening below.

The honest summary. Exposing this to the internet moves it from “a program on my machine” to “a service someone can knock on”. Everything below is about making that a deliberate, bounded decision rather than an accident. PersonalClaw does not terminate TLS itself and does not open a port for you — you bring a tunnel. (Tailscale can also do this via Tailscale Serve/Funnel — but if you’re using Tailscale, prefer the private-tailnet path above.)

your phone ──https──▶ your tunnel (terminates TLS) ──http──▶ PersonalClaw on 127.0.0.1
cloudflared / Tailscale / Traefik

Three things you provide, in order:

  1. A tunnel that terminates TLS. cloudflared, Tailscale Serve, Traefik, Caddy, nginx — any of them. Plain http across the internet would put your session cookie on the wire in cleartext, so TLS is a precondition, not a nice-to-have.
  2. A password, so a stranger who finds the URL cannot walk in.
  3. dashboard.public_url, which is how you tell PersonalClaw it is exposed. That single setting is what turns on the hardening described below.
Terminal window
personalclaw auth set-password # prompts twice, never echoes
personalclaw auth enable # start offering the sign-in page
personalclaw auth status # confirm both

Minimum 12 characters. Length beats symbols — a passphrase you can remember is better than a short string you keep in a note app. The password is stored as an argon2id hash under ~/.personalclaw/auth/credentials.json (mode 0600) and never leaves your machine.

Your token link keeps working. Sign-in is an additional front door, never a replacement. If you forget the password, walk to the machine and run personalclaw token.

Some changes need a recent sign-in. A sign-in lasts 30 days, so a phone left unlocked would otherwise be enough to plant a lasting way in. Pairing a device, making a device sign-in code, creating an integration token, pairing a channel’s owner, setting the first password, making sign-in less strict and storing a secret that changes who can sign in (such as PERSONALCLAW_BYPASS_LOCAL_NETWORKS in Settings → Secrets) each need a sign-in from the last 10 minutes; from an older one the dashboard asks you to sign in again — with your password, or with a new personalclaw token link where password sign-in is off — and then carries on. Changing the password asks for the current one, and the 2FA code when you have one set up. Signing a device out, revoking a token, removing a secret, replacing the sign-in key and turning incident mode on never ask. The desktop app’s own window never asks.

In ~/.personalclaw/config.json:

{
"dashboard": {
"public_url": "https://pc.example.com",
"trusted_proxies": ["127.0.0.1"]
}
}

Setting public_url changes three things:

EffectWhy
Session cookiegains Secureso the browser refuses to send it over plain http
WebSocket CSPallows wss://<your-host>otherwise the dashboard loads and then silently receives nothing
X-Real-IPhonored only from trusted_proxiessee below

trusted_proxies is the address your tunnel connects from, as PersonalClaw sees it — usually 127.0.0.1 for a local tunnel daemon, or the container/bridge address in Docker (e.g. 172.18.0.0/16). Single addresses and CIDR blocks both work.

Why this list matters. Your tunnel tells PersonalClaw the real client address via X-Real-IP; without it every request looks like it came from the tunnel. But any process that can reach the gateway could claim to be your tunnel and set that header to anything — which would let it move a session’s IP binding. So on an exposed instance the header is believed only from a peer you named here. Leave it empty and no forwarded header is trusted at all (safe, but sessions bind to the tunnel’s address rather than the real client’s).

X-Real-IP is the only forwarded header PersonalClaw reads. X-Forwarded-For and X-Forwarded-Proto are ignored on every path, trusted peer or not — one source for the client address beats two with a precedence rule, and the Secure-cookie/wss:// decisions come from public_url, which is your own statement rather than a header anyone upstream can set. Configure your proxy to send X-Real-IP (see the recipes in Step 2b below). tests/test_forwarded_header_docs_match_code.py fails the build if this promise and the code ever drift apart again.

public_url is deliberately not editable from the Settings UI — widening a network surface should be a deliberate file edit, not a click.

Step 2b — the proxy config itself (nginx / Caddy)

Section titled “Step 2b — the proxy config itself (nginx / Caddy)”

⚠️ Mount PersonalClaw at the ORIGIN ROOT — never under a subpath. https://pc.example.com works; https://example.com/claw does not. There is no base-path mode: the SPA’s asset URLs, its origin-relative WebSocket (/api/ws), the PWA manifest’s start_url, and the service worker’s registration scope are all rooted at /. A subpath mount loads a blank or half-broken dashboard and is not a configuration you can fix from the proxy side — give PersonalClaw its own hostname (or subdomain) instead. The companion registry refuses a subpath base_url outright for the same reason (endpointSocketUrl returns undefined rather than silently dialling the root).

The one failure this section exists to prevent: a broken WebSocket upgrade. An HTTP-only proxy config serves the dashboard perfectly and then the page never updates, because /api/ws is not a plain request — it needs an explicit protocol upgrade. This is the single most common way a self-hosted reverse proxy in front of this gateway goes wrong.

nginx — all four upgrade-related directives are load-bearing; omitting any one of them is the broken-socket failure above:

# In the http{} block, once. `Connection` must vary with the request: hardcoding "upgrade" sends it
# on ordinary requests too and breaks keepalive to the backend.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
http2 on;
server_name pc.example.com;
ssl_certificate /etc/letsencrypt/live/pc.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pc.example.com/privkey.pem;
# Root only — see the subpath warning above. Do not use `location /claw/`.
location / {
proxy_pass http://127.0.0.1:10000;
# ── the WebSocket upgrade: all four lines are required ──
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
# The event socket is long-lived and mostly idle. nginx's 60s default closes it and the
# dashboard reconnect-loops; 1h is comfortable.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Streamed agent output must not be buffered, or tokens arrive in batches.
proxy_buffering off;
}
}

Caddy — the upgrade is handled for you; X-Real-IP is not:

pc.example.com {
# reverse_proxy negotiates the WebSocket upgrade automatically — there is no equivalent of
# nginx's four directives to forget. It does NOT set X-Real-IP, so set it explicitly.
reverse_proxy 127.0.0.1:10000 {
header_up X-Real-IP {remote_host}
# Match the long-lived event socket; Caddy's default would otherwise cut it.
transport http {
read_timeout 3600s
write_timeout 3600s
}
}
}

Who must set X-Real-IP, and why it is not a detail. X-Real-IP carries a claim about who the client is, and the gateway uses it to bind a session to an address. It is only trustworthy because your proxy overwrites it on every request: both recipes above set the header from the connection nginx/Caddy actually sees ($remote_addr / {remote_host}), which discards whatever the client sent. Never add_header/append it, never pass a client-supplied value through, and never copy a value out of X-Forwarded-For — a header a client can write is not evidence, and forwarding one lets a caller choose its own apparent IP.

That is also why the gateway believes the header only from an address listed in trusted_proxies, and why X-Forwarded-For is ignored on every path (see above). Two controls, both required: the proxy must be the sole writer of the header, and the gateway must know which peer is allowed to assert it. If the proxy runs on the same host, trusted_proxies: ["127.0.0.1"] matches these recipes; in Docker use the bridge network’s address instead, and bind the gateway so only the proxy can reach it — a gateway reachable directly, bypassing the proxy, is a gateway whose X-Real-IP anyone can set.

Section titled “Step 3 — 2FA (recommended if you are actually on the internet)”
Terminal window
personalclaw auth totp setup # prints a secret + otpauth:// URI, ONCE

Add it to any authenticator app, verify a code works, and only then require it (Settings → Account → Require a 2FA code, or auth.require_totp: true).

Requiring a code before enrolling a secret would make sign-in impossible; personalclaw auth status warns you about exactly that state, and the local token link remains the way to fix it.

personalclaw auth totp disable turns 2FA off and deletes the secret, so the entry in your authenticator app stops being any use to anyone; turning it on again enrolls a new one. It is refused while a code is still required at login — turn Require a 2FA code off first.

Step 4 — pairing a phone without typing your password into it

Section titled “Step 4 — pairing a phone without typing your password into it”

Typing a long passphrase into a phone keyboard, over a tunnel, in public, is the worst place to spend your password. Instead:

Terminal window
personalclaw auth enroll # prints e.g. 7K4M-QP93

On the phone, open your public URL, tap Use a device code instead, and enter it. The code:

  • works once;
  • expires in 5 minutes;
  • is stored only as a hash, so nobody can read it back off the disk;
  • mints an ordinary session with the same lifetime as a password sign-in.

Losing a code costs nothing — run the command again. personalclaw auth enroll --clear invalidates any outstanding ones.

Sessions survive a gateway restart (they are recorded in ~/.personalclaw/sessions.json, mode 0600). That is deliberate: being logged out by an unattended update, while away from home, is exactly the situation this feature exists to prevent.

Settings → Devices lists everything signed in: each browser, paired phone and desktop app, and every token a script or the CLI minted — what it is, when it signed in, when it was last seen and from which address. Sign one out there, or choose Sign out all other devices if a device is lost or you do not recognise one. Under Integrations it also lists every token an external agent reaches an inbound surface with (an IDE’s MCP token, a registered client), with when each stops working and a Revoke. From the terminal on the gateway’s computer:

Terminal window
personalclaw auth revoke --all # end every session, everywhere
personalclaw auth rotate-key # ...and replace the key every sign-in is signed with

Every sign-out survives a restart too. Your password and 2FA enrollment are untouched, so you just sign in again. Sign out on this device’s own row revokes that session properly — the token dies, not just the cookie.

If you think the key itself, or a sign-in, was copied, replace the key: Settings → Security → Sign-in key, or personalclaw auth rotate-key. Signing sessions out one by one leaves the key that could mint new ones; replacing it signs out every browser, paired device and token at once, this one included, and each is told the key was replaced when it next connects. Integration tokens are separate credentials and keep working. The CLI asks the running gateway, which holds the key in memory, and replaces the key itself only when no gateway is running; the route is POST /api/auth/rotate-key with {"confirm": true}, and a signed-out browser’s refusal carries detail.reason: "key_replaced" (a script’s Bearer gets the one auth_bearer_invalid, as for every refusal). The replaced key stays in sessions.json beside those sign-outs, only to recognise the sign-ins it signed so each device can be told why, never to admit one, and is forgotten once none of those sign-outs is remembered (97 days at most: the 90-day sign-in limit, then the week a sign-out is explained for).

A signed-out device is told why the next time it is used — “This device was signed out today at 09:14 from Settings → Devices on another device”, or “Your sign-in on this device lasted 30 days and ended…” — and how to sign back in, on the page it opens and in the dashboard if it was open. Each kind of sign-in has a limit of 20 (browsers, paired devices, and tokens separately), so the CLI or a script minting tokens never signs a browser or a phone out.

A sign-in lasts auth.session_ttl (30 days by default) and never more than 90 days, the limit for a long-lived credential: the longer a sign-in lasts, the longer a copied link or a stolen cookie keeps working. Set it in Settings → Security → Sign-in lifetime, which offers nothing longer than 90 days. Setting auth.session_ttl longer anywhere else is refused with a sentence saying so; a config file that already says longer is applied as 90 days, and personalclaw doctor (and the Doctor page, under Security) says so until you fix it.

A link from personalclaw token is the exception: it keeps its own lifetime, 20 hours unless it was made with --ttl (at most 90 days), and a browser that opens it stays signed in that long — which is how a container install signs in, since its gateway opens no browser at start. For a longer sign-in, open a link from personalclaw token --ttl 30d. Settings → Security says when the browser you are using was signed in by such a link, and until when.

After auth.lockout_threshold failures (default 5) from one address, sign-in is refused for auth.lockout_window (default 15m) and returns Retry-After. Wrong-code attempts on the pairing form count too. Every attempt — success, failure, lockout — lands in the security event log (personalclaw security events). Set both in Settings → Security → Sign-in lockout; a change that allows more guesses (more attempts, or a shorter lockout) asks first.

Getting a push when a run needs your approval

Section titled “Getting a push when a run needs your approval”

Remote access gets you to the dashboard. This gets the dashboard to interrupt you — which is the point, because the thing that actually caps how much your agent can do unattended is how long a blocked run waits for a decision.

Every push carries two ids and nothing else: {"kind": "approval", "item_id": "<id>"}. Not the tool name, not its arguments, not the session, not a title. Your phone wakes up, and then it fetches the decision from your gateway over the link you set up above. A push service — Google’s, Apple’s, Mozilla’s, or your own ntfy host — never sees what is being approved. That is not a nicety: it is the only claim that holds for every transport below, since not all of them are encrypted.

Pick a transport in Settings → Companion apps → Phone push. The guide leads with ntfy because it is the documented default recommendation: fully self-hosted, no keys, no third party. Web push is what a browser or installed PWA uses; the relay is only for the store app.

Section titled “ntfy / UnifiedPush (recommended — fully self-hosted)”

The documented default: no third party is involved at all. Run ntfy and point the gateway at a topic:

  1. Settings → Companion apps → Push backend → ntfy.
  2. Paste your topic’s full https URL, e.g. https://ntfy.example/personalclaw. Plain http is refused at both the settings field and the sender — an unencrypted ping would put the item id on the wire in the clear.
  3. Subscribe to the same topic in the ntfy app on your phone.

Nothing else to set up: no VAPID keys, no per-device subscription. The tradeoff is cosmetic and deliberate — the ntfy app shows the raw {"kind":…,"item_id":…} body, because the alternative is composing a human sentence and a sentence is content. A pretty notification is not worth putting the tool’s arguments through someone else’s server.

Pick a topic name nobody can guess. An ntfy topic URL is a capability: anyone who knows it can publish to it, so a guessable topic means anyone can make your phone buzz. It still cannot approve anything — approving happens on your gateway, behind your session.

What mobile.push_backend defaults to, and the right transport when your phone runs the dashboard as a PWA or browser tab. Uses your browser’s own push subscription. One-time setup on the gateway:

personalclaw push init # generates a VAPID keypair into the credential store
personalclaw push status # backend, keypair, subscribed devices

Then, on the phone: open #/companion, tap Turn on push, and allow notifications. The subscription is stored per device, so each phone or browser you turn it on from gets its own row.

Two real constraints, stated rather than discovered:

  • iOS needs the PWA installed. Safari will not hold a push subscription for an ordinary tab. Add the dashboard to your home screen first (Share → Add to Home Screen), then turn push on from the installed app. The companion screen says so instead of showing a button that cannot work.
  • A service worker needs a secure context: an https page, or localhost on the gateway’s own machine. Plain http:// is not one, at a LAN address or at the tailnet address the steps above open, so there is no service worker there: no web push and no install. Settings → Companion apps → Install & offline names that as the reason. On the plain-http tailnet path, use the ntfy backend above for push: it needs no service worker.

Rotating the keypair (personalclaw push init --force) invalidates every existing subscription, because each browser bound its subscription to the old public key. Only do it if a key leaked, and re-subscribe each device afterwards.

The iOS/Android store app cannot hold a web-push subscription — Apple and Google only deliver to it through their own push services, and only whoever holds the app’s signing credentials can send there. Relay users route the same ids-only ping through a stateless, open-source relay (personalclaw-push-relay) that forwards it to APNs/FCM. Deploy it yourself, with the push credentials of the app it sends for. The relay stores nothing, retries nothing, and refuses any body that is not exactly the two ids, so it is a dumb pipe by construction, not a cloud middle tier.

  1. Settings → Companion apps → Push backend → relay, and paste the relay’s https URL (plain http is refused at the sender, same as ntfy).
  2. In the store app, open the companion screen and tap Turn on push — the app registers its device token with your gateway, which holds it; the relay sees a token only in transit, per ping.

The visible notification is a static “PersonalClaw — Attention needed”: any text worth varying would have to be composed from the item, i.e. content the relay must never see. Tapping it opens the exact approval card, fetched from your own gateway.

personalclaw push test # one content-free ping to every subscribed device
personalclaw push test --kind approval --item-id abc123

It prints the backend, how many devices took the ping, and the exact payload that was sent, so you can read the ids-only claim rather than take it on trust.

The notification opens #/companion?approval=<id> and scrolls that specific card into view with the whole decision on screen — tool, full arguments, where it came from, how long it has been waiting. Approve or deny, and the run you were pinged about continues. If the approval timed out while your phone was in your pocket, the screen says so rather than leaving you hunting for a card that no longer exists.

Below the approvals are the running loops, open tasks, the Inbox and recent notifications. Every part of the screen follows what changes while it is open, and reads again when you come back to it or its connection returns, so a page you left open days ago shows what is true now: a loop that has ended is not offered for pausing, and a task finished at your desk is not listed as open.

Which notifications reach the phone at all is Settings → Notifications: every kind has a row, and Approval needed ships with the phone among its targets. Turn it off there and nothing else changes.

  • Never port-forward your router straight to the gateway. Opening a port on your home router to PORT on the machine puts an unencrypted http:// listener on the public internet with no TLS and no tunnel in front of it. That is the worst of both paths: cleartext cookies on the wire and a public address anyone can scan. Use a tailnet (encrypted, private) or a TLS-terminating tunnel — never a bare forwarded port.
  • AUTH_MODE=none cannot be exposed — and that is a feature, not a limitation. When auth is off, the gateway is forced to bind loopback (127.0.0.1) no matter what you set; an unauthenticated gateway can never reach a non-loopback interface. If you want remote access, keep the default local_token (the tailnet path) or set a password (the tunnel path). Don’t reach for none.
  • Never expose over plain http. Publishing an http:// (not https://) URL to the internet leaks your session cookie on the wire — see the Secure-cookie note under What this does not protect you from. A tailnet sidesteps this entirely (it is encrypted end to end); a public tunnel must terminate TLS.
  • Never combine PERSONALCLAW_BYPASS_LOCAL_NETWORKS=1 with a proxy in front of the gateway. This is the one combination on this page that hands the whole internet a token-free dashboard, and it does it silently. The variable is a dev convenience: it skips the token for any request whose client address is private. On a home LAN “private address” really does mean “someone in my house”. Behind a reverse proxy it does not — the address the gateway sees is the proxy’s own (127.0.0.1 for a local tunnel daemon, 172.18.x.x on a Docker bridge), and both are private. So every request the proxy forwards, from anywhere, is admitted with no token. Leave the variable unset whenever trusted_proxies or public_url is set. personalclaw doctor fails the remote row when it sees both, naming the variable and the config together.

If personalclaw doctor reports remote: ❌ bound beyond loopback with auth OFF, you have hit the first two anti-patterns at once (a PERSONALCLAW_BIND_HOST override with AUTH_MODE=none). Fix it before exposing anything: either bind loopback again, or set a password and use one of the two supported paths.

If it reports the PERSONALCLAW_BYPASS_LOCAL_NETWORKS row instead, unset that variable (or clear trusted_proxies/public_url) before you expose anything. Note what that row does not do: it reports, it does not block. Nothing about who is admitted changes when it appears, so an exposed instance stays exposed until you act on it.

Being straight about the limits, because a false sense of safety is worse than none:

  • A weak password. Rate limiting slows online guessing; it does nothing about a password that appears in a breach list. Use a passphrase.
  • A compromised device. A session cookie on a stolen unlocked phone is a session. Sign it out from Settings → Devices.
  • Your tunnel provider. Anything terminating your TLS can see your traffic. That is inherent to the arrangement, not specific to PersonalClaw.
  • The agent’s own reach. Sign-in controls who reaches the dashboard. What the agent may do once inside is governed separately — see SECURITY.md, the approval modes, and the autonomy guardrails.
  • Plain-http exposure. If you set an http:// public URL, the cookie will not carry Secure (setting it would break sign-in entirely) and your session is exposed on the wire. Don’t.

My phone can’t reach the tailnet address. Confirm both devices are up on the same tailnet (tailscale status on the machine lists your phone), that the gateway was started with PERSONALCLAW_BIND_HOST=0.0.0.0 (it binds loopback-only by default), and that personalclaw doctor prints remote: ✅ tailnet …. If doctor says local-only, the gateway isn’t on the tailnet yet.

The page loads but nothing updates. The WebSocket is blocked, and there are two separate reasons it can be — check both:

  1. Confirm public_url matches the host in your browser’s address bar exactly, including port — the CSP is derived from it.
  2. Also set dashboard.url to the same public URL. public_url puts wss://<host> in the Content-Security-Policy, so the browser is allowed to open the socket, but it is dashboard.url that adds your public origin to the origin allowlist the gateway checks on the upgrade. With only public_url set the browser opens the socket and the gateway refuses it, which looks identical to a CSP problem. State-changing requests return 403 for the same reason.

(Both apply to the public-tunnel path; the tailnet path needs no public_url.)

I get signed out immediately, or the cookie never appears. An https public_url sets Secure, so the cookie is dropped over plain http. Either serve the dashboard over TLS or unset public_url.

Everything says “not enabled”. personalclaw auth status. Enabling sign-in requires a password first, by design.

I am locked out and away from home. Wait out the window (default 15m). If you are truly stuck, you need local access — that is the intended floor: nothing remote can override the lockout.

  • containers.md — Docker/compose, including the unattended PERSONALCLAW_LOGIN_USER / PERSONALCLAW_LOGIN_PASSWORD seed.
  • SECURITY.md — threat model and reporting.