Bridge an existing HTTP API to A2A agents: JSON-RPC, REST, and gRPC from one handler
Most teams that need an A2A endpoint already own the hard part: the business logic. They have an HTTP service that takes a request, does the work, and returns a result. What they lack is the agent-facing surface, the Agent Card and the JSON-RPC, REST, and gRPC bindings that other agents expect. Reaching for a full agent framework to provide that surface is overkill and usually means rewriting logic that already works. The lighter path is a stateless bridge: implement one authenticated handler, and let an adapter expose it over every A2A transport.
The shape of the bridge
The adapter is deliberately small. It terminates the three A2A bindings, translates each into a single internal call, and forwards that call to your existing HTTP handler. It holds no conversation state and makes no decisions; your handler owns the goal.
A2A client
| JSON-RPC (/rpc) | REST (/rest) | gRPC
+----------+----------+------------------+-------+
stateless A2A adapter
|
POST { message, contextId }
Authorization: Bearer <HANDLER_TOKEN>
v
your existing business handlerOne handler backs all three transports, so request validation, authz, and the actual work live in exactly one place instead of being reimplemented per binding.
The contract your handler implements
The adapter calls your handler with a JSON body containing the A2A message and a context id, and expects an A2A 1.0 Message back with non-empty parts. A minimal handler using Express looks like this:
import express from "express";
const app = express();
app.use(express.json({ limit: "2mb" }));
app.post("/a2a-work", (req, res) => {
const { message, contextId } = req.body ?? {};
// Authenticate the adapter-to-handler call yourself, or rely on HANDLER_TOKEN
// enforced at your reverse proxy.
const text = message?.parts?.find((p) => typeof p.text === "string")?.text;
if (!text) {
return res.status(400).json({ parts: [{ text: "Send a non-empty text message." }] });
}
// Your existing logic lives here. This is where an LLM, a workflow engine,
// or a plain deterministic service turns the message into a result.
const answer = `Handled in context ${contextId ?? "new"}: ${text}`;
return res.status(200).json({
role: "ROLE_AGENT",
parts: [{ text: answer }],
});
});
app.listen(8080, "127.0.0.1");The only hard requirement is non-empty parts. Everything else, routing to skills, calling internal APIs, escalating to a human, is your decision in the handler. Build and deploy that endpoint first; the adapter cannot invent behavior you have not implemented.
What the generated server ships
Exporting the adapter produces a small tar archive rather than a hidden binary. It contains the server, a canonicalization module used for card signing, a generated Agent Card, a package manifest, and a README:
server.mjs, the stateless adapter.canonicalize.mjs, the canonical JSON used when the card is signed.agent-card.json, the public card with a singlehandle-messageskill by default.package.json, pinned to Node 22+ with explicit dependency versions.README.md, the deployment and security notes.
The dependencies are ordinary, auditable libraries rather than a private runtime: the official A2A SDK, @grpc/grpc-js and @bufbuild/protobuf for native gRPC, Express for HTTP, and jose for card signing. Run npm install, configure environment variables, and npm start; no secret is ever embedded in the export.
One handler, three bindings
With the adapter running, the same handler is reachable three ways. JSON-RPC carries the standard envelope at /rpc:
curl -s http://127.0.0.1:9999/rpc \
-H "Authorization: Bearer $A2A_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "req-1",
"method": "SendMessage",
"params": {
"message": {
"messageId": "msg-1",
"role": "ROLE_USER",
"parts": [{ "text": "Reconcile today's failed renewals" }]
}
}
}'The REST binding at /rest takes the params object directly, with no JSON-RPC wrapper:
curl -s http://127.0.0.1:9999/rest \
-H "Authorization: Bearer $A2A_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"messageId": "msg-2",
"role": "ROLE_USER",
"parts": [{ "text": "Summarize open support tickets" }]
}
}'Native gRPC uses the official service descriptor and ProtoJSON conversion rather than hand-encoded messages, including server-streaming methods. The Agent Card is served publicly at /.well-known/agent-card.json, and its supportedInterfaces array tells clients which of these URLs and bindings exist.
Configuration, and what is required
| Variable | Required | Purpose |
|---|---|---|
A2A_TOKEN | Yes | Random bearer secret clients present; use at least 32 random characters |
HANDLER_URL | Yes | The URL of your authenticated business handler |
HANDLER_TOKEN | No | Bearer token the adapter presents to your handler |
CORS_ORIGINS | No | Comma-separated browser origins allowed to call the agent |
PUBLIC_URL | No | Public base URL advertised in the card |
HOST, PORT | No | HTTP listener settings; local listeners default to loopback |
GRPC_PORT, GRPC_PUBLIC_URL | No | gRPC listener and its advertised address |
TLS_CERT_FILE, TLS_KEY_FILE | No | Certificate and key; required for non-loopback gRPC |
SIGNING_JWK_FILE | No | JWK used to sign the Agent Card |
The honest capability boundary
This is the part most "instant agent" generators gloss over, and it matters operationally. A stateless message adapter is not an autonomous model and not a persistent task engine. Concretely:
- Streaming calls emit the final message; they do not stream incremental model tokens.
- Persistent tasks that survive a restart, remote cancellation of running work, and push notifications all require a custom executor plus a durable store. If you need the task to still exist after the process restarts, this adapter alone does not provide it.
- The card must not advertise capabilities the deployment lacks. If you have not implemented durable tasks, do not claim them; clients route based on the card and will treat advertised capabilities as real.
When you outgrow the bridge, the upgrade path is clear: put an executor and store behind the same handler, advance tasks through their real states, and only then advertise the corresponding methods. The adapter is the correct starting point for "expose my service to agents", not the end state for a long-running autonomous worker.
Deployment security
Local listeners bind to 127.0.0.1 by default. For HTTP in production, put an HTTPS reverse proxy in front with rate limits, and expose non-loopback gRPC only with a certificate and key. The shared bearer token represents a single client principal; before multi-user deployment, integrate your identity provider so each caller is distinguishable. If you sign the card with SIGNING_JWK_FILE, distribute the matching public JWKS through a trusted channel; never trust a key merely because the agent served it alongside the card. Finally, review dependency advisories and commit a lockfile so the audited versions are what actually run.
You can generate this adapter, inspect the full card, and exercise all three bindings from the Powerduck workspace; the online demo shows the spec-driven loop, and the trust model for the generated card is covered in the Agent Card discovery and verification guide.