Deploying A2A on Hermes Agent: A Docker Gateway Reference Stack
Running A2A in production means bridging two protocols that were never designed to talk to each other — and doing it behind a gateway that handles auth, tenant isolation, and streaming without dropping tokens. This reference stack solves the three problems that block A2A deployments: protocol translation (JSON-RPC 2.0 to OpenAI-compatible chat completions), streaming reconciliation (A2A artifact SSE to Hermes run-event SSE), and multi-tenant security (PostgreSQL Row-Level Security on every query). If you need agents on different frameworks to delegate work to each other, the pattern here is the deployment path.
Key takeaways
- 3 layers, 1 Docker Compose stack — SilvaEngine Gateway handles transport and auth, A2A Daemon Engine handles protocol logic, HermesAgentHandler bridges to Hermes Agent's OpenAI-compatible API. The docker-a2a-hermes-agent-gateway repo packages all three.
- 5 protocol surfaces on one port — JSON-RPC 2.0, GraphQL, SSE stream, SSE push, and Agent Card discovery, all behind a single gateway on port 8765 with JWT or AWS Cognito authentication.
- PostgreSQL Row-Level Security enforces tenant isolation — the
partition_key = "{endpoint_id}#{Part-Id}"composite key is enforced at the database level via RLS policies on all four A2A tables, not just in application code. - A2A SDK v2's single-message constraint shapes the streaming architecture — the bridge emits token chunks to SSE in real-time and a single accumulated Message to the SDK EventQueue after stream completion, avoiding
InvalidAgentResponseError. - 15 E2E test checks across 5 scripts — from non-streaming smoke tests to full SSE streaming pipelines with HTTP fallback verification, all runnable with
pip install requests.
The Agent2Agent Protocol (A2A) defines how AI agents discover, delegate, and stream tasks to each other over JSON-RPC 2.0. Hermes Agent (by Nous Research) exposes an OpenAI-compatible API Server at /v1/chat/completions and a runs-based SSE streaming interface at /v1/runs and /v1/runs/{id}/events. The two do not speak the same language. A2A sends message/send with structured parts; Hermes receives chat completion payloads. A2A streams task artifacts via SSE; Hermes streams token deltas via run events.
The docker-a2a-hermes-agent-gateway repository bridges that gap in a single container image and docker compose stack. It runs the SilvaEngine Gateway with only the a2a_daemon_engine module registered, exposing the full A2A protocol surface and bridging A2A tasks to a Hermes Agent API Server instance over HTTP + SSE. State persists to a bundled PostgreSQL backend with Row-Level Security for tenant isolation.
This article maps the three-layer architecture, the request lifecycle from A2A client to Hermes and back, the configuration and deployment patterns, and the operational concerns for running A2A on Hermes Agent in production. It is a reference deployment walkthrough — the general pattern (gateway-mediated A2A bridge to any agent framework) is the subject; the Docker stack is the worked implementation.
The three-layer architecture
The stack separates concerns into three layers, each owned by a distinct component:
The gateway is the only always-on service. Both Hermes and PostgreSQL are profile-gated siblings — bundle them for a self-contained stack, or turn the profiles off and point HERMES_API_URL and PG_HOST at external instances. This matters for production: you can run the gateway in your VPC and point it at a managed Postgres (RDS, Cloud SQL) and a Hermes instance running on a GPU node elsewhere.
Layer 1: SilvaEngine Gateway — transport and auth
The SilvaEngine Gateway is a FastAPI gateway for authenticated, in-process access to installed modules. It exposes module GraphQL and REST routes through a configurable YAML route manifest — adding a new module requires only manifest changes, zero gateway Python code. In this stack, only the A2A Daemon Engine is registered.
The gateway owns:
- Authentication — local JWT (HS256) or AWS Cognito (RS256 + JWKS), selected by
GATEWAY_AUTH_PROVIDER - Routing — YAML manifest maps URL paths to module dispatch functions
- SSE client lifecycle — the
sse_managerresolves per-module and manages long-lived client connections - Rate limiting — per-IP in-memory rate limiting (
GATEWAY_RATE_LIMITrequests perGATEWAY_RATE_WINDOWseconds) - Thread-pool dispatch — synchronous module dispatch functions run in a configurable thread pool (
GATEWAY_DISPATCH_WORKERS, default 32 in the Docker image)
The gateway builds partition_key = "{endpoint_id}#{Part-Id}" from the URL path segment and the Part-Id request header. Every tenant-scoped request requires that header. The Agent Card endpoint at /{ep}/.well-known/agent-card.json is public (no auth) per the A2A spec, but still requires Part-Id because the card is resolved per partition.
Layer 2: A2A Daemon Engine — protocol logic
The a2a_daemon_engine is not a standalone service. It is loaded as a registered gateway module via deploy() in main.py, which declares three gateway-facing entry points:
| Entry Point | Gateway Route | Method | Purpose |
|---|---|---|---|
a2a_core_graphql |
POST /{ep}/a2a_core_graphql |
POST | GraphQL CRUD for agents, tasks, messages, settings |
a2a |
POST /{ep}/a2a |
POST | A2A JSON-RPC protocol (message/send, tasks/get, tasks/cancel, tasks/list) |
sse_message |
POST /{ep}/a2a_sse |
POST | A2A JSON-RPC message + push to SSE clients |
The gateway additionally exposes GET /{ep}/a2a_sse for the SSE stream. The daemon does not listen on its own port or run its own HTTP server in production. All transport, auth, and SSE client lifecycle is owned by the gateway.
The daemon provides:
- A2A SDK v1.0 — JSON-RPC over HTTP, built on the official A2A SDK server pattern
- Public Agent Card at
/.well-known/agent-card.jsonwith ETag and Last-Modified support - Task state machine —
submitted→working→input-required|completed|failed|canceled - Dual-backend persistence — DynamoDB (PynamoDB) or PostgreSQL (SQLAlchemy + Alembic). The Docker image forces PostgreSQL.
- Multi-tenant isolation — composite partition keys (
{endpoint_id}#{part_id}) with PostgreSQL Row-Level Security - Pluggable LLM handlers — per-agent
module_name/class_nameselection in the agent registry
Layer 3: HermesAgentHandler — the bridge
The Hermes bridge handler (a2a_daemon_engine/handlers/a2a_hermes_handler.py) is the only framework-specific code. It implements an ask_model() interface: accept A2A message parts and context, call the Hermes Agent API Server, convert the response back to A2A message parts, and for streaming, forward token deltas to the SSE channel.
The handler supports two execution modes:
Non-streaming maps to POST /v1/chat/completions on the Hermes API Server — the OpenAI-compatible endpoint. The request carries converted A2A message parts as a chat completion payload. Hermes processes the request and returns a single response. The handler converts the response to an A2A Message with ROLE_AGENT and emits it to the SDK EventQueue. The client receives a single JSON-RPC response with the full agent text.
Streaming maps to POST /v1/runs to create a run, then opens an SSE connection to GET /v1/runs/{id}/events. Hermes streams events as they happen: token deltas (message.delta), reasoning metadata (reasoning.available), tool call/result notifications, approval requests (approval.required), and lifecycle events (run.created, run.completed, run.failed). The handler runs a drain loop in a background thread. Each message.delta event is pushed to the gateway's SSE manager for real-time delivery to connected clients. When run.completed arrives, the accumulated text is emitted as a single A2A Message to the SDK EventQueue.
The request lifecycle
A single message/send with stream=true traverses eight steps:
1. Client POST /{ep}/a2a {jsonrpc, method:"message/send", params}
Headers: Authorization: Bearer *** Part-Id:
2. Gateway auth (local JWT / Cognito) → route match from routes.yaml
→ partition_key = "{ep}#{Part-Id}"
3. a2a_daemon dispatch_a2a → A2ADaemonExecutor
→ resolve_agent(): agent metadata (DB) > setting dict > Config (env)
4. Handler HermesAgentHandler (A2A_AI_AGENT_MODULE / _CLASS)
5. Hermes POST {HERMES_API_URL}/v1/runs (Bearer HERMES_API_KEY)
GET {HERMES_API_URL}/v1/runs/{id}/events (SSE)
6. Broadcast token chunks → subscribers on GET /{ep}/a2a_sse
7. Persist task + messages written to PostgreSQL (a2a_* tables, RLS-scoped)
8. Response accumulated reply also returned in the HTTP JSON-RPC result Step 8 matters: even when streaming, the HTTP response carries the full reply. A client that misses SSE frames can still fall back to the HTTP response. The E2E test suite (test_hermes_sse_live.py) explicitly verifies this fallback in its step 06.
Agent resolution priority
Config resolution follows a priority chain: agent metadata (DB) → setting dict → Config defaults (env vars). Per-agent overrides win over global defaults. Two agents can point to different frameworks — one to Hermes for reasoning-heavy tasks, another to a different handler for workflow-orchestration tasks — and the A2A protocol surface looks identical to the calling agent.
For a Hermes-backed agent, the metadata stored in the a2a_agents table looks like:
{
"module_name": "a2a_daemon_engine.handlers.a2a_hermes_handler",
"class_name": "HermesAgentHandler",
"hermes_api_url": "http://127.0.0.1:8642",
"hermes_api_key": "hermes-local-key",
"hermes_model": "hermes-agent",
"hermes_timeout": 300.0
}The env-var defaults (HERMES_API_URL, HERMES_API_KEY, HERMES_MODEL) let the bridge reach Hermes without a DB agent record — the resolve_agent() function in a2a_ai_agent_utility.py falls back to env vars when no agent record exists. This means you can start the stack and send a message/send without registering any agent, and it will route to Hermes using the env defaults.
A2A state mapping
The bridge maps Hermes SSE events to A2A task states. This table is the heart of the bridge — every framework integration produces an equivalent table:
| Hermes SSE Event | A2A Task State | Bridge Action |
|---|---|---|
run.created (run_id returned) |
WORKING |
Register run_id for cancel support |
message.delta |
WORKING |
Accumulate token; emit to SSE per-chunk |
reasoning.available |
WORKING |
Reasoning metadata — no token emission |
tool.call / tool.result |
WORKING |
Tool execution metadata only |
approval.required |
INPUT_REQUIRED |
Emit approval chunk; store pending_approval |
run.completed |
COMPLETED |
Set stream event; accumulate final text |
run.failed |
FAILED |
Emit error chunk; set FAILED state |
POST /v1/runs/{id}/stop |
CANCELED |
External cancel via tasks/cancel |
POST /v1/runs/{id}/approval |
(continues run) | Resolved via operation="approval_response" |
The HERMES_INTEGRATION.md document in the a2a_daemon_engine repository records the exact Hermes event format, configuration keys, and end-to-end flow details.
Human-in-the-loop approval across agent boundaries
Hermes supports human-in-the-loop approval gates — when an agent needs permission to execute a sensitive action, it pauses and emits an approval request. The bridge translates this into the A2A INPUT_REQUIRED state, which signals to the calling agent (or human operator) that input is needed. The response comes back through POST /v1/runs/{id}/approval, and the run continues.
This is where the bridge pattern shows its value. A2A defines INPUT_REQUIRED as a first-class task state. Hermes has its own approval mechanism. The bridge maps one to the other, and the calling agent — which may itself be an A2A client running on a completely different framework — sees a standard protocol state transition, not a Hermes-specific detail. An agent delegation chain can include a step that requires human approval (a purchase authorization, a data access decision, a quote approval), and the A2A protocol carries that gate transparently across framework boundaries.
The A2A SDK v2 constraint and the dual-path fix
The A2A SDK v2 (a2a-sdk==1.0.2) imposes two constraints on the on_message_send path that shaped the bridge implementation:
- Single Message only. Emitting multiple
Messageobjects to the SDK EventQueue raisesInvalidAgentResponseError: Multiple Message objects received. - No TaskStatusUpdateEvent. Status events raise
InvalidAgentResponseError: Received TaskStatusUpdateEvent in message mode.
A naive bridge would emit one Message per token delta — the natural streaming pattern. The SDK rejects this. It also rejects status events (WORKING, COMPLETED) on the message/send path.
The fix is a dual-path output channel:
- SSE (gateway-managed): Token chunks are pushed to SSE in real-time. Connected clients see streaming output as it happens. Status events (WORKING, COMPLETED, FAILED) also go to SSE only.
- SDK EventQueue: After the stream completes, a single accumulated
Messagecontaining the full response text is emitted to the SDK EventQueue. This is what the JSON-RPCmessage/sendresponse returns.
The client gets real-time streaming via SSE and a clean single-message JSON-RPC response via the SDK. Both channels work; neither violates the SDK constraints.
Protocol surfaces on one port
The gateway exposes five protocol surfaces on a single port (default 8765):
| Protocol | Route | Auth | Purpose |
|---|---|---|---|
| GraphQL | POST /{ep}/a2a_core_graphql |
Yes | A2A core queries/mutations (agents, tasks, messages, settings) |
| JSON-RPC 2.0 | POST /{ep}/a2a |
Yes | A2A protocol: message/send, tasks/get, tasks/cancel, tasks/list |
| SSE (stream) | GET /{ep}/a2a_sse |
Yes | Long-lived per-partition A2A task event stream |
| SSE (push) | POST /{ep}/a2a_sse |
Yes | JSON-RPC message + push to connected SSE clients |
| Agent Card | GET /{ep}/.well-known/agent-card.json |
Public | A2A discovery document (Part-Id header still required) |
The JSON-RPC message/send params shape used by the test harnesses:
{
"message": {
"role": "ROLE_USER",
"parts": [{ "text": "Say hello from A2A" }]
},
"metadata": {
"operation": "task_execution",
"agent_uuid": "a2a-hermes-agent",
"stream": true,
"task_data": { "task_id": "my-task-001", "task_type": "hermes_test" },
"system_prompt": "You are a concise assistant.",
"conversation_history": []
}
}The metadata.operation field selects the execution path: task_execution for agent runs with streaming support, message_response for non-streaming chat. The agent_uuid targets a specific agent in the registry. The stream flag enables SSE broadcasting. The task_data.task_id is a caller-supplied id used by tasks/get and tasks/cancel.
SSE is per-partition, not per-task. Every task in {ep}#{Part-Id} broadcasts to all subscribers of that partition. Order of operations matters: connect the SSE listener before sending the message, or early token chunks are missed.
Persistence and multi-tenancy
The Docker image forces db_backend=postgresql — DynamoDB is not supported. The daemon uses literal, unprefixed table names:
| Table | Holds |
|---|---|
a2a_agents |
Agent records + per-agent handler/model metadata |
a2a_tasks |
Task lifecycle + status |
a2a_messages |
Message turns per task |
a2a_settings |
Per-partition setting dicts |
Because the names are unprefixed, do not share PG_DB with another module that uses the same names.
Tenant isolation uses PostgreSQL Row-Level Security. The session variable app.tenant_id is set to the request's partition_key ("{endpoint_id}#{Part-Id}"), and RLS policies scope every query to it. Tables and policies are created automatically on gateway startup when initialize_tables=1. This means a forgotten partition_key filter in application code cannot leak cross-tenant rows — the database enforces the boundary.
The RLS implementation lives in a2a_daemon_engine/utils/rls.py (set_rls_context and create_rls_policies) and the migration 0005_enable_rls_policies. The set_rls_context function runs a per-request SET app.tenant_id on the connection, and create_rls_policies enables and forces RLS with a tenant_isolation policy on all four A2A tables. RLS is inert in DynamoDB mode.
Deployment with Docker Compose
The stack has one always-on service and two optional profile-gated siblings:
| Service | Container name | Always on? | Profile | Purpose |
|---|---|---|---|---|
a2a-gateway |
a2a-hermes-gateway |
Yes | — | SilvaEngine Gateway (A2A-only routes) + Hermes bridge |
postgres |
a2a-postgres |
Optional | postgres |
Bundled PostgreSQL persistence backend |
hermes |
container-hermes |
Optional | hermes |
Bundled Hermes Agent (OpenAI-compatible API + dashboard) |
COMPOSE_PROFILES is the single switch for both siblings:
| Value | Services started |
|---|---|
| empty | gateway only (external Postgres + external Hermes) |
postgres |
gateway + bundled Postgres |
hermes |
gateway + bundled Hermes (external Postgres) |
postgres,hermes |
gateway + bundled Postgres and Hermes (default) |
When a sibling is bundled, keep its host reference pointed at the service name: PG_HOST=postgres and HERMES_API_URL=http://hermes:<API_SERVER_PORT>. When a sibling is external, point those at your own instance (e.g. PG_HOST=host.docker.internal, HERMES_API_URL=http://host.docker.internal:8642).
Quick start
cp .env.example .env
# Fill in: JWT_SECRET_KEY, ADMIN_PASSWORD, API_SERVER_KEY,
# HERMES_API_KEY (= API_SERVER_KEY), HERMES_MODEL_PROVIDER + provider key, HERMES_MODEL
mkdir -p www/hermes www/projects
DOCKER_BUILDKIT=1 docker compose build
docker compose up -d # COMPOSE_PROFILES=postgres,hermes is the default
docker compose ps # wait for (healthy)
curl -f http://localhost:8765/health
pip install requests
python test_hermes_hello.py # end-to-end smoke testBoth silvaengine_gateway and a2a_daemon_engine are pip-installed from git into the image (no host source mount). The image is generic and fully env-driven — no secrets are baked in. The modules are cloned from public GitHub repos under ideabosque over git+https — no credentials or SSH deploy key needed.
The .env inline comment trap
Docker Compose's env_file parser does not strip inline comments. A line like:
HERMES_API_KEY=hermes-local-key # token for Hermessets HERMES_API_KEY to the literal string hermes-local-key # token for Hermes (comment included), which silently breaks authentication. The rules: put nothing after the value on any KEY=value line. Put notes on their own # comment lines above the variable.
Verification: 15 E2E checks across 5 scripts
The stack ships with standalone Python test harnesses (only dependency: requests). They load ./.env, resolve or mint a gateway JWT, and talk to the running stack:
| Script | Kind | What it does |
|---|---|---|
test_hermes_hello.py |
Smoke | Non-streaming message/send, prints the reply |
test_hermes_hello_sse.py |
Smoke | One prompt streamed back over SSE |
test_hermes_gateway_live.py |
E2E suite | 9 checks: Hermes health, gateway health, agent card, GraphQL ping, message/send, tasks/get, tasks/list, tasks/cancel, failure path |
test_hermes_sse_live.py |
E2E suite | 6 checks: health x2, SSE connect, live token chunks, COMPLETED status, HTTP fallback |
test_hermes_chatbot.py |
Interactive | REPL against the A2A surface with live SSE streaming |
All non-interactive scripts print PASS/FAIL per step and exit non-zero on failure, so they work as CI gates. The unit test suite (test_hermes_handler.py) runs 24 tests with mocked HTTP via httpx.MockTransport — no services required.
Operational patterns
Route changes without rebuilds
routes.yaml is bind-mounted read-only into the container. Edit the host file and restart the gateway process — no rebuild needed:
make restartAfter an upstream change
Because silvaengine_gateway and a2a_daemon_engine are pip-installed from git at build time, an upstream change requires a rebuild with --no-cache so the git layer re-clones the latest @main:
DOCKER_BUILDKIT=1 docker compose build --no-cache
docker compose up -d --force-recreateThere is no version pinning — @main is a moving target. Pin a tag or commit in requirements-modules.txt if you need reproducibility.
Scaling beyond one worker
In-memory task state, rate-limit counters, and the SSE client registry are per-process. With GATEWAY_WORKERS > 1, switch to shared backends (GATEWAY_TASK_BACKEND=dynamodb, GATEWAY_RATE_LIMIT_BACKEND=dynamodb, plus region_name and aws_* credentials) and use sticky sessions for SSE. The default configuration starts one Uvicorn process.
Security defaults to change
JWT_SECRET_KEY=change-me-in-production— replace withopenssl rand -hex 32ADMIN_PASSWORD=change-me— replace with a real passwordPOSTGRES_PASSWORD=silvaengine— replace with a real passwordGATEWAY_CORS_ORIGINS=*allows any origin without credentials. Set an explicit list if you need cookies/credentials.- The bundled Hermes mounts the host Docker socket (
/var/run/docker.sock). That is effectively root on the host for anything inside that container. Only run thehermesprofile on a host you control, and remove the mount if the agent does not need to launch containers. - The Hermes dashboard defaults to enabled on port 9119 with empty basic-auth credentials. Set
HERMES_DASHBOARD_BASIC_AUTH_*or bind the port to localhost before exposing the host. - RLS is the tenant boundary. A caller that can set an arbitrary
Part-Idreads that partition's data — treatPart-Idas authorization-relevant input in any front-end you put in front of this.
What this enables
The three-layer stack gives you three capabilities that are hard to assemble from scratch:
1. A2A protocol compliance without rewriting Hermes. Any A2A client can discover the Hermes-backed agent via its Agent Card, send tasks via message/send, stream responses via SSE, and track task lifecycle through standard A2A states. The client does not know that the remote agent runs Hermes — it sees an A2A endpoint with a JSON-RPC interface.
2. Multi-tenant agent serving from one gateway. The Part-Id header combined with PostgreSQL RLS means one gateway instance serves multiple tenants with hard database-level isolation. Each tenant gets its own agent registry, task history, and message store — all in the same four tables, scoped by partition_key.
3. Human-in-the-loop approval across agent boundaries. Hermes approval gates map to A2A INPUT_REQUIRED states. An agent delegation chain can include a step that requires human approval, and the A2A protocol carries that state transition back to the originating agent or operator — regardless of which framework each agent in the chain runs on.
The reference implementation also supports AWS Lambda dispatch for serverless A2A, experimental gRPC transport with bidirectional streaming, and dual-backend persistence (DynamoDB or PostgreSQL). The a2a_daemon_engine repository and its Hermes integration guide contain the full implementation, configuration reference, and state mapping details. The SilvaEngine Gateway repository documents the route manifest system, authentication providers, and module auto-initialization.
Related reading
- MCP + A2A: The Two Protocols Behind Every Production Agentic AI System — the complementary roles of MCP (agents to tools) and A2A (agents to agents) in the two-layer protocol stack
- Integrating A2A with Existing Agent Frameworks: A Hermes Agent Demonstration — the general bridge pattern and how it applies to OpenClaw and other frameworks beyond Hermes
- MCP Module Code Standard — the structural pattern for production-ready agent modules, applicable to A2A handler code as well
A mid-market distributor needs quoting agents that talk to catalog agents that talk to inventory agents — each backed by a different framework, each owned by a different team. A2A gives those agents a shared protocol. A bridge layer lets Hermes Agent participate without rewriting its internals. The docker-a2a-hermes-agent-gateway packages that bridge into a single container image with PostgreSQL persistence, RLS multi-tenancy, and 15 E2E test checks.
Request a scoped build
One-week discovery. You get a system inventory, workflow map, and fixed scope — whether or not you build with us.
Want this built for your systems?
Every document here comes from real production work. If you have a target system and a workflow in mind, we can scope a build in one week.
Request a scoped buildOne-week discovery. You get a system inventory, workflow map, and fixed scope — whether or not you build with us.