n4nAI

Building your first A2A-compliant agent

Hands-on tutorial to build a2a agent compliant with the Agent-to-Agent protocol: serve an agent card, handle JSON-RPC tasks, and test with a client.

n4n Team3 min read613 words

Audio narration

Coming soon — every post will get a voice note here.

To build a2a agent that interoperates with the growing ecosystem of agent runtimes, you need to speak JSON-RPC 2.0 over HTTP and publish a machine-readable Agent Card. This tutorial walks through a minimal but spec-compliant echo agent in Python, then a client that drives a task to completion, so you can see the wire format before adding real logic.

Prerequisites

  • Python 3.10+ (tested on 3.11)
  • pip install flask requests
  • curl for quick checks
  • Basic familiarity with HTTP and JSON
  • Optional: an OpenAI-compatible LLM endpoint if you want the agent to do more than echo

No external A2A SDK is required. The protocol is simple enough to implement directly, which makes the contract obvious.

The A2A surface area

A2A defines two primary HTTP resources:

  1. An Agent Card, typically served at /.well-known/agent.json, describing name, capabilities, skills, and the agent’s base URL.
  2. A task endpoint, usually /tasks, accepting JSON-RPC 2.0 calls. The core method is tasks/send, which carries a message with parts (text, file, or data).

The response is a task object with a status.state (submitted, working, completed, failed) and optional artifacts. That is the entire handshake for a synchronous agent.

Step 1: Publish the Agent Card

Create agent.py and start with the card. This is what another agent discovers before calling you.

from flask import Flask, request, jsonify

app = Flask(__name__)

AGENT_CARD = {
    "protocolVersion": "0.1.0",
    "name": "Echo Agent",
    "description": "A minimal A2A-compliant echo agent",
    "url": "http://localhost:5000",
    "capabilities": {"streaming": False, "pushNotifications": False},
    "skills": [
        {
            "id": "echo",
            "name": "Echo",
            "description": "Returns the input text prefixed with 'Echo: '",
            "tags": ["echo", "demo"],
        }
    ],
}

@app.route("/.well-known/agent.json", methods=["GET"])
def agent_card():
    return jsonify(AGENT_CARD)

Run it temporarily to confirm the card is served:

python agent.py &
curl http://localhost:5000/.well-known/agent.json

Expected output (pretty-printed by curl):

{
  "protocolVersion": "0.1.0",
  "name": "Echo Agent",
  "description": "A minimal A2A-compliant echo agent",
  "url": "http://localhost:5000",
  "capabilities": {"streaming": false, "pushNotifications": false},
  "skills": [
    {"id": "echo", "name": "Echo", "description": "Returns the input text prefixed with 'Echo: '", "tags": ["echo", "demo"]}
  ]
}

Step 2: Implement the task endpoint

Extend agent.py with the JSON-RPC handler. A2A reuses JSON-RPC id matching, so echo the id from the request.

@app.route("/tasks", methods=["POST"])
def tasks():
    payload = request.json
    if payload.get("method") != "tasks/send":
        return jsonify({
            "jsonrpc": "2.0",
            "id": payload.get("id"),
            "error": {"code": -32601, "message": "Method not found"},
        })

    params = payload["params"]
    task_id = params["id"]
    msg = params["message"]

    text = ""
    for part in msg.get("parts", []):
        if part.get("type") == "text":
            text += part.get("text", "")

    return jsonify({
        "jsonrpc": "2.0",
        "id": payload["id"],
        "result": {
            "id": task_id,
            "status": {"state": "completed"},
            "artifacts": [
                {"parts": [{"type": "text", "text": f"Echo: {text}"}]}
            ],
        },
    })

This handler extracts text parts, builds a completed task with a single artifact, and returns it. No session store, no async—just the minimal compliant shape.

Step 3: Run and verify the agent

Start the server (if not already running) and send a raw tasks/send via curl:

curl -X POST http://localhost:5000/tasks \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tasks/send",
    "params": {
      "id": "task-001",
      "message": {
        "role": "user",
        "parts": [{"type": "text", "text": "hello a2a"}]
      }
    }
  }'

Expected response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "id": "task-001",
    "status": {"state": "completed"},
    "artifacts": [
      {"parts": [{"type": "text", "text": "Echo: hello a2a"}]}
    ]
  }
}

If you see that, you have a working agent. The hard part of learning to build a2a agent is mostly understanding that the protocol does not hide the message shape—it is plain JSON.

Step 4: Write a client

A client that talks to your agent is just another HTTP caller. Put this in client.py:

import requests
import json

def send_task(base_url: str, text: str, task_id: str = "task-123"):
    payload = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tasks/send",
        "params": {
            "id": task_id,
            "message": {
                "role": "user",
                "parts": [{"type": "text", "text": text}],
            },
        },
    }
    r = requests.post(f"{base_url}/tasks", json=payload)
    r.raise_for_status()
    return r.json()

if __name__ == "__main__":
    res = send_task("http://localhost:5000", "ping from client")
    print(json.dumps(res, indent=2))

Run it:

python client.py

You should get the same structured task result, now prefixed with Echo: ping from client.

Step 5: Replace echo with real inference

Echo is a fine smoke test, but most agents exist to call a model. Swap the artifact generation for an OpenAI-compatible chat completion. Install the SDK:

pip install openai

Modify the handler to call the model instead of string concatenation:

from openai import OpenAI

client = OpenAI(base_url="https://api.openai.com/v1", api_key="YOUR_KEY")

# Inside tasks():
completion = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": text}],
)
reply = completion.choices[0].message.content

# Then in result artifacts:
"artifacts": [{"parts": [{"type": "text", "text": reply}]}]

If you want resilience across providers, an OpenRouter-class gateway such as n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models with automatic fallback when a provider is degraded, so the base_url swap is the only change needed.

The Agent Card should then reflect the real skill:

{
  "id": "summarize",
  "name": "Summarizer",
  "description": "Summarizes input text using an LLM",
  "tags": ["nlp", "summarization"]
}

Common pitfalls when you build a2a agent integrations

Mismatched IDs. JSON-RPC requires the response id to match the request id. Some HTTP clients auto-generate it; keep it explicit.

Assuming streaming. The base spec supports streaming: false. If you set it true, clients expect Server-Sent Events on the same endpoint. Don’t claim a capability you don’t implement.

Losing the parts array. A2A messages are arrays of typed parts, not a single text field. Even if you only use text, wrap it: {"type": "text", "text": "..."}.

Agent Card URL drift. The url in the card must match where tasks are actually served. Discovery tools will call {url}/tasks; a mismatch yields 404s that are painful to debug.

Wrapping up

You now have a runnable server, a client, and the knowledge to swap in a model. The next step is to add tasks/get for async flows and push notifications for long-running jobs. But the core lesson stands: to build a2a agent systems, get the Agent Card and the tasks/send JSON-RPC shape right first, then layer intelligence behind the artifact.

Tagsa2atutorialagent-developmentprotocol

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All agent-to-agent (a2a) communication protocols posts →