# Manual Cloudflare tunnel setup — human runbook

Step-by-step recovery path for when the Real Agent automation fails or is unavailable. Every command below is isolated in its own code block — nothing to parse from prose. Every path is brand-scoped so multiple brands can coexist on the same Linux user.

Prerequisites: `cloudflared` installed; the brand's VNC running on its own X display + rfbport + websockify + CDP port (per `brand.json`'s `vncDisplay`/`rfbPort`/`websockifyPort`/`cdpPort` fields — defaults Real Agent=`:99`/`5900`/`6080`/`9222`, Real Agent=`:100`/`5901`/`6081`/`9223`, Real Agent-2=`:101`/`5902`/`6082`/`9224`, Real Agent-3=`:102`/`5903`/`6083`/`9225`, Real Agent-4=`:103`/`5904`/`6084`/`9226`); SSH access to the device. The defaults table is historical — runtime readers (`paths.ts`, admin/MCP, `vnc.sh`, `test-laptop-vnc-boot.sh`) loud-fail with `reason=cdp-port-unresolved` if any of `rfbPort`/`websockifyPort`/`cdpPort` is missing from the live `brand.json`; the values above show what each brand's `brand.json` contains, not what the readers fall back to.

---

## Why several hostnames (admin, public, operator)

Each Real Agent installation exposes distinct surfaces from the same device and the same local port. The platform UI distinguishes them by the inbound `Host:` header.

- **admin hostname** — the operator's private chat + admin panel. PIN-protected. Only the owner uses it.
- **public hostname** — the public-facing agent (customer chat, marketing agent, WhatsApp webhook target). Reachable by anyone on the internet.
- **operator hostname** (optional) — a trimmed admin dashboard (sessions, chat, data — no graph, no browser). Admin-gated like the admin host, recognised by membership in `operator-domains.json`. See § "Operator dashboard ingress".

Several tunnel hostnames, one connector, one local service. The tunnel's `ingress:` block routes each hostname to `http://localhost:${PORT}`; the platform UI classifies the host (operator → public → admin) and serves the matching surface.

---

## Two modes — which are you in?

### Mode A — you own the domain (this runbook)

You have a Cloudflare account and a domain already on that account. You run every step below on your own device using your own cert.pem. Your hostnames are:

- `admin.<yourdomain>`
- `public.<yourdomain>`

### Mode B — you don't own a domain (Real Agent-provided subdomain)

First-time users without a Cloudflare account or domain use a subdomain of the Real Agent-operated domain (`maxy.bot`). Real Agent owns the CF account and issues each user a token for a pre-created tunnel. Hostnames are:

- `<username>.maxy.bot` — admin surface (e.g. `neo.maxy.bot`, `joel.maxy.bot`)
- public surface is optional and, when enabled, uses a second Real Agent-issued subdomain.

Mode B skips Steps 1–5 of this runbook entirely — the user receives a token from Real Agent and only runs Step 6 (the connector) with `--token <X>`. Mode B is documented separately in `managed-tunnel-setup.md`.

**The rest of this runbook covers Mode A only.**

---

## How this runbook is used

This runbook is the contract the agent follows when setting up a Cloudflare tunnel. The agent reads the step it is about to execute, runs the `cloudflared` command (or writes the config / starts the service) via the Bash tool, and streams the literal output back to the operator. There is no shell-script wrapper, no orchestrator state machine, no MCP-tool surface — the PTY itself is the operator-visible surface, and cloudflared's stdout/stderr is the evidence.

The setup is done when, and only when, `curl -I https://<admin-hostname>` issued from outside the local network returns `HTTP/2 200` (or `HTTP/1.1 200 OK`). The agent must paste the curl response into chat before claiming success. No service-active or `cloudflared exit 0` claim substitutes for that response.

Apex hostnames (e.g. `maxy.chat`) cannot be routed by `cloudflared tunnel route dns` — see §Step 4. The agent quotes the ACTION REQUIRED block from §Step 4 verbatim and the operator edits the apex CNAME in the dashboard.

---

## Step 0 — Set brand context

Run this once per shell session. Every subsequent step reads these variables. The `BRAND` value determines the on-disk directory (`${HOME}/.${BRAND}/cloudflared/`) so multiple brands on the same Linux user do not collide.

For Real Agent:

```
export BRAND=maxy
export PORT=19200
export CFG_DIR="${HOME}/.${BRAND}/cloudflared"
mkdir -p "${CFG_DIR}"
```

For Real Agent:

```
export BRAND=realagent
export PORT=19500
export CFG_DIR="${HOME}/.${BRAND}/cloudflared"
mkdir -p "${CFG_DIR}"
```

---

## Step 1 — OAuth login

The DISPLAY value is per-brand. Read it from the brand manifest so the chromium tab opens in the correct VNC:

```
export VNC_DISPLAY=":$(jq -r '.vncDisplay // 99' "${HOME}/${BRAND}/platform/config/brand.json")"
DISPLAY="${VNC_DISPLAY}" cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel login
```

**Why:** Produces the account-level credential that authorises creating tunnels and routing DNS. One-time per device per brand per account.

**Success:** VNC browser opens on the "Authorize Cloudflare Tunnel" page. Click any zone. Terminal prints `You have successfully logged in.`

**`cloudflared` quirk — cert.pem lands at the default path regardless of `--origincert`.** The login subcommand always writes to `~/.cloudflared/cert.pem`. Move it into the brand-scoped directory immediately so every subsequent step works off `${CFG_DIR}/cert.pem`:

```
mv ~/.cloudflared/cert.pem "${CFG_DIR}/cert.pem"
```

**If the login fails with `You have an existing certificate`:** cert.pem already exists at the default path from a prior login. Either skip to Step 2 (if the existing cert is correct), or remove it and re-run:

```
rm ~/.cloudflared/cert.pem
```

**If the browser does not open:** confirm the VNC display exists:

```
ls /tmp/.X11-unix/
```

You should see `X99`. If not, start VNC before retrying Step 1.

### States the consent page can render

The dashboard at `dash.cloudflare.com/argotunnel?...` is `cloudflared`'s native consent surface. The automation wrapper does not inspect or auto-click it; the operator drives every variation manually on the VNC.

1. **Pre-authorize — the Authorize button is visible.** Single-zone account. Click **Authorize**. Cloudflare's callback fires and `~/.cloudflared/cert.pem` lands a second or two later. Continue with the `mv` above.

2. **Multi-zone picker — "Please select the zone you want to add a Tunnel to" with a list of zones.** Multi-zone account. Click the zone row you want first; the page renders the Authorize button. Click **Authorize**. Same callback path as state 1.

3. **Post-success — "Cloudflared has installed a certificate" modal.** The bound account already has a cert authorised for the zones the dashboard knows about. The OAuth callback is idempotent — navigating to a fresh `cloudflared tunnel login` URL still fires the callback, so `~/.cloudflared/cert.pem` will land. Wait a second or two, then run the `mv`.

   If the cert *also* exists at `${CFG_DIR}/cert.pem` from a prior partial run, Step 1 is already complete — skip to Step 2.

4. **Blank or button-less — neither the Authorize button nor the success modal is on the page.** The browser may be mid-load, signed out of Cloudflare, blocking on captcha, or showing some other state. Sign in to Cloudflare in the same browser, navigate back to the URL `cloudflared tunnel login` printed, and complete the click manually. If the URL has expired, kill `cloudflared` (`Ctrl+C`) and re-run Step 1 to get a fresh URL.

---

## Step 2 — List existing tunnels

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list
```

**Why:** Shows every tunnel on the brand's account by name and UUID. Use before creating (to avoid name collision) and before deleting (to find the UUID).

**Success:** A table of NAME, ID (UUID), CREATED, CONNECTIONS. An empty list is fine.

**If it fails with `cert.pem not found`:** run Step 1.

---

## Step 3 — Create the tunnel

Choose a tunnel name following the convention `{product}-{device}` (e.g. `maxy-neo`, `maxy-pi`, `realagent-laptop`). Then:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel create "${BRAND}-$(hostname -s)"
```

**Why:** Creates a new tunnel on the brand's CF account. Writes the connector credentials file (distinct from cert.pem: this is per-tunnel runtime auth; cert.pem is account auth). The credentials filename is the tunnel UUID.

**Success:** Output contains:
```
Created tunnel <name> with id <UUID>
```
Note the UUID — every subsequent step needs it.

**If it fails with `already exists`:** a tunnel with that name exists. Either run Step 2 to find its UUID and skip to Step 4, or delete it:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel delete "${BRAND}-$(hostname -s)"
```

Then re-run Step 3.

**Capture the UUID as an env var so every subsequent step references it:**

```
export TUNNEL_ID=<UUID>
```

Replace `<UUID>` with the value from Step 3's output.

**Note — credentials JSON already lives in `${CFG_DIR}`.** Unlike cert.pem, `cloudflared tunnel create` writes its `<UUID>.json` credentials next to wherever `--origincert` points. Because Step 1's `mv` put cert.pem in `${CFG_DIR}`, the credentials landed there too. No move needed.

---

## Step 4 — Route DNS for each hostname

One command per hostname.

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" admin.maxy.bot
```

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" public.maxy.bot
```

**Why:** Creates a CNAME in Cloudflare DNS pointing each hostname at `<UUID>.cfargotunnel.com`. `--overwrite-dns` makes it idempotent.

**Success — any of these output shapes:** cloudflared emits one of three distinct messages on exit 0, all of which mean the hostname is routed correctly. Do not parse stdout to distinguish them — exit 0 is the signal.

1. New record: `Added CNAME <hostname> which will route to this tunnel`
2. Overwritten: `Added CNAME <hostname> which will route to this tunnel` (same shape as new; `--overwrite-dns` makes overwrite transparent)
3. Idempotent no-op: `<timestamp> INF <hostname> is already configured to route to your tunnel tunnelID=<UUID>`

Shape 3 is what a repeat run against an already-configured hostname emits — `cloudflared` exits 0 and the agent treats it as a no-op success.

**If it fails with `zone not found`:** the hostname's parent domain isn't on this brand's Cloudflare account. Either add it in the dashboard (Websites → Add a site) and re-run, or sign into the account that already owns the domain.

### Apex hostnames (e.g. `maxy.chat` as a bare root domain)

`cloudflared tunnel route dns` **cannot** create a CNAME at a zone apex — standard DNS forbids CNAME at apex (conflicts with SOA/NS), and Cloudflare's workaround (CNAME flattening) is only exposed via the CF API/dashboard, not the CLI. Running `tunnel route dns --overwrite-dns <UUID> maxy.chat` silently misroutes to the first non-matching zone it finds (observed: it created `maxy.chat.maxy.bot` under the wrong zone).

**Workflow for apex hostnames:**

1. **Manual — create the CNAME once in the dashboard.** CF dashboard → `<apex-zone>` → DNS → Add record. Type=CNAME, Name=`@` (zone apex), Content=`<UUID>.cfargotunnel.com`, Proxy=on. Cloudflare applies CNAME flattening automatically for apex CNAMEs under a CF-managed zone.
2. **Scripted — add the ingress rule in Step 5's config.yml** (same as any subdomain hostname). Without this, traffic arrives at the connector and falls through to `http_status:404`.
3. **Scripted — add the hostname to `alias-domains.json`** if the apex should render the public surface (see §Troubleshooting at the end of this runbook for the classification rule).

Steps 2 and 3 are part of the normal scripted flow. Only Step (1) is the irreducible manual action for apex hostnames.

---

## Step 5 — Write config.yml

The heredoc below expands `${BRAND}`, `${PORT}`, `${CFG_DIR}`, and `${TUNNEL_ID}` at write time. **If any of those variables is empty in the current shell, the file gets written with blank values and cloudflared refuses to start with the cryptic error `"cloudflared tunnel run" requires the ID or name of the tunnel`.** The most common cause is running Step 5 in a fresh shell where Step 0 wasn't re-exported.

**Fail-fast guard — run this first.** It exits non-zero if any required variable is unset or empty, so you cannot accidentally write a broken file:

```
: "${BRAND:?set Step 0}"; : "${PORT:?set Step 0}"; : "${CFG_DIR:?set Step 0}"; : "${TUNNEL_ID:?set via Step 3}"
```

Only if that line prints nothing (all vars set) do you proceed to write the config:

```
cat > "${CFG_DIR}/config.yml" <<EOF
tunnel: ${TUNNEL_ID}
credentials-file: ${CFG_DIR}/${TUNNEL_ID}.json
no-autoupdate: true
ingress:
  - hostname: admin.maxy.bot
    service: http://localhost:${PORT}
  - hostname: public.maxy.bot
    service: http://localhost:${PORT}
  - service: http_status:404
EOF
```

**Why:** Tells cloudflared at startup which tunnel to run, where its credentials are, and how to route each hostname to the brand's local service port. The trailing `http_status:404` line is the mandatory catch-all. `no-autoupdate: true` stops the connector downloading a new binary and replacing itself (which exits the running process); the cloudflared version is installer-owned and bumped deliberately. This is belt-and-braces with the `--no-autoupdate` flag `resume-tunnel.sh` already passes on the spawn argv — the flag covers every existing install on its next service restart, this line covers a connector started any other way from a freshly-provisioned config.

**Success:** Confirm with:

```
cat "${CFG_DIR}/config.yml"
```

**If it fails on YAML:** validate with:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" --config "${CFG_DIR}/config.yml" tunnel ingress validate
```

### Off-LAN SSH and SMB ingress

The same tunnel can also carry SSH and SMB to the Pi so an off-LAN
operator can SSH in or mount the brand share without VPN or port
forwarding. Insert SSH/SMB ingress rules between the HTTP rules and the
catch-all `http_status:404`:

```
ingress:
  - hostname: admin.maxy.bot
    service: http://localhost:${PORT}
  - hostname: public.maxy.bot
    service: http://localhost:${PORT}
  - hostname: ssh.maxy.bot
    service: ssh://localhost:22
  - hostname: smb.maxy.bot
    service: tcp://localhost:445
  - service: http_status:404
```

Then route the new hostnames (Step 4 equivalent):

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" ssh.maxy.bot
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" smb.maxy.bot
```

**Order matters:** route HTTPS hostnames first and rewrite `config.yml`
only after the HTTPS pass is durable, so a failure on `ssh` or `smb` route
DNS does not nuke the HTTPS ingress; the failing hostname is logged and
config.yml is rendered without it.

**SMB depends.** Before adding the SMB ingress, confirm the
brand stanza exists in `/etc/samba/smb.conf`:

```
grep -q "^\[${BRAND}\]" /etc/samba/smb.conf && echo present || echo missing
```

If `missing`, provision Samba first (the installer's post-install step
does this on every Pi). The script skips the SMB ingress with
`[tunnel-install] smb-ingress-skipped reason=samba-not-provisioned`
rather than failing.

**Access policy — API or dashboard.** `cloudflared` CLI has no
Access-application create subcommand, so author the policy either via
the Cloudflare API with the reused Access-scoped token (`<brand>-access`,
minted once if absent — see `references/api.md` § Access) or by hand in
the dashboard. After the
ingress is in place, the dashboard path is:

```
Cloudflare → Zero Trust → Access → Applications → Add → Self-hosted
  Application name:    ssh.maxy.bot          (or smb.maxy.bot)
  Application domain:  ssh.maxy.bot          (or smb.maxy.bot)
  Policy → Action:     Allow
  Include → Emails:    <operator email>
```

Verify ingress validity:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel ingress validate "${CFG_DIR}/config.yml"
```

Then verify off-LAN reachability from a Mac:

```
cloudflared access ssh --hostname ssh.<brand>.<rootdomain>
cloudflared access tcp --hostname smb.<brand>.<rootdomain> --url tcp://localhost:4445
# In Finder: Cmd-K → smb://localhost:4445
```

---

## Step 5b — Write tunnel.state

`resume-tunnel.sh` (the `ExecStartPre=` in the brand's service) reads `${CFG_DIR}/tunnel.state` to discover which tunnel to run. **Without this file the script exits 0 silently and no connector is spawned** — see lines 34-37 of `platform/scripts/resume-tunnel.sh`. The raw CLI flow in this runbook does not write `tunnel.state` automatically, so write it explicitly here:

Re-run the Step 5a fail-fast guard if your shell is new or if Step 5a was a while ago:

```
: "${BRAND:?set Step 0}"; : "${CFG_DIR:?set Step 0}"; : "${TUNNEL_ID:?set via Step 3}"
```

Then write the state file:

```
cat > "${CFG_DIR}/tunnel.state" <<EOF
{
  "tunnelId": "${TUNNEL_ID}",
  "tunnelName": "${BRAND}-$(hostname -s)",
  "domain": "maxy.bot",
  "configPath": "${CFG_DIR}/config.yml",
  "credentialsPath": "${CFG_DIR}/${TUNNEL_ID}.json",
  "sshHostname": "ssh.maxy.bot",
  "smbHostname": "smb.maxy.bot"
}
EOF
```

Omit `sshHostname` and `smbHostname` when those ingresses are not in
use. The fields are additive; the script's re-run path rehydrates them
on every invocation so a repeat setup run without
`SSH_HOSTNAME` / `SMB_HOSTNAME` set does not silently drop the
SSH/SMB ingress.

**Why each field:** `resume-tunnel.sh` reads `tunnelId`, `domain`, `configPath` (all used; domain is for logging, tunnelId for the log line, configPath is passed as `--config` to cloudflared). `credentialsPath` and `tunnelName` are not read by `resume-tunnel.sh` itself but are consumed by other tools (e.g. `tunnel-status` in the cloudflared plugin), so write them anyway.

**Success:**

```
cat "${CFG_DIR}/tunnel.state" | jq .tunnelId
```

Prints the UUID from Step 3. If it prints empty or null, the heredoc's env expansion failed — re-run the fail-fast guard.

**If you skip this step:** `systemctl --user status maxy.service` will show `ExecStartPre=resume-tunnel.sh (code=exited, status=0/SUCCESS)` (no error) but `ps -ef | grep '[c]loudflared'` will show no Real Agent connector. Silent failure — the exact gap this step closes.

---

## Step 6 — Restart the brand's user-space service so it respawns the connector with the new config

You do **not** run `cloudflared` manually. The brand's existing user-space systemd unit (`~/.config/systemd/user/${BRAND}.service`) declares `ExecStartPre=/home/<user>/${BRAND}/platform/scripts/resume-tunnel.sh`, and that pre-start script reads `${CFG_DIR}/tunnel.state` and `${CFG_DIR}/config.yml` (the files Steps 5 and 5b just wrote) and spawns the connector in the user's cgroup. On this first provision the connector is not yet running, so the brand-service restart triggers `resume-tunnel.sh`, which spawns it with the new config. (This is the *only* case where a brand-service restart spawns the connector — once it is running, `resume-tunnel.sh` skips by design and a `config.yml` reload requires restarting the connector unit directly; see Step 7.)

```
systemctl --user restart "${BRAND}.service"
```

**Cgroup trap — when the admin agent issues this command via the Bash tool, the Bash subprocess runs *inside* `${BRAND}.service`'s cgroup.** A direct `systemctl --user restart ${BRAND}.service` from that cgroup tells systemd to SIGTERM the entire cgroup — the node server, the claude subprocess, the Bash child, and the agent itself all die simultaneously. cgroup membership is inherited: `setsid`, `nohup`, `disown`, and `&` all stay in the caller's cgroup, and `systemd-run --scope` runs in the caller's scope. Only `systemd-run --user --unit=<name> --on-active=<N>s` creates a genuinely new transient unit with its own cgroup. The agent uses that primitive to arm the restart a few seconds after the Bash call returns:

```
systemd-run --user --unit=maxy-tunnel-restart-$(date +%s).service --on-active=3s --collect \
  /bin/systemctl --user restart "${BRAND}.service"
```

The transient timer fires from outside the service's cgroup — semantically identical to a direct `systemctl --user restart`, but the agent survives.

When walking through manually from an SSH shell you do **not** need `systemd-run` — your SSH shell already lives in a separate user-scope cgroup (`user@<uid>.service`), so the direct `systemctl restart` does not kill the caller. The extra indirection only matters when the caller *is* inside the service being restarted (the admin agent's case).

**Why:** `resume-tunnel.sh` is the deterministic, brand-scoped spawner. Running `cloudflared` manually duplicates the connector (two processes for one tunnel) and races the brand service on every service restart. The service path is the only correct production path.

**Success:**

```
systemctl --user status "${BRAND}.service" --no-pager
```

Shows `active (running)` with `ExecStartPre=...resume-tunnel.sh (code=exited, status=0/SUCCESS)`. Then confirm the connector is actually alive:

```
ps -ef | grep '[c]loudflared'
```

Expect one line per running brand, each with `--config ${CFG_DIR}/config.yml` under your Linux user (not root). Then verify end-to-end from any machine:

```
curl -I https://admin.maxy.bot
```

A non-530 response means the tunnel is live.

**If the connector is missing from `ps`**, the spawn failed silently. Check `${CFG_DIR}/logs/cloudflared.log` for the reason (most often: `config.yml` was written with empty `tunnel:` because Step 5's fail-fast guard was skipped).

**If the hostname returns `530`:** DNS has not propagated yet. Wait 30–60 seconds and retry.

**If the hostname returns `connection refused`:** your local service is not listening on `${PORT}`. Start it.

**If cloudflared exits with `tunnel not found`:** the `${TUNNEL_ID}` in config.yml does not match an existing tunnel on the brand's account. Re-run Step 2 to confirm.

---

## Step 7 — Production durability (user-space systemd via the platform service)

**Do NOT use `sudo cloudflared service install`.** It writes `/etc/systemd/system/cloudflared.service`, copies your config to `/etc/cloudflared/config.yml` (a system-wide path), and runs the connector as root — breaking brand isolation on any device that hosts more than one brand under the same Linux user. We tried this path on 2026-04-19; it created a duplicate connector parallel to the existing user-space one and required an explicit uninstall to undo.

The correct production pattern is already in place on every Real Agent device: the brand's platform UI itself is a **user-space systemd service** at `~/.config/systemd/user/<brand>.service`, and that service spawns the cloudflared connector as an `ExecStartPre=` via `platform/scripts/resume-tunnel.sh`. The connector runs as the Linux user (not root), its config is read from `${CFG_DIR}/config.yml` (brand-scoped, never `/etc`), and multiple brands coexist because each has its own unit file (`maxy.service`, `realagent.service`, etc.).

**The connector is a supervised, self-healing unit.** `resume-tunnel.sh` does not run cloudflared as a child of the brand service. It spawns it into its own transient systemd **service** — `cloudflared-<brand>.service` under `cloudflared.slice` — with `Restart=always`. Two consequences: a brand-service restart no longer reaps the connector (the cgroup decoupling), and systemd resurrects the connector on **any** exit — crash, OOM, manual kill. The connector also runs with `--no-autoupdate`, so it never downloads a new binary and replaces itself; the version is installer-owned (pinned in the installer, bumped deliberately). `StartLimitIntervalSec=300`/`StartLimitBurst=5` mean a genuinely broken binary surfaces as a `failed` unit instead of looping forever.

**What the supervisor needs to resume.** `resume-tunnel.sh` respawns the connector with `cloudflared … --config "${CFG_DIR}/config.yml" tunnel run`, which authenticates with the per-tunnel **credentials-file** named in that config — not `cert.pem`. The account cert is a create/route credential; the run/resume path never reads it. So a tunnel resumes on reboot whenever `config.yml` and its `credentials-file` are present, whether the tunnel was created via OAuth (this runbook) or supplied as an API-token credentials-file. The supervisor passes `--origincert` only when `cert.pem` exists; absent it, the token connector still runs. For a configured tunnel (a valid `tunnel.state`) where neither a usable credentials-file nor a cert is present, `resume-tunnel.sh` logs `op=resume-refused reason=missing-run-credential` at brand-service start instead of skipping silently — so a non-resumable tunnel is visible at the next start rather than discovered as an outage.

**Observability.** A connector death is now a logged, countable event, not an invisible gap:

```
# Every restart systemd performs is journaled for the connector's own unit:
journalctl --user -u cloudflared-<brand>.service | grep "Scheduled restart job"
# (each line: "Scheduled restart job, restart counter is at N")

# Confirm auto-update is off — cloudflared prints its effective settings at startup:
grep "no-autoupdate:true" "${CFG_DIR}/logs/cloudflared.log"

# Single-connector census — resume-tunnel logs a reap on every brand-service start:
grep "\[tunnel-supervise\] op=reap" "${CFG_DIR}/logs/cloudflared.log"
# (each completed start ends in "found=<n> killed=<m> survivor=<pid>"; healthy steady state is found=1)
```

`no-autoupdate:true` in cloudflared's startup `Settings:` line is the per-box rollout acceptance signal. A unit that has tripped its start limit shows `Active: failed` under `systemctl --user status cloudflared-<brand>.service` — investigate the binary rather than restarting blindly. A reap census whose final line shows `found>1` after a completed start, or any per-kill `target=<pid> tunnel=<id>` line whose tunnel id is not this brand's, is the failure signature (a stray survived, or a co-resident brand's connector was matched); `ps -eo pid,etime,args | grep '[c]loudflared.*tunnel run'` should then show exactly one connector, carrying `--no-autoupdate`.

**Durability prerequisites (one-time per device):**

```
loginctl enable-linger "$(whoami)"
```

Lets user-space services survive logout/reboot. Without linger, user units only start at interactive login.

**Confirm the brand's unit is enabled:**

```
systemctl --user is-enabled "${BRAND}.service"
```

Expected: `enabled`. If `disabled`, enable it:

```
systemctl --user enable "${BRAND}.service"
```

**After any change to `${CFG_DIR}/config.yml`, restart the connector unit — not the brand service:**

```
systemctl --user restart "cloudflared-${BRAND}.service"
```

With `Restart=always` this is a clean reload: systemd starts a fresh connector under the same unit, reading the edited `config.yml`. The brief ~1s blip is the same one every cloudflared upgrade incurs.

**Why not the brand service?** Since the connector was decoupled into its own unit (and made a supervised `Restart=always` service), a `systemctl --user restart "${BRAND}.service"` does not replace the running connector: its `ExecStartPre=resume-tunnel.sh` sees the supervised connector already `is-active` and does not respawn it, so the edited `config.yml` is not picked up by a brand-service restart. Restart the connector unit directly (above) to apply a config change. What the brand-service restart *does* do now is **reap strays**: before the skip, `resume-tunnel.sh` terminates every bare or stale `cloudflared … tunnel run` bound to this brand's cert/config/tunnel-id, leaving exactly the one supervised connector. So the two operations are distinct — restart the connector unit to load new ingress; restart the brand service to clear a duplicate connector. Restarting the brand service still only *spawns* a connector when none is running (the first-provision case in Step 6).

**Success:**

```
systemctl --user status "${BRAND}.service"
```

Shows `active (running)` with the brand's platform UI + cloudflared alive. A separate `ps -ef | grep '[c]loudflared'` confirms one connector per running brand, all under user `$(whoami)`, none under root.

**If you already ran `sudo cloudflared service install` before reading this** — undo it:

```
sudo cloudflared service uninstall
```

Removes the root-level systemd unit, `/etc/cloudflared/config.yml`, and the duplicate connector. The user-space service remains unaffected.

---

## Multi-ingress: adding hostnames for prototype services

Once the brand tunnel is up (admin + public + optionally SSH/SMB), additional service hostnames can be added without re-running the OAuth/cert/create-tunnel steps. This is the pattern the venture-studio `prototype-host` skill uses to expose hosted landing pages and wedge prototypes as `https://<surface>.<BRAND_HOSTNAME>`.

The flow is: append an ingress rule to `${CFG_DIR}/config.yml` immediately before the final `http_status:404` catch-all, validate the YAML, create the DNS row, restart the brand cloudflared service.

### 1. Append the ingress rule (awk, not sed)

`sed` cannot reliably insert before the last multi-line YAML rule. `awk` matches the literal catch-all line and inserts two lines before each match:

```
SURFACE="<surface>"            # DNS label, e.g. landing or prototype
PORT="<port>"                  # local port the dev server / file server binds to
HOSTNAME="${SURFACE}.${BRAND_HOSTNAME}"

# Idempotency — skip if this hostname is already in config.yml.
if grep -qE "^[[:space:]]*-[[:space:]]+hostname:[[:space:]]+${HOSTNAME}\b" "${CFG_DIR}/config.yml"; then
  echo "ingress-row-exists hostname=${HOSTNAME}"
else
  awk -v host="${HOSTNAME}" -v port="${PORT}" '
    /^[[:space:]]*-[[:space:]]+service:[[:space:]]+http_status:404[[:space:]]*$/ {
      print "  - hostname: " host
      print "    service: http://localhost:" port
    }
    { print }
  ' "${CFG_DIR}/config.yml" > "${CFG_DIR}/config.yml.new"
  mv "${CFG_DIR}/config.yml.new" "${CFG_DIR}/config.yml"
fi
```

After the edit the file looks like:

```yaml
tunnel: <TUNNEL_ID>
credentials-file: <CFG_DIR>/<TUNNEL_ID>.json
ingress:
  - hostname: admin.<BRAND_HOSTNAME>
    service: http://localhost:<PORT>
  - hostname: public.<BRAND_HOSTNAME>
    service: http://localhost:<PORT>
  - hostname: <surface>.<BRAND_HOSTNAME>
    service: http://localhost:<port>
  - service: http_status:404
```

### 2. Validate

```
cloudflared --origincert "${CFG_DIR}/cert.pem" \
  --config "${CFG_DIR}/config.yml" \
  tunnel ingress validate
```

Non-zero exit aborts the flow; cloudflared's stderr is the literal diagnostic.

### 3. Create the DNS row

```
TUNNEL_ID=$(jq -r '.id // .tunnelId' "${CFG_DIR}/tunnel.state")
cloudflared --origincert "${CFG_DIR}/cert.pem" \
  tunnel route dns --overwrite-dns "${TUNNEL_ID}" "${HOSTNAME}"
```

`--overwrite-dns` replaces any existing CNAME for the hostname (common after a prior install on the same account). Without it, `tunnel route dns` errors `record already exists`.

### 4. Reload the connector

```
systemctl --user restart "cloudflared-${BRAND}.service"
```

Cloudflared does not watch `config.yml` for changes (the brand service unit launches it without `--config-watch`). Restart is the deterministic way to pick up the new rule; the brief ~1s connector blip is the same one every cloudflared upgrade incurs.

**Single-connector guarantee.** Restarting `cloudflared-${BRAND}.service` replaces the *supervised* connector in place. It does **not** touch a bare `cloudflared … tunnel run` started outside systemd (a manual run, a stale process serving old ingress) — so a bare stray keeps answering with its own `http_status:404` catch-all and Cloudflare can route requests to it, the exact intermittent-404 failure this guarantee closes. If `ps -eo pid,args | grep '[c]loudflared.*tunnel run'` shows more than one connector for this brand, restart the brand service (`systemctl --user restart "${BRAND}.service"`, with the `systemd-run` indirection from Step 6 when you are the admin agent): its `ExecStartPre=resume-tunnel.sh` reaps every stray bound to this brand's cert/config/tunnel-id and leaves exactly one. The reap is brand-scoped — a co-resident brand's connector (different cert path, different tunnel id) is never touched. Never start a second `cloudflared … tunnel run` by hand to apply a change; that stacks the duplicate this guarantee exists to prevent.

### Verification

```
curl -I https://<surface>.<BRAND_HOSTNAME>
```

Anything other than `HTTP/2 200` (or the dev server's natural 200/304 response) means either the rule didn't land, the systemd unit serving `<port>` isn't running, or DNS hasn't propagated. Run `cloudflared --origincert "${CFG_DIR}/cert.pem" --config "${CFG_DIR}/config.yml" tunnel ingress validate` and `dig <surface>.<BRAND_HOSTNAME>` to isolate which.

### Removing a prototype hostname

Manual for now (no scripted removal — the `prototype-host` skill does not yet ship a remove companion):

```
# 1. Stop and disable the service unit.
systemctl --user disable --now "${BRAND}-prototype-<surface>.service"

# 2. Strip the ingress row from config.yml — match the literal hostname line plus the following service line.
awk -v host="${SURFACE}.${BRAND_HOSTNAME}" '
  $0 ~ "^[[:space:]]*-[[:space:]]+hostname:[[:space:]]+" host "[[:space:]]*$" { skip = 2; next }
  skip > 0 { skip--; next }
  { print }
' "${CFG_DIR}/config.yml" > "${CFG_DIR}/config.yml.new" && mv "${CFG_DIR}/config.yml.new" "${CFG_DIR}/config.yml"

# 3. Delete the DNS row (dashboard, since the cloudflared CLI has no "route dns remove").
#    Cloudflare → DNS → <zone> → delete CNAME for <surface>.<BRAND_HOSTNAME>.

# 4. Restart cloudflared.
systemctl --user restart "cloudflared-${BRAND}.service"
```

---

## Operator dashboard ingress (operator host)

The operator dashboard is a trimmed admin surface (sessions, chat, WhatsApp reader, data — no graph, no browser). It routes to the **same brand UI port** as the admin host (`${MAXY_UI_PORT}`) and is gated by the **same admin gate** (Cloudflare Access / remote-auth + PIN + Claude OAuth) — it is not a public host. A request to the operator host that is unauthenticated hits the identical login flow as the bare admin host; the UI server then classifies the host and serves the operator shell.

**The operator hostname is arbitrary** — any FQDN the operator chooses (e.g. `glsops.sitedesk.online`). There is no `operator.` prefix rule: the UI classifies a host as operator **only** when the host is listed in `~/.${BRAND}/operator-domains.json`. Provisioning therefore has two scripted parts — the tunnel ingress (so the host reaches the brand UI) and the operator-domains.json write (so the UI serves the operator shell instead of the full admin shell).

Take the operator FQDN from chat:

```
OPERATOR_HOSTNAME="glsops.sitedesk.online"   # the operator's chosen FQDN
PORT="${MAXY_UI_PORT}"                          # the brand UI port — same service as admin/public
```

### 1. Ingress + DNS

Run the Multi-ingress flow above with `HOSTNAME="${OPERATOR_HOSTNAME}"` and `PORT="${MAXY_UI_PORT}"`: the awk append (step 1), validate (step 2), create the DNS row (step 3), reload the connector (step 4). After the edit the ingress block carries the operator row before the catch-all (hostnames are illustrative — yours are whatever the operator registered):

```yaml
ingress:
  - hostname: glsmith.sitedesk.online   # admin (primary)
    service: http://localhost:<MAXY_UI_PORT>
  - hostname: gls.sitedesk.online       # public (also in alias-domains.json)
    service: http://localhost:<MAXY_UI_PORT>
  - hostname: glsops.sitedesk.online    # operator (also in operator-domains.json)
    service: http://localhost:<MAXY_UI_PORT>
  - service: http_status:404
```

### 2. Classify the host as operator

The ingress only gets the request to the brand UI; without this write the UI serves the **full admin shell** on the operator host. Append the hostname to `operator-domains.json` at the **brand root** (not `${CFG_DIR}` — the platform UI watches the brand root and hot-reloads within ~2 s, no restart):

```
F="${HOME}/.${BRAND}/operator-domains.json"
[ -f "$F" ] || echo '[]' > "$F"
jq --arg H "${OPERATOR_HOSTNAME}" 'if index($H) then . else . + [$H] end' "$F" > /tmp/op.json && mv /tmp/op.json "$F"
cat "$F"
```

The UI logs `[operator-domains] reloaded <n> operator domain(s): …` within ~2 s of the write. A configured brand whose boot/reload log shows `0 operator domain(s)` while an operator host is expected means the file was written to the wrong path (e.g. under `cloudflared/`).

**Verification.** `curl -I https://${OPERATOR_HOSTNAME}` returns the admin login (not a 404, not the public agent page). After login the dashboard shows the sessions list and WhatsApp reader, the header menu carries only Dashboard / Chat / Data, and `https://${OPERATOR_HOSTNAME}/graph` returns 404. The standing serve signal is `[host-class] host=${OPERATOR_HOSTNAME} class=operator served=operator.html`; `class=admin served=index.html` for a configured operator host is the full-shell leak signature (the operator-domains.json write was missed or written to the wrong path).

> Installer auto-provisioning of this host is out of scope here (documented manual/scripted add only); wiring it into the installer is tracked separately.

---

## Full reset

If everything is in a bad state and you want to start over. Assumes Step 0 has been run so `${BRAND}` and `${CFG_DIR}` are set.

Find the tunnel name:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list
```

Delete the tunnel (replace `<name>`):

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel delete <name>
```

Remove the entire brand-scoped cloudflared directory:

```
rm -rf "${CFG_DIR}"
```

Order matters: delete the tunnel *before* removing cert.pem (delete needs the cert). Then start again from Step 0.

If the service was installed (Step 7), also uninstall it:

```
sudo cloudflared service uninstall
```

---

## Troubleshooting — admin hostname serves the public agent

If `admin.<yourdomain>` is rendering the public-agent UI, the tunnel is fine but the platform UI is treating the admin hostname as public. The platform UI classifies a host as public when either:

- the hostname starts with `public.`, or
- the hostname appears in `${HOME}/.${BRAND}/alias-domains.json` (note: brand root, **not** `${CFG_DIR}` — `alias-domains.json` lives one level above `cloudflared/`, because that's where the platform UI watches).

Earlier sessions populated this file via the now-deleted `tunnel-add-hostname` MCP tool, which wrote every routed hostname — including `admin.*` — into the alias set. The pollution persists across installs.

**Diagnose:**

```
cat "${HOME}/.${BRAND}/alias-domains.json"
```

If `admin.<yourdomain>` appears in the array, remove it:

```
jq --arg H "admin.<yourdomain>" 'map(select(. != $H))' "${HOME}/.${BRAND}/alias-domains.json" > /tmp/alias.json && mv /tmp/alias.json "${HOME}/.${BRAND}/alias-domains.json"
```

Replace `<yourdomain>` with your actual domain. The platform UI watches the file — no restart required; the next request to the admin hostname routes to the admin surface.

Leave legitimate public aliases (e.g. `maxy.chat`, or `<yourdomain>` root acting as public) in the file.
