@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.
- package/README.md +59 -0
- package/dist/chunks/http.d.ts +31 -4
- package/dist/chunks/http.js +7 -1
- package/dist/chunks/websocket.d.ts +24 -1
- package/dist/http.d.ts +2 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +44 -1
- package/docs/work-runtime-guide.md +938 -0
- package/package.json +3 -2
|
@@ -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)`.
|