← labs · 03 · serve
lab 03 · ~8 min

The smallest valid A2A server.

Three implementations of the minimum runnable A2A surface: an agent-card at the well-known path plus a JSON-RPC handler that serves one skill. Pick your stack. Each tab ships ~40 lines of code, a copy button, and the two-line run command. Skills, auth and x402 are additions on top of this floor.

file server.py · 38 lines · python 3.11+
# server.py — minimum A2A server. FastAPI + uvicorn. No external A2A SDK.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

AGENT_CARD = {
    "$schema": "https://a2a-protocol.org/schemas/agent-card/v1.json",
    "name": "hello-a2a",
    "version": "0.1.0",
    "description": "Minimum runnable A2A server. One skill: hello.",
    "url": "http://localhost:8080",
    "endpoints": {"a2a": "http://localhost:8080/api/a2a"},
    "auth": {"type": "none"},
    "capabilities": {"streaming": False},
    "skills": [{
        "id": "hello",
        "name": "Hello",
        "description": "Greet by name. Returns a string.",
        "inputModes": ["text/plain"],
        "outputModes": ["text/plain"],
    }],
}

@app.get("/.well-known/agent-card.json")
async def agent_card():
    return AGENT_CARD

@app.post("/api/a2a")
async def a2a(req: Request):
    body = await req.json()
    rpc_id = body.get("id")
    method = body.get("method")
    params = body.get("params", {})
    if method == "message/send":
        text = (params.get("message", {}).get("parts", [{}])[0] or {}).get("text", "")
        result = {"parts": [{"kind": "text", "text": f"hello, {text or 'world'}!"}]}
        return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
    return JSONResponse({"jsonrpc": "2.0", "id": rpc_id,
        "error": {"code": -32601, "message": "method not found"}}, status_code=200)
Run it locally pip install fastapi uvicorn uvicorn server:app --port 8080 curl http://localhost:8080/.well-known/agent-card.json JSON-RPC call: POST /api/a2a with { method: "message/send", params: { message: { parts: [{ text: "world" }] } } }
file server.js · 42 lines · node 20+
// server.js — minimum A2A server. Express. No external A2A SDK.
const express = require("express");
const app = express();
app.use(express.json());

const AGENT_CARD = {
  "$schema": "https://a2a-protocol.org/schemas/agent-card/v1.json",
  name: "hello-a2a",
  version: "0.1.0",
  description: "Minimum runnable A2A server. One skill: hello.",
  url: "http://localhost:8080",
  endpoints: { a2a: "http://localhost:8080/api/a2a" },
  auth: { type: "none" },
  capabilities: { streaming: false },
  skills: [{
    id: "hello",
    name: "Hello",
    description: "Greet by name. Returns a string.",
    inputModes: ["text/plain"],
    outputModes: ["text/plain"]
  }]
};

app.get("/.well-known/agent-card.json", (_, res) => res.json(AGENT_CARD));

app.post("/api/a2a", (req, res) => {
  const { id, method, params = {} } = req.body || {};
  if (method === "message/send") {
    const text = params?.message?.parts?.[0]?.text || "world";
    return res.json({
      jsonrpc: "2.0", id,
      result: { parts: [{ kind: "text", text: `hello, ${text}!` }] }
    });
  }
  res.json({
    jsonrpc: "2.0", id,
    error: { code: -32601, message: "method not found" }
  });
});

app.listen(8080, () => console.log("a2a · http://localhost:8080"));
Run it locally npm init -y && npm install express node server.js curl http://localhost:8080/.well-known/agent-card.json Same JSON-RPC envelope: POST /api/a2a { jsonrpc: "2.0", id: 1, method: "message/send", params: ... }
file worker.ts · 42 lines · wrangler 3+
// worker.ts — minimum A2A server on Cloudflare Workers. No external A2A SDK.
const AGENT_CARD = {
  "$schema": "https://a2a-protocol.org/schemas/agent-card/v1.json",
  name: "hello-a2a",
  version: "0.1.0",
  description: "Minimum runnable A2A server on a Worker.",
  url: "https://hello-a2a.workers.dev",
  endpoints: { a2a: "https://hello-a2a.workers.dev/api/a2a" },
  auth: { type: "none" },
  capabilities: { streaming: false },
  skills: [{
    id: "hello",
    name: "Hello",
    description: "Greet by name. Returns a string.",
    inputModes: ["text/plain"],
    outputModes: ["text/plain"]
  }]
};

const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Accept"
};

export default {
  async fetch(req: Request): Promise<Response> {
    const { pathname } = new URL(req.url);
    if (req.method === "OPTIONS") return new Response(null, { headers: CORS });

    if (pathname === "/.well-known/agent-card.json") {
      return Response.json(AGENT_CARD, { headers: CORS });
    }
    if (pathname === "/api/a2a" && req.method === "POST") {
      const { id, method, params = {} } = await req.json() as any;
      if (method === "message/send") {
        const text = params?.message?.parts?.[0]?.text || "world";
        return Response.json({
          jsonrpc: "2.0", id,
          result: { parts: [{ kind: "text", text: `hello, ${text}!` }] }
        }, { headers: CORS });
      }
      return Response.json({
        jsonrpc: "2.0", id,
        error: { code: -32601, message: "method not found" }
      }, { headers: CORS });
    }
    return new Response("not found", { status: 404, headers: CORS });
  }
};
Deploy it npm create cloudflare@latest hello-a2a -- --type=hello-world-ts cd hello-a2a && cp ../worker.ts src/index.ts npx wrangler deploy CORS headers are non-optional for Workers: WebMCP browsers will need them on the well-known route too.
This is the floor, not the ceiling. Skills, multiple methods (tasks/send, tasks/get, tasks/cancel), streaming responses (SSE on tasks/subscribe), bearer auth and x402 paywalling are all additions on top. Start from this floor — every production A2A server in the registry started this small.