Specification · v1.3
The Agent Mesh Protocol
Everything an implementation has to agree on, and nothing about any particular one. If you can open an MQTT connection, this document is the whole contract.
1 · The model
Four nouns. Everything else in this document is a consequence of them.
Agent
A participant with a stable agentId and a set of capabilities. It holds one
outbound connection to a broker and is never a server. An agent is both worker and
requester — nothing distinguishes the two roles on the wire.
Capability
Something an agent will do, named as a dotted service string
(code.review, schema.review). A capability is data:
a name, an argument schema and a prompt template. The protocol never learns what any of
them mean.
Job
One request and its outcome, identified by a jobId. A job is created by an
invoke and always ends in exactly one terminal result.
Mesh root
A topic prefix — agents by default — under which everything lives. Two
meshes on one broker are two roots, and they cannot see each other.
2 · Topic map
Three families under the mesh root. <root> is agents
unless a deployment changes it.
| Topic | Direction | Retained | Purpose |
|---|---|---|---|
| <root>/registry/<agentId>/profile | agent → all | retained | Capability catalog. How discovery happens |
| <root>/registry/<agentId>/status | agent → all | retained | Presence. Also the last will |
| <root>/commands/<agentId>/invoke | any → agent | no | Ask this agent to do something |
| <root>/commands/<agentId>/cancel | any → agent | no | Stop a job it is running |
| <root>/commands/<agentId>/query | any → agent | no | List services, or check a job. Replies on …/query/reply |
| <root>/commands/<agentId>/config | any → agent | no | Capability CRUD. Replies on …/config/reply |
| <root>/jobs/<owner>/<jobId>/events | agent → requester | no | Progress milestones. Advisory |
| <root>/jobs/<owner>/<jobId>/result | agent → requester | retained | The one terminal payload |
Everything is QoS 1. Job topics are always owner-scoped: there
is no unscoped jobs/<jobId>/… form, and a result belongs to exactly one
owner and one topic.
What to subscribe to
<root>/jobs/<you>/# your jobs — events and results
<root>/jobs/<you>/+/result results only, no progress noise
<root>/registry/+/profile every agent's capabilities
Do not subscribe to <root>/jobs/#. That is everyone's
traffic, and the only legitimate reasons to read it are an observer — a notifier, a
dashboard, an audit log — never a participant.
3 · Registry
An agent publishes its profile retained on connect. Because it is retained, an agent that connects later receives it immediately — discovery needs no announcement round, no polling and no directory service.
{ "agentId": "dba",
"displayName": "Database reviewer",
"status": "online",
"protocolVersion": "1.3",
"capabilities": [
{ "service": "schema.review",
"description": "Reviews a migration for lock risk.",
"requestSchema": { "migration": "string" } }
],
"ownerPolicy": { "required": true, "verified": false },
"ts": "2026-08-25T20:14:03Z" }
ownerPolicy exists so a client can read what a deployment
actually enforces rather than inferring it from a version number. Advertise a
capability; never imply one.
Leaving
An empty retained payload on profile and
status means the agent has left. A status of
offline means the same thing. Deleting on either is what stops the registry
filling with agents that are not there.
Presence on unclean disconnect is the broker's job: status is registered as
the last will, so an agent that dies mid-thought is marked offline by
the broker itself. No heartbeat service exists, and none is needed.
4 · Commands
invoke
{ "service": "code.review",
"args": { "repo": "acme/web-app", "pr": 42 },
"requestedBy": "alice", // REQUIRED — scopes the reply
"jobId": "rev-118", // optional; the agent generates one otherwise
"parentJobId": null, // delegation only
"rootJobId": "rev-118",
"depth": 0 }
| Field | Required | Meaning |
|---|---|---|
service | yes | Capability name. Unknown → terminal error |
args | no | Arguments. Advisory against requestSchema |
requestedBy | yes | Your stable name. Determines owner |
jobId | no | Reusing an active one → terminal duplicate |
parentJobId | no | The job that asked for this one |
rootJobId | no | The original request the whole chain shares |
depth | no | Hops from the root. 0 entered directly |
requestedBy is rejected. The refusal is published to
jobs/public/<jobId>/result — not to your scope, which you never
subscribed to. So a client that forgets it sees silence, and that silence is the reason
the field is enforced rather than defaulted.
cancel
{ "jobId": "rev-118", "requestedBy": "alice" } — see
§10.
5 · Job topics
Every payload on a job topic carries jobId, owner,
ts and type.
events — not retained
{ "jobId": "rev-118", "owner": "alice",
"type": "progress", "message": "analyzing the diff",
"ts": "2026-08-25T20:14:09Z" }
Milestones are advisory and never a source of truth. A subscriber joining mid-job sees the retained result but none of the earlier events.
result — retained
{ "jobId": "rev-118", "owner": "alice",
"type": "review",
"verdict": "REQUEST_CHANGES",
"summary": "Blocking on the migration: ALTER without CONCURRENTLY.",
"ts": "2026-08-25T20:17:31Z" }
Known types: result, error, cancelled,
duplicate, rejected, timeout, and domain types an
agent chooses. The list is open — treat an unrecognised type as terminal.
Ordering
Ordering holds per topic only. events and result
are different topics, so a terminal result may be observed before a milestone published
earlier. Never infer sequence across topics; use ts.
6 · Job lifecycle
┌──────────┐
invoke ─────────▶│ accepted │
└────┬─────┘
┌────▼─────┐
┌─────│ started │─────┐
│ └────┬─────┘ │
result │ │ watchdog │ cancel
┌─────▼────┐ ┌───▼──────┐ ┌──▼────────┐
│ done │ │ timeout │ │ cancelled │
└──────────┘ └──────────┘ └───────────┘
rejected ── refused before any work: unknown service, missing
requestedBy, depth exceeded, owner mismatch
duplicate ── a jobId already active
Terminal states are done, error, timeout,
cancelled, rejected and duplicate.
7 · Delivery guarantees
Stated exactly, because "reliable" means nothing on its own.
| Message | Delivery | Retained | Notes |
|---|---|---|---|
invoke | at-least-once | no | A redelivery can duplicate a job; an active jobId is rejected |
events | at-least-once | no | Late subscribers see no earlier milestones |
result | at-least-once | yes | Last write wins. Readable indefinitely |
profile / status | at-least-once | yes | Empty payload deletes it and means the agent left |
Exactly-once is not offered. QoS 2 would give it hop-by-hop and still not end to end, because an executor can complete work and die before publishing. Assume at-least-once and make capabilities idempotent.
8 · Owner scoping
owner is derived from requestedBy: lowercased, restricted to
[a-z0-9_-] with anything else becoming -, and edges trimmed. An
empty result becomes public.
"Mohanad.Q!" → mohanad-q
"--ci--" → ci
"" → public
It is the same value you put in an invoke and the one you subscribe with, which is the whole routing mechanism: results go to the requester's scope, and that scope is what the requester already listens on. There is no callback channel.
Multi-tenancy is therefore a subscription filter rather than authorization code you write and get wrong — and it is enforceable in broker ACLs, which is the only place it can actually be enforced.
9 · Delegation
An agent that meets work outside its capabilities asks the agent that owns it. An
ask is an ordinary invoke with requestedBy set to the asking
agent, so the answer lands in the asker's own scope — which it already subscribes to.
Nothing new is needed on the wire.
Lineage
Every ask creates its own job with its own id. A chain of agents is a chain of jobs, linked by three fields:
rev-118 parent — root rev-118 depth 0
ask-9f3c parent rev-118 root rev-118 depth 1
ask-2b71 parent ask-9f3c root rev-118 depth 2
Without them a five-agent chain is five unrelated ids: a failure cannot be attributed to the request that caused it, and a cancel has no way to find what to stop.
Hop limit
maxDepth (default 4) bounds the chain. A request arriving
deeper is rejected before any work starts, and the asking side refuses
too, so nothing reaches the wire. A cycle allowed to begin is a cycle that runs until
something else stops it.
Two forms
- Declared — a capability states its dependencies and they are gathered before the executor starts, then injected. Deterministic; needs no tool; cannot adapt to what a job turns out to need.
- Dynamic — the executor discovers the need mid-job and asks. Adapts to anything; depends on it choosing to.
The requester gets one answer either way. That two agents produced it is an implementation detail of the mesh.
10 · Cancellation
Cooperative, and terminal at the mesh boundary:
cancel_acknowledgedis published to the job's event stream.- A retained
type: "cancelled"result is published, so listeners are never left waiting on a job that will never report. - Any later publish for that job is suppressed, keeping the client's view consistent with the acknowledgement.
- Cancel propagates to whatever this job delegated; those agents cancel their children in turn.
One cancel unwinds a whole chain without any agent knowing its shape.
cancelled as "no further traffic will be honoured", not
"the work stopped".
11 · Capabilities are data
A capability is a JSON entry. The protocol contains no service name
anywhere — not code.review, not anything — and never learns what one
means.
{ "service": "schema.review",
"description": "Reviews a migration for lock risk.",
"requestSchema": { "migration": "string" },
"prompt": "Review migration {{migration}}. Flag lock risk and missing indexes." }
An invoke names a service; the agent looks it up, renders the
template, and hands the result to whatever executes work. That is the entire
coupling. Everything domain-specific lives in the template, which is why adding
an agent is minutes and no rebuild.
requestSchema is advisory. A capability declaring
pr: number will happily receive a string and find out inside the prompt.
Enforcement is a known gap.
12 · Variables
A capability should be portable, but real prompts need deployment-specific values — channel ids, recipient lists, internal paths. There are two kinds of placeholder, and the order they expand in is a security property, not a style choice.
| Placeholder | Filled from | Expanded |
|---|---|---|
${VAR} | Deployment config, then the environment | first |
{{jobId}} {{requestedBy}} | The job itself | second |
{{arg}} | The caller's args | last |
"${MQTT_PASSWORD}" as an argument value, the next pass expands
it, and every invoke becomes an arbitrary environment read by anyone who can publish to
the mesh.
Resolution order
- Deployment config — versioned with the rest of your deployment
- A local store, file-permission protected and never committed
- The process environment
Resolved at dispatch, never in the catalog
The catalog is published to a retained topic. Resolving values before publishing would broadcast every deployment secret to anyone subscribed to the registry. On the wire the prompt stays a template — and a conformant implementation keeps it that way.
A related rule: never put credentials in args. Payloads are
readable by every subscriber to that topic, and results are retained, so a secret
published once persists until explicitly overwritten.
13 · Durability
An agent holds one persistent session: clean: false, QoS 1
throughout, a keepalive, automatic reconnect, and a last will for presence. The connection
is outbound only, so it works behind NAT.
The property this buys is the one worth testing: an invoke published while an agent is offline is queued by the broker and delivered when it reconnects. It is also the property implementers most often skip verifying.
Two agents sharing a client id will fight over the session and kick each other in turn. Durability and multi-instance safety are in tension, and an implementation should detect the collision and say so rather than let it look like a flaky network.
14 · Trust model
MQTT delivers topic and payload only. A publisher's broker identity does not travel with the message. Therefore:
requestedByis self-declared — whatever string the sender put there.- By default it is required but not authenticated. Anyone with broker credentials can claim any owner scope.
- Isolation is therefore conventional, not enforced, unless the broker enforces it.
This is fine for one team's private mesh. It is not sufficient for agents belonging to parties who do not already trust each other. The fix is broker-side — ACLs scoped per username, or rule-engine enrichment that lets an agent verify the claim — which means the protocol delegates its hardest problem to your deployment. Know that going in.
# the boundary that can actually see identity
allow subscribe <root>/jobs/${username}/#
allow publish <root>/commands/+/invoke
deny subscribe <root>/jobs/#
15 · Conformance
An implementation is conformant when all of the following hold. Each is directly observable from another client on the same broker — which is the point: conformance is something you can check, not something you can claim.
- A retained profile appears on connect, and is deleted (empty retained payload) on clean shutdown.
- Presence is published as a last will, not by a heartbeat.
- The client id is stable across restarts, so an invoke published during downtime is queued and delivered on reconnect.
- Every job reaches a terminal result, retained, on every path — including rejection, unknown service, duplicate id, hop-limit refusal, timeout, cancellation, and an executor that throws.
- Results are retained; events are not.
- Job topics are always owner-scoped; there is no unscoped form.
- A delegated ask carries
parentJobId,rootJobIdanddepth, and a request deeper thanmaxDepthis refused before any work starts. ${VAR}expands before{{args}}wherever prompts are rendered.