A2A Agent Protocol Architecture in OmniRoute: A Deep Dive into Google's Agent-to-Agent Standard
OmniRoute implements the Google A2A (v0.3) protocol as a first-class A2A Server that other agents can discover, call, and stream results from, using a layered architecture built on top of its existing /v1/chat/completions endpoint.
The A2A agent protocol architecture in OmniRoute enables seamless interoperability between AI agents through a JSON-RPC 2.0 interface. Rather than building a separate stack, OmniRoute extends its production routing pipeline to support agent-to-agent communication, allowing external agents to leverage its smart routing capabilities through a standardized protocol.
Core Architecture Layers
OmniRoute's A2A implementation consists of nine coordinated layers, each handling a specific concern in the agent communication lifecycle.
Discovery Layer: Agent Card
The discovery layer exposes an Agent Card at /.well-known/agent.json that advertises the server's URL, version, capabilities, and available skills. This follows the A2A specification for automatic agent discovery.
In src/app/a2a/route.ts, the OPTIONS handler serves the Agent Card:
// src/app/a2a/route.ts - Agent Card discovery endpoint
export async function OPTIONS(request: NextRequest) {
return NextResponse.json({
name: "OmniRoute A2A Server",
version: "0.3.0",
url: "http://localhost:20128/a2a",
skills: [
{ id: "smart-routing", name: "Smart Routing", description: "Route prompts to optimal LLM providers" },
{ id: "quota-management", name: "Quota Management", description: "Query provider quotas and usage" }
]
});
}
Any A2A-compatible agent can fetch this card to understand what the server offers before initiating communication.
JSON-RPC Router Layer
The JSON-RPC router in src/app/a2a/route.ts handles all /a2a POST traffic. It validates authentication using constant-time bearer token comparison (tokensMatch), parses requests, dispatches to skill handlers, and returns either JSON-RPC responses or SSE streams.
// Authentication check in src/app/a2a/route.ts
const authHeader = request.headers.get("authorization") || "";
const token = authHeader.replace("Bearer ", "");
if (!tokensMatch(token, process.env.OMNIROUTE_API_KEY || "")) {
return jsonRpcError("1", -32000, "Unauthorized");
}
If no OMNIROUTE_API_KEY is configured, the endpoint operates in open mode—suitable for development, not production.
Task Manager: Stateful Lifecycle Control
The task manager in src/lib/a2a/taskManager.ts implements an in-memory state machine with optional persistence:
submitted → working → completed | failed | cancelled
Key responsibilities include:
- UUIDv4 task ID generation for unique task tracking
- TTL enforcement with automatic cleanup of expired tasks
- Per-state event recording for audit trails
- Statistics aggregation for monitoring
This layer ensures that long-running agent tasks remain trackable even across temporary disconnections.
Task Execution Layer
The executeA2ATaskWithState function in src/lib/a2a/taskExecution.ts wraps skill handlers within the task lifecycle. It:
- Transitions task to
workingstate - Invokes the selected skill handler
- Captures results or errors
- Updates to
completed,failed, orcancelled
Errors propagate back to the JSON-RPC response with appropriate codes and messages.
Skills Registry: Pluggable Business Logic
Skills are registered in a A2A_SKILL_HANDLERS map, with each skill ID mapping to an async handler:
| Skill ID | Handler File | Purpose |
|---|---|---|
smart-routing |
src/lib/a2a/skills/smartRouting.ts |
Forwards prompts to OmniRoute's routing pipeline |
quota-management |
src/lib/a2a/skills/quotaManagement.ts |
Natural-language quota and usage queries |
Each skill implements its own business logic while conforming to a standard interface: (params: any, taskId: string) => Promise<any>.
Smart Routing Skill Implementation
The smart-routing skill is the flagship capability. It receives a prompt and metadata, forwards it to OmniRoute's existing /v1/chat/completions infrastructure, then enriches the response with routing metadata:
// src/lib/a2a/skills/smartRouting.ts - conceptual flow
export async function smartRoutingHandler(params: any, taskId: string) {
const { messages, metadata } = params;
// Use OmniRoute's production routing engine
const routingResult = await routeToOptimalProvider({
messages,
modelPreference: metadata?.model || "auto",
budgetConstraint: metadata?.budget
});
return {
artifacts: [{ type: "text", content: routingResult.content }],
metadata: {
routing_explanation: routingResult.explanation,
provider: routingResult.provider,
model: routingResult.model,
actual_cost: routingResult.cost,
latency_ms: routingResult.latency
}
};
}
Routing Logger for Observability
After each successful smart-routing call, src/lib/a2a/routingLogger.ts records a compact representation containing:
- Selected combo (provider + model)
- Actual cost and latency
- Routing explanation
- Timestamp and task correlation
This enables analytics dashboards and cost attribution across agent-to-agent traffic.
SSE Streaming Layer
The createA2AStream function in src/lib/a2a/streaming.ts converts task execution into Server-Sent Events. It manages:
- Stream initiation via
beginStream - Chunk-by-chunk delivery of partial results
- Stream termination via
endStreamwith final state
Clients using message/stream receive data: lines containing JSON-RPC notification objects until the task reaches a terminal state.
Schema Validation Layer
All A2A data structures are validated using Zod schemas in open-sse/mcp-server/schemas/a2a.ts:
- Agent Card structure
- Task lifecycle objects
- JSON-RPC request/response envelopes
- SSE event shapes
This ensures type-safe handling across the entire pipeline and catches malformed requests early.
Complete Request Flow
External Agent
↓
GET /.well-known/agent.json (discover capabilities)
↓
POST /a2a with JSON-RPC body
↓
src/app/a2a/route.ts
├── Authenticate (bearer token check)
├── Parse method (message/send, message/stream, tasks/get, tasks/cancel)
├── Create task via taskManager
└── Dispatch to skill handler
↓
src/lib/a2a/taskExecution.ts
└── Execute skill within state machine
↓
src/lib/a2a/skills/smartRouting.ts
└── Call OmniRoute /v1/chat/completions pipeline
↓
src/lib/a2a/routingLogger.ts (record decision)
↓
Return result or SSE stream
Client Integration Examples
Synchronous Task Execution
curl -X POST http://localhost:20128/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/send",
"params": {
"skill": "smart-routing",
"messages": [{"role":"user","content":"Write a Python hello world"}],
"metadata": {"model":"auto","budget":0.10}
}
}'
Streaming with SSE
curl -N -X POST http://localhost:20128/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{
"jsonrpc":"2.0",
"id":"stream-1",
"method":"message/stream",
"params":{"skill":"smart-routing","messages":[{"role":"user","content":"Explain quantum computing"}]}
}'
Python Client with Discovery
import requests, json
BASE = "http://localhost:20128"
HEADERS = {"Content-Type":"application/json","Authorization":"Bearer YOUR_KEY"}
# Discover capabilities
card = requests.get(f"{BASE}/.well-known/agent.json").json()
skills = [s['id'] for s in card['skills']]
print(f"Agent {card['name']} v{card['version']} – skills: {skills}")
# Execute smart-routing task
resp = requests.post(f"{BASE}/a2a", headers=HEADERS, json={
"jsonrpc":"2.0","id":"task-1","method":"message/send",
"params":{"skill":"smart-routing",
"messages":[{"role":"user","content":"Write quicksort in Python"}],
"metadata":{"model":"auto","budget":0.05}}
})
result = resp.json()["result"]
print("Response:", result["artifacts"][0]["content"])
print("Routing:", result["metadata"]["routing_explanation"])
Key Design Decisions
| Decision | Rationale |
|---|---|
| Reuse existing pipeline | Smart-routing skill calls the same /v1/chat/completions infrastructure used by direct clients—no code duplication |
| In-memory task state by default | Low latency for typical use cases; persistence pluggable for durability requirements |
| Constant-time token comparison | Prevents timing attacks on the authentication layer |
| Zod for validation | TypeScript-native, excellent error messages, runtime and static type alignment |
| SSE over WebSockets | Simpler infrastructure, firewall-friendly, aligns with A2A specification |
File Reference
| Path | Responsibility |
|---|---|
src/app/a2a/route.ts |
JSON-RPC endpoint, auth, dispatch, discovery |
src/lib/a2a/taskManager.ts |
Task lifecycle, TTL, cleanup, statistics |
src/lib/a2a/taskExecution.ts |
Skill execution within state machine |
src/lib/a2a/streaming.ts |
SSE stream creation and management |
src/lib/a2a/skills/smartRouting.ts |
Core routing skill implementation |
src/lib/a2a/skills/quotaManagement.ts |
Quota query skill |
src/lib/a2a/routingLogger.ts |
Observability and analytics |
open-sse/mcp-server/schemas/a2a.ts |
Zod schema definitions |
Summary
- OmniRoute's A2A architecture implements Google A2A v0.3 as a server that exposes smart routing capabilities to external agents
- Nine coordinated layers handle discovery, routing, task lifecycle, execution, streaming, and observability
- Skills-based extensibility allows new agent capabilities to be added without modifying core protocol code
- Production-hardened design reuses the existing
/v1/chat/completionspipeline while adding minimal A2A-specific overhead - Type safety throughout via Zod schemas with runtime validation
Frequently Asked Questions
What version of the A2A protocol does OmniRoute support?
OmniRoute implements Google A2A v0.3 according to the source code in src/lib/a2a/README.md and the Agent Card version fields. This is the latest stable specification as of the v3.8.50 release.
Can I add custom skills to the A2A server?
Yes. The skills registry in src/app/a2a/route.ts uses a simple handler map pattern. Create a new file in src/lib/a2a/skills/, export an async handler function with the signature (params: any, taskId: string) => Promise<any>, and register it in A2A_SKILL_HANDLERS with a unique skill ID.
How does authentication work for A2A endpoints?
Authentication uses bearer token validation with constant-time comparison (tokensMatch). Set OMNIROUTE_API_KEY in your environment, then include Authorization: Bearer YOUR_KEY in requests. If the environment variable is unset, the endpoint accepts all requests—useful for development but not recommended for production.
What's the difference between message/send and message/stream?
message/send executes synchronously and returns the complete result as a single JSON-RPC response. message/stream delivers partial output via Server-Sent Events, with each chunk containing a JSON-RPC notification object. Streaming is preferred for long-generation tasks or when lower time-to-first-token is desired.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →