Getting started with A2A
Wrap a plain HTTP service as an A2A agent, register it, confirm it, invoke it through the KubeMQ gateway, then keep it alive or remove it.
The A2A connector turns any HTTP service that accepts a JSON-RPC 2.0 POST into a callable agent. You register an agent's
URL with the gateway, then route JSON-RPC message/send requests to it through
POST /a2a/<agent_id> — no KubeMQ SDK on the agent. This walkthrough takes you from a
running server to a verified round-trip.
Prerequisites
Any running KubeMQ works, including Try KubeMQ. MCP and A2A are on by default on port 9090. On Kubernetes, run kubectl --context YOUR_KUBE_CONTEXT -n kubemq port-forward svc/messaging-rest 9090:9090 first.
- Python 3, to run the throwaway agent in the fastest path.
curl(or one of the language clients below) to send the message.
Confirm the gateway is live by fetching the platform agent card:
curl http://localhost:9090/.well-known/agent-card.jsonEnable / disable
The A2A connector is enabled by default. Start kubemq-server and the /a2a/* and
/agents/* routes are live immediately — there is no =true flag to set.
To disable A2A, set its enable variable to false:
docker run -d --pull always --platform linux/amd64 --name kubemq --hostname kubemq -e STORE_ENGINE=next -e STORE_NEXT_ACK_POLICY=strict -e STORE_STORE_PATH=/kubemq/store -e API_BIND_ADDRESS=0.0.0.0 -e CONNECTORSA2_A_ENABLE=false -v kubemq-data:/kubemq/store europe-docker.pkg.dev/kubemq/images/kubemq-next:latestThe disable variable name is irregular by design — the config key Connectors.A2A.Enable
snake-cases to CONNECTORSA2_A_ENABLE, not KUBEMQ_A2A_ENABLE. See
Shared HTTP server for why, and never rely on the
older off-by-default behavior.
How it works
A message/send request travels from the caller to the gateway, across the broker to the
agent's virtual subscriber, out to the agent over HTTP, and back the same way — the
gateway relays the agent's JSON-RPC response to the caller unchanged.
The gateway proxies the request to the agent over the broker and relays the reply unchanged.
Fastest path: wrap a plain HTTP service
A short Python server stands in for your own service, so you can prove the whole round trip with python3 and curl. These commands are for bash on macOS and Linux; on Windows, run them in WSL (Windows Subsystem for Linux).
Start a throwaway agent
Save this as order_status_agent.py. It answers every POST with a JSON-RPC 2.0 result, with no KubeMQ SDK:
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
# 127.0.0.1 keeps the agent off your network. Docker Engine on Linux needs 172.17.0.1 (see step 2).
BIND = os.environ.get("AGENT_BIND", "127.0.0.1")
class Agent(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers["Content-Length"])
req = json.loads(self.rfile.read(n))
reply = json.dumps({
"jsonrpc": "2.0",
"id": req.get("id"),
"result": {"status": "in-transit", "eta": "2 days"},
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(reply)
HTTPServer((BIND, 8090), Agent).serve_forever()Run it in a second terminal and leave it running:
python3 order_status_agent.pyOn Docker Engine on Linux, run it on the container bridge address instead:
AGENT_BIND=172.17.0.1 python3 order_status_agent.pyAt first the terminal shows nothing and stays busy while the agent runs; it prints one line for each request it answers.
Register it
KubeMQ runs in a container, so it reaches your agent through your container engine's address for the host, not localhost:
| Container engine | Agent address |
|---|---|
| Docker Desktop (macOS, Windows) | http://host.docker.internal:8090/ |
Docker Engine on Linux (agent started with AGENT_BIND=172.17.0.1) | http://172.17.0.1:8090/ |
| Podman (macOS) | http://host.containers.internal:8090/ |
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "order-status-agent",
"name": "Order Status Agent",
"url": "YOUR_AGENT_URL"
}'Replace:
YOUR_AGENT_URL— the address from the table for your container engine.
You should see:
{"agent_id":"order-status-agent","name":"Order Status Agent","url":"http://host.docker.internal:8090/","registered_by":"anonymous","skills":null,"protocolVersions":["1.0"],"last_seen":"2026-09-27T10:00:00.123456Z","registered_at":"2026-09-27T10:00:00.123456Z"}On Kubernetes, register a URL the KubeMQ pods can reach, such as a Service in the cluster.
Confirm it is registered
curl http://localhost:9090/agents/order-status-agentYou should see:
{"agent_id":"order-status-agent","name":"Order Status Agent","url":"http://host.docker.internal:8090/","registered_by":"anonymous","skills":null,"protocolVersions":["1.0"],"last_seen":"2026-09-27T10:00:00.123456Z","registered_at":"2026-09-27T10:00:00.123456Z"}Invoke it
Route a JSON-RPC message/send request through the gateway to your agent with POST /a2a/<agent_id>:
curl -X POST http://localhost:9090/a2a/order-status-agent \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "What is the status of order 42?"}]
}
}
}'You should see:
{"jsonrpc": "2.0", "id": 1, "result": {"status": "in-transit", "eta": "2 days"}}The gateway relays your agent's reply unchanged.
Keep it alive, or remove it
A registration expires about 5 minutes after the agent's last heartbeat (AgentTTLSeconds, default 300; see Configuration). To keep it, send a heartbeat before then:
curl -X POST http://localhost:9090/agents/heartbeat -H "Content-Type: application/json" -d '{"agent_id":"order-status-agent"}'You should see:
{"ok":true}To remove it now:
curl -X DELETE http://localhost:9090/agents/order-status-agentYou should see:
{"ok":true}Stop the agent with Ctrl-C in Terminal 2.
Using your own service
The throwaway agent exists only to prove the round trip. To use your own service, register the address where it already listens. It must accept the gateway's JSON-RPC 2.0 POST and answer with a JSON-RPC 2.0 response: the request's id plus a result or an error. It needs no KubeMQ SDK and no new dependency. A long-running agent sends heartbeats on a timer, or registers again, so its registration never expires; see Agent Registry.
Register from code
The same registration and call in six languages.
Register an agent
An agent registers itself by POSTing its agent card — including the absolute
url the gateway will call — to POST /agents/register. The snippets below register
your agent at http://localhost:18080/; with KubeMQ in a container, use an address from
the table in Register it instead.
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{"id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"]}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"]
}'var card = new JsonObject
{
["agent_id"] = "echo-agent-01",
["name"] = "Echo Agent",
["description"] = "A simple echo agent for testing",
["version"] = "1.0.0",
["url"] = "http://localhost:18080/",
["skills"] = new JsonArray(new JsonObject
{
["id"] = "echo", ["name"] = "Echo",
["description"] = "Echoes back the received message",
["tags"] = new JsonArray("test", "echo")
}),
["defaultInputModes"] = new JsonArray("text"),
["defaultOutputModes"] = new JsonArray("text"),
["protocolVersions"] = new JsonArray("1.0")
};
using var client = new HttpClient();
var resp = await client.PostAsync(
"http://localhost:9090/agents/register",
new StringContent(card.ToJsonString(), System.Text.Encoding.UTF8, "application/json"));
Console.WriteLine($"Registered: {(int)resp.StatusCode}");card := map[string]interface{}{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": []map[string]interface{}{
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": []string{"test", "echo"},
},
},
"defaultInputModes": []string{"text"},
"defaultOutputModes": []string{"text"},
"protocolVersions": []string{"1.0"},
}
data, _ := json.Marshal(card)
resp, err := http.Post("http://localhost:9090/agents/register", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Registration failed: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("Registered: %d\n", resp.StatusCode)var card = Map.of(
"agent_id", "echo-agent-01",
"name", "Echo Agent",
"description", "A simple echo agent for testing",
"version", "1.0.0",
"url", "http://localhost:18080/",
"skills", List.of(Map.of(
"id", "echo", "name", "Echo",
"description", "Echoes back the received message",
"tags", List.of("test", "echo")
)),
"defaultInputModes", List.of("text"),
"defaultOutputModes", List.of("text"),
"protocolVersions", List.of("1.0")
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:9090/agents/register"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Registered: " + resp.statusCode());card = {
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": ["test", "echo"],
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"],
}
async with httpx.AsyncClient() as client:
resp = await client.post("http://localhost:9090/agents/register", json=card)
print(f"Registered: {resp.status_code}")const card = {
agent_id: "echo-agent-01",
name: "Echo Agent",
description: "A simple echo agent for testing",
version: "1.0.0",
url: "http://localhost:18080/",
skills: [
{
id: "echo",
name: "Echo",
description: "Echoes back the received message",
tags: ["test", "echo"],
},
],
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
protocolVersions: ["1.0"],
};
const resp = await fetch("http://localhost:9090/agents/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(card),
});
console.log("Registered:", resp.status);A 200 response means the agent is registered. The gateway returns the stored card
with server-managed registered_at and last_seen fields populated.
Send a message
Route a JSON-RPC 2.0 message/send request to the agent through
POST /a2a/<agent_id>. The gateway forwards it to the agent and relays the reply back.
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}]
}
}
}'var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello, agent!" })
}
}
};
using var client = new HttpClient();
var resp = await client.PostAsync(
"http://localhost:9090/a2a/echo-agent-01",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"Status: {(int)resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Hello, agent!"}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post("http://localhost:9090/a2a/echo-agent-01", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Println(string(body))var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of(
"parts", List.of(Map.of("text", "Hello, agent!"))
)
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:9090/a2a/echo-agent-01"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + resp.statusCode());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}],
},
},
}
async with httpx.AsyncClient() as client:
resp = await client.post("http://localhost:9090/a2a/echo-agent-01", json=payload)
print(f"Status: {resp.status_code}")
data = resp.json()
print(json.dumps(data, indent=2))
assert "result" in dataconst request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: {
parts: [{ text: "Hello, agent!" }],
},
},
};
const resp = await fetch("http://localhost:9090/a2a/echo-agent-01", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
console.log("Response:", JSON.stringify(data, null, 2));Verify the reply
If your agent echoes the request body inside result.echo, the reply confirms the
round-trip through the gateway:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"echo": {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": { "parts": [{ "text": "Hello, agent!" }] }
}
}
}
}A response containing result is a successful round-trip. A response with an error
object instead means the gateway or agent reported a problem — see
Error handling for the codes and how to
distinguish transport failures from application errors.
Troubleshooting
- Connection refused on port 9090: KubeMQ is not running, or port 9090 is not published.
- The response has an
errorobject instead ofresult: readerror.message. The gateway answers HTTP 200 with the error in the body. A message that startsagent unreachable:means KubeMQ cannot reach the registeredurl; recheck the address you chose in Register it. agent not found(code-32002) a few minutes after it worked: the registration expired. Register again, or send heartbeats.
What's next
Configuration
Tune timeouts, concurrency limits, TTL, and the agent response-size cap.
Synchronous messaging
Context IDs, custom methods, header forwarding, and concurrent requests.
Agent registry
Register, list, heartbeat, and deregister agents over the REST management API.
Building agents
Write a compliant HTTP agent server — no KubeMQ SDK required.
Was this page helpful?