Agent systems are becoming distributed systems. When you chain three agents together with HTTP calls, you get away with it. When you coordinate twenty agents across cloud boundaries, language runtimes, and ephemeral execution contexts, HTTP starts to hurt.
NATS published a protocol spec that treats agents as first-class messaging participants. The Synadia Agent Protocol for NATS defines discovery, conversation, and liveness primitives so agents can find each other, exchange typed streams, and handle failure without a central orchestrator knowing every agent’s implementation details.
This is not a framework. It is a contract. If your agent speaks NATS, any other agent on the same fabric can call it without caring whether it is a Python script, a hosted model wrapper, or a long-running service.
Why Message Brokers for Agent Coordination
HTTP works for request-response. It breaks down when you need:
- Decoupled discovery: Agents come and go. A message broker lets agents announce capabilities without hardcoding endpoints.
- Backpressure and flow control: Streaming responses from LLMs can overwhelm callers. NATS subjects support rate limiting and acknowledgment semantics.
- Failure isolation: If an agent crashes mid-workflow, durable subscriptions and message replay let you recover without rebuilding the entire orchestration chain.
- Multi-cloud and edge: NATS bridges cloud vendors, on-prem clusters, and edge devices with a single security model. HTTP requires API gateways, service meshes, or custom proxies.
When you orchestrate agents with HTTP, you rebuild these patterns in application code. When you use a message broker, they are transport primitives.
NATS Messaging Primitives for Agents
NATS supports three patterns that map directly to agent coordination needs:
| Pattern | Agent Use Case | Failure Mode |
|---|---|---|
| Request-Reply | Tool calls, synchronous queries | Timeout if agent crashes before reply |
| Pub-Sub | Broadcast events, state updates | Subscribers miss messages if offline |
| Streaming (JetStream) | Durable state logs, replay | Storage overhead, replay latency |
Request-Reply for Tool Calls
An orchestrator sends a prompt to agents.summarize.request. Any agent subscribed to that subject receives the message, processes it, and replies. The orchestrator does not need to know which agent instance handled the request.
// Type definitions for request and response
public record PromptRequest
{
public string Prompt { get; init; }
public Dictionary<string, object> Context { get; init; }
}
public record AgentResponse
{
public string Type { get; init; } // "text", "tool_call", "error"
public string Content { get; init; }
public object Data { get; init; }
}
// Orchestrator sends a request
var response = await nats.RequestAsync<PromptRequest, AgentResponse>(
"agents.summarize.request",
new PromptRequest
{
Prompt = "Summarize Q3 earnings",
Context = new Dictionary<string, object>
{
["document_id"] = "doc-123"
}
}
);
If the agent crashes before replying, the orchestrator sees a timeout. You handle retries at the orchestration layer or use JetStream to persist the request for later replay.
Pub-Sub for State Broadcasts
When an agent updates shared state (a document index, a knowledge graph, a session context), it publishes to a subject. Other agents subscribe and update their local views.
// Agent publishes state update
await nats.PublishAsync("state.documents.updated", new DocumentUpdate
{
DocumentId = "doc-123",
Embedding = embeddingVector
});
// Other agents subscribe
await foreach (var msg in nats.SubscribeAsync<DocumentUpdate>("state.documents.*"))
{
UpdateLocalIndex(msg.Data);
}
This decouples state synchronization from orchestration logic. Agents do not call each other directly. They react to events.
JetStream for Durable State Logs
JetStream adds persistence and replay. If an agent crashes, it can replay messages from the last acknowledged offset. If you need audit logs or debugging traces, JetStream stores the entire message history.
// Create a durable stream for agent interactions
var stream = await jsm.AddStreamAsync(new StreamConfig
{
Name = "AGENT_INTERACTIONS",
Subjects = new[] { "agents.*.request", "agents.*.response" }
});
// Consumer replays from last acknowledged message
var consumer = await jsm.AddOrUpdateConsumerAsync("AGENT_INTERACTIONS", new ConsumerConfig
{
Durable = "orchestrator-consumer",
AckPolicy = AckPolicy.Explicit
});
You pay for storage and replay latency. Use JetStream when you need guaranteed delivery or forensic visibility. Use core NATS pub-sub when you need speed and can tolerate message loss.
Synadia Agent Protocol: Discovery, Conversation, Liveness
The protocol defines three responsibilities:
1. Discovery
Agents announce capabilities by publishing to agents.discovery. Callers subscribe to that subject and build a registry of available agents and their supported operations.
{
"agent_id": "summarizer-v2",
"capabilities": ["summarize", "extract_entities"],
"subjects": {
"summarize": "agents.summarizer-v2.summarize",
"extract_entities": "agents.summarizer-v2.extract"
}
}
This avoids hardcoding agent endpoints. When you deploy a new agent version, it announces itself. Old versions stop announcing when they shut down.
2. Conversation
Agents receive prompts and return typed streams. The protocol specifies a JSON schema for requests and responses. Callers send a PromptRequest with context and tool definitions. Agents reply with a stream of AgentResponse messages (text chunks, tool calls, errors).
{
"prompt": "Summarize Q3 earnings",
"context": {
"document_id": "doc-123"
},
"tools": [
{
"name": "fetch_document",
"parameters": {
"id": "string"
}
}
]
}
The agent streams back:
{ "type": "text", "content": "Q3 revenue increased by 15%..." }
{ "type": "tool_call", "tool": "fetch_document", "args": { "id": "doc-456" } }
{ "type": "done" }
This maps to OpenAI’s streaming API shape but runs over NATS instead of HTTP.
3. Liveness
Agents publish heartbeats to agents.heartbeat.<agent_id>. Orchestrators subscribe and track which agents are alive. If an agent stops sending heartbeats, the orchestrator removes it from the registry.
// Agent sends heartbeat every 5 seconds (C# 11+ PeriodicTimer)
var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync())
{
await nats.PublishAsync($"agents.heartbeat.{agentId}", new Heartbeat
{
Timestamp = DateTimeOffset.UtcNow,
Status = "healthy"
});
}
This is simpler than HTTP health checks. No load balancer configuration, no polling endpoints. Agents broadcast liveness. Orchestrators listen.
Architecture: NATS Topology for Multi-Agent Systems
A typical NATS-based agent system has three layers:
- Core NATS cluster: Handles pub-sub and request-reply. Runs in Kubernetes, on-prem, or as a managed service. Agents connect via TCP or WebSocket.
- JetStream persistence: Stores durable streams for audit logs, state replay, and guaranteed delivery. Optional. Add it when you need durability.
- Agent mesh: Agents run anywhere (cloud functions, containers, edge devices). They connect to the NATS cluster and subscribe to subjects based on their capabilities.
Orchestrator (subscribes to agents.*.response)
|
+---------v---------+
| NATS Cluster |
| (pub-sub core) |
+---------+---------+
|
+-------------+-------------+
| | |
+-----v-----+ +-----v-----+ +-----v-----+
| Agent A | | Agent B | | Agent C |
| (Python) | | (C#) | | (Rust) |
+-----------+ +-----------+ +-----------+
Security boundaries map to NATS accounts. You can isolate agents by tenant, environment, or trust level without changing application code.
Failure Modes and Observability
Failure Modes
| Failure | Impact | Mitigation |
|---|---|---|
| Agent crashes mid-request | Orchestrator sees timeout | Use JetStream for durable requests, replay on restart |
| NATS cluster partition | Agents in different partitions cannot communicate | Deploy NATS with clustering and failover, monitor split-brain |
| Message loss (core NATS) | Subscribers miss updates | Use JetStream for critical messages, accept loss for ephemeral events |
| Slow consumer | Agent cannot keep up with message rate | NATS applies backpressure, drops messages, or disconnects slow consumers |
Observability
NATS exposes metrics via Prometheus. Track:
- Message rate per subject: Identifies hot agents or bottlenecks.
- Consumer lag (JetStream): Shows how far behind an agent is in processing durable streams.
- Connection count: Monitors agent churn and liveness.
You can also subscribe to agents.* with a monitoring agent that logs all interactions for debugging or compliance.
When to Use NATS for Agent Orchestration
Use NATS when:
- You coordinate agents across cloud boundaries, languages, or runtimes.
- You need decoupled discovery and do not want to hardcode agent endpoints.
- You want built-in backpressure, retries, and failure isolation without rebuilding them in application code.
- You need audit logs or replay for compliance or debugging.
Avoid NATS when:
- You have a single-language, single-cloud system where HTTP is simpler.
- You do not need decoupling and can tolerate tight coupling between orchestrator and agents.
- You cannot run a message broker (serverless environments with no persistent connections).
Technical Verdict
NATS gives you the transport primitives that HTTP forces you to rebuild. If your agent system spans multiple clouds, languages, or execution contexts, a message broker is the right abstraction. The Synadia Agent Protocol maps agent coordination patterns (discovery, streaming responses, liveness) to NATS subjects and message types.
The trade-off is operational complexity. You run a NATS cluster. You monitor message rates, consumer lag, and connection health. You design subject hierarchies and security boundaries.
If you are already running microservices with NATS, adding agents is straightforward. If you are starting from scratch, evaluate whether the decoupling and failure isolation justify the infrastructure overhead. For systems with fewer than ten agents in a single cloud, HTTP may be simpler. For systems with dozens of agents across boundaries, NATS is the right tool.