Learn

Tutorial: build an A2A server on Cloudflare Workers

Build a minimal A2A v1.0 agent on Cloudflare Workers: Agent Card, JSON-RPC SendMessage and GetTask, a multi-turn task and correct error codes, tested with curl.

An A2A server on Cloudflare Workers needs two routes: GET /.well-known/agent-card.json, which returns the Agent Card, and a POST endpoint that speaks JSON-RPC 2.0 and implements the A2A methods. The Worker below does both in about 140 lines of TypeScript with no dependencies. It handles SendMessage, GetTask and CancelTask, runs a multi-turn task through TASK_STATE_INPUT_REQUIRED, and returns the specification’s error codes for everything it doesn’t support.

Every command and output in this tutorial was run on September 26, 2026 with Wrangler 4.141 and wrangler dev, against the A2A v1.0 specification (latest release v1.0.1, protocol version string 1.0). The code keeps tasks in memory, which is fine for local testing and wrong for production; the last section explains what to change.

What you will build

An illustrative order-status agent. A client agent sends an order number, as text or as a structured data part, and gets the shipping status back as an artifact. If the message has no order number, the task pauses in TASK_STATE_INPUT_REQUIRED and asks for one, and the client answers on the same task.

Route Purpose
GET /.well-known/agent-card.json The Agent Card, with the endpoint URL built from the request’s origin
POST /a2a JSON-RPC 2.0: SendMessage, GetTask, CancelTask, and errors for the rest

Step 1: Create the project

mkdir a2a-order-status && cd a2a-order-status
npm init -y
npm install --save-dev wrangler
mkdir src

Create wrangler.jsonc:

{
  "name": "a2a-order-status",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-26"
}

Step 2: Write the Worker

Save this as src/index.ts:

// A minimal A2A v1.0 agent (JSON-RPC binding) on Cloudflare Workers.
type Part = { text?: string; data?: unknown; mediaType?: string };
type Message = { messageId: string; role: string; parts: Part[]; taskId?: string; contextId?: string };
type Artifact = { artifactId: string; name: string; parts: Part[] };
type Status = { state: string; message?: Message; timestamp: string };
type Task = { id: string; contextId: string; status: Status; artifacts?: Artifact[]; history?: Message[] };

// Demo data. A real agent would call your order system here.
const ORDERS: Record<string, string> = {
  "A-1001": "Shipped on 2026-09-24. Expected delivery 2026-09-29.",
  "A-1002": "Packed. Not shipped yet.",
};
const TERMINAL = ["TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED"];
const tasks = new Map<string, Task>(); // In memory: fine for `wrangler dev`, not for production.

function agentCard(origin: string) {
  return {
    name: "Order Status Demo",
    description: "Looks up the shipping status of an order by its order number. Demo data only.",
    version: "0.1.0",
    supportedInterfaces: [{ url: `${origin}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0" }],
    capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false },
    defaultInputModes: ["text/plain", "application/json"],
    defaultOutputModes: ["text/plain", "application/json"],
    skills: [{
      id: "order-status", name: "Order status",
      description: 'Send an order number as text or as a data part {"orderId": "A-1001"}. Without one, the task asks for it.',
      tags: ["orders", "shipping"],
      examples: ["Where is order A-1001?", '{"orderId":"A-1002"}'],
    }],
  };
}

const ok = (id: unknown, result: unknown) => Response.json({ jsonrpc: "2.0", id, result });
const fail = (id: unknown, code: number, message: string) =>
  Response.json({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
const now = () => new Date().toISOString();

function findOrderId(parts: Part[]): string | undefined {
  for (const p of parts) {
    const data = p.data as { orderId?: unknown } | undefined;
    if (typeof data?.orderId === "string") return data.orderId;
    const match = p.text?.match(/\b[A-Z]-\d{4}\b/);
    if (match) return match[0];
  }
}

// One step of work each time a message arrives on a task.
function work(task: Task, msg: Message) {
  task.history!.push(msg);
  const orderId = findOrderId(msg.parts);
  if (!orderId) {
    const ask: Message = { messageId: crypto.randomUUID(), role: "ROLE_AGENT", taskId: task.id,
      contextId: task.contextId, parts: [{ text: "Which order? Send the order number, for example A-1001." }] };
    task.history!.push(ask);
    task.status = { state: "TASK_STATE_INPUT_REQUIRED", message: ask, timestamp: now() };
    return;
  }
  const status = ORDERS[orderId] ?? "No order with that number.";
  const parts = [{ text: `${orderId}: ${status}` }, { data: { orderId, found: orderId in ORDERS, status }, mediaType: "application/json" }];
  task.artifacts = [{ artifactId: crypto.randomUUID(), name: "order-status", parts }];
  task.status = { state: "TASK_STATE_COMPLETED", timestamp: now() };
}

// historyLength: unset = all history, 0 = omit history, n = the last n messages.
function view(task: Task, historyLength?: number): Task {
  if (historyLength === undefined) return task;
  const { history = [], ...rest } = task;
  return historyLength === 0 ? rest : { ...rest, history: history.slice(-historyLength) };
}
const badLength = (h: unknown) => h !== undefined && !(Number.isInteger(h) && (h as number) >= 0);

async function handleRpc(request: Request): Promise<Response> {
  let req: any;
  try { req = await request.json(); } catch { return fail(null, -32700, "Invalid JSON payload"); }
  if (req?.jsonrpc !== "2.0" || typeof req.method !== "string") return fail(req?.id, -32600, "Request payload validation error");
  const id = req.id, params = req.params ?? {};
  const version = request.headers.get("A2A-Version") || "0.3"; // No header means 0.3 (spec 3.6.2).
  if (version !== "1.0") return fail(id, -32009, `A2A version ${version} is not supported. Send A2A-Version: 1.0`);

  switch (req.method) {
    case "SendMessage": {
      const msg = params.message as Message | undefined;
      if (!msg?.messageId || msg.role !== "ROLE_USER" || !Array.isArray(msg.parts) || msg.parts.length === 0)
        return fail(id, -32602, "message needs a messageId, role ROLE_USER and at least one part");
      if (msg.parts.some((p) => typeof p?.text !== "string" && p?.data === undefined))
        return fail(id, -32005, "Only text and data parts are supported");
      const historyLength = params.configuration?.historyLength;
      if (badLength(historyLength)) return fail(id, -32602, "historyLength must be a non-negative integer");
      let task: Task | undefined;
      if (msg.taskId) {
        task = tasks.get(msg.taskId);
        if (!task) return fail(id, -32001, "Task not found");
        if (msg.contextId && msg.contextId !== task.contextId) return fail(id, -32602, "contextId does not match the task");
        if (TERMINAL.includes(task.status.state)) return fail(id, -32004, "Task is in a terminal state");
      } else {
        task = { id: crypto.randomUUID(), contextId: msg.contextId ?? crypto.randomUUID(),
          status: { state: "TASK_STATE_SUBMITTED", timestamp: now() }, history: [] };
        tasks.set(task.id, task);
      }
      work(task, { ...msg, taskId: task.id, contextId: task.contextId });
      return ok(id, { task: view(task, historyLength) });
    }
    case "GetTask": {
      const task = tasks.get(params.id);
      if (!task) return fail(id, -32001, "Task not found");
      if (badLength(params.historyLength)) return fail(id, -32602, "historyLength must be a non-negative integer");
      return ok(id, view(task, params.historyLength));
    }
    case "CancelTask": {
      const task = tasks.get(params.id);
      if (!task) return fail(id, -32001, "Task not found");
      if (TERMINAL.includes(task.status.state)) return fail(id, -32002, "Task cannot be canceled");
      task.status = { state: "TASK_STATE_CANCELED", timestamp: now() };
      return ok(id, task);
    }
    case "SendStreamingMessage": case "SubscribeToTask": case "ListTasks": case "GetExtendedAgentCard":
      return fail(id, -32004, `${req.method} is not supported by this agent`);
    case "CreateTaskPushNotificationConfig": case "GetTaskPushNotificationConfig":
    case "ListTaskPushNotificationConfigs": case "DeleteTaskPushNotificationConfig":
      return fail(id, -32003, "Push notifications are not supported");
    default:
      return fail(id, -32601, "Method not found");
  }
}

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/.well-known/agent-card.json" && request.method === "GET")
      return Response.json(agentCard(url.origin), { headers: { "Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*" } });
    if (url.pathname === "/a2a" && request.method === "POST") {
      if (Number(request.headers.get("Content-Length") ?? 0) > 64 * 1024) return new Response("Too large", { status: 413 });
      return handleRpc(request);
    }
    return new Response("Not found", { status: 404 });
  },
};

How it maps to the specification

Data model. The types follow the ProtoJSON shapes in a2a.proto: roles are ROLE_USER and ROLE_AGENT, parts are {"text": ...} or {"data": ..., "mediaType": ...}, and states are TASK_STATE_* strings. A task has a server-generated id, a contextId, a status with a timestamp, and optional artifacts and history.

Version check. The JSON-RPC binding carries the protocol version in the A2A-Version header. The specification says a missing header means 0.3, and a server must answer an unsupported version with VersionNotSupportedError. This server speaks only 1.0.

SendMessage. A message without a taskId creates a task; the server generates the ID, as the specification requires. A message with a taskId continues that task, and the server checks three rules from section 3.4: the task must exist (-32001), a contextId, if sent, must match the task’s (-32602), and a task in a terminal state accepts no more messages (-32004). The response wraps the task as {"task": ...}, because SendMessageResponse is a oneof of task or message.

Result or question. work() either completes the task with an artifact that holds the answer twice, as text for models and as a data part for code, or moves it to TASK_STATE_INPUT_REQUIRED with a status message that asks for the order number. The status message is also appended to history.

GetTask and historyLength. GetTask returns the task itself, not wrapped. historyLength follows section 3.2.4: unset returns all history, 0 omits the field, and n returns the last n messages. Anything else is an invalid parameter.

CancelTask. Only tasks that haven’t reached a terminal state can be canceled; the rest get TaskNotCancelableError (-32002).

Refused operations. The card declares streaming, pushNotifications and extendedAgentCard as false, and section 3.3.4 says the server must then refuse those operations: UnsupportedOperationError (-32004) for streaming and the extended card, PushNotificationNotSupportedError (-32003) for push configuration. ListTasks is refused too, for the reason in the FAQ.

Behaviors worth knowing

Every call returns a finished or paused task. By default, a SendMessage call must wait until the task reaches a terminal state or an interrupted one such as TASK_STATE_INPUT_REQUIRED; a client can ask for an immediate return with returnImmediately in the request configuration. This server does its work inside the request, so it always answers with TASK_STATE_COMPLETED or TASK_STATE_INPUT_REQUIRED, and the brief TASK_STATE_SUBMITTED state is never visible to the client. An agent that calls slow back-end systems would return early and let clients poll with GetTask.

Clients follow tasks by polling. The card declares no streaming and no push notifications, so GetTask is the only way to follow a task. That is enough for work that finishes within a request.

Retries create new tasks. The specification allows, but does not require, SendMessage to be idempotent, and suggests the messageId as the key for spotting duplicates. This server doesn’t deduplicate: if a client times out and resends the same message, it gets a second task. A production agent that changes anything, such as starting a return, should remember recent messageId values per client and return the original task for a repeat.

The card follows the request. agentCard() builds the interface URL from the request’s origin, so the same code gives http://localhost:8791/a2a in development and your workers.dev or custom domain URL after deployment. If you serve the card from one host and the endpoint from another, set the URL explicitly instead.

Step 3: Run it

npx wrangler dev --port 8791

Wrangler prints Ready on http://127.0.0.1:8791. Use a second terminal for the requests below. The JSON responses are piped through python -m json.tool where that makes them easier to read.

Fetch the Agent Card

curl -s http://localhost:8791/.well-known/agent-card.json
{"name":"Order Status Demo","description":"Looks up the shipping status of an order by its order number. Demo data only.","version":"0.1.0","supportedInterfaces":[{"url":"http://localhost:8791/a2a","protocolBinding":"JSONRPC","protocolVersion":"1.0"}],"capabilities":{"streaming":false,"pushNotifications":false,"extendedAgentCard":false},"defaultInputModes":["text/plain","application/json"],"defaultOutputModes":["text/plain","application/json"],"skills":[{"id":"order-status","name":"Order status","description":"Send an order number as text or as a data part {\"orderId\": \"A-1001\"}. Without one, the task asks for it.","tags":["orders","shipping"],"examples":["Where is order A-1001?","{\"orderId\":\"A-1002\"}"]}]}

The interface URL comes from the request’s origin, so after deployment the same code serves the public HTTPS URL.

Send a message without an order number

curl -s http://localhost:8791/a2a \
  -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"msg-1","role":"ROLE_USER","parts":[{"text":"Where is my order?"}]},"configuration":{"historyLength":0}}}' \
  | python -m json.tool
{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "task": {
            "id": "f6823c46-368b-4298-95bf-0b82789ceff0",
            "contextId": "96bb6e1a-71de-4a0c-8402-43893e536898",
            "status": {
                "state": "TASK_STATE_INPUT_REQUIRED",
                "message": {
                    "messageId": "2d98e9e0-950b-4a37-a258-c9f6121170a3",
                    "role": "ROLE_AGENT",
                    "taskId": "f6823c46-368b-4298-95bf-0b82789ceff0",
                    "contextId": "96bb6e1a-71de-4a0c-8402-43893e536898",
                    "parts": [
                        {
                            "text": "Which order? Send the order number, for example A-1001."
                        }
                    ]
                },
                "timestamp": "2026-09-26T20:37:31.121Z"
            }
        }
    }
}

The task is waiting for input. historyLength: 0 kept the history out of the response.

Answer on the same task

Copy the task ID into a shell variable and reply with the order number:

TASK=f6823c46-368b-4298-95bf-0b82789ceff0
curl -s http://localhost:8791/a2a \
  -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
  -d '{"jsonrpc":"2.0","id":2,"method":"SendMessage","params":{"message":{"messageId":"msg-2","taskId":"'"$TASK"'","role":"ROLE_USER","parts":[{"text":"It is A-1001"}]},"configuration":{"historyLength":0}}}' \
  | python -m json.tool
{
    "jsonrpc": "2.0",
    "id": 2,
    "result": {
        "task": {
            "id": "f6823c46-368b-4298-95bf-0b82789ceff0",
            "contextId": "96bb6e1a-71de-4a0c-8402-43893e536898",
            "status": {
                "state": "TASK_STATE_COMPLETED",
                "timestamp": "2026-09-26T20:37:41.992Z"
            },
            "artifacts": [
                {
                    "artifactId": "0404af81-0d72-481b-a6ee-8c7f21315e7c",
                    "name": "order-status",
                    "parts": [
                        {
                            "text": "A-1001: Shipped on 2026-09-24. Expected delivery 2026-09-29."
                        },
                        {
                            "data": {
                                "orderId": "A-1001",
                                "found": true,
                                "status": "Shipped on 2026-09-24. Expected delivery 2026-09-29."
                            },
                            "mediaType": "application/json"
                        }
                    ]
                }
            ]
        }
    }
}

Same task ID, same context, now TASK_STATE_COMPLETED with one artifact.

Read the task with GetTask

curl -s http://localhost:8791/a2a \
  -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
  -d '{"jsonrpc":"2.0","id":3,"method":"GetTask","params":{"id":"'"$TASK"'","historyLength":2}}' \
  | python -m json.tool
{
    "jsonrpc": "2.0",
    "id": 3,
    "result": {
        "id": "f6823c46-368b-4298-95bf-0b82789ceff0",
        "contextId": "96bb6e1a-71de-4a0c-8402-43893e536898",
        "status": {
            "state": "TASK_STATE_COMPLETED",
            "timestamp": "2026-09-26T20:37:41.992Z"
        },
        "artifacts": [
            {
                "artifactId": "0404af81-0d72-481b-a6ee-8c7f21315e7c",
                "name": "order-status",
                "parts": [
                    {
                        "text": "A-1001: Shipped on 2026-09-24. Expected delivery 2026-09-29."
                    },
                    {
                        "data": {
                            "orderId": "A-1001",
                            "found": true,
                            "status": "Shipped on 2026-09-24. Expected delivery 2026-09-29."
                        },
                        "mediaType": "application/json"
                    }
                ]
            }
        ],
        "history": [
            {
                "messageId": "2d98e9e0-950b-4a37-a258-c9f6121170a3",
                "role": "ROLE_AGENT",
                "taskId": "f6823c46-368b-4298-95bf-0b82789ceff0",
                "contextId": "96bb6e1a-71de-4a0c-8402-43893e536898",
                "parts": [
                    {
                        "text": "Which order? Send the order number, for example A-1001."
                    }
                ]
            },
            {
                "messageId": "msg-2",
                "taskId": "f6823c46-368b-4298-95bf-0b82789ceff0",
                "role": "ROLE_USER",
                "parts": [
                    {
                        "text": "It is A-1001"
                    }
                ],
                "contextId": "96bb6e1a-71de-4a0c-8402-43893e536898"
            }
        ]
    }
}

With historyLength: 2, the server returned the last two of the three messages: its question and the client’s answer.

Send structured data in one step

curl -s http://localhost:8791/a2a \
  -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
  -d '{"jsonrpc":"2.0","id":4,"method":"SendMessage","params":{"message":{"messageId":"msg-3","role":"ROLE_USER","parts":[{"data":{"orderId":"A-1002"},"mediaType":"application/json"}]},"configuration":{"historyLength":0}}}'
{"jsonrpc":"2.0","id":4,"result":{"task":{"id":"5bb63816-3366-45bb-a25d-942e894dccd0","contextId":"18cba46d-0454-4cc9-bff6-52ab201bf51e","status":{"state":"TASK_STATE_COMPLETED","timestamp":"2026-09-26T20:37:58.755Z"},"artifacts":[{"artifactId":"c2b89726-9274-472a-8905-703c678a75a7","name":"order-status","parts":[{"text":"A-1002: Packed. Not shipped yet."},{"data":{"orderId":"A-1002","found":true,"status":"Packed. Not shipped yet."},"mediaType":"application/json"}]}]}}}

A data part with the field the skill asked for completes the task in one call.

Step 4: Exercise the error paths

H=(-H 'Content-Type: application/json' -H 'A2A-Version: 1.0')
# 1. No A2A-Version header: the server must assume 0.3
curl -s http://localhost:8791/a2a -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":5,"method":"GetTask","params":{"id":"'"$TASK"'"}}'; echo
# 2. Unknown task
curl -s http://localhost:8791/a2a "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":6,"method":"GetTask","params":{"id":"no-such-task"}}'; echo
# 3. Message to a finished task
curl -s http://localhost:8791/a2a "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":7,"method":"SendMessage","params":{"message":{"messageId":"msg-4","taskId":"'"$TASK"'","role":"ROLE_USER","parts":[{"text":"Thanks"}]}}}'; echo
# 4. Cancel a finished task
curl -s http://localhost:8791/a2a "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":8,"method":"CancelTask","params":{"id":"'"$TASK"'"}}'; echo
# 5. Streaming, which the card says we don't support
curl -s http://localhost:8791/a2a "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":9,"method":"SendStreamingMessage","params":{}}'; echo
# 6. A v0.3 method name
curl -s http://localhost:8791/a2a "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":10,"method":"message/send","params":{}}'; echo
# 7. v0.3 message shape (lowercase role, kind field, no messageId)
curl -s http://localhost:8791/a2a "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":11,"method":"SendMessage","params":{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]}}}'; echo
# 8. A file part
curl -s http://localhost:8791/a2a "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":12,"method":"SendMessage","params":{"message":{"messageId":"msg-5","role":"ROLE_USER","parts":[{"url":"https://example.com/receipt.pdf","mediaType":"application/pdf"}]}}}'; echo
# 9. Broken JSON
curl -s http://localhost:8791/a2a "${H[@]}" -d '{"jsonrpc":"2.0",'; echo
{"jsonrpc":"2.0","id":5,"error":{"code":-32009,"message":"A2A version 0.3 is not supported. Send A2A-Version: 1.0"}}
{"jsonrpc":"2.0","id":6,"error":{"code":-32001,"message":"Task not found"}}
{"jsonrpc":"2.0","id":7,"error":{"code":-32004,"message":"Task is in a terminal state"}}
{"jsonrpc":"2.0","id":8,"error":{"code":-32002,"message":"Task cannot be canceled"}}
{"jsonrpc":"2.0","id":9,"error":{"code":-32004,"message":"SendStreamingMessage is not supported by this agent"}}
{"jsonrpc":"2.0","id":10,"error":{"code":-32601,"message":"Method not found"}}
{"jsonrpc":"2.0","id":11,"error":{"code":-32602,"message":"message needs a messageId, role ROLE_USER and at least one part"}}
{"jsonrpc":"2.0","id":12,"error":{"code":-32005,"message":"Only text and data parts are supported"}}
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Invalid JSON payload"}}
Code A2A or JSON-RPC name Returned here when
-32700 JSONParseError The body isn’t valid JSON
-32600 InvalidRequestError The body isn’t a JSON-RPC 2.0 request
-32601 MethodNotFoundError The method is unknown, including v0.3 names such as message/send
-32602 InvalidParamsError The message is malformed, historyLength is invalid, or contextId doesn’t match
-32001 TaskNotFoundError The task ID doesn’t exist
-32002 TaskNotCancelableError The task already reached a terminal state
-32003 PushNotificationNotSupportedError Any push notification configuration method
-32004 UnsupportedOperationError Streaming, subscribe, ListTasks, extended card, or a message to a finished task
-32005 ContentTypeNotSupportedError A part that isn’t text or data
-32009 VersionNotSupportedError A2A-Version is missing (so 0.3) or not 1.0

Step 5: Validate the card

Fetch the card and paste it into the Agent Card validator, or post it to the validator’s API:

curl -s http://localhost:8791/.well-known/agent-card.json > card.json
python -c "import json; print(json.dumps({'input': open('card.json').read()}))" > payload.json
curl -s -X POST https://emissar.ai/api/tools/validate-card \
  -H 'Content-Type: application/json' --data-binary @payload.json > result.json
python -c "import json; r=json.load(open('result.json')); print(r['version'], r['summary']); [print(c['level'], '|', c['message']) for c in r['checks'] if c['level'] != 'pass']"
1.0 {'pass': 7, 'warn': 1, 'fail': 1}
fail | supportedInterfaces[0].url uses http://, not https://: http://localhost:8791/a2a. Production deployments MUST use HTTPS.
warn | The card isn't signed, so clients can't check that it wasn't altered after publication. Signing is optional (MAY), but clients SHOULD verify at least one signature before trusting a card.

The failure is expected locally: the card advertises http://localhost:8791/a2a, and production A2A endpoints must use HTTPS. To check the card as it will look after deployment, we replaced the origin with an illustrative https://a2a-order-status.example.workers.dev and ran the same commands on that file:

1.0 {'pass': 8, 'warn': 1, 'fail': 0}
warn | The card isn't signed, so clients can't check that it wasn't altered after publication. Signing is optional (MAY), but clients SHOULD verify at least one signature before trusting a card.

8 checks passed, with one warning for the optional signature. After you deploy, run the validator with your real domain, so it also checks the HTTP headers.

Step 6: Before production

This server is correct on the wire but deliberately small. Before real traffic:

  1. Store tasks durably. Cloudflare’s documentation says there is no guarantee two requests reach the same Worker instance, and recommends against relying on mutable global state. Move the Map into a Durable Object, which Cloudflare suggests for shared state, or a D1 table keyed by task ID.
  2. Authenticate callers and scope tasks. Declare securitySchemes and securityRequirements in the card, check credentials on every request, and store the client identity with each task. Today anyone who has a task ID can read it. The specification requires servers to check access on every operation, and to scope GetTask, CancelTask and ListTasks to the caller.
  3. Implement ListTasks once you can scope it. The specification lists it among the core operations.
  4. Add limits. Rate-limit per client, cap the number of parts and the text length, and read the body with a size cap instead of trusting the Content-Length header.
  5. Serve the card properly. Add an ETag and answer If-None-Match with 304, as in Tutorial: publish your first Agent Card. Handle OPTIONS preflight requests if browser-based clients will call /a2a.
  6. Deploy with npx wrangler deploy, then run the validator against your domain. We ran only the local steps in this tutorial.

For what to expose and how to protect it, see Making your customer service agent-ready. For the full state machine behind work(), see The A2A task lifecycle, state by state.

Questions

Why does the server reject requests without an A2A-Version header?
The specification says a server must treat a missing A2A-Version as 0.3. This server implements only 1.0, so it answers with VersionNotSupportedError (-32009). Servers that also speak 0.3 accept the missing header and use the 0.3 method names and shapes.
Can I keep tasks in a global Map in production?
No. Cloudflare does not guarantee that two requests reach the same Worker instance, and instances can be evicted, so a task created on one request may be missing on the next. Store tasks in a Durable Object or a D1 database.
Why is ListTasks refused?
ListTasks must return only the tasks the caller is allowed to see. This tutorial server has no authentication, so it cannot scope the list and refuses the call. Implement ListTasks once you have per-client authentication.

Sources

  1. A2A Protocol Specification (sections 3, 5.4, 9 and 13) (accessed )
  2. A2A normative protocol definition (a2a.proto) (accessed )
  3. A2A releases (v1.0.1, May 28, 2026) (accessed )
  4. JSON-RPC 2.0 Specification (accessed )
  5. How Workers works (Cloudflare Workers documentation) (accessed )
  6. Errors and exceptions (Cloudflare Workers documentation) (accessed )
  7. Cloudflare D1 documentation (accessed )
  8. Cloudflare Durable Objects documentation (accessed )
  9. Emissar Agent Card validator (accessed )