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:
- 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.
- 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.
The simplest path: a tailnet (recommended)
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 exposureSteps
- Put the gateway on the tailnet. Install Tailscale on the machine running PersonalClaw and
bring it up:
Terminal window tailscale uptailscale ip -4 # prints this machine's tailnet address, e.g. 100.101.102.103 - 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.
- 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 - 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:
On the phone, open
Terminal window personalclaw token # prints http://localhost:10000?token=…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.)
The shape of it
Section titled “The shape of it”your phone ──https──▶ your tunnel (terminates TLS) ──http──▶ PersonalClaw on 127.0.0.1 cloudflared / Tailscale / TraefikThree things you provide, in order:
- 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.
- A password, so a stranger who finds the URL cannot walk in.
dashboard.public_url, which is how you tell PersonalClaw it is exposed. That single setting is what turns on the hardening described below.
Step 1 — set a password
Section titled “Step 1 — set a password”personalclaw auth set-password # prompts twice, never echoespersonalclaw auth enable # start offering the sign-in pagepersonalclaw auth status # confirm bothMinimum 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.
Step 2 — declare the public URL
Section titled “Step 2 — declare the public URL”In ~/.personalclaw/config.json:
{ "dashboard": { "public_url": "https://pc.example.com", "trusted_proxies": ["127.0.0.1"] }}Setting public_url changes three things:
| Effect | Why | |
|---|---|---|
| Session cookie | gains Secure | so the browser refuses to send it over plain http |
| WebSocket CSP | allows wss://<your-host> | otherwise the dashboard loads and then silently receives nothing |
X-Real-IP | honored only from trusted_proxies | see 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.
Step 3 — 2FA (recommended if you are actually on the internet)
Section titled “Step 3 — 2FA (recommended if you are actually on the internet)”personalclaw auth totp setup # prints a secret + otpauth:// URI, ONCEAdd 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:
personalclaw auth enroll # prints e.g. 7K4M-QP93On 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, and ending them
Section titled “Sessions, and ending them”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:
personalclaw auth revoke --all # end every session, everywherepersonalclaw auth rotate-key # ...and replace the key every sign-in is signed withEvery 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.
Failed attempts
Section titled “Failed attempts”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.
ntfy / UnifiedPush (recommended — fully self-hosted)
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:
- Settings → Companion apps → Push backend → ntfy.
- Paste your topic’s full https URL, e.g.
https://ntfy.example/personalclaw. Plainhttpis refused at both the settings field and the sender — an unencrypted ping would put the item id on the wire in the clear. - 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.
Web push (browsers and the installed PWA)
Section titled “Web push (browsers and the installed PWA)”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 storepersonalclaw push status # backend, keypair, subscribed devicesThen, 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
httpspage, orlocalhoston the gateway’s own machine. Plainhttp://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-httptailnet 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 store app (APNs/FCM via a push relay)
Section titled “The store app (APNs/FCM via a push relay)”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.
- Settings → Companion apps → Push backend → relay, and paste the relay’s https
URL (plain
httpis refused at the sender, same as ntfy). - 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.
Checking it without a phone in your hand
Section titled “Checking it without a phone in your hand”personalclaw push test # one content-free ping to every subscribed devicepersonalclaw push test --kind approval --item-id abc123It 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.
What a tap does
Section titled “What a tap does”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.
Anti-patterns — do not do these
Section titled “Anti-patterns — do not do these”- Never port-forward your router straight to the gateway. Opening a port on your home router
to
PORTon the machine puts an unencryptedhttp://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=nonecannot 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 defaultlocal_token(the tailnet path) or set a password (the tunnel path). Don’t reach fornone.- Never expose over plain http. Publishing an
http://(nothttps://) URL to the internet leaks your session cookie on the wire — see theSecure-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=1with 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.1for a local tunnel daemon,172.18.x.xon a Docker bridge), and both are private. So every request the proxy forwards, from anywhere, is admitted with no token. Leave the variable unset whenevertrusted_proxiesorpublic_urlis set.personalclaw doctorfails theremoterow 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.
What this does not protect you from
Section titled “What this does not protect you from”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 carrySecure(setting it would break sign-in entirely) and your session is exposed on the wire. Don’t.
Troubleshooting
Section titled “Troubleshooting”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:
- Confirm
public_urlmatches the host in your browser’s address bar exactly, including port — the CSP is derived from it. - Also set
dashboard.urlto the same public URL.public_urlputswss://<host>in the Content-Security-Policy, so the browser is allowed to open the socket, but it isdashboard.urlthat adds your public origin to the origin allowlist the gateway checks on the upgrade. With onlypublic_urlset the browser opens the socket and the gateway refuses it, which looks identical to a CSP problem. State-changing requests return403for 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.
Related
Section titled “Related”- containers.md — Docker/compose, including the unattended
PERSONALCLAW_LOGIN_USER/PERSONALCLAW_LOGIN_PASSWORDseed. - SECURITY.md — threat model and reporting.