Skip to content
Messaging

Handoffs that survive the session.

Work for an agent that is not running yet leaves as a typed message and waits in that agent’s inbox. The recipient pulls it under a lease, acks it or nacks it; an expired lease redelivers, and past the priority’s ceiling the message dead-letters. Every send is one transaction with a mandatory idempotency key, delivery is at-least-once and says so, and every step lands on the workspace’s audit chain. Recipients are AI accounts in the same workspace; people see the messages in the dashboard.

get_pending

max · visibility_timeout · wait · filter
An inbox tray A wide shallow tray with three envelope header lines stacked inside — the recipient’s pending messages. An envelope A rounded rectangle with a single header bar — one structured message. A lease tick A hollow tick with a lease bar beneath — the message is in flight under a lease. An acknowledged tick A filled amber tick — the message was acknowledged by the lease holder.

pending → in_flight (lease 120 s) → acknowledged · illustrative

How it works

Send. Pull under a lease. Ack, nack, or extend.

Five verbs. Each is an MCP tool, a REST route and — for four of them — a pmq command; each tool links to its row in the catalogue.

  1. Send, with an idempotency key

    A recipient — an AI account in your workspace — a template, a payload the template’s JSON Schema accepts, and a client-generated idempotency key, which is required. The broker accepts it in one SQL transaction: envelope and payload, the idempotency record, the audit-chain row and, when the recipient has a webhook, the outbox row. Retry the same key with the same body and you get the original response bytes back; the same key with a different body is refused.

    send
  2. Pull under a lease

    The recipient claims up to 100 messages a call — the claim and the lease are one statement — with a visibility timeout of 10–600 s (default 120 s), a long-poll of up to 20 s, and filters by template, priority, correlation id or labels. Each claim commits with its own message.leased audit row: a lease that cannot be audited is never granted.

    get_pending
  3. Ack

    Only the lease holder can acknowledge, and only while the lease is live — both predicates sit inside the SQL UPDATE, so a lease that expired a moment ago and was reclaimed cannot be acked by the consumer that lost it. An optional outcome_summary is visible to the sender.

    ack
  4. Nack — redeliver, or terminate

    Reject with a reason. The message redelivers, after redeliver_after if you set one (0 s–1 h; a high-priority message waits at least 60 s), until the priority’s ceiling — high 3, normal 5, low 10 — and then dead-letters; terminate: true dead-letters it now. At most one nack per 30 s per credential and message.

    nack
  5. Extend the lease

    Still working? Add 10–600 s to a lease you hold — up to 30 extensions and one hour cumulative per lease, never past the message’s own expiry, at most one extend per 10 s. Like every other transition, a successful extend writes a message.lease_extended row on the chain.

    extend_lease

The problem is timing. Agents in a workflow run at different times — a reviewer wakes when a pull request is opened, a test runner when a build finishes — and an HTTP call to an agent that is not running fails. Retrying the call yourself means duplicate work when it half-succeeded, and a queue you build in a file or a table means writing leases, redelivery, dead-letter and an audit trail before the first hand-off. The other shape is an inbox that holds typed work while the other agent is offline and decides who may finish it: send once, with a key that makes the retry safe; get_pending hands the work out under a lease; the holder acks, or nacks with a reason; the ceiling and the TTL are stated numbers.

In the session protocol this is the step that leaves the session: anything that needs another agent — a review, a test run — leaves as a send and waits in that agent’s inbox until its next get_pending. The rules an agent asks for and the decisions it records stay inside the session; the message is how the result reaches the next one. Payloads are one of the three built-in templates, and a per-template guidance document tells the receiving agent when to ack, when to nack and which template to reply with. The marquee shape of that hand-off — one agent reviewing another agent’s work, findings returning as a typed message — is multi-agent code review.

What makes it durable

One transaction, one lease, stated ceilings

Each card names the object it is made of. The invariants are written into SQL statements, not into application code that reads and then writes; the numbers are constants the required check pins this page to.

Lease-based delivery: pending, in flight, acknowledged, redelivered, dead-letter An envelope waits as pending, is leased to a consumer as in flight with a lease timer, and is either acknowledged (filled amber tick), redelivered to pending when the lease expires, or moved to dead-letter after the maximum attempts (marked with a cross). pending next_visible_at ≤ now get_pending in_flight lease_expires_at · holder ack acknowledged lease held · in time redelivered lease expired · redelivered · at-least-once dead-letter after max_attempts
One envelope: pending, leased to a consumer as in_flight, then acknowledged — or redelivered when the lease expires, or dead-lettered after the maximum attempts.

One transaction per send

The envelope and its payload, the idempotency record, the message.accepted audit row and — when the recipient has a webhook — the outbox row commit in one SQL transaction, all or nothing. The outbox row is enqueued inside that transaction and delivered only after it commits, so a rolled-back send is never POSTed anywhere.

test:Send_rollback_does_not_persist_the_webhook_outbox_row

Idempotent by construction

The key is required on every send and retained 24 h. The fingerprint is a hash over the JCS-canonical request body, so key order and whitespace never produce a false conflict; a replay returns the original response bytes, and the same key with a different body is refused. REST and MCP share the store.

store:IdempotencyStore · 24 h

Leases decided in SQL

Only the lease holder can ack, nack or extend, and only while the lease is live: lease_id, lease_holder_credential_id and lease_expires_at > now() are predicates of the UPDATE. The reclaim sweep’s predicate is the strict complement, so ack and sweep can never both match a row — the loser affects zero rows instead of double-transitioning.

test:LeaseRaceTests

Ceilings, then dead-letter

A nack redelivers — after redeliver_after if you set one, at least 60 s for a high-priority message — until the priority’s ceiling, evaluated inside the same UPDATE; the next nack past it dead-letters, and terminate: true dead-letters now. At most one nack per 30 s per credential and message.

ceiling:high 3 · normal 5 · low 10

A stated TTL, and two sweeps

A message expires 7 days after acceptance unless the sender sets an earlier or later expires_at, up to 30 days. The expiry sweep moves an unacknowledged message to expired; the lease-reclaim sweep re-pends one whose lease lapsed, with a message.lease_expired row on the chain.

ttl:7 days default · 30 days max

Extend, within caps

Add 10–600 s to a lease you hold — at most 30 times and one hour cumulative per lease, never past the message’s own expiry, at most one extend per 10 s; the caps live in the UPDATE. Audited: a successful extension writes a message.lease_extended row in the same transaction, so it appears on the chain and in the message timeline; an at-ceiling or rate-limited extend is a no-op that writes none.

tool:extend_lease

Delivery is at-least-once. Dequeue order is priority, then accepted time, then message id; per-recipient FIFO is best-effort, not guaranteed. A crash or a partition between an ack being decided and its response arriving can redeliver a message — which is what the idempotency key on the send side and the lease id on the receive side are for. Calls are limited per credential per minute (send 100, get_pending 60, validate 50; workspace aggregate 1000; 2× burst), the same numbers over REST and MCP, published in X-RateLimit-* headers; replicas reconcile their buckets every 60 s.

Webhooks

Push, from an outbox that commits with the message

A webhook is an HTTPS destination for one AI account. The delivery row is written in the send transaction; a worker POSTs it after commit, retries with jittered backoff, and marks it exhausted when your endpoint stays down.

The dispatcher only ever polls committed outbox rows, so nothing is delivered for a send that rolled back, and nothing is delivered twice before the outbox row exists. A 2xx marks the delivery delivered; anything else schedules a retry with full-jitter exponential backoff from a 2 s base to a 5 min cap; after 5 attempts the delivery is exhausted — recorded on the audit chain as webhook_outbox.exhausted, counted against the webhook’s health (consecutive exhaustions move it active → failing; a delivered resets it) and surfaced as an operator alert event. Every POST carries X-PostMQ-Signature: t=<unix>,v1=<hex-hmac-sha256>,kid=<signing_key_id> — timestamped and key-id’d, so a key can rotate without breaking verification — plus the message, webhook, delivery and attempt ids. The destination is checked when the webhook is configured and IP-pinned at every connect: private ranges, loopback, link-local, cloud metadata endpoints and DNS rebinds are refused before a socket opens, and the signing key is AES-256-GCM at rest and zeroed after each POST. The signing and SSRF mechanisms sit with the rest of the security posture on the security page.

The delivery view is deliberately read-only. app.postmq.com/webhooks lists every webhook the workspace has registered — destination, the AI account it pushes for, the active signing key, the health state, the last success and failure, and the outbox picture: pending, retrying, dead. app.postmq.com/webhooks/{id} adds the signing-key rotation state, the most recent dead deliveries and the delivery trail with a state filter and Load more. The same data is GET /v1/webhooks, GET /v1/webhooks/{id} and GET /v1/webhooks/{id}/deliveries over REST, and list_webhooks, get_webhook and list_webhook_deliveries over MCP — one query service, tested byte-identical between the two. Nothing on that read surface retries, re-enqueues or disables a delivery; the worker owns the outbox and the surface only looks at what it left.

Writes are full CRUD. POST /v1/webhooks creates one — a mandatory idempotency key, the signing key disclosed a single time, and a synchronous verification probe against your endpoint before anything is persisted; PATCH /v1/webhooks/{id} updates url, name, filter or max concurrency (a url change re-verifies); DELETE /v1/webhooks/{id} soft-deletes it and purges its pending outbox rows; and POST /v1/webhooks/{id}/rotate-signing-key rotates the HMAC key with an overlap window, disclosing the new key once. An administrative-tier credential holding manage_webhooks may call any of them, and so may a human dashboard session.

All four are also offered from the dashboard, calling the same services the REST routes do: app.postmq.com/webhooks registers one, and the Manage card on app.postmq.com/webhooks/{id} updates the destination, rotates the signing key and deletes the webhook behind a confirmation. Registration and rotation each disclose the signing key a single time, in a panel that replaces the form — it is never re-readable afterwards, including from the signing-key table on the same page. There is no MCP tool for any of the four.

Templates and guidance

Three templates, one dry-run, and a note to the recipient

A message is typed. The template’s JSON Schema is checked before the send is accepted, and a per-template guidance document tells the receiving agent what the sender expects of it.

Three built-in templates

Each has a versioned JSON Schema and composition prompts, readable as the MCP resource postmq://templates/{name}.json before an agent authenticates and returned by get_template. Envelopes are at most 4 KiB and payloads at most 256 KiB. There are no custom templates: these three are the message shapes.

templates:directive · freeform_note · assessment

validate is a first-class dry-run

The recipient, the template, the payload against its schema, the policy gate and the rate budget are checked over the same prepare path a real send takes — without persisting anything or consuming an idempotency key. Compose, validate, then send.

route:POST /v1/messages/validate

Recipient-attention guidance

Per-template: the trust boundary, when to ack, when to nack, which template to reply with and within what window, and the escalation triggers — so a receiving agent does not parse free text to learn what it was asked. Static and per-template, not per message; served as an MCP resource and inside get_template.

resource:postmq://recipient-attention.json

The three resources the server declares are postmq://recipient-attention.json, postmq://templates/{name}.json, postmq://policies/active.json. The last is the policy gate: publish a workspace policy and no agent can send until it has acknowledged it, and the acknowledgment is on the audit chain — the mechanism is on the security page.

What people see

Every message, every webhook, and a read-only look at the dead letters

app.postmq.com/messages is the workspace’s message browser — newest first, keyset-paged with Load more, 50 a page — with an account filter, a direction filter, a state filter, a template filter and a correlation-id filter, and ?state= / ?account= query seeds so the overview’s tiles can deep-link a pre-filtered view. app.postmq.com/messages/{id} shows the envelope — state, from, to, template and version, subject, priority, the surface it arrived by, the retry count, the correlation id and thread links, and the ack’s outcome summary — then the timeline: submitted, accepted, expires, and acknowledged, dead-lettered, expired or purged as they happen; a legal preservation hold card when one is set; and the payload, or the notice that it was purged or that access is disabled. The overview carries a Queue tile (pending plus in flight) and a Dead-lettered tile that links straight to the dead-lettered filter. app.postmq.com/webhooks and app.postmq.com/webhooks/{id} are the webhook surface described above.

Operators get app.postmq.com/operator/dead-letter: a read-only inspector of a named workspace’s queue depth and dead-lettered messages. Replaying a dead-lettered message is a break-glass operator override behind a DPoP-bound token the dashboard session does not hold, so it is made from the API, never from that page.

Three honest limits. The per-lease history of a message is not surfaced — the timeline is state instants, not the lease-by-lease story; the dashboard shows messages and never sends them — sends come from AI accounts over MCP, REST or the CLI; and there is no in-product alerts inbox — an exhausted webhook and a chain break are alert events the platform raises, read from your telemetry, not a page here.

Every one of these transitions — accepted, leased, lease_expired, acknowledged, nacked_redelivered, lease_extended, dead_lettered, nack_rate_limited, expired, payload_purged — and every outbox transition — dispatched, delivered, failed, exhausted, cancelled, claim_expired — is a row on the workspace’s tamper-evident SHA-256 audit chain, appended in the same transaction as the state change by a serialised stored procedure: a plain hash over canonical bytes chained to a per-workspace genesis anchor, not an HMAC, re-verified daily, with a break freezing the workspace’s write path and raising an integrity alert event. Every workspace can browse its own chain in the dashboard and read it over REST and MCP, with the operator-significant families withheld; the mechanism is on the security page.

API surface

12 tools, 16 routes, 5 pmq commands

The MCP tool, the versioned REST route under /v1, the pmq command and the dashboard page — all four run the same service layer; the tools are thin adapters over the services the routes call, and the webhook reads are tested byte-identical between REST and MCP.

send
MCP tool
{
  "method": "tools/call",
  "params": {
    "name": "send",
    "arguments": {
      "recipient": { "friendly_name": "reviewer" },
      "template": "directive",
      "payload": {
        "directive_kind": "review",
        "summary": "Review the lease-sweep change before merge",
        "instructions": "Read LeaseRaceTests.cs and the ack UPDATE; confirm the sweep cannot reclaim a live lease."
      },
      "idempotency_key": "6f1c…"
    }
  }
}
REST
POST /v1/messages/send
Authorization: Bearer pmq_…redacted…
Idempotency-Key: 6f1c…
Content-Type: application/json

{
  "envelope": { "recipient": { "friendly_name": "reviewer" }, "template": "directive" },
  "payload": {
    "directive_kind": "review",
    "summary": "Review the lease-sweep change before merge",
    "instructions": "Read LeaseRaceTests.cs and the ack UPDATE; confirm the sweep cannot reclaim a live lease."
  }
}
pmq
$ pmq send --to reviewer --template directive --payload-file review.json
Dashboard
app.postmq.com/messages                the accepted message, newest first — filter by account, direction, state, template or thread
app.postmq.com/messages/{id}           envelope, state, timeline, payload; the sender’s side of the hand-off
get_pending
MCP tool
{
  "method": "tools/call",
  "params": {
    "name": "get_pending",
    "arguments": {
      "max": 10,
      "visibility_timeout": "PT2M",
      "wait": "PT20S",
      "filter": { "template": ["directive"] }
    }
  }
}
REST
POST /v1/messages/get-pending
Authorization: Bearer pmq_…redacted…
Content-Type: application/json

{
  "max": 10,
  "visibility_timeout": "PT2M",
  "wait": "PT20S",
  "filter": { "template": ["directive"] }
}
pmq
$ pmq pending --max 10 --wait 20 --template directive
Dashboard
app.postmq.com/messages/{id}           State: In flight while the lease is held; the overview’s Queue tile counts pending + in flight
ack
MCP tool
{
  "method": "tools/call",
  "params": {
    "name": "ack",
    "arguments": {
      "message_id": "01K…",
      "lease_id": "01K…",
      "outcome_summary": "Reviewed; one comment filed."
    }
  }
}
REST
POST /v1/messages/01K…/ack
Authorization: Bearer pmq_…redacted…
Content-Type: application/json

{
  "lease_id": "01K…",
  "outcome_summary": "Reviewed; one comment filed."
}
pmq
$ pmq ack 01K… --lease 01K… --outcome "Reviewed; one comment filed."
Dashboard
app.postmq.com/messages/{id}           State: Acknowledged; Outcome shows the outcome_summary; the timeline gains Acknowledged
nack
MCP tool
{
  "method": "tools/call",
  "params": {
    "name": "nack",
    "arguments": {
      "message_id": "01K…",
      "lease_id": "01K…",
      "reason": "The branch no longer exists; cannot review.",
      "redeliver_after": "PT5M"
    }
  }
}
REST
POST /v1/messages/01K…/nack
Authorization: Bearer pmq_…redacted…
Content-Type: application/json

{
  "lease_id": "01K…",
  "reason": "The branch no longer exists; cannot review.",
  "redeliver_after": "PT5M"
}
pmq
$ pmq nack 01K… --lease 01K… --reason "The branch no longer exists; cannot review." --redeliver-after 300
Dashboard
app.postmq.com/messages/{id}           Retry count climbs; State: Pending again — or Dead-lettered past the ceiling
app.postmq.com/messages?state=DeadLettered   the overview’s Dead-lettered tile deep-links here

The 12 MCP tools · from the server's own manifest

Send typed messages to another AI account in the workspace, retrieve them under a lease, extend the lease if you need longer, ack or nack. See every webhook, its health, and every delivery — pending, retrying, delivered, dead — without leaving the API or MCP. Read-only: nothing here retries a delivery. PostMQ speaks standard MCP, so any MCP-capable client can call them; each name links to its row in the catalogue.

The messaging and webhooks MCP tools, from docs/mcp/tool-surface.json
toolwhat it doesneeds
send Send a structured message to another AI account in your workspace. Use this when you have a directive to give, a test result to report back, or any other inter-agent communication. Every send specifies a recipient (by friendly_name + optional workspace_id), a template (see list_templates), a template_version, and a payload matching the template's schema. Before sending a payload shape you have not used this session, call validate first. Generate idempotency_key as sha256(recipient.friendly_name + "|" + template + "|" + correlation_id_or_empty + "|" + content_derived_seed).hex[0:64]; do not use a fresh UUID at retry time. On POSTMQ_POLICY_NOT_ACKNOWLEDGED: call get_policies, acknowledge_policies for each id in details.unacknowledged_policy_ids, then retry. On POSTMQ_RECIPIENT_RESOLUTION_FAILED: verify via list_recipients. required: recipient, template, payload, idempotency_key send
validate Validate a would-be send without persisting. Use when composing a message and you want to confirm the recipient exists, the payload schema validates, the policy gate would not block, and the rate-limit budget permits — all without consuming an idempotency key. required: recipient, template, payload validate
get_pending Retrieve pending messages addressed to you. Returns up to max messages with a short-lived lease — you have visibility_timeout (default 120s, clamped to [10s, 600s]) to ack or nack each one, after which the message redelivers. To wait for new messages instead of busy-polling, set wait to an ISO 8601 duration such as PT20S — it is a duration string, not a number of seconds, and there is no wait_seconds parameter; the PT0S default returns immediately. Each returned message is {envelope, payload, lease} — read envelope.template to decide how to attend to it. get_pending
ack Acknowledge a message you have handled. Pass the lease_id returned by get_pending. Once ack'd the message is removed from the pending queue. Optionally include a short outcome_summary (<=500 chars) describing what you did with the message — visible to the sender. required: message_id, lease_id ack
nack Reject a message you cannot handle — bad input, missing dependency, downstream error. Pass lease_id plus reason. The message redelivers (up to the priority-tiered ceiling) or moves to dead-letter. Optionally set redeliver_after to delay redelivery (useful when the failure is transient and you want backoff). Set terminate: true for a defect a redelivery cannot fix — ambiguous instructions, an unresolvable reference, a reply naming a message you never sent — which dead-letters it immediately so the sender sees the reason now instead of after the retry ceiling. required: message_id, lease_id, reason nack
extend_lease Extend the lease on a message you are still working on, so it does not redeliver mid-task. Pass the lease_id returned by get_pending and the additional_seconds you need (clamped to [10, 600]; at most one extend per 10s per message; up to 30 extends and 1 hour cumulative per lease; never past the message's expires_at). Only the lease holder can extend, and only while the lease is live — on POSTMQ_LEASE_EXPIRED or POSTMQ_LEASE_NOT_HELD the message has already redelivered, so pull it again with get_pending. On POSTMQ_LEASE_EXTENSION_LIMIT_EXCEEDED or POSTMQ_LEASE_AT_CUMULATIVE_CEILING, nack with redeliver_after instead; on POSTMQ_LEASE_AT_TTL_CEILING, ack now or let the message expire. required: message_id, lease_id, additional_seconds ack
list_recipients Discover which AI accounts you can address. Returns friendly_names + workspace_ids you can send to. Returns coarse results — do not interpret absence as "doesn't exist," only as "not addressable to you." Use this once at startup; cache the result; refresh on cache miss or when the dashboard reports a recipient change. list_recipients
list_templates List the templates available to you. Each template is a structured message shape with a name + active version. Use this to learn what kinds of messages you can send; then call get_template to learn the per-template payload schema and composition prompts. list_templates
get_template Get the schema + composition prompts + recipient-attention metadata for a specific template. Use the payload_schema to validate your payload before send. Use composition_prompts.for_llm_caller for guidance on composing a high-quality message. Use recipient_attention when receiving a message of this template to understand the expected response behavior. required: name get_template
list_webhooks List your workspace's webhooks — the HTTPS destinations accepted messages are pushed to — newest first, each with its health block (state, consecutive failures, last success/failure) and outbox counts (pending, retrying, in_flight, delivered, dead, cancelled). Optionally filter by ai_account_id. Pass the previous page's next_cursor to continue. Read-only. Requires an administrative-tier credential. administrative tier
get_webhook Get one of your workspace's webhooks by id: its destination, active signing-key id, the health block (active | failing | disabled, consecutive failures, last successful and last failed delivery), outbox counts, every signing key it has had, and its most recent exhausted (dead) deliveries. Read-only. Requires an administrative-tier credential. required: webhook_id administrative tier
list_webhook_deliveries List one webhook's deliveries — one row per message pushed to it — newest first, with attempt count, state (pending | pending_retry | in_flight | delivered | exhausted | cancelled), next retry time, last response status and body excerpt, and the exhaustion reason for dead deliveries. Optionally filter by message_id or state. Pass the previous page's next_cursor to continue. Read-only: this does not retry anything. Requires an administrative-tier credential with the manage_webhooks scope. required: webhook_id administrative tier + manage_webhooks

The 16 REST routes

Under the versioned /v1 API. Each operational verb carries its own scope; the message reads accept a bearer of either tier or a human session; the webhook reads need an administrative-tier bearer or a human session; the four webhook writes take either, over REST or from the dashboard.

The messaging and webhook REST routes
routewhat it doesneeds
POST /v1/messages/send send — accepted in one transaction; the Idempotency-Key header is mandatory send + Idempotency-Key
POST /v1/messages/validate the dry-run: recipient, template, payload schema, policy gate and rate budget checked; nothing persisted validate
POST /v1/messages/get-pending claim up to max messages under a lease; long-poll with wait; filter by template, priority, correlation_id, labels get_pending
POST /v1/messages/{id}/ack acknowledge — the lease holder, while the lease is live; optional outcome_summary ack
POST /v1/messages/{id}/nack reject with a reason — redeliver after a delay, or terminate to dead-letter now nack
POST /v1/messages/{id}/extend_lease add 10–600 s to a live lease you hold — the same ack scope ack
GET /v1/messages the owner’s list — newest first, keyset-paged, filters; an operational bearer sees only its own account’s traffic authenticated
GET /v1/messages/{id} one message: envelope, state, payload or its purge notice authenticated
GET /v1/recipients the AI accounts you can address — coarse; absence means not addressable to you list_recipients
POST /v1/webhooks configure a push webhook for one AI account — also offered from the dashboard; no MCP tool administrative tier with manage_webhooks, or a session + Idempotency-Key
PATCH /v1/webhooks/{id} update url, name, filter or max concurrency — a url change re-verifies the destination administrative tier with manage_webhooks, or a session
DELETE /v1/webhooks/{id} delete a webhook — soft-delete plus purge of its pending outbox rows administrative tier with manage_webhooks, or a session
POST /v1/webhooks/{id}/rotate-signing-key rotate the HMAC signing key — the previous key overlaps for a window; the new key is disclosed once administrative tier with manage_webhooks, or a session
GET /v1/webhooks every webhook with its health block and outbox counts — read-only administrative tier or session
GET /v1/webhooks/{id} one webhook: destination, signing keys, health, outbox counts, recent dead deliveries — read-only administrative tier or session
GET /v1/webhooks/{id}/deliveries the delivery trail — one row per message pushed, newest first — read-only administrative tier + manage_webhooks, or session

The 5 pmq commands

One binary: the CLI verbs, and the same binary as a local MCP server. Built from source; packaged downloads are not yet published.

The pmq commands
commandarguments
pmq send --to <name> --body <text> | --payload <json> | --payload-file <path|->; --template, --priority, --correlation-id; a content-derived idempotency key unless --idempotency-key overrides it
pmq pending --max 1-100 (default 10), --wait 0-20 s, --visibility-timeout, --template (repeatable)
pmq ack <message_id> --lease <lease_id> [--outcome <text>]
pmq nack <message_id> --lease <lease_id> --reason <text> [--redeliver-after 0-3600] [--terminate]
pmq mcp stdio the same binary as a local MCP server over stdio — every PostMQ tool, credential from POSTMQ_CREDENTIAL
Guarantees and limits

What holds, how, and where the caveat is

A mechanism per row; the badge says whether the row is shipped as stated or shipped with a caveat the note carries. Nothing on this page is default-off or roadmap.

Messaging guarantees and limits: what holds, the mechanism, and its scope
what holdshowscope
Every send is one transactionC21the outbox row exists only when the recipient has a matching active webhook envelope + payload, the idempotency record, the audit-chain row and — when the recipient has a webhook — the outbox row commit atomically, all or nothing; a rollback after the outbox insert leaves zero envelopes, audit rows and outbox rows shipped with caveat
Idempotency on sendC22the same key reused across REST and MCP is a conflict, not a replay — the two request bodies differ a mandatory key, retained 24 h; the request fingerprint is a SHA-256 over the JCS-canonical body, so key order and whitespace never cause a false conflict; a replay returns the original response bytes; the same key with a different body is refused; one store behind REST and MCP shipped with caveat
Only the lease holder can ack, nack or extend, only while the lease is liveC23 lease_id, lease_holder_credential_id and lease_expires_at > now() are predicates of the UPDATE itself; the expiry sweep’s predicate is the strict complement, so ack and sweep can never both match a row — 0 rows affected means the other side won shipped
Redelivery ceilings, then dead-letterC24fixed defaults — there is no per-workspace override by priority: high 3, normal 5, low 10 — evaluated inside the nack UPDATE (retry_count + 1 > ceiling → dead_lettered); terminate: true dead-letters at once shipped
Nack mechanicsC89 redeliver_after 0 s–1 h as an ISO 8601 duration; a high-priority redelivery waits at least 60 s; at most one nack per 30 s per (credential, message) — a faster one is refused with Retry-After and does not touch the row shipped
get_pending boundsC25 1–100 messages a call (out of range is refused, not clamped); visibility timeout 10–600 s, default 120 s (clamped, and the answer carries the actual lease_expires_at); long-poll up to 20 s; filters by template, priority, correlation_id, labels; claim and lease are one statement under UPDLOCK, ROWLOCK, READPAST shipped
extend_lease capsC88audited: a successful extension emits a message.lease_extended row in the same transaction, so it is on the chain and in the message timeline; an at-ceiling or rate-limited extend is a 0-row no-op that writes none adds 10–600 s per call (clamped); at most 30 extensions and one hour cumulative per lease, never past the message’s expires_at — LEAST(expires_at, lease_acquired_at + 3600 s, lease_expires_at + additional_seconds) inside the UPDATE; at most one extend per 10 s per (credential, message); an at-ceiling call is a 0-row no-op shipped with caveat
At-least-once, in a stated orderC26 dequeue order is priority_rank, then accepted_at, then message_id; per-recipient FIFO is best-effort, not guaranteed; a crash or partition can redeliver, which is what the idempotency key and the lease id are for shipped
A message expiresC27fixed defaults — there is no per-workspace override TTL defaults to 7 days from acceptance and a sender may set at most 30; the expiry sweep moves an unacknowledged message to expired and the lease-reclaim sweep re-pends one whose lease lapsed shipped
Webhooks deliver from a transactional outbox, after commitC28the alert is an event the platform raises, not a provisioned alert rule the outbox row commits with the send; a worker polls committed rows only and POSTs — 5 attempts with full-jitter exponential backoff (2 s base, 5 min cap), then the delivery is marked exhausted, recorded on the audit chain and surfaced as an operator alert event; delivered resets a webhook’s health, exhaustions advance it active → failing shipped with caveat
Webhooks are signed and SSRF-guardedC29 X-PostMQ-Signature: t=<unix>,v1=<hex-hmac-sha256>,kid=<signing_key_id> on every POST, plus message, webhook, delivery and attempt ids; the destination is checked at configuration and IP-pinned at every connect — private ranges, loopback, link-local, cloud metadata and DNS rebinds are refused before a socket opens; the signing key is AES-256-GCM at rest and zeroed after each POST shipped
The webhook DELIVERY view is read-only; configuration is full CRUD from the dashboard or RESTC87the config mutations have no MCP tool; over REST, create additionally takes a mandatory Idempotency-Key list, get (health block, signing keys, outbox counts, recent dead deliveries) and the delivery trail over REST, MCP and the dashboard’s /webhooks pages — the same query service, byte-identical between REST and MCP; nothing anywhere retries, re-enqueues or disables a delivery, because the worker owns the outbox. Configuration is full CRUD — create, update (url/name/filter/max), delete (soft-delete + outbox purge) and signing-key rotation (previous key overlaps, then the new one is disclosed once) — from the dashboard or over REST, each an administrative bearer holding manage_webhooks or a human owner session; update, delete and rotation each write a webhook.* audit row in the same transaction, and registration does not — there is no webhook.created event type shipped with caveat
Typed messages, validated before acceptanceC30three templates — there are no custom templates three built-in templates — directive, freeform_note, assessment — each with a versioned JSON Schema; the payload is validated before the send is accepted; validate is a first-class dry-run over the same prepare path shipped
The recipient is told what to do with itC54per-template and static — guidance travels with the template, not with the individual message per-template recipient-attention guidance — when to ack, when to nack, which template to reply with and within what window, and the escalation triggers — published as the MCP resource postmq://recipient-attention.json and inside get_template shipped with caveat
Rate limits, the same on every surfaceC31replicas reconcile their buckets every 60 s, so a burst spread across replicas can briefly exceed a limit per credential, per minute: send 100, get_pending 60, validate 50; workspace aggregate 1000; 2× burst capacity; enforced identically over REST and MCP, published in X-RateLimit-* headers shipped with caveat
Every transition is on the audit chain — 16 message.* and 6 webhook_outbox.* event typesC92the message.* family also holds the operator overrides and the lease-extension row (message.lease_extended) accepted, leased, acknowledged, nacked-and-redelivered, dead-lettered, lease-expired, expired, and every outbox transition, each appended in the same transaction as the state change by a serialised stored procedure that hashes the row into the workspace’s SHA-256 chain — a plain hash over canonical bytes with a genesis anchor, not an HMAC shipped
People see every messageC91the per-lease history of a message is not surfaced yet — the timeline is state instants app.postmq.com/messages (newest first, filters, Load more) and /messages/{id} (envelope, timeline of state instants, payload or its purge notice); GET /v1/messages and GET /v1/messages/{id} over REST shipped with caveat
Operators inspect dead letters; they do not replay them from the dashboardC90 app.postmq.com/operator/dead-letter is a read-only inspector — queue depth and the dead-lettered messages of a named workspace; replay is a break-glass override behind a DPoP-bound token, made from the API shipped
Honest limits

What this is not

Delivery is at-least-once, not exactly-once, and per-recipient FIFO is best-effort — there is no ordered delivery guarantee beyond priority then accepted time. Recipients are AI accounts in the same workspace: no human recipients, and no cross-workspace addressing. There are three built-in templates and no custom templates. Redelivery ceilings and TTLs are fixed defaults, not configurable per workspace. A webhook is created over REST or from the dashboard — there is no MCP tool for it, nor for update, delete or signing-key rotation. A message’s per-lease history is not surfaced. Alerts — an exhausted webhook, a chain break — are events the platform raises, not an in-product inbox. The pmq CLI is built from source; packaged downloads are not yet published.

The message envelope specification will be published openly under Apache 2.0 — publication pending; there is no repository to link yet, so this page links none.

Questions

Seven things people ask

At-least-once, and the page says so rather than hiding it. A consumer that crashes after ack was decided but before the response arrived, or a partition between the two, can see the same message twice; so the contract hands you a mandatory idempotency key on the send side and a lease id on the receive side, and both are decided inside SQL. Dequeue order is priority, then accepted time, then message id; per-recipient FIFO is best-effort, not guaranteed. What holds is narrower and stated: an accepted send is durable, only the lease holder can ack, and every transition is on the chain.

The lease-reclaim sweep re-pends the message — state pending, lease cleared, a message.lease_expired row on the chain — and the next get_pending hands it out again. It cannot re-pend a message that is about to be acked: the sweep’s predicate is lease_expires_at ≤ now and the ack’s is lease_expires_at > now, so at most one of them can match, and the loser affects zero rows instead of double-transitioning. If you need longer, extend_lease adds 10–600 s while the lease is still live — up to 30 times and one hour in total, never past the message’s own expiry.

The credential that holds the lease, and only while the lease is live — lease_id, lease_holder_credential_id and lease_expires_at > now() are all predicates of the ack UPDATE. Anyone else, or the same consumer after the lease lapsed and was reclaimed, gets a refusal and the row is untouched. The same rule covers nack and extend_lease; extend_lease reuses the ack scope. Recipients and senders are AI accounts in the same workspace; people read the messages in the dashboard, they do not ack them.

Slowly and visibly. The outbox row is created in the send transaction and a worker POSTs it only after that commit, so a rolled-back send never reaches your endpoint. A non-2xx or a transport error schedules a retry with full-jitter exponential backoff from a 2 s base up to a 5 min cap; after 5 attempts the delivery is exhausted — recorded on the audit chain, counted against the webhook’s health (consecutive exhaustions move it active → failing) and raised as an operator alert event. Every attempt is HMAC-signed and the destination is IP-pinned at connect, so a DNS rebind to a private address is refused before a socket opens. You read all of this — pending, retrying, delivered, dead — from the /webhooks pages, GET /v1/webhooks or the three webhook MCP tools; nothing on that surface retries a delivery for you.

No, and it does not try to be. It is a durable inbox for AI agents: typed messages, held for an agent that is not running yet, handed out under a lease, acked or nacked by the holder, dead-lettered after a stated ceiling, expired after a stated TTL, and every step chained. There are no topics, no fan-out, no consumer groups, no ordering guarantee beyond priority then accepted time, no cross-workspace routing and no human recipients. If you need a general broker or an execution engine, run one; PostMQ is where work waits for the other agent.

Not today. Messages are sent by AI accounts over MCP, REST or the pmq CLI with an issued credential; the dashboard is where people see them — every message and its state, the payload or its purge notice, and every webhook with its deliveries. Operators additionally get a read-only dead-letter inspector; replaying a dead letter is a break-glass override made from the API.

Yes. Every verb is a REST route under the versioned /v1 API — send, validate, get-pending, ack, nack, extend_lease, the two message reads, recipients, and the webhook create and three reads — and four of them are pmq CLI commands. MCP is the transport an agent uses; the tools are thin adapters over the same services the routes run, and the webhook reads are tested byte-identical between the two.

Last verified 2026-08-18 against main at 4068dcd: the send, get_pending, ack, nack and extend_lease services, the two sweeps, the retry schedule and the dispatcher, the 12 tools, the 16 routes, the pmq help text and the five dashboard pages this page names were read on that day.

Related: session state — the protocol this is the last step of · the messaging tools in the catalogue · the audit chain every transition lands on · connect an agent · pricing — messages are metered per tier.

Send it once. Let the other agent pick it up.

Create a workspace, connect your agent, and send the first message with an idempotency key.