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:
npm install @ziprack/sdkThen import the client:
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:
<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:
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:
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:
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:
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:
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():
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:
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:
Rack.mock.ai('score_pitch', (vars) => ({ clarity: 4, feedback: `Nice work on "${vars.pitch}"` }));
Rack.mock.resetAttempt(); // replay a completed attemptTrying 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 theseMock 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:
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:
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:
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:
npx ziprack-mcpThe 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:
{
"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.