Summary
There are two redundant shapes for the same "agent card" concept, and the agent-author-facing one hides most A2A fields inside an untyped opts grab-bag. Proposing we consolidate on %A2A.AgentCard{} as the single canonical shape, transforming only at the JSON boundary — done in a backward-compatible way via pattern matching so existing loose-map agents keep working.
The two shapes today
1. A2A.Agent.card() — what the agent_card/0 callback returns (lib/a2a/agent.ex:138, :170):
@type card :: %{
name: String.t(),
description: String.t(),
version: String.t(),
skills: [skill()],
opts: keyword() # <- capabilities, provider, security_schemes, ... all hidden here, untyped
}
2. %A2A.AgentCard{} — the decode/remote shape (lib/a2a/agent_card.ex), a fully-typed struct with all 16 A2A spec fields as first-class keys (capabilities, provider, security_schemes, supported_interfaces, signatures, etc.).
The opts keyword in shape 1 is a weaker, untyped second encoding of fields that shape 2 already models properly. An author defining an agent has no typed surface for capabilities/provider/security — they go into an opaque keyword list and are only interpreted later at encode time.
Proposal
Make %A2A.AgentCard{} the single canonical card shape:
agent_card/0 returns a %A2A.AgentCard{}.
- It flows unchanged through
:get_agent_card → A2A.Registry → A2A.JSON.encode_agent_card/2 → JSON, and the same struct type comes back from decode_agent_card/1 / over the wire.
- Transformation happens only at the JSON edge (where
encode_agent_card/decode_agent_card already live).
Backward compatibility via pattern matching
We do not need to break existing agents that return the loose map. Support both with multiple function heads, e.g.:
def encode_agent_card(%A2A.AgentCard{} = card, opts), do: ... # new canonical shape
def encode_agent_card(%{name: _, description: _} = card, opts), do: ... # legacy loose map
Same approach for :get_agent_card consumers / registry. New agents get the typed struct; old loose-map agents keep working untouched. The loose-map path can be documented as deprecated and removed in a later major.
Open design question
%A2A.AgentCard{} has @enforce_keys [:name, :description, :url, :version, :skills] (lib/a2a/agent_card.ex:61) — but an agent author doesn't know their own public URL at definition time; it's the deployment base_url, injected later at encode (A2A.get_agent_card/2 passes it as :url). This is very likely why the loose author-facing map exists.
Consolidating means picking one of:
- relax
@enforce_keys to drop :url (author cards may omit it; encode supplies it), or
- make
url injectable / overridable at encode time (encode's :url opt already wins), or
- keep
:url enforced and require authors to pass a placeholder.
Want maintainer direction on this before implementing.
Scope (if approved)
agent_card/0 @callback type → A2A.AgentCard.t() (lib/a2a/agent.ex:170)
__using__ default build_card_ast → emit %A2A.AgentCard{} (lib/a2a/agent.ex:398)
A2A.Registry specs ×3 (lib/a2a/registry.ex:50,68,95)
- legacy loose-map heads + deprecation note
- test agents
Context
Came out of #52, which fixes the immediate bug (a populated %A2A.AgentCard{} had its rich fields silently dropped by encode_agent_card/2). #52 is intentionally small and backward-compatible; this issue is the broader consistency follow-up.
Summary
There are two redundant shapes for the same "agent card" concept, and the agent-author-facing one hides most A2A fields inside an untyped
optsgrab-bag. Proposing we consolidate on%A2A.AgentCard{}as the single canonical shape, transforming only at the JSON boundary — done in a backward-compatible way via pattern matching so existing loose-map agents keep working.The two shapes today
1.
A2A.Agent.card()— what theagent_card/0callback returns (lib/a2a/agent.ex:138,:170):2.
%A2A.AgentCard{}— the decode/remote shape (lib/a2a/agent_card.ex), a fully-typed struct with all 16 A2A spec fields as first-class keys (capabilities,provider,security_schemes,supported_interfaces,signatures, etc.).The
optskeyword in shape 1 is a weaker, untyped second encoding of fields that shape 2 already models properly. An author defining an agent has no typed surface for capabilities/provider/security — they go into an opaque keyword list and are only interpreted later at encode time.Proposal
Make
%A2A.AgentCard{}the single canonical card shape:agent_card/0returns a%A2A.AgentCard{}.:get_agent_card→A2A.Registry→A2A.JSON.encode_agent_card/2→ JSON, and the same struct type comes back fromdecode_agent_card/1/ over the wire.encode_agent_card/decode_agent_cardalready live).Backward compatibility via pattern matching
We do not need to break existing agents that return the loose map. Support both with multiple function heads, e.g.:
Same approach for
:get_agent_cardconsumers / registry. New agents get the typed struct; old loose-map agents keep working untouched. The loose-map path can be documented as deprecated and removed in a later major.Open design question
%A2A.AgentCard{}has@enforce_keys [:name, :description, :url, :version, :skills](lib/a2a/agent_card.ex:61) — but an agent author doesn't know their own public URL at definition time; it's the deploymentbase_url, injected later at encode (A2A.get_agent_card/2passes it as:url). This is very likely why the loose author-facing map exists.Consolidating means picking one of:
@enforce_keysto drop:url(author cards may omit it; encode supplies it), orurlinjectable / overridable at encode time (encode's:urlopt already wins), or:urlenforced and require authors to pass a placeholder.Want maintainer direction on this before implementing.
Scope (if approved)
agent_card/0@callbacktype →A2A.AgentCard.t()(lib/a2a/agent.ex:170)__using__defaultbuild_card_ast→ emit%A2A.AgentCard{}(lib/a2a/agent.ex:398)A2A.Registryspecs ×3 (lib/a2a/registry.ex:50,68,95)Context
Came out of #52, which fixes the immediate bug (a populated
%A2A.AgentCard{}had its rich fields silently dropped byencode_agent_card/2). #52 is intentionally small and backward-compatible; this issue is the broader consistency follow-up.