For isolated staging builds, set PLATFORM_ENV=staging, an explicit staging BACKEND_ORIGIN,
and STAGING_SITE_ORIGINS as JSON mapping all four app IDs to distinct HTTPS origins.
For example: {"ntwrk":"https://hub-stage.example.test","slop":"https://slop-stage.example.test","peon":"https://peon-stage.example.test","friends":"https://friends-stage.example.test"}.
The full map keeps hub navigation and published skills from linking back to production.
Leave MCP_URL unset or at its default template; each staging MCP URL is its configured origin plus /mcp.
Do not set DEPLOY_TARGET=production with staging origins. Canonical app names, source paths,
Pages project identities and official plugin snapshots stay unchanged. A staging build does not deploy anything.
Four public sites share one backend, one admin panel and one database (PRD 40, AGENTS.md "Platform decisions"). Each site is its own brand (founder direction, 2026-10-10): no app site names The Network or another app, and ntwrk.party names no app. ntwrk.party asks a person what they want (friends, dating, work). Local dev for the sites and the backend together: docs/runbook-platform.md.
| App | Site | Dev port | Join |
|---|---|---|---|
ntwrk |
ntwrk.party | 5101 | Invite-only. /join explains the invite text and returns members to their agent. |
slop |
slop.date | 5102 | Open. 13+ join; matching and photos 18+ only. The safety notice is information, not a step. |
peon |
peon.biz | 5103 | Open waitlist. 13+ join; matching 18+ only. |
friends |
friends.help | 5104 | Open. 13+ join; matching and group plans 18+ only. |
Ages on the pages equal the platform registry (packages/platform/src/apps.ts minJoinAge,
minMatchAge); a test checks it. Members aged 13 to 17 are never matched or introduced.
<site>/public/*.html: one file per page (landing, join, privacy, terms, guidelines, support, 404; slop also hassafety). Each site has its ownstyles.css. The SMS terms are a section of the terms page (terms.html#sms). The landing page links to agents and Messages, with privacy and terms in its footer. There is no standalone account portal or Sign in link.<site>/static/: files copied intodist/as they are (robots.txt; add_redirectsor.well-known/*files here).skills/<name>/SKILL.md: the Agent Skills file per site (ntwrk-party,slop-date,peon-biz,friends-help). The build serves it at/SKILL.mdand/.well-known/agent-skills/<name>/SKILL.mdwith/.well-known/agent-skills/index.json(sha256 digests). ntwrk.party's index lists all four.skills.config.ts: the one backend origin and the MCP URL template (BACKEND_ORIGIN,MCP_URL; defaultshttps://api.ntwrk.partyandhttps://{domain}/mcp). Each site is its own MCP endpoint (the router forwards/mcpwith the site's signed host); the backend origin's/mcpnames no app. The build fills{{BACKEND_ORIGIN}},{{MCP_URL}},{{TEXT_LINE}},{{MESSAGES_HREF}}and{{TURNSTILE_SITE_KEY}}in pages and skills, and fails on a leftover{{.shared/: the only JavaScript.join.ts,auth.ts,turnstile.tsanddemo.tsfind elements bydata-*attributes, so each site writes its own markup and copy.sites.ts: the site list and the build (Bun's HTML bundler, thenstatic/,_headersand the skill files intodist/).PRODUCT.md: who the sites are for and the design principles.
The landing pages are agent-first (founder decision 10) and hold four things: the name, one line
("Let your agent find you the perfect date."), the prompt field, and a row of agent logos with no names.
A click copies the prompt Read https://<domain>/SKILL.md and follow it. and
opens the agent with the prompt filled in. The prompt sits in a field with a Copy button, and the logos sit under it. agents.ts is the list (ChatGPT, Claude, Grok, Muse,
Gemini, Perplexity, Cursor, OpenClaw, Hermes) and writes the row into {{AGENTS}} at build time; an
agent with no prompt link is a button that copies and says where to paste. The last tile opens
Messages to the shared line (TEXT_LINE, default in skills.config.ts) with the app's keyword.
shared/agent.ts tells a phone from a desktop: Cursor shows on a desktop only, and the text tile
shows on a phone or a Mac only. The page is black and white with one colour per site
(--l-hi in each styles.css); shared/landing.css has the layout and the font (Host Grotesk, OFL,
shared/fonts/). Logos are in shared/logos/ (from @lobehub/icons-static-svg, MIT; the Messages
logo from simple-icons). Link behaviour: docs/research/2026-10-10-agent-deep-links.md.
The pages work without JavaScript (links open the agent; the prompt stays selectable). Join needs it and say so in <noscript>.
bun run sites:dev # all four sites; backend paths proxied to PLATFORM_API_ORIGIN (default http://127.0.0.1:8790)
bun run sites:dev slop # one site
bun run sites/sites.ts # build every site into sites/<domain>/dist
DEPLOY_TARGET=production TURNSTILE_SITE_KEY=... bun run sites/sites.ts # production build: refuses draft legal text
bunx tsc --noEmit -p sites/tsconfig.json # typecheck (the root tsconfig does not include sites/)The dev proxy forwards the same paths as the production router (/api/*, /mcp, /oauth/*,
/.well-known/oauth-*). It sends X-Forwarded-Host: <site domain> so the dev backend picks the app,
and, with PLATFORM_PROXY_SECRET set, the router's signed headers. It removes Domain= from
cookies so they bind to 127.0.0.1. When the API is down, those paths answer
502 {ok:false, error:"api_unreachable"}, and the pages show an inline error.
Same origin only: GET /api/app, GET /api/me, POST /api/auth/otp/start {phone, turnstileToken?},
POST /api/auth/otp/verify, POST /api/auth/logout, POST /api/join, GET /api/me/export,
POST /api/me/stop, POST /api/me/delete {scope}, GET /api/demo. Every POST sends
content-type: application/json and a JSON body ({} when it has no fields); the API refuses any
other POST with 415. sites/test/contract.test.ts runs the client against createPublicApi.
POST /api/join sends consent.wording as the exact text of the [data-consent-wording]
element. A test checks that it equals the platform's canonical text for the app.
Members continue in their agent or text conversation. STOP and leave remain text commands. Export and deletion requests use help@ntwrk.party. The authenticated backend APIs remain available for service integrations; the sites do not expose a member dashboard.
peon.biz sends the "Looking for work" or "Hiring for a team" choice as the first item of
interests (looking-for-work or hiring), because the contract has no field for it.
GET /api/demo is optional (shared/demo.ts): no landing page shows it since the agent-first pages.
The phone step on join mounts Cloudflare Turnstile when the build has
TURNSTILE_SITE_KEY (a public site key). The client sends the token as turnstileToken. Without a
key (local dev) there is no widget and no token. The CSP allows https://challenges.cloudflare.com
for scripts and frames only.
Each site is a Cloudflare Pages project (founder decision 8): ntwrk-party, slop-date, peon-biz,
friends-help (<site>/wrangler.toml, pages_build_output_dir = "./dist"). The build writes the
router ../deploy/router.ts into dist/_worker.js (Pages advanced mode) and dist/_routes.json, so
only /api, /api/*, /mcp, /mcp/*, /oauth/* and /.well-known/oauth-* run it. The router
forwards those paths to BACKEND_ORIGIN with signed x-network-proxy-* headers (secret
PLATFORM_PROXY_SECRET, set once per project) and every other path is a static file. CI deploys
from main behind the production GitHub environment (.github/workflows/deploy-sites.yml,
wrangler pages deploy <dist> --project-name <name> --branch main); there are no PR previews. By
hand, deploys need founder approval and go through scripts/wrangler.sh with NTWRK_ALLOW_DEPLOY=1.
All four projects are in the ntwrk.party Cloudflare account (its id is CLOUDFLARE_ACCOUNT_ID, from
the environment or the CI variable). Each answers on <project>.pages.dev, which the backend accepts as that app's host; slop.date
and friends.help DNS (another account) point there. Details: docs/deploy.md.
Network and Friends may claim an approved assistant draft after normal phone sign-in through a same-origin POST /oauth/signup-draft (the existing signed OAuth router). Only first name and home city fill existing editable controls. The draft is optional and recoverable until completion or expiry; missing, expired or revoked drafts leave the ordinary form usable. Human edits win over delayed responses. An opaque marker returned by the normal authenticated account API binds form state to the account; authentication transitions clear prior values and reject old in-flight responses. No browser storage caches these facts across accounts. Updated forms also send their reviewed account marker with Join; the server rejects a changed account before any age, person, membership or consent write. The page clears the old fields and loads the current account for review without another phone code. Older clients may omit the marker; it never authorizes a request. Age and the app text-consent checkbox remain for the person. No personal fields or credentials are carried in a URL. Slop and Peon do not use this path.