Public links and web pages
A space's pages open in any browser, without the app: a link-in-bio, a portfolio, a business page, a menu, a guestbook. Nothing is on the web until the owner adds the Public links kit, and then only what a visitor of the space may see.
Addresses #
| Address | When it works |
|---|---|
<site>/w/<handle>/<path> | Always (<site> = WEB_URL, else the backend's CONVEX_SITE_URL) |
https://<handle>.<WEB_ZONE>/<path> | When WEB_ZONE is set and the web proxy runs (targets/web-proxy) |
https://<own domain>/<path> | Once its TXT record proves it, through the web proxy |
<site>/embed/<handle>/<path> | The same page for an iframe on another site |
<site>/web/oembed?url=<page address> | oEmbed JSON, so sites embed a page from its link |
The domain isn't decided yet, so every part of it is a backend setting: WEB_ZONE, WEB_URL, WEB_CNAME (what own domains point at; default domains.<WEB_ZONE>), APP_URL (for "Open in the app"; default zvessa://), WEB_RESERVED (more handles nobody may take), WEB_PROXY_SECRET, and CF_API_TOKEN + CF_ZONE_ID for own domains through Cloudflare for SaaS.
Settings: /web #
Everything is a setting in the blueprint, changed like anything else (HQ → Web, the in-app AI, or an outside AI over MCP with propose_change), approved by a person, undone from History.
"web": {
"handle": "ann",
"domains": ["shop.ann.dev"],
"home": "links",
"routes": [
{ "path": "", "page": "links" },
{ "path": "work", "page": "portfolio", "title": "Ann's work", "description": "Portraits and prints", "image": "https://…" }
],
"card": { "title": "Ann's studio", "description": "…", "image": "https://…" },
"data": ["guestbook"],
"index": false,
"embed": "any",
"appLink": true,
"nav": true
}
| Setting | What it does | Default |
|---|---|---|
handle | The free address. 3-31 lowercase letters, digits, single dashes. One space per handle; one let go stays with its space for 7 days | none |
domains | Up to 5 own addresses, each proven with a DNS record | none |
home | The page at the address itself | the first page visitors see |
routes | Which pages are on the web and at what path (up to 3 parts) | every page visitors see, at /<page id> |
card | The share card (Open Graph, Twitter). A route's own title/description/image wins | the space's name and about, the page's first image |
data | Collections whose records may show (records.list, records.count) | none: records stay in the app |
index | Let search engines list the pages (public spaces only); adds robots.txt and sitemap.xml | false (pages say noindex) |
embed | "any", "off" or a list of https origins allowed to frame the pages | "any" |
appLink | "Open in the app" on pages | true |
nav | A menu of the pages when there's more than one | true |
Words on web pages are settings in /words: webOpenApp, webMore, webMadeWith, webNotFound, webLang.
What's safe, and how #
- Visibility, server side. Only
publicandlinkspaces are on the web. Private and friends-only spaces, kids spaces (never), a page or block hidden from visitors (visibleTo, levels, badges), HQ, and a route to a hidden page all answer the same "Nothing here" (404), so nobody can tell what exists. Pages are drawn fromblueprintFor(bp, "visitor"), the same cut the app sends a visitor. - Data is opt-in. Records show only from collections in
/web/data, and email and phone fields never show on the web. - No code runs. Pages are plain HTML with no script:
Content-Security-Policy: default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; form-action 'none'. Every word is escaped; links are https or mailto only, outside links getrel="noopener nofollow ugc". Mini-apps, chats, forms and other live blocks stay in the app (the page offers "Open in the app"). - Clickjacking. Normal pages send
frame-ancestors 'none'; only/embed/…follows/web/embed. - Own domains are proven with a TXT record
_zvessa.<domain>=zvessa-verify=<token>before they serve. One never proven frees up after 3 days. Live domains are re-checked daily. - Light. Pages are a few KB, cached for 60 seconds, styled from the space's own style (colours, type, corners, light and dark).
Blocks #
| Block | What it is |
|---|---|
web.share | The space's (or one page's) web address with Copy and Share; hidden while not on the web |
hq.web | HQ's Web panel: address, handle, own domains with DNS steps and status, pages on the web, search engines, embeds and embed code |
A link-in-bio is a stack of buttons; a portfolio is headings, text, images and cards. Web pages draw stack, heading, text, image, button (#page-id links become web paths), list, card, stat, divider, records.list, records.count and web.share.
Tools (in-app AI and MCP) #
| Tool | Scope | What it does |
|---|---|---|
get_web | read | On the web or why not, the address, each page's link and embed link, embed code, each domain's status and DNS records |
check_handle | read | Whether a handle is free and allowed |
check_web_domain | read (manage) | Checks an own domain's DNS now |
preview_web_page | read | What a visitor gets at a path (words, or the whole HTML), even before it's live |
Setting the handle, domains, routes, cards and the rest is propose_change on /web.
App functions #
api.web.addresses.mine (HQ panel), .link({ page? }) (share block), .available({ handle }), .checkNow({ host }). HTTP routes are in convex/web/http.ts; the page drawing is convex/lib/web/render.ts; the settings and checks are convex/lib/web/model.ts.
Not yet #
Forms people fill in on the web without an account (needs spam protection), feeds and photo books drawn on the web, QR codes for an address, and per-page analytics.