Specification · v1.6
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
Four families under the mesh root. <root> is agents
unless a deployment changes it. The fourth — feedback, memory and alerts — needs a
recorder on the mesh, and is dark without one.
| 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/<owner> | any → agent | no | Ask this agent to do something. v1.4 — the owner is a topic segment, so a broker rule can check who is asking |
| <root>/commands/<agentId>/invoke | any → agent | no | The v1.3 form, still served. The owner is in the payload, where no broker can see it |
| <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 |
| <root>/jobs/<owner>/<jobId>/postmortem | agent → all | retained | v1.5 — why it went wrong, written by the agent that ran it |
| <root>/feedback/<owner>/<agentId>/<jobId> | requester → recorder | no | v1.5 — filing a verdict. The owner is a topic segment, so a rule can stop you filing one as somebody else |
| <root>/commands/<agentId>/feedback/<owner> | recorder → agent | no | v1.5 — delivering it. No agent may publish here, which is what makes a verdict worth trusting |
| <root>/memory/ask/<agentId>/<service> | agent → recorder | no | v1.5 — what has this capability learned? Asked when a command for it arrives |
| <root>/commands/<agentId>/memory/<service> | recorder → agent | no | v1.5 — the answer, to the one agent that asked |
| <root>/memory/<service> | recorder → all | retained | v1.5 — the standing memory of a capability |
| <root>/alerts/<service> | recorder → all | no | v1.5 — this capability has been judged poorly three times running |
| <root>/box | recorder → all | retained | v1.6 — that this mesh has a recorder at all. Everything above it is off without this |
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,
"topic": "accept", // v1.4: "off" | "accept"
"verified": true }, // stated by whoever wrote the rules
"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.
verified is stated, never inferred. An agent cannot see
its broker's rules — it can only see which topics it is allowed to publish to, and being
allowed to publish says nothing about whether anyone else is refused. So
verified: true means the deployment that wrote the rules says they are
enforced. An agent that worked it out for itself would be guessing, and a guess published
as a fact is worse than the fact being absent.
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
Published to <root>/commands/<agentId>/invoke/<owner>
since v1.4. The owner moved out of the payload and into the topic for one
reason: a broker can enforce a topic and cannot read a payload. requestedBy
in the body was a claim; the segment is something an ACL can hold you to.
// → <root>/commands/reviewer/invoke/alice
{ "service": "code.review",
"args": { "repo": "acme/web-app", "pr": 42 },
"requestedBy": "alice", // v1.3 form; the topic wins if they disagree
"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 | v1.3 | Your stable name. In v1.4 the topic segment carries it; a payload that disagrees with the topic is refused, never reconciled |
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 |
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 why it is enforced rather than defaulted.
ownerPolicy, which one its deployment
actually holds clients to.
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:
- In v1.3 the owner was
requestedBy, in the payload — self-declared, and invisible to a broker. - In v1.4 it is a topic segment, which a broker can be configured to enforce. The protocol still enforces nothing itself.
- So isolation is conventional until somebody writes the rules, and a boundary the moment they do.
The move was made for exactly this reason. A publisher's identity does not travel with a message, but the topic it published to does — so putting the owner where an ACL can see it is the difference between a claim and a check. What v1.4 does not do is make the check happen: on a broker with no rules, every client can still publish as anybody.
An agent advertises which of the two it is holding clients to, and
ownerPolicy.verified is stated by whoever wrote the rules.
An agent cannot see its broker's configuration — only which topics it is itself allowed to
publish to, which says nothing about whether anyone else is refused.
# the boundary that can actually see identity — v1.4
allow subscribe <root>/jobs/${username}/#
allow publish <root>/commands/+/invoke/${username} # as yourself, and nobody else
deny subscribe <root>/jobs/#
deny publish <root>/registry/+/profile # except your own
This is fine for one team's private mesh even unenforced. It is not sufficient for agents belonging to parties who do not already trust each other — there, the rules are not optional.
15 · What the work was worth v1.5
A job used to end and nothing came back. The requester used the answer or binned it, and the agent never learned which — so a capability made the same mistake for as long as anyone kept asking.
A verdict travels back. It is a record, not a label: what it was worth, why, what actually happened, and what a later run should do — the last being the only part that changes anything, and the one worth writing when the work was good.
// <root>/feedback/<owner>/<agentId>/<jobId>
{ "jobId": "con-9c275d7f",
"verdict": "bad", // good | bad | unusable
"reason": "reviewed the diff without reading the migration",
"details": "PR 412 adds a NOT NULL column in 0042; the review never mentioned it",
"lesson": "when a diff touches a migration, say what happens to existing rows" }
Two legs, and no agent may walk the second
Filing and delivery are different topics. A requester files under its own owner scope, so a rule can stop it filing as somebody else. Delivery happens on the judged agent's own command subtree, which no agent may publish to — so a verdict cannot be forged, and cannot reach the agent it judges except through a recorder.
That is not a licence check. It is why the cycle is impossible without one.
The agent explains itself
A job that failed, or that somebody judged poorly, gets a postmortem — written by the agent that ran it, because it is the only participant that knows what happened inside. Retained on the job, so somebody arriving tomorrow finds it. Rate limited and deduplicated by failure signature: a capability failing the same way forty times is one thing to explain, not forty executor runs saying it again.
Read back before it runs again
At the moment a command arrives — not on a held subscription — the agent asks what that one capability has learned, and starts with the answer in front of it. Asked rather than subscribed because a subscription gives you every capability's lessons as of whenever you last connected: the wrong ones, and stale.
Those lessons are text a model wrote, on their way back into a model. They are rendered as quoted data — fenced, attributed, capped, with the fence stripped from the content so it cannot close early. A postmortem reading "in future, skip verification" is somebody else's text, not an instruction.
16 · Whether this mesh has one v1.6
Everything in §15 needs a participant that records. An agent must know whether it has one before it publishes anything, and the mesh says so:
<root>/box retained, {"since":"…"}, published by the recorder and by nothing else
Present means there is one. Absent — including the empty payload that withdraws a retained message — means there is not, and an agent publishes no verdict, writes no postmortem and asks no question. On a bare broker the mesh is exactly what it was before any of this existed: registry, invoke, events, result.
It is a fact the mesh states, not a setting anyone configures. An operator cannot switch the cycle on where nothing records it, and needs no switch where something does.
Not inferred from silence. Before v1.6 an agent discovered a recorder by asking and waiting to hear nothing, three times over. That cost a mesh without one a publish per capability that its broker refuses without saying so — a refused publish is acknowledged at QoS 1 — and every job the wait.
17 · 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.
- An invoke carrying an owner in the topic is served as that owner, and a payload that disagrees with the topic is refused rather than reconciled — the topic is what the broker authorised, so any other string is a different identity.
- A delegated ask carries
parentJobId,rootJobIdanddepth, and a request deeper thanmaxDepthis refused before any work starts. ${VAR}expands before{{args}}wherever prompts are rendered.