@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.
- package/README.md +47 -35
- package/dist/chunks/http.d.ts +1 -1
- package/dist/chunks/websocket.d.ts +1 -0
- package/dist/http.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/docs/work-runtime-guide.md +348 -272
- package/package.json +2 -2
|
@@ -1,43 +1,48 @@
|
|
|
1
|
-
# Cohub
|
|
1
|
+
# Cohub App Runtime Guide
|
|
2
2
|
|
|
3
|
-
This guide explains how to use the Cohub SDK **inside a published
|
|
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
|
-
`
|
|
6
|
-
|
|
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
|
|
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
|
|
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--
|
|
26
|
-
- [Commerce](#commerce-
|
|
27
|
-
- [Realtime rooms](#realtime-rooms-
|
|
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
|
|
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
|
-
|
|
37
|
-
opens
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
64
|
-
| `client.auth.request({ scopes, reason })` |
|
|
65
|
-
| `client.
|
|
66
|
-
| `client.
|
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
94
|
+
and context. This is the normal case when a viewer opens an App through Cohub.
|
|
85
95
|
|
|
86
|
-
- `client.context()` returns the **real** `
|
|
87
|
-
and permission
|
|
88
|
-
-
|
|
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
|
|
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
|
|
101
|
-
not authorization: API access remains controlled by the
|
|
102
|
-
its
|
|
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
|
|
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
|
|
121
|
+
**empty string `""`**, and viewer grants are unavailable (empty).
|
|
112
122
|
- `client.auth.request()` opens a popup to
|
|
113
|
-
`${brokerOrigin}/
|
|
123
|
+
`${brokerOrigin}/app-auth?app=${appId}`.
|
|
114
124
|
|
|
115
|
-
> **Broker mode requires configuration.** You must pass `
|
|
116
|
-
>
|
|
117
|
-
> when the
|
|
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
|
|
130
|
-
public
|
|
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.
|
|
137
|
-
spaceId = detail.
|
|
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 `
|
|
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:
|
|
157
|
-
const detail = await client.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
186
|
+
An App's effective permission for one Space is the union of **two grant
|
|
187
|
+
sources** — either one is enough:
|
|
178
188
|
|
|
179
|
-
###
|
|
189
|
+
### App scopes (direct, no viewer consent)
|
|
180
190
|
|
|
181
|
-
Granted by the publisher **at publish time
|
|
182
|
-
viewer action needed.
|
|
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
|
|
186
|
-
session.view
|
|
187
|
-
file.view
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
> They never substitute for each other.**
|
|
284
|
+
### Managing grants
|
|
213
285
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
`
|
|
217
|
-
|
|
218
|
-
|
|
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 |
|
|
305
|
+
| Operation | SDK call | Scope needed | Source |
|
|
223
306
|
|---|---|---|---|
|
|
224
|
-
| Read space config | `space.get()` / `space.getConfig()` | `space.view` |
|
|
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",
|
|
227
|
-
| Send a prompt (read-only) | `space.prompt({ accessMode: "read_only",
|
|
228
|
-
| Read turn result | `session.turns.get(turnId)` | `session.view` |
|
|
229
|
-
| Stream generation | `session.subscribeGeneration(
|
|
230
|
-
| Read file tree | `space.files.tree()` | `file.view` |
|
|
231
|
-
| Read file content | `space.files.read(path)` | `file.view` |
|
|
232
|
-
|
|
|
233
|
-
|
|
|
234
|
-
|
|
|
235
|
-
|
|
|
236
|
-
| List
|
|
237
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
258
|
-
-
|
|
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
|
-
|
|
338
|
+
**Image generation app** (create + poll):
|
|
339
|
+
- `appScopes: ["space.view", "taskrun.view"]`
|
|
340
|
+
- viewer grant at runtime: `generation.create`
|
|
262
341
|
|
|
263
|
-
|
|
342
|
+
**File-reader app** (static, no viewer action):
|
|
343
|
+
- `appScopes: ["space.view", "file.view"]`
|
|
264
344
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
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**.
|
|
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
|
|
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
|
|
374
|
+
const isDevApp =
|
|
306
375
|
location.pathname.startsWith("/dev/") ||
|
|
307
376
|
location.hostname.includes("dev");
|
|
308
377
|
|
|
309
378
|
const client = createCohubClient({
|
|
310
|
-
env:
|
|
379
|
+
env: isDevApp ? "dev" : "prod",
|
|
311
380
|
});
|
|
312
381
|
```
|
|
313
382
|
|
|
314
383
|
### Broker mode configuration (standalone pages only)
|
|
315
384
|
|
|
316
|
-
If the
|
|
317
|
-
iframe), pass the `
|
|
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:
|
|
322
|
-
|
|
323
|
-
brokerOrigin:
|
|
324
|
-
|
|
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
|
|
402
|
+
#### Broker mode without a pre-known appId
|
|
334
403
|
|
|
335
|
-
The `
|
|
336
|
-
it while writing the
|
|
337
|
-
instead pass the
|
|
338
|
-
runtime via the public `
|
|
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
|
-
- `
|
|
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:
|
|
350
|
-
|
|
351
|
-
brokerOrigin:
|
|
418
|
+
env: isDevApp ? "dev" : "prod",
|
|
419
|
+
app: {
|
|
420
|
+
brokerOrigin: isDevApp ? "https://dev.cohub.live" : "https://cohub.live",
|
|
352
421
|
ownerUsername,
|
|
353
422
|
spaceSlug,
|
|
354
|
-
|
|
423
|
+
appSlug,
|
|
355
424
|
},
|
|
356
425
|
});
|
|
357
426
|
```
|
|
358
427
|
|
|
359
|
-
Either `
|
|
360
|
-
you pass both, the explicit `
|
|
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:
|
|
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
|
|
373
|
-
throw new Error("Not running inside a published
|
|
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
|
|
378
|
-
const space = client.space(spaceId);
|
|
447
|
+
const space = client.space(ctx.space.id);
|
|
379
448
|
|
|
380
|
-
// 4. Request viewer
|
|
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
|
|
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:**
|
|
408
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|
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
|
|
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
|
|
490
|
-
|
|
491
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
>
|
|
527
|
-
|
|
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) +
|
|
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** — `
|
|
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:**
|
|
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.
|
|
678
|
+
### Account-level data (`spaces.list` / `user.listSessions` / `user.getActivity`)
|
|
616
679
|
|
|
617
|
-
**Scopes:** viewer `user.space.list` / `user.session.list` /
|
|
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 —
|
|
620
|
-
the
|
|
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
|
-
|
|
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
|
|
648
|
-
in a published
|
|
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 (`
|
|
751
|
+
### Realtime rooms (`app.realtime`)
|
|
683
752
|
|
|
684
|
-
**Scopes:** none — uses the published
|
|
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
|
|
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
|
|
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
|
|
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
|
-
####
|
|
859
|
+
#### Desktop command calls that complete later
|
|
791
860
|
|
|
792
|
-
A `
|
|
793
|
-
acknowledges it. The host waits for the
|
|
794
|
-
|
|
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
|
|
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.
|
|
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
|
|
819
|
-
so a reload can restore the pending interaction.
|
|
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
|
|
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
|
|
830
|
-
> -
|
|
831
|
-
> - allowedViewerScopes: `["session.prompt.fullaccess", "generation.create"]`
|
|
899
|
+
> **Publish this App with:**
|
|
900
|
+
> - `appScopes: ["space.view", "session.view", "taskrun.view"]`
|
|
832
901
|
>
|
|
833
|
-
>
|
|
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
|
|
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
|
|
960
|
+
const isDevApp =
|
|
890
961
|
location.pathname.startsWith("/dev/") ||
|
|
891
962
|
location.hostname.includes("dev");
|
|
892
963
|
|
|
893
964
|
const client = createCohubClient({
|
|
894
|
-
env:
|
|
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
|
|
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
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
|
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
|
|
1008
|
+
log(outEl, `Already granted: [${scopes.join(", ")}]`);
|
|
939
1009
|
return true;
|
|
940
1010
|
}
|
|
941
|
-
log(outEl, `Requesting
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
- [ ] **
|
|
1119
|
-
(
|
|
1120
|
-
(
|
|
1121
|
-
- [ ] **Viewer scopes
|
|
1122
|
-
|
|
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
|
|
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
|
|
1138
|
-
`
|
|
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
|
|
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
|
|
1221
|
+
## 8. Publishing an App (API/SDK)
|
|
1148
1222
|
|
|
1149
|
-
Before creating
|
|
1150
|
-
the Space has a slug. The API rejects
|
|
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
|
|
1155
|
-
await client.
|
|
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
|
-
|
|
1162
|
-
allowedViewerScopes: ["session.prompt.fullaccess", "generation.create"],
|
|
1235
|
+
appScopes: ["space.view", "session.view", "taskrun.view"],
|
|
1163
1236
|
});
|
|
1164
1237
|
|
|
1165
|
-
// Create a directory
|
|
1166
|
-
await client.
|
|
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
|
-
|
|
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
|
|
1177
|
-
await client.
|
|
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
|
-
|
|
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
|
|
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.
|
|
1265
|
+
await client.apps.publishVersion(appId);
|
|
1195
1266
|
```
|
|
1196
1267
|
|
|
1197
|
-
Other SDK methods: `
|
|
1198
|
-
|
|
1199
|
-
`
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
+
```
|