Moving Google's A2A codelab agent to a2a-sdk v1.0 and Cloud Run

Migrating the CrewAI burger agent from Google's A2A codelab to a2a-sdk v1.0, deploying it to Cloud Run, and connecting a TypeScript client to it.

The Agent2Agent logo, blue A-2-A circles beside the Agent2Agent wordmark in blue on a white card
On this page

Introduction

In the AIMock article, the buyer agent’s tests ran against a mocked A2A seller agent. This article takes the same TypeScript client to a real one: the burger agent from Google’s A2A codelab, moved from a2a-sdk v0.2 to v1.0 and deployed to Cloud Run.

The state of the A2A ecosystem

While researching the protocol I went looking for public agents you can freely interact with.

What exists

  • Hello World Agent: https://hello-world-gxfr.onrender.com/.well-known/agent.json. The first public A2A agent. Echo responses only, but useful for checking a client works
  • A2A Registry Playground: a2a-registry.org/playground, a playground for talking to registered agents
  • Google codelab: Purchasing Concierge, a hands-on that deploys two agents to Cloud Run and has them talk over A2A. Runs inside the free tier

Demo agents exist, production endpoints do not

What existsWhat does not yet
Hello world / echo agentsProduction A2A endpoints published by real companies
The Google codelab demoA2A APIs from the likes of Stripe or Twilio
Community toy agentsAn agent marketplace with genuinely useful capabilities

A2A has 150+ participating organizations and over 22,000 GitHub stars, but there are almost no useful public agents you can freely interact with. Enterprise A2A adoption (Azure AI Foundry, Bedrock AgentCore) sits almost entirely behind authentication.

Running a real A2A agent from the Google codelab locally

The demo tests use a mocked A2A seller agent written with http.createServer. The next step was to run a real A2A agent locally: the one from Google’s Purchasing Concierge codelab.

Cloning the repo and adjusting it

Terminal window
git clone https://github.com/alphinside/purchasing-concierge-intro-a2a-codelab-starter.git
cd purchasing-concierge-intro-a2a-codelab-starter/remote_seller_agents/burger_agent

This burger agent is an A2A seller agent built on CrewAI, LiteLLM, and Vertex AI Gemini. It takes burger orders from a user and processes them.

The starter repo needed these changes before it would run:

1. Move to Python 3.13

pyproject.toml
requires-python = ">=3.13"
Dockerfile
FROM python:3.13-slim

2. Add litellm as an explicit dependency

agent.py does import litellm, but litellm was not in the pyproject.toml dependencies. It used to arrive as a transitive dependency of CrewAI; newer versions have dropped it.

Terminal window
uv add litellm

Migrating a2a-sdk v0.2 to v1.0

The original code targets a2a-sdk v0.2, but the >=0.2.16 constraint installs v1.1.2, whose breaking changes produce import errors. I updated the code for v1.0.

Why v1.0 changed so much

Protobuf (Protocol Buffers) is Google’s format for defining message types. You describe them once in a .proto schema, and a generator produces matching classes for each language. The same message can travel as binary over gRPC or as JSON.

In v1.0, A2A promoted a2a.proto from a gRPC-specific file to the normative source of truth for all three bindings: JSON-RPC, REST, and gRPC. a2a-sdk v1.0 followed by replacing its Pydantic models with types generated from that file. Most of v1.0’s breaking changes trace back to this one decision.

a2a.proto as the source every consumer is derived fromAt the top, a2a.proto, the single source of truth, with three lines: message Part with a oneof content, enum Role with ROLE_USER, and rpc SendMessage. It fans out into three cards. The Python a2a-sdk gets generated classes, so a part is built as Part(text="...") and the role is Role.ROLE_AGENT. The JSON-RPC binding sends ProtoJSON over HTTP, with the method SendMessage and the role ROLE_USER. The gRPC binding sends binary over HTTP/2 using rpc SendMessage. REST carries the same messages as JSON.a2a.protoThe single source of truth for every bindingmessage Part { oneof content … }enum Role { ROLE_USER = 1; … }rpc SendMessage(…)generate typesdefine bindingsa2a-sdk (Python)Generated classesPart(text="...")Role.ROLE_AGENTJSON-RPCProtoJSON over HTTP"method": "SendMessage""role": "ROLE_USER"gRPCBinary over HTTP/2rpc SendMessageROLE_USER = 1REST carries the same messages as JSON too.

Every consumer is derived from the same proto, which is why the same names turn up in the Python SDK and on the wire.

ChangeWhere it comes from
Part(root=TextPart(text="...")) → Part(text="...")Part is a proto message whose content is a oneof, so the field is set directly
"user" → "ROLE_USER"Enum values changed to SCREAMING_SNAKE_CASE to comply with ProtoJSON
message/send → SendMessageThe method is named after the proto’s rpc SendMessage
url → supported_interfacesEach binding is declared as its own AgentInterface

Updating the code for v1.0

The codelab pins a2a-sdk>=0.2.16, which implements A2A v0.2; that spec’s send method is message/send. The main changes:

Itemv0.2v1.0
Server constructionA2AStarletteApplication classcreate_jsonrpc_routes() / create_agent_card_routes() route factories
AgentCardurl fieldsupported_interfaces list (AgentInterface)
RequestHandlerno agent_cardagent_card is required
Part constructionPart(root=TextPart(text="..."))Part(text="...") (Protobuf-based)
JSON-RPC methodmessage/sendSendMessage
Agent card path/.well-known/agent.json/.well-known/agent-card.json
Protocol versionnone (implicitly 0.3)an A2A-Version: 1.0 header is required
Error typeServerErroruse UnsupportedOperationError directly

__main__.py, server construction:

__main__.py
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.types import AgentCard, AgentInterface, AgentCapabilities, AgentSkill
from starlette.applications import Starlette
agent_card = AgentCard(
name="burger_seller_agent",
description="Helps with creating burger orders",
version="1.0.0",
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[
AgentInterface(protocol_binding="JSONRPC", url=agent_host_url)
],
skills=[skill],
)
request_handler = DefaultRequestHandler(
agent_executor=BurgerSellerAgentExecutor(),
task_store=InMemoryTaskStore(),
agent_card=agent_card, # required in v1.0
)
routes = []
routes.extend(create_agent_card_routes(agent_card))
routes.extend(create_jsonrpc_routes(request_handler, "/"))
app = Starlette(routes=routes)
uvicorn.run(app, host=host, port=port)

agent_executor.py, Protobuf-based types:

agent_executor.py
from a2a.types import Message, Part, Role, Task
from a2a.utils.errors import UnsupportedOperationError
class BurgerSellerAgentExecutor(AgentExecutor):
async def execute(self, context, event_queue):
query = context.message.parts[0].text if context.message else ""
result = await self.agent.invoke(query, context.context_id)
# v1.0: construct directly with Part(text="..."), no TextPart wrapper
await event_queue.enqueue_event(
Message(
role=Role.ROLE_AGENT,
parts=[Part(text=str(result))],
message_id=context.task_id or "msg",
context_id=context.context_id,
)
)

agent.py, going async:

CrewAI’s kickoff() is synchronous, but in a2a-sdk v1.0 the request handler is called from an async context. Calling a sync method inside an async context raises Agent execution was invoked synchronously from within a running event loop.

agent.py
def invoke(self, query, sessionId) -> str:
async def invoke(self, query, sessionId) -> str:
# ...
response = crew.kickoff(inputs)
response = await crew.kickoff_async(inputs)
return response

Confirming it works

Terminal window
# the Vertex AI API has to be enabled
gcloud services enable aiplatform.googleapis.com
# start it locally
uv run . --host 127.0.0.1 --port 8080

Checking the agent card:

Terminal window
$ curl http://127.0.0.1:8080/.well-known/agent-card.json | jq .name
"burger_seller_agent"

Sending a message over A2A v1.0:

Terminal window
$ curl -X POST http://127.0.0.1:8080/ \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{"jsonrpc":"2.0","id":"1","method":"SendMessage",
"params":{"message":{"role":"ROLE_USER",
"parts":[{"text":"What burgers do you have?"}],
"messageId":"m1","contextId":"ctx1"}}}'

The response:

{
"result": {
"message": {
"role": "ROLE_AGENT",
"parts": [{
"text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. Do any of these sound good?"
}]
}
},
"id": "1",
"jsonrpc": "2.0"
}

The whole path works: Vertex AI Gemini → CrewAI → A2A v1.0.

Deploying to Cloud Run

With it working locally, the next step is Cloud Run. This burger agent is a Starlette ASGI server started by uvicorn, which makes Cloud Run the right fit rather than Cloud Functions. The agent serves several Starlette routes: /.well-known/agent-card.json and the JSON-RPC endpoint at /.

Terminal window
gcloud run deploy burger-agent \
--source . \
--port 8080 \
--allow-unauthenticated \
--region us-central1 \
--min-instances 0 \
--max-instances 1 \
--memory 1Gi

--source . runs a Dockerfile build on Cloud Build, stores the image in Artifact Registry, and deploys it to Cloud Run.

After deploying, set HOST_OVERRIDE to the service URL so the agent card’s url field points at Cloud Run:

Terminal window
gcloud run services update burger-agent \
--region us-central1 \
--set-env-vars HOST_OVERRIDE=https://burger-agent-XXXXXXXXXX.us-central1.run.app

Checking it:

Terminal window
$ curl https://burger-agent-XXXXXXXXXX.us-central1.run.app/.well-known/agent-card.json | jq .name
"burger_seller_agent"
$ curl -X POST https://burger-agent-XXXXXXXXXX.us-central1.run.app/ \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{"jsonrpc":"2.0","id":"1","method":"SendMessage",
"params":{"message":{"role":"ROLE_USER",
"parts":[{"text":"What burgers do you have?"}],
"messageId":"m1","contextId":"ctx1"}}}'
{
"result": {
"message": {
"role": "ROLE_AGENT",
"parts": [{
"text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. What would you like to order?"
}]
}
}
}

On cost: with --min-instances 0 the service scales to zero when idle, so an idle demo costs nothing. The price is a 10–15 second cold start, which is fine for a demo or test.

Bringing the TypeScript buyer agent to A2A v1.0

To connect to the real seller agent on Cloud Run, the TypeScript A2A client needed the v1.0 treatment too.

Changes to the A2A client

src/buyer-agent/a2a-client.ts
export class A2AClient {
// v1.0: agent-card.json (was: agent.json)
async discover(): Promise<AgentCard> {
const url = `${this.agentUrl}/.well-known/agent-card.json`;
const res = await fetch(url);
this.card = await res.json();
return this.card;
}
// v1.0: SendMessage plus the A2A-Version header
async sendMessage(text: string, contextId?: string): Promise<A2AMessage> {
const rpcRequest = {
jsonrpc: "2.0",
id: messageId,
method: "SendMessage", // was: "tasks/send"
params: {
message: {
role: "ROLE_USER", // was: "user"
parts: [{ text }], // was: { type: "text", text }
messageId,
contextId,
},
},
};
const res = await fetch(this.agentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"A2A-Version": "1.0", // required in v1.0
},
body: JSON.stringify(rpcRequest),
});
const rpcResponse = await res.json();
return rpcResponse.result.message; // was: result (a task object)
}
}

v0.1 to v1.0 changes, summarised

My TypeScript client predated the SDK’s version: tasks/send, { type: "text" } and agent.json are A2A v0.1.

Itemv0.1v1.0
Agent card path/.well-known/agent.json/.well-known/agent-card.json
JSON-RPC methodtasks/sendSendMessage
Role values"user" / "agent""ROLE_USER" / "ROLE_AGENT"
Part structure{ type: "text", text: "..." }{ text: "..." }
HeadernoneA2A-Version: 1.0
Responseresult is a Task (array of messages)result.message is a single Message

Connecting to the real Cloud Run agent

I confirmed the TypeScript A2A client talking to the real burger agent on Cloud Run:

const client = new A2AClient("https://burger-agent-XXXXXXXXXX.us-central1.run.app");
const card = await client.discover();
// → Name: burger_seller_agent, Skills: ["create_burger_order"]
const reply = await client.sendMessage("What burgers do you have?");
// → role: ROLE_AGENT
// → "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, ..."

The mocked tests and the real agent run through the same A2A client code and the same v1.0 protocol. Tests point at AIMock’s mock server; production points at the real Cloud Run agent. Only the URL changes.

Summary

  • A2AStarletteApplication is gone. Use the route factories (create_jsonrpc_routes / create_agent_card_routes) inside a standard Starlette app
  • The type system moved from Pydantic to Protobuf. Construct directly, as in Part(text="...")
  • The agent card path changed from agent.json to agent-card.json
  • The JSON-RPC method changed from message/send to SendMessage, and the A2A-Version: 1.0 header is required
  • An A2A agent is a Starlette ASGI server, so it deploys to Cloud Run (a Docker container), not Cloud Functions (a single-function handler model)
  • On Cloud Run, the HOST_OVERRIDE environment variable sets the agent card’s public URL
  • The TypeScript A2A client needs the same v1.0 treatment: endpoint path, method name, role values, header
  • Because mocks and the real agent share the same client code and protocol, switching between test and production is a matter of changing a URL

References

Share this article