DEV Community

Strands on AgentCore, Answering Two Other Clouds: The Contract, the Header, and the microVM

Strands ships no A2A server integration, which turns out to be the easiest starting position of the three frameworks I used: you drop the agent behind the a2a-sdk reference routes and you are serving the protocol's own implementation. The hard part was never Strands. It was AgentCore Runtime - a container contract that is narrow, mostly undocumented in one place, and different from every other runtime in the mesh. I ran that agent alongside a Google ADK agent on Cloud Run and a Microsoft Agent Framework agent on Container Apps, all three answering the same research brief over A2A v1.0 to one coordinator. Everything below is the AWS side of it. The code: github.com/xbill9/multicloud-a2a-subagent. The contract, in one place Every clause here is load-bearing, and each one differs from what the other two runtimes want: | AgentCore Runtime | Cloud Run | Container Apps | | |---|---|---|---| | port | 9000 | $PORT , 8080 | 8080 | | invoke path | / | yours | yours | | health | GET /ping โ†’ {"status": "Healthy"} | yours | yours | | architecture | ARM64, required | any | amd64 | | card | GET /.well-known/agent-card.json | same | same | Port 9000, not 8080 - 8080 is the HTTP protocol's port. Path / , not /invocations - that is the path the platform exposes to your callers, not the one your container serves. A Starlette app that already serves JSON-RPC at / and the card at the well-known path satisfies this unmodified. /ping is the one you have to add, and it is not your existing /health . A container that fails the ping never reaches the point of being invoked. One detail worth copying: my /ping deliberately omits time_of_last_update . A timestamp that advances on every poll reads as a continuous status change, which stops the idle session timeout from ever firing and leaks sessions until MaxLifetime. AgentCore drops the A2A-Version header This is the finding I would most want another AWS builder to have for free. a2a-sdk reads the protocol version from an A2A-Version request header. When the header is absent it assumes 0.3 , and then rejects the request its own handler cannot serve: A2A version '0.3' is not supported by this handler. Expected version '1.0'. Cloud Run and Container Apps forward that header untouched. AgentCore does not. So the same client, the same a2a-sdk on both ends, and the same server code succeed on two clouds and fail on the third - with an error that blames the protocol version and names nothing about the platform that removed it. The fix is a middleware that fills the header when it is missing, and only when it is missing: if VERSION_HEADER.lower() not in {k.lower() for k in request.headers}: request.scope["headers"] = [ request.scope["headers"], (VERSION_HEADER.lower().encode(), PROTOCOL_VERSION_CURRENT.encode()), ] A header that says 0.3 is a real client statement and should still be rejected. An absent header is not evidence of an old client; it is no evidence at all. It had also been latent for a week. My deployed image predated the version check in a2a-sdk , so the leg had been green for a reason that stopped being true the moment I rebuilt it onto a current SDK. A session is a microVM, and you are probably minting one per call My coordinator generated a fresh AgentCore session id for every call. AgentCore gives each session its own microVM, so every call was paying for a microVM start. It presented as a fixed per-client cost - one client's cell was always slow - until I noticed the slow cell moved between clients. A fixed per-client cost cannot move. Something per-call can: google-adk โ†’ the AWS agent | runs | measured | |---|---|---| | fresh session id per call (default) | 5 | 5953, 5970, 5926, 5984, 6037ms | | session id pinned | 2 | 710, 704ms | The two conditions were interleaved in time, so this is not warming drift. Releasing the pin brings it straight back, and the whole column drops with the pin because none of the cells is paying for a new microVM. Set your session id explicitly unless you actually want per-call isolation. What is not established here: why cold capacity lands on one call rather than another, or how many warm sessions AgentCore keeps. Only the cause of the latency is proven. Least privilege: discovery is a separate action The predecessor to this project left an open question: scoping bedrock-agentcore:InvokeAgentRuntime to runtime/ and runtime/ / was denied 403 on the agent-card fetch, and only Resource: "" worked. That is an unpleasant thing to leave in a policy. The resource scope was never the problem. Discovery is a separate action. This policy works - card fetch and invocation both 200, no wildcard resource anywhere: { "Action": ["bedrock-agentcore:InvokeAgentRuntime", "bedrock-agentcore:GetAgentCard"], "Resource": ["arn:aws:bedrock-agentcore:us-west-2: :runtime/research_aws- ", "arn:aws:bedrock-agentcore:us-west-2: :runtime/research_aws- /"] } A policy granting only InvokeAgentRuntime denies the card fetch however the resources are written. Widening to Resource: "*" appeared to fix it because a wildcard with the wrong action set still fails - something else in that policy differed. Honest limit on the claim: the original failing policy lives in another repo and is not in hand, so "the missing element was the action" is a strong inference from a live measurement rather than a diff of the two. The general shape holds on all three clouds: discovery is privileged separately from invocation, and a credential that reaches the call but not the card fails somewhere that looks nothing like auth. Which is why the credential in this project is attached to the httpx client rather than to a single request. Calling Bedrock from another cloud with no access key The coordinator runs on Google Cloud and calls this agent. There is no AWS access key anywhere in it. The chain: Cloud Run mints a workload OIDC token for an audience you choose โ†’ the coordinator presents that token to STS AssumeRoleWithWebIdentity โ†’ temporary credentials come back โ†’ the request is signed with SigV4. Four traps, each of which looks like correct configuration: AWS federates with accounts.google.com natively. Creating an explicit IAM OIDC identity provider for it breaks federation with InvalidIdentityToken . Azure's Entra is the opposite - there you must create one. Same-looking task, opposite rules. The condition keys do not mean what they are named. accounts.google.com:oaud is the token's aud . accounts.google.com:aud is the token's azp , which is a number. Putting an audience string in :aud can never match. Audience alone is not authorization. The audience is caller-chosen, so an audience-only condition proves only that some identity in that IdP minted the token. Pin the subject too, using the immutable numeric ID rather than an email, which can be freed and re-bound. InvalidIdentityToken and AccessDenied are different worlds. The first means the token could not be validated at all - a provider-setup problem. The second means it validated fine and your conditions did not match - a policy problem. Nothing else in the response tells you which one you are in, and this single distinction has saved me more time than the federation work itself took. One habit follows from that, and it is worth more than the rest: log the raw provider response at every auth boundary. In an agent system an error comes back as a tool result, and a model in the middle will happily paraphrase AccessDenied: condition ... did not match into "there was an issue with the credentials." A raised message is not an observable. That decision is what eventually answered the GetAgentCard question above - AWS had been naming the missing action in the response body all along, and the earlier adapter kept the status code and threw the body away. Strands is the one framework that gives you a seam Worth saying, because it is a real advantage and it only becomes visible next to the other two. agent = Agent( model=BedrockModel(model_id="us.amazon.nova-micro-v1:0"), system_prompt=INSTRUCTION, tools=[tool(web_search)], ) async def respond(prompt: str) -> str: return str(await agent.invoke_async(prompt)) That async (prompt) -> reply function is a boundary you can wrap from outside - provenance stamping, search accounting, error typing, all of it layered on without touching the agent. Neither of the other two frameworks has one. ADK's to_a2a() takes an agent and serialises its event stream; Agent Framework's A2AExecutor calls the agent too. On both of those, anything you want to do between the model and the wire has to be done inside that framework's own object model. The one place Strands costs you something: it bundles no web search. strands-agents-tools is a separate distribution and carries http_request , not a search API. Since giving one cloud a retrieval product the others lack would have turned my model comparison into a comparison of search backends, all three clouds got the same plain function - bound through Strands' own @tool decorator, which is genuinely a different tool-call implementation from the other two and is the part worth measuring. How Nova micro actually did Twenty-four briefs, each answered by all three clouds, scored twice - once by a deterministic rubric, once by re-ranking the same stored drafts with a model judge: | cloud / model | availability | win% rubric โ†’ judge | regret rubric โ†’ judge | |---|---|---|---| aws / nova-micro | 100% | 33% โ†’ 0% | 1.32 โ†’ 9.38 | azure / gpt-5-mini | 96% | 43% โ†’ 87% | 0.97 โ†’ 0.52 | gcp / gemini-2.5-flash | 58% | 43% โ†’ 43% | 1.54 โ†’ 2.21 | Take the good news and the bad news in that order, because both are real. Bedrock was the only leg that answered every single time. 100% availability, where Gemini managed 58% - it hit Vertex quota - and Azure 96%. On this corpus the most valuable property any participant had was producing an answer at all, and the AWS leg is the only one that never failed to. It was also consistently the fastest brain in the mesh. And it wrote the worst drafts, by a marg

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.