Skip to content

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 serve points 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 machineWhere
Three tailnet switches, and the phone on the tailnetSteps 1–2
The machine profile: the tailnet name and the logins allowed through itStep 3
One Serve handler, the Home Screen install, one push subscriptionSteps 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:

  1. Enable MagicDNS. This is what makes your-machine.your-tailnet.ts.net resolve for your own devices. Without it you would be typing an IP address, and an IP address cannot have a certificate.
  2. Enable HTTPS Certificates. Tailscale then provisions a real, publicly-trusted certificate for that name. HTTPS requires MagicDNS, so do them in this order.
  3. Enable Serve. This one has no switch to find in advance: the first time you run tailscale serve on 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:

FieldRead as
remoteHostThe hostname the console also answers to behind the proxy — what --remote names.
remoteUsersThe logins allowed to arrive that way — what --remote-user names.
notifyCommandThe out-of-band notifier (Step 7), when PHASE_CONSOLE_NOTIFY is not set.
webhooksRows 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.
maxSessionsThe machine's lane ceiling — every console's live lanes summed. --max-sessions stays each console's own ceiling.
hookScriptAn absolute path to the session-presence hook script install-hooks and hooks-status use, when that file exists.
categories, quietHoursRecorded, and reported among what a console inherits. What reaches a device is still chosen on that device (Step 6).
instancesPer 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:

FlagMeaning
--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 isWhen it fires
In this tabThe 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 deviceA 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:

CategoryDefaultFires when
Permission neededonA 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 youonA 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 youonA 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 persononA 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 failedonA 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 haltedonA 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 waitingonEvery 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 happeningonA 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 failedonEach phase as it lands, with what it cost. The steady pulse of a run you are not watching.
Plan finishedonA run reached the end of its plan. The one you actually wanted to be told about.
Work became readyoffA phase became startable because what it was waiting on finished — including work finished by a session you ran yourself, elsewhere.
Plans changed on diskoffAny 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 endedonAn 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 problemsonThe 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 limitsonA 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 climbingonEarly 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 lowonA 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 sessiononA 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 digestoffA 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 grantedonA 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 itemIts two buttonsWhy
a Task — a sign-in, a code, a token, somebody else's approvalOpen · I did itI 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 scopeAllow · DenyAllow 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 raiseOpen · DenyA 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 familyOpen · DenyNo 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 holdApprove · DenyBoth 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 ownerOpenIts yes needs the card — its evidence, its block, or the owner's key.
a Decision — options to choose betweenOpenIts 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:

ButtonOnWhat it does
AllowPermission neededAnswers the card. The session unblocks where it stands.
AllowA 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.
ApproveGate 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.
DenyPermission 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 hPermission needed — the T-15 and T-5 warning a card sends before it runs out, beside AllowMoves 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 itA 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 answered rather 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] and phase-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:

RowBlockingPasses when
unit — the fleet unityesthe unit file is installed
console — the supervisoryesit answers on loopback from a live beat
beareryesfleet.token is readable (skip before the supervisor's first boot)
censusnothe supervisor serves the census; an orphaned or port-taken console is a warning
poller — the usage pollernothe supervisor holds the machine's one poller
delivery — the fleet subscriptionnoat least one device hears every console (skip until one does)
serve — the Serve handlerno443 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:

PathWhat
fleet.tokenThe 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.jsonThe 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.jsonThe machine hold, {at, by}, read by every console's automatic starts.
push/The supervisor's own push keys and subscriptions.json.
usage.jsonThe account meters the one poller read, by credential fingerprint.
accounts/The machine login's registration the poller reads through.
fleet.logIts 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".

ExposureWhat it meansWhat is asked of you
loopback127.0.0.1, ::1, localhostNothing. Useful only for a browser on this machine.
lan10.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 dotNothing. This is the ordinary choice.
publicanything 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:

  1. AirDrop or Files — phase-console fleet ca --export ~/Desktop/phase-console-ca.pem, then send it to the phone. This is the normal way.
  2. 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.
  3. 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.

CodeWhenWhat it means
401not-paired, code-unknown, code-expired, code-burnt, password-wrong, password-unsetThis device is not (yet) one of yours. The body says which of the six.
403reach-settingsThe reach is changed on the machine itself, or by a Tailscale-verified login — never by a paired device.
409register-fullAll thirty-two device places are taken. Revoke one nobody uses.
421reach-hostYou opened a hostname the door does not answer to. It answers to the one address the profile names, and compares it exactly.
429reach-limitedToo 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:

RowAnswers
reach-modewhich door this machine uses, and whether the profile's block survived validation
reach-listenthe door's state, its URL, and — on a failure — the exact command that fixes it
reach-exposureloopback / lan / public, and whether a public host was acknowledged
reach-tlsthe source, days left, expiry and fingerprint; a warning inside the expiry window
reach-deviceshow many devices are paired and how many codes are outstanding
reach-passwordwhether 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:

RequestVerdict
Loopback Host, no identity headerServed. You, at this machine — unchanged from before.
Your remote hostname + an allowlisted loginServed. You, through the proxy.
Your remote hostname, no identity header403. Something reached the console without going through the proxy.
Your remote hostname, a login not on the list403. Someone else on your network.
Any other Host421. This is what a DNS-rebinding page arrives with.
Loopback Host carrying an identity header421. 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.0 instead of this. It puts the console — which with --allow-run starts 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

SymptomCause
The name does not resolve on the phoneMagicDNS 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 returnsServe 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 certificatesHTTPS 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 hostRemote 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 nobodyNo 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 iOSNot installed to the Home Screen, or you are on plain HTTP. Both are required.
Turn on is missing and a banner explains whyPermission 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 appearsThree 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 goneThe 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 appA 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 reboottailscale serve was run without --bg.
Worked for weeks, then stoppedThe machine's node key expired. Disable key expiry (Step 1).
Works on wifi, not on cellularTailscale 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. |