@ziprack/sdk 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ # The Manifest
2
+
3
+ - [Introduction](#introduction)
4
+ - [Core Fields](#core-fields)
5
+ - [The Requires Block](#the-requires-block)
6
+ - [Capability Sections](#capability-sections)
7
+ - [A Complete Example](#a-complete-example)
8
+ - [Validation Errors](#validation-errors)
9
+
10
+ <a name="introduction"></a>
11
+ ## Introduction
12
+
13
+ `manifest.json` sits at the root of the zip and is the applet's entire declaration of intent. It
14
+ names the applet, states which capabilities it calls, and describes the shape of everything the Rack
15
+ will need to validate, store, index, report on and render.
16
+
17
+ **Declaration is mandatory, not advisory.** A call to a capability the manifest did not declare is
18
+ refused at runtime with `undeclared_capability`. That is also what makes capability-diff review at
19
+ upload meaningful: the manifest is a contract a reviewer can read.
20
+
21
+ <a name="core-fields"></a>
22
+ ## Core Fields
23
+
24
+ ```json
25
+ {
26
+ "schema": "ziprack/1",
27
+ "slug": "my-applet",
28
+ "name": "My Applet",
29
+ "version": "1.0.0",
30
+ "entry": "index.html",
31
+ "requires": { "capabilities": [], "profile": [] },
32
+ "media": []
33
+ }
34
+ ```
35
+
36
+ | Field | Rules |
37
+ |---|---|
38
+ | `schema` | Required. Exactly `ziprack/1`. |
39
+ | `slug` | Required. `^[a-z0-9]+(-[a-z0-9]+)*$`, max 100. Becomes `/a/{slug}`. **Immutable.** |
40
+ | `name` | Required, max 255. Shown to participants and admins. |
41
+ | `version` | Required. `MAJOR.MINOR.PATCH` with optional `-prerelease`, max 50. Unique per applet. |
42
+ | `entry` | Optional, default `index.html`. Relative, no `..`, ends in `.html`. |
43
+ | `requires` | Required, even if empty. See below. |
44
+ | `media` | Optional. See [Media](media.md). |
45
+
46
+ Everything else at the top level belongs to a capability. A section whose owning capability is not
47
+ declared is a validation error, and so is a section no installed capability owns.
48
+
49
+ <a name="the-requires-block"></a>
50
+ ## The Requires Block
51
+
52
+ ```json
53
+ "requires": {
54
+ "capabilities": ["auth@1", "attempt@1", "activity@1", "ai@1"],
55
+ "profile": ["display_name", "business_unit", "locale"]
56
+ }
57
+ ```
58
+
59
+ - Each capability reference matches `^[a-z][a-z0-9_]*@\d+$`. Declaring a capability twice is an
60
+ error, and so is declaring one this Rack does not implement — the error names what it does.
61
+ - `profile` lists the attribute keys you want released, and requires `auth@1`. Keys match
62
+ `^[a-z][a-z0-9_]*$`. A key the deployment does not release is simply absent at launch;
63
+ [degrade gracefully](auth.md).
64
+ - Declaring `attempt@1` has a side effect: it pins every participant to the version they started on.
65
+ See [Versioning](versioning.md#version-pinning).
66
+
67
+ <a name="capability-sections"></a>
68
+ ## Capability Sections
69
+
70
+ | Section | Capability | Shape |
71
+ |---|---|---|
72
+ | `segments[]` | `attempt@1` | `{key, label, values[{key,label}]}` — dimensions the admin filters by |
73
+ | `activities[]` | `activity@1` | `{id, type, label, scale?, segment?, sequence?}` — required, at least one |
74
+ | `reporting.metrics[]` | `activity@1` (+ `attempt@1`) | `{key, label, pre, post}` — pairs two declared activities |
75
+ | `collections[]` | `db@1` | `{name, scope, schema, indexed?, quota?, permissions?, seed?}` |
76
+ | `uploads[]` | `storage@1` | `{name, scope, mime[], max_bytes?, max_files?}` |
77
+ | `prompts[]` | `ai@1` | `{id, mode, template, vars?, output_schema?, budget?, model?}` |
78
+ | `broadcast` | `broadcast@1` | `{channels?, facilitator_events?}` |
79
+
80
+ Each is specified in full on its capability's page. Two cross-cutting rules are worth stating here:
81
+
82
+ **Scopes are declarations, not runtime choices.** `db@1` collections and `storage@1` uploads each
83
+ declare a scope — `attempt`, `user`, `room` or `applet` — and an `attempt` scope requires
84
+ `attempt@1` while a `room` scope requires `broadcast@1`. The scope answers, at declaration time,
85
+ what happens to the data when an attempt completes.
86
+
87
+ **Quotas and budgets are ceilings you may lower, not raise.** The deployment's config sets hard
88
+ maxima; a manifest asking for more fails validation, and a manifest asking for nothing gets the
89
+ deployment's defaults.
90
+
91
+ <a name="a-complete-example"></a>
92
+ ## A Complete Example
93
+
94
+ An applet exercising every capability at once. Most applets need three or four.
95
+
96
+ ```json
97
+ {
98
+ "schema": "ziprack/1",
99
+ "slug": "team-summit",
100
+ "name": "Team Summit",
101
+ "version": "2.1.0",
102
+ "entry": "index.html",
103
+
104
+ "requires": {
105
+ "capabilities": ["auth@1", "attempt@1", "activity@1", "db@1", "storage@1", "ai@1", "broadcast@1"],
106
+ "profile": ["display_name", "business_unit", "locale"]
107
+ },
108
+
109
+ "media": [
110
+ { "id": "intro.welcome", "provider": "vimeo", "key": "912345678" },
111
+ { "id": "debrief.clip", "provider": "youtube", "key": "dQw4w9WgXcQ" }
112
+ ],
113
+
114
+ "segments": [
115
+ {
116
+ "key": "track",
117
+ "label": "Track",
118
+ "values": [
119
+ { "key": "rd", "label": "R&D" },
120
+ { "key": "commercial", "label": "Commercial" }
121
+ ]
122
+ }
123
+ ],
124
+
125
+ "activities": [
126
+ { "id": "intro.confidence", "type": "rating", "label": "How confident do you feel about the new operating model?", "scale": { "min": 1, "max": 6 }, "sequence": 1 },
127
+ { "id": "team.pitch", "type": "text", "label": "In one sentence, what is your team's pitch?", "segment": "track", "sequence": 2 },
128
+ { "id": "outro.confidence", "type": "rating", "label": "How confident do you feel about the new operating model?", "scale": { "min": 1, "max": 6 }, "sequence": 3 }
129
+ ],
130
+
131
+ "reporting": {
132
+ "metrics": [
133
+ { "key": "confidence", "label": "Confidence", "pre": "intro.confidence", "post": "outro.confidence" }
134
+ ]
135
+ },
136
+
137
+ "collections": [
138
+ {
139
+ "name": "ideas",
140
+ "scope": "room",
141
+ "schema": {
142
+ "type": "object",
143
+ "properties": {
144
+ "title": { "type": "string", "maxLength": 120 },
145
+ "body": { "type": "string", "maxLength": 2000 },
146
+ "status": { "type": "string", "enum": ["draft", "posted", "picked"] },
147
+ "votes": { "type": "integer", "minimum": 0 }
148
+ },
149
+ "required": ["title", "status"]
150
+ },
151
+ "indexed": ["status", "votes"],
152
+ "quota": { "max_records": 500, "max_bytes": 16384 },
153
+ "permissions": { "update": "owner", "delete": "facilitator" }
154
+ }
155
+ ],
156
+
157
+ "uploads": [
158
+ { "name": "artifacts", "scope": "room", "mime": ["image/png", "image/jpeg"], "max_bytes": 5242880, "max_files": 10 }
159
+ ],
160
+
161
+ "prompts": [
162
+ {
163
+ "id": "score_pitch",
164
+ "mode": "analysis",
165
+ "template": "Score this team pitch for clarity and specificity.\n\nPitch: {{pitch}}\nTrack: {{track}}",
166
+ "vars": {
167
+ "type": "object",
168
+ "properties": { "pitch": { "type": "string" }, "track": { "type": "string" } },
169
+ "required": ["pitch"]
170
+ },
171
+ "output_schema": {
172
+ "type": "object",
173
+ "properties": {
174
+ "clarity": { "type": "integer", "minimum": 1, "maximum": 5 },
175
+ "feedback": { "type": "string" }
176
+ },
177
+ "required": ["clarity", "feedback"]
178
+ },
179
+ "budget": { "max_calls_per_attempt": 3, "max_output_tokens": 400 }
180
+ }
181
+ ],
182
+
183
+ "broadcast": {
184
+ "channels": ["main", "board"],
185
+ "facilitator_events": ["advance", "lock", "reveal"]
186
+ }
187
+ }
188
+ ```
189
+
190
+ <a name="validation-errors"></a>
191
+ ## Validation Errors
192
+
193
+ The validator runs core rules first, then the rules of every declared capability, then cross-field
194
+ checks. All errors are reported at once, keyed by manifest path, and the installer surfaces them as
195
+ one message. The checks that catch people out:
196
+
197
+ | Error | Cause |
198
+ |---|---|
199
+ | `Unknown manifest section [x]` | No installed capability owns that top-level key. |
200
+ | `Manifest section [collections] requires the [db] capability` | The section is there; the declaration is not. |
201
+ | `This Rack does not implement [db@1]. Implemented: …` | Wrong capability or wrong version for this deployment. |
202
+ | `Activity references unknown segment [track]` | `activities[].segment` must name a declared segment key. |
203
+ | `Metric references unknown activity [outro.x]` | `pre` and `post` must be declared activity ids. |
204
+ | `reporting.metrics needs the attempt capability` | Metrics are computed over completed attempts. |
205
+ | `A room-scoped collection requires the [broadcast] capability` | Scope implies capability. |
206
+ | `Indexed path [votes] must be a string, integer, number or boolean property declared in the schema` | You can only index what you declared, and only scalars. |
207
+ | `Only applet-scoped collections can be seeded` | A seed is shared reference data; see [Seeding](database.md#seeding). |
208
+ | `Placeholder {{pitch}} is not declared in vars.properties` | Prompt templates and their `vars` schema must agree. |
209
+ | `Analysis prompts need an output_schema of type object` | `mode: "analysis"` is structured output by definition. |
210
+ | `The $room channel is reserved for the Rack` | Pick another channel name. |
@@ -0,0 +1,190 @@
1
+ # The Rack MCP Server
2
+
3
+ - [Introduction](#introduction)
4
+ - [Installing](#installing)
5
+ - [Connecting an Agent](#connecting-an-agent)
6
+ - [Tools](#tools)
7
+ - [Uploading a Package](#uploading-a-package)
8
+ - [The Scaffold Skill](#the-scaffold-skill)
9
+ - [Contributing Tools From a Capability](#contributing-tools)
10
+
11
+ <a name="introduction"></a>
12
+ ## Introduction
13
+
14
+ `ziprack/mcp` puts a [Model Context Protocol](https://modelcontextprotocol.io) server on the Rack,
15
+ at `/ziprack/mcp`. An agent connected to it acts as one of the Rack's admins. It can list applets
16
+ and their versions, read what participants produced, upload and promote packages, and fetch the
17
+ skill that scaffolds a new applet.
18
+
19
+ It is the Rack-side counterpart of the two authoring servers, which run locally with no Rack.
20
+ `ziprack-mcp` serves the SDK docs and `ziprack-studio mcp` serves Studio blocks (see
21
+ [The JavaScript SDK](sdk.md) and [zipRACK Studio](studio.md)).
22
+
23
+ Agents authenticate in one of two ways. Clients that can complete a browser OAuth redirect use
24
+ Passport OAuth, as claude.ai and Claude Code do. Clients such as Cursor, which only accept
25
+ localhost redirects, use a minted token. Both kinds are Passport tokens with the `mcp:use` scope on
26
+ the `api` guard. Every call needs `ziprack.use-admin`, and tools see only the applets the caller may
27
+ see (see [Who May Do What](reporting.md#who-may-do-what)).
28
+
29
+ <a name="installing"></a>
30
+ ## Installing
31
+
32
+ The package is optional. A Rack without it never needs Passport.
33
+
34
+ ```sh
35
+ composer require ziprack/mcp
36
+ php artisan passport:install # keys, migrations, and the personal access client
37
+ ```
38
+
39
+ The Rack must do the rest itself, because a package can't impose it:
40
+
41
+ - **API guard.** In `config/auth.php`, add
42
+ `'api' => ['driver' => 'passport', 'provider' => 'users']`.
43
+ - **User model.** It uses `Laravel\Passport\HasApiTokens` and implements
44
+ `Laravel\Passport\Contracts\OAuthenticatable`.
45
+ - **Consent screen.** Call `Passport::authorizationView(...)` to render your approve and deny page.
46
+ Post it with plain HTML forms, not Inertia visits: the redirect goes to the client's origin or
47
+ a custom scheme.
48
+ - **Login redirect.** Add `AuthenticationException::redirectUsing(fn () => route('login'))`, so
49
+ that `/oauth/authorize` sends a signed-out admin to the login page rather than returning a 401.
50
+ - **Custom schemes.** `php artisan vendor:publish --tag=mcp-config`, then set
51
+ `custom_schemes => ['claude', 'cursor', 'vscode']` in `config/mcp.php`.
52
+ - **Optional client model.** Override `skipsAuthorization()` on a custom Passport client model so a
53
+ client that already holds a token isn't asked for consent again.
54
+
55
+ `config/ziprack-mcp.php` is published with the `ziprack-config` tag:
56
+
57
+ | Key | Default | |
58
+ |---|---|---|
59
+ | `enabled` | `true` | Register the endpoint, upload route and token routes |
60
+ | `path` | `ziprack/mcp` | The endpoint |
61
+ | `middleware` | `auth:api`, `mcp:use` scope, `can:ziprack.use-admin`, `throttle:60,1` | |
62
+ | `oauth_routes` | `true` | laravel/mcp's discovery and dynamic client registration routes |
63
+ | `token_ttl_days` | `30` | Lifetime of a minted token |
64
+ | `upload_ttl_minutes` | `15` | Lifetime of a signed upload URL |
65
+
66
+ <a name="connecting-an-agent"></a>
67
+ ## Connecting an Agent
68
+
69
+ **Claude Code and claude.ai (OAuth).** Add the endpoint URL. The client discovers the
70
+ authorization server from `/.well-known/oauth-protected-resource`, registers itself, and sends the
71
+ admin to the consent screen.
72
+
73
+ ```sh
74
+ claude mcp add --transport http my-rack https://rack.example.com/ziprack/mcp
75
+ ```
76
+
77
+ **Cursor and other localhost-only clients (minted token).** Mint a token, either in the Rack's admin
78
+ UI (backed by `GET|POST /ziprack/admin/mcp/tokens` and `DELETE /ziprack/admin/mcp/tokens/{id}`) or
79
+ from the command line:
80
+
81
+ ```sh
82
+ php artisan ziprack:mcp:token admin@example.com --name="Cursor laptop"
83
+ php artisan ziprack:mcp:revoke admin@example.com --all
84
+ ```
85
+
86
+ Then send the token as a bearer header:
87
+
88
+ ```json
89
+ { "mcpServers": { "my-rack": { "url": "https://rack.example.com/ziprack/mcp", "headers": { "Authorization": "Bearer <token>" } } } }
90
+ ```
91
+
92
+ The plaintext is shown once. A token acts as the admin who minted it, and it stops working once that
93
+ user is no longer an admin.
94
+
95
+ <a name="tools"></a>
96
+ ## Tools
97
+
98
+ | Tool | From | |
99
+ |---|---|---|
100
+ | `rack-status` | mcp | Format, implemented capabilities, health notes. Needs `ViewRack` |
101
+ | `list-applets`, `get-applet`, `get-applet-version` | mcp | Applets, versions, review state, manifest |
102
+ | `create-upload` | mcp | A signed upload URL (see below) |
103
+ | `promote-version` | mcp | Make a version current. It cannot approve, so it cannot force |
104
+ | `get-scaffold-skill` | mcp | The applet scaffold skill's files |
105
+ | `list-attempts`, `get-attempt`, `attempt-overview` | attempt | |
106
+ | `list-statements`, `applet-metrics` | activity | |
107
+ | `list-collections`, `list-records` | db | |
108
+ | `list-files` | storage | |
109
+ | `ai-spend` | ai | |
110
+ | `list-rooms`, `get-room` | broadcast | |
111
+
112
+ Capability tools are only present when their capability package is installed.
113
+
114
+ - **Same logic as the admin API.** Data tools run the same controller actions as the
115
+ [admin API](reporting.md), so filters, pagination and preview handling match what the admin UI
116
+ shows. Live data is the default; pass `preview: "preview"` or `preview: "all"` to include staff
117
+ preview runs.
118
+ - **Audit log.** Writes (`mcp:upload`, `mcp:promote`) go to the audit log with the admin and the
119
+ OAuth client or token name they came through.
120
+
121
+ <a name="uploading-a-package"></a>
122
+ ## Uploading a Package
123
+
124
+ MCP has no binary arguments, and an agent can't push a 50 MB zip through its context. Instead,
125
+ `create-upload` returns a signed, single-use URL with a ready-made `curl` command:
126
+
127
+ ```sh
128
+ curl -sS -H 'Accept: application/json' -F 'package=@dist-zip/my-applet-1.1.0.zip' '<upload_url>'
129
+ ```
130
+
131
+ The response is the installed version, in the same shape as `POST /ziprack/admin/versions/upload`.
132
+ The rules in [Publishing](publishing.md) are unchanged.
133
+
134
+ - **Default.** A later version installs as not current.
135
+ - **With `activate: true`.** The version is promoted, unless it asks for capabilities that no admin
136
+ has approved yet. In that case the response carries a `warning` and the version waits in the
137
+ review queue.
138
+ - **Reuse.** A used or expired URL answers `410`. Call `create-upload` again.
139
+
140
+ <a name="the-scaffold-skill"></a>
141
+ ## The Scaffold Skill
142
+
143
+ The canonical `ziprack-scaffold` skill lives in `packages/mcp/resources/skills/ziprack-scaffold/`.
144
+
145
+ - **Tool.** `get-scaffold-skill` returns every file of the skill, keyed by path.
146
+ - **Prompt.** `install-scaffold-skill` (with `agent: "claude"` or `"cursor"`) tells the agent to
147
+ write those files into `.claude/skills/` or `.cursor/skills/`.
148
+
149
+ The skill scaffolds an applet on `@ziprack/sdk`, and its last step publishes that applet through
150
+ `create-upload`.
151
+
152
+ <a name="contributing-tools"></a>
153
+ ## Contributing Tools From a Capability
154
+
155
+ A capability contributes tools the same way it contributes admin routes:
156
+
157
+ 1. Implement `ZipRack\Core\Capabilities\ProvidesMcpTools` on the capability class.
158
+ 2. Return tool class names from `mcpTools()`.
159
+ 3. Have each tool extend `ZipRack\Mcp\Tools\ZipRackTool`. It provides the helpers below.
160
+
161
+ Helpers on `ZipRackTool`:
162
+
163
+ | Helper | |
164
+ |---|---|
165
+ | `applet()` | Resolves the `slug` argument among the applets the caller may see |
166
+ | `visibleApplets()` | A query of the applets the caller may see |
167
+ | `owned()` | Loads a model that belongs to that applet |
168
+ | `callAdmin()` | Runs your admin controller action in-process and returns its JSON |
169
+ | `audit()` | Writes an audit-log row for a write |
170
+
171
+ The tool classes only load when `ziprack/mcp` is installed, so list it under `suggest`, not
172
+ `require`.
173
+
174
+ A tool that only some users should have takes `#[RequiresAbility(Ability::ViewRack)]` (or any
175
+ other ability). Everyone else is neither offered it nor able to call it. Tools that take a slug
176
+ need nothing extra: `applet()` already sees only the caller's applets.
177
+
178
+ <a name="adding-tools-from-the-rack"></a>
179
+ ## Adding Tools From the Rack
180
+
181
+ The Rack can add tools of its own, for what only it knows about, such as its roles or its
182
+ per-applet settings. Register them from a service provider:
183
+
184
+ ```php
185
+ app(ZipRack\Mcp\ToolRegistry::class)->register(SetGuestAccess::class, ListMyApplets::class);
186
+ ```
187
+
188
+ Rack tools extend `ZipRackTool` like any other, so they get the helpers above, the audit log and
189
+ `#[RequiresAbility]`. A Rack tool with the same name (`#[Name('list-applets')]`) as a zipRACK or
190
+ capability tool replaces it.
@@ -0,0 +1,85 @@
1
+ # Media
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Media](#declaring-media)
5
+ - [Rendering a Player](#rendering-a-player)
6
+ - [Why the Rack Builds the URL](#why-the-rack-builds-the-url)
7
+ - [Privacy Defaults](#privacy-defaults)
8
+
9
+ <a name="introduction"></a>
10
+ ## Introduction
11
+
12
+ Vimeo and YouTube embeds are a routine requirement, and they need no capability. What they do need
13
+ is a declaration: the applet names a video by id, and the Rack builds the player URL.
14
+
15
+ Everything else — your own images, audio and video — ships inside the zip and is referenced
16
+ relatively. The CSP allows `img-src`/`media-src` of `'self' data: blob:` plus whatever hosts the
17
+ deployment has allowlisted, and nothing else.
18
+
19
+ <a name="declaring-media"></a>
20
+ ## Declaring Media
21
+
22
+ ```json
23
+ "media": [
24
+ { "id": "corine.diagnosis", "provider": "vimeo", "key": "912345678" },
25
+ { "id": "intro.welcome", "provider": "youtube", "key": "dQw4w9WgXcQ" }
26
+ ]
27
+ ```
28
+
29
+ | Field | Rules |
30
+ |---|---|
31
+ | `id` | Required. `^[a-z0-9][a-z0-9_.:-]*$`, max 150, unique. |
32
+ | `provider` | Required. Must be in the deployment's `ziprack.media.providers` — `vimeo`, `youtube`. |
33
+ | `key` | Required. `^\d+$` for Vimeo, `^[\w-]{11}$` for YouTube. |
34
+
35
+ Validation happens at upload, not at runtime, so a typo in a video id fails the package rather than
36
+ the participant's session. Where `ziprack.media.resolve_oembed` is on, the Rack also performs the
37
+ oEmbed lookup server-side at install and caches the title, thumbnail and duration.
38
+
39
+ <a name="rendering-a-player"></a>
40
+ ## Rendering a Player
41
+
42
+ ```ts
43
+ const url = Rack.media.embedUrl('corine.diagnosis');
44
+ if (url) {
45
+ frame.src = url;
46
+ }
47
+
48
+ const embed = Rack.media.get('corine.diagnosis');
49
+ embed?.meta?.title; // cached oEmbed metadata, when resolved
50
+ embed?.meta?.duration;
51
+
52
+ Rack.media.all(); // every declared embed, by id
53
+ ```
54
+
55
+ Both reads are synchronous — the embeds arrive in launch data.
56
+
57
+ ```html
58
+ <iframe id="player" allow="autoplay; fullscreen; picture-in-picture; encrypted-media"></iframe>
59
+ ```
60
+
61
+ The Rack sets a matching `allow` on the applet's own iframe. Permissions Policy has to be chained
62
+ through both frames: if the outer frame does not carry it, your nested player silently loses
63
+ autoplay and fullscreen, which is a genuinely hard bug to diagnose from inside the applet.
64
+
65
+ Nested players inherit the applet's sandbox flags, so the player also runs with an opaque origin —
66
+ no cookies, no storage of its own. Both providers' JS APIs are `postMessage`-driven, so they
67
+ continue to work. A nested frame cannot talk to the Rack: the bridge checks `event.source`.
68
+
69
+ <a name="why-the-rack-builds-the-url"></a>
70
+ ## Why the Rack Builds the URL
71
+
72
+ Because a URL the applet controls is a channel the applet controls. An applet could otherwise encode
73
+ data in a player URL — `player.vimeo.com/video/123?h=<something>` — and while that request lands in
74
+ a provider's logs the attacker cannot read, the cleanest answer is to remove the path and query
75
+ string from the applet's reach entirely. With declared media there is nothing to encode.
76
+
77
+ It also buys back `connect-src 'self'`: the oEmbed lookup happens server-side at upload, so the
78
+ applet never needs to talk to `vimeo.com` at runtime.
79
+
80
+ <a name="privacy-defaults"></a>
81
+ ## Privacy Defaults
82
+
83
+ The Rack builds YouTube URLs against `youtube-nocookie.com` and Vimeo URLs with `dnt=1`. For pharma
84
+ and financial-services clients this is frequently procurement-gating rather than a nicety, and it is
85
+ not configurable per applet.
@@ -0,0 +1,121 @@
1
+ # The Bridge Protocol
2
+
3
+ - [When You Need This](#when-you-need-this)
4
+ - [The Envelope](#the-envelope)
5
+ - [Handshake](#handshake)
6
+ - [Calls and Acks](#calls-and-acks)
7
+ - [Streaming](#streaming)
8
+ - [Events](#events)
9
+ - [What the Shell Does](#what-the-shell-does)
10
+
11
+ <a name="when-you-need-this"></a>
12
+ ## When You Need This
13
+
14
+ Almost never. Use [`@ziprack/sdk`](sdk.md) — it is the reference implementation of everything below,
15
+ plus mock mode and a test host. This page exists for two cases: you are implementing the applet side
16
+ in a language or runtime the SDK does not cover, and you are working on the Rack itself.
17
+
18
+ <a name="the-envelope"></a>
19
+ ## The Envelope
20
+
21
+ Every message in both directions is one JSON object:
22
+
23
+ ```json
24
+ { "ns": "ziprack", "v": 1, "type": "activity:statement", "requestId": "b1f0…", "payload": { } }
25
+ ```
26
+
27
+ | Field | Rules |
28
+ |---|---|
29
+ | `ns` | Always `ziprack`. Anything else is ignored. |
30
+ | `v` | Always `1`. Versioned with the format string, not with capabilities. |
31
+ | `type` | `rack:*` for lifecycle, or `{capability}:{action}` matching `^[a-z][a-z0-9_]*:[a-z][a-z0-9_.]*$`. |
32
+ | `requestId` | Present when you want an ack. The SDK uses `crypto.randomUUID()`. |
33
+ | `payload` | Type-specific object. |
34
+
35
+ The applet posts to `window.parent` with target origin `*`, because it has no origin of its own to
36
+ assert. **The host trusts by `event.source`**: a message is honoured only when it came from the
37
+ applet iframe's `contentWindow`. `event.origin` is never consulted — the applet's origin is opaque
38
+ by design, so comparing it would be theatre. The same rule protects the applet in reverse: ignore
39
+ any message whose source is not `window.parent`, which is what makes a nested Vimeo player unable to
40
+ impersonate the Rack.
41
+
42
+ <a name="handshake"></a>
43
+ ## Handshake
44
+
45
+ ```
46
+ applet → host { type: "rack:ready" }
47
+ host → applet { type: "rack:launch", payload: { …launch data… } }
48
+ ```
49
+
50
+ The applet announces readiness as early as it can; the host replies with the full launch payload
51
+ (see [Launch Data](sdk.md#launch-data)). Do not send capability calls before `rack:launch` arrives —
52
+ queue them, as the SDK does.
53
+
54
+ <a name="calls-and-acks"></a>
55
+ ## Calls and Acks
56
+
57
+ ```
58
+ applet → host { type: "db:create", requestId: "r1", payload: { collection: "ideas", data: {…} } }
59
+ host → applet { type: "rack:ack", requestId: "r1", payload: { ok: true, data: { record: {…} } } }
60
+ ```
61
+
62
+ An ack always carries the `requestId` it answers. On failure:
63
+
64
+ ```json
65
+ { "ok": false, "error": "quota_exceeded", "message": "Collection [ideas] is full (500 records)." }
66
+ ```
67
+
68
+ Error codes are listed in [Acks and Errors](sdk.md#acks-and-errors) and are part of the contract:
69
+ they do not change within `ziprack/1`.
70
+
71
+ A message sent without a `requestId` is fire-and-forget — the host processes it and sends nothing
72
+ back. `rack:eject` is the usual example.
73
+
74
+ <a name="streaming"></a>
75
+ ## Streaming
76
+
77
+ A streamed call receives multiple acks under one `requestId`:
78
+
79
+ ```json
80
+ { "ok": true, "partial": true, "delta": "Well, " }
81
+ { "ok": true, "partial": true, "delta": "that depends…" }
82
+ { "ok": true, "done": true, "data": { "message_id": "m_1", "text": "Well, that depends…", "usage": {…} } }
83
+ ```
84
+
85
+ Only the terminal ack resolves the call; each partial resets the ack timeout. `ai:chat` is the only
86
+ streaming surface in `ziprack/1`.
87
+
88
+ <a name="events"></a>
89
+ ## Events
90
+
91
+ Unsolicited messages from the host carry no `requestId`:
92
+
93
+ ```json
94
+ { "ns": "ziprack", "v": 1, "type": "rack:event",
95
+ "payload": { "event": "advance", "payload": { "step": 3 }, "channel": "main",
96
+ "from": { "participant_id": 42, "role": "facilitator" } } }
97
+ ```
98
+
99
+ The reserved channel `$room` carries Rack-generated room events — `presence`, `state`, `role`,
100
+ `status`. An applet cannot publish to it.
101
+
102
+ <a name="what-the-shell-does"></a>
103
+ ## What the Shell Does
104
+
105
+ Between the frame and the Rack sits the shell, a small script the Rack serves with the player page.
106
+ Knowing its behaviour explains timings you would otherwise find surprising:
107
+
108
+ - **Batching.** Calls are queued for up to 300 ms and posted together to
109
+ `POST /ziprack/s/{session}/messages`, maximum 50 per request. Acks come back in order and are
110
+ relayed individually.
111
+ - **Coalescing.** Only the latest pending `attempt:state` survives a batch, so calling `setState()`
112
+ on every keystroke is safe.
113
+ - **Retry.** A failed batch is retried once after 1 s; after that every message in it acks
114
+ `persist_failed`.
115
+ - **Keepalive.** On page hide the pending batch flushes with `keepalive: true`.
116
+ - **Files.** `storage:put` is intercepted and re-sent as multipart to
117
+ `POST /ziprack/s/{session}/files`; the `File` never travels as JSON.
118
+ - **Streams.** When an ack carries `data.stream === true`, the shell opens an `EventSource` against
119
+ `/ziprack/s/{session}/stream/{requestId}` and relays each chunk as a partial ack.
120
+ - **Completion.** A successful `attempt:complete` flips the Rack's own completion chrome — which is
121
+ why a completed attempt lands on its final screen rather than restarting.