KubeMQ
AiwayAI Agents (A2A)

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

Enable / 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:latest

The 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:

order_status_agent.py
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:

Terminal 2
python3 order_status_agent.py

On Docker Engine on Linux, run it on the container bridge address instead:

Terminal 2
AGENT_BIND=172.17.0.1 python3 order_status_agent.py

At 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 engineAgent 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/
Terminal
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:

Output
{"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

Terminal
curl http://localhost:9090/agents/order-status-agent

You should see:

Output
{"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>:

Terminal
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:

Output
{"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:

Terminal
curl -X POST http://localhost:9090/agents/heartbeat -H "Content-Type: application/json" -d '{"agent_id":"order-status-agent"}'

You should see:

Output
{"ok":true}

To remove it now:

Terminal
curl -X DELETE http://localhost:9090/agents/order-status-agent

You should see:

Output
{"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 data
const 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 error object instead of result: read error.message. The gateway answers HTTP 200 with the error in the body. A message that starts agent unreachable: means KubeMQ cannot reach the registered url; 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

Was this page helpful?

On this page