@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.
- package/dist/db/index.d.ts +2 -1
- package/dist/docs/README.md +56 -0
- package/dist/docs/activity.md +144 -0
- package/dist/docs/ai.md +159 -0
- package/dist/docs/applet-packages.md +118 -0
- package/dist/docs/attempts.md +187 -0
- package/dist/docs/auth.md +106 -0
- package/dist/docs/broadcast.md +226 -0
- package/dist/docs/conformance.md +102 -0
- package/dist/docs/database.md +230 -0
- package/dist/docs/extending.md +261 -0
- package/dist/docs/installation.md +186 -0
- package/dist/docs/introduction.md +124 -0
- package/dist/docs/manifest.md +210 -0
- package/dist/docs/mcp.md +190 -0
- package/dist/docs/media.md +85 -0
- package/dist/docs/protocol.md +121 -0
- package/dist/docs/publishing.md +127 -0
- package/dist/docs/reporting.md +190 -0
- package/dist/docs/sdk.md +258 -0
- package/dist/docs/security.md +153 -0
- package/dist/docs/storage.md +131 -0
- package/dist/docs/studio.md +425 -0
- package/dist/docs/versioning.md +101 -0
- package/dist/docs/ziprack-1-authoring.md +182 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +3 -0
- package/dist/mcp/docs.d.ts +31 -0
- package/dist/mcp/docs.js +107 -0
- package/dist/mcp/index.d.ts +4 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/rpc.d.ts +33 -0
- package/dist/mcp/rpc.js +86 -0
- package/dist/mcp/server.d.ts +23 -0
- package/dist/mcp/server.js +55 -0
- package/dist/mcp/tools.d.ts +20 -0
- package/dist/mcp/tools.js +106 -0
- package/dist/ziprack-sdk.iife.js.map +1 -1
- package/package.json +10 -4
package/dist/db/index.d.ts
CHANGED
|
@@ -14,7 +14,8 @@ export interface ListQuery {
|
|
|
14
14
|
export interface RecordRow<T extends object = JsonObject> {
|
|
15
15
|
id: string;
|
|
16
16
|
data: T;
|
|
17
|
-
|
|
17
|
+
/** The participant who created it; null for records seeded from the package. */
|
|
18
|
+
owner_id: number | null;
|
|
18
19
|
created_at: string;
|
|
19
20
|
updated_at: string;
|
|
20
21
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# zipRACK Documentation
|
|
2
|
+
|
|
3
|
+
zipRACK is a micro app framework: a Laravel host — **the Rack** — that runs sandboxed static web
|
|
4
|
+
apps — **applets** — and services their capability requests over `postMessage`. An applet owns
|
|
5
|
+
everything the participant sees. The Rack owns login, persistence, AI access, real-time coordination
|
|
6
|
+
and reporting.
|
|
7
|
+
|
|
8
|
+
These docs cover both halves. If you are building an applet, start with
|
|
9
|
+
[Applet Packages](applet-packages.md). If you are standing up or maintaining a Rack, start with
|
|
10
|
+
[Installation](installation.md).
|
|
11
|
+
|
|
12
|
+
## Prologue
|
|
13
|
+
|
|
14
|
+
- [Introduction](introduction.md) — the trust boundary, the core model, the objects
|
|
15
|
+
- [Versioning](versioning.md) — the three version axes and why they never couple
|
|
16
|
+
|
|
17
|
+
## Building an applet
|
|
18
|
+
|
|
19
|
+
- [Applet Packages](applet-packages.md) — the zip, the installer rules, the starter
|
|
20
|
+
- [The Manifest](manifest.md) — every field, every rule the validator enforces
|
|
21
|
+
- [The JavaScript SDK](sdk.md) — `Rack.init()`, acks, errors, mock mode, testing
|
|
22
|
+
- [zipRACK Studio](studio.md) — the visual builder, the derived manifest, the MCP server
|
|
23
|
+
- [The Bridge Protocol](protocol.md) — the envelope, for anyone not using the SDK
|
|
24
|
+
|
|
25
|
+
## Capabilities
|
|
26
|
+
|
|
27
|
+
- [Authentication](auth.md) — `auth@1`
|
|
28
|
+
- [Attempts](attempts.md) — `attempt@1`
|
|
29
|
+
- [Activity](activity.md) — `activity@1`
|
|
30
|
+
- [Database](database.md) — `db@1`
|
|
31
|
+
- [Storage](storage.md) — `storage@1`
|
|
32
|
+
- [AI](ai.md) — `ai@1`
|
|
33
|
+
- [Broadcast](broadcast.md) — `broadcast@1`
|
|
34
|
+
- [Media](media.md) — declared embeds, no capability required
|
|
35
|
+
|
|
36
|
+
## Running a Rack
|
|
37
|
+
|
|
38
|
+
- [Installation](installation.md) — composing a Rack from capability packages
|
|
39
|
+
- [Security](security.md) — the sandbox, the CSP, the threats it does not cover
|
|
40
|
+
- [Publishing](publishing.md) — install, capability diff, review queue, promotion, kill switch
|
|
41
|
+
- [Reporting](reporting.md) — the admin API, CSV export, metrics
|
|
42
|
+
- [Who may do what](reporting.md#who-may-do-what) — `AppletAccess`, abilities, applet owners
|
|
43
|
+
- [Conformance](conformance.md) — proving a deployment is still a Rack
|
|
44
|
+
- [The Rack MCP Server](mcp.md) — agents that read applet data and upload packages, over OAuth or minted tokens
|
|
45
|
+
|
|
46
|
+
## Extending
|
|
47
|
+
|
|
48
|
+
- [Writing a Capability](extending.md) — the `Capability` interface, end to end
|
|
49
|
+
- [Contributing MCP tools](mcp.md#contributing-tools) — `ProvidesMcpTools`
|
|
50
|
+
- [Adding MCP tools from the Rack](mcp.md#adding-tools-from-the-rack) — `ToolRegistry`
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
Also in this repository: [`ziprack-1-authoring.md`](ziprack-1-authoring.md), a one-page condensed
|
|
55
|
+
brief suitable for dropping into an applet project as `CLAUDE.md`. The same content, expanded, is
|
|
56
|
+
published to the CN IDEAS skills library as an instruction set.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Activity — `activity@1`
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Declaring Activities](#declaring-activities)
|
|
5
|
+
- [Emitting Statements](#emitting-statements)
|
|
6
|
+
- [Activity Types](#activity-types)
|
|
7
|
+
- [Metrics](#metrics)
|
|
8
|
+
- [Designing for the Export](#designing-for-the-export)
|
|
9
|
+
|
|
10
|
+
<a name="introduction"></a>
|
|
11
|
+
## Introduction
|
|
12
|
+
|
|
13
|
+
A **statement** is an append-only record that *an actor did a verb on an activity with a result*.
|
|
14
|
+
It is the audit trail, and because every statement has the same shape, it is also the one export
|
|
15
|
+
that works identically for every applet on every Rack.
|
|
16
|
+
|
|
17
|
+
Statements are never updated and never deleted. Re-answering is normal: reporting reads the **last**
|
|
18
|
+
`answered` statement per activity per attempt.
|
|
19
|
+
|
|
20
|
+
`db@1` does not replace this. Statements are what happened; [records](database.md) are what is
|
|
21
|
+
currently true.
|
|
22
|
+
|
|
23
|
+
<a name="declaring-activities"></a>
|
|
24
|
+
## Declaring Activities
|
|
25
|
+
|
|
26
|
+
Every question you report on must be declared. At least one activity is required.
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"activities": [
|
|
30
|
+
{ "id": "intro.connection", "type": "rating", "label": "How connected do you feel to patients?",
|
|
31
|
+
"scale": { "min": 1, "max": 6 }, "sequence": 1 },
|
|
32
|
+
{ "id": "rd.corine.reaction", "type": "text", "label": "What stood out to you about Corine's story?",
|
|
33
|
+
"segment": "track", "sequence": 2 }
|
|
34
|
+
]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Field | Rules |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `id` | Required, max 150, unique. **This is the data contract.** |
|
|
40
|
+
| `type` | Required, max 100. Drives how the admin renders and exports the value. |
|
|
41
|
+
| `label` | Required, max 1000. Becomes the CSV header and the analysis heading. |
|
|
42
|
+
| `scale` | Optional object, for `rating` and `scale` types. |
|
|
43
|
+
| `segment` | Optional. Must name a declared [segment](attempts.md#segments) key, or be `null` for shared. |
|
|
44
|
+
| `sequence` | Optional integer ≥ 0. Controls export column order; defaults to array position. |
|
|
45
|
+
|
|
46
|
+
Use stable, dotted, lowercase ids: `{phase}.{topic}.{question}`. An undeclared id still records, but
|
|
47
|
+
it exports unlabeled and appended after every declared column — useful as a safety net, never as a
|
|
48
|
+
plan.
|
|
49
|
+
|
|
50
|
+
<a name="emitting-statements"></a>
|
|
51
|
+
## Emitting Statements
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// The common case: a committed answer.
|
|
55
|
+
await Rack.activity.answer('intro.connection', 5);
|
|
56
|
+
|
|
57
|
+
// Navigation and progress. Fire and forget.
|
|
58
|
+
void Rack.activity.progress('rd.chapter_2');
|
|
59
|
+
|
|
60
|
+
// Anything else.
|
|
61
|
+
void Rack.activity.statement({
|
|
62
|
+
verb: 'viewed',
|
|
63
|
+
activity: { id: 'rd.corine.video', type: 'media' },
|
|
64
|
+
result: { duration_seconds: 184 },
|
|
65
|
+
context: { autoplay: false },
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`answer()` emits `verb: 'answered'` with the value in `result.response` — **the only field reporting
|
|
70
|
+
reads** — and fills `activity.type` from your manifest declaration. `progress()` emits
|
|
71
|
+
`verb: 'progressed'` with `activity.type: 'step'`.
|
|
72
|
+
|
|
73
|
+
Verbs match `^[a-z][a-z0-9_.:-]*$`. `occurred_at` defaults to now, in ISO 8601; pass it yourself only
|
|
74
|
+
when replaying something that genuinely happened earlier.
|
|
75
|
+
|
|
76
|
+
A statement emitted while staff [preview](attempts.md#preview-runs) the applet is stored with
|
|
77
|
+
`is_preview` set, with or without an attempt, so an audit trail can leave it out. The applet does
|
|
78
|
+
nothing different.
|
|
79
|
+
|
|
80
|
+
Read your own declarations when you need them:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
Rack.activity.activities(); // LaunchActivity[]
|
|
84
|
+
Rack.activity.activity('intro.connection'); // { id, type, label, scale, … } | null
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Rendering a question from its declared `label` and `scale` keeps the participant's wording and the
|
|
88
|
+
CSV header identical by construction — a small discipline that pays for itself the first time
|
|
89
|
+
someone edits a question.
|
|
90
|
+
|
|
91
|
+
<a name="activity-types"></a>
|
|
92
|
+
## Activity Types
|
|
93
|
+
|
|
94
|
+
`type` is free text, but the admin understands these:
|
|
95
|
+
|
|
96
|
+
| `type` | `result.response` | Used for |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `text` | string | Free text. Feeds admin AI response analysis. |
|
|
99
|
+
| `rating` | number within `scale` | Likert or self-rating. Eligible for metrics. |
|
|
100
|
+
| `scale` | number within `scale` | Semantic alias of `rating`. |
|
|
101
|
+
| `choice` | string key, or array of keys | Single or multi-select. |
|
|
102
|
+
| `custom` | any JSON | Anything else. Exported JSON-encoded. |
|
|
103
|
+
|
|
104
|
+
<a name="metrics"></a>
|
|
105
|
+
## Metrics
|
|
106
|
+
|
|
107
|
+
A metric pairs a pre and a post rating of the same thing, and gives the admin a shift card rather
|
|
108
|
+
than two columns to eyeball.
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
"reporting": {
|
|
112
|
+
"metrics": [
|
|
113
|
+
{ "key": "connection", "label": "Connection",
|
|
114
|
+
"pre": "intro.connection", "post": "outro.connection" }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`pre` and `post` must be declared activity ids, and metrics require `attempt@1`, because they are
|
|
120
|
+
computed over completed attempts. Both activities should use the same `scale`, or the shift means
|
|
121
|
+
nothing.
|
|
122
|
+
|
|
123
|
+
<a name="designing-for-the-export"></a>
|
|
124
|
+
## Designing for the Export
|
|
125
|
+
|
|
126
|
+
The CSV an admin downloads is:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
ID, Name, Email, {first segment label}, Status, Started At, Completed At,
|
|
130
|
+
{declared activities in sequence order, headed by label},
|
|
131
|
+
{undeclared activity ids, appended}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Each cell is the last `result.response` for that activity in that attempt; non-scalars are
|
|
135
|
+
JSON-encoded. Which yields three rules worth writing on the wall:
|
|
136
|
+
|
|
137
|
+
1. **`id` is the data contract.** Renaming one between versions splits a column: old attempts keep
|
|
138
|
+
the old column, new attempts fill a new one.
|
|
139
|
+
2. **`label` is the header.** Renaming one is free, and changes only how the column reads.
|
|
140
|
+
3. **`sequence` is the column order.** Set it deliberately; the export reads top to bottom the way
|
|
141
|
+
the experience does.
|
|
142
|
+
|
|
143
|
+
Write labels as full sentences, exactly as the participant sees them. An admin reading a
|
|
144
|
+
spreadsheet six months later has no other copy of the question.
|
package/dist/docs/ai.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# AI — `ai@1`
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [Declaring Prompts](#declaring-prompts)
|
|
5
|
+
- [Analysis](#analysis)
|
|
6
|
+
- [Chat](#chat)
|
|
7
|
+
- [Budgets](#budgets)
|
|
8
|
+
- [Untrusted Variables](#untrusted-variables)
|
|
9
|
+
- [Not This Capability](#not-this-capability)
|
|
10
|
+
|
|
11
|
+
<a name="introduction"></a>
|
|
12
|
+
## Introduction
|
|
13
|
+
|
|
14
|
+
**The applet never sees a key and never sends a prompt.** It calls a prompt template the manifest
|
|
15
|
+
declared and the Rack stores, supplying *variables*, never instructions.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const { data } = await Rack.ai.ask('score_pitch', { pitch, track });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Four things fall out of that design at once:
|
|
22
|
+
|
|
23
|
+
- Prompt injection *from the applet* is structurally impossible — there is no instruction channel.
|
|
24
|
+
- Model, system prompt and output schema are Rack-owned, so they can be tuned or swapped without
|
|
25
|
+
republishing the applet.
|
|
26
|
+
- Cost is governable: hard caps per prompt, per attempt, per participant, per applet, enforced
|
|
27
|
+
server-side.
|
|
28
|
+
- **No CSP change is required.** The call travels as a `postMessage`, not a `fetch`.
|
|
29
|
+
|
|
30
|
+
<a name="declaring-prompts"></a>
|
|
31
|
+
## Declaring Prompts
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
"prompts": [
|
|
35
|
+
{
|
|
36
|
+
"id": "score_pitch",
|
|
37
|
+
"mode": "analysis",
|
|
38
|
+
"template": "Score this team pitch for clarity and specificity.\n\nPitch: {{pitch}}\nTrack: {{track}}",
|
|
39
|
+
"vars": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": { "pitch": { "type": "string" }, "track": { "type": "string" } },
|
|
42
|
+
"required": ["pitch"]
|
|
43
|
+
},
|
|
44
|
+
"output_schema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"clarity": { "type": "integer", "minimum": 1, "maximum": 5 },
|
|
48
|
+
"feedback": { "type": "string" }
|
|
49
|
+
},
|
|
50
|
+
"required": ["clarity", "feedback"]
|
|
51
|
+
},
|
|
52
|
+
"budget": { "max_calls_per_attempt": 3, "max_output_tokens": 400 }
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Field | Rules |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `id` | Required. `^[a-z][a-z0-9_.-]*$`, max 100, unique. |
|
|
60
|
+
| `mode` | Required. `analysis` or `chat`. |
|
|
61
|
+
| `template` | Required, max 20 000. `{{placeholder}}` for each variable. |
|
|
62
|
+
| `vars` | Optional JSON Schema (`type: object`). **If present, every placeholder must be declared in it.** |
|
|
63
|
+
| `output_schema` | Required for `analysis`, `type: object`. |
|
|
64
|
+
| `budget` | Optional. `max_calls_per_attempt`, `max_calls_per_participant`, `max_output_tokens`. |
|
|
65
|
+
| `model` | Optional hint. Honoured only if the deployment allowlists it. |
|
|
66
|
+
|
|
67
|
+
<a name="analysis"></a>
|
|
68
|
+
## Analysis
|
|
69
|
+
|
|
70
|
+
Structured output, conforming to the declared `output_schema`. For scoring a response, summarising
|
|
71
|
+
back to the participant, or branching on what someone wrote.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
interface Score { clarity: number; feedback: string }
|
|
75
|
+
|
|
76
|
+
const result = await Rack.ai.ask<Score>('score_pitch', { pitch, track: 'rd' });
|
|
77
|
+
|
|
78
|
+
if (! result.ok) {
|
|
79
|
+
return result.error === 'budget_exceeded'
|
|
80
|
+
? showGenericFeedback()
|
|
81
|
+
: showRetry();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
render(result.data.output.feedback, result.data.output.clarity);
|
|
85
|
+
result.data.usage; // { input_tokens, output_tokens }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The Rack validates the model's output against your schema before it acks, so `output` either matches
|
|
89
|
+
the schema or the call fails. You do not need defensive parsing — you do need a path for the failure
|
|
90
|
+
case, because a model can be unavailable.
|
|
91
|
+
|
|
92
|
+
Always write the non-AI fallback. An applet whose experience stalls when a call fails has made an
|
|
93
|
+
LLM into a dependency the framework was designed to keep optional.
|
|
94
|
+
|
|
95
|
+
<a name="chat"></a>
|
|
96
|
+
## Chat
|
|
97
|
+
|
|
98
|
+
Conversational, with history held by the Rack and scoped to the attempt or room. The applet never
|
|
99
|
+
holds the transcript.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const chat = Rack.ai.chat('coach');
|
|
103
|
+
|
|
104
|
+
const reply = await chat.send(message, (delta, soFar) => {
|
|
105
|
+
bubble.textContent = soFar; // streams as it arrives
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (reply.ok) {
|
|
109
|
+
bubble.textContent = reply.data.text;
|
|
110
|
+
reply.data.message_id;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await chat.reset(); // start a fresh conversation
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Streaming works through chunked acks: partials carry `delta`, the terminal ack carries the full
|
|
117
|
+
`text` and `usage`. Each chunk resets the ack timeout, and the whole call allows 60 s.
|
|
118
|
+
|
|
119
|
+
Messages cap at 4000 characters and history at 40 messages (`ziprack-ai.max_message_length`,
|
|
120
|
+
`history_messages`); older turns fall off rather than failing the call.
|
|
121
|
+
|
|
122
|
+
<a name="budgets"></a>
|
|
123
|
+
## Budgets
|
|
124
|
+
|
|
125
|
+
Every layer is enforced server-side, and the applet cannot raise any of them:
|
|
126
|
+
|
|
127
|
+
| Limit | Declared in | Default | Ceiling |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| Calls per attempt | `budget.max_calls_per_attempt` | 20 | — |
|
|
130
|
+
| Calls per participant | `budget.max_calls_per_participant` | 200 | — |
|
|
131
|
+
| Output tokens | `budget.max_output_tokens` | 800 | 2000 (`ziprack-ai.budget.max_output_tokens_cap`) |
|
|
132
|
+
| Calls per applet per day | deployment | 5000 | — |
|
|
133
|
+
| Calls per Rack per month | deployment | 100 000 | — |
|
|
134
|
+
| Variable payload | deployment | 16 KB | — |
|
|
135
|
+
|
|
136
|
+
Exceeding any of them returns `budget_exceeded`. Declare the budget your experience actually needs:
|
|
137
|
+
a lower cap is a smaller incident, and the number is visible to whoever reviews the capability diff.
|
|
138
|
+
|
|
139
|
+
Spend is reported per prompt in the admin API (`/ziprack/admin/applets/{applet}/ai/spend`).
|
|
140
|
+
|
|
141
|
+
<a name="untrusted-variables"></a>
|
|
142
|
+
## Untrusted Variables
|
|
143
|
+
|
|
144
|
+
Variables still reach a model. Assume a participant will type adversarial text into any field you
|
|
145
|
+
pass through, and design the template so that it would not matter:
|
|
146
|
+
|
|
147
|
+
- Put participant text **after** the instructions, clearly delimited, and say what it is.
|
|
148
|
+
- Never ask the model to follow instructions found inside a variable.
|
|
149
|
+
- Prefer `analysis` with a tight `output_schema` over free-form text you will parse yourself — a
|
|
150
|
+
schema-constrained answer is a much smaller blast radius.
|
|
151
|
+
- Show model output as model output. Do not render it as if it were Rack-authored guidance.
|
|
152
|
+
|
|
153
|
+
<a name="not-this-capability"></a>
|
|
154
|
+
## Not This Capability
|
|
155
|
+
|
|
156
|
+
The admin console's **AI Response Analysis** — summaries of free-text answers across attempts — is a
|
|
157
|
+
Rack feature operating on collected [statements](activity.md). It works for any applet declaring
|
|
158
|
+
`activity@1`, whether or not that applet uses `ai@1` at all, and it is configured and budgeted
|
|
159
|
+
separately. See [Reporting](reporting.md).
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Applet Packages
|
|
2
|
+
|
|
3
|
+
- [The Zip](#the-zip)
|
|
4
|
+
- [Installer Rules](#installer-rules)
|
|
5
|
+
- [The Starter](#the-starter)
|
|
6
|
+
- [Relative URLs](#relative-urls)
|
|
7
|
+
- [The SBOM](#the-sbom)
|
|
8
|
+
- [Building and Packaging](#building-and-packaging)
|
|
9
|
+
|
|
10
|
+
<a name="the-zip"></a>
|
|
11
|
+
## The Zip
|
|
12
|
+
|
|
13
|
+
An applet ships as a single zip, named for the applet and the version it contains:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
{slug}-{version}.zip
|
|
17
|
+
├── manifest.json required, at the zip root
|
|
18
|
+
├── sbom.json required — CycloneDX, or package-lock.json
|
|
19
|
+
├── index.html the entry (configurable via manifest.entry)
|
|
20
|
+
└── assets/… everything the entry references, by RELATIVE path
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Nothing else is special. The Rack extracts the archive, keeps the file tree intact, and serves it
|
|
24
|
+
through a token-prefixed path. There is no build step on the server, no server-side templating, no
|
|
25
|
+
framework to satisfy — whatever your bundler emits is what runs.
|
|
26
|
+
|
|
27
|
+
<a name="installer-rules"></a>
|
|
28
|
+
## Installer Rules
|
|
29
|
+
|
|
30
|
+
`PackageInstaller` is security code, identical on every Rack, and it refuses the whole upload rather
|
|
31
|
+
than skipping a bad file:
|
|
32
|
+
|
|
33
|
+
- **Static extensions only.** `html js mjs css map json wasm txt vtt svg png jpg jpeg gif webp ico
|
|
34
|
+
woff woff2 ttf mp3 mp4 wav webm ogg pdf`. A `.php` in your `dist/` rejects the package.
|
|
35
|
+
- **No `..`, no absolute paths, no symlinks, no NUL bytes.** Dotfiles and `__MACOSX/` are skipped
|
|
36
|
+
silently, so a zip built on macOS is fine.
|
|
37
|
+
- **50 MB by default** (`ziprack.max_upload_mb`).
|
|
38
|
+
- **`manifest.entry` must exist** in the zip and end in `.html`.
|
|
39
|
+
- **The slug must match** the applet you are uploading to, and it is immutable once created.
|
|
40
|
+
- **A `version` string uploads once per applet.** Bump it every time, including during testing.
|
|
41
|
+
|
|
42
|
+
Installation runs inside a transaction: the version row is created, every declared capability's
|
|
43
|
+
`onInstall()` hook runs — this is where `db@1` creates the collection tables and their generated
|
|
44
|
+
columns — and only then are files extracted. A failure anywhere rolls the whole thing back and
|
|
45
|
+
deletes the extracted directory.
|
|
46
|
+
|
|
47
|
+
<a name="the-starter"></a>
|
|
48
|
+
## The Starter
|
|
49
|
+
|
|
50
|
+
`starter/` is a minimal, working `ziprack/1` applet: plain Vite and TypeScript, `@ziprack/sdk`, one
|
|
51
|
+
question, one statement, one completion.
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm install
|
|
55
|
+
npm run dev # mock mode: the whole experience runs standalone
|
|
56
|
+
npm run package # dist-zip/{slug}-{version}.zip, ready to upload
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Edit `public/manifest.json` first — `slug`, `name`, `version`, and the capabilities you intend to
|
|
60
|
+
call. Everything else follows from there.
|
|
61
|
+
|
|
62
|
+
<a name="relative-urls"></a>
|
|
63
|
+
## Relative URLs
|
|
64
|
+
|
|
65
|
+
The Rack serves your package from `/ziprack/pkg/{token}/…`, where `{token}` is a signed, expiring
|
|
66
|
+
value. A root-relative URL like `/assets/app.js` therefore 404s in production while working
|
|
67
|
+
perfectly on your dev server — the single most common packaging mistake.
|
|
68
|
+
|
|
69
|
+
With Vite, one line prevents it:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
export default defineConfig({
|
|
73
|
+
base: './', // required
|
|
74
|
+
build: { outDir: 'dist', emptyOutDir: true },
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The same applies to anything you hand-write: `./assets/logo.svg`, never `/assets/logo.svg`.
|
|
79
|
+
|
|
80
|
+
<a name="the-sbom"></a>
|
|
81
|
+
## The SBOM
|
|
82
|
+
|
|
83
|
+
Every package carries a `sbom.json` (CycloneDX) or a `package-lock.json` at its root. An applet is
|
|
84
|
+
*built output*, so there is nothing to audit unless the lockfile comes with it — the same reason
|
|
85
|
+
`npm audit` reads a lockfile rather than code.
|
|
86
|
+
|
|
87
|
+
On install the Rack extracts the purl list and batches it against OSV.dev. Findings land in the
|
|
88
|
+
[review queue](publishing.md) rather than blocking the upload.
|
|
89
|
+
|
|
90
|
+
Be clear about the limit: the SBOM is self-attested. A hostile publisher can ship an accurate
|
|
91
|
+
lockfile alongside a tampered bundle. This catches negligence, not malice — which is still worth
|
|
92
|
+
having, because negligence is the common case.
|
|
93
|
+
|
|
94
|
+
<a name="building-and-packaging"></a>
|
|
95
|
+
## Building and Packaging
|
|
96
|
+
|
|
97
|
+
`npm run package` in the starter does the whole job:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
// scripts/package.js — abbreviated
|
|
101
|
+
const manifest = JSON.parse(readFileSync('dist/manifest.json', 'utf8'));
|
|
102
|
+
|
|
103
|
+
execSync('npm sbom --sbom-format cyclonedx --omit dev', { cwd: lockDir }) // → dist/sbom.json
|
|
104
|
+
execSync(`zip -r -X "${zipPath}" . -x ".*" -x "__MACOSX*"`, { cwd: 'dist' });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Two details worth keeping if you write your own: `-X` drops extra file attributes, and excluding
|
|
108
|
+
dotfiles keeps `.DS_Store` out of an archive the installer would otherwise skip anyway.
|
|
109
|
+
|
|
110
|
+
Upload the result through the admin API, or from the deployment:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
php artisan ziprack:install dist-zip/my-applet-1.0.0.zip
|
|
114
|
+
php artisan ziprack:promote my-applet 1.0.0
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Uploads land **unpublished**. Promotion is always a separate, deliberate action — see
|
|
118
|
+
[Publishing](publishing.md).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Attempts — `attempt@1`
|
|
2
|
+
|
|
3
|
+
- [Introduction](#introduction)
|
|
4
|
+
- [The Lifecycle](#the-lifecycle)
|
|
5
|
+
- [Resume State](#resume-state)
|
|
6
|
+
- [Segments](#segments)
|
|
7
|
+
- [Preview Runs](#preview-runs)
|
|
8
|
+
- [Completion](#completion)
|
|
9
|
+
- [Version Pinning](#version-pinning)
|
|
10
|
+
|
|
11
|
+
<a name="introduction"></a>
|
|
12
|
+
## Introduction
|
|
13
|
+
|
|
14
|
+
An **attempt** is one participant's run of an applet. It holds three things: a `status`, a `state`
|
|
15
|
+
blob you own, and a `segment` map. The Rack creates it on first launch, hands it back on every
|
|
16
|
+
subsequent launch, and freezes it when you complete it.
|
|
17
|
+
|
|
18
|
+
Declaring `attempt@1` is also the only way an applet becomes version-pinned. That is not a side
|
|
19
|
+
note; see [Version Pinning](#version-pinning).
|
|
20
|
+
|
|
21
|
+
An applet without `attempt@1` is an *app*: no run, no completion, everyone always on the current
|
|
22
|
+
version.
|
|
23
|
+
|
|
24
|
+
<a name="the-lifecycle"></a>
|
|
25
|
+
## The Lifecycle
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
launch → in_progress → …work… → complete() → completed → (relaunch lands on the final screen)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const launch = await Rack.init({ slug: 'my-applet' });
|
|
33
|
+
|
|
34
|
+
Rack.attempt.id; // number | null
|
|
35
|
+
Rack.attempt.status; // 'in_progress' | 'completed' | null
|
|
36
|
+
Rack.attempt.state(); // your blob, exactly as last saved
|
|
37
|
+
Rack.attempt.segment(); // Record<string, string> | null
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every applet declaring `attempt@1` owes the participant this at boot:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
if (Rack.attempt.status === 'completed') {
|
|
44
|
+
return renderFinalScreen();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const saved = Rack.attempt.state<State>() ?? initialState();
|
|
48
|
+
renderStep(saved.step);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
<a name="resume-state"></a>
|
|
52
|
+
## Resume State
|
|
53
|
+
|
|
54
|
+
`state` is a JSON object, capped at **64 KB encoded** (`ziprack-attempt.max_state_bytes`). It is for
|
|
55
|
+
resuming position, plus anything cheap that makes a return visit feel continuous — a draft in a
|
|
56
|
+
textarea, a scroll offset, which cards are flipped.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
void Rack.attempt.setState({ step: 4, draft: textarea.value });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Call it after every transition. You do not need to debounce: the shell coalesces pending
|
|
63
|
+
`attempt:state` messages so only the latest value is sent in each batch. You do not need to await
|
|
64
|
+
it either — the local copy updates immediately, so `Rack.attempt.state()` reflects your write even
|
|
65
|
+
before the ack lands.
|
|
66
|
+
|
|
67
|
+
**State is not data capture.** An answer the participant committed belongs in a
|
|
68
|
+
[statement](activity.md); if it exists only in `state`, it will not appear in any export, and a
|
|
69
|
+
completed attempt will freeze it out of reach. The test: if someone would want it in a CSV, it is a
|
|
70
|
+
statement.
|
|
71
|
+
|
|
72
|
+
<a name="segments"></a>
|
|
73
|
+
## Segments
|
|
74
|
+
|
|
75
|
+
Segments are the dimensions admins filter and group by — track, cohort, region. Declare them:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
"segments": [
|
|
79
|
+
{
|
|
80
|
+
"key": "track",
|
|
81
|
+
"label": "Track",
|
|
82
|
+
"values": [
|
|
83
|
+
{ "key": "rd", "label": "R&D" },
|
|
84
|
+
{ "key": "commercial", "label": "Commercial" }
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
and set them as soon as they are known:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
await Rack.attempt.setSegment({ track: 'rd' });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
At most 20 keys, string values up to 100 characters. The admin UI filters by the **first** declared
|
|
97
|
+
segment, so put the one people will actually slice by first.
|
|
98
|
+
|
|
99
|
+
Segments also drive activity scoping: an activity declaring `"segment": "track"` is understood to
|
|
100
|
+
exist only for participants in that track, which keeps export columns honest.
|
|
101
|
+
|
|
102
|
+
<a name="preview-runs"></a>
|
|
103
|
+
## Preview Runs
|
|
104
|
+
|
|
105
|
+
A participant gets exactly one attempt per applet, which is correct for them and useless for the
|
|
106
|
+
person who has to check the thing works. Staff get a second track:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
GET /a/{slug}?preview=1 resume my open preview run
|
|
110
|
+
GET /a/{slug}?preview=new start a fresh one
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Both require `ziprack.manage-applet` for the applet. For anyone else the parameter is **ignored rather than
|
|
114
|
+
refused**, so a link pasted into a chat never dead-ends a participant on an error page.
|
|
115
|
+
|
|
116
|
+
A preview run is a real attempt — same pinning, same statements, same records, same AI budget — but
|
|
117
|
+
it carries a Rack-owned `is_preview` flag, and that flag is not the applet's to set. `attempt@1`
|
|
118
|
+
gives you `setSegment()`; it does not give you a way to declare yourself a preview. An applet cannot
|
|
119
|
+
tell the difference and should not try.
|
|
120
|
+
|
|
121
|
+
Reporting hides them. The attempts table, the overview counts, the metric cards and the CSV export
|
|
122
|
+
all default to live attempts only:
|
|
123
|
+
|
|
124
|
+
```http
|
|
125
|
+
GET /ziprack/admin/applets/{applet}/attempts live runs (the default)
|
|
126
|
+
GET /ziprack/admin/applets/{applet}/attempts?preview=preview
|
|
127
|
+
GET /ziprack/admin/applets/{applet}/attempts?preview=all
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Rows carry `is_preview` so a UI showing `preview` or `all` can badge them.
|
|
131
|
+
|
|
132
|
+
The live attempt keeps `attempt_number` 1 for the lifetime of the applet; preview runs take the
|
|
133
|
+
numbers above it. Starting a fresh preview never touches the live run, so you can hammer the preview
|
|
134
|
+
button on an applet you have already completed for real without disturbing your own record of it.
|
|
135
|
+
|
|
136
|
+
The preview comes from the launch, not the attempt, so it reaches capabilities that have no attempt
|
|
137
|
+
at all. Statements and `db@1` records written during a preview carry their own `is_preview`, and the
|
|
138
|
+
record browser hides them by default too (see [Reporting](reporting.md#preview-runs)).
|
|
139
|
+
|
|
140
|
+
What preview does **not** do is partition every capability's storage. Per-applet aggregates — [`ai@1`](ai.md) spend, the
|
|
141
|
+
[`storage@1`](storage.md) gallery — count preview activity like any other. Budget accordingly on a
|
|
142
|
+
Rack where people preview a lot.
|
|
143
|
+
|
|
144
|
+
<a name="completion"></a>
|
|
145
|
+
## Completion
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
await Rack.activity.answer('outro.confidence', 5); // confirm the last answer first
|
|
149
|
+
await Rack.attempt.complete({ score: 42 }); // optional summary
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`complete()` is **idempotent** — calling it twice is not an error, and the second call returns the
|
|
153
|
+
same result. After it:
|
|
154
|
+
|
|
155
|
+
- `setState()` and `setSegment()` are refused with `completed`.
|
|
156
|
+
- Attempt-scoped [records](database.md) freeze.
|
|
157
|
+
- The Rack flips its own completion chrome, and a relaunch lands on the final screen rather than
|
|
158
|
+
restarting the experience.
|
|
159
|
+
|
|
160
|
+
That last point is a contract, not a courtesy: build a final screen worth landing on.
|
|
161
|
+
|
|
162
|
+
Which is why the Rack's completion card is **dismissible**. It announces the moment once, over the
|
|
163
|
+
top of whatever you just rendered; "Keep looking around" (or Escape) shrinks it to a corner toast
|
|
164
|
+
that keeps the way out without covering your screen. Nothing reopens it, and a relaunch of a
|
|
165
|
+
completed attempt shows no card at all — so the last thing the participant sees is yours, not the
|
|
166
|
+
Rack's.
|
|
167
|
+
|
|
168
|
+
<a name="version-pinning"></a>
|
|
169
|
+
## Version Pinning
|
|
170
|
+
|
|
171
|
+
> **Requesting `attempt@1` implies pinned. Its absence implies rolling.**
|
|
172
|
+
|
|
173
|
+
One rule, derived rather than declared, with no manifest field to get wrong.
|
|
174
|
+
|
|
175
|
+
**Why they are opposed.** An attempt must stay on the version it started on: activity ids cannot
|
|
176
|
+
shift mid-run without corrupting the export. An app must roll: every participant on the current
|
|
177
|
+
version, always, or people are stranded on stale builds.
|
|
178
|
+
|
|
179
|
+
Consequences worth knowing before you ship:
|
|
180
|
+
|
|
181
|
+
- An applet that adds `attempt@1` in a later version changes its own update semantics. The
|
|
182
|
+
[capability diff](publishing.md#capability-diff-review) flags this prominently, and it requires
|
|
183
|
+
re-approval.
|
|
184
|
+
- In-progress attempts continue to pin to their starting version after you promote a new one. Two
|
|
185
|
+
versions run side by side until the last one drains.
|
|
186
|
+
- Changing an activity `id` between versions splits export columns. Changing a `label` only changes
|
|
187
|
+
the header.
|