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.

Requests are directed, never broadcast. An agent asked for a capability it publishes simply does the work. There is no bidding, no contention and no election. The only decision is on the asking side, which is why every agent reads the registry.

2 · Topic map

Three families under the mesh root. <root> is agents unless a deployment changes it.

TopicDirectionRetainedPurpose
<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 }
FieldRequiredMeaning
serviceyesCapability name. Unknown → terminal error
argsnoArguments. Advisory against requestSchema
requestedByyesYour stable name. Determines owner
jobIdnoReusing an active one → terminal duplicate
parentJobIdnoThe job that asked for this one
rootJobIdnoThe original request the whole chain shares
depthnoHops from the root. 0 entered directly
Omitting requestedBy is rejected. The refusal is published to jobs/public/<jobId>/resultnot 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.

Every path reaches one, and each publishes a retained result — including rejection, an executor that crashes, and a handler that throws. A client is never left waiting on a job that quietly stopped existing. This is the guarantee most worth implementing carefully, because its absence is invisible until someone is waiting.

7 · Delivery guarantees

Stated exactly, because "reliable" means nothing on its own.

MessageDeliveryRetainedNotes
invokeat-least-oncenoA redelivery can duplicate a job; an active jobId is rejected
eventsat-least-oncenoLate subscribers see no earlier milestones
resultat-least-onceyesLast write wins. Readable indefinitely
profile / statusat-least-onceyesEmpty 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:

  1. cancel_acknowledged is published to the job's event stream.
  2. A retained type: "cancelled" result is published, so listeners are never left waiting on a job that will never report.
  3. Any later publish for that job is suppressed, keeping the client's view consistent with the acknowledgement.
  4. 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.

What it does not guarantee is that computation stopped. An agent already mid-turn may run to completion internally; its output is discarded rather than published. Read 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.

PlaceholderFilled fromExpanded
${VAR}Deployment config, then the environmentfirst
{{jobId}} {{requestedBy}}The job itselfsecond
{{arg}}The caller's argslast
Environment expansion must happen before argument substitution. Reversed, a caller passes "${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

  1. Deployment config — versioned with the rest of your deployment
  2. A local store, file-permission protected and never committed
  3. 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.

All of it depends on the client id being stable across restarts. Never derive it from a process id or a timestamp. With a changing id every restart is a new session: the broker's queued QoS-1 messages stay orphaned with the dead one, and any invoke published while the agent was down is lost silently. It also leaks one abandoned session per restart, since MQTT 3.1.1 has no session expiry.

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:

  • requestedBy is 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.

  1. A retained profile appears on connect, and is deleted (empty retained payload) on clean shutdown.
  2. Presence is published as a last will, not by a heartbeat.
  3. The client id is stable across restarts, so an invoke published during downtime is queued and delivered on reconnect.
  4. 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.
  5. Results are retained; events are not.
  6. Job topics are always owner-scoped; there is no unscoped form.
  7. A delegated ask carries parentJobId, rootJobId and depth, and a request deeper than maxDepth is refused before any work starts.
  8. ${VAR} expands before {{args}} wherever prompts are rendered.
There are three independent implementations — TypeScript, JavaScript and Python — and a test that stands two of them against one broker to check each can discover, delegate to and answer the other with lineage intact. A specification with one implementation is a description of that implementation; writing the second is how this document got audited.