
Chess as a test bench for A2A: a reference implementation for startups selling agents
A deterministic arbiter plays two LLM chess agents against each other over the Agent2Agent (A2A) protocol, each a server on Amazon Bedrock AgentCore Runtime reaching a private engine over MCP. Mid-game, Black is replaced by a player on a different framework under the same published name, and the caller is never told. A2A makes that swap possible; we also pin one trace across both runtimes, check a scope per skill, and bound each move by a time budget. how many characters
This is the implementation half of a pair. Its companion, How enterprises will hire your agent: MCP or A2A , covers the protocol choice itself, as a distribution decision.
If your startup sells an agent, you own one side of a boundary. The caller is someone else's orchestrator, on someone else's platform, in a framework you did not choose.
This post works through a reference implementation. A deterministic arbiter with no model plays two LLM chess agents against each other over the Agent2Agent (A2A) protocol. Each player is an A2A server on Amazon Bedrock AgentCore (AgentCore) Runtime, reaches a private Stockfish engine through the Model Context Protocol (MCP), and is found through AWS Agent Registry (Agent Registry). Mid-game, Black is swapped for a player on a different framework, answering in a different shape, under the same published name. The arbiter is never told and keeps playing. That is what matters if you sell an agent: you can change the framework, model and engine behind it without your buyer changing anything. Chess is the vehicle, not the subject.
We build an A2A server that meets the AgentCore container contract and is reachable by both the platform and the caller, publish a card that says what the agent does and nothing about how, get one trace across two runtimes, put per-skill authorisation where it can be enforced, register the agent so a caller searches for a capability rather than being handed a URL, and cap the total time one move may take, retries included.
The architecture

Figure 1: The arbiter as an A2A client off-platform, two players as A2A servers on AgentCore Runtime, each with a private Stockfish behind MCP inside its own container, unreachable from the arbiter or the opponent; one trace id per turn crosses both runtimes.

Figure 2: The mid-game swap: the same arbiter process and the same board on both sides, Black's seat rebound from the Strands Agents runtime answering with a TextPart to the LangGraph runtime answering with a DataPart, the public name unchanged.
The arbiter is not an agent and does not run on AgentCore. It is a deterministic process with no model, running on a server of its own. It stands in for the buyer's orchestrator, so every property the players claim must hold against a caller sharing none of their infrastructure; the players are the product, the arbiter the customer. Per move it sends a FEN and the legal moves, with one trace id per turn. Each player's engine is a private MCP Each player's engine is a private tool it reaches over MCP inside its own container, so nothing outside ever sees its analysis, and the engine call appears in the trace as a step of its own.is would not be an opponent, which is why chess makes opaque execution self-evident.
How A2A and MCP work here
The A2A overview describes a request as "four main steps across three stages: agent discovery, authentication, and the messaging APIs". Discovery is the agent card, which the 0.3.0 specification recommends serving at
/.well-known/agent-card.json; it declares skills, each with its own security requirement, and a securitySchemes block (5.5.3 , 5.5.4 ).Messaging is JSON-RPC 2.0. A client calls
message/send with a message whose parts each carry a kind (text, file or data). The server answers with a task carrying a status.state from the TaskState enum and, on completion, artifacts: "Tasks in completed state SHOULD use artifacts for returning the generated output to the clients" (6.1 ). message/stream does the same over Server-Sent Events (SSE) and requires capabilities.streaming: true.Opaque execution is one of A2A's stated design principles: "Agents collaborate without exposing their internal logic, memory, or proprietary tools. Interactions rely on declared capabilities and shared context." The same page positions A2A against MCP: MCP connects a model to tools and data, A2A crosses the boundary between agents, and "Wrapping an agent as a simple tool is limiting: it can't capture the agent's full capabilities."
MCP is the inner protocol: the player speaks MCP to its engine, and the arbiter never speaks MCP to a remote player's engine. The model discovers the engine with
tools/list and calls it with tools/call . A tool declares name, description and inputSchema, optionally outputSchema, and a result may carry structuredContent alongside content.Prerequisites
An AWS account with AgentCore available and two Amazon Bedrock models enabled:
us.anthropic.claude-opus-5 for White and us.openai.gpt-5.6-sol for both Blacks, as cross-Region inference profiles. The build and deploy need uv and Python 3.13, an ARM64 container builder, and the AWS CLI.Setting it up
The A2A protocol contract is host
0.0.0.0, port 9000, "ARM64 container", POST / for the JSON-RPC body, GET /.well-known/agent-card.json, and GET /ping returning {"status": "Healthy"} or "HealthyBusy". Build the image from the Dockerfile with --platform linux/arm64 and tag it chess-a2a:latest; any container builder that takes that flag will do. The Dockerfile, trimmed:1
2
3
4
5
6
7
FROM --platform=linux/arm64 public.ecr.aws/docker/library/python:3.13-slim
...
ENV CHESS_BIND_HOST=0.0.0.0 # explicit; see below
ENV AWS_GENAI_CONTENT_EXTRACTION_OPT_OUT=true # requires ADOT >= 0.18.0; see Observability
ENV UNIFIED_TRACES_DESTINATION_ENABLED=true
ENV CHESS_SERVER=a2a CHESS_AGENT=white
ENTRYPOINT ["/usr/local/bin/chess-entrypoint"]build_a2a_app from bedrock-agentcore supplies both servers' routes; its /ping returns {"status": "Healthy"} and nothing else. The contract page warns: "Do not set time_of_last_update to the current time on every ping", because that "prevents the idle session timeout from ever firing — sessions then persist until MaxLifetime and can exhaust your session quota."Set the bind host yourself. The SDK's container detection looks for
/.dockerenv or $DOCKER_CONTAINER, and not every container builder creates /.dockerenv. The deployed image has neither, so the detection would bind loopback inside the container, where the runtime cannot reach the server and every invocation fails with HTTP 424 RuntimeClientError, with nothing obviously wrong in the logs.One image, three runtimes. White, Black and the replacement Black are three deployments of the same image.
ContainerConfiguration has one member, containerUri, Required: Yes, with no command or entrypoint field, so the image's entrypoint script dispatches on environment: CHESS_SERVER must be a2a or langgraph (anything else exits 2), then exec opentelemetry-instrument "chess-${CHESS_SERVER}-server" --agent "$CHESS_AGENT" --port 9000.Never advertise the bind address. An A2A client sends its RPC to the card's
url, not the address it fetched the card from. The card builder's advertised_url() resolves AGENTCORE_RUNTIME_URL, then --public-url, then CHESS_PUBLIC_URL, then host:port with a wildcard rewritten to loopback, and warns on that last fallback:1
2
bind 0.0.0.0:9000
note binding 0.0.0.0 with no public URL set; the card advertises http://127.0.0.1:9000. If this is a container with a mapped port, set CHESS_PUBLIC_URL ...Deployed,
AGENTCORE_RUNTIME_URL is set by the platform and wins, so this takes care of itself. The warning is there for anything you run with a mapped port, where the published port differs from the container's.Then the deploy. Push the image to Amazon ECR and call
CreateAgentRuntime once per player; agentRuntimeArtifact, agentRuntimeName and roleArn are required, and the name should be framework-neutral because the runtime ARN lands in the card's url. get-agent-runtime on chess_black_alt reads back the ECR chess-a2a:latest image as containerUri, "serverProtocol": "A2A", environmentVariables of CHESS_SERVER=langgraph, CHESS_AGENT=black-langgraph and CHESS_BIND_HOST=0.0.0.0, a lifecycleConfiguration of "idleRuntimeSessionTimeout": 900, "maxLifetime": 28800, and "status": "READY".The execution role is scoped and the trust policy is conditioned. The live AWS Identity and Access Management (IAM) role has one inline policy, matching the policy document we deploy:
ecr:BatchGetImage and ecr:GetDownloadUrlForLayer on the chess-a2a repository only, CloudWatch Logs on /aws/bedrock-agentcore/runtimes/*, X-Ray segments, cloudwatch:PutMetricData, logs:PutResourcePolicy (required by the observability page for the unified span destination), and bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream on foundation-model/* and inference-profile/*. The trust policy restricts bedrock-agentcore.amazonaws.com to this account and Region with aws:SourceAccount and an ArnLike on aws:SourceArn of arn:aws:bedrock-agentcore:us-east-1:123456789012:*.The agent card
InvokeAgentRuntime is SigV4-signed, so a deployed card cannot be opened in a browser; uv run chess-demo card white-discovered fetches it through the AWS API, via a profile holding a search query, not an endpoint (runtime ARN replaced):1
2
3
4
5
6
7
8
agent chess-player-white [discovered via 'an agent that can play chess']
protocol A2A 0.3.0 over JSONRPC, streaming=True
url https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3A...%2Fchess_white-<id>/invocations
auth oauth2 / clientCredentials
chess:move Propose a move — plays a piece on the board.
chess:read Read the position and comment. Cannot move.
skills propose_move requires chess:move; explain_position requires chess:read
absent model, framework, engine, strength — confirmed, nothing leakedWhite's card took 19.4 s, against 3.6 s and 4.2 s for the two Blacks'; we read the gap as the runtime starting. The raw card (
--raw) has twelve top-level fields, among them protocolVersion, securitySchemes (one oauth2 clientCredentials scheme, tokenUrl https://example.invalid/oauth2/token), skills and version (0.1.0). Each skill carries its own security ([{"oauth2": ["chess:move"]}] on propose_move), so a caller sees the two grants before sending anything.The
absent line is the point. chess-demo card scans the serialised card for strands, langgraph, stockfish, claude, gpt-, elo, multipv and depth, and finds none of them. The absence is checked, and all three deployed cards came back the same. One function, build_card, builds the card, and deliberately has no access to a tool registry, so nothing the agent can do reaches the card by accident: it sets version="0.1.0" and protocol_version="0.3.0", and takes skills from chess_skills(only=router.supported), which selects from a fixed catalogue of PROPOSE_MOVE and EXPLAIN_POSITION and raises ValueError for any id outside it, so the card is derived from what the skill router serves and an unknown skill fails at startup. A test asserts that analyse_position, engine_info and stockfish never reach a card.One JSON-RPC call
The arbiter sends per move a
text part with a FEN and legal moves, plus metadata: {"skill": "propose_move"}, because message/send has no skill selector in 0.3.0. chess-demo call adds metadata only with --skill; by default it sends the unlabelled request that DEFAULT_SKILL (see Auth) resolves. uv run chess-demo call white-discovered:1
2
3
4
5
method message/send skill=(default: propose_move)
state completed in 18.1s
artifact agent_response parts=['text']
MOVE: c1g5
WHY: Bg5 is the engine's top choice and the most aggressive, pressuring f6 and preparing Nd5 ...18.1 s on a fresh session; first calls ranged from 17.8 s to 22.0 s, later calls 3.6 to 4.2 s. The read-only skill (
--skill explain_position) answered in 22.0 s with a different artifact, position_analysis. The player's reply began "I'm not able to name a move", then described the position instead. Its handler is a separate agent with no output contract a move could travel through.A deployed endpoint is SigV4-signed, so to show the wire format we ran the same server directly and sent the same
message/send body with curl. /ping answered {"status":"Healthy"}; message/send returned HTTP 200 in 5.6 s:1
2
3
4
5
{"result": {"kind": "task", "id": "0ff5a66f-...", "contextId": "f927c821-...",
"status": {"state": "completed", "timestamp": "2026-10-09T09:44:41.884369+00:00"},
"artifacts": [{"artifactId": "28f32aee-...", "name": "agent_response",
"parts": [{"kind": "text", "text": "M"}, {"kind": "text", "text": "OVE: c"},
{"kind": "text", "text": "3d5\nWHY:"}, ..., {"kind": "text", "text": ""}]}]}}Twenty-three
TextPart chunks for a two-line answer: spec-compliant streaming once the Strands executor is told enable_a2a_compliant_streaming=True. a2a.utils.get_artifact_text joins parts with newlines, giving M\nOVE: c\n3d5\nWHY: ...; the clients concatenate with "" and read artifacts once from the final task, since Task.artifacts is cumulative, which a test pins.One more difference from a self-hosted A2A server, from the contract page : unlike the A2A convention of JSON-RPC errors over HTTP 200, "AgentCore Runtime returns the real HTTP status code (for example, 409 or 404). Parse the JSON-RPC
error body even on non-2xx responses…".The game loop, and the swap
One function,
play_game, is the whole arbiter. Per move it mints a trace (one per turn, see below), builds a MoveRequest carrying the FEN, legal moves, ply and trace, and validates the reply, re-requesting an illegal move up to MAX_MOVE_ATTEMPTS = 3. The swap is a hook asked before the seat is read: replacement = await swap_in(board.ply + 1, board.turn), and a non-None result rebinds the seat and appends a SwapRecord. "Before the seat is read" fixes an off-by-one the first version had. --swap-black-at N is the 1-based move number the replacement plays; the provider fires when colour == "black" and ply >= at_ply, so with White to move an odd N fires at N + 1 (with random players, --swap-black-at 3 printed at ply 4), and a --fen with Black to move flips the parity.With the swap set for Black's fourth move (
uv run chess-game --white white-discovered --black black-discovered --swap-black-at 4 --swap-black-to black-discovered-alt --max-moves 8 --no-viewer), header and rationale lines elided:1
2
3
4
5
6
7
8
9
10
11
6. Bg5 17844ms 6... O-O 18310ms 7. Nd5 4065ms
SWAP black: profile black-discovered -> black-discovered-alt, at ply 4
public agent name unchanged ("chess-player-black") — the caller cannot tell
position handed over: r1bq1rk1/pppp1ppp/2n2n2/2bNp1B1/2B1P3/3P1N2/PPP2PPP/R2QK2R b KQ - 3 7
7... Be7 21163ms 8. h4 4207ms 8... d6 3641ms
9. Nxe7+ 4245ms 9... Qxe7 3721ms
result no result: move cap reached (8 plies)
final fen r1b2rk1/ppp1qppp/2np1n2/4p1B1/2B1P2P/3P1N2/PPP2PP1/R2QK2R w KQ - 0 10
trace ids 4 unique across 8 moves
rejections 0 refused by the arbiter
latency 77196ms total, 9649ms mean per moveWall clock was 81.6 s against a
latency sum of 77.2 s, so discovery and the arbiter added about 4.4 s. Three first calls (17.8, 18.3 and 21.2 s) are 57.3 s of that, 74 percent; the other five moves averaged 3,976 ms. The swap opens a session on a third runtime, so it pays again at ply 4.The run's record shows the swap at Black's fourth move, and records both the replaced and the replacing player under the same public name,
chess-player-black. The position handed over is the one after White's previous move, checked against the move list: the replacement picked up a game it had never seen and played on.Each move also records which runtime answered and in what form, and both change at the swap. The Strands player answers in text, on a
MOVE:/WHY: contract; the LangGraph player answers with a DataPart (6.5.3 ) named chess_move, carrying the move, a rationale, and its own claim to have consulted its private engine, which is a claim rather than something the caller can check. From the swap on, every Black move is recorded as data rather than text.
Figure 3: The arena mid-game in a later run, Black to move at ply 21. Each move records the agent that produced it, its latency and its artifact kind:
text from the Strands White, data from the LangGraph Black that took the seat at the swap. Move 15's two halves carry one trace id, 1-6acd2261-bac053e412a430c7bbbd4d05; move 16 has its own.The arbiter records nothing about a remote player that it cannot know. Give a remote profile a model and the configuration will not load, and the error says where the model belongs: on the profile the server runs, not on the caller's. When the arbiter writes that player out, the model and strength fields are empty, with a note that both are opaque to the caller.
A move is bounded by wall clock, not attempt count. Four retries against a 120 s transport timeout once meant an eight-minute pause in front of an audience.
MoveBudget gives each move one 60 s allowance covering every attempt and backoff, and the AgentCore client sets botocore's read_timeout to that budget.A refusal must not be retried. Both clients parse the JSON-RPC body regardless of HTTP status;
-32054 and -32053 are retried, 400, 401, 403 and 404 are not, and a failed task raises A2ATaskFailed with its status message instead of arriving as an empty move.A slow model looks exactly like a deadlock. The original Black model,
us.openai.gpt-6-astra, went from 4 to 5.5 s per move to over 87 s for 32 tokens in one day, and a 120 s client timeout against a 203 s move presents as a hang with a healthy /ping. Black moved to us.openai.gpt-5.6-sol (one CHESS_MODEL change); both Blacks must run the same model and both engines are capped to the same strength, which a test pins, so nothing here reads as one model outplaying another.Observability: one trace, two runtimes
AgentCore Observability lands spans in CloudWatch only once Transaction Search is on, "a one-time setup" on the observability page ; in the demo account it is
ACTIVE. The agent must run under ADOT: the page requires aws-opentelemetry-distro>=0.18.0 and shows CMD ["opentelemetry-instrument", "python", "main.py"], which entrypoint.sh does. The two Dockerfile variables come from the same page: UNIFIED_TRACES_DESTINATION_ENABLED=true sends spans to the spans stream in /aws/bedrock-agentcore/runtimes/<agent_id>-<endpoint_name> and needs logs:PutResourcePolicy on the execution role; AWS_GENAI_CONTENT_EXTRACTION_OPT_OUT=true "Keeps model payloads and tool request/response data on spans". AWS X-Ray (X-Ray) head sampling still governs what is recorded; the account's Default rule is at 100 percent.Then the caller has to send the trace.
InvokeAgentRuntime has four tracing parameters, traceId (X-Amzn-Trace-Id), traceParent (traceparent), traceState and baggage (API reference ); the arbiter sends two. MoveTrace mints one X-Ray id per turn (1-<8 hex epoch>-<24 hex random>) and a 16-hex span id; as_invoke_kwargs() returns {"traceId": "Root=<id>;Parent=<span>;Sampled=1", "traceParent": "00-<32 hex>-<span>-01"}, the W3C form being the same id without prefix and dashes.The trace is per turn, not per ply, because per-ply ids would put one runtime on every trace; the game printed
4 unique across 8 moves. uv run chess-demo trace picks the swap turn and counts log events per runtime log group, 52 s after the game ended:1
2
3
4
5
trace 6ac8b48cc67228a17a784f94326beb82
chess_black_alt-<id>-DEFAULT 78 events
chess_white-<id>-DEFAULT 121 events
TOTAL 199
console https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#xray:traces/6ac8b48cc67228a17a784f94326beb82199 events, 121 from the Strands White and 78 from the LangGraph Black, on one trace. The gap between them is response shape, not work: White streams its answer, so each chunk costs an artifact append and its queue events, while the LangGraph Black returns one
DataPart. The engine call shows as its own span under each player, 69 ms under White and 348 ms under the LangGraph Black.
Figure 4: CloudWatch trace view for the swap turn, with spans from both runtimes,
chess_white and chess_black_alt, under one trace, and the execute_tool analyse_position span under each player.The span that stole every request
Both players run the same image, but only one of them joined the caller's trace. White's spans did; the LangGraph Black's did not.
Propagation was not the problem. A probe at the ASGI boundary, switched on by
CHESS_TRACE_PROBE=1, showed both containers receiving traceparent and X-Amzn-Trace-Id intact.What differed was what each server already had open when a request arrived. The Strands server had nothing, so the caller's context was picked up and used. The LangGraph server had a trace of its own, and a trace already in progress cannot be re-pointed at the caller's.
Where that trace came from: the LangGraph server opened its MCP engine session with
asyncio.run(serve()) during startup. The MCP initialize call inside it is traced, and it ran in a task that is an ancestor of every later request, so its span stayed current and each request's spans became children of it, carrying its trace id instead of the caller's. Any library that opens a span at startup under asyncio.run can do this to you.The fix is one ASGI middleware on both servers:
1
2
3
4
5
6
7
8
9
10
11
# the ASGI middleware (trimmed)
async def middleware(scope, receive, send):
incoming = extract(_headers(scope))
if trace.get_current_span(incoming).get_span_context().trace_id == _NO_TRACE:
await app(scope, receive, send) # nothing to pin to; do not clobber the runtime
return
token = otel_context.attach(incoming) # replaces any context left current by startup
try:
await app(scope, receive, send)
finally:
otel_context.detach(token)The regression test opens a
startup-leak span with an unrelated trace id, sends a request carrying the caller's traceparent through the middleware, and asserts the app saw only CALLER_TRACE.Auth and identity: where per-skill scoping has to live
An opponent that could invoke
propose_move would be playing your pieces. So there are two scopes: chess:move to propose a move, chess:read to comment on the position. Each skill requires exactly one of them. A request that names no skill is treated as a move request (DEFAULT_SKILL), so it has to clear the higher bar instead of passing as read-only.Enforcement is in the agent, because the platform gates the runtime. The inbound JWT authoriser's
CustomJWTAuthorizerConfiguration has allowedScopes, and the Developer Guide gives the semantics: "If configured, at least one scope value in the incoming token must match one of the configured values." That is an OR across the whole runtime: a token carrying only chess:read passes a runtime whose allowedScopes is ["chess:move", "chess:read"], so the platform authoriser is the outer gate only.The token arrives in the request headers, but which skill is being asked for is only known once the body is parsed. So the check is split in two:
require_bearer validates the token at the door and stashes its claims in a context variable the rest of the request can read, and authorize_skill checks those claims against the skill after routing:1
2
3
4
5
6
7
8
9
10
11
12
# the skill router (trimmed; newer than the deployed image)
class SkillRouter(AgentExecutor):
def __init__(self, move_executor, *, policy=None, explain=None):
self._handlers = {"propose_move": self._propose_move} # an explicit table
if explain is not None:
self._handlers["explain_position"] = self._explain
async def execute(self, context, event_queue):
skill = resolve_skill(context.message.metadata if context.message else None)
authorize_skill(skill, self.policy) # AuthError -> self._refuse(...), a failed task
handler = self._handlers.get(skill) # None -> self._unsupported(...); never a default
...The first version inferred the route, treating "not explain" as "must be a move". That was a real escalation: on the LangGraph server, which has no explain handler, a
chess:read token naming explain_position passed the read-scope check, fell through, and proposed a move. A router that infers its default is a security boundary that fails open, and a regression test now asserts that a read-only caller cannot reach the move path on a move-only agent. The fix came after the image that is deployed.Two more fail-open defaults, fixed later: a JWKS issuer without
CHESS_JWT_AUDIENCE is refused at startup, and duplicate Authorization headers are a 400. Unknown skills fail closed, and tests cover all three.Enforcement is opt-in and off by default; the deployed runtimes have no inbound authoriser and are reached over SigV4. To show the refusal we ran the same server with the development HS256 issuer (
issuer https://chess-demo.invalid, aud UNCHECKED) against a random-stand-in Black and sent two tokens. With chess:move chess:read the game played; with chess:read only:1
chess_a2a.agents.a2a_player.A2APlayerError: chess-player-white@a2a: A2A call failed after 1 attempt(s) in 0.0s of 60s budget: ["A2ATaskFailed: forbidden: token for commentator lacks scope 'chess:move' required by skill 'propose_move'; it carries ['chess:read']"]The reason names scope, skill and token; it arrived as a
failed task and took 0.0s of the 60 s budget, because A2ATaskFailed is not retryable. With no token, the ASGI door answers HTTP/1.1 401 Unauthorized with www-authenticate: Bearer scope="chess:move chess:read" before any body is parsed.Turning on the platform authoriser changes the transport. A runtime takes either IAM SigV4 or JWT bearer tokens, "but not both simultaneously" (Runtime OAuth ), and a JWT-authorised runtime expects the token in an
Authorization header. So the signed client the arbiter uses has to be swapped for an HTTP client that sends Authorization: Bearer.Discovery: a question instead of an address
The
white-discovered profile holds no endpoint, only kind = "registry", registry_id, search_query = "an agent that can play chess", record_name = "chess-player-white" and region = "us-east-1". A runtime_arn on a registry profile is rejected at load; record_name is a tie-break after the search. uv run chess-demo search:1
2
3
4
5
6
query "an agent that can play chess"
matched 3 agent(s)
record agent skills runtime
chess-player-black chess-player-black ['propose_move', 'explain_position'] chess_black-<id>
chess-player-white chess-player-white ['propose_move', 'explain_position'] chess_white-<id>
chess-player-black-alt chess-player-black ['propose_move'] chess_black_alt-<id>The search returned three records in 1.4 s. The replacement's record,
chess-player-black-alt, publishes the agent name chess-player-black, identical to the Strands Black's, so the swap is invisible by name. The cards do differ, in defaultOutputModes and skill count, so the claim is narrower than it looks: a caller cannot tell the two apart by name.A record is more than an address: the Agent Registry concepts page says a record "contains an agent card that describes the agent’s capabilities, skills, and communication interface per the A2A (Agent-to-Agent) protocol specification" and "AWS Agent Registry validates agent records against the A2A protocol schema to ensure correctness." So
resolve_one can refuse an unfit agent before invoking it: it keeps the candidates whose skills include the required skill and, if none do, raises RegistryError naming the query, the missing skill and what each record offers. The same page: consumers "only see approved records", with IAM or JWT inbound authorisation. This registry is IAM, so the arbiter searches with the SigV4 credentials it already has and needs no identity provider.A card is data somebody else controls. The runtime ARN is percent-encoded into the card's
url, so runtime_arn_from_card_url checks the decoded ARN rather than trusting it: service bedrock-agentcore, partition aws, a runtime/ resource, and a Region matching expect_region, each failure raising RegistryError. _same_account_only then refuses records from another account unless allow_cross_account=True, and keeps them with a warning if AWS Security Token Service (AWS STS) is unreachable. Before these checks, a card naming a runtime in another account was accepted; a test now rejects it.When to use this, and when not to
This is a mechanism demo, not a domain analogue: two chess agents owned by one team is the case where A2A is overhead. AgentCore Memory and AgentCore Gateway are out of scope: each move is answered from the request alone, and Gateway enters only as the place a buyer would front this agent as a tool.
A2A is how you cross a framework or organisational boundary, not how you build a multi-agent application you own end to end. The Well-Architected Agentic AI Lens puts the rule as "Communication pattern follows interaction shape." (AGENTREL01-BP03 ): A2A fits "when agents need peer collaboration with streaming responses or multi-turn exchanges." AGENTPERF04-BP02 states the outcome.
Use A2A when:
- You are crossing a framework or organisational boundary: the orchestrator is not on your platform, in your repo or in your framework.
- The caller is someone you do not control. You publish a card and the caller decides.
- The work outlives its caller, which is what A2A's task lifecycle is for.
Use something else when:
- One team owns every agent in one repo. In-process orchestration in Strands or LangGraph is simpler.
- Your buyer can write your output schema in advance. Then you are a tool, and an MCP tool with an
inputSchemaandoutputSchemais the smaller surface to ship. Opacity does not decide this either way: an MCP server is also a black box. - Your buyer has no platform team. If your buyer runs an MCP gateway, shipping MCP first has the lower adoption bar, as How enterprises will hire your agent: MCP or A2A argues.
That post's four questions for a seller:
- Can your buyer write your output schema in advance? If yes, you are a tool. If determining the schema is what they pay you for, you are an agent.
- Does your work need to survive the caller disconnecting? If so, A2A's task lifecycle is the fit.
- Do you need to ask the buyer's system something mid-call? No longer an automatic A2A answer; check which MCP revision the buyer's gateway runs.
- What is your buyer's integration budget? If it is effectively zero, this question can override the other three.
Conclusion
A2A gave this implementation the two things that matter when the caller is not yours: a card that declares each skill and the scope it needs while saying nothing about what is behind them, and a response shape loose enough that a text answer and a data answer are both valid. That is what let Black change framework mid-game without the arbiter being told.
Per-skill authorisation is code you write, because
message/send has no skill selector in 0.3.0 and the platform authoriser gates the whole runtime. Implementing A2A was the straightforward part. The work went into getting the container reachable, keeping one trace across both runtimes, and checking a scope per skill.Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article