App development
Cohub App capabilities — runtime context, permissions, prompts, generation, files, realtime, surface, and commerce.
Cohub Apps are published web pages that run inside a Cohub runtime. From app code you can talk to Agents, generate media, read and write Space files, share realtime state between viewers, expose methods to Agents, and sell products.
This page is a capability map: what each scenario can do, which SDK surface to call, and what authorization it needs. For the full runtime reference, see the App Runtime Guide.
Runtime in one minute
createCohubClient() needs no token inside an App — the host provides
short-lived auth. Runtime APIs only work inside a published App.
- Bridge mode — the App runs in a Cohub iframe (default).
- Broker mode — the App is opened standalone; the SDK falls back to a
popup broker. Pass
app: { brokerOrigin, appId }(or the slug triple) tocreateCohubClientto enable it.
import { createCohubClient } from "@neta-art/cohub";
const client = createCohubClient({ env: isDevApp ? "dev" : "prod" });
const ctx = await client.context();
if (!ctx?.app?.id) throw new Error("Not inside a published app");env matters in the browser: an app served from a dev host must pass
env: "dev", or it will silently call production.
Context
const ctx = await client.context();
ctx.app.id; // App id
ctx.app.slug; // public slug
ctx.app.homeSpace; // the Space that owns the App
ctx.viewer; // current viewer, may be null
ctx.invocation; // where the App was opened from
ctx.permissions; // appScopes + viewerGrants, for rendering stateinvocation carries surface, source, spaceId, sessionId, turnId,
and toolCallId when available. It describes where an open came from — it is
context, not authorization.
client.app.onContextChanged(cb) pushes fresh context on sign-in or grant
changes, so permission state renders without polling.
Capability scenarios
Every scenario below assumes client is initialized and spaceId is known
(ctx.app.homeSpace.id or ctx.invocation.spaceId). Scope lines show the
minimum authorization; app scopes cover only the App's own Space, and viewer
grants via client.auth.request() are needed elsewhere.
Prompt an Agent
Send a prompt into a Space Chat and stream the reply.
// session.prompt.fullaccess + session.view
const result = await space.prompt({
accessMode: "full_access",
content: [{ type: "text", text: "Describe a shiba inu on Mars." }],
sessionId: null, // null creates a session; pass an id to continue
});
const stop = space.session(result.session.id).subscribeGeneration({
state: (e) => renderPartial(e.state),
finalized: (e) => render(e.turn.assistantText),
error: (e) => showError(e),
});Key points:
space.prompt()returns immediately with a turn whose reply arrives viasubscribeGeneration(or by pollingturns.get()).accessModemust match the scope you hold:full_accessneedssession.prompt.fullaccess,read_onlyneedssession.prompt.readonly. This mismatch is the most common 403.
LLM completion (read-only)
One-shot completion with no persisted session or turn — ideal for inline suggestions, summaries, or classification.
// session.prompt.readonly + session.view
const result = await space.prompt({
accessMode: "read_only", // must be explicit; omitted → full_access → 403
sessionId: null, // read-only prompts use a throwaway session
content: [{ type: "text", text: prompt }],
});Generation (image / video / audio)
Create a multimodal generation task and wait for outputs.
// viewer grant generation.create + taskrun.view
const result = await client.generations.createAndWait(
{
spaceId,
model: "gpt-image-2", // from client.models.listMultimodal()
content: [{ type: "text", text: "A cat on the moon, cartoon style" }],
parameters: { size: "1024x1024" },
},
{ onPoll: (d) => updateProgress(d.run.status) },
);
const imageUrl = result.output?.find((b) => b.type === "image")?.source?.url;Key points:
generation.createcan only come from a viewer grant — request it from a user gesture:await client.auth.request({ scopes: ["generation.create"], reason: "Generate images in this app", });Polling the result needs
taskrun.view.generation.createwithouttaskrun.viewis the classic "task created but never completes" bug.
Space files
Read the file tree and file contents, and write files back.
const space = client.space(spaceId);
// file.view
const tree = await space.files.tree();
const file = await space.files.read("data.json");
// file.edit
await space.files.write("output/result.json", JSON.stringify(data));Sandbox commands
Run a shell command in the Space sandbox.
// command.execute
const run = await space.runCommand({ command: ["node", "scripts/build.mjs"] });Session realtime
Subscribe to Chat events while an Agent works.
// session.view
const stop = session.subscribe({
progress: (e) => renderProgress(e.payload),
finalized: (e) => render(e.payload),
});Realtime rooms
Multiplayer state, presence, and generic JSON events between viewers of the same App. Runtime-native — no scope or consent needed.
const room = await client.app.realtime.createRoom({ code: "TEAM-ALPHA" });
const stop = room.subscribe("shared.state.updated", ({ data }) => {
render(data);
});
await room.publish("shared.state.updated", { value: 42 });Key points:
- Events are ordered while connected but not replayed. Re-fetch authoritative state after reconnecting.
- Use
room.send()for high-rate traffic,publish()for meaningful updates.
Expose methods to Agents (App Surface)
Register named methods that the Cohub host — including an Agent via
cohub desktop open <app> --call <method> — can call on your running App.
client.app.surface.handle("image.open", async (input, { commandId }) => {
const result = await openImageStudio(input);
await client.ui.reportResult(commandId, {
status: "applied",
result,
error: null,
});
});Key points:
- Only registered methods are reachable. No DOM access, no script execution.
- Calls are delivered at-least-once; handlers should be safe to repeat.
- A Surface response only acknowledges delivery; report the final result via
client.ui.reportResult().
Composer context
Attach one compact context chip to the Cohub composer while the App is active.
client.app.composer.setChip({
key: "selection",
label: "3 selected",
content: "Selected records:\n- customer_123\n- customer_456",
});
client.app.composer.clearChip("selection");Commerce
Sell one-time products and consume credits, bound to the App's runtime identity. Requires commerce enabled on the Space.
const { entitlements, credits } = await client.app.commerce.getEntitlements();
// Feature unlock
const unlocked = entitlements.some((e) => e.benefitKey === "pro" && e.enabled);
if (!unlocked) await client.app.commerce.purchase({ productKey: "pro_unlock" });
// Metered action
const result = await client.app.commerce.consumeCredits({
amount: 10,
operationId: crypto.randomUUID(), // stable id per logical action
reason: "Export high-resolution image",
});
if (result.status === "insufficient") {
await client.app.commerce.purchase({ productKey: "credit_pack" });
}
// After checkout returns, re-query authoritative order state
const state = await client.app.commerce.getCheckoutState();
if (state.orderId) {
const { order } = await client.app.commerce.getOrder(state.orderId);
}Key points:
- Use a stable, unique
operationIdper logical action — retries stay idempotent. - Checkout return is not proof of payment. Re-query
getCheckoutState()/getOrder()after redirect. - See the App Commerce Guide for product setup.
Models
Listing models needs no scope — just authentication.
const models = await client.models.list();
const multimodal = await client.models.listMultimodal();Account-level data
Beyond the App's own Space, viewer grants unlock the viewer's account data.
// user.space.list
await client.auth.request({ scopes: ["user.space.list"], reason: "Show your spaces" });
const { spaces } = await client.spaces.list();
// user.session.list
const { sessions } = await client.user.listSessions({ limit: 20 });
// user.usage.read
const activity = await client.user.getActivity({ days: 30 });Permissions in one page
App authorization is the union of two sources — either is enough:
| Source | Granted by | Covers | Lifetime |
|---|---|---|---|
| App scopes | Publisher at publish time | Only the App's own Space; eight bounded scopes | While published |
| Viewer grants | Viewer via consent dialog | Any permission the viewer holds, on any Space they pick | 14 days, revocable |
The golden rule:
Reads on the App's own Space → app scopes
Actions, other Spaces, generation, account data → viewer grants
Request viewer grants from a user gesture (button click), state a clear
reason, and let silent reuse cover return visits — auth.request only opens
a dialog when something new is needed.
Publishing and verifying
Publish targets, versions, and management details live in Apps.
The one rule that matters during development: runtime APIs (context(),
auth.request, realtime, commerce) only work inside a published App.
Local file:// pages and bare static URLs cannot exercise them — publish and
test against the real runtime, and publish a new version (cohub apps publish-version) after changes.
Best practices
- Least privilege: request the smallest scope set that works
- Call
auth.requestfrom user gestures with a clear reason - Treat invocation context as routing info, not authorization
- Keep server data authoritative; realtime is a transport, resync after reconnect
- Make Surface handlers and credit consumption idempotent
- Never put tokens or secrets in URLs or shipped assets