What it is
The nats package implements the
NATS Agent Protocol v0.3
for Phero agents. It provides two top-level types:
-
nats.Server: registers anyagent.Agentas a NATS micro service. It handles discovery (via$SRV.PING/INFO), streaming prompt responses, periodic heartbeats, and on-demand status replies. The agent is wire-compatible with the TypeScript and Python SDKs in the synadia-agents repository. -
nats.Client: discovers compliant agents on the NATS bus and sends them prompts.Client.AsTool()wraps a remote agent as anllm.Toolthat any local Phero agent can call. -
nats.Resolver: combines discovery with heartbeat tracking for callers that stay running. It caches each agent's handle, reuses it only while the agent keeps sending heartbeats, and looks the agent up again after a failed prompt.
Unlike the A2A transport (HTTP-based), the NATS transport uses pub/sub messaging, which makes it well-suited for service meshes and environments where NATS is already running.
Example: registering an agent as a NATS server
See the full code in examples/nats-agent/server.
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
natsgo "github.com/nats-io/nats.go"
"github.com/henomis/phero/v2/agent"
"github.com/henomis/phero/v2/llm/openai"
natsagent "github.com/henomis/phero/v2/nats"
)
func main() {
nc, _ := natsgo.Connect(natsgo.DefaultURL)
defer nc.Drain()
llmClient := openai.New(os.Getenv("OPENAI_API_KEY"))
a, _ := agent.New(llmClient, "my-agent", "A helpful assistant.")
srv, _ := natsagent.New(nc, a, "alice", "demo")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Serves until ctx is cancelled, then drains in-flight prompts.
if err := srv.Start(ctx); err != nil {
log.Println(err)
}
}
New(nc, agent, owner, name) registers the agent as a NATS micro service.
The owner and name values become the 4th and 5th tokens of the prompt subject
(agents.prompt.phero.<owner>.<name>).
Start(ctx) serves until the context is cancelled, then drains (see
Graceful shutdown). A Server serves once: calling
Start again after it has drained returns ErrServerStopped.
Graceful shutdown
Cancelling the context passed to Start shuts the server down. It does not abandon prompts
that are already running. The shutdown happens in this order:
- The prompt endpoint is unsubscribed, so NATS sends new prompts to another replica in the
agentsqueue group. A prompt already buffered on this instance gets a500reply with error codeserver_draininginstead of being served. It is a 500 rather than a 503 because the protocol only allows the status codes listed in its §9.2 table. Callers retry 5xx errors, so the prompt goes to another replica. - Heartbeats stop, so this instance no longer shows as live.
- Handlers that are already running keep their own context and finish their LLM call.
- If they are still running when most of the drain budget is used up, their context is cancelled. The last fifth of the budget is left for them to return.
WithDrainTimeout(d) sets the total budget for all of this (default 30 s). Set it to cover a
typical completion: those calls are already paid for. A zero or negative value cancels in-flight handlers
immediately. If handlers still have not returned when the budget runs out, Start returns
ErrDrainIncomplete.
To trigger the same shutdown from elsewhere, for example from a signal handler, call
srv.Drain(ctx). It is safe to call concurrently and more than once, and it also makes a
blocked Start return. The ctx you pass limits only how long that caller waits;
it does not limit the drain itself.
srv, _ := natsagent.New(nc, a, "alice", "demo",
natsagent.WithDrainTimeout(2*time.Minute),
)
sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
go func() {
<-sigCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := srv.Drain(shutdownCtx); err != nil {
log.Printf("drain: %v", err)
}
}()
_ = srv.Start(context.Background())
Readiness
Start blocks for the server's whole life. To know when the agent can take prompts, wait on
srv.Ready(). The channel closes once the service is registered, the first heartbeat is
published, and the broker has acknowledged both with a flush. From then on, any connection can discover
and prompt the agent.
Ready() never closes if Start fails or the server drains first, so wait on it
together with Start's result:
errc := make(chan error, 1)
go func() { errc <- srv.Start(ctx) }()
select {
case <-srv.Ready():
// registered and reachable
case err := <-errc:
return err
}
It is a one-time startup signal, not a health check: it stays closed after a lost connection and after a
drain. A Resolver in the same process still needs to receive a heartbeat of its own before it
reports the agent online; WaitReady covers that.
Identifiers, sessions and payload limits
The agent id, owner, name and session are inserted into NATS subjects, so New checks each
one with ValidateSubjectToken. Only [A-Za-z0-9_-] is allowed, up to 64
characters. ., * and > are rejected because they would change
which subjects the server subscribes to. For example, an owner of * would create a wildcard
subscription in the shared queue group. Invalid values return ErrInvalidSubjectToken.
ValidateSubjectToken is exported, so you can check identifiers when you load your config.
With WithSession(s), the agent registers under the instance name
InstanceName(name, s) (name-s). It advertises s itself as
metadata.session in its service info, heartbeats and status replies. To address agents by
session, use FilterBySession(s), which saves you from building the combined name yourself.
srv, _ := natsagent.New(nc, a, "acme", "worker", natsagent.WithSession("prod"))
// registers as agents.prompt.phero.acme.worker-prod, metadata.session = "prod"
agents, _ := c.Discover(ctx,
natsagent.FilterByOwner("acme"),
natsagent.FilterBySession("prod"),
)
WithMaxPayload sets the prompt size limit that the agent advertises (default "1MB").
New compares it with the max_payload of the NATS connection. If you did not set
a limit, the default is lowered to the connection's limit. If you set one higher than the connection
allows, New returns ErrInvalidMaxPayload. On the client side,
Prompt checks the prompt against both limits and returns ErrPayloadTooLarge
before publishing. Without that check, the failure would be the less clear
nats: maximum payload exceeded error.
The limit applies to each prompt, not to the answer. An answer larger than the server connection's
max_payload is sent as several response chunks, each small enough to publish,
and stream.Text joins them back together. If a chunk still cannot be published, the
caller gets a 500 service error with code response_too_large (or
response_failed) instead of an empty answer.
Example: discovering and prompting a remote agent
See the full code in examples/nats-agent/client.
nc, _ := natsgo.Connect(natsgo.DefaultURL)
defer nc.Drain()
c := natsagent.NewClient(nc)
// Discover all compliant agents on the bus
agents, err := c.Discover(ctx)
if err != nil {
panic(err)
}
// Stream a prompt to the first discovered agent
stream, err := agents[0].Prompt(ctx, "What is NATS?")
if err != nil {
panic(err)
}
defer stream.Close()
text, _ := stream.Text(ctx)
fmt.Println(text)
Discover uses a stall strategy: it collects responses to a $SRV.INFO.agents
fan-out request for up to 750 ms of silence (configurable), capped by a 2-second absolute deadline.
Discovery options such as FilterByOwner, FilterByName and
FilterBySession narrow the results client-side.
Both calls respect ctx. If ctx has an earlier deadline than the discovery
timeout, Discover stops at that deadline. A cancelled ctx returns its own error
instead of ErrNoAgentsFound. Prompt returns immediately if ctx has
already ended, so no agent starts a prompt that nobody will read.
Sending attachments and headers
Prompt sends text only. Send takes a *nats.Request with
attachments (raw bytes; the base64 encoding is done for you) and extra NATS headers:
stream, err := agents[0].Send(ctx, &natsagent.Request{
Prompt: "Summarise this report",
Attachments: []natsagent.Attachment{{Filename: "report.pdf", Content: pdfBytes}},
Header: nats.Header{"X-Tenant": []string{"acme"}},
})
Send checks the request before publishing: an empty prompt, attachments sent to an agent that
does not advertise attachments_ok, an attachment without a filename, or a request larger than
the agent's or the connection's max_payload. Headers count toward that limit, as they do for
NATS itself. Header names starting with Nats- are reserved by NATS.
On the server, a handler reads the request it is serving with nats.RequestFrom(ctx): the
prompt, the headers, and every attachment's bytes. That includes attachments that are not images, which
the LLM only sees as an [attachment: report.pdf] placeholder. The context reaches an agent's
tools too, so a tool can read, say, a tenant header. The headers are a copy.
func (h *handler) Run(ctx context.Context, parts ...llm.ContentPart) (*agent.Result, error) {
if req, ok := natsagent.RequestFrom(ctx); ok {
tenant := req.Header.Get("X-Tenant")
// ...
}
// ...
}
A header is whatever the caller chose to send. It identifies nobody, so do not use it for authorization; restrict who may publish to the prompt subject with NATS permissions instead.
Wrapping a remote agent as a tool
Any discovered agent can be turned into an llm.Tool that a local Phero agent can call.
This is the building block for NATS-based multi-agent pipelines.
c := natsagent.NewClient(nc)
agents, _ := c.Discover(ctx, natsagent.FilterByName("researcher"))
tool, err := agents[0].AsTool("researcher", "Research a topic and return key facts.")
if err != nil {
panic(err)
}
orchestrator.AddTool(tool)
AgentHandle.AsTool(toolName, toolDesc) is shorthand for Client.AsTool(info, toolName, toolDesc).
The tool sends a JSON-encoded prompt to the remote agent's prompt subject and collects the streamed
response chunks before returning the full text to the LLM.
This tool keeps the one handle that discovery returned for as long as the tool exists. That works for a script that discovers, prompts and exits. For a process that stays up, use a Resolver instead: when the target restarts, it gets a new instance, and a tool holding the old handle keeps prompting an address that no longer exists.
Resolver: long-lived callers
Resolver looks up an agent by (owner, name) and returns a live
AgentHandle without running discovery on every call. It adds a
HeartbeatTracker to the Client:
- A cached handle is reused only while the agent is still sending heartbeats and the cache TTL (default
5 min, see
WithResolverCacheTTL) has not expired. - When several callers miss the cache for the same agent at the same time, only one discovery runs.
Invalidate(owner, name)removes a cached handle, so the nextResolveruns discovery again.Resolver.AsToollooks the agent up on every call and invalidates the handle when a prompt fails. If the target restarts between calls, the next call finds the new instance.
c := natsagent.NewClient(nc)
resolver, err := natsagent.NewResolver(c)
if err != nil {
panic(err)
}
defer resolver.Close()
// Optional: wait until the agents have sent a heartbeat. This uses no NATS traffic.
err = resolver.WaitReady(ctx, []natsagent.AgentKey{
{Owner: "newsroom", Name: "researcher"},
}, 30*time.Second, 0)
tool, err := resolver.AsTool("newsroom", "researcher",
"researcher", "Research a topic and return key facts.")
if err != nil {
panic(err)
}
orchestrator.AddTool(tool)
The heartbeat tracker starts when NewResolver returns, so create the resolver
before starting the agents whose first heartbeats WaitReady has to see.
Retrying: permanent vs transient errors
nats.Permanent(err) reports whether retrying cannot fix an error. It returns true for
requests the agent will always reject: an empty or oversized prompt, attachments the agent does not
accept, a malformed envelope, a 4xx ServiceError other than 429, or a question the client
cannot answer (see below). It also returns true for configuration
errors, such as a nil dependency or an empty or invalid identifier. It returns false for everything else,
including ErrNoAgentsFound, ErrStreamTimeout, a 429 rate limit and context
errors. Use it instead of listing error kinds yourself, so new error kinds are classified correctly without
changes on your side.
A *ServiceError carries the status Code and Description from the
headers, plus the optional JSON body: ErrCode (e.g. "rate_limited"),
Message (falls back to Description) and RetryAfter, how long the
agent asks you to wait. The client does not retry on its own; RetryAfter is there for your
retry logic.
text, err := stream.Text(ctx)
if err != nil && !natsagent.Permanent(err) {
var se *natsagent.ServiceError
if errors.As(err, &se) && se.RetryAfter > 0 {
time.Sleep(se.RetryAfter) // the agent said when to come back
}
// worth retrying, perhaps against another replica
}
Agents that ask questions
The protocol lets an agent pause and ask the caller a question mid-stream, for example a confirmation
before deleting files. The Go client cannot answer. When a question arrives, stream.Text
stops at once and returns a *QueryError (matching ErrQueryNotSupported) with
the question in Prompt. Any text received before it is discarded, and
Permanent reports the error as permanent, because sending the same prompt gets the same
question. Through AsTool, the question reaches the calling LLM, which can try again with a
prompt that already answers it.
The remote agent is not told that nobody will answer. It waits until its own timeout and then either fails or continues with a default of its own choosing, which may mean the action goes ahead anyway. Check what the agents you call do in that case.
What doesn't cross the wire
The server sends the caller the text of the agent's Result. The protocol has no field for the
rest, so some of what works locally is lost when an agent is served over NATS:
-
Handoffs. Locally, whoever calls
Runruns the agents listed inResult.Handoffs. Over NATS the caller is another process, possibly another SDK, and never sees them, so the handoff does nothing. The caller receives the text the model wrote alongside the handoff, or, when it wrote none, a note naming each target and its context (for examplehanded off to writer: draft the bio; seeResult.ReplyText). The server logs a warning naming the dropped targets (seeWithLogger). To route across NATS, have the agent return its decision in its answer instead, for example with structured output (SetResponseFormat) and anext_agentfield the caller reads. -
Token usage and timings (
Result.Summary). Collect them where the agent runs, for example with the OpenTelemetry tracer on the server. -
Attachments that are not images. The LLM only sees an
[attachment: name]placeholder. The handler can still read their bytes withRequestFrom.
Choosing the error a caller receives
When the handler fails, the server sends the caller a service error with a code from the protocol's
table (400, 401, 403, 404, 409, 429, 500). To choose it, return a *nats.CodedError from
Run, directly or wrapped:
return nil, &natsagent.CodedError{
Code: 429,
ErrCode: "quota_exceeded",
Message: "daily quota used up",
RetryAfter: 30 * time.Second, // sent as retry_after_s
}
Any other code is sent as 500, an empty ErrCode is filled in from the code
("rate_limited" for 429), and an empty Message from the wrapped
Err. A tool's error does not fail an agent's run, so a CodedError returned by a
tool never reaches the caller.
Without a CodedError, errors from the LLM are mapped from the provider's
llm.ProviderError. Only a rate limit keeps its code: a rejected API key is the agent's
configuration, not the caller's credentials, and a rejected request may be the agent's own fault, so
both are reported as server errors.
- Provider 429 → 429
rate_limited, with the provider's wait inretry_after_s - Provider 401 or 403 → 500
llm_auth_failed - Provider 400, 413 or 422 → 500
llm_rejected - Any other provider status → 500
llm_error
A run cut short by a shutdown is reported as 500 server_draining, and any other error as
500 internal_error. To decide yourself, pass WithErrorMapper: it sees every
error first, and returning nil falls back to the rules above. It is also the place to keep
internal detail away from callers, since by default the error's text is sent as the message:
srv, _ := natsagent.New(nc, a, "alice", "my-agent",
natsagent.WithErrorMapper(func(err error) *natsagent.CodedError {
if errors.Is(err, ErrInvoiceNotFound) {
return &natsagent.CodedError{Code: 404, Message: "no such invoice"}
}
return nil // use the defaults
}),
)
Multi-agent NATS pipeline
For a production-style example, see
examples/nats-agent/multi-agent.
It shows three specialised agents (researcher, writer, editor) each running as a NATS micro service,
coordinated by a local orchestrator that finds them through a Resolver
and calls each one as an llm.Tool. If a worker restarts during a run, the orchestrator finds
the new instance.
docker run --rm -p 4222:4222 nats
# Terminal 1
OPENAI_API_KEY=<key> go run ./examples/nats-agent/multi-agent/researcher
# Terminal 2
OPENAI_API_KEY=<key> go run ./examples/nats-agent/multi-agent/writer
# Terminal 3
OPENAI_API_KEY=<key> go run ./examples/nats-agent/multi-agent/editor
# Terminal 4 — orchestrator
OPENAI_API_KEY=<key> go run ./examples/nats-agent/multi-agent/orchestrator -topic "quantum computing"
API reference
Server
nats.New(nc, handler, owner, name, opts...) (*Server, error)— create a server;handleris anyHandler, e.g.*agent.Agentsrv.Start(ctx) error— serve until ctx is cancelled, then drain; returnsErrServerStoppedif the server has already drainedsrv.Ready() <-chan struct{}— closed once the service is registered and the broker has acknowledged it and the first heartbeat; never closed ifStartfails or the server drains firstsrv.Drain(ctx) error— stop accepting prompts and wait for in-flight ones; safe to call concurrently and more than once
Server options
WithAgentID(id string)— themetadata.agentvalue and 3rd subject token (default:"phero")WithSession(session string)— register asInstanceName(name, session)and advertisemetadata.sessionWithVersion(v string)— the micro service version (default:"0.1.0")WithMaxPayload(s string)— advertised max prompt size, e.g."512KB"(default:"1MB", lowered to the connection's limit)WithAttachmentsOk(ok bool)— advertise attachment support in endpoint metadataWithHeartbeatInterval(d time.Duration)— how often to publish a heartbeat (default: 30 s)WithKeepaliveInterval(d time.Duration)— how often to send a keepalive chunk during a long response (default: 30 s)WithDrainTimeout(d time.Duration)— total shutdown budget for in-flight prompts (default: 30 s)WithLogger(logger *slog.Logger)— where the server logs what it cannot tell the caller, such as dropped handoffs (default:slog.Default())WithErrorMapper(fn func(error) *CodedError)— choose the error sent when the handler fails (default:CodedErrorin the chain, then the LLM provider mapping, then 500)
Client
nats.NewClient(nc, opts...) *Client— create a clientclient.Discover(ctx, opts...) ([]*AgentHandle, error)— discover agents on the busclient.Prompt(ctx, info, text) (*Stream, error)— send a prompt and get a streamclient.Send(ctx, info, req *Request) (*Stream, error)— send a prompt with attachments and headersclient.AsTool(info, toolName, toolDesc) (*llm.Tool, error)— wrap a remote agent as a tool
Client options
WithDiscoveryTimeout(d time.Duration)— absolute deadline for discovery (default: 2 s; discovery also stops after 750 ms with no new replies)WithInactivityTimeout(d time.Duration)— how long a stream waits for the next chunk (default: 60 s)
Discovery options
FilterByAgent(agent string)— keep only agents with matchingmetadata.agentFilterByOwner(owner string)— keep only agents with matchingmetadata.ownerFilterByName(name string)— keep only agents with a matching instance name (for sessioned agents, theInstanceName)FilterBySession(session string)— keep only agents with matchingmetadata.session
AgentHandle
handle.Prompt(ctx, text) (*Stream, error)— shorthand forClient.Prompthandle.Send(ctx, req *Request) (*Stream, error)— shorthand forClient.Sendnats.RequestFrom(ctx) (*Request, bool)— in a handler or tool, the request being servedhandle.AsTool(toolName, toolDesc) (*llm.Tool, error)— shorthand forClient.AsToolhandle.AgentInfo— embeddedAgentInfowith name, owner, session, subjects, etc.
Stream
stream.Text(ctx) (string, error)— collect all chunks into a single stringstream.Close()— unsubscribe from the reply subject
Resolver
nats.NewResolver(client, opts...) (*Resolver, error)— create a resolver and start its heartbeat trackerresolver.Resolve(ctx, owner, name) (*AgentHandle, error)— return the cached handle while the agent is live, otherwise run discoveryresolver.Invalidate(owner, name)— remove a cached handleresolver.AsTool(owner, name, toolName, toolDesc) (*llm.Tool, error)— a tool that looks the agent up on every callresolver.WaitReady(ctx, keys, timeout, poll) error— wait until everyAgentKeyhas sent a heartbeatresolver.Close() error— stop the heartbeat tracker (theClientstays open)WithResolverCacheTTL(d time.Duration)— how long a handle stays cached before discovery runs again (default: 5 min)
HeartbeatTracker
nats.NewHeartbeatTracker(nc) (*HeartbeatTracker, error)— subscribe toagents.hb.*.*.*tracker.IsAgentOnline(owner, name) bool/tracker.IsOnline(instanceID) bool— true if a heartbeat arrived within 3× its intervaltracker.Stop() error— unsubscribe
Helpers
nats.InstanceName(name, session) string— the instance name an agent registers under withWithSessionnats.ValidateSubjectToken(token) error— check that an identifier is usable as a subject tokennats.Permanent(err) bool— whether retrying cannot fix the error
Errors
ErrNilConn/ErrNilHandler/ErrNilClient— nil dependency passed toNeworNewResolverErrEmptyOwner/ErrEmptyName— blank owner or name passed toNewErrInvalidSubjectToken— an identifier contains characters that are not allowed in a subject tokenErrInvalidMaxPayload—max_payloadcannot be parsed, or is above the connection's limitErrEmptyPrompt— empty prompt passed toPromptErrPayloadTooLarge— prompt exceeds the agent's advertised or the connection's max payloadErrAttachmentsNotAllowed— the agent does not accept attachmentsErrInvalidAttachment— an attachment has no filenameErrMalformedEnvelope— the request payload is not a valid envelopeErrNoAgentsFound—Discoverfound no matching agentsErrStreamTimeout— no chunk arrived within the inactivity timeoutErrServiceError— the agent returned an error; useerrors.Aswith*ServiceErrorto readCode,Description,ErrCode,MessageandRetryAfterErrQueryNotSupported— the agent asked a question mid-stream; useerrors.Aswith*QueryErrorto read itErrDrainIncomplete— handlers were still running when the drain budget ran outErrServerStopped—Startcalled on a server that has already drained
Run the simple example
Start a NATS server first (plain core NATS — no JetStream needed):
docker run --rm -p 4222:4222 nats
# Terminal 1 — start the agent server
OPENAI_API_KEY=<key> go run ./examples/nats-agent/server -owner=alice -name=demo
# Terminal 2 — interactive client
go run ./examples/nats-agent/client
Interoperability
Phero agents are fully wire-compatible with the TypeScript and Python SDKs from synadia-agents. Any client speaking the NATS Agent Protocol can discover and prompt a Phero agent.
- Protocol version. Phero agents advertise
protocol_version: "0.3".Discoveronly returns agents whose version starts with0.3; qualifiers such as0.3.1are accepted. While the protocol is at 0.x, a new MINOR version can change the wire format (0.2 used different subjects), so agents on any other version are skipped like any other non-compliant reply. - Heartbeats and status replies include the optional
protocol_versionand anendpoints.promptdeclaration: the prompt subject and itsmax_payload/attachments_okmetadata, copied from the registration.HeartbeatTrackerignores a malformed declaration and still counts the heartbeat, so liveness never depends on the optional fields. - Error codes come only from the protocol's status-code table. A prompt refused during a
drain gets
500with error codeserver_draining. - Not implemented: the optional sender-identity extension (§13: signed
Agent-Senderheaders,min_sender_trust). Phero agents behave like endpoints withmin_sender_trust: any. They accept every request and ignore anyAgent-Senderheader, as the protocol requires of agents that don't implement §13.
# Discover the running Phero agent with the Python SDK
uv run python examples/01-discover.py --url nats://127.0.0.1:4222
# Stream a single prompt
uv run python examples/02-prompt-text.py --url nats://127.0.0.1:4222 "What is 2+2?"
Related packages
- a2a: HTTP-based agent-to-agent transport — use when NATS is not available
- memory:
memory/natsstores conversation history in NATS JetStream KV;memory/nats.Open(nc, bucket, sessionID)creates the bucket if it does not exist, or binds the existing one - tool:
tool/kvgives an agentkv_read/kv_writetools over a JetStream KV bucket (kv.Open(nc, bucket), thenstore.Tools()) - agent: the agent type wrapped by both
ServerandClient.AsTool - llm:
AsTool()returns an*llm.Tool