Resources
Authoring Brief
On this page
Introduction
Drop this page into an applet project as CLAUDE.md. It is the whole ziprack/1 contract, condensed.
You are building an applet: a self-contained static web app that a Rack hosts inside a sandboxed iframe. A Rack is any host that implements ziprack/1. The Rack owns login, persistence, AI access, real-time coordination and reporting. The applet owns everything the participant sees. It never talks to a database, never calls an API, never holds a credential. It asks the Rack over postMessage through @ziprack/sdk, and the Rack decides, enforces and persists.
Follow this document exactly. A Rack rejects packages and messages that break it.
Mental Model
| Object | What it is |
|---|---|
| Applet | One experience or app, identified by an immutable slug. Has many versions. |
| Version | One uploaded zip. Immutable. Exactly one is current. |
| Attempt | One participant's run (attempt@1). Pinned to the version it started on. Holds status, state, segment. |
| Room | A shared session: an applet plus its participants (broadcast@1). Two roles: participant, facilitator. |
| Statement | Append-only "actor did verb on activity with result" (activity@1). The audit trail and the uniform export. |
| Record | A mutable row in a declared collection at a declared scope (db@1). Working state. Carries over to every new version, except attempt scope. |
| Profile attribute | A Rack-owned fact about the participant, released only if declared and permitted (auth@1). |
| Role | A name the participant holds in this applet: member or guest, applet-author, rack-administrator, plus the manifest's own roles the Rack grants or assigns. Sent at launch; no capability. |
Rules That Do Not Bend
- Declare every capability you call.
requires.capabilitiesis mandatory, not advisory. An undeclared call is refused at runtime withundeclared_capability. - Nothing persists inside the iframe. No cookies, no
localStorage, nofetchto the Rack. If it matters, send it throughRack.*. - Answers are statements, not state. State is for resuming position. Every committed answer is
Rack.activity.answer(). - Resume must work. Read
Rack.attempt.state()andRack.attempt.statusat launch and put the participant back where they were. - Degrade gracefully. A requested profile attribute may be withheld. A room may have no facilitator yet. Design for absence.
- Your data outlives your version.
user,roomandappletrecords carry over every promotion. Change a collection's shape compatibly, or ship a data migration.
Package Layout
{slug}-{version}.zip
├── manifest.json required, at the zip root
├── sbom.json required: CycloneDX (or package-lock.json)
├── index.html the entry (configurable via manifest.entry)
├── migrations/… optional: data migrations, YYYY_MM_DD_HHMMSS_name.js, kept forever
└── assets/… everything the entry references, by RELATIVE pathInstaller rules: static files only (html js mjs css map json wasm txt vtt svg png jpg jpeg gif webp ico woff woff2 ttf mp3 mp4 wav webm ogg pdf); no .., absolute paths or symlinks; dotfiles and __MACOSX/ skipped; total size capped by the Rack; entry must exist and end in .html; every asset URL relative (base: './' in Vite); a version uploads once per applet.
npm run package in the starter builds all of this, including the SBOM from your lockfile. See Applet Packages.
manifest.json
{
"schema": "ziprack/1",
"slug": "my-applet",
"name": "My Applet",
"version": "1.0.0",
"entry": "index.html",
"requires": {
"capabilities": ["auth@1", "attempt@1", "activity@1"],
"profile": ["display_name", "locale"]
},
"media": [{ "id": "intro", "provider": "youtube", "key": "dQw4w9WgXcQ" }],
"segments": [],
"activities": [],
"reporting": { "metrics": [] }
}| Field | Rules |
|---|---|
schema |
Required, ziprack/1. |
slug |
Required. ^[a-z0-9]+(-[a-z0-9]+)*$, max 100. Immutable. |
name, version |
Required. version is semver with optional prerelease. |
entry |
Optional, default index.html. Relative, ends in .html. |
requires.capabilities |
Required (may be empty). Each id@version; the Rack must implement every one. |
requires.profile |
Attribute keys, only with auth@1. Withheld keys are simply absent. |
media[] |
provider in the Rack's allowlist (vimeo, youtube); key ^\d+$ (Vimeo) or ^[\w-]{11}$ (YouTube). The Rack builds the URL. |
roles[] |
Optional, no capability. {name, label?, description?, grant?}, max 20. name ^[a-z][a-z0-9]*(-[a-z0-9]+)*$, max 40, not rack-administrator, applet-author, member or guest. grant: everyone, members, guests or assigned (default: only whom the Rack assigns). |
Every other top-level block belongs to a capability and is invalid unless that capability is declared:
| Block | Capability | Shape |
|---|---|---|
segments[] |
attempt@1 |
{key, label, values[{key,label}]}; the first segment drives report filtering |
activities[], reporting.metrics[] |
activity@1 |
{id, type, label, scale?, segment?, sequence?}; metrics pair a pre and post rating and need attempt@1 |
collections[] |
db@1 |
{name, scope, schema, indexed?, quota?, permissions?, seed?}; seed (applet scope only) loads at promotion into an empty collection |
uploads[] |
storage@1 |
{name, scope, mime[], max_bytes?, max_files?} |
prompts[] |
ai@1 |
{id, mode, template, vars?, output_schema?, budget?} |
broadcast |
broadcast@1 |
{channels?, facilitator_events?} |
Scopes (db@1, storage@1): attempt (frozen at completion; needs attempt@1), user (outlives attempts), room (shared; needs broadcast@1), applet (shared by everyone; read-mostly). In db@1, user, room and applet records belong to the applet and carry over every promotion; attempt records stay with their attempt. storage@1 applet slots are still per version.
Full rules: Manifest.
Data Across Versions
When a new version changes a collection, the Rack validates the existing records against the new schema at promotion and refuses to lose any silently: rejected records, a dropped collection that holds records, or a lowered quota wait for a human to confirm. A scope cannot change; use a new collection name. To reshape existing records, ship a data migration in migrations/:
// migrations/2026_10_09_120000_add_difficulty.js
export function up(db, ctx) { // db: { collection: [{ id, scope_id, owner_id, data, created_at, updated_at }] }
for (const q of db.questions) q.data.difficulty ??= 'medium';
ctx.log('back-filled difficulty');
}
export function down(db) { // runs if someone rolls back to a version without this file
for (const q of db.questions) delete q.data.difficulty;
}Mutate db in place or return { collection: records[] }; a returned collection replaces that collection (missing records are deleted), others are kept. Migrations run in the browser of whoever promotes, with no network: no imports, no fetch. The Rack rejects the whole result if any record breaks the new schema or quota, changes scope_id or owner_id, or moves to a collection of another scope. Keep every migration you ever ship; the Rack runs only those not yet applied. A snapshot is taken before every cutover. attempt and preview records are never passed in.
See Data Migrations.
The SDK
import { Rack } from '@ziprack/sdk';
const launch = await Rack.init({ slug: 'my-applet' }); // mock mode when not inside a Rack| Namespace | Calls | Notes |
|---|---|---|
Rack |
init(), on('launch'|'event'), mode, eject() |
eject() needs no capability |
Rack.auth |
user(), attribute(key, fallback), roles(), hasRole(name), isGuest() |
Released profile from launch data (auth@1). Roles need no capability, and capabilities don't check them: use them to choose what to render, not to protect data |
Rack.attempt |
status, state(), segment(), setState(), setSegment(), complete() |
State max 64 KB; complete() idempotent; after it, state and segment are refused with completed |
Rack.activity |
answer(id, value), progress(stepId), statement({...}) |
Verbs ^[a-z][a-z0-9_.:-]*$; answers put the value in result.response |
Rack.db.collection(name) |
create, get, update, delete, list, count |
Filters and sorting only on indexed paths; 100 per page |
Rack.storage |
put(upload, file), url(id), list(), delete(id) |
Files are addressable only once scanned clean |
Rack.ai |
ask(promptId, vars), chat(promptId).send(text, onDelta), chat(promptId).reset() |
You supply variables, never instructions |
Rack.broadcast |
join(channel) → {listen, publish, leave}, presence(), state.get/set, claim(), transfer(id), on('presence'|'state'|'role'|'status') |
Facilitator-only events are declared in the manifest and enforced by the Rack |
Rack.media |
embedUrl(id) |
Rack-built player URL |
Every call resolves to { ok, data?, error?, message? } and never throws. Error codes: timeout, disposed, persist_failed, undeclared_capability, unknown_type, validation, forbidden, completed, quota_exceeded, budget_exceeded, not_found, conflict. Do not block the participant on acks; await only what you must confirm (for example the final answer before complete()).
Full surface: SDK.
Mock Mode and Tests
Outside a Rack (npm run dev), or if the Rack does not answer within 3 s, the SDK runs a local mock that reads ./manifest.json so declared capabilities, activities, collections and prompts behave as they will in production. State and records persist in localStorage under ziprack-mock:{slug}:*. Rooms use BroadcastChannel, so two tabs of your dev server form a room. Rack.mock.ai(promptId, fn) answers prompts yourself; Rack.mock.resetAttempt() replays a completed attempt. The mock participant is a member; add ?ziprack-guest to the URL to launch as a guest, and ?ziprack-roles=judge,applet-author to hold those roles.
For tests, import { FakeHost } from '@ziprack/sdk/testing' and pass host.transport() to Rack.init({ transport }); then inspect host.statements, host.answers(), host.records(). new FakeHost(manifest, { guest: true, roles: ['judge'] }) sets the identity.
Runtime Constraints
The Rack renders you with sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox" and no allow-same-origin: an opaque origin. Cookies, localStorage, IndexedDB and fetch to the Rack all fail. window.open works. The entry document's CSP is:
| Directive | Allowed |
|---|---|
script-src |
'self' + the Rack's origin, 'unsafe-inline' 'unsafe-eval' blob:. Bundle every dependency |
style-src, font-src |
'self' + the Rack's font hosts |
img-src, media-src |
'self' data: blob: + the Rack's allowlist. Not https:. |
connect-src |
'self' + the Rack's origin. No external APIs, no analytics |
frame-src |
Vimeo and YouTube players, via declared media only |
Ship your images and media inside the zip. A persistent Rack bar sits above the frame; never ask participants for credentials inside an applet. See Runtime Environment.
Publishing
Uploads land inactive (an applet's first version is the exception, and goes live). Promoting a version is a deliberate human action. A new version that adds a capability or a profile attribute waits for explicit approval; adding or removing attempt@1 changes the applet from rolling to pinned (or back) and is flagged prominently. Known vulnerabilities from your SBOM are shown to the reviewer. Changing an activity id between versions splits export columns; changing a label only changes headers. Collection changes are checked against the live data at promotion (see Data Across Versions); a version with migrations is promoted from the Rack's UI, where they run. See Review & Release.
Pre-Upload Checklist
-
schema: "ziprack/1", version bumped,requires.capabilitieslists exactly what you call. -
sbom.jsonpresent (npm run packagedoes it). - Every question has an
activities[]entry with a stableid, atype, a full-sentencelabel. -
setState()after every transition; refreshing mid-way in mock mode resumes. -
complete()exactly once; reloading a completed attempt shows the final screen. - Changed a collection's schema? Existing records still validate, or a migration in
migrations/reshapes them (withdown). - Every role-gated control also works for someone without the role, and nothing secret depends on a role.
- All URLs relative, no external
<script>, no external images or APIs. - Layout works at phone width and leaves the Rack bar alone.