---
name: cloudflare
description: Cloudflare operations — tunnel setup/reset, DNS, Pages hosting, D1 data capture, and dashboard guidance. Zero agent-facing MCP tools; every operation is the agent invoking `cloudflared` or `wrangler` directly via Bash, calling the Cloudflare API with a reused per-scope narrow token, or quoting a dashboard click-path the operator performs themselves.
account-owned-files: [{"file": "data-portal.json", "description": "The account's data-portal folder index, written by bin/portal-index-push.mjs on every push."}]
tools: []
mcp-manifest: skip
---

# Cloudflare Tunnel

Each installation has its own Cloudflare account. Two auth paths serve two operation classes. **Tunnel** auth is OAuth: the operator signs in via `cloudflared tunnel login` (issued by the agent through Bash); `cloudflared` writes `cert.pem` to the brand-scoped config directory. **API** auth (DNS, zones, Pages, D1, Access, token management) is the master token: the operator provisions one fully-scoped master token in the dashboard (an advanced, operator-guided step the agent never automates), stored at the account-scoped secrets file; the agent reads it only to provision **one stable narrow token per scope** — minting it once if absent, persisting it to the secrets file, reusing it thereafter — and periodically reconciles strays. Account state is brand-isolated and the master token never leaves the secrets file — see `references/api.md`.

## When to activate

- Operator mentions Cloudflare, custom domain, public access, or internet access
- Operator wants to expose the assistant publicly with a custom URL
- Operator asks about setting up remote access with a custom domain
- Operator says "set up Cloudflare", "reset the tunnel", "my tunnel is broken", or similar

## What it unlocks

- Public internet access via a custom domain
- Reliable mobile admin access (replacing or supplementing Tailscale)
- Custom-branded public chat URLs
- Webhook endpoints on a stable domain

## Operator-facing surface

The plugin registers no agent-facing MCP tools. Every Cloudflare operation is the agent invoking `cloudflared` or `wrangler` directly via Bash, or calling the Cloudflare API with a reused per-scope narrow token, with stdout/stderr streamed verbatim into the PTY chat (secrets redacted). The OAuth URL printed by `cloudflared tunnel login` is linkified by the native PTY; the operator clicks it in their own browser. The one shell helper is [bin/cf-token.sh](bin/cf-token.sh), a deterministic per-scope token resolver that faithfully relays the Cloudflare token API (it invents no flags and drives no dashboard); there is no wrapper around `cloudflared` or `wrangler`, no orchestrator state machine, no MCP-tool surface.

### Skills

| Skill | Purpose |
|---|---|
| [cloudflare/SKILL.md](skills/cloudflare/SKILL.md) | The entry point for every Cloudflare operation. Routes each operation class to its reference, names the outcome contracts (tunnel external HTTP 200; hosting deployed URL; D1 round-trip), and states the tool-discipline + secret-redaction rules that bind the agent. |
| [site-deploy/SKILL.md](skills/site-deploy/SKILL.md) | Assemble one canonical content tree into a live Pages deployment on its custom domain that survives the install device being offline. Names the single-source-tree convention, the reused per-scope token, the form-to-D1 trigger, the R2 binding trigger, Open Graph correctness, and the cache-busted live-`200` done-gate; defers the command blocks to `hosting-sites.md`, `d1-data-capture.md` and `r2-object-storage.md`. Admin-run; a content specialist hands the deploy off to it. |
| [calendar-site/SKILL.md](skills/calendar-site/SKILL.md) | Assemble and deploy one account's public booking page — a static page plus edge Functions over a D1 `bookings` table — on its own custom domain, so a submission survives the install device being offline. Admin-run; defers the deploy and the live done-gate to `site-deploy`. |
| [data-portal/SKILL.md](skills/data-portal/SKILL.md) | Stand up one account's gated file-drop portal on its own custom domain: each enrolled person signs in with their own passcode and can upload, list, download and delete only their own files, into one R2 bucket under a per-person prefix with a D1 manifest row per upload. The same portal also shows that sub-account's deliverable folders, published from the device as metadata only and opened through a short-lived signed link. The bucket is created by the owning account's own session — the broker registers ownership to the creator and never reassigns it — while the admin assembles and deploys. Admin-run; defers the deploy and the live done-gate to `site-deploy`. |

### References

| Reference | Topics |
|---|---|
| [manual-setup.md](references/manual-setup.md) | Tunnel runbook — Steps 0–7 with isolated `cloudflared` command blocks. The agent reads the relevant step before issuing each command. |
| [api.md](references/api.md) | Cloudflare API library — canonical docs URL + curated endpoint map (DNS, zones, tunnels, token list/create/verify/revoke), the advanced master-token creation walkthrough, the account-scoped secrets-file storage convention, the agent's reuse-a-stable-per-scope-token discipline, and the standing reconcile pass that revokes strays. |
| [d1-data-capture.md](references/d1-data-capture.md) | Form → Pages Function → D1 store → read/sweep. The Pages-Edit **and** D1-Edit token-scope requirement, `wrangler d1 create`/`execute --remote`, the `swept` column, plus the establishment discipline: `<accountId>-<purpose>` naming, one store per purpose, and the canonical `acceptances` schema. |
| [r2-object-storage.md](references/r2-object-storage.md) | R2 buckets under the same establishment discipline as D1: `<accountId>-<purpose>` naming via `cf-store-name.sh`, one bucket per purpose, `wrangler r2 bucket create`, and the store-provision breadcrumb. |
| [hosting-sites.md](references/hosting-sites.md) | Deploy a static or Next.js site to Cloudflare Pages via `wrangler pages deploy`. House/single-tenant path: on a multi-tenant install a client account is denied the `pages` scope (`reason=not-house`) and publishes with `storage-broker:storage-pages-deploy` instead. |
| [web-analytics.md](references/web-analytics.md) | Why a registered site reports 0 visitors — the RUM API is blocked (no permission group), registered ≠ collecting, edge auto-injection is impossible for a Pages-only custom domain, and the beacon must be injected into the site HTML, rebuilt, and redeployed. Outcome: beacon present in live HTML. |
| [dashboard-guide.md](references/dashboard-guide.md) | Click-paths for operations the operator prefers to do by hand — sign in, switch accounts, add a site, edit an apex CNAME, verify nameservers, delete a tunnel, manage CNAME records, author an Access policy. |
| [reset-guide.md](references/reset-guide.md) | Decision tree for reset vs. patch, the exact `pkill` incantation for token-mode connectors, and the dashboard cleanup paths for stray records and rogue entries. |

The agent loads these references on demand via `plugin-read` as the conversation requires. They are not auto-injected into the system prompt.

### Helper

| Helper | Purpose |
|---|---|
| [bin/cf-token.sh](bin/cf-token.sh) | Deterministic per-scope token resolver. `bash bin/cf-token.sh <scope> <secrets-file>` (scope: `pages`, `d1`, `dns`, `access`) reads the master's `cfat_`/`cfut_` prefix, routes verify/`permission_groups`/mint to the matching endpoint family, reuses a persisted token or mints once if absent (no expiry), verifies through the transient post-mint window, persists to the secrets file, and prints only the secrets key name on stdout (never a token value). It removes the endpoint-routing decision from the agent, which is what the false-negative `Invalid API Token` / `9109` failures turned on. curl + jq only; exit-code gated. |
| [bin/cf-store-name.sh](bin/cf-store-name.sh) | Deterministic storage-name resolver. `bash bin/cf-store-name.sh <accountId> <purpose>` prints the canonical `<accountId>-<purpose>` name (nothing else) for a D1 database or R2 bucket, so a store's name identifies its owner and purpose. Lowercases and validates each segment to `[a-z0-9-]`; fails closed on empty or invalid input. No network, no secrets. |
| [bin/cf-schema-check.sh](bin/cf-schema-check.sh) | Canonical acceptance-schema gate. Feed it the stored DDL (`wrangler d1 execute --json --command "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL" \| jq -r '.[0].results[].sql'`); exit 0 iff the store carries the one canonical `acceptances` shape (`doc_ref`, `doc_type`, `party`, `signer_name`, `accepted_at`, `swept`; no stray `dual_acceptances` table; `signer_name` not a bare `name`), exit 1 with the first drift named on stderr. Exit-code gated. |

### Success contract

The setup-done claim only fires when `curl -I https://<hostname>` issued from outside the local network returns `HTTP/2 200` (or `HTTP/1.1 200 OK`) and the response is surfaced verbatim in chat. No state file, no service-active claim, no `cloudflared` exit code substitutes for the live HTTP response. When the curl returns anything else, the agent diagnoses with `cloudflared tunnel info <tunnelId>` and `systemctl --user status cloudflared-${BRAND}.service`, cites the relevant step in `references/reset-guide.md`, and stops.

## Identity model

- **Product identity** (Real Agent vs Real Agent) — known from `brand.json` (`productName`, `configDir`).
- **Cloudflare account identity** — `cert.pem` from OAuth. One account per brand per device.
- **Account binding drift** — `~/{configDir}/cloudflared/account-binding.json` is a historical drift marker. Reset by `rm -rf ~/.${BRAND}/cloudflared/` per `references/reset-guide.md` when switching accounts.

## Data-portal folder index

The portal shows a sub-account's deliverable folders alongside what the client uploaded. Which folders those are is never a list in the code: it is derived per account from that account's own `SCHEMA.md` — the buckets in its `<!-- ontology-buckets -->` region plus `output`, intersected with its `allowed-top-level` block. A brand on a different vertical exposes different folders with no code change. `bin/schema-exposed-dirs.mjs` is the only place that decision is made. An operator can add non-ontology folders (a workflow folder such as `inbound-invoices`) by listing them in `exposeFolders` in the account's `data-portal.json`; each must match `[A-Za-z0-9_-]+` and is never a system folder (`agents`, `secrets`, `specialists`, `state`, `memory`, `cache`, `logs`, `tmp`) or dot-prefixed. Those extras union on top of the schema-derived set and, unlike ontology buckets, are not intersected with `allowed-top-level`; empty or absent behaves as before.

`bin/portal-index-push.mjs` walks those folders and full-replaces the account's rows in the portal's D1 `directory` table each cycle. Metadata only — names, sizes, times — so listing survives the device being offline, and a deleted or renamed file converges on the next cycle without a tombstone. Every walked folder gets a row of its own marked `isDir`, not only the files inside it, so an exposed folder holding nothing at any depth still renders and opens; a folder whose `readdir` fails publishes nothing, so the tree never offers one the device does not have. `isDir` is a later column and `IF NOT EXISTS` adds nothing to an existing table, so a portal that predates it needs one hand-run `ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0`. The scheduling heartbeat runs it hourly for every account carrying a `data-portal.json`; it is also runnable standalone, which is how it stays operable while the heartbeat spawn path is unproven on a device.

Opening a file reaches the install: the portal probes it, then mints an `/api/portal/fetch` link signed with the account's `PORTAL_FETCH_SECRET` and valid for five minutes. The install verifies the signature **and independently re-derives the exposed folder set**, so a correctly signed link for a non-exposed path is still refused. That second check is the security boundary, not the signature.

Exposure is narrowed twice. The install re-derives the account's exposed set from `SCHEMA.md` on every request and refuses anything outside it, which is the account-isolation boundary. On top of that, a person's `people.folders` grant (written at enrolment, empty means the full set) narrows them to named top-level folders inside that set. The grant lives only in the portal's D1, so a portal compromise widens a person back to the account's exposed set and never beyond it. `op=list` carries `granted=<n|full>` and an out-of-grant download logs `op=fetch-through result=not-granted`, distinct from `result=absent`. A download naming a folder logs `result=is-directory` and mints no link, distinct from both.

What to watch, on the device:

```
[portal-index] op=resolve account=<id> schemaPresent=<bool> allowed=<n> domainBuckets=<dirs|none> extraFolders=<n> exposed=<dirs|none>
[portal-index] op=replace account=<id> deleted=all inserted=<n>
[portal-index] op=verify  account=<id> rowsAfter=<n> expected=<n>
[portal-index] op=collision account=<id> dir=<dir> claimedBy=plugin-owned
[portal-index] op=exposed-changed account=<id> added=<dirs|none> removed=<dirs|none>
[portal-index] op=audit accounts=<n> stale=<n> never-ran=<n>
```

`exposed=none` where deliverables exist means the schema was missing or carried no ontology region — not that the walk found nothing. `rowsAfter` disagreeing with `expected` is a half-applied replace. An `op=collision` line explains a folder that has silently vanished from the client's view, which nothing else would report. `op=exposed-changed` fires only on a delta, so it is rare and always worth reading; a `removed=` naming a populated bucket is the client-visible regression. `extraFolders=<n>` counts the folders the operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in `exposed=` means the guard dropped every one.

`op=audit` is the standing check, and it is the only one that speaks when the push does **not**. It runs in-process inside the heartbeat rather than as a spawn, and reads only local state, so it cannot be blinded by the same failure it is watching for. It fires hourly even when everything is healthy — an audit that goes quiet on success is indistinguishable from an audit that stopped running. `result=never-ran` on an account with a `data-portal.json` means no cycle has ever succeeded for it; `result=stale` with `ageMin` means cycles have stopped. Its input is written only after `op=verify`, so a stale entry means the push stopped succeeding rather than merely stopped logging.

## Data-portal upload pull

The index push publishes what the device holds; the pull is the other direction. An upload lands in R2 under the person's prefix with one `manifest` row at `ingested = 0`, and the `portal-uploads-pull` loop in the admin server carries it onto the device every minute. It does not live in this plugin: it sits beside `runDataPortalAudit` in `platform/ui/server/portal-uploads-pull.ts`, because that audit already resolves the house Cloudflare credential and each account's portal database and bucket, and a second enumeration or credential path is how the two would drift.

Per pending row the order is fetch, write, verify, then claim. The object is fetched by `objectKey` through the storage broker, written to a dot-prefixed `.uploads-tmp/` partial (invisible to the index walk, so a partial can never be published as a deliverable), renamed into `uploads/<ownerName>/<filename>`, and only then is `ingested` set to 1 — and only if the written size equals `manifest.size`. A row claimed before the bytes land is a lost file carrying a flag that says it arrived. Anything that fails leaves the row at 0 and it is retried next cycle. `ownerName` and `filename` must each be a single path segment, checked on the device rather than trusted from the portal's own gate, and `fileId` must be a UUID before it reaches a statement, because `d1Query` takes raw SQL and binds nothing.

What to watch, on the device:

```
[portal-pull] op=claim account=<id> fileId=<uuid> bytes=<n> result=written|fetch-failed|write-failed|verify-failed|claim-failed|bad-owner|bad-filename|bad-fileid
[portal-pull] op=done account=<id> pulled=<n> failed=<n>
[portal-pull] op=malformed account=<id> rows=<n>
[portal-pull] op=failed account=<id> err="<message>"
[data-portal-audit] account=<id> … oldestUningestedHrs=<n|na> pendingRows=<n|na>
```

`[data-portal-audit]` is the standing signal, printed every cycle including at `pendingRows=0`, so a quiet line reads as healthy rather than as a stopped pull. When a client says a file never arrived: a `pendingRows` that stays non-zero with a rising `oldestUningestedHrs` means the pull runs and cannot finish, so read `result=` on `op=claim`. `pendingRows=na` means the manifest read itself failed, a different fault. No `[data-portal-audit]` line at all means the audit loop is not running, a third. Both counts are computed from the manifest alone, so they survive R2 being unreachable — which is exactly when a stalled pull matters most. `result=claim-failed` is the one outcome that leaves a file on disk with its row unclaimed; the next cycle re-fetches and overwrites the same path, which is deliberate.

Log lines carry counts and ids only. An object key is `<ownerId>/<filename>`, a real person's filename, and the brand journal is not where those belong.

On the portal side `[data-portal] op=list` carries `account=` and `op=fetch-through` carries the outcome; on the install `[portal-fetch]` logs `op=request` then exactly one of `op=served`, `op=denied`, `op=miss`. A portal `fetch-through` with no matching install `op=request` means the request never arrived, rather than having been refused. `[portal-fetch] op=no-secret account=<id>` is an unconfigured portal, which the `op=denied reason=secret` line alone would not distinguish from a bad signature.

## Discipline

The agent's permitted surfaces are: direct `cloudflared` and `wrangler` invocations via Bash following the references; Cloudflare API calls authenticated by a reused per-scope narrow token, minted once if absent and persisted to the secrets file (the master token stays in that same file, never on a command line that echoes, never in chat); the reference files; and live verification (`curl -I` for tunnels, the deployed URL for hosting, a `SELECT` for D1). Out of bounds: Playwright or Chrome DevTools driving the dashboard, browser-automating master-token creation, WebSearch-for-CF-recipes, ad-hoc `cloudflared` flag invention not in the runbook, and writing or echoing any token. When a step fails, the agent reports the exact output (secrets redacted), cites the recovery step from `references/reset-guide.md`, and stops.
