Documentation ziprack/1SDK 0.3.0

Building an Applet

The JavaScript SDK

On this page

Introduction

@ziprack/sdk is how your applet talks to the Rack it runs on. It performs the launch handshake, gives you typed access to every capability your manifest declares, and turns each call into a message the Rack acknowledges.

The SDK is also your development environment. When there is no Rack around, it becomes one: mock mode services every declared capability locally, and an in-memory test host lets you assert on exactly what your applet sent.

Installation

To get started, install the package with npm:

Shell
npm install @ziprack/sdk

Then import the client:

TypeScript
import { Rack } from '@ziprack/sdk';

Rack is a singleton, one per frame, which is what an applet always is. If you need a fresh client for tests or an unusual multi-frame setup, you may call createRack() instead.

If your applet has no build step, every Rack also serves an IIFE build of the SDK at /ziprack/assets/sdk.js. It exposes window.Rack (and window.ZipRack) and is the same code:

HTML
<script src="/ziprack/assets/sdk.js"></script>

Even so, you should prefer the npm package when you have a bundler, because mock mode and the test host come with it.

Booting

Your applet boots by calling init, which resolves with the launch payload:

TypeScript
const launch = await Rack.init({ slug: 'my-applet' });

init() announces rack:ready to the parent, waits for rack:launch, and resolves with the launch payload. It is safe to call repeatedly: subsequent calls return the same promise. Calls made before the handshake completes are queued, not dropped, so you never have to gate your UI on initialisation order.

The init method accepts the following options:

Option Default Purpose
slug — Labels the mock session and locates ./manifest.json when standalone.
launchTimeoutMs 3000 How long to wait for rack:launch before falling back to mock mode.
ackTimeoutMs 5000 Per-call ack timeout. Streamed calls reset it per chunk.
manifestUrl ./manifest.json Where mock mode reads your manifest.
transport — Inject a transport (tests). Skips the handshake and mock detection.

Rack.mode is 'pending' until the handshake settles, then 'host' or 'mock', and Rack.launched tells you whether launch data has arrived.

Launch Data

Everything the Rack is willing to tell you up front arrives in one payload. There is no second round trip to fetch the participant, the attempt or the manifest:

TypeScript
interface LaunchData {
    schema: 'ziprack/1';
    actor: { id: number; name: string };
    applet: { slug: string; name: string; version: string };
    capabilities: string[];                  // as declared, e.g. ['auth@1', 'db@1']
    media: Record<string, MediaEmbed>;       // Rack-built player URLs by declared id
    roles?: string[];                        // e.g. ['member', 'judge']

    auth?:      { user: LaunchUser; withheld?: string[] };
    attempt?:   { id, status, state, segment };
    activity?:  { activities: LaunchActivity[] };
    db?:        { collections: Array<{ name, scope }> };
    storage?:   { uploads: Array<{ name, scope, mime, max_bytes }> };
    ai?:        { prompts: Array<{ id, mode }> };
    broadcast?: { room, role, members, state, channels };
}

Each capability contributes its own key, and only when declared. roles is core rather than a capability, so it arrives whatever you declare. You should read it through Rack.auth.roles(), Rack.auth.hasRole(name) and Rack.auth.isGuest(), which need no capability and treat a missing roles as no roles at all (see Roles).

The typed accessors read from the same payload, so Rack.auth.user(), Rack.auth.roles(), Rack.attempt.state() and Rack.activity.activities() are synchronous and free. You may also subscribe to the launch, or ask whether the manifest declared a capability:

TypeScript
Rack.on('launch', (data) => render(data));   // fires immediately if already launched
Rack.declares('broadcast');                  // did the manifest declare it?

Acks and Errors

Every SDK call resolves to an AckResult and never throws. There is no try/catch ceremony around a capability call; there is a branch on ok.

Every result has the same shape:

TypeScript
interface AckResult<T> {
    ok: boolean;
    data?: T;
    error?: AckErrorCode;
    message?: string;     // human-readable, safe to log, not to show
    details?: object;     // validation errors, keyed by field
}

For example, you may check ok and branch on the error code when a write is refused:

TypeScript
const result = await Rack.db.collection('ideas').create({ title, status: 'draft' });

if (! result.ok) {
    if (result.error === 'quota_exceeded') return showFull();
    return showRetry();
}

const idea = result.data.record;

Error Codes

The error property is always one of the following codes:

Code Means
timeout No ack within the timeout.
disposed The client was torn down while the call was in flight.
persist_failed The Rack could not process the message.
undeclared_capability The manifest does not declare it. This is a packaging bug, not a runtime condition.
unknown_type Malformed or unimplemented message type.
validation Payload failed the handler's rules or a declared schema. See details.
forbidden Permitted by the manifest, refused for this participant (not the owner, not the facilitator).
completed The attempt is finished; state, segment and attempt-scoped records are frozen.
quota_exceeded Collection full, upload slot full, file too large.
budget_exceeded An ai@1 budget was reached.
not_found Unknown record, file, channel or collection.
conflict A concurrent write lost.

Don't Block on Acks

You should not block the participant on an ack. Pending setState() calls are coalesced so that only the latest value is sent, which is why calling it on every keystroke is safe.

Typically, you fire and forget progress and state, and await only what you must confirm. Usually that is the last answer before complete():

TypeScript
void Rack.attempt.setState({ step: 4 });             // fire and forget
await Rack.activity.answer('outro.confidence', 5);   // confirm, then:
await Rack.attempt.complete();

Events

The Rack pushes events into the frame: room broadcasts, presence changes, role grants. Typically, you subscribe through the capability that owns them (see Broadcast). Of course, you may also read the raw stream:

TypeScript
Rack.on('event', ({ event, payload, channel, from }) => {
    console.log(event, payload, channel, from?.participant_id);
});

Mock Mode

When you run npm run dev, there is no Rack, so the SDK becomes one. Mock mode reads your ./manifest.json and services every capability locally. Declared capabilities, activities, collections, prompts and uploads behave exactly as they will in production, including refusing calls the manifest did not declare.

The console tells you when mock mode is active, and logs what your applet sends:

[ziprack] running in mock mode (not embedded in a Rack)
[ziprack mock] activity:statement { verb: 'answered', … }

Each capability has a local stand-in:

Surface Mock behaviour
attempt State and status persist in localStorage under ziprack-mock:{slug}:*. Refresh resumes.
activity Statements are logged and kept in memory.
db Records persist in localStorage, validated against the declared schema.
storage Files are held as blob URLs and marked clean.
ai Returns a stub conforming to output_schema, or your own handler.
broadcast Uses BroadcastChannel, so two tabs of your dev server form a room.

Two hooks exist only in mock mode. The mock.ai method lets you supply your own response for a prompt, and mock.resetAttempt lets you replay a completed attempt:

TypeScript
Rack.mock.ai('score_pitch', (vars) => ({ clarity: 4, feedback: `Nice work on "${vars.pitch}"` }));
Rack.mock.resetAttempt();   // replay a completed attempt

Trying Other Identities

The mock participant is a member holding the manifest's everyone and members grants. You may try other identities from the page URL:

http://localhost:5173/?ziprack-guest                         a guest, with the guests grants
http://localhost:5173/?ziprack-roles=judge,applet-author     as if the Rack had assigned these

Mock mode also engages if a real Rack fails to answer rack:launch within launchTimeoutMs. That is a deliberate development convenience, and the console says so. If you see it in production, something is wrong with the Rack, not with your applet.

Testing

@ziprack/sdk/testing ships an in-memory Rack you can assert against. To use it, create a FakeHost from your manifest and pass its transport to init:

TypeScript
import { createRack } from '@ziprack/sdk';
import { FakeHost } from '@ziprack/sdk/testing';
import manifest from '../public/manifest.json';

it('records the answer and completes exactly once', async () => {
    const host = new FakeHost(manifest);
    const rack = createRack();
    await rack.init({ transport: host.transport() });

    await rack.activity.answer('intro.confidence', 5);
    await rack.attempt.complete();
    await rack.attempt.complete();                      // idempotent

    expect(host.answers()).toEqual({ 'intro.confidence': 5 });
    expect(host.attempt().status).toBe('completed');
});

The host gives you the following members to assert against:

Member Gives you
host.transport() Pass to rack.init({ transport }).
host.statements Every statement the applet emitted, in order.
host.answers() The last answered value per activity, which is what a Rack would report for the attempt.
host.attempt(), host.records(name), host.files() Persisted state.
host.push(event) Push a rack:event in, as the Rack would, to drive broadcast handlers.

new FakeHost(manifest, options) takes the mock Rack's options. Those worth knowing:

Option
guest Launch as a guest: guest instead of member, and the guests grants.
roles Roles held on top of the grants, as if assigned. Any name, built-in ones included.
role The room role, participant or facilitator, instead of the one the assignment mode gives.
participantId, participantName The actor.

For example, you may launch as a participant who holds the judge role:

TypeScript
const host = new FakeHost(manifest, { roles: ['judge'] });

Leaving the Applet

When your applet is done with the participant, you may ask the Rack to navigate away using the eject method:

TypeScript
Rack.eject();

The Rack sends the participant back to the applet list, or wherever the deployment sends people. eject needs no capability and takes no ack. In mock mode, it logs what would have happened.

The MCP Server

The SDK ships a stdio MCP server that gives an AI assistant these docs and the applet authoring guidelines. You may run it with npx:

Shell
npx ziprack-mcp

The docs are bundled into the package when it is published, so what the assistant reads always matches the SDK version in your node_modules, not whatever it remembers of an older one. The starter already includes the configuration in .mcp.json:

JSON
{
    "mcpServers": {
        "ziprack": {
            "command": "npx",
            "args": ["ziprack-mcp"]
        }
    }
}

The server provides the following tools:

Tool Purpose
ziprack_guidelines The rules every applet follows (ziprack-1-authoring.md). Read before writing applet code.
ziprack_list_docs Every doc in reading order, with its section anchors.
ziprack_search_docs The best-matching sections, in full, for one or more phrasings.
ziprack_read_doc One doc, or one section of it by anchor.

Every answer names the SDK version it came from.