套餐买好了,怎么真正开出一个能聊天的 Agent?本文按 templates 看档位、POST 建实例(五道关卡逐一拆解)、PATCH 改名、access-session 签 60 分钟接入凭据、stats 看账号统计的顺序,把怡途Claw 实例开通全流程走完,并说清 access token 无法提前吊销、stats 的有效性判定其实看实例到期日而非订阅表这两个最容易误解的点。
拿到怡途Claw 的账号、买好套餐(309 讲过怎么付钱、313 讲过怎么查余额和套餐),下一步最自然的事就是:开一个 Agent 实例给自己用。这一篇就把「从零到拿到能聊天的接入凭据」整条链路一次讲完,对应到接口上就是 GET /api/app/instances/templates(看配置档位)→ POST /api/app/instances(建实例)→ PATCH /api/app/instances/:id(改名字)→ POST /api/app/chat/instances/:id/access-session(签发 60 分钟的接入 token,旁边的 GET .../entry 和 GET .../connection 是同一个东西的同义入口)→ GET /api/app/instances/stats(看账号维度的运行情况)。这条链路把 300(部署)、302(流式对话)、303(切模型)、305(会话)、306(启停)这些文章里出现的「实例」概念第一次端到端串起来,所以即使是读过前面文章的,看这一篇也不会觉得重复。
先说一句总纲,免得后面乱:实例这条线的控制器是 @Controller('app/instances')(建/改/查/启停都在这里)和 @Controller('app/chat')(access-session 和 connection 在这里),统一走 JwtAuthGuard,部分写操作(建/启/停/重启/改大模型)还多挂一层 SubscriptionGuard,所以请求要带登录后拿到的 JWT(307 讲过怎么拿 token),套餐又必须在有效期内。access-session 那一组接口是 GET/POST instances/:id/{access-session,entry,connection} 三个同体入口,返回一份完整的「前端可以直接拿来用的接入信息」——token、过期时间、HTTP base、WS URL、控制台 URL、聊天 URL 全都在一份 payload 里,不用你再去拼。
一、先看配置档位:GET /api/app/instances/templates
创建实例之前,先看看平台允许的「配置档位」有哪些。这一步没有任何副作用、不查库(返回的是一个内存常量),但仍然要带 JWT——@Controller('app/instances') 类级别挂了 JwtAuthGuard,只是没有再叠 SubscriptionGuard,所以套餐过期也能查:
GET /api/app/instances/templates
Authorization: Bearer <你的 JWT>
返回的是后端 getConfigTemplates() 把 CONFIG_TEMPLATES 这个常量展开的结果。src/instances/dto/create-instance.dto.ts 里的定义非常直接:
| type | memory | cpus | 描述 |
|---|---|---|---|
| standard | 512m | 1 | 标准配置 - 适合个人使用 |
| premium | 1g | 2 | 高级配置 - 适合小型团队 |
| enterprise | 2g | 4 | 企业配置 - 适合高负载场景 |
注意这只是「APP 端在 CreateInstanceDto.type 里能传的档位枚举」。POST /api/app/instances 这个入口对 type/memory/cpus/disk/env['PLAN_ID'...]/env['OPENCLAW_*']/env['OPENAI_*'] 这些字段是全部拒绝的(见下面的 buildAppManagedLlmPayload),APP 端真要走「自定义配置」只有一条路:先在 309 买好对应套餐,套餐里绑定的资源配额(memory/cpus/disk/Docker 镜像/runtime)会随订单一起下发到 instance 上。所以 templates 这个接口的真实用途是「前端给用户展示档位、然后引导他去选套餐」,而不是「前端拿这个档位直接传给后端建实例」。
二、建第一个实例:POST /api/app/instances
POST /api/app/instances
Authorization: Bearer <你的 JWT>
Content-Type: application/json
{
"name": "我的开发 Agent",
"chatProviderId": "<你的聊天供应商 ID>",
"mediaProviderId": "<可选:媒体供应商 ID>",
"llmConfig": {
"chatModelId": "<可选:模型 ID>",
"imageGenerationModel": "<可选:图片模型 ID>",
"videoGenerationModel": "<可选:视频模型 ID>"
},
"skills": ["<可选:技能 ID 列表>"]
}
控制器 create() 看起来不复杂,但实际跑到 InstancesService.create() 之后有 5 道关卡,挨个踩一遍才知道哪儿会卡:
第一道:JWT 必须是登录态。@UseGuards(JwtAuthGuard, SubscriptionGuard),SubscriptionGuard.canActivate() 会去 BillingService.getUserPackageAccessStatus(userId),要求至少有「有效套餐 OR 未过期实例」两条之一。这个守卫对「访问已有实例」是合理的兜底(已经跑着的实例不因为续费延迟就一刀切不可用),但对「创建新实例」会变成漏洞——所以在 create() 控制器里又加了一层显式检查:
const subscription = await this.billingService.getCurrentSubscription(req.user.userId);
if (!subscription) {
throw new ServiceUnavailableException('当前没有有效套餐订阅,请先购买套餐');
}
注释把原因写得很清楚:单看 SubscriptionGuard 的「实例兜底」会导致用户不付费也能无限创建实例。所以没有当前订阅就建不了实例,一定先去 309 付钱(最常见的「套餐过期当晚想再开一个 Agent」就是这个错)。
第二道:实例数不得超过已支付套餐订单数。这一行非常关键:
const purchaseCount = await this.paymentOrderRepository.count({
where: { userId, status: PaymentStatus.PAID, type: In([MONTHLY, YEARLY]) },
});
const instanceCount = await this.instanceRepository.count({
where: { userId, status: Not('deleted') },
});
if (instanceCount >= purchaseCount) {
throw new BadRequestException(
`请先购买套餐再创建新实例(当前 ${instanceCount}/${purchaseCount})`,
);
}
简单说就是「一次购买 = 一个实例」(月度/年度订阅计,RECHARGE 类型的充值订单不算)。status: Not('deleted') 意味着已删除的实例不占配额,但已暂停(isSuspendedDueToSubscription() 为 true)、stopped、error 状态的实例都算占用。所以想再建一个,先去买个月度/年度套餐;想用充值余额开通实例(不走 create() 这条路)目前不支持。
第三道:APP 端不允许直接传套餐和资源配置。buildAppManagedLlmPayload() 是 create() 的前哨,专门负责把 APP 端「想绕开套餐直接指定配置」的尝试一一拦下:
- 传了
dto.planId→ 400,提示「请先完成支付」 - 传了
dto.memory/dto.cpus/dto.disk→ 400,提示「请通过支付购买套餐」 dto.env里塞了保留键(PLAN_ID/OPENCLAW_BASE_URL/OPENCLAW_API_KEY/OPENCLAW_MODEL_ID/OPENAI_BASE_URL/OPENAI_API_KEY/OPENAI_MODEL)→ 400dto.llmConfig.baseUrl或dto.llmConfig.apiKey非空 → 400,提示「请选择系统提供的模型供应商」dto.llmConfig.chatModelId有值但dto.chatProviderId为空 → 400,提示「必须同时指定模型供应商」
这一连串限制的潜台词是:APP 端只允许「选系统提供的供应商 + 选套餐里配好的 runtime」,想自定义大模型地址/密钥就只能走控制台(303 讲的 PUT /api/app/instances/:id/llm-config 也是一样,APP 端更新时也只允许 chatProviderId / mediaProviderId / chatModelId / imageGenerationModel / videoGenerationModel,不允许 baseUrl / apiKey)。
第四道:Docker 镜像的来源。create() 会先尝试用「后台配置镜像」resolveConfiguredDefaultImage(),如果 dto.planId 存在就改用 planConfig.getRuntimeDockerImage(dto.runtimeType || planConfig.runtimeType) 解析(按 runtimeType 拆 openclaw / claude-code / codex 之类),解析失败就 fallback 到默认镜像。expiresAt 默认从套餐续期日推算(个人版默认 4GB 内存 / 2 CPU / 20GB 硬盘),runtime 缺省走套餐或 DEFAULT_RUNTIME_TYPE。
第五道:重名检查。name 不能和当前用户已有的非删除实例同名,否则 400「Instance with this name already exists」。
五道都过了之后,返回的是 sanitizeInstance() 处理过的实例对象——env 和 token 字段被整体抹掉、customLLMConfig.apiKey 替换成 [REDACTED]。注意 POST 的返回体里只有实例本身,不含 provider 名称/支持模型清单这类展开信息;要拿那份「大模型摘要」得单独调 GET /api/app/instances/:id/llm-config(走 resolveAppLlmConfig(),返回 provider 名/类型/baseUrl/hasApiKey/apiKeyMasked/supportedModels,以及 chat 和 media 两条轨的完整快照),这一点 303 里讲过。
POST 成功之后实例是 creating 或 pending 状态,需要等 Docker 容器起来(这一步在 306 讲过的 start() / restart() 路径里走得更细),别一回来发现状态还是 creating 就误判失败。
三、改个名字:PATCH /api/app/instances/:id
建完想改个名字,最朴素的方式:
PATCH /api/app/instances/<instanceId>
Authorization: Bearer <你的 JWT>
Content-Type: application/json
{ "name": "重命名后的 Agent" }
控制器就一段:
@Patch(':id')
async rename(@Request() req, @Param('id') id: string, @Body('name') name?: string) {
const trimmed = (name || '').trim();
if (!trimmed) {
throw new BadRequestException('实例名称不能为空');
}
const instance = await this.instancesService.update(req.user.userId, id, { name: trimmed });
return {
success: true,
message: '实例名称已更新',
data: this.sanitizeInstance(instance),
};
}
注意几件事:
name走trim(),空字符串(包含纯空格)会 400- 没传
name字段时name是undefined,trim()之前就被短路判空 400 - 鉴权只挂
JwtAuthGuard没挂SubscriptionGuard,所以套餐过期的旧实例也能改名字(但其他写操作不行) - 实例所有权由
findOne(userId, id)兜底,跨用户 ID 改名字会 404
四、拿接入凭据:POST /api/app/chat/instances/:id/access-session
这是整篇文章的「主角」。建好实例、改完名字、等它跑起来(status === 'running')之后,前端要拿一组能让用户「立刻能聊天」的 URL 和 token。这一步由 @Controller('app/chat') 下的三个同体入口提供:
POST /api/app/chat/instances/<instanceId>/access-session
GET /api/app/chat/instances/<instanceId>/entry
GET /api/app/chat/instances/<instanceId>/connection
三个接口底层都走 buildChatConnectionPayload(req, id),所以行为完全一致;区别只在前端习惯(POST 一般用在「点按钮立即拿一组新的」,GET/GET 适合「页面打开时自动续期」)。控制器只有一段核心逻辑,挨个拆解:
第一段:实例必须 running,否则 503。
if (instance.status !== 'running') {
throw new ServiceUnavailableException('实例未运行');
}
前端拿到 503 时要主动跳到「启动中」或「启动失败」状态,不要静默重试——stopped 状态是用户主动停的或套餐过期暂停的,需要先 POST /api/app/instances/:id/start(306 讲过)。
第二段:刷新网关 token。如果容器已经起来但 instance.token 还是空(极端情况,比如数据库落库后容器又重启了一次),会自动跑一次 refreshGatewayToken(userId, id) 拿最新的内部 token,.catch(() => null) 静默失败(不影响下一步发 access session)。
第三段:签发 60 分钟的 access session token。InstanceAccessSessionService.createSession(userId, instanceId) 生成一个自签的 HMAC-SHA256 token,payload 结构:
{
kind: 'instance_access',
purpose: 'control-ui',
userId, instanceId,
iat, exp, // exp = now + 60min
jti: crypto.randomUUID(),
}
签名密钥按 INSTANCE_ACCESS_SESSION_SECRET → JWT_SECRET → 默认串 'openclaw-instance-access-secret' 的顺序取,签名格式 ${base64url(payload)}.${base64url(HMAC-SHA256(payload))}。TTL 由 INSTANCE_ACCESS_SESSION_TTL_MS 配置决定,没配就是 60 分钟。注意这个 token 是「实例接入」专用的,不等于登录 JWT——它不存数据库,服务端不存续约状态,完全靠签名 + exp 自校验,过期就只能重新 access-session 拿一个。
第四段:拼装一份前端直接能用的 URL 包。返回的 data.transport 包含:
httpBaseUrl:形如https://www.yitukeji.cn/api/proxy/instances/<id>,HTTP 转发入口websocketUrl:wss://www.yitukeji.cn/api/{terminal|proxy}/instances/<id>?access_session=<token>,根据 runtime 走不同的 WS 路径controlUiUrl:控制台入口(带#token=...&gatewayUrl=...哈希参数,前端从 hash 读取)chatUrl:默认会话的聊天页…/chat?access_session=…&session=maingatewayUrl:等于websocketUrl,字段冗余方便不同前端defaultSession:固定'main',对应 305 讲的「不传 sessionId 时回落到主会话」
auth 字段告诉前端 token 有三种合法的传递方式(任选其一):
queryParam: 'access_session':放到 URL 查询参数(?access_session=<token>)headerName: 'x-instance-access-session':放到 HTTP header- 默认还会写 cookie
oc_instance_access(在proxy.controller.ts的res.cookie(INSTANCE_ACCESS_COOKIE_NAME, accessSessionToken, …))
proxy-access.utils.ts 里 getAccessSessionToken() 的解析顺序是:queryParam → x-instance-access-session header → referer URL → x-original-url URL → cookie。也就是说前端用 access_session=... 拼 URL 是最直白的;后端代理链路(Referer 链)也会自动从父 URL 里把 token 提出来用;浏览器同源场景下用 cookie 最省心。
Claude Code runtime 的特殊路径。当 instance.runtimeType === 'claude-code' 时,这个 runtime 没有 gateway/HTTP 服务,所以走的是终端 PTY WebSocket:
const terminalBasePath = `/api/terminal/instances/${id}`;
const websocketUrl = `${wsOrigin}${terminalBasePath}?access_session=${encodeURIComponent(token)}`;
非 Claude Code(openclaw / hermes / codex 等)走 /api/proxy/instances/<id> 的统一代理。controlUiUrl 总是走 proxyBasePath——因为 control-ui 是个统一的前端壳,能根据 runtime 动态决定要不要跳到 PTY 终端。
踩坑表:
- 拿
accessToken之后不要自己拼接进浏览器 localStorage 长期持有——exp 只有 60 分钟,存了过期了反而麻烦。直接当作「本次会话的凭据」用就行 - 同一个 token 在过期之前可以反复用,不需要每分钟都重新签发(高频签发只会更快撞 TTL)
- 这个 token 无法提前吊销。
verifySession()只做两件事:用全局密钥校验 HMAC-SHA256 签名、检查exp是否过期;它不查数据库、不比对instance.token、也不查实例当前状态。换句话说签发出去的 token 在 60 分钟内一直有效,即使中途实例被stop了、restart了、甚至被删掉了——代理层放行之后才会在转发目标那一侧失败(连不上容器)。所以:别把这个 token 当长期凭据分发给第三方,也别指望「停掉实例」能收回已经发出去的接入权限 - 由此推论,前端拿到 503「实例未运行」时是
access-session签发阶段的检查(instance.status !== 'running'直接拒签),而不是 token 校验阶段的检查——已经签出去的 token 不受这个检查约束 - 跨用户拿不到别人的 token(签发时把
userId+instanceId写进了 payload,代理层会用 payload 里的这两个字段做资源归属判断)
五、看账号维度统计:GET /api/app/instances/stats
GET /api/app/instances/stats
Authorization: Bearer <你的 JWT>
控制器把 getUserStats 和 getUserInstanceSummary 两个数组合并返回:
{
success: true,
data: {
// getUserStats 部分
total: <所有非删除实例数>,
running: <status==='running' 的数量>,
stopped: <status==='stopped' 的数量>,
byType: { standard: 1, premium: 0, enterprise: 0, ... },
// getUserInstanceSummary 部分
totalInstances: <同上 total,但口径是 Not('deleted')>,
runningInstances: <同上 running>,
suspendedInstances: <suspendReason==='subscription_expired' 的数量>,
subscriptionStatus: { isValid, isExpired, expiresAt?, message? },
packageStatus: <同 subscriptionStatus, 字段一致>
}
}
关键点:
byType是按实例type字段聚合的(standard/premium/enterprise),不是按套餐分suspendedInstances专门算isSuspendedDueToSubscription()——也就是suspendReason === 'subscription_expired'的实例,对应 306 讲过的「套餐过期被暂停」。套餐一恢复、用户主动调一次start()(start 会清isSuspendedDueToSubscription暂停态)这个计数就归零subscriptionStatus和packageStatus是同一个对象挂到两个字段上(控制器里packageStatus: subscriptionStatus),前端读哪个都一样,别以为是两套独立数据- 最容易误解的一点:这里的
subscriptionStatus并不是去查 313 讲的订阅表(/api/app/billing/subscription那一套ACTIVE/CANCELLED/TRIAL状态机),而是checkUserSubscription()按「实例自己的expiresAt」算出来的:非删除实例数为 0 →{isValid: false, isExpired: true, message: '您没有购买任何实例...'};有任一实例expiresAt > now或expiresAt为空(视为永久有效)→ 有效;全部过期 →isValid: false且expiresAt取所有实例里最晚的那个到期日。所以同一个账号在这个接口和/api/app/billing/subscription可能给出不一致的「有效性」判断——前者看实例到期日,后者看订阅记录状态。要跟财务对账、要判断能不能续费,看 313 那一组;要判断当前实例还能不能用,看这里 - 这个接口只读,类级别只有
JwtAuthGuard没有SubscriptionGuard,套餐过期也能查(正是为了在「套餐过期」页面给用户看「你还有 N 个被暂停的实例,续费后点 start 就能恢复」)
六、整体跑一遍:从 0 到能聊天的 curl
把上面所有步骤串起来就是:
# 假设你已经有 JWT=<你的 token>、chatProviderId=<供应商 id>
BASE="https://www.yitukeji.cn"
# 1. 看配置档位(可选)
curl -s "$BASE/api/app/instances/templates" -H "Authorization: Bearer $JWT"
# 2. 建实例
curl -s -X POST "$BASE/api/app/instances" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d "{\"name\":\"dev-agent\",\"chatProviderId\":\"$chatProviderId\"}"
# 3. (可选)改名字
curl -s -X PATCH "$BASE/api/app/instances/<id>" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"name":"my-dev-agent"}'
# 4. 等状态变 running(轮询 GET /api/app/instances/:id 即可),然后签接入凭据
curl -s -X POST "$BASE/api/app/chat/instances/<id>/access-session" \
-H "Authorization: Bearer $JWT"
# 5. 看账号维度统计
curl -s "$BASE/api/app/instances/stats" -H "Authorization: Bearer $JWT"
拿到第 4 步的 data.transport.controlUiUrl 直接丢给前端,就能打开控制台跟 Agent 聊天了;想自己接 WebSocket 走 data.transport.websocketUrl,token 走 ?access_session= 或 x-instance-access-session header 都行。流式聊天怎么发 / 怎么收见 302,多会话怎么分见 305,要换大模型见 303,要停实例释放资源见 306。
这一条线下来,「开箱即用」的最小可用流程就齐了:建实例 → 等运行 → 签接入凭据 → 聊天 → 看用量 → 切模型 → 启停 → 注销(308)。下一篇计划写 app-agents 「代理分销业绩」之外更细一层的对话统计侧(app-chat stats 和 stream),把单实例维度的 token / 消息数 / 工具调用次数聚合起来——和 300/306/313 都不重叠,敬请期待。