A2A Agent Card 实现指南:构建、校验与签名

A2A Agent Card 实现指南:构建、校验与签名

这篇指南带您从一个空文件,走到一张可以放心发布的 Agent Card。它假定您已经知道 Agent Card 是什么;如果还不清楚,请先读 Agent Card 。逐字段的解释仍然留在概念页。这一页补上概念页没有的部分:填写卡片时要做的决策、可以直接运行的校验程序,以及从运行时错误反推卡片问题的对照表。

所有内容都对照 A2A v1.0.1 核对过:该标签下的 a2a.proto、规范本身,以及规范自带的示例卡。规范与 proto 不一致时,以 proto 为准(规范第 1.4 节)。

路线

  1. 声明接口:客户端在哪里、以什么方式访问智能体。
  2. 声明能力:您承诺了什么,以及没有承诺时要返回的错误。
  3. 描述技能。
  4. 声明安全方案。
  5. 校验:反复运行校验程序,直到输出 OK: no findings。
  6. 签名(可选),然后发布并设置缓存。

签名放在最后,因为签名覆盖的是最终内容:签名之后再改任何内容,签名都会失效。

1. 声明接口

supportedInterfaces 是一个列表,每一项包含 url、protocolBinding 和 protocolVersion,三个字段都必填。列表按顺序读取,所以顺序本身就是契约的一部分。

您的情况声明方式规则与出处
一个端点、一种传输一项这三个字段在 a2a.proto 中标记为 REQUIRED
多种传输每个「协议绑定 + URL」一项,最优先的放最前列表应当按优先顺序声明所有支持的组合,第一项是首选接口(第 8.3.1 节)
多种传输共用一个端点每种绑定各写一项,url 重复使用URL 可以重复使用(第 8.3.1 节)
一个端点后面有多个智能体或租户在该项上设置 tenant客户端必须把它复制到每条请求消息中;协议不定义其格式,含义由服务器决定(AgentInterface.tenant,第 8.3.2 节)。见多租户
JSONRPC、GRPC、HTTP+JSON 之外的绑定用 URI 作为 protocolBinding自定义绑定应当用 URI 标识,绑定发生破坏性变更时必须使用新的 URI(第 5.8 节)。见自定义协议绑定
本地开发普通的 http:// URLproto 要求生产环境使用绝对 HTTPS URL,所以切换之前校验程序会给出警告

2. 声明能力

能力声明是一份契约。能力为 false 或缺失,而客户端仍然使用它时,智能体必须返回特定的错误(第 3.3.4 节)。只对您已经实现的能力声明 true。

能力声明 true 的前提为 false 或缺失时,智能体必须返回
streamingSendStreamingMessage 和 SubscribeToTask 可用两者都返回 UnsupportedOperationError
pushNotifications推送通知配置的创建、获取、列出、删除可用PushNotificationNotSupportedError
extendedAgentCard已认证的 GetExtendedAgentCard 能返回已配置的卡片UnsupportedOperationError;如果声明了 true 却没有配置扩展卡片,则返回 ExtendedAgentCardNotConfiguredError
带 required: true 的 extensions[]没有这个扩展,您的智能体就无法工作服务器要求使用该扩展而客户端没有声明支持时,返回 ExtensionSupportRequiredError

错误名称及其在 JSON-RPC、gRPC、HTTP 中的映射见 A2A 错误码 。客户端在每个请求中通过 A2A-Extensions 服务参数(以逗号分隔的扩展 URI 列表)指明想使用的扩展;见 A2A 扩展 。

streaming、pushNotifications 和 extendedAgentCard 都是 optional 布尔值。第 3.3.4 节把 false 和缺失同等对待,但签名不会:显式写出的可选字段,即使是 false,也属于被签名的内容;省略的字段则不属于(第 8.4.1 节)。选定一种写法,并保证签名时与对外提供时完全一致。

3. 描述技能

一个技能需要 id、name、description,以及 tags 中至少一项;examples、inputModes、outputModes 和 securityRequirements 是可选的。每张卡片都值得做三项检查:

  • skills 为空列表的卡片无效,没有 tags 的技能也无效。标记为必填的数组必须至少包含一个元素(第 5.7 节)。
  • 保证 id 在整张卡片内唯一。proto 把它描述为唯一标识符,校验程序遇到重复会给出警告。
  • 技能自己的 inputModes 和 outputModes 会替换该技能的卡片默认值,所以要如实声明。

技能是写给阅读卡片的人或智能体看的描述,没有任何 A2A 操作会选择某个技能。细节和规范中的示例见 AgentSkill 。

4. 声明安全方案

securitySchemes 是一个映射,键是您自定义的名称,值是一个方案,每个方案恰好设置五个键中的一个:apiKeySecurityScheme、httpAuthSecurityScheme、oauth2SecurityScheme、openIdConnectSecurityScheme 或 mtlsSecurityScheme。securityRequirements 列出客户端必须满足哪些方案以及哪些 scope:

"securityRequirements": [{ "schemes": { "google": { "list": ["openid", "profile", "email"] } } }]
  • 要求中用到的每个名称,无论在卡片上还是在技能上,都必须在 securitySchemes 中定义。
  • oauth2SecurityScheme 恰好设置一种流程。新卡片不要声明 implicit 和 password:v1.0.1 的 proto 把二者都标记为 deprecated。
  • 如果设置了 extendedAgentCard: true,至少要声明一个方案。扩展卡片面向已认证的客户端,没有任何方案,客户端就没有可用来认证的手段。
  • 每种方案和流程内部的必填字段见文末的表格 。

各方案类型和 OAuth 2.0 流程的说明见 SecurityScheme 。

5. 校验卡片

把下面的程序保存为 agent-card-lint.jq。它只读取卡片,不发起任何网络请求,应用前面各节的规则,再加上 v1.0.1 a2a.proto 中的 REQUIRED 字段。

# Agent Card lint for A2A v1.0.1.
# Usage: jq -r -f agent-card-lint.jq agent-card.json
# Checks come from the REQUIRED annotations in a2a.proto, specification sections 5.7, 8.3 and 8.4,
# and the v0.3 -> v1.0 renames. ERROR lines break the specification; WARN lines are advisory.

def blank: . == null or . == "" or . == [];

# Required strings, arrays (at least one element, section 5.7) and objects.
def need($o; $path; $strs; $arrs; $objs):
  if ($o | type) != "object" then "ERROR \($path): expected a JSON object"
  else
    ($strs[] as $k | select($o[$k] | blank) | "ERROR \($path)/\($k): required string is missing or empty"),
    ($arrs[] as $k | select(($o[$k] | type) != "array" or ($o[$k] | length) == 0)
      | "ERROR \($path)/\($k): required array is missing or empty"),
    ($objs[] as $k | select($o[$k] == null) | "ERROR \($path)/\($k): required object is missing")
  end;

def v03: {
  "url": "supportedInterfaces[0].url",
  "preferredTransport": "supportedInterfaces[].protocolBinding",
  "additionalInterfaces": "supportedInterfaces[]",
  "protocolVersion": "supportedInterfaces[].protocolVersion",
  "supportsAuthenticatedExtendedCard": "capabilities.extendedAgentCard",
  "security": "securityRequirements"
};

def card_fields: ["name", "description", "supportedInterfaces", "provider", "version", "documentationUrl",
  "capabilities", "securitySchemes", "securityRequirements", "defaultInputModes", "defaultOutputModes",
  "skills", "signatures", "iconUrl"];

# Scheme type -> [required strings, required objects]; flow type -> same.
def scheme_req: {
  "apiKeySecurityScheme": [["location", "name"], []],
  "httpAuthSecurityScheme": [["scheme"], []],
  "oauth2SecurityScheme": [[], ["flows"]],
  "openIdConnectSecurityScheme": [["openIdConnectUrl"], []],
  "mtlsSecurityScheme": [[], []]
};
def flow_req: {
  "authorizationCode": [["authorizationUrl", "tokenUrl"], ["scopes"]],
  "clientCredentials": [["tokenUrl"], ["scopes"]],
  "deviceCode": [["deviceAuthorizationUrl", "tokenUrl"], ["scopes"]],
  "implicit": [[], []],
  "password": [[], []]
};

def requirement_names($path):
  (. // []) | to_entries[] | .key as $i | .value
  | if (type == "object" and (.schemes | type) == "object") then (.schemes | keys[] | {name: ., at: "\($path)/\($i)"})
    else {bad: "\($path)/\($i)"} end;

. as $card
| if type != "object" then "ERROR /: an Agent Card must be a JSON object" else
  [
    need($card; ""; ["name", "description", "version"];
      ["supportedInterfaces", "defaultInputModes", "defaultOutputModes", "skills"]; ["capabilities"]),

    (keys[] | select(. as $k | card_fields | index($k) | not) | . as $k
      | if v03[$k] then "ERROR /\($k): v0.3 field, use \(v03[$k]) in v1.0"
        else "WARN /\($k): not an AgentCard field in v1.0.1 (typo?)" end),

    (($card.supportedInterfaces // []) | to_entries[] | .key as $i | .value as $v
      | need($v; "/supportedInterfaces/\($i)"; ["url", "protocolBinding", "protocolVersion"]; []; []),
        (select(($v | type) == "object") | $v
          | (select((.url // "https://") | startswith("https://") | not)
              | "WARN /supportedInterfaces/\($i)/url: production URLs must be absolute HTTPS"),
            (select((.protocolBinding // "JSONRPC") as $b | ["JSONRPC", "GRPC", "HTTP+JSON"] | index($b) == null and ($b | test("^[a-z][a-z0-9+.-]*:") | not))
              | "WARN /supportedInterfaces/\($i)/protocolBinding: not JSONRPC, GRPC or HTTP+JSON; custom bindings should use a URI (section 5.8)"),
            (select((.protocolVersion // "1.0") | test("^[0-9]+\\.[0-9]+$") | not)
              | "WARN /supportedInterfaces/\($i)/protocolVersion: expected major.minor such as 1.0"))),

    (select($card.provider != null) | need($card.provider; "/provider"; ["url", "organization"]; []; [])),

    (($card.skills // []) | to_entries[] | .key as $i | .value as $s
      | need($s; "/skills/\($i)"; ["id", "name", "description"]; ["tags"]; []),
        (select(($s | type) == "object" and ($s | has("security"))) | "ERROR /skills/\($i)/security: v0.3 field, use securityRequirements in v1.0")),
    ((($card.skills // []) | map(select(type == "object") | .id) | group_by(.)[] | select(length > 1 and .[0] != null) | .[0])
      | "WARN /skills: id \"\(.)\" is used more than once"),

    (($card.securitySchemes // {}) | to_entries[] | .key as $n | .value as $v
      | "/securitySchemes/\($n)" as $p
      | ($v | if type == "object" then keys else [] end) as $ks
      | if ($ks | length) != 1 or (scheme_req | has($ks[0]) | not)
          then "ERROR \($p): set exactly one of \(scheme_req | keys_unsorted | join(", "))"
        else $ks[0] as $t
          | need($v[$t]; "\($p)/\($t)"; scheme_req[$t][0]; []; scheme_req[$t][1]),
            (select($t == "oauth2SecurityScheme" and ($v[$t].flows | type) == "object")
              | ($v[$t].flows | keys) as $fk
              | "\($p)/\($t)/flows" as $fp
              | if ($fk | length) != 1 or (flow_req | has($fk[0]) | not)
                  then "ERROR \($fp): set exactly one of \(flow_req | keys_unsorted | join(", "))"
                else need($v[$t].flows[$fk[0]]; "\($fp)/\($fk[0])"; flow_req[$fk[0]][0]; []; flow_req[$fk[0]][1]),
                     (select(["implicit", "password"] | index($fk[0])) | "WARN \($fp)/\($fk[0]): deprecated in v1.0.1")
                end)
        end),

    ([$card.securityRequirements | requirement_names("/securityRequirements"),
      (($card.skills // []) | to_entries[] | .key as $i | .value | select(type == "object")
        | .securityRequirements | requirement_names("/skills/\($i)/securityRequirements"))][]
      | if .bad then "ERROR \(.bad): a requirement is an object with a \"schemes\" map"
        elif (.name as $n | ($card.securitySchemes // {}) | has($n) | not) then "ERROR \(.at): scheme \"\(.name)\" is not defined in securitySchemes"
        else empty end),

    (select($card.capabilities.extendedAgentCard == true and (($card.securitySchemes // {}) | length) == 0)
      | "WARN /capabilities/extendedAgentCard: GetExtendedAgentCard needs authentication, but no securitySchemes are declared"),
    (($card.capabilities.extensions // []) | to_entries[] | select(.value.uri | blank)
      | "WARN /capabilities/extensions/\(.key): extension without a uri"),

    (($card.signatures // []) | to_entries[] | .key as $i | .value as $g
      | need($g; "/signatures/\($i)"; ["protected", "signature"]; []; []),
        (select(($g | type) == "object" and ($g.protected | blank | not)) | $g.protected
          | (try (gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson) catch null) as $h
          | if $h == null then "ERROR /signatures/\($i)/protected: not base64url-encoded JSON"
            else (("alg", "kid") as $k | select($h[$k] | blank) | "ERROR /signatures/\($i)/protected: header needs \($k)"),
                 (select($h.typ != "JOSE") | "WARN /signatures/\($i)/protected: typ should be JOSE") end))
  ] | if length == 0 then "OK: no findings" else .[] end
end

对您的卡片运行它。ERROR 行表示卡片无效或按现有写法无法工作:缺少必填字段、残留 v0.3 字段、引用了未声明的内容。WARN 行只是建议。

jq -r -f agent-card-lint.jq agent-card.json

在 CI 中,遇到任何 ERROR 就让构建失败:

if jq -r -f agent-card-lint.jq agent-card.json | grep -q '^ERROR'; then exit 1; fi

它对官方示例卡的判断

想拿一张卡片来试,可以复制规范第 8.5 节的 JSON,或者从 a2aproject/A2A 的克隆中提取:

git show v1.0.1:docs/specification.md \
  | awk '/^### 8\.5\. Sample Agent Card/{f=1} f&&/^```json/{g=1;next} g&&/^```/{exit} g' > agent-card.json

对这份示例的两个版本运行,校验程序给出不同的结论:

卡片结果
标签 v1.0.1 中的第 8.5 节示例ERROR /security: v0.3 field, use securityRequirements in v1.0
main 分支(提交 98279d6,2026-10-20)中的第 8.5 节示例OK: no findings

两份示例只差一行:标签版本仍用 v0.3 的写法书写安全要求,main 已经更正。如果您从旧版本的规范复制卡片,请先检查这个字段。SecurityScheme 对此有更详细的说明。

故意改坏它

检查程序只有在该失败时真的失败,才有价值。下面每一行在 main 示例上只改一处,并给出由此产生的唯一一条结果:

jq '<edit>' agent-card.json | jq -r -f agent-card-lint.jq
修改结果
del(.name)ERROR /name: required string is missing or empty
del(.skills[0].tags)ERROR /skills/0/tags: required array is missing or empty
.supportedInterfaces[0].protocolBinding="REST"WARN /supportedInterfaces/0/protocolBinding: not JSONRPC, GRPC or HTTP+JSON; custom bindings should use a URI (section 5.8)
.securityRequirements=[{"schemes":{"nope":{"list":[]}}}]ERROR /securityRequirements/0: scheme "nope" is not defined in securitySchemes
.securityRequirements=[{"google":["openid"]}]ERROR /securityRequirements/0: a requirement is an object with a "schemes" map
.securitySchemes.google.openIdConnectSecurityScheme={}ERROR /securitySchemes/google/openIdConnectSecurityScheme/openIdConnectUrl: required string is missing or empty
.capabilities.extensions=[{"required":true}]WARN /capabilities/extensions/0: extension without a uri
.signatures[0].protected="@@@"ERROR /signatures/0/protected: not base64url-encoded JSON

它不检查什么

  • URL 是否有响应,以及服务器是否真的实现了卡片声明的内容。
  • 密码学意义上的签名。它只检查 protected 头能否解码并包含 alg 和 kid,仅此而已。
  • v1.0.1 之后的规则。这个程序针对 v1.0.1 的 proto 编写,并在 jq 1.8.1 上测试过。

迁移 v0.3 卡片

校验程序会指出它发现的每一个 v0.3 字段,以及替代它的 v1.0 字段:

v0.3 字段v1.0 的替代
urlsupportedInterfaces[0].url
preferredTransportsupportedInterfaces[].protocolBinding
additionalInterfacessupportedInterfaces[]
protocolVersionsupportedInterfaces[].protocolVersion
supportsAuthenticatedExtendedCardcapabilities.extendedAgentCard
security(卡片与技能)securityRequirements

这些字段同样出现在 Agent Card 的迁移表中,protobuf 字段编号见 Agent Card Proto 字段编号 。

6. 给卡片签名(可选)

签名让客户端确认卡片真实且未被改动。规范的规则(第 8.4 节):

  • 先用 JSON 规范化方案(RFC 8785)对卡片做规范化,再按 JSON Web Signature(RFC 7515)签名。
  • signatures 不参与签名内容。
  • 遵守字段在场性:省略您没有设置的可选字段和持有默认值的字段,但必填字段和显式设置的可选字段必须保留。
  • 在 protected 头中放入 alg、typ 和 kid;typ 应当是 JOSE。头中还可以带 jku,即密钥集的地址。

不依赖密码学库就能检查的一步,是规范化后的形式。下面这条 jq 命令完成排除 signatures 和键排序,并去掉在场性规则要求丢弃的空 extensions 数组:

jq -S -c 'del(.signatures) | del(.capabilities.extensions | select(. == []))' agent-card.json

对规范中规范化示例所用的最小卡片运行它(同一张卡片在 Agent Card 页面上),输出与规范给出的规范化字符串完全一致:

{"capabilities":{"pushNotifications":false,"streaming":false},"defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],"description":"An example agent that echoes messages.","name":"Example Agent","skills":[{"description":"Echoes the input.","id":"echo","name":"Echo","tags":["echo"]}],"supportedInterfaces":[{"protocolBinding":"JSONRPC","protocolVersion":"1.0","url":"https://example.com/a2a/v1"}],"version":"1.0.0"}

这个示例在规范的 main 分支中;v1.0.1 标签使用的是更短的片段,同一条命令同样能复现它的预期输出。

只把它当作"签名时的内容与对外提供的内容"之间的快速比对。jq 不是 RFC 8785 的实现。在 jq 1.8.1 上,它把 1e2 输出为 1E+2,而 RFC 8785 给出 100;它保留 1.0,而 RFC 8785 给出 1;它按码点排序键,而 RFC 8785 按 UTF-16 码元排序,所以包含 U+FFFF 的键会排在包含 emoji 的键之前,与 RFC 8785 正好相反。Agent Card 很少含有数字,但 AgentExtension.params 是自由结构,可能含有。真正的签名请使用您所用语言的 JCS 库和 JWS 库生成。

7. 发布并设置缓存

把卡片放在 https://{server_domain}/.well-known/agent-card.json(第 8.2 节)。Agent Card 很少变化,所以规范要求服务器帮助客户端缓存(第 8.6.1 节):

  • 发送 Cache-Control,其 max-age 与卡片的更新频率相匹配(应当)。
  • 发送由 version 或卡片内容哈希派生的 ETag(应当)。Last-Modified 是可选的。

检查您的服务器实际发送了什么:

curl -sI https://<your-domain>/.well-known/agent-card.json | grep -i -E 'cache-control|etag|last-modified'

客户端应当遵循 HTTP 缓存(RFC 9111),缓存过期后发送带 If-None-Match 或 If-Modified-Since 的条件请求。没有 ETag 或 Last-Modified,就没有可供比较的条件,客户端只能重新下载整张卡片。

8. 出问题时

大多数卡片问题表现为智能体返回的错误,或客户端无法继续。下表把每种现象对应到背后的卡片错误。

您看到的现象可能的卡片原因修复
SendStreamingMessage 或 SubscribeToTask 返回 UnsupportedOperationErrorcapabilities.streaming 为 false 或缺失流式传输可用后再声明 true;否则客户端只能使用非流式的 SendMessage
任一推送配置操作返回 PushNotificationNotSupportedErrorcapabilities.pushNotifications 为 false 或缺失四个操作都可用后再声明 true
GetExtendedAgentCard 返回 UnsupportedOperationErrorcapabilities.extendedAgentCard 为 false 或缺失声明 true 并配置扩展卡片
ExtendedAgentCardNotConfiguredError声明了 extendedAgentCard: true,但没有配置扩展卡片配置扩展卡片,或声明 false
ExtensionSupportRequiredError某个扩展是 required: true,而客户端没有在 A2A-Extensions 中声明让客户端声明它;如果智能体没有它也能工作,就改为 required: false
客户端选不出端点,或调用了错误的端点只有 v0.3 的 url、supportedInterfaces 为空,或条目顺序错误运行校验程序;按优先顺序列出接口
凭据正确但认证失败某条要求引用了不在 securitySchemes 中的方案,或写成了 v0.3 的形状运行校验程序;使用 {"schemes": {"name": {"list": []}}}
签名验证失败签名后内容被改动、signatures 没有被排除、可选字段的在场性不一致,或序列化器不符合 RFC 8785用上面的 jq 命令比对规范化形式,并用 JCS 库签名
客户端不断重新下载整张卡片没有 Cache-Control 或 ETag二者都加上(第 8.6.1 节)

必填字段速查

下面是 v1.0.1 a2a.proto 中标记为 REQUIRED 的字段,校验程序检查的正是这些。这些表格是由脚本与 proto 比对的,而不是靠肉眼。

对象必填字段
AgentCardname, description, version, supportedInterfaces, capabilities, defaultInputModes, defaultOutputModes, skills
AgentInterfaceurl, protocolBinding, protocolVersion
AgentProviderurl, organization
AgentSkillid, name, description, tags
AgentCardSignatureprotected, signature
apiKeySecuritySchemelocation, name
httpAuthSecuritySchemescheme
oauth2SecuritySchemeflows
openIdConnectSecuritySchemeopenIdConnectUrl
authorizationCode 流程authorizationUrl, tokenUrl, scopes
clientCredentials 流程tokenUrl, scopes
deviceCode 流程deviceAuthorizationUrl, tokenUrl, scopes

AgentCapabilities、AgentExtension、mtlsSecurityScheme,以及已弃用的 implicit 和 password 流程没有必填字段。

相关页面

来源:A2A 官方规范:Capability Validation(3.3.4)、Field Presence and Optionality(5.7)、Protocol Binding 标识(5.8)、Discovery Mechanisms、Protocol Declaration Requirements、Agent Card Signing、Sample Agent Card 与 Caching(8.2 至 8.6) ;a2a.proto v1.0.1 ;规范在 v1.0.1 标签与 main 提交 98279d6 的示例卡