创建日期:2026-10-20 | 最近更新:2026-10-20 基准:A2UI v0.9.1,
common_types.json的Action/CheckRule/FunctionCall,client_to_server.json,以及catalogs/basic/catalog.json的 14 个函数。完整消息示例已过 schema 校验;文中明确标为「错误写法」的片段除外。
动作、校验与函数:把用户的点击送回去
前面几篇讲的是「Agent 怎么把 UI 发下来」。这一篇讲反方向:用户操作之后,什么被发回服务端、什么留在本地、什么会被拦截。
A2UI 把交互拆成三种,边界非常清楚:
| 机制 | 执行位置 | 是否通知 Agent | 典型用途 |
|---|---|---|---|
action.event | 服务端 | 是 | 提交表单、确认订单 |
action.functionCall | 客户端 | 否 | 打开链接、本地计算 |
checks | 客户端 | 否 | 按钮置灰、输入校验提示 |
1. Action:事件还是本地函数
交互组件(典型是 Button)的 action 是一个二选一的结构:
{
"action": {
"event": {
"name": "submit_booking",
"context": { "itemId": "123" }
}
}
}
{
"action": {
"functionCall": {
"call": "openUrl",
"args": { "url": "https://a2ui.org/help" },
"returnType": "void"
}
}
}
Action 的 schema 是 oneOf:
- 有
event:发一个服务端事件; - 有
functionCall:执行一个客户端函数; - 两个都有、或都没有:校验失败。
1.1 服务端事件
{
"id": "submit_btn",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"action": {
"event": {
"name": "confirm_booking",
"context": {
"name": { "path": "/booking/name" },
"guests": { "path": "/booking/guests" }
}
}
}
}
event.name 是 Agent 用来 switch 的稳定标识。context 是「从数据模型里挑出来的一个视图」:只带这次事件真正需要的字段,而不是整个模型。
规范对 context 有一条很实用的建议:
Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.
也就是说:
- 需要用户当前输入 → 用
{ "path": "..." }; - 固定的表单 ID、组件类型之类的静态值 → 直接写字面量。
1.2 本地函数
{
"id": "help_btn",
"component": "Button",
"child": "help_label",
"action": {
"functionCall": {
"call": "openUrl",
"args": { "url": "https://a2ui.org/help" },
"returnType": "void"
}
}
}
本地函数不会通知 Agent。用户点开帮助链接,服务端不会收到任何消息。这是刻意的:导航、滚动、本地计算这类行为不需要一次网络往返。
openUrl 的实现有强制安全要求:只允许 http: / https:,拒绝 javascript:、data: 等 scheme;新窗口要带 noopener,noreferrer。这是 Renderer 的职责,但协议把它写进了实现指南,因为它是 XSS 的入口。
2. 客户端上行:action 消息的五个必备字段
当用户触发 event 时,客户端构造一条 action 消息:
{
"version": "v0.9.1",
"action": {
"name": "confirm_booking",
"surfaceId": "booking",
"sourceComponentId": "submit_btn",
"timestamp": "2026-10-20T10:00:00Z",
"context": {
"name": "Alice",
"terms": true
}
}
}
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 来自组件的 action.event.name |
surfaceId | 是 | 事件发生在哪块 surface |
sourceComponentId | 是 | 触发事件的组件 ID |
timestamp | 是 | ISO 8601 时间戳 |
context | 是 | 组件 action.event.context 解析后的对象;可以是空对象 {} |
context 是必填的,即使没有内容也要写 {}。这一点 schema 里是 required,不是可选。
解析时机
规范要求客户端的本地数据模型写入是同步的。所以点击发生时的顺序是:
- 用户最后一次输入已经写入本地模型;
- 点击按钮;
- 客户端解析
context里的所有path; - 打包
action消息; - 发送。
不存在「输入还没落库就提交」的竞态。这也是为什么客户端不需要对输入做网络防抖。
3. checks:在发出去之前先拦一道
Button、TextField、CheckBox、ChoicePicker、Slider、DateTimeInput 都支持 checks。它的形状是:
{
"checks": [
{
"condition": {
"call": "required",
"args": { "value": { "path": "/booking/terms" } },
"returnType": "boolean"
},
"message": "Please accept the terms"
}
]
}
注意两层结构:
- 外层是
CheckRule:{ condition, message }; - 内层
condition是一个返回 boolean 的FunctionCall。
condition 可以是简单函数,也可以用 and / or / not 组合:
{
"condition": {
"call": "and",
"args": {
"values": [
{
"call": "required",
"args": { "value": { "path": "/form/terms" } },
"returnType": "boolean"
},
{
"call": "or",
"args": {
"values": [
{
"call": "required",
"args": { "value": { "path": "/form/email" } },
"returnType": "boolean"
},
{
"call": "required",
"args": { "value": { "path": "/form/phone" } },
"returnType": "boolean"
}
]
},
"returnType": "boolean"
}
]
},
"returnType": "boolean"
},
"message": "Accept terms and provide email or phone"
}
行为语义:
- Button:任意一个 check 失败 → 按钮自动置灰,不会发出 action。
- 输入组件:失败的 check 会产生对应的
message,在组件渲染时展示。
校验的边界
规范明确说:checks 是 UX 层的,不是数据完整性层:
Validation checks are designed to manage UI State (User Experience) by preventing invalid interactions before they happen. They are not a replacement for Data Integrity checks, which must still be performed on the agent.
所以服务端收到 action 后必须重新校验。客户端的置灰只是减少无效请求,不是安全边界。
一个必须记住的坑:checks 不是 FunctionCall 数组
规范正文里的 Contact Form 示例写成了:
{
"checks": [
{
"call": "required",
"args": { "value": { "path": "/booking/terms" } },
"message": "Please accept the terms"
}
]
}
这不符合 common_types.json 的 CheckRule。正确写法必须把函数调用包进 condition:
{
"checks": [
{
"condition": {
"call": "required",
"args": { "value": { "path": "/booking/terms" } }
},
"message": "Please accept the terms"
}
]
}
仓库自己的测试样例(test/cases/checkable_components.json、button_checks.json)用的都是 condition 结构。再次说明:以 schema 和测试样例为准。
4. 函数:Catalog 里的可调用白名单
FunctionCall 的结构:
{
"call": "formatDate",
"args": {
"value": "2026-10-20T00:00:00Z",
"format": "yyyy-MM-dd"
},
"returnType": "string"
}
| 字段 | 必填 | 说明 |
|---|---|---|
call | 是 | 函数名,必须存在于当前 Catalog 的 functions 里 |
args | 否 | 命名参数对象;参数值可以是字面量、DataBinding 或嵌套 FunctionCall |
returnType | 否 | string / number / boolean / array / object / any / void;schema 标注默认 boolean |
FunctionCall 的 schema 里有一个 oneOf:
{
"oneOf": [{ "$ref": "catalog.json#/$defs/anyFunction" }]
}
这意味着未知函数直接校验失败。不是「运行时报个警告」,而是生成侧就应该被拦住。这也是 A2UI 「不执行任意代码」的一部分:Agent 只能调用白名单里的名字。
4.1 基础 Catalog 的 14 个函数
| 函数 | 参数 | 返回 | 作用 |
|---|---|---|---|
required | value | boolean | 非 null、非 undefined、非空串、非空数组 |
regex | value、pattern | boolean | 字符串匹配正则 |
length | value、min 或 max(至少一个) | boolean | 字符串长度范围 |
numeric | value、min 或 max(至少一个) | boolean | 数值范围 |
email | value | boolean | 邮箱格式 |
formatString | value | string | 字符串插值,见下一节 |
formatNumber | value、decimals?、grouping? | string | 数字格式化 |
formatCurrency | value、currency、decimals?、grouping? | string | 货币格式化 |
formatDate | value、format | string | 按 Unicode TR35 模式格式化日期 |
pluralize | value、other(必需),以及 zero / one / two / few / many | string | 按 CLDR 复数类别选词 |
openUrl | url | void | 打开链接(仅 http/https) |
and | values(至少 2 个) | boolean | 逻辑与 |
or | values(至少 2 个) | boolean | 逻辑或 |
not | value | boolean | 逻辑非 |
这些函数既可以出现在 checks.condition 里,也可以出现在任何 Dynamic* 属性里(只要返回类型匹配)。
4.2 returnType 的一个 schema 宽松处
规范说 returnType 默认是 boolean。但 JSON Schema 的 default 只是注解,校验器不会替你填。更微妙的是:DynamicString / DynamicNumber / DynamicBoolean 在检查函数返回值时,只写了:
{ "properties": { "returnType": { "const": "string" } } }
没有 required: ["returnType"]。所以一个不写 returnType 的 FunctionCall 在类型槽里会「空过」校验。
我用 Draft 2020-12 实测过:
formatString放进checks.condition,returnType: "string"→ INVALID(正确,校验器发现它不是 boolean);- 同一个调用删掉
returnType→ VALID(校验器没东西可比较)。
结论:生产代码应当显式写 returnType。不写不会让 schema 报错,但会让类型检查失去意义,错误会推迟到运行时。
5. formatString:在字符串里嵌表达式
formatString 是基础 Catalog 里最复杂、也最常用的函数。它支持在字符串里嵌入:
| 语法 | 含义 |
|---|---|
${/user/name} | 绝对路径 |
${firstName} | 相对路径(集合作用域内) |
${now()} | 无参函数调用 |
${formatDate(value:${/currentDate}, format:'yyyy-MM-dd')} | 具名参数的函数调用 |
${upper(${now()})} | 嵌套函数 |
\${ | 字面量 ${ |
示例:
{
"id": "welcome",
"component": "Text",
"text": {
"call": "formatString",
"args": {
"value": "Hello, ${/user/firstName}! Welcome back to ${/appName}."
},
"returnType": "string"
}
}
非字符串值的转换规则和普通绑定一致:数字/布尔用标准表示,null / undefined 变空串,对象/数组 JSON 字符串化。
formatString 的实现需要手动解析并订阅嵌套路径,不能靠普通的「参数变了再重算」;这是 Renderer 的事,但作为 Agent 侧的作者,你要知道:字符串里的路径也是数据依赖,数据一变,整段文本要重算。
6. 校验闭环:prompt → generate → validate
0.9 是 prompt-first,所以协议把「生成后校验」写成了正式流程:
- Prompt:把目标 UI、Catalog 的 JSON Schema、若干合法示例塞进提示词;
- Generate:让模型输出 A2UI JSON;
- Validate:用 schema 校验;
- 失败:把标准格式的错误喂回去,让模型自纠;
- 成功:发给客户端渲染。
标准错误格式
客户端(或代客户端校验的系统)向上报告时,用 client_to_server.json 的 error 消息:
{
"version": "v0.9.1",
"error": {
"code": "VALIDATION_FAILED",
"surfaceId": "booking",
"path": "/components/0/text",
"message": "Expected string, got integer"
}
}
VALIDATION_FAILED 的四个字段都是必需的:code、surfaceId、path、message。path 是 JSON Pointer,指向出错字段,例如 /components/0/text、/components/2/checks/0。
其他错误用通用格式:
{
"version": "v0.9.1",
"error": {
"code": "RENDER_FAILED",
"surfaceId": "booking",
"message": "Component 'Chart' is not implemented"
}
}
通用格式要求 code、surfaceId、message,允许额外字段;code 不能是 VALIDATION_FAILED。
两阶段校验
规范建议「防御在深度」:
- Agent 侧,发送前:拿 Catalog schema 校验生成的 JSON。失败就重试、修,或者降级成纯文本。
- 客户端侧,接收后:用本地 Catalog 再校验一遍。防的是版本错配、被篡改的 Agent 输出。失败就发
VALIDATION_FAILED回去。
再加一层优雅降级:schema 过了,但某个组件渲染器没实现,就渲染 fallback 或跳过该节点,不要让整个 surface 崩掉。
7. Capabilities:谁支持哪些 Catalog
这是交互闭环的前置条件。客户端必须先告诉 Agent「我能渲染什么」,Agent 才能决定「发什么」。
客户端在 metadata 里带:
{
"a2uiClientCapabilities": {
"v0.9": {
"supportedCatalogIds": [
"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
]
}
}
}
服务端能力(通常在 A2A AgentCard 的 extension params 里):
{
"v0.9": {
"supportedCatalogIds": [
"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
],
"acceptsInlineCatalogs": false
}
}
两点注意:
- schema 里外层键固定是
"v0.9",即使消息版本是 v0.9.1; inlineCatalogs只有在服务端acceptsInlineCatalogs: true时才应发送。
这些对象不是 A2UI 的一等消息,而是传输层 metadata(A2A、AG-UI、MCP 初始化等各自决定放哪)。协议只规定形状,不规定信封。
8. 把闭环串起来:一次预订
下面这条链覆盖了「下发 → 输入 → 点击 → 回传 → 更新」。五条消息都过了 schema 校验。
① 创建 surface,开启数据模型同步
{
"version": "v0.9.1",
"createSurface": {
"surfaceId": "booking",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"sendDataModel": true
}
}
② 下发组件:输入框、勾选框、状态文本、提交按钮
{
"version": "v0.9.1",
"updateComponents": {
"surfaceId": "booking",
"components": [
{
"id": "root",
"component": "Column",
"children": ["name_field", "terms_check", "status_text", "submit_label", "submit_btn"]
},
{
"id": "name_field",
"component": "TextField",
"label": "Name",
"value": { "path": "/booking/name" },
"variant": "shortText",
"checks": [
{
"condition": {
"call": "length",
"args": {
"value": { "path": "/booking/name" },
"min": 2
},
"returnType": "boolean"
},
"message": "Name must be at least 2 characters"
}
]
},
{
"id": "terms_check",
"component": "CheckBox",
"label": "I accept the terms",
"value": { "path": "/booking/terms" }
},
{
"id": "status_text",
"component": "Text",
"text": { "path": "/booking/status" },
"variant": "caption"
},
{
"id": "submit_label",
"component": "Text",
"text": "Confirm"
},
{
"id": "submit_btn",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"checks": [
{
"condition": {
"call": "required",
"args": { "value": { "path": "/booking/terms" } },
"returnType": "boolean"
},
"message": "Please accept the terms"
}
],
"action": {
"event": {
"name": "confirm_booking",
"context": {
"name": { "path": "/booking/name" },
"terms": { "path": "/booking/terms" }
}
}
}
}
]
}
}
③ 初始化数据
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "booking",
"value": {
"booking": {
"name": "",
"terms": false,
"status": "Draft"
}
}
}
}
此时按钮是灰的,因为 terms 是 false,required 失败。
④ 用户输入并提交
用户输入 Alice,勾选条款。本地模型立刻变成:
{ "name": "Alice", "terms": true, "status": "Draft" }
按钮的 check 通过。点击后,客户端解析 context,发出:
{
"version": "v0.9.1",
"action": {
"name": "confirm_booking",
"surfaceId": "booking",
"sourceComponentId": "submit_btn",
"timestamp": "2026-10-20T10:00:00Z",
"context": {
"name": "Alice",
"terms": true
}
}
}
如果开启了 sendDataModel,这条消息的 transport metadata 里还会带完整模型:
{
"version": "v0.9.1",
"surfaces": {
"booking": {
"booking": { "name": "Alice", "terms": true, "status": "Draft" }
}
}
}
⑤ Agent 校验并回写
服务端重新校验业务规则(checks 只是 UX),然后更新状态:
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "booking",
"path": "/booking/status",
"value": "Confirmed"
}
}
status_text 立刻从 Draft 变成 Confirmed。整条链没有重发组件树。
9. 本篇自检清单
-
action只包含event或functionCall之一。 -
event.name稳定,能被 Agent 直接switch。 -
event.context只带需要的字段;静态值写字面量,动态值写{ path }。 - 上行
action的五个字段(含context)齐全。 -
checks使用{ condition, message },不是裸FunctionCall。 - 每个 check 的
condition返回 boolean,并显式写returnType。 - 服务端对
action重新做数据完整性校验。 - 本地函数不依赖网络;
openUrl只允许 http/https。 - 未知函数名会被 schema 拦住,不要指望运行时兜底。
- 校验失败时按
VALIDATION_FAILED的四个字段回报,方便模型自纠。 -
a2uiClientCapabilities使用 schema 要求的"v0.9"外层键。
10. 协议全景回顾
到这里,0.9 的核心已经闭合了:
Catalog(词汇表 + 安全白名单)
↓
createSurface(建画布、锁 catalog、可选同步数据模型)
↓
updateComponents(扁平邻接表,root 出现即建树)
↓
updateDataModel(JSON Pointer,细粒度 upsert/delete)
↓
用户输入(本地双向绑定,同步写模型)
↓
action.event(解析 context,发回服务端)
↓
Agent 校验、更新、再下发
↓
deleteSurface(移除)
四句话总结 A2UI 0.9:
- UI 是数据:组件树是扁平 JSON,不是可执行代码。
- 状态是路径:组件通过 JSON Pointer 绑定到每个 surface 的数据模型。
- 交互分两条路:通知 Agent 用
event,纯本地行为用functionCall,拦在发送前用checks。 - 安全靠白名单:组件、函数、主题都必须先在 Catalog 里声明;运行时不去下载、不执行任意代码。
回到系列首页:A2UI 入门。如果你要接着做实现,建议的下一步不是写渲染器,而是先把 Catalog schema + 校验器 + 一个最小 Agent 生成循环跑通:只要能让模型稳定产出能过 schema 的 JSONL,A2UI 的最难部分就已经过去了。