MQ
Queue and stream admin for your org: create them, watch them drain, ack what you pulled.
Also for this capability: API · CLI · MCP · SDKs
Queue and stream admin for your org: create them, watch them drain, ack what you pulled.
| Base URL | https://api.hanzo.ai |
| Operations | 15 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1061 · MQ — Queues and Streams — Draft · read the specification →
/v1/mq is the queue side of the platform bus: an org creates durable streams,
inspects and purges them, reads stored messages by sequence, manages pull
consumers and pulls the next batch. It rides the same embedded broker the tenant
publish door rides (HIP-1060) and shares none of its operations.
This HIP specifies the split, the tenancy encoding, and the delivery semantics a
client has to know because they are not the broker's defaults. The implementation
is hanzoai/cloud apps/mq.
Motivation
One broker can front two products or one confused one. When the authored intent for this surface was written it carried publish, subscribe, request/reply, keyed storage and object storage alongside the queue operations — five capabilities on one address, three of which the platform already serves elsewhere. Serving all of it would have meant the same broker call behind two doors with two shapes, and a client choosing between them by accident.
The split is therefore the first normative statement here, not a footnote.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
The split
The subject side — publishing, request/reply, keyed state — is the pubsub capability's surface (HIP-1060). The queue side — stream lifecycle, stored-message access, consumer lifecycle, pull delivery — is this one. No operation appears on both, and an operation added to either that already exists on the other is a defect.
Two further families stay off this surface for the same reason: keyed storage is
already a product of its own, and object storage is /v1/s3. The broker's own
mechanisms for both are an implementation detail of this deployment, not a second
door.
The served set and the refused set are both closed lists in
apps/mq/typed_wire_test.go, checked against the live router in both directions:
an operation leaving the served list fails, and an operation that starts being
served while still named as refused fails. A refusal row MUST be deleted the day
its fact stops being true.
Tenancy
The broker also carries platform-internal streams, so isolation is enforced by this surface and not by the broker:
- Stream names are namespaced per org on the wire and presented bare to the
caller (
apps/mq/mq.go:194). - Stream subjects are confined to the org's subject root
(
apps/mq/mq.go:206); callers state subjects relative to it, the root is added inbound and stripped outbound.
The org encoding MUST be injective into the broker's name alphabet, so two
distinct orgs can never share a namespace. TestNamespaceEncodingIsInjective
holds that property.
Consequences: two orgs cannot bind overlapping subjects, no tenant stream can capture a platform subject, and a caller's handle can only ever resolve to a stream inside its own namespace. The org comes from the validated principal (HIP-0026) and MUST NOT be nameable in a request.
Delivery semantics that are not the broker's defaults
- A pull acknowledges on delivery. This surface has no acknowledge operation,
so an explicit consumer whose messages were never acknowledged would redeliver
forever. The operation says so in its own prose and
TestConsumerPullAcksOnDeliverypins it. A client that needs acknowledge-after-processing MUST use the broker port, where the acknowledgement is expressible. - An empty waiting pull answers 408; a no-wait pull answers 200 with an empty page. The distinction is the caller's, chosen per request.
- A pull's wait is bounded by a server cap, so a caller cannot park requests on this surface indefinitely.
- Purge reports the difference between the stream's message count before and after, because the client library discards the broker's own purged count.
Availability is honest
Mount never requires a live broker: the connection retries, so the surface mounts and can describe itself with nothing running. Until the plane is reachable, every operation answers 503 and the health operation reports degraded. It MUST NOT report healthy on a connection it does not have.
What it owns, charges and emits
The capability owns no store: the durable log it administers is the broker's one
file store, owned by the pubsub capability that runs the node (HIP-1060). This
surface reaches it as a client over the one bus knob (apps/mq/mq.go:93) and
forks nothing of its own; the client library is nats.go (Apache 2.0).
The addresses are the operations at /v1/mq (plugin/mq/openapi.json): health
and info; stream list, create, get, update, delete and purge; stored-message
read and delete by sequence; consumer list, create, get and delete; and the
pull. All typed, none declared.
It is free, in those words: the plugin declares Price: cloud.Free
(plugin/mq/main.go:23), and no meter runs behind any route.
It publishes no events on the platform bus, so a customer's webhooks (HIP-1310) receive nothing from it. It emits nothing to observability beyond the request span every route gets; the degraded state is answered on the health operation, not exported.
The stage is ga — the manifest row declares none, and absent is ga
(HIP-0139 §8).
Rationale
The alternative to a closed refusal ledger is a comment saying "not yet". Measured on this corpus, that is how a servable operation stays unserved forever: nobody can tell whether the sentence is still true, so nobody acts on it. Making both directions a test means the refusal expires by itself the day the wire moves.
Security Considerations
The name and subject encoding is the whole tenant boundary. It is injective by construction rather than by convention, because a collision is a cross-tenant read of a durable log. The org is never an input field, so there is no request shape that asks the surface to trust the caller about who it is.
Every functional test opens a real embedded broker node on a random port with a temporary store and drives the HTTP surface over it — stream lifecycle, pull and acknowledge, a second org seeing nothing, platform streams invisible, and the degraded answer when the plane is down.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | mq at its own prefix | 15 operations |
| CLI | hanzo mq … | 15 of 15 |
| SDK | MqApi in every published client | 15 methods |
| MCP | tool mq on https://api.hanzo.ai/v1/mcp | 15 operations, 3 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/mq/info, operation get_mq_info:
hanzo mq infoimport { Configuration, MqApi } from 'hanzoai';
const api = new MqApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getMqInfo();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import MqApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = MqApi(client).get_mq_info()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.MqAPI.GetMqInfo(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, mq_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = mq_api::get_mq_info(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.MqApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new MqApi(client).getMqInfo();curl https://api.hanzo.ai/v1/mq/info \
-H "Authorization: Bearer $HANZO_API_KEY"Tool mq, op get_mq_info — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "mq",
"arguments": {
"op": "get_mq_info",
"input": {}
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/mq/health | Reports whether the message plane behind this surface answers. |
GET /v1/mq/info | Returns the broker's identity and the org's stream count. |
DELETE /v1/mq/stream/{name}/message/{seq} | Erases one message by sequence; the sequence gap remains. |
GET /v1/mq/stream/{name}/message | Reads stored messages without a consumer: by sequence, by newest on a subject, or walking a subject forward from a sequence. |
POST /v1/mq/stream/{name}/purge | Removes messages from a stream, leaving its consumers in place. |
GET /v1/mq/stream/{name} | Returns one stream's configuration and live state. |
PUT /v1/mq/stream/{name} | Reconfigures an existing stream; the path names the stream, and the immutable fields (storage, retention) must restate what they are. |
DELETE /v1/mq/stream/{name} | Removes a stream with all its messages and consumers. |
POST /v1/mq/stream/{stream}/consumer/{name}/next | Pulls the consumer's next batch. |
GET /v1/mq/stream/{stream}/consumer/{name} | Returns one consumer's configuration and delivery state. |
DELETE /v1/mq/stream/{stream}/consumer/{name} | Removes a consumer and its delivery state; unacknowledged messages stay in the stream. |
GET /v1/mq/stream/{stream}/consumer | Returns a stream's consumers, name-ordered, with delivery state. |
POST /v1/mq/stream/{stream}/consumer | Creates a durable pull consumer on a stream and returns it. |
GET /v1/mq/stream | Returns the org's streams, name-ordered, with their live state. |
POST /v1/mq/stream | Creates a durable stream in the org's namespace and returns it. |
How is this guide?