A2A Agent Card Implementation Guide: Build, Validate, Sign

A2A Agent Card Implementation Guide: Build, Validate, Sign

This guide takes you from an empty file to an Agent Card you can publish with confidence. It assumes you know what an Agent Card is; if not, start with Agent Card . Field-by-field explanations stay on the concept pages. This page adds what they do not have: the decisions you make while filling in a card, a validator you can run, and a map from runtime errors back to card mistakes.

Everything is checked against A2A v1.0.1: the a2a.proto at that tag, the specification, and the specification’s own sample cards. Where the specification and the proto disagree, the proto wins (specification section 1.4).

The path

  1. Declare interfaces: where and how clients reach the agent.
  2. Declare capabilities: what you promise, and the errors you owe when you do not.
  3. Describe skills.
  4. Declare security.
  5. Validate: run the checker until it prints OK: no findings.
  6. Sign (optional), then publish and cache.

Signing comes last because a signature covers the final content: any edit after signing invalidates it.

1. Declare interfaces

supportedInterfaces is a list of entries with url, protocolBinding, and protocolVersion, all three required. The list is read in order, so its order is part of the contract.

Your situationDeclareRule and source
One endpoint, one transportOne entryThe three fields are REQUIRED in a2a.proto
Several transportsOne entry per binding and URL, most preferred firstThe list SHOULD declare all supported combinations in preference order, and the first entry is the preferred interface (section 8.3.1)
Several transports on one endpointRepeat the same url once per bindingURLs MAY be reused (section 8.3.1)
Many agents or tenants behind one endpointSet tenant on the entryClients MUST copy it into every request message; the protocol does not define its format, and the server decides what it means (AgentInterface.tenant, section 8.3.2). See Multi-Tenancy
A binding other than JSONRPC, GRPC, or HTTP+JSONUse a URI as protocolBindingCustom bindings SHOULD be identified by a URI, and a breaking change to a binding MUST get a new URI (section 5.8). See Custom Protocol Bindings
Local developmentA plain http:// URLThe proto requires an absolute HTTPS URL in production, so the validator warns until you switch

2. Declare capabilities

Capabilities are a contract. When a capability is false or absent and a client uses it anyway, the agent MUST answer with a specific error (section 3.3.4). Declare true only for what you have built.

CapabilityDeclare true whenIf false or absent, the agent MUST return
streamingSendStreamingMessage and SubscribeToTask workUnsupportedOperationError for both
pushNotificationsCreate, Get, List, and Delete of push notification configs workPushNotificationNotSupportedError
extendedAgentCardAn authenticated GetExtendedAgentCard returns a configured cardUnsupportedOperationError; and if you declare true but configured no extended card, ExtendedAgentCardNotConfiguredError
extensions[] with required: trueYour agent cannot work without the extensionExtensionSupportRequiredError when the server requests the extension and the client did not declare support for it

Error names and their JSON-RPC, gRPC, and HTTP mappings are in A2A Error Codes . Clients name the extensions they want to use in each request through the A2A-Extensions service parameter, a comma-separated list of extension URIs; see Extensions .

streaming, pushNotifications, and extendedAgentCard are optional booleans. Section 3.3.4 treats false and absent the same, but signing does not: an optional field you wrote explicitly, even as false, belongs to the signed content, and one you omitted does not (section 8.4.1). Choose one style and keep it identical between signing and serving.

3. Describe skills

A skill needs id, name, description, and at least one entry in tags; examples, inputModes, outputModes, and securityRequirements are optional. Three checks are worth running on every card:

  • A card with an empty skills list is invalid, and so is a skill with no tags. Arrays marked required MUST hold at least one element (section 5.7).
  • Keep id unique across the card. The proto describes it as a unique identifier, and the validator warns on duplicates.
  • A skill’s inputModes and outputModes replace the card defaults for that skill, so declare them accurately.

Skills describe the agent to whoever reads the card; no A2A operation selects one. The details, with the specification’s example, are on AgentSkill .

4. Declare security

securitySchemes is a map from a name you choose to a scheme, and each scheme sets exactly one of five keys: apiKeySecurityScheme, httpAuthSecurityScheme, oauth2SecurityScheme, openIdConnectSecurityScheme, or mtlsSecurityScheme. securityRequirements lists which schemes, and which scopes, a client must satisfy:

"securityRequirements": [{ "schemes": { "google": { "list": ["openid", "profile", "email"] } } }]
  • Every name used in a requirement, on the card or on a skill, must be defined in securitySchemes.
  • An oauth2SecurityScheme sets exactly one flow. Do not declare implicit or password in new cards: the v1.0.1 proto marks both deprecated.
  • If you set extendedAgentCard: true, declare at least one scheme. The extended card is for authenticated clients, and with no scheme there is nothing for them to authenticate with.
  • The required fields inside each scheme and flow are in the table at the end .

The scheme types and OAuth 2.0 flows are explained on SecurityScheme .

5. Validate the card

Save this as agent-card-lint.jq. It reads only the card, makes no network calls, and applies the rules from the previous sections plus the REQUIRED fields of a2a.proto at v1.0.1.

# 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

Run it on your card. ERROR lines mean the card is invalid or cannot work as written: a missing required field, a v0.3 leftover, a reference to something that is not declared. WARN lines are advisory.

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

In CI, fail the build on any ERROR:

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

What it reports on the official sample cards

To get a card to try, copy the JSON from section 8.5 of the specification, or extract it from a clone of 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

Run against two copies of that sample, the validator gives different answers:

CardResult
Section 8.5 sample at tag v1.0.1ERROR /security: v0.3 field, use securityRequirements in v1.0
Section 8.5 sample on main (commit 98279d6, 2026-10-20)OK: no findings

The two samples differ in exactly one line: the tag still writes the security requirement in the v0.3 shape, and main corrected it. If you copy a card from an older copy of the specification, check that field first. SecurityScheme covers this in more detail.

Break it on purpose

A checker is only useful if it fails when it should. Each row edits the main sample in one place and shows the single finding that results:

jq '<edit>' agent-card.json | jq -r -f agent-card-lint.jq
EditFinding
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

What it does not check

  • That the URLs answer, or that the server really implements what the card declares.
  • The cryptographic signature. It checks that the protected header decodes and has alg and kid, nothing more.
  • Rules from after v1.0.1. The program is written for the v1.0.1 proto and was tested with jq 1.8.1.

Migrating a v0.3 card

The validator names every v0.3 field it finds and the v1.0 field that replaces it:

v0.3 fieldv1.0 replacement
urlsupportedInterfaces[0].url
preferredTransportsupportedInterfaces[].protocolBinding
additionalInterfacessupportedInterfaces[]
protocolVersionsupportedInterfaces[].protocolVersion
supportsAuthenticatedExtendedCardcapabilities.extendedAgentCard
security (card and skill)securityRequirements

The same fields are in the migration table on Agent Card , and the protobuf field numbers are in Agent Card Proto Field Numbers .

6. Sign the card (optional)

A signature lets a client confirm the card is authentic and unchanged. The specification’s rules (section 8.4):

  • Canonicalize the card with the JSON Canonicalization Scheme (RFC 8785), then sign it as a JSON Web Signature (RFC 7515).
  • Exclude signatures from the signed content.
  • Respect field presence: omit optional fields you did not set and fields holding default values, but always keep REQUIRED fields and optional fields you set explicitly.
  • Put alg, typ, and kid in the protected header; typ SHOULD be JOSE. The header may also carry jku, the URL of the key set.

The one step you can check without a crypto library is the canonical form. This jq command applies the exclusion and the key ordering, and removes the empty extensions array that the presence rule drops:

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

Run on the minimal card from the specification’s canonicalization example (the same card is on Agent Card ), it prints exactly the canonical string the specification gives:

{"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"}

That example is in the main branch of the specification; the v1.0.1 tag uses a shorter fragment, and the same command reproduces its expected output as well.

Use this only as a quick diff between what you signed and what you serve. jq is not an RFC 8785 implementation. On jq 1.8.1 it prints 1e2 as 1E+2 where RFC 8785 gives 100, keeps 1.0 where RFC 8785 gives 1, and orders keys by code point where RFC 8785 orders by UTF-16 code unit, so a key containing U+FFFF sorts before one containing an emoji, the opposite of RFC 8785. Agent Cards seldom hold numbers, but AgentExtension.params is a free-form structure and can. Produce the real signature with a JCS library and a JWS library for your language.

7. Publish and cache

Serve the card at https://{server_domain}/.well-known/agent-card.json (section 8.2). Agent Cards change rarely, so the specification asks servers to help clients cache them (section 8.6.1):

  • Send Cache-Control with a max-age that matches how often the card changes (SHOULD).
  • Send an ETag derived from version or from a hash of the card (SHOULD). Last-Modified is optional.

Check what your server sends:

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

Clients are expected to follow HTTP caching (RFC 9111) and, once a cached card expires, send a conditional request with If-None-Match or If-Modified-Since. Without an ETag or a Last-Modified there is nothing to be conditional on, so they must download the whole card again.

8. When it breaks

Most card problems show up as an error from the agent or a client that cannot proceed. This table maps each symptom to the card mistake behind it.

What you seeLikely card causeFix
UnsupportedOperationError from SendStreamingMessage or SubscribeToTaskcapabilities.streaming is false or absentDeclare true once streaming works; otherwise clients have to use the non-streaming SendMessage
PushNotificationNotSupportedError from any push config operationcapabilities.pushNotifications is false or absentDeclare true once all four operations work
UnsupportedOperationError from GetExtendedAgentCardcapabilities.extendedAgentCard is false or absentDeclare true and configure an extended card
ExtendedAgentCardNotConfiguredErrorextendedAgentCard: true, but no extended card is configuredConfigure the extended card, or declare false
ExtensionSupportRequiredErrorAn extension is required: true and the client did not declare it in A2A-ExtensionsHave the client declare it, or set required: false if your agent can work without it
Client cannot choose an endpoint, or calls the wrong oneOnly a v0.3 url, an empty supportedInterfaces, or entries in the wrong orderRun the validator; list interfaces in preference order
Authentication fails with correct credentialsA requirement names a scheme that is not in securitySchemes, or is written in the v0.3 shapeRun the validator; use {"schemes": {"name": {"list": []}}}
Signature verification failsContent changed after signing, signatures was not excluded, presence of optional fields differs, or the serializer is not RFC 8785 compliantDiff the canonical forms with the jq command above, and sign with a JCS library
Clients keep downloading the full cardNo Cache-Control or ETagAdd both (section 8.6.1)

Required fields at a glance

These are the fields a2a.proto at v1.0.1 marks REQUIRED. The validator checks exactly these. The tables below are compared with the proto by a script, not by eye.

ObjectRequired fields
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 flowauthorizationUrl, tokenUrl, scopes
clientCredentials flowtokenUrl, scopes
deviceCode flowdeviceAuthorizationUrl, tokenUrl, scopes

AgentCapabilities, AgentExtension, mtlsSecurityScheme, and the deprecated implicit and password flows have no required fields.

Related pages

Source: A2A official specification: Capability Validation (3.3.4), Field Presence and Optionality (5.7), Protocol Binding identification (5.8), Discovery Mechanisms, Protocol Declaration Requirements, Agent Card Signing, Sample Agent Card, and Caching (8.2 to 8.6) ; a2a.proto at v1.0.1 ; sample cards from the specification at the v1.0.1 tag and at main commit 98279d6