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