@neta-art/cohub 8.20.1 → 8.21.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.
@@ -1,1331 +0,0 @@
1
- # Cohub App Runtime Guide
2
-
3
- This guide explains the Cohub SDK in a published App iframe or a registered
4
- standalone origin. New Apps should use `auth.authorize()`; see the
5
- [authorization contract](../../../docs/app-authorization.md). Legacy APIs below
6
- retain their original return types. Read this before building any
7
- App that calls Cohub capabilities from browser-side JavaScript.
8
-
9
- It is written to be self-contained: an agent or developer who reads only this
10
- file plus the SDK type definitions should be able to build a working App
11
- without reverse-engineering source code.
12
-
13
- > **Vocabulary note.** Apps were previously called *Works*. The SDK speaks the
14
- > canonical App vocabulary (`client.apps`, `appScopes`); the legacy
15
- > `client.works` / `workScopes` spellings remain as deprecated aliases, and
16
- > existing `/w/` public URLs keep working.
17
-
18
- ---
19
-
20
- ## Table of contents
21
-
22
- 1. [Mental model](#1-mental-model)
23
- 2. [Two deployment modes: bridge vs broker](#2-two-deployment-modes-bridge-vs-broker)
24
- 3. [The permission model — read this twice](#3-the-permission-model--read-this-twice)
25
- 4. [Initialization recipe](#4-initialization-recipe)
26
- 5. [Capability reference](#5-capability-reference)
27
- - [LLM chat](#llm-chat-spaceprompt--subscribegeneration)
28
- - [Image / media generation](#image--media-generation-generationscreateandwait)
29
- - [Model listing](#model-listing-modelslist--modelslistmultimodal)
30
- - [File reads](#file-reads-spacefiles)
31
- - [Account-level data](#account-level-data-spaceslist--userlistsessions--usergetactivity)
32
- - [Commerce](#commerce-appcommerce)
33
- - [Realtime rooms](#realtime-rooms-apprealtime)
34
- 6. [Complete working example](#6-complete-working-example)
35
- 7. [Common pitfalls checklist](#7-common-pitfalls-checklist)
36
- 8. [Publishing an App (API/SDK)](#8-publishing-an-app-apisdk)
37
-
38
- ---
39
-
40
- ## 1. Mental model
41
-
42
- An **App** is a published, shareable web page hosted by Cohub. When a viewer
43
- opens an App, Cohub serves its HTML/JS inside a **runtime** that bridges the
44
- App's code to Cohub's backend.
45
-
46
- From the App's JavaScript, you create a Cohub client and call APIs the same
47
- way you would from any other client — **but** the client is pre-wired to obtain
48
- short-lived access tokens from the Cohub shell (the runtime host) instead of
49
- requiring the viewer to paste an API key.
50
-
51
- ```
52
- ┌─────────────────────────────────────────┐
53
- │ Cohub shell (host page / iframe parent) │
54
- │ │
55
- │ ┌───────────────────────────────────┐ │
56
- │ │ Your App (iframe or standalone) │ │
57
- │ │ │ │
58
- │ │ createCohubClient() ──► token ──►│──┼──► Cohub API
59
- │ │ client.context() ◄── identity │ │
60
- │ │ client.auth.authorize() ► consent│ │
61
- │ └───────────────────────────────────┘ │
62
- └─────────────────────────────────────────┘
63
- ```
64
-
65
- These runtime-only APIs form the foundation; everything else is standard SDK:
66
-
67
- | API | What it does | Returns |
68
- |---|---|---|
69
- | `client.context()` | Asks the host for the App's identity | `{ app, space, viewer?, invocation?, shell?, permissions, capabilities?, mode? }` or `null` |
70
- | `client.auth.authorize({ target, scopes, reason?, alwaysAsk?, fallback? })` | Requests consent for an account, a Space, or a viewer-picked Space; also silently grants approved read-only scopes on the trusted Shell Space | `{ status: "granted", requestedTarget, target, resolution, grant }` \| `{ status: "cancelled" }` \| `{ status: "denied", code }` |
71
- | `client.auth.request()` / `requestSpace()` / `requestCreateSpace()` | Legacy entry points, kept for older Apps | original shapes (`boolean`, `{ granted, space }`) |
72
- | `client.context().permissions.viewerGrants` | Render the viewer's current per-space grants | `{ spaceId, scopes }[]` |
73
- | `client.app.commerce.*` / `client.app.realtime.*` | Commerce and realtime, bound to the app's runtime identity | (see below) |
74
-
75
- > **Runtime requirement.** Use a published App iframe or a standalone origin
76
- > registered with Cohub. The SDK discovers the transport automatically.
77
- > Initialize `context()` before using APIs to discover Host capabilities.
78
-
79
- `client.app.onContextChanged(listener)` pushes a fresh context whenever the
80
- host's state changes (sign-in, invocation, grants), so an App can render its
81
- permission state without polling.
82
-
83
- ---
84
-
85
- ## 2. Two deployment modes: bridge vs broker
86
-
87
- The SDK auto-detects which mode you are in based on whether the page is inside
88
- an iframe. You normally do **not** need to set the mode explicitly.
89
-
90
- ### Bridge mode (default, primary)
91
-
92
- The App runs inside a Cohub-hosted iframe (`window.parent !== window`). The
93
- SDK communicates with the parent window via `postMessage` to request tokens
94
- and context. This is the normal case when a viewer opens an App through Cohub.
95
-
96
- - `client.context()` returns the **real** `app.id`, `space.id`, current viewer,
97
- invocation, and permission state from the host.
98
- - An App opened through `cohub desktop open` also receives an `invocation`
99
- snapshot with the originating `spaceId`, `sessionId`, `turnId`, and
100
- `toolCallId` when available. The invocation Space may differ from the App's
101
- own `space.id`.
102
- - `client.auth.authorize()` triggers an in-shell consent flow (no popup window).
103
-
104
- ```js
105
- const ctx = await client.context();
106
- console.log(ctx.viewer?.userUuid ?? null);
107
- console.log(ctx.invocation?.sessionId ?? null);
108
- ```
109
-
110
- Invocation fields describe where the open came from. They are identifiers,
111
- not authorization: API access remains controlled by the app session token and
112
- its grants.
113
-
114
- ### Broker mode (standalone deployment)
115
-
116
- The App is accessed as a standalone page (`window.parent === window`), e.g.
117
- a direct static-asset URL not wrapped in the Cohub iframe. The SDK opens a
118
- **popup window** to a Cohub auth-broker page to obtain tokens.
119
-
120
- - `client.context()` is **answered locally** by the SDK after anonymously
121
- resolving the page Origin. It has the same shape as bridge context, including
122
- App identity, Home Space, capabilities and App scopes. `shell.surface` is
123
- `"broker"`; there is no active shell Space, session or turn.
124
- - `client.auth.authorize()` opens the environment's trusted Cohub broker.
125
- - Broker tokens stay in memory and are never restored from or written to
126
- `localStorage`.
127
-
128
- > **No mode configuration is required.** `createCohubClient({ env })` uses
129
- > bridge inside the Cohub shell and broker on a registered standalone Origin.
130
- > Unregistered pages receive `null` from `context()`. Explicit `app` transport
131
- > options remain available for legacy integrations and local debugging.
132
-
133
- ### Detecting the mode at runtime
134
-
135
- ```js
136
- const ctx = await client.context();
137
- const isBroker = ctx?.mode === "broker";
138
- ```
139
-
140
- In broker mode you cannot get a spaceId from `context()`. Resolve it via the
141
- public by-slug App API (anonymous, no token needed):
142
-
143
- ```js
144
- const isBroker = ctx?.mode === "broker";
145
- let spaceId;
146
- if (isBroker) {
147
- const detail = await client.apps.getBySlug(ownerUsername, spaceSlug, appSlug);
148
- spaceId = detail.app.spaceId;
149
- } else {
150
- // Prefer where the App is running; fall back to the Space that owns it.
151
- spaceId = ctx.shell?.space?.id ?? ctx.invocation?.spaceId ?? ctx.app.homeSpace?.id;
152
- }
153
- ```
154
-
155
- ### Broker mode: user-activation ordering gotcha
156
-
157
- `HttpTransport` calls `getAccessToken()` on **every** request — including
158
- public ones like `apps.getBySlug()`. In broker mode, a token exchange opens a
159
- popup, which **consumes the browser's user-activation budget**. If a second
160
- popup (the consent dialog) follows in the same click, the browser blocks it.
161
-
162
- **Fix:** call `auth.authorize()` **before** any other API call that triggers
163
- `getAccessToken()`. The token returned by that exchange is cached in memory for
164
- the lifetime of the client, so later `getAccessToken()` calls reuse it without
165
- a popup.
166
-
167
- ```js
168
- // WRONG: getBySlug() opens a popup, consumes activation, consent popup blocked
169
- const detail = await client.apps.getBySlug(owner, spaceSlug, appSlug);
170
- await client.auth.authorize({ target: { kind: "pick-space" }, scopes });
171
-
172
- // RIGHT: authorize() opens the only popup, then getBySlug() hits the token cache
173
- await client.auth.authorize({ target: { kind: "pick-space" }, scopes });
174
- const detail = await client.apps.getBySlug(owner, spaceSlug, appSlug);
175
- ```
176
-
177
- Bridge mode is unaffected — `getAccessToken()` uses `postMessage` (no popup),
178
- so ordering does not matter there.
179
-
180
- ---
181
-
182
- ## 3. The permission model — read this twice
183
-
184
- > This is the **single most common source of bugs**. Every 403 you encounter
185
- > in an App will almost certainly trace back to a missing grant of the wrong
186
- > type. Read this section carefully.
187
-
188
- An App's effective permission for one Space is the union of **two grant
189
- sources** — either one is enough:
190
-
191
- ### App scopes (direct, no viewer consent)
192
-
193
- Granted by the publisher **at publish time** via `appScopes`. The App always
194
- has them — no viewer action needed. They are deliberately bounded to eight
195
- read-and-act scopes and apply **only to the App's own Space**:
196
-
197
- ```
198
- space.view — read space config, list models
199
- session.view — read sessions, turns, stream generation updates
200
- file.view — read files / file tree
201
- file.edit — write files
202
- taskrun.view — read task run details (used by generation polling!)
203
- session.prompt.readonly — send read-only prompts (no side effects)
204
- session.prompt.fullaccess— send prompts with full access (write, create sessions)
205
- command.execute — run sandbox shell commands
206
- ```
207
-
208
- ### Viewer grants (consent-required, any permission, per Space)
209
-
210
- A viewer grants these through a consent dialog triggered by
211
- `client.auth.authorize()`. A viewer may grant
212
- **any** permission they currently hold on the target Space — including scopes
213
- outside the eight app scopes, such as `generation.create` or the account-level
214
- `user.*` scopes. Two hard rules are enforced by the server:
215
-
216
- - At **grant time** the viewer must currently hold every requested permission
217
- on the target Space.
218
- - At **use time** the grant only works while the viewer still holds that
219
- permission there — losing a membership or a role downgrade takes effect
220
- immediately.
221
-
222
- Viewer grants are **per Space**: one viewer can hold a different grant for the
223
- App's own Space and for each Space they picked. Grants last **14 days**; the
224
- session token lives **1 hour** and is refreshed silently. Revoking a grant
225
- takes effect immediately — silently or via the API.
226
-
227
- > `allowedViewerScopes` is deprecated and no longer enforced: viewer grants
228
- > are not gated by the app configuration. The field stays on the wire for
229
- > compatibility; do not use it in new apps.
230
-
231
- ### The golden rule
232
-
233
- > **Reads on the App's own Space need app scopes. Everything else — actions,
234
- > other Spaces, account-level data — needs a viewer grant.**
235
-
236
- `session.prompt.fullaccess` lets you **send** a prompt, but reading the reply
237
- still needs `session.view`. `generation.create` (a viewer grant) lets you
238
- **create** a generation task, but polling its result needs `taskrun.view` —
239
- either from the App's `appScopes`, or from a viewer grant checked against the
240
- requested Space.
241
-
242
- ### Requesting viewer grants
243
-
244
- Normal consent must be requested **from a user gesture** (a button click).
245
- A request whose explicit target is the trusted `ctx.shell.space.id` is also
246
- silent for the read-only Shell scopes `space.view`, `file.view`,
247
- `file.view.filtered`, `session.view`, `taskrun.view`, and `checkpoint.view`.
248
- The Host decides this by comparing the request against the Space it is actually
249
- showing, and the server still checks the viewer's current permission on that
250
- Space. The API keeps its single `authorize` endpoint and is not told whether
251
- consent was interactive, so there is no flag to trust or forge. Broker surfaces
252
- keep the interactive flow. A Shell hosting an embedded App applies the same rule
253
- on its behalf. Existing grants are read without extending their expiry. Any other target, mixed scope set, `pick-space`,
254
- account data, writes, or actions keeps the interactive flow:
255
-
256
- ```js
257
- // 1. Target a known Space. The result names the Space that was actually
258
- // granted, which may differ from the request when the viewer cannot use it.
259
- const result = await client.auth.authorize({
260
- target: { kind: "space", spaceId: invocationSpaceId },
261
- scopes: ["taskrun.view"],
262
- reason: "This app reads generation tasks in the Space you opened it from.",
263
- });
264
- if (result.status === "granted" && result.target.kind === "space") {
265
- const space = client.space(result.target.spaceId);
266
- }
267
-
268
- // 2. One consent: the viewer picks the Space. The host loads the space list —
269
- // the app only learns the Space the viewer chose.
270
- const picked = await client.auth.authorize({
271
- target: { kind: "pick-space" },
272
- scopes: ["file.view", "session.view"],
273
- reason: "This app reads the Space you pick.",
274
- });
275
- if (picked.status === "granted" && picked.target.kind === "space") {
276
- const space = client.space(picked.target.spaceId);
277
- }
278
-
279
- // 3. Account-level data needs an account target, not a Space.
280
- const account = await client.auth.authorize({
281
- target: { kind: "account" },
282
- scopes: ["user.space.list"],
283
- });
284
-
285
- // 4. Strict target: never resolve to another Space.
286
- const strict = await client.auth.authorize({
287
- target: { kind: "space", spaceId: projectSpaceId },
288
- scopes: ["file.edit"],
289
- fallback: "none",
290
- });
291
- ```
292
-
293
- The result tells the App exactly what happened:
294
-
295
- | Field | Meaning |
296
- |---|---|
297
- | `status` | `granted`, `cancelled`, or `denied` |
298
- | `requestedTarget` | What the App asked for |
299
- | `target` | What was actually granted (`account`, or a Space with `spaceId`/`name`) |
300
- | `resolution` | `requested`, `selected`, or `fallback` |
301
- | `grant` | `{ id, spaceId, scopes, expiresAt }` from the server |
302
-
303
- Rules worth internalizing:
304
-
305
- - **Act on `target`, never on the request.** API calls are never redirected to
306
- another Space on your behalf; if you asked for Space A and got B, use B.
307
- - **Unavailable Spaces fall back by default.** The dialog shows the
308
- viewer-controlled Space that will be used. Pass `fallback: "none"` when the
309
- operation only makes sense in the Space you named.
310
- - **Cancellation is not an error.** `{ status: "cancelled" }` means the viewer
311
- closed the dialog; keep their input and offer the action again.
312
- - **Incremental growth.** New consent keeps scopes from a still-valid grant and
313
- adds the new ones; it never revives an expired or revoked scope.
314
- - Pass `alwaysAsk: true` to skip silent reuse — for re-confirming a grant or
315
- letting the viewer switch to another Space.
316
-
317
- Creating a viewer-owned Space stays on `client.auth.requestCreateSpace({ space, scopes })`
318
- (never silent, each confirm mints a new Space). `{ granted: false }` **may still
319
- carry a `space`**: the Space was created but bootstrap or authorization did not
320
- finish. Keep the id, resume against it, and never create a second Space or
321
- delete it automatically. An App can also create a viewer-owned Space directly
322
- with `client.spaces.create()` once it holds a `space.create` viewer grant.
323
-
324
- ### Checking grant state at runtime
325
-
326
- `client.context().permissions` reports everything an App needs to render its
327
- state — **without** triggering a dialog:
328
-
329
- ```js
330
- const ctx = await client.context();
331
- ctx.permissions.appScopes // publisher scopes granted at publish time
332
- ctx.permissions.viewerGrants // per-space viewer consents: [{ spaceId, scopes }]
333
- ctx.permissions.scopes // union of both (flat; for quick checks)
334
- ctx.permissions.viewerScopes // flat viewer scopes (legacy compatibility)
335
- ```
336
-
337
- Apps never cache or manage grants themselves — the host does. Checking state
338
- is for **rendering**; acting is `auth.authorize()`'s job.
339
-
340
- ### Managing grants
341
-
342
- The App runtime can render the viewer's current grants from
343
- `client.context().permissions.viewerGrants`. It must not call
344
- `client.apps.listMyGrants()` or `revokeMyGrant()` with its App session token:
345
- the server rejects App sessions from managing their own authorization.
346
-
347
- Use an account-authenticated SDK client or the CLI to list and revoke grants:
348
-
349
- ```bash
350
- cohub apps grants <app> # list your grants for an app
351
- cohub apps revoke <app> <grantId> # revoke one
352
- ```
353
-
354
- Those account-level APIs return grant rows with
355
- `{ id, spaceId, scopes, expiresAt, revokedAt }`. Revocation is durable: silent
356
- re-authorization can never revive a revoked grant — only a fresh consent
357
- dialog can.
358
-
359
- ### Complete API → scope mapping
360
-
361
- | Operation | SDK call | Scope needed | Source |
362
- |---|---|---|---|
363
- | Read space config | `space.get()` / `space.getConfig()` | `space.view` | app |
364
- | List models | `client.models.list()` / `listMultimodal()` | *(none — just authenticated)* | — |
365
- | Send a prompt (full) | `space.prompt({ accessMode: "full_access", ... })` | `session.prompt.fullaccess` | app or viewer |
366
- | Send a prompt (read-only) | `space.prompt({ accessMode: "read_only", ... })` | `session.prompt.readonly` | app or viewer |
367
- | Read turn result | `session.turns.get(turnId)` | `session.view` | app or viewer |
368
- | Stream generation | `session.subscribeGeneration(...)` | `session.view` | app or viewer |
369
- | Read file tree | `space.files.tree()` | `file.view` | app or viewer |
370
- | Read file content | `space.files.read(path)` | `file.view` | app or viewer |
371
- | Write files | `space.files.*` (write) | `file.edit` | app or viewer |
372
- | Create generation task | `client.generations.create(request)` | `generation.create` | **viewer only** |
373
- | **Poll generation result** | `client.generations.wait(taskRunId)` / `createAndWait()` | **`taskrun.view`** | app or viewer |
374
- | Read task run detail | `client.tasks.get(taskRunId)` | `taskrun.view` | app or viewer |
375
- | List tasks in a Space | `client.tasks.list({ spaceId })` | `taskrun.view` on that Space | app or viewer |
376
- | List all owned task runs | `client.tasks.list()` | `user.taskrun.list` | **viewer only** |
377
- | Create a Space for the viewer | `client.auth.requestCreateSpace({ space, scopes })` | requested scopes on the new Space | **viewer consent** |
378
- | Request any consent | `client.auth.authorize({ target, scopes })` | the requested scopes on the resolved target | **viewer consent** |
379
- | Create a viewer-owned Space directly | `client.spaces.create(input)` | `space.create` | **viewer only** |
380
- | List viewer's spaces | `client.spaces.list()` | `user.space.list` | **viewer only** |
381
- | List viewer's sessions | `client.user.listSessions()` | `user.session.list` | **viewer only** |
382
- | Read viewer's activity | `client.user.getActivity()` | `user.usage.read` | **viewer only** |
383
- | Run sandbox shell commands | `space.runCommand({ command })` | `command.execute` | app or viewer |
384
- | Commerce: entitlements | `client.app.commerce.getEntitlements()` | *(runtime only, no scope)* | — |
385
- | Commerce: consume credits | `client.app.commerce.consumeCredits()` | *(runtime only, no scope)* | — |
386
- | Commerce: purchase | `client.app.commerce.purchase()` | *(runtime only, no scope)* | — |
387
- | Realtime rooms | `client.app.realtime.createRoom()` / `joinRoom()` | *(runtime only, no scope)* | — |
388
-
389
- "app or viewer" means either source suffices: the app scope covers only the
390
- App's own Space; any other Space needs a viewer grant on that Space.
391
-
392
- ### Minimal scope sets for common App types
393
-
394
- **LLM chat app** (send prompt + read reply on its own Space):
395
- - `appScopes: ["space.view", "session.view", "session.prompt.fullaccess"]`
396
-
397
- **Image generation app** (create + poll):
398
- - `appScopes: ["space.view", "taskrun.view"]`
399
- - viewer grant at runtime: `generation.create`
400
-
401
- **File-reader app** (static, no viewer action):
402
- - `appScopes: ["space.view", "file.view"]`
403
-
404
- **Cross-space app** (acts on Spaces the viewer picks):
405
- - `appScopes: []` (or its own-Space needs)
406
- - viewer grants via `auth.authorize({ target: { kind: "pick-space" } })` at runtime
407
-
408
- ---
409
-
410
- ## 4. Initialization recipe
411
-
412
- ### No-build HTML (CDN import)
413
-
414
- Apps are typically single HTML files with no bundler. Import the SDK from an
415
- ESM CDN:
416
-
417
- ```js
418
- import { createCohubClient } from "https://esm.sh/@neta-art/cohub@latest";
419
- ```
420
-
421
- `@latest` keeps an App on the current SDK release. Pin an exact version only
422
- when a deployment needs reproducible dependency updates.
423
-
424
- ### Environment detection — critical
425
-
426
- The SDK defaults to **production**. An App running on a dev/staging host
427
- (e.g. `dev.cohub.live`, a `/dev/` path prefix) **must** pass `env: "dev"`
428
- explicitly — browsers do not inject `ENV` like Node does. If you omit this,
429
- your App will call the production API while the runtime host expects dev,
430
- causing silent auth failures.
431
-
432
- ```js
433
- const isDevApp =
434
- location.pathname.startsWith("/dev/") ||
435
- location.hostname.includes("dev");
436
-
437
- const client = createCohubClient({
438
- env: isDevApp ? "dev" : "prod",
439
- });
440
- ```
441
-
442
- ### Automatic standalone mode
443
-
444
- Published public `file` and `directory` Apps may also be opened at a standalone
445
- URL: the deployment's standalone host template with the App id substituted, for
446
- example `https://<app-id>.apps.example.com`. Standalone origins are opt-in
447
- against deployment configuration; without a template, Apps have no standalone
448
- URL.
449
-
450
- The same client initialization works in the Cohub iframe and at this direct
451
- URL. The SDK first uses the parent bridge when embedded; at a top-level page it
452
- anonymously resolves `window.location.origin` and selects the trusted broker.
453
- The resolver returns identity only and never returns a token.
454
-
455
- Explicit `app: { brokerOrigin, appId }` and slug-triple configuration remains
456
- supported for older externally hosted Apps, but new Apps should not declare a
457
- mode or hardcode their App id.
458
-
459
- ### Standard initialization sequence
460
-
461
- ```js
462
- // 1. Create client (env is mandatory in the browser)
463
- const client = createCohubClient({ env: isDevApp ? "dev" : "prod" });
464
-
465
- // 2. Get runtime context (and keep it fresh). `app` and its Home Space are
466
- // present in bridge and broker mode; shell location exists only when hosted.
467
- const ctx = await client.context();
468
- if (!ctx?.app?.id) {
469
- throw new Error("Not running inside a published app.");
470
- }
471
- const stopContextWatch = client.app.onContextChanged((next) => renderGrants(next));
472
-
473
- // 3. Request viewer grants from a user gesture (e.g. button click). Target
474
- // where the App is running, or `{ kind: "pick-space" }` when it has no
475
- // location of its own.
476
- const consent = await client.auth.authorize({
477
- target: {
478
- kind: "space",
479
- spaceId: ctx.shell?.space?.id ?? ctx.invocation?.spaceId ?? ctx.app.homeSpace?.id,
480
- },
481
- scopes: ["session.prompt.fullaccess", "generation.create"],
482
- reason: "This app sends prompts and generates images.",
483
- });
484
- if (consent.status !== "granted" || consent.target.kind !== "space") {
485
- throw new Error("Consent was not granted.");
486
- }
487
-
488
- // 4. Call capabilities on the Space that was actually granted — never on the
489
- // one you asked for.
490
- const activeSpace = client.space(consent.target.spaceId);
491
- const result = await activeSpace.prompt({ content: [{ type: "text", text: "Hello" }] });
492
- ```
493
-
494
- > **Normal interactive `auth.authorize()` calls must use a user gesture** (click
495
- > handler). The approved Shell read-only path may run during initialization in
496
- > bridge mode. Broker mode has no trusted Shell Space, so it keeps the normal
497
- > popup and gesture requirements. `alwaysAsk: true` always returns to the
498
- > interactive flow.
499
-
500
- ---
501
-
502
- ## 5. Capability reference
503
-
504
- Each recipe below shows the exact code pattern and scope requirements.
505
- Assume `client` and `space` are already initialized per [§4](#4-initialization-recipe).
506
-
507
- ### LLM chat (`space.prompt` + `subscribeGeneration`)
508
-
509
- **Scopes:** `session.prompt.fullaccess` (to send) + `session.view` (to
510
- read/stream). Either may come from `appScopes` (own Space) or a viewer grant.
511
- For a read-only prompt (no side effects), use `session.prompt.readonly`
512
- instead — but you **must** pass `accessMode: "read_only"` in the call (see the
513
- read-only recipe below).
514
-
515
- > **`accessMode` defaults to `full_access`.** If you omit it, the backend
516
- > treats the call as full-access and requires `session.prompt.fullaccess`. This
517
- > is the #1 cause of "I requested `session.prompt.readonly` but still got 403".
518
- > Always set `accessMode` explicitly to match the scope you hold.
519
-
520
- `space.prompt()` is **asynchronous** — it returns immediately with a turn
521
- whose `assistantText` is `null`. You must either stream the reply via
522
- `subscribeGeneration` or poll `turns.get()`.
523
-
524
- ```js
525
- // Send a prompt (creates or continues a session) — full access
526
- const result = await space.prompt({
527
- accessMode: "full_access", // default; needs session.prompt.fullaccess
528
- content: [{ type: "text", text: "Describe a shiba inu on Mars." }],
529
- sessionId: null, // null → creates a new session; pass an id to continue
530
- model: "gpt-5.5", // optional; omit for default
531
- intent: "followup", // "followup" | "steer" | "compact"
532
- });
533
- const sessionId = result.session.id;
534
- const turnId = result.turn.id;
535
-
536
- // --- Option A: stream the reply (preferred) ---
537
- // Requires session.view
538
- const stop = space.session(sessionId).subscribeGeneration({
539
- state(event) {
540
- // Partial text as it streams in
541
- const text = (event.state?.contentBlocks ?? [])
542
- .filter(b => b.type === "text")
543
- .map(b => b.text)
544
- .join("");
545
- console.log("streaming:", text);
546
- },
547
- finalized(event) {
548
- const turn = event.turn;
549
- const reply = turn.assistantText
550
- ?? (turn.assistantContent ?? []).filter(b => b.type === "text").map(b => b.text).join("");
551
- console.log("final:", reply);
552
- },
553
- error(event) {
554
- console.error("stream error:", event);
555
- },
556
- });
557
- // Call stop() to unsubscribe when done.
558
-
559
- // --- Option B: poll for the reply (fallback) ---
560
- // Also requires session.view
561
- async function waitForTurn(sessionId, turnId) {
562
- while (true) {
563
- const { turn } = await space.session(sessionId).turns.get(turnId);
564
- if (turn.status === "completed") return turn;
565
- if (turn.status === "failed") throw new Error(turn.errorMessage || "failed");
566
- await new Promise(r => setTimeout(r, 1500));
567
- }
568
- }
569
- const turn = await waitForTurn(sessionId, turnId);
570
- const reply = turn.assistantText;
571
- ```
572
-
573
- **Turn status values:** `"pending" | "running" | "completed" | "failed"`.
574
-
575
- **Turn reply fields:** `turn.assistantText` (string | null) and
576
- `turn.assistantContent` (ContentBlock[] | null). Always check both —
577
- `assistantText` is a convenience; `assistantContent` is the source of truth.
578
-
579
- > **Do not silently swallow `subscribeGeneration` errors.** If `session.view`
580
- > is missing, the WebSocket subscription fails. If you catch and ignore it,
581
- > your code silently degrades to polling — which will also 403. Surface the
582
- > error so you can diagnose the missing scope.
583
-
584
- #### Read-only prompt (`accessMode: "read_only"`)
585
-
586
- Use this when your App only needs to **generate** a reply without persisting
587
- any side effects (no new session is written, no turn stored on the space's
588
- history). It requires the lighter `session.prompt.readonly` scope.
589
-
590
- The critical detail: you **must** pass `accessMode: "read_only"` explicitly
591
- in the `space.prompt()` call. The scope you hold and the `accessMode` you
592
- send must match — the backend picks the permission check based on
593
- `accessMode`, defaulting to `full_access` when omitted.
594
-
595
- ```js
596
- // 1. Ensure the read-only scope is granted (silent when already covered)
597
- await client.auth.authorize({
598
- target: { kind: "space", spaceId: space.id },
599
- scopes: ["session.prompt.readonly"],
600
- reason: "Generate a one-off character reply (read-only).",
601
- });
602
-
603
- // 2. Send the prompt with accessMode matching the granted scope
604
- const result = await space.prompt({
605
- accessMode: "read_only", // ← required; omitting it → full_access → 403
606
- sessionId: null, // read-only prompts use a throwaway session
607
- content: [{ type: "text", text: prompt }],
608
- });
609
- const sessionId = result.session.id;
610
- const turnId = result.turn.id;
611
-
612
- // 3. Read the reply — still needs session.view
613
- const stop = space.session(sessionId).subscribeGeneration({
614
- finalized: (event) => {
615
- const reply = event.turn.assistantText
616
- ?? (event.turn.assistantContent ?? [])
617
- .filter(b => b.type === "text").map(b => b.text).join("");
618
- console.log("reply:", reply);
619
- stop();
620
- },
621
- error: (event) => console.error("stream error:", event),
622
- });
623
- ```
624
-
625
- > **Scope/accessMode mismatch → 403.** Holding `session.prompt.readonly` but
626
- > calling `space.prompt({ content })` (no `accessMode`) fails because the
627
- > backend defaults to `full_access` and checks `session.prompt.fullaccess`.
628
- > Symmetrically, holding `session.prompt.fullaccess` while passing
629
- > `accessMode: "read_only"` works only if `session.prompt.readonly` is
630
- > additionally granted — otherwise 403. Always keep them in sync.
631
-
632
- ### Image / media generation (`generations.createAndWait`)
633
-
634
- **Scopes:** viewer grant `generation.create` (to create) + `taskrun.view`
635
- (to poll; from `appScopes` for the App's own Space, or a viewer grant on the
636
- target Space).
637
-
638
- `createAndWait` is a convenience that calls `create` then `wait` (polls
639
- `GET /api/tasks/{id}`). **Both scopes are required** — missing `taskrun.view`
640
- is the #1 cause of "generation creates but never returns" bugs.
641
-
642
- ```js
643
- const result = await client.generations.createAndWait(
644
- {
645
- spaceId,
646
- model: "gpt-image-2", // model id from models.listMultimodal()
647
- content: [{ type: "text", text: "A cat on the moon, cartoon style" }],
648
- parameters: { // optional, model-specific
649
- size: "1024x1024",
650
- // quality: "auto", // gpt-image-2 supports quality
651
- },
652
- },
653
- {
654
- onPoll: (detail) => console.log("status:", detail.run.status),
655
- // intervalMs: 1500, // optional poll interval
656
- // timeoutMs: 30 * 60 * 1000, // optional timeout (default 30 min)
657
- },
658
- );
659
-
660
- // Extract the image URL from the output blocks
661
- const image = (result.output ?? []).find(
662
- b => b.type === "image" && b.source?.url
663
- );
664
- const imageUrl = image?.source?.url;
665
- ```
666
-
667
- **Output block types:** `text`, `image`, `video`, `audio`. Each media block has
668
- a `source` of `{ type: "url", url }` or `{ type: "base64", mediaType, data }`.
669
-
670
- **Two-step alternative** (if you need the `taskRunId` immediately):
671
-
672
- ```js
673
- const created = await client.generations.create(request);
674
- // created.taskRunId — generation task is queued
675
- const result = await client.generations.wait(created.taskRunId, { onPoll });
676
- ```
677
-
678
- ### Model listing (`models.list` / `models.listMultimodal`)
679
-
680
- **Scopes:** none — only requires a valid authenticated token (any token, no
681
- specific scope). Returns 401 without a token, but never 403.
682
-
683
- Call after initialization (after `context()` succeeds, so a token exists):
684
-
685
- ```js
686
- // All models grouped by provider
687
- const catalog = await client.models.list();
688
- // catalog: { cohub: [...], openai: [...], ... }
689
-
690
- // Generation-capable models only (for image/video/audio generation)
691
- const { models } = await client.models.listMultimodal();
692
- // models: [{ model, title, description, ... }, ...]
693
- ```
694
-
695
- Use `listMultimodal()` to populate a model picker for generation. Each entry's
696
- `model` field is the id you pass to `generations.createAndWait({ model })`.
697
-
698
- ### File reads (`space.files`)
699
-
700
- **Scopes:** `file.view` (read) + `space.view` (often needed for the space context).
701
-
702
- ```js
703
- // List the file tree
704
- const tree = await space.files.tree();
705
- // tree: nested file/directory entries
706
-
707
- // Read a file (returns an HTTP Response — .text() / .blob() / .arrayBuffer())
708
- const response = await space.files.read("path/to/file.txt");
709
- const text = await response.text();
710
-
711
- // Read multiple files at once
712
- const files = await space.files.readMany(["a.txt", "b.json"]);
713
- ```
714
-
715
- ### Account-level data (`spaces.list` / `user.listSessions` / `user.getActivity`)
716
-
717
- **Scopes:** viewer grants `user.space.list` / `user.session.list` /
718
- `user.usage.read` — a publisher can never pre-grant these via `appScopes`.
719
-
720
- These access the **viewer's** account-level data across all their spaces —
721
- not the App's own space. Each requires a separate viewer grant.
722
-
723
- ```js
724
- // List the viewer's spaces — needs user.space.list
725
- await client.auth.authorize({
726
- target: { kind: "account" },
727
- scopes: ["user.space.list"],
728
- reason: "Show your space list.",
729
- });
730
- const spaces = await client.spaces.list();
731
-
732
- // List sessions across all the viewer's spaces — needs user.session.list
733
- await client.auth.authorize({
734
- target: { kind: "account" },
735
- scopes: ["user.session.list"],
736
- reason: "List your recent sessions.",
737
- });
738
- const { sessions } = await client.user.listSessions({ limit: 20 });
739
-
740
- // Read activity — needs user.usage.read
741
- await client.auth.authorize({
742
- target: { kind: "account" },
743
- scopes: ["user.usage.read"],
744
- reason: "Show your activity.",
745
- });
746
- const activity = await client.user.getActivity({ days: 30 }); // last 30 days
747
- ```
748
-
749
- For task runs specifically, the account scope is `user.taskrun.list`: with an
750
- explicit viewer grant, the unscoped `client.tasks.list()` returns every Task
751
- Run **owned by** the viewer — including runs from Spaces they can no longer
752
- access. Without it, the unscoped list stays space-scoped to live grants.
753
-
754
- ### Commerce (`app.commerce`)
755
-
756
- **Scopes:** none — runs inside the App runtime, no scope needed. Only works
757
- in a published App.
758
-
759
- ```js
760
- // Check entitlements and credit balance in one call
761
- const { entitlements, credits } = await client.app.commerce.getEntitlements();
762
-
763
- // Feature unlock: purchase if not entitled
764
- const unlocked = entitlements.some(e => e.benefitKey === "space_pro" && e.enabled);
765
- if (!unlocked) {
766
- await client.app.commerce.purchase({ productKey: "pro_unlock" });
767
- // purchase() redirects to checkout; after return, re-check entitlements
768
- }
769
-
770
- // Credit consumption for a metered action
771
- const result = await client.app.commerce.consumeCredits({
772
- amount: 10,
773
- operationId: crypto.randomUUID(), // idempotency key
774
- reason: "Export high-res image",
775
- });
776
- if (result.status === "insufficient") {
777
- await client.app.commerce.purchase({ productKey: "credit_pack" });
778
- }
779
-
780
- // After checkout return, query the order
781
- const checkoutState = await client.app.commerce.getCheckoutState();
782
- if (checkoutState.orderId) {
783
- const { order } = await client.app.commerce.getOrder(checkoutState.orderId);
784
- }
785
- ```
786
-
787
- `purchase()` creates a purchase attempt ID automatically. If application code
788
- retries the call after a timeout, pass the same `purchaseAttemptId` to ensure
789
- the retry resolves to the original Billing order.
790
-
791
- ### Realtime rooms (`app.realtime`)
792
-
793
- **Scopes:** none — uses the published App's runtime identity without an
794
- additional consent dialog. The CLI and ordinary server auth cannot create or
795
- join these rooms.
796
-
797
- ```js
798
- const room = await client.app.realtime.createRoom({
799
- code: "TEAM-ALPHA", // optional; generated when omitted
800
- maxParticipants: 64,
801
- expiresInSeconds: 2 * 60 * 60,
802
- });
803
-
804
- const stopEvents = room.subscribe("shared.state.updated", (event) => {
805
- console.log(event.sequence, event.data, event.self);
806
- });
807
- console.log(room.members); // initial snapshot
808
- const stopMembers = room.onMembersChanged((members) => {
809
- console.log(members);
810
- });
811
-
812
- await room.setPresence({ status: "active" });
813
- await room.publish("shared.state.updated", { value: 42 });
814
-
815
- stopEvents();
816
- stopMembers();
817
- await room.leave();
818
- ```
819
-
820
- Join an existing room with
821
- `client.app.realtime.joinRoom({ code: "TEAM-ALPHA" })`. Codes are scoped to
822
- one App and are identifiers, not credentials; the runtime session and a
823
- short-lived admission ticket provide authorization.
824
-
825
- | Surface | Purpose |
826
- |---|---|
827
- | `createRoom()` / `joinRoom()` | Create or enter a code-scoped room |
828
- | `subscribe()` / `subscribeAll()` | Receive typed or all business events |
829
- | `publish()` | Send an acknowledged event; accepts an optional correlation-only `clientEventId` |
830
- | `send()` / `onSendError()` | Send without an ACK for high-rate traffic; observe asynchronous failures |
831
- | `members` / `onMembersChanged()` | Read the initial member snapshot and later membership or presence changes |
832
- | `setPresence()` | Replace this participant's transient presence object |
833
- | `state` / `onStateChange()` | Observe `connecting`, `joined`, `reconnecting`, `expired`, or `closed` |
834
- | `onOutOfSync()` | Detect sequence jumps in the current live stream |
835
- | `leave()` | Release membership and SDK listeners |
836
-
837
- Room events are ordered while connected but are not replayed. A reconnect
838
- refreshes the member snapshot and advances the sequence cursor, so use
839
- `onStateChange()` to resync authoritative application state after reconnecting;
840
- `onOutOfSync()` only reports gaps visible in the current live stream. Payloads
841
- are transient and are not stored in the App.
842
-
843
- | Limit | Value |
844
- |---|---|
845
- | Room code | Generated when omitted; custom codes are 3–48 uppercase letters, digits, `_`, or `-`, starting with a letter or digit |
846
- | Lifetime | 2 hours by default; 60 seconds to 24 hours, absolute from creation |
847
- | Participants | 16 by default; 2–128 |
848
- | Active rooms | 512 per App |
849
- | Event name | 1–64 ASCII letters, digits, `.`, `_`, `:`, or `-`, starting with a letter or digit; `cohub.*` is reserved |
850
- | Event payload | 16 KB of JSON |
851
- | Presence payload | 2 KB of JSON |
852
- | Publish rate | 2,000 events per second per room |
853
- | Presence rate | 30 updates per second per connection |
854
- | Pending mutations | 256 per connection before backpressure errors |
855
-
856
- `createRoom()` returns HTTP 429 with `ROOM_QUOTA_EXCEEDED` at the active-room
857
- limit. Activity never extends `expiresAt`; expired rooms release their slot
858
- automatically.
859
-
860
- #### High-frequency events
861
-
862
- `publish()` waits for an ACK, so a loop that awaits each call is capped at
863
- roughly `1000 / RTT` events per second. Use `send()` for input frames and other
864
- high-rate traffic:
865
-
866
- ```js
867
- room.onSendError((error) => console.warn("dropped frame", error.message));
868
- room.onStateChange((state) => {
869
- if (state !== "joined") pauseSimulation();
870
- });
871
-
872
- room.send("input.frame", { frame, pad });
873
- ```
874
-
875
- `send()` preserves server ordering but drops calls while the room is not joined
876
- and reports rate, validation, membership, and backpressure failures through
877
- `onSendError()`. Use `publish()` whenever a specific event must be confirmed.
878
-
879
- #### Seats and participant identity
880
-
881
- Every connection is a participant by default, so two tabs appear twice. Each
882
- member includes an opaque `userKey` that is stable for one room and viewer,
883
- letting an application group connections without seeing the account ID.
884
-
885
- Set `seatPerUser: true` when each viewer should occupy at most one seat:
886
-
887
- ```js
888
- const room = await client.app.realtime.createRoom({
889
- maxParticipants: 2,
890
- seatPerUser: true,
891
- });
892
- ```
893
-
894
- A second tab or reconnect takes over the existing seat instead of consuming a
895
- new one. The server keeps the participant ID, updates `room.participantId`, and
896
- closes the superseded connection without emitting a leave event. Without this
897
- mode, an unclean disconnect can retain its seat lease for up to one minute.
898
-
899
- #### Desktop command calls that complete later
900
-
901
- A `desktop open --call` command with a Surface request stays pending after the
902
- App acknowledges it. The host waits for the App to be mounted and ready, then
903
- the App receives the originating `commandId` in the handler context and
904
- returns an acknowledgement immediately:
905
-
906
- ```js
907
- let activeCommandId = null;
908
-
909
- client.app.surface.handle("image.open", async (input, { commandId }) => {
910
- if (!commandId) throw new Error("image.open must be called by a desktop command");
911
- activeCommandId = commandId;
912
- openImageStudio(input);
913
- return { accepted: true };
914
- });
915
-
916
- async function useImage(result) {
917
- if (!activeCommandId) return;
918
- await client.desktop.reportResult(activeCommandId, {
919
- status: "applied",
920
- result,
921
- error: null,
922
- });
923
- activeCommandId = null;
924
- }
925
- ```
926
-
927
- The App should persist the command id alongside its local/server-backed draft
928
- so a reload can restore the pending interaction. An App session may only
929
- report a command that targets that same App. Existing
930
- `client.app.surface.handle(method, handler)` usage remains unchanged.
931
-
932
- ---
933
-
934
- ## 6. Complete working example
935
-
936
- A no-build HTML App for LLM chat and image generation. Use it as a starting
937
- point and keep only the capabilities you need.
938
-
939
- > **Publish this App with:**
940
- > - `appScopes: ["space.view", "session.view", "taskrun.view"]`
941
- >
942
- > `session.prompt.fullaccess` and `generation.create` are requested as viewer
943
- > grants at runtime. See [§8](#8-publishing-an-app-apisdk) for the publish
944
- > API call.
945
-
946
- ### `index.html`
947
-
948
- ```html
949
- <!DOCTYPE html>
950
- <html lang="en">
951
- <head>
952
- <meta charset="UTF-8" />
953
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
954
- <title>Cohub SDK Demo</title>
955
- <link rel="stylesheet" href="style.css" />
956
- </head>
957
- <body>
958
- <div class="container">
959
- <h1>Cohub SDK Demo</h1>
960
-
961
- <section class="card">
962
- <h2>1. Context</h2>
963
- <button id="btn-context" class="btn">Get context</button>
964
- <pre id="output-context" class="output"></pre>
965
- </section>
966
-
967
- <section class="card">
968
- <h2>2. Authorize</h2>
969
- <button id="btn-auth" class="btn">Request viewer grants</button>
970
- <pre id="output-auth" class="output"></pre>
971
- </section>
972
-
973
- <section class="card">
974
- <h2>3. LLM chat</h2>
975
- <div id="chat-log" class="chat-log"></div>
976
- <textarea id="chat-input" rows="2">Say hello in one sentence.</textarea>
977
- <button id="btn-chat" class="btn">Send</button>
978
- <pre id="output-chat" class="output"></pre>
979
- </section>
980
-
981
- <section class="card">
982
- <h2>4. Image generation</h2>
983
- <textarea id="img-prompt" rows="3">A cat on the moon, cartoon style</textarea>
984
- <button id="btn-img" class="btn">Generate</button>
985
- <div id="img-result"></div>
986
- <pre id="output-img" class="output"></pre>
987
- </section>
988
- </div>
989
- <script type="module" src="app.js"></script>
990
- </body>
991
- </html>
992
- ```
993
-
994
- ### `app.js`
995
-
996
- ```js
997
- import { createCohubClient } from "https://esm.sh/@neta-art/cohub@latest";
998
-
999
- // --- Environment detection (critical: browsers don't inject ENV) ---
1000
- const isDevApp =
1001
- location.pathname.startsWith("/dev/") ||
1002
- location.hostname.includes("dev");
1003
-
1004
- const client = createCohubClient({
1005
- env: isDevApp ? "dev" : "prod",
1006
- });
1007
-
1008
- const REQUIRED_SCOPES = ["generation.create", "session.prompt.fullaccess"];
1009
-
1010
- let space = null;
1011
- let spaceId = null;
1012
- let sessionId = null;
1013
-
1014
- const $ = (id) => document.getElementById(id);
1015
-
1016
- function setOutput(el, data) {
1017
- el.textContent = typeof data === "object" ? JSON.stringify(data, null, 2) : String(data);
1018
- }
1019
-
1020
- function log(el, msg) {
1021
- el.textContent += `[${new Date().toLocaleTimeString()}] ${msg}\n`;
1022
- }
1023
-
1024
- // --- Runtime initialization ---
1025
- async function ensureRuntime(outEl) {
1026
- const ctx = await client.context();
1027
- if (!ctx?.app?.id) {
1028
- throw new Error("Not running inside a published app runtime.");
1029
- }
1030
- // Prefer the current Cohub location, then the Space that owns the App.
1031
- spaceId = ctx.shell?.space?.id ?? ctx.invocation?.spaceId ?? ctx.app.homeSpace?.id;
1032
- if (!spaceId) throw new Error("No Space to act on — request one with auth.authorize().");
1033
- space = client.space(spaceId);
1034
- return ctx;
1035
- }
1036
-
1037
- // --- Viewer grant helpers ---
1038
- // Render state from context; act through auth.authorize (silent when covered).
1039
- function hasViewerGrant(ctx, scope, spaceId) {
1040
- return (ctx?.permissions?.viewerGrants ?? []).some(
1041
- (g) => g.spaceId === (spaceId ?? ctx?.app?.homeSpace?.id) && g.scopes.includes(scope),
1042
- );
1043
- }
1044
-
1045
- async function ensureViewerScopes(scopes, reason, outEl) {
1046
- const ctx = await ensureRuntime(outEl);
1047
- const ctxScopes = new Set(ctx?.permissions?.scopes ?? []);
1048
- const missing = scopes.filter((s) => !ctxScopes.has(s));
1049
- if (missing.length === 0) {
1050
- log(outEl, `Already granted: [${scopes.join(", ")}]`);
1051
- return true;
1052
- }
1053
- log(outEl, `Requesting: [${missing.join(", ")}]...`);
1054
- const result = await client.auth.authorize({
1055
- target: { kind: "space", spaceId: ctx?.shell?.space?.id ?? ctx?.invocation?.spaceId },
1056
- scopes,
1057
- reason,
1058
- });
1059
- if (result.status === "granted") {
1060
- log(outEl, `Authorized on ${result.target.kind === "space" ? result.target.spaceId : "account"}.`);
1061
- } else {
1062
- log(outEl, result.status === "cancelled" ? "Consent cancelled." : `Consent denied (${result.code}).`);
1063
- }
1064
- return result.status === "granted";
1065
- }
1066
-
1067
- // --- LLM chat (space.prompt + subscribeGeneration) ---
1068
- function extractText(blocks) {
1069
- return (blocks ?? [])
1070
- .filter((b) => b.type === "text" && typeof b.text === "string")
1071
- .map((b) => b.text)
1072
- .join("");
1073
- }
1074
-
1075
- function waitForTurn(sid, turnId, onStream) {
1076
- return new Promise((resolve, reject) => {
1077
- let done = false;
1078
- let stop = null;
1079
-
1080
- const finish = (fn) => {
1081
- if (done) return;
1082
- done = true;
1083
- if (stop) try { stop(); } catch {}
1084
- fn();
1085
- };
1086
-
1087
- // Primary path: stream via WebSocket (needs session.view)
1088
- try {
1089
- stop = space.session(sid).subscribeGeneration({
1090
- state: (event) => {
1091
- const text = extractText(event.state?.contentBlocks);
1092
- if (text) onStream?.(text);
1093
- },
1094
- finalized: (event) => finish(() => resolve(event.turn)),
1095
- error: (event) => finish(() =>
1096
- reject(new Error(event?.rawEvent?.payload?.message || "stream error"))),
1097
- });
1098
- } catch (err) {
1099
- console.warn("subscribeGeneration failed:", err);
1100
- }
1101
-
1102
- // Fallback: poll (also needs session.view — if missing, both paths 403)
1103
- const poll = async () => {
1104
- try {
1105
- const { turn } = await space.session(sid).turns.get(turnId);
1106
- if (turn.assistantText) onStream?.(turn.assistantText);
1107
- if (turn.status === "completed") finish(() => resolve(turn));
1108
- if (turn.status === "failed" || turn.status === "cancelled") {
1109
- finish(() => reject(new Error(turn.errorMessage || "failed")));
1110
- }
1111
- } catch (err) {
1112
- // Don't silently swallow — surface 403 (missing session.view)
1113
- console.error("poll error:", err);
1114
- }
1115
- };
1116
- const interval = setInterval(poll, 2000);
1117
- poll();
1118
- setTimeout(() => finish(() => reject(new Error("timeout"))), 120000);
1119
- // Note: in production, clear the interval in finish()
1120
- });
1121
- }
1122
-
1123
- async function chat(text, outEl) {
1124
- const result = await space.prompt({
1125
- content: [{ type: "text", text }],
1126
- sessionId: sessionId || null,
1127
- intent: "followup",
1128
- });
1129
- sessionId = result.session.id;
1130
- const turn = await waitForTurn(sessionId, result.turn.id);
1131
- return turn.assistantText || extractText(turn.assistantContent) || "(empty)";
1132
- }
1133
-
1134
- // --- Image generation (generations.createAndWait) ---
1135
- async function generateImage(prompt, outEl) {
1136
- const result = await client.generations.createAndWait(
1137
- {
1138
- spaceId,
1139
- model: "gpt-image-2",
1140
- content: [{ type: "text", text: prompt }],
1141
- parameters: { size: "1024x1024" },
1142
- },
1143
- { onPoll: (d) => log(outEl, `poll: ${d.run.status}`) },
1144
- );
1145
- const image = (result.output ?? []).find(
1146
- (b) => b.type === "image" && b.source?.url,
1147
- );
1148
- return image?.source?.url ?? null;
1149
- }
1150
-
1151
- // --- Event handlers (auth.authorize must be in a user gesture) ---
1152
- $("btn-context").addEventListener("click", async () => {
1153
- const out = $("output-context");
1154
- out.textContent = "";
1155
- try {
1156
- const ctx = await client.context();
1157
- setOutput(out, {
1158
- "app.id": ctx?.app?.id,
1159
- "space.id": ctx?.space?.id,
1160
- permissions: ctx?.permissions,
1161
- });
1162
- } catch (err) { log(out, err.message); }
1163
- });
1164
-
1165
- $("btn-auth").addEventListener("click", async () => {
1166
- const out = $("output-auth");
1167
- out.textContent = "";
1168
- const ok = await ensureViewerScopes(
1169
- REQUIRED_SCOPES,
1170
- "This demo needs to send prompts and generate images.",
1171
- out,
1172
- );
1173
- if (ok) log(out, "Ready to use capabilities.");
1174
- });
1175
-
1176
- $("btn-chat").addEventListener("click", async () => {
1177
- const out = $("output-chat");
1178
- out.textContent = "";
1179
- const text = $("chat-input").value.trim();
1180
- if (!text) return;
1181
- try {
1182
- await ensureRuntime(out);
1183
- const ok = await ensureViewerScopes(
1184
- ["session.prompt.fullaccess"],
1185
- "LLM chat needs session.prompt.fullaccess.",
1186
- out,
1187
- );
1188
- if (!ok) return;
1189
- const reply = await chat(text, out);
1190
- log(out, `Reply: ${reply}`);
1191
- } catch (err) { log(out, err.message); }
1192
- });
1193
-
1194
- $("btn-img").addEventListener("click", async () => {
1195
- const out = $("output-img");
1196
- out.textContent = "";
1197
- const prompt = $("img-prompt").value.trim();
1198
- if (!prompt) return;
1199
- try {
1200
- await ensureRuntime(out);
1201
- const ok = await ensureViewerScopes(
1202
- ["generation.create"],
1203
- "Image generation needs a generation.create viewer grant.",
1204
- out,
1205
- );
1206
- if (!ok) return;
1207
- const url = await generateImage(prompt, out);
1208
- if (url) {
1209
- $("img-result").innerHTML = `<img src="${url}" style="max-width:100%" />`;
1210
- }
1211
- log(out, "Done.");
1212
- } catch (err) { log(out, err.message); }
1213
- });
1214
-
1215
- // Auto-fetch context on load (does not require auth)
1216
- (async () => {
1217
- try {
1218
- const ctx = await client.context();
1219
- if (ctx) {
1220
- setOutput($("output-context"), {
1221
- "app.id": ctx.app?.id,
1222
- "app.homeSpace.id": ctx.app?.homeSpace?.id,
1223
- mode: ctx.mode,
1224
- permissions: ctx.permissions,
1225
- });
1226
- }
1227
- } catch {}
1228
- })();
1229
- ```
1230
-
1231
- ---
1232
-
1233
- ## 7. Common pitfalls checklist
1234
-
1235
- Before publishing your App, verify each item:
1236
-
1237
- - [ ] **Environment**: passed `env: "dev"` (or `"prod"`) explicitly — the SDK
1238
- defaults to prod and browsers don't inject `ENV`.
1239
- - [ ] **App scopes include every read on the App's own Space**:
1240
- `session.view` (LLM reply reads), `taskrun.view` (generation polling),
1241
- `file.view` (file reads). Missing any of these → 403 on reads.
1242
- - [ ] **Viewer-grant-only scopes are requested at runtime, not configured at
1243
- publish time**: `generation.create` and every `user.*` scope can only come
1244
- from a viewer grant.
1245
- - [ ] **`session.prompt.*` scope matches `space.prompt({ accessMode })`.**
1246
- Omitting `accessMode` defaults to `full_access`, so holding only
1247
- `session.prompt.readonly` and then calling `space.prompt({ content })` → 403.
1248
- Set `accessMode: "read_only"` explicitly when using the readonly scope.
1249
- - [ ] **`session.prompt.fullaccess` does NOT include `session.view`** — they
1250
- are separate. Sending a prompt succeeds but reading the reply 403s without
1251
- `session.view`.
1252
- - [ ] **`generation.create` does NOT include `taskrun.view`** — creating a
1253
- generation task succeeds but polling the result 403s without `taskrun.view`.
1254
- - [ ] **`auth.authorize()` is called from a user gesture** (button click), not
1255
- on page load. It is safe to call repeatedly — covered scopes renew silently.
1256
- - [ ] **Cross-Space access targets the right Space** — viewer grants are per
1257
- Space. Pass `target: { kind: "space", spaceId }` to name one, or
1258
- `target: { kind: "pick-space" }` to let the viewer choose. Act on the
1259
- returned `target`, which may differ from the request. Use
1260
- `auth.requestCreateSpace` to mint a new viewer-owned Space in one consent, or
1261
- `spaces.create()` directly once the app holds a `space.create` viewer grant.
1262
- - [ ] **`subscribeGeneration` errors are not silently swallowed** — if the
1263
- stream fails, surface it; a silent fallback to polling will also 403 if
1264
- `session.view` is missing.
1265
- - [ ] **Broker mode**: initialize `context()` early so Origin discovery is warm,
1266
- then call `auth.authorize()` from a user action before protected API calls.
1267
- - [ ] **Space has a slug and owner has a username** before publishing — the
1268
- API rejects Apps when either is missing.
1269
- - [ ] **Model ids are not hardcoded** — use `client.models.listMultimodal()`
1270
- to fetch available models dynamically (requires auth but no scope).
1271
-
1272
- ---
1273
-
1274
- ## 8. Publishing an App (API/SDK)
1275
-
1276
- Before creating an App through the API, ensure the owner has a username and
1277
- the Space has a slug. The API rejects Apps when either public identity part
1278
- is missing.
1279
-
1280
- ```js
1281
- // Create a single-file App (HTML file)
1282
- await client.apps.create({
1283
- spaceId,
1284
- slug: "my-html-demo",
1285
- status: "published",
1286
- targetType: "file",
1287
- targetRef: "demo/index.html",
1288
- appScopes: ["space.view", "session.view", "taskrun.view"],
1289
- });
1290
-
1291
- // Create a directory App (must contain index.html)
1292
- await client.apps.create({
1293
- spaceId,
1294
- slug: "my-site",
1295
- status: "published",
1296
- targetType: "directory",
1297
- targetRef: "site",
1298
- appScopes: ["space.view", "session.view", "taskrun.view", "file.view"],
1299
- });
1300
-
1301
- // Create a port App (sandbox dev server)
1302
- await client.apps.create({
1303
- spaceId,
1304
- slug: "live-preview",
1305
- status: "published",
1306
- targetType: "port",
1307
- targetRef: "5173",
1308
- appScopes: ["space.view"],
1309
- });
1310
- ```
1311
-
1312
- `targetRef` is a path **relative to the Space filesystem root** (not a local
1313
- path). For file Apps, the target must be an HTML file (`.html`/`.htm`).
1314
-
1315
- Update the published version from the current target:
1316
-
1317
- ```js
1318
- await client.apps.publishVersion(appId);
1319
- ```
1320
-
1321
- Other SDK methods: `apps.get(appId)`, `apps.getBySlug(username, spaceSlug,
1322
- appSlug)`, `apps.listBySpace(spaceId)`, `apps.update(appId, input)`,
1323
- `apps.delete(appId)`, `apps.getStats(appId)`, `apps.download(appId)`.
1324
-
1325
- From the CLI (`--dir` takes the path inside the target Space's workspace, not a local path):
1326
-
1327
- ```bash
1328
- cohub apps publish <slug> --dir site --app-scope space.view --app-scope file.view
1329
- cohub apps grants <slug> # list your viewer grants for an app
1330
- cohub apps revoke <slug> <grantId>
1331
- ```