Documentation ziprack/1SDK 0.3.0

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

  1. Declare every capability you call. requires.capabilities is mandatory, not advisory. An undeclared call is refused at runtime with undeclared_capability.
  2. Nothing persists inside the iframe. No cookies, no localStorage, no fetch to the Rack. If it matters, send it through Rack.*.
  3. Answers are statements, not state. State is for resuming position. Every committed answer is Rack.activity.answer().
  4. Resume must work. Read Rack.attempt.state() and Rack.attempt.status at launch and put the participant back where they were.
  5. Degrade gracefully. A requested profile attribute may be withheld. A room may have no facilitator yet. Design for absence.
  6. Your data outlives your version. user, room and applet records 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 path

Installer 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

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/:

JavaScript
// 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

TypeScript
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.capabilities lists exactly what you call.
  • sbom.json present (npm run package does it).
  • Every question has an activities[] entry with a stable id, a type, a full-sentence label.
  • 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 (with down).
  • 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.