开发者

App 开发

Cohub App 能力全景 — runtime 上下文、权限、prompt、生成、文件、实时、Surface 与商业化。

Cohub App 是运行在 Cohub runtime 中的已发布网页。在 App 代码里,你可以与 Agent 对话、生成媒体、读写 Space 文件、在多个 viewer 之间共享实时状态、向 Agent 暴露方法,以及销售商品。

本页是一张能力地图:每个场景能做什么、调用哪个 SDK 表面、需要什么授权。 完整运行时参考见 App Runtime Guide

Runtime 一分钟

在 App 内,createCohubClient() 无需 token — 宿主提供短时鉴权。Runtime API 只在已发布的 App 中可用。

  • Bridge 模式 — App 运行在 Cohub iframe 中(默认)。
  • Broker 模式 — App 作为独立页面打开;SDK 回退到 popup broker。给 createCohubClient 传入 app: { brokerOrigin, appId }(或 slug 三元组)即可启用。
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 很重要:dev 域名上的 App 必须显式传 env: "dev",否则会静默 调用生产环境。

Context

const ctx = await client.context();

ctx.app.id;                        // App id
ctx.app.slug;                      // 公开 slug
ctx.app.homeSpace;                 // 拥有该 App 的 Space
ctx.viewer;                        // 当前 viewer,可能为 null
ctx.invocation;                    // App 从哪里被打开
ctx.permissions;                   // appScopes + viewerGrants,用于渲染状态

invocation 在可用时携带 surfacesourcespaceIdsessionIdturnIdtoolCallId。它描述的是打开来源 — 是上下文,不是授权。

client.app.onContextChanged(cb) 会在登录或授权变化时推送新 context,权限 状态无需轮询即可渲染。

能力场景

以下场景假设 client 已初始化、spaceId 已知(ctx.app.homeSpace.idctx.invocation.spaceId)。权限行给出最小授权;app scopes 只覆盖 App 自己的 Space,其他 Space 需通过 client.auth.request() 获取 viewer grant。

Agent 对话

向 Space Chat 发送 prompt,并流式读取回复。

// 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 创建 session;传入 id 则继续对话
});

const stop = space.session(result.session.id).subscribeGeneration({
  state: (e) => renderPartial(e.state),
  finalized: (e) => render(e.turn.assistantText),
  error: (e) => showError(e),
});

要点:

  • space.prompt() 立即返回一个 turn,回复通过 subscribeGeneration 流式 到达(或轮询 turns.get())。
  • accessMode 必须与持有的 scope 匹配:full_access 需要 session.prompt.fullaccessread_only 需要 session.prompt.readonly。 这是目前最常见的 403 原因。

LLM completion(只读)

一次性补全,不落 session、不存 turn — 适合内联建议、摘要、分类。

// session.prompt.readonly + session.view
const result = await space.prompt({
  accessMode: "read_only", // 必须显式传入;省略 → full_access → 403
  sessionId: null, // 只读 prompt 使用临时 session
  content: [{ type: "text", text: prompt }],
});

Generation(图片 / 视频 / 音频)

创建多模态生成任务并等待输出。

// viewer grant generation.create + taskrun.view
const result = await client.generations.createAndWait(
  {
    spaceId,
    model: "gpt-image-2", // 来自 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;

要点:

  • generation.create 只能来自 viewer grant,必须在用户手势中请求:

    await client.auth.request({
      scopes: ["generation.create"],
      reason: "Generate images in this app",
    });
    
  • 读取结果需要 taskrun.view。只有 generation.create 而没有 taskrun.view,就是经典的「任务创建成功但永远等不到结果」。

Space 文件

读取文件树与文件内容,并把结果写回。

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 命令

在 Space sandbox 中执行 shell 命令。

// command.execute
const run = await space.runCommand({ command: ["node", "scripts/build.mjs"] });

Session 实时事件

Agent 工作时订阅 Chat 事件。

// session.view
const stop = session.subscribe({
  progress: (e) => renderProgress(e.payload),
  finalized: (e) => render(e.payload),
});

Realtime 房间

同一 App 的多个 viewer 之间的多人状态、presence 与通用 JSON 事件。运行时 原生提供 — 无需 scope 或授权弹窗。

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 });

要点:

  • 事件在连接期间有序,但不会重放。重连后应重新拉取权威状态。
  • 高频数据用 room.send(),有意义的更新用 publish()

向 Agent 暴露方法(App Surface)

注册具名方法,Cohub 宿主 — 包括通过 cohub desktop open <app> --call <method> 调用的 Agent — 可以调用运行中的 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,
  });
});

要点:

  • 只有注册过的方法可达。不提供 DOM 访问,也不执行脚本。
  • 调用语义是 at-least-once,处理函数应可安全重复执行。
  • Surface 响应只确认送达;最终结果通过 client.ui.reportResult() 上报。

Composer 上下文

App 激活期间,向 Cohub composer 附加一个紧凑的上下文 chip。

client.app.composer.setChip({
  key: "selection",
  label: "3 selected",
  content: "Selected records:\n- customer_123\n- customer_456",
});

client.app.composer.clearChip("selection");

商业化

销售一次性商品并消耗积分,绑定 App 的 runtime 身份。需要 Space 启用 commerce。

const { entitlements, credits } = await client.app.commerce.getEntitlements();

// 功能解锁
const unlocked = entitlements.some((e) => e.benefitKey === "pro" && e.enabled);
if (!unlocked) await client.app.commerce.purchase({ productKey: "pro_unlock" });

// 按量动作
const result = await client.app.commerce.consumeCredits({
  amount: 10,
  operationId: crypto.randomUUID(), // 每个逻辑动作一个稳定 id
  reason: "Export high-resolution image",
});
if (result.status === "insufficient") {
  await client.app.commerce.purchase({ productKey: "credit_pack" });
}

// checkout 返回后,重新查询权威订单状态
const state = await client.app.commerce.getCheckoutState();
if (state.orderId) {
  const { order } = await client.app.commerce.getOrder(state.orderId);
}

要点:

  • 每个逻辑动作使用稳定、唯一的 operationId,重试保持幂等。
  • checkout 返回不等于支付成功。跳转回来后应重新查询 getCheckoutState() / getOrder()
  • 商品配置见 App Commerce Guide

Models

列出模型无需 scope,仅需鉴权。

const models = await client.models.list();
const multimodal = await client.models.listMultimodal();

账户级数据

超出 App 自己 Space 的范围时,viewer grant 可以解锁 viewer 的账户数据。

// 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 });

权限一页纸

App 的授权是两个来源的并集 — 任一满足即可:

来源 由谁授予 覆盖范围 有效期
App scopes 发布者在发布时授予 仅 App 自己的 Space;八个有界 scope 发布期间有效
Viewer grants viewer 通过授权对话框授予 viewer 自身持有的任意权限,作用于其选择的 Space 14 天,可撤销

黄金法则:

读 App 自己的 Space → app scopes
写操作、其他 Space、generation、账户数据 → viewer grants

在用户手势(按钮点击)中调用 auth.request,写清楚 reason;已授权时静默 复用 — 只有需要新权限时才会弹窗。

发布与验证

发布目标、版本与管理细节见 Apps

开发期只有一条关键规则:runtime API(context()auth.request、realtime、 commerce)只在已发布的 App 中可用。本地 file:// 页面和裸静态 URL 无法 验证它们 — 发布后在真实 runtime 中测试,改动后通过 cohub apps publish-version 发布新版本。

Best practices

  • 最小权限:只申请能工作的最小 scope 集合
  • 在用户手势中调用 auth.request,并写清理由
  • 把 invocation 当作路由信息,而不是授权
  • 服务端数据是权威;realtime 只是传输层,重连后重新同步
  • Surface 处理函数与积分消耗保持幂等
  • 永远不要把 token 或密钥放进 URL 或随包资源

相关

  • Apps — 发布与管理
  • SDK — 完整 client 表面
  • CLI — 终端工作流