Pages
Pages are persistent, self-hosted HTML mini apps stored in the workspace and rendered inside Vorno (sidebar → Pages). Use them for dashboards, reports, trackers, and small tools that should outlive the conversation — optionally auto-refreshed on a schedule and, where the capability is available, publishable as password-protected public links.
Availability (read this before offering to build a Page)
Section titled “Availability (read this before offering to build a Page)”Pages is off by default in every workspace. It is a persisted workspace capability, not a global app flag and not a renderer preference — the host re-reads it on every call.
- Turning it on: Settings → Workspace → Pages. The setting is stored per workspace, so enabling it in one workspace does not enable it in another, and a copied or restored workspace keeps whatever it was saved with.
- What the switch covers: the Pages navigator, the
*_page/write_page_datasession tools, scheduled refresh, and publication. If it is off,create_pageand friends fail withPAGES_DISABLED— that is the host refusing, not a bug to work around. Offer to enable it in Settings instead of retrying. - What stays available when it is off:
delete_page, unpublishing, and removing an approved action. Turning Pages off never strands a published copy or an approval. - Sharing is a second, separate gate. Publishing additionally requires the build’s sharing flag and a configured Vorno publication endpoint. Both are absent in a default install, so Share is unavailable unless it has been explicitly configured — see Sharing. Assume a Page is local-only unless the user tells you otherwise.
- Confirmed actions are desktop-only. Source actions, scripts, and session callbacks need a real click the host can vouch for, which only the Electron desktop app can supply. The web interface renders Pages and reads data, but declines anything that changes state.
Folder layout (managed — do not edit directly)
Section titled “Folder layout (managed — do not edit directly)”{workspace}/pages/{slug}/├── page.json # config/manifest (watcher trigger — always written LAST)├── index.html # the page content (sha256 digest tracked in page.json)└── data/ ├── store.sqlite # internal store (scripts only — never read this) └── snapshot.json # the ONLY data artifact pages/hosts readAlways use the session tools (create_page, update_page, write_page_data, delete_page) instead of file tools. They keep content digests, grants, the config watcher, and open renders consistent. list_pages / get_page are read-only and return absolute paths when you do need to Read something (e.g. data.snapshotPath).
Names that stay craft-* on purpose
Section titled “Names that stay craft-* on purpose”Vorno is a fork, and the Pages storage and bridge contracts are shared with upstream. These identifiers are compatibility contracts, not stale branding — a page, script, or tool that renames them stops working:
| Identifier | Where it appears |
|---|---|
craft-pages/v1 |
the protocol field of every bridge message |
pages/{slug}/ |
the on-disk page folder inside the workspace |
CRAFT_WORKSPACE_PATH, CRAFT_PAGE_SLUG, CRAFT_PAGE_DIR, CRAFT_PAGE_DATA_DIR |
the environment given to refresh and script-grant runs |
CRAFT_PAGES_SHARE_API_URL |
the publication-endpoint variable (its value must be a Vorno endpoint — see Sharing) |
@craft-agent/shared/pages/data-store |
the importable store helper |
Everything a person reads — window chrome, dialogs, the published shell — says Vorno. Everything a machine matches on keeps the name above.
Choosing a kind
Section titled “Choosing a kind”| Kind | Sandbox | Use for |
|---|---|---|
static |
no JS | fixed documents, formatted reports |
interactive |
JS + forms | calculators, explorers, tools |
live |
JS + receives snapshot updates while open | dashboards fed by refresh scripts or write_page_data |
Data model
Section titled “Data model”Each page has a small data store with two shapes:
- kv —
key → any JSON value(objects/arrays fine). For current values: settings, latest totals, status objects. - series — named timeseries of
{ t: epoch ms, v: number }points. For anything charted over time.(series, t)writes are idempotent upserts, so re-running a write is safe.
write_page_data applies one transactional patch and regenerates data/snapshot.json:
write_page_data({ slug: "build-health", set: { summary: { total: 42, failing: 3 }, updatedBy: "agent" }, delete: ["obsolete_key"], appendSeries: { "ci.duration_ms": [{ v: 84213 }, { t: 1756165200000, v: 79544 }] }, pruneSeries: { "ci.duration_ms": 1748000000000 }})The snapshot the page receives looks like:
{ "version": 1, "generatedAt": 1756191234567, "kv": { "summary": { "total": 42, "failing": 3 } }, "series": { "ci.duration_ms": [ { "t": 1756165200000, "v": 79544 } ] }}Series are ascending by t, capped at the newest 1000 points per series. The store itself is bounded too: at most 1000 kv keys and 100 distinct series per page — a write that would exceed either limit fails whole (rolled back) with a clear error. Design keys/series as stable names you update, not as ever-growing sets (put lists inside one kv value; don’t mint item-<id> keys or per-day series names).
Authoring page HTML
Section titled “Authoring page HTML”Rules that make pages work everywhere (local sandbox AND published copies):
- One full standalone HTML document. Inline ALL CSS and JS. No external requests of any kind — published copies are served with
connect-src 'none'(scripted network egress is blocked), so CDN scripts, fonts, or fetch() calls would break them. Frame self-navigation remains a documented residual. Render charts with inline SVG/canvas you draw yourself. - Data arrives via the bridge, not fetch. The host injects the data snapshot through
postMessage;livepages get replacement snapshots automatically whenever the data changes. - The iframe is opaque-origin (
sandboxwithoutallow-same-origin): no cookies, no localStorage, no parent DOM access. Keep state in JS variables.
Bridge snippet (copy-paste)
Section titled “Bridge snippet (copy-paste)”<script> let nonce = null;
function render(snapshot) { // snapshot = { version, generatedAt, kv, series } or null (no data yet) // ... update the DOM ... }
window.addEventListener('message', (event) => { const msg = event.data; if (!msg || msg.protocol !== 'craft-pages/v1') return; if (msg.type === 'init') { // { page: {slug, kind}, nonce, snapshot, grants } nonce = msg.payload.nonce; handleGrants(msg.payload.grants); // [{ id, action, expiresAt }] — usable grants render(msg.payload.snapshot); } else if (msg.type === 'data') { // live pages: replacement snapshot render(msg.payload.snapshot); } else if (msg.type === 'action-result') { handleActionResult(msg.payload.result); // { requestId, ok, status?, body?, error?, durationMs } } else if (msg.type === 'grants') { // reply to 'grant-request' + pushed on grant changes handleGrants(msg.payload.grants); } });
// Ask the host for init (also delivered automatically after load) window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'ready' }, '*');</script>Opening external links
Section titled “Opening external links”Sandboxed pages cannot navigate. Ask the host (only http/https URLs, requires a user gesture):
window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'open-url', nonce, url: 'https://example.com' }, '*');Source actions (grants)
Section titled “Source actions (grants)”Interactive pages can trigger calls against the workspace’s sources (e.g. a “Send email” button using a connected Google source) — without ever seeing credentials. The page posts an action request; the host validates it against a grant and executes server-side.
window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'action', requestId: crypto.randomUUID(), nonce, // from init — required grantId: 'grant_1a2b3c4d', // an approved grant on this page invocation: { kind: 'api', method: 'GET', path: '/health' } // must match the grant}, '*');// Result arrives as an 'action-result' message (match by requestId).Requesting grants from inside the page
Section titled “Requesting grants from inside the page”A page asks for the grants it needs with a grant-request message; the host shows the user
an approval dialog and answers with a grants message (the full list of currently usable
grants). Match grants to needs by comparing action descriptors — do NOT hardcode grant ids.
let grants = []; // [{ id, action, expiresAt }]function grantFor(action) { // find by descriptor, never by id return grants.find(g => g.action.kind === action.kind && g.action.sourceSlug === action.sourceSlug && (action.kind === 'mcp' ? g.action.toolName === action.toolName : g.action.method === action.method && g.action.pathPattern === action.pathPattern));}function handleGrants(list) { grants = list || []; /* enable/disable buttons */ }
const NEEDS = [ { key: 'read', description: 'Refresh the task list', action: { kind: 'mcp', sourceSlug: 'todoist', toolName: 'list_tasks' } }, { key: 'write', description: 'Add and complete tasks', action: { kind: 'mcp', sourceSlug: 'todoist', toolName: 'update_task' } },];// After init: request anything still missing (max 8 entries per request).if (NEEDS.some(n => !grantFor(n.action))) { window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'grant-request', nonce, requests: NEEDS }, '*');}Denied descriptors are remembered for the rest of the render — re-requesting them is answered with the current grant list instead of another dialog, so pages cannot nag. Design for denial: keep the page useful with buttons disabled and show what approval would unlock.
What you must know about grants:
- Grants are user-approved capabilities persisted in the page config:
{ kind: 'api', sourceSlug, method, pathPattern }(anchored regex),{ kind: 'mcp', sourceSlug, toolName },{ kind: 'script', script, runtime?, args? }, or{ kind: 'session', sessionId, message }(both see below). You cannot mint them with a session tool — the page requests them (grant-request) and the user approves them in the host dialog. - Grants are bound to the exact content digest at approval time and always expire.
apiandmcpgrants default to 7 days and are capped at 30; the privileged kinds (script,session) default to 24 hours and are capped at 7 days. Editing the page’s HTML invalidates all grants by design — the page should simply re-request on next open. - The user can remove any approval at any time (page ⋯ menu → Approved actions, or inline in the Share dialog). Open renders receive an updated
grantsmessage when that happens, so drive button state fromhandleGrantsinstead of caching the init-time list. - If a granted source loses authentication, actions fail fast with an error starting with
source-auth-required(e.g.source-auth-required: reconnect "gmail" in the app). The host shows a reconnect banner above the page. Treat it as retryable: show a “reconnect in the app” hint and let the user simply click again after reconnecting — do not permanently disable the button. - Only api GET actions may run without a user gesture. Everything else — api non-GET, every mcp tool (opaque: it may write), and every script — requires a fresh user gesture inside the page (button click): fire the action directly from the click handler, never from a timer or on load. For data that should be visible on open, render from the snapshot and make live calls button-driven.
- The gesture is proven by the host, not by the page. The host mints a single-use activation ticket bound to the render’s lease and content digest, valid for at most 10 seconds, and the first use of each approved capability in a render also asks the user to confirm. A page cannot assert its own activation, and the browser’s activation signal is not accepted as proof — so an action fired from a timer, a hover, a stale window, or a page reload is refused rather than silently allowed. Treat a refusal as “ask the user to click again”.
- Per-render caps: 5 requests in flight, 2 mutating at a time with up to 4 more queued behind them, and 30 starts/minute. Above the render there are also ceilings a re-mount cannot reset: 60 starts/minute per page and 120 per workspace. Cancel with
{ type: 'action-cancel', requestId, nonce }. - Published (shared) copies never execute actions — viewers get
public-actions-disabled, and the public shell supplies an empty grant list. - Desktop only. The web interface has no way to confirm a click, so it declines mutating actions outright.
get_page lists existing grants with a stale flag (digest mismatch or expired).
Running a host script (script grants)
Section titled “Running a host script (script grants)”A script grant lets a local page run a workspace-relative script on the host machine — the highest-privilege action a page can take. It reuses the same runner as scheduled refreshes: argv spawn (never a shell), path confined to the workspace (symlink-aware), a CRAFT_*-only environment (no PATH, no credentials), and a 60s default timeout (15min max).
// Descriptor requested via grant-request:// { kind: 'script', script: 'pages/<slug>/build.sh', runtime: 'bun'|'node'|'python3'?, args?: string[] }// The invocation is a BARE TRIGGER — script/runtime/args all come from the grant:window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'action', requestId: crypto.randomUUID(), nonce, grantId: scriptGrant.id, invocation: { kind: 'script' } // nothing else — the page cannot pass args}, '*');// action-result body → { exitCode, stdout, stderr }. ok === (exitCode === 0),// but stdout/stderr are returned even on a non-zero exit.Rules specific to script grants:
- Args are pinned at approval time. The page cannot supply or change
script/runtime/argsat call time — approving a grant approves one exact command, not a family of them. To vary behavior, approve a wrapper script (e.g.run.sh) and let it decide. - Match by descriptor the same way as other kinds, but on
script+runtime(defaulting tobun) + orderedargs— there is nosourceSlug/toolName. - Always mutating — only fire from a real click handler; it will be rejected without fresh user activation.
- Not shareable. A page that holds a script grant cannot be published at all (publish fails with
PAGE_SHARE_SCRIPT_GRANT) — even the inert view-only path is refused, and stale/expired script grants count too. The user can remove the approval (⋯ → Approved actions, or inline in the Share dialog) and then publish; you cannot revoke grants with a tool. Mention this trade-off when adding a script action to a page the user may want to share. - The script’s working directory is the workspace root;
CRAFT_PAGE_DIR/CRAFT_PAGE_DATA_DIR/CRAFT_WORKSPACE_PATHpoint it at the page’s own data. Running a script does not touch the page’s scheduled-refresh status.
Messaging a session (session callbacks)
Section titled “Messaging a session (session callbacks)”A session grant lets a local page deliver one fixed message to one existing session — a button that nudges a running piece of work, not a general messaging API.
// Descriptor requested via grant-request — BOTH fields are pinned at approval:// { kind: 'session', sessionId: '<an existing session id>', message: 'Re-run the export.' }// The invocation is a BARE TRIGGER — target and body both come from the grant:window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'action', requestId: crypto.randomUUID(), nonce, grantId: sessionGrant.id, invocation: { kind: 'session' } // nothing else — no target, no body}, '*');// action-result carries no body: ok:true means delivered, and a refusal names// a closed code (session-not-found / session-closed / session-busy). The page// never learns anything else about the session.Rules specific to session grants:
- The message is pinned at approval time, verbatim. You cannot template it, parameterize it, or change it at call time. Approving the grant means “this page may send this exact sentence to that session”. To say something different, request a different grant.
- Keep it short: 1000 characters max. The cap is a security control, not a style rule — the consent dialog shows the body in full, and it can only do that if the body fits. Never assume the user saw only part of it; they saw all of it, and a long wall of text is a worse ask, not a longer one.
- The target must already exist, be in this workspace, and not be finished. Name a real session id (
list_sessionsin your own session, or ask the user which one). There is no “current session” default — an id that doesn’t resolve inside the page’s workspace, or that names an archived or closed (done/cancelled) session, is refused before the user is even asked, so the request just fails. The approval dialog shows the session’s real name, resolved by the host. A session that is merely busy is still grantable: busy is a moment, not a state, so it is refused at click time (session-busy) rather than at approval. - A page can never close, archive, delete, or re-status a session, and there is no flag that changes this. The only thing a callback does is send a message.
- Delivery is attributed. The host prepends an unforgeable line naming the page and grant, so the transcript shows the message came from a page rather than from the user. Your pinned text follows it unchanged — write it to read well after that line.
- Refused when the session is busy. If the target is mid-turn the callback fails with
session-busyrather than interrupting or queueing behind the running turn. Surface it as “try again in a moment”, not as a permanent error. Archived and closed (done/cancelled) sessions refuse withsession-closed. - Always mutating — only fire from a real click handler, and the user confirms the first use in each render.
- Shorter expiry: session grants expire in 24 hours by default (7 days maximum), like script grants, rather than the 7/30 days api and mcp grants get.
- Not shareable. A page holding a session grant cannot be published at all (publish fails with
PAGE_SHARE_SESSION_GRANT), including the view-only path, and stale/expired ones count. Same trade-off as script grants — mention it before adding a callback to a page the user may want to share. - Never runs on a schedule. A
refreshspec may only name ascriptgrant; a scheduled tick can never fire a callback.
Scheduled refresh
Section titled “Scheduled refresh”Give a page a refresh spec (on create_page or update_page) to update its data deterministically — no agent session is created:
refresh: { cron: "*/15 * * * *", script: "scripts/refresh-build-health.ts" }The cron expression is validated on write: it must parse, must actually fire, and must not run more often than every 5 minutes (*/5 * * * * is the fastest accepted schedule) — an invalid spec makes create_page/update_page fail with the reason.
The script must live inside the workspace and runs under Bun with a minimal environment: CRAFT_WORKSPACE_PATH, CRAFT_PAGE_SLUG, CRAFT_PAGE_DIR, CRAFT_PAGE_DATA_DIR (plus other CRAFT_* vars). Flow: update the store → export the snapshot → exit 0. The executor stamps page.json afterwards, which pushes the new snapshot to open renders.
// scripts/refresh-build-health.ts (Bun)import { openPageDataStore } from '@craft-agent/shared/pages/data-store';
const store = openPageDataStore(process.env.CRAFT_WORKSPACE_PATH!, process.env.CRAFT_PAGE_SLUG!);const res = await fetch('https://ci.example.com/api/summary'); // scripts CAN use the networkconst summary = await res.json();store.kvSet('summary', summary);store.seriesAppend('ci.duration_ms', { v: summary.durationMs });store.exportSnapshot();store.close();If @craft-agent/shared is not resolvable from the workspace (e.g. packaged installs), write a self-contained script with bun:sqlite against $CRAFT_PAGE_DATA_DIR/store.sqlite using this exact schema, and write the snapshot atomically (temp file + rename) to $CRAFT_PAGE_DATA_DIR/snapshot.json:
CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS timeseries (series TEXT NOT NULL, t INTEGER NOT NULL, v REAL NOT NULL, PRIMARY KEY (series, t)) WITHOUT ROWID;(kv value is JSON-encoded; snapshot shape as shown above. Simpler alternative: skip the script and update the page yourself with write_page_data.)
Sharing
Section titled “Sharing”Publishing is a user action, not an agent action — there is no publish tool. The user clicks Share on the page. Your job is to author HTML that survives publication and to describe the trade-offs honestly before the user commits.
When Share is available
Section titled “When Share is available”Three things must all hold: the workspace has Pages enabled, the build has sharing enabled, and a Vorno publication endpoint is configured. A default install satisfies neither of the last two, so Share is unavailable out of the box. Do not promise a public link you cannot confirm — check the page’s share state with get_page and let the user tell you whether publishing is set up.
Publication targets only https://pages.vorno.ai (or an explicit localhost endpoint during development). Craft’s endpoint and arbitrary origins are refused for fresh publication. An already-published copy from an older endpoint stays revocable: update and unpublish recover the API base from the recognized stored URL, so turning the endpoint over never orphans a live page.
What the published copy is
Section titled “What the published copy is”A separate public service, isolated from Vorno’s session sharing — its own host, storage bucket, and credentials. A viewer gets:
- A Vorno-branded shell with a permanent line reading “Published by a Vorno user — not by Vorno.” Publication attributes content to the user, never to Vorno.
- The page itself inside an opaque sandboxed iframe with a per-document CSP:
connect-src 'none',default-src 'none',form-action 'none',object-src 'none',base-uri 'none', scripts only for non-statickinds, plusno-storeandnosniff. The honest claim is no scripted network egress —fetch,XHR,WebSocket, and beacons cannot leave the page. Frame self-navigation is a known residual, not an erased risk: do not tell a user a published page is incapable of reaching the network under every interpretation. - No privileged bridge. Actions return
public-actions-disabled, grant requests return an empty list, and there is noopen-urlrelay. - Optional password protection (minimum 8 characters) and, only if the user opts in, the current data snapshot. Opt-in snapshots are scanned for secret-looking keys before upload and the user is warned; nothing scans the HTML you wrote, so never inline a credential.
- Size limits: 5 MB of HTML, 2 MB of snapshot, 10 MB total. Publication attempts are rate-limited per IP, and password attempts per publication.
Revocation, deletion, and retention
Section titled “Revocation, deletion, and retention”- Unpublish is immediate. Public access is revoked first, so every public route 404s before physical deletion is even attempted. Deletion of the stored objects follows immediately on a best-effort basis and retries on the next authenticated delete if it fails; a pending cleanup is surfaced rather than hidden.
- Published content is retained for at most 30 days from its last successful update, then removed, whether or not anyone unpublishes it. A published page is not permanent hosting — say so when a user asks for a link they plan to keep. “Update” means content or password: republishing the page and setting, changing, or clearing the viewer password each restart the full 30 days.
- An expired page is indistinguishable from one that never existed. Past the deadline every public route returns the same bare 404 as an unknown id and as an unpublished page. That is deliberate — the alternative would disclose someone’s publishing history to anyone who could guess a URL. So you cannot tell a user “your link expired” from the outside; check the page’s own share state instead.
- Expiry never strands the owner. Unpublish and cleanup keep working after the deadline, so a page that has aged out can still be tidied up properly.
- Operational logs are retained for at most 90 days. They record the fact and outcome of publication operations, not page content. This is deliberately longer than content retention, to keep an abuse-investigation window.
delete_pageunpublishes first and blocks deletion if revocation cannot be confirmed, so a local delete can never leave an unmanageable public copy. Only the Electron desktop host offers a separately confirmed “forget local state” recovery when the management key is lost; headless and web paths deliberately have no such escape hatch.
This section states the publication service’s policy, and the service enforces it in code. What it is not is something this build can demonstrate: whether any of it is reachable at all depends on the availability check above. Treat that as the authority, and never tell a user their page is published, retained, or deleted on the strength of this document alone.
A page that cannot be published at all
Section titled “A page that cannot be published at all”A page holding a script or session grant is refused publication outright (PAGE_SHARE_SCRIPT_GRANT / PAGE_SHARE_SESSION_GRANT), including the view-only path, and stale or expired grants still count. Mention this before adding a script button or a session callback to a page the user has said they want to share.
Starter template
Section titled “Starter template”<!doctype html><html><head><meta charset="utf-8"><title>Build Health</title><style> body { font-family: -apple-system, system-ui, sans-serif; margin: 24px; color: #1a1a1a; } .metric { font-size: 40px; font-weight: 700; } .muted { color: #777; font-size: 12px; } svg { width: 100%; height: 160px; }</style></head><body> <h1>Build Health</h1> <div class="metric" id="total">—</div> <div class="muted" id="updated">waiting for data…</div> <svg id="chart" viewBox="0 0 600 160" preserveAspectRatio="none"></svg>
<script> let nonce = null;
function render(snapshot) { if (!snapshot) return; const summary = snapshot.kv.summary || {}; document.getElementById('total').textContent = summary.total ?? '—'; document.getElementById('updated').textContent = 'updated ' + new Date(snapshot.generatedAt).toLocaleString();
const points = snapshot.series['ci.duration_ms'] || []; const max = Math.max(1, ...points.map(p => p.v)); const w = 600 / Math.max(1, points.length); document.getElementById('chart').innerHTML = points .map((p, i) => `<rect x="${i * w}" y="${160 - (p.v / max) * 150}" width="${Math.max(1, w - 2)}" height="${(p.v / max) * 150}" fill="#4f7cff"/>`) .join(''); }
window.addEventListener('message', (event) => { const msg = event.data; if (!msg || msg.protocol !== 'craft-pages/v1') return; if (msg.type === 'init') { nonce = msg.payload.nonce; render(msg.payload.snapshot); } else if (msg.type === 'data') { render(msg.payload.snapshot); } }); window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'ready' }, '*');</script></body></html>Recipes
Section titled “Recipes”- “Make me a dashboard of X that updates every N minutes” →
create_page(kindlive, content +refreshspec) → write the refresh script into the workspace → seed initial data withwrite_page_dataso it isn’t empty before the first tick. - “Track this number over time” → page with a series chart; append points with
write_page_datawhenever you learn a new value (idempotent by timestamp). - “Turn this report into something I can share” →
create_page(kindstatic, fully inline HTML) → point the user at the Share button, and say plainly that publishing may not be available in their build and that a published copy is retained for at most 30 days from its last successful content or password update. - Iterating on a page →
update_pagewith newcontent; warn the user that existing grants go stale on content changes.