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.

FieldWhat it isConvention
nameHuman-readable agent nameShort, stable, unique within your organisation; never reused after retirement
descriptionWhat the agent doesTwo or three sentences: what it does, what it will not do, who should call it
url, preferredTransportWhere and how to call itOne URL per environment; never a staging URL in a production registry
versionThe agent's own versionSemantic versioning, rules below
protocolVersionA2A protocol version spokenSet explicitly; do not infer it from the agent version
providerOrganisation and URLThe owning organisation, not the individual team
skillsList of AgentSkill (id, name, description, tags, examples, modes)Ids stable forever; tags from a controlled vocabulary
capabilitiesStreaming, push notifications, extensionsCustom metadata goes in an extension here
securitySchemes, securityHow callers authenticateRequired for anything not public
supportsAuthenticatedExtendedCardA richer card for authenticated callersPut 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

A card is authored, linted, published, then read by machines and peopleagent-card.jsonin the agent repoCard linterCI, before mergevocabulary.yamltags, owners, statesRegistrycard + digest + historyrulespublish on releaseRouting agentmatches skills, tagsPolicy enginedata class, regionCatalog UIowners, lifecycleSunset jobdeprecated -> retiredStandard A2A fields serve every client; your conventions live in a namespaced extension that only your tools need to understand.
Cards are linted against a shared vocabulary before publication and then consumed by routers, policy, people and lifecycle jobs.

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 a refund-agent without 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:

ChangeBumpWhy
Skill removed, skill id changed, input or output modes narrowedMajorExisting callers break
New security requirement on an existing skillMajorUnauthenticated callers break
Skill added, modes widened, new optional extension paramsMinorAdditive
Instruction, model or tool change with the same skillsMinorBehaviour changes even though the interface does not; callers pinning a minor should notice
Description, examples or documentation onlyPatchNo 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

FailureSymptomGuard
Tag sprawlRouter finds nothing or the wrong agentNamespaced vocabulary, unknown tags rejected
Reused skill id with new meaningOld callers get new behaviourIds are permanent; compare against published card
Same version, different cardCaches disagree about what an agent doesRegistry stores a digest and rejects duplicates
Sensitive metadata in public cardInternal topology exposedExtended card or registry-internal view
Deprecated foreverNobody migratesMandatory sunset date enforced by the registry
Ad-hoc top-level fieldsStrict parsers reject the cardEverything 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

  1. Inventory your current cards and list every distinct tag, name style and missing field.
  2. Write a namespaced vocabulary file and an owner list, and put them in a shared repository with review.
  3. Define one registry-metadata extension under a URI you control, with owner, lifecycle, data classes, regions and runbook.
  4. Adopt the version table, including the minor bump for model and instruction changes.
  5. Add the card linter and the published-card comparison to every agent's CI.
  6. Move internal-only metadata out of public cards into the extended card or the registry's internal view.
  7. Have the registry enforce sunset dates and keep retired cards in history.
Key takeaway: A registry is only as useful as the consistency of what agents publish to it. Use the A2A card's own fields for what they define, keep names and skill ids permanent, draw tags from a small namespaced vocabulary, version model and instruction changes as minor bumps, put owner, lifecycle and data classes in one extension under a URI you control, and enforce all of it with a linter in CI so the conventions survive contact with twenty teams.