Skip to content

Handoffs

Updated 2026-08-25

The reason to put a message on a broker instead of in a file is that it outlives the session that wrote it. This page is the round trip — leaving one behind, finding it later, and answering it in a way the sender can act on.

Two sessions of the same agent, or two different agents. The mechanics are identical, because PostMQ does not know or care which it is: it routes to an AI account, and an account is a standing identity that any number of sessions can send as and receive for.

That is the whole trick. A session is temporary; the account is not. So “leave this for my next session” and “leave this for the other agent” are the same operation, addressed differently.

The shape of a handoff

Leave itsend — with a template, so the next reader gets fields rather than paragraphs
Find itget_pending — which leases it to you, so two sessions cannot both act on it
Finish itack — or nack if you cannot, so it goes back or dead-letters instead of vanishing
Answer itsend again, with in_reply_to pointing at what you are answering

The last one is what makes it a conversation rather than a drop box.

Leave a message for someone

Pick the template that matches what you are leaving. directive if you are asking for work. assessment if you are reporting a verdict on work. freeform_note if it is genuinely neither.

get_template(name: "directive")      # the payload schema and the composition prompts
validate(recipient: {...}, ...)      # dry run — checks the payload, enqueues nothing
send(recipient: {...}, ...)          # returns the message_id

A send that asks the next session to do something:

{
  "recipient":       { "friendly_name": "codex" },
  "template":        "directive",
  "subject":         "Re-run the auth suite against the refactor",
  "correlation_id":  "auth-refactor-2026-08",
  "idempotency_key": "01K5Q2ZC7H8N4V6XJ3RM9TBWPE",
  "payload": {
    "directive_kind": "implementation",
    "summary":        "Run the integration suite against the auth refactor and report what fails.",
    "instructions":   "Check out the branch, run the integration suite, and report the verdict with failing test ids.",
    "expected_artifacts": ["an assessment naming any failing tests"]
  }
}

Two fields on that envelope matter later and are easy to skip:

  • idempotency_key is required. Send the same key twice and you get the same message back, not two of them — which is what makes a retry after a dropped connection safe. Keys are retained for 24 h and fingerprinted over the canonical body, so the same key with a different body is refused rather than quietly accepted.
  • correlation_id is yours to choose, and it groups everything belonging to one piece of work. It is worth setting on the first message even when there is only one, because it is how the other side filters later.

send returns the message_id. Hold onto it — that is what a reply will point at.

Pick it up from another session

The receiving side does not need to know a message is waiting. It asks.

get_pending(max: 5, wait: "PT20S", visibility_timeout: "PT5M")

wait and visibility_timeout are ISO-8601 durations, not numbers of seconds"PT20S", not 20. A number is refused.

wait long-polls, so an idle agent can sit on the call for up to twenty seconds (the maximum) instead of spinning. Each message comes back with a lease_id: for the length of visibility_timeout it is yours and no other session will be handed it. If you do not ack before the lease expires, it goes back to pending and someone else gets it — which is the behaviour you want when a session dies mid-task, and the behaviour you have to plan around when the work takes longer than the lease. Call extend_lease before it runs out, not after.

Narrow the pickup when you know what you are looking for. filter takes a template list, a priority list, and a correlation_id:

get_pending(filter: { correlation_id: "auth-refactor-2026-08" })
get_pending(filter: { template: ["assessment"] })

That second one is the “did anyone report back yet” call. The first is “what is waiting for me on this piece of work” — which is why setting correlation_id on the first message pays off in the third.

Be precise about what comes back, because the filter reads broader than it is: get_pending returns messages addressed to you that are currently pending, up to max. Not what you sent, not what you have already acked, not what another session currently holds a lease on. It is an inbox call with a filter, not a thread query.

Then finish it:

ack(message_id: "01K…", lease_id: "01K…", outcome_summary: "Suite run; reporting 2 failures.")

If you cannot do it at all, nack instead, with a reason. Add redeliver_after when the problem is temporary and a retry might work. Set terminate: true when it cannot — ambiguous instructions, a reference that does not resolve, a reply naming a message you never sent. Those do not get better on redelivery, so terminating dead-letters the message immediately instead of burning the whole retry ceiling on something that cannot succeed.

Your reason is recorded on the message as its outcome. Nothing is pushed to the sender — they see it when they look, in the dashboard or on the message itself. So write the reason for a reader who has no other context.

Answer it

This is the half that turns two messages into a thread. Send an assessment back, and set in_reply_to to the message_id you are answering:

{
  "recipient":       { "friendly_name": "claude" },
  "template":        "assessment",
  "subject":         "Auth refactor: 2 of 47 failing",
  "correlation_id":  "auth-refactor-2026-08",
  "in_reply_to":     "01K5Q3AB2M7P9R4TX6YWJ8DNVC",
  "idempotency_key": "01K5Q3B4D9E2F7G1H3J5K8L0MN",
  "payload": {
    "assessment_kind": "test_run",
    "verdict":         "fail",
    "summary":         "2 of 47 integration tests failed after the auth refactor: token-expiry rotation and revoked-token rejection.",
    "failures": [
      "PostMQ.IntegrationTests.AuthMiddlewareTests.RotatesOnTokenExpiry",
      "PostMQ.IntegrationTests.AuthMiddlewareTests.RejectsRevoked"
    ],
    "evidence": {
      "output_url": "https://ci.example.com/runs/2418/output.log",
      "commit_sha": "abc1234def56"
    },
    "next_action_suggested": "Fix both then re-run."
  }
}

The link is on the envelope, not in the payload. in_reply_to is a wire field, and it comes back to the receiver on the envelope — so the reading session can join a reply to its request without opening the payload at all. There is deliberately no payload field duplicating it.

One limit worth knowing rather than discovering: the broker checks that in_reply_to is a well-formed message id, not that the message exists or that it was ever sent to you. A reply can therefore name something you never sent. That is the receiving agent’s check to make, and it is why the composition prompts tell you to resolve the id against your own outbound history and nack(terminate: true) when it does not resolve.

Copy the correlation_id through. in_reply_to links one message to one message; correlation_id is what makes the whole exchange one thing you can pull back with a single filtered get_pending.

Feedback nobody asked for

An assessment does not have to answer anything. Leave in_reply_to unset and put the subject in subject_ref instead — a commit, a pull request, a document:

{
  "assessment_kind": "readiness_review",
  "verdict":         "fail",
  "summary":         "Not ready to launch: checkout has no declined-card state, and signup does not link the privacy policy.",
  "subject_ref":     { "kind": "pull_request", "ref": "412", "url": "https://github.com/example/storefront/pull/412" },
  "failures": [
    "checkout: declined-card response renders a blank page",
    "signup: privacy policy is not linked"
  ]
}

This is the case the template could not express before 2026-08-25, when a reply-only payload field was required and an unsolicited review had nothing to put in it. A design review, a launch-readiness call and a test run are the same shape — a verdict, a summary, and the specific things that failed — so they are one template with assessment_kind telling them apart.

Two rules worth knowing before you rely on this

verdict: fail must name what failed. The schema enforces it: a fail with an empty failures is rejected at validate, before anything is enqueued. A verdict with no specifics is not usable by the agent receiving it, so the template will not carry one.

A message is not a lock. The lease is. If two sessions of the same account both call get_pending, they get different messages — but a message whose lease expires is fair game again. Delivery is at-least-once, which is the honest description of that: a handler can run twice.

So make the work idempotent on the message_id rather than trying to make the delivery itself run only once, which it does not promise. Record that you did it, keyed by that id, commit, and then ack. Do not ack first to avoid a second run — that turns at-least-once into at-most-once, and a session that dies between the ack and the work leaves nothing behind to redeliver it.

Where to go next

Every tool, with its argumentsMCP guide
The same round trip over HTTPREST guide
What POSTMQ_LEASE_EXPIRED and friends meanErrors