Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

买好套餐之后:怡途Claw 从零建 Agent 实例到拿接入凭据全流程

2026年8月6日65 min read

套餐买好了,怎么真正开出一个能聊天的 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 .../entryGET .../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 里的定义非常直接:

typememorycpus描述
standard512m1标准配置 - 适合个人使用
premium1g2高级配置 - 适合小型团队
enterprise2g4企业配置 - 适合高负载场景

注意这只是「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)、stoppederror 状态的实例都算占用。所以想再建一个,先去买个月度/年度套餐;想用充值余额开通实例(不走 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)→ 400
  • dto.llmConfig.baseUrldto.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() 处理过的实例对象——envtoken 字段被整体抹掉、customLLMConfig.apiKey 替换成 [REDACTED]。注意 POST 的返回体里只有实例本身,不含 provider 名称/支持模型清单这类展开信息;要拿那份「大模型摘要」得单独调 GET /api/app/instances/:id/llm-config(走 resolveAppLlmConfig(),返回 provider 名/类型/baseUrl/hasApiKey/apiKeyMasked/supportedModels,以及 chatmedia 两条轨的完整快照),这一点 303 里讲过。

POST 成功之后实例是 creatingpending 状态,需要等 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),
  };
}

注意几件事:

  • nametrim(),空字符串(包含纯空格)会 400
  • 没传 name 字段时 nameundefinedtrim() 之前就被短路判空 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 tokenInstanceAccessSessionService.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_SECRETJWT_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 转发入口
  • websocketUrlwss://www.yitukeji.cn/api/{terminal|proxy}/instances/<id>?access_session=<token>,根据 runtime 走不同的 WS 路径
  • controlUiUrl:控制台入口(带 #token=...&gatewayUrl=... 哈希参数,前端从 hash 读取)
  • chatUrl:默认会话的聊天页 …/chat?access_session=…&session=main
  • gatewayUrl:等于 websocketUrl,字段冗余方便不同前端
  • defaultSession:固定 'main',对应 305 讲的「不传 sessionId 时回落到主会话」

auth 字段告诉前端 token 有三种合法的传递方式(任选其一):

  1. queryParam: 'access_session':放到 URL 查询参数(?access_session=<token>
  2. headerName: 'x-instance-access-session':放到 HTTP header
  3. 默认还会写 cookie oc_instance_access(在 proxy.controller.tsres.cookie(INSTANCE_ACCESS_COOKIE_NAME, accessSessionToken, …)

proxy-access.utils.tsgetAccessSessionToken() 的解析顺序是: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>

控制器把 getUserStatsgetUserInstanceSummary 两个数组合并返回:

{
  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 暂停态)这个计数就归零
  • subscriptionStatuspackageStatus 是同一个对象挂到两个字段上(控制器里 packageStatus: subscriptionStatus),前端读哪个都一样,别以为是两套独立数据
  • 最容易误解的一点:这里的 subscriptionStatus不是去查 313 讲的订阅表(/api/app/billing/subscription 那一套 ACTIVE/CANCELLED/TRIAL 状态机),而是 checkUserSubscription() 按「实例自己的 expiresAt」算出来的:非删除实例数为 0 → {isValid: false, isExpired: true, message: '您没有购买任何实例...'};有任一实例 expiresAt > nowexpiresAt 为空(视为永久有效)→ 有效;全部过期 → isValid: falseexpiresAt 取所有实例里最晚的那个到期日。所以同一个账号在这个接口和 /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 statsstream),把单实例维度的 token / 消息数 / 工具调用次数聚合起来——和 300/306/313 都不重叠,敬请期待。