@neta-art/cohub 8.1.0 → 8.2.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.
@@ -1,43 +1,48 @@
1
- # Cohub Work Runtime Guide
1
+ # Cohub App Runtime Guide
2
2
 
3
- This guide explains how to use the Cohub SDK **inside a published Work** — the
3
+ This guide explains how to use the Cohub SDK **inside a published App** — the
4
4
  only environment where runtime APIs (`context()`, `auth.request`,
5
- `work.commerce.*`, `work.realtime.*`) function. Read this before writing any
6
- Work that calls Cohub capabilities from browser-side JavaScript.
5
+ `app.commerce.*`, `app.realtime.*`) function. Read this before building any
6
+ App that calls Cohub capabilities from browser-side JavaScript.
7
7
 
8
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
9
+ file plus the SDK type definitions should be able to build a working App
10
10
  without reverse-engineering source code.
11
11
 
12
+ > **Vocabulary note.** Apps were previously called *Works*. The SDK speaks the
13
+ > canonical App vocabulary (`client.apps`, `appScopes`); the legacy
14
+ > `client.works` / `workScopes` spellings remain as deprecated aliases, and
15
+ > existing `/w/` public URLs keep working.
16
+
12
17
  ---
13
18
 
14
19
  ## Table of contents
15
20
 
16
21
  1. [Mental model](#1-mental-model)
17
22
  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)
23
+ 3. [The permission model — read this twice](#3-the-permission-model--read-this-twice)
19
24
  4. [Initialization recipe](#4-initialization-recipe)
20
25
  5. [Capability reference](#5-capability-reference)
21
26
  - [LLM chat](#llm-chat-spaceprompt--subscribegeneration)
22
27
  - [Image / media generation](#image--media-generation-generationscreateandwait)
23
28
  - [Model listing](#model-listing-modelslist--modelslistmultimodal)
24
29
  - [File reads](#file-reads-spacefiles)
25
- - [Account-level data](#account-level-data-spaceslist--userlistsessions--usergetusage)
26
- - [Commerce](#commerce-workcommerce)
27
- - [Realtime rooms](#realtime-rooms-workrealtime)
30
+ - [Account-level data](#account-level-data-spaceslist--userlistsessions--usergetactivity)
31
+ - [Commerce](#commerce-appcommerce)
32
+ - [Realtime rooms](#realtime-rooms-apprealtime)
28
33
  6. [Complete working example](#6-complete-working-example)
29
34
  7. [Common pitfalls checklist](#7-common-pitfalls-checklist)
30
- 8. [Publishing a Work (API/SDK)](#8-publishing-a-work-apisdk)
35
+ 8. [Publishing an App (API/SDK)](#8-publishing-an-app-apisdk)
31
36
 
32
37
  ---
33
38
 
34
39
  ## 1. Mental model
35
40
 
36
- A **Work** is a published, shareable web page hosted by Cohub. When a viewer
37
- opens a Work, Cohub serves its HTML/JS inside a **runtime** that bridges the
38
- Work's code to Cohub's backend.
41
+ An **App** is a published, shareable web page hosted by Cohub. When a viewer
42
+ opens an App, Cohub serves its HTML/JS inside a **runtime** that bridges the
43
+ App's code to Cohub's backend.
39
44
 
40
- From the Work's JavaScript, you create a Cohub client and call APIs the same
45
+ From the App's JavaScript, you create a Cohub client and call APIs the same
41
46
  way you would from any other client — **but** the client is pre-wired to obtain
42
47
  short-lived access tokens from the Cohub shell (the runtime host) instead of
43
48
  requiring the viewer to paste an API key.
@@ -47,7 +52,7 @@ requiring the viewer to paste an API key.
47
52
  │ Cohub shell (host page / iframe parent) │
48
53
  │ │
49
54
  │ ┌───────────────────────────────────┐ │
50
- │ │ Your Work (iframe or standalone) │ │
55
+ │ │ Your App (iframe or standalone) │ │
51
56
  │ │ │ │
52
57
  │ │ createCohubClient() ──► token ──►│──┼──► Cohub API
53
58
  │ │ client.context() ◄── identity │ │
@@ -56,19 +61,24 @@ requiring the viewer to paste an API key.
56
61
  └─────────────────────────────────────────┘
57
62
  ```
58
63
 
59
- Four runtime-only APIs form the foundation; everything else is standard SDK:
64
+ Five runtime-only APIs form the foundation; everything else is standard SDK:
60
65
 
61
66
  | API | What it does | Returns |
62
67
  |---|---|---|
63
- | `client.context()` | Asks the host for the Work's identity | `{ work, space, viewer?, permissions }` or `null` |
64
- | `client.auth.request({ scopes, reason })` | Requests viewer authorization and caches a scoped token; Cohub may silently approve the publisher's own workspace preview or background | `true` / `false` |
65
- | `client.app.commerce.*` | Entitlement checks, credit consumption, purchases | (see Commerce section) |
66
- | `client.app.realtime.*` | Temporary rooms, events, presence, and membership | (see Realtime rooms section) |
68
+ | `client.context()` | Asks the host for the App's identity | `{ app, space, viewer?, invocation?, permissions }` or `null` |
69
+ | `client.auth.request({ scopes, reason, spaceId?, alwaysAsk? })` | Ensures the app holds these scopes; silent when a grant already covers them, consent dialog otherwise | `true` / `false` |
70
+ | `client.auth.requestSpace({ scopes, reason, alwaysAsk? })` | One consent: the viewer picks a Space and grants the scopes on it | `{ granted, space }` |
71
+ | `client.context().permissions.viewerGrants` | Render the viewer's current per-space grants | `{ spaceId, scopes }[]` |
72
+ | `client.app.commerce.*` / `client.app.realtime.*` | Commerce and realtime, bound to the app's runtime identity | (see below) |
67
73
 
68
74
  > **Runtime-only constraint.** These APIs only work inside a **published**
69
- > Work. Outside that context (a static asset URL, a local `file://` preview,
75
+ > App. Outside that context (a static asset URL, a local `file://` preview,
70
76
  > a plain Node script) `context()` returns `null` and the other runtime APIs
71
- > fail. Always develop against a published Work.
77
+ > fail. Always develop against a published App.
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.
72
82
 
73
83
  ---
74
84
 
@@ -79,15 +89,15 @@ an iframe. You normally do **not** need to set the mode explicitly.
79
89
 
80
90
  ### Bridge mode (default, primary)
81
91
 
82
- The Work runs inside a Cohub-hosted iframe (`window.parent !== window`). The
92
+ The App runs inside a Cohub-hosted iframe (`window.parent !== window`). The
83
93
  SDK communicates with the parent window via `postMessage` to request tokens
84
- and context. This is the normal case when a viewer opens a Work through Cohub.
94
+ and context. This is the normal case when a viewer opens an App through Cohub.
85
95
 
86
- - `client.context()` returns the **real** `space.id`, `work.id`, current viewer,
87
- and permission scopes from the host.
88
- - A Work opened through `cohub ui preview` also receives an `invocation`
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`
89
99
  snapshot with the originating `spaceId`, `sessionId`, `turnId`, and
90
- `toolCallId` when available. The invocation Space may differ from the Work's
100
+ `toolCallId` when available. The invocation Space may differ from the App's
91
101
  own `space.id`.
92
102
  - `client.auth.request()` triggers an in-shell consent flow (no popup window).
93
103
 
@@ -97,24 +107,24 @@ console.log(ctx.viewer?.userUuid ?? null);
97
107
  console.log(ctx.invocation?.sessionId ?? null);
98
108
  ```
99
109
 
100
- Invocation fields describe where the preview came from. They are identifiers,
101
- not authorization: API access remains controlled by the Work session token and
102
- its scopes.
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.
103
113
 
104
114
  ### Broker mode (standalone deployment)
105
115
 
106
- The Work is accessed as a standalone page (`window.parent === window`), e.g.
116
+ The App is accessed as a standalone page (`window.parent === window`), e.g.
107
117
  a direct static-asset URL not wrapped in the Cohub iframe. The SDK opens a
108
118
  **popup window** to a Cohub auth-broker page to obtain tokens.
109
119
 
110
120
  - `client.context()` is **answered locally** by the SDK: `space.id` is an
111
- **empty string `""`**, and `viewerScopes` is **always empty**.
121
+ **empty string `""`**, and viewer grants are unavailable (empty).
112
122
  - `client.auth.request()` opens a popup to
113
- `${brokerOrigin}/work-auth?work=${workId}`.
123
+ `${brokerOrigin}/app-auth?app=${appId}`.
114
124
 
115
- > **Broker mode requires configuration.** You must pass `work: { brokerOrigin,
116
- > workId }` — or `work: { brokerOrigin, ownerUsername, spaceSlug, workSlug }`
117
- > when the workId isn't known yet — to `createCohubClient` for broker mode to
125
+ > **Broker mode requires configuration.** You must pass `app: { brokerOrigin,
126
+ > appId }` — or `app: { brokerOrigin, ownerUsername, spaceSlug, appSlug }`
127
+ > when the appId isn't known yet — to `createCohubClient` for broker mode to
118
128
  > activate. Without it, a standalone page gets `ParentBridgeTransport` which
119
129
  > has no parent to talk to, so `context()` returns `null`. See
120
130
  > [Initialization recipe](#4-initialization-recipe).
@@ -126,15 +136,15 @@ const ctx = await client.context();
126
136
  const isBroker = !ctx?.space?.id; // bridge has a real id; broker is ""
127
137
  ```
128
138
 
129
- In broker mode you cannot get `spaceId` from `context()`. Resolve it via the
130
- public Work API (no token needed):
139
+ In broker mode you cannot get a spaceId from `context()`. Resolve it via the
140
+ public by-slug App API (anonymous, no token needed):
131
141
 
132
142
  ```js
133
143
  const isBroker = !ctx?.space?.id;
134
144
  let spaceId;
135
145
  if (isBroker) {
136
- const detail = await client.works.get(workId); // public, no auth
137
- spaceId = detail.work.spaceId;
146
+ const detail = await client.apps.getBySlug(ownerUsername, spaceSlug, appSlug);
147
+ spaceId = detail.app.spaceId;
138
148
  } else {
139
149
  spaceId = ctx.space.id;
140
150
  }
@@ -143,7 +153,7 @@ if (isBroker) {
143
153
  ### Broker mode: user-activation ordering gotcha
144
154
 
145
155
  `HttpTransport` calls `getAccessToken()` on **every** request — including
146
- public ones like `works.get()`. In broker mode, an uncached token request
156
+ public ones like `apps.getBySlug()`. In broker mode, an uncached token request
147
157
  opens a popup, which **consumes the browser's user-activation budget**. If a
148
158
  second popup (`auth.request`) follows in the same click, the browser blocks it.
149
159
 
@@ -153,13 +163,13 @@ second popup (`auth.request`) follows in the same click, the browser blocks it.
153
163
  popup.
154
164
 
155
165
  ```js
156
- // WRONG: works.get() opens a popup, consumes activation, auth.request popup blocked
157
- const detail = await client.works.get(workId);
166
+ // WRONG: getBySlug() opens a popup, consumes activation, auth.request popup blocked
167
+ const detail = await client.apps.getBySlug(owner, spaceSlug, appSlug);
158
168
  await client.auth.request({ scopes, reason });
159
169
 
160
- // RIGHT: auth.request opens the only popup, then works.get() hits token cache
170
+ // RIGHT: auth.request opens the only popup, then getBySlug() hits token cache
161
171
  await client.auth.request({ scopes, reason });
162
- const detail = await client.works.get(workId);
172
+ const detail = await client.apps.getBySlug(owner, spaceSlug, appSlug);
163
173
  ```
164
174
 
165
175
  Bridge mode is unaffected — `getAccessToken()` uses `postMessage` (no popup),
@@ -167,115 +177,174 @@ so ordering does not matter there.
167
177
 
168
178
  ---
169
179
 
170
- ## 3. The scope model — read this twice
180
+ ## 3. The permission model — read this twice
171
181
 
172
182
  > This is the **single most common source of bugs**. Every 403 you encounter
173
- > in a Work will almost certainly trace back to a missing scope of the wrong
183
+ > in an App will almost certainly trace back to a missing grant of the wrong
174
184
  > type. Read this section carefully.
175
185
 
176
- Cohub Work permissions come in **two disjoint sets**. They do not overlap and
177
- do not imply each other.
186
+ An App's effective permission for one Space is the union of **two grant
187
+ sources** either one is enough:
178
188
 
179
- ### Work scopes (direct, no user consent)
189
+ ### App scopes (direct, no viewer consent)
180
190
 
181
- Granted by the publisher **at publish time**. The Work always has them — no
182
- viewer action needed. These are **read** permissions.
191
+ Granted by the publisher **at publish time** via `appScopes`. The App always
192
+ has them — no viewer action needed. They are deliberately bounded to eight
193
+ read-and-act scopes and apply **only to the App's own Space**:
183
194
 
184
195
  ```
185
- space.view — read space config, list models
186
- session.view — read sessions, turns, stream generation updates
187
- file.view — read files / file tree
188
- taskrun.view read task run details (used by generation polling!)
196
+ space.view — read space config, list models
197
+ session.view — read sessions, turns, stream generation updates
198
+ file.view — read files / file tree
199
+ file.edit write files
200
+ taskrun.view — read task run details (used by generation polling!)
201
+ session.prompt.readonly — send read-only prompts (no side effects)
202
+ session.prompt.fullaccess— send prompts with full access (write, create sessions)
203
+ command.execute — run sandbox shell commands
189
204
  ```
190
205
 
191
- Set via `workScopes` when creating/updating a Work.
206
+ ### Viewer grants (consent-required, any permission, per Space)
207
+
208
+ A viewer grants these through a consent dialog triggered by
209
+ `client.auth.request()` / `client.auth.requestSpace()`. A viewer may grant
210
+ **any** permission they currently hold on the target Space — including scopes
211
+ outside the eight app scopes, such as `generation.create` or the account-level
212
+ `user.*` scopes. Two hard rules are enforced by the server:
213
+
214
+ - At **grant time** the viewer must currently hold every requested permission
215
+ on the target Space.
216
+ - At **use time** the grant only works while the viewer still holds that
217
+ permission there — losing a membership or a role downgrade takes effect
218
+ immediately.
219
+
220
+ Viewer grants are **per Space**: one viewer can hold a different grant for the
221
+ App's own Space and for each Space they picked. Grants last **14 days**; the
222
+ session token lives **1 hour** and is refreshed silently. Revoking a grant
223
+ takes effect immediately — silently or via the API.
224
+
225
+ > `allowedViewerScopes` is deprecated and no longer enforced: viewer grants
226
+ > are not gated by the app configuration. The field stays on the wire for
227
+ > compatibility; do not use it in new apps.
192
228
 
193
- ### Viewer scopes (consent-required, action permissions)
229
+ ### The golden rule
230
+
231
+ > **Reads on the App's own Space need app scopes. Everything else — actions,
232
+ > other Spaces, account-level data — needs a viewer grant.**
233
+
234
+ `session.prompt.fullaccess` lets you **send** a prompt, but reading the reply
235
+ still needs `session.view`. `generation.create` (a viewer grant) lets you
236
+ **create** a generation task, but polling its result needs `taskrun.view` —
237
+ either from the App's `appScopes`, or from a viewer grant checked against the
238
+ requested Space.
194
239
 
195
- Declared by the publisher at publish time as **allowed** (`allowedViewerScopes`),
196
- but **not active** until the viewer approves them through a consent dialog
197
- triggered by `client.auth.request()`. These are **action** permissions.
240
+ ### Requesting viewer grants
198
241
 
242
+ Both helpers must be called **from a user gesture** (button click). They are
243
+ silent when an existing grant already covers the scopes — the dialog only
244
+ opens when something new is needed:
245
+
246
+ ```js
247
+ // Target a known Space (omit spaceId for the App's own Space).
248
+ const ok = await client.auth.request({
249
+ scopes: ["taskrun.view"],
250
+ spaceId: invocationSpaceId,
251
+ reason: "This app reads generation tasks in the Space you opened it from.",
252
+ });
253
+
254
+ // One consent: the viewer picks the Space. The host loads the space list —
255
+ // the app only learns the pick. Returning viewers silently reuse their last pick.
256
+ const { granted, space } = await client.auth.requestSpace({
257
+ scopes: ["file.view", "session.view"],
258
+ reason: "This app reads the Space you pick.",
259
+ });
260
+ if (granted && space) {
261
+ const picked = client.space(space.id);
262
+ }
199
263
  ```
200
- taskrun.view — read Task Runs the viewer can already access
201
- session.prompt.readonly — send read-only prompts (no side effects)
202
- session.prompt.fullaccess send prompts with full access (write, create sessions)
203
- generation.create — create generation tasks (image/video/audio)
204
- user.space.list — list the viewer's spaces (account-level)
205
- user.session.list — list recent sessions the viewer can view as themselves (across spaces)
206
- user.usage.read — read the viewer's aggregated usage
264
+
265
+ Pass `alwaysAsk: true` to skip silent reuse and force a fresh dialog — for
266
+ re-confirming a grant or letting the viewer switch to another Space.
267
+
268
+ ### Checking grant state at runtime
269
+
270
+ `client.context().permissions` reports everything an App needs to render its
271
+ state — **without** triggering a dialog:
272
+
273
+ ```js
274
+ const ctx = await client.context();
275
+ ctx.permissions.appScopes // publisher scopes granted at publish time
276
+ ctx.permissions.viewerGrants // per-space viewer consents: [{ spaceId, scopes }]
277
+ ctx.permissions.scopes // union of both (flat; for quick checks)
278
+ ctx.permissions.viewerScopes // flat viewer scopes (legacy compatibility)
207
279
  ```
208
280
 
209
- ### The golden rule
281
+ Apps never cache or manage grants themselves — the host does. Checking state
282
+ is for **rendering**; acting is `auth.request`'s job.
210
283
 
211
- > **Read operations need work scopes. Action operations need viewer scopes.
212
- > They never substitute for each other.**
284
+ ### Managing grants
213
285
 
214
- `session.prompt.fullaccess` lets you **send** a prompt, but does **not** let
215
- you **read** the result — that needs `session.view` (a work scope).
216
- `generation.create` lets you **create** a generation task, but reading its
217
- result needs `taskrun.view`, either from the publishing Space's work scopes or
218
- from viewer authorization checked against the requested Space or Session.
286
+ The App runtime can render the viewer's current grants from
287
+ `client.context().permissions.viewerGrants`. It must not call
288
+ `client.apps.listMyGrants()` or `revokeMyGrant()` with its App session token:
289
+ the server rejects App sessions from managing their own authorization.
290
+
291
+ Use an account-authenticated SDK client or the CLI to list and revoke grants:
292
+
293
+ ```bash
294
+ cohub apps grants <app> # list your grants for an app
295
+ cohub apps revoke <app> <grantId> # revoke one
296
+ ```
297
+
298
+ Those account-level APIs return grant rows with
299
+ `{ id, spaceId, scopes, expiresAt, revokedAt }`. Revocation is durable: silent
300
+ re-authorization can never revive a revoked grant — only a fresh consent
301
+ dialog can.
219
302
 
220
303
  ### Complete API → scope mapping
221
304
 
222
- | Operation | SDK call | Scope needed | Type |
305
+ | Operation | SDK call | Scope needed | Source |
223
306
  |---|---|---|---|
224
- | Read space config | `space.get()` / `space.getConfig()` | `space.view` | work |
307
+ | Read space config | `space.get()` / `space.getConfig()` | `space.view` | app |
225
308
  | List models | `client.models.list()` / `listMultimodal()` | *(none — just authenticated)* | — |
226
- | Send a prompt (full) | `space.prompt({ accessMode: "full_access", content, ... })` | `session.prompt.fullaccess` | viewer |
227
- | Send a prompt (read-only) | `space.prompt({ accessMode: "read_only", content, ... })` | `session.prompt.readonly` | viewer |
228
- | Read turn result | `session.turns.get(turnId)` | `session.view` | work |
229
- | Stream generation | `session.subscribeGeneration({ state, finalized })` | `session.view` | work |
230
- | Read file tree | `space.files.tree()` | `file.view` | work |
231
- | Read file content | `space.files.read(path)` | `file.view` | work |
232
- | Create generation task | `client.generations.create(request)` | `generation.create` | viewer |
233
- | **Poll generation result** | `client.generations.wait(taskRunId)` / `createAndWait()` | **`taskrun.view`** | **work** |
234
- | Read task run detail | `client.tasks.get(taskRunId)` | `taskrun.view` | work |
235
- | List viewer's spaces | `client.spaces.list()` | `user.space.list` | viewer |
236
- | List viewer's sessions | `client.user.listSessions()` | `user.session.list` | viewer |
237
- | Read viewer's activity | `client.user.getActivity()` | `user.usage.read` | viewer |
309
+ | Send a prompt (full) | `space.prompt({ accessMode: "full_access", ... })` | `session.prompt.fullaccess` | app or viewer |
310
+ | Send a prompt (read-only) | `space.prompt({ accessMode: "read_only", ... })` | `session.prompt.readonly` | app or viewer |
311
+ | Read turn result | `session.turns.get(turnId)` | `session.view` | app or viewer |
312
+ | Stream generation | `session.subscribeGeneration(...)` | `session.view` | app or viewer |
313
+ | Read file tree | `space.files.tree()` | `file.view` | app or viewer |
314
+ | Read file content | `space.files.read(path)` | `file.view` | app or viewer |
315
+ | Write files | `space.files.*` (write) | `file.edit` | app or viewer |
316
+ | Create generation task | `client.generations.create(request)` | `generation.create` | **viewer only** |
317
+ | **Poll generation result** | `client.generations.wait(taskRunId)` / `createAndWait()` | **`taskrun.view`** | app or viewer |
318
+ | Read task run detail | `client.tasks.get(taskRunId)` | `taskrun.view` | app or viewer |
319
+ | List tasks in a Space | `client.tasks.list({ spaceId })` | `taskrun.view` on that Space | app or viewer |
320
+ | List all owned task runs | `client.tasks.list()` | `user.taskrun.list` | **viewer only** |
321
+ | List viewer's spaces | `client.spaces.list()` | `user.space.list` | **viewer only** |
322
+ | List viewer's sessions | `client.user.listSessions()` | `user.session.list` | **viewer only** |
323
+ | Read viewer's activity | `client.user.getActivity()` | `user.usage.read` | **viewer only** |
324
+ | Run sandbox shell commands | `space.runCommand({ command })` | `command.execute` | app or viewer |
238
325
  | Commerce: entitlements | `client.app.commerce.getEntitlements()` | *(runtime only, no scope)* | — |
239
326
  | Commerce: consume credits | `client.app.commerce.consumeCredits()` | *(runtime only, no scope)* | — |
240
327
  | Commerce: purchase | `client.app.commerce.purchase()` | *(runtime only, no scope)* | — |
241
328
  | Realtime rooms | `client.app.realtime.createRoom()` / `joinRoom()` | *(runtime only, no scope)* | — |
242
329
 
243
- ### Minimal scope sets for common Work types
244
-
245
- **LLM chat Work** (send prompt + read reply):
246
- - workScopes: `["space.view", "session.view"]`
247
- - allowedViewerScopes: `["session.prompt.fullaccess"]`
248
-
249
- **Image generation Work** (create + poll):
250
- - workScopes: `["space.view", "taskrun.view"]`
251
- - allowedViewerScopes: `["generation.create"]`
330
+ "app or viewer" means either source suffices: the app scope covers only the
331
+ App's own Space; any other Space needs a viewer grant on that Space.
252
332
 
253
- **LLM + image generation Work** (the demo):
254
- - workScopes: `["space.view", "session.view", "taskrun.view"]`
255
- - allowedViewerScopes: `["session.prompt.fullaccess", "generation.create"]`
333
+ ### Minimal scope sets for common App types
256
334
 
257
- **File-reader Work** (static, no viewer action):
258
- - workScopes: `["space.view", "file.view"]`
259
- - allowedViewerScopes: `[]`
335
+ **LLM chat app** (send prompt + read reply on its own Space):
336
+ - `appScopes: ["space.view", "session.view", "session.prompt.fullaccess"]`
260
337
 
261
- ### Checking granted scopes at runtime
338
+ **Image generation app** (create + poll):
339
+ - `appScopes: ["space.view", "taskrun.view"]`
340
+ - viewer grant at runtime: `generation.create`
262
341
 
263
- `client.context()` returns a `permissions` object with three arrays:
342
+ **File-reader app** (static, no viewer action):
343
+ - `appScopes: ["space.view", "file.view"]`
264
344
 
265
- ```js
266
- const ctx = await client.context();
267
- ctx.permissions.scopes // all effective scopes (work + viewer)
268
- ctx.permissions.workScopes // work scopes granted at publish time
269
- ctx.permissions.viewerScopes // viewer scopes the current viewer has approved
270
- ```
271
-
272
- To check whether a **viewer** scope is already granted (to skip re-requesting):
273
-
274
- ```js
275
- function hasViewerScope(ctx, scope) {
276
- return (ctx?.permissions?.viewerScopes ?? []).includes(scope);
277
- }
278
- ```
345
+ **Cross-space app** (acts on Spaces the viewer picks):
346
+ - `appScopes: []` (or its own-Space needs)
347
+ - viewer grants via `auth.requestSpace` at runtime
279
348
 
280
349
  ---
281
350
 
@@ -283,45 +352,45 @@ function hasViewerScope(ctx, scope) {
283
352
 
284
353
  ### No-build HTML (CDN import)
285
354
 
286
- Works are typically single HTML files with no bundler. Import the SDK from an
355
+ Apps are typically single HTML files with no bundler. Import the SDK from an
287
356
  ESM CDN:
288
357
 
289
358
  ```js
290
359
  import { createCohubClient } from "https://esm.sh/@neta-art/cohub@latest";
291
360
  ```
292
361
 
293
- `@latest` keeps a Work on the current SDK release. Pin an exact version only
362
+ `@latest` keeps an App on the current SDK release. Pin an exact version only
294
363
  when a deployment needs reproducible dependency updates.
295
364
 
296
365
  ### Environment detection — critical
297
366
 
298
- The SDK defaults to **production**. A Work running on a dev/staging host
367
+ The SDK defaults to **production**. An App running on a dev/staging host
299
368
  (e.g. `dev.cohub.live`, a `/dev/` path prefix) **must** pass `env: "dev"`
300
369
  explicitly — browsers do not inject `ENV` like Node does. If you omit this,
301
- your Work will call the production API while the runtime host expects dev,
370
+ your App will call the production API while the runtime host expects dev,
302
371
  causing silent auth failures.
303
372
 
304
373
  ```js
305
- const isDevWork =
374
+ const isDevApp =
306
375
  location.pathname.startsWith("/dev/") ||
307
376
  location.hostname.includes("dev");
308
377
 
309
378
  const client = createCohubClient({
310
- env: isDevWork ? "dev" : "prod",
379
+ env: isDevApp ? "dev" : "prod",
311
380
  });
312
381
  ```
313
382
 
314
383
  ### Broker mode configuration (standalone pages only)
315
384
 
316
- If the Work may be accessed as a standalone page (not inside the Cohub
317
- iframe), pass the `work` option so the SDK can fall back to broker mode:
385
+ If the App may be accessed as a standalone page (not inside the Cohub
386
+ iframe), pass the `app` option so the SDK can fall back to broker mode:
318
387
 
319
388
  ```js
320
389
  const client = createCohubClient({
321
- env: isDevWork ? "dev" : "prod",
322
- work: {
323
- brokerOrigin: isDevWork ? "https://dev.cohub.live" : "https://cohub.live",
324
- workId: "<your-published-work-id>",
390
+ env: isDevApp ? "dev" : "prod",
391
+ app: {
392
+ brokerOrigin: isDevApp ? "https://dev.cohub.live" : "https://cohub.live",
393
+ appId: "<your-published-app-id>",
325
394
  },
326
395
  });
327
396
  ```
@@ -330,70 +399,67 @@ When inside the Cohub iframe, the SDK auto-detects bridge mode and ignores
330
399
  broker config. When standalone, it uses broker mode. **One codebase, both
331
400
  deployments.**
332
401
 
333
- #### Broker mode without a pre-known workId
402
+ #### Broker mode without a pre-known appId
334
403
 
335
- The `workId` is only generated at publish time, so you often cannot hardcode
336
- it while writing the Work. In standalone deployments you can omit `workId` and
337
- instead pass the Work's public **slug triple**. The SDK resolves the workId at
338
- runtime via the public `works.getBySlug` API (anonymous, no auth required),
404
+ The `appId` is only generated at publish time, so you often cannot hardcode
405
+ it while writing the App. In standalone deployments you can omit `appId` and
406
+ instead pass the App's public **slug triple**. The SDK resolves the appId at
407
+ runtime via the public `apps.getBySlug` API (anonymous, no auth required),
339
408
  caches it, and starts broker mode with it.
340
409
 
341
410
  All three values are known before publishing:
342
411
 
343
- - `workSlug` — the slug you chose when creating the Work.
412
+ - `appSlug` — the slug you chose when creating the App.
344
413
  - `ownerUsername` — the space owner's username (`cohub auth whoami`).
345
414
  - `spaceSlug` — the space's slug (`cohub spaces get <spaceId>`).
346
415
 
347
416
  ```js
348
417
  const client = createCohubClient({
349
- env: isDevWork ? "dev" : "prod",
350
- work: {
351
- brokerOrigin: isDevWork ? "https://dev.cohub.live" : "https://cohub.live",
418
+ env: isDevApp ? "dev" : "prod",
419
+ app: {
420
+ brokerOrigin: isDevApp ? "https://dev.cohub.live" : "https://cohub.live",
352
421
  ownerUsername,
353
422
  spaceSlug,
354
- workSlug,
423
+ appSlug,
355
424
  },
356
425
  });
357
426
  ```
358
427
 
359
- Either `workId` or the full slug triple is enough to activate broker mode. If
360
- you pass both, the explicit `workId` wins and no lookup is performed. Inside
428
+ Either `appId` or the full slug triple is enough to activate broker mode. If
429
+ you pass both, the explicit `appId` wins and no lookup is performed. Inside
361
430
  the Cohub iframe both are ignored (bridge mode).
362
431
 
363
432
  ### Standard initialization sequence
364
433
 
365
434
  ```js
366
435
  // 1. Create client (env is mandatory in the browser)
367
- const client = createCohubClient({ env: isDevWork ? "dev" : "prod" });
436
+ const client = createCohubClient({ env: isDevApp ? "dev" : "prod" });
368
437
 
369
- // 2. Get runtime context
438
+ // 2. Get runtime context (and keep it fresh)
370
439
  const ctx = await client.context();
371
440
  if (!ctx?.space?.id) {
372
- // Not in a Work runtime (or broker mode — see §2)
373
- throw new Error("Not running inside a published Work.");
441
+ // Not in an app runtime (or broker mode — see §2)
442
+ throw new Error("Not running inside a published app.");
374
443
  }
444
+ const stopContextWatch = client.app.onContextChanged((next) => renderGrants(next));
375
445
 
376
446
  // 3. Obtain the space client for API calls
377
- const spaceId = ctx.space.id;
378
- const space = client.space(spaceId);
447
+ const space = client.space(ctx.space.id);
379
448
 
380
- // 4. Request viewer scopes (from a user gesture, e.g. button click)
449
+ // 4. Request viewer grants (from a user gesture, e.g. button click)
381
450
  const ok = await client.auth.request({
382
451
  scopes: ["session.prompt.fullaccess", "generation.create"],
383
- reason: "This Work needs to send prompts and generate images.",
452
+ reason: "This app sends prompts and generates images.",
384
453
  });
385
- if (!ok) {
386
- // Viewer denied — handle gracefully
387
- }
388
454
 
389
455
  // 5. Call capabilities
390
456
  const result = await space.prompt({ content: [{ type: "text", text: "Hello" }] });
391
457
  ```
392
458
 
393
459
  > **`auth.request` must be called from a user gesture** (click handler).
394
- Browsers block popups (broker mode) and some consent flows (bridge mode)
395
- when triggered programmatically without user activation. Do not call it on
396
- page load.
460
+ > Browsers block popups (broker mode) and some consent flows (bridge mode)
461
+ > when triggered programmatically without user activation. Do not call it on
462
+ > page load. It is safe to call repeatedly: covered scopes renew silently.
397
463
 
398
464
  ---
399
465
 
@@ -404,15 +470,16 @@ Assume `client` and `space` are already initialized per [§4](#4-initialization-
404
470
 
405
471
  ### LLM chat (`space.prompt` + `subscribeGeneration`)
406
472
 
407
- **Scopes:** viewer `session.prompt.fullaccess` (to send) + work `session.view` (to read/stream).
408
- For a read-only prompt (no side effects), use viewer `session.prompt.readonly`
473
+ **Scopes:** `session.prompt.fullaccess` (to send) + `session.view` (to
474
+ read/stream). Either may come from `appScopes` (own Space) or a viewer grant.
475
+ For a read-only prompt (no side effects), use `session.prompt.readonly`
409
476
  instead — but you **must** pass `accessMode: "read_only"` in the call (see the
410
477
  read-only recipe below).
411
478
 
412
479
  > **`accessMode` defaults to `full_access`.** If you omit it, the backend
413
- treats the call as full-access and requires `session.prompt.fullaccess`. This
414
- is the #1 cause of "I requested `session.prompt.readonly` but still got 403".
415
- Always set `accessMode` explicitly to match the scope you requested.
480
+ > treats the call as full-access and requires `session.prompt.fullaccess`. This
481
+ > is the #1 cause of "I requested `session.prompt.readonly` but still got 403".
482
+ > Always set `accessMode` explicitly to match the scope you hold.
416
483
 
417
484
  `space.prompt()` is **asynchronous** — it returns immediately with a turn
418
485
  whose `assistantText` is `null`. You must either stream the reply via
@@ -431,7 +498,7 @@ const sessionId = result.session.id;
431
498
  const turnId = result.turn.id;
432
499
 
433
500
  // --- Option A: stream the reply (preferred) ---
434
- // Requires work scope: session.view
501
+ // Requires session.view
435
502
  const stop = space.session(sessionId).subscribeGeneration({
436
503
  state(event) {
437
504
  // Partial text as it streams in
@@ -454,7 +521,7 @@ const stop = space.session(sessionId).subscribeGeneration({
454
521
  // Call stop() to unsubscribe when done.
455
522
 
456
523
  // --- Option B: poll for the reply (fallback) ---
457
- // Also requires work scope: session.view
524
+ // Also requires session.view
458
525
  async function waitForTurn(sessionId, turnId) {
459
526
  while (true) {
460
527
  const { turn } = await space.session(sessionId).turns.get(turnId);
@@ -473,25 +540,24 @@ const reply = turn.assistantText;
473
540
  `turn.assistantContent` (ContentBlock[] | null). Always check both —
474
541
  `assistantText` is a convenience; `assistantContent` is the source of truth.
475
542
 
476
- > **Do not silently swallow `subscribeGeneration` errors.** If the work scope
477
- `session.view` is missing, the WebSocket subscription fails. If you catch and
478
- ignore it, your code silently degrades to polling — which will also 403.
479
- Surface the error so you can diagnose the missing scope.
543
+ > **Do not silently swallow `subscribeGeneration` errors.** If `session.view`
544
+ > is missing, the WebSocket subscription fails. If you catch and ignore it,
545
+ > your code silently degrades to polling — which will also 403. Surface the
546
+ > error so you can diagnose the missing scope.
480
547
 
481
548
  #### Read-only prompt (`accessMode: "read_only"`)
482
549
 
483
- Use this when your Work only needs to **generate** a reply without persisting
550
+ Use this when your App only needs to **generate** a reply without persisting
484
551
  any side effects (no new session is written, no turn stored on the space's
485
- history). It requires the lighter viewer scope `session.prompt.readonly`
486
- instead of `session.prompt.fullaccess`.
552
+ history). It requires the lighter `session.prompt.readonly` scope.
487
553
 
488
554
  The critical detail: you **must** pass `accessMode: "read_only"` explicitly
489
- in the `space.prompt()` call. The scope you request via `auth.request` and
490
- the `accessMode` you send must match — the backend picks the permission check
491
- based on `accessMode`, defaulting to `full_access` when omitted.
555
+ in the `space.prompt()` call. The scope you hold and the `accessMode` you
556
+ send must match — the backend picks the permission check based on
557
+ `accessMode`, defaulting to `full_access` when omitted.
492
558
 
493
559
  ```js
494
- // 1. Request ONLY the read-only scope from a user gesture
560
+ // 1. Ensure the read-only scope is granted (silent when already covered)
495
561
  await client.auth.request({
496
562
  scopes: ["session.prompt.readonly"],
497
563
  reason: "Generate a one-off character reply (read-only).",
@@ -506,7 +572,7 @@ const result = await space.prompt({
506
572
  const sessionId = result.session.id;
507
573
  const turnId = result.turn.id;
508
574
 
509
- // 3. Read the reply — still needs the work scope: session.view
575
+ // 3. Read the reply — still needs session.view
510
576
  const stop = space.session(sessionId).subscribeGeneration({
511
577
  finalized: (event) => {
512
578
  const reply = event.turn.assistantText
@@ -519,24 +585,21 @@ const stop = space.session(sessionId).subscribeGeneration({
519
585
  });
520
586
  ```
521
587
 
522
- Publish the Work with:
523
- - workScopes: `["space.view", "session.view"]` (still needed to read the reply)
524
- - allowedViewerScopes: `["session.prompt.readonly"]`
525
-
526
- > **Scope/accessMode mismatch 403.** Requesting `session.prompt.readonly`
527
- but calling `space.prompt({ content })` (no `accessMode`) fails because the
528
- backend defaults to `full_access` and checks `session.prompt.fullaccess`.
529
- Symmetrically, requesting `session.prompt.fullaccess` while passing
530
- `accessMode: "read_only"` also works only if `session.prompt.readonly` is
531
- additionally granted — otherwise 403. Always keep them in sync.
588
+ > **Scope/accessMode mismatch → 403.** Holding `session.prompt.readonly` but
589
+ > calling `space.prompt({ content })` (no `accessMode`) fails because the
590
+ > backend defaults to `full_access` and checks `session.prompt.fullaccess`.
591
+ > Symmetrically, holding `session.prompt.fullaccess` while passing
592
+ > `accessMode: "read_only"` works only if `session.prompt.readonly` is
593
+ > additionally granted otherwise 403. Always keep them in sync.
532
594
 
533
595
  ### Image / media generation (`generations.createAndWait`)
534
596
 
535
- **Scopes:** viewer `generation.create` (to create) + work `taskrun.view` (to poll).
597
+ **Scopes:** viewer grant `generation.create` (to create) + `taskrun.view`
598
+ (to poll; from `appScopes` for the App's own Space, or a viewer grant on the
599
+ target Space).
536
600
 
537
601
  `createAndWait` is a convenience that calls `create` then `wait` (polls
538
- `GET /api/tasks/{id}`). **Both scopes are required** — `generation.create`
539
- for the create step, `taskrun.view` for the poll step. Missing `taskrun.view`
602
+ `GET /api/tasks/{id}`). **Both scopes are required** — missing `taskrun.view`
540
603
  is the #1 cause of "generation creates but never returns" bugs.
541
604
 
542
605
  ```js
@@ -597,7 +660,7 @@ Use `listMultimodal()` to populate a model picker for generation. Each entry's
597
660
 
598
661
  ### File reads (`space.files`)
599
662
 
600
- **Scopes:** work `file.view` (read) + `space.view` (often needed for the space context).
663
+ **Scopes:** `file.view` (read) + `space.view` (often needed for the space context).
601
664
 
602
665
  ```js
603
666
  // List the file tree
@@ -612,12 +675,13 @@ const text = await response.text();
612
675
  const files = await space.files.readMany(["a.txt", "b.json"]);
613
676
  ```
614
677
 
615
- ### Account-level data (`spaces.list` / `user.listSessions` / `user.getUsage`)
678
+ ### Account-level data (`spaces.list` / `user.listSessions` / `user.getActivity`)
616
679
 
617
- **Scopes:** viewer `user.space.list` / `user.session.list` / `user.usage.read`.
680
+ **Scopes:** viewer grants `user.space.list` / `user.session.list` /
681
+ `user.usage.read` — a publisher can never pre-grant these via `appScopes`.
618
682
 
619
- These access the **viewer's** account-level data across all their spaces — not
620
- the Work's own space. Each requires a separate viewer scope.
683
+ These access the **viewer's** account-level data across all their spaces —
684
+ not the App's own space. Each requires a separate viewer grant.
621
685
 
622
686
  ```js
623
687
  // List the viewer's spaces — needs user.space.list
@@ -642,10 +706,15 @@ await client.auth.request({
642
706
  const activity = await client.user.getActivity({ days: 30 }); // last 30 days
643
707
  ```
644
708
 
645
- ### Commerce (`work.commerce`)
709
+ For task runs specifically, the account scope is `user.taskrun.list`: with an
710
+ explicit viewer grant, the unscoped `client.tasks.list()` returns every Task
711
+ Run **owned by** the viewer — including runs from Spaces they can no longer
712
+ access. Without it, the unscoped list stays space-scoped to live grants.
713
+
714
+ ### Commerce (`app.commerce`)
646
715
 
647
- **Scopes:** none — runs inside the Work runtime, no scope needed. Only works
648
- in a published Work.
716
+ **Scopes:** none — runs inside the App runtime, no scope needed. Only works
717
+ in a published App.
649
718
 
650
719
  ```js
651
720
  // Check entitlements and credit balance in one call
@@ -679,9 +748,9 @@ if (checkoutState.orderId) {
679
748
  retries the call after a timeout, pass the same `purchaseAttemptId` to ensure
680
749
  the retry resolves to the original Billing order.
681
750
 
682
- ### Realtime rooms (`work.realtime`)
751
+ ### Realtime rooms (`app.realtime`)
683
752
 
684
- **Scopes:** none — uses the published Work's runtime identity without an
753
+ **Scopes:** none — uses the published App's runtime identity without an
685
754
  additional consent dialog. The CLI and ordinary server auth cannot create or
686
755
  join these rooms.
687
756
 
@@ -710,7 +779,7 @@ await room.leave();
710
779
 
711
780
  Join an existing room with
712
781
  `client.app.realtime.joinRoom({ code: "TEAM-ALPHA" })`. Codes are scoped to
713
- one Work and are identifiers, not credentials; the runtime session and a
782
+ one App and are identifiers, not credentials; the runtime session and a
714
783
  short-lived admission ticket provide authorization.
715
784
 
716
785
  | Surface | Purpose |
@@ -729,14 +798,14 @@ Room events are ordered while connected but are not replayed. A reconnect
729
798
  refreshes the member snapshot and advances the sequence cursor, so use
730
799
  `onStateChange()` to resync authoritative application state after reconnecting;
731
800
  `onOutOfSync()` only reports gaps visible in the current live stream. Payloads
732
- are transient and are not stored in the Work.
801
+ are transient and are not stored in the App.
733
802
 
734
803
  | Limit | Value |
735
804
  |---|---|
736
805
  | Room code | Generated when omitted; custom codes are 3–48 uppercase letters, digits, `_`, or `-`, starting with a letter or digit |
737
806
  | Lifetime | 2 hours by default; 60 seconds to 24 hours, absolute from creation |
738
807
  | Participants | 16 by default; 2–128 |
739
- | Active rooms | 512 per Work |
808
+ | Active rooms | 512 per App |
740
809
  | Event name | 1–64 ASCII letters, digits, `.`, `_`, `:`, or `-`, starting with a letter or digit; `cohub.*` is reserved |
741
810
  | Event payload | 16 KB of JSON |
742
811
  | Presence payload | 2 KB of JSON |
@@ -787,18 +856,18 @@ new one. The server keeps the participant ID, updates `room.participantId`, and
787
856
  closes the superseded connection without emitting a leave event. Without this
788
857
  mode, an unclean disconnect can retain its seat lease for up to one minute.
789
858
 
790
- #### UI command calls that complete later
859
+ #### Desktop command calls that complete later
791
860
 
792
- A `preview.show` command with a Surface request stays pending after the Work
793
- acknowledges it. The host waits for the Work to be mounted and ready, then the
794
- Work receives the originating `commandId` in the handler context and returns an
795
- acknowledgement immediately:
861
+ A `desktop open --call` command with a Surface request stays pending after the
862
+ App acknowledges it. The host waits for the App to be mounted and ready, then
863
+ the App receives the originating `commandId` in the handler context and
864
+ returns an acknowledgement immediately:
796
865
 
797
866
  ```js
798
867
  let activeCommandId = null;
799
868
 
800
869
  client.app.surface.handle("image.open", async (input, { commandId }) => {
801
- if (!commandId) throw new Error("image.open must be called by a UI command");
870
+ if (!commandId) throw new Error("image.open must be called by a desktop command");
802
871
  activeCommandId = commandId;
803
872
  openImageStudio(input);
804
873
  return { accepted: true };
@@ -806,7 +875,7 @@ client.app.surface.handle("image.open", async (input, { commandId }) => {
806
875
 
807
876
  async function useImage(result) {
808
877
  if (!activeCommandId) return;
809
- await client.ui.reportResult(activeCommandId, {
878
+ await client.desktop.reportResult(activeCommandId, {
810
879
  status: "applied",
811
880
  result,
812
881
  error: null,
@@ -815,22 +884,24 @@ async function useImage(result) {
815
884
  }
816
885
  ```
817
886
 
818
- The Work should persist the command id alongside its local/server-backed draft
819
- so a reload can restore the pending interaction. A Work session may only report a command that targets that same Work. Existing
887
+ The App should persist the command id alongside its local/server-backed draft
888
+ so a reload can restore the pending interaction. An App session may only
889
+ report a command that targets that same App. Existing
820
890
  `client.app.surface.handle(method, handler)` usage remains unchanged.
821
891
 
822
892
  ---
823
893
 
824
894
  ## 6. Complete working example
825
895
 
826
- A no-build HTML Work for LLM chat and image generation. Use it as a starting
896
+ A no-build HTML App for LLM chat and image generation. Use it as a starting
827
897
  point and keep only the capabilities you need.
828
898
 
829
- > **Publish this Work with:**
830
- > - workScopes: `["space.view", "session.view", "taskrun.view"]`
831
- > - allowedViewerScopes: `["session.prompt.fullaccess", "generation.create"]`
899
+ > **Publish this App with:**
900
+ > - `appScopes: ["space.view", "session.view", "taskrun.view"]`
832
901
  >
833
- > See [§8](#8-publishing-a-work-apisdk) for the publish API call.
902
+ > `session.prompt.fullaccess` and `generation.create` are requested as viewer
903
+ > grants at runtime. See [§8](#8-publishing-an-app-apisdk) for the publish
904
+ > API call.
834
905
 
835
906
  ### `index.html`
836
907
 
@@ -855,7 +926,7 @@ point and keep only the capabilities you need.
855
926
 
856
927
  <section class="card">
857
928
  <h2>2. Authorize</h2>
858
- <button id="btn-auth" class="btn">Request viewer scopes</button>
929
+ <button id="btn-auth" class="btn">Request viewer grants</button>
859
930
  <pre id="output-auth" class="output"></pre>
860
931
  </section>
861
932
 
@@ -886,12 +957,12 @@ point and keep only the capabilities you need.
886
957
  import { createCohubClient } from "https://esm.sh/@neta-art/cohub@latest";
887
958
 
888
959
  // --- Environment detection (critical: browsers don't inject ENV) ---
889
- const isDevWork =
960
+ const isDevApp =
890
961
  location.pathname.startsWith("/dev/") ||
891
962
  location.hostname.includes("dev");
892
963
 
893
964
  const client = createCohubClient({
894
- env: isDevWork ? "dev" : "prod",
965
+ env: isDevApp ? "dev" : "prod",
895
966
  });
896
967
 
897
968
  const REQUIRED_SCOPES = ["generation.create", "session.prompt.fullaccess"];
@@ -914,31 +985,30 @@ function log(el, msg) {
914
985
  async function ensureRuntime(outEl) {
915
986
  const ctx = await client.context();
916
987
  if (!ctx?.space?.id) {
917
- throw new Error("Not running inside a published Work runtime.");
988
+ throw new Error("Not running inside a published app runtime.");
918
989
  }
919
990
  spaceId = ctx.space.id;
920
991
  space = client.space(spaceId);
921
992
  return ctx;
922
993
  }
923
994
 
924
- // --- Viewer scope management ---
925
- function getViewerScopes(ctx) {
926
- return ctx?.permissions?.viewerScopes ?? [];
927
- }
928
-
929
- function missingViewerScopes(ctx, scopes) {
930
- const have = new Set(getViewerScopes(ctx));
931
- return scopes.filter((s) => !have.has(s));
995
+ // --- Viewer grant helpers ---
996
+ // Render state from context; act through auth.request (silent when covered).
997
+ function hasViewerGrant(ctx, scope, spaceId) {
998
+ return (ctx?.permissions?.viewerGrants ?? []).some(
999
+ (g) => g.spaceId === (spaceId ?? ctx?.space?.id) && g.scopes.includes(scope),
1000
+ );
932
1001
  }
933
1002
 
934
1003
  async function ensureViewerScopes(scopes, reason, outEl) {
935
1004
  const ctx = await ensureRuntime(outEl);
936
- const missing = missingViewerScopes(ctx, scopes);
1005
+ const ctxScopes = new Set(ctx?.permissions?.scopes ?? []);
1006
+ const missing = scopes.filter((s) => !ctxScopes.has(s));
937
1007
  if (missing.length === 0) {
938
- log(outEl, `Already have scopes: [${scopes.join(", ")}]`);
1008
+ log(outEl, `Already granted: [${scopes.join(", ")}]`);
939
1009
  return true;
940
1010
  }
941
- log(outEl, `Requesting scopes: [${missing.join(", ")}]...`);
1011
+ log(outEl, `Requesting: [${missing.join(", ")}]...`);
942
1012
  const ok = await client.auth.request({ scopes, reason });
943
1013
  log(outEl, ok ? "Authorized." : "Authorization denied.");
944
1014
  return ok;
@@ -964,7 +1034,7 @@ function waitForTurn(sid, turnId, onStream) {
964
1034
  fn();
965
1035
  };
966
1036
 
967
- // Primary path: stream via WebSocket (needs work scope: session.view)
1037
+ // Primary path: stream via WebSocket (needs session.view)
968
1038
  try {
969
1039
  stop = space.session(sid).subscribeGeneration({
970
1040
  state: (event) => {
@@ -1035,7 +1105,7 @@ $("btn-context").addEventListener("click", async () => {
1035
1105
  try {
1036
1106
  const ctx = await client.context();
1037
1107
  setOutput(out, {
1038
- "work.id": ctx?.work?.id,
1108
+ "app.id": ctx?.app?.id,
1039
1109
  "space.id": ctx?.space?.id,
1040
1110
  permissions: ctx?.permissions,
1041
1111
  });
@@ -1080,7 +1150,7 @@ $("btn-img").addEventListener("click", async () => {
1080
1150
  await ensureRuntime(out);
1081
1151
  const ok = await ensureViewerScopes(
1082
1152
  ["generation.create"],
1083
- "Image generation needs generation.create. taskrun.view comes from workScopes.",
1153
+ "Image generation needs a generation.create viewer grant.",
1084
1154
  out,
1085
1155
  );
1086
1156
  if (!ok) return;
@@ -1098,7 +1168,7 @@ $("btn-img").addEventListener("click", async () => {
1098
1168
  const ctx = await client.context();
1099
1169
  if (ctx) {
1100
1170
  setOutput($("output-context"), {
1101
- "work.id": ctx.work?.id,
1171
+ "app.id": ctx.app?.id,
1102
1172
  "space.id": ctx.space?.id,
1103
1173
  permissions: ctx.permissions,
1104
1174
  });
@@ -1111,17 +1181,18 @@ $("btn-img").addEventListener("click", async () => {
1111
1181
 
1112
1182
  ## 7. Common pitfalls checklist
1113
1183
 
1114
- Before publishing your Work, verify each item:
1184
+ Before publishing your App, verify each item:
1115
1185
 
1116
1186
  - [ ] **Environment**: passed `env: "dev"` (or `"prod"`) explicitly — the SDK
1117
1187
  defaults to prod and browsers don't inject `ENV`.
1118
- - [ ] **Work scopes include all read operations**: `space.view`, `session.view`
1119
- (for LLM reply reads), `taskrun.view` (for generation polling), `file.view`
1120
- (for file reads). Missing any of these → 403 on reads.
1121
- - [ ] **Viewer scopes include all action operations**: `session.prompt.fullaccess`
1122
- (or `.readonly`) for prompts, `generation.create` for generation.
1188
+ - [ ] **App scopes include every read on the App's own Space**:
1189
+ `session.view` (LLM reply reads), `taskrun.view` (generation polling),
1190
+ `file.view` (file reads). Missing any of these → 403 on reads.
1191
+ - [ ] **Viewer-grant-only scopes are requested at runtime, not configured at
1192
+ publish time**: `generation.create` and every `user.*` scope can only come
1193
+ from a viewer grant.
1123
1194
  - [ ] **`session.prompt.*` scope matches `space.prompt({ accessMode })`.**
1124
- Omitting `accessMode` defaults to `full_access`, so requesting only
1195
+ Omitting `accessMode` defaults to `full_access`, so holding only
1125
1196
  `session.prompt.readonly` and then calling `space.prompt({ content })` → 403.
1126
1197
  Set `accessMode: "read_only"` explicitly when using the readonly scope.
1127
1198
  - [ ] **`session.prompt.fullaccess` does NOT include `session.view`** — they
@@ -1130,73 +1201,78 @@ Before publishing your Work, verify each item:
1130
1201
  - [ ] **`generation.create` does NOT include `taskrun.view`** — creating a
1131
1202
  generation task succeeds but polling the result 403s without `taskrun.view`.
1132
1203
  - [ ] **`auth.request()` is called from a user gesture** (button click), not
1133
- on page load.
1204
+ on page load. It is safe to call repeatedly — covered scopes renew silently.
1205
+ - [ ] **Cross-Space access targets the right Space** — viewer grants are per
1206
+ Space. Pass `spaceId` when requesting, or use `auth.requestSpace` to let the
1207
+ viewer pick.
1134
1208
  - [ ] **`subscribeGeneration` errors are not silently swallowed** — if the
1135
1209
  stream fails, surface it; a silent fallback to polling will also 403 if
1136
1210
  `session.view` is missing.
1137
- - [ ] **Broker mode**: if the Work may be accessed standalone, pass
1138
- `work: { brokerOrigin, workId }` and call `auth.request()` before any other
1211
+ - [ ] **Broker mode**: if the App may be accessed standalone, pass
1212
+ `app: { brokerOrigin, appId }` and call `auth.request()` before any other
1139
1213
  API call (to avoid user-activation exhaustion).
1140
1214
  - [ ] **Space has a slug and owner has a username** before publishing — the
1141
- API rejects Works when either is missing.
1215
+ API rejects Apps when either is missing.
1142
1216
  - [ ] **Model ids are not hardcoded** — use `client.models.listMultimodal()`
1143
1217
  to fetch available models dynamically (requires auth but no scope).
1144
1218
 
1145
1219
  ---
1146
1220
 
1147
- ## 8. Publishing a Work (API/SDK)
1221
+ ## 8. Publishing an App (API/SDK)
1148
1222
 
1149
- Before creating a Work through the API, ensure the owner has a username and
1150
- the Space has a slug. The API rejects Works when either public identity part
1223
+ Before creating an App through the API, ensure the owner has a username and
1224
+ the Space has a slug. The API rejects Apps when either public identity part
1151
1225
  is missing.
1152
1226
 
1153
1227
  ```js
1154
- // Create a single-file Work (HTML file)
1155
- await client.works.create({
1228
+ // Create a single-file App (HTML file)
1229
+ await client.apps.create({
1156
1230
  spaceId,
1157
1231
  slug: "my-html-demo",
1158
1232
  status: "published",
1159
1233
  targetType: "file",
1160
1234
  targetRef: "demo/index.html",
1161
- workScopes: ["space.view", "session.view", "taskrun.view"],
1162
- allowedViewerScopes: ["session.prompt.fullaccess", "generation.create"],
1235
+ appScopes: ["space.view", "session.view", "taskrun.view"],
1163
1236
  });
1164
1237
 
1165
- // Create a directory Work (must contain index.html)
1166
- await client.works.create({
1238
+ // Create a directory App (must contain index.html)
1239
+ await client.apps.create({
1167
1240
  spaceId,
1168
1241
  slug: "my-site",
1169
1242
  status: "published",
1170
1243
  targetType: "directory",
1171
1244
  targetRef: "site",
1172
- workScopes: ["space.view", "session.view", "taskrun.view", "file.view"],
1173
- allowedViewerScopes: ["session.prompt.fullaccess", "generation.create"],
1245
+ appScopes: ["space.view", "session.view", "taskrun.view", "file.view"],
1174
1246
  });
1175
1247
 
1176
- // Create a port Work (sandbox dev server)
1177
- await client.works.create({
1248
+ // Create a port App (sandbox dev server)
1249
+ await client.apps.create({
1178
1250
  spaceId,
1179
1251
  slug: "live-preview",
1180
1252
  status: "published",
1181
1253
  targetType: "port",
1182
1254
  targetRef: "5173",
1183
- workScopes: ["space.view"],
1184
- allowedViewerScopes: [],
1255
+ appScopes: ["space.view"],
1185
1256
  });
1186
1257
  ```
1187
1258
 
1188
1259
  `targetRef` is a path **relative to the Space filesystem root** (not a local
1189
- path). For file Works, the target must be an HTML file (`.html`/`.htm`).
1260
+ path). For file Apps, the target must be an HTML file (`.html`/`.htm`).
1190
1261
 
1191
1262
  Update the published version from the current target:
1192
1263
 
1193
1264
  ```js
1194
- await client.works.publishVersion(workId);
1265
+ await client.apps.publishVersion(appId);
1195
1266
  ```
1196
1267
 
1197
- Other SDK methods: `works.get(workId)`, `works.getBySlug(username, spaceSlug,
1198
- workSlug)`, `works.listBySpace(spaceId)`, `works.update(workId, input)`,
1199
- `works.delete(workId)`.
1200
- `works.getBySlug(username, spaceSlug,
1201
- workSlug)`, `works.listBySpace(spaceId)`, `works.update(workId, input)`,
1202
- `works.delete(workId)`.
1268
+ Other SDK methods: `apps.get(appId)`, `apps.getBySlug(username, spaceSlug,
1269
+ appSlug)`, `apps.listBySpace(spaceId)`, `apps.update(appId, input)`,
1270
+ `apps.delete(appId)`, `apps.getStats(appId)`, `apps.download(appId)`.
1271
+
1272
+ From the CLI:
1273
+
1274
+ ```bash
1275
+ cohub apps publish <slug> --dir site --app-scope space.view --app-scope file.view
1276
+ cohub apps grants <slug> # list your viewer grants for an app
1277
+ cohub apps revoke <slug> <grantId>
1278
+ ```