# 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.

- Source: https://oharu121.com/blog/a2a-sdk-v1-migration-crewai-agent-cloud-run-typescript-client/
- Published: 2026-11-01T12:00:00+09:00
- Tags: A2A, CrewAI, Python, Google Cloud, TypeScript, AI Agent

---
## Introduction

In [the AIMock article](/blog/aimock-deterministic-testing-ai-agent-llm-vectordb-a2a/),
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](https://www.a2a-registry.org/playground),
  a playground for talking to registered agents
- **Google codelab**: [Purchasing Concierge](https://codelabs.developers.google.com/intro-a2a-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 exists | What does not yet |
| --- | --- |
| Hello world / echo agents | Production A2A endpoints published by real companies |
| The Google codelab demo | A2A APIs from the likes of Stripe or Twilio |
| Community toy agents | An 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](https://codelabs.developers.google.com/intro-a2a-purchasing-concierge)
codelab.

### Cloning the repo and adjusting it

```bash
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**

```toml title="pyproject.toml"
requires-python = ">=3.13"
```

```dockerfile title="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.

```bash
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](https://github.com/a2aproject/A2A/blob/main/docs/whats-new-v1.md) 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.

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

| Change | Where 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` → `SendMessage` | The method is named after the proto's `rpc SendMessage` |
| `url` → `supported_interfaces` | Each 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](https://github.com/a2aproject/A2A/tree/v0.2.5/specification); that
spec's send method is `message/send`. The main changes:

| Item | v0.2 | v1.0 |
| --- | --- | --- |
| Server construction | `A2AStarletteApplication` class | `create_jsonrpc_routes()` / `create_agent_card_routes()` route factories |
| AgentCard | `url` field | `supported_interfaces` list (`AgentInterface`) |
| RequestHandler | no `agent_card` | `agent_card` is required |
| Part construction | `Part(root=TextPart(text="..."))` | `Part(text="...")` (Protobuf-based) |
| JSON-RPC method | `message/send` | `SendMessage` |
| Agent card path | `/.well-known/agent.json` | `/.well-known/agent-card.json` |
| Protocol version | none (implicitly 0.3) | an `A2A-Version: 1.0` header is required |
| Error type | `ServerError` | use `UnsupportedOperationError` directly |

**`__main__.py`, server construction:**

```python title="__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:**

```python title="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`.

```python title="agent.py" del={1,4} ins={2,5}
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

```bash
# 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:

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

Sending a message over A2A v1.0:

```bash
$ 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:

```json
{
  "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 `/`.

> **HEADS UP**
>
> Starlette is a Python ASGI web framework, and uvicorn is the server that runs a
> Starlette app. Together they make an HTTP server that keeps listening on a port.
> Cloud Run runs exactly that kind of container; Cloud Functions only calls a
> single function per request, which leaves no room for a whole server with its own
> routes.

```bash
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:

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

Checking it:

```bash
$ 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"}}}'
```

```json
{
  "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

```ts title="src/buyer-agent/a2a-client.ts" ins={4,15,18,19,30,36}
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](https://github.com/a2aproject/A2A/tree/v0.1.0/specification).

| Item | v0.1 | v1.0 |
| --- | --- | --- |
| Agent card path | `/.well-known/agent.json` | `/.well-known/agent-card.json` |
| JSON-RPC method | `tasks/send` | `SendMessage` |
| Role values | `"user"` / `"agent"` | `"ROLE_USER"` / `"ROLE_AGENT"` |
| Part structure | `{ type: "text", text: "..." }` | `{ text: "..." }` |
| Header | none | `A2A-Version: 1.0` |
| Response | `result` 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:

```ts
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

- [A2A Protocol](https://a2a-protocol.org)
- [What's New in A2A Protocol v1.0](https://github.com/a2aproject/A2A/blob/main/docs/whats-new-v1.md)
- [Google codelab: A2A Purchasing Concierge](https://codelabs.developers.google.com/intro-a2a-purchasing-concierge)
- [A2A Registry](https://www.a2a-registry.org/)
