Quickstart:让 Agent 替用户下单
本页用一个"替用户在亚马逊比价下单"的场景,说明面向个人用户的 App 如何按顺序调用 Qoni SDK:为已经登录的用户创建一个有自己邮箱的 Agent,把两者绑定,委托有限的权限,再让 Agent 通过 Web Agent 完成购物。遇到需要用户出手的时刻,Agent 会暂停并发起一次交互,把决定交还给用户。
迈向智能体 Web
Qoni 的目标是让 Agent 成为 Web 世界的一等公民。模型负责思考,Web 是 Agent 行动的地方,Qoni 是两者之间的那一层。下图是这个分层:人把有范围、有期限的权限委托给 Agent,每一步都可审计;Agent 在云端托管浏览器里替人操作网页,登录状态在任务之间复用;做完之后,把结果交还给人。
Qoni 是面向个人 AI 智能体的托管式基础设施,为 Agent 提供身份、行动和记忆三样东西:GenAuth 让 Agent 拥有绑定真人的独立身份,授权有范围、有期限,每一步可审计;Web Agent 让 Agent 在云端托管浏览器里操作真实网页,没有 API 也能干活;GUMem 记住用户说过什么、做过什么,不用教第二遍。三者通过同一个 Qoni SDK 接入。
场景
用户在你的 App 里对购物助理说了一句话:
帮我打开亚马逊电商,搜索 MacBook Neo 512GB 版本,找到最低价的,然后帮我下单。
把这句话变成一笔真实的订单,Agent 需要四样东西:
| 需要什么 | 为什么需要 | 对应步骤 |
|---|---|---|
| Agent 自己的 Identity | Agent 有独立的身份和邮箱,能接收验证码、发送通知,不冒用用户的账号 | ① |
| 用户与 Agent 的绑定 | 确认这个 Agent 归这位用户所有,别人无法借用 | ② |
| 一张委托 Token | 限定 Agent 能读什么、能做什么、多久内有效 | ③ |
| 能与用户协作的执行 | 打开网页、比价、结账;需要用户登录、回答、接管、补全信息或确认时请用户出手 | ④ |
用户本人的 Identity 不在这张表里:用户已经通过 GenAuth 注册和登录了你的 App,做法见 用户注册与登录。
整体流程
下图是整体逻辑:上半部分是开通流程,每位用户只走一次;下半部分是每次下单都会走的任务流程。
开通只做最少的事:为已登录的用户创建 Agent、完成绑定,再取得委托 Token。委托 Token 过期后,只需重新执行第 ③ 步。收货地址和信用卡不需要提前填写:第一次下单走到结账时,Agent 会请用户补全,并保存到 Profile 供之后的订单使用。
前置条件
- Node.js 20.11 或更高版本。
- 一组 Qoni AccessKey ID 和 Secret。这组 AccessKey 的权限策略需要允许本页申请的全部 Scope。
- 用户已经通过 GenAuth 注册和登录你的 App,你的服务端保存着用户 ID 和登录态(Access Token)。做法见 用户注册与登录。
- 一个能把请求推送到用户面前的界面,比如网页弹窗或手机推送。第 ④ 步会用它处理各类交互。
安装 SDK,并在 App 服务端初始化客户端:
npm install @qoniai/qoniimport { 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 也用它给用户发送订单摘要。
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 从中识别用户本人。
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 在服务端用静默委托直接取得,不需要跳转授权页。
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() |
这个场景依次会遇到这五种交互,其中信息补全只在第一次下单时出现:
- Agent在受控浏览器中打开 amazon.com
- 登录亚马逊用户在实时浏览器里亲自登录网站,Agent 和你的 App 都看不到密码。SDK 方法handle.openLogin()handle.confirmSignedIn()
- Agent搜索 “MacBook Neo 512GB”,发现同一型号有两种颜色
- 选择颜色一个问题,回答是结构化数据。这里 answerType 是 single_choice,options 列出两种颜色。SDK 方法handle.answer()handle.skip()
- Agent只保留所选颜色,按“商品价 + 运费”比价,把最低价加入购物车,进入结账时遇到验证码
- 处理验证码登录之外需要用户亲手操作浏览器的情况。邮件验证码发到 Agent 自己的邮箱,不需要接管。SDK 方法handle.connectControl()handle.releaseControl()
- Agent进入结账,发现 Profile 里还没有收货地址和信用卡
- 补全收货地址和信用卡按 fields 渲染的通用表单。带 profileField 的字段保存到 Profile,之后的订单不再询问。SDK 方法handle.submit()handle.skip()
- Agent把资料保存到用户 Profile,填好收货地址,整理订单摘要
- 确认支付并下单只有允许或拒绝。用户允许后,Agent 才代填信用卡并点击「下单」。SDK 方法handle.confirm()handle.reject()
- Agent从 GenAuth 取卡代填,提交订单,用 Agent 自己的邮箱发送订单摘要
先用委托 Token 启动任务:
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 要做的事,比如画界面、等用户操作。换成你自己的业务时,保留骨架,替换这两类注释对应的部分即可。
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 日志、任务事件、截图和产物中。
下一步
- 阅读 Enterprise Agent,了解企业场景里如何用同一套交互类型,让 Agent 用员工本人的账号办事。
- 阅读 用户注册与登录,了解用户如何通过 GenAuth 托管页注册和登录你的 App。
- 阅读 Agent 身份模型 和 委托令牌收窄,了解 Agent Identity 与委托权限的设计。