Skip to content

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_data session tools, scheduled refresh, and publication. If it is off, create_page and friends fail with PAGES_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 read

Always 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).

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.

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

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).

Rules that make pages work everywhere (local sandbox AND published copies):

  1. 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.
  2. Data arrives via the bridge, not fetch. The host injects the data snapshot through postMessage; live pages get replacement snapshots automatically whenever the data changes.
  3. The iframe is opaque-origin (sandbox without allow-same-origin): no cookies, no localStorage, no parent DOM access. Keep state in JS variables.
<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>

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' }, '*');

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).

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. api and mcp grants 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 grants message when that happens, so drive button state from handleGrants instead 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).

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/args at 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 to bun) + ordered args — there is no sourceSlug/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_PATH point it at the page’s own data. Running a script does not touch the page’s scheduled-refresh status.

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_sessions in 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-busy rather 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 with session-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 refresh spec may only name a script grant; a scheduled tick can never fire a callback.

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 network
const 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.)

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.

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.

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-static kinds, plus no-store and nosniff. 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 no open-url relay.
  • 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.
  • 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_page unpublishes 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 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.

<!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>
  • “Make me a dashboard of X that updates every N minutes” → create_page (kind live, content + refresh spec) → write the refresh script into the workspace → seed initial data with write_page_data so it isn’t empty before the first tick.
  • “Track this number over time” → page with a series chart; append points with write_page_data whenever you learn a new value (idempotent by timestamp).
  • “Turn this report into something I can share” → create_page (kind static, 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_page with new content; warn the user that existing grants go stale on content changes.