@neta-art/cohub 2.5.0 → 2.7.0

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,938 @@
1
+ # Cohub Work Runtime Guide
2
+
3
+ This guide explains how to use the Cohub SDK **inside a published Work** — the
4
+ only environment where runtime APIs (`context()`, `auth.request`,
5
+ `work.commerce.*`) function. Read this before writing any Work that calls Cohub
6
+ capabilities from browser-side JavaScript.
7
+
8
+ It is written to be self-contained: an agent or developer who reads only this
9
+ file plus the SDK type definitions should be able to build a working Work
10
+ without reverse-engineering source code.
11
+
12
+ ---
13
+
14
+ ## Table of contents
15
+
16
+ 1. [Mental model](#1-mental-model)
17
+ 2. [Two deployment modes: bridge vs broker](#2-two-deployment-modes-bridge-vs-broker)
18
+ 3. [The scope model — read this twice](#3-the-scope-model--read-this-twice)
19
+ 4. [Initialization recipe](#4-initialization-recipe)
20
+ 5. [Capability reference](#5-capability-reference)
21
+ - [LLM chat](#llm-chat-spaceprompt--subscribegeneration)
22
+ - [Image / media generation](#image--media-generation-generationscreateandwait)
23
+ - [Model listing](#model-listing-modelslist--modelslistmultimodal)
24
+ - [File reads](#file-reads-spacefiles)
25
+ - [Account-level data](#account-level-data-spaceslist--userlistsessions--usergetusage)
26
+ - [Commerce](#commerce-workcommerce)
27
+ 6. [Complete working example](#6-complete-working-example)
28
+ 7. [Common pitfalls checklist](#7-common-pitfalls-checklist)
29
+ 8. [Publishing a Work (API/SDK)](#8-publishing-a-work-apisdk)
30
+
31
+ ---
32
+
33
+ ## 1. Mental model
34
+
35
+ A **Work** is a published, shareable web page hosted by Cohub. When a viewer
36
+ opens a Work, Cohub serves its HTML/JS inside a **runtime** that bridges the
37
+ Work's code to Cohub's backend.
38
+
39
+ From the Work's JavaScript, you create a Cohub client and call APIs the same
40
+ way you would from any other client — **but** the client is pre-wired to obtain
41
+ short-lived access tokens from the Cohub shell (the runtime host) instead of
42
+ requiring the viewer to paste an API key.
43
+
44
+ ```
45
+ ┌─────────────────────────────────────────┐
46
+ │ Cohub shell (host page / iframe parent) │
47
+ │ │
48
+ │ ┌───────────────────────────────────┐ │
49
+ │ │ Your Work (iframe or standalone) │ │
50
+ │ │ │ │
51
+ │ │ createCohubClient() ──► token ──►│──┼──► Cohub API
52
+ │ │ client.context() ◄── identity │ │
53
+ │ │ client.auth.request() ──► consent│ │
54
+ │ └───────────────────────────────────┘ │
55
+ └─────────────────────────────────────────┘
56
+ ```
57
+
58
+ Three runtime-only APIs form the foundation; everything else is standard SDK:
59
+
60
+ | API | What it does | Returns |
61
+ |---|---|---|
62
+ | `client.context()` | Asks the host for the Work's identity | `{ work, space, viewer?, permissions }` or `null` |
63
+ | `client.auth.request({ scopes, reason })` | Shows the viewer a consent dialog; on approval, caches a token carrying those scopes | `true` / `false` |
64
+ | `client.work.commerce.*` | Entitlement checks, credit consumption, purchases | (see Commerce section) |
65
+
66
+ > **Runtime-only constraint.** These three APIs only work inside a **published**
67
+ > Work. Outside that context (a static asset URL, a local `file://` preview,
68
+ > a plain Node script) `context()` returns `null` and `auth.request` / commerce
69
+ > calls fail. Always develop against a published Work.
70
+
71
+ ---
72
+
73
+ ## 2. Two deployment modes: bridge vs broker
74
+
75
+ The SDK auto-detects which mode you are in based on whether the page is inside
76
+ an iframe. You normally do **not** need to set the mode explicitly.
77
+
78
+ ### Bridge mode (default, primary)
79
+
80
+ The Work runs inside a Cohub-hosted iframe (`window.parent !== window`). The
81
+ SDK communicates with the parent window via `postMessage` to request tokens
82
+ and context. This is the normal case when a viewer opens a Work through Cohub.
83
+
84
+ - `client.context()` returns the **real** `space.id`, `work.id`, and current
85
+ permission scopes from the host.
86
+ - `client.auth.request()` triggers an in-shell consent flow (no popup window).
87
+
88
+ ### Broker mode (standalone deployment)
89
+
90
+ The Work is accessed as a standalone page (`window.parent === window`), e.g.
91
+ a direct static-asset URL not wrapped in the Cohub iframe. The SDK opens a
92
+ **popup window** to a Cohub auth-broker page to obtain tokens.
93
+
94
+ - `client.context()` is **answered locally** by the SDK: `space.id` is an
95
+ **empty string `""`**, and `viewerScopes` is **always empty**.
96
+ - `client.auth.request()` opens a popup to
97
+ `${brokerOrigin}/work-auth?work=${workId}`.
98
+
99
+ > **Broker mode requires configuration.** You must pass `work: { brokerOrigin,
100
+ > workId }` to `createCohubClient` for broker mode to activate. Without it, a
101
+ > standalone page gets `ParentBridgeTransport` which has no parent to talk to,
102
+ > so `context()` returns `null`. See [Initialization recipe](#4-initialization-recipe).
103
+
104
+ ### Detecting the mode at runtime
105
+
106
+ ```js
107
+ const ctx = await client.context();
108
+ const isBroker = !ctx?.space?.id; // bridge has a real id; broker is ""
109
+ ```
110
+
111
+ In broker mode you cannot get `spaceId` from `context()`. Resolve it via the
112
+ public Work API (no token needed):
113
+
114
+ ```js
115
+ const isBroker = !ctx?.space?.id;
116
+ let spaceId;
117
+ if (isBroker) {
118
+ const detail = await client.works.get(workId); // public, no auth
119
+ spaceId = detail.work.spaceId;
120
+ } else {
121
+ spaceId = ctx.space.id;
122
+ }
123
+ ```
124
+
125
+ ### Broker mode: user-activation ordering gotcha
126
+
127
+ `HttpTransport` calls `getAccessToken()` on **every** request — including
128
+ public ones like `works.get()`. In broker mode, an uncached token request
129
+ opens a popup, which **consumes the browser's user-activation budget**. If a
130
+ second popup (`auth.request`) follows in the same click, the browser blocks it.
131
+
132
+ **Fix:** call `auth.request()` **before** any other API call that triggers
133
+ `getAccessToken()`. After `auth.request` succeeds, the token is cached in
134
+ `localStorage` and subsequent `getAccessToken()` calls hit the cache — no
135
+ popup.
136
+
137
+ ```js
138
+ // WRONG: works.get() opens a popup, consumes activation, auth.request popup blocked
139
+ const detail = await client.works.get(workId);
140
+ await client.auth.request({ scopes, reason });
141
+
142
+ // RIGHT: auth.request opens the only popup, then works.get() hits token cache
143
+ await client.auth.request({ scopes, reason });
144
+ const detail = await client.works.get(workId);
145
+ ```
146
+
147
+ Bridge mode is unaffected — `getAccessToken()` uses `postMessage` (no popup),
148
+ so ordering does not matter there.
149
+
150
+ ---
151
+
152
+ ## 3. The scope model — read this twice
153
+
154
+ > This is the **single most common source of bugs**. Every 403 you encounter
155
+ > in a Work will almost certainly trace back to a missing scope of the wrong
156
+ > type. Read this section carefully.
157
+
158
+ Cohub Work permissions come in **two disjoint sets**. They do not overlap and
159
+ do not imply each other.
160
+
161
+ ### Work scopes (direct, no user consent)
162
+
163
+ Granted by the publisher **at publish time**. The Work always has them — no
164
+ viewer action needed. These are **read** permissions.
165
+
166
+ ```
167
+ space.view — read space config, list models
168
+ session.view — read sessions, turns, stream generation updates
169
+ file.view — read files / file tree
170
+ taskrun.view — read task run details (used by generation polling!)
171
+ ```
172
+
173
+ Set via `workScopes` when creating/updating a Work.
174
+
175
+ ### Viewer scopes (consent-required, action permissions)
176
+
177
+ Declared by the publisher at publish time as **allowed** (`allowedViewerScopes`),
178
+ but **not active** until the viewer approves them through a consent dialog
179
+ triggered by `client.auth.request()`. These are **action** permissions.
180
+
181
+ ```
182
+ session.prompt.readonly — send read-only prompts (no side effects)
183
+ session.prompt.fullaccess — send prompts with full access (write, create sessions)
184
+ generation.create — create generation tasks (image/video/audio)
185
+ user.space.list — list the viewer's spaces (account-level)
186
+ user.session.list — list the viewer's sessions across all spaces
187
+ user.usage.read — read the viewer's aggregated usage
188
+ ```
189
+
190
+ ### The golden rule
191
+
192
+ > **Read operations need work scopes. Action operations need viewer scopes.
193
+ > They never substitute for each other.**
194
+
195
+ `session.prompt.fullaccess` lets you **send** a prompt, but does **not** let
196
+ you **read** the result — that needs `session.view` (a work scope).
197
+ `generation.create` lets you **create** a generation task, but reading its
198
+ result needs `taskrun.view` (a work scope).
199
+
200
+ ### Complete API → scope mapping
201
+
202
+ | Operation | SDK call | Scope needed | Type |
203
+ |---|---|---|---|
204
+ | Read space config | `space.get()` / `space.getConfig()` | `space.view` | work |
205
+ | List models | `client.models.list()` / `listMultimodal()` | *(none — just authenticated)* | — |
206
+ | Send a prompt | `space.prompt({ content, ... })` | `session.prompt.fullaccess` (or `.readonly`) | viewer |
207
+ | Read turn result | `session.turns.get(turnId)` | `session.view` | work |
208
+ | Stream generation | `session.subscribeGeneration({ state, finalized })` | `session.view` | work |
209
+ | Read file tree | `space.files.tree()` | `file.view` | work |
210
+ | Read file content | `space.files.read(path)` | `file.view` | work |
211
+ | Create generation task | `client.generations.create(request)` | `generation.create` | viewer |
212
+ | **Poll generation result** | `client.generations.wait(taskRunId)` / `createAndWait()` | **`taskrun.view`** | **work** |
213
+ | Read task run detail | `client.tasks.get(taskRunId)` | `taskrun.view` | work |
214
+ | List viewer's spaces | `client.spaces.list()` | `user.space.list` | viewer |
215
+ | List viewer's sessions | `client.user.listSessions()` | `user.session.list` | viewer |
216
+ | Read viewer's usage | `client.user.getUsage()` | `user.usage.read` | viewer |
217
+ | Commerce: entitlements | `client.work.commerce.getEntitlements()` | *(runtime only, no scope)* | — |
218
+ | Commerce: consume credits | `client.work.commerce.consumeCredits()` | *(runtime only, no scope)* | — |
219
+ | Commerce: purchase | `client.work.commerce.purchase()` | *(runtime only, no scope)* | — |
220
+
221
+ ### Minimal scope sets for common Work types
222
+
223
+ **LLM chat Work** (send prompt + read reply):
224
+ - workScopes: `["space.view", "session.view"]`
225
+ - allowedViewerScopes: `["session.prompt.fullaccess"]`
226
+
227
+ **Image generation Work** (create + poll):
228
+ - workScopes: `["space.view", "taskrun.view"]`
229
+ - allowedViewerScopes: `["generation.create"]`
230
+
231
+ **LLM + image generation Work** (the demo):
232
+ - workScopes: `["space.view", "session.view", "taskrun.view"]`
233
+ - allowedViewerScopes: `["session.prompt.fullaccess", "generation.create"]`
234
+
235
+ **File-reader Work** (static, no viewer action):
236
+ - workScopes: `["space.view", "file.view"]`
237
+ - allowedViewerScopes: `[]`
238
+
239
+ ### Checking granted scopes at runtime
240
+
241
+ `client.context()` returns a `permissions` object with three arrays:
242
+
243
+ ```js
244
+ const ctx = await client.context();
245
+ ctx.permissions.scopes // all effective scopes (work + viewer)
246
+ ctx.permissions.workScopes // work scopes granted at publish time
247
+ ctx.permissions.viewerScopes // viewer scopes the current viewer has approved
248
+ ```
249
+
250
+ To check whether a **viewer** scope is already granted (to skip re-requesting):
251
+
252
+ ```js
253
+ function hasViewerScope(ctx, scope) {
254
+ return (ctx?.permissions?.viewerScopes ?? []).includes(scope);
255
+ }
256
+ ```
257
+
258
+ ---
259
+
260
+ ## 4. Initialization recipe
261
+
262
+ ### No-build HTML (CDN import)
263
+
264
+ Works are typically single HTML files with no bundler. Import the SDK from an
265
+ ESM CDN:
266
+
267
+ ```js
268
+ import { createCohubClient } from "https://esm.sh/@neta-art/cohub@2";
269
+ ```
270
+
271
+ > Pin to a major version (`@2`) or an exact version (`@2.6.0`) to avoid
272
+ > breaking changes. Check `npm view @neta-art/cohub version` for the latest.
273
+
274
+ ### Environment detection — critical
275
+
276
+ The SDK defaults to **production**. A Work running on a dev/staging host
277
+ (e.g. `dev.cohub.run`, a `/dev/` path prefix) **must** pass `env: "dev"`
278
+ explicitly — browsers do not inject `ENV` like Node does. If you omit this,
279
+ your Work will call the production API while the runtime host expects dev,
280
+ causing silent auth failures.
281
+
282
+ ```js
283
+ const isDevWork =
284
+ location.pathname.startsWith("/dev/") ||
285
+ location.hostname.includes("dev");
286
+
287
+ const client = createCohubClient({
288
+ env: isDevWork ? "dev" : "prod",
289
+ });
290
+ ```
291
+
292
+ ### Broker mode configuration (standalone pages only)
293
+
294
+ If the Work may be accessed as a standalone page (not inside the Cohub
295
+ iframe), pass the `work` option so the SDK can fall back to broker mode:
296
+
297
+ ```js
298
+ const client = createCohubClient({
299
+ env: isDevWork ? "dev" : "prod",
300
+ work: {
301
+ brokerOrigin: isDevWork ? "https://dev.cohub.run" : "https://cohub.run",
302
+ workId: "<your-published-work-id>",
303
+ },
304
+ });
305
+ ```
306
+
307
+ When inside the Cohub iframe, the SDK auto-detects bridge mode and ignores
308
+ broker config. When standalone, it uses broker mode. **One codebase, both
309
+ deployments.**
310
+
311
+ ### Standard initialization sequence
312
+
313
+ ```js
314
+ // 1. Create client (env is mandatory in the browser)
315
+ const client = createCohubClient({ env: isDevWork ? "dev" : "prod" });
316
+
317
+ // 2. Get runtime context
318
+ const ctx = await client.context();
319
+ if (!ctx?.space?.id) {
320
+ // Not in a Work runtime (or broker mode — see §2)
321
+ throw new Error("Not running inside a published Work.");
322
+ }
323
+
324
+ // 3. Obtain the space client for API calls
325
+ const spaceId = ctx.space.id;
326
+ const space = client.space(spaceId);
327
+
328
+ // 4. Request viewer scopes (from a user gesture, e.g. button click)
329
+ const ok = await client.auth.request({
330
+ scopes: ["session.prompt.fullaccess", "generation.create"],
331
+ reason: "This Work needs to send prompts and generate images.",
332
+ });
333
+ if (!ok) {
334
+ // Viewer denied — handle gracefully
335
+ }
336
+
337
+ // 5. Call capabilities
338
+ const result = await space.prompt({ content: [{ type: "text", text: "Hello" }] });
339
+ ```
340
+
341
+ > **`auth.request` must be called from a user gesture** (click handler).
342
+ Browsers block popups (broker mode) and some consent flows (bridge mode)
343
+ when triggered programmatically without user activation. Do not call it on
344
+ page load.
345
+
346
+ ---
347
+
348
+ ## 5. Capability reference
349
+
350
+ Each recipe below shows the exact code pattern and scope requirements.
351
+ Assume `client` and `space` are already initialized per [§4](#4-initialization-recipe).
352
+
353
+ ### LLM chat (`space.prompt` + `subscribeGeneration`)
354
+
355
+ **Scopes:** viewer `session.prompt.fullaccess` (to send) + work `session.view` (to read/stream).
356
+
357
+ `space.prompt()` is **asynchronous** — it returns immediately with a turn
358
+ whose `assistantText` is `null`. You must either stream the reply via
359
+ `subscribeGeneration` or poll `turns.get()`.
360
+
361
+ ```js
362
+ // Send a prompt (creates or continues a session)
363
+ const result = await space.prompt({
364
+ content: [{ type: "text", text: "Describe a shiba inu on Mars." }],
365
+ sessionId: null, // null → creates a new session; pass an id to continue
366
+ model: "gpt-5.5", // optional; omit for default
367
+ intent: "followup", // "followup" | "steer" | "compact"
368
+ });
369
+ const sessionId = result.session.id;
370
+ const turnId = result.turn.id;
371
+
372
+ // --- Option A: stream the reply (preferred) ---
373
+ // Requires work scope: session.view
374
+ const stop = space.session(sessionId).subscribeGeneration({
375
+ state(event) {
376
+ // Partial text as it streams in
377
+ const text = (event.state?.contentBlocks ?? [])
378
+ .filter(b => b.type === "text")
379
+ .map(b => b.text)
380
+ .join("");
381
+ console.log("streaming:", text);
382
+ },
383
+ finalized(event) {
384
+ const turn = event.turn;
385
+ const reply = turn.assistantText
386
+ ?? (turn.assistantContent ?? []).filter(b => b.type === "text").map(b => b.text).join("");
387
+ console.log("final:", reply);
388
+ },
389
+ error(event) {
390
+ console.error("stream error:", event);
391
+ },
392
+ });
393
+ // Call stop() to unsubscribe when done.
394
+
395
+ // --- Option B: poll for the reply (fallback) ---
396
+ // Also requires work scope: session.view
397
+ async function waitForTurn(sessionId, turnId) {
398
+ while (true) {
399
+ const { turn } = await space.session(sessionId).turns.get(turnId);
400
+ if (turn.status === "completed") return turn;
401
+ if (turn.status === "failed") throw new Error(turn.errorMessage || "failed");
402
+ await new Promise(r => setTimeout(r, 1500));
403
+ }
404
+ }
405
+ const turn = await waitForTurn(sessionId, turnId);
406
+ const reply = turn.assistantText;
407
+ ```
408
+
409
+ **Turn status values:** `"pending" | "running" | "completed" | "failed"`.
410
+
411
+ **Turn reply fields:** `turn.assistantText` (string | null) and
412
+ `turn.assistantContent` (ContentBlock[] | null). Always check both —
413
+ `assistantText` is a convenience; `assistantContent` is the source of truth.
414
+
415
+ > **Do not silently swallow `subscribeGeneration` errors.** If the work scope
416
+ `session.view` is missing, the WebSocket subscription fails. If you catch and
417
+ ignore it, your code silently degrades to polling — which will also 403.
418
+ Surface the error so you can diagnose the missing scope.
419
+
420
+ ### Image / media generation (`generations.createAndWait`)
421
+
422
+ **Scopes:** viewer `generation.create` (to create) + work `taskrun.view` (to poll).
423
+
424
+ `createAndWait` is a convenience that calls `create` then `wait` (polls
425
+ `GET /api/tasks/{id}`). **Both scopes are required** — `generation.create`
426
+ for the create step, `taskrun.view` for the poll step. Missing `taskrun.view`
427
+ is the #1 cause of "generation creates but never returns" bugs.
428
+
429
+ ```js
430
+ const result = await client.generations.createAndWait(
431
+ {
432
+ spaceId,
433
+ model: "gpt-image-2", // model id from models.listMultimodal()
434
+ content: [{ type: "text", text: "A cat on the moon, cartoon style" }],
435
+ parameters: { // optional, model-specific
436
+ size: "1024x1024",
437
+ // quality: "auto", // gpt-image-2 supports quality
438
+ },
439
+ },
440
+ {
441
+ onPoll: (detail) => console.log("status:", detail.run.status),
442
+ // intervalMs: 1500, // optional poll interval
443
+ // timeoutMs: 30 * 60 * 1000, // optional timeout (default 30 min)
444
+ },
445
+ );
446
+
447
+ // Extract the image URL from the output blocks
448
+ const image = (result.output ?? []).find(
449
+ b => b.type === "image" && b.source?.url
450
+ );
451
+ const imageUrl = image?.source?.url;
452
+ ```
453
+
454
+ **Output block types:** `text`, `image`, `video`, `audio`. Each media block has
455
+ a `source` of `{ type: "url", url }` or `{ type: "base64", mediaType, data }`.
456
+
457
+ **Two-step alternative** (if you need the `taskRunId` immediately):
458
+
459
+ ```js
460
+ const created = await client.generations.create(request);
461
+ // created.taskRunId — generation task is queued
462
+ const result = await client.generations.wait(created.taskRunId, { onPoll });
463
+ ```
464
+
465
+ ### Model listing (`models.list` / `models.listMultimodal`)
466
+
467
+ **Scopes:** none — only requires a valid authenticated token (any token, no
468
+ specific scope). Returns 401 without a token, but never 403.
469
+
470
+ Call after initialization (after `context()` succeeds, so a token exists):
471
+
472
+ ```js
473
+ // All models grouped by provider
474
+ const catalog = await client.models.list();
475
+ // catalog: { cohub: [...], openai: [...], ... }
476
+
477
+ // Generation-capable models only (for image/video/audio generation)
478
+ const { models } = await client.models.listMultimodal();
479
+ // models: [{ model, title, description, ... }, ...]
480
+ ```
481
+
482
+ Use `listMultimodal()` to populate a model picker for generation. Each entry's
483
+ `model` field is the id you pass to `generations.createAndWait({ model })`.
484
+
485
+ ### File reads (`space.files`)
486
+
487
+ **Scopes:** work `file.view` (read) + `space.view` (often needed for the space context).
488
+
489
+ ```js
490
+ // List the file tree
491
+ const tree = await space.files.tree();
492
+ // tree: nested file/directory entries
493
+
494
+ // Read a file (returns an HTTP Response — .text() / .blob() / .arrayBuffer())
495
+ const response = await space.files.read("path/to/file.txt");
496
+ const text = await response.text();
497
+
498
+ // Read multiple files at once
499
+ const files = await space.files.readMany(["a.txt", "b.json"]);
500
+ ```
501
+
502
+ ### Account-level data (`spaces.list` / `user.listSessions` / `user.getUsage`)
503
+
504
+ **Scopes:** viewer `user.space.list` / `user.session.list` / `user.usage.read`.
505
+
506
+ These access the **viewer's** account-level data across all their spaces — not
507
+ the Work's own space. Each requires a separate viewer scope.
508
+
509
+ ```js
510
+ // List the viewer's spaces — needs user.space.list
511
+ await client.auth.request({
512
+ scopes: ["user.space.list"],
513
+ reason: "Show your space list.",
514
+ });
515
+ const spaces = await client.spaces.list();
516
+
517
+ // List sessions across all the viewer's spaces — needs user.session.list
518
+ await client.auth.request({
519
+ scopes: ["user.session.list"],
520
+ reason: "List your recent sessions.",
521
+ });
522
+ const { sessions } = await client.user.listSessions({ limit: 20 });
523
+
524
+ // Read aggregated usage — needs user.usage.read
525
+ await client.auth.request({
526
+ scopes: ["user.usage.read"],
527
+ reason: "Show your usage summary.",
528
+ });
529
+ const usage = await client.user.getUsage(30); // last 30 days
530
+ ```
531
+
532
+ ### Commerce (`work.commerce`)
533
+
534
+ **Scopes:** none — runs inside the Work runtime, no scope needed. Only works
535
+ in a published Work.
536
+
537
+ ```js
538
+ // Check entitlements and credit balance in one call
539
+ const { entitlements, credits } = await client.work.commerce.getEntitlements();
540
+
541
+ // Feature unlock: purchase if not entitled
542
+ const unlocked = entitlements.some(e => e.benefitKey === "space_pro" && e.enabled);
543
+ if (!unlocked) {
544
+ await client.work.commerce.purchase({ productKey: "pro_unlock" });
545
+ // purchase() redirects to checkout; after return, re-check entitlements
546
+ }
547
+
548
+ // Credit consumption for a metered action
549
+ const result = await client.work.commerce.consumeCredits({
550
+ amount: 10,
551
+ operationId: crypto.randomUUID(), // idempotency key
552
+ reason: "Export high-res image",
553
+ });
554
+ if (result.status === "insufficient") {
555
+ await client.work.commerce.purchase({ productKey: "credit_pack" });
556
+ }
557
+
558
+ // After checkout return, query the order
559
+ const checkoutState = await client.work.commerce.getCheckoutState();
560
+ if (checkoutState.orderId) {
561
+ const { order } = await client.work.commerce.getOrder(checkoutState.orderId);
562
+ }
563
+ ```
564
+
565
+ ---
566
+
567
+ ## 6. Complete working example
568
+
569
+ A no-build HTML Work that tests LLM chat and image generation. This is the
570
+ exact pattern that was verified end-to-end. Adapt it to your needs.
571
+
572
+ > **Publish this Work with:**
573
+ > - workScopes: `["space.view", "session.view", "taskrun.view"]`
574
+ > - allowedViewerScopes: `["session.prompt.fullaccess", "generation.create"]`
575
+ >
576
+ > See [§8](#8-publishing-a-work-apisdk) for the publish API call.
577
+
578
+ ### `index.html`
579
+
580
+ ```html
581
+ <!DOCTYPE html>
582
+ <html lang="en">
583
+ <head>
584
+ <meta charset="UTF-8" />
585
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
586
+ <title>Cohub SDK Demo</title>
587
+ <link rel="stylesheet" href="style.css" />
588
+ </head>
589
+ <body>
590
+ <div class="container">
591
+ <h1>Cohub SDK Demo</h1>
592
+
593
+ <section class="card">
594
+ <h2>1. Context</h2>
595
+ <button id="btn-context" class="btn">Get context</button>
596
+ <pre id="output-context" class="output"></pre>
597
+ </section>
598
+
599
+ <section class="card">
600
+ <h2>2. Authorize</h2>
601
+ <button id="btn-auth" class="btn">Request viewer scopes</button>
602
+ <pre id="output-auth" class="output"></pre>
603
+ </section>
604
+
605
+ <section class="card">
606
+ <h2>3. LLM chat</h2>
607
+ <div id="chat-log" class="chat-log"></div>
608
+ <textarea id="chat-input" rows="2">Say hello in one sentence.</textarea>
609
+ <button id="btn-chat" class="btn">Send</button>
610
+ <pre id="output-chat" class="output"></pre>
611
+ </section>
612
+
613
+ <section class="card">
614
+ <h2>4. Image generation</h2>
615
+ <textarea id="img-prompt" rows="3">A cat on the moon, cartoon style</textarea>
616
+ <button id="btn-img" class="btn">Generate</button>
617
+ <div id="img-result"></div>
618
+ <pre id="output-img" class="output"></pre>
619
+ </section>
620
+ </div>
621
+ <script type="module" src="app.js"></script>
622
+ </body>
623
+ </html>
624
+ ```
625
+
626
+ ### `app.js`
627
+
628
+ ```js
629
+ import { createCohubClient } from "https://esm.sh/@neta-art/cohub@2";
630
+
631
+ // --- Environment detection (critical: browsers don't inject ENV) ---
632
+ const isDevWork =
633
+ location.pathname.startsWith("/dev/") ||
634
+ location.hostname.includes("dev");
635
+
636
+ const client = createCohubClient({
637
+ env: isDevWork ? "dev" : "prod",
638
+ });
639
+
640
+ const REQUIRED_SCOPES = ["generation.create", "session.prompt.fullaccess"];
641
+
642
+ let space = null;
643
+ let spaceId = null;
644
+ let sessionId = null;
645
+
646
+ const $ = (id) => document.getElementById(id);
647
+
648
+ function setOutput(el, data) {
649
+ el.textContent = typeof data === "object" ? JSON.stringify(data, null, 2) : String(data);
650
+ }
651
+
652
+ function log(el, msg) {
653
+ el.textContent += `[${new Date().toLocaleTimeString()}] ${msg}\n`;
654
+ }
655
+
656
+ // --- Runtime initialization ---
657
+ async function ensureRuntime(outEl) {
658
+ const ctx = await client.context();
659
+ if (!ctx?.space?.id) {
660
+ throw new Error("Not running inside a published Work runtime.");
661
+ }
662
+ spaceId = ctx.space.id;
663
+ space = client.space(spaceId);
664
+ return ctx;
665
+ }
666
+
667
+ // --- Viewer scope management ---
668
+ function getViewerScopes(ctx) {
669
+ return ctx?.permissions?.viewerScopes ?? [];
670
+ }
671
+
672
+ function missingViewerScopes(ctx, scopes) {
673
+ const have = new Set(getViewerScopes(ctx));
674
+ return scopes.filter((s) => !have.has(s));
675
+ }
676
+
677
+ async function ensureViewerScopes(scopes, reason, outEl) {
678
+ const ctx = await ensureRuntime(outEl);
679
+ const missing = missingViewerScopes(ctx, scopes);
680
+ if (missing.length === 0) {
681
+ log(outEl, `Already have scopes: [${scopes.join(", ")}]`);
682
+ return true;
683
+ }
684
+ log(outEl, `Requesting scopes: [${missing.join(", ")}]...`);
685
+ const ok = await client.auth.request({ scopes, reason });
686
+ log(outEl, ok ? "Authorized." : "Authorization denied.");
687
+ return ok;
688
+ }
689
+
690
+ // --- LLM chat (space.prompt + subscribeGeneration) ---
691
+ function extractText(blocks) {
692
+ return (blocks ?? [])
693
+ .filter((b) => b.type === "text" && typeof b.text === "string")
694
+ .map((b) => b.text)
695
+ .join("");
696
+ }
697
+
698
+ function waitForTurn(sid, turnId, onStream) {
699
+ return new Promise((resolve, reject) => {
700
+ let done = false;
701
+ let stop = null;
702
+
703
+ const finish = (fn) => {
704
+ if (done) return;
705
+ done = true;
706
+ if (stop) try { stop(); } catch {}
707
+ fn();
708
+ };
709
+
710
+ // Primary path: stream via WebSocket (needs work scope: session.view)
711
+ try {
712
+ stop = space.session(sid).subscribeGeneration({
713
+ state: (event) => {
714
+ const text = extractText(event.state?.contentBlocks);
715
+ if (text) onStream?.(text);
716
+ },
717
+ finalized: (event) => finish(() => resolve(event.turn)),
718
+ error: (event) => finish(() =>
719
+ reject(new Error(event?.rawEvent?.payload?.message || "stream error"))),
720
+ });
721
+ } catch (err) {
722
+ console.warn("subscribeGeneration failed:", err);
723
+ }
724
+
725
+ // Fallback: poll (also needs session.view — if missing, both paths 403)
726
+ const poll = async () => {
727
+ try {
728
+ const { turn } = await space.session(sid).turns.get(turnId);
729
+ if (turn.assistantText) onStream?.(turn.assistantText);
730
+ if (turn.status === "completed") finish(() => resolve(turn));
731
+ if (turn.status === "failed" || turn.status === "cancelled") {
732
+ finish(() => reject(new Error(turn.errorMessage || "failed")));
733
+ }
734
+ } catch (err) {
735
+ // Don't silently swallow — surface 403 (missing session.view)
736
+ console.error("poll error:", err);
737
+ }
738
+ };
739
+ const interval = setInterval(poll, 2000);
740
+ poll();
741
+ setTimeout(() => finish(() => reject(new Error("timeout"))), 120000);
742
+ // Note: in production, clear the interval in finish()
743
+ });
744
+ }
745
+
746
+ async function chat(text, outEl) {
747
+ const result = await space.prompt({
748
+ content: [{ type: "text", text }],
749
+ sessionId: sessionId || null,
750
+ intent: "followup",
751
+ });
752
+ sessionId = result.session.id;
753
+ const turn = await waitForTurn(sessionId, result.turn.id);
754
+ return turn.assistantText || extractText(turn.assistantContent) || "(empty)";
755
+ }
756
+
757
+ // --- Image generation (generations.createAndWait) ---
758
+ async function generateImage(prompt, outEl) {
759
+ const result = await client.generations.createAndWait(
760
+ {
761
+ spaceId,
762
+ model: "gpt-image-2",
763
+ content: [{ type: "text", text: prompt }],
764
+ parameters: { size: "1024x1024" },
765
+ },
766
+ { onPoll: (d) => log(outEl, `poll: ${d.run.status}`) },
767
+ );
768
+ const image = (result.output ?? []).find(
769
+ (b) => b.type === "image" && b.source?.url,
770
+ );
771
+ return image?.source?.url ?? null;
772
+ }
773
+
774
+ // --- Event handlers (auth.request must be in a user gesture) ---
775
+ $("btn-context").addEventListener("click", async () => {
776
+ const out = $("output-context");
777
+ out.textContent = "";
778
+ try {
779
+ const ctx = await client.context();
780
+ setOutput(out, {
781
+ "work.id": ctx?.work?.id,
782
+ "space.id": ctx?.space?.id,
783
+ permissions: ctx?.permissions,
784
+ });
785
+ } catch (err) { log(out, err.message); }
786
+ });
787
+
788
+ $("btn-auth").addEventListener("click", async () => {
789
+ const out = $("output-auth");
790
+ out.textContent = "";
791
+ const ok = await ensureViewerScopes(
792
+ REQUIRED_SCOPES,
793
+ "This demo needs to send prompts and generate images.",
794
+ out,
795
+ );
796
+ if (ok) log(out, "Ready to use capabilities.");
797
+ });
798
+
799
+ $("btn-chat").addEventListener("click", async () => {
800
+ const out = $("output-chat");
801
+ out.textContent = "";
802
+ const text = $("chat-input").value.trim();
803
+ if (!text) return;
804
+ try {
805
+ await ensureRuntime(out);
806
+ const ok = await ensureViewerScopes(
807
+ ["session.prompt.fullaccess"],
808
+ "LLM chat needs session.prompt.fullaccess.",
809
+ out,
810
+ );
811
+ if (!ok) return;
812
+ const reply = await chat(text, out);
813
+ log(out, `Reply: ${reply}`);
814
+ } catch (err) { log(out, err.message); }
815
+ });
816
+
817
+ $("btn-img").addEventListener("click", async () => {
818
+ const out = $("output-img");
819
+ out.textContent = "";
820
+ const prompt = $("img-prompt").value.trim();
821
+ if (!prompt) return;
822
+ try {
823
+ await ensureRuntime(out);
824
+ const ok = await ensureViewerScopes(
825
+ ["generation.create"],
826
+ "Image generation needs generation.create. taskrun.view comes from workScopes.",
827
+ out,
828
+ );
829
+ if (!ok) return;
830
+ const url = await generateImage(prompt, out);
831
+ if (url) {
832
+ $("img-result").innerHTML = `<img src="${url}" style="max-width:100%" />`;
833
+ }
834
+ log(out, "Done.");
835
+ } catch (err) { log(out, err.message); }
836
+ });
837
+
838
+ // Auto-fetch context on load (does not require auth)
839
+ (async () => {
840
+ try {
841
+ const ctx = await client.context();
842
+ if (ctx) {
843
+ setOutput($("output-context"), {
844
+ "work.id": ctx.work?.id,
845
+ "space.id": ctx.space?.id,
846
+ permissions: ctx.permissions,
847
+ });
848
+ }
849
+ } catch {}
850
+ })();
851
+ ```
852
+
853
+ ---
854
+
855
+ ## 7. Common pitfalls checklist
856
+
857
+ Before publishing your Work, verify each item:
858
+
859
+ - [ ] **Environment**: passed `env: "dev"` (or `"prod"`) explicitly — the SDK
860
+ defaults to prod and browsers don't inject `ENV`.
861
+ - [ ] **Work scopes include all read operations**: `space.view`, `session.view`
862
+ (for LLM reply reads), `taskrun.view` (for generation polling), `file.view`
863
+ (for file reads). Missing any of these → 403 on reads.
864
+ - [ ] **Viewer scopes include all action operations**: `session.prompt.fullaccess`
865
+ (or `.readonly`) for prompts, `generation.create` for generation.
866
+ - [ ] **`session.prompt.fullaccess` does NOT include `session.view`** — they
867
+ are separate. Sending a prompt succeeds but reading the reply 403s without
868
+ `session.view`.
869
+ - [ ] **`generation.create` does NOT include `taskrun.view`** — creating a
870
+ generation task succeeds but polling the result 403s without `taskrun.view`.
871
+ - [ ] **`auth.request()` is called from a user gesture** (button click), not
872
+ on page load.
873
+ - [ ] **`subscribeGeneration` errors are not silently swallowed** — if the
874
+ stream fails, surface it; a silent fallback to polling will also 403 if
875
+ `session.view` is missing.
876
+ - [ ] **Broker mode**: if the Work may be accessed standalone, pass
877
+ `work: { brokerOrigin, workId }` and call `auth.request()` before any other
878
+ API call (to avoid user-activation exhaustion).
879
+ - [ ] **Space has a slug and owner has a username** before publishing — the
880
+ API rejects Works when either is missing.
881
+ - [ ] **Model ids are not hardcoded** — use `client.models.listMultimodal()`
882
+ to fetch available models dynamically (requires auth but no scope).
883
+
884
+ ---
885
+
886
+ ## 8. Publishing a Work (API/SDK)
887
+
888
+ Before creating a Work through the API, ensure the owner has a username and
889
+ the Space has a slug. The API rejects Works when either public identity part
890
+ is missing.
891
+
892
+ ```js
893
+ // Create a single-file Work (HTML file)
894
+ await client.works.create({
895
+ spaceId,
896
+ slug: "my-html-demo",
897
+ status: "published",
898
+ targetType: "file",
899
+ targetRef: "demo/index.html",
900
+ workScopes: ["space.view", "session.view", "taskrun.view"],
901
+ allowedViewerScopes: ["session.prompt.fullaccess", "generation.create"],
902
+ });
903
+
904
+ // Create a directory Work (must contain index.html)
905
+ await client.works.create({
906
+ spaceId,
907
+ slug: "my-site",
908
+ status: "published",
909
+ targetType: "directory",
910
+ targetRef: "site",
911
+ workScopes: ["space.view", "session.view", "taskrun.view", "file.view"],
912
+ allowedViewerScopes: ["session.prompt.fullaccess", "generation.create"],
913
+ });
914
+
915
+ // Create a port Work (sandbox dev server)
916
+ await client.works.create({
917
+ spaceId,
918
+ slug: "live-preview",
919
+ status: "published",
920
+ targetType: "port",
921
+ targetRef: "5173",
922
+ workScopes: ["space.view"],
923
+ allowedViewerScopes: [],
924
+ });
925
+ ```
926
+
927
+ `targetRef` is a path **relative to the Space filesystem root** (not a local
928
+ path). For file Works, the target must be an HTML file (`.html`/`.htm`).
929
+
930
+ Update the published version from the current target:
931
+
932
+ ```js
933
+ await client.works.publishVersion(workId);
934
+ ```
935
+
936
+ Other SDK methods: `works.get(workId)`, `works.getBySlug(username, spaceSlug,
937
+ workSlug)`, `works.listBySpace(spaceId)`, `works.update(workId, input)`,
938
+ `works.delete(workId)`.