Back to Library
A2A

Deploying A2A on Hermes Agent: A Docker Gateway Reference Stack

Last updated: July 23, 2026

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:

A2A on Hermes Agent — Three-Layer Stack docker-a2a-hermes-agent-gateway reference deployment 1 A2A Client Any agent or application speaking JSON-RPC 2.0 message/send · tasks/get · SSE 2 SilvaEngine Gateway Transport · auth · routing · SSE client lifecycle JWT / Cognito auth Part-Id tenant routing SSE client registry Rate limiting 3 A2A Daemon Engine Protocol logic · task state machine · handler dispatch Agent Card serving Task lifecycle HermesAgentHandler Dual-path streaming Hermes Agent API Server OpenAI-compatible · SSE runs /v1/chat/completions /v1/runs + /events PostgreSQL Persistence · RLS tenant isolation a2a_agents / tasks messages / settings Gateway is the only always-on service — Hermes and PostgreSQL are profile-gated siblings — ideabosque.com/library

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_manager resolves per-module and manages long-lived client connections
  • Rate limiting — per-IP in-memory rate limiting (GATEWAY_RATE_LIMIT requests per GATEWAY_RATE_WINDOW seconds)
  • 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.json with ETag and Last-Modified support
  • Task state machinesubmittedworkinginput-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_name selection 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:

  1. Single Message only. Emitting multiple Message objects to the SDK EventQueue raises InvalidAgentResponseError: Multiple Message objects received.
  2. 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 Message containing the full response text is emitted to the SDK EventQueue. This is what the JSON-RPC message/send response 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 test

Both 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 Hermes

sets 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 restart

After 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-recreate

There 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 with openssl rand -hex 32
  • ADMIN_PASSWORD=change-me — replace with a real password
  • POSTGRES_PASSWORD=silvaengine — replace with a real password
  • GATEWAY_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 the hermes profile 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-Id reads that partition's data — treat Part-Id as 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


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 build

One-week discovery. You get a system inventory, workflow map, and fixed scope — whether or not you build with us.