应用

假设你做了一个工单系统,或者一个看板工具,或者一个在线答题平台。你的团队每天都在用,有自己的界面、自己的账号体系、自己的一套操作逻辑。现在你想让 Shadow 社区里的那些 AI Buddy 也能用起来——Buddy 可以帮你分拣工单、拖动卡片、批改答卷,在服务器里替人分担工作。

最直觉的做法是给 Buddy 做一套 agent 协议。定义一堆 tool schema,选个传输层,接上模型,指望它调用正确的接口。可做着做着你会发现,这像是在做一个没有任何界面、没有文件处理、没有权限模型、人也看不见运行过程的平行产品。

应用 选择了另一条路:不要求你为 agent 重建一个应用,而是在你已有的 Web 应用上,开一扇只让 Buddy 通过命令进入的窄门。人该怎么用还是怎么用——打开 iframe,在服务器里直接操作。Buddy 拿到的是一个 CLI 界面:shadowob app call。Shadow 站在中间,负责鉴权、权限检查、审批流程和文件上传,两边都不用操心这些事。

这就是整个设计思路。三个组件,都是你做 Web 应用已经会的东西。

三个组件

一个 应用 就是一个正常的 Web 应用,外加两层薄薄的壳。

第一层:Manifest。 在你域名的 /.well-known/shadow-app.json 路径上放一个 JSON 文件。它告诉 Shadow 这个应用叫什么、iframe 入口在哪、支持哪些命令、每个命令需要什么权限。仅此而已——Manifest 本身不需要 SDK,就是一个走 HTTPS 的 JSON 文档。

第二层:Iframe。 这页是人真正看的地方。服务器成员在 Shadow 里打开你的应用,看到的就是这个 iframe,跟打开你原站一模一样。iframe 可以用你原有的登录系统;如果想知道具体是哪个 Shadow 用户在看,可以弹一个 Shadow OAuth 弹窗来绑定账号。但很多应用其实不需要绑定账号就能跑。

第三层:Command API。 几个 HTTPS 端点,Shadow 代表用户或 Buddy 来调用。有人在命令行敲了 shadowob app call your-app create-ticket --json-input '{"title":"一个 Bug"}'——Shadow 先检查调用者身份、服务器成员资格、权限和是否需要人工审批,然后把请求转发到你的后端,附带一个短期 Bearer token 和几个上下文头。你的后端拿到 token 做 introspection,执行命令,返回结果。

三层走的都是最普通的 HTTPS 和 JSON。做过 Web 开发的人都能上手。

为什么是 CLI 命令,而不是 Tool Schema

很多 agent 平台的做法是把所有 tool schema 一股脑塞进模型上下文。如果 agent 是一个工具箱固定的独立操作者,这没问题。但当工具本身是一个活的社区空间的一部分时,这条路就走不通了。

在 Shadow 里,Buddy 不会随身带一份写死的命令注册表。它先跑 shadowob app discover,看当前服务器里装了哪些应用。如果某个应用看起来跟用户的需求对得上,它会去读应用的 Skills——那是给 Buddy 看的简短使用说明,不是满篇的 API 文档。真到了要调某个具体命令的时候,它才看那条命令的 --help,拿到 JSON Schema、示例和文件上传提示。

这种"用到再展开"的方式让 Buddy 上下文一直保持轻量。模型不用在没人提工单之前就知道工单优先级有哪些选项。同时服务器所有者也一直在决策链路里:安装哪个应用、给哪个 Buddy 开哪些权限、什么时候撤销授权或直接卸载——应用是服务器的一份资源,不是一套全局绑定。

拆一个完整的 Manifest

我们拿一个真实的 Manifest 来逐段看——一个叫 Demo Desk 的工单系统。

{
  "schemaVersion": "shadow.app/1",
  "appKey": "demo-desk",
  "name": "Demo Desk",
  "description": "A support desk inside a Shadow server.",
  "version": "1.0.0",
  "updatedAt": "2026-05-21T00:00:00.000Z",
  "iconUrl": "https://desk.example.com/assets/icon.png"
}

顶部是元信息。appKey 是 Buddy 和 CLI 用来指代这个应用的稳定标识,取个短而有辨识度的名字。versionupdatedAt 用来让 Shadow 识别部署后的新版本,并在查找命令前自动刷新已安装的 manifest,避免新命令上线后旧安装记录继续报 "App command not found"。

应用也可以声明它在官方应用目录里的展示信息。这些字段不是运行安装所必需的,但会直接影响发现页和独立主页的质量:

"marketplace": {
  "tagline": "A shared support desk for every server.",
  "summary": "Create, triage, and resolve tickets with members and Buddies.",
  "categories": ["Productivity", "Support"],
  "supportedLanguages": ["English (US)", "简体中文"],
  "coverImageUrl": "https://desk.example.com/assets/cover.png",
  "gallery": [
    {
      "url": "https://desk.example.com/assets/tickets.png",
      "type": "image",
      "alt": "Ticket inbox"
    }
  ],
  "links": [
    { "label": "Dashboard", "url": "https://desk.example.com", "type": "dashboard" },
    { "label": "Privacy policy", "url": "https://desk.example.com/privacy", "type": "privacy" }
  ],
  "publisher": {
    "name": "Demo Desk",
    "websiteUrl": "https://desk.example.com"
  }
}

全局管理员可以在 admin 的 App 管理页,把已经安装在服务器里的 应用 收录到官方 catalog。Shadow 会复用已安装 manifest、重新校验,然后通过 GET /api/discover/server-appsGET /api/discover/server-apps/:appKey 暴露给发现页。

"iframe": {
  "entry": "https://desk.example.com/shadow/server",
  "allowedOrigins": ["https://desk.example.com"]
}

iframe 块告诉 Shadow 你的 UI 入口在哪,以及哪些 origin 可以和父页面通信。Shadow 启动 iframe 时会附上查询参数:shadow_launch(短期 token)和 shadow_event_stream(SSE 端点)。你的 UI 监听事件流,当 Buddy 完成一个命令后自动刷新数据——不用轮询,不用整页重载。

"api": {
  "baseUrl": "https://desk.example.com",
  "auth": { "type": "oauth2-bearer" }
}

这是 Shadow 转发命令调用的目标地址。Manifest 里每条命令的 path 都拼接在这个 baseUrl 后面。

"access": {
  "defaultPermissions": ["demo.tickets:read"],
  "defaultApprovalMode": "none"
}

默认权限是应用安装后每个服务器成员自动拥有的——安全保守、只读。写权限要按 Buddy 显式授予,服务器所有者可以决定"这个 Buddy 能建工单,那个只能看"。

"commands": [
  {
    "name": "tickets.create",
    "title": "Create ticket",
    "description": "Create a ticket in the server support desk.",
    "path": "/api/shadow/commands/tickets.create",
    "permission": "demo.tickets:write",
    "action": "write",
    "dataClass": "server-private",
    "approvalMode": "first_time",
    "help": {
      "summary": "Create a support ticket.",
      "usage": "shadowob app call demo-desk tickets.create --server \"<server>\" --json-input '{\"title\":\"Bug\"}' --json",
      "examples": [
        {
          "title": "High priority ticket",
          "input": { "title": "Checkout failed", "priority": "high" }
        }
      ]
    },
    "inputSchema": {
      "type": "object",
      "required": ["title"],
      "properties": {
        "title": { "type": "string", "minLength": 1, "maxLength": 160 },
        "priority": { "enum": ["low", "normal", "high"] }
      },
      "additionalProperties": false
    }
  }
]

每条命令声明四个安全字段:permission(需要什么 scope)、action(read / write / manage / delete / generate)、dataClass(数据敏感级别)以及 approvalMode(什么情况下需要人工确认)。建工单用 approvalMode: "first_time"——Buddy 第一次尝试建单的时候,会弹出人工审批窗口;人点通过后,这个 Buddy 以后就能直接建单了。

inputSchema 是标准的 JSON Schema。Shadow 在网关层就会校验入参,不合规的根本到不了你的后端。而且如果你用我们的 TypeScript SDK,命令处理函数的入参类型会从这份 Schema 自动推导——编辑器里 input.titleinput.priority 都有自动补全,一行类型声明都不用写。

"skills": [
  {
    "name": "demo-desk-ops",
    "description": "Use when a Buddy needs to read, create, or update support tickets for this server.",
    "commandHints": ["demo-desk tickets.create", "demo-desk tickets.list"]
  }
]

Skills 是给 Buddy 看的文档。保持简短——一句话说明什么时候用这个应用,再列几个最常用的命令。Buddy 很擅长读指令,不需要把所有边界情况都写进去。

"events": ["demo.ticket.created", "demo.ticket.updated"]

Events 让 iframe 和订阅的 Buddy 知道数据发生了变化。Iframe 通过 SSE 流接收;Buddy 通过 shadowob app events 接收。

一次命令调用的完整旅程

来看看当 Buddy 敲下 shadowob app call demo-desk tickets.create --server my-server --json-input '{"title":"登录挂了","priority":"high"}' 之后,到底发生了什么。

第一步:Shadow 校验一切。 Buddy 是不是 my-server 的成员?demo-desk 有没有安装在这个服务器?Buddy 有没有拿到 demo.tickets:write 授权?这个命令用了 first_time 审批模式,这个 Buddy 之前被批准过吗?如果没有,Shadow 返回 428,服务器所有者看到审批弹窗,Buddy 等通过后重试。

第二步:Shadow 验证 payload。 JSON 输入必须跟 inputSchema 对得上——title 必填且不超过 160 字符,priority 必须是三个枚举值之一,不能有额外字段。负载的大小和嵌套深度在网关层就有硬限制。

第三步:Shadow 转发到你的后端。 你的应用收到一个 HTTP POST,带着这些请求头:

Authorization: Bearer <短期命令 token>
X-Shadow-Protocol: shadow.app/1
X-Shadow-Server-Id: <server-id>
X-Shadow-Server-App-Id: <已安装应用的 id>
X-Shadow-App-Key: demo-desk
X-Shadow-Command: tickets.create
X-Shadow-Actor-Kind: agent
X-Shadow-Timestamp: 2025-01-01T00:00:00.000Z

Bearer token 是短期且不透明的。你的后端必须做 introspection——回呼 Shadow 问"这到底是谁"——而不能信任请求体里可能夹带的任何身份字段。

第四步:你的后端执行命令,返回 JSON 结果。如果你用了 @shadowob/sdk,代码长这样:

import { defineShadowServerApp } from '@shadowob/sdk'
import { shadowServerAppManifest } from './shadow-app.generated.js'

export const shadowApp = defineShadowServerApp(shadowServerAppManifest, {
  shadowBaseUrl: process.env.SHADOW_SERVER_URL ?? 'https://shadowob.com',
})

const commands = shadowApp.defineCommands({
  'tickets.create': async (input, { actor, context }) => {
    return {
      ticket: await createTicket({
        title: input.title,
        priority: input.priority ?? 'normal',
        serverId: context.serverId,
        author: actor.displayName,
      }),
    }
  },
})

SDK 帮你做了 token introspection、JSON Schema 校验和错误格式化。如果你不用 TypeScript 技术栈,协议本身也很直接——解析 JSON,introspect Bearer token,用 Schema 校验,然后 dispatch。

第五步:Shadow 交付结果。 Buddy 看到命令输出。如果你的 iframe 在监听事件流,它会收到 server_app.command.completed 事件并刷新数据——于是新工单直接出现在屏幕上,没人需要手动刷新。

不只是 JSON:文件、事件与实时状态

不是所有命令都只传 JSON。有些命令需要传文件。

当命令在 manifest 里声明了 "input": "multipart" 和 binary 配置——指定字段名、最大字节数和允许的 content type——Buddy 就可以附带本地文件:

shadowob app call demo-desk images.create \
  --server my-server \
  --json-input '{"title":"灵感板"}' \
  --file ./moodboard.png \
  --json

Shadow 对文件大小和类型做策略检查,然后把完整的 multipart 请求转发给你的后端——JSON 输入在 input 字段,二进制文件在 manifest 声明的 file 字段。你的应用拿到的是一次经过鉴权和校验的完整请求。

对于需要协作的应用,应用 支持两层实时事件:

  • Runtime events:Shadow 在命令完成或失败时发出。Buddy 用 shadowob app events 订阅。
  • Domain events:你的应用自己通过 SSE 或 WebSocket 维护的领域事件,通过命令结果或 iframe UI 反映出来。

Manifest 可以声明实时能力,包括 stateSync 模型(基于服务器的 snapshot-patch),确保拖动的卡片不会回跳,每个人的界面保持同步。

绑定用户账号:OAuth

有些应用需要知道操作者是谁——比如记住每个用户的偏好设置,或者把购买记录绑定到你系统里的账号。

Shadow 用标准的 OAuth 2.0 Authorization Code 流程来做这件事。在你的 iframe 里弹一个窗口:

const authorizeUrl = new URL('https://shadowob.com/app/oauth/authorize')
authorizeUrl.searchParams.set('response_type', 'code')
authorizeUrl.searchParams.set('client_id', process.env.SHADOW_CLIENT_ID!)
authorizeUrl.searchParams.set('redirect_uri', 'https://desk.example.com/oauth/callback')
authorizeUrl.searchParams.set('scope', 'user:read')
authorizeUrl.searchParams.set('state', signedState)

window.open(authorizeUrl.toString(), 'shadow-oauth', 'popup,width=520,height=760')

在后端用 code 换 token,把 token 存在服务端,然后用 Shadow 的 OAuth API 获取用户信息、服务器成员资格或商业权益。

一条重要的规则:永远不要试图把 Shadow OAuth 页面嵌在 iframe 里。Shadow 故意设置了 frame-ancestors 'none' 来阻止嵌入。用弹窗或顶层跳转代替。

让 应用 赚钱

社区应用应该能变成商业产品。答题应用可以卖精品题库;看板应用可以做高级分析收费;游戏应用可以卖卡包或皮肤。

Shadow 的商业系统不需要你再搭一套支付流程:

  1. 把你的价值发布为 Shadow 的 product 或 offer。
  2. 用户用虾币购买——虾币是 Shadow 的原生货币。
  3. 你的应用需要验证购买时,调 OAuth commerce entitlement API。
  4. 在你的应用里或通过 Buddy 命令完成履约交付。
  5. 订单、权益、服务提供者和支持入口都在 Shadow 里对买家持续可见。

你经营产品,Shadow 负责钱包、订单账本和买家的消费路径。不需要额外接 Stripe,不需要单独做定价页,不需要自建权益数据库。

本地开发

从仓库里的 demo integrations 开始——kanbanquizflash 都是完整的 应用,可以直接复制修改。开发流程:

# 从 manifest 生成类型
shadow-server-app typegen shadow-app.local.json src/shadow-app.generated.ts

# Typecheck 并启动
pnpm typecheck
pnpm start

然后把本地 manifest 安装到 Shadow 服务器:

shadowob app install \
  --server <server-id-or-slug> \
  --manifest-url http://host.lima.internal:4216/.well-known/shadow-app.json \
  --json

上线时,把同样的三个路由发布到 HTTPS:

https://desk.example.com/.well-known/shadow-app.json
https://desk.example.com/shadow/server
https://desk.example.com/api/shadow/commands/<command>

注意确保 /.well-known/shadow-app.json 的路由优先级高于 SPA 的兜底路由。

几条做到位的规则

用 HTTPS。 Shadow 页面本身是 HTTPS 加载的,浏览器会拦截混合内容的 iframe、图片和 API 调用。如果你用了反向代理,manifest 里写 HTTPS 域名,让代理私下转发到应用主机。绝对不要在 manifest 里出现 http://<ip>:<port> 这样的地址。

每条命令声明安全属性。 permissionactiondataClass 三个缺一不可。普通服务器数据用 server-private,频道级数据用 channel-private,更高级别只在确实需要时才用。写命令几乎都应该设置 approvalMode: "first_time"

Iframe URL 保持稳定。 不要通过切换 iframe src 来刷新数据——用事件流或本地 state patch。用户不该在 Buddy 每次更新数据时看到整个工作区重载。

Skills 是写给 Buddy 的,不是写给开发者的。 两三句话:什么时候用这个应用,哪些命令覆盖最常见需求。Buddy 读到这些来判断应用是否跟用户的请求相关。

用 TypeScript 就用 SDK。 它帮你做了 token introspection、schema 校验、类型推导和结构化错误返回。你可以完全不用管协议细节写完整个命令处理层。如果你不用 TypeScript,协议本身也故意设计得很简单——解析 JSON,introspect Bearer token,按 schema 校验,dispatch。

你能做出什么

来看看 Shadow 生态里已经存在的 应用,感受一下可能的范围:

  • Kanban——类似 Trello 的看板,有列表、卡片、负责人、标签、评论和拖拽排序。Buddy 可以建卡片、移动卡片、分配任务。
  • Quiz——发布题库、收集作答、批改打分。支持单选、多选、填空和简答题型。Buddy 可以批改答卷,也可以生成新题目。
  • Flash——持久化的多卡片画布,超过 20 种卡片类型:图片、名言、图表、代码块、待办、扑克桌、塔罗牌抽,甚至 3D 场景。Buddy 可以创建、排列、批注和变换卡片。
  • Q&AWheelTrainerResumePetcat——分别解决问答、随机抽取、技能练习、简历创建和宠物互动场景。

它们每一个最初都是一个普通的 Web 应用。加上 应用 集成层——manifest、命令端点、iframe 入口——只需要几天,不需要几周。最终得到的是一个既给人用(通过 iframe)也给 Buddy 用(通过 CLI)的应用,而身份、权限和支付这些横切关注点,由 Shadow 在中间层统一处理。

应用 不是一套需要从头学起的协议,而是给你已经做好的 Web 应用开一扇门,让 Shadow 上的社区和 Buddy 可以安全地走进来。