Reaching it from your phone
Tailscale end to end — serve, identity, push notifications, and what is actually enforced.
An unattended run exists so you can stop watching it. That only pays off if it can still reach you.
A run halts when it needs a person — a command it will not take on its own authority, a check only you can make. It raises an approval and waits. If the console is only reachable from the chair in front of it, every one of those pauses lasts until you sit back down.
This section sets up the console so you can watch a run, answer an approval, and start the next phase from a phone, on any network, without putting anything on the public internet.
It takes about ten minutes, and most of it is clicking two switches.
The short way. Settings → Reach this console from your phone reads your tailnet live: which devices are on it, whether
servepoints at this console, and whether the console's own remote settings agree with it — that last disagreement is the one that looks fine from your desk and fails from the phone. The card carries a copy-paste prompt that does the whole setup; it is Prompt 2 in the README. Everything below is the same thing done by hand, plus the parts the card does not cover — the machine profile, push notifications, out-of-band alerts, and what is enforced.
The shape of it
your phone, anywhere your machine
──────────────────── ────────────
https://your-machine.your-tailnet.ts.net
│
│ encrypted, private network, no open ports
▼
tailscaled ──────────── sets Tailscale-User-Login: you@example.com
│
│ http://127.0.0.1:4123
▼
Phase Console ───────── still bound to loopback. It always was.
The console does not open a port on a network — not before this, and not after. It keeps
listening on 127.0.0.1, and something that already knows who you are is placed in front of that
socket. Tailscale Serve is that something: it terminates TLS, authenticates the caller
against your private network, and forwards to loopback with the caller's login in a header.
That header is the whole authentication story, and it is only worth anything because the console stays on loopback. If it listened on a network interface, anyone who could reach it could simply send the header themselves. This is Tailscale's own guidance: "it's best practice to only have the service listen on localhost."
You need a Tailscale account. The free tier covers this comfortably.
With more than one console on the machine, Pro puts one process between Serve and the consoles — the fleet supervisor — so the phone still sees one name, one Home Screen app and one subscription:
https://your-machine.your-tailnet.ts.net/ the fleet app: every console, one list
https://your-machine.your-tailnet.ts.net/c/<id>/ one console's own pages
│
tailscaled ──────────── sets Tailscale-User-Login
│
│ http://127.0.0.1:4100
▼
fleet supervisor ────── checks the login, asserts it on the hop, forwards to loopback
│
│ http://127.0.0.1:4123 · http://127.0.0.1:4130 · …
▼
each console ────────── still bound to loopback
Once for the machine — not once per repository
Everything the phone path needs belongs to the machine and to you, not to a repository, so it is done once:
| Once, for the machine | Where |
|---|---|
| Three tailnet switches, and the phone on the tailnet | Steps 1–2 |
| The machine profile: the tailnet name and the logins allowed through it | Step 3 |
| One Serve handler, the Home Screen install, one push subscription | Steps 4–6 |
| A notifier that needs no browser (optional) | Step 7 |
A console on its own is one repository's, and the steps publish that console. Once Step 3 is written
it needs no remote flags to start — phase-console ~/code/your-repo, or ./start --root ~/code/your-repo from the console's folder — because it reads the profile itself.
With Pro, the one-time setup gains one item, and each repository after it is one command. Install the fleet supervisor and give it the Serve handler (below); Steps 4–6 then publish the supervisor rather than a console. From then on a new repository is:
cd ~/code/another-repo && phase-console start
— and nothing else. It joins the supervisor, takes the profile, and is on the phone at /c/<id>/ the
moment it binds, with its notifications arriving on the subscription the phone already holds: no
Serve command, no second Home Screen app, no second subscription. start with no name resolves the
directory you stand in, walking up: the first registered console at or above it, else the nearest
directory with a docs/plans/, a plans/ or a .phase-console.json. The capability flags that console should have (--allow-run and the rest)
still go on its own start line — capabilities are per console on purpose.
Step 1 · Turn on three things for your tailnet
All three are off by default and all three are needed. The first two are on the DNS page of the admin console:
- Enable MagicDNS. This is what makes
your-machine.your-tailnet.ts.netresolve for your own devices. Without it you would be typing an IP address, and an IP address cannot have a certificate. - Enable HTTPS Certificates. Tailscale then provisions a real, publicly-trusted certificate for that name. HTTPS requires MagicDNS, so do them in this order.
- Enable Serve. This one has no switch to find in advance: the first time you run
tailscale serveon a tailnet that has never used it, the command prints an approval link containing that machine's node ID and then waits rather than exiting. Open the link, approve it, and the command you already ran continues on its own. If you would rather do it up front, run the Step 4 command now and click what it gives you.
Know what you are agreeing to. Every certificate on the web is recorded in the public Certificate Transparency log, so enabling this publishes your machine's name — e.g.
your-machine.your-tailnet.ts.net. The name becomes public. The machine does not: it stays unreachable from the internet, and nothing about this opens a port.
While you are in the admin console, on the Machines page, disable key expiry for this machine. Node keys expire by default, and when one does, remote access stops with no warning and no obvious cause.
Step 2 · Put your phone on the same tailnet
Install the Tailscale app, sign in with the same account, and — this one is easy to miss — make sure
"Use Tailscale DNS" is ON in the app's settings. It is what lets the phone resolve the .ts.net
name. Without it the name simply will not load, and nothing else in this guide will work.
Check tailscale status on your machine; the phone should be listed.
Step 3 · Tell the console who may arrive — once, in the machine profile
A phone is admitted on two facts: the hostname the proxy serves, and the logins allowed through it.
Both are the machine's, so write them once in the machine profile,
~/.config/phase-console/fleet.json, from the console's folder:
node viewer/shared/instances.mjs profile-set remoteHost '"your-machine.your-tailnet.ts.net"'
node viewer/shared/instances.mjs profile-set remoteUsers '["you@example.com"]'
node viewer/shared/instances.mjs profile # the file as every console reads it
Use your real MagicDNS name — tailscale status --json prints it as Self.DNSName — and the login
you signed in with. The value is JSON; null clears a key, and a value the profile cannot act on is
refused rather than written.
Every console reads the profile when it starts, field by field. A console started with no --remote
and no --remote-user inherits both, so neither belongs on its command line. Remote access is
inherited only as a usable pair: a host with no login, or logins with no host, is left alone and the
console boots local-only rather than refusing to start over a half-written file. The profile is read
at startup — restart a running console to pick up a change.
What the file holds, and what a console does with each field:
| Field | Read as |
|---|---|
remoteHost | The hostname the console also answers to behind the proxy — what --remote names. |
remoteUsers | The logins allowed to arrive that way — what --remote-user names. |
notifyCommand | The out-of-band notifier (Step 7), when PHASE_CONSOLE_NOTIFY is not set. |
webhooks | Rows of {url, name?, categories?} that every console started with --allow-webhooks delivers to. They are the file's: a console lists them and never stores or removes them. |
maxSessions | The machine's lane ceiling — every console's live lanes summed. --max-sessions stays each console's own ceiling. |
hookScript | An absolute path to the session-presence hook script install-hooks and hooks-status use, when that file exists. |
categories, quietHours | Recorded, and reported among what a console inherits. What reaches a device is still chosen on that device (Step 6). |
instances | Per console, by id: autostart (true, false or once — whether it starts its work unattended at boot) and overrides of remoteHost, remoteUsers, notifyCommand, webhooks, categories and quietHours for that console alone. |
A flag still wins, for the one console it is passed to. Neither flag changes what the server binds to:
| Flag | Meaning |
|---|---|
--remote <host> | The console also answers to this hostname, which is fronted by an authenticating proxy. Repeatable. Wins over remoteHost. |
--remote-user <login> | A login allowed to arrive that way. Repeatable, or PHASE_CONSOLE_REMOTE_USERS as a comma-separated list. Wins over remoteUsers. |
./start --root ~/code/your-repo --allow-writes --allow-run \
--remote your-machine.your-tailnet.ts.net \
--remote-user you@example.com
--remote with no allowed login at all — none from --remote-user, the environment or the profile —
refuses to start. Starting with no allowlist would look completely correct and quietly admit
everyone on your network, so it is an error rather than a warning.
The same, from the phase-console bin. phase-console profile prints the file;
phase-console profile set <key> <json> writes one field. With --instance <sel>, profile prints
what that one console takes from the file, and profile set writes that console's overrides (only
the six overridable fields are accepted there). phase-console autostart [instance] true|false|once
sets a console's autostart. When a profile exists, phase-console start prints an inherits line
naming the fields it took from it.
To have a console survive reboots and logouts, install it as a background agent (launchd on macOS, a systemd user service on Linux):
./start --install-agent --root ~/code/your-repo --allow-writes --allow-run
./start --agent-status
With the profile written, leave --remote and --remote-user off the agent: named, they are baked
into the unit; unnamed, nothing is baked and the console reads the profile at every boot, like every
other console on the machine. That is also why the Desktop launcher carries no remote settings.
Settings ▸ Essentials ▸ Desktop launcher writes ~/Desktop/Phase Console — <name>.command —
Phase Console — hub.command for a console named hub (a console with no name keeps the bare
Phase Console.command; on Linux the entry is ~/Desktop/phase-console-<name>.desktop). Since
LAUNCHER_REV 12 the file carries no root, port or remote settings of its own: the same file starts
any console on this machine, its name picks which, and the console reads the profile itself. Remote
flags an installed agent already has are kept when the launcher re-installs it, never dropped.
Step 4 · Put the proxy in front of it
Settings → Reach this console from your phone prints the command for this console's own port. For the console on 4123 it is:
tailscale serve --bg --https=443 http://127.0.0.1:4123
tailscale serve status # confirm what is being served
The first run on a tailnet that has never used Serve will not return. It prints "Serve is not enabled on your tailnet" with an approval link, and waits for you to open it. That is the Step 1 item you cannot do in advance — approve it and the command finishes by itself. Every run after that returns immediately.
--bg is not optional if you want this to last. With it, Serve is persistent: it comes back
after a reboot and after tailscale down / tailscale up. Without it, Serve lives only as long as
that foreground command, and you will be re-running it by hand forever.
To undo it: tailscale serve --https=443 http://127.0.0.1:4123 off, or tailscale serve reset to
clear everything.
Running it as a login agent rather than in a terminal? Re-install the agent with the same
arguments instead of restarting by hand, reading the flags off the current plist or unit:
bash <skill>/viewer/deploy/agent.sh install --root <repo> [existing flags] --remote <host> --remote-user <login>.
More than one console, and no supervisor yet? Tailscale has one 443, and the card's command
follows one rule (serveCommandFor, in viewer/server/tailscale.ts). The console on 4123 takes 443
and any other console 4000 + its own port. That natural port is given up only when a running
sibling console already holds it: the card then offers 4000 + the port, or the first port above it
that no handler holds, names the console it would have displaced, and says so in a warning. A stopped
or orphaned console on that port, or a program no console on this machine knows, does not count — the
natural URL is the one bookmarks assume. When a handler already points at this console's port there
is no command to print. Each console published this way is its own origin, its own Home Screen app and
its own subscription, and a grant scoped to tcp:443 below needs its port added. The fleet supervisor
is the way out of all of that: one handler, one origin — see
One origin for every console.
Now open https://your-machine.your-tailnet.ts.net/ on the phone. Padlock, no warning, no port
number.
Step 5 · Install it on the Home Screen
In Safari: Share → Add to Home Screen.
This is not decoration. On iOS, web notifications only exist for a site installed to the Home Screen — in an ordinary Safari tab the permission cannot even be requested. Since the notification is the entire point of being reachable, the install is part of the setup rather than a nicety.
Once it is installed, open it from the Home Screen and grant notification permission from the button in Settings. Permission is never demanded on load: a page that asks the moment it opens gets refused by reflex, and that refusal is permanent.
You will get a notification when a run halts, when it is parked, when it finishes, and when an approval is waiting — and deliberately not for every phase, because a channel that fires constantly is a channel you learn to ignore.
Android needs none of this — notifications work in a normal HTTPS tab — but installing it still gives you a cleaner window.
With the supervisor, the site you install is the fleet origin — the tailnet name itself — and you
install it once. A console's pages under /c/<id>/ register no service worker of their own, so they
never become a second app.
Step 6 · Turn on push, and choose what it sends
Settings ▸ Notifications (#/settings/notifications) has two switches, and the difference between them is the
whole point:
| What it is | When it fires | |
|---|---|---|
| In this tab | The Notification API, raised by the page off the console's own live event. | Only while a tab is open somewhere — and only when that tab is in the background and the browser holds no push subscription (a subscribed browser's service worker shows the card instead, so nothing arrives twice). |
| On this device | A push subscription, delivered by Apple, Google or Mozilla to a service worker. | With the console closed, the phone locked, the laptop asleep. |
Press Turn on under On this device, then Send a test — it goes out through the real push service and back, so a notification appearing proves the whole chain rather than the last hop of it.
Do it on the laptop too. http://127.0.0.1 counts as a secure context, so the same button works
there with no HTTPS involved, and every browser gets its own subscription and its own choices.
A console's own origin keeps its own signing keys and its own list of subscribed devices, so
subscribing at one console's own address does not subscribe you to another. The supervisor removes
that per-console step: its origin holds one subscription that hears every console. A console opened
under /c/<id>/ offers no Turn on of its own — it says to turn notifications on at the fleet page,
once, for the device. A device still subscribed to a single console keeps hearing that console until
you remove it in that console's Settings.
Every notification names its console. A push carries the console that sent it, and the card's
title reads <title> · <console name> unless the title already says it — one device can hear more
than one console. A notification's tag, which decides which card replaces which, is namespaced by the
console's id, so two consoles never overwrite each other's cards. That namespacing changed every tag
once, at 5.0.0: a card delivered before the upgrade stands beside its successor rather than being
replaced by it.
Twenty categories, per device, because a phone and a laptop rarely want the same ones. This
table is the catalogue (viewer/server/push/catalogue.ts), and a test holds it to it — if the two
ever disagree, the catalogue is right:
| Category | Default | Fires when |
|---|---|---|
| Permission needed | on | A session is blocked on a decision only you can make — a command outside its rules, a gate, or a check it cannot make itself. Nothing proceeds until you answer. |
| Session waiting on you | on | A Claude session is stopped at a permission prompt or a question — a lane of the autopilot, one you ran in a terminal, or an agent session. The body is the question itself, with the plan and phase when the console can tell which, never the directory. A lane whose phase already has a card waiting in the approval queue is not pushed twice. The row is resolved when the wait is answered or the session ends, and a wait still unanswered after an hour is pushed once more. A question a relayed session asks arrives here too, answered by rule when its window closes unless you answer first. |
| A phase needs you | on | A phase did its work and stopped at something no automation may sign off — a check written as prose, a verification only a person can make, or a human step: a sign-in, a code, a secret, a permission, an approval only you can give. A push for an item opens the item itself (#/turn/<id>), with the buttons a paired device may press (below) — and, for a device code, the code. It is not failed and not finished; it is waiting, and it will keep waiting. |
| Gate needs a person | on | A phase is held at a gate only a person may clear — a physical act, a third party, a credential no session holds. The board will call the phase ready the moment it is approved and not one second before, so nothing else moves and nothing else will ask. |
| QA verdict owed or failed | on | A finished phase still owes its QA verdict, or QA recorded a fail. Either way the plan gates on it: every dependent phase is held until pass or waived is recorded, and nothing records one by itself. Sent only after the console's own chase (the at-finish dispatcher, then the ladder) left the verdict owed. |
| Run halted | on | A run stopped on something that must not be automated past — a failed verification, a phase that would not settle. Includes a run that was interrupted with nothing driving it. |
| Run parked or waiting | on | Every remaining phase needs a person, or the run is asleep until a usage window reopens. Not an error — it just will not move on its own. |
| Nothing is happening | on | A session is still running and still spending, and it has stopped producing work — silent for ten minutes, six turns without touching a tool, or three attempts that changed nothing. Not urgent: it is not blocked on you, and the run has not stopped. It is the money question rather than the permission question. |
| Phase finished or failed | on | Each phase as it lands, with what it cost. The steady pulse of a run you are not watching. |
| Plan finished | on | A run reached the end of its plan. The one you actually wanted to be told about. |
| Work became ready | off | A phase became startable because what it was waiting on finished — including work finished by a session you ran yourself, elsewhere. |
| Plans changed on disk | off | Any plan or handoff was written. Genuinely everything — an agent editing a handoff mid-phase fires this. Off by default because it is a firehose, not a signal. |
| A session ended | on | An agent session or terminal finished while you were not watching it, or exited with an error. Closing one yourself is not announced — you already know. |
| Console problems | on | The console degraded, its file watch went deaf, or it restarted after a crash. The supervisor failing quietly is the worst case, because everything else still looks fine. |
| Usage limits | on | A Claude account this console runs work as hit a usage window — the 5-hour session, the weekly allowance, or a per-model one — with when it resets, plus what the run did about it (waited, switched account, paused) and an account that needs signing in again. Also Login needs renewing — before a login a live or queued run depends on can no longer be renewed (see below). |
| Usage climbing | on | Early warning while a window fills — 80% is "plan your afternoon", 95% is "the next long phase will not finish" — and, hours ahead, an account serving live runs that its measured burn will wall: when, when it resets, and which runs are burning it (2 hours before by default). On by default since control-tower phase 92 (#141): the warning that could have said "walls ≈10:50, resets in four days" was off. The wall itself still announces under Usage limits. The lead is the usageForecastLeadHours preference (hours, default 2); usageForecastHold: true also holds new admissions on that account until the window resets. |
| Budget spent or running low | on | A budget that can stop work reached 80% or ran out — a phase's wait (its Waits on: max or the plan's Wait budget:), a phase's or the run's dollars, the recovery ladder's cap, the failure streak. The first line says which budget and the arithmetic — 60m wait budget · 39.7m accrued · 20.3m left · asked for 90m — and what spent it; the run page's card beside it raises the budget where it was declared (the plan's line for a wait, the run's setting for dollars and rungs, Clear streak for the streak) and retries in one press. Once per budget per attempt; not urgent (control-tower phase 14, #40). |
| Issue drafted by a session | on | A session tripped over a problem outside its phase and drafted an issue for it (the plan's Issues: word allows it). Under draft it waits in the inbox for your Approve, Discard or edit; under file it was filed at once and this tells you what landed. Not urgent: nothing is spending while a draft waits, and a filed issue is a record, not a wall. |
| Hourly digest | off | A summary instead of a stream: once an hour, Your turn first — Your turn: 3 need you — 1 came back from a check · 4 handled by the AI since the last digest, counted from the page's own answer — then every decision waiting on you with how long it has waited and when it expires, every parked run and every stalled session — and, once the channel answers again, the notifications an outage kept from arriving. Nothing waiting sends nothing: what the AI handled is said beside something that waits, never on its own. Off by default: the categories above already say each thing as it happens. |
| Permission granted | on | A grant was applied (control-tower phase 149): who granted which rule, through which door, for how far — this call, this phase, this plan, this repository or always — until when, and exactly what it changed. Every grant is a row Settings ▸ Permissions lists and revokes, and the push opens there, at that grant (#/settings/permissions?grant=<id>, control-tower phase 138); the session it answered resumes by itself. Not urgent: it records authority given, it asks nothing of you. |
Login needs renewing (under Usage limits, control-tower phase 91). A signed-in account's
access token lapses every few hours; while the console runs it renews each one through the Claude
CLI's own refresh a few minutes before it lapses, so an unattended run does not stop for it. When a
renewal FAILS — the refresh was refused, or the CLI could not reach the sign-in service — and a live
or queued run depends on that account, the phone and the notification inbox say so once, with the
runs that wait on it and the exact fix: CLAUDE_CONFIG_DIR=$HOME/…/<profile>/config claude auth login
for a profile, claude auth login for the machine login, or "paste a new setup-token" for a token
account. A run already stopped by an expired or signed-out login resumes by itself once claude auth status and a usage read both succeed on the identity it started on; an organisation or policy refusal
still waits for your press. For runs left alone for days, a long-lived token (claude setup-token)
never needs renewing — the account card and the start door offer it.
Five are sent urgent — Permission needed, Session waiting on you, A phase needs you, QA verdict owed or failed and Run halted — because they are the ones that mean nothing moves until you act; urgent interrupts a focus mode and buzzes a wrist. The other fifteen arrive quietly. A channel that always buzzes is a channel you turn off, and the notification it gets turned off for is the one that mattered. A REMINDER of a person's turn is never urgent, whatever its category: it is a repeat, not news, so it never breaks through a device's quiet hours (below).
A gate is deliberately one of the quiet fifteen, and it is the distinction the split exists for: it waits on a decision rather than on a session parked dead with a hook open, and nothing is spending while it waits.
Payloads are encrypted to a key only your browser holds (RFC 8291), so the push service
relays a notification about your plans without being able to read one. Nothing is installed to make
that work — the implementation is node:crypto and about four hundred lines.
Your turn — a human step on the phone
When a run needs an act only you can do — a sign-in that opens a browser, a device code, a token to
paste, an approval on somebody else's dashboard — the push says so as A phase needs you, titled by
the act itself ("Your turn: Sign the gh CLI in"; an operator act's "Now: Rotate the deploy key"), and
its body says where and how it is checked, in words (since turn-by-turn phase 3): "Phase 3 of demo",
or "the plan demo" for its own errand, at the machine this console runs on for a step that must be
done there, nothing added for one you can do from the phone. A command or a proof ref never rides the
lock screen — they are on the item. A device-code step puts the code itself in the body, so the
phone is enough. Every push for
an item opens the item — Your turn's #/turn/<id>, expanded with its guide and its buttons — the tap
on the body and Open alike, and the bell's row and a webhook's link go to the same address (control-tower
phase 138). No code you type, token or password is ever in a push — only a device code, which is useless
without your own signed-in browser.
The lock screen offers only what a paired device may press. A pressed button is a signed action,
and a signed action is the device door: the door table lets it make low and medium grants, answers and
declines — nothing else on its own (viewer/shared/door-model.js, the one table every door is read
from). So the buttons an item's push carries are read from that table, never written by hand — and,
since 7.1, chosen by the item's TYPE, the same word its card leads with (Task, Approval, Decision):
| The item | Its two buttons | Why |
|---|---|---|
| a Task — a sign-in, a code, a token, somebody else's approval | Open · I did it | I did it runs the item's proof on the console; a proof that lands resumes the phase's own session told what was proven, one that does not is said, and the item waits on. |
| a permission item at low or medium risk — the wall an ask or an allow list, priced at its narrowest scope | Allow · Deny | Allow grants it at the NARROWEST scope the item offers (this one call), through the device door, exactly as the item's own Grant would; Deny tells the session to find another way. |
| a permission item at high risk — a deny-wall rule, a capability, a profile raise | Open · Deny | A high grant needs the rule typed and, with an owner key, the key touched: the first button opens the item, where both are. The console reads the table again when a press arrives, so a grant pressed anyway is refused and the item opens. |
| a permission item on the never list — a guard, a credential, a forced push, the host family | Open · Deny | No grant is offered through any door; the item says why, and the manual path. |
| an Approval that is a run request — a phase asked the run to isolate, switch account, raise its budget, stop or hold | Approve · Deny | Both are answers, which the device door may give: Approve takes the act through the run's own verb, Deny tells every phase that asked no. The console reads the door table again when the press arrives. |
| any other Approval — a check by eye, a held plan, a request waiting for the owner | Open | Its yes needs the card — its evidence, its block, or the owner's key. |
| a Decision — options to choose between | Open | Its answer is one of its options, on the page; a check of an unanswered decision would only send it back. |
iOS draws no buttons at all and shows the notification, so there the tap is the whole answer: it opens the item.
One turn is one push (control-tower phase 132). The errand a parked phase writes for the same step — or for a person errand, a credential the preflight found missing, a question the relay will not answer — is the same item, not a second card: it pushes nothing of its own, and its row's button is I've done this — check on that item. Every push for an item, the first and each reminder, rides one tag, so the phone holds one notification per item.
A step you have not done is reminded: 15 minutes after the first push, then an hour after that, six hours after that, then daily — each gap counted from the last reminder or from your last open or check, whichever is later. A reminder replaces the step's notification rather than stacking beside it ("Still your turn … Reminder 2"). Snooze holds the next reminder back (an hour, a day at most), and reminders obey the devices' own quiet hours — the one quiet-hours setting there is (below): a reminder that falls due while every device that would hear it is inside its window waits for the first one to end — deferred, never dropped — and, being a repeat rather than news, it never breaks through a quiet window the way an urgent first push may. Reminders stop when the step is proven, when you press I can't do this (it becomes an errand carrying your reason), or when it is withdrawn; a step whose window closes (seven days when it names none) expires into an errand too, so nothing reminds for ever.
When nothing would reach you
A console that announces to nobody looks exactly like a console with nothing to say, so it checks.
An announcement no subscribed device will take is recorded as no-device in the delivery ledger (Logs ▸
Delivery shows it); when the reason is that no device is subscribed at all, it is also logged, once per process,
as push.no-device. The console then
judges its whole channel — a subscribed device, a notifier (PHASE_CONSOLE_NOTIFY or the profile's
notifyCommand), or a webhook row — and while none of them reaches anyone it keeps one push-broken
issue, delivery-channel, naming the first category that found nobody. Under --remote the channel
also needs Tailscale running and Serve pointing at this console's port. The issue withdraws itself the
moment a channel appears (env.delivery-channel is logged both ways), and quiet hours alone never
raise it. With nothing to hear it and unread items waiting, the inbox raises its own row about it.
phase-console doctor asks the same probe in its delivery row, which reports and does not block.
A device subscribed at the fleet origin counts as a channel for every console, read from the
supervisor's own beat. Under a direct reach the probe judges the door too: a door that is not
listening fails the row outright, and a door that is listening with no device paired yet — or with
a certificate inside its expiry window — passes with a warning saying which.
Quiet hours on a device
Each subscribed device can carry a daily quiet window — Settings ▸ Notifications ▸ Devices ▸ Quiet
hours, off until you switch it on (it opens at 22:00–08:00; edit from there). Inside the window
nothing is pushed to that device. Everything else still happens: the inbox gets the record, an
open tab gets the event, the notifier runs, webhooks post — the morning finds the night in the bell,
and the delivery ledger reads quiet for the device rather than reading as a failure.
Urgent still gets through by default (an approval held until morning stops the fleet dead until
morning); untick it on a device that must never buzz at night. Times are on the console's own clock.
It is the only quiet-hours setting for notifications (control-tower phase 138, #215). A person's
turn's reminders obey it: one falls due while every device that hears A phase needs you is inside its
window, and it waits for the first of them to end; a reminder is never urgent, so it never breaks
through. A device with no window — or no device at all — is always awake, and a reminder goes out when
it is due. The reminders' own quiet hours, the reminderQuiet preference of earlier versions, are
gone: a config that still holds one has it moved, once, at boot, onto every device with no window of
its own (a device with its own keeps it), and the key is removed (push.quiet-migrated in the log). A
page from before the change that still sends it is treated the same way, and nothing stores it again.
Not the scheduler's Quiet hours in docs/controls.md — those stop phases boarding; these stop
pushes reaching one device. The two are independent and the names collide on purpose: both mean
"not now", about different things.
Answering from the notification itself
Some notifications carry buttons, and pressing one answers without opening anything:
| Button | On | What it does |
|---|---|---|
| Allow | Permission needed | Answers the card. The session unblocks where it stands. |
| Allow | A phase needs you — a permission item a paired device may grant: low or medium risk at the narrowest scope it offers (control-tower phase 138) | Grants it at that scope — this one call — through the device door, as the item's own Grant would: the item settles, the session resumes by itself, and a Permission granted push opens Settings ▸ Permissions at the grant. Never on a high or never item: there the first button is the tap that opens the item. |
| Approve | Gate needs a person, when the gate is a person's to clear (manual, or overdue) — never an ai/auto gate; a phase's run request (7.1) | Records the gate clearance in gate-status.md and lets the phase board; on a run request, takes the act the phases asked the run for. |
| Deny | Permission needed; a permission item; a run request (7.1) | Answers the card the other way, or denies the item, or tells the phases that asked no; either way the session is told and finds another way. |
| Extend 2 h | Permission needed — the T-15 and T-5 warning a card sends before it runs out, beside Allow | Moves the card's deadline two hours later. Inside the hook call's hour it simply moves; past it the session is told no at the hour and the card stands for twelve hours, and allowing it then resumes the phase with that one call granted once. |
| I did it | A phase needs you — an act ("Your turn: …") | Runs the step's proof on the console. A proof that lands marks the step proven and resumes the phase's own session saying what was proven; one that does not land records what it read, and the step waits on. A step that names no proof is proven on your word. |
Android and desktop render them. iOS ignores the array and shows the notification, which is the
correct degradation — tapping it opens the item (#/turn/<id>) or Your turn (#/approve lands there,
below), where the same buttons are. Which of them an item's push carries is the table in Your turn — a human step on the
phone, above: only what the device door may press.
Three things this deliberately cannot do, and the reasons are worth knowing before you rely on it:
- A button may answer a question; it may never start or kill work. Recover, Nudge, Freeze and Stop are never on a notification. A mis-tap on a lock screen should not be able to set a session running or tear one down, so those keep the button they have always had: Open.
- The notification carries no address. What it holds is a signed token naming one inbox item and the verbs the console offered for it. The service worker posts that token to one fixed route and the console decides what it means — so a payload can never become a general request generator sitting in your notification shade.
- It expires, and it is spendable once. Twelve hours, and a second press answers
already answeredrather than trying again. An expired or already-cleared item opens the console instead, which is exactly what every button did before this existed.
A relayed question carries no buttons: a button may allow or deny, never choose one of several
labels, so its answer is a tap on Your turn (#/approve lands there), where its options are cards.
The supervisor's cards (Pro)
When the console's supervisor wants a person — a remedy it suggests rather than presses, or an act only
a person may take — the push says so as A phase needs you. A suggestion is titled "Supervisor
suggests …", its body the reason and the first piece of evidence. Its one action is the tap: it opens the
Runs home's Your turn group (#/runs?group=needs-you), where the card stands with its evidence and its ONE
action — Accept (the verb it names, pressed as you), Dismiss, and, on a card the supervisor's hourly
cap held, Extend the cap. An escalation, an act only a person may take, is an item on Your turn like
any other (control-tower phase 136): its push opens it at #/turn/<id>, where the supervisor's card is drawn
inside the item beside its guide — the exact phase-console run … command and what you should then see —
and the proof that clears it. None of these is a button on the notification: Accept presses a verb that
starts or moves work, and a button may answer a question but never start work (above). The card is also in
the fleet's Needs you, each console's rows under its name, every console's Your turn is merged at
GET /api/fleet/turn, and the fleet's Supervisor tab shows every console's policy word and what it
sees. A paired phone presses as the device door through the fleet supervisor, and a physical item with a
link shows a QR to carry it to the phone.
#/approve — the whole queue, thumb-sized: now Your turn
https://your-console/#/approve — the address every push linked to until 6.2, and the one bookmarks
hold — and #/approve?step=<id> land in one hop on Your turn: #/turn, and #/turn/<id> for one item
(control-tower phase 137). That page is the phone's whole queue: one column of cards under Do now, Needs
one detail from you, Coming up, Being checked, Done and Handled by the AI, each item with why only
you, its guide as steps and its ONE primary action, in buttons big enough to hit. Bookmark #/turn, or reach
it from any notification.
A Session waiting on you is an item like the rest. A lane of the autopilot that the console can place on a
plan and phase carries Answer it, and your words reach that lane's session as an instruction for the
rest of the phase (behind --allow-run). A session stopped at its own terminal prompt has no action — the
console genuinely cannot answer for it — and its card says so: you answer it where it runs.
Where the action carries your words — evidence on a gate, a reason on a permission card, the answer to a lane — the card has a text field, and on Chrome and Safari a microphone button beside it that dictates into it. Firefox has no speech API, so there is no button there rather than a dead one. Dictation fills the field; it never presses the button.
Step 7 · Alerts with no browser involved at all (optional)
Push still needs a browser somewhere, even a closed one. For a machine where that is not true — a
headless box, a pager, a chat channel — point a notifier at a script. It is run as
your-script "<title>" "<body>" whenever a run needs a person:
#!/bin/sh
# ~/.local/bin/phase-notify
curl -s -H "Title: $1" -d "$2" https://ntfy.sh/your-private-topic-name >/dev/null
Set it once for every console in the machine profile, or for one shell with the environment variable, which wins over the file:
chmod +x ~/.local/bin/phase-notify
node viewer/shared/instances.mjs profile-set notifyCommand '"/absolute/path/to/phase-notify"' # every console
export PHASE_CONSOLE_NOTIFY=~/.local/bin/phase-notify # this shell only
ntfy is the shortest path — install its app, subscribe to the topic. Pushover, Slack or a webhook of your own work the same way.
This sends plan names and approval details to whatever service you choose. Pick a topic name nobody will guess, and if the work is sensitive, self-host ntfy or point the script somewhere you control. Both places are out of a web page's reach on purpose: the environment, and a file no route writes — so nothing reachable from a browser gets to choose which command runs.
One origin for every console — the fleet supervisor (Pro)
Steps 3–6 publish one console. With more than one on the machine, publish the fleet supervisor
instead: one process for every console, bound to 127.0.0.1:4100, behind the one Serve handler, with
one subscription on the phone. It has its own log, its own push keys and register, and a bearer every
console verifies it by; it starts no console by itself.
Set it up, once
phase-console fleet install # the unit com.phase-console.fleet
phase-console profile set remoteHost '"your-machine.your-tailnet.ts.net"'
phase-console profile set remoteUsers '["you@example.com"]'
fleet install writes and loads the unit (launchd RunAtLoad + KeepAlive; systemd
Restart=always), sets every installed console's login switch from that console's autostart in the
profile — held at login when it is false or a stop marker stands — and prints the resulting table, then
prints the Serve plan. --port N and
--remote HOST --remote-user LOGIN are its other options; with no remote flags the supervisor reads
the profile's remoteHost and remoteUsers. phase-console fleet render prints the unit and the table
and writes nothing.
The Serve handler moves only when you move it. fleet install prints the one command that does:
tailscale serve --bg --https=443 http://127.0.0.1:4100
While 443 is held by another handler — a console published in Step 4 — it says NOT published and
names the occupant, and it never displaces it, even with --publish-serve. --publish-serve is the
acknowledgement for the case where 443 is free: the installer then runs the command itself. Run it by
hand when you have decided to move the phone over, because running it takes the phone away from
whatever answers on 443 now.
Then open the tailnet name on the phone, add it to the Home Screen, and press Notify this device
about every console once. Every console's notifications arrive there, each naming its console, and a
tap opens that console's page under /c/<id>/.
Then, per repository
cd <repo> && phase-console start. When the supervisor's beat is live and its bearer readable, start
joins through it: the supervisor registers the console and gives it its port, answers with the profile
fields it inherits and its autostart, and moves in the session-presence events recorded under that
root before any console claimed it. start prints joined (the name, port and /c/<id>/ path) and,
when there is something to say, inherits and adopted; it then skips its own registration. A console behind the supervisor needs no --remote of its
own: its notifications reach the supervisor over loopback and go out on the fleet subscription, and
its pages are served under /c/<id>/ while its heartbeat says it is running. With no supervisor up,
start registers the console itself, exactly as before.
What the page at / is — the fleet app
- Needs you — every console's inbox on one list, worst-first, each row naming its console. A remedy
pressed there is sent to the console that asked, through its page under
/c/<id>/. A console that should be up and is not, one that does not answer, no device hearing the fleet, Tailscale stopped and Serve pointing elsewhere are rows of their own: instance health is work. - Consoles — the census, one row each: liveness from its heartbeat, lanes, how many things it is asking, when it last beat and when a phone last reached it; start, restart, freeze, thaw and stop behind a confirm, and the machine hold above them.
- Health — the part of the list that is about the machine rather than the work, with the supervisor's own beat and devices.
- Accounts — each running console's accounts, under its name.
Before the handler moves, the same app is a console's Fleet destination (#/fleet, offered while a
supervisor beats on the machine): the console asks the supervisor on the page's behalf, and starting or
stopping consoles from there needs that console's --allow-run.
The poll is not a visit. To build Needs you the supervisor asks every console for its inbox, and
marks each of those requests x-phase-console-fleet-poll: 1. A console serves it and does not record
it as a remote visit, so the Consoles tab's "when a phone last reached it" (lastRemoteAt) stays the
phone's rather than reading "just now" forever. The mark is believed only beside the supervisor's
verified assertion and is dropped from anything the supervisor forwards, so a caller cannot hide a
visit behind it.
The verbs
phase-console fleet status— the supervisor, then every console's liveness from its own heartbeat.phase-console fleet <start|stop|restart|freeze|thaw|logs|status> <instance>— that verb on any console, carried out by the supervisor.phase-console fleet hold [--by NAME]andphase-console fleet release— hold every automatic start on the machine at once, and let it go. A hold stops no live session; when the supervisor is down the bin writes the same file directly.phase-console fleet instances [--json]— the census as the supervisor serves it.phase-console fleet uninstall | start | stop | restart | status | log [-f] | render— the supervisor itself.
phase-console doctor fleet [--json] checks the supervisor and exits 0 when every blocking row passes,
1 naming the first that does not:
| Row | Blocking | Passes when |
|---|---|---|
unit — the fleet unit | yes | the unit file is installed |
console — the supervisor | yes | it answers on loopback from a live beat |
bearer | yes | fleet.token is readable (skip before the supervisor's first boot) |
census | no | the supervisor serves the census; an orphaned or port-taken console is a warning |
poller — the usage poller | no | the supervisor holds the machine's one poller |
delivery — the fleet subscription | no | at least one device hears every console (skip until one does) |
serve — the Serve handler | no | 443 reaches the supervisor; skip with the command until it does |
Where it keeps its state
Everything lives under ~/.local/state/phase-console/fleet/ ($XDG_STATE_HOME/phase-console/fleet/
when that is set), and none of it is a console:
| Path | What |
|---|---|
fleet.token | The bearer: 64 hex, 0600, minted once by the supervisor and never rotated by itself. A console's own token is an HMAC-SHA256 of console:<id> under it, so nothing per console is stored. |
daemon.json | The supervisor's beat — pid, port, when it started, beat and stopped, how many devices it holds, its build. Live means a beat within 90 s, no later stop, and the pid exists. |
hold.json | The machine hold, {at, by}, read by every console's automatic starts. |
push/ | The supervisor's own push keys and subscriptions.json. |
usage.json | The account meters the one poller read, by credential fingerprint. |
accounts/ | The machine login's registration the poller reads through. |
fleet.log | Its log; fleet.out.log and fleet.err.log are the unit's output. |
lanes/ | The machine lane ceiling's tokens. |
sessions/inbox/ | Session-presence events no registered console claimed yet — a join adopts them. |
viewer/fleet/paths.mjs is the one definition of these paths; node viewer/fleet/paths.mjs shell
prints the unit and path keys the installer reads, as key=value lines.
One usage poller for the machine. The supervisor claims pollerOwner in the machine-wide learned
store and beats it; an owner is live while its beat is within 90 s and its pid exists. It reads every
registered console's accounts and the machine's own login, and writes each read to usage.json. While
an owner is live, a console's poller asks the endpoint nothing: it adopts each new shared read, checking
at least every 30 s. The tick after the owner goes, the console polls for itself again.
A direct address — without Tailscale (Pro)
Everything above puts Tailscale between your phone and this machine. You do not have to. The
supervisor can open a second HTTPS listener of its own — the direct door — on one address of this
machine, and a phone reaches it by name or by IP with no VPN at all. The loopback bind never widens:
the door is a separate listener the supervisor opens and closes from the machine profile, and a
console still binds 127.0.0.1 and nothing else.
Choose it when Tailscale is not an option — a phone you cannot install it on, a network that blocks it, a guest device you want paired for an afternoon. Keep Serve when you already have a tailnet: it is fewer moving parts, and nobody has to trust a certificate.
Where the door may listen
One address, written out in full — never a wildcard, never "all interfaces".
| Exposure | What it means | What is asked of you |
|---|---|---|
loopback | 127.0.0.1, ::1, localhost | Nothing. Useful only for a browser on this machine. |
lan | 10.x, 172.16–31.x, 192.168.x, 100.64/10, 169.254/16, fc00::/7, fe80::/10, or a name ending .local .lan .home .internal .home.arpa .localdomain .ts.net — or a bare name with no dot | Nothing. This is the ordinary choice. |
public | anything else — a routable address, or a DNS name that resolves to one | --acknowledge <that exact host>. The door refuses to open otherwise, and the refusal says so. |
The acknowledgement exists because a public address answers the whole internet. Pairing and its rate limit are the only things in front of it, and that is a decision a person makes deliberately, once, naming the host — never a default and never something a session passes for you.
phase-console fleet reach direct --listen 192.168.1.20 # the ordinary case
phase-console fleet reach # read it back
phase-console fleet reach tailscale # close it again
fleet reach prints the mode, the profile's block and the door's own facts — listen, tls,
door: <state>, address, url, the certificate's days left and fingerprint, how many devices are
paired, whether a password is set, and where the CA lives. The state is the thing to read:
listening is what you want; address-unassigned means that address is not on this machine,
port-in-use means choose another --port, tls-unavailable means openssl could not be found,
unacknowledged means the address is public and you have not said so, cert-expired speaks for
itself, and unconfigured means the profile's block was dropped as invalid — the row names which
rule dropped it.
The certificate the phone has to trust
The door mints its own certificate authority (local-ca) with openssl, valid ten years, and a leaf
under it valid one year. Nothing on the internet has heard of that CA, so each phone installs it once
— otherwise every page is a certificate warning and, more importantly, the service worker will not
register and push will never work.
Get the file onto the phone by the shortest route that is actually available to you:
- AirDrop or Files —
phase-console fleet ca --export ~/Desktop/phase-console-ca.pem, then send it to the phone. This is the normal way. - Through the Tailscale door, if you still have one — open the fleet page over Serve and download it there. Useful when you are moving a phone off Tailscale rather than onto the door.
- Click through the warning and take it from the pairing page itself, last — the page offers Download the certificate while it can see that the worker will not register.
Then trust it, which on every platform is a second step distinct from installing it:
- iPhone / iPad — in the phone's own settings app: Profile Downloaded → Install; then General → About → Certificate Trust Settings, and turn on full trust for Phase Console local CA. Installing without that second step looks done and is not.
- Android — in the phone's own settings app: Security → Encryption & credentials → Install a certificate → CA certificate.
- Mac — open it into Keychain Access (System) and set Trust to Always Trust.
- Windows — Install Certificate → Trusted Root Certification Authorities.
Compare the fingerprint the phone shows against the one phase-console fleet ca prints. That
comparison is the whole security of this step; a CA you installed without checking it is a CA
somebody else may have handed you.
Under --tls custom the console asks for your own PEMs instead and offers no download — the phone
already trusts whoever issued them.
On iOS, add it to the Home Screen before you pair
An iOS web app installed to the Home Screen keeps its own cookie jar. Pair in Safari and the Home Screen app is still a stranger; you would pair twice and wonder why. So: Share → Add to Home Screen → open Phase Console from the Home Screen → pair there. The pairing page says so itself when it can tell it is not running standalone.
Pairing a phone
phase-console fleet pair --label "Mobin's iPhone"
prints a URL and an eight-character code, good for ten minutes. Open the URL on the phone — from the Home Screen app on iOS — and type the code. At most three codes are outstanding at once, a code is burnt after five wrong attempts, and a code is single-use.
A password is the fallback for a phone you cannot hand a fresh code to:
phase-console fleet password prompts twice with no echo (twelve characters minimum, stored scrypt-
hashed). It is set on the machine itself and never through a paired device. --clear removes it;
with none set, the pairing page offers codes only, which is a perfectly good way to live.
A paired phone gets a __Host-phase-console cookie — Secure, HttpOnly, SameSite=Lax, good for
400 days and silently re-issued after 30. That cookie is the device. The register holds up to
thirty-two of them:
phase-console fleet devices # id, label, via, site, standalone, lastSeenAt
phase-console fleet devices revoke <id> # immediately, no grace
Revoking is the off switch for a lost phone; the supervisor stops honouring that cookie at once.
What the door enforces
A device is asserted to each console as device:<slug>#<id6> — a login shape no person can have —
and it arrives as a remote caller always. So presence and the permission hooks, which answer only
a local caller, are simply not reachable from a phone (/c/<id>/hooks is a 403). A paired device can
read and drive the consoles; it can never change the reach settings that let it in.
| Code | When | What it means |
|---|---|---|
| 401 | not-paired, code-unknown, code-expired, code-burnt, password-wrong, password-unset | This device is not (yet) one of yours. The body says which of the six. |
| 403 | reach-settings | The reach is changed on the machine itself, or by a Tailscale-verified login — never by a paired device. |
| 409 | register-full | All thirty-two device places are taken. Revoke one nobody uses. |
| 421 | reach-host | You opened a hostname the door does not answer to. It answers to the one address the profile names, and compares it exactly. |
| 429 | reach-limited | Too many wrong attempts from here — ten per source or fifty overall in ten minutes. Retry-After says how long. |
Renewal, and the rows that watch it
The local-ca leaf is renewed thirty days before it expires, by the supervisor, while it runs.
Custom PEMs are re-read every sixty seconds, so replacing the files is the whole procedure. Inside
CERT_EXPIRING_DAYS (14) the console raises a reach-cert-expiring inbox row, and a door that
should be listening and is not raises reach-down — the two things about this door worth
interrupting you for.
phase-console doctor fleet judges the rest, in six rows:
| Row | Answers |
|---|---|
reach-mode | which door this machine uses, and whether the profile's block survived validation |
reach-listen | the door's state, its URL, and — on a failure — the exact command that fixes it |
reach-exposure | loopback / lan / public, and whether a public host was acknowledged |
reach-tls | the source, days left, expiry and fingerprint; a warning inside the expiry window |
reach-devices | how many devices are paired and how many codes are outstanding |
reach-password | whether a password is set (not setting one is fine, and the row says so) |
Under tailscale all six still print and none of them blocks — they are advice about a door you are
not using. The Serve-handler row rewords itself to say so rather than failing.
The fleet app's Reach section is the same door from a browser on the machine: the mode switch, the pairing QR, the device table and the password, with the door's state at the top. The prompt below hands the whole thing to Claude instead:
Make my Phase Console reachable from my phone without Tailscale, over a direct address.
1. Check the ground first and stop if any of it is missing:
openssl version the door mints its own certificate with it
phase-console doctor fleet the fleet supervisor installed and answering
(install it with: phase-console fleet install)
ifconfig | grep "inet " this machine's LAN address — the one the
phone can reach (ipconfig on Windows)
2. Decide WITH me whether the door is on the LAN or public — never decide it
for me. A LAN address (10.x, 172.16–31.x, 192.168.x, or a .lan/.local name)
is reachable only on this network. A public address or DNS name answers the
whole internet, behind pairing and its rate limit; if I choose that, I will
say so and only then pass --acknowledge <that host>. Never pass --acknowledge
on your own.
3. Open the door:
phase-console fleet reach direct --listen <the LAN address> [--port 4443] [--host <name>]
Then read it back and tell me the door's state:
phase-console fleet reach
listening is what we want; address-unassigned means the address is not on
this machine, port-in-use means pick another --port, tls-unavailable means
openssl is missing.
4. Hand me the certificate authority the phone must trust, and how:
phase-console fleet ca --export ~/Desktop/phase-console-ca.pem
iPhone/iPad: AirDrop or mail the file; in the phone's own settings app,
Profile Downloaded → Install, then General → About → Certificate Trust
Settings, and turn on full trust for "Phase Console local CA". Android: in
the phone's settings, Security → Encryption & credentials → Install a
certificate → CA certificate. Mac: open it into Keychain Access (System)
and set Trust to Always Trust. Read the fingerprint from phase-console
fleet ca back to me so I can compare it on the phone.
5. Pair the phone:
phase-console fleet pair --label "<my phone>"
Hand me the URL and the code. I open the URL on the phone — from the Home
Screen app on iOS — and type the code.
6. Confirm:
phase-console fleet devices
lists the phone; phase-console doctor fleet exits 0.
7. Finish by telling me what listens where, what the phone trusts, and how to
close the door again: phase-console fleet reach tailscale.
Never widen a console's --host to expose it on a network interface: only the
fleet supervisor opens this door, and it opens it on the one address I named.
What is actually enforced
Once a console has a hostname — --remote, or remoteHost from the profile — strict Host checking
turns on, and every request is judged by this table:
| Request | Verdict |
|---|---|
Loopback Host, no identity header | Served. You, at this machine — unchanged from before. |
| Your remote hostname + an allowlisted login | Served. You, through the proxy. |
| Your remote hostname, no identity header | 403. Something reached the console without going through the proxy. |
| Your remote hostname, a login not on the list | 403. Someone else on your network. |
Any other Host | 421. This is what a DNS-rebinding page arrives with. |
Loopback Host carrying an identity header | 421. See below. |
That last row is doing real work and is worth understanding. Anyone on your private network can put
whatever they like in a Host header — including 127.0.0.1. If a loopback Host alone meant
"local", such a request would skip the identity check entirely. It cannot: the proxy sets the
identity header on everything it forwards, so a loopback Host arriving with one is a combination
no honest client produces.
The other half of the assumption is that a caller cannot simply claim to be you. Serve overwrites
Tailscale-User-Login with the authenticated identity rather than passing through whatever the
client sent — worth knowing rather than assuming, and easy to confirm on your own setup:
# sent with a forged identity, through the proxy — served, because the proxy replaced it
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Tailscale-User-Login: mallory@example.com' https://your-machine.your-tailnet.ts.net/api/state
A 200 means the header you sent never reached the console. A 403 would mean it did, and that the
only thing standing between you and impersonation is the attacker not knowing which login to claim.
You can check the rest of it from the machine, without a phone:
C=http://127.0.0.1:4123
H=your-machine.your-tailnet.ts.net
curl -s -o /dev/null -w '%{http_code}\n' $C/api/state # 200
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: evil.example" $C/api/state # 421
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: $H" $C/api/state # 403
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: $H" -H "Tailscale-User-Login: you@example.com" $C/api/state # 200
Use curl, not a fetch() in a browser console — Host is a forbidden header name there, so it is
dropped silently and every case will look like a pass.
Behind the fleet supervisor the table applies at the supervisor, which takes its hostname and
logins from its own flags or the profile. It refuses a request that arrives already carrying its
assertion header (400) and a proxied identity when no hostname and logins are configured (403).
On the hop to a console it drops every Tailscale-User-* and forwarding header, sets Host to
loopback, and asserts the login with the shared bearer instead. A console judges that assertion before
anything else and always as a remote caller, so presence and the permission hooks stay shut to it; it
refuses a wrong or repeated assertion (403), one that arrives on a non-loopback Host (421), and
one beside a Tailscale-User-Login header (403).
Locking it down further (optional)
Serve obeys your access rules like anything else on the tailnet. If more than one person or machine is on yours, scope it. In the access controls:
{
"grants": [
{ "src": ["autogroup:owner"], "dst": ["autogroup:self"], "ip": ["tcp:443"] }
]
}
It is also worth removing machines you no longer use. They are the only devices that could reach the console at all.
What not to do
- Do not use
--host 0.0.0.0instead of this. It puts the console — which with--allow-runstarts agent sessions that edit your repository — on every network you join, with no authentication whatsoever. It also breaks the approval hook: the address the child sessions call back on is derived from the bind address, and that hook fails open, so the ask-list would stop working silently and every session would run on the deny rules alone. - Do not use
tailscale funnel. Funnel is Serve's public sibling: it publishes to the entire internet. Everything above depends on the caller being someone your network already vouched for. - Do not skip HTTPS. Plain HTTP to a hostname is not a secure context, so notifications are unavailable and the Home Screen install is degraded. The tunnel is encrypted either way — this is about what the browser will let the page do.
When it does not work
| Symptom | Cause |
|---|---|
| The name does not resolve on the phone | MagicDNS off in the admin console, or "Use Tailscale DNS" off in the phone's Tailscale app. |
tailscale serve prints "Serve is not enabled on your tailnet" and never returns | Serve is a tailnet capability that is off until someone approves it. Open the link the command printed — it is specific to that machine — and approve it. The command is waiting for exactly that and will continue on its own; do not kill it. |
tailscale serve errors about certificates | HTTPS Certificates not enabled. Step 1. |
| 403 — "No caller identity" | You reached the console directly rather than through Serve, or Serve is not running. Check tailscale serve status. |
| 403 — "… is not allowed to use this console" | The login is real but not in --remote-user (or, with no flag, the profile's remoteUsers). |
| 421 — "does not answer to …" | The hostname you opened is not the one you passed to --remote (or, with no flag, the profile's remoteHost). They must match exactly. |
| 421 — "arrived through a proxy but asks for a local hostname" | Something rewrote the Host header to localhost. Serve does not; a proxy in between might. |
| The console will not start | --remote with no allowed login from --remote-user, the environment or the profile. The error says so. |
| The console starts local-only although the profile names a host | Remote access is inherited only as a pair: the profile has remoteHost and no remoteUsers (or the reverse), and no flag supplies the other half. |
| A delivery-channel issue says announcements are reaching nobody | No subscribed device, no notifier and no webhook — or, under --remote, Tailscale stopped or Serve pointing elsewhere. Give the console one channel; the issue withdraws itself. |
| The notification button does nothing on iOS | Not installed to the Home Screen, or you are on plain HTTP. Both are required. |
| Turn on is missing and a banner explains why | Permission was refused for this site once. A page cannot ask twice — it has to be changed in browser settings. |
| Send a test says it was handed over, and nothing appears | Three separate yeses are involved — the push service, the browser, and the operating system — and only the first answers back. This is almost always the third: macOS System Settings → Notifications → your browser, or Windows Settings → System → Notifications. A Focus mode does it silently too. |
| Send a test says gone | The subscription was revoked at the browser end. Turn it off and on again; the register drops dead subscriptions by itself. |
| Push worked, then stopped after reinstalling the app | A reinstall makes a new subscription. The old row is dropped on its next failure; subscribe again from the new install. |
| Worked yesterday, dead after a reboot | tailscale serve was run without --bg. |
| Worked for weeks, then stopped | The machine's node key expired. Disable key expiry (Step 1). |
| Works on wifi, not on cellular | Tailscale toggled off on the phone, or iOS disabled its VPN profile. |
| /c/<id>/ shows a page naming the console's liveness | The census does not show that console running (503) — start it: phase-console fleet start <instance>. A console that is running and does not answer is a 502. |
| fleet install says NOT published: 443 is held by … | Another handler — usually a console from Step 4 — holds 443, and the installer never displaces it. Run the printed command when you decide to move the phone over. |
| A console under /c/<id>/ has no Turn on, only a line about the fleet page | Correct: subscribe once at the fleet origin, which hears every console. |
| The Serve card offers a port other than the usual one and names another console | A running sibling holds the natural port. Publish on the offered port, or publish the supervisor instead. |
| phase-console start prints no joined line | The supervisor's beat is not live or its bearer is unreadable, so the console registered itself. phase-console doctor fleet names which. |
| A certificate warning on the phone, and Turn on never appears | The local CA is not installed, or it is installed but not trusted — on iOS those are two separate steps. Until the worker registers there is no push. Compare the fingerprint against phase-console fleet ca. |
| 421 — "This door does not answer to that address" | You opened a hostname or IP the door does not serve. It answers to the one address the profile names and compares it exactly — use the address phase-console fleet reach prints. |
| 429 — "Too many wrong attempts from here" | Ten failures from one source, or fifty overall, inside ten minutes. Retry-After says how long; a burnt pairing code needs a fresh one either way. |
| The phone cannot reach the door at all, and nothing is logged | macOS Application Firewall is dropping the connection before the supervisor sees it — System Settings → Network → Firewall → Options — or the address is simply not reachable from that network. phase-console fleet reach saying listening means the socket is open here, not that the phone can get to it. |