Once more than a handful of agents exist, the registry becomes the place where one team learns what another team's agent does, a router decides where to send a request, and a policy engine decides whether it may. All of that runs on metadata, and metadata written by twenty teams without conventions is useless: one team tags its agent Billing, another billing-agent, a third finance, and the router matches none of them.
This article sets conventions for the metadata an ADK Java agent publishes. It builds on the A2A agent card, which the A2A Java SDK models as the io.a2a.spec.AgentCard record, rather than on an invented descriptor format. The A2A specification names registries and catalogs as one way to discover agents but does not define a registry API or a metadata vocabulary, so everything beyond the card's fields is a convention you choose; the recommendations here are labelled as such. Capability matching is covered in the dynamic discovery article and in-process registries in the agent registry design article; this page is about what goes into the card and how you keep it consistent.
What the agent card already carries
The card already carries most of what a registry needs. The fields below are the accessors on AgentCard in A2A Java SDK 0.3.2; check your SDK version, because the card has gained fields between protocol releases. The right-hand column is the convention this article recommends.
| Field | What it is | Convention |
|---|---|---|
name | Human-readable agent name | Short, stable, unique within your organisation; never reused after retirement |
description | What the agent does | Two or three sentences: what it does, what it will not do, who should call it |
url, preferredTransport | Where and how to call it | One URL per environment; never a staging URL in a production registry |
version | The agent's own version | Semantic versioning, rules below |
protocolVersion | A2A protocol version spoken | Set explicitly; do not infer it from the agent version |
provider | Organisation and URL | The owning organisation, not the individual team |
skills | List of AgentSkill (id, name, description, tags, examples, modes) | Ids stable forever; tags from a controlled vocabulary |
capabilities | Streaming, push notifications, extensions | Custom metadata goes in an extension here |
securitySchemes, security | How callers authenticate | Required for anything not public |
supportsAuthenticatedExtendedCard | A richer card for authenticated callers | Put internal-only metadata there |
The specification's recommended public location is /.well-known/agent-card.json on the agent's domain, and it warns that cards can contain sensitive information and must then be access controlled. Treat that warning as a design input: on-call rotations, internal hostnames and data classifications belong in the authenticated extended card or in the registry's internal view, not in the public card.
Architecture
The linter and the registry read the same vocabulary file, so a card that passes CI is a card the registry will accept. Consumers rely on the standard fields first and on the extension only for organisation-specific decisions.
Names and identifiers
Identifiers are the part you cannot fix later, because callers hard-code them. Recommended rules:
- Registry key: the owning domain plus the agent name, for example
support.example.com/refund-agent. Two teams can both have arefund-agentwithout colliding. - Agent names: lowercase, hyphen-separated, ASCII, at most 40 characters. Names describe the job (
refund-agent), not the implementation (gemini-refund-bot-v2). - Skill ids: lowercase with underscores, a verb and an object (
issue_refund,lookup_order). An id names a contract; if the contract changes incompatibly, add a new id and deprecate the old one rather than editing it. - Never reuse a retired name or skill id. A caller that cached the old card would silently start calling something else.
Tags from a controlled vocabulary
Free-form tags are why routers fail. Replace them with a namespaced vocabulary kept in one file in a shared repository, and reject unknown tags in CI. A namespace prefix makes the tag self-describing and lets the linter apply different rules to different kinds of tag.
# vocabulary.yaml (owned by the platform team, changed by pull request)
domain: [billing, orders, identity, support, logistics]
action: [read, write, refund, notify, search]
data: [public, internal, pii, payment]
region: [eu, us, apac]
lifecycle_states: [experimental, ga, deprecated, retired]A skill's tags then read like domain:billing, action:refund, data:payment. Keep the vocabulary small. A namespace with three hundred values is free text with extra steps. Adding a value is a reviewed pull request, which is the point: the review is where someone notices that finance and billing are the same thing.
Two version fields, two meanings
The card has two version fields and they answer different questions. protocolVersion says which A2A protocol the server speaks, and changes when you upgrade the SDK. version is yours. A workable semantic versioning rule for agents:
| Change | Bump | Why |
|---|---|---|
| Skill removed, skill id changed, input or output modes narrowed | Major | Existing callers break |
| New security requirement on an existing skill | Major | Unauthenticated callers break |
| Skill added, modes widened, new optional extension params | Minor | Additive |
| Instruction, model or tool change with the same skills | Minor | Behaviour changes even though the interface does not; callers pinning a minor should notice |
| Description, examples or documentation only | Patch | No behaviour change |
The fourth row is the agent-specific one. A model upgrade can change what an agent does as much as a new skill, so treating it as a patch tells consumers nothing happened. Store the card's content digest alongside the version in the registry, too. Two cards with the same version and different digests mean someone published without bumping, and the registry should reject the second.
This is the consumer-facing half of versioning. The release side, which decides what counts as one version of an agent inside your own deployment, is covered in the ADK Java versioning article, and serving the card over A2A in the first place in the A2A integration article. Keep the two numbers related but separate: one release can publish the same card version if nothing callers can observe changed.
Custom metadata in an extension
Ownership, lifecycle and data classification are not card fields, and adding ad-hoc top-level keys breaks strict parsers and collides with future protocol fields. The card already has a slot for this: capabilities.extensions, a list of AgentExtension records each with a uri, a description, a params map and a required flag. Define one extension under a URI your organisation controls, version it in the URI, and set required to false so clients that do not understand it still work.
import io.a2a.spec.*;
AgentExtension registryMeta = new AgentExtension.Builder()
.uri("https://agents.example.com/ext/registry-metadata/v1")
.description("Ownership, lifecycle and data handling for the internal registry")
.required(false)
.params(Map.of(
"owner_team", "support-agents",
"lifecycle", "ga",
"data_classes", List.of("pii", "payment"),
"regions", List.of("eu"),
"runbook", "go/refund-agent-runbook"))
.build();
AgentSkill refund = new AgentSkill.Builder()
.id("issue_refund").name("Issue a refund")
.description("Refunds a delivered order up to the policy limit after customer confirmation.")
.tags(List.of("domain:billing", "action:refund", "data:payment"))
.examples(List.of("Refund order 4417, the parcel arrived damaged"))
.build();
AgentCard card = new AgentCard.Builder()
.name("refund-agent").version("3.2.0").protocolVersion("0.3.0")
.description("Handles refund requests for delivered orders. Does not change orders or addresses.")
.url("https://refunds.support.example.com/a2a").preferredTransport("JSONRPC")
.provider(new AgentProvider("Example Corp", "https://example.com"))
.capabilities(new AgentCapabilities.Builder().streaming(true).extensions(List.of(registryMeta)).build())
.defaultInputModes(List.of("text/plain")).defaultOutputModes(List.of("text/plain"))
.skills(List.of(refund))
.build();The builder may enforce required fields such as url and skills at build(); if it rejects your card, the exception names the missing field. Write the extension's params as a small documented schema of their own and bump the URI to v2 for incompatible changes, exactly as you would an API.
Lifecycle and deprecation
Lifecycle is the metadata people forget to maintain. Four states are enough: experimental (no stability promise, hidden from automatic routing), ga, deprecated and retired. Two rules make them useful. A deprecated card must carry a sunset date and a replacement registry key, so callers know where to go and when. And the registry, not the agent, moves a card to retired on the sunset date, removing it from discovery while keeping it in history so old traces still resolve. Routers should prefer ga, accept deprecated with a logged warning, and never pick experimental unless asked explicitly.
A card linter in CI
Conventions that are not checked decay within a quarter. Run a linter over every card in CI, before merge, using the same vocabulary file the registry uses.
public final class CardLinter {
static final String EXT = "https://agents.example.com/ext/registry-metadata/v1";
static final Pattern NAME = Pattern.compile("[a-z][a-z0-9-]{1,39}");
static final Pattern SKILL_ID = Pattern.compile("[a-z][a-z0-9_]{1,63}");
static final Pattern SEMVER = Pattern.compile("\\d+\\.\\d+\\.\\d+");
public List<String> lint(AgentCard c, Vocabulary v) {
var errs = new ArrayList<String>();
if (!NAME.matcher(c.name()).matches()) errs.add("name: " + c.name());
if (c.version() == null || !SEMVER.matcher(c.version()).matches()) errs.add("version: not semver");
if (c.protocolVersion() == null) errs.add("protocolVersion: missing");
var seen = new HashSet<String>();
for (AgentSkill s : c.skills()) {
if (!SKILL_ID.matcher(s.id()).matches()) errs.add("skill id: " + s.id());
if (!seen.add(s.id())) errs.add("skill id duplicated: " + s.id());
if (s.tags() == null || s.tags().isEmpty()) errs.add(s.id() + ": no tags");
else for (String t : s.tags()) if (!v.allows(t)) errs.add(s.id() + ": unknown tag " + t);
}
var ext = c.capabilities().extensions() == null ? null : c.capabilities().extensions().stream()
.filter(e -> EXT.equals(e.uri())).findFirst().orElse(null);
if (ext == null) { errs.add("registry-metadata extension missing"); return errs; }
Map<String, Object> p = ext.params();
if (!v.isTeam(p.get("owner_team"))) errs.add("owner_team unknown");
Object state = p.get("lifecycle");
if (!v.isState(state)) errs.add("lifecycle invalid");
if ("deprecated".equals(state) && (p.get("sunset") == null || p.get("replacement") == null))
errs.add("deprecated without sunset and replacement");
return errs;
}
}Add one check the linter cannot do alone: compare the candidate card with the one currently published and fail if a skill disappeared or a skill id changed without a major version bump. That turns the version table above from advice into a rule.
Worked example: three submissions
The cards in this example are illustrative. Three teams submit cards in the same week. The orders team's card tags its lookupOrder skill with Orders and read. The linter reports skill id: lookupOrder, unknown tag Orders and unknown tag read; the fix is lookup_order with domain:orders and action:read. The billing team removes issue_credit from its agent and bumps 2.4.0 to 2.5.0; the published-card comparison fails because a removed skill requires 3.0.0, and the team also marks the old skill's agent deprecated with a replacement. The identity team's card passes every rule but puts an internal hostname in its public description; no linter catches that, which is why the reviewer checklist still includes reading the description as an outsider would.
After the fixes the router's query for domain:orders plus action:read returns exactly one ga agent, which is the outcome the conventions exist for.
Failure modes
| Failure | Symptom | Guard |
|---|---|---|
| Tag sprawl | Router finds nothing or the wrong agent | Namespaced vocabulary, unknown tags rejected |
| Reused skill id with new meaning | Old callers get new behaviour | Ids are permanent; compare against published card |
| Same version, different card | Caches disagree about what an agent does | Registry stores a digest and rejects duplicates |
| Sensitive metadata in public card | Internal topology exposed | Extended card or registry-internal view |
| Deprecated forever | Nobody migrates | Mandatory sunset date enforced by the registry |
| Ad-hoc top-level fields | Strict parsers reject the card | Everything custom inside one extension |
Trade-offs
A strict vocabulary makes publishing slower and occasionally blocks a team waiting on a review. The alternative, letting tags float and cleaning up later, never gets cleaned up. Putting metadata in an extension costs a level of nesting and some parsing code in your tools, in exchange for cards that any A2A client can still read. Bumping the minor version on model changes produces more versions than a pure interface rule would, which is the honest outcome: the agent did change. Finally, conventions apply to card content, not to how the registry stays available; for that, see the registry high availability article.
What to do next
- Inventory your current cards and list every distinct tag, name style and missing field.
- Write a namespaced vocabulary file and an owner list, and put them in a shared repository with review.
- Define one registry-metadata extension under a URI you control, with owner, lifecycle, data classes, regions and runbook.
- Adopt the version table, including the minor bump for model and instruction changes.
- Add the card linter and the published-card comparison to every agent's CI.
- Move internal-only metadata out of public cards into the extended card or the registry's internal view.
- Have the registry enforce sunset dates and keep retired cards in history.