---
name: prototype-host
description: Use when the operator asks to "host the landing", "host the prototype", "deploy this", "give me a public URL", or any variant that means "make the landing page or wedge prototype reachable on a real URL the founder can send to a customer". Scaffolds a source tree, allocates a local port, edits the brand's cloudflared ingress, creates a DNS row, runs the dev server (or static file-server) under a systemd-user unit, and verifies HTTP 200 from outside the box. Idempotent: re-run for the same surface restarts cleanly without duplicating ports, ingress rows, or units.
---

# prototype-host

## Outcome

A founder asks "host the landing" or "host the prototype" and ends the turn with a working public URL — `https://<surface>.<brand-hostname>` — that returns HTTP 200, backed by a dev server (or static file server) supervised by systemd so it survives reboot. No CI, no Vercel, no Pages — the install device itself serves the prototype through the brand's existing cloudflared tunnel.

The skill is a composition of primitives that already exist on every Maxy Code install: cloudflared multi-hostname ingress (see [`cloudflared/references/manual-setup.md`](../../../../platform/plugins/cloudflare/references/manual-setup.md)), `systemctl --user` unit files for per-process supervision, and the agent's Bash + Read + Write + Edit tools for source scaffolding. The skill's job is to compose them deterministically so the founder never has to.

## Inputs

The agent gathers these before invoking any of the six phases. Defaults below; use `AskUserQuestion` only when the operator's intent is ambiguous about surface name or framework.

| Input | Default | Notes |
|---|---|---|
| `surface` | `landing` for "host the landing"; `prototype` for "host the prototype"; otherwise ask | The DNS label; becomes `<surface>.<brand-hostname>`. Lowercase, kebab-case, must be a valid DNS label (`^[a-z0-9-]+$`, max 63 chars). |
| `framework` | `static` for `landing`; `vite` for `prototype` | One of `static`, `vite`, `next`, `sveltekit`, `astro`. See [`references/scaffold-frameworks.md`](references/scaffold-frameworks.md). |
| `surface-dir` | `${PROJECT_ROOT}/.docs/data-room/prototype/<surface>/` | Source tree root. |

Two environment values the skill derives, never asks for:

- `BRAND` — from `jq -r '.hostname' <PROJECT_ROOT>/platform/config/brand.json` (same idiom every other skill uses).
- `BRAND_HOSTNAME` — the cloudflared root domain for this install: `jq -r '.cloudflareRootDomain // .hostname' <PROJECT_ROOT>/platform/config/brand.json`. If the field is empty, abort with literal stderr `[prototype-host] FATAL brand-hostname-unresolved — set cloudflareRootDomain in brand.json`.

## Hard prerequisite — brand-pack must have run

Before phase 1, check `[ -d "${PROJECT_ROOT}/.docs/data-room/06-product-ip/brand" ]` and `[ -n "$(ls -A "${PROJECT_ROOT}/.docs/data-room/06-product-ip/brand" 2>/dev/null)" ]`. If either fails, abort:

```
[prototype-host] FATAL brand-pack-missing — run brand-pack first; hosted surfaces consume 06-product-ip/brand/{color-palette,typography-system,tone-of-voice}.md
```

This is doctrinal — hosting a brandless landing ships a surface that either confounds conversion data or forces a re-render after brand-pack. The check is exit-code, not LLM judgement.

## Phases

Every phase emits exactly one structured stdout line on success:

```
[prototype-host] phase=<n> surface=<surface> framework=<framework> port=<port> ingress=<state> dns=<state> service=<unit> status=<systemd-state> http=<code>
```

Any phase that cannot produce its key/value pair fails by emitting literal stderr (no narration, no retry, no menu-phrasings — [[feedback_no_stdout_parsing_for_control_flow]]) and exits non-zero. The agent surfaces the stderr verbatim to the operator.

### Phase 1 — Surface intake (idempotency check)

If `<surface-dir>/.port` exists, this is a re-run. Read the port from `.port`, skip directly to phase 5 (systemd restart) then phase 6 (verify). Log:

```
[prototype-host] phase=1 surface=<surface> idempotent=true port=<port>
```

Otherwise proceed to phase 2.

### Phase 2 — Scaffold source

Run the framework's canonical scaffolder into `<surface-dir>/`. The exact commands per framework live in [`references/scaffold-frameworks.md`](references/scaffold-frameworks.md). Static `landing` is a special case: write `<surface-dir>/index.html` directly from `01-narrative/LANDING.md` using the brand tokens.

Brand application (every framework):

1. Read `06-product-ip/brand/color-palette.md` — extract the CSS custom properties (the file ships a `:root { --color-*: ... }` block).
2. Read `06-product-ip/brand/typography-system.md` — extract the font-family + scale tokens.
3. For `static`: inline both into the `<head>` of `<surface-dir>/index.html`.
4. For framework scaffolds: write the tokens into the framework's global stylesheet entry point (`src/index.css` for Vite/Astro, `app/globals.css` for Next, `src/app.css` for SvelteKit).
5. For the landing's hero + CTA copy: read `06-product-ip/brand/tone-of-voice.md` and apply it when rewriting `01-narrative/LANDING.md` into HTML.

### Phase 3 — Port allocation

First unbound port in 4100–4199:

```bash
for p in $(seq 4100 4199); do
  ss -tln 2>/dev/null | awk '{print $4}' | grep -qE ":${p}$" || { echo "$p"; break; }
done
```

Write to `<surface-dir>/.port`. If the range is exhausted, abort:

```
[prototype-host] FATAL port-range-exhausted range=4100-4199 — free a port or extend the range
```

The range is a skill constant (not a `brand.json` field) because it sits below every documented admin/chat/VNC band and above the OS ephemeral range default. Extending the range is a follow-up task, not a per-install knob.

### Phase 4 — Cloudflared ingress edit

Per [`references/cloudflared-ingress-edit.md`](references/cloudflared-ingress-edit.md):

1. Append a hostname rule for `<surface>.<BRAND_HOSTNAME>` → `http://localhost:<port>` immediately before the final `http_status:404` catch-all in `${CFG_DIR}/config.yml`. The reference doc carries the exact `awk` insert pattern (sed cannot safely insert before the last line of a multi-line YAML rule).
2. Validate: `cloudflared --origincert "${CFG_DIR}/cert.pem" --config "${CFG_DIR}/config.yml" tunnel ingress validate`. Non-zero exit aborts with the literal cloudflared error.
3. Create DNS row: `cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" <surface>.<BRAND_HOSTNAME>`. `TUNNEL_ID` comes from `${CFG_DIR}/tunnel.state` (the Step 5b file every install writes).
4. Reload cloudflared: `systemctl --user restart ${BRAND}-cloudflared.service`.

Idempotency: before appending, grep `${CFG_DIR}/config.yml` for `<surface>.<BRAND_HOSTNAME>`. If present, skip the append + DNS route + reload — the row already exists; this is a re-run after a failed phase 5 or 6.

### Phase 5 — Systemd user unit

Per [`references/systemd-user-service.md`](references/systemd-user-service.md), write `${HOME}/.config/systemd/user/${BRAND}-prototype-<surface>.service`. Two modes:

- **Dev-server mode** (frameworks with hot-reload — Vite, Next dev, SvelteKit dev, Astro dev): `ExecStart=` runs the framework's dev command bound to `<port>` via `PORT=` or `--port`.
- **Static file-server mode** (the `static` framework): run `npm run build` first (no-op for raw HTML), then `ExecStart=cloudflared file-server --port <port> <surface-dir>` (or `<surface-dir>/dist` for built output). No Node process for raw static landings.

Then:

```bash
systemctl --user daemon-reload
systemctl --user enable --now "${BRAND}-prototype-<surface>.service"
```

Check `systemctl --user is-active "${BRAND}-prototype-<surface>.service"` returns `active`. If not, dump the last 50 lines of `journalctl --user -u ${BRAND}-prototype-<surface>.service -n 50 --no-pager` to stderr and abort.

### Phase 6 — External verification

```bash
curl -I -m 15 "https://<surface>.<BRAND_HOSTNAME>"
```

The skill emits the full HTTP response line (e.g. `HTTP/2 200`) to chat. Anything other than `200` aborts with the full curl output as literal stderr — same setup-done contract Task 288 established for cloudflare setup. This is end-to-end proof because the chat itself runs through the same tunnel: if the cloudflared connector were dead, the agent would never have answered the operator.

Final success line:

```
[prototype-host] DONE surface=<surface> url=https://<surface>.<BRAND_HOSTNAME> port=<port> service=${BRAND}-prototype-<surface>.service http=200
```

## Idempotency contract

Re-running the skill with the same `<surface>` is a first-class flow, not an error:

| State on re-run | Behaviour |
|---|---|
| `<surface-dir>/.port` exists, ingress row exists, service active | Restart the systemd unit; re-verify; emit `idempotent=true` |
| `.port` exists, ingress row missing | Re-run phase 4 (append, validate, route, reload); restart unit; verify |
| `.port` exists, service failed | Restart unit; if still failing, dump journal and abort |
| Nothing exists | Fresh six-phase run |

The skill never allocates a second port for the same surface, never appends a duplicate ingress row, never creates a second systemd unit with a different name.

## Failure surfaces

Likely failure points and the literal stderr the agent should expect:

- **Port range exhausted** — `[prototype-host] FATAL port-range-exhausted range=4100-4199`
- **Ingress validate fails** — cloudflared's own multi-line YAML error, verbatim
- **DNS route fails** — cloudflared's stderr (typically a 1xxx error code from the API)
- **Systemd unit fails to start** — last 50 lines of journalctl, verbatim
- **Curl returns non-200** — full `curl -I` output, verbatim

Diagnostic commands the agent should suggest on failure:

```bash
cat ${CFG_DIR}/config.yml
cloudflared --origincert "${CFG_DIR}/cert.pem" --config ${CFG_DIR}/config.yml tunnel ingress validate
systemctl --user status ${BRAND}-prototype-<surface>.service
journalctl --user -u ${BRAND}-prototype-<surface>.service -n 200 --no-pager
ss -tln | grep :<port>
dig <surface>.<BRAND_HOSTNAME>
```

## Out of scope

- Production hosting (Cloudflare Pages, Vercel, Netlify, Workers). The dev server on the install device covers the founder workflow; production deploy is a separate, later task.
- TLS / cert handling beyond cloudflared's own.
- Per-prototype auth gating — the public landing is public; access control is a separate skill.
- `brand.json` schema changes. Port range is a skill constant.
- `paths.ts` reads.
- Multi-tunnel — one tunnel per install with multiple ingress rules is the existing pattern.
- CI/CD or git-push deploy.
- Pre-built framework templates beyond the framework's official `create-*` scaffolders.
- Telemetry on the public URL — the founder wires their own (Plausible, GA, server logs).
- A `prototype-host-remove` companion skill. For now: `systemctl --user disable --now ${BRAND}-prototype-<surface>.service` + manually strip the ingress row + delete the DNS record. Removal is a follow-up task if it becomes friction.

## Multi-prototype is supported by construction

Port allocation scans first-free; ingress rules are additive; systemd unit names are namespaced by `<surface>`. Running `landing` + `prototype` + `prototype-v2` simultaneously on the same install requires no special handling — invoke the skill three times with three different surface names.
