跳到正文

Quickstart:让 Agent 替用户下单 ​

本页用一个"替用户在亚马逊比价下单"的场景,说明面向个人用户的 App 如何按顺序调用 Qoni SDK:为已经登录的用户创建一个有自己邮箱的 Agent,把两者绑定,委托有限的权限,再让 Agent 通过 Web Agent 完成购物。遇到需要用户出手的时刻,Agent 会暂停并发起一次交互,把决定交还给用户。

迈向智能体 Web ​

Qoni 的目标是让 Agent 成为 Web 世界的一等公民。模型负责思考,Web 是 Agent 行动的地方,Qoni 是两者之间的那一层。下图是这个分层:人把有范围、有期限的权限委托给 Agent,每一步都可审计;Agent 在云端托管浏览器里替人操作网页,登录状态在任务之间复用;做完之后,把结果交还给人。

迈向智能体 Web一个分层模型:人把有范围、可审计的权限委托给 Agent,Agent 在云端托管浏览器里操作 Web,再把结果交还给人。HTMLWebAgent人委托给 Agent每一步可审计结果交还给人云端托管浏览器没有 API 也能干活登录态跨任务复用并行执行网页任务

Qoni 是面向个人 AI 智能体的托管式基础设施,为 Agent 提供身份、行动和记忆三样东西:GenAuth 让 Agent 拥有绑定真人的独立身份,授权有范围、有期限,每一步可审计;Web Agent 让 Agent 在云端托管浏览器里操作真实网页,没有 API 也能干活;GUMem 记住用户说过什么、做过什么,不用教第二遍。三者通过同一个 Qoni SDK 接入。

场景 ​

用户在你的 App 里对购物助理说了一句话:

帮我打开亚马逊电商,搜索 MacBook Neo 512GB 版本,找到最低价的,然后帮我下单。

把这句话变成一笔真实的订单,Agent 需要四样东西:

需要什么为什么需要对应步骤
Agent 自己的 IdentityAgent 有独立的身份和邮箱,能接收验证码、发送通知,不冒用用户的账号①
用户与 Agent 的绑定确认这个 Agent 归这位用户所有,别人无法借用②
一张委托 Token限定 Agent 能读什么、能做什么、多久内有效③
能与用户协作的执行打开网页、比价、结账;需要用户登录、回答、接管、补全信息或确认时请用户出手④

用户本人的 Identity 不在这张表里:用户已经通过 GenAuth 注册和登录了你的 App,做法见 用户注册与登录。

整体流程 ​

下图是整体逻辑:上半部分是开通流程,每位用户只走一次;下半部分是每次下单都会走的任务流程。

开通每位用户一次 · Token 过期后只需重做 ③
  1. GenAuth用户已登录已通过 GenAuth 托管页注册或登录
  2. 1GenAuth为 Agent 创建 Identity分配用户自定义的专属邮箱
  3. 2GenAuth绑定用户与 Agent凭用户登录态确认归属
  4. 3GenAuth生成委托 Token服务端静默取得,不跳转授权页
grant.token把用户的授权交给任务
下单每个任务一次

开通只做最少的事:为已登录的用户创建 Agent、完成绑定,再取得委托 Token。委托 Token 过期后,只需重新执行第 ③ 步。收货地址和信用卡不需要提前填写:第一次下单走到结账时,Agent 会请用户补全,并保存到 Profile 供之后的订单使用。

前置条件 ​

  • Node.js 20.11 或更高版本。
  • 一组 Qoni AccessKey ID 和 Secret。这组 AccessKey 的权限策略需要允许本页申请的全部 Scope。
  • 用户已经通过 GenAuth 注册和登录你的 App,你的服务端保存着用户 ID 和登录态(Access Token)。做法见 用户注册与登录。
  • 一个能把请求推送到用户面前的界面,比如网页弹窗或手机推送。第 ④ 步会用它处理各类交互。

安装 SDK,并在 App 服务端初始化客户端:

bash
npm install @qoniai/qoni
ts
import { Qoni, type InteractionHandle } from '@qoniai/qoni'

// 只在 App 服务端初始化;AccessKey 不能下发到浏览器或手机端
const qoni = new Qoni({
  accessKey: process.env.QONI_ACCESS_KEY!,
  secretKey: process.env.QONI_SECRET_KEY!,
})

// 用户已通过 GenAuth 登录:从你保存的登录态中取出用户 ID 和 Access Token
const userId = '<genauth-user-id>'
const userAccessToken = '<user-access-token>'

接入指南 ​

① 为 Agent 创建 Identity ​

这一步为用户创建专属的购物助理 Agent。Qoni 会给它分配一个专属邮箱,邮箱名由用户自定义:网站把验证码发到这里时,Agent 可以自己读取;下单完成后,Agent 也用它给用户发送订单摘要。

ts
const agent = await qoni.genauth.agents.create({
  displayName: 'Alex 的购物助理',
  description: '替 Alex 比价、下单,并发送订单通知',
  channels: {
    email: '<用户自定义的邮箱名>', // 例如 alex-shopper;Qoni 补上域名,生成可收可发的专属邮箱
  },
})
console.log(agent.id, agent.email)
  • agent.email 属于 Agent 本身,不是用户的邮箱。Agent 注册网站、接收验证码时用的是自己的身份,用户的个人邮箱不会外泄。
  • 邮箱名由用户在你的 App 里填写。名字已被占用时创建会失败,App 应提示用户换一个。
  • 邮箱能否被读取或用于发送,由第 ③ 步的 Scope 决定。创建 Agent 本身不授予任何权限。

检查点:拿到了 agent.id,agent.email 由用户自定义的邮箱名和 Qoni 分配的域名组成。

② 绑定用户与 Agent ​

这一步声明"这个 Agent 归这位用户所有"。绑定使用用户登录 GenAuth 后的 Access Token,GenAuth 从中识别用户本人。

ts
const binding = await qoni.genauth.agents.bindUser({
  agentId: agent.id,
  userAccessToken, // 证明用户本人在场
  relation: 'owner',
})
console.log(binding.bindingId, binding.userId === userId)
  • 绑定要求用户的登录态,而不是一个裸用户 ID。这样,你的 App 无法在用户不在场时,把别人的 Identity 绑定给某个 Agent。
  • 一个 Agent 只有一位 owner。只有已绑定的 Agent,才能在第 ③ 步申请这位用户的委托。

检查点:binding.userId 等于用户的 GenAuth 用户 ID。

③ 生成委托 Token 并配置 Scope ​

这一步为 Agent 取得这位用户的委托 Token。Agent 已在第 ② 步凭用户的登录态绑定给用户本人,所以 App 在服务端用静默委托直接取得,不需要跳转授权页。

ts
const grant = await qoni.genauth.delegateAgent({
  mode: 'silent',
  userId, // 用户的 GenAuth 用户 ID
  agentId: agent.id,
  scopes: ['*'], // 申请 AccessKey 权限策略允许的全部 Scope
  expiresIn: '30m',
})
console.log(grant.grantId, grant.auditId)
  • 示例用 * 申请当前 AccessKey 权限策略允许的全部 Scope。这个场景会用到读取和补全用户 Profile、代填信用卡、收发 Agent 邮箱,以及执行 DoAnything 和站点登录等权限。生产环境建议只申请任务需要的 Scope。全部可申请的 Scope 和每个 Scope 放行的操作,见 Qoni SDK:可申请的 Scopes。
  • 静默委托不经过授权页,响应里直接带回 grant.token。前提是 Agent 已在第 ② 步绑定给这位用户:只有绑定过的 Agent 才能取得这位用户的委托,userId 必须是用户本人的 GenAuth 用户 ID,不能替别人申请。
  • * 会展开成当前 AccessKey 权限策略允许的全部 Scope。需要把实际授予的 Scope 写进审计记录时,再调用 qoni.genauth.introspectDelegationToken({ token: grant.token }) 查询;跑任务本身不需要这一步。
  • AccessKey 权限策略不允许 user.payment:use 时,Agent 不会代填信用卡:到了支付页,它会请用户接管浏览器自己完成支付。

检查点:

  • 拿到了 grant.token,有效期 30 分钟。
  • 服务端记录了 grant.grantId 和 grant.auditId,用于之后的 audit。

④ 用 DoAnything 发起任务并处理交互 ​

这一步把用户的那句话交给 Web Agent 执行。DoAnything 在受控浏览器里打开亚马逊、搜索、比价、结账;需要用户出手时,它会暂停并发起一次交互,等你的 App 把用户的决定提交回来。

交互按"需要用户做什么"分成五种类型,和具体业务无关:

类型含义可用的 SDK 方法
site_login登录:请用户在受控浏览器里亲自登录某个网站openLogin()、confirmSignedIn()
take_control人类接管:登录之外、需要用户亲手操作浏览器的任何情况,例如验证码connectControl()、refreshControl()、releaseControl()
ask_user问询用户:问一个问题,按 answerType 和 options 收集结构化回答answer()、skip()
confirmation确认:只有允许或拒绝两种结果confirm()、reject()
fill_form信息补全:按 fields 渲染通用表单,让用户填写结构化数据submit()、skip()

这个场景依次会遇到这五种交互,其中信息补全只在第一次下单时出现:

  1. Agent在受控浏览器中打开 amazon.com
  2. site_login登录等待用户
    登录亚马逊
    用户在实时浏览器里亲自登录网站,Agent 和你的 App 都看不到密码。
    SDK 方法handle.openLogin()handle.confirmSignedIn()
  3. Agent搜索 “MacBook Neo 512GB”,发现同一型号有两种颜色
  4. ask_user问询用户等待用户
    选择颜色
    一个问题,回答是结构化数据。这里 answerType 是 single_choice,options 列出两种颜色。
    SDK 方法handle.answer()handle.skip()
  5. Agent只保留所选颜色,按“商品价 + 运费”比价,把最低价加入购物车,进入结账时遇到验证码
  6. take_control人类接管等待用户
    处理验证码
    登录之外需要用户亲手操作浏览器的情况。邮件验证码发到 Agent 自己的邮箱,不需要接管。
    SDK 方法handle.connectControl()handle.releaseControl()
  7. Agent进入结账,发现 Profile 里还没有收货地址和信用卡
  8. fill_form信息补全等待用户仅首次下单
    补全收货地址和信用卡
    按 fields 渲染的通用表单。带 profileField 的字段保存到 Profile,之后的订单不再询问。
    SDK 方法handle.submit()handle.skip()
  9. Agent把资料保存到用户 Profile,填好收货地址,整理订单摘要
  10. confirmation确认下单闸门
    确认支付并下单
    只有允许或拒绝。用户允许后,Agent 才代填信用卡并点击「下单」。
    SDK 方法handle.confirm()handle.reject()
  11. Agent从 GenAuth 取卡代填,提交订单,用 Agent 自己的邮箱发送订单摘要

先用委托 Token 启动任务:

ts
const run = await qoni.doAnything.run({
  token: grant.token,
  prompt: `
    打开 https://www.amazon.com ,搜索 "MacBook Neo 512GB"。
    同一型号有多种颜色时,先问我要哪种。
    只比较型号、容量和颜色完全一致的商品,按"商品价 + 运费"找出总价最低的一个。
    使用我 Profile 里的收货地址和信用卡结账;缺少时请我补全。
    提交订单前,请我确认商品、总价和支付方式。
    下单成功后,用你的邮箱把订单号、总价和预计送达时间发到我的邮箱。
    只返回 JSON:{ orderId, title, color, seller, totalPrice, currency, deliveryEstimate }。
  `,
  capture: { screenshots: true },
})

再在 run.wait() 中按类型处理交互。处理函数的骨架对任何业务都通用:五个分支对应五种类型,每个分支用到的 SDK 方法都列了出来。下面的注释用这个场景举例:本场景 说明这一次的 payload 长什么样、会出现几次,→ 标出你的 App 要做的事,比如画界面、等用户操作。换成你自己的业务时,保留骨架,替换这两类注释对应的部分即可。

ts
const handled = new Set<string>() // 已经处理过的交互 ID

async function handleInteraction(handle: InteractionHandle) {
  const request = handle.interaction
  // 同一个交互在创建、状态更新和事件回放时都会触发回调:只处理待处理的,并按 ID 去重
  if (request.status !== 'pending' || handled.has(request.id)) return
  handled.add(request.id)

  // 分支按本场景出现的顺序排列
  switch (request.type) {
    // 登录:请用户亲自登录 request.payload.sites 里的网站
    // 本场景:每个需要登录的网站一次,这里只有亚马逊;登录状态保存后,之后的任务不再出现。sites 是
    //   [{ siteId: 'amazon', displayName: 'Amazon', loginUrl: 'https://www.amazon.com/ap/signin' }]
    case 'site_login': {
      const login = await handle.openLogin() // 打开受控登录浏览器,返回实时画面地址 login.liveUrl
      // → 在你的 App 里打开 login.liveUrl,用户在实时画面里输入亚马逊账号和密码,完成后点「我已登录」
      await handle.confirmSignedIn() // 用户点「我已登录」后调用:DoAnything 保存登录状态,复查后继续搜索
      break
    }

    // 问询用户:request.payload.question 是问题,answerType 和 options 描述答案的结构
    // 本场景:Agent 每遇到一个拿不准的选择就问一次。这里是颜色:question 是「要哪种颜色?」,
    //   answerType 是 'single_choice',options 是
    //   [{ value: 'space-gray', label: '深空灰' }, { value: 'silver', label: '银色' }]
    case 'ask_user': {
      // → 你的 App 按 answerType 画成单选,用户选中深空灰后,提交这个选项的 value
      await handle.answer('space-gray')
      // await handle.skip() // 用户不想选,Agent 按自己的判断继续
      break
    }

    // 人类接管:登录之外需要用户亲手操作浏览器,request.payload.reason 说明原因
    // 本场景:每遇到一次必须由人处理的验证就接管一次。这里是结账时的图形验证码,
    //   reason 是 'captcha_required'
    case 'take_control': {
      await handle.connectControl() // 接管浏览器
      // → 在你的 App 里打开 request.payload.liveUrl,用户亲手完成验证码
      // await handle.refreshControl() // 实时画面过期时刷新
      // 用户点「已完成」后调用:把浏览器交还给 Agent,继续结账
      await handle.releaseControl()
      break
    }

    // 信息补全:按 request.payload.fields 渲染表单,提交时以 field.name 为键
    // 本场景:只在 Profile 缺少结账资料时出现,通常是第一次下单。fields 是
    //   [{ name: 'shippingAddress', label: '收货地址', type: 'address',
    //      required: true, profileField: 'shippingAddress' },
    //    { name: 'paymentCard', label: '信用卡', type: 'payment_card',
    //      required: true, sensitive: true, profileField: 'paymentCard' }]
    case 'fill_form': {
      // → 你的 App 按 fields 渲染地址和信用卡两个输入项,用户填好后提交
      // 带 profileField 的字段写入用户 Profile,之后的订单不再询问
      await handle.submit({
        shippingAddress: {
          line1: '500 Howard St', city: 'San Francisco', state: 'CA', postalCode: '94105', country: 'US',
        },
        paymentCard: { number: '<card-number>', expMonth: 12, expYear: 2029, cvc: '<cvc>', holderName: 'Alex Chen' },
      })
      // await handle.skip() // 用户放弃填写:Agent 不会继续结账
      break
    }

    // 确认:request.payload.summary 写明需要用户同意的操作,结果只有允许或拒绝
    // 本场景:每笔订单下单前一次;确认之后价格变了,Agent 会重新发起一次。summary 是
    //   「在 Amazon.com 用 Visa •••• 4242 支付 $1,299.00,购买 Apple MacBook Neo 512GB 深空灰」
    case 'confirmation': {
      // → 你的 App 展示 summary,以及「允许」「拒绝」两个按钮
      await handle.confirm() // 用户允许:Agent 从 GenAuth 取卡代填,然后下单
      // await handle.reject() // 用户拒绝:Agent 不下单,任务结束
      break
    }
  }
}

const order = await run.wait({ onInteraction: handleInteraction })
console.log(order.status, order.output)

同一类交互会出现多次:

  • 每一次交互都有自己的 request.id 和 payload。同一种类型可以在一次任务里出现多次:每个需要登录的网站各一次 site_login,每个拿不准的选择各一次 ask_user,每次图形验证码各一次 take_control。本场景的颜色、验证码只是其中一例;换一个商品,Agent 可能还会问配置或卖家,也可能在登录时就遇到验证码。
  • 交互按顺序一个一个出现:任务在交互处暂停,用户处理完,Agent 才继续执行,下一次交互才会出现。
  • 同一个交互也会多次触发回调:创建、状态从 pending 变成 active 和 resolved,以及断线重连后的事件回放,都会再次调用 onInteraction。所以处理函数开头只处理 pending 状态,并记下处理过的 request.id,避免重复弹窗或重复提交。你的界面同样应该按 request.id 更新同一张卡片,而不是每次回调新建一张。

几点说明:

  • 密码和卡号不经过 Agent:登录和验证码都在实时浏览器里由用户亲手完成。信用卡在 fill_form 中是敏感字段,GenAuth 加密存储,之后任何读取都只返回卡组织和后四位;卡号只在 submit() 这一次请求里经过你的服务端,不要写进自己的数据库、日志或错误上报。用户在 confirmation 中允许后,Web Agent 运行时凭 user.payment:use 从 GenAuth 取卡,直接填进受控浏览器的支付表单,卡号和 CVC 不会进入模型上下文、事件、截图或日志。
  • 写入 Profile 需要授权:profileField 字段写回 Profile 需要委托包含 user.profile:write;同一位用户再次下单时,不会再出现 fill_form。
  • 验证码不一定要接管:网站把验证码发到 Agent 邮箱时,Agent 凭 agent.mail:read 自己读取,不会发起 take_control;只有图形验证码这类必须由人处理的情况才会接管。
  • 等待与过期:交互需要用户操作,可能要等几分钟。处理函数应该在用户完成操作后才调用对应的方法;交互过期时,SDK 方法会抛出错误,Agent 会按任务描述决定是否重试。

检查结果 ​

任务结束后,确认以下几点:

  • order.status 为 succeeded,order.output 是用户所选颜色中总价最低那件商品的订单:

    json
    {
      "orderId": "112-4839201-5528261",
      "title": "Apple MacBook Neo 512GB",
      "color": "Space Gray",
      "seller": "Amazon.com",
      "totalPrice": 1299,
      "currency": "USD",
      "deliveryEstimate": "2026-10-09"
    }
  • 用户的邮箱收到了一封由 agent.email 发出的订单摘要。

  • 用户在第 ④ 步依次处理了登录、问询、接管、信息补全和确认;拒绝任何一步,任务都不会越过这一步。

  • 同一位用户再次下单时,不会再出现信息补全,Agent 直接使用 Profile 里的地址和信用卡。

  • 服务端保存了 grant.grantId、grant.auditId 和 order.runId,可以在 Qoni Console 中把这笔订单追溯到用户的哪一次授权。

  • 完整卡号没有出现在 App 日志、任务事件、截图和产物中。

下一步 ​