# KubeMQ Documentation (/)
**One broker. Every protocol.** KubeMQ is a Kubernetes-native enterprise message broker that ships as a single binary. Your existing Kafka, RabbitMQ, MQTT, AMQP, STOMP, AWS SQS/SNS, and Google Cloud Pub/Sub clients connect to it unchanged — and native SDKs in 10 languages give you queues, pub/sub, and request-reply over gRPC, REST, and WebSocket. No ZooKeeper, no external database, no sidecars.
## What brings you here? [#what-brings-you-here]
## Keep your clients — change one connection string [#keep-your-clients--change-one-connection-string]
KubeMQ speaks each protocol natively, on its standard port. The client that talks to Kafka or RabbitMQ today talks to KubeMQ tomorrow.
## Built on four messaging patterns [#built-on-four-messaging-patterns]
Every protocol and every SDK rides the same core patterns.
## Why teams choose KubeMQ [#why-teams-choose-kubemq]
* **Built for Kubernetes** — [Helm charts and an operator](/deploy/kubernetes-helm), KEDA autoscaling, and clustering designed for safe rollouts.
* **Enterprise-grade** — [role-based access control, audit logging](/configure), FIPS builds, and replicated storage with a strict zero-loss durability mode.
* **AI-ready** — built-in [Model Context Protocol (MCP) and Agent-to-Agent (A2A) gateways](/aiway) for agentic workloads.
* **Simple to run** — one binary, a [web dashboard](/operate), and the [kmq CLI](/deploy/cli) for messaging, administration, and observability.
## Explore the documentation [#explore-the-documentation]
***
Questions? [Open an issue on GitHub](https://github.com/kubemq-io/kubemq-community) or [contact support](https://kubemq.io/contact-us/). Building with AI agents? This site ships [llms.txt](/llms.txt) for machine consumption.
# KubeMQ Aiway (/aiway)
**KubeMQ Aiway is an AI Agents Fabric** — a switchboard that lets AI agents and AI
applications find each other, talk to each other, and stream results in real time, with
the reliability of an enterprise message broker underneath. **A2A** and **MCP** are the
two doors into the same fabric: register an agent, discover it by capability, invoke it
synchronously or as a live stream, and orchestrate the whole thing from an LLM.
## What is KubeMQ Aiway [#what-is-kubemq-aiway]
Aiway is a switchboard for AI agents. Agents register what they can do, callers find
them by capability, and the fabric routes every call — synchronous or streamed — over an
enterprise message broker. KubeMQ does the discovery, routing, protocol bridging,
streaming, security, high availability, and observability so that no caller and no agent
has to.
The agents themselves stay **plain HTTP services** — no KubeMQ SDK, no broker library, no
protobuf, no special runtime. An agent is just an HTTP server that speaks JSON-RPC 2.0
(and, optionally, Server-Sent Events). Aiway bridges the broker to that endpoint for you,
so you can wrap an existing internal microservice as a first-class AI agent in minutes.
Two doors open into the same fabric:
* **A2A (Agent-to-Agent)** — agents register their endpoint and capabilities; any caller
discovers them *by capability* and invokes them, synchronously or as a live stream.
* **MCP (Model Context Protocol)** — LLM hosts (Claude Desktop, IDEs, agent frameworks)
plug in over the open MCP standard and gain both **messaging** and **agent
orchestration** through a single endpoint.
## The big picture [#the-big-picture]
A caller — over A2A HTTP, gRPC/REST, or an MCP host — reaches the fabric, which discovers
the target agent, bridges the call to its plain-HTTP endpoint, and relays the response
(or a live SSE stream) back. The agents never touch the broker directly.
*Callers reach Aiway over A2A, gRPC/REST, or MCP; the Agent Bridge POSTs to each agent's plain-HTTP URL and the streaming relay carries live events back.*
The **Agent Bridge** is the per-agent virtual subscriber that translates broker messages
into HTTP calls to your agent (and relays responses and SSE streams back). It is why
agents need zero KubeMQ dependency: the bridge does all the broker and protocol
translation, so the agent only ever sees an HTTP `POST`.
The MCP **agent-bridge tools** (`agent_list`, `agent_info`, `agent_send`, `agent_query`,
covered under [MCP](/aiway/mcp)) invoke agents *through* this same Agent Bridge —
same concept at two layers, not two meanings. The tools are the LLM-facing door; the
Agent Bridge is the per-agent connector they (and A2A callers) route through.
## One fabric, three planes [#one-fabric-three-planes]
Aiway is not "A2A plus a separate MCP server." It is **one fabric** with three planes
that share the same core and compose freely — an LLM over MCP can enqueue durable work
*and* call an A2A agent in the same session, and a gRPC backend can invoke the same agent
an LLM just used.
| Plane | Door | What it does |
| ------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Agent plane** | A2A | Agents register, get discovered by capability, get invoked, and stream results. |
| **LLM plane** | MCP | LLM hosts discover and drive both agents and messaging through one endpoint. |
| **Messaging plane** | KubeMQ core | Durable queues, pub/sub events, a persisted/replayable event log, and RPC (commands/queries) — the same primitives the MCP messaging tools expose. |
## Two doors in [#two-doors-in]
## The enterprise envelope [#the-enterprise-envelope]
What makes Aiway production-grade rather than a demo is the envelope around both doors —
the same auth, HA, guardrails, and observability that the rest of KubeMQ runs on:
* **Security** — token-based authentication, per-agent ownership (only the registrant can
modify or remove an agent), origin validation, and TLS / mutual-TLS.
* **High availability** — the agent registry is replicated to every node, so an agent
registered on one node is reachable and discoverable cluster-wide; if a node fails, its
agents expire and re-register elsewhere and the roster self-heals.
* **Guardrails & backpressure** — TTL liveness, default and max request timeouts, a
max-agents ceiling, a per-agent concurrency cap, a max agent-response size, and
traffic gating that rejects calls when the core isn't ready instead of hanging.
* **Observability** — per-agent request, error, latency, and active-stream metrics
(Prometheus + a durable per-agent store) plus a live operations dashboard, so you can
govern an entire agent fleet.
Both doors run on the [shared HTTP server](/connectors/concepts/shared-http-server) (port
9090\) and inherit its unified middleware. The shared foundations are documented once
under Connectors:
## Next steps [#next-steps]
# Use cases (/aiway/use-cases)
New to Aiway? Start with the [overview](/aiway).
KubeMQ Aiway is an AI Agents Fabric: agents register, callers discover them by
capability, and anyone invokes them — synchronously or as a live stream — over an
enterprise message broker. The scenarios below show where that fabric is uniquely
strong, grouped by the problem they solve. Each one names the scenario, how Aiway
delivers it, and why it is hard to get this cleanly any other way.
A few terms recur:
* **Agent Bridge** — the per-agent virtual subscriber KubeMQ spawns for each
registered agent. It translates broker messages into HTTP POSTs to your agent's URL
and relays the response (or live stream) back, which is why agents need no KubeMQ
SDK. See [Building agents](/aiway/a2a/guides/building-agents).
* **Skill tags** — the capability labels (`tags[]`) an agent advertises on each skill,
used to discover agents by what they can do rather than by URL. See the
[Agent registry](/aiway/a2a/registry).
* **The transport-vs-application error split** — Aiway returns `Executed: false` when
the agent never processed the request (unreachable, timeout, oversized response), so
it is **safe to retry**; an `Executed: true` response with a JSON-RPC error means the
agent ran and returned an error, so **don't blind-retry**. See
[Error handling](/aiway/a2a/error-handling).
## Onboarding & orchestration [#onboarding--orchestration]
### Zero-SDK agent onboarding [#zero-sdk-agent-onboarding]
**Scenario:** Turn an existing internal HTTP microservice — a Python summarizer, a Go
OCR service, a Node scraper — into a first-class AI agent in minutes.
**How:** the service stays a plain HTTP server. It registers an Agent Card; the Agent
Bridge does all protocol translation.
**Why Aiway:** no KubeMQ client library, broker SDK, protobuf, or special runtime in the
agent — most frameworks force an SDK and a specific language, while Aiway onboards *any*
HTTP service in any stack. Walk it end-to-end in the
[Tutorial](/aiway/tutorial).
### LLM as orchestrator over a real agent fleet [#llm-as-orchestrator-over-a-real-agent-fleet]
**Scenario:** An LLM host (Claude Desktop, an IDE, an agent framework) plans a task,
discovers the right specialist agents, invokes them, and persists intermediate results
— all from one MCP connection.
**How:** `agent_list` / `agent_info` to discover, `agent_send` / `agent_query` to
invoke, `events_store_publish` to log, `queue_send` to hand off — same session, same
fabric.
**Why Aiway:** the LLM gets messaging **and** agent orchestration through one
open-standard endpoint, with enterprise auth and high availability — no glue code, no
custom tool server per agent. See the
[MCP agent-bridge tools](/aiway/mcp/tools/agent-bridge).
### Polyglot, mixed-protocol orchestration [#polyglot-mixed-protocol-orchestration]
**Scenario:** A gRPC backend, a REST web app, and an LLM all call the *same* agent
during one workflow.
**How:** every transport — the A2A HTTP gateway, gRPC, REST, the MCP bridge — converges
on the same Agent Bridge path.
**Why Aiway:** one agent, callable from every protocol your stack already speaks, with no
per-protocol adapters or duplicated endpoints. See
[Synchronous messaging](/aiway/a2a/sync-messaging).
## Real-time & long-running work [#real-time--long-running-work]
### Real-time long-running tasks with live progress [#real-time-long-running-tasks-with-live-progress]
**Scenario:** A caller kicks off a multi-minute job — document generation, transcode,
deep research, multi-step reasoning — and watches `task.status` and `task.artifact`
events stream in with a progress bar.
**How:** `message/stream` opens an SSE relay with keepalive, idle timeout, and
auto-cancel when the caller disconnects.
**Why Aiway:** streaming, cancellation, and backpressure (per-agent concurrency cap,
response-size limits) are built in — *production* streaming, not a fragile long-poll. See
[Streaming (SSE)](/aiway/a2a/streaming).
### Human-in-the-loop and approval flows [#human-in-the-loop-and-approval-flows]
**Scenario:** An agent streams a draft, a human reviews it, and on approval the workflow
resumes or hands off to another agent.
**How:** streaming for live drafts; durable queues or persisted events park the task
while it awaits a decision; a second agent invocation continues the work.
**Why Aiway:** real-time streaming *and* durable hand-off coexist on one fabric, so
long-lived, human-gated workflows don't need a separate workflow engine. See the
[Streaming task pipeline](/aiway/a2a/scenarios/streaming-task-pipeline).
### Ephemeral, on-demand agents [#ephemeral-on-demand-agents]
**Scenario:** Spin up specialist agents on demand — per job, per tenant — have them
register, take work, then disappear, without callers caring.
**How:** an agent registers on start, heartbeats while alive, and TTL-expires when gone;
discovery and routing always reflect the live set.
**Why Aiway:** the self-healing, capability-indexed roster makes transient agents
first-class — callers always see only what is actually available right now. See the
[Agent registry](/aiway/a2a/registry).
## Routing & multi-tenancy [#routing--multi-tenancy]
### Capability-based dynamic routing [#capability-based-dynamic-routing]
**Scenario:** "Find an agent that can `translate-legal` for `de-DE`, prefer the
lowest-latency one, call it, and fall back to another if it errors."
**How:** discover via `GET /agents?skill_tags=…` (or `agent_list`), select at runtime,
invoke, and use the transport-vs-application error split to fail over.
**Why Aiway:** agents are addressed **by capability**, not by URL — new agents appear in
discovery the moment they register, and the routing logic never changes. See the
[Multi-agent gateway](/aiway/a2a/scenarios/multi-agent-gateway).
### Multi-tenant agent directory [#multi-tenant-agent-directory]
**Scenario:** Multiple teams publish agents into one shared directory; each team can
manage only its own; everyone can discover and call them, subject to authorization.
**How:** ownership is bound to the registering identity (only the owner can modify or
deregister; a blank owner fails closed); discovery is open, invocation is authenticated.
**Why Aiway:** a built-in, fail-closed ownership model turns a shared registry into a
safe internal agent marketplace, without bespoke RBAC plumbing. See the
[Agent registry](/aiway/a2a/registry).
### Distributed, multi-region agent mesh [#distributed-multi-region-agent-mesh]
**Scenario:** Agents run close to data or users in different nodes or regions; callers
anywhere reach any agent.
**How:** the registry replicates cluster-wide; agents register on a local node; the
cluster routes calls to the owning node; on node loss, agents re-register elsewhere.
**Why Aiway:** location transparency and a self-healing registry come from the broker, so
callers never track where an agent physically runs. See the
[A2A architecture](/aiway/a2a/architecture).
## Reliability & governance [#reliability--governance]
### Resilient, retry-aware agent calls [#resilient-retry-aware-agent-calls]
**Scenario:** A flaky agent or a slow network shouldn't corrupt a workflow, and the
orchestrator needs to know *what* failed.
**How:** the `Executed: false` (transport, safe-retry) vs `Executed: true` plus error
(application, don't blind-retry) split, with specific A2A error codes and per-agent
timeouts.
**Why Aiway:** the fabric encodes retry-safe semantics at the protocol level, so
orchestration logic gets clean, correct failure signals for free. See
[Error handling](/aiway/a2a/error-handling).
### Secure enterprise agent gateway [#secure-enterprise-agent-gateway]
**Scenario:** Expose agents to partners or teams behind authentication, mutual TLS, and
origin controls, and reject traffic when the platform is degraded.
**How:** the shared security chain — auth, ownership, TLS/mTLS, trusted origins, traffic
gating — wraps every agent call.
**Why Aiway:** an enterprise security envelope is built into the fabric, so you don't
bolt a gateway in front of every agent. See
[Agent authentication](/aiway/a2a/guides/authentication).
### Governed agent fleet [#governed-agent-fleet]
**Scenario:** An ops team needs per-agent traffic, error rates, latency, active streams,
and a live roster across a large fleet, surviving restarts.
**How:** per-agent metrics (Prometheus plus a durable store) and a live dashboard;
TTL and heartbeat keep the roster honest.
**Why Aiway:** fleet-grade observability and lifecycle are native — not something you
assemble from logs after the fact. See the
[A2A configuration](/aiway/a2a/configuration) for the limits that govern a fleet.
## Hybrid pipelines & safe LLM access [#hybrid-pipelines--safe-llm-access]
### Agents plus durable messaging [#agents-plus-durable-messaging]
**Scenario:** An agent's streamed artifacts are written to a persisted, replayable event
log; downstream agents and classic consumers read from it; failed work lands in a
dead-letter queue for human review.
**How:** mix `events_store_publish` / `events_store_read` (or queues with a
`dead_letter_queue`) with agent invocation, on one fabric.
**Why Aiway:** agents and enterprise messaging are the *same* substrate, so you compose
durable, replayable, at-least-once pipelines with AI agents inside them — no integration
layer. See the [Events Store tools](/aiway/mcp/tools/events).
### MCP tool gateway for safe enterprise access [#mcp-tool-gateway-for-safe-enterprise-access]
**Scenario:** Give any MCP-speaking LLM safe, governed access to enterprise messaging —
queues, events, persisted logs, RPC — without handing it the raw broker.
**How:** the MCP messaging tools expose curated operations with input schemas,
reserved-channel protection, timeouts, and auth.
**Why Aiway:** the LLM gets a bounded, schema'd, authenticated surface onto real
enterprise messaging — a controlled blast radius, not a firehose. See the
[Queue tools](/aiway/mcp/tools/queues).
## Next steps [#next-steps]
# Configure KubeMQ (/configure)
Everything you need to configure KubeMQ for production. KubeMQ ships as a single binary with no external dependencies, so configuring it comes down to choosing how you run it and tuning the settings for your workload.
Looking for metrics, tracing, and logging? **Turning them on** is here —
[Observability settings](/configure/reference/observability) has the OpenTelemetry,
audit and notification fields for both targets. **Reading and acting on them** lives under
[Operate](/operate).
# Drive KubeMQ from the terminal (/deploy/cli)
`kmq` is KubeMQ's command-line client — a single static binary that talks to the
management API on port **`:8080`**, the same API the web dashboard uses. **`kmq` does
not run the broker.** It is a client, not a server: every command it runs is an HTTP
call against a KubeMQ instance that is already up.
You need a KubeMQ server already running and reachable before any of the steps below
will work. If you don't have one yet, the [Quickstart](/deploy/quickstart) gets
you there in a few minutes.
## Install [#install]
```sh
curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh
```
`install.sh` detects your OS and architecture, downloads the matching archive, and
performs a mandatory SHA-256 checksum verification before installing `kmq` into the
first writable `PATH` directory.
To pin a specific version instead of the latest release:
```sh
curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh -s -- --version v0.3.1
```
Prefer a container image over a local binary:
```sh
podman run --rm europe-docker.pkg.dev/kubemq/images/kmq:latest version
```
(`docker run` works identically in place of `podman run`.)
Full installer flags and environment variables (`KMQ_VERSION`, `KMQ_INSTALL_DIR`,
signature verification) are in the [kmq CLI reference](/operate/kmq-cli#installation).
## Connect [#connect]
`kmq` reads a **context** — a named connection profile — to know which server to talk
to. Create one pointing at your running server:
```sh
kmq context create default --api-address http://localhost:8080
```
Confirm the connection:
```sh
kmq status
```
You should see a compact JSON health digest with `is_healthy: true` — visible proof
that `kmq` can reach your KubeMQ server.
## Send your first message [#send-your-first-message]
Round-trip a message through the **Queues** pattern — guaranteed delivery, send now,
receive whenever you're ready:
```sh
kmq queue send orders '{"id":1}'
```
```sh
kmq queue receive orders
```
The second command returns the message you just sent. For a wider view across every
pattern:
```sh
kmq overview
```
## Teach your coding agent [#teach-your-coding-agent]
`kmq` ships an installable agent skill so a coding agent can drive it correctly without
guessing at flags. Three ways to install it:
```sh
npx skills add kubemq-io/kmq
```
The universal installer reaches 70+ agents — Claude Code, Cursor, Codex, Gemini CLI,
Windsurf, and more.
```sh
claude plugin marketplace add kubemq-io/kmq
```
The Claude Code plugin path, if you're already using Claude Code's plugin marketplace.
```sh
kmq skills install
```
A zero-Node fallback that installs the skill stub locally for Claude Code; add
`--global` to install it for the current user instead of just the current project.
## Where next [#where-next]
`kmq` also ships migration commands (`kmq assess kafka`, `kmq migrate
assess|replicate|translate|cutover`) that move an external Kafka cluster onto KubeMQ in
phases — see the full reference for the complete workflow.
# Install KubeMQ with Docker (/deploy/docker)
Docker is recommended for **development and testing only**. For production, deploy on Kubernetes with [Helm and the operator](/deploy/kubernetes-helm).
New here? Start with the [Quickstart](/deploy/quickstart) — it runs KubeMQ, opens the dashboard, and round-trips your first message in about 5 minutes. This page is the in-depth Docker reference: Compose, configuration, connectors, persistence, health checks, and standalone upgrade/rollback/backup.
## Docker Compose [#docker-compose]
For a more structured setup, use Docker Compose. This configuration includes persistent storage and environment variable support.
```yaml title="docker-compose.yml"
services:
kubemq:
image: europe-docker.pkg.dev/kubemq/images/kubemq:next
container_name: kubemq
hostname: kubemq
ports:
- "50000:50000" # gRPC
- "9090:9090" # REST
- "8080:8080" # API & Dashboard
environment:
- KUBEMQ_TOKEN=${KUBEMQ_TOKEN:-}
volumes:
- kubemq-data:/kubemq/store
restart: unless-stopped
volumes:
kubemq-data:
```
Save this file and start KubeMQ:
```bash title="Terminal"
docker compose up -d
```
A downloadable version of this file is available at [/docker-compose.yml](/docker-compose.yml).
### Stop and remove [#stop-and-remove]
```bash title="Terminal"
docker compose down
```
To also remove persistent data:
```bash title="Terminal"
docker compose down -v
```
## Configuration [#configuration]
KubeMQ is configured through environment variables. Pass them using the `-e` flag with `docker run` or the `environment` section in Docker Compose.
### Common environment variables [#common-environment-variables]
| Variable | Default | Description |
| -------------------------------- | ------- | ------------------------------------------------------------- |
| `KUBEMQ_TOKEN` | — | License key (required for standalone mode outside Kubernetes) |
| `LOG_LEVEL` | `2` | Log verbosity: 0=Trace 1=Debug 2=Info 3=Warn 4=Error 5=Fatal |
| `API_PORT` | `8080` | Dashboard and management API port |
| `CONNECTORS_GRPC_PORT` | `50000` | gRPC transport port |
| `CONNECTORS_REST_PORT` | `9090` | REST transport port |
| `STORE_CLEAN_STORE` | `false` | Remove stored data on startup |
| `STORE_MAX_RETENTION` | `1440` | Max message retention in minutes (default: 24 hours) |
| `STORE_MAX_MESSAGES` | `0` | Max messages per channel (0 = unlimited) |
| `STORE_MAX_QUEUE_SIZE` | `0` | Max queue size in bytes (0 = unlimited) |
| `QUEUE_MAX_NUMBER_OF_MESSAGES` | `1024` | Max messages per receive request |
| `QUEUE_MAX_WAIT_TIMEOUT_SECONDS` | `3600` | Max polling wait timeout in seconds |
### Example with configuration [#example-with-configuration]
**Sizing for local eval:** a single container is light — 1-2 vCPU and 2 GB RAM is enough for local development and evaluation traffic on any of the four patterns. Scale up only if you drive sustained high-throughput persistence (Events Store, Queues, or the Kafka connector) with a large `STORE_MAX_RETENTION`. Production/cluster sizing lives in the Helm [production checklist](/deploy/kubernetes-helm#sizing-eval-vs-production).
## Ports [#ports]
KubeMQ exposes three core network ports by default. Wire-protocol connectors are opt-in — they open additional ports only when you enable them.
| Port | Protocol | Service | Description |
| ------- | ------------- | ---------------- | ---------------------------------------------------------------------------------------- |
| `50000` | gRPC (HTTP/2) | Transport | Primary SDK transport. All SDKs connect here by default. |
| `9090` | HTTP | REST / WebSocket | REST API and WebSocket connections for subscriptions. |
| `8080` | HTTP | Dashboard / API | Web dashboard, health probes (`/health`, `/ready`), and Prometheus metrics (`/metrics`). |
The gRPC port (50000) is the primary transport used by all KubeMQ SDKs. The REST port (9090) is useful for testing with cURL or when gRPC is not available. The dashboard port (8080) provides a management UI and health endpoints.
## Enabling wire-protocol connectors [#enabling-wire-protocol-connectors]
The seven wire-protocol connectors (MQTT, AMQP 0-9-1, AMQP 1.0, STOMP, Kafka, AWS, GCP Pub/Sub) are
**disabled by default**. Each opens a new network port and must be explicitly enabled. Enable
them with environment variables:
| Connector | Env var | Ports opened |
| --------------------- | ------------------------------- | ------------------------ |
| MQTT | `CONNECTORSMQTT_ENABLE=true` | 1883 / 8883 / 8083 |
| AMQP 0-9-1 (RabbitMQ) | `CONNECTORS_AMQP_ENABLE=true` | 5672 / 5671 |
| AMQP 1.0 | `CONNECTORS_AMQP10_ENABLE=true` | 5672 / 5671 (shared mux) |
| STOMP | `CONNECTORS_STOMP_ENABLE=true` | 61613 / 61614 |
| Kafka | `CONNECTORS_KAFKA_ENABLE=true` | 9092 / 9093 |
| AWS (SQS & SNS) | `CONNECTORS_AWS_ENABLE=true` | 4566 |
| GCP Pub/Sub | `CONNECTORS_GCP_ENABLE=true` | 8085 |
Example — start KubeMQ with MQTT and the AWS connector enabled:
**Env var spelling matters.** The MQTT enable variable is `CONNECTORSMQTT_ENABLE` — no
underscore between `CONNECTORS` and `MQTT`. Other connectors use an underscore:
`CONNECTORS_AMQP_ENABLE`, `CONNECTORS_STOMP_ENABLE`, etc. See the
[connectors reference](/configure/reference/connectors) for all variable names.
## Kafka single-node (Docker) [#kafka-single-node-docker]
KubeMQ can expose a Kafka wire-protocol endpoint for single-node Docker development.
Kafka runs on the **`next` storage engine** — which is **auto-selected with zero
configuration** on a fresh store when the Kafka connector is enabled. You do not set
the engine first; see [Storage Engines](/configure/reference/storage-engines) for
the full selection rules. Enable Kafka with `CONNECTORS_KAFKA_ENABLE=true`.
### User-defined network [#user-defined-network]
Docker's default bridge network has no DNS, so a Kafka client resolving the advertised
container name will not be able to connect there. Create a user-defined network first,
then attach KubeMQ to it with a stable `--hostname`:
```bash title="Terminal"
docker network create mynet
docker run -d \
--network mynet \
--name kubemq \
--hostname kubemq \
-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \
-e CONNECTORS_KAFKA_ENABLE=true \
europe-docker.pkg.dev/kubemq/images/kubemq:next
```
Other containers attached to `mynet` reach Kafka at `kubemq:9092`.
### Port-mapped host access [#port-mapped-host-access]
To connect from a Kafka client running on the host (outside Docker), advertise
`localhost` and publish the Kafka port. The published host port must equal
`CONNECTORS_KAFKA_PORT` (`9092` by default):
A Kafka client on the host can now connect at `localhost:9092`.
### Persistent volume [#persistent-volume]
Give the container a stable `--name`/`--hostname` so the advertised name and the
store's host-subdir stay stable across restarts, and mount a volume for `/store`:
```bash title="Terminal"
docker run -d \
--name kubemq \
--hostname kubemq \
-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \
-e CONNECTORS_KAFKA_ENABLE=true \
-v kubemq-kafka-data:/kubemq/store \
europe-docker.pkg.dev/kubemq/images/kubemq:next
```
**The user-defined-network and port-mapped-host recipes above are mutually exclusive per
deployment.** A single advertised endpoint means you pick either user-defined-network
reachability or port-mapped host reachability — not both. See the
[Kafka connector](/connectors/kafka) overview and the
[Kafka settings reference](/configure/reference/connectors#kafka) for the full set
of options (TLS port, SASL mechanisms, OAUTHBEARER, and more).
## Persistence [#persistence]
By default, KubeMQ stores data inside the container at `/store`. This data is lost when the container is removed. To persist data across container restarts, mount a volume.
### Named volume [#named-volume]
### Bind mount [#bind-mount]
Mount a host directory for easier access to the stored data:
Persistence is required for **Events Store** and **Queues** patterns. Events (fire-and-forget) and RPC patterns do not require persistence, but the store is still used for internal metadata.
## Health checks [#health-checks]
KubeMQ exposes health endpoints on the API port:
```bash title="Terminal"
# Liveness check — is the process running?
curl http://localhost:8080/health
# Readiness check — is the broker ready to accept traffic?
curl http://localhost:8080/ready
```
Add a Docker health check to your `docker run` command:
```bash title="Terminal"
docker run -d \
--name kubemq \
-p 50000:50000 \
-p 9090:9090 \
-p 8080:8080 \
-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \
--health-cmd="curl -f http://localhost:8080/health || exit 1" \
--health-interval=10s \
--health-timeout=5s \
--health-retries=3 \
europe-docker.pkg.dev/kubemq/images/kubemq:next
```
## Upgrade, rollback & backup [#upgrade-rollback--backup]
KubeMQ ships on the mandatory `:next` tag — there's no version number to bump. "Upgrading" means re-pulling `:next` and recreating the container underneath the same name; "rolling back" means restarting from an image you deliberately kept on disk, not from a pinned digest. `:next` is the only supported channel — see the Helm [image-tag policy](/deploy/kubernetes-helm#production-checklist) for why pinning by digest or semver isn't the model.
### Upgrade [#upgrade]
Re-pull the image and recreate the container:
```bash title="Terminal"
docker pull europe-docker.pkg.dev/kubemq/images/kubemq:next
docker stop kubemq
docker rm kubemq
```
Then start it again with the same command you used originally (same name, same volume) — Docker resolves `:next` to the image you just pulled:
### Roll back [#roll-back]
Because `:next` is a moving tag, "rolling back" means restarting from a **local image you retained before upgrading** — not re-pulling an older digest. Do one of these *before* you upgrade:
```bash title="Terminal"
# Option A — tag the currently-running image under a name you control
docker tag europe-docker.pkg.dev/kubemq/images/kubemq:next kubemq-known-good
# Option B — or just record its image ID
docker inspect --format='{{.Image}}' kubemq
```
If the upgrade goes wrong, stop and remove the new container, then start a container from the retained local image (or ID) instead of `:next`:
```bash title="Terminal"
docker stop kubemq
docker rm kubemq
docker run -d \
--name kubemq \
--hostname kubemq \
-p 50000:50000 \
-p 9090:9090 \
-p 8080:8080 \
-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \
-v kubemq-data:/kubemq/store \
kubemq-known-good
```
This is **local image retention**, not registry pinning. `:next` stays the only image reference you pull from the registry — the rollback target lives only on this host, which is why you must tag or record it *before* you re-pull.
### Back up the store [#back-up-the-store]
The `kubemq-data` volume — mounted at `/kubemq/store` in the server container — holds everything **Events Store** and **Queues** persist. Back it up with a throwaway container that mounts the same volume (the path inside the throwaway container is arbitrary):
```bash title="Terminal"
docker run --rm -v kubemq-data:/store -v "$(pwd)":/backup alpine \
tar czf /backup/kubemq-store-backup.tar.gz -C /store .
```
Restore it the same way, in reverse, onto a fresh volume before starting KubeMQ:
```bash title="Terminal"
docker run --rm -v kubemq-data:/store -v "$(pwd)":/backup alpine \
sh -c "cd /store && tar xzf /backup/kubemq-store-backup.tar.gz"
```
## Related [#related]
Run KubeMQ and send your first message in about 5 minutes.
Deploy KubeMQ to Kubernetes for production use.
Env vars, a mounted config.yaml, the CONFIG variable, and docker-compose.
Adopt KubeMQ as a drop-in Kafka broker — what works and how to migrate.
# Deploy KubeMQ (/deploy)
## One broker, two jobs [#one-broker-two-jobs]
KubeMQ is a Kubernetes-native message broker that ships as a single binary with two co-equal headline capabilities.
**Consolidate your messaging stack** — point your existing Kafka, RabbitMQ, AWS SQS/SNS, MQTT, STOMP, AMQP, or Google Cloud Pub/Sub client at KubeMQ by changing one connection string. No rewrite, no new SDK, no new wire protocol to learn.
**Run your AI-agent fabric** — KubeMQ ships built-in **Agent-to-Agent (A2A)** and **Model Context Protocol (MCP)** gateways. Agents register, get discovered by capability, and stream results; any MCP-compatible LLM host — Claude, Cursor, or your own IDE — drives both messaging and agents over the open MCP standard.
Both capabilities ride the same core:
* **10-language SDKs plus REST**
* **Four messaging patterns** (Events, Events Store, Queues, and RPC — Commands and Queries)
* Built-in persistence
* A web dashboard
* Clustering and high availability
* No ZooKeeper, no external database, no sidecars
## Choose your path [#choose-your-path]
### What do you want to do [#what-do-you-want-to-do]
Two things KubeMQ does — pick the one you came for.
### Ways to get started [#ways-to-get-started]
Or just get it running:
## The four messaging patterns [#the-four-messaging-patterns]
## Upgrading an existing install? [#upgrading-an-existing-install]
Coming from KubeMQ v2? Follow the [v2 → v3 migration guide](/deploy/migrate-v2-to-v3). Rolling upgrades, rollback, and backup are covered in [Install with Docker](/deploy/docker) and [Install on Kubernetes](/deploy/kubernetes-helm).
# Install KubeMQ with Helm (/deploy/kubernetes-helm)
## Prerequisites [#prerequisites]
Before you begin, ensure you have the following installed and configured:
* **Kubernetes cluster** (v1.20 or later) — any distribution (EKS, GKE, AKS, k3s, minikube)
* **kubectl** — configured to access your cluster
* **Helm v3** — download from [helm.sh](https://helm.sh/docs/intro/install/)
* **KubeMQ license key** — required for the cluster to start
Running KubeMQ for local development? Consider using [Docker](/deploy/docker) instead for a simpler setup.
## How it works [#how-it-works]
KubeMQ ships **four** Helm charts. The standard install path is a 3-step sequence:
1. **`kubemq-crds`** — CRD schema only. Registers the `KubemqCluster` (and `KubemqConnector`) resource types with Kubernetes. No workloads, no operator.
2. **`kubemq-controller`** — The operator Deployment. Watches for `KubemqCluster` resources and reconciles StatefulSets, Services, and configuration.
3. **`kubemq-cluster`** — Renders one `KubemqCluster` custom resource. This chart is a thin passthrough: nearly every Helm value you set becomes the identically-named field on the CR's `spec`, and the operator installed in step 2 does the actual reconciling.
A fourth chart, the **umbrella `kubemq`** chart, bundles CRDs + operator + one `KubemqCluster` CR into a single release — a one-shot alternative to running the three charts above in sequence. It requires only `key` (your license) to render.
All components are installed into the `kubemq` namespace.
## Add the KubeMQ Helm repository [#add-the-kubemq-helm-repository]
Register the KubeMQ Helm chart repository and update your local chart index.
The charts are published as stable GA releases, each versioned independently — `kubemq-crds` **3.2.0**, `kubemq-cluster` **3.2.0**, the umbrella `kubemq` **3.2.0**, and `kubemq-controller` **2.0.0**. Install with plain `helm` commands — no `--devel` flag is needed.
The chart **version** (each chart's own semver, e.g. `kubemq-cluster` at `3.2.0`) is separate from the chart's **appVersion** — the product line it tracks. appVersion is **3.0.0** (the server) for `kubemq-crds`, `kubemq-cluster`, and the umbrella `kubemq`; it's **2.0.0** (the operator) for `kubemq-controller` — appVersion is per-chart, not one shared number, and it names the GA line rather than the exact build (the current operator build on `:next` is **v2.3.0**). Both are separate again from the operator/server container **image tag** `:next` you'll see later on this page — the server rides the mutable `:next` image tag rather than a pinned version. Don't confuse the three.
```bash title="Terminal"
helm repo add kubemq-charts https://kubemq-io.github.io/charts
helm repo update
```
Verify the repository was added successfully:
```bash title="Terminal"
helm search repo kubemq-charts
```
You should see charts for `kubemq-crds`, `kubemq-controller`, `kubemq-cluster`, and the umbrella `kubemq` chart.
## Install KubeMQ [#install-kubemq]
KubeMQ installation on Kubernetes requires three Helm charts installed in order: CRDs, the controller (operator), and the cluster itself.
### Install KubeMQ CRDs [#install-kubemq-crds]
The Custom Resource Definitions must be installed first. They define the `KubemqCluster` resource type that the operator manages.
```bash title="Terminal"
helm install --create-namespace -n kubemq kubemq-crds kubemq-charts/kubemq-crds
```
### Install KubeMQ controller [#install-kubemq-controller]
The KubeMQ controller (operator) watches for `KubemqCluster` resources and manages the lifecycle of KubeMQ nodes.
```bash title="Terminal"
helm install --wait -n kubemq kubemq-controller kubemq-charts/kubemq-controller
```
### Install KubeMQ cluster [#install-kubemq-cluster]
Deploy the KubeMQ cluster. Replace `YOUR_LICENSE_KEY` with your actual license key.
```bash title="Terminal"
helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster \
--set key=YOUR_LICENSE_KEY
```
By default, this creates a 3-node cluster. To deploy a single standalone node for development, add `--set standalone=true`.
## Supplying the license from a Secret [#supplying-the-license-from-a-secret]
Passing `--set key=YOUR_LICENSE_KEY` stores the raw license as a Helm value (recoverable via `helm get values`) and on the `KubemqCluster` object. To keep the token out of cluster state — the recommended path for **GitOps**, where manifests live in git — put the license in a Kubernetes Secret and reference it instead.
```bash title="Terminal"
kubectl create secret generic kmq-license -n kubemq --from-literal=key=YOUR_LICENSE_KEY
```
Then install (or apply a CR) with `keySecretRef` in place of `key`:
```bash title="Terminal"
helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster \
--set keySecretRef=kmq-license
```
The operator resolves the key from the Secret at reconcile time; the raw token never appears in the `KubemqCluster` object or the Helm release values.
`keySecretRef` is **opt-in** — the literal `key` / `--set key=` path is unchanged and remains the default. Set only one: providing both a literal `key` and `keySecretRef` is rejected. The optional `keySecretKey` overrides the Secret's data key (defaults to `key`); `licenseSecretRef` / `licenseSecretKey` behave the same for `spec.license`.
**Operator logs from before v2.3.0 contain the activation key in plaintext.** Earlier operator builds printed the full key at INFO level on every activation. As of **v2.3.0** the operator logs only a last-4 fingerprint, everywhere — including inside licence-store error strings.
If you have archived operator logs, or a log aggregator that ingested them, **treat those archives as containing the activation key and rotate it.** Anyone with `kubectl logs` access on the operator over that window had the token.
## Verify installation [#verify-installation]
Confirm that all KubeMQ pods are running and ready.
```bash title="Terminal"
kubectl get pods -n kubemq
```
Expected output:
```text
NAME READY STATUS RESTARTS AGE
kubemq-controller-xxxxxxxxx-xxxxx 1/1 Running 0 2m
kubemq-cluster-0 1/1 Running 0 1m
kubemq-cluster-1 1/1 Running 0 1m
kubemq-cluster-2 1/1 Running 0 1m
```
Check the services exposed by KubeMQ:
```bash title="Terminal"
kubectl get svc -n kubemq
```
To access the KubeMQ dashboard, port-forward the API service:
```bash title="Terminal"
kubectl port-forward -n kubemq svc/kubemq-cluster 8080:8080
```
Then open `http://localhost:8080` in your browser.
## Charts & images [#charts--images]
| Chart | Purpose |
| ------------------- | ---------------------------------------------------------------------------- |
| `kubemq-crds` | Registers the `KubemqCluster` / `KubemqConnector` CRD schemas — no workloads |
| `kubemq-controller` | The operator Deployment |
| `kubemq-cluster` | Renders one `KubemqCluster` CR (thin `spec.*` passthrough) |
| `kubemq` (umbrella) | CRDs + operator + one CR in a single release (`key` required) |
All four charts are published as stable GA releases — `kubemq-crds` `3.2.0`, `kubemq-cluster` `3.2.0`, the umbrella `kubemq` `3.2.0`, and `kubemq-controller` `2.0.0` — a plain `helm install` resolves them, no `--devel` needed (see the callout above).
### Images and the `:next` channel [#images-and-the-next-channel]
The `kubemq-controller` chart's operator and server images default to the mutable image-tag `:next` — `operatorImage` and `kubemqImage` in the [Controller chart values](#controller-chart-values) below. `kubemqImage` isn't consumed by the operator's own container — it feeds the operator's `RELATED_IMAGE_KUBEMQ_CLUSTER` environment variable, which is what the operator uses as the container image when it renders a `KubemqCluster` StatefulSet.
**Tracking `:next` vs. pinning a digest.** Tracking `:next` — letting each pod roll re-pull the tag — is the default and recommended path; it always lands the current build. If a deployment needs byte-for-byte reproducibility instead, you may pin the same image to an immutable digest, `europe-docker.pkg.dev/kubemq/images/kubemq:next@sha256:`. The tag is still `:next`, so this stays compatible with the `:next`-only policy — it isn't a semver pin. The tradeoff: once pinned, a pod roll no longer adopts a new `:next` build on its own. You own the upgrade — re-pull `:next`, read its current digest, and re-pin:
```bash title="Terminal"
docker pull europe-docker.pkg.dev/kubemq/images/kubemq:next && \
docker inspect --format='{{index .RepoDigests 0}}' europe-docker.pkg.dev/kubemq/images/kubemq:next
```
**There is no `image.tag` — set the whole reference in `image.image`.** The `kubemq-cluster` chart passes **every** value straight through into the `KubemqCluster` object, so `--set image.tag=…` reaches the API server as an undeclared field and the install fails outright:
```text
server-side apply failed … .spec.image.tag: field not declared in schema
```
`spec.image.image` is **one full reference** — repository, and tag or digest, together:
```yaml title="values.yaml (kubemq-cluster)"
image:
image: europe-docker.pkg.dev/kubemq/images/kubemq:next@sha256:
pullPolicy: IfNotPresent
```
Keep the tag `:next` — that is the mandatory release channel, and a digest appended to it is the supported way to freeze a build. **Don't substitute a semver tag** such as `:v3.1.3`: the server does not publish one, and a reference that names it will not resolve.
## Configurable values [#configurable-values]
The snippets below are illustrative, one per chart — not exhaustive field tables. Field-by-field defaults and valid values for the `KubemqCluster` spec live in the Configuration Reference (linked throughout this section).
### Controller chart values [#controller-chart-values]
```yaml title="values.yaml (kubemq-controller)"
operatorImage: europe-docker.pkg.dev/kubemq/images/kubemq-operator:next
kubemqImage: europe-docker.pkg.dev/kubemq/images/kubemq:next
imagePullSecrets:
- name: my-registry-secret
# Images for the connector workloads started by the operator
connectorTargetsImage: europe-docker.pkg.dev/kubemq/images/kubemq-targets:next
connectorSourcesImage: europe-docker.pkg.dev/kubemq/images/kubemq-sources:next
connectorBridgesImage: europe-docker.pkg.dev/kubemq/images/kubemq-bridges:next
```
`connectorTargetsImage`, `connectorSourcesImage`, and `connectorBridgesImage` set the images the operator uses for the Targets, Sources, and Bridges connector workloads. Pin these to your own registry mirror in production — don't rely on the chart's built-in default.
### Cluster chart values [#cluster-chart-values]
Only `key` (your license, or `keySecretRef` — see [Supplying the license from a Secret](#supplying-the-license-from-a-secret)) and `imagePullSecrets` are native to this chart. Every other value is a **verbatim passthrough** to the `KubemqCluster` CR — a Helm value maps 1:1 to the `spec.*` field of the same name:
```yaml title="values.yaml (kubemq-cluster)"
key: "YOUR_LICENSE_KEY"
# Everything below passes through as spec.* on the KubemqCluster CR —
# see the Configuration Reference for the full field list.
replicas: 3
volume:
size: 50Gi
```
For the complete field list, see [Deployment & High Availability](/configure/reference/deployment) and [Storage Engines](/configure/reference/storage-engines).
### Umbrella chart values [#umbrella-chart-values]
Only `key` is chart-native, and it's **required** — the release fails to render without it. The operator image is hardcoded in the chart's template, not driven by `operatorImage`/`kubemqImage`:
```yaml title="values.yaml (kubemq)"
key: "YOUR_LICENSE_KEY"
```
Any value you set in a `values.yaml` **pins** that field permanently — once active, the server's built-in default no longer applies for that field, even across upgrades. Keep `values.yaml` minimal and only set what you intend to override long-term.
### Using a values file [#using-a-values-file]
For complex configurations, create a `values.yaml` file for the `kubemq-cluster` chart:
```yaml title="values.yaml"
key: "YOUR_LICENSE_KEY"
replicas: 3
standalone: false
volume:
size: 20Gi
storageClass: fast-ssd
grpc:
expose: LoadBalancer
port: 50000
api:
expose: LoadBalancer
port: 8080
resources:
requestsCpu: "2"
requestsMemory: 4Gi
limitsCpu: "4"
limitsMemory: 8Gi
```
Then install with:
```bash title="Terminal"
helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster -f values.yaml
```
## Declarative install (KubemqCluster CR) [#declarative-install-kubemqcluster-cr]
The `kubemq-controller` you installed above is the KubeMQ operator: it watches for `KubemqCluster` custom resources and reconciles the actual cluster state to match. Instead of passing cluster settings as Helm flags, you can describe the desired cluster declaratively in a `KubemqCluster` manifest and let the controller create and manage it.
This reuses the controller already running from the steps above — there is nothing extra to install. Apply a `KubemqCluster` resource and the operator does the rest.
### Create a KubeMQ cluster [#create-a-kubemq-cluster]
Define the desired cluster state in a `KubemqCluster` custom resource, then apply it with `kubectl`.
#### Basic cluster [#basic-cluster]
```yaml title="kubemq-cluster.yaml"
apiVersion: core.k8s.kubemq.io/v1beta1
kind: KubemqCluster
metadata:
name: kubemq-cluster
namespace: kubemq
spec:
replicas: 3
key: "YOUR_LICENSE_KEY"
```
Apply the manifest:
```bash title="Terminal"
kubectl apply -f kubemq-cluster.yaml
```
#### Standalone node (development) [#standalone-node-development]
For local development or testing, deploy a single standalone node:
```yaml title="kubemq-standalone.yaml"
apiVersion: core.k8s.kubemq.io/v1beta1
kind: KubemqCluster
metadata:
name: kubemq-dev
namespace: kubemq
spec:
replicas: 1
standalone: true
key: "YOUR_LICENSE_KEY"
grpc:
expose: NodePort
nodePort: 32000
api:
expose: NodePort
nodePort: 32080
```
#### Production cluster [#production-cluster]
A production-ready configuration with resource limits, persistent storage, and LoadBalancer services:
```yaml title="kubemq-production.yaml"
apiVersion: core.k8s.kubemq.io/v1beta1
kind: KubemqCluster
metadata:
name: kubemq-production
namespace: kubemq
spec:
replicas: 3
key: "YOUR_LICENSE_KEY"
volume:
size: 50Gi
storageClass: fast-ssd
grpc:
expose: LoadBalancer
port: 50000
bodyLimit: 100000000
rest:
disabled: false
expose: ClusterIP
port: 9090
api:
expose: LoadBalancer
port: 8080
resources:
requestsCpu: "2"
requestsMemory: 4Gi
limitsCpu: "4"
limitsMemory: 8Gi
health:
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
```
**A `next`-engine cluster requires `spec.volume.size` — and a new cluster on a clean store comes up on `next`.** So this applies to a default install, not just to clusters that asked for `next`. A volumeless `next`-engine cluster raises the `EphemeralNextStore` warning — its durable data would otherwise live on ephemeral container storage. The storage engine itself (`legacy` vs `next`) is **established once, at cluster creation**, and is immutable thereafter — there is no in-place migration between engines; to run `legacy` you name it explicitly at creation. See [Storage Engines](/configure/reference/storage-engines) for the full engine-selection model.
To enable Kafka on Kubernetes, see the zero-config recipe in [Configure → Zero-config Kafka](/configure/kubernetes#zero-config-kafka).
Check the status of your cluster after applying a manifest:
```bash title="Terminal"
kubectl get kubemqclusters -n kubemq
```
### Manage the cluster [#manage-the-cluster]
#### Scale [#scale]
Update the `replicas` field in your manifest and re-apply, or patch the resource directly:
```bash title="Terminal"
kubectl patch kubemqcluster kubemq-cluster -n kubemq \
--type merge -p '{"spec":{"replicas":5}}'
```
For clustered mode (non-standalone), use an **odd** number of replicas (3, 5, 7) to maintain consensus quorum. With even numbers, the cluster cannot tolerate as many failures.
#### Update configuration [#update-configuration]
Edit the `KubemqCluster` resource to change configuration. The operator reconciles changes automatically.
```bash title="Terminal"
kubectl edit kubemqcluster kubemq-cluster -n kubemq
```
Or apply an updated manifest:
```bash title="Terminal"
kubectl apply -f kubemq-cluster-updated.yaml
```
#### Delete [#delete]
Remove a KubeMQ cluster while keeping the controller running (so it can still manage other clusters):
```bash title="Terminal"
kubectl delete kubemqcluster kubemq-cluster -n kubemq
```
This removes all pods and services for that cluster. Persistent volume claims are retained by default.
### Field reference [#field-reference]
Field-by-field settings for the `KubemqCluster` spec — types, defaults, and valid values — live in the Configuration Reference, not here: [Deployment & High Availability](/configure/reference/deployment), [Storage Engines](/configure/reference/storage-engines), and [Connectors](/configure/reference/connectors).
## Upgrade [#upgrade]
Upgrade an existing KubeMQ installation to a new version or change configuration:
```bash title="Terminal"
helm upgrade --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster \
--set key=YOUR_LICENSE_KEY \
--reuse-values
```
The `--reuse-values` flag preserves your existing configuration and only applies the changes you specify.
### Helm does not upgrade CRDs [#helm-does-not-upgrade-crds]
Helm **never** upgrades CRDs shipped in a chart's `crds/` directory — running `helm upgrade` against `kubemq-crds` will not update an already-installed `KubemqCluster` CRD. That is deliberate: it is what stops a `helm uninstall` from cascade-deleting every `KubemqCluster` and its data. The CRD-upgrade path is applying the canonical manifest directly, not `helm upgrade`:
```bash title="Terminal"
kubectl apply -f https://raw.githubusercontent.com/kubemq-io/charts/master/kubemq-crds/crds/kubemqclusters.core.k8s.kubemq.io.crd.yaml
```
**Charts 3.2.0 carries a CRD schema change — apply it.** The new rule rejects a `KubemqCluster` that sets **both** an `oidc` block and the JWT `key` / `signatureType` fields, a combination that previously produced a broker with **no authentication at all**. Skipping the apply doesn't break anything: it only means the API server won't enforce the rule at admission, and the operator's own check catches the same case at reconcile instead. See [the authentication callout](/configure/reference/security#authentication) for how to find affected clusters before you upgrade.
### Upgrade order [#upgrade-order]
Upgrade in this order:
1. **CRDs and the operator, together, in one step.** The window where CRDs are upgraded but the operator is still old is transient — it is not a resting state you should pause in.
2. **Server images.** Because the server rides the mutable `:next` tag rather than a pinned semantic version, "upgrading" the server means **rolling the pods so they re-pull the current `:next` image** — there's no version number to bump to. If you've pinned the image to a digest for reproducibility (see [Images and the `:next` channel](#images-and-the-next-channel)), a plain pod roll won't pick up a new build on its own — re-pull `:next`, read its current digest, and re-pin it to upgrade.
3. **Charts** — the Helm release metadata itself (`--reuse-values`/`--set` as needed).
**An old operator strips fields it doesn't know about.** Don't add `spec.env`, `spec.envFromSecrets`, `expose`, or `nodePort` — or rely on the engine writing itself back onto the CR — until the operator itself has been upgraded. An old operator **permanently** strips unknown fields the first time it reconciles a resource, it doesn't just ignore them once.
**Operator rollback is not engine-safe.** If you roll the operator back, pin `spec.store.engine: next` explicitly first — a clustered `next` CR left to auto-detect will crash-loop under an old operator. The engine-establishment annotation on the CR survives rollback (it's untyped metadata an old operator doesn't touch), so re-upgrading the operator later re-derives the engine correctly.
An explicit `legacy` CR gets **one** checksum-triggered rolling restart on upgrade. An **established** cluster keeps the engine it already has — the operator names it explicitly and nothing rolls. A **fresh** cluster with no engine on record is created with `STORE_ENGINE=auto`, and the server resolves it at boot. Once the engine is established as `next`, the **replicas-freeze** arms: `spec.replicas` becomes immutable on that CR.
### v2.8.x → v2.9.0 (breaking) [#v28x--v290-breaking]
The six wire-protocol connectors — MQTT, AMQP 0.9.1, AMQP 1.0, STOMP, AWS, and GCP — changed their CRD field from `disabled: bool` to `enabled: *bool`. This requires all three components upgraded in lockstep: `kubemq-crds` ≥ 2.13.0, `kubemq-operator` ≥ 1.19.0, and the server ≥ v3.0.0-b7. Kafka's `enabled: *bool` opt-in field is unaffected — it already used this shape, consistent with the post-v2.9 pattern.
## Uninstall [#uninstall]
Remove KubeMQ from your cluster. If you installed the three charts separately, uninstall in reverse order — this is the recommended, default path:
```bash title="Terminal"
helm uninstall -n kubemq kubemq-cluster
helm uninstall -n kubemq kubemq-controller
helm uninstall -n kubemq kubemq-crds
```
If you installed the umbrella `kubemq` chart, you can remove the whole release in one step instead:
```bash title="Terminal"
helm uninstall kubemq -n kubemq
```
The umbrella chart ships a `pre-delete` hook designed to delete the `KubemqCluster` resource and let the operator drain its finalizer *before* the operator itself is removed — that's what makes the one-step path possible.
{/* TODO(CH-PREDEL): drop this caveat when the chart default preDelete.image is fixed */}
**This isn't safe out-of-the-box today.** The hook's default `preDelete.image` (`europe-docker.pkg.dev/kubemq/images/kubectl:latest`) doesn't resolve, so a plain `helm uninstall kubemq -n kubemq` will **hang** waiting on the hook. `helm uninstall` doesn't take `--set` itself, so override the value with an upgrade first, then uninstall — either point the hook at a `kubectl` image you know resolves:
```bash title="Terminal"
helm upgrade kubemq kubemq-charts/kubemq -n kubemq --reuse-values \
--set preDelete.image=
helm uninstall kubemq -n kubemq
```
or disable the hook and delete the cluster yourself first:
```bash title="Terminal"
helm upgrade kubemq kubemq-charts/kubemq -n kubemq --reuse-values \
--set preDelete.enabled=false
kubectl delete kubemqcluster -n kubemq
helm uninstall kubemq -n kubemq
```
Until this is fixed, prefer the 3-step reverse uninstall above.
Uninstalling deletes all KubeMQ pods and services. Persistent volume claims (PVCs) are retained by default. Delete them manually if you want to remove all data: `kubectl delete pvc -n kubemq -l app=kubemq-cluster`.
To also remove the namespace:
```bash title="Terminal"
kubectl delete namespace kubemq
```
## Production checklist [#production-checklist]
Before you point real traffic at a cluster, run down this list.
| Check | What to do |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Odd replica count, 3 or more** | Use **3, 5, or 7** replicas for clustered mode. The operator creates a `PodDisruptionBudget` only from 3 up — below that it creates none and emits an `UnsupportedReplicaCount` warning event, because the only correct budget at 2 replicas would block every node drain. An even count gets an `EvenReplicaCount` warning: 4 tolerates the same single failure as 3, at the cost of a node and a copy of the data. See [Disruption budget & pod spread](/configure/reference/deployment#disruption-budget--pod-spread). |
| **Shutdown grace period** | Set `terminationGracePeriodSeconds` to at least **45**. The server splits the grace between connector teardown — which **requeues in-flight messages that would otherwise be lost** — and store shutdown. At the Kubernetes default of 30s the requeue backstop is only **1 second**; 45s funds the full **12-second** backstop. See [Shutdown grace period](/configure/reference/deployment#shutdown-grace-period). |
| **Session affinity on AWS / GCP** | If you enable the **AWS** or **GCP Pub/Sub** connector on more than one replica, set `sessionAffinity: ClientIP`. Their delete/ack tokens are bound to the replica that issued them — without stickiness an SQS queue never drains and GCP acks are refused. Prefer ingress cookie affinity where clients share a NAT or egress IP. See [Service exposure & session affinity](/configure/reference/connectors#service-exposure--session-affinity). |
| **`volume.size` set** | Set `spec.volume.size` (or the `volume.size` Helm value) explicitly. It's **required for the `next` storage engine** — a volumeless `next` cluster raises the `EphemeralNextStore` warning because its durable data would otherwise live on ephemeral container storage. See [Storage Engines](/configure/reference/storage-engines). |
| **Resource requests/limits** | Set `resources.requestsCpu` / `requestsMemory` / `limitsCpu` / `limitsMemory` (or the matching `spec.resources.*` fields). There are no built-in defaults — an unset field is simply omitted from the pod spec. See the sizing table below. |
| **Service exposure** | Decide `LoadBalancer` vs `ClusterIP` (vs `NodePort`) per interface (`grpc.expose`, `rest.expose`, `api.expose`) based on whether clients connect from outside the cluster. |
| **Health probes** | Set `spec.health.enabled: true` so the operator wires a liveness probe (`/health` on the API port) and `API_BIND_ADDRESS=0.0.0.0` so the kubelet can reach it. Off by default. The API port also serves a `/ready` **readiness** endpoint that gates when the pod can actually serve — for a clustered `next`-engine cold start, it holds the pod not-Ready until cluster quorum forms. See [Health probe](/configure/reference/deployment#health-probe) for the full liveness/readiness model. |
| **Secure it** | Turn on **authentication** (JWT or OIDC), **authorization** (policy-based roles), and **TLS/mTLS** before exposing the cluster beyond a trusted network. All off by default. Full field-by-field reference: [Security](/configure/reference/security). |
| **Image-tag policy** | The server rides the mutable **`:next`** tag — that's the mandatory release channel, not a placeholder. Tracking `:next` (re-pulling on every pod roll) is the default and recommended path — own the rolling model: "upgrading" means re-pulling `:next` and rolling the pods, there's no version number to bump to. For byte-for-byte reproducibility you may instead pin `…/kubemq:next@sha256:` — the tag stays `:next`, so it's compatible with this policy — but then you own upgrades: a pod roll won't adopt a new `:next` build, so you upgrade by re-pulling `:next` and re-pinning the new digest (see [Images and the `:next` channel](#images-and-the-next-channel)). For change control without digest-pinning, **mirror `:next` to your own registry** and promote on your own schedule. |
| **PVC retention** | Deleting a `KubemqCluster` (or uninstalling the chart) retains PVCs by default — data survives. Delete them explicitly (`kubectl delete pvc -n kubemq -l app=kubemq-cluster`) if you actually want to wipe storage. |
### Sizing: eval vs. production [#sizing-eval-vs-production]
Illustrative starting points, not hard requirements — tune to your message volume, payload size, and retention needs.
| | Eval / dev | Production |
| ---------------------- | ----------------------------- | --------------------------------- |
| Replicas | 1 (`standalone: true`) | 3, 5, or 7 |
| CPU request / limit | `250m` / `500m` | `2` / `4` |
| Memory request / limit | `512Mi` / `1Gi` | `4Gi` / `8Gi` |
| `volume.size` | `5Gi` (or unset for `legacy`) | `50Gi`+, `fast-ssd` storage class |
| Service exposure | `ClusterIP` / port-forward | `LoadBalancer` |
## Related [#related]
Task guide: values.yaml, the KubemqCluster CR, single-node vs HA, and interface exposure.
Reference: the legacy and next persistence engines, zero-config engine selection, and durability trade-offs — types, defaults, and valid values.
Reference: Kubernetes packaging, replicas/standalone, resources, health probes, and Service exposure — types, defaults, and valid values.
# Get a License Key (/deploy/license-key)
A standalone KubeMQ server requires a license token to start. Getting one is
**free** — about **2-3 minutes the first time**, since it includes creating a free
account. This page explains what the token is, where to get one, and how to pass
it to the server.
## Why a license key is required [#why-a-license-key-is-required]
When KubeMQ runs **outside Kubernetes** — for example, a single Docker container on
your laptop or a CI runner — the server validates a license token on startup.
Without a valid `KUBEMQ_TOKEN`, a standalone server will **not start**: it logs a
licensing error and exits immediately. Set the token before you run the container.
The token is read from the `KUBEMQ_TOKEN` environment variable (or, equivalently,
the `--key` command-line flag). It is free to obtain and ties your running server to
your KubeMQ account.
## Get your token [#get-your-token]
### Create an account or sign in [#create-an-account-or-sign-in]
Register or log in at the KubeMQ account portal:
Create a free account or sign in to retrieve your license token.
### Copy your license token [#copy-your-license-token]
Once signed in, locate your license key and copy its value. This is the string you
will pass to the server as `KUBEMQ_TOKEN`.
### Pass it to the server [#pass-it-to-the-server]
Provide the token wherever you start KubeMQ — see the methods below.
## Pass the token to the server [#pass-the-token-to-the-server]
The same license value works across every way you run KubeMQ. In the examples below,
replace `YOUR_LICENSE_KEY` with the token you copied.
Pass the token with `-e KUBEMQ_TOKEN=…`. The `docker run` command already shows
`YOUR_LICENSE_KEY` as a placeholder:
Add the token to the `environment` section of your service:
```yaml title="docker-compose.yml"
services:
kubemq:
image: europe-docker.pkg.dev/kubemq/images/kubemq:next
container_name: kubemq
ports:
- "50000:50000"
- "9090:9090"
- "8080:8080"
environment:
- KUBEMQ_TOKEN=YOUR_LICENSE_KEY
```
On Kubernetes, the same license value is supplied to the chart with `--set key=…`:
```bash title="Terminal"
helm install kubemq-cluster kubemq/kubemq \
--set key=YOUR_LICENSE_KEY
```
## One license, two surfaces [#one-license-two-surfaces]
A single license value reaches KubeMQ through two different surfaces:
| Surface | How the key is supplied | Mechanism |
| --------------------- | ------------------------------------------------------------ | --------------------------------------------------- |
| Standalone server | `-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY` (or the `--key` flag) | The server validates the token on startup. |
| Kubernetes deployment | Helm `--set key=YOUR_LICENSE_KEY` / `KubemqCluster.spec.key` | The chart passes the key into the cluster resource. |
Under Kubernetes, the **server** skips its own license check — licensing is handled
by the operator. The chart still needs the `key` value, so supply it via
`--set key=…` or `KubemqCluster.spec.key`.
## Next steps [#next-steps]
Run KubeMQ locally with the token-bearing Docker command.
Deploy KubeMQ to Kubernetes and supply the key via the chart.
# Migrate from v2 to v3 (/deploy/migrate-v2-to-v3)
Moving from KubeMQ v2.10.x to KubeMQ v3. Companion to the [KubeMQ v3 release notes](/release-notes/v3).
## The short version [#the-short-version]
* **Recommended: start fresh.** Deploy a new v3 cluster on the new (next) storage engine, move your applications to it, and retire the v2 cluster when you're done. The new cluster starts empty; no data is carried over.
* **Alternative: upgrade in place.** Replace the v2 server with v3 on your existing cluster. The server detects your existing data automatically and keeps running on it — same data, same configuration, no conversion.
* **Either way, v3 is a one-way door.** There is no rollback to v2. Plan the move, take a backup first, and validate before you commit.
## Choosing your path [#choosing-your-path]
| | Path A — new cluster (recommended) | Path B — in-place upgrade |
| ------------------------------ | ----------------------------------- | ------------------------- |
| Storage engine after migration | next (new) | legacy (unchanged) |
| Existing data | not carried over — fresh start | fully preserved |
| Kafka connector available | yes | no (requires next) |
| Application cutover | planned, gradual | none — same endpoints |
| Rollback to v2 | old cluster untouched until retired | **none** |
* Choose **Path A** when you want the full v3 feature set — the [Kafka connector](/connectors/kafka), strict durability mode, log compaction — and can treat messaging data as transient: queues drained before cutover, event history not needed on the new cluster.
* Choose **Path B** when preserving in-flight data and endpoints matters more than the next-engine features. It also works as a first step — you can stand up a fresh next-engine cluster later and move to it.
## Before you begin [#before-you-begin]
* **Version** — confirm you are on v2.10.x.
* **Back up your store directory.** Mandatory for Path B — it is your only way back. Recommended for Path A as part of retiring the old cluster.
* **License** — v3 uses the same license mechanism as v2 (see [License key](/deploy/license-key)). Unsure whether your license covers v3? Ask [support@kubemq.io](mailto:support@kubemq.io) before you start.
* **Get v3** — images and install commands are in [Docker](/deploy/docker) and [Kubernetes with Helm](/deploy/kubernetes-helm). Note the v3 Helm charts are published on the prerelease channel and require `--devel` on install and upgrade.
* **Read** the [breaking changes](/release-notes/v3#breaking-changes-at-a-glance) in the release notes.
## Path A — start a new v3 cluster (recommended) [#path-a--start-a-new-v3-cluster-recommended]
### Deploy the new cluster [#deploy-the-new-cluster]
* **Docker / VM** — follow the [install guide](/deploy/docker). A fresh data directory means the server selects the next storage engine on its own; no engine setting required.
* **Kubernetes** — create a new KubemqCluster resource per [Kubernetes with Helm](/deploy/kubernetes-helm), and set the storage engine explicitly in the cluster spec (`spec.store.engine: next`) so the new cluster starts on the next engine.
* **Verify** — the dashboard cluster overview shows the engine per node, or run `kmq cluster info`. The startup log also prints the selected engine.
### Move your applications [#move-your-applications]
The connection change is the whole change: same APIs, same SDK calls — point your applications at the new address. v2-era SDKs keep working against v3; upgrading SDKs is separate and not required for migration.
Recommended cutover order per pattern:
* **Queues** — move producers first. New messages arrive on the new cluster while consumers drain the old backlog; move consumers once the old queues are empty.
* **Pub/Sub (Events, Events Store)** — move subscribers first so nothing published on the new cluster is missed, then move publishers.
* **Commands/Queries** — move responders first, then requesters.
Per application: change the connection configuration, deploy, then confirm its traffic on the new cluster's [dashboard](/operate/web-dashboard).
### Drain and retire the v2 cluster [#drain-and-retire-the-v2-cluster]
1. Let consumers drain the remaining queue backlogs on the old cluster.
2. Confirm zero traffic on the v2 dashboard.
3. Take a final backup, keep it per your retention policy, and decommission.
The old cluster is your safety net until this moment — after retirement, the move is complete and there is nothing to roll back to.
## Path B — upgrade the existing cluster in place [#path-b--upgrade-the-existing-cluster-in-place]
### What happens on first start [#what-happens-on-first-start]
The v3 server inspects the existing data directory and detects the v2-era (legacy) engine automatically — your data and configuration are used as-is. Detection is read-only and fail-safe: on anything unexpected the server stops with a clear error instead of touching your data. It never wipes, never converts, never guesses. Your endpoints, channels, queues, and subscriptions continue unchanged; applications reconnect and continue.
### Upgrade steps [#upgrade-steps]
* **Docker / VM** — stop v2, back up the store directory, start v3 on the same store path (same commands as in [Docker](/deploy/docker), pointing at your existing data volume).
* **Kubernetes** — update the chart/image per [Kubernetes with Helm](/deploy/kubernetes-helm) (remember `--devel`). Plan a maintenance window for the restart rather than assuming a zero-downtime mixed-version roll.
### Verify [#verify]
* The startup log reports the detected engine; the dashboard overview shows every node healthy on the legacy engine.
* Spot-check queue depths and subscriptions, and run a send/receive round-trip: `kmq queue send` / `kmq queue receive` ([kmq CLI](/operate/kmq-cli)).
### Life on the legacy engine [#life-on-the-legacy-engine]
Everything in v3 works on the legacy engine **except** the next-engine features: the Kafka connector, the strict ack durability mode, and log compaction / time-based retention. Native retention limits continue to work exactly as in v2. Details: [Storage engines](/configure/reference/storage-engines).
Moving to the next engine later is Path A with your upgraded cluster as the source — there is no in-place engine conversion.
## No rollback — read this before you start [#no-rollback--read-this-before-you-start]
Once a cluster runs v3, returning to v2 is not supported — for either path. Your protections instead of rollback:
* **Path A** — the untouched v2 cluster remains live until you retire it.
* **Path B** — the mandatory pre-upgrade backup is the only way back; restoring it means accepting the loss of everything since the backup.
Additionally, cluster nodes rebuilt with the v3 recovery procedure cannot roll back below v3.1.0.
## After you migrate [#after-you-migrate]
* Enable what you need — [protocol connectors](/connectors), [management-plane accounts](/configure/reference/security), [OpenTelemetry](/learn/guides/opentelemetry) — all off by default.
* Use the [kmq CLI](/operate/kmq-cli) for day-2 operations.
* Help: [support@kubemq.io](mailto:support@kubemq.io).
## Frequently asked questions [#frequently-asked-questions]
**Can I run v2 and v3 side by side?**
Yes — that is Path A. The two clusters are independent until you retire the old one.
**Do I have to upgrade my SDKs?**
No. v2 SDKs work against v3; SDK upgrades are independent of the migration.
**Can I move my queue backlog or event history to the new cluster?**
No — there is no data carry-over between clusters. Plan the cutover so queues drain on the old cluster before you retire it.
**Can I switch my upgraded (legacy) cluster to the next engine later?**
Yes — by standing up a next-engine cluster and moving to it (Path A). There is no in-place engine switch.
**What happens if I try to downgrade?**
Downgrading is not supported. Restore the pre-upgrade backup to a v2 server if you must go back, accepting the loss of everything since that backup.
# Quickstart (/deploy/quickstart)
The whole flow takes about 5 minutes the first time — roughly 2-3 of those are the
free account signup in Step 1. By the end you'll have KubeMQ running, the dashboard
open, and a message you sent yourself showing up in real time.
## 1 · Get your key [#get-your-key]
A standalone KubeMQ server needs a free license token to start — there's no keyless
mode. Grabbing one is **under a minute if you already have an account, \~2-3 minutes
including the free signup** if you don't.
[Get a license key →](/deploy/license-key)
## 2 · Run KubeMQ [#2--run-kubemq]
Replace `YOUR_LICENSE_KEY` below with the token from Step 1, then run:
This starts the broker with gRPC on port `50000`, REST on port `9090`, and the web
dashboard on port `8080`.
## 3 · Open the dashboard [#3--open-the-dashboard]
Open [`http://localhost:8080`](http://localhost:8080).
An empty dashboard here is healthy — it just means nothing has been sent yet. The
`orders` channel appears and ticks the moment you complete Step 4.
## 4 · Send & receive your first message [#4--send--receive-your-first-message]
This uses **Queues** — guaranteed, persistent delivery where send and receive are
independent steps, so nothing is lost if you run them in either order. Send one message
to a channel called `orders`, then receive it back.
Install `kmq`, KubeMQ's command-line client, then send and receive:
```sh
curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh
```
```sh
kmq context create default --api-address http://localhost:8080
kmq queue send orders '{"id":1}'
kmq queue receive orders
```
```text
{"id":1}
```
`kmq queue receive orders` prints the body of the message it just pulled off the
`orders` channel — the same `{"id":1}` you sent.
Watch the `orders` channel tick in the dashboard as the two commands run. The full
command reference and the agent-skill install are in [Drive KubeMQ from the
terminal](/deploy/cli).
No installation needed — this talks straight to the REST gateway on port `9090`.
```bash title="Terminal"
curl -X POST http://localhost:9090/queue/send \
-H "Content-Type: application/json" \
-d '{
"Channel": "orders",
"ClientID": "quickstart-curl",
"BodyString": "{\"id\":1}"
}'
```
```bash title="Terminal"
curl -X POST http://localhost:9090/queue/receive \
-H "Content-Type: application/json" \
-d '{
"Channel": "orders",
"ClientID": "quickstart-curl",
"MaxNumberOfMessages": 1,
"WaitTimeSeconds": 5
}'
```
```text
{"is_error":false,"message":"OK","data":{"MessagesReceived":1,"Messages":[{"MessageID":"...","Channel":"orders","Body":"eyJpZCI6MX0="}]}}
```
`Body` comes back base64-encoded (the wire format for raw bytes) — decode it and you'll see `{"id":1}`.
```go title="main.go"
package main
import (
"context"
"fmt"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("quickstart"),
)
if err != nil {
log.Fatal("Failed to create client:", err)
}
defer client.Close()
channel := "orders"
// Send
result, err := client.SendQueueMessage(ctx, kubemq.NewQueueMessage().
SetChannel(channel).
SetBody([]byte(`{"id":1}`)),
)
if err != nil {
log.Fatal("Failed to send:", err)
}
if result.IsError {
log.Fatal("Send error:", result.Error)
}
fmt.Printf("Sent: id=%s\n", result.MessageID)
// Receive
resp, err := client.PollQueue(ctx, &kubemq.PollRequest{
Channel: channel,
MaxItems: 1,
WaitTimeoutSeconds: 5,
AutoAck: true,
})
if err != nil {
log.Fatal("Failed to receive:", err)
}
for _, dsMsg := range resp.Messages {
fmt.Printf("Received: %s\n", dsMsg.Message.Body)
}
}
```
Requires Go 1.23 or later. Nine more languages plus REST are covered in [Client
SDKs](/sdks).
## 5 · Pick your path [#5--pick-your-path]
## Didn't work? [#didnt-work]
* **Port already in use** — something else is bound to `50000`, `9090`, or `8080`. Stop
it, or change the published ports in the `docker run` command.
* **Missing or invalid token** — `KUBEMQ_TOKEN` must be a real key from [Get a license
key](/deploy/license-key). A missing or malformed token makes the server exit
immediately on startup.
* **Container not `Up`** — run `docker ps` and check the `kubemq` container's status;
`docker logs kubemq` shows the exit reason if it isn't running.
# Connectors (/connectors)
A **connector** is a built-in, server-side **protocol gateway** in kubemq-server that
bridges an external protocol to KubeMQ messaging — no KubeMQ SDK is required on the
client. Point a standard HTTP, JSON-RPC, or CloudEvents client at the server and it
speaks to the message broker for you.
## What is a connector [#what-is-a-connector]
KubeMQ ships connectors in **two classes**, all built into the server.
**Shared-HTTP gateway** — runs on the shared HTTP server (port 9090):
* **CloudEvents** — a native CNCF CloudEvents-over-HTTP interface for all five
messaging patterns — Events, Events Store, Queues, Commands, and Queries — with CESQL
routing and SSE.
KubeMQ's two AI-facing HTTP gateways — **AI Agents (A2A)** and **MCP** — also fit this
connector definition: they are built-in, server-side HTTP gateways that run on the same
shared HTTP server. They are now documented under [Aiway](/aiway), KubeMQ's AI
Agents Fabric, where the agent-to-agent story is told as a whole.
**Wire-protocol connectors** — six connectors that speak a standard messaging
protocol on their **own dedicated ports**, so an existing client connects by changing
only its connection string, broker address, or endpoint URL:
* **AMQP 1.0** — native AMQP 1.0 clients, all five patterns (ports 5672 / 5671).
* **RabbitMQ (AMQP 0-9-1)** — RabbitMQ clients over the AMQP 0-9-1 wire dialect (5672 / 5671).
* **AWS (SQS & SNS)** — unmodified AWS SDKs over the real SQS/SNS HTTP protocols (port 4566).
* **MQTT** — MQTT 3.1.1 / 5.0 clients (ports 1883 / 8883 / 8083).
* **STOMP** — native STOMP 1.0/1.1/1.2 clients (ports 61613 / 61614).
* **Google Cloud Pub/Sub** — unmodified Pub/Sub clients via `PUBSUB_EMULATOR_HOST` over the real Pub/Sub v1 gRPC protocol (port 8085).
A connector is distinct from the other ways you reach KubeMQ. A **native SDK client**
links a KubeMQ library into your application and speaks the gRPC API. A **framework
adapter** (NestJS, Spring Boot, Celery, KEDA, and the others under
[Integrations](/integrations)) wires KubeMQ into a specific framework's
programming model. A connector, by contrast, runs **inside the server** and accepts a
standard wire protocol directly — nothing KubeMQ-specific is installed on the caller.
The **HTTP gateways** listen on the **shared HTTP server (port 9090)** and are
**enabled by default** — start kubemq-server and CloudEvents (along with the Aiway
gateways, A2A and MCP) is live. The **wire-protocol connectors** listen on their own
dedicated ports and are **all opt-in (disabled by default)** — each opens a new network
listener, so you enable only the ones you need.
*CloudEvents is the shared-HTTP gateway documented here; the AI gateways (A2A & MCP) run on the same port 9090 server but are documented under Aiway. The six wire-protocol connectors listen on their own ports. All front the same message broker.*
## The HTTP gateway [#the-http-gateway]
AI agents (A2A) and MCP now live under **[Aiway](/aiway)** — KubeMQ's AI Agents
Fabric. They are still built-in HTTP gateways on the shared HTTP server; their docs
just moved to where the agent-to-agent story is told end to end.
## The wire-protocol connectors [#the-wire-protocol-connectors]
Speak a standard messaging protocol on a dedicated port — point an existing client at
KubeMQ by changing only its connection string, broker address, or endpoint URL.
## Shared foundations [#shared-foundations]
Every **HTTP gateway** — CloudEvents here, plus the Aiway gateways (A2A and MCP) — runs
on **one HTTP server** and shares the same auth model, middleware chain, and metrics
surface. That shared story is told once here, and each gateway links back to it instead
of repeating it (the wire-protocol connectors document their own ports and auth in their
own pages):
Every HTTP gateway also inherits the unified middleware pipeline — JWT auth, CORS, origin
validation, TLS/mTLS, a traffic gate, and OpenTelemetry tracing — and the reserved
`_AGENTS_.` channel prefix, which user channels may not use. Connector metrics roll up
into the cluster-wide observability view alongside the rest of the server.
To **disable** an HTTP gateway, set its enable env var to `false` — for example
`CONNECTORSCE_ENABLE=false` for CloudEvents. The HTTP gateways are on by default; you
never need to set `=true` for them. See [Shared HTTP server](/connectors/concepts/shared-http-server)
for their disable vars and why the names look the way they do. The **wire-protocol connectors are
all opt-in** — disabled by default. Enable each one explicitly with its enable env var:
`CONNECTORSMQTT_ENABLE=true`, `CONNECTORS_AMQP_ENABLE=true`, `CONNECTORS_AMQP10_ENABLE=true`,
`CONNECTORS_STOMP_ENABLE=true`, `CONNECTORS_AWS_ENABLE=true`, `CONNECTORS_GCP_ENABLE=true`.
Each wire-protocol connector documents its own ports, enable var, and auth in its own pages.
## Migrating from another broker [#migrating-from-another-broker]
Already running RabbitMQ, ActiveMQ, JMS, AMQP 1.0, AWS SQS/SNS, STOMP, GCP Pub/Sub, or
MQTT? The [Migration hub](/connectors/how-to/migration) maps each ecosystem to its KubeMQ
wire-protocol connector and walks through the cutover — endpoint changes, concept mapping,
a working client example, and what does not carry over — for all eight.
# Messaging Patterns (/learn)
KubeMQ is a single message broker that supports four distinct messaging patterns — from fire-and-forget broadcasting to reliable point-to-point delivery to synchronous request-reply. This section is a learning track, not just a reference: it starts with vendor-neutral fundamentals, shows how KubeMQ implements each pattern, helps you pick and combine them, then composes them into real architectures.
One broker, four patterns, every use case.
## How to read this track [#how-to-read-this-track]
Work through it in order, or jump straight to the tier you need. Each step links to the next.
**New to messaging?** Start with the [Concepts](/learn/concepts) — six short pages that explain the ideas every pattern is built on.
## The Four Patterns [#the-four-patterns]
## The Four Patterns at a Glance [#the-four-patterns-at-a-glance]
| Feature | [Events](/learn/events) | [Events Store](/learn/events-store) | [Queues](/learn/queues) | [RPC](/learn/rpc) |
| ------------------ | ----------------------- | ----------------------------------- | ----------------------- | ----------------- |
| Direction | Pub/Sub | Pub/Sub | Point-to-Point | Request-Reply |
| Persistence | No | Yes (disk) | Yes (disk) | No |
| Delivery guarantee | At-most-once | At-least-once | Exactly-once | At-most-once |
| Replay | No | Yes (6 positions) | No | No |
| Acknowledgment | No | No | Yes (manual) | Yes (automatic) |
| Ordering | No | Yes (sequence) | Yes (FIFO) | N/A |
| Response | No | No | No | Yes |
| Dead letter queue | No | No | Yes | No |
| Delayed delivery | No | No | Yes | No |
| Caching | No | No | No | Yes (queries) |
## Which Pattern Should I Use? [#which-pattern-should-i-use]
Use the [interactive decision guide](/learn/guides/choosing-a-pattern) to find the right pattern for your use case.
| If you need... | Use |
| --------------------------------------------- | ----------------------------------- |
| Lowest latency, message loss acceptable | [Events](/learn/events) |
| Pub/sub with no message loss | [Events Store](/learn/events-store) |
| Replay historical messages | [Events Store](/learn/events-store) |
| One consumer per message, guaranteed delivery | [Queues](/learn/queues) |
| Retry, DLQ, delayed delivery | [Queues](/learn/queues) |
| Synchronous request-reply | [RPC](/learn/rpc) |
| Execute action, confirm success | [RPC Commands](/learn/rpc) |
| Request data, get result | [RPC Queries](/learn/rpc) |
## Combining Patterns [#combining-patterns]
Patterns can work together in a single application. KubeMQ's channel routing syntax lets you publish to multiple patterns simultaneously. Here are two common shapes.
*Order pipeline: a command processes the order, emits an event to the durable log, which feeds a queue for reliable fulfillment.*
*CQRS shape: commands write through the event store, the read service subscribes to build its projection, and clients query that read side.*
Channel routing syntax: use `;` to separate channels and `:` to prefix the pattern type:
```text
events:live-feed;events_store:archive;queues:process
```
## Get Started [#get-started]
# Integrations (/integrations)
KubeMQ **integrations** are framework and platform adapters that wire KubeMQ into a
specific framework's programming model — so you keep writing idiomatic NestJS, Spring,
Celery, or Ray Serve code while the adapter handles the messaging underneath. Every
integration is a native gRPC SDK client on port `50000`; there is no separate protocol to
learn and no SDK call to make by hand.
The protocol gateways are documented separately: A2A and MCP under
[**Aiway**](/aiway), and CloudEvents under [**Connectors**](/connectors).
## What an integration is [#what-an-integration-is]
An integration is a thin layer over the native KubeMQ gRPC SDK: it exposes KubeMQ through
a framework's own idioms (decorators, listeners, broker URLs, scalers) and connects to the
broker on `:50000` like any other SDK client.
There are three ways to reach KubeMQ, differing in **what you install** and **where the
bridge runs**:
| Approach | What you install | Where the bridge runs | Wire protocol | Example |
| ------------------------------- | ---------------------------------- | --------------------------------- | ------------------------------------- | -------------------------------------- |
| Native SDK client | a KubeMQ library | in your app | gRPC `:50000` | [Go](/sdks/go), [Python](/sdks/python) |
| Framework adapter (integration) | the adapter package + the gRPC SDK | in your app, inside the framework | gRPC `:50000` | this section |
| Server connector | nothing on the caller | inside kubemq-server | HTTP / JSON-RPC / CloudEvents `:9090` | [Connectors](/connectors) |
An integration **is** a native SDK client — it just sits *inside* a framework, exposing
KubeMQ through that framework's idioms rather than raw SDK calls. A **server connector**,
by contrast, runs inside kubemq-server and accepts a standard wire protocol on the shared
HTTP server, so the caller installs nothing KubeMQ-specific — see
[Connectors](/connectors) for that side of the picture.
Because integrations are gRPC clients, they connect to the **gRPC server on port 50000**,
which runs independently and is **always on**. They are **not** HTTP connectors and need
**no connector enable flag** — `CONNECTORS*_ENABLE` gates only the HTTP connectors
(A2A / MCP / CloudEvents) at `:9090`. The only prerequisite for any integration is a
running KubeMQ broker reachable on `:50000`.
## Architecture [#architecture]
Different apps load different adapters, but they all converge on the same native gRPC SDK
and the same broker on port `50000`.
*Each framework loads its own adapter, but all integrations ride the native gRPC SDK to the broker on :50000.*
## Messaging Framework Adapters [#messaging-framework-adapters]
Wire KubeMQ in as the transport for an application-messaging framework.
## Task & Inference Processing [#task--inference-processing]
Use KubeMQ as the work queue behind distributed task and ML-inference systems.
## Platform & Operations [#platform--operations]
Integrate KubeMQ with the surrounding platform — provisioning, DI, and autoscaling.
## Compare the integrations [#compare-the-integrations]
Every integration is a native gRPC SDK client on `:50000`. They differ by language,
framework, the KubeMQ patterns they expose, and what you install.
| Integration | Language / Runtime | Framework + min version | KubeMQ patterns | Primary use case | Package / install |
| ---------------------------------------- | ------------------------ | ----------------------------- | -------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------ |
| [NestJS](/integrations/nestjs) | TypeScript / Node 20.11+ | NestJS 10.x / 11.x | Events · Events Store · Queues · Commands · Queries | Decorator-driven NestJS microservices | `npm i @kubemq/nestjs-transport kubemq-js` |
| [Spring Boot](/integrations/spring-boot) | Java / Kotlin / JVM 17+ | Spring Boot 3.2.0+ | Events · Events Store · Queues · Commands · Queries | Spring services + Spring Cloud Stream | `io.kubemq:kubemq-spring-boot-starter` |
| [MassTransit](/integrations/masstransit) | C# / .NET 8.0 | MassTransit 8.5+ | Events · Events Store · Queues · Commands · Queries | KubeMQ as a MassTransit transport | NuGet `MassTransit.KubeMQ` |
| [FastStream](/integrations/faststream) | Python 3.11+ | FastStream 0.6.7+ | Events · Events Store · Queues · Commands · Queries | Async, event-driven Python apps | `pip install kubemq-faststream` |
| [Watermill](/integrations/watermill) | Go 1.25+ | Watermill | Events · Events Store · Queues · Commands · Queries | Watermill pub/sub + CQRS in Go | `go get github.com/kubemq-io/watermill-kubemq` |
| [Celery](/integrations/celery) | Python 3.10+ | Celery 5.4+ | Queues (tasks) · result backend | Distributed Python task queues | `pip install kubemq-celery` |
| [Ray Serve](/integrations/rayserve) | Python 3.10+ | Ray Serve 2.50+ | Queues · Queries · Events | Async / sync ML inference | `uv pip install kubemq-rayserve` |
| [.NET Aspire](/integrations/aspire) | C# / .NET 8.0 or 9.0 | .NET Aspire 9.0+ | Container provisioning + DI (all patterns via `IKubeMQClient`) | Provision KubeMQ + wire `IKubeMQClient` | NuGet `KubeMQ.Aspire.Hosting` + `KubeMQ.Aspire.Client` |
| [KEDA](/integrations/keda) | Go (cluster service) | KEDA 2.10+ · Kubernetes 1.27+ | Queue-depth (`Waiting`) metric | Autoscale queue consumers on Kubernetes | Helm `kubemq-keda-scaler` |
## Which integration should I use? [#which-integration-should-i-use]
Power users: the [comparison matrix](#compare-the-integrations) above lists every
integration's language, framework version, patterns, and install command side by side.
| If you need to… | Use |
| ---------------------------------------------------------- | ---------------------------------------- |
| Wire KubeMQ into a NestJS app with decorators & DI | [NestJS](/integrations/nestjs) |
| Add KubeMQ to a Spring Boot service (template + listeners) | [Spring Boot](/integrations/spring-boot) |
| Use KubeMQ as a MassTransit transport (.NET) | [MassTransit](/integrations/masstransit) |
| Build async Python apps with the FastStream framework | [FastStream](/integrations/faststream) |
| Use KubeMQ as a Watermill pub/sub in Go | [Watermill](/integrations/watermill) |
| Run distributed Python task queues (drop-in Celery broker) | [Celery](/integrations/celery) |
| Serve async/sync ML inference on Ray Serve | [Ray Serve](/integrations/rayserve) |
| Provision KubeMQ + wire `IKubeMQClient` in .NET Aspire | [.NET Aspire](/integrations/aspire) |
| Autoscale queue consumers on Kubernetes by queue depth | [KEDA](/integrations/keda) |
## Next steps [#next-steps]
# Operate KubeMQ (/operate)
Once KubeMQ is deployed and configured, this is where you keep it healthy. KubeMQ exposes metrics, distributed tracing, structured logging, and an audit trail, so you always have visibility into what the server is doing and can catch issues early. Operators interact with a running broker through the web dashboard and the kmq CLI.
# kmq CLI (/operate/kmq-cli)
`kmq` is KubeMQ's command-line client — a single static Go binary that talks to the
KubeMQ management API on port **`:8080`** (the same API the web dashboard uses). It is
built to be **agent-native**: predictable output, typed exit codes, and a
self-describing command tree make it a first-class tool for AI coding agents as well as
human operators.
## Overview [#overview]
`kmq` exposes every messaging pattern — Queues, Events, Events Store, and RPC
(Commands/Queries) — plus full observability (status, metrics, connections, audit,
connectors, agents) and a set of meta/discovery commands. Four properties make it
suited to automated and agent-driven use:
* **Token-efficient** — a `kmq queue send` is a few hundred tokens versus a multi-round
exchange over a protocol like MCP.
* **Deterministic** — typed exit codes and machine-readable `json`/`ndjson` output make
results easy to branch on in a script or an agent loop.
* **Self-describing** — `kmq schema` emits the whole command tree, offline, with no
network call.
* **Bounded by default** — every stream/subscribe/replay command honors
`--count`/`--duration`/`--idle`, so an agent can never loop forever waiting on it.
`kmq` is a *client of* the management API — it does not embed the broker, and it never
talks to the message broker directly. Every action is an HTTP call to the `:8080`
management API. `kmq mcp` reads the server's **registered MCP tools**; it does not make
`kmq` itself an MCP server.
## Architecture [#architecture]
`kmq` never talks to the message broker directly. Every action is an HTTP call to the
`:8080` management API: one-shot actions dispatch via `POST /api/request`
(`{type, data}`) or dedicated REST routes, and streaming commands use Server-Sent
Events / WebSocket subscription endpoints. Meta commands (`schema`, `cheat`, `skills`)
serve content **compiled into the binary**, so they work fully offline and never need
the server.
## Installation [#installation]
```sh
# Primary — GitHub Releases (kubemq-io/kmq), no credentials
curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh
# Pin a version, or verify the cosign signature strictly
curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh -s -- --version v0.3.1
curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh -s -- --verify-signature
# Container image (no install)
podman run --rm europe-docker.pkg.dev/kubemq/images/kmq:latest version
```
`install.sh` detects OS (`linux`/`darwin`/`windows`) and architecture (`amd64`/`arm64`),
resolves the version, downloads the archive plus `checksums.txt`, and performs a
**mandatory SHA-256 verification** — the install aborts if it cannot verify the
checksum. An **optional cosign signature check** runs on top of that (best-effort by
default, strict when requested), and the binary installs to the first writable `PATH`
directory.
| Install env var | Purpose |
| ---------------------- | ------------------------------------------------- |
| `KMQ_VERSION` | Explicit version to install (e.g. `v0.3.1`) |
| `KMQ_INSTALL_DIR` | Target directory for the `kmq` binary |
| `KMQ_BASE_URL` | Mirror/staging base URL (switches to mirror mode) |
| `KMQ_PREFIX` | Object prefix for a mirror (default `kmq`) |
| `KMQ_VERIFY_SIGNATURE` | Set to `1` to require a valid cosign signature |
## Contexts & configuration [#contexts--configuration]
A **context** is a named connection profile (API address, token, TLS, defaults).
Contexts live under `$XDG_CONFIG_HOME/kmq/` (`~/.config/kmq/` by default):
```text
$XDG_CONFIG_HOME/kmq/
├── contexts/
│ ├── default.json
│ └── prod.json
└── current-context # pointer file (active context name)
```
```sh
kmq context create default --api-address http://localhost:8080
kmq context create prod \
--api-address https://kubemq.example.com:8080 \
--token kmq__ --tls
kmq context use prod
kmq context list # (alias: ls) — current-context marked
kmq context current
kmq context edit prod --token kmq__
kmq context delete staging # (aliases: rm, remove)
```
Configuration is resolved by precedence, highest to lowest:
| Precedence | Source |
| ----------- | ----------------------------------------------- |
| 1 (highest) | Persistent flags (`--context`, `--api-address`) |
| 2 | Environment variables |
| 3 | Active-context file |
| 4 (lowest) | Built-in defaults (`http://127.0.0.1:8080`) |
| Env var | Purpose |
| ----------------- | --------------------------------------------------------------------- |
| `KMQ_TOKEN` | Service-account Bearer key (`kmq__`) — preferred in CI |
| `KMQ_CONTEXT` | Active context name (overrides the pointer file) |
| `KMQ_API_ADDRESS` | Management API URL |
### Authentication & roles [#authentication--roles]
Authentication is opt-in on the server — see [Security](/configure/reference/security)
for the account model and roles. Check whether it is on with `kmq doctor -o json | jq .auth`
(`on`/`off`). When auth is on, supply a service-account key via `KMQ_TOKEN` or the active
context. Service-account roles gate what the CLI can do:
| Role | Grants |
| ------------ | ---------------------------------------------------------------------- |
| `read_only` | list/inspect, metrics, status, overview, schema, doctor, billing |
| `read_write` | the above + send/receive/stream/subscribe/purge, channel create/delete |
| `admin` | the above + audit, account management |
A handful of commands and routes succeed with **no token**, even when server auth is
on: `kmq doctor`, `kmq metrics scrape`, `kmq billing` (routes `/ready`, `/health`,
`/metrics`, `/billing`, `/api/v1/auth/status`). Don't read the role table above as
universal gating — these are the exceptions.
`config set`/`revert` and account management require the `admin` role. Service accounts
never carry `admin`, so those operations are deliberate non-goals of the CLI.
## Global flags & output discipline [#global-flags--output-discipline]
Persistent flags are inherited by every subcommand:
| Flag | Default | Purpose |
| --------------- | --------- | ----------------------------------------------------------- |
| `-o, --output` | `json` | Output format: `json` \| `ndjson` \| `yaml` \| `table` |
| `--context` | — | Use a specific context (overrides current-context) |
| `--api-address` | — | Target `:8080` endpoint (overrides context) |
| `--no-color` | `false` | Disable color in table output |
| `--verbose` | `false` | Request timing to stderr (token redacted) |
| `--yes` | `false` | Confirm destructive operations without prompting |
| `--dry-run` | `false` | Render the action without executing |
| `--fields` | — | Project output to these camelCase wire fields |
| `--detail` | `summary` | `summary` \| `full` verbosity, for commands that support it |
Output discipline:
* **Data → stdout**, warnings/errors/diagnostics → **stderr** — safe to pipe.
* One-shot commands default to compact `json`; **streaming** commands (`queue stream`,
`*subscribe`, `*replay`, `conn watch`, `command/query receive`) default to
**`ndjson`** (one record per line, flushed per record).
* `metrics scrape` emits raw Prometheus text; `cheat` and `skills get` emit raw
markdown — for those, `-o` is ignored.
```sh
kmq queue receive orders --count 10 -o ndjson | jq .body # stream, per-line
kmq estore replay telemetry --from-first --count 100 -o json # buffer then array
kmq status --fields is_healthy,channels,clients
```
## Exit codes [#exit-codes]
`kmq` returns typed exit codes so scripts and agents can branch deterministically. On
error it also writes a JSON envelope to stderr: `{"error":{"code":"...","message":"...","retryable":bool}}`.
| Code | Name | Meaning | Retry? |
| ---- | --------- | --------------------------------------- | ------------------------ |
| 0 | OK | Success | — |
| 1 | Generic | Unclassified error | No |
| 2 | Usage | Bad flags / usage | No |
| 3 | NotFound | Resource not found | No |
| 4 | Auth | Auth required / failed / forbidden | No |
| 5 | Conn | Server unreachable | Yes (server down?) |
| 6 | Timeout | Request timed out | Yes |
| 7 | Partial | Partial success | Case-by-case |
| 8 | Retryable | Server initializing / too many attempts | Yes — `kmq` auto-retries |
Server wire codes map onto this table as follows: `auth_required`, `auth_failed`,
`forbidden`, `must_change`, and `tls_required` all map to exit code **4**;
`auth_initializing` and `too_many_attempts` map to exit code **8** and are
auto-retried; `seed_read_only`, `duplicate`, and `service_admin_forbidden` map to exit
code **2**; any empty or unknown wire code maps to exit code **1**.
## Command reference [#command-reference]
Every send command reads its body from (in priority) a positional argument →
`--body-base64` → `-f/--file` → stdin, and accepts `--message-id`/`--client-id`/
`--metadata`/`-M`/`--tag k:v` (repeatable). Every stream/subscribe/replay/receive
command accepts the bounding flags `--count N`, `--duration 5m`, `--idle 30s` —
whichever fires first stops it; Ctrl-C also exits cleanly with code 0.
### Messaging [#messaging]
| Pattern | Send | Receive / Subscribe | Notes |
| ----------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Queue](/learn/queues) | `kmq queue send ` | `kmq queue receive [--count N]` | persistent, at-least-once |
| Queue (peek) | — | `kmq queue peek [--count N]` | non-consuming |
| Queue (interactive) | — | `kmq queue stream [--visibility 60] [--wait 5] [--auto-ack]` | WS poll/ack/reject session |
| Queue (drain) | — | `kmq queue purge --yes` | destructive |
| [Events](/learn/events) | `kmq events send ` | `kmq events subscribe [--group g]` | fire-and-forget pub/sub |
| [Events Store](/learn/events-store) | `kmq estore send ` | `kmq estore subscribe ` | persistent + replayable |
| Events Store (replay) | — | `kmq estore replay ` | historical replay — one offset mode: `--new-only` (default), `--from-first`, `--from-last`, `--from-sequence N`, `--from-time `, `--since-seconds N`, plus `--group` |
| [Command](/learn/rpc) (RPC) | `kmq command send --timeout 30` | `kmq command receive [--respond-body '{}' \| --command 'sh']` | request/ack |
| [Query](/learn/rpc) (RPC) | `kmq query send --timeout 30` | `kmq query receive [--respond-body '{}' \| --command 'sh']` | request/data |
**Queue send** extras: `--max-receive-count`, `--dead-letter `,
`--expiration-seconds`, `--delay-seconds`.
**RPC responders**: `receive` with `--respond-body ''` echoes a static reply;
with `--command ''` the inbound body is piped to the command's stdin and its
stdout becomes the reply; with neither, requests are printed for manual handling.
`--respond-body` and `--command` are mutually exclusive.
### Channels [#channels]
```sh
kmq channel create --type queues|events|events_store|commands|queries
kmq channel delete --type queues --yes
kmq channel list [--type ]
kmq channel inspect # full detail: clients, rates, totals
# per-family shortcuts also exist: kmq queue list / kmq queue inspect
```
### Cluster [#cluster]
```sh
kmq cluster info [--node] # (alias: snapshot) cluster-merged, or --node for local
kmq cluster health # /ready — leadership role, ready/healthy
kmq cluster nodes # topology: node, type, unavailable nodes
```
### Observability [#observability]
```sh
kmq status # composite digest: /ready + snapshot, one-line health
kmq overview [--detail full] # per-pattern rollups (queues / pubsub / request-reply / totals)
kmq metrics scrape # raw Prometheus /metrics (auth-exempt)
kmq metrics history [--metric message_rate|volume_rate|error_rate|messages|bytes]
kmq conn list # active connections (first SSE snapshot)
kmq conn inspect
kmq conn watch # live SSE stream of connection events
kmq audit query [--from ..] [--to ..] [--event-type queue.send] [--limit N] ...
kmq audit stats [--group-by event_type|client_id|category|channel|transport]
kmq connector list # aws / gcp / amqp / amqp10 / stomp / mqtt / ce
kmq connector inspect [--operation ]
kmq agent list [--limit N] [--offset N] # A2A agents
kmq agent inspect
kmq mcp list # server's registered MCP tools
kmq mcp inspect
```
### Kafka [#kafka]
```sh
kmq kafka probe --bootstrap host:port[,host:port] # dial a source cluster, read-only
kmq kafka probe -b host:port --dial-timeout 30s # override the 15s default
kmq kafka probe -b host:port \
--aws-access-key --aws-secret-key --aws-session-token # MSK IAM
```
`kmq kafka probe` dials a **source** Apache Kafka / MSK / Confluent cluster over the
Kafka wire protocol and reports back the brokers plus their `ApiVersions`. It is
**read-only** — it never produces, commits, or auto-creates topics — so it's safe to
point at a production cluster. Flags: `-b/--bootstrap host:port[,host:port...]`,
`--dial-timeout` (default `15s`), and AWS MSK-IAM auth via
`--aws-access-key`/`--aws-secret-key`/`--aws-session-token`. Run it as a first
connectivity check ahead of the [Migration](#migration) family below, or see
[Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) for the full
workflow.
### Migration [#migration]
| Command | Flags | Notes |
| --------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `kmq assess kafka` | `--bootstrap`, `--tls`, `--sasl-mechanism` | Read-only fit assessment of an external Kafka cluster → per-topic READY/CAVEAT/UNKNOWN/BLOCKED + a T1–T4 verdict. Never writes to the source. |
| `kmq migrate assess\|replicate\|translate\|cutover` | `--state`, `--dry-run`, `--force` | **Beta** four-phase migration (assess → replicate → translate → cutover) from a Kafka cluster to KubeMQ. |
For the full narrative (per-source auth, MirrorMaker 2 hybrid, rollback, staged dry-run) see
[Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka).
### Meta & discovery [#meta--discovery]
```sh
kmq version # binary version
kmq whoami # identity + role (auth on), or {"authenticated":false,"auth":"disabled"} (auth off)
kmq doctor # connectivity + auth check (no auth required)
kmq config get [--fields ..] # server config (read-only, server-redacted)
kmq billing # usage/license (auth-exempt)
kmq schema -o json # full command tree + roles + exit codes (offline)
kmq cheat [topic] # embedded recipes (offline)
kmq docs [topic] [--open] # signpost to the online docs + LLM corpus
kmq skills ... # serve/install the agent skill (see next section)
```
`kmq cheat` topics (embedded, offline): `queue`, `events`, `estore`, `rpc`, `drain`,
`subscribe`, `health`, `auth`, `context`, `output`.
`kmq schema -o json` is the machine-readable contract — the full command tree (path,
description, required role, flags), the exit-code table, the server wire error-code
catalogue, and the CLI/server versions — with no network and no auth. It is the
recommended way for an agent to introspect the CLI.
## Agent-skill distribution [#agent-skill-distribution]
Any coding agent can be taught to drive `kmq` with one command, and the instructions
always match the installed binary version — the skill content is served straight from
the binary, so it can never go stale between releases.
```sh
kmq skills # list skills (alias: kmq skills list) → 'core'
kmq skills get core # print the core skill (raw markdown)
kmq skills get core --full # + the full command reference
kmq skills get --all # every skill
kmq skills path [name] # skills source dir, or '(embedded)'
kmq skills install [--global] [--force] # install the stub locally (zero-Node)
```
| Install path | Command | Reach |
| ------------------- | --------------------------------------------- | ---------------------------------------------------------------------- |
| Universal installer | `npx skills add kubemq-io/kmq` | 70+ agents (Claude Code, Cursor, Codex, Gemini CLI, Windsurf, …) |
| Claude Code plugin | `claude plugin marketplace add kubemq-io/kmq` | Claude Code |
| Zero-Node fallback | `kmq skills install` | Local Claude Code install; `--global` installs it for the current user |
## Server communication [#server-communication]
`kmq` speaks to the management API over HTTP. One-shot actions dispatch as
`POST /api/request` with `{type, data}` (or dedicated REST routes); the server replies
HTTP 200 with an envelope:
```json
{ "error": false, "error_string": "", "code": "", "data": {} }
```
`error: false` decodes `data` into the requested output; `error: true` maps the `code`
field to an exit code via the wire-code table above. A handful of routes are auth-exempt
(no Bearer needed) — the same ones called out in the **Auth-exempt commands** note above.
Retryable server states (`auth_initializing`, `too_many_attempts`) are auto-retried
with exponential backoff, cancellable by SIGINT/SIGTERM. Streaming uses SSE/WebSocket
subscription endpoints and stops on the first of `--count`/`--duration`/`--idle` or a
signal. SIGINT/SIGTERM cancel the root context — streaming/waiting commands close
gracefully and exit 0 on clean cancellation.
## Related [#related]
# Web Dashboard (/operate/web-dashboard)
The web dashboard is the primary visual surface for a running KubeMQ broker. It's a
single-page application served on port **8080** that shows cluster health, per-channel
throughput, connected clients, protocol connectors, AI agents, the audit trail, and live
configuration.
## Overview [#overview]
The dashboard is an embedded SPA served by the [management API](/operate/observability/api-reference).
Every number it renders comes from the same metrics collectors that feed the Prometheus
exporter and the JSON management endpoints — the dashboard is one view over that data, not
a separate source of truth.
The dashboard is **read-only for messaging data**. Producing and consuming happen through
the client SDKs, the REST/gRPC API, or a drop-in connector — never from the UI. The only
write paths in the dashboard are control-plane operations: editing or reverting
configuration, and managing accounts.
## Access [#access]
| Property | Value |
| --------- | -------------------- |
| URL | `http://:8080` |
| Served by | Management API |
| Bundle | Embedded SPA |
The dashboard is open by default, with no login required. When control-plane
authentication is enabled, it requires a session login, and features are gated by the
caller's role (viewer / editor / admin). See
[Security](/configure/reference/security) for the account model and roles.
## The shell [#the-shell]
Common controls appear on every page:
* **Left navigation** — switches between the sections listed below.
* **Time range** — selects the window used by all rate charts on the page.
* **Refresh interval** — auto-refresh rate for live values.
* **Theme** — light / dark toggle.
* **Language** — UI localization.
## Navigation map [#navigation-map]
## Sections [#sections]
### Overview [#overview-1]
The landing page. Aggregate cluster health, total send/receive rates across all patterns,
and a connector band summarizing every active protocol connector at a glance. Use it as
the daily starting point before drilling into a specific channel or connector.
### Channels [#channels]
One page per messaging family — [Events](/learn/events),
[Events Store](/learn/events-store), [Queues](/learn/queues), and
[Commands / Queries](/learn/rpc) (RPC). Each page lists the channels of that family
with their current rates and, where applicable, depth. Clicking a channel opens a detail
view with throughput and backlog history over the selected time range, plus the clients
attached to it.
Key per-channel values:
* **Send / receive rate** — current-activity message rate (msg/s).
* **Waiting** (Queues) — messages ready and waiting for a consumer.
* **Delayed** (Queues) — messages scheduled for future delivery.
* **Clients** — connected producers/consumers on the channel.
### Clients [#clients]
Every client connected to the broker: identity, channels in use, and activity. Use it to
confirm expected consumers are attached and to spot clients that connected or dropped.
### Connectors [#connectors]
One page per drop-in protocol connector — [AWS SQS/SNS](/connectors/aws),
[Google Pub/Sub](/connectors/gcp-pub-sub), [MQTT](/connectors/mqtt),
[AMQP 0-9-1](/connectors/rabbitmq), [AMQP 1.0](/connectors/amqp),
[STOMP](/connectors/stomp), and [CloudEvents](/connectors/cloudevents). Each shares the same shape: connection/link
counts, per-operation throughput, error rates, and history charts specific to that
protocol's operations. These pages are how you confirm a drop-in connector is receiving
client traffic and mapping it onto KubeMQ channels.
### Agents [#agents]
**A2A** and **MCP** pages showing registered AI agents, their liveness, and request
throughput/latency. See [Aiway](/aiway).
### Accounts [#accounts]
Present when control-plane authentication is enabled. Manage user and service accounts,
assign roles, and issue/revoke API keys. Gated to the admin role. See
[Security](/configure/reference/security).
### Audit [#audit]
The audit trail — control, data, and system events — with filtering. The who-did-what
record for management and messaging activity. See [Audit Logging](/operate/observability/audit).
### Config [#config]
A live view of the effective server configuration, with the ability to change and revert
it. A control-plane write path, gated by role. See
[Security](/configure/reference/security) for who can write, and
[Observability settings](/configure/reference/observability) for the fields shown.
### System [#system]
Node and cluster status: per-node health, cluster membership, and liveness. In a
multi-node deployment, most metric pages present cluster-wide aggregates while System
exposes the per-node breakdown.
## How to read the numbers [#how-to-read-the-numbers]
* **Rates** are current-activity deltas in messages/second — short spikes under bursty
producers are normal; a sustained slope is the signal that warrants action.
* **Depths** (waiting, delayed, backlog) are point-in-time counts, not rates.
* A rising **Waiting** count while the receive rate stays flat means consumers are not
keeping up. A non-zero **Delayed** count is intentional scheduling, not a stuck queue.
The same values are exported as Prometheus metrics and available on the JSON management
API. See [Prometheus Metrics](/operate/observability/metrics) and the
[Management API](/operate/observability/api-reference).
## Related [#related]
# Release Notes (/release-notes)
Release notes for the KubeMQ server, the kmq CLI, and the client SDKs. Each release gets its own page covering what's new, what changed, breaking changes, and how to upgrade.
## Current release [#current-release]
**KubeMQ v3** (v3.1.4, August 2026) is the largest release in the product's history — a rebuilt storage and clustering core, drop-in support for the messaging protocols you already use, ten client SDKs, and a new management experience.
## All releases [#all-releases]
| Release | Date | Highlights |
| -------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
| [v3](/release-notes/v3) (v3.1.4) | August 2026 | Drop-in protocol connectors, Raft-based storage engine, kmq CLI, rebuilt dashboard, 10 SDKs, MCP/A2A |
## Staying up to date [#staying-up-to-date]
* Coming from v2? The [v2 → v3 migration guide](/deploy/migrate-v2-to-v3) covers both paths — a fresh v3 cluster or an in-place upgrade.
* Install and upgrade procedures: [Docker](/deploy/docker) · [Kubernetes with Helm](/deploy/kubernetes-helm).
* Questions about a release? Reach us at [support@kubemq.io](mailto:support@kubemq.io).
# KubeMQ v3 (/release-notes/v3)
**KubeMQ v3.1.4 · August 2026.** KubeMQ v3 is the largest release in the product's history. Since v2.10.1, KubeMQ has been rebuilt around a new replicated storage core and extended to speak the messaging protocols your teams already use — Kafka, RabbitMQ, MQTT, AMQP 1.0/JMS, STOMP, AWS SQS/SNS, Google Pub/Sub, and CloudEvents — directly, with no bridges and no code changes.
Everything your applications rely on today keeps working. The native gRPC, REST, and WebSocket APIs and all four messaging patterns — [Queues](/learn/queues), [Events](/learn/events), [Events Store](/learn/events-store), and [Commands/Queries](/learn/rpc) — are unchanged, and in-place upgrades preserve your data and configuration automatically.
## Highlights at a glance [#highlights-at-a-glance]
* **Drop-in protocol support** — your existing Kafka, RabbitMQ, MQTT, AMQP 1.0, STOMP, SQS/SNS, and Google Pub/Sub clients connect directly to KubeMQ, unmodified.
* **New storage engine** — a Raft-based replication and persistence core with a selectable durability contract, alongside the existing engine, which remains fully supported.
* **Ten client SDKs** — the five you use today (Go, Java, C#/.NET, TypeScript, Python) upgraded for v3, plus five new: C++, Kotlin, Ruby, Rust, and Elixir.
* **Framework integrations** — Spring Boot, NestJS, Celery, FastStream, MassTransit, .NET Aspire, Ray Serve, Watermill, and KEDA autoscaling.
* **Rebuilt management dashboard** — per-protocol consoles, live configuration editing, accounts with roles, audit trail, six languages.
* **New kmq CLI** — messaging, cluster administration, observability, and a guided Kafka migration workflow from one binary.
* **OpenTelemetry** — distributed traces and metrics exported over OTLP.
* **Enterprise security** — role-based access control for the management plane, API keys and service accounts, audit logging, and FIPS builds.
* **AI-ready** — a built-in Model Context Protocol (MCP) server and Agent-to-Agent (A2A) support for agentic workloads.
* **New Developer Center** — this documentation site.
## Protocol connectors — use your existing clients [#protocol-connectors--use-your-existing-clients]
In v2, connecting non-KubeMQ clients meant deploying separate connector containers next to the broker. In v3, the broker itself speaks each protocol on its native port. The same client that talks to Kafka, RabbitMQ, or SQS today talks to KubeMQ tomorrow. All connectors are disabled by default and enabled per protocol in configuration; CloudEvents is on by default. A [migration guide](/connectors/how-to/migration) is available for each ecosystem.
| Protocol | Ports | In short |
| ----------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Kafka](/connectors/kafka) | 9092 / 9093 TLS | Full wire-protocol compatibility — producers (idempotent), consumer groups, offset management, admin operations, multi-partition topics, transactions with exactly-once semantics, log compaction and retention (enabling Kafka Connect and Kafka Streams). Share groups ("Queues for Kafka") in preview. Requires the next storage engine. |
| [RabbitMQ / AMQP 0-9-1](/connectors/rabbitmq) | 5672 / 5671 TLS | Exchanges (direct, fanout, topic, headers), queues, bindings, publisher confirms, dead-letter exchanges with RabbitMQ-exact headers, prefetch, virtual hosts. |
| [AMQP 1.0 / JMS](/connectors/amqp) | 5672 / 5671 TLS | Links, credit-based flow control, durable subscriptions, request/reply — compatible with Qpid JMS, go-amqp, and AMQP.Net Lite, including JMS message selectors. Both AMQP versions share one port with automatic detection. |
| [MQTT 3.1.1 / 5.0](/connectors/mqtt) | 1883 / 8883 TLS / 8083 WS | QoS 0/1/2, shared subscriptions, last-will messages, MQTT 5 user properties and request/response. |
| [STOMP 1.0–1.2](/connectors/stomp) | 61613 / 61614 TLS | All three acknowledgment modes, receipts, heartbeats, request/reply. |
| [AWS SQS / SNS](/connectors/aws) | 4566 | Point your AWS SDK or CLI at KubeMQ — SQS with long polling, visibility timeouts, FIFO queues; SNS topics, subscriptions, and filter policies with Signature-V4 authentication. |
| [Google Cloud Pub/Sub](/connectors/gcp-pub-sub) | 8085 | Emulator-compatible gRPC surface for the official client libraries — publish, pull, streaming pull, ordering keys, filters, dead-letter, seek/snapshots, schema validation. |
| [CloudEvents 1.0](/connectors/cloudevents) | 9090 (on by default) | Native HTTP endpoint for structured and binary CloudEvents — publish into any pattern, consume over server-sent events with resume, route by content (CESQL). |
The built-in connectors supersede the separate kubemq-targets, kubemq-sources, and kubemq-bridges deployments for the protocols above. The external connector containers remain available for other integrations — databases, caches, and third-party services.
## New storage engine [#new-storage-engine]
v3 introduces a new replicated storage engine built on Raft consensus, designed for stronger durability guarantees and safer day-2 operations. Full details: [Storage engines](/configure/reference/storage-engines).
* **Two engines, one server.** The v2 engine ("legacy", unchanged) and the new engine ("next") ship in the same binary and are selected in configuration. New deployments can adopt the next engine; existing ones stay on legacy until they choose to migrate.
* **Selectable durability contract** on the next engine. In **strict** mode, a message is acknowledged only after it is replicated to a quorum *and* written to disk — zero acknowledged-message loss, even through node crashes. In **fast** mode (the default), acknowledgment follows quorum replication, with disk sync completing in a bounded background window.
* **Engine auto-detection.** On upgrade, the server inspects the existing data directory and resolves the correct engine on its own. It never wipes or reinterprets data on a mismatch — ambiguity is a loud startup error, not a silent guess.
* **Log compaction and time-based retention** on the next engine — the same mechanisms that enable Kafka Connect and Kafka Streams support.
* **Engine scope.** The Kafka connector requires the next engine. All other connectors and all native messaging patterns run on both engines.
## Clustering and operations [#clustering-and-operations]
Clustering has been rebuilt around the new engine with one focus: predictable failure handling.
* **Safe node replacement.** A node that returns with lost or empty storage no longer rejoins silently. It stays up, reports the reason on its readiness endpoint, and is restored through an explicit, guided `kmq cluster replace` procedure that re-adds it as a learner until it has caught up.
* **Calmer elections.** Pre-vote prevents a rejoining node from disrupting a healthy cluster, and leader changes warm up connector state — Kafka coordinators included — before serving traffic.
* **Kubernetes-aware lifecycle.** Replica identity derives from the pod name, readiness is decoupled from liveness (a booting node reports live while quorum forms, avoiding restart loops), and shutdown phases are sized to the pod's termination grace period.
* **Replication security.** Optional mutual TLS on the cluster replication port.
## New kmq command-line tool [#new-kmq-command-line-tool]
One installable binary for everything that previously needed scripts, curl, or the dashboard. Full reference: [kmq CLI](/operate/kmq-cli).
* **Messaging** — send, receive, peek, stream, and purge queues; publish and subscribe to events; replay the Events Store from six starting positions; send commands and queries and run responders.
* **Cluster administration** — inspect health, members, and nodes; add, promote, remove, and replace members. Mutations are role-gated and confirmation-gated.
* **Observability** — live connection watch, metrics, audit queries, and connector and agent inspection.
* **Kafka migration workflow** — `kmq assess kafka` produces a read-only migration-fitness report of an existing Kafka cluster; `kmq migrate` replicates topics with exact offset mapping, translates configuration, and manages cutover.
* **Automation-friendly** — machine-readable output, typed exit codes, dry-run support, and a complete offline schema of every command.
## Client SDKs — ten languages [#client-sdks--ten-languages]
The SDK family has doubled from five languages to ten, all covering the four messaging patterns over the native gRPC API, each documented with working examples per pattern. Start here: [Client SDKs](/sdks).
* **Upgraded** — Go, Java, C#/.NET, TypeScript (Node.js), and Python: updated and fully compatible with v3.
* **New** — C++, Kotlin, Ruby, Rust, and Elixir.
## Framework integrations [#framework-integrations]
Adapters for the frameworks your teams already use — you keep writing idiomatic framework code; the adapter handles the messaging underneath: Spring Boot, NestJS, Celery, FastStream, MassTransit, .NET Aspire, Ray Serve, Watermill, and KEDA autoscaling on queue depth. Details: [Integrations](/integrations).
## Management dashboard — rebuilt [#management-dashboard--rebuilt]
What was a monitoring view in v2 is now a full management console, protected by login and roles. See [Web dashboard](/operate/web-dashboard).
* **Cluster overview** — health, throughput, storage-engine status, and replication posture per node.
* **Per-protocol consoles** — dedicated pages for Kafka, RabbitMQ-style queues, MQTT clients, AMQP 1.0 links, STOMP destinations, SQS/SNS, Google Pub/Sub, and CloudEvents, each modeled on the console your team already knows.
* **Live configuration** — view and change every subsystem's settings from the UI, with revert support and role gating.
* **Accounts and roles** — user accounts, service accounts, and API keys managed in the UI.
* **Audit trail** — a searchable log of who changed what, and when.
* **Channel monitor** — a live, observe-only tap on any channel.
* **Six languages** — English, Spanish, French, German, Portuguese (Brazil), and Italian, in light and dark themes.
## Observability [#observability]
* **OpenTelemetry** — distributed traces and metrics exported over OTLP (gRPC or HTTP), with W3C trace-context propagation and configurable sampling. Off by default. Setup: [OpenTelemetry guide](/learn/guides/opentelemetry).
* **Metrics that survive restarts** — counters are durable; a server restart no longer resets your Prometheus series.
* **Per-connector metrics** — dedicated metric families for every protocol connector.
* **Audit logging** — control-plane and data-plane events recorded as CloudEvents with configurable retention, queryable from the API, the CLI, and the dashboard. On by default.
## Security and access control [#security-and-access-control]
The management plane — dashboard and management API — is now protected by full authentication and role-based access control. Reference: [Security](/configure/reference/security).
* **Management-plane access control** — user accounts with session login and service accounts with API keys; three roles (read-only, read-write, admin); deny-by-default on every management endpoint; hardened credential storage, login throttling, and CSRF protection. Off by default — existing deployments are unaffected until you enable it.
* **Safer defaults** — the management port binds to localhost unless authentication is enabled or the server runs in a container or Kubernetes; secrets are redacted from configuration views.
* **Data-plane security carried forward** — TLS/mTLS, JWT and OIDC authentication, and policy-based authorization continue as in v2, extended to the new protocol connectors.
* **FIPS builds** — a dedicated FIPS image line, cryptographically verified and vulnerability-scan-gated before publication.
* **Signed supply chain** — release images are vulnerability-scanned before publication; CLI artifacts ship with signed checksums.
## AI and agentic workloads [#ai-and-agentic-workloads]
KubeMQ speaks the two emerging standards for AI-agent communication out of the box. Start here: [AI Way](/aiway).
* **Model Context Protocol (MCP) server built in** — AI assistants and agents send, receive, and inspect KubeMQ messages through standard MCP tools, with no extra infrastructure.
* **Agent-to-Agent (A2A) protocol** — agent registration and discovery, agent cards, synchronous request/reply, and streaming responses, backed by a cluster-replicated agent registry.
* Dedicated dashboard pages for agent and MCP activity.
## Kubernetes and deployment [#kubernetes-and-deployment]
Install guide: [Kubernetes with Helm](/deploy/kubernetes-helm).
* **Helm charts** — cluster, controller, CRDs, and an umbrella chart.
* **Declarative cluster resource** — a zero-configuration KubemqCluster custom resource; every connector and subsystem is configurable in the spec, with per-connector service exposure.
* **Guard rails** — the operator records the cluster's established storage engine and refuses unsafe changes (engine switches, enabling Kafka on a legacy-engine cluster) instead of applying them silently.
* **License visibility** — license type, holder, and expiry surfaced directly in `kubectl get`.
* **Escape hatches** — an environment-variable overlay and secret projection for tunables the typed spec doesn't cover, with a deny-list protecting settings the operator owns.
## Breaking changes at a glance [#breaking-changes-at-a-glance]
* **Helm installation** — the v2 stable chart line is retired. v3 charts are published on the prerelease channel and require `--devel` on install and upgrade.
* **Storage engine choice is per-deployment** — there is no in-place migration between the legacy and next engines. A data directory created on one engine cannot be opened by the other; the server fails loudly rather than converting.
* **Node-rebuild recovery sets a version floor** — cluster nodes rebuilt with the v3 recovery procedure cannot roll back below v3.1.0.
* **Kafka requires the next engine** — enabling the Kafka connector on a legacy-engine deployment is a configuration error.
* **Management API surface changes** — some management and statistics endpoints changed between v2 and v3.
* **Storage sizing on the next engine** — native retention limits (maximum retention, queue size, and message caps) currently apply on the legacy engine only; plan storage for next-engine channels accordingly.
* **External connector containers superseded** — the separate targets/sources/bridges deployments are replaced by built-in connectors for broker protocols; they remain available for other integrations.
## Known limitations [#known-limitations]
* MQTT retained messages are not supported; the broker advertises this to connecting clients.
* AMQP 0-9-1 transactions are not supported.
* STOMP transactions and selectors are not supported.
* Kafka — replication factor is always cluster-managed; the next-generation consumer protocol (KIP-848) and Kerberos are not supported.
* Native retention limits are not yet enforced on next-engine channels.
## Upgrading [#upgrading]
In-place upgrades from v2.10.x keep your data and configuration — the server detects your existing storage automatically. The [v2 → v3 migration guide](/deploy/migrate-v2-to-v3) covers both upgrade paths step by step. Adopting the next storage engine or the Kafka connector is a planned migration; see [Storage engines](/configure/reference/storage-engines) and the per-ecosystem [migration guides](/connectors/how-to/migration).
Questions? Reach us at [support@kubemq.io](mailto:support@kubemq.io).
# Client SDKs (/sdks)
KubeMQ ships **official client libraries for ten languages**, each a thin, idiomatic wrapper over the same gRPC API. Every SDK speaks the four messaging patterns — [Events](/learn/events), [Events Store](/learn/events-store), [Queues](/learn/queues), and [RPC](/learn/rpc) — so the concepts you learn in one language carry directly to the rest.
Pick your language below, or expand it in the sidebar — the trees are structured identically, so `reference/client` in Go maps to `reference/client` in Python.
## Choose your language [#choose-your-language]
## No SDK for your language? [#no-sdk-for-your-language]
Every KubeMQ operation is also available over the **REST and WebSocket APIs**, so any language with an HTTP client can talk to KubeMQ. See the [Quick Start](/deploy/quickstart) for a language-agnostic first message.
# Agent Cards (/aiway/a2a/agent-cards)
An **agent card** is the machine-readable description of an agent's identity and
capabilities. KubeMQ serves agent cards at the standard A2A `/.well-known/agent-card.json`
endpoints so callers can discover what an agent does before sending it a request.
## Overview [#overview]
Following the A2A protocol convention, KubeMQ exposes two kinds of card:
* **Platform card** — describes the KubeMQ gateway itself. The `name` is always
`kubemq`. It carries no agent-specific skills; it advertises the gateway and the
protocol versions it speaks.
* **Individual card** — describes one registered agent. It is the `AgentCard` you
supplied at registration, **enriched** with the server-managed `registered_at` and
`last_seen` timestamps. This is the same representation returned by
`GET /agents/{agent_id}`.
Cards are read-only discovery surfaces. You create and update them through the
[registry](/aiway/a2a/registry) (register / heartbeat); the card endpoints
just expose the current state. Both card endpoints are **public** — no JWT is required
to read them, since discovery must work before a caller authenticates.
## The two card endpoints [#the-two-card-endpoints]
| Endpoint | Returns |
| ------------------------------------------------- | --------------------------------------------- |
| `GET /.well-known/agent-card.json` | Platform card — the KubeMQ gateway's own card |
| `GET /a2a/{agent_id}/.well-known/agent-card.json` | Individual agent's enriched card |
Requesting a well-known card for a non-existent agent returns **HTTP 404**.
### Platform card [#platform-card]
The platform card represents the gateway, not any agent. Its `name` is always `kubemq`:
```json
{
"name": "kubemq",
"description": "KubeMQ A2A Gateway",
"version": "latest",
"url": "http://localhost:9090/",
"skills": [],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"]
}
```
### Individual agent card [#individual-agent-card]
An individual card returns the agent's registered fields plus the server-managed
timestamps:
```json
{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": ["test", "echo"]
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"],
"registered_at": "2026-04-06T10:00:00Z",
"last_seen": "2026-04-06T10:05:00Z"
}
```
## Card fields [#card-fields]
The `AgentCard` carries the agent's identity, endpoint, and capabilities. The
**required** fields are `agent_id`, `name`, and `url`.
| Field | JSON key | Required | Description |
| -------------------- | -------------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| Agent ID | `agent_id` | Yes | Unique identifier — 2–128 chars, lowercase alphanumeric with hyphens, starting and ending with alphanumeric |
| Name | `name` | Yes | Human-readable name (max 256 chars) |
| Description | `description` | No | Free-text description (max 2048 chars) |
| Version | `version` | No | Agent version, e.g. `1.0.0` (max 64 chars) |
| URL | `url` | Yes | Agent endpoint — an absolute `http://` or `https://` URL (max 2048 chars) |
| Skills | `skills` | No | List of `AgentSkill` entries (see below) |
| Capabilities | `capabilities` | No | Free-form capability map |
| Default input modes | `defaultInputModes` | No | e.g. `["text"]` |
| Default output modes | `defaultOutputModes` | No | e.g. `["text"]` |
| Protocol versions | `protocolVersions` | No | Defaults to `["1.0"]` if omitted |
| Metadata | `metadata` | No | Key-value string map |
`registered_at` and `last_seen` are **server-managed** — KubeMQ sets them on
registration and heartbeat. Do not send them when registering; they will be
overwritten.
### Skills and tags [#skills-and-tags]
Each entry in `skills` is an `AgentSkill`. Skills make agents discoverable: the
registry filters by skill **tags** when listing agents, so well-chosen tags let
callers find the right agent without knowing its ID.
| Field | JSON key | Required | Description |
| ----------- | ------------- | -------- | --------------------------------- |
| ID | `id` | Yes | Skill identifier |
| Name | `name` | Yes | Skill name |
| Description | `description` | No | What the skill does |
| Tags | `tags` | No | Tags used for discovery filtering |
To find every agent advertising a tag, pass it to the list endpoint —
`GET /agents?skill_tags=echo` — see the [registry](/aiway/a2a/registry).
## Fetch a card [#fetch-a-card]
The example below reads an individual agent's card. The curl tab hits the standard
well-known endpoint; the language tabs fetch the same card representation via the
registry API and print every field.
```bash
# Platform card (the gateway itself)
curl http://localhost:9090/.well-known/agent-card.json
# Individual agent card
curl http://localhost:9090/a2a/echo-agent-01/.well-known/agent-card.json
```
```csharp
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "full-info-agent-01";
using var client = new HttpClient();
var resp = await client.GetAsync($"{KubeMqUrl}/agents/{AgentId}");
Console.WriteLine($"Status: {(int)resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
Console.WriteLine("\n--- Agent Card ---");
Console.WriteLine($" agent_id: {data["agent_id"]}");
Console.WriteLine($" name: {data["name"]}");
Console.WriteLine($" description: {data["description"]}");
Console.WriteLine($" version: {data["version"]}");
Console.WriteLine($" url: {data["url"]}");
Console.WriteLine($" defaultInputModes: {data["defaultInputModes"]}");
Console.WriteLine($" defaultOutputModes: {data["defaultOutputModes"]}");
Console.WriteLine($" protocolVersions: {data["protocolVersions"]}");
Console.WriteLine($" registered_at: {data["registered_at"]}");
Console.WriteLine($" last_seen: {data["last_seen"]}");
var skills = data["skills"]?.AsArray() ?? [];
Console.WriteLine($"\n--- Skills ({skills.Count}) ---");
foreach (var skill in skills)
{
Console.WriteLine($" [{skill!["id"]}] {skill["name"]}: {skill["description"]}");
Console.WriteLine($" tags: {skill["tags"]}");
}
```
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "full-info-agent-01"
)
func main() {
resp, err := http.Get(kubemqURL + "/agents/" + agentID)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
var data map[string]interface{}
json.Unmarshal(body, &data)
fmt.Println("\n--- Agent Card ---")
fmt.Printf(" agent_id: %v\n", data["agent_id"])
fmt.Printf(" name: %v\n", data["name"])
fmt.Printf(" description: %v\n", data["description"])
fmt.Printf(" version: %v\n", data["version"])
fmt.Printf(" url: %v\n", data["url"])
fmt.Printf(" defaultInputModes: %v\n", data["defaultInputModes"])
fmt.Printf(" defaultOutputModes: %v\n", data["defaultOutputModes"])
fmt.Printf(" protocolVersions: %v\n", data["protocolVersions"])
fmt.Printf(" registered_at: %v\n", data["registered_at"])
fmt.Printf(" last_seen: %v\n", data["last_seen"])
skills, _ := data["skills"].([]interface{})
fmt.Printf("\n--- Skills (%d) ---\n", len(skills))
for _, s := range skills {
sk, _ := s.(map[string]interface{})
fmt.Printf(" [%v] %v: %v\n", sk["id"], sk["name"], sk["description"])
fmt.Printf(" tags: %v\n", sk["tags"])
}
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "full-info-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/" + AGENT_ID))
.GET().build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + resp.statusCode());
var data = MAPPER.readTree(resp.body());
System.out.println("\n--- Agent Card ---");
System.out.println(" agent_id: " + data.path("agent_id").asText());
System.out.println(" name: " + data.path("name").asText());
System.out.println(" description: " + data.path("description").asText());
System.out.println(" version: " + data.path("version").asText());
System.out.println(" url: " + data.path("url").asText());
System.out.println(" defaultInputModes: " + data.path("defaultInputModes"));
System.out.println(" defaultOutputModes: " + data.path("defaultOutputModes"));
System.out.println(" protocolVersions: " + data.path("protocolVersions"));
System.out.println(" registered_at: " + data.path("registered_at").asText());
System.out.println(" last_seen: " + data.path("last_seen").asText());
var skills = data.path("skills");
System.out.println("\n--- Skills (" + skills.size() + ") ---");
for (var skill : skills) {
System.out.println(" [" + skill.get("id").asText() + "] "
+ skill.get("name").asText() + ": " + skill.get("description").asText());
System.out.println(" tags: " + skill.get("tags"));
}
}
}
```
```python
"""Agent-Info example — retrieves and displays all agent card fields."""
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "full-info-agent-01"
async def main() -> None:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{KUBEMQ_URL}/agents/{AGENT_ID}")
print(f"Status: {resp.status_code}")
data = resp.json()
print("\n--- Agent Card ---")
print(f" agent_id: {data.get('agent_id')}")
print(f" name: {data.get('name')}")
print(f" description: {data.get('description')}")
print(f" version: {data.get('version')}")
print(f" url: {data.get('url')}")
print(f" defaultInputModes: {data.get('defaultInputModes')}")
print(f" defaultOutputModes: {data.get('defaultOutputModes')}")
print(f" protocolVersions: {data.get('protocolVersions')}")
print(f" registered_at: {data.get('registered_at')}")
print(f" last_seen: {data.get('last_seen')}")
skills = data.get("skills", [])
print(f"\n--- Skills ({len(skills)}) ---")
for skill in skills:
print(f" [{skill['id']}] {skill['name']}: {skill['description']}")
print(f" tags: {skill.get('tags', [])}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "info-agent-01";
async function main() {
const resp = await fetch(`${KUBEMQ_URL}/agents/${AGENT_ID}`);
const agent = await resp.json();
console.log("=== Agent Card Details ===");
console.log(` agent_id: ${agent.agent_id}`);
console.log(` name: ${agent.name}`);
console.log(` description: ${agent.description}`);
console.log(` version: ${agent.version}`);
console.log(` url: ${agent.url}`);
console.log(` registered_at: ${agent.registered_at}`);
console.log(` last_seen: ${agent.last_seen}`);
console.log(` protocolVersions: ${JSON.stringify(agent.protocolVersions)}`);
console.log(` defaultInputModes: ${JSON.stringify(agent.defaultInputModes)}`);
console.log(` defaultOutputModes:${JSON.stringify(agent.defaultOutputModes)}`);
console.log("\n=== Skills ===");
for (const skill of agent.skills || []) {
console.log(` - ${skill.id}: ${skill.name} (tags: ${(skill.tags || []).join(", ")})`);
console.log(` ${skill.description}`);
}
console.log("\n=== Full JSON ===");
console.log(JSON.stringify(agent, null, 2));
}
main().catch(console.error);
```
The curl tab uses the public `/.well-known/agent-card.json` endpoint; the language
tabs read the **same** card via `GET /agents/{agent_id}` (the registry API), which
returns an identical representation. Both surfaces return the enriched card.
## Related [#related]
# How It Works (/aiway/a2a/architecture)
The A2A connector is a **transparent JSON-RPC 2.0 proxy**. It never runs your agent's
logic — it routes a request to the right agent, waits for the reply, and relays it back
unchanged. The piece that makes agents plain HTTP servers (no KubeMQ SDK) is the
**virtual subscriber**: a per-agent internal bridge that turns a Query into an
outbound HTTP POST.
## Overview [#overview]
When a caller hits `POST /a2a/`, the request does **not** go straight to your
agent over HTTP. Instead it is published as a **Query** on the agent's internal
channel, picked up by that agent's virtual subscriber, and forwarded to the agent's
registered URL as an HTTP POST. The agent's response travels the same path in reverse.
This indirection is what lets agents stay simple — they are standard A2A-compliant HTTP
servers with **zero KubeMQ dependencies** (no broker client, no protobuf, no client library) — while
still benefiting from KubeMQ's routing, authorization, metrics, and persistence
infrastructure. For why agents are plain HTTP URLs, see the [overview](/aiway/a2a).
## The components [#the-components]
The gateway is one of several cooperating parts. The A2A connector handles the protocol;
the **Agent Registry** tracks who is registered; the **Subscriber Manager** owns the
lifecycle of every **virtual subscriber**; and the **Replicator** keeps registry state
in sync across a cluster.
*The A2A connector proxies via the broker; a per-agent virtual subscriber bridges the Query to an HTTP POST.*
| Component | Role |
| ---------------------- | ------------------------------------------------------------------------------------ |
| **A2A connector** | Transparent JSON-RPC 2.0 proxy; agent-management REST API; SSE relay |
| **Agent Registry** | SQLite-backed store of registered agents with TTL liveness and cluster replication |
| **Subscriber Manager** | Creates/destroys virtual subscribers on registration, deregistration, and TTL expiry |
| **Virtual Subscriber** | Per-agent broker-to-HTTP bridge; the reason agents need no KubeMQ SDK |
| **Replicator** | Propagates registry changes across cluster nodes over Events Store |
## The virtual-subscriber bridge [#the-virtual-subscriber-bridge]
Each registered agent gets its own **virtual subscriber** — an internal broker client that
subscribes to Queries on the agent's channel and forwards them to the agent over HTTP. It
is spawned by the Subscriber Manager when the agent registers and torn down when the agent
deregisters or its TTL expires.
When a Query arrives, the virtual subscriber:
Extracts the JSON-RPC body from the broker request.
Unpacks forwarded HTTP headers (the
`a2a_hdr_*`
tags) back into real headers.
Sets the
`X-KubeMQ-Caller-ID`
header to the original caller's identity.
Makes an HTTP
`POST`
to the agent's registered URL with
`Content-Type: application/json`
.
Wraps the agent's HTTP response and publishes it back on the broker reply channel.
The subscriber dispatches on the `a2a_method` tag in the incoming Query: `message/stream`
is routed to the streaming handler (which opens an SSE connection to the agent),
`stream_cancel` is routed to the cancel handler, and everything else — including
`message/send` and `tasks/send` — goes to the synchronous request handler. All broker
operations for an agent use the client ID `a2a-vsub-` (for example
`a2a-vsub-agent-b`).
Virtual subscribers use a queue subscription with the queue group set to the agent ID, so
load-balanced delivery works correctly across the cluster.
## Internal channels [#internal-channels]
All agent-platform traffic uses channels under the reserved `_AGENTS_.` prefix. User
channels that start with `_AGENTS_.` are rejected by `IsReservedChannel` — see
[Auth & security](/connectors/reference/auth-and-security).
| Channel | Purpose | Transport |
| ----------------------------- | ------------------------------------------------------------------------------- | ------------ |
| `_AGENTS_.agents/` | Request/reply to the agent via its virtual subscriber (including stream cancel) | Query |
| `_AGENTS_.stream/` | Temporary SSE stream events relayed by the virtual subscriber | Events |
| `_AGENTS_.discovery` | Registry replication events across cluster nodes | Events Store |
## Header forwarding [#header-forwarding]
Caller HTTP headers ride along to the agent through the broker request's `Tags` field using
an `a2a_hdr_` prefix: the connector packs each allowed header as
`a2a_hdr_: `, and the virtual subscriber strips the prefix and replays
it as a real HTTP header on the outbound POST.
Hop-by-hop and sensitive headers are **never** forwarded: `Connection`, `Keep-Alive`,
`Transfer-Encoding`, `Te`, `Trailer`, `Upgrade`, `Host`, `Content-Length`,
`Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `Proxy-Authenticate`,
`X-Forwarded-For`, and `X-Real-Ip` are dropped.
Regardless of which headers the caller sent, the virtual subscriber always sets
**`X-KubeMQ-Caller-ID`** on the outbound request, carrying the original caller's KubeMQ
client identity — so your agent can always tell who called it. Any KubeMQ transport (gRPC,
REST, the A2A HTTP gateway, or the MCP bridge) can forward headers the same way by setting
`a2a_hdr_*` tags on the request.
## Concurrency control [#concurrency-control]
Each virtual subscriber enforces a **per-agent** concurrency cap using a buffered-channel
semaphore sized to `AgentMaxConcurrency` (default `100`). When a Query arrives and the
semaphore has capacity, a handler goroutine runs and releases its slot on completion. When
all slots are occupied, the overflow Query is **immediately rejected** with a transport
failure — `Executed: false`, `Error: "server busy: concurrency limit reached"` — without
spawning a goroutine.
This keeps a single hot agent from consuming unbounded goroutines under load. The cap, the
response-size limit, and the timeout/gateway-buffer behavior are covered in detail in
[Concurrency & limits](/aiway/a2a/guides/concurrency).
## Timeouts and the gateway buffer [#timeouts-and-the-gateway-buffer]
Each request carries a timeout taken from `params.configuration.timeout` in the JSON-RPC
body, falling back to `DefaultTimeoutSeconds` (`300`) and capped at `MaxTimeoutSeconds`
(`3600`). Before forwarding, the gateway adds a **`GatewayTimeoutBuffer` of 10 seconds** to
the downstream deadline so the gateway never times out before the agent does. SSE stream
endpoints skip the per-route timeout middleware entirely because they are long-lived; they
are bounded by the idle timeout instead (see [Streaming](/aiway/a2a/streaming)).
## Cluster behavior [#cluster-behavior]
Virtual subscribers are **local to the node where the agent registered** — they are not
replicated. Registry *state*, however, is shared cluster-wide.
*Only the registering node spawns the virtual subscriber; other nodes store the card and route Queries to it over the cluster mesh.*
* An agent registers on **Node A** → its virtual subscriber is spawned on **Node A only**.
* Replication events over `_AGENTS_.discovery` propagate the `AgentCard` to **Nodes B and C**; they store the card but do **not** spawn a subscriber.
* The cluster mesh routes Queries from any node to the virtual subscriber on Node A.
* If **Node A** goes down, the agent's heartbeat expires on all nodes and its subscribers are cleaned up.
* When the agent re-registers on **Node B**, a fresh virtual subscriber is spawned there.
## Transport vs. application errors [#transport-vs-application-errors]
Because the bridge is the boundary between the broker and HTTP, it cleanly separates two failure
classes via the `Executed` flag on the response:
* **Transport error** (`Executed: false`) — the agent never processed the request: it was unreachable, timed out, returned `502`/`503`/`504`, or its response exceeded the size cap. Safe to retry.
* **Application error** (`Executed: true`) — the agent *did* process the request and returned an HTTP `4xx`/`5xx` with a JSON-RPC error body. Retrying blindly usually will not help.
This distinction lets callers choose the right retry strategy — see
[Error handling](/aiway/a2a/error-handling).
## Related [#related]
# Configuration (/aiway/a2a/configuration)
The A2A connector is **enabled by default** on the [shared HTTP server](/connectors/concepts/shared-http-server) — start kubemq-server and `/a2a/*` is live with no flag to set. Configuration tunes agent liveness, request timeouts, concurrency, and response limits; you only set values that differ from the defaults below.
## Overview [#overview]
A2A settings live under `connectors.a2a` in the kubemq-server configuration (the Go `A2aConfig` struct). Every field has a working default, so a minimal deployment needs no A2A configuration at all. You can override any field through a YAML/TOML config file, an environment variable, or a Docker `-e` flag — they map one-to-one.
The connector listens on the shared HTTP server's port (`9090` by default, inherited from `Rest.Port`) and is **on unless you disable it**. There is no `=true` flag — older KubeMQ builds were off-by-default, but current builds ship all three connectors enabled.
## Config fields [#config-fields]
Defaults are taken verbatim from the `A2aConfig` struct in kubemq-server.
| Field | Type | Default | Description |
| ----------------------- | ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Enable` | `bool` | `true` | Whether the A2A connector is served. Set to `false` to disable `/a2a/*` and the registry. |
| `AgentTTLSeconds` | `int` | `300` | Liveness window for a registered agent. An agent that does not heartbeat within this window is considered stale. Must be positive. |
| `DefaultTimeoutSeconds` | `int` | `300` | Timeout applied to an agent request when the caller does not set `params.configuration.timeout`. Must be positive. |
| `MaxTimeoutSeconds` | `int` | `3600` | Upper bound for caller-specified timeouts. Larger values are capped to this. Must be `>= DefaultTimeoutSeconds`. |
| `MaxAgents` | `int` | `0` | Maximum number of registered agents. `0` means unlimited. Cannot be negative. |
| `MaxSSEIdleSeconds` | `int` | `300` | Idle timeout for an SSE stream. When it fires, a `task.error` with code `-32001` is sent and the agent is asked to cancel. Must be positive. |
| `TrustedOrigins` | `[]string` | `["auto"]` | Origins allowed by Origin validation. `auto` matches localhost and the bind address; `*` allows all. See [Authentication](/aiway/a2a/guides/authentication). |
| `AgentMaxResponseBytes` | `int64` | `10485760` | Maximum agent response size in bytes (10 MB). Oversized responses are rejected with `-32603`. `0` means unlimited. Cannot be negative. |
| `AgentTLSSkipVerify` | `bool` | `false` | When agents are registered with `https://` URLs, skip TLS certificate verification. Leave `false` in production. |
| `AgentMaxConcurrency` | `int` | `100` | Maximum simultaneous in-flight requests per agent. The 101st concurrent request is rejected with `-32603`. A non-positive value resets to `100`. |
**Validation.** kubemq-server rejects the configuration at startup if `AgentTTLSeconds`, `DefaultTimeoutSeconds`, or `MaxSSEIdleSeconds` is not positive, if `MaxTimeoutSeconds < DefaultTimeoutSeconds`, or if `MaxAgents` / `AgentMaxResponseBytes` is negative.
## Enable / disable [#enable--disable]
The A2A connector is enabled by default. To **disable** it, set its enable variable to `false`:
**The enable variable name is irregular by design.** `Connectors.A2A.Enable` becomes **`CONNECTORSA2_A_ENABLE`** — the `2`-to-`A` boundary inside `A2A` splits into `A2_A`. kubemq-server derives every env var by snake-casing the dotted config path with two regexes, stripping dots, and uppercasing. Do not "fix" the name to `CONNECTORS_A2A_ENABLE`; that string is not bound and has no effect. The full algorithm and the matching MCP/CE names are documented in [Shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default).
## Environment variables [#environment-variables]
Each field maps to one environment variable via the same transform. These are the A2A variables in full:
| Variable | Config field | Default |
| ----------------------------------------- | -------------------------------------- | ---------- |
| `CONNECTORSA2_A_ENABLE` | `Connectors.A2A.Enable` | `true` |
| `CONNECTORSA2_A_AGENT_TTL_SECONDS` | `Connectors.A2A.AgentTTLSeconds` | `300` |
| `CONNECTORSA2_A_DEFAULT_TIMEOUT_SECONDS` | `Connectors.A2A.DefaultTimeoutSeconds` | `300` |
| `CONNECTORSA2_A_MAX_TIMEOUT_SECONDS` | `Connectors.A2A.MaxTimeoutSeconds` | `3600` |
| `CONNECTORSA2_A_MAX_AGENTS` | `Connectors.A2A.MaxAgents` | `0` |
| `CONNECTORSA2_A_MAX_SSE_IDLE_SECONDS` | `Connectors.A2A.MaxSSEIdleSeconds` | `300` |
| `CONNECTORSA2_A_TRUSTED_ORIGINS` | `Connectors.A2A.TrustedOrigins` | `auto` |
| `CONNECTORSA2_A_AGENT_MAX_RESPONSE_BYTES` | `Connectors.A2A.AgentMaxResponseBytes` | `10485760` |
| `CONNECTORSA2_A_AGENT_TLS_SKIP_VERIFY` | `Connectors.A2A.AgentTLSSkipVerify` | `false` |
| `CONNECTORSA2_A_AGENT_MAX_CONCURRENCY` | `Connectors.A2A.AgentMaxConcurrency` | `100` |
## Configuration file [#configuration-file]
kubemq-server loads a YAML or TOML file (auto-detected) from the `--config` flag or the `CONFIG` environment variable. The A2A connector sits under `connectors.a2a`, alongside the shared `http` block. This example pins every A2A field to its default and shows the shared HTTP/CORS context:
```yaml title="config.yaml"
connectors:
rest:
enable: true
port: "9090"
http:
readtimeout: 60
bodylimit: "100M"
cors:
alloworigins: ["*"]
allowmethods: ["GET", "POST", "DELETE", "OPTIONS"]
allowheaders: ["Authorization", "Content-Type", "MCP-Protocol-Version", "MCP-Session-Id", "Last-Event-ID", "Accept"]
a2a:
enable: true
agentttlseconds: 300
defaulttimeoutseconds: 300
maxtimeoutseconds: 3600
maxagents: 0
maxsseidleseconds: 300
trustedorigins: ["auto"]
agentmaxresponsebytes: 10485760
agenttlsskipverify: false
agentmaxconcurrency: 100
```
The shared HTTP server inherits its port from `connectors.rest.port`. To run the connectors on a separate port from REST, set `connectors.http.port` explicitly. See [Shared HTTP server](/connectors/concepts/shared-http-server) for the port-inheritance and middleware details.
## Tuning the limits [#tuning-the-limits]
The concurrency and response-size caps are the two limits you are most likely to hit under load. The `.kb` examples exercise both against a live server — the curl call below shows the wire behavior, and the language tabs run the full client that fires past the limit and reports the rejection.
### Per-agent concurrency [#per-agent-concurrency]
`AgentMaxConcurrency` (default `100`) bounds in-flight requests per agent. The 101st concurrent request to a single agent is rejected with JSON-RPC error `-32603` ("internal error") while the first 100 succeed.
```bash
# Each of these is a normal request; firing more than AgentMaxConcurrency
# of them at once against one agent makes the overflow return -32603.
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": { "message": { "parts": [{ "text": "hello" }] } }
}'
```
```csharp
// .kb/integration-a2a/examples/csharp/limits/concurrency-limit/Client.cs
using System.Net.Http.Json;
var kubemqUrl = Environment.GetEnvironmentVariable("KUBEMQ_URL") ?? "http://localhost:9090";
var agentId = "concurrency-agent-01";
const int total = 101; // limit is 100; 1 request will be rejected
using var http = new HttpClient();
async Task<(bool ok, int? code)> SendOne(int i)
{
var payload = new
{
jsonrpc = "2.0",
id = i,
method = "message/send",
@params = new { message = new { parts = new[] { new { text = $"req-{i}" } } } }
};
var resp = await http.PostAsJsonAsync($"{kubemqUrl}/a2a/{agentId}", payload);
var body = await resp.Content.ReadFromJsonAsync();
if (body?.Error is not null)
return (false, body.Error.Code);
return (true, null);
}
var results = await Task.WhenAll(Enumerable.Range(0, total).Select(SendOne));
var rejected = results.Count(r => r.code == -32603);
Console.WriteLine($"Successes: {results.Count(r => r.ok)} Rejected (-32603): {rejected}");
```
```go
// .kb/integration-a2a/examples/go/limits/concurrency-limit/client.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"sync"
"sync/atomic"
)
func main() {
kubemqURL := os.Getenv("KUBEMQ_URL")
if kubemqURL == "" {
kubemqURL = "http://localhost:9090"
}
agentID := "concurrency-agent-01"
const total = 101 // limit is 100; 1 request is rejected
var success, rejected int64
var wg sync.WaitGroup
for i := 0; i < total; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": i, "method": "message/send",
"params": map[string]any{"message": map[string]any{
"parts": []map[string]string{{"text": fmt.Sprintf("req-%d", i)}}}},
})
resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(body))
if err != nil {
return
}
defer resp.Body.Close()
var out struct {
Error *struct {
Code int `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Error != nil && out.Error.Code == -32603 {
atomic.AddInt64(&rejected, 1)
} else {
atomic.AddInt64(&success, 1)
}
}(i)
}
wg.Wait()
fmt.Printf("Successes: %d Rejected (-32603): %d\n", success, rejected)
}
```
```java
// .kb/integration-a2a/examples/java/limits/concurrency-limit/Client.java
import java.net.URI;
import java.net.http.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
public class Client {
public static void main(String[] args) throws Exception {
String kubemqUrl = System.getenv().getOrDefault("KUBEMQ_URL", "http://localhost:9090");
String agentId = "concurrency-agent-01";
int total = 101; // limit is 100; 1 request is rejected
HttpClient http = HttpClient.newHttpClient();
AtomicInteger success = new AtomicInteger();
AtomicInteger rejected = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(total);
var tasks = IntStream.range(0, total).>mapToObj(i -> () -> {
String payload = """
{"jsonrpc":"2.0","id":%d,"method":"message/send",
"params":{"message":{"parts":[{"text":"req-%d"}]}}}""".formatted(i, i);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(kubemqUrl + "/a2a/" + agentId))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
String body = http.send(req, HttpResponse.BodyHandlers.ofString()).body();
if (body.contains("-32603")) rejected.incrementAndGet();
else success.incrementAndGet();
return null;
}).toList();
pool.invokeAll(tasks);
pool.shutdown();
System.out.printf("Successes: %d Rejected (-32603): %d%n", success.get(), rejected.get());
}
}
```
```python
# .kb/integration-a2a/examples/python/limits/concurrency-limit/client.py
import asyncio
import os
import httpx
KUBEMQ_URL = os.getenv("KUBEMQ_URL", "http://localhost:9090")
AGENT_ID = "concurrency-agent-01"
TOTAL = 101 # limit is 100; 1 request is rejected
async def send_one(client: httpx.AsyncClient, i: int) -> int | None:
payload = {
"jsonrpc": "2.0",
"id": i,
"method": "message/send",
"params": {"message": {"parts": [{"text": f"req-{i}"}]}},
}
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
error = resp.json().get("error")
return error["code"] if error else None
async def main() -> None:
async with httpx.AsyncClient(timeout=30) as client:
codes = await asyncio.gather(*(send_one(client, i) for i in range(TOTAL)))
rejected = sum(1 for c in codes if c == -32603)
successes = sum(1 for c in codes if c is None)
print(f"Successes: {successes} Rejected (-32603): {rejected}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
// .kb/integration-a2a/examples/typescript/limits/concurrency-limit/client.ts
const kubemqUrl = process.env.KUBEMQ_URL ?? "http://localhost:9090";
const agentId = "concurrency-agent-01";
const total = 101; // limit is 100; 1 request is rejected
async function sendOne(i: number): Promise {
const resp = await fetch(`${kubemqUrl}/a2a/${agentId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: i,
method: "message/send",
params: { message: { parts: [{ text: `req-${i}` }] } },
}),
});
const body = (await resp.json()) as { error?: { code: number } };
return body.error?.code ?? null;
}
const codes = await Promise.all(
Array.from({ length: total }, (_, i) => sendOne(i)),
);
const rejected = codes.filter((c) => c === -32603).length;
const successes = codes.filter((c) => c === null).length;
console.log(`Successes: ${successes} Rejected (-32603): ${rejected}`);
```
### Response size [#response-size]
`AgentMaxResponseBytes` (default `10485760`, 10 MB) caps the size of an agent's reply. A larger response is rejected before it reaches the caller, who receives `-32603` with the message `internal error: response too large`.
```bash
# A normal request; the cap is hit only when the agent's reply exceeds
# AgentMaxResponseBytes, in which case the response carries error -32603.
curl -X POST http://localhost:9090/a2a/oversize-agent-01 \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": { "message": { "parts": [{ "text": "return a big payload" }] } }
}'
```
```csharp
// .kb/integration-a2a/examples/csharp/limits/response-size/Client.cs
using System.Net.Http.Json;
var kubemqUrl = Environment.GetEnvironmentVariable("KUBEMQ_URL") ?? "http://localhost:9090";
var agentId = "oversize-agent-01";
using var http = new HttpClient();
var payload = new
{
jsonrpc = "2.0",
id = 1,
method = "message/send",
@params = new { message = new { parts = new[] { new { text = "return a big payload" } } } }
};
var resp = await http.PostAsJsonAsync($"{kubemqUrl}/a2a/{agentId}", payload);
var body = await resp.Content.ReadFromJsonAsync();
if (body?.Error is not null)
Console.WriteLine($"Error code: {body.Error.Code} Message: {body.Error.Message}");
else
Console.WriteLine("Response within the size limit.");
```
```go
// .kb/integration-a2a/examples/go/limits/response-size/client.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
kubemqURL := os.Getenv("KUBEMQ_URL")
if kubemqURL == "" {
kubemqURL = "http://localhost:9090"
}
agentID := "oversize-agent-01"
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "message/send",
"params": map[string]any{"message": map[string]any{
"parts": []map[string]string{{"text": "return a big payload"}}}},
})
resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(body))
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out struct {
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Error != nil {
fmt.Printf("Error code: %d Message: %s\n", out.Error.Code, out.Error.Message)
} else {
fmt.Println("Response within the size limit.")
}
}
```
```java
// .kb/integration-a2a/examples/java/limits/response-size/Client.java
import java.net.URI;
import java.net.http.*;
public class Client {
public static void main(String[] args) throws Exception {
String kubemqUrl = System.getenv().getOrDefault("KUBEMQ_URL", "http://localhost:9090");
String agentId = "oversize-agent-01";
String payload = """
{"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"parts":[{"text":"return a big payload"}]}}}""";
HttpClient http = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(kubemqUrl + "/a2a/" + agentId))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
String body = http.send(req, HttpResponse.BodyHandlers.ofString()).body();
if (body.contains("-32603"))
System.out.println("Error code: -32603 (response too large)");
else
System.out.println("Response within the size limit.");
}
}
```
```python
# .kb/integration-a2a/examples/python/limits/response-size/client.py
import os
import httpx
KUBEMQ_URL = os.getenv("KUBEMQ_URL", "http://localhost:9090")
AGENT_ID = "oversize-agent-01"
def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {"message": {"parts": [{"text": "return a big payload"}]}},
}
resp = httpx.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload, timeout=30)
error = resp.json().get("error")
if error:
print(f"Error code: {error['code']} Message: {error['message']}")
else:
print("Response within the size limit.")
if __name__ == "__main__":
main()
```
```typescript
// .kb/integration-a2a/examples/typescript/limits/response-size/client.ts
const kubemqUrl = process.env.KUBEMQ_URL ?? "http://localhost:9090";
const agentId = "oversize-agent-01";
const resp = await fetch(`${kubemqUrl}/a2a/${agentId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: { message: { parts: [{ text: "return a big payload" }] } },
}),
});
const body = (await resp.json()) as {
error?: { code: number; message: string };
};
if (body.error) {
console.log(`Error code: ${body.error.code} Message: ${body.error.message}`);
} else {
console.log("Response within the size limit.");
}
```
Timeouts compound. The gateway adds `GatewayTimeoutBuffer` (10s) on top of the effective request timeout before timing out the proxied call to the agent, so the agent always has a slightly longer window than the caller-facing timeout. See [Concurrency & limits](/aiway/a2a/guides/concurrency).
## Related [#related]
# Error Handling (/aiway/a2a/error-handling)
Every A2A response is a **JSON-RPC 2.0** envelope, so failures come back as a structured
`error` object with a numeric `code` and a `message` — never as a raw HTTP error. This
page is the field guide to those codes: which ones the gateway raises, which come from
the agent, and how to decide whether to retry.
## Overview [#overview]
The A2A gateway returns errors at HTTP `200` with a JSON-RPC `error` body for anything it
can parse as a request. A failed call looks like this:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32002,
"message": "agent not found: nonexistent-agent"
}
}
```
There are two families of codes:
* **JSON-RPC base codes** (`-32700`, `-32600`, `-32601`, `-32602`, `-32603`, `-32010`)
— shared with the [MCP connector](/aiway/mcp) and raised by the gateway for
malformed requests, bad methods, or auth failures.
* **A2A-specific codes** (`-32001` to `-32004`) — raised when the gateway routes to a
registered agent but the call to that agent fails (timeout, missing agent, rejection,
or an unparseable reply).
## JSON-RPC base codes [#json-rpc-base-codes]
These are the standard JSON-RPC 2.0 codes returned by the shared HTTP layer for requests
that never reach an agent — the body or method is wrong, or authentication failed.
| Code | Constant | Meaning | Typical cause |
| -------- | ----------------------- | ---------------------- | -------------------------------------------------------------------------- |
| `-32700` | `JSONRPCParseError` | Parse error | Invalid JSON body, or `Content-Type` is not `application/json` |
| `-32600` | `JSONRPCInvalidRequest` | Invalid request | Missing `method`, wrong `jsonrpc` version, or an invalid `agent_id` format |
| `-32601` | `JSONRPCMethodNotFound` | Method not found | Unknown `method` for the endpoint |
| `-32602` | `JSONRPCInvalidParams` | Invalid params | The `params` object failed validation |
| `-32603` | `JSONRPCInternalError` | Internal error | Unexpected gateway-side failure |
| `-32010` | `jsonrpcAuthError` | Authentication failure | Missing or invalid JWT on a protected route |
The `agent_id` in the URL path is validated before routing. An invalid format (for
example uppercase characters) is rejected with `-32600` — the request never reaches an
agent. See [Authentication](/aiway/a2a/guides/authentication) for the
`-32010` auth flow.
## A2A-specific codes [#a2a-specific-codes]
These codes are raised by the gateway when it has a registered agent to route to but the
outbound HTTP call to that agent fails.
| Code | Constant | Meaning |
| -------- | ------------------------- | -------------------------------------------------------------- |
| `-32001` | `jsonrpcAgentTimeout` | The agent did not respond within the request timeout |
| `-32002` | `jsonrpcAgentNotFound` | No agent is registered under that `agent_id` |
| `-32003` | `jsonrpcAgentUnavailable` | The agent rejected the request (unreachable or returned a 5xx) |
| `-32004` | `jsonrpcInvalidResponse` | The agent replied, but the body was not valid JSON-RPC |
The request timeout that drives `-32001` defaults to `DefaultTimeoutSeconds` (300s) and is
capped by `MaxTimeoutSeconds` (3600s); per-call, set `params.configuration.timeout`
(seconds) to lower it. The same code is also emitted as a `task.error` envelope when an
SSE stream hits its idle timeout (`MaxSSEIdleSeconds`, default 300s) — see
[SSE behavior](/aiway/a2a/guides/sse-behavior).
## Transport vs application errors [#transport-vs-application-errors]
Underneath the JSON-RPC codes, the gateway's virtual subscriber draws a sharp line
between a call the agent **never processed** and one it **processed but rejected**. This
distinction is what lets you decide whether retrying is safe.
The agent's reply carries an `Executed` flag:
* **`Executed: false`** — a **transport error**. The agent never handled the request, so
retrying (against another instance, or after a backoff) is safe. Mapped to `-32001`
(timeout), `-32002` (not found), or `-32003` (unavailable).
* **`Executed: true`** — the agent **did** process the request. A `2xx` is a success; a
`4xx`/`5xx` body is an **application error** the agent chose to return. Retrying the
same call will produce the same result, so handle it as a business outcome rather than
blindly retrying.
| Condition | Gateway result | Classification |
| ------------------------------------------------------ | ------------------------------------------------------------ | -------------- |
| Connection refused / DNS failure | `Executed: false` · "agent unreachable" | Transport |
| HTTP timeout (deadline exceeded) | `Executed: false` · "agent timeout" | Transport |
| HTTP 502 / 503 / 504 | `Executed: false` · "agent unavailable: 50x" | Transport |
| Response body exceeds `AgentMaxResponseBytes` (10 MB) | `Executed: false` · "agent response too large" | Transport |
| Concurrency limit reached (`AgentMaxConcurrency`, 100) | `Executed: false` · "server busy: concurrency limit reached" | Transport |
| HTTP 200–299 | `Executed: true` · response body | Success |
| HTTP 400 / 401 / 403 / 404 / 409 / 422 / 500 | `Executed: true` · response body | Application |
## Retry strategy [#retry-strategy]
A safe default: **retry transport errors, surface application errors.**
* **`-32002` (agent not found)** — the target is not in the registry. Do not retry the
same `agent_id`; re-resolve the agent (it may have expired its TTL) or
[register it](/aiway/a2a/registry) first.
* **`-32001` (timeout)** — retry with backoff. If it persists, the agent is overloaded or
the per-call timeout is too low for the workload; raise
`params.configuration.timeout`.
* **`-32003` (unavailable)** and `Executed: false` — the agent never ran the request;
retry with exponential backoff, optionally against a healthy instance.
* **`-32004` (invalid response)** — the agent is misbehaving (non-JSON-RPC body). Retrying
rarely helps; treat it as a bug in the agent.
* **JSON-RPC base codes** (`-32600`, `-32602`, `-32700`) — the request itself is wrong.
Fix the payload; retrying unchanged will fail identically.
* **Application errors** (`Executed: true` with a `4xx`/`5xx` body) — these are the
agent's deliberate response. Handle them as domain outcomes, not infrastructure faults.
## Examples [#examples]
The snippets below trigger each error family against a running gateway: a missing agent
(`-32002`), malformed JSON-RPC requests (base codes), and a timeout (`-32001`).
### Agent not found (-32002) [#agent-not-found--32002]
Send a `message/send` to an `agent_id` that is not registered. The gateway returns a
JSON-RPC error with code `-32002`.
```bash
curl -X POST http://localhost:9090/a2a/nonexistent-agent \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {"message": {"parts": [{"text": "Hello?"}]}}
}'
# => {"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"agent not found: nonexistent-agent"}}
```
```csharp
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "nonexistent-agent";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello?" })
}
}
};
using var client = new HttpClient();
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
var error = data["error"];
Console.WriteLine($"\nError code: {error?["code"]}");
Console.WriteLine($"Error message: {error?["message"]}");
var code = error?["code"]?.GetValue();
if (code == -32002)
Console.WriteLine("\nAgent-not-found error (-32002) received as expected!");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "nonexistent-agent"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Hello?"}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var pretty bytes.Buffer
json.Indent(&pretty, body, "", " ")
fmt.Println(pretty.String())
var result map[string]interface{}
json.Unmarshal(body, &result)
errorObj, _ := result["error"].(map[string]interface{})
code, _ := errorObj["code"].(float64)
msg, _ := errorObj["message"].(string)
fmt.Printf("\nError code: %.0f\n", code)
fmt.Printf("Error message: %s\n", msg)
if int(code) != -32002 {
fmt.Fprintf(os.Stderr, "Expected -32002, got %.0f\n", code)
os.Exit(1)
}
fmt.Println("\nAgent-not-found error (-32002) received as expected!")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "nonexistent-agent";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Hello?")))
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));
var error = data.path("error");
System.out.println("\nError code: " + error.path("code").asInt());
System.out.println("Error message: " + error.path("message").asText());
assert error.path("code").asInt() == -32002 : "Expected -32002, got " + error.path("code").asInt();
System.out.println("\nAgent-not-found error (-32002) received as expected!");
}
}
```
```python
import asyncio
import json
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "nonexistent-agent"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Hello?"}]},
},
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
data = resp.json()
print(json.dumps(data, indent=2))
error = data.get("error", {})
print(f"\nError code: {error.get('code')}")
print(f"Error message: {error.get('message')}")
assert error.get("code") == -32002, f"Expected -32002, got {error.get('code')}"
print("\nAgent-not-found error (-32002) received as expected!")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: { parts: [{ text: "Hello?" }] },
},
};
console.log("Sending to nonexistent agent...");
const resp = await fetch(`${KUBEMQ_URL}/a2a/nonexistent-agent`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
console.log("Response:", JSON.stringify(data, null, 2));
if (data.error) {
console.log(`\nError code: ${data.error.code} (expect -32002)`);
console.log(`Error message: ${data.error.message}`);
console.log(`Match: ${data.error.code === -32002}`);
}
}
main().catch(console.error);
```
### Invalid request (JSON-RPC base codes) [#invalid-request-json-rpc-base-codes]
Malformed payloads are rejected by the gateway with base codes — invalid JSON or a wrong
`Content-Type` yields `-32700`, while a missing `method` or wrong `jsonrpc` version yields
`-32600`.
```bash
# Invalid JSON body -> -32700
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Content-Type: application/json" \
-d '{invalid json!!!}'
# Missing method field -> -32600
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "params": {}}'
# Bad jsonrpc version -> -32600
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "1.0", "id": 1, "method": "message/send", "params": {}}'
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
using var client = new HttpClient();
Console.WriteLine("=== Test 1: Invalid JSON ===");
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent("{invalid json!!!}", Encoding.UTF8, "application/json"));
var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
var error = data["error"];
Console.WriteLine($" Code: {error?["code"]} (expected -32700)");
Console.WriteLine($" Message: {error?["message"]}");
Console.WriteLine("\n=== Test 2: Missing method field ===");
var payload2 = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["params"] = new JsonObject()
};
resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload2.ToJsonString(), Encoding.UTF8, "application/json"));
data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
error = data["error"];
Console.WriteLine($" Code: {error?["code"]} (expected -32600)");
Console.WriteLine($" Message: {error?["message"]}");
Console.WriteLine("\n=== Test 3: Bad jsonrpc version ===");
var payload3 = new JsonObject
{
["jsonrpc"] = "1.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject()
};
resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload3.ToJsonString(), Encoding.UTF8, "application/json"));
data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
error = data["error"];
Console.WriteLine($" Code: {error?["code"]} (expected -32600)");
Console.WriteLine($" Message: {error?["message"]}");
Console.WriteLine("\nAll invalid request errors demonstrated!");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
)
func main() {
fmt.Println("=== Test 1: Invalid JSON ===")
req, _ := http.NewRequest("POST", kubemqURL+"/a2a/"+agentID, strings.NewReader("{invalid json!!!}"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
json.Unmarshal(body, &data)
errorObj, _ := data["error"].(map[string]interface{})
code, _ := errorObj["code"].(float64)
fmt.Printf(" Code: %.0f (expected -32700)\n", code)
fmt.Printf(" Message: %v\n", errorObj["message"])
fmt.Println("\n=== Test 2: Missing method field ===")
payload2, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"params": map[string]interface{}{},
})
resp, _ = http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(payload2))
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
json.Unmarshal(body, &data)
errorObj, _ = data["error"].(map[string]interface{})
code, _ = errorObj["code"].(float64)
fmt.Printf(" Code: %.0f (expected -32600)\n", code)
fmt.Printf(" Message: %v\n", errorObj["message"])
fmt.Println("\n=== Test 3: Bad jsonrpc version ===")
payload3, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "1.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{},
})
resp, _ = http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(payload3))
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
json.Unmarshal(body, &data)
errorObj, _ = data["error"].(map[string]interface{})
code, _ = errorObj["code"].(float64)
fmt.Printf(" Code: %.0f (expected -32600)\n", code)
fmt.Printf(" Message: %v\n", errorObj["message"])
fmt.Println("\nAll invalid request errors demonstrated!")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
System.out.println("=== Test 1: Invalid JSON ===");
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{invalid json!!!}"))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
var error = data.path("error");
System.out.println(" Code: " + error.path("code").asInt() + " (expected -32700)");
System.out.println(" Message: " + error.path("message").asText());
System.out.println("\n=== Test 2: Missing method field ===");
req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
MAPPER.writeValueAsString(Map.of("jsonrpc", "2.0", "id", 1, "params", Map.of()))))
.build();
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
data = MAPPER.readTree(resp.body());
error = data.path("error");
System.out.println(" Code: " + error.path("code").asInt() + " (expected -32600)");
System.out.println(" Message: " + error.path("message").asText());
System.out.println("\n=== Test 3: Bad jsonrpc version ===");
req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
MAPPER.writeValueAsString(Map.of(
"jsonrpc", "1.0", "id", 1, "method", "message/send", "params", Map.of()))))
.build();
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
data = MAPPER.readTree(resp.body());
error = data.path("error");
System.out.println(" Code: " + error.path("code").asInt() + " (expected -32600)");
System.out.println(" Message: " + error.path("message").asText());
System.out.println("\nAll invalid request errors demonstrated!");
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
async def main() -> None:
async with httpx.AsyncClient() as client:
print("=== Test 1: Invalid JSON ===")
resp = await client.post(
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
content=b"{invalid json!!!}",
headers={"Content-Type": "application/json"},
)
data = resp.json()
error = data.get("error", {})
print(f" Code: {error.get('code')} (expected -32700)")
print(f" Message: {error.get('message')}")
print("\n=== Test 2: Missing method field ===")
resp = await client.post(
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json={"jsonrpc": "2.0", "id": 1, "params": {}},
)
data = resp.json()
error = data.get("error", {})
print(f" Code: {error.get('code')} (expected -32600)")
print(f" Message: {error.get('message')}")
print("\n=== Test 3: Bad jsonrpc version ===")
resp = await client.post(
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json={"jsonrpc": "1.0", "id": 1, "method": "message/send", "params": {}},
)
data = resp.json()
error = data.get("error", {})
print(f" Code: {error.get('code')} (expected -32600)")
print(f" Message: {error.get('message')}")
print("\nAll invalid request errors demonstrated!")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
async function sendRaw(label: string, url: string, opts: RequestInit) {
console.log(`=== ${label} ===`);
try {
const resp = await fetch(url, opts);
const text = await resp.text();
let data: unknown;
try {
data = JSON.parse(text);
} catch {
data = text;
}
console.log(`Status: ${resp.status}`);
console.log(`Response: ${JSON.stringify(data, null, 2)}\n`);
} catch (err) {
console.log(`Error: ${err}\n`);
}
}
async function main() {
await sendRaw(
"Invalid JSON body (expect -32700)",
`${KUBEMQ_URL}/a2a/${AGENT_ID}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{invalid json!!!}",
},
);
await sendRaw(
"Missing method field (expect -32600)",
`${KUBEMQ_URL}/a2a/${AGENT_ID}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, params: {} }),
},
);
await sendRaw(
"Wrong JSON-RPC version (expect -32600)",
`${KUBEMQ_URL}/a2a/${AGENT_ID}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "1.0", id: 1, method: "message/send", params: {} }),
},
);
await sendRaw(
"Wrong Content-Type (expect -32700)",
`${KUBEMQ_URL}/a2a/${AGENT_ID}`,
{
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
},
);
await sendRaw(
"Invalid agent_id format (expect -32600)",
`${KUBEMQ_URL}/a2a/UPPERCASE-BAD`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
},
);
}
main().catch(console.error);
```
### Timeout (-32001) [#timeout--32001]
Set a short per-call timeout via `params.configuration.timeout` (seconds) and target an
agent that is slower than that. The gateway returns `-32001` once the deadline passes.
```bash
curl -X POST http://localhost:9090/a2a/slow-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "This will timeout"}]},
"configuration": {"timeout": 1}
}
}'
# => {"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"agent timeout: slow-agent-01"}}
```
```csharp
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "slow-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "This will timeout" })
},
["configuration"] = new JsonObject { ["timeout"] = 1 }
}
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Console.WriteLine("Sending request with timeout=1 to slow agent (5s delay)...");
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
var error = data["error"];
Console.WriteLine($"\nError code: {error?["code"]}");
Console.WriteLine($"Error message: {error?["message"]}");
var code = error?["code"]?.GetValue();
if (code == -32001)
Console.WriteLine("\nTimeout error (-32001) received as expected!");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "slow-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{"parts": []map[string]interface{}{{"text": "This will timeout"}}},
"configuration": map[string]interface{}{"timeout": 1},
},
}
data, _ := json.Marshal(payload)
fmt.Println("Sending request with timeout=1 to slow agent (5s delay)...")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var pretty bytes.Buffer
json.Indent(&pretty, body, "", " ")
fmt.Println(pretty.String())
var result map[string]interface{}
json.Unmarshal(body, &result)
errorObj, _ := result["error"].(map[string]interface{})
code, _ := errorObj["code"].(float64)
msg, _ := errorObj["message"].(string)
fmt.Printf("\nError code: %.0f\n", code)
fmt.Printf("Error message: %s\n", msg)
if int(code) != -32001 {
fmt.Fprintf(os.Stderr, "Expected -32001, got %.0f\n", code)
os.Exit(1)
}
fmt.Println("\nTimeout error (-32001) received as expected!")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "slow-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "This will timeout"))),
"configuration", Map.of("timeout", 1)
)
);
var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
System.out.println("Sending request with timeout=1 to slow agent (5s delay)...");
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));
var error = data.path("error");
System.out.println("\nError code: " + error.path("code").asInt());
System.out.println("Error message: " + error.path("message").asText());
assert error.path("code").asInt() == -32001 : "Expected -32001, got " + error.path("code").asInt();
System.out.println("\nTimeout error (-32001) received as expected!");
}
}
```
```python
import asyncio
import json
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "slow-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "This will timeout"}]},
"configuration": {"timeout": 1},
},
}
async with httpx.AsyncClient(timeout=30) as client:
print("Sending request with timeout=1 to slow agent (5s delay)...")
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
data = resp.json()
print(json.dumps(data, indent=2))
error = data.get("error", {})
print(f"\nError code: {error.get('code')}")
print(f"Error message: {error.get('message')}")
assert error.get("code") == -32001, f"Expected -32001, got {error.get('code')}"
print("\nTimeout error (-32001) received as expected!")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "slow-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: { parts: [{ text: "This will timeout" }] },
configuration: { timeout: 1 },
},
};
console.log("Sending with timeout=1s to a 5s-delay agent...");
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
console.log("Response:", JSON.stringify(data, null, 2));
if (data.error) {
console.log(`\nError code: ${data.error.code} (expect -32001)`);
console.log(`Error message: ${data.error.message}`);
console.log(`Match: ${data.error.code === -32001}`);
} else {
console.log("\nUnexpected: got success response instead of timeout");
}
}
main().catch(console.error);
```
## Related [#related]
# Getting Started (/aiway/a2a/getting-started)
The A2A connector turns any HTTP server into a callable agent. You register an agent's
URL with the gateway, then route JSON-RPC `message/send` requests to it through
`POST /a2a/` — no KubeMQ SDK on the agent. This walkthrough takes you from a
running server to a verified round-trip.
## Prerequisites [#prerequisites]
* A running **kubemq-server** with the shared HTTP server reachable on **port 9090**.
* An HTTP endpoint to register as your agent. For this guide, run one of the example
echo agents from `.kb/integration-a2a/examples` (each agent registers itself on
startup and echoes the request body back).
* `curl` (or one of the language clients below) to send the message.
Confirm the gateway is live by fetching the platform agent card:
```bash
curl http://localhost:9090/.well-known/agent-card.json
```
## Enable / disable [#enable--disable]
The A2A connector is **enabled by default**. Start kubemq-server and the `/a2a/*` and
`/agents/*` routes are live immediately — there is **no `=true` flag to set**.
To **disable** A2A, set its enable variable to `false`:
The disable variable name is irregular by design — the config key `Connectors.A2A.Enable`
snake-cases to `CONNECTORSA2_A_ENABLE`, not `KUBEMQ_A2A_ENABLE`. See
[Shared HTTP server](/connectors/concepts/shared-http-server) for why, and never rely on the
older off-by-default behavior.
## How it works [#how-it-works]
A `message/send` request travels from the caller to the gateway, across the broker to the
agent's virtual subscriber, out to the agent over HTTP, and back the same way — the
gateway relays the agent's JSON-RPC response to the caller unchanged.
*The gateway proxies the request to the agent over the broker and relays the reply unchanged.*
## Steps [#steps]
### Register an agent [#register-an-agent]
An agent registers itself by POSTing its **agent card** — including the absolute
`url` the gateway will call — to `POST /agents/register`. The example echo agents do
this on startup; the snippets below show the registration call.
```bash
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{"id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"]}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"]
}'
```
```csharp
var card = new JsonObject
{
["agent_id"] = "echo-agent-01",
["name"] = "Echo Agent",
["description"] = "A simple echo agent for testing",
["version"] = "1.0.0",
["url"] = "http://localhost:18080/",
["skills"] = new JsonArray(new JsonObject
{
["id"] = "echo", ["name"] = "Echo",
["description"] = "Echoes back the received message",
["tags"] = new JsonArray("test", "echo")
}),
["defaultInputModes"] = new JsonArray("text"),
["defaultOutputModes"] = new JsonArray("text"),
["protocolVersions"] = new JsonArray("1.0")
};
using var client = new HttpClient();
var resp = await client.PostAsync(
"http://localhost:9090/agents/register",
new StringContent(card.ToJsonString(), System.Text.Encoding.UTF8, "application/json"));
Console.WriteLine($"Registered: {(int)resp.StatusCode}");
```
```go
card := map[string]interface{}{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": []map[string]interface{}{
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": []string{"test", "echo"},
},
},
"defaultInputModes": []string{"text"},
"defaultOutputModes": []string{"text"},
"protocolVersions": []string{"1.0"},
}
data, _ := json.Marshal(card)
resp, err := http.Post("http://localhost:9090/agents/register", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Registration failed: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("Registered: %d\n", resp.StatusCode)
```
```java
var card = Map.of(
"agent_id", "echo-agent-01",
"name", "Echo Agent",
"description", "A simple echo agent for testing",
"version", "1.0.0",
"url", "http://localhost:18080/",
"skills", List.of(Map.of(
"id", "echo", "name", "Echo",
"description", "Echoes back the received message",
"tags", List.of("test", "echo")
)),
"defaultInputModes", List.of("text"),
"defaultOutputModes", List.of("text"),
"protocolVersions", List.of("1.0")
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:9090/agents/register"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Registered: " + resp.statusCode());
```
```python
card = {
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": ["test", "echo"],
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"],
}
async with httpx.AsyncClient() as client:
resp = await client.post("http://localhost:9090/agents/register", json=card)
print(f"Registered: {resp.status_code}")
```
```typescript
const card = {
agent_id: "echo-agent-01",
name: "Echo Agent",
description: "A simple echo agent for testing",
version: "1.0.0",
url: "http://localhost:18080/",
skills: [
{
id: "echo",
name: "Echo",
description: "Echoes back the received message",
tags: ["test", "echo"],
},
],
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
protocolVersions: ["1.0"],
};
const resp = await fetch("http://localhost:9090/agents/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(card),
});
console.log("Registered:", resp.status);
```
A `200` response means the agent is registered. The gateway returns the stored card
with server-managed `registered_at` and `last_seen` fields populated.
### Send a message [#send-a-message]
Route a JSON-RPC 2.0 `message/send` request to the agent through
`POST /a2a/`. The gateway forwards it to the agent and relays the reply back.
```bash
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}]
}
}
}'
```
```csharp
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello, agent!" })
}
}
};
using var client = new HttpClient();
var resp = await client.PostAsync(
"http://localhost:9090/a2a/echo-agent-01",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"Status: {(int)resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
```
```go
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Hello, agent!"}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post("http://localhost:9090/a2a/echo-agent-01", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Println(string(body))
```
```java
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of(
"parts", List.of(Map.of("text", "Hello, agent!"))
)
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:9090/a2a/echo-agent-01"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + resp.statusCode());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));
```
```python
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}],
},
},
}
async with httpx.AsyncClient() as client:
resp = await client.post("http://localhost:9090/a2a/echo-agent-01", json=payload)
print(f"Status: {resp.status_code}")
data = resp.json()
print(json.dumps(data, indent=2))
assert "result" in data
```
```typescript
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: {
parts: [{ text: "Hello, agent!" }],
},
},
};
const resp = await fetch("http://localhost:9090/a2a/echo-agent-01", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
console.log("Response:", JSON.stringify(data, null, 2));
```
### Verify the reply [#verify-the-reply]
The echo agent returns the full request body inside `result.echo`, confirming the
round-trip through the gateway:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"echo": {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": { "parts": [{ "text": "Hello, agent!" }] }
}
}
}
}
```
A response containing `result` is a successful round-trip. A response with an `error`
object instead means the gateway or agent reported a problem — see
[Error handling](/aiway/a2a/error-handling) for the codes and how to
distinguish transport failures from application errors.
## What's next [#whats-next]
# AI Agents (A2A) (/aiway/a2a)
The **A2A connector** turns KubeMQ into a gateway for AI agents. It implements a
subset of Google's Agent-to-Agent protocol as a transparent JSON-RPC 2.0 proxy: a
caller POSTs to `/a2a/{agent_id}`, and KubeMQ routes the request to the right agent
and relays the reply back — agents stay plain HTTP servers with **zero KubeMQ
dependencies**.
**Part of Aiway.** A2A is one of the two doors into
[KubeMQ Aiway](/aiway), the AI Agents Fabric. New here? Start with the
[Aiway overview](/aiway), or follow the end-to-end
[Aiway tutorial](/aiway/tutorial).
## What is A2A [#what-is-a2a]
A2A lets one agent call another through a single, well-known endpoint instead of
wiring point-to-point connections between every pair of agents. KubeMQ sits in the
middle as the gateway and does the work that would otherwise be repeated in each
agent: looking up where a target agent lives, enforcing timeouts and concurrency
limits, forwarding the right headers, and proxying Server-Sent Event streams.
Two pieces make this work:
* An **agent registry** — a REST API where agents announce themselves by `agent_id`
and HTTP URL. The registry tracks each agent's card (name, skills, version) with
TTL-based liveness.
* A **virtual subscriber** (also called the **Agent Bridge**) — when an agent
registers, KubeMQ spawns an internal subscriber on the internal channel
`_AGENTS_.agents/`. Incoming JSON-RPC requests arrive over the broker, and
the virtual subscriber forwards each one as an HTTP `POST` to the agent's registered
URL, then relays the response back. The MCP *agent-bridge tools*
(`agent_list`/`agent_info`/`agent_send`/`agent_query`) invoke agents *through* this
Agent Bridge — same concept, two layers, not two meanings.
Because the virtual subscriber handles all broker and protobuf translation, **an agent
is just an HTTP server that speaks JSON-RPC 2.0** — there is no KubeMQ SDK, no
protobuf, and no broker knowledge on the agent side.
This is a breaking change from older KubeMQ A2A docs, which described agents that
embedded a KubeMQ SDK. Agents are now registered by absolute `http(s)://` URL and
require no library. See [Building agents](/aiway/a2a/guides/building-agents)
for the current model.
## Why A2A on KubeMQ [#why-a2a-on-kubemq]
* **No SDK on agents** — register a URL; KubeMQ bridges the broker to your agent's HTTP
endpoint for you.
* **One gateway, many agents** — callers always POST to `/a2a/{agent_id}`; routing,
discovery, and lifecycle are centralized.
* **Sync and streaming** — `message/send` for request/reply, `message/stream` for
long-running tasks proxied as SSE.
* **Method-agnostic proxy** — standard A2A methods and any custom JSON-RPC method are
forwarded as-is; the agent decides what to handle.
* **Built-in guardrails** — per-agent concurrency caps, timeout enforcement with a
gateway buffer, response-size limits, and selective header forwarding.
## Architecture [#architecture]
A caller never connects to an agent directly. The request flows through the A2A
gateway, over the broker to the target agent's virtual subscriber, and out as an HTTP POST.
*The gateway proxies JSON-RPC over the broker; the virtual subscriber calls the agent's HTTP URL.*
## Endpoint surface [#endpoint-surface]
| Method | Path | Purpose |
| ------ | --------------------------------------------- | ---------------------------------------------------------------------------------- |
| `POST` | `/a2a/{agent_id}` | JSON-RPC 2.0 proxy to the agent (`message/send`, `message/stream`, custom methods) |
| `GET` | `/a2a/{agent_id}/stream` | SSE streaming endpoint |
| `GET` | `/a2a/{agent_id}/.well-known/agent-card.json` | Individual agent card |
| `GET` | `/.well-known/agent-card.json` | Platform-level card |
| `POST` | `/agents/register` | Register an agent |
| `POST` | `/agents/heartbeat` | Agent heartbeat (refresh liveness) |
| `POST` | `/agents/deregister` | Deregister an agent |
| `GET` | `/agents` | List registered agents (optional skill-tag filter) |
| `GET` | `/agents/{agent_id}` | Get one agent's card |
The A2A connector runs on the [shared HTTP server](/connectors/concepts/shared-http-server)
(port 9090) and is **enabled by default** — there is no flag to turn it on. To disable
it, set `CONNECTORSA2_A_ENABLE=false`.
## Send a message [#send-a-message]
A request is a JSON-RPC 2.0 envelope POSTed to `/a2a/{agent_id}`. The example below
sends `message/send` to a registered `echo-agent-01`.
```bash
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}]
}
}
}'
```
```csharp
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello, agent!" })
}
}
};
using var client = new HttpClient();
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"Status: {(int)resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
if (data["result"] != null)
Console.WriteLine("\nBasic send completed successfully!");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Hello, agent!"}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
var pretty bytes.Buffer
json.Indent(&pretty, body, "", " ")
fmt.Println(pretty.String())
var result map[string]interface{}
json.Unmarshal(body, &result)
if _, ok := result["result"]; !ok {
fmt.Fprintf(os.Stderr, "Missing 'result' in response\n")
os.Exit(1)
}
fmt.Println("\nBasic send completed successfully!")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of(
"parts", List.of(Map.of("text", "Hello, agent!"))
)
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + resp.statusCode());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));
assert data.has("result");
System.out.println("\nBasic send completed successfully!");
}
}
```
```python
import asyncio
import json
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}],
},
},
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json=payload,
)
print(f"Status: {resp.status_code}")
data = resp.json()
print(json.dumps(data, indent=2))
assert "result" in data
print("\nBasic send completed successfully!")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: {
parts: [{ text: "Hello, agent!" }],
},
},
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
console.log("Response:", JSON.stringify(data, null, 2));
if (data.result) {
console.log("\nBasic send completed successfully!");
} else if (data.error) {
console.error("\nError:", data.error.message);
}
}
main().catch(console.error);
```
## Supported languages [#supported-languages]
Every A2A operation ships with a curl example plus client code in five languages,
sourced from real working examples.
| Language | Client |
| ----------- | ------------------------------------ |
| curl / HTTP | Raw JSON-RPC over HTTP |
| C# | `HttpClient` + `System.Text.Json` |
| Go | `net/http` + `encoding/json` |
| Java | `java.net.http.HttpClient` + Jackson |
| Python | `httpx` (async) |
| TypeScript | `fetch` |
## Next steps [#next-steps]
# Reference (/aiway/a2a/reference)
The complete API surface of the KubeMQ A2A connector: HTTP endpoints, the JSON-RPC 2.0 wire format, the agent card schema, internal channels, configuration fields, error codes, and Prometheus metrics. The connector is a transparent JSON-RPC proxy — agents are plain HTTP servers registered by URL, reached through a per-agent virtual subscriber over the broker.
All HTTP endpoints are served on the [shared HTTP server](/connectors/concepts/shared-http-server) (default port `9090`), which also hosts the REST, MCP, and CloudEvents connectors. Prometheus metrics are exposed separately on port `8080` (see [Observability](/connectors/concepts/observability)).
## HTTP Endpoints [#http-endpoints]
### A2A endpoints [#a2a-endpoints]
| Method | Path | Description | Request body | Success | Error |
| ------ | ------------------------ | ------------------------------------- | -------------------- | ----------------------------- | ---------------------- |
| POST | `/a2a/{agent_id}` | JSON-RPC 2.0 request (sync or stream) | JSON-RPC 2.0 payload | JSON-RPC result or SSE stream | JSON-RPC error |
| GET | `/a2a/{agent_id}` | Not supported | — | — | 405 Method Not Allowed |
| GET | `/a2a/{agent_id}/stream` | SSE stream via GET | — | `text/event-stream` | — |
`POST /a2a/{agent_id}` is the primary endpoint. Its behavior depends on the JSON-RPC `method`: `message/stream` opens an SSE proxy; every other method is forwarded synchronously to the agent. `GET /a2a/{agent_id}` always returns **405** — use POST for JSON-RPC or `GET /a2a/{agent_id}/stream` for a server-pushed stream.
```bash
# Synchronous message/send
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"parts":[{"text":"Hello, agent!"}]}}}'
# SSE stream via GET
curl -N http://localhost:9090/a2a/echo-agent-01/stream \
-H 'Accept: text/event-stream'
```
### Registry endpoints [#registry-endpoints]
Standard HTTP JSON responses (not JSON-RPC). See [Agent registry](/aiway/a2a/registry) for the full workflow.
| Method | Path | Description | Request body | Success | Error |
| ------ | -------------------- | --------------------------------------------- | ------------------ | ------------------------------------- | --------------- |
| POST | `/agents/register` | Register an agent | Agent card JSON | 200 + enriched card | 400 / 403 / 409 |
| GET | `/agents` | List agents (filter by `skill_tags`, `limit`) | — | 200 + `[, …]` (bare array) | — |
| GET | `/agents/{agent_id}` | Get one agent | — | 200 + agent card | 404 |
| POST | `/agents/deregister` | Deregister (JSON body) | `{"agent_id":"…"}` | 200 + `{"ok":true}` | 404 |
| DELETE | `/agents/{agent_id}` | Deregister (REST, backward compat) | — | 200 + `{"ok":true}` | 404 |
| POST | `/agents/heartbeat` | Refresh liveness | `{"agent_id":"…"}` | 200 + `{"ok":true}` | 400 |
`POST /agents/register` errors: **400** (card validation), **403** (ownership conflict — a different principal owns this `agent_id`), **409** (`MaxAgents` limit reached). `registered_by` is set automatically from the caller's JWT claims.
```bash
# List agents tagged "search" or "nlp", capped at 10
curl 'http://localhost:9090/agents?skill_tags=search,nlp&limit=10'
```
### Agent card endpoints [#agent-card-endpoints]
| Method | Path | Description | Success | Error |
| ------ | --------------------------------------------- | ------------------------------------- | ---------------------- | ----- |
| GET | `/.well-known/agent-card.json` | Platform agent card (KubeMQ metadata) | 200 (`name: "kubemq"`) | — |
| GET | `/a2a/{agent_id}/.well-known/agent-card.json` | Individual agent card from registry | 200 + enriched card | 404 |
Both `.well-known/agent-card.json` paths are public — they bypass authentication. See [Agent cards](/aiway/a2a/agent-cards).
```bash
# Platform card — confirms the A2A connector is reachable
curl http://localhost:9090/.well-known/agent-card.json
```
## JSON-RPC 2.0 wire format [#json-rpc-20-wire-format]
The connector uses JSON-RPC 2.0 for all client-to-agent communication. A request carries three required fields plus an optional `params` object.
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": { "parts": [{ "text": "Hello, agent!" }] },
"contextId": "optional-correlation-id",
"configuration": { "timeout": 30 }
}
}
```
### Request fields [#request-fields]
| Field | Type | Required | Description |
| ------------------------------ | ----------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `jsonrpc` | string | Yes | Must be `"2.0"` |
| `id` | integer or string | Yes | Request identifier, echoed in the response |
| `method` | string | Yes | JSON-RPC method name (missing/empty → `-32600`) |
| `params` | object | No | Method parameters |
| `params.message.parts` | array | No | Message content; each part has a `text` field |
| `params.contextId` | string | No | Correlation ID, passed to the agent unmodified |
| `params.configuration.timeout` | number | No | Request timeout in seconds (falls back to `DefaultTimeoutSeconds`, capped at `MaxTimeoutSeconds`) |
### Methods [#methods]
KubeMQ is method-agnostic — it forwards any method to the agent unchanged. The five methods below are recognized for metrics labeling; all others are recorded as `method="unknown"`.
| Method | Behavior | Response |
| -------------------- | ------------------------------------------- | ------------------- |
| `message/send` | Synchronous proxy to the agent | JSON-RPC response |
| `message/stream` | SSE stream proxy via the virtual subscriber | `text/event-stream` |
| `tasks/get` | Forwarded to the agent | JSON-RPC response |
| `tasks/cancel` | Forwarded to the agent | JSON-RPC response |
| `tasks/send` | Forwarded to the agent | JSON-RPC response |
| *(any other method)* | Forwarded to the agent | JSON-RPC response |
JSON-RPC batch requests (an array of request objects) are **not supported** — send individual requests. Notifications (requests without an `id`) are forwarded to the agent but receive no response.
### Success and error responses [#success-and-error-responses]
On success, the agent's return value is placed verbatim in `result` — KubeMQ does not modify the payload. On failure, the response carries an `error` object instead.
```json
{ "jsonrpc": "2.0", "id": 1, "result": { "…": "agent output, unmodified" } }
```
```json
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32002, "message": "agent not found: nonexistent-agent" } }
```
## Agent card schema [#agent-card-schema]
The `AgentCard` is submitted on registration and returned, enriched with server-managed timestamps, from the list/get/well-known endpoints. Required fields are `agent_id`, `name`, and `url`.
| Field | JSON key | Type | Required | Description |
| --------------------- | --------------------- | --------- | -------- | -------------------------------------------------------------------------- |
| `AgentID` | `agent_id` | string | Yes | Unique ID; 2–128 chars, `^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$` |
| `Name` | `name` | string | Yes | Human-readable name (max 256 chars) |
| `Description` | `description` | string | No | Agent description (max 2048 chars) |
| `Version` | `version` | string | No | Agent version (max 64 chars) |
| `URL` | `url` | string | Yes | Absolute `http://` or `https://` URL (max 2048 chars) |
| `RegisteredBy` | `registered_by` | string | No | JWT principal that registered the agent (server-set) |
| `Capabilities` | `capabilities` | object | No | Free-form capability map |
| `Skills` | `skills` | array | No | List of `AgentSkill` (see below) |
| `DefaultInputModes` | `defaultInputModes` | string\[] | No | Default input modes |
| `DefaultOutputModes` | `defaultOutputModes` | string\[] | No | Default output modes |
| `SupportedInterfaces` | `supportedInterfaces` | JSON | No | Opaque JSON |
| `SecuritySchemes` | `securitySchemes` | JSON | No | Opaque JSON |
| `Security` | `security` | JSON | No | Opaque JSON |
| `ProtocolVersions` | `protocolVersions` | string\[] | No | Supported versions (default `["1.0"]`) |
| `Metadata` | `metadata` | object | No | Key-value metadata |
| `LastSeen` | `last_seen` | timestamp | — | Last heartbeat/registration (server-set) |
| `RegisteredAt` | `registered_at` | timestamp | — | Original registration time, preserved across re-registrations (server-set) |
### AgentSkill [#agentskill]
| Field | JSON key | Type | Required | Description |
| ------------- | ------------- | --------- | -------- | ------------------------------------------- |
| `ID` | `id` | string | Yes | Skill identifier |
| `Name` | `name` | string | Yes | Skill name |
| `Description` | `description` | string | No | Skill description |
| `Tags` | `tags` | string\[] | No | Skill tags, used by the `skill_tags` filter |
```json
{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"url": "http://localhost:18080/",
"skills": [{ "id": "echo", "name": "Echo", "tags": ["test", "echo"] }]
}
```
## Internal channel map [#internal-channel-map]
The connector and registry use the reserved `_AGENTS_.` prefix internally. User channels carrying this prefix are rejected (`IsReservedChannel`). Callers never address these channels directly — the connector and virtual subscriber own them.
| Channel pattern | Purpose | Transport |
| ----------------------------- | -------------------------------------------------------------------------------- | ------------ |
| `_AGENTS_.agents/{agent_id}` | Request/reply to an agent via its virtual subscriber (including `stream_cancel`) | Query |
| `_AGENTS_.stream/{stream_id}` | Temporary SSE stream events relayed by the virtual subscriber | Events |
| `_AGENTS_.discovery` | Registry replication across cluster nodes | Events Store |
Caller HTTP headers are forwarded to the agent as `a2a_hdr_*` tags on the broker request; the virtual subscriber always sets `X-KubeMQ-Caller-ID` to the original caller's identity. See [Architecture](/aiway/a2a/architecture).
## SSE event types [#sse-event-types]
Streaming responses (`message/stream` or `GET /a2a/{agent_id}/stream`) are delivered as Server-Sent Events. Each event has an `event:` line and a `data:` line carrying the stream envelope. A keepalive comment (`: keepalive`) is sent every 30 seconds; the idle timeout is `MaxSSEIdleSeconds` (default 300s). See [Streaming](/aiway/a2a/streaming).
| SSE event | Envelope `type` | Description | Terminal |
| --------------- | --------------- | ----------------------- | -------- |
| `task.status` | `status_update` | Progress update | No |
| `task.artifact` | `artifact` | Intermediate artifact | No |
| `task.done` | `done` | Successful completion | Yes |
| `task.error` | `error` | Failure | Yes |
| `message` | *(default)* | Any other envelope type | No |
```text
event: task.status
data: {"stream_id":"…","type":"status_update","payload":{"status":"working"}}
event: task.done
data: {"stream_id":"…","type":"done","payload":{"final_result":"completed"}}
```
## Error codes [#error-codes]
The connector returns standard JSON-RPC 2.0 base codes plus four A2A-specific codes. See [Error handling](/aiway/a2a/error-handling) for the transport-vs-application distinction and retry guidance.
| Code | Name | Trigger |
| -------- | ---------------------- | ------------------------------------------------------------------------- |
| `-32700` | Parse Error | Malformed JSON body, or `Content-Type` is not `application/json` |
| `-32600` | Invalid Request | Missing `method` field, `jsonrpc` != `"2.0"`, or empty/invalid `agent_id` |
| `-32601` | Method Not Found | Reserved — KubeMQ forwards all methods to agents and does not raise this |
| `-32602` | Invalid Params | Malformed `params` object |
| `-32603` | Internal Error | Server-side failure |
| `-32010` | Authentication Failure | JWT auth error on `/a2a/*` (REST endpoints return HTTP 401 instead) |
| `-32001` | Agent Timeout | Agent did not respond within the timeout |
| `-32002` | Agent Not Found | No agent registered with the given `agent_id` |
| `-32003` | Agent Unavailable | Agent rejected the request |
| `-32004` | Invalid Response | Invalid response from the agent |
### Transport vs application errors [#transport-vs-application-errors]
The virtual subscriber sets `Executed: false` on **transport** failures (the agent never processed the request) and `Executed: true` on **application** errors (the agent processed it and returned an error body). This lets callers choose a retry strategy.
| Agent result | `pb.Response` | Category |
| -------------------------------------------- | -------------------------------------------------------------------- | ----------- |
| Connection refused / DNS failure | `Executed: false`, `Error: "agent unreachable: …"` | Transport |
| HTTP timeout | `Executed: false`, `Error: "agent timeout"` | Transport |
| HTTP 502 / 503 / 504 | `Executed: false`, `Error: "agent unavailable: …"` | Transport |
| Response exceeds `AgentMaxResponseBytes` | `Executed: false`, `Error: "agent response too large"` | Transport |
| Concurrency limit reached | `Executed: false`, `Error: "server busy: concurrency limit reached"` | Transport |
| HTTP 200–299 | `Executed: true`, `Body: response` | Success |
| HTTP 400 / 401 / 403 / 404 / 409 / 422 / 500 | `Executed: true`, `Body: response` | Application |
## Configuration fields [#configuration-fields]
`A2aConfig` is configured in `Connectors.A2A.*`. The connector is **enabled by default** — set `CONNECTORSA2_A_ENABLE=false` to disable it. See [Configuration](/aiway/a2a/configuration) and the [shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default) enable model for the irregular env-var naming.
| Field | Default | Disable/override env var | Description |
| ----------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------- |
| `Enable` | `true` | `CONNECTORSA2_A_ENABLE` | Enable the A2A connector (set `=false` to disable) |
| `AgentTTLSeconds` | `300` | `CONNECTORSA2_A_AGENT_TTL_SECONDS` | Agent expiry TTL (liveness check) |
| `DefaultTimeoutSeconds` | `300` | `CONNECTORSA2_A_DEFAULT_TIMEOUT_SECONDS` | Default request timeout |
| `MaxTimeoutSeconds` | `3600` | `CONNECTORSA2_A_MAX_TIMEOUT_SECONDS` | Maximum allowed request timeout (must be ≥ `DefaultTimeoutSeconds`) |
| `MaxAgents` | `0` (unlimited) | `CONNECTORSA2_A_MAX_AGENTS` | Maximum registered agents |
| `MaxSSEIdleSeconds` | `300` | `CONNECTORSA2_A_MAX_SSE_IDLE_SECONDS` | SSE stream idle timeout |
| `TrustedOrigins` | `["auto"]` | `CONNECTORSA2_A_TRUSTED_ORIGINS` | Trusted origins for browser requests |
| `AgentMaxResponseBytes` | `10485760` (10 MB) | `CONNECTORSA2_A_AGENT_MAX_RESPONSE_BYTES` | Max agent HTTP response size; larger → `Executed: false` |
| `AgentTLSSkipVerify` | `false` | `CONNECTORSA2_A_AGENT_TLS_SKIP_VERIFY` | Skip TLS verification for outbound agent calls (dev only) |
| `AgentMaxConcurrency` | `100` | `CONNECTORSA2_A_AGENT_MAX_CONCURRENCY` | Max concurrent in-flight requests per agent; overflow → "server busy" |
The gateway adds a `GatewayTimeoutBuffer` of 10 seconds on top of the caller-specified timeout so it does not time out before the downstream agent.
## Metrics [#metrics]
Prometheus metrics are exposed on port `8080` at `/metrics`. See [Observability](/connectors/concepts/observability) for the full surface and the web AI dashboard.
| Metric | Type | Labels | Description |
| -------------------------------------- | --------- | ------------------------------ | ------------------------------------------------------------- |
| `kubemq_a2a_requests_total` | Counter | `agent_id`, `method`, `status` | A2A requests forwarded to agents |
| `kubemq_a2a_request_duration_seconds` | Histogram | `agent_id` | A2A request duration |
| `kubemq_a2a_errors_total` | Counter | `agent_id`, `error_code` | A2A gateway errors |
| `kubemq_a2a_registry_operations_total` | Counter | `op`, `status` | Registry operations (register, deregister, heartbeat, expire) |
| `kubemq_a2a_sse_streams_active` | Gauge | — | Currently active SSE streams |
The `method` label is sanitized against the five standard methods; any other value is recorded as `method="unknown"` to bound Prometheus label cardinality.
```bash
curl http://localhost:8080/metrics 2>/dev/null | grep kubemq_a2a_
```
# Agent Registry (/aiway/a2a/registry)
The **agent registry** is a REST API where agents announce themselves to KubeMQ by
`agent_id` and HTTP URL. It is the source of truth for who can be reached over the A2A
gateway: registering an agent spawns its [virtual subscriber](/aiway/a2a/architecture),
and a TTL-based liveness check removes agents that stop sending heartbeats.
## Overview [#overview]
Every agent that callers can reach through `POST /a2a/{agent_id}` must first be
registered. A registration is an **agent card** — `agent_id`, human-readable `name`,
absolute `url`, and an optional list of skills. The registry persists cards in SQLite,
tracks each agent's `last_seen` time, and replicates state across a cluster so any node
can route to any agent.
The registry exposes five operations as plain HTTP+JSON (not JSON-RPC):
| Operation | Method · Path | Purpose |
| ---------- | -------------------------------------------------------- | ---------------------------------------------- |
| Register | `POST /agents/register` | Add or re-register an agent card |
| List | `GET /agents` | List agents, optionally filtered by skill tags |
| Get one | `GET /agents/{agent_id}` | Fetch a single agent's full card |
| Heartbeat | `POST /agents/heartbeat` | Refresh `last_seen` to stay alive |
| Deregister | `POST /agents/deregister` or `DELETE /agents/{agent_id}` | Remove an agent |
## How it works [#how-it-works]
The registry is a service backed by SQLite. Registering spawns a virtual subscriber and
emits a replication event; a background liveness checker sweeps expired agents.
*Registration persists the card and spawns a virtual subscriber; the liveness checker prunes agents past their TTL.*
## The agent card [#the-agent-card]
An agent card describes one agent. `agent_id`, `name`, and `url` are required; the
`url` must be an absolute `http://` or `https://` address. Server-managed fields
(`registered_at`, `last_seen`) are populated on the response.
| Field | Type | Required | Description |
| -------------------- | --------- | ---------- | -------------------------------------------------------------------- |
| `agent_id` | string | yes | Unique identifier; 2–128 chars, lowercase alphanumeric and hyphens |
| `name` | string | yes | Human-readable name (max 256 chars) |
| `url` | string | yes | Absolute `http(s)://` endpoint the gateway POSTs to (max 2048 chars) |
| `description` | string | no | Free-text description (max 2048 chars) |
| `version` | string | no | Agent version (max 64 chars) |
| `skills` | array | no | List of `AgentSkill` objects (see below) |
| `defaultInputModes` | string\[] | no | Default input modes, e.g. `["text"]` |
| `defaultOutputModes` | string\[] | no | Default output modes, e.g. `["text"]` |
| `protocolVersions` | string\[] | no | Supported protocol versions; defaults to `["1.0"]` |
| `registered_at` | string | server-set | Original registration time (preserved across re-registration) |
| `last_seen` | string | server-set | Last heartbeat or registration time |
Each entry in `skills` is an **AgentSkill**: `id` (required), `name` (required),
`description`, and `tags` (used by the [list](#list-agents) filter and skill-based
discovery).
```json
{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": ["test", "echo"]
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"]
}
```
## Register an agent [#register-an-agent]
`POST /agents/register` with the agent card as the JSON body. Re-registering the same
`agent_id` upserts the card and **preserves the original `registered_at`**. The response
is the stored card with `registered_at` and `last_seen` populated.
When auth is enabled, the `registered_by` field is set from the JWT principal and is
used for [ownership](#ownership) checks. Registration fails with `400` (validation),
`403` (ownership conflict), or `409` (the `MaxAgents` limit was reached).
```bash
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{"id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"]}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"]
}'
```
```csharp
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const int AgentPort = 18080;
var card = new JsonObject
{
["agent_id"] = "echo-agent-01",
["name"] = "Echo Agent",
["description"] = "A simple echo agent for testing",
["version"] = "1.0.0",
["url"] = $"http://localhost:{AgentPort}/",
["skills"] = new JsonArray(new JsonObject
{
["id"] = "echo", ["name"] = "Echo",
["description"] = "Echoes back the received message",
["tags"] = new JsonArray("test", "echo")
}),
["defaultInputModes"] = new JsonArray("text"),
["defaultOutputModes"] = new JsonArray("text"),
["protocolVersions"] = new JsonArray("1.0")
};
using var client = new HttpClient();
var resp = await client.PostAsync(
$"{KubeMqUrl}/agents/register",
new StringContent(card.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"Registered: {(int)resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
Console.WriteLine(JsonSerializer.Serialize(JsonNode.Parse(body), new JsonSerializerOptions { WriteIndented = true }));
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
agentPort = 18080
)
func main() {
card := map[string]interface{}{
"agent_id": agentID,
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": fmt.Sprintf("http://localhost:%d/", agentPort),
"skills": []map[string]interface{}{
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": []string{"test", "echo"},
},
},
"defaultInputModes": []string{"text"},
"defaultOutputModes": []string{"text"},
"protocolVersions": []string{"1.0"},
}
data, _ := json.Marshal(card)
resp, err := http.Post(kubemqURL+"/agents/register", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Registration failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Registered: %d\n", resp.StatusCode)
var pretty bytes.Buffer
json.Indent(&pretty, body, "", " ")
fmt.Println(pretty.String())
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final int AGENT_PORT = 18080;
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var card = Map.of(
"agent_id", "echo-agent-01",
"name", "Echo Agent",
"description", "A simple echo agent for testing",
"version", "1.0.0",
"url", "http://localhost:" + AGENT_PORT + "/",
"skills", List.of(Map.of(
"id", "echo", "name", "Echo",
"description", "Echoes back the received message",
"tags", List.of("test", "echo"))),
"defaultInputModes", List.of("text"),
"defaultOutputModes", List.of("text"),
"protocolVersions", List.of("1.0")
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/register"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Registered: " + resp.statusCode());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));
}
}
```
```python
import asyncio
import json
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_PORT = 18080
async def main() -> None:
card = {
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": f"http://localhost:{AGENT_PORT}/",
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": ["test", "echo"],
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"],
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card)
print(f"Registered: {resp.status_code}")
print(json.dumps(resp.json(), indent=2))
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_PORT = 18080;
async function main() {
const card = {
agent_id: "echo-agent-01",
name: "Echo Agent",
description: "A simple echo agent for testing",
version: "1.0.0",
url: `http://localhost:${AGENT_PORT}/`,
skills: [
{
id: "echo",
name: "Echo",
description: "Echoes back the received message",
tags: ["test", "echo"],
},
],
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
protocolVersions: ["1.0"],
};
const resp = await fetch(`${KUBEMQ_URL}/agents/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(card),
});
const data = await resp.json();
console.log("Registered:", JSON.stringify(data, null, 2));
}
main().catch(console.error);
```
## List agents [#list-agents]
`GET /agents` returns a bare JSON array `[, ...]`. Add `?skill_tags=tag1,tag2`
(comma-separated) to filter by skill tags, and `?limit=N` to cap the page size. Skill-tag
filtering is applied in memory after fetching, enabling skill-based discovery.
```bash
# All agents
curl http://localhost:9090/agents
# Filter by skill tags
curl "http://localhost:9090/agents?skill_tags=echo"
```
```csharp
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
using var client = new HttpClient();
Console.WriteLine("=== All Agents ===");
var resp = await client.GetAsync($"{KubeMqUrl}/agents");
var agentsRoot = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
var agents = agentsRoot is JsonArray arr ? arr : agentsRoot["agents"]!.AsArray();
foreach (var agent in agents)
{
var skills = agent!["skills"]?.AsArray().Select(s => s!["id"]!.GetValue()).ToList() ?? [];
Console.WriteLine($" {agent["agent_id"]}: skills=[{string.Join(", ", skills)}]");
}
Console.WriteLine($"\nTotal agents: {agents.Count}");
Console.WriteLine("\n=== Filter by skill_tags=echo ===");
resp = await client.GetAsync($"{KubeMqUrl}/agents?skill_tags=echo");
var echoRoot = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
var filtered = echoRoot is JsonArray echoArr ? echoArr : echoRoot["agents"]!.AsArray();
foreach (var agent in filtered)
Console.WriteLine($" {agent!["agent_id"]}");
```
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const kubemqURL = "http://localhost:9090"
func listAgents(url string, label string) {
resp, err := http.Get(url)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// GET /agents returns a bare JSON array: [, ...]
var agents []map[string]interface{}
if err := json.Unmarshal(body, &agents); err != nil {
// Fallback for a {"agents":[...]} wrapper, if ever present.
var wrapper map[string]interface{}
json.Unmarshal(body, &wrapper)
if raw, ok := wrapper["agents"].([]interface{}); ok {
for _, a := range raw {
if m, ok := a.(map[string]interface{}); ok {
agents = append(agents, m)
}
}
}
}
fmt.Printf("=== %s ===\n", label)
for _, agent := range agents {
fmt.Printf(" %s\n", agent["agent_id"])
}
fmt.Printf("\nTotal: %d\n\n", len(agents))
}
func main() {
listAgents(kubemqURL+"/agents", "All Agents")
listAgents(kubemqURL+"/agents?skill_tags=echo", "Filter by skill_tags=echo")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
System.out.println("=== All Agents ===");
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents"))
.GET().build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var root = MAPPER.readTree(resp.body());
var agents = root.isArray() ? root : root.get("agents");
for (var agent : agents) {
System.out.println(" " + agent.get("agent_id").asText());
}
System.out.println("\nTotal agents: " + agents.size());
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
async def main() -> None:
async with httpx.AsyncClient() as client:
print("=== All Agents ===")
resp = await client.get(f"{KUBEMQ_URL}/agents")
data = resp.json()
agents = data.get("agents", data) if isinstance(data, dict) else data
for agent in agents:
skills = [s["id"] for s in agent.get("skills", [])]
print(f" {agent['agent_id']}: skills={skills}")
print(f"\nTotal agents: {len(agents)}")
print("\n=== Filter by skill_tags=echo ===")
resp = await client.get(f"{KUBEMQ_URL}/agents", params={"skill_tags": "echo"})
data = resp.json()
filtered = data.get("agents", data) if isinstance(data, dict) else data
for agent in filtered:
print(f" {agent['agent_id']}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
async function main() {
console.log("=== List all agents ===");
const allResp = await fetch(`${KUBEMQ_URL}/agents`);
const allData = await allResp.json();
const allAgents = Array.isArray(allData) ? allData : (allData.agents || []);
console.log(`Found ${allAgents.length} agent(s):`);
for (const agent of allAgents) {
const skillIds = (agent.skills || []).map((s: { id?: string }) => s.id).filter(Boolean);
console.log(` - ${agent.agent_id} (skills: ${skillIds.join(", ") || "none"})`);
}
console.log("\n=== Filter by skill_tags=echo ===");
const echoResp = await fetch(`${KUBEMQ_URL}/agents?skill_tags=echo`);
const echoData = await echoResp.json();
const echoAgents = Array.isArray(echoData) ? echoData : (echoData.agents || []);
for (const agent of echoAgents) {
console.log(` - ${agent.agent_id}`);
}
}
main().catch(console.error);
```
## Get one agent [#get-one-agent]
`GET /agents/{agent_id}` returns the full agent card, or `404` if the agent is not
registered. Use it to inspect server-managed fields like `registered_at` and `last_seen`.
```bash
curl http://localhost:9090/agents/echo-agent-01
```
```csharp
using System.Text.Json;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
using var client = new HttpClient();
var resp = await client.GetAsync($"{KubeMqUrl}/agents/{AgentId}");
Console.WriteLine($"Status: {(int)resp.StatusCode}");
var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
Console.WriteLine($" agent_id: {data["agent_id"]}");
Console.WriteLine($" name: {data["name"]}");
Console.WriteLine($" url: {data["url"]}");
Console.WriteLine($" registered_at: {data["registered_at"]}");
Console.WriteLine($" last_seen: {data["last_seen"]}");
```
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
)
func main() {
resp, err := http.Get(kubemqURL + "/agents/" + agentID)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
var data map[string]interface{}
json.Unmarshal(body, &data)
fmt.Printf(" agent_id: %v\n", data["agent_id"])
fmt.Printf(" name: %v\n", data["name"])
fmt.Printf(" url: %v\n", data["url"])
fmt.Printf(" registered_at: %v\n", data["registered_at"])
fmt.Printf(" last_seen: %v\n", data["last_seen"])
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/" + AGENT_ID))
.GET().build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + resp.statusCode());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
async def main() -> None:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{KUBEMQ_URL}/agents/{AGENT_ID}")
print(f"Status: {resp.status_code}")
data = resp.json()
print(f" agent_id: {data.get('agent_id')}")
print(f" name: {data.get('name')}")
print(f" url: {data.get('url')}")
print(f" registered_at: {data.get('registered_at')}")
print(f" last_seen: {data.get('last_seen')}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
async function main() {
const resp = await fetch(`${KUBEMQ_URL}/agents/${AGENT_ID}`);
const agent = await resp.json();
console.log(` agent_id: ${agent.agent_id}`);
console.log(` name: ${agent.name}`);
console.log(` url: ${agent.url}`);
console.log(` registered_at: ${agent.registered_at}`);
console.log(` last_seen: ${agent.last_seen}`);
}
main().catch(console.error);
```
## Heartbeat [#heartbeat]
`POST /agents/heartbeat` with `{"agent_id": "..."}` refreshes the agent's `last_seen`
time. An agent must heartbeat (or re-register) within its [TTL](#ttl-and-liveness) to
avoid being expired. When auth is enabled, the ownership check applies. The response is
`{"ok": true}`; heartbeating an unregistered agent returns an error.
```bash
curl -X POST http://localhost:9090/agents/heartbeat \
-H "Content-Type: application/json" \
-d '{"agent_id": "echo-agent-01"}'
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
using var client = new HttpClient();
var body = new JsonObject { ["agent_id"] = AgentId };
var resp = await client.PostAsync(
$"{KubeMqUrl}/agents/heartbeat",
new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
Console.WriteLine($"Heartbeat: status={(int)resp.StatusCode} last_seen={data["last_seen"]}");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
)
func main() {
body, _ := json.Marshal(map[string]string{"agent_id": agentID})
resp, err := http.Post(kubemqURL+"/agents/heartbeat", "application/json", bytes.NewReader(body))
if err != nil {
fmt.Fprintf(os.Stderr, "Heartbeat failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var data map[string]interface{}
json.Unmarshal(raw, &data)
fmt.Printf("Heartbeat: status=%d last_seen=%v\n", resp.StatusCode, data["last_seen"])
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/heartbeat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
MAPPER.writeValueAsString(Map.of("agent_id", AGENT_ID))))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
System.out.println("Heartbeat: status=" + resp.statusCode()
+ " last_seen=" + data.path("last_seen").asText());
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
async def main() -> None:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{KUBEMQ_URL}/agents/heartbeat",
json={"agent_id": AGENT_ID},
)
data = resp.json()
print(f"Heartbeat: status={resp.status_code} last_seen={data.get('last_seen')}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
async function main() {
const resp = await fetch(`${KUBEMQ_URL}/agents/heartbeat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: AGENT_ID }),
});
const data = await resp.json();
console.log(`Heartbeat: status=${resp.status}, last_seen=${data.last_seen}`);
}
main().catch(console.error);
```
## Deregister [#deregister]
Remove an agent with either `POST /agents/deregister` (body `{"agent_id": "..."}`) or
`DELETE /agents/{agent_id}`. Deregistering deletes the card, stops the agent's virtual
subscriber, and drains in-flight requests. Both methods return `{"ok": true}`; when auth
is enabled, the [ownership](#ownership) check applies.
```bash
# Deregister via POST
curl -X POST http://localhost:9090/agents/deregister \
-H "Content-Type: application/json" \
-d '{"agent_id": "echo-agent-01"}'
# Or via DELETE
curl -X DELETE http://localhost:9090/agents/echo-agent-01
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
using var client = new HttpClient();
// Deregister via POST
var body = new JsonObject { ["agent_id"] = AgentId };
var postResp = await client.PostAsync(
$"{KubeMqUrl}/agents/deregister",
new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"POST deregister: {(int)postResp.StatusCode}");
// Or via DELETE
var delResp = await client.DeleteAsync($"{KubeMqUrl}/agents/{AgentId}");
Console.WriteLine($"DELETE: {(int)delResp.StatusCode}");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
)
func main() {
// Deregister via POST
body, _ := json.Marshal(map[string]string{"agent_id": agentID})
resp, err := http.Post(kubemqURL+"/agents/deregister", "application/json", bytes.NewReader(body))
if err != nil {
fmt.Fprintf(os.Stderr, "Deregister POST failed: %v\n", err)
os.Exit(1)
}
resp.Body.Close()
fmt.Printf("POST /agents/deregister: %d\n", resp.StatusCode)
// Or via DELETE
req, _ := http.NewRequest(http.MethodDelete, kubemqURL+"/agents/"+agentID, nil)
resp, err = http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "DELETE failed: %v\n", err)
os.Exit(1)
}
resp.Body.Close()
fmt.Printf("DELETE /agents/%s: %d\n", agentID, resp.StatusCode)
}
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
// Deregister via POST
var postReq = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/deregister"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"agent_id\": \"" + AGENT_ID + "\"}"))
.build();
var postResp = client.send(postReq, HttpResponse.BodyHandlers.ofString());
System.out.println("POST deregister: " + postResp.statusCode());
// Or via DELETE
var delReq = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/" + AGENT_ID))
.DELETE()
.build();
var delResp = client.send(delReq, HttpResponse.BodyHandlers.ofString());
System.out.println("DELETE: " + delResp.statusCode());
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
async def main() -> None:
async with httpx.AsyncClient() as client:
# Deregister via POST
resp = await client.post(
f"{KUBEMQ_URL}/agents/deregister",
json={"agent_id": AGENT_ID},
)
print(f"POST /agents/deregister: {resp.status_code}")
# Or via DELETE
resp = await client.delete(f"{KUBEMQ_URL}/agents/{AGENT_ID}")
print(f"DELETE /agents/{AGENT_ID}: {resp.status_code}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
async function main() {
// Deregister via POST
const postResp = await fetch(`${KUBEMQ_URL}/agents/deregister`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: AGENT_ID }),
});
console.log(`POST deregister status: ${postResp.status}`);
// Or via DELETE
const delResp = await fetch(`${KUBEMQ_URL}/agents/${AGENT_ID}`, {
method: "DELETE",
});
console.log(`DELETE status: ${delResp.status}`);
}
main().catch(console.error);
```
## TTL and liveness [#ttl-and-liveness]
The registry expires agents that go silent. A background **liveness checker** runs every
60 seconds and deletes any agent whose `last_seen` is older than `AgentTTLSeconds`
(default `300` — five minutes). Expiring an agent also removes its virtual subscriber and
emits a `deregister` replication event.
To stay registered, an agent must [heartbeat](#heartbeat) (or re-register) within the TTL
window. A safe interval is well under `AgentTTLSeconds` — for the default 300s TTL, a
heartbeat every 60–120 seconds gives plenty of margin.
Tune the window with `AgentTTLSeconds`. See
[Configuration](/aiway/a2a/configuration) for the full `A2aConfig` field set
and the disable env var.
## Ownership [#ownership]
When authentication is enabled, the registry records the JWT principal that registered
each agent in `registered_by`. Heartbeat and deregister then enforce an **ownership
check**: only the registering principal may refresh or remove the agent. Cross-principal
re-registration is rejected with `403` (ownership conflict).
The check **fails closed**: if auth is enabled and an agent's `registered_by` is blank,
no principal can modify or delete it. See
[Authentication](/aiway/a2a/guides/authentication) for the auth model and
`X-KubeMQ-Caller-ID` propagation.
## MaxAgents limit [#maxagents-limit]
`MaxAgents` caps the total number of registered agents. The default is `0`, meaning
**unlimited**. When a positive limit is set and reached, new registrations are rejected
with `409` (conflict) — re-registering an existing agent still succeeds, since it does not
grow the count.
## Response and status codes [#response-and-status-codes]
| Status | Operation | Meaning |
| ------ | --------------------------------- | ----------------------------------------------- |
| `200` | register / heartbeat / get / list | Success |
| `400` | register / heartbeat | Validation error (bad card, missing `agent_id`) |
| `403` | register / heartbeat / deregister | Ownership conflict (auth enabled) |
| `404` | get / deregister | Agent not found |
| `409` | register | `MaxAgents` limit reached |
## Related [#related]
# Streaming (SSE) (/aiway/a2a/streaming)
When an agent produces results incrementally — progress updates, partial artifacts, a
final answer — A2A streams them back to the caller as **Server-Sent Events (SSE)**. The
caller sends a JSON-RPC `message/stream` request and reads a sequence of typed event
frames until a terminal `task.done` or `task.error`.
## Overview [#overview]
Synchronous [`message/send`](/aiway/a2a/sync-messaging) returns a single reply.
**`message/stream`** instead opens a long-lived SSE connection and relays each event the
agent emits as it happens, so callers can show progress or consume artifacts before the
task finishes.
There are two ways to start a stream against the same agent:
| Trigger | Request | Pre-stream error format |
| -------------------------------------------------------- | ------------- | --------------------------------------------------------- |
| `POST /a2a/{agent_id}` with `"method": "message/stream"` | JSON-RPC body | JSON-RPC 2.0 error at HTTP 200 |
| `GET /a2a/{agent_id}/stream` | query/headers | HTTP status code + `{"is_error": true, "message": "..."}` |
The POST form is the common case and what the examples below use. In both cases the
response is `Content-Type: text/event-stream` and the wire protocol is identical.
## How it works [#how-it-works]
The gateway does not hold a socket open to your agent on the caller's behalf. Instead it
uses the agent's [virtual subscriber](/aiway/a2a/architecture) as an **SSE
relay**: a temporary internal channel carries the agent's events to the gateway, which
forwards them to the caller. This subscribe-first ordering guarantees no event is lost
between the query and the agent's first emission.
*The virtual subscriber relays the agent's SSE events over a temporary internal channel; the gateway forwards them to the caller until a terminal envelope closes the stream.*
## Event types [#event-types]
Each SSE frame has an `event:` name and a JSON `data:` payload. The agent's envelope
`type` maps to the SSE event name the caller sees:
| Envelope `type` | SSE event name | Meaning |
| --------------- | --------------- | ------------------------------------------ |
| `status_update` | `task.status` | Progress or status update (non-terminal) |
| `artifact` | `task.artifact` | A partial or complete result artifact |
| `done` | `task.done` | Terminal — the task completed successfully |
| `error` | `task.error` | Terminal — the task failed |
| (other) | `message` | Default event name for untyped envelopes |
Each `data:` line is a stream envelope:
```json
{
"stream_id": "f3c1...",
"type": "status_update",
"payload": { "status": "working", "progress": 3, "total": 5 }
}
```
A caller reads frames until it sees `task.done` or `task.error`, then stops — both are
terminal and the gateway closes the connection after sending them.
## Stream a task [#stream-a-task]
Send a `message/stream` request with `Accept: text/event-stream` and read the event
frames as they arrive. Stop on the terminal `task.done` / `task.error`.
```bash
curl -N -X POST http://localhost:9090/a2a/stream-agent-01 \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"message": {"parts": [{"text": "Stream me some updates"}]}
}
}'
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "stream-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/stream",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Stream me some updates" })
}
}
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}")
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
Console.WriteLine("Connecting to SSE stream...");
using var resp = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? eventType = null;
int eventCount = 0;
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
if (line.StartsWith("event: "))
eventType = line[7..];
else if (line.StartsWith("data: ") && eventType != null)
{
eventCount++;
var data = line[6..];
Console.WriteLine($"[{eventType}] {data}");
if (eventType is "task.done" or "task.error")
break;
}
else if (line.Length == 0)
eventType = null;
}
Console.WriteLine($"\nReceived {eventCount} events");
Console.WriteLine("Stream completed!");
```
```go
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "stream-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Stream me some updates"}},
},
},
}
data, err := json.Marshal(payload)
if err != nil {
fmt.Fprintf(os.Stderr, "Marshal failed: %v\n", err)
os.Exit(1)
}
req, err := http.NewRequest(http.MethodPost, kubemqURL+"/a2a/"+agentID, bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request build failed: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
fmt.Println("Connecting to SSE stream...")
eventCount := 0
eventType := ""
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
eventType = strings.TrimPrefix(line, "event: ")
} else if strings.HasPrefix(line, "data: ") {
eventCount++
dataStr := strings.TrimPrefix(line, "data: ")
fmt.Printf("[%s] %s\n", eventType, dataStr)
if eventType == "task.done" || eventType == "task.error" {
break
}
}
}
fmt.Printf("\nReceived %d events\n", eventCount)
fmt.Println("Stream completed!")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "stream-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/stream",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Stream me some updates")))
)
);
var client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(60))
.build();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
System.out.println("Connecting to SSE stream...");
var resp = client.send(req, HttpResponse.BodyHandlers.ofLines());
int eventCount = 0;
String currentEvent = null;
for (var it = resp.body().iterator(); it.hasNext(); ) {
String line = it.next();
if (line.startsWith("event: ")) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith("data: ")) {
eventCount++;
String data = line.substring(6);
System.out.println("[" + currentEvent + "] " + data);
if ("task.done".equals(currentEvent) || "task.error".equals(currentEvent)) {
break;
}
}
}
System.out.println("\nReceived " + eventCount + " events");
System.out.println("Stream completed!");
}
}
```
```python
import asyncio
import json
import httpx
from httpx_sse import aconnect_sse
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "stream-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"message": {"parts": [{"text": "Stream me some updates"}]},
},
}
async with httpx.AsyncClient(timeout=60) as client:
print("Connecting to SSE stream...")
async with aconnect_sse(
client,
"POST",
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json=payload,
headers={"Accept": "text/event-stream"},
) as event_source:
event_count = 0
async for event in event_source.aiter_sse():
event_count += 1
data = json.loads(event.data)
print(f"[{event.event}] {json.dumps(data)}")
if event.event in ("task.done", "task.error"):
break
print(f"\nReceived {event_count} events")
print("Stream completed!")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "stream-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/stream",
params: { message: { parts: [{ text: "Stream me some updates" }] } },
};
console.log("=== POST-based streaming ===");
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(request),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let eventCount = 0;
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
if (!frame.trim()) continue;
let eventType = "";
let eventData = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event: ")) eventType = line.slice(7).trim();
else if (line.startsWith("data: ")) eventData = line.slice(6).trim();
}
if (!eventType) continue;
eventCount++;
const payload = JSON.parse(eventData);
console.log(`[${eventType}] ${JSON.stringify(payload)}`);
if (eventType === "task.done" || eventType === "task.error") {
console.log(`\nStream complete. Total events: ${eventCount}`);
reader.cancel();
return;
}
}
}
console.log(`\nStream ended. Total events: ${eventCount}`);
}
main().catch(console.error);
```
A typical stream is a sequence of `task.status` frames followed by a terminal `task.done`:
```text
Connecting to SSE stream...
[task.status] {"type": "status_update", "payload": {"status": "working", "progress": 1, "total": 5}}
[task.status] {"type": "status_update", "payload": {"status": "working", "progress": 2, "total": 5}}
[task.status] {"type": "status_update", "payload": {"status": "working", "progress": 5, "total": 5}}
[task.done] {"type": "done", "payload": {"final_result": "completed", "event_count": 5}}
```
## Keepalive and idle timeout [#keepalive-and-idle-timeout]
To keep the connection alive through proxies during quiet periods, the gateway emits an
SSE comment line — `: keepalive` — every **30 seconds**. It is a comment, not an event,
so SSE clients ignore it; it exists only to keep the socket warm.
If the agent produces no events for `MaxSSEIdleSeconds` (default **300 seconds**), the
gateway closes the stream: it sends a terminal `task.error` with code `-32001`
(`"stream idle timeout"`) and issues a best-effort cancel to the agent. Tune the window
with `MaxSSEIdleSeconds` — see [Configuration](/aiway/a2a/configuration).
## Client disconnect [#client-disconnect]
If the caller closes the connection before a terminal event, the gateway detects the
disconnect and **cancels the work on the agent** rather than letting it run to completion.
It sends a `stream_cancel` query to the agent's virtual subscriber on
`_AGENTS_.agents/{agent_id}` (carrying `a2a_stream_id`, with a 10-second timeout), which
closes the relay's HTTP connection to the agent. This frees the
[concurrency slot](/aiway/a2a/guides/concurrency) promptly instead of waiting
for the task to finish.
The SSE stream endpoint omits the per-route timeout middleware — long-lived streams are
bounded by `MaxSSEIdleSeconds`, not the 60-second request timeout that applies to
`message/send`.
## Response size limit [#response-size-limit]
During relay, the virtual subscriber tracks the accumulated `data:` bytes of each SSE
event. If a single event exceeds `AgentMaxResponseBytes` (default **10 MB**), the relay is
aborted to protect the gateway from memory exhaustion. Keep individual artifact events
under this bound; for large results, chunk them across multiple `task.artifact` events.
## Related [#related]
# Synchronous Messaging (/aiway/a2a/sync-messaging)
Synchronous A2A messaging is a request/reply call: a caller POSTs a JSON-RPC 2.0
request to `POST /a2a/`, the gateway routes it to the target agent through
its virtual subscriber, and the agent's reply comes back on the same HTTP response —
no SSE, no polling.
## Overview [#overview]
`message/send` is the workhorse of the A2A connector. You address an agent by ID in
the URL, put a standard JSON-RPC 2.0 envelope in the body, and read the reply
synchronously. The gateway is **method-agnostic**: it forwards whatever `method` you
send — `message/send`, `tasks/get`, `tasks/cancel`, or your own `custom/action` — and
returns the agent's response as-is. Use synchronous messaging whenever you want a
single answer back in one round-trip; switch to
[streaming](/aiway/a2a/streaming) when the agent produces incremental
output over time.
## How it works [#how-it-works]
A synchronous call travels from the caller through the gateway and the agent's
virtual subscriber to the plain HTTP agent, then the reply retraces the path.
*The gateway proxies one request to one agent and relays the reply on the same HTTP response.*
The gateway validates the agent ID and `Content-Type`, reads the timeout from
`params.configuration.timeout` (falling back to `DefaultTimeoutSeconds`, capped at
`MaxTimeoutSeconds`), packs the caller's `X-*` headers, and issues a Query on
`_AGENTS_.agents/`. The agent's virtual subscriber unpacks the request,
POSTs it to the agent's registered URL, and relays the response.
## Basic message/send [#basic-messagesend]
Send a text message and read the synchronous reply. The agent receives the full
JSON-RPC envelope and returns its result.
```bash
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}]
}
}
}'
```
```csharp
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello, agent!" })
}
}
};
using var client = new HttpClient();
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"Status: {(int)resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
if (data["result"] != null)
Console.WriteLine("\nBasic send completed successfully!");
```
```go
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
)
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Hello, agent!"}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Println(string(body))
```
```java
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of(
"parts", List.of(Map.of("text", "Hello, agent!"))
)
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + resp.statusCode());
var data = MAPPER.readTree(resp.body());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data));
```
```python
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Hello, agent!"}],
},
},
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
print(f"Status: {resp.status_code}")
data = resp.json()
print(json.dumps(data, indent=2))
assert "result" in data
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: {
parts: [{ text: "Hello, agent!" }],
},
},
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
console.log("Response:", JSON.stringify(data, null, 2));
```
## Context IDs [#context-ids]
Set `params.contextId` to correlate a sequence of messages with the same agent — for
conversation threading or session management. The gateway forwards it to the agent,
which echoes it back so callers can confirm the correlation.
```bash
curl -X POST http://localhost:9090/a2a/context-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Track this request"}]},
"contextId": "ctx-001"
}
}'
```
```csharp
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Track this request" })
},
["contextId"] = "ctx-001"
}
};
using var client = new HttpClient();
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/context-agent-01",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
var returnedCtx = data["result"]?["contextId"]?.GetValue();
Console.WriteLine($"Received contextId: {returnedCtx}");
```
```go
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{"parts": []map[string]interface{}{{"text": "Track this request"}}},
"contextId": "ctx-001",
},
}
data, _ := json.Marshal(payload)
resp, _ := http.Post("http://localhost:9090/a2a/context-agent-01", "application/json", bytes.NewReader(data))
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
if r, ok := result["result"].(map[string]interface{}); ok {
fmt.Printf("Received contextId: %v\n", r["contextId"])
}
```
```java
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Track this request"))),
"contextId", "ctx-001"
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/context-agent-01"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
var returnedCtx = data.path("result").path("contextId").asText(null);
System.out.println("Received contextId: " + returnedCtx);
```
```python
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Track this request"}]},
"contextId": "ctx-001",
},
}
async with httpx.AsyncClient() as client:
resp = await client.post("http://localhost:9090/a2a/context-agent-01", json=payload)
data = resp.json()
returned_ctx = data.get("result", {}).get("contextId")
print(f"Received contextId: {returned_ctx}")
```
```typescript
const contextId = "ctx-001";
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: { parts: [{ text: "Track this request" }] },
contextId,
},
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/context-agent-01`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
const echoedCtx = data.result?.contextId;
console.log(`Echoed contextId: ${echoedCtx}`);
```
## Custom methods [#custom-methods]
The gateway forwards any JSON-RPC `method` name to the agent unchanged — standard A2A
methods like `tasks/get` and `tasks/cancel`, or your own `custom/action`. KubeMQ does
not interpret the method; the agent decides how to handle it.
```bash
curl -X POST http://localhost:9090/a2a/custom-method-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "custom/action",
"params": {"data": "custom-payload"}
}'
```
```csharp
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "custom/action",
["params"] = new JsonObject { ["data"] = "custom-payload" }
};
using var client = new HttpClient();
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/custom-method-agent-01",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
var handledMethod = data["result"]?["handled_method"]?.GetValue();
Console.WriteLine($"Handled method: {handledMethod}");
```
```go
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "custom/action",
"params": map[string]interface{}{"data": "custom-payload"},
}
data, _ := json.Marshal(payload)
resp, _ := http.Post("http://localhost:9090/a2a/custom-method-agent-01", "application/json", bytes.NewReader(data))
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
r, _ := result["result"].(map[string]interface{})
fmt.Printf("Handled method: %v\n", r["handled_method"])
```
```java
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "custom/action",
"params", Map.of("data", "custom-payload")
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/custom-method-agent-01"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
System.out.println("Handled method: " + data.path("result").path("handled_method").asText());
```
```python
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "custom/action",
"params": {"data": "custom-payload"},
}
async with httpx.AsyncClient() as client:
resp = await client.post("http://localhost:9090/a2a/custom-method-agent-01", json=payload)
data = resp.json()
result = data.get("result", {})
print(f"Handled method: {result.get('handled_method')}")
```
```typescript
async function sendRpc(method: string, params: unknown) {
const request = { jsonrpc: "2.0", id: Date.now(), method, params };
const resp = await fetch(`${KUBEMQ_URL}/a2a/custom-method-agent-01`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
return resp.json();
}
const custom = await sendRpc("custom/action", { data: "custom-payload" });
console.log("Response:", JSON.stringify(custom, null, 2));
```
## Header forwarding [#header-forwarding]
Caller request headers prefixed with `X-` are forwarded to the agent; hop-by-hop and
sensitive headers (`Authorization`, `Cookie`, `X-Forwarded-For`, and others) are
stripped. The gateway always injects `X-KubeMQ-Caller-ID` so the agent knows which
client originated the call.
```bash
curl -X POST http://localhost:9090/a2a/header-agent-01 \
-H "Content-Type: application/json" \
-H "X-Custom-Header: my-custom-value" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Check my headers"}]}
}
}'
```
```csharp
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Check my headers" })
}
}
};
using var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/header-agent-01")
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
request.Headers.Add("X-Custom-Header", "my-custom-value");
var resp = await client.SendAsync(request);
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
var received = data["result"]?["received_headers"];
Console.WriteLine($"Forwarded headers: {received?.ToJsonString()}");
```
```go
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Check my headers"}},
},
},
}
data, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "http://localhost:9090/a2a/header-agent-01", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Custom-Header", "my-custom-value")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
received, _ := result["result"].(map[string]interface{})["received_headers"].(map[string]interface{})
fmt.Printf("Forwarded headers: %v\n", received)
```
```java
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Check my headers")))
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/header-agent-01"))
.header("Content-Type", "application/json")
.header("X-Custom-Header", "my-custom-value")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
System.out.println("Forwarded headers: " + data.path("result").path("received_headers"));
```
```python
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Check my headers"}]},
},
}
async with httpx.AsyncClient() as client:
resp = await client.post(
"http://localhost:9090/a2a/header-agent-01",
json=payload,
headers={"X-Custom-Header": "my-custom-value"},
)
data = resp.json()
received = data.get("result", {}).get("received_headers", {})
print(f"Forwarded headers: {received}")
```
```typescript
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: { parts: [{ text: "Check my headers" }] },
},
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/header-agent-01`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Custom-Header": "test-value-123",
},
body: JSON.stringify(request),
});
const data = await resp.json();
const headers = data.result?.received_headers || {};
console.log(`X-Custom-Header: ${headers["x-custom-header"] || "(not forwarded)"}`);
console.log(`X-KubeMQ-Caller-ID: ${headers["x-kubemq-caller-id"] || "(not present)"}`);
```
## Concurrent requests [#concurrent-requests]
Multiple simultaneous calls to the same agent are processed in parallel up to the
per-agent concurrency cap (`AgentMaxConcurrency`, default 100). Requests beyond the
cap are rejected immediately with a "server busy" error instead of being queued — see
[Concurrency & limits](/aiway/a2a/guides/concurrency).
```bash
# Fire 20 requests in parallel against the same agent
for i in $(seq 1 20); do
curl -s -X POST http://localhost:9090/a2a/concurrent-agent-01 \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"message/send\",\"params\":{\"message\":{\"parts\":[{\"text\":\"Request #$i\"}]}}}" &
done
wait
```
```csharp
const int NumRequests = 20;
async Task SendRequest(HttpClient httpClient, int requestId)
{
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = requestId,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = $"Request #{requestId}" })
}
}
};
var resp = await httpClient.PostAsync(
$"{KubeMqUrl}/a2a/concurrent-agent-01",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
return JsonNode.Parse(body);
}
using var client = new HttpClient();
var tasks = Enumerable.Range(1, NumRequests).Select(i => SendRequest(client, i)).ToArray();
var results = await Task.WhenAll(tasks);
var successes = results.Count(r => r?["result"] != null);
Console.WriteLine($"Successes: {successes} / {NumRequests}");
```
```go
const numRequests = 20
results := make(chan map[string]interface{}, numRequests)
var wg sync.WaitGroup
for i := 1; i <= numRequests; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
payload := map[string]interface{}{
"jsonrpc": "2.0", "id": id, "method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": fmt.Sprintf("Request #%d", id)}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post("http://localhost:9090/a2a/concurrent-agent-01", "application/json", bytes.NewReader(data))
if err != nil {
results <- nil
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var r map[string]interface{}
json.Unmarshal(body, &r)
results <- r
}(i)
}
wg.Wait()
close(results)
successes := 0
for r := range results {
if r != nil {
if _, ok := r["result"]; ok {
successes++
}
}
}
fmt.Printf("Successes: %d / %d\n", successes, numRequests)
```
```java
static final int NUM_REQUESTS = 20;
var client = HttpClient.newHttpClient();
List>> futures = new ArrayList<>();
for (int i = 1; i <= NUM_REQUESTS; i++) {
var payload = Map.of(
"jsonrpc", "2.0", "id", i, "method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Request #" + i)))
)
);
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/concurrent-agent-01"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
futures.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofString()));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
int successes = 0;
for (var future : futures) {
var data = MAPPER.readTree(future.get().body());
if (data.has("result")) successes++;
}
System.out.println("Successes: " + successes + " / " + NUM_REQUESTS);
```
```python
NUM_REQUESTS = 20
async def send_request(client: httpx.AsyncClient, request_id: int) -> dict:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": "message/send",
"params": {
"message": {"parts": [{"text": f"Request #{request_id}"}]},
},
}
resp = await client.post("http://localhost:9090/a2a/concurrent-agent-01", json=payload)
return resp.json()
async with httpx.AsyncClient() as client:
tasks = [send_request(client, i) for i in range(1, NUM_REQUESTS + 1)]
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = sum(1 for r in results if isinstance(r, dict) and "result" in r)
print(f"Successes: {successes} / {NUM_REQUESTS}")
```
```typescript
const NUM_REQUESTS = 20;
async function sendRequest(id: number): Promise {
const request = {
jsonrpc: "2.0",
id,
method: "message/send",
params: { message: { parts: [{ text: `Request ${id}` }] } },
};
try {
const resp = await fetch(`${KUBEMQ_URL}/a2a/concurrent-agent-01`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
return !!data.result;
} catch {
return false;
}
}
const promises = Array.from({ length: NUM_REQUESTS }, (_, i) => sendRequest(i + 1));
const results = await Promise.all(promises);
const succeeded = results.filter((r) => r).length;
console.log(`Succeeded: ${succeeded} / ${NUM_REQUESTS}`);
```
## Parameters [#parameters]
The body is a standard JSON-RPC 2.0 request. Method-specific fields live under
`params`.
| Field | Type | Required | Default | Description |
| ------------------------------ | ---------------- | ------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `jsonrpc` | string | yes | — | Must be `"2.0"`. |
| `id` | string \| number | yes | — | Request identifier; echoed back on the response. |
| `method` | string | yes | — | Any JSON-RPC method name, e.g. `message/send`, `tasks/get`, or a custom one. Empty method is rejected with `-32600`. |
| `params.message.parts` | array | for `message/send` | — | Message content parts, each with a `text` (or other typed) field. |
| `params.contextId` | string | no | — | Correlation ID forwarded to the agent for conversation threading. |
| `params.configuration.timeout` | number | no | `DefaultTimeoutSeconds` (300) | Per-request timeout in seconds, capped at `MaxTimeoutSeconds` (3600). |
The URL path segment `` selects the target agent and is validated against
the registry before routing.
## Response [#response]
On success the gateway returns the agent's JSON-RPC `result` verbatim (the echo agent
mirrors the request under `result.echo`):
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"echo": {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": { "parts": [{ "text": "Hello, agent!" }] }
}
}
}
}
```
A response carrying an `error` object signals a problem. KubeMQ distinguishes two
failure classes:
* **Transport errors** — the agent never processed the request (unreachable, timeout,
`502`/`503`/`504`, or an oversized response). Internally these surface as
`Executed: false`; safe to retry.
* **Application errors** — the agent processed the request and returned a JSON-RPC
error in its response body (`Executed: true`). Retrying without changing the request
usually will not help.
A timeout, for example, returns:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32001,
"message": "agent timeout: echo-agent-01"
}
}
```
See [Error handling](/aiway/a2a/error-handling) for the full code list and
retry guidance.
## Related [#related]
# Configuration (/aiway/mcp/configuration)
The MCP connector runs on KubeMQ's [shared HTTP server](/connectors/concepts/shared-http-server) and is **enabled by default**. Configuration is minimal: a tool-execution timeout and an origin-validation allow-list. This page lists every `McpConfig` field with its verified default and shows how to set each one through TOML, environment variables, and Docker.
## Configuration fields [#configuration-fields]
These are the `McpConfig` fields and their defaults, taken verbatim from `kubemq-server`. All three connectors share the HTTP server's CORS, auth, traffic-gate, and TLS settings — those live in [Shared HTTP server](/connectors/concepts/shared-http-server) and [Auth & security](/connectors/reference/auth-and-security), not here.
| Field | Type | Default | Description |
| -------------------- | ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Enable` | `bool` | `true` | Whether the MCP connector is mounted. Enabled by default — set to `false` to turn it off. |
| `ToolTimeoutSeconds` | `int` | `300` | Server-side timeout applied to each tool execution. A tool that exceeds this window returns a tool error (`isError: true`). Must be positive when the connector is enabled. |
| `TrustedOrigins` | `[]string` | `["auto"]` | Origins permitted by the connector's `Origin`-header validation. `auto` allows localhost variants and the server bind address; `*` allows all; an explicit list permits only those origins. |
`ToolTimeoutSeconds` (default `300`) is the **server-side** tool-execution budget. It is independent of any client-side HTTP timeout and of the per-call `timeout_seconds` argument on tools like `command_send`, `query_send`, and `agent_send`, which is itself capped at `300`.
## Enable / disable [#enable--disable]
The MCP connector is **enabled by default**. Start `kubemq-server` and `POST /mcp` is live — no `=true` flag is required.
To **disable** it, set its enable env var to `false`:
The disable variable is `CONNECTORSMCP_ENABLE` — there is **no underscore** between `MCP` and `ENABLE`. The name is generated by snake-casing the dotted config key `Connectors.MCP.Enable`; because `MCP` has no lowercase letters, the transform inserts no separator and the segments join. This differs from older KubeMQ docs that described MCP as off-by-default and instructed enabling with `=true` — that framing is stale. See [Shared HTTP server → enable model](/connectors/concepts/shared-http-server#enable-model-on-by-default) for the full algorithm.
## TOML configuration [#toml-configuration]
Set the fields under a `[Connectors.MCP]` table in `config.toml`:
```toml title="config.toml"
[Connectors.MCP]
Enable = true
ToolTimeoutSeconds = 300
TrustedOrigins = ["auto"]
```
## Environment variables [#environment-variables]
Each config field binds to an environment variable derived from its dotted key. The dots are stripped, the remainder is snake-cased and upper-cased, so `Connectors.MCP.ToolTimeoutSeconds` becomes `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS`.
| Variable | Config field | Default | Description |
| ------------------------------------ | ----------------------------------- | ------- | ---------------------------------------------------------- |
| `CONNECTORSMCP_ENABLE` | `Connectors.MCP.Enable` | `true` | Enable (default) or disable (`false`) the MCP connector. |
| `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS` | `Connectors.MCP.ToolTimeoutSeconds` | `300` | Server-side per-tool execution timeout, in seconds. |
| `CONNECTORSMCP_TRUSTED_ORIGINS` | `Connectors.MCP.TrustedOrigins` | `auto` | Comma-separated origin allow-list for `Origin` validation. |
```bash title="env.sh"
export CONNECTORSMCP_ENABLE=true
export CONNECTORSMCP_TOOL_TIMEOUT_SECONDS=300
export CONNECTORSMCP_TRUSTED_ORIGINS=auto
```
The `Origin` allow-list set here is an MCP-specific check layered on top of the shared HTTP server's CORS middleware. For how `auto`, `*`, and explicit origins are evaluated — and the `-32010` error returned on a rejected origin — see [Auth & security](/connectors/reference/auth-and-security).
## Docker [#docker]
Pass the same variables to `docker run`. This example keeps MCP at its default-on state and raises the tool timeout:
The connector is served because `Enable` defaults to `true`; to turn it off, add `-e CONNECTORSMCP_ENABLE=false` as shown in [Enable / disable](#enable--disable) above.
## Client protocol settings [#client-protocol-settings]
The settings above are server-side. When a client opens a session it negotiates the protocol in the `initialize` handshake. KubeMQ expects protocol version `2025-11-25`, a `clientInfo` object, and an empty `capabilities` object — capabilities are server-driven.
| Setting | Value | Description |
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------- |
| Protocol version | `2025-11-25` | MCP protocol version sent in the `initialize` request and echoed in the `MCP-Protocol-Version` response header. |
| Client info | `{ name, version }` | Identifies the client to the server. |
| Capabilities | `{}` | Empty object — the server advertises its own capabilities in the response. |
```json title="initialize.json"
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "my-agent",
"version": "1.0.0"
}
}
}
```
The server replies with its own `protocolVersion`, `capabilities`, `serverInfo`, and a `sessionId` under `result._meta`. The handshake and session reuse are covered in depth in [Session management](/aiway/mcp/guides/session-management).
## Related [#related]
# Getting Started with MCP (/aiway/mcp/getting-started)
The MCP connector is **enabled by default** on the shared HTTP server. Start KubeMQ and `POST /mcp` is live — there is no flag to turn it on. This guide takes you from a running server to your first tool call, with a Claude Desktop config, raw JSON-RPC over `curl`, and the official MCP SDK in nine languages.
## Prerequisites [#prerequisites]
* A running `kubemq-server` with its [shared HTTP server](/connectors/concepts/shared-http-server) on port `9090`. Docker is the quickest way to get one.
* For Claude Desktop: a current Claude Desktop install.
* For the SDK examples (optional): one of the nine supported runtimes — Python 3.10+, Node.js 18+, Java 21+, .NET 8+, Go 1.21+, Rust 1.75+, Ruby 3.1+, Kotlin 1.9+, or Swift 6.0+. The `curl` path needs no SDK or runtime — it speaks the MCP JSON-RPC protocol directly over HTTP.
## Enable / disable [#enable--disable]
The MCP connector is **enabled by default** — start `kubemq-server` and `POST /mcp` is immediately available. No `=true` flag is needed.
Port `9090` is the [shared HTTP server](/connectors/concepts/shared-http-server) that hosts MCP alongside the REST, A2A, and CloudEvents connectors. Port `50000` is the gRPC port used by native SDKs and by the command/query subscribers behind some MCP tools.
To **disable** MCP, set its enable env var to `false`:
The enable var name `CONNECTORSMCP_ENABLE` is irregular by design, and older KubeMQ docs described MCP as off-by-default — both are explained on [Shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default). Set it to `false` to disable; never to `true` to enable.
## How it works [#how-it-works]
Every MCP interaction is a JSON-RPC 2.0 call to `POST /mcp`. A client first negotiates a session with `initialize`, acknowledges it with `notifications/initialized`, then discovers tools with `tools/list` and invokes them with `tools/call`. The connector resolves each tool to a KubeMQ operation and returns the result.
*The MCP handshake establishes a session, then each `tools/call` maps to a KubeMQ operation.*
## Connect Claude Desktop [#connect-claude-desktop]
Point Claude Desktop at KubeMQ's MCP endpoint by adding a server entry to `claude_desktop_config.json` (Claude Desktop → Settings → Developer → Edit Config):
```json title="claude_desktop_config.json"
{
"mcpServers": {
"kubemq": {
"url": "http://localhost:9090/mcp"
}
}
}
```
Restart Claude Desktop. It performs the `initialize` handshake over the Streamable HTTP transport, discovers all 15 KubeMQ tools, and makes them available in the conversation. For an authenticated server, in-depth transport options, and a generic (non-Claude) JSON-RPC client walkthrough, see [Client setup](/aiway/mcp/guides/client-setup).
## The handshake by hand [#the-handshake-by-hand]
The steps below drive the protocol directly with `curl` so you can see exactly what each MCP client does for you.
### Initialize a session [#initialize-a-session]
Send an `initialize` request to negotiate the protocol version (`2025-11-25`) and open a session:
```bash title="terminal"
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0.0" }
}
}'
```
The server replies with its capabilities and a session ID under `result._meta.sessionId`. The same value is also returned in the `MCP-Session-Id` response header:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": { "tools": {} },
"serverInfo": { "name": "kubemq", "version": "..." },
"_meta": { "sessionId": "" }
}
}
```
Save the `sessionId` — every subsequent request carries it in the `MCP-Session-Id` header.
### Acknowledge the handshake [#acknowledge-the-handshake]
Tell the server the handshake is complete with the `notifications/initialized` notification, passing the session ID:
```bash title="terminal"
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: ' \
-d '{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}'
```
This is a notification, so it has no `id` field. The server returns HTTP `200 OK` with body `{"jsonrpc":"2.0","result":null,"id":null}` — there is no meaningful JSON-RPC result to parse.
### List the available tools [#list-the-available-tools]
Discover what you can call with `tools/list`. KubeMQ returns 11 core tools, plus 4 agent-bridge tools when the agent registry is available (15 total):
```bash title="terminal"
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: ' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}'
```
Each entry carries a `name`, `description`, and `inputSchema`. The [Tools overview](/aiway/mcp/tools) maps every tool to its KubeMQ operation.
### Call your first tool [#call-your-first-tool]
Invoke `queue_send` to put a message on a queue channel, passing the session ID:
```bash title="terminal"
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: ' \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": {
"channel": "my-queue",
"body": "Hello from MCP"
}
}
}'
```
A successful call returns a `content` array with the result text and `isError: false`:
```json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{ "type": "text", "text": "Message sent successfully to queue 'my-queue'" }],
"isError": false
}
}
```
## The same call from an SDK [#the-same-call-from-an-sdk]
Each official MCP SDK performs the `initialize` handshake and tracks the session ID for you over the Streamable HTTP transport — you only write the `tools/call`. All examples read `KUBEMQ_MCP_URL` (default `http://localhost:9090`) and append `/mcp`.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: ' \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": {
"channel": "example-queue",
"body": "Hello from MCP",
"metadata": "example-metadata",
"tags": { "env": "dev", "source": "mcp-example" }
}
}
}'
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "queue_send",
Arguments: map[string]any{
"channel": "example-queue",
"body": "Hello from Go MCP",
"metadata": "example-metadata",
"tags": map[string]any{"env": "dev", "source": "mcp-example"},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: queue_send")
fmt.Printf("Result: %+v\n", result)
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("queue_send", {
"channel": "example-queue",
"body": "Hello from Python MCP",
"metadata": "example-metadata",
"tags": {"env": "dev", "source": "mcp-example"},
})
print(f"Tool: queue_send")
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
async function main() {
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({
name: "queue_send",
arguments: {
channel: "example-queue",
body: "Hello from TypeScript MCP",
metadata: "example-metadata",
tags: { env: "dev", source: "mcp-example" },
},
});
console.log(JSON.stringify(result, null, 2));
await client.close();
}
main().catch(console.error);
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import java.util.Map;
public class QueueSend {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(new CallToolRequest(
"queue_send",
Map.of(
"channel", "example-queue",
"body", "Hello from Java MCP",
"metadata", "example-metadata",
"tags", Map.of("env", "dev", "source", "mcp-example")
)
));
System.out.println(result);
client.closeGracefully();
}
}
```
```csharp
using ModelContextProtocol.Client;
class QueueSend
{
static async Task Main(string[] args)
{
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("queue_send", new Dictionary
{
["channel"] = "example-queue",
["body"] = "Hello from C# MCP",
["metadata"] = "example-metadata",
["tags"] = new Dictionary { ["env"] = "dev", ["source"] = "mcp-example" },
});
Console.WriteLine($"Tool: queue_send");
Console.WriteLine($"Result: {result}");
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("queue_send", mapOf(
"channel" to "example-queue",
"body" to "Hello from Kotlin MCP",
"metadata" to "example-metadata",
"tags" to mapOf("env" to "dev", "source" to "mcp-example")
))
println("Tool: queue_send")
println("Result: $result")
client.close()
httpClient.close()
}
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("queue_send", {
"channel" => "example-queue",
"body" => "Hello from Ruby MCP",
"metadata" => "example-metadata",
"tags" => { "env" => "dev", "source" => "mcp-example" },
})
puts "Tool: queue_send"
puts "Result: #{result}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("queue_send", json!({
"channel": "example-queue",
"body": "Hello from Rust MCP",
"metadata": "example-metadata",
"tags": {"env": "dev", "source": "mcp-example"}
})).await?;
println!("Tool: queue_send");
println!("Result: {result:#?}");
Ok(())
}
```
```swift
import Foundation
import MCP
@main
struct QueueSend {
static func main() async throws {
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("queue_send", arguments: [
"channel": "example-queue",
"body": "Hello from Swift MCP",
"metadata": "example-metadata",
"tags": ["env": "dev", "source": "mcp-example"],
])
print("Tool: queue_send")
print("Result: \(result)")
}
}
```
`queue_send` and the event-publishing tools work on their own, but `command_send` and `query_send` need an active subscriber on the target channel, and the `agent_*` bridge tools need agents registered with the server. Without them, those calls time out.
## What's next [#whats-next]
# MCP (/aiway/mcp)
The **MCP connector** exposes KubeMQ messaging operations as
[Model Context Protocol](https://modelcontextprotocol.io) tools, so Claude and other AI
models can publish, subscribe, and call KubeMQ — and reach registered agents — without
a KubeMQ-specific client library. It speaks MCP protocol version `2025-11-25` over
JSON-RPC 2.0 at a single endpoint on the [shared HTTP server](/connectors/concepts/shared-http-server).
**Part of Aiway.** MCP is one of the two doors into
[KubeMQ Aiway](/aiway), the AI Agents Fabric. New here? Start with the
[Aiway overview](/aiway), or follow the end-to-end
[Aiway tutorial](/aiway/tutorial).
## What MCP is [#what-mcp-is]
The Model Context Protocol is an open standard for connecting AI models to external
tools and data over a uniform JSON-RPC 2.0 interface. A model's MCP client connects to
an MCP **server**, asks it which tools it offers (`tools/list`), and invokes them by
name (`tools/call`). KubeMQ is one such server: every KubeMQ messaging operation is
published as a named MCP tool.
Because the protocol is standard, any MCP-aware model or runtime can use KubeMQ with no
KubeMQ code on the caller. You point the official MCP SDK for your language — or a
client like Claude Desktop — at the endpoint, and the model gains messaging, persistent
events, request/reply, channel introspection, and a bridge to A2A agents as native
tools.
## Why MCP with KubeMQ [#why-mcp-with-kubemq]
* **No KubeMQ SDK on the caller** — clients use the official MCP SDK for their
language; KubeMQ is just an MCP server they connect to.
* **One endpoint** — `POST /mcp` for JSON-RPC requests (single or batch), plus
`GET /mcp` for a keepalive SSE stream, both on the shared HTTP port `9090`.
* **15 ready-to-use tools** — 11 core messaging tools plus 4 agent-bridge tools,
covering every KubeMQ pattern.
* **A bridge to agents** — the model can list, inspect, and message agents registered
with the [A2A connector](/aiway/a2a) through the same tool interface.
* **Enabled by default** — start kubemq-server and `/mcp` is live; there is no flag to
turn it on.
## How it works [#how-it-works]
An MCP client connects to the `/mcp` endpoint, completes the `initialize` handshake to
obtain a session, then calls tools. The connector translates each tool call into a
native KubeMQ operation; agent-bridge tools forward over the broker to the A2A registry.
*The MCP connector turns JSON-RPC tool calls into KubeMQ operations and bridges to A2A agents.*
The connector runs on the [shared HTTP server](/connectors/concepts/shared-http-server) and
inherits its middleware, [authentication](/aiway/mcp/guides/authentication),
and [observability](/connectors/concepts/observability). The reserved `_AGENTS_.` channel
prefix is rejected for direct messaging tools — use the agent-bridge tools to reach
agents.
## The 15 tools [#the-15-tools]
KubeMQ exposes 15 MCP tools across five categories — 11 core messaging tools that are
always available, plus 4 agent-bridge tools that appear when the agent registry is
present.
| Category | Tools | Count |
| ------------------ | ----------------------------------------------------------------------------------------- | -----: |
| Queue | `queue_send`, `queue_receive`, `queue_peek` | 3 |
| Events | `events_publish`, `events_store_publish`, `events_store_read`, `events_store_read_latest` | 4 |
| Command / Query | `command_send`, `query_send` | 2 |
| Channel management | `channel_list`, `channel_info` | 2 |
| Agent bridge | `agent_list`, `agent_info`, `agent_send`, `agent_query` | 4 |
| **Total** | | **15** |
## Endpoint surface [#endpoint-surface]
| Method | Path | Description |
| ------ | ------ | ---------------------------------------------- |
| `POST` | `/mcp` | JSON-RPC 2.0 request handler (single or batch) |
| `GET` | `/mcp` | SSE keepalive stream |
JSON-RPC methods on `POST /mcp`: `initialize`, `notifications/initialized`, `ping`,
`tools/list`, and `tools/call`. The `MCP-Protocol-Version` response header is always
`2025-11-25`, and `MCP-Session-Id` carries the session returned by `initialize`. See
the [endpoints reference](/aiway/mcp/reference/endpoints) for full signatures.
## Discover the tools [#discover-the-tools]
The `tools/list` method returns every available tool with its `name`, `description`, and
`inputSchema`. It is the first call after `initialize` — it tells the model what KubeMQ
can do.
```bash
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-11-25" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}'
```
```csharp
using ModelContextProtocol.Client;
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var tools = await client.ListToolsAsync();
foreach (var tool in tools)
{
Console.WriteLine($"{tool.Name}: {tool.Description}");
}
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
if err != nil {
log.Fatal(err)
}
for _, tool := range result.Tools {
fmt.Printf("%s: %s\n", tool.Name, tool.Description)
}
}
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
public class ToolsList {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var tools = client.listTools();
System.out.println(tools);
client.closeGracefully();
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val tools = client.listTools()
println(tools)
client.close()
httpClient.close()
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
for tool in tools.tools:
print(f"{tool.name}: {tool.description}")
if __name__ == "__main__":
asyncio.run(main())
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
tools = client.list_tools
puts tools
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let tools = client.list_tools(Default::default()).await?;
println!("{tools:#?}");
Ok(())
}
```
```swift
import Foundation
import MCP
@main
struct ToolsList {
static func main() async throws {
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let (tools, _) = try await client.listTools()
for tool in tools {
print("\(tool.name): \(tool.description ?? "")")
}
}
}
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
async function main() {
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const tools = await client.listTools();
console.log(JSON.stringify(tools, null, 2));
await client.close();
}
main().catch(console.error);
```
## Supported languages [#supported-languages]
Every operation has a `curl` example plus the official MCP SDK in nine languages. The
SDK wraps the Streamable HTTP transport — session IDs, request IDs, and JSON-RPC
serialization are handled for you.
| Language | MCP SDK package | Source |
| --------------- | ------------------------------------ | --------------------- |
| C# | `ModelContextProtocol` | NuGet |
| Go | `github.com/mark3labs/mcp-go` | Go modules |
| Java | `io.modelcontextprotocol:sdk` | Maven Central |
| Kotlin | `io.modelcontextprotocol:kotlin-sdk` | Maven Central |
| Python | `mcp` | PyPI |
| Ruby | `mcp` | RubyGems |
| Rust | `rmcp` | crates.io |
| Swift | `mcp-swift-sdk` | Swift Package Manager |
| TypeScript / JS | `@modelcontextprotocol/sdk` | npm |
## Next steps [#next-steps]
# 2. Build & register an agent (/aiway/tutorial/build-and-register-an-agent)
In [step 1](/aiway/tutorial/setup) you started KubeMQ and confirmed the Aiway
endpoints are live with an empty agent roster. Now you'll build the **research agent**
the rest of this tutorial discovers, invokes, and streams — a plain HTTP server that
speaks JSON-RPC 2.0, with **no KubeMQ SDK**. KubeMQ's per-agent
[Agent Bridge](/aiway/a2a/architecture) (a virtual subscriber) does all the broker
work; your agent only answers HTTP POSTs.
This page keeps the agent minimal so you can follow the end-to-end story. For the full
agent contract — every method, header forwarding, and the lifecycle endpoints — see
[Building agents](/aiway/a2a/guides/building-agents).
The agent listens on its own port (`18080` below). It **registers** with — and is
invoked through — the Aiway gateway on the shared HTTP port `9090`. Keep both running.
## Write the research agent [#write-the-research-agent]
The agent handles two JSON-RPC methods on a single `POST /` route:
* **`message/send`** — returns one synchronous result (used in
[step 3](/aiway/tutorial/discover-and-invoke)).
* **`message/stream`** — emits Server-Sent Events: three `task.status` updates
(`searching` → `reading` → `summarizing`), then a `task.artifact`, then a terminal
`task.done` (consumed in [step 4](/aiway/tutorial/stream-live-results)).
The `task.artifact` envelope follows the wire format in
[SSE behavior](/aiway/a2a/guides/sse-behavior) — `type: "artifact"` with a
`payload` carrying a `name` and `data`. [Building agents](/aiway/a2a/guides/building-agents)
shows only the `status` → `done` path, so the artifact shape comes from the SSE
behavior reference.
Save this as `research_agent.py`. It uses only `aiohttp` (server) and `httpx`
(registration) — install them with `pip install aiohttp httpx`.
```python
"""A plain-HTTP research agent — no KubeMQ SDK."""
import asyncio
import json
import signal
import httpx
from aiohttp import web
KUBEMQ_URL = "http://localhost:9090" # Aiway gateway (register + invoke)
AGENT_ID = "research-agent-01"
AGENT_PORT = 18080 # the agent's own HTTP port
def extract_query(body: dict) -> str:
"""Pull the caller's text out of the JSON-RPC params."""
parts = body.get("params", {}).get("message", {}).get("parts", [])
return " ".join(p.get("text", "") for p in parts).strip() or "(empty query)"
async def handle_send(body: dict) -> web.Response:
"""message/send — return one synchronous JSON-RPC result."""
query = extract_query(body)
return web.json_response({
"jsonrpc": "2.0",
"id": body.get("id"),
"result": {
"summary": f"Research summary for: {query}",
"sources": 3,
},
})
async def handle_stream(request: web.Request, body: dict) -> web.StreamResponse:
"""message/stream — emit status events, an artifact, then done over SSE."""
query = extract_query(body)
resp = web.StreamResponse(
status=200,
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
)
await resp.prepare(request)
stages = ["searching", "reading", "summarizing"]
for i, status in enumerate(stages, start=1):
event = json.dumps({
"type": "status_update",
"payload": {"status": status, "progress": i, "total": len(stages)},
})
await resp.write(f"event: task.status\ndata: {event}\n\n".encode())
await asyncio.sleep(0.5)
# Intermediate artifact — envelope per the SSE behavior reference.
artifact = json.dumps({
"type": "artifact",
"payload": {
"name": "summary.json",
"data": {"summary": f"Research summary for: {query}", "sources": 3},
},
})
await resp.write(f"event: task.artifact\ndata: {artifact}\n\n".encode())
# Terminal event — stop reading after this.
done = json.dumps({"type": "done", "payload": {"final_result": "completed", "event_count": 4}})
await resp.write(f"event: task.done\ndata: {done}\n\n".encode())
await resp.write_eof()
return resp
async def handle_request(request: web.Request) -> web.StreamResponse:
body = await request.json()
if body.get("method") == "message/stream":
return await handle_stream(request, body)
return await handle_send(body)
async def register_agent() -> None:
card = {
"agent_id": AGENT_ID,
"name": "Research Agent",
"description": "Searches, reads, and summarizes — a mock LLM research agent.",
"version": "1.0.0",
"url": f"http://localhost:{AGENT_PORT}/",
"skills": [
{
"id": "research",
"name": "Research",
"description": "Searches sources and returns a summary.",
"tags": ["research", "summarize"],
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"],
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card)
print(f"Registered: {resp.status_code}")
print(json.dumps(resp.json(), indent=2))
async def main() -> None:
app = web.Application()
app.router.add_post("/", handle_request)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", AGENT_PORT)
await site.start()
print(f"Research agent listening on port {AGENT_PORT}")
# Always start the server BEFORE registering — KubeMQ may route the moment
# registration succeeds.
await register_agent()
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
await stop.wait()
await runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())
```
Run it in one terminal and leave it running:
```bash
python research_agent.py
```
It prints `Research agent listening on port 18080` and then the registration response.
## Register the Agent Card [#register-the-agent-card]
The script above self-registers on startup, but you can register (or re-register) any
agent with a plain `curl` — useful for a sidecar agent or a language not shown here. The
body is the **Agent Card**: `agent_id`, `url`, `name`, and the `skills` array, with
`protocolVersions: ["1.0"]` (this is the A2A card protocol version — not the MCP session
version you'll see in [step 5](/aiway/tutorial/orchestrate-from-an-llm)).
```bash
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "research-agent-01",
"name": "Research Agent",
"description": "Searches, reads, and summarizes — a mock LLM research agent.",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [
{"id": "research", "name": "Research", "description": "Searches sources and returns a summary.", "tags": ["research", "summarize"]}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"]
}'
```
A `200` response echoes the stored card, enriched with server-set `registered_at` and
`last_seen` timestamps. See the [agent registry](/aiway/a2a/registry) for the full
card schema and the [agent cards](/aiway/a2a/agent-cards) reference for every field.
## Confirm it's in the roster [#confirm-its-in-the-roster]
In step 1 `GET /agents` returned an empty list. Now it returns your agent:
```bash
curl http://localhost:9090/agents
```
```json
[
{
"agent_id": "research-agent-01",
"name": "Research Agent",
"url": "http://localhost:18080/",
"skills": [
{"id": "research", "name": "Research", "tags": ["research", "summarize"]}
],
"protocolVersions": ["1.0"],
"registered_at": "2026-06-26T10:00:00Z",
"last_seen": "2026-06-26T10:00:00Z"
}
]
```
## Keep it alive (heartbeat & TTL) [#keep-it-alive-heartbeat--ttl]
The registry expires agents that go silent. A background liveness checker removes any
agent whose `last_seen` is older than the TTL (`AgentTTLSeconds`, default **300s**). To
stay registered, an agent must heartbeat (or re-register) within that window — a beat
every 30–60s gives plenty of margin:
```bash
curl -X POST http://localhost:9090/agents/heartbeat \
-H "Content-Type: application/json" \
-d '{"agent_id": "research-agent-01"}'
```
For this tutorial the agent stays up the whole time, so you won't hit the TTL — but a
production agent should heartbeat on a timer and deregister on shutdown. The
[agent registry](/aiway/a2a/registry) covers heartbeat, TTL, deregistration, and
ownership in full.
## Next step [#next-step]
Your research agent is live in the fabric. Next, find it by capability and call it
synchronously.
# 3. Discover & invoke (/aiway/tutorial/discover-and-invoke)
In [Step 2](/aiway/tutorial/build-and-register-an-agent) you registered the
`research-agent-01` agent with a `research` skill. Now you'll act as a **caller**: first
discover the agent by its capability (no need to know its ID up front), then invoke it
synchronously and read the result back in a single round-trip.
This step assumes KubeMQ is running on port `9090` and `research-agent-01` is already
registered (Steps 1 and 2). Verify with `curl http://localhost:9090/agents` — the agent
should appear in the roster.
## Discover by capability [#discover-by-capability]
Callers don't have to hard-code agent IDs. The registry lets you find agents by **skill
tag**: `GET /agents?skill_tags=research` returns every registered agent that advertises
the `research` skill as a bare JSON array of agent cards. This is capability-based
discovery — ask for *what you need done*, get back *who can do it*.
```bash
curl "http://localhost:9090/agents?skill_tags=research"
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
async def main() -> None:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{KUBEMQ_URL}/agents",
params={"skill_tags": "research"},
)
agents = resp.json()
for agent in agents:
skills = [s["id"] for s in agent.get("skills", [])]
print(f" {agent['agent_id']}: skills={skills}, url={agent['url']}")
if __name__ == "__main__":
asyncio.run(main())
```
The response includes `research-agent-01` — its `agent_id`, `url`, and advertised
`skills`:
```json
[
{
"agent_id": "research-agent-01",
"name": "Research Agent",
"url": "http://localhost:18080/",
"skills": [
{
"id": "research",
"name": "Research",
"description": "Searches sources and returns a summary.",
"tags": ["research", "summarize"]
}
],
"protocolVersions": ["1.0"]
}
]
```
`skill_tags` accepts a comma-separated list (`?skill_tags=research,summarize`) and is
matched in memory after the roster is fetched. See the
[Agent registry](/aiway/a2a/registry) for the full list/filter API.
## Invoke synchronously [#invoke-synchronously]
Once you know the agent's ID, invoke it by POSTing a JSON-RPC 2.0 `message/send` envelope
to `POST /a2a/research-agent-01`. The gateway routes the request to the agent through its
[Agent Bridge](/aiway/a2a/architecture) (the per-agent virtual subscriber) and
relays the agent's reply on the **same HTTP response** — no SSE, no polling.
```bash
curl -X POST http://localhost:9090/a2a/research-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Summarize the history of message queues"}]
}
}
}'
```
```python
import asyncio
import json
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "research-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "Summarize the history of message queues"}],
},
},
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
data = resp.json()
print(json.dumps(data, indent=2))
if "error" in data:
print(f"\nAgent returned an error: {data['error']['message']}")
else:
print("\nGot a synchronous result from the agent.")
if __name__ == "__main__":
asyncio.run(main())
```
The agent's JSON-RPC `result` comes back verbatim under `result`:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"summary": "Research summary for: Summarize the history of message queues",
"sources": 3
}
}
```
## Header forwarding & error handling [#header-forwarding--error-handling]
A couple of behaviors are worth knowing before you build real callers:
* **Header forwarding** — any request header you send prefixed with `X-` is forwarded to
the agent (hop-by-hop and sensitive headers like `Authorization` and `Cookie` are
stripped), and the gateway always injects `X-KubeMQ-Caller-ID` so the agent knows who
originated the call. Use this to pass tracing or tenant context through to your agent.
* **Transport vs application errors** — when a response carries an `error` object, KubeMQ
distinguishes two classes:
* **Transport errors** — the agent never processed the request (unreachable, timeout,
`502`/`503`/`504`, or an oversized reply). Internally these surface as
`Executed: false`, and they are **safe to retry**.
* **Application errors** — the agent processed the request and chose to return a
JSON-RPC error (`Executed: true`). Retrying the same request usually won't help —
fix the request instead.
Tell them apart so your retry logic only re-sends calls that never reached the agent.
For the full `message/send` envelope (context IDs, custom methods), header-forwarding
rules, and the complete error-code list, see
[Synchronous messaging](/aiway/a2a/sync-messaging) and the
[Agent registry](/aiway/a2a/registry).
## Next step [#next-step]
You've discovered and invoked your agent in one round-trip. Next, switch from a single
reply to a live stream of task events as the agent works.
# Tutorial (/aiway/tutorial)
Tutorial = the end-to-end *fabric* story (build → discover → stream → LLM orchestration); the A2A and MCP **Getting started** pages are single-gateway quickstarts. New here? This page is the guided path.
Across five steps you start KubeMQ, build and register a plain-HTTP "research" agent with **no KubeMQ SDK**, discover it by capability and invoke it synchronously, stream its live task events over SSE, and finally let an LLM discover and call that same agent over MCP — proving the "one fabric" thesis end-to-end. Every step uses Python and `curl`.
## What you'll build [#what-youll-build]
By the last step, an LLM host reaches your custom research agent through the MCP→A2A bridge — discovering it with `agent_list` and invoking it with `agent_send`, all over the message broker.
*The LLM discovers and invokes the research agent you built — through MCP, Aiway, and the message broker.*
## Prerequisites [#prerequisites]
* **Docker** (to run KubeMQ locally) — or a KubeMQ server already running with the shared HTTP server on port `9090`.
* **Python 3.11+** for the agent server and the clients.
* **`curl`** for probing the Aiway endpoints.
No KubeMQ SDK is required anywhere in this tutorial. The agent is a plain HTTP service, and every call to the fabric is JSON over HTTP.
## The steps [#the-steps]
Follow the steps in order — each one builds on the last.
# 5. Orchestrate from an LLM (MCP) (/aiway/tutorial/orchestrate-from-an-llm)
In [Step 2](/aiway/tutorial/build-and-register-an-agent) you built and registered
`research-agent-01`, and in Steps [3](/aiway/tutorial/discover-and-invoke) and
[4](/aiway/tutorial/stream-live-results) you discovered and invoked it as an A2A
caller. Now you'll put an **LLM** in the driver's seat: an MCP client connects to the
same KubeMQ server, discovers your agent, and calls it — all through the **Model Context
Protocol**, with no new glue code on your side. This is the **MCP→A2A bridge**: the LLM
reaches the exact agent you built in Step 2.
This step assumes KubeMQ is running on port `9090` and `research-agent-01` is registered
and listening (Steps 1 and 2). The MCP connector is **enabled by default** — `POST /mcp`
is already live on the shared HTTP server. For the Python path, install the MCP SDK with
`pip install mcp`.
## Connect and list the tools [#connect-and-list-the-tools]
An MCP client opens a session against `POST http://localhost:9090/mcp`, negotiates the
protocol version (`2025-11-25`), then calls `tools/list`. KubeMQ exposes **15 tools** —
**11 core messaging tools** plus **4 agent-bridge tools** (`agent_list`, `agent_info`,
`agent_send`, `agent_query`) that appear when the A2A agent registry is available. The
agent-bridge tools are what let the LLM reach your agent.
The MCP session version `2025-11-25` is a **different** version from the A2A Agent Card's
`protocolVersions: ["1.0"]` you registered in
[Step 2](/aiway/tutorial/build-and-register-an-agent) — one is the MCP handshake,
the other is the agent's A2A card version. They are not interchangeable.
First open a session with `initialize`, then list the tools with the returned session ID:
```bash
# 1. Initialize a session — note the protocol version is the MCP one.
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "tutorial", "version": "1.0.0"}
}
}'
# 2. List the tools (substitute the session ID from the response above).
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-Id: " \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}'
```
The `mcp` SDK runs the `initialize` handshake and tracks the session ID for you over the
Streamable HTTP transport — you only call `tools/list`:
```python
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
KUBEMQ_MCP_URL = "http://localhost:9090"
async def main() -> None:
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = [t.name for t in tools.tools]
print(f"{len(names)} tools available") # 15: 11 core + 4 agent-bridge
print("agent-bridge tools:", [n for n in names if n.startswith("agent_")])
if __name__ == "__main__":
asyncio.run(main())
```
The tool list includes the four agent-bridge tools alongside the 11 core messaging tools:
```json
["agent_list", "agent_info", "agent_send", "agent_query"]
```
## Discover the agent with agent\_list [#discover-the-agent-with-agent_list]
`agent_list` reads the same A2A registry you queried in
[Step 3](/aiway/tutorial/discover-and-invoke) — but now through an MCP tool call.
Pass `skill_tags` to filter by capability, exactly as before, and the LLM gets back
`research-agent-01` without ever hard-coding its ID.
```bash
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-Id: " \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "agent_list",
"arguments": {"skill_tags": ["research"]}
}
}'
```
```python
result = await session.call_tool("agent_list", {"skill_tags": ["research"]})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
The result text is a JSON array of agent summaries — your agent is in it:
```json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{"type": "text", "text": "[{\"agent_id\":\"research-agent-01\",\"name\":\"Research Agent\",\"skills\":[{\"id\":\"research\",\"name\":\"Research\",\"tags\":[\"research\",\"summarize\"]}]}]"}],
"isError": false
}
}
```
## Invoke the agent with agent\_send [#invoke-the-agent-with-agent_send]
`agent_send` wraps your message in an A2A `message/send` envelope and forwards it through
the per-agent [Agent Bridge](/aiway/a2a/architecture) — the same path the direct A2A
call took in [Step 3](/aiway/tutorial/discover-and-invoke), just initiated from MCP.
By default the call is **blocking**: it waits up to `timeout_seconds` for the agent's
reply (**default `60`, max `300`**). Pass `blocking: false` for fire-and-forget, or
`context_id` to thread the message into an existing conversation.
```bash
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-Id: " \
-d '{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "agent_send",
"arguments": {
"agent_id": "research-agent-01",
"message": "Summarize the history of message queues",
"timeout_seconds": 60
}
}
}'
```
```python
result = await session.call_tool("agent_send", {
"agent_id": "research-agent-01",
"message": "Summarize the history of message queues",
"timeout_seconds": 60,
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
The agent's reply comes back in the `content` text — the same result your Step 2 agent
returns from `message/send`, now delivered to the LLM:
```json
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [{"type": "text", "text": "{\"summary\":\"Research summary for: Summarize the history of message queues\",\"sources\":3}"}],
"isError": false
}
}
```
An unknown `agent_id` returns `isError: true` with `Agent '' not found` — a tool-level
error in the `content` block, not a JSON-RPC protocol error. The bridge also adds a
gateway timeout buffer on top of your `timeout_seconds`. See
[Agent-bridge tools](/aiway/mcp/tools/agent-bridge) for `agent_info`, `agent_query`,
and the full argument and error reference.
## One fabric, no glue code [#one-fabric-no-glue-code]
Step back and notice what just happened. The LLM **discovered** your agent by capability
and **invoked** it — and you wrote no integration code to make that possible. The same
`research-agent-01` you built in Step 2 (a plain-HTTP service, no KubeMQ SDK) is now
reachable by an A2A caller *and* by any MCP-speaking LLM, over the same message broker.
That's the Aiway thesis: **one fabric, two doors** — register once, reach it from both.
To wire this into a real LLM host such as Claude Desktop — a `claude_desktop_config.json`
entry that points at `http://localhost:9090/mcp` — see
[MCP Getting started](/aiway/mcp/getting-started). The host then sees all 15 tools
(including the four agent-bridge tools) and can call your agent from a conversation.
## Where to go next [#where-to-go-next]
You've built the full fabric end-to-end: started KubeMQ, registered a zero-SDK agent,
discovered and invoked it, streamed live task events, and orchestrated it from an LLM over
MCP. From here, go deeper:
# 1. Set up & start KubeMQ (/aiway/tutorial/setup)
This first step gets KubeMQ running locally and confirms the two Aiway doors — the **A2A** agent gateway and the **MCP** gateway — are live on the shared HTTP server (port `9090`). Both are enabled by default, so there is nothing to turn on.
Need Docker? Get it from [docker.com/get-started](https://www.docker.com/get-started/). Already have a KubeMQ server reachable on port `9090`? Skip straight to the probes in Step 2.
## Setup steps [#setup-steps]
### Start KubeMQ [#start-kubemq]
Run KubeMQ locally with a single Docker command. This starts the broker with gRPC on port `50000`, the shared HTTP server (where the A2A and MCP gateways live) on port `9090`, and the dashboard on port `8080`.
Confirm the dashboard is up at `http://localhost:8080`, then move on to verify the Aiway endpoints.
### Verify the A2A agent roster [#verify-the-a2a-agent-roster]
The A2A gateway exposes the agent registry at `GET /agents`. On a fresh server the roster is empty — that is exactly what you want to see before registering an agent in the next step.
```bash title="Terminal"
curl http://localhost:9090/agents
```
You should get back an empty roster:
```json
{
"agents": []
}
```
An empty list confirms the A2A gateway is live and ready to accept registrations. You'll register `research-agent-01` here in Step 2.
### Verify the MCP gateway [#verify-the-mcp-gateway]
The MCP gateway answers at `POST /mcp`. Open a session with a JSON-RPC `initialize` call — this negotiates the MCP protocol version (`2025-11-25`) and proves the gateway is reachable.
```bash title="Terminal"
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "aiway-tutorial", "version": "1.0.0" }
}
}'
```
A successful handshake returns the server's protocol version and capabilities:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": { "tools": {} },
"serverInfo": { "name": "kubemq-mcp", "version": "1.0.0" }
}
}
```
The MCP **session** protocol version (`2025-11-25`) is a different field from the A2A **Agent Card** `protocolVersions` (which is `["1.0"]`, used when you register an agent in Step 2). They are two distinct layers — don't conflate them.
With KubeMQ running and both Aiway gateways answering, you're ready to build an agent and register it with the fabric.
# 4. Stream live results (/aiway/tutorial/stream-live-results)
In [step 3](/aiway/tutorial/discover-and-invoke) you called `research-agent-01` with
`message/send` and got a single reply. Real research takes time, though — and your agent
already emits progress as it works. In this step you'll switch to **`message/stream`** and
watch the task unfold live: status updates as the agent searches, reads, and summarizes,
then the result artifact, then a terminal "done" — all over a single **Server-Sent Events
(SSE)** connection.
This step assumes KubeMQ is running on port `9090` and `research-agent-01` is registered
and listening (Steps 1 and 2). All calls go to the **Aiway gateway** on `9090`, not to the
agent's own port — the gateway relays the agent's events back to you.
## How streaming works [#how-streaming-works]
`message/send` returns one reply on the same HTTP response. `message/stream` instead opens
a long-lived `text/event-stream` connection: the gateway uses the agent's
[Agent Bridge](/aiway/a2a/architecture) (its per-agent virtual subscriber) as an
**SSE relay**, forwarding each event the agent emits to you as it happens. You read frames
until a terminal event closes the stream.
Each frame has an `event:` name and a JSON `data:` envelope. The agent's envelope `type`
maps to the SSE event name you see:
| SSE event | Envelope `type` | Meaning | Terminal |
| --------------- | --------------- | ---------------------------------------------- | -------- |
| `task.status` | `status_update` | Progress update (non-terminal) | No |
| `task.artifact` | `artifact` | A partial or complete result artifact | No |
| `task.done` | `done` | The task completed successfully | Yes |
| `task.error` | `error` | The task failed (carries `code` and `message`) | Yes |
The `research-agent-01` agent from [step 2](/aiway/tutorial/build-and-register-an-agent)
emits, in order: three `task.status` updates (`searching` → `reading` → `summarizing`),
one `task.artifact`, then a terminal `task.done`. Stop reading on `task.done` (or
`task.error`). The exact wire format and envelope shapes are in
[SSE behavior](/aiway/a2a/guides/sse-behavior).
## Open a stream [#open-a-stream]
Send a `message/stream` request to `POST /a2a/research-agent-01` with
`Accept: text/event-stream`. The body is a JSON-RPC 2.0 envelope identical to `message/send`,
just with the `message/stream` method. (You can also open a stream with
`GET /a2a/research-agent-01/stream` — the POST form is the common case.)
```bash
curl -N -X POST http://localhost:9090/a2a/research-agent-01 \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"message": {
"parts": [{"text": "Summarize the history of message queues"}]
}
}
}'
```
The `-N` flag disables curl's output buffering so frames print the instant they arrive.
This uses `httpx` with the `httpx-sse` helper for clean SSE parsing — install both with
`pip install httpx httpx-sse`.
```python
import asyncio
import json
import httpx
from httpx_sse import aconnect_sse
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "research-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"message": {"parts": [{"text": "Summarize the history of message queues"}]},
},
}
async with httpx.AsyncClient(timeout=None) as client:
print("Connecting to SSE stream...")
async with aconnect_sse(
client,
"POST",
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json=payload,
headers={"Accept": "text/event-stream"},
) as event_source:
async for event in event_source.aiter_sse():
data = json.loads(event.data)
print(f"[{event.event}] {json.dumps(data)}")
# task.done and task.error are terminal — stop reading.
if event.event in ("task.done", "task.error"):
break
print("Stream complete.")
if __name__ == "__main__":
asyncio.run(main())
```
The `httpx-sse` library skips keepalive comment lines for you and yields one object per
event with `.event` (the name) and `.data` (the JSON payload).
## Read the live event sequence [#read-the-live-event-sequence]
As the agent works, the frames arrive in order. You'll see the three status updates, then
the artifact, then the terminal `task.done`:
```text
Connecting to SSE stream...
[task.status] {"type": "status_update", "payload": {"status": "searching", "progress": 1, "total": 3}}
[task.status] {"type": "status_update", "payload": {"status": "reading", "progress": 2, "total": 3}}
[task.status] {"type": "status_update", "payload": {"status": "summarizing", "progress": 3, "total": 3}}
[task.artifact] {"type": "artifact", "payload": {"name": "summary.json", "data": {"summary": "Research summary for: Summarize the history of message queues", "sources": 3}}}
[task.done] {"type": "done", "payload": {"final_result": "completed", "event_count": 4}}
Stream complete.
```
Mapped to the event table above:
* **`task.status`** (`status_update`) — non-terminal progress. The agent emits one per
stage (`searching`, `reading`, `summarizing`) with `progress`/`total` so a UI can show a
bar. There can be any number of these.
* **`task.artifact`** (`artifact`) — the result payload. The envelope carries a `name`
(`summary.json`) and a `data` object with the actual result. An agent can emit several
artifacts; the [artifact envelope is defined in SSE behavior](/aiway/a2a/guides/sse-behavior).
* **`task.done`** (`done`) — **terminal**. After this the gateway closes the connection.
A failed task ends with `task.error` instead, carrying a `code` and `message`. Always
break on either.
## Keepalive, idle timeout & disconnect [#keepalive-idle-timeout--disconnect]
A few stream behaviors matter once you build real callers — all relayed by the gateway,
not the agent:
* **Keepalive** — during quiet periods the gateway emits an SSE comment line
(`: keepalive`) every **30 seconds** so proxies don't drop the idle socket. It's a
comment, not an event; SSE client libraries (including `httpx-sse`) ignore it. If you
parse the stream by hand, skip any line starting with `:`.
* **Idle timeout** — if the agent emits no events for `MaxSSEIdleSeconds` (default
**300s**), the gateway closes the stream with a terminal `task.error` (code `-32001`,
`"stream idle timeout"`). The timer measures the gap *between* events, so a long task
stays alive as long as it heartbeats with periodic `task.status` frames.
* **Auto-cancel on disconnect** — if you close the connection before a terminal event, the
gateway detects it and **cancels the work on the agent** (via a `stream_cancel` to the
Agent Bridge) rather than letting it run to completion. Closing your SSE reader is a real
cancellation signal — it frees the agent's concurrency slot promptly.
For the full streaming walkthrough, see [Streaming (SSE)](/aiway/a2a/streaming). For
the exact wire format, the artifact envelope, keepalive cadence, idle-timeout behavior, and
disconnect cancellation, see [SSE behavior](/aiway/a2a/guides/sse-behavior).
## Next step [#next-step]
You've watched a task stream live from `task.status` through `task.artifact` to a terminal
`task.done`. So far every call has been a hand-written HTTP request. In the final step, an
**LLM** does the discovering and invoking for you — over MCP, through the same fabric.
# Docker (single-node) (/configure/docker)
Docker single-node **is** the standalone KubeMQ server, containerized. One container
runs the full server: every interface, every connector, the persistent store, and the
embedded messaging engine. There is no separate "bare-binary" target — the container
*is* the binary. This guide hosts the complete, runnable Docker configs; the
[reference](/configure/reference) pages show per-setting snippets and link back
here.
**Install KubeMQ first** → [Docker install](/deploy/docker). This
page covers how to *configure* a single node, not how to install one.
| Port | Interface | Purpose |
| ------- | ---------------- | -------------------------------------------------------------------------- |
| `50000` | gRPC | Primary SDK transport. |
| `9090` | REST / WebSocket | REST API, WebSocket, and the shared HTTP server (MCP · A2A · CloudEvents). |
| `8080` | API / Dashboard | Web dashboard, management API, health probes, and metrics. |
The store is bind-mounted to **`/kubemq/store`** inside the container. Persistence is
required for the **Events Store** and **Queues** patterns.
**Two things a persistent store needs, and both are easy to miss.**
**Mount at `/kubemq/store`, not `/store`.** The image's working directory is `/kubemq` and
the default `store.storepath` is the *relative* `./store`, so the server writes to
`/kubemq/store`. A volume mounted at `/store` receives nothing and the data dies with the
container — and every health check on this page still passes, because the server is
running fine, just onto container-local disk.
**Pin the hostname with `--hostname`.** The store layout is
`//…`, and Docker assigns a fresh random hostname to every
container. Recreate the container without `--hostname` and the server boots clean and
healthy onto a *new empty directory*, with the old one sitting intact beside it. On the
`next` engine the node's raft identity moves with it too. Setting KubeMQ's own `HOST` does
**not** substitute — the directory name comes from the OS hostname.
## Three ways to supply config [#three-ways-to-supply-config]
Every setting has a documented default, so a server runs with no overrides at all. To change
a setting you have three delivery methods, in order of how much you are configuring:
### Per-field environment variables [#per-field-environment-variables]
Pass each setting as `-e GROUP_FIELD=value`. The env var is the `config.yaml` key
snake-cased, dotless, and uppercased — `store.maxretention` becomes `STORE_MAX_RETENTION`.
This is the lightest method, best for a handful of overrides:
**The connector acronym variables drop the underscore.** The CloudEvents enable variable
is **`CONNECTORSCE_ENABLE`** (no underscore between `CONNECTORS` and `CE`). The same
collapse applies to `CONNECTORSMCP_*` and `CONNECTORSMQTT_*`, while the Title-case
connectors keep the underscore (`CONNECTORS_AMQP_*`, `CONNECTORS_STOMP_*`,
`CONNECTORS_AWS_*`), and A2A splits to `CONNECTORSA2_A_*`. The full rule is in
[the reference legend](/configure/reference).
**What happens when you get it wrong depends on which form you used, and the warning is
not reliable in either direction:**
| You set | Does it work? | Does the server say anything? |
| ----------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `CONNECTORS_MQTT_ENABLE` (wrong twin of a collapsed name) | **No** | Yes — an `IGNORED` warning, because the name is in the `CONNECTORS_` namespace |
| `CONNECTORS_CE_ENABLE` (CloudEvents only) | **Yes** — CE is the one connector that binds both forms | Yes — an `IGNORED` warning **that is wrong**; the value is applied anyway |
| A typo in a correct collapsed name (`CONNECTORSMQT_ENABLE`) | No | **Nothing at all** — collapsed names sit outside the warner's namespace list |
So: an `IGNORED` warning for a `CONNECTORS_CE_*` variable is a false alarm — do not
"fix" a working setting because of it. And the absence of a warning proves nothing about a
collapsed-form name. Check the effective value in the dashboard rather than trusting the
log either way.
### A mounted config.yaml [#a-mounted-configyaml]
Once you are setting more than a few fields, keep them in a `config.yaml` and mount it into
the container. The server auto-detects YAML or TOML; point it at the file with the
`--config` flag.
```yaml title="config.yaml"
log:
level: 1
store:
storepath: ./store
maxretention: 2880
connectors:
grpc:
enable: true
port: "50000"
rest:
enable: true
port: "9090"
ce:
enable: false
api:
port: "8080"
```
**The license key is not a `config.yaml` field.** It is read straight from the environment
as **`KUBEMQ_TOKEN`** (or passed as the `--key` flag) — there is no viper binding and no
config-file key for it. A `key:` entry in `config.yaml` is the *Helm* spelling; put it here
and the server ignores it and refuses to start unlicensed. Keep the token in the
environment, as every `docker run` on this page does.
Mount the file and select it with `--config`. **The flag goes after the image name *and*
after the binary path:**
```bash title="Terminal"
docker run -d \
--name kubemq \
--hostname kubemq \
-p 50000:50000 \
-p 9090:9090 \
-p 8080:8080 \
-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \
-v "$(pwd)/kubemq-store:/kubemq/store" \
-v "$(pwd)/config.yaml:/kubemq/config.yaml:ro" \
europe-docker.pkg.dev/kubemq/images/kubemq:next \
/kubemq/kubemq-run --config /kubemq/config.yaml
```
**You must repeat `/kubemq/kubemq-run`.** The image declares no `ENTRYPOINT` — only
`CMD ["/kubemq/kubemq-run"]` — so anything you put after the image name **replaces** the
command rather than appending to it. Passing just `--config /kubemq/config.yaml` makes
Docker try to execute `--config` as the binary, and the container never starts.
**`enable` is the Docker toggle — but the default differs by family.** The interfaces and
the HTTP-family connectors (gRPC, REST, API, MCP, A2A, CloudEvents) ship **on by default**;
the wire-protocol connectors (MQTT, AMQP, STOMP, Kafka, AWS, GCP) are **opt-in** and ship
**off**. On Docker you flip either with `enable: true | false` (env `..._ENABLE`) — turn an
always-on interface **off** with `enable: false`, or turn a wire connector **on** with
`enable: true`. The Helm/CRD surface splits the two: the always-on interfaces use an opt-out
`disabled: true`, while the wire connectors use an opt-in `enabled: true` (see
[Kubernetes](/configure/kubernetes)). Same toggles, inverted boolean.
### The CONFIG environment variable [#the-config-environment-variable]
When mounting a file is awkward — orchestrators that inject env vars, CI runners, secret
managers — supply the **whole** config inline through the `CONFIG` environment variable.
The server writes the value to a file and loads it, exactly as if you had passed
`--config`:
```bash title="Terminal"
docker run -d \
--name kubemq \
--hostname kubemq \
-p 50000:50000 \
-p 9090:9090 \
-p 8080:8080 \
-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \
-v "$(pwd)/kubemq-store:/kubemq/store" \
-e CONFIG="$(cat config.yaml)" \
europe-docker.pkg.dev/kubemq/images/kubemq:next
```
`CONFIG` also accepts a **base64-encoded** payload, which avoids newline and quoting
issues when the value passes through a secret store or a templating layer:
```bash title="Terminal"
docker run -d \
--name kubemq \
--hostname kubemq \
-p 50000:50000 \
-p 9090:9090 \
-p 8080:8080 \
-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \
-v "$(pwd)/kubemq-store:/kubemq/store" \
-e CONFIG="$(base64 < config.yaml)" \
europe-docker.pkg.dev/kubemq/images/kubemq:next
```
## docker-compose [#docker-compose]
The same single node as a `docker-compose.yaml`, combining a mounted `config.yaml` with a
couple of per-field environment overrides:
```yaml title="docker-compose.yaml"
services:
kubemq:
image: europe-docker.pkg.dev/kubemq/images/kubemq:next
container_name: kubemq
hostname: kubemq
command: ["/kubemq/kubemq-run", "--config", "/kubemq/config.yaml"]
ports:
- "50000:50000" # gRPC
- "9090:9090" # REST / WebSocket
- "8080:8080" # API / Dashboard
environment:
- KUBEMQ_TOKEN=${KUBEMQ_TOKEN:-}
- LOG_LEVEL=1
volumes:
- kubemq-store:/kubemq/store
- ./config.yaml:/kubemq/config.yaml:ro
restart: unless-stopped
volumes:
kubemq-store:
```
Start it:
```bash title="Terminal"
docker compose up -d
```
To supply the config inline instead of mounting a file, drop the `command` and
`config.yaml` volume and set `CONFIG` (or `CONFIG` as base64) in the `environment` block.
## Configure by domain [#configure-by-domain]
Every setting in the configs above is documented in the reference, grouped by domain. Each
page lists the `config.yaml` key, the environment variable, the type, the default, and the
valid values.
## Verify [#verify]
Confirm the node is up. Open the dashboard at
`http://localhost:8080`, then check the container logs:
```bash title="Terminal"
docker logs kubemq
```
A healthy start logs each interface binding to its port. The health and readiness probes
on the API port confirm the server is accepting traffic:
```bash title="Terminal"
curl http://localhost:8080/health
curl http://localhost:8080/ready
```
## Related [#related]
* [Kubernetes (Helm)](/configure/kubernetes) — the production target with replicas and `Service` exposure.
* [Configuration overview](/configure) — the two targets and the config model.
* [Configuration reference](/configure/reference) — every setting, grouped by domain.
# Kubernetes (Helm) (/configure/kubernetes)
**Install KubeMQ first** → [Helm install](/deploy/kubernetes-helm).
This page covers how to *configure* a KubeMQ cluster.
On Kubernetes, KubeMQ runs through the **operator**. You describe the server with a
`KubemqCluster` custom resource, the operator reconciles it into a StatefulSet, Services,
and configuration, and the **Helm charts** render that resource from your `values.yaml`.
Because the chart writes values straight into the CR, a Helm value path equals the
`KubemqCluster` `spec.*` path with the leading `spec.` removed (`spec.grpc.port` →
`grpc.port`).
This guide is the single source of truth for **complete, runnable** Helm and CRD
configurations. The [Configuration overview](/configure) and the
[reference pages](/configure/reference) show minimal single-setting snippets and
link here.
## Configure with values.yaml [#configure-with-valuesyaml]
For anything beyond the license key, supply a `values.yaml` file. Each value maps **1:1**
to a `KubemqCluster` `spec.*` field — the chart renders your values verbatim into the CR
spec, so there is no separate Helm schema to learn. A complete `values.yaml`:
```yaml title="values.yaml"
# Replace with your license key
key: YOUR_LICENSE_KEY
# High availability: 3 replicas (set standalone: true for a single node)
replicas: 3
standalone: false
# Persistent store
volume:
size: 20Gi
storageClass: fast-ssd
# Interfaces — .port moves the listener + Service port together
grpc:
port: 50000
expose: LoadBalancer
# REST is on by default; MCP / A2A / CloudEvents ride the REST HTTP port
# (disabled: false is the default — shown here only to be explicit)
rest:
disabled: false
port: 9090
expose: ClusterIP
api:
port: 8080
expose: LoadBalancer
# Store limits and retention — enforced on the `legacy` engine only (see callout below)
store:
messagesRetentionMinutes: 1440
maxChannels: 0
# Pod resources
resources:
requestsCpu: "2"
requestsMemory: 4Gi
limitsCpu: "4"
limitsMemory: 8Gi
```
**`store.messagesRetentionMinutes` and the other `store.max*` limits are enforced by the
`legacy` engine only — and a new cluster on a clean store comes up on `next`.** So the
`1440` above does nothing on a default install: native Events Store and Queues channels
have no age, size, or count cap and grow until the disk does. Size by `spec.volume.size`,
or use Kafka topic channels where age eviction matters. See
[Native retention scope](/configure/reference/storage-engines#native-retention-scope).
Install (or upgrade) the cluster with the file:
```bash title="Terminal"
helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster -f values.yaml
```
The same configuration as a `KubemqCluster` CR — apply it directly with `kubectl apply -f`
if you manage the resource yourself rather than through Helm:
```yaml title="kubemqcluster.yaml"
apiVersion: core.k8s.kubemq.io/v1beta1
kind: KubemqCluster
metadata:
name: kubemq-cluster
namespace: kubemq
spec:
key: YOUR_LICENSE_KEY
replicas: 3
standalone: false
volume:
size: 20Gi
storageClass: fast-ssd
grpc:
port: 50000
expose: LoadBalancer
rest:
disabled: false
port: 9090
expose: ClusterIP
api:
port: 8080
expose: LoadBalancer
store:
messagesRetentionMinutes: 1440
maxChannels: 0
resources:
requestsCpu: "2"
requestsMemory: 4Gi
limitsCpu: "4"
limitsMemory: 8Gi
```
**REST is enabled by default on the chart.** The cluster chart ships `rest.disabled: false`
(`spec.rest.disabled: false`), so REST — and the MCP, A2A, and CloudEvents connectors that
ride the **REST HTTP port** — are reachable out of the box. `disabled` is an **opt-out**
boolean: omit the key entirely to leave REST on; set `rest.disabled: true` only to turn REST
off, which also takes MCP, A2A, and CloudEvents offline.
**Version floor.** The first-class `spec.telemetry.*`, `spec.audit.*`, and `spec.http.*`
fields, and the aligned `spec.authentication.*` fields, are present throughout the current
GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with
`kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA
release predates this reference and will **reject** these fields: upgrade to the current
line rather than trying to work out which pre-GA build carried which field.
**On Kubernetes, `.port` moves everything together.** For `grpc`, `rest`, and `api`, setting
`spec..port` makes the operator emit the matching listener env var
(`CONNECTORS_GRPC_PORT` / `CONNECTORS_REST_PORT` / `API_PORT`) **and** set the Kubernetes
`Service` `port`/`targetPort` **and** the container port — the in-pod listener and the
`Service` port move as one. This matches Docker, where the same `*_PORT` setting moves the
actual listener (which you then publish with `-p`) — see the
[Docker guide](/configure/docker).
## Single-node vs HA [#single-node-vs-ha]
The same chart runs both topologies — the difference is `replicas`.
```yaml title="values.yaml"
key: YOUR_LICENSE_KEY
replicas: 1
standalone: true
```
```yaml title="values.yaml"
key: YOUR_LICENSE_KEY
replicas: 3
standalone: false
```
A single replica (or `standalone: true`) runs one non-clustered node — the equivalent of
one Docker container. Three or more replicas give you high availability; the operator wires
up clustering across the pods. For the full deployment and HA reference, see
[Deployment & HA](/configure/reference/deployment).
## Expose interfaces [#expose-interfaces]
Each interface (`grpc`, `rest`, `api`) is fronted by its own Kubernetes `Service`. Control
the `Service` type with `expose` and the published node port with `nodePort`:
```yaml title="values.yaml"
grpc:
expose: LoadBalancer # ClusterIP | NodePort | LoadBalancer
api:
expose: NodePort
nodePort: 32080 # only used with NodePort
```
* **`ClusterIP`** — reachable only inside the cluster (the default for internal-only
interfaces).
* **`NodePort`** — published on every node at `nodePort`; useful for direct access without a
cloud load balancer.
* **`LoadBalancer`** — provisions an external load balancer (cloud environments).
`nodePort` applies only when `expose: NodePort`. On Docker there is no `Service` — publish
ports with `-p` instead (see the [Docker guide](/configure/docker)).
## Zero-config Kafka [#zero-config-kafka]
On a **fresh** cluster, enabling the Kafka connector is the whole story — no separate
engine setting to manage:
```yaml title="values.yaml"
key: YOUR_LICENSE_KEY
replicas: 3
standalone: false
kafka:
enabled: true
```
With the store empty and `store.engine` left unset, the operator auto-selects the `next`
persistence engine at first boot — see
[Storage Engines → Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection)
for the full selection rules.
**The default `replicas: 3` means Kafka producers should use `acks>=1` for durable
writes.** The `next` engine acknowledges a publish only after it's quorum-replicated
across raft peers, so an `acks=0` producer won't see a stalled or leaderless partition.
External reachability needs `spec.kafka.expose` plus an advertised host/port pair — see
[Connectors → Kafka](/configure/reference/connectors#kafka) for the full
listener/TLS/SAN details.
## Configure by domain [#configure-by-domain]
Every server setting — with its type, default, valid values, and both per-target columns —
lives in the domain reference pages.
## Verify [#verify]
Confirm the operator has reconciled the cluster and the pods are ready.
Check the `KubemqCluster` resource and its status:
```bash title="Terminal"
kubectl get kubemqcluster -n kubemq
```
Inspect the full status, including the reconcile phase and any conditions:
```bash title="Terminal"
kubectl describe kubemqcluster kubemq-cluster -n kubemq
```
Confirm the server pods are running:
```bash title="Terminal"
kubectl get pods -n kubemq
```
To reach the dashboard, port-forward the API service and open
`http://localhost:8080`:
```bash title="Terminal"
kubectl port-forward -n kubemq svc/kubemq-cluster 8080:8080
```
## Related [#related]
* [Configuration overview](/configure) — the two targets and the config model.
* [Docker (single-node)](/configure/docker) — the local / dev target.
* [Deployment & HA reference](/configure/reference/deployment) — packaging, replicas, and exposure in full.
* [Install with Helm](/deploy/kubernetes-helm) — the step-by-step install guide.
# Advanced (config.yaml-only) (/configure/reference/advanced)
These are advanced, low-level knobs. The **message-broker engine** (`broker.*`), **runtime
tuning** (`tuning.*`), and **standalone clustering** (`cluster.*`) have **no Helm/CRD path**
— they are Docker / `config.yaml`-only, so their Helm/CRD column is `—` (per the
[reference legend](/configure/reference)). On Kubernetes the operator owns those
concerns. **Routing** is the exception: three of its fields (`data`, `url`, `autoReload`)
map to `spec.routing.*` on the CRD.
## Message-broker engine [#message-broker-engine]
The embedded message-broker engine's internal limits and buffers, configured through the
`broker.*` block. These are Docker / `config.yaml`-only; there is **no Helm/CRD path** (the
operator owns the engine on Kubernetes). Most fields bind to a `BROKER_*` env var — but the
two auto-assigned ports (`port`, `monitoringPort`) have **no env var** and are file-only.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------ | -------------- | ------------------------- | ------------ | ------------------------------------------------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Port | int | auto (free port) | port number | `broker.port` · *(no env var)* | — | Ephemeral — a free port is picked at every startup (`getFreePort()`). Not meant to be pinned; **no `BROKER_PORT` env var exists**. Not validated. |
| Max payload | int (bytes) | `1048576000` (\~1000 MiB) | ≥ 0 | `broker.maxpayload` · `BROKER_MAX_PAYLOAD` | — | Max message payload in **bytes**. Cast to `int32` when applied, so the effective ceiling is 2,147,483,647 bytes (\~2 GiB); larger values overflow. |
| Write deadline | int (ms) | `2000` | ≥ 0 | `broker.writedeadline` · `BROKER_WRITE_DEADLINE` | — | Per-write deadline in **milliseconds**. |
| Max connections | int | `0` (unlimited) | ≥ 0 | `broker.maxconn` · `BROKER_MAX_CONN` | — | `0` = unlimited. |
| Monitoring port | int | auto (free port) | 1–65535 | `broker.monitoringport` · *(no env var)* | — | Ephemeral — picked at every startup. **No `BROKER_MONITORING_PORT` env var exists.** This is the only broker field validated as a port. |
| Write buffer size | int (**MB**) | `2` (→ 2 MB) | ≥ 0 | `broker.writebuffersize` · `BROKER_WRITE_BUFFER_SIZE` | — | Value is in **megabytes** — the config value is multiplied by 1024×1024 (`broker.go:52`). `2` means 2 MB, **not** 2 bytes. |
| Read buffer size | int (**MB**) | `10` (→ 10 MB) | ≥ 0 | `broker.readbuffersize` · `BROKER_READ_BUFFER_SIZE` | — | Value is in **megabytes** (×1024×1024, `broker.go:53`). Read-ahead buffer. `10` = 10 MB. |
| Disk sync (s) | int | `5` | ≥ 0 | `broker.disksyncseconds` · `BROKER_DISK_SYNC_SECONDS` | — | Disk flush interval in **seconds**. Lowered from 60 s → 5 s to shrink the data-loss window. |
| Slice max messages | int | `0` (unlimited) | ≥ 0 | `broker.slicemaxmessages` · `BROKER_SLICE_MAX_MESSAGES` | — | `0` = no message-count limit per file slice. |
| Slice max bytes | int64 (**MB**) | `64` (→ 64 MB) | ≥ 0 | `broker.slicemaxbytes` · `BROKER_SLICE_MAX_BYTES` | — | Per-slice ceiling in **megabytes** (×1024×1024, `broker.go:56`). `64` = 64 MB. |
| Slice max age (s) | int | `0` (no rollover) | ≥ 0 | `broker.slicemaxageseconds` · `BROKER_SLICE_MAX_AGE_SECONDS` | — | `0` = no age-based slice rollover. Value in **seconds**. |
| Parallel recovery | int | `4` | **≥ 1** | `broker.parallelrecovery` · `BROKER_PARALLEL_RECOVERY` | — | Recovery worker count. Must be **≥ 1** (`0` or negative is rejected). |
`broker.writeBufferSize`, `broker.readBufferSize`, and `broker.sliceMaxBytes` are expressed
in **megabytes**, not bytes — the raw config value is multiplied by 1024×1024 internally. Set
`readBufferSize: 10` for a 10 MB buffer. `broker.maxPayload`, by contrast, is in **bytes**.
## Runtime tuning [#runtime-tuning]
Optional Go-runtime tuning through the `tuning.*` block. Every zero value means
"auto-detect from the environment." **`TuningConfig` is the one config domain with no env-var
bindings at all** — there are **no `TUNING_*` environment variables** (verified: `tuning.go`'s
`defaultTuningConfig()` makes no `bindViperEnv` call). These keys are therefore **only settable
through a mounted `config.yaml`/TOML file**. There is no Helm/CRD path. For the three fields
that wrap standard Go knobs, the Go runtime independently honors the native `GOGC`,
`GOMEMLIMIT`, and `GOMAXPROCS` env vars.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| --------------------- | ---- | ---------- | ------------ | ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------- |
| GC percent | int | `0` (auto) | ≥ 0 | `tuning.gc_percent` · *(no env var)* | — | Config-file-only. Wraps Go GC frequency; use the native `GOGC` env var instead for env-based tuning. |
| Memory limit (MB) | int | `0` (auto) | ≥ 0 | `tuning.memory_limit_mb` · *(no env var)* | — | Config-file-only. Wraps `GOMEMLIMIT`; use the native `GOMEMLIMIT` env var for env-based tuning. |
| Max procs | int | `0` (auto) | ≥ 0 | `tuning.max_procs` · *(no env var)* | — | Config-file-only. Wraps `GOMAXPROCS`; use the native `GOMAXPROCS` env var for env-based tuning. |
| Pipe init buffer (KB) | int | `0` (auto) | ≥ 0 | `tuning.pipe_init_buf_kb` · *(no env var)* | — | Config-file-only. Internal memory-pipe initial buffer size. |
| Pipe max buffer (KB) | int | `0` (auto) | ≥ 0 | `tuning.pipe_max_buf_kb` · *(no env var)* | — | Config-file-only. Internal memory-pipe maximum buffer size. |
| Allow TLS SHA-1 | bool | `false` | true / false | `tuning.tls_allow_sha1` · *(no env var)* | — | Config-file-only. Permits SHA-1 in TLS cipher suites (legacy interop). |
`tuning.*` keys use `snake_case` in `config.yaml` (they carry explicit `mapstructure` tags —
`gc_percent`, `memory_limit_mb`, etc.), unlike the dotted-camel keys elsewhere. Because there
are **no `TUNING_*` env vars**, a K8s CRD/Helm chart cannot set these today without a custom
mounted config file — prefer the native `GOGC` / `GOMEMLIMIT` / `GOMAXPROCS` container env
vars for GC/memory/CPU tuning.
## Standalone clustering [#standalone-clustering]
Manual standalone clustering for Docker, configured through the `cluster.*` block. Uses the
server's standard **`enable: true/false`** toggle. This is Docker / `config.yaml`-only (env
`CLUSTER_*`) — there is **no Helm/CRD path**. On Kubernetes, high availability is
`spec.replicas` (operator-managed clustering), not `cluster.*`; the operator injects the
`CLUSTER_*` env vars into the StatefulSet pod template itself — see
[Deployment & High Availability](/configure/reference/deployment).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------- | ------ | -------- | ------------ | ------------------------------------ | ------------- | -------------------------------------------------------------------------------------------------- |
| Enable | bool | `false` | true / false | `cluster.enable` · `CLUSTER_ENABLE` | — | Master toggle. When `false`, all other `cluster.*` fields are ignored (validation short-circuits). |
| Cluster name | string | `kubemq` | name string | `cluster.name` · `CLUSTER_NAME` | — | Shared cluster identifier across peers. |
| Cluster port | int | `5228` | 1–65535 | `cluster.port` · `CLUSTER_PORT` | — | Peer-to-peer cluster port. Validated only when `enable: true`. |
| Cluster routes | string | `""` | route list | `cluster.routes` · `CLUSTER_ROUTES` | — | Addresses of peer nodes to connect to. |
| Is seed | bool | `false` | true / false | `cluster.isseed` · `CLUSTER_IS_SEED` | — | Whether this node is a seed node. Struct field is `IsSeed`; config.yaml key is `cluster.isseed`. |
The `next` storage engine uses an additional replication membership plane managed by Dragonboat. Its dedicated `Cluster.Replication.*` configuration fields and `POD_NAME`→`ReplicaID` auto-derivation are documented in the [Storage Engines](/configure/reference/storage-engines) reference page.
## Routing [#routing]
Channel-routing rules, configured through the `routing.*` block. The server uses the standard
**`enable: true/false`** toggle. On the CRD, routing has **no explicit toggle** — the operator
auto-emits `ROUTING_ENABLE=true` whenever `spec.routing.data` **or** `spec.routing.url` is set.
Three fields map to `spec.routing.*`; `enable` and `filePath` are Docker-only (`—`).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------- | ------ | --------- | -------------------- | -------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | `false` | true / false | `routing.enable` · `ROUTING_ENABLE` | — | Master toggle (server side). On the CRD it is implicit — set automatically when `data` or `url` is provided. When `false`, the rest of the block is ignored. |
| Routing data | string | `""` | inline routing rules | `routing.data` · `ROUTING_DATA` | `spec.routing.data` | Inline routing table. Consumed **raw** (not base64-decoded), so the operator emits it as a plain ConfigMap variable. Takes precedence over `filePath` and `url`. |
| Routing URL | string | `""` | URL | `routing.url` · `ROUTING_URL` | `spec.routing.url` | Fetch the routing table from a URL. Validated as a URL when set. Struct field is `URL`; config.yaml key is `routing.url`. Emitted raw. |
| Auto-reload (ms) | int | `0` (off) | ≥ 0 | `routing.autoreload` · `ROUTING_AUTO_RELOAD` | `spec.routing.autoReload` | Reload interval in **milliseconds**. `0` disables periodic reload. CRD field is `int32`, `omitempty` — the operator only emits `ROUTING_AUTO_RELOAD` when non-zero (no CRD-side default). |
| Routing file | string | `""` | file path | `routing.filepath` · `ROUTING_FILE_PATH` | — | Load rules from a file path. Docker / config.yaml-only — **not** exposed on the CRD (superseded by inline `data`). Validated as a filename when set. |
When routing is enabled you must provide **exactly one** source: `data`, `filePath`, or `url`
(enabling routing with none set is a configuration error). Precedence when more than one is
present: `data` → `filePath` → `url`. `autoReload` must be ≥ 0.
## Example [#example]
Set a message-broker buffer. On Docker this is a `config.yaml` key (in **MB**) or a `BROKER_*`
env var; on Kubernetes the broker engine has **no Helm/CRD path**, so the operator owns it.
This is a single-setting snippet — see the [Docker guide](/configure/docker) for complete,
runnable configurations.
```yaml title="config.yaml"
broker:
readBufferSize: 10 # megabytes → 10 MB
```
```yaml title="values.yaml"
# Not available on Helm/CRD — the message-broker engine is config.yaml-only.
# On Kubernetes the operator manages the broker engine.
```
For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG`
variable) see the [Docker guide](/configure/docker).
# Connectors (/configure/reference/connectors)
KubeMQ ships ten connectors — the **MCP** and **A2A (agents)** agent platforms,
**CloudEvents**, and seven wire-protocol connectors: **MQTT**, **AMQP 0.9.1**, **AMQP 1.0**,
**STOMP**, **Kafka**, **AWS** (SQS/SNS), and **GCP Pub/Sub**. The three HTTP-server connectors
(MCP, A2A, CloudEvents) are **on by default**; the **seven wire-protocol connectors are opt-in
(disabled by default)** — each opens a new network port and must be explicitly enabled.
Each setting is shown for both targets — Docker single-node (`config.yaml` key · env var)
and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting
is not available on that surface (it is `config.yaml`/env-var-only — supply it through a
mounted config file or a raw pod env var, never a typed CRD field).
Connector environment-variable prefixes follow the acronym rule: all-caps acronym
segments **drop** the underscore (`CONNECTORSMCP_*`, `CONNECTORSCE_*`, `CONNECTORSMQTT_*`,
`CONNECTORSA2_A_*`), while Title-case segments **keep** it (`CONNECTORS_AMQP_*`,
`CONNECTORS_AMQP10_*`, `CONNECTORS_STOMP_*`, `CONNECTORS_KAFKA_*`, `CONNECTORS_AWS_*`,
`CONNECTORS_GCP_*`). See [the reference legend](/configure/reference) for the full rule
and the silently-ignored wrong twin.
## Enabling and disabling a connector [#enabling-and-disabling-a-connector]
The toggle shape differs by connector type and by target:
* **HTTP-server connectors (MCP, A2A, CloudEvents):** always on by default. Docker turns
them **off** with `enable: false`; Kubernetes/Helm turns them off with `disabled: true`
(omit the key while the connector is on).
* **Wire-protocol connectors (MQTT, AMQP 0.9.1, AMQP 1.0, STOMP, Kafka, AWS, GCP Pub/Sub):**
**disabled by default** (opt-in). Docker turns them **on** with `enable: true`; Kubernetes/Helm
turns them on with `enabled: true` (a positive-sense field — omitting it leaves the connector
off).
The snippets below show turning CloudEvents off (HTTP-server connector, uses `disabled:`) and
enabling MQTT (wire-protocol connector, uses `enabled:`). See the
[Docker guide](/configure/docker) and the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="config.yaml"
connectors:
ce:
enable: false
```
```yaml title="values.yaml"
ce:
disabled: true
```
```bash title="docker run"
docker run -e CONNECTORSMQTT_ENABLE=true ...
```
```yaml title="values.yaml"
mqtt:
enabled: true
```
## Service exposure & session affinity [#service-exposure--session-affinity]
Every wire connector carries the same Kubernetes exposure surface on the CRD, alongside
its protocol settings. These are Kubernetes-only — on Docker you publish a port with
`docker run -p`.
| Field | Type | Default | Valid values | Helm/CRD path | Notes |
| ------------------- | ------------- | ----------- | ----------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Service exposure | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | `spec..expose` | Type of the connector's `Service`. |
| Session affinity | string (enum) | `None` | `None` / `ClientIP` | `spec..sessionAffinity` | Pins a client to one replica. **Required for AWS and GCP** — see the callout below. |
| Node port | int32 | *unset* | 30000–32767 | `spec..nodePort` | Honored **only** when `expose: NodePort`. Unset ⇒ the kernel assigns one, which you cannot configure into a client ahead of the install. |
| TLS node port | int32 | *unset* | 30000–32767 | `spec..tlsNodePort` | Same, for the connector's TLS listener. |
| WebSocket node port | int32 | *unset* | 30000–32767 | `spec.mqtt.wsNodePort` | MQTT only — its WebSocket listener. |
Which connector has which:
| Connector | `expose` | `sessionAffinity` | `nodePort` | `tlsNodePort` | `wsNodePort` |
| ----------- | -------------- | ----------------- | ---------- | ------------- | ------------ |
| MQTT | ✅ | ✅ | ✅ | ✅ | ✅ |
| AMQP 0.9.1 | ✅ | ✅ | ✅ | ✅ | — |
| AMQP 1.0 | ✅ | ✅ | ✅ | ✅ | — |
| STOMP | ✅ | ✅ | ✅ | ✅ | — |
| Kafka | ✅ | ✅ | ✅ | ✅ | — |
| AWS | ✅ | ✅ | ✅ | — | — |
| GCP Pub/Sub | ✅ | ✅ | ✅ | — | — |
| CloudEvents | — (rides REST) | — | — | — | — |
**Session affinity is not optional for the AWS and GCP connectors on a multi-replica
cluster.** Both protocols hand the client a token that only the replica that minted it can
honor:
* **AWS** — an SQS **receipt handle** is bound to the replica that issued it, and it is the
only way to delete a message. A delete that lands on another replica is refused, the
message reappears at the visibility timeout, **and the queue never drains**. A client
holding a single keep-alive connection is pinned by accident and never sees this; it
bites when the connection breaks, or when the client does not pool connections.
* **GCP Pub/Sub** — an **ack id** minted by one replica and acked on another is refused,
with no disruption required to trigger it. Ordered subscriptions additionally need
stickiness to preserve per-key ordering.
Set `sessionAffinity: ClientIP` on both:
```yaml title="values.yaml"
aws:
enabled: true
expose: NodePort
sessionAffinity: ClientIP
nodePort: 30171
gcp:
enabled: true
expose: NodePort
sessionAffinity: ClientIP
nodePort: 32439
```
**`ClientIP` affinity is unreliable when clients share a NAT or egress IP** — every client
behind it looks like one address and lands on one replica. Where an ingress exists, prefer
**cookie-based affinity on the ingress** instead.
**AMQP 0.9.1 and AMQP 1.0 share one listener, and one Service.** Both ride `5672`/`5671`;
enabling both is supported and both are served through the **`-amqp`** Service.
Enabling `amqp10` additionally creates a **`-amqp10`** Service as a
discoverability alias onto that same listener. `expose` and `sessionAffinity` are
**shared** between the two blocks — last one wins — so set them consistently. The operator
raises an `AmqpSessionAffinityConflict` warning event if the two disagree.
**Kafka exposure on a multi-replica cluster needs one address per broker.** Kafka hands
every client an address **per broker** in its Metadata response, so a single Service —
`ClusterIP`, `NodePort`, or `LoadBalancer` alike — is one address that round-robins across
all replicas and cannot address a 3-broker cluster. Setting `kafka.expose: LoadBalancer`
on a multi-replica cluster does **not** give you working external Kafka.
* **In-cluster, any replica count:** leave `spec.kafka.advertisedHost` unset. The operator
derives per-broker addresses from the pods' stable DNS names and gives each pod its own
advertised host. Nothing to configure.
* **External, single replica:** `expose` + `advertisedHost` works.
* **External, multi-replica:** you must provision one client-reachable address per broker
— a Service or LoadBalancer per pod, or a per-pod NodePort — and list them in
`spec.kafka.peers`. The operator does not create per-broker addressing for you. Leave
`advertisedHost` unset in this case: with a peer map each broker advertises itself from
its own entry, and one `advertisedHost` could only ever be right for one of them.
**CloudEvents has no port or Service of its own.** It rides the server's shared HTTP
listener alongside REST, and is reached and exposed through **`spec.rest.expose` /
`spec.rest.nodePort`**. There is deliberately no `spec.ce.expose`.
## MCP [#mcp]
The Model Context Protocol agent platform, served on the shared HTTP server. Env prefix
`CONNECTORSMCP_*` (all-caps `MCP` collapses the underscore after `CONNECTORS`); CRD group
`spec.mcp.*`. This is an **HTTP-family connector — on by default** (opt-out): Docker turns it
off with `enable: false`, Kubernetes/Helm with `spec.mcp.disabled: true`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------- | --------- | ----------- | ---------------------- | -------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
| Enable / disable | bool | `true` (on) | true / false | `connectors.mcp.enable` · `CONNECTORSMCP_ENABLE` | `spec.mcp.disabled` | Inverted boolean: Docker `enable: false` turns it off; Helm `disabled: true` turns it off. |
| Tool timeout (s) | int | `300` | `> 0` | `connectors.mcp.tooltimeoutseconds` · `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS` | `spec.mcp.toolTimeoutSeconds` | Must be positive (rejected if ≤ 0). CRD minimum 1. |
| Trusted origins | string\[] | `["auto"]` | origin list / `"auto"` | `connectors.mcp.trustedorigins` · `CONNECTORSMCP_TRUSTED_ORIGINS` | `spec.mcp.trustedOrigins` | `auto` derives allowed origins from the request host. |
**The MCP env prefix is `CONNECTORSMCP_` — no underscore between `CONNECTORS` and `MCP`.** The
natural `CONNECTORS_MCP_*` form does **not** bind — the server starts, accepts the variable
without error, and silently ignores it. Unlike CloudEvents, MCP has no natural-name alias, so
`CONNECTORSMCP_ENABLE` / `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS` / `CONNECTORSMCP_TRUSTED_ORIGINS`
are the only working names.
## A2A (Agents) [#a2a-agents]
The agent-to-agent platform, served on the shared HTTP server. Env prefix `CONNECTORSA2_A_*`;
the CRD group is `spec.agents.*` (note the group name differs from the connector name). This is
an **HTTP-family connector — on by default** (opt-out): Docker turns it off with `enable: false`,
Kubernetes/Helm with `spec.agents.disabled: true`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------------------- | --------- | ------------------ | ------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Enable / disable | bool | `true` (on) | true / false | `connectors.a2a.enable` · `CONNECTORSA2_A_ENABLE` | `spec.agents.disabled` | Inverted boolean. |
| Agent TTL (s) | int | `300` | `> 0` | `connectors.a2a.agentttlseconds` · `CONNECTORSA2_A_AGENT_TTL_SECONDS` | `spec.agents.agentTtlSeconds` | Must be positive. CRD json tag is `agentTtlSeconds` (lowercase `tl`). |
| Default timeout (s) | int | `300` | `> 0` | `connectors.a2a.defaulttimeoutseconds` · `CONNECTORSA2_A_DEFAULT_TIMEOUT_SECONDS` | `spec.agents.defaultTimeoutSeconds` | Must be positive. |
| Max timeout (s) | int | `3600` | `≥ default timeout` | `connectors.a2a.maxtimeoutseconds` · `CONNECTORSA2_A_MAX_TIMEOUT_SECONDS` | `spec.agents.maxTimeoutSeconds` | Cross-field: must be ≥ Default timeout (enforced server-side, not by the CRD schema). |
| Max agents | int | `0` | `≥ 0` (`0` = unlimited) | `connectors.a2a.maxagents` · `CONNECTORSA2_A_MAX_AGENTS` | `spec.agents.maxAgents` | `0` = unlimited. |
| Max SSE idle (s) | int | `300` | `> 0` | `connectors.a2a.maxsseidleseconds` · `CONNECTORSA2_A_MAX_SSE_IDLE_SECONDS` | `spec.agents.maxSseIdleSeconds` | Must be positive. CRD json tag is `maxSseIdleSeconds`. |
| Trusted origins | string\[] | `["auto"]` | origin list / `"auto"` | `connectors.a2a.trustedorigins` · `CONNECTORSA2_A_TRUSTED_ORIGINS` | `spec.agents.trustedOrigins` | |
| Agent max response (bytes) | int64 | `10485760` (10 MB) | `≥ 0` | `connectors.a2a.agentmaxresponsebytes` · `CONNECTORSA2_A_AGENT_MAX_RESPONSE_BYTES` | `spec.agents.agentMaxResponseBytes` | Caps a downstream agent's response body. |
| Agent TLS skip verify | bool | `false` | true / false | `connectors.a2a.agenttlsskipverify` · `CONNECTORSA2_A_AGENT_TLS_SKIP_VERIFY` | `spec.agents.agentTlsSkipVerify` | CRD json tag is `agentTlsSkipVerify`. |
| Agent max concurrency | int | `100` | any int (`≤ 0` accepted) | `connectors.a2a.agentmaxconcurrency` · `CONNECTORSA2_A_AGENT_MAX_CONCURRENCY` | `spec.agents.agentMaxConcurrency` | `≤ 0` is **accepted** and silently clamped back to `100` — it does not error, and there is no output saying it was rewritten. |
| Metrics retention (h) | int | `168` (7 days) | `> 0` | `connectors.a2a.metricsretentionhours` · `CONNECTORSA2_A_METRICS_RETENTION_HOURS` | `spec.agents.metricsRetentionHours` | Must be positive. Retention window for per-agent metrics. |
**The A2A env prefix is `CONNECTORSA2_A_` — not `CONNECTORS_A2A_`.** The `convertEnvFormat`
rule splits `A2A` into `A2_A` (the regex breaks between the digit and the trailing `A`), so the
agent variables read `CONNECTORSA2_A_MAX_AGENTS`, `CONNECTORSA2_A_AGENT_TTL_SECONDS`, and so on.
Neither `CONNECTORS_A2A_*` nor `CONNECTORSA2A_*` binds — both are silently ignored. Note also
that the CRD group is `spec.agents.*`, not `spec.a2a.*`.
## CloudEvents [#cloudevents]
The CloudEvents connector, served on the shared HTTP server. Env prefix `CONNECTORSCE_*`;
CRD group `spec.ce.*`. This is an **HTTP-family connector — on by default** (opt-out): Docker
turns it off with `enable: false`, Kubernetes/Helm with `spec.ce.disabled: true`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------- | ---- | ----------- | ----------------------- | ----------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------ |
| Enable / disable | bool | `true` (on) | true / false | `connectors.ce.enable` · `CONNECTORSCE_ENABLE` | `spec.ce.disabled` | Inverted boolean. |
| Timeout (s) | int | `60` | `> 0` | `connectors.ce.timeoutseconds` · `CONNECTORSCE_TIMEOUT_SECONDS` | `spec.ce.timeoutSeconds` | Must be positive. |
| Sub buffer size | int | `100` | `1`–`10000` | `connectors.ce.subbuffsize` · `CONNECTORSCE_SUB_BUFF_SIZE` | `spec.ce.subBuffSize` | Rejected if ≤ 0 or > 10000. |
| Max SSE idle (s) | int | `300` | `> 0` | `connectors.ce.maxsseidleseconds` · `CONNECTORSCE_MAX_SSE_IDLE_SECONDS` | `spec.ce.maxSseIdleSeconds` | Must be positive. CRD json tag is `maxSseIdleSeconds`. |
| Max SSE connections | int | `0` | `≥ 0` (`0` = unlimited) | `connectors.ce.maxsseconnections` · `CONNECTORSCE_MAX_SSE_CONNECTIONS` | `spec.ce.maxSseConnections` | `0` = unlimited. CRD json tag is `maxSseConnections`. |
**CloudEvents accepts both env forms.** The primary name is the collapsed `CONNECTORSCE_*`
(e.g. `CONNECTORSCE_ENABLE`), but CE is the one connector that also binds the natural
`CONNECTORS_CE_*` alias (`CONNECTORS_CE_ENABLE`, `CONNECTORS_CE_TIMEOUT_SECONDS`, …). Both
resolve to the same setting — this compensating alias exists only for CloudEvents; MCP, A2A,
and MQTT do **not** have it.
**But the server warns that the alias is IGNORED, and that warning is wrong.** The
unknown-variable warner does not track the alias binding, so a `CONNECTORS_CE_*` variable is
reported as ignored while its value is being applied. Meanwhile a typo in the *collapsed*
form (`CONNECTORSC_ENABLE`) produces no warning at all, because collapsed names sit outside
the warner's namespace list. See [the reference legend](/configure/reference) for the
full picture.
## MQTT [#mqtt]
The MQTT 3.1.1 / 5.0 wire protocol. Env prefix `CONNECTORSMQTT_*` (no underscore after
`CONNECTORS`); CRD group `spec.mqtt.*`. This is a **wire-protocol connector — opt-in (disabled
by default)**: Docker turns it on with `enable: true`, Kubernetes/Helm with `spec.mqtt.enabled:
true` (a positive-sense `*bool` — omitting it leaves MQTT off). Ports are `string` server-side
(`""` disables a listener) and `int32` on the CRD.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------------------------------- | ------ | -------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Enable / disable | bool | **`false` (opt-in)** | true / false | `connectors.mqtt.enable` · `CONNECTORSMQTT_ENABLE` | `spec.mqtt.enabled` | Opt-in: set `true` to open ports 1883/8883/8083. Helm uses positive-sense `enabled: true`. |
| Port | string | `1883` | port / `""` | `connectors.mqtt.port` · `CONNECTORSMQTT_PORT` | `spec.mqtt.port` | Plaintext TCP listener; `""` disables it. CRD type int32 (1–65535). |
| TLS port | string | `8883` | port / `""` | `connectors.mqtt.tlsport` · `CONNECTORSMQTT_TLS_PORT` | `spec.mqtt.tlsPort` | TLS listener; **active only when Security mode ≠ None**. `""` disables. CRD int32 (1–65535). |
| WebSocket port | string | `8083` | port / `""` | `connectors.mqtt.wsport` · `CONNECTORSMQTT_WS_PORT` | `spec.mqtt.wsPort` | WebSocket listener; `""` disables. CRD int32 (1–65535). |
| Default pattern | enum | `events` | `events` / `store` / `none` | `connectors.mqtt.defaultpattern` · `CONNECTORSMQTT_DEFAULT_PATTERN` | `spec.mqtt.defaultPattern` | KubeMQ pattern for prefixless topics. |
| Sub buffer size | int | `100` | `1`–`10000` | `connectors.mqtt.subbuffsize` · `CONNECTORSMQTT_SUB_BUFF_SIZE` | `spec.mqtt.subBuffSize` | Rejected if ≤ 0 or > 10000. |
| Queue ACK timeout (s) | int | `30` | `> 0` | `connectors.mqtt.queueacktimeoutseconds` · `CONNECTORSMQTT_QUEUE_ACK_TIMEOUT_SECONDS` | `spec.mqtt.queueAckTimeoutSeconds` | Must be positive. |
| RPC timeout (s) | int | `30` | `> 0` | `connectors.mqtt.rpctimeoutseconds` · `CONNECTORSMQTT_RPC_TIMEOUT_SECONDS` | `spec.mqtt.rpcTimeoutSeconds` | Must be positive. |
| RPC max pending | int | `1024` | `> 0` | `connectors.mqtt.rpcmaxpending` · `CONNECTORSMQTT_RPC_MAX_PENDING` | `spec.mqtt.rpcMaxPending` | Must be positive. |
| Detail history enabled | bool | `true` | true / false | `connectors.mqtt.detailhistoryenabled` · `CONNECTORSMQTT_DETAIL_HISTORY_ENABLED` | `spec.mqtt.detailHistoryEnabled` | Master switch for per-entity (client/subscription) detail-page history recording. |
| Detail history max entities | int | `5000` | `≥ 0` (`0` = unbounded) | `connectors.mqtt.detailhistorymaxentities` · `CONNECTORSMQTT_DETAIL_HISTORY_MAX_ENTITIES` | `spec.mqtt.detailHistoryMaxEntities` | Caps tracked per-entity history keys; new keys refused beyond it. Validated even when history is off. |
| Capabilities · max clients | int64 | `0` | `≥ 0` (`0` = unlimited) | `connectors.mqtt.capabilities.maxclients` · `CONNECTORSMQTT_CAPABILITIES_MAX_CLIENTS` | `spec.mqtt.capabilities.maxClients` | `0` = unlimited (explicit opt-in; startup logs a WARN). Never clamped. |
| Capabilities · max packet size (bytes) | uint32 | `4194304` (4 MB) | `0` or `1`–`4294967295` | `connectors.mqtt.capabilities.maxpacketsizebytes` · `CONNECTORSMQTT_CAPABILITIES_MAX_PACKET_SIZE_BYTES` | `spec.mqtt.capabilities.maxPacketSizeBytes` | A `0` is clamped back to 4 MB (0 = "unlimited" is a DoS footgun); effective value logged at startup. |
| Capabilities · receive maximum | uint16 | `1024` | `0` or `1`–`65535` | `connectors.mqtt.capabilities.receivemaximum` · `CONNECTORSMQTT_CAPABILITIES_RECEIVE_MAXIMUM` | `spec.mqtt.capabilities.receiveMaximum` | A `0` is clamped back to 1024. |
| Capabilities · max inflight | uint16 | `8192` | `0`–`65535` | `connectors.mqtt.capabilities.maxinflight` · `CONNECTORSMQTT_CAPABILITIES_MAX_INFLIGHT` | `spec.mqtt.capabilities.maxInflight` | Not clamped. |
| Capabilities · max session expiry (s) | uint32 | `3600` | `0` or `1`–`4294967295` | `connectors.mqtt.capabilities.maxsessionexpiryseconds` · `CONNECTORSMQTT_CAPABILITIES_MAX_SESSION_EXPIRY_SECONDS` | `spec.mqtt.capabilities.maxSessionExpirySeconds` | A `0` is clamped back to 3600. |
| Capabilities · max message expiry (s) | int64 | `86400` | `≥ 0` | `connectors.mqtt.capabilities.maxmessageexpiryseconds` · `CONNECTORSMQTT_CAPABILITIES_MAX_MESSAGE_EXPIRY_SECONDS` | `spec.mqtt.capabilities.maxMessageExpirySeconds` | Rejected if negative. |
| Capabilities · max QoS | byte | `2` | `0`–`2` | `connectors.mqtt.capabilities.maxqos` · `CONNECTORSMQTT_CAPABILITIES_MAX_QOS` | `spec.mqtt.capabilities.maxQos` | Rejected if > 2. |
| Capabilities · min protocol version | byte | `4` | `4` / `5` | `connectors.mqtt.capabilities.minprotocolversion` · `CONNECTORSMQTT_CAPABILITIES_MIN_PROTOCOL_VERSION` | `spec.mqtt.capabilities.minProtocolVersion` | `4` = MQTT 3.1.1, `5` = MQTT 5.0. |
| Capabilities · max subscriptions per client | int | `1000` | `≥ 0` (`0` = unlimited) | `connectors.mqtt.capabilities.maxsubscriptionsperclient` · `CONNECTORSMQTT_CAPABILITIES_MAX_SUBSCRIPTIONS_PER_CLIENT` | `spec.mqtt.capabilities.maxSubscriptionsPerClient` | Caps distinct subscription filters per client; excess SUBSCRIBEs get SUBACK 0x97 (DoS cap). `0` = unlimited. |
**The MQTT env prefix is `CONNECTORSMQTT_` — no underscore between `CONNECTORS` and `MQTT`.**
The natural `CONNECTORS_MQTT_*` form does **not** bind and is silently ignored. This applies to
every MQTT variable, including the nested `CONNECTORSMQTT_CAPABILITIES_*` keys. Unlike
CloudEvents, MQTT has no natural-name alias.
**Some MQTT capabilities are forced, not configurable.** At server construction the connector
pins `RetainAvailable = 0` (retained messages rejected), `SharedSubAvailable = 1`, and
`WildcardSubAvailable = 1` regardless of config. A partially-specified `capabilities` block
leaves unset safety caps (`maxPacketSizeBytes`, `receiveMaximum`, `maxSessionExpirySeconds`) at
`0`, which the server clamps back to their safe defaults rather than treating as "unlimited" —
the effective caps are logged at startup. At least one listener port (`port`, `tlsPort`, or
`wsPort`) must be non-empty, and no two may share the same value.
## AMQP 0.9.1 [#amqp-091]
The AMQP 0.9.1 / RabbitMQ wire protocol. Env prefix `CONNECTORS_AMQP_*`; CRD group
`spec.amqp.*`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| --------------------- | ------ | -------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Enable | bool | **`false` (opt-in)** | true / false | `connectors.amqp.enable` · `CONNECTORS_AMQP_ENABLE` | `spec.amqp.enabled` | Opt-in wire connector: Docker `enable: true`, Helm `enabled: true` (positive-sense). Opens ports 5672/5671 (shared mux with AMQP 1.0). |
| Port | int | `5672` | `0`–`65535` (`0` disables) | `connectors.amqp.port` · `CONNECTORS_AMQP_PORT` | `spec.amqp.port` | Plaintext listener, shared with AMQP 1.0. `Port` and `TlsPort` cannot both be `0` when enabled. CRD accepts `1`–`65535`. |
| TLS port | int | `5671` | `0`–`65535` (`0` disables) | `connectors.amqp.tlsport` · `CONNECTORS_AMQP_TLS_PORT` | `spec.amqp.tlsPort` | TLS listener. |
| Heartbeat (s) | int | `60` | ≥ `0` | `connectors.amqp.heartbeatseconds` · `CONNECTORS_AMQP_HEARTBEAT_SECONDS` | `spec.amqp.heartbeatSeconds` | |
| Frame max (bytes) | int | `131072` | ≥ `4096` (accepted; > 512 MiB is clamped down) | `connectors.amqp.framemax` · `CONNECTORS_AMQP_FRAME_MAX` | `spec.amqp.frameMax` | Silently **clamped down to 536870912 (512 MiB)** if set higher (a stderr warning is emitted); a value ≥ 2³² would otherwise narrow to `0` and disable the codec frame-size cap. |
| Channel max | int | `2047` | `1`–`65535` | `connectors.amqp.channelmax` · `CONNECTORS_AMQP_CHANNEL_MAX` | `spec.amqp.channelMax` | |
| Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.amqp.maxconnections` · `CONNECTORS_AMQP_MAX_CONNECTIONS` | `spec.amqp.maxConnections` | `0` = unlimited. |
| Max body size (bytes) | int | `104857600` | > `0` | `connectors.amqp.maxbodysize` · `CONNECTORS_AMQP_MAX_BODY_SIZE` | `spec.amqp.maxBodySize` | |
| Default vhost | string | `default` | non-empty; no whitespace or `;:*>`; no trailing `.` | `connectors.amqp.defaultvhost` · `CONNECTORS_AMQP_DEFAULT_VHOST` | `spec.amqp.defaultVhost` | Becomes a channel segment, so it must pass the channel-charset rules. |
| Get batch size | int | `32` | `1`–`1024` | `connectors.amqp.getbatchsize` · `CONNECTORS_AMQP_GET_BATCH_SIZE` | `spec.amqp.getBatchSize` | Must also be ≤ `queue.maxNumberOfMessages` (cross-checked in top-level config validation). |
| Dead-letter max hops | int | `16` | ≥ `1` | `connectors.amqp.deadlettermaxhops` · `CONNECTORS_AMQP_DEAD_LETTER_MAX_HOPS` | `spec.amqp.deadLetterMaxHops` | |
| Max receive count | int | `0` | ≥ `0` (`0` = unlimited) | `connectors.amqp.maxreceivecount` · `CONNECTORS_AMQP_MAX_RECEIVE_COUNT` | `spec.amqp.maxReceiveCount` | `0` = unlimited. Must also be ≤ `queue.maxReceiveCount` (cross-checked in top-level config validation). |
**Two cross-checks against the queue limits.** The server rejects the config unless
`amqp.maxReceiveCount ≤ queue.maxReceiveCount` and `amqp.getBatchSize ≤
queue.maxNumberOfMessages`. Keep the AMQP values within the queue ceilings (see
[Storage & Queues](/configure/reference/storage-queues)).
## AMQP 1.0 [#amqp-10]
The AMQP 1.0 wire protocol (also the JMS / Qpid path). Env prefix `CONNECTORS_AMQP10_*`;
CRD group `spec.amqp10.*`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------------ | ----- | -------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Enable | bool | **`false` (opt-in)** | true / false | `connectors.amqp10.enable` · `CONNECTORS_AMQP10_ENABLE` | `spec.amqp10.enabled` | Opt-in wire connector. Opens ports 5672/5671 (shared mux with AMQP 0.9.1). |
| Port | int | `5672` | `0`–`65535` (`0` disables) | `connectors.amqp10.port` · `CONNECTORS_AMQP10_PORT` | `spec.amqp10.port` | Shared with AMQP 0.9.1 — a `Port == Amqp.Port` collision is intentionally allowed (the mux dedupes the bind). `Port` and `TlsPort` cannot both be `0`. |
| TLS port | int | `5671` | `0`–`65535` (`0` disables) | `connectors.amqp10.tlsport` · `CONNECTORS_AMQP10_TLS_PORT` | `spec.amqp10.tlsPort` | |
| Max frame size (bytes) | int | `131072` | ≥ `512` | `connectors.amqp10.maxframesize` · `CONNECTORS_AMQP10_MAX_FRAME_SIZE` | `spec.amqp10.maxFrameSize` | Spec floor 512. |
| Max message size (bytes) | int64 | `104857600` | > `0` | `connectors.amqp10.maxmessagesize` · `CONNECTORS_AMQP10_MAX_MESSAGE_SIZE` | `spec.amqp10.maxMessageSize` | `int64`; matches the AMQP 0.9.1 `maxBodySize` default (100 MB). |
| Session max | int | `256` | `1`–`65535` | `connectors.amqp10.sessionmax` · `CONNECTORS_AMQP10_SESSION_MAX` | `spec.amqp10.sessionMax` | |
| Max links per session | int | `256` | ≥ `1` | `connectors.amqp10.maxlinkspersession` · `CONNECTORS_AMQP10_MAX_LINKS_PER_SESSION` | `spec.amqp10.maxLinksPerSession` | |
| Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.amqp10.maxconnections` · `CONNECTORS_AMQP10_MAX_CONNECTIONS` | `spec.amqp10.maxConnections` | `0` = unlimited. |
| Idle timeout (s) | int | `120` | ≥ `0` (`0` = disabled) | `connectors.amqp10.idletimeoutseconds` · `CONNECTORS_AMQP10_IDLE_TIMEOUT_SECONDS` | `spec.amqp10.idleTimeoutSeconds` | `0` = disabled. |
| Default pattern | enum | `queues` | `queues` / `events` / `events-store` / `commands` / `queries` | `connectors.amqp10.defaultpattern` · `CONNECTORS_AMQP10_DEFAULT_PATTERN` | `spec.amqp10.defaultPattern` | KubeMQ pattern mapped from AMQP addresses. |
| Get batch size | int | `32` | `1`–`1024` | `connectors.amqp10.getbatchsize` · `CONNECTORS_AMQP10_GET_BATCH_SIZE` | `spec.amqp10.getBatchSize` | |
| Max unsettled per link | int | `1024` | ≥ `1` | `connectors.amqp10.maxunsettledperlink` · `CONNECTORS_AMQP10_MAX_UNSETTLED_PER_LINK` | `spec.amqp10.maxUnsettledPerLink` | |
| Default RPC timeout (s) | int | `30` | ≥ `1` | `connectors.amqp10.defaultrpctimeoutseconds` · `CONNECTORS_AMQP10_DEFAULT_RPC_TIMEOUT_SECONDS` | `spec.amqp10.defaultRpcTimeoutSeconds` | |
| RPC max pending | int | `512` | ≥ `1` | `connectors.amqp10.rpcmaxpending` · `CONNECTORS_AMQP10_RPC_MAX_PENDING` | `spec.amqp10.rpcMaxPending` | |
**AMQP 1.0 shares port `5672` (and TLS `5671`) with AMQP 0.9.1.** The two protocols are
demultiplexed on a single shared listener, so running both on the default ports is fine.
If you set a **different** port for one, keep the pair consistent so clients reach the
listener you intend.
## STOMP [#stomp]
The STOMP 1.0 / 1.1 / 1.2 wire protocol. Env prefix `CONNECTORS_STOMP_*`; CRD group
`spec.stomp.*`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| --------------------- | ------ | -------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | **`false` (opt-in)** | true / false | `connectors.stomp.enable` · `CONNECTORS_STOMP_ENABLE` | `spec.stomp.enabled` | Opt-in wire connector. Opens ports 61613/61614. |
| Port | string | `61613` | `""` (disabled) or `1`–`65535` | `connectors.stomp.port` · `CONNECTORS_STOMP_PORT` | `spec.stomp.port` | Plaintext listener. String on the server (`""` disables); the CRD takes an `int32` (`1`–`65535`). `Port` must differ from `TlsPort`. |
| TLS port | string | `61614` | `""` (disabled) or `1`–`65535` | `connectors.stomp.tlsport` · `CONNECTORS_STOMP_TLS_PORT` | `spec.stomp.tlsPort` | TLS listener. At least one of `Port`/`TlsPort` must be set. |
| Default pattern | enum | `events` | `events` / `queues` / `store` / `none` | `connectors.stomp.defaultpattern` · `CONNECTORS_STOMP_DEFAULT_PATTERN` | `spec.stomp.defaultPattern` | KubeMQ pattern for bare (prefixless) destinations. |
| Sub buffer size | int | `100` | `1`–`10000` | `connectors.stomp.subbuffsize` · `CONNECTORS_STOMP_SUB_BUFF_SIZE` | `spec.stomp.subBuffSize` | Events deliver-channel buffer. |
| Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.stomp.maxconnections` · `CONNECTORS_STOMP_MAX_CONNECTIONS` | `spec.stomp.maxConnections` | `0` = unlimited. |
| Max body size (bytes) | int | `104857600` | > `0` | `connectors.stomp.maxbodysize` · `CONNECTORS_STOMP_MAX_BODY_SIZE` | `spec.stomp.maxBodySize` | |
| Heartbeat (ms) | int | `10000` | ≥ `0` (`0` = disabled) | `connectors.stomp.heartbeatms` · `CONNECTORS_STOMP_HEARTBEAT_MS` | `spec.stomp.heartbeatMs` | Advertised sx,sy; `0` disables the server side. |
| Queue ACK timeout (s) | int | `30` | > `0` | `connectors.stomp.queueacktimeoutseconds` · `CONNECTORS_STOMP_QUEUE_ACK_TIMEOUT_SECONDS` | `spec.stomp.queueAckTimeoutSeconds` | |
| RPC timeout (s) | int | `30` | > `0` (accepted; > `2147483` is clamped down) | `connectors.stomp.rpctimeoutseconds` · `CONNECTORS_STOMP_RPC_TIMEOUT_SECONDS` | `spec.stomp.rpcTimeoutSeconds` | Silently **clamped down to `2147483`** (\~24.8 days) if set higher, so `timeout × 1000` cannot overflow the `int32` RPC-bridge deadline. |
| RPC max pending | int | `1024` | > `0` | `connectors.stomp.rpcmaxpending` · `CONNECTORS_STOMP_RPC_MAX_PENDING` | `spec.stomp.rpcMaxPending` | In-flight RPC cap. |
## Kafka [#kafka]
The embedded **Kafka drop-in connector** — KubeMQ speaks the native Kafka wire protocol,
so real `librdkafka`/`kcat`/Java clients connect unchanged. Env prefix `CONNECTORS_KAFKA_*`;
CRD group `spec.kafka.*`. As of v3.1 the connector is compiled into the **default build**
(no build tag) and gated purely at runtime by `Enable`.
Eight fields are exposed as typed CRD fields (`spec.kafka.*`) — including the Service-exposure
type; the remaining advanced knobs — fourteen scalars **plus the six-field OAUTHBEARER
block** — are **`config.yaml`/env-var-only** (Helm/CRD path `—`) and were deliberately
deferred from the CRD in v3.1 — set them via a mounted config file or a raw pod env var. The
SASL credential store is **secret/file-only** (no env var, no CRD field).
See the [Kafka connector overview](/connectors/kafka) and the
[migration guide](/connectors/kafka/how-to/migrate-from-kafka) for adoption
planning, fitness assessment, and cutover tooling beyond this config reference.
**Kafka requires the `next` storage engine — but it's zero-config.** On a **fresh** store
with Kafka enabled and `Store.Engine` unset, the server **auto-selects `next`** (a `NOTICE`
is logged) — no manual `store.engine=next` step needed. It fails closed only when the store
directory already holds `legacy` data (a config error naming the conflicting directory) or
`Store.Engine=legacy` is set explicitly alongside Kafka. Pinning `STORE_ENGINE=next` skips
the probe entirely and always wins. See
[Storage Engines](/configure/reference/storage-engines#zero-config-engine-selection).
### Core (CRD-exposed) settings [#core-crd-exposed-settings]
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------- | ------------- | -------------------- | ----------------------------------------- | ------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | **`false` (opt-in)** | true / false | `connectors.kafka.enable` · `CONNECTORS_KAFKA_ENABLE` | `spec.kafka.enabled` | Opt-in wire connector. Opens ports 9092/9093. |
| Port | string | `9092` | `""` (disabled) or `1`–`65535` | `connectors.kafka.port` · `CONNECTORS_KAFKA_PORT` | `spec.kafka.port` | Plaintext listener. **A TLS-only config (`TlsPort` set, `Port` empty) is rejected** — the TLS accept path is not yet wired, so `Port` is currently required. `Port` must differ from `TlsPort`. |
| TLS port | string | `9093` | `""` (disabled) or `1`–`65535` | `connectors.kafka.tlsport` · `CONNECTORS_KAFKA_TLS_PORT` | `spec.kafka.tlsPort` | Reserved TLS listener (see the TLS-only note above). |
| Advertised host | string | `""` | hostname / IP | `connectors.kafka.advertisedhost` · `CONNECTORS_KAFKA_ADVERTISED_HOST` | `spec.kafka.advertisedHost` | The single broker address handed to every client in Metadata/FindCoordinator. **Set it** to the external LoadBalancer DNS / NodePort IP (or the in-cluster Service DNS) — leaving it `""` falls back to the server `Host`, then the pod hostname, which is unreachable off-pod (connect-then-hang). The TLS cert SAN must include this value. |
| Advertised port | int | `0` | `0`–`65535` (`0` = use `Port`) | `connectors.kafka.advertisedport` · `CONNECTORS_KAFKA_ADVERTISED_PORT` | `spec.kafka.advertisedPort` | The external LB/NodePort port. `0` = fall back to `Port` on the config.yaml/env path. **The CRD schema is stricter than the server**: `spec.kafka.advertisedPort` enforces `minimum: 1`, so `0` is rejected on the typed CRD field even though the server itself accepts it. |
| Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.kafka.maxconnections` · `CONNECTORS_KAFKA_MAX_CONNECTIONS` | `spec.kafka.maxConnections` | `0` = unlimited. |
| Max message bytes | int | `1048576` | `1`–`1073741824` (1 GiB) | `connectors.kafka.maxmessagebytes` · `CONNECTORS_KAFKA_MAX_MESSAGE_BYTES` | `spec.kafka.maxMessageBytes` | Per-message cap (1 MiB default). Hard ceiling 1 GiB — Kafka frames are `int32`-length-prefixed, so a larger value would truncate. |
| Service exposure | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — (Docker: `-p` host port mapping) | `spec.kafka.expose` | Kubernetes Service type for the Kafka listener. Kafka also takes `sessionAffinity`, `nodePort`, and `tlsNodePort` — see [Service exposure & session affinity](#service-exposure--session-affinity), including the **multi-replica addressing** limit. |
### Advanced settings (config.yaml / env-var only — no CRD field) [#advanced-settings-configyaml--env-var-only--no-crd-field]
These are on the `configData` allowlist (deferred from the CRD in v3.1). Every one has a
working env var but **no `spec.kafka.*` path** — mount them via config file or a raw pod env var.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------------------------- | --------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Max fetch wait (ms) | int | `1000` | ≤ `300000` (5 min) | `connectors.kafka.maxfetchwaitms` · `CONNECTORS_KAFKA_MAX_FETCH_WAIT_MS` | — | Ceiling a client's `fetch.max.wait.ms` is clamped to. `≤ 0` falls back to the 1 s default; an over-ceiling value is rejected. |
| Timestamp type | string | `CreateTime` | `""` / `CreateTime` | `connectors.kafka.timestamptype` · `CONNECTORS_KAFKA_TIMESTAMP_TYPE` | — | `LogAppendTime` is **rejected** (deferred); any other value is rejected. |
| Offsets retention (min) | int | `10080` (7 days) | ≤ `52560000` (100 yr) | `connectors.kafka.offsetsretentionminutes` · `CONNECTORS_KAFKA_OFFSETS_RETENTION_MINUTES` | — | Committed-offset expiry. `≤ 0` is floored to the default (no "retain forever"); over-ceiling rejected. |
| Max groups | int | `10000` | ≤ `10000000` | `connectors.kafka.maxgroups` · `CONNECTORS_KAFKA_MAX_GROUPS` | — | Coordinator-wide consumer-group registry cap. `≤ 0` floored to default; over-ceiling rejected. |
| Max topics per request | int | `10000` | ≤ `1000000` | `connectors.kafka.maxtopicsperrequest` · `CONNECTORS_KAFKA_MAX_TOPICS_PER_REQUEST` | — | Per-request distinct-topic cap (DoS fan-out guard). `≤ 0` floored; over-ceiling rejected. |
| Max partitions per request | int | `100000` | ≤ `10000000` | `connectors.kafka.maxpartitionsperrequest` · `CONNECTORS_KAFKA_MAX_PARTITIONS_PER_REQUEST` | — | Per-request distinct-partition cap. `≤ 0` floored; over-ceiling rejected. |
| SCRAM iterations | int | `4096` | ≤ `1000000` | `connectors.kafka.scramiterations` · `CONNECTORS_KAFKA_SCRAM_ITERATIONS` | — | PBKDF2 iteration count for the SCRAM verifier. `≤ 0` floored to 4096 (RFC-7677 minimum); over-ceiling rejected. |
| SASL mechanisms | string\[] | `[]` (offer `PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`) | subset of `PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`, `OAUTHBEARER` (empty = offer the first three only) | `connectors.kafka.saslmechanisms` · `CONNECTORS_KAFKA_SASL_MECHANISMS` | — | Empty = offer the first three (`PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`). `OAUTHBEARER` is **never** part of the implicit default — it must be listed explicitly, and doing so requires `OAuthBearer.Issuer` set (see OAUTHBEARER authentication below). A non-empty allow-list restricts what `SaslHandshake` offers (e.g. SCRAM-only, dropping cleartext PLAIN); an unknown entry is rejected. |
| Produce byte rate | int | `0` (unlimited) | ≤ `1099511627776` (1 TiB/s) | `connectors.kafka.producebyterate` · `CONNECTORS_KAFKA_PRODUCE_BYTE_RATE` | — | Per-principal produce quota (bytes/s). `0` = unlimited (`ThrottleMillis=0`). `< 0` floored to 0; over-ceiling rejected. |
| Fetch byte rate | int | `0` (unlimited) | ≤ `1099511627776` (1 TiB/s) | `connectors.kafka.fetchbyterate` · `CONNECTORS_KAFKA_FETCH_BYTE_RATE` | — | Fetch-direction twin of the produce quota. |
| Max transaction timeout (ms) | int | `900000` (15 min) | ≤ `86400000` (24 h) | `connectors.kafka.maxtransactiontimeoutms` · `CONNECTORS_KAFKA_MAX_TRANSACTION_TIMEOUT_MS` | — | Ceiling on the `transaction.timeout.ms` a client negotiates. `≤ 0` floored; over-ceiling rejected. |
| Transactional ID expiration (ms) | int64 | `604800000` (7 days) | ≤ `3153600000000` (100 yr) | `connectors.kafka.transactionalidexpirationms` · `CONNECTORS_KAFKA_TRANSACTIONAL_ID_EXPIRATION_MS` | — | Idle-`transactional.id` reaper deadline. `int64`. `≤ 0` floored; over-ceiling rejected. |
| Max transactional IDs | int | `10000` | ≤ `10000000` | `connectors.kafka.maxtransactionalids` · `CONNECTORS_KAFKA_MAX_TRANSACTIONAL_I_DS` | — | Txn-coordinator registry cap. **Env-name trap** (see callout). `≤ 0` floored; over-ceiling rejected. |
| Producer ID block size | int | `1000` | ≤ `1000000` | `connectors.kafka.produceridblocksize` · `CONNECTORS_KAFKA_PRODUCER_ID_BLOCK_SIZE` | — | Producer-ID allocation block. `≤ 0` floored; over-ceiling rejected. |
| Produce pipeline depth | int | `5` | `1`–`5` | `connectors.kafka.producepipelinedepth` · `CONNECTORS_KAFKA_PRODUCE_PIPELINE_DEPTH` | — | Per-partition in-flight produce batches. `5` matches Kafka's idempotent in-flight cap, and is the hard ceiling — above it the broker cannot de-duplicate on replay, so it is rejected. `1` is serial behavior, the emergency rollback. `< 1` is floored to `1` (the safe side), **not** to the default. Worst-case memory is `depth × maxMessageBytes` held per hot partition. |
| Per-broker peer map | string | `""` | `id@host:port,…` | `connectors.kafka.peers` · `CONNECTORS_KAFKA_PEERS` | — | The clustered per-broker **client-reachable** advertised addresses, same grammar as the replication peer map. The id is the peer's raft replica id (= its Kafka broker node id). Meaningful **only** when Kafka is enabled on a clustered `next` cluster — setting it while the connector is on but the cluster is not is **rejected**. A peer's Kafka address must never equal that same id's raft address; that paste mistake is rejected, since clients would otherwise hammer the replication listener. See the [multi-replica exposure callout](#service-exposure--session-affinity). |
| SASL credentials | struct\[] | — | list of `{username, password}` | `connectors.kafka.credentials` (config file / secret only) | — | **Secret — no env var, no CRD field.** SASL/PLAIN + SCRAM user store; when non-empty, SASL auth is enforced on every listener. `Validate()` rejects empty or duplicate usernames, empty passwords, and a username matching the reserved internal dashboard identity. Passwords are redacted in logs. |
**Kafka env-var trap: `CONNECTORS_KAFKA_MAX_TRANSACTIONAL_I_DS`.** The `MaxTransactionalIDs`
field renders to `..._MAX_TRANSACTIONAL_I_DS` (an extra underscore before `DS`), **not** the
intuitive `..._MAX_TRANSACTIONAL_IDS`. The wrong form does not bind and is silently ignored.
All other Kafka names follow the normal `CONNECTORS_KAFKA_*` rule.
### Kafka SASL credentials [#kafka-sasl-credentials]
**Kafka SASL credentials are secret/file-only.** Unlike AWS, there is **no `CredentialsData`
env-var escape hatch and no CRD field** — supply the `credentials` list through a mounted
`config.yaml` (or Secret-mounted file). On Kubernetes the delivery mechanism is
[`spec.envFromSecrets`](/configure/reference/deployment#advanced--other-top-level-spec-fields),
which projects an existing Secret into the pod without the values transiting the operator.
Plan the credential delivery path before enabling SASL on Kubernetes.
### OAUTHBEARER authentication [#oauthbearer-authentication]
OAUTHBEARER activates when `OAuthBearer.Issuer` is non-empty — there is no separate enable
flag. It is enforced only on the TLS/SASL\_SSL listener (`TlsPort`); like the advanced knobs
above, it is `config.yaml`/env-var-only (Helm/CRD path `—`, not a CRD field).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------------------- | ------ | ------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Issuer | string | `""` | non-empty to activate; else OAUTHBEARER stays off | `connectors.kafka.oauthbearer.issuer` · `CONNECTORS_KAFKA_OAUTH_BEARER_ISSUER` (alias of `CONNECTORS_KAFKAO_AUTH_BEARER_ISSUER`) | — | Non-empty **requires** a non-empty `TlsPort` — a bearer token must not cross a plaintext transport. |
| Client ID | string | `""` | required unless Skip client-ID check is `true` | `connectors.kafka.oauthbearer.clientid` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_CLIENT_ID` | — | The OIDC audience. Enforced only when `Issuer` is set. |
| Skip client-ID check | bool | `false` | true / false | `connectors.kafka.oauthbearer.skipclientidcheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_SKIP_CLIENT_ID_CHECK` | — | The one skip-flag Kafka **permits** as `true` — some IdPs legitimately omit or vary the audience claim. |
| Skip expiry check | bool | `false` | must stay `false` | `connectors.kafka.oauthbearer.skipexpirycheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_SKIP_EXPIRY_CHECK` | — | `true` is **hard-rejected** on the Kafka listener — stricter than the generic OIDC authentication path, which only warns. |
| Skip issuer check | bool | `false` | must stay `false` | `connectors.kafka.oauthbearer.skipissuercheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_SKIP_ISSUER_CHECK` | — | `true` is **hard-rejected** — it would accept tokens from any issuer. |
| Insecure skip signature check | bool | `false` | must stay `false` | `connectors.kafka.oauthbearer.insecureskipsignaturecheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_INSECURE_SKIP_SIGNATURE_CHECK` | — | `true` is **hard-rejected** — it would accept forged/unsigned tokens. |
**Both OAUTHBEARER env forms work — use the readable one.** The generic snake-caser fuses
`Kafka` and `OAuthBearer` into one word and produces the unguessable
`CONNECTORS_KAFKAO_AUTH_BEARER_*`. Because nobody can guess that, the server **also** binds
the natural **`CONNECTORS_KAFKA_OAUTH_BEARER_*`** form deliberately, and both resolve to the
same setting. Prefer the natural form:
```bash
CONNECTORS_KAFKA_OAUTH_BEARER_ISSUER=https://idp.example.com
CONNECTORS_KAFKA_OAUTH_BEARER_CLIENT_ID=kubemq
```
**One caveat: the server's unknown-variable warner does not know about the alias**, so
setting the natural form prints an `IGNORED` warning even though the value is applied. The
warning is wrong. Do not "fix" a working issuer because of it — confirm the effective value
in the dashboard instead.
**Skip-flags are hard-rejected on Kafka, not just warned.** Setting `Insecure skip signature
check`, `Skip issuer check`, or `Skip expiry check` to `true` on the Kafka listener is
**rejected outright** by `Validate()` — stricter than the generic OIDC authentication path,
which only warns. `Skip client-ID check` is the one permitted skip. (`OAuthBearer.Issuer` also
requires a non-empty `TlsPort` — see the Issuer row above.)
## AWS [#aws]
The AWS SQS/SNS-compatible connector. Env prefix `CONNECTORS_AWS_*`; CRD group `spec.aws.*`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------------ | --------- | -------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | **`false` (opt-in)** | true / false | `connectors.aws.enable` · `CONNECTORS_AWS_ENABLE` | `spec.aws.enabled` | Opt-in wire connector. Opens port 4566. |
| Port | string | `4566` | `1`–`65535` | `connectors.aws.port` · `CONNECTORS_AWS_PORT` | `spec.aws.port` | Rejected if it collides with the gRPC/REST/HTTP or GCP listener port. |
| Region | string | `kubemq` | non-empty | `connectors.aws.region` · `CONNECTORS_AWS_REGION` | `spec.aws.region` | |
| Account ID | string | `000000000000` | `^[0-9]{12}$` | `connectors.aws.accountid` · `CONNECTORS_AWS_ACCOUNT_ID` | `spec.aws.accountId` | Exactly 12 digits. |
| Advertised URL | string | `""` | `""` or `scheme://host[:port]` | `connectors.aws.advertisedurl` · `CONNECTORS_AWS_ADVERTISED_URL` | `spec.aws.advertisedUrl` | Rejected unless it parses with a scheme and host. |
| Max inflight per queue | int | `20000` | `1`–`10000000` | `connectors.aws.maxinflightperqueue` · `CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE` | `spec.aws.maxInflightPerQueue` | Over-ceiling rejected (OOM guard). |
| Max concurrent polls | int | `1024` | `1`–`1000000` | `connectors.aws.maxconcurrentpolls` · `CONNECTORS_AWS_MAX_CONCURRENT_POLLS` | `spec.aws.maxConcurrentPolls` | Over-ceiling rejected. |
| Read timeout (s) | int | `60` | `1`–`3600` | `connectors.aws.readtimeout` · `CONNECTORS_AWS_READ_TIMEOUT` | `spec.aws.readTimeout` | Per-action sync deadline; over-ceiling rejected. |
| Body limit | string | `2M` | size string | `connectors.aws.bodylimit` · `CONNECTORS_AWS_BODY_LIMIT` | `spec.aws.bodyLimit` | |
| Message signing | bool | `false` | true / false | `connectors.aws.messagesigning` · `CONNECTORS_AWS_MESSAGE_SIGNING` | `spec.aws.messageSigning` | Sign SNS Notification / SubscriptionConfirmation envelopes (SigV2). |
| Signing cert TTL (h) | int | `8760` | ≥ `1` | `connectors.aws.signingcertttlhours` · `CONNECTORS_AWS_SIGNING_CERT_TTL_HOURS` | `spec.aws.signingCertTtlHours` | Self-signed signing-cert validity (default 365 days). Applies when message signing is on. |
| Credentials (data blob) | string | `""` | JSON or base64-of-JSON credential array | `connectors.aws.credentialsdata` · `CONNECTORS_AWS_CREDENTIALS_DATA` | `spec.aws.credentialsData` | SigV4 credential array. On Helm/CRD this is rendered into a **Kubernetes Secret** (not a plain ConfigMap). |
| Credentials (structured) | struct\[] | — | list of `{accessKeyId, secretAccessKey, clientID}` | `connectors.aws.credentials` (config file only) | — | **No env var, no CRD field.** File/structured-only credential list; the env/CRD path is `credentialsData`. Empty or duplicate `accessKeyId` is rejected; `clientID` defaults to `accessKeyId`. |
**AWS credentials go through a Secret, not a plain CRD value.** The `spec.aws.credentialsData`
field exists but the operator writes it into a Kubernetes **Secret** (`CONNECTORS_AWS_CREDENTIALS_DATA`),
never a ConfigMap. On Docker, set `connectors.aws.credentialsdata` (JSON or base64-of-JSON) or
the `CONNECTORS_AWS_CREDENTIALS_DATA` env var. The fully-structured `connectors.aws.credentials`
list is config-file-only and has no env/CRD route.
## GCP Pub/Sub [#gcp-pubsub]
The Google Cloud Pub/Sub emulator connector (gRPC). Env prefix `CONNECTORS_GCP_*`; CRD group
`spec.gcp.*`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------------------- | ------ | -------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Enable | bool | **`false` (opt-in)** | true / false | `connectors.gcp.enable` · `CONNECTORS_GCP_ENABLE` | `spec.gcp.enabled` | Opt-in wire connector. Opens port 8085. |
| Port | string | `8085` | `1`–`65535` | `connectors.gcp.port` · `CONNECTORS_GCP_PORT` | `spec.gcp.port` | gRPC listener (Pub/Sub emulator convention). Rejected if it collides with the gRPC/REST/HTTP or AWS listener port. |
| Advertised endpoint | string | `""` | endpoint | `connectors.gcp.advertisedendpoint` · `CONNECTORS_GCP_ADVERTISED_ENDPOINT` | `spec.gcp.advertisedEndpoint` | Endpoint advertised to clients. |
| Max message bytes | int | `10485760` (10 MiB) | `1`–`1073741824` (1 GiB) | `connectors.gcp.maxmessagebytes` · `CONNECTORS_GCP_MAX_MESSAGE_BYTES` | `spec.gcp.maxMessageBytes` | Feeds the gRPC frame ceiling (value + 1 MiB); hard cap 1 GiB. |
| Default ack deadline (s) | int | `10` | `10`–`600` | `connectors.gcp.defaultackdeadlineseconds` · `CONNECTORS_GCP_DEFAULT_ACK_DEADLINE_SECONDS` | `spec.gcp.defaultAckDeadlineSeconds` | |
| Max outstanding messages | int | `1000` | > `0` | `connectors.gcp.maxoutstandingmessages` · `CONNECTORS_GCP_MAX_OUTSTANDING_MESSAGES` | `spec.gcp.maxOutstandingMessages` | |
| Max inflight per subscription | int | `20000` | > `0` | `connectors.gcp.maxinflightpersubscription` · `CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION` | `spec.gcp.maxInflightPerSubscription` | |
| Max concurrent polls | int | `1024` | > `0` | `connectors.gcp.maxconcurrentpolls` · `CONNECTORS_GCP_MAX_CONCURRENT_POLLS` | `spec.gcp.maxConcurrentPolls` | |
| Max concurrent streams | int | `1024` | `0` (default) or `1`–`65536` | `connectors.gcp.maxconcurrentstreams` · `CONNECTORS_GCP_MAX_CONCURRENT_STREAMS` | `spec.gcp.maxConcurrentStreams` | Per-server cap on concurrent StreamingPull streams. `0` = use the built-in default (1024). |
| Delivery shards | int | `16` | `1`–`256` | `connectors.gcp.deliveryshards` · `CONNECTORS_GCP_DELIVERY_SHARDS` | `spec.gcp.deliveryShards` | Striped delivery-pool shards. |
| Max ack extension (s) | int | `600` | `0` (disabled) or `10`–`3600` | `connectors.gcp.maxackextensionseconds` · `CONNECTORS_GCP_MAX_ACK_EXTENSION_SECONDS` | `spec.gcp.maxAckExtensionSeconds` | `0` disables the ordered-head ack-deadline keep-alive. |
| Stream close (s) | int | `1800` | > `0` | `connectors.gcp.streamcloseseconds` · `CONNECTORS_GCP_STREAM_CLOSE_SECONDS` | `spec.gcp.streamCloseSeconds` | |
| Max seek replay | int | `1000000` | > `0` | `connectors.gcp.maxseekreplay` · `CONNECTORS_GCP_MAX_SEEK_REPLAY` | `spec.gcp.maxSeekReplay` | |
| Enable reflection | bool | `false` | true / false | `connectors.gcp.enablereflection` · `CONNECTORS_GCP_ENABLE_REFLECTION` | `spec.gcp.enableReflection` | gRPC server reflection. |
**Enable GCP Pub/Sub explicitly.** A stock kubemq-server does **not** bind port 8085 until you set
`CONNECTORS_GCP_ENABLE=true` (Docker) or `spec.gcp.enabled: true` (Kubernetes). Point clients at
the connector with `PUBSUB_EMULATOR_HOST=localhost:8085` — no auth, no TLS (emulator mode).
# Core & Licensing (/configure/reference/core)
These are the foundational settings every KubeMQ server needs: the **license key** that
activates the server, the **log level** that controls how much it writes, and the **host
identity** it reports itself under. Each setting is shown for both deployment targets —
Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A
dash (`—`) in the Helm/CRD column means the setting is not available on that surface.
## Licensing & identity [#licensing--identity]
The license is required on both targets. On Docker you can pass the key directly as an
env var, point at a license file, or embed the key in `config.yaml`; on Helm the chart
renders `key` (or `license`) straight into the CR.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------- | ------ | ---------------- | --------------------------------------------------------- | ----------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| License token | string | `""` | activation UUID (online) **or** offline license-data blob | — · `KUBEMQ_TOKEN` (or `--key` flag) | `spec.key` | `License.KubeMQToken` — the value the runtime license service validates. Env/flag-only (`os.Getenv`); no config.yaml key, no viper binding. `spec.key` is consumed **by the operator** (activation + inventory metadata), **not** injected into the pod as `KUBEMQ_TOKEN`. |
| License data | string | `""` | license-data blob (base64/PEM) | `license.key.data` · `LICENSE_KEY_DATA` | `spec.license` | `License.Key.Data`. Operator renders `spec.license` → `LICENSE_KEY_DATA` secret. Redacted in settings API. |
| License from file | string | `""` | filesystem path | `license.key.filename` · `LICENSE_KEY_FILENAME` | — | `License.Key.Filename`. Docker/on-disk only; `data` wins over `filename`. |
| Host identity | string | derived hostname | any host string | `host` · `HOST` | — | `Config.Host`. Auto-derived from OS hostname when empty; explicit `viper.BindEnv("Host","HOST")`. No CRD field. ⚠️ **Changing it orphans the store** — see the warning below. |
**Changing `HOST` silently orphans the entire persistent store.** The store layout is
`//…`, so the host identity is part of the data path, not just a label.
Point a running node at a new `HOST` and it **boots clean and completely healthy onto an
empty directory**, with the previous store sitting intact beside it, untouched and
unread. Nothing fails, nothing warns, and every health check passes — the messages are
simply gone from the server's point of view.
The same trap catches you from the other direction on Docker, where `HOST` is derived from
the OS hostname: a container recreated without `--hostname` gets a fresh random one and
lands on a new empty directory. See the
[Docker configuration guide](/configure/docker).
If you must change it, treat it as a **migration**: stop the server, move
`/` to `/`, then start it. To recover from an
accidental change, set `HOST` back to the original value — the old directory is still
there.
`KUBEMQ_TOKEN` and `LICENSE_KEY_DATA` are two different fields, not aliases for the
same setting. **License token** (`KUBEMQ_TOKEN`, `License.KubeMQToken`) is read
directly from the environment — there is no `config.yaml` key and no viper binding —
and it is the value the runtime license service validates. **License data**
(`license.key.data` · `LICENSE_KEY_DATA`, `License.Key.Data`) is the license-data blob
bound to `config.yaml`/Helm. On Kubernetes, `spec.key` is consumed **by the operator**
for activation and inventory metadata; it is **not** injected into the pod as
`KUBEMQ_TOKEN`. Only `spec.license` is delivered to the running server, rendered as the
`LICENSE_KEY_DATA` secret.
`LicenseConfig.Validate()` is dead code — `Config.Validate()` never calls it, so no
startup-time check runs against the license fields above. The real gate is the license
service, which validates `KUBEMQ_TOKEN` at runtime; a missing or invalid token fails
there, not at config validation.
## Logging [#logging]
`Log level` controls verbosity — the same value on both targets, passed through to the
server unchanged. `Log to file` / `Log file path` enable and locate an on-disk log; both
are Docker / `config.yaml`-only (no Helm/CRD path).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------- | -------------------- | ---------- | --------------------------------------------------------- | ------------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Log level | int (`LogLevelType`) | `2` (Info) | `0`=Trace `1`=Debug `2`=Info `3`=Warn `4`=Error `5`=Fatal | `log.level` · `LOG_LEVEL` | `spec.log.level` | Higher = less. Validate rejects only negatives; a value >5 silently maps to the verbose default (Trace/Debug), not silent. CRD constrains 0–5. |
| Log to file | bool | `false` | `true` \| `false` | `log.fileenable` · `LOG_FILE_ENABLE` | — | When `true`, **Log file path** must be non-empty. |
| Log file path | string | `""` | filesystem path | `log.filepath` · `LOG_FILE_PATH` | — | Required when file logging is enabled. |
The **0–5 scale above is authoritative** (`0`=Trace … `5`=Fatal, default `2`=Info). The
operator passes the value through to the server **verbatim**. An older CRD annotation
describes a different scale (`0`=silent / `1`=info / `2`=debug) — that annotation does
**not** match the running server, so do not rely on it. Use the 0–5 enum above.
## Example [#example]
Set the log level on each target. This is a single-setting snippet — see the
[Docker guide](/configure/docker) and the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="config.yaml"
log:
level: 2
```
```yaml title="values.yaml"
log:
level: 2
```
For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG`
variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to
the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes).
# Deployment & High Availability (/configure/reference/deployment)
These settings cover how KubeMQ is **packaged and run on Kubernetes** — the container
image, persistent storage, resource requests/limits, health probes, node scheduling, and
`Service` exposure — plus the **high-availability** controls (`replicas` and `standalone`).
Unlike the rest of this reference, these fields do **not** exist in the server's
`config.yaml`: they are typed on the `KubemqCluster` **CRD** and consumed by the operator,
which renders the StatefulSet, Services, and PersistentVolumeClaim. The pattern is therefore
**inverted** — the **Helm/CRD path is the real one**, and the Docker column is `—`
(Kubernetes-only) except where the operator translates a CRD field into a pod env var (shown
as `· env VAR`). On Docker single-node, the equivalent concerns are `docker run` flags
(`-p`, `-v`, `--cpus` / `--memory`) covered in the [Kubernetes guide](/configure/kubernetes).
## Kubernetes packaging [#kubernetes-packaging]
### Container image [#container-image]
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------ | ------------- | ------------------------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Image | string | `europe-docker.pkg.dev/kubemq/images/kubemq:next` | image reference | — (K8s-only) | `spec.image.image` | Resolution order: `spec.image.image` → operator env `RELATED_IMAGE_KUBEMQ_CLUSTER` → built-in fallback (`config/image.go:8,18-30`). No server env binding. |
| Pull policy | string (enum) | `Always` | `IfNotPresent` / `Always` / `Never` | — | `spec.image.pullPolicy` | CRD pattern `(IfNotPresent\|Always\|Never)`; empty is coerced to `Always` (`config/image.go:14,34-37`). |
| Image pull secrets | string\[] | `[]` | Secret names | — | — (chart value `imagePullSecrets[]`) | **Not** a typed CRD field — supplied via Helm values / a `spec.statefulsetConfigData` override, not the KubemqCluster spec. |
### Storage (volume) [#storage-volume]
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------- | ------ | ---------------------------- | -------------------------- | ---------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Volume size | string | *unset → ephemeral (no PVC)* | k8s quantity (e.g. `50Gi`) | — | `spec.volume.size` | When set, the operator renders a `volumeClaimTemplates` PVC (`ReadWriteOnce`) mounted at `./kubemq/store`. When **unset/empty, the pod has no persistent volume** and the store is ephemeral (`deployment.go:78-81`, `deployment/statefulset.go:108-124`). There is **no built-in `10Gi` default** in code. **Required for durable `next`-engine data** — a `next` cluster with no `spec.volume.size` set raises the `EphemeralNextStore` warning (its durable data would otherwise live on ephemeral container storage). |
| Storage class | string | `""` → cluster default | StorageClass name | — | `spec.volume.storageClass` | Only consulted when `size` is set; empty renders a blank `storageClassName:` → the cluster's default StorageClass (`config/volume.go:8`). |
**`spec.store.path` on the `next` engine: the operator rejects a leading `/`.** The
operator itself supplies the mount-rooted absolute path for the PVC, so a
`spec.store.path` value starting with `/` is rejected outright — the operator, not
the server, owns that absolute path. This is the **inverse** of the server-layer
behavior documented on [Storage & Queues](/configure/reference/storage-queues):
the `next` engine's **server process** honors an absolute `StorePath` verbatim (it's
the `legacy` engine that rewrites a leading `/` to `./`). Keep the two facts distinct —
"the operator rejects a leading `/` in the CR field" and "the next-engine server
process honors an absolute `StorePath`" are both true, at different layers.
### Resources [#resources]
Each field is rendered into the pod's `resources:` block only when non-empty; there are no
defaults (`config/resources.go:32-47`).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------------- | ------ | ------- | ----------------------------------- | ---------------------------------- | ----------------------------------------- | ------------------------------------------- |
| CPU limit | string | unset | k8s CPU quantity (e.g. `2`, `500m`) | — | `spec.resources.limitsCpu` | Pod `resources.limits.cpu`. |
| Memory limit | string | unset | k8s memory quantity (e.g. `2Gi`) | — | `spec.resources.limitsMemory` | Pod `resources.limits.memory`. |
| Ephemeral-storage limit | string | unset | k8s quantity | — | `spec.resources.limitsEphemeralStorage` | Pod `resources.limits.ephemeral-storage`. |
| CPU request | string | unset | k8s CPU quantity | — | `spec.resources.requestsCpu` | Pod `resources.requests.cpu`. |
| Memory request | string | unset | k8s memory quantity | — | `spec.resources.requestsMemory` | Pod `resources.requests.memory`. |
| Ephemeral-storage request | string | unset | k8s quantity | — | `spec.resources.requestsEphemeralStorage` | Pod `resources.requests.ephemeral-storage`. |
### Health probe [#health-probe]
The liveness probe is **off by default** and only injected when `enabled: true`
(`config/health.go:46-60`).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------- | ----- | ------- | ---------------- | -------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enabled | bool | `false` | true / false | · env `API_BIND_ADDRESS=0.0.0.0` (side-effect when true) | `spec.health.enabled` | When true, the operator adds a `livenessProbe` httpGet `/health` on the API port **and** sets `API_BIND_ADDRESS=0.0.0.0` so the kubelet (pod IP) can reach the probe — the API binds `127.0.0.1` otherwise (`config/health.go:48-60`). |
| Initial delay (s) | int32 | `5` | any; `≤0` → `5` | — | `spec.health.initialDelaySeconds` | `config/health.go:38-40`. |
| Period (s) | int32 | `10` | any; `≤0` → `10` | — | `spec.health.periodSeconds` | `config/health.go:35-37`. |
| Timeout (s) | int32 | `5` | any; `≤0` → `5` | — | `spec.health.timeoutSeconds` | `config/health.go:32-34`. |
| Success threshold | int32 | `1` | any; `≤0` → `1` | — | `spec.health.successThreshold` | `config/health.go:29-31`. |
| Failure threshold | int32 | `12` | any; `≤0` → `12` | — | `spec.health.failureThreshold` | `config/health.go:41-43`. |
**`/health` (liveness) vs `/ready` (readiness).** The table above covers the **optional**
`/health` liveness probe (`spec.health.enabled`, off by default). Readiness is separate: for a
**clustered `next`-engine deployment** (`engine=next` and not `standalone`), the operator wires
a **readinessProbe** against a `/ready` endpoint on the API port — it holds the pod not-`Ready`
until cluster quorum forms, so traffic only reaches pods that can actually serve. Standalone and
legacy-engine pods run a single/loopback node with no multi-node quorum to wait on, so the
operator does **not** render this readinessProbe for them. The response code varies by mode, so
it isn't listed here.
### Node scheduling [#node-scheduling]
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------- | ------------------ | ------- | -------------------------- | ---------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- |
| Node selectors | map\[string]string | `{}` | node label key/value pairs | — | `spec.nodeSelectors.keys` | Rendered as the pod `nodeSelector`; an empty map applies no constraint (`config/node_selectors.go:9-24`). |
### Service exposure & interface toggles [#service-exposure--interface-toggles]
The three built-in interface Services — **gRPC** (`50000`), **REST/WebSocket** (`9090`), and
**API/dashboard** (`8080`) — each expose a Service type, optional NodePort, custom port, and a
disable toggle. These HTTP/interface Services are **opt-out** via `disabled: true`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------- | ------------- | ------------------- | ----------------------------------------- | ------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| gRPC disabled | bool | `false` | true / false | · env `CONNECTORS_GRPC_ENABLE=false` | `spec.grpc.disabled` | Deletes the `-grpc` Service and disables the listener (`config/grpc.go:64-66`, `deployment.go:203-206`). |
| gRPC Service type | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — | `spec.grpc.expose` | Empty → `ClusterIP` (`config/grpc.go:56`); CRD pattern-validated. |
| gRPC NodePort | int32 | `0` (auto-assigned) | 30000–32767 | — | `spec.grpc.nodePort` | Applied only when `expose: NodePort` **and** value > 0 (`config/grpc.go:77-81`). |
| gRPC port | int32 | `50000` | port | · `CONNECTORS_GRPC_PORT` | `spec.grpc.port` | Moves the container/Service/target port and emits the env (`config/grpc.go:71-75`). |
| REST disabled | bool | `false` | true / false | · env `CONNECTORS_REST_ENABLE=false` | `spec.rest.disabled` | The `-rest` Service (9090) is **shared** by REST/MCP/Agents/CE — the operator removes it only when **all four** are disabled (`deployment.go:99-120`). |
| REST Service type | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — | `spec.rest.expose` | Empty → `ClusterIP` (`config/rest.go:71`). |
| REST NodePort | int32 | `0` (auto) | 30000–32767 | — | `spec.rest.nodePort` | Applied only when `expose: NodePort` and > 0 (`config/rest.go:91-95`). |
| REST port | int32 | `9090` | port | · `CONNECTORS_REST_PORT` | `spec.rest.port` | `config/rest.go:85-88`. |
| API disabled | bool | `false` | true / false | · env `API_ENABLE=false` | `spec.api.disabled` | Deletes the `-api` Service (`config/api.go:65-66`, `deployment.go:89-92`). |
| API Service type | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — | `spec.api.expose` | Empty → `ClusterIP` (`config/api.go:58`). |
| API NodePort | int32 | `0` (auto) | 30000–32767 | — | `spec.api.nodePort` | Applied only when `expose: NodePort` and > 0 (`config/api.go:79-83`). |
| API port | int32 | `8080` | port | · `API_PORT` | `spec.api.port` | `config/api.go:73-76`. |
**`spec.statefulsetConfigData` is a one-way door for every other packaging field.** It does
not merge with, patch, or extend the StatefulSet the operator builds — it **replaces the
body outright**. The moment it is set, `spec.image`, `spec.volume`, `spec.resources`,
`spec.health`, `spec.nodeSelectors`, `spec.podAntiAffinity` and
`spec.terminationGracePeriodSeconds` stop having any effect on the pod, silently: they stay
in the CR, they still validate, and they change nothing. You now own the whole pod spec,
including the parts the operator was maintaining for you across upgrades. Use it only when
a field you need genuinely has no typed equivalent, and expect to re-check it on every
operator upgrade.
Only the **exposure** subset of `spec.grpc` / `spec.rest` / `spec.api` lives here. Their
tuning fields (buffer/body limits, gRPC reflection, REST read/write timeouts, CORS, API
allow-origins, and the API-auth block) are documented on the
[Interfaces](/configure/reference/interfaces) and
[Security](/configure/reference/security) pages.
### Advanced / other top-level spec fields [#advanced--other-top-level-spec-fields]
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------------- | ------------------ | ------- | ------------------------- | ---------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| StatefulSet override | string (YAML) | unset | StatefulSet YAML fragment | — | `spec.statefulsetConfigData` | ⚠️ Escape hatch — when set, **replaces the operator-generated StatefulSet body** with your fragment (`deployment/statefulset.go:126-135,268-271`). All other packaging fields above are ignored for the STS. See the warning below. |
| Config data (OIDC) | string | unset | OIDC block | — | `spec.configData` | OIDC-only passthrough — **not** a generic `config.yaml`. See [Security](/configure/reference/security). |
| License | string | unset | license key | — (Secret `LICENSE_KEY_DATA`) | `spec.license` | Injected as Secret data, not env (`deployment.go:66-68`). Config/secret-only. |
| Key | string | unset | activation key | — | `spec.key` | Top-level activation key; secret-only. |
| Key from Secret | string | unset | existing Secret name | — | `spec.keySecretRef` · `spec.keySecretKey` | Sources `key` from an existing Secret instead of the literal. Data key defaults to `key`; override with `keySecretKey`. **Mutually exclusive with the literal `key`** — setting both is rejected. See [Supplying the license from a Secret](/deploy/kubernetes-helm#supplying-the-license-from-a-secret). |
| License from Secret | string | unset | existing Secret name | — | `spec.licenseSecretRef` · `spec.licenseSecretKey` | Same mechanism for `spec.license`; data key defaults to `license`. Mutually exclusive with the literal. |
| Env overlay | map\[string]string | `{}` | env key/value pairs | — | `spec.env` | Last-wins overlay onto the pod ConfigMap — the general escape hatch for any server env key the CRD doesn't type. Operator-computed identity keys are **rejected**: `STORE_ENGINE`, `CLUSTER_ENABLE`, `CLUSTER_NAME`, `CLUSTER_ROUTES`, `API_BIND_ADDRESS`, `CHECKSUM`, `POD_NAME`, and any `CLUSTER_REPLICATION_*` key — **not** a `CLUSTER_*` wildcard (e.g. `CLUSTER_PORT` is still allowed). Not for secrets. |
| Env from Secrets | string\[] | `[]` | existing Secret names | — | `spec.envFromSecrets` | Projects existing Secret(s) as pod env (`envFrom`) — the general Secret-envFrom escape hatch; Secret values never transit the operator. This is the mechanism behind [Kafka SASL credentials](/configure/reference/connectors#kafka-sasl-credentials). |
## High availability [#high-availability]
High availability on Kubernetes is **multiple replicas** managed by the operator — not the
Docker `cluster.*` block.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------- | -------- | ------- | --------------------------------------- | ---------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Replica count | `*int32` | `3` | CRD `Minimum=0`; `0`/nil coerced to `3` | — | `spec.replicas` | nil → 3 (`deployment.go:59-62`), and `SetReplicas(0)` → 3 (`deployment/statefulset.go:183-191`). Backs the `scale` subresource (`specpath=.spec.replicas`). More than one replica = clustered HA (operator wires `CLUSTER_*` + the 5228 cluster port). **Once the engine is established as `next`, `spec.replicas` is immutable** — a CEL rule plus an operator fail-closed guard reject any change (see the engine-establishment guard below). |
| Standalone | bool | `false` | true / false | — | `spec.standalone` | true → a single **non-clustered** node: the operator omits `CLUSTER_NAME`/`CLUSTER_ROUTES`/`CLUSTER_ENABLE` and the `5228` cluster-port from the pod (`deployment/statefulset.go:40-47,103-107`). On the `next` engine, standalone runs a **loopback raft** and skips replication wiring entirely. |
**`spec.replicas` becomes immutable once the engine is `next` — and `next` is what a new
cluster gets.** This is a one-way door, armed at creation on a default install, not an
edge case for people who opted into `next`. Once the engine is established as `next`, a
CEL rule on the CRD and an operator fail-closed guard both reject **any** change to the
replica count — up or down. `helm upgrade --set replicas=5` fails; so does editing the CR.
**Choose the replica count before you create the cluster.** 3 is the smallest count that
gets a disruption budget; see [Disruption budget & pod
spread](#disruption-budget--pod-spread). Changing it afterwards means creating a new
cluster and migrating, so decide with the ceiling in mind rather than the starting load.
On Kubernetes, high availability is `spec.replicas` (operator-managed clustering) — **not**
the `config.yaml`-only `cluster.*` block, which has no Helm/CRD path. See the
[Advanced reference](/configure/reference/advanced) for the Docker `cluster.*` path.
### Disruption budget & pod spread [#disruption-budget--pod-spread]
Both default to **on** for a multi-replica cluster — the operator creates them without
being asked, and these fields exist to tune or disable them.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------------- | ----- | -------------------------------- | ------------ | ---------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
| Disruption budget enabled | bool | `true` | true / false | — | `spec.podDisruptionBudget.enabled` | Set `false` to create no `PodDisruptionBudget`. |
| Minimum available | int32 | raft majority (`replicas/2 + 1`) | ≥ `1` | — | `spec.podDisruptionBudget.minAvailable` | Defaults to **2 of 3, 3 of 5**. Overriding it downward trades quorum safety for drainability. |
| Anti-affinity enabled | bool | `true` | true / false | — | `spec.podAntiAffinity.enabled` | Spreads replicas across nodes. |
| Anti-affinity required | bool | `false` | true / false | — | `spec.podAntiAffinity.required` | `false` (default) = *preferred* spread; `true` = *required*. See the trade below. |
**Use an odd replica count, 3 or more. The operator refuses to protect anything smaller,
and tells you.** A budget is created only from **3 replicas up** — below that the operator
creates none and emits an **`UnsupportedReplicaCount`** Warning event explaining why: a
single replica forms a quorum of one and survives its own restart, while two replicas need
both, so any single failure loses quorum. The only correct budget at 2 replicas would be
`minAvailable: 2`, which blocks every voluntary eviction and wedges node drains
indefinitely — the operator refuses to ship one that deadlocks maintenance.
An **even** replica count gets an **`EvenReplicaCount`** Warning event: the majority of 4
is 3, the same single-failure tolerance as 3, at the cost of an extra replica and another
copy of the data.
These two events are your only signal — nothing else reports that no budget was created:
```bash
kubectl get events -n --field-selector reason=UnsupportedReplicaCount
```
**The anti-affinity trade is real — choose it deliberately.**
`required: false` (the default) means pods **always schedule**, but two replicas **may**
share a node, so a single node loss can still cost quorum. `required: true` **guarantees**
the spread, but a replica stays `Pending` while there are fewer schedulable nodes than
replicas.
### Shutdown grace period [#shutdown-grace-period]
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------------------- | ----- | -------------------------- | ------------ | ---------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| Termination grace period (s) | int32 | *unset* → Kubernetes' `30` | ≥ `1` | — (`docker stop -t`) | `spec.terminationGracePeriodSeconds` · env `KUBEMQ_TERMINATION_GRACE_PERIOD_SECONDS` | The pod's shutdown budget. The operator sets the **pod spec** and passes the **same number to the server** — they always move together. |
**This setting governs message durability on shutdown, not just tidiness.** The server
splits the grace period between **connector teardown** — which requeues in-flight messages
that would otherwise be **lost** — and **store shutdown**.
Kubernetes does not expose `terminationGracePeriodSeconds` to the container, so the
operator both sets the pod spec **and** passes the same value as
`KUBEMQ_TERMINATION_GRACE_PERIOD_SECONDS`. Left unset, Kubernetes uses **30s** and the
server assumes 30s.
**The concrete numbers:** at the default 30-second grace the requeue backstop is **1
second**. **45 seconds funds the full 12-second backstop.** Set at least `45` on any
cluster where losing in-flight messages on a pod roll matters, and higher still if your
connectors hold long in-flight batches.
```yaml title="values.yaml"
terminationGracePeriodSeconds: 45
```
**Connector Services & ports (operator-exposed).** The pod always publishes container ports
for every wire connector — MQTT `1883/8883/8083`, AMQP `5672/5671`, STOMP `61613/61614`,
AWS `4566`, GCP `8085`, **Kafka `9092/9093`** (`deployment/statefulset.go:70-102`,
`deployment/service.go:192-256`). The matching ClusterIP `Service` is created **only when
that connector is enabled** (`spec..enabled: true`) — otherwise the operator
prunes it (`deployment.go:122-199`). Wire connectors are **opt-in** (`enabled: true`,
default off); the HTTP interfaces above (gRPC/REST/API) remain **opt-out** (`disabled: true`).
**`status.engine`.** The `KubemqCluster` status subresource exposes `status.engine`
(`legacy` / `next` / `auto`) — the engine established for this cluster by the guard below.
Read it alongside `status.replicas` when auditing the replicas-freeze caveat above: once
`status.engine` reports `next`, `spec.replicas` can no longer change.
**`auto` is not an engine.** It means the cluster **delegated** the choice to the server,
which resolves it at boot from the store directory. On such a cluster `status.engine` and
the `established-engine` annotation both read `auto`, and the engine actually running is
reported **only** in the server's boot NOTICE in the pod log — see
[Which engine am I actually on?](/configure/reference/storage-engines#which-engine-am-i-actually-on).
### Engine-establishment guard [#engine-establishment-guard]
The operator records the **live** persistence engine in the
`core.k8s.kubemq.io/established-engine` annotation (`legacy` / `next` / `auto`) — once
present, this annotation is **authoritative** on an established cluster, **outranking
`spec.store.engine`** for every guard decision. A mismatch between the two is **refused,
not silently applied**. Derivation runs in order: (1) the annotation, if present; (2) else
the explicit `spec.store.engine`; (3) else the pod ConfigMap's `STORE_ENGINE` key; (4)
else, if a StatefulSet or retained PVC already exists, the engine is **unknown** and the
operator refuses to guess — set `spec.store.engine` explicitly or delete the retained PVCs;
(5) else the cluster has **no engine on record** and the operator delegates: it writes
`STORE_ENGINE=auto` and the server resolves the engine at boot from the store directory
(clean ⇒ `next`).
**From operator v2.3.0 a fresh cluster gets `auto`, not a pinned engine** — the operator no
longer inverts the server's clean-store default. An established cluster keeps its named
engine and nothing rolls. **Setting `spec.store.engine` later on an `auto` cluster is
allowed**, because `auto` records a delegation rather than an established engine — pin the
engine the server actually resolved; a pin that disagrees with the data on disk is refused
by the server at boot, and the server never deletes a datadir.
The operator **never changes a live cluster's engine.** Two advisory CEL rules on the CRD
back this: `spec.store.engine` is immutable once set (born-one-mode), and `spec.replicas`
is immutable once the engine is established as `next`.
This operator-side derivation runs **before** the server-side probe documented on
[Storage Engines](/configure/reference/storage-engines#zero-config-engine-selection)
and the Kafka callout on [Connectors](/configure/reference/connectors#kafka) — two
layers of one decision: the operator decides what the pod's env will say before the pod
ever boots, and when that env says `auto`, the server-side probe is what actually decides
once it does.
## Example [#example]
Publish the gRPC port on each target. On Kubernetes you set the `Service` exposure and node
port; on Docker you publish the port with `-p`. This is a single-setting snippet — see the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="values.yaml"
grpc:
expose: NodePort
nodePort: 32000
```
For the full install flow (CRDs → operator → cluster), `values.yaml` mapped to the
`KubemqCluster` spec, and single-node vs HA, see the
[Kubernetes guide](/configure/kubernetes).
# Configuration Reference (/configure/reference)
The reference documents the KubeMQ server settings an operator configures, grouped by
domain. Every option is rendered in a table with the same seven columns — most pages carry
several, one per settings group — so the same field reads identically whether you run
KubeMQ with Docker or on Kubernetes via Helm. This page is the legend — read it once, then
every table downstream is unambiguous.
**Not every field in the server's config struct appears here.** Internal, test-only, and
derived fields are deliberately out of scope, and a handful of newer knobs are documented
on the page that owns their behavior rather than in a settings table. If a variable you
found in a log or a struct dump isn't here, that is not proof it does nothing — check the
domain page for the feature it belongs to first.
## How to read the tables [#how-to-read-the-tables]
Every settings table on every domain page uses the same **7 columns**:
| Column | What it tells you |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Setting** | The human-readable name of the option. |
| **Type** | The value type — `int`, `bool`, `string`, size string, enum, etc. |
| **Default** | The value the server uses when the setting is omitted. |
| **Valid values** | The accepted range or enum, where one applies. |
| **Docker (config.yaml key · env var)** | Both Docker forms: the `config.yaml` key **and** its environment variable. |
| **Helm/CRD path** | The `KubemqCluster` `spec.*` path. The **Helm value** is that path with the leading `spec.` removed (`spec.grpc.port` → `grpc.port`). |
| **Notes** | Name divergences, validation rules, cross-field constraints, and version floors. |
The Docker column always shows **two** forms of the same setting. The `config.yaml` key is
viper-lowercased with no per-segment separators (for example `store.maxretention`); the
environment variable is that key in `UPPER_SNAKE` (`STORE_MAX_RETENTION`).
The Helm/CRD column shows the CRD `spec.*` path. Because the chart renders `values.yaml`
straight into the CR `spec`, a Helm value is just that path minus `spec.` — there is no
separate Helm schema to learn.
## The "—" convention [#the--convention]
A dash (`—`) in the **Helm/CRD path** column means the setting is **not available on that
surface** — it is a Docker / `config.yaml`-only knob with no Helm route. It does **not**
mean "to be filled in later."
Whole domains are Docker-only by design: the message-broker engine (`broker.*`), runtime
tuning (`tuning.*`), and standalone clustering (`cluster.*`) are advanced settings exposed
through `config.yaml`/env only. On Kubernetes the operator owns those concerns. See the
[Advanced](/configure/reference/advanced) page.
## The env-var acronym rule [#the-env-var-acronym-rule]
Docker derives each environment variable from the `config.yaml` key with `convertEnvFormat`:
snake-case the key, drop the dots, uppercase. The trap is in the **connector prefixes** —
how `CONNECTORS` joins the next segment depends on whether that segment is a Title-case word
or an all-caps acronym:
| Connector segment | Form | Env-var prefix |
| ------------------ | ------------------------------------------- | --------------------- |
| `Amqp` (0.9.1) | Title-case → **keeps** the underscore | `CONNECTORS_AMQP_*` |
| `Amqp10` (1.0) | Title-case → keeps the underscore | `CONNECTORS_AMQP10_*` |
| `Stomp` | Title-case → keeps the underscore | `CONNECTORS_STOMP_*` |
| `Aws` | Title-case → keeps the underscore | `CONNECTORS_AWS_*` |
| `Kafka` | Title-case → keeps the underscore | `CONNECTORS_KAFKA_*` |
| `Gcp` | Title-case → keeps the underscore | `CONNECTORS_GCP_*` |
| `MCP` | all-caps acronym → **drops** the underscore | `CONNECTORSMCP_*` |
| `CE` (CloudEvents) | all-caps acronym → drops the underscore | `CONNECTORSCE_*` |
| `MQTT` | all-caps acronym → drops the underscore | `CONNECTORSMQTT_*` |
| `A2A` (agents) | splits to `A2_A` | `CONNECTORSA2_A_*` |
**The wrong twin is silently ignored — except for CloudEvents.** CloudEvents is the one
connector that binds **both** forms: the collapsed `CONNECTORSCE_*` (primary) and the
underscored `CONNECTORS_CE_*` (a compensating alias) resolve to the same setting — either
`CONNECTORSCE_ENABLE` or `CONNECTORS_CE_ENABLE` works. **MCP, A2A, and MQTT have no such
alias.** For those, only the collapsed form binds — `CONNECTORSMCP_*`, `CONNECTORSA2_A_*`,
and `CONNECTORSMQTT_*`. The underscored twin (`CONNECTORS_MCP_*`, `CONNECTORS_A2_A_*`,
`CONNECTORS_MQTT_*`) does **not** bind — the server starts and accepts the variable
without error.
**Kafka's OAUTHBEARER block has the same kind of alias as CloudEvents:** the generic rule
produces the unguessable `CONNECTORS_KAFKAO_AUTH_BEARER_*`, so the natural
`CONNECTORS_KAFKA_OAUTH_BEARER_*` is bound deliberately alongside it. Both work.
**The server's "IGNORED" warning is unreliable in both directions — do not use it as
proof.** It reports variables that sit in a KubeMQ namespace prefix but bind to no config
key, and that heuristic has two holes:
* **False alarms.** The two deliberate aliases above — `CONNECTORS_CE_*` and
`CONNECTORS_KAFKA_OAUTH_BEARER_*` — are bound by a path the warner doesn't track, so it
reports them as IGNORED **while they are being applied**. Do not "fix" a working setting
because of this warning.
* **Silence that means nothing.** The warner's namespace list contains only prefixes ending
in `_` (`STORE_`, `CLUSTER_`, `API_`, `CONNECTORS_`, `BROKER_`, `QUEUE_`, `ROUTING_`,
`AUTHORIZATION_`, `AUTHENTICATION_`, `LOG_`, `METRICS_`, `AUDIT_`). Every collapsed-acronym
form is structurally outside it, so a typo in `CONNECTORSMQTT_*`, `CONNECTORSMCP_*`,
`CONNECTORSCE_*` or `CONNECTORSA2_A_*` produces **no output at all**. So are the
`LICENSE_`, `NOTIFICATION_`, `SECURITY_`, `TELEMETRY_` and `TUNING_` namespaces — a typo
in `LICENSE_KEY_DATA` is silent.
Confirm the **effective** value in the dashboard rather than trusting the log either way.
## `enable` ↔ `disabled` inversion [#enable--disabled-inversion]
The enable model is **not uniform** — it splits into two families, and each family's
default and CRD field name are different:
* **HTTP-family** interfaces/connectors (gRPC, REST, API, MCP, A2A, CloudEvents) are
**opt-out** — **on by default**. Docker turns one off with `enable: false` — for
example `connectors.ce.enable: false` (env `CONNECTORSCE_ENABLE=false`). Helm/CRD turns
one off with `spec..disabled: true`; omit the key while the connector stays on.
* **Wire-protocol** connectors (MQTT, AMQP 0.9.1, AMQP 1.0, STOMP, Kafka, AWS, GCP) are
**opt-in** — **off by default**. Docker turns one on with
`enable: true`. Helm/CRD turns one on with `spec..enabled: true`; omit the key and
the connector stays off.
**Docker always uses `enable: true | false`** regardless of family — only the default and
the Helm/CRD field name (`disabled` vs `enabled`) differ. Each domain page shows the form
that applies on each target.
## Configuration domains [#configuration-domains]
The settings are grouped across these nine domain pages.
# Interfaces (gRPC · REST · API · HTTP) (/configure/reference/interfaces)
KubeMQ fronts its server core with four interfaces: the **gRPC** transport, the
**REST/WebSocket** transport, the management/dashboard **API** (with opt-in API
authentication), and the **shared HTTP server** that hosts MCP, A2A, and CloudEvents on
the REST port. Each setting is shown for both targets — Docker single-node (`config.yaml`
key · env var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column
means the setting is not a typed CRD field; on Kubernetes it is reachable only through
`spec.configData` (raw config) or a directly-set pod env var.
**All four interfaces are opt-out (always-on).** gRPC, REST, and API are enabled by
default on both targets. On Docker they use the `enable: true/false` model; on the chart
they use the inverted `disabled: true/false` framing (`spec.grpc.disabled`,
`spec.rest.disabled`, `spec.api.disabled`) — all shipped as `disabled: false`. Set
`disabled: true` to turn one off. The shared HTTP server has no toggle of its own — it
rides the REST port and is emitted only when you set one of its `spec.http.*` fields.
**The shared HTTP listener does not belong to REST.** It starts if **any** of REST, MCP,
A2A, or CloudEvents is enabled, and stops only when all four are off. Disabling REST alone
retires the REST routes and leaves the other three serving on the same port.
**`.port` on Kubernetes moves everything together.** Setting `spec.grpc.port` /
`spec.rest.port` / `spec.api.port` makes the operator emit the matching listener env var
(`CONNECTORS_GRPC_PORT` / `CONNECTORS_REST_PORT` / `API_PORT`) **and** set the Kubernetes
`Service` `port`/`targetPort` **and** the container port — the in-pod listener and the
Service port move as one. On Docker the same env var moves the actual listener, which you
then publish with `docker run -p`.
**A default `helm install` publishes nothing outside the cluster.** The shipped
`values.yaml` sets no `expose` and no `nodePort` for any interface, and a `KubemqCluster`
with `expose` unset gets a **`ClusterIP`** Service. gRPC is *not* reachable on every node
out of the box — to reach it from outside you set `expose` yourself (`NodePort` or
`LoadBalancer`), and if you need a **predictable** port you must also set `nodePort`,
because an unset one is assigned by the kernel and cannot be configured into a client
ahead of the install.
## gRPC [#grpc]
The primary client transport. Enabled by default on both targets.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------- | ------------ | --------------------- | ----------------------------------------- | ------------------------------------------------------------------------ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable / disable | bool | enabled (`true`) | true / false | `connectors.grpc.enable` · `CONNECTORS_GRPC_ENABLE` | `spec.grpc.disabled` | Inverted boolean: Docker `enable: true`, Helm `disabled: false`. When disabled, no listener starts and the rest of the section is skipped. |
| Port | int (string) | `50000` | 1–65535 | `connectors.grpc.port` · `CONNECTORS_GRPC_PORT` | `spec.grpc.port` | Server field is a string port; rejected if non-numeric or out of range. |
| Sub buffer size | int | `100` | ≥ 0 | `connectors.grpc.subbuffsize` · `CONNECTORS_GRPC_SUB_BUFF_SIZE` | `spec.grpc.bufferSize` | Per-subscription channel buffer. Negative rejected. **Name divergence:** server `subBuffSize` ↔ CRD `bufferSize`. |
| Body limit | int (bytes) | `104857600` (100 MB) | ≥ 0 bytes | `connectors.grpc.bodylimit` · `CONNECTORS_GRPC_BODY_LIMIT` | `spec.grpc.bodyLimit` | Server `int` bytes; CRD `int32` bytes, emitted only when non-zero (no CRD type default). Chart example sets `10000000`. |
| gRPC reflection | bool | `false` | true / false | `connectors.grpc.enablereflection` · `CONNECTORS_GRPC_ENABLE_REFLECTION` | `spec.grpc.enableReflection` | Enables server reflection for grpcurl/tooling. |
| Service exposure | enum | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | (`-p` host port) | `spec.grpc.expose` | Kubernetes `Service` type; empty ⇒ `ClusterIP`. The chart ships **no** `expose` value. On Docker use `-p`. |
| NodePort | int | `0` (kernel-assigned) | 30000–32767 | (`-p`) | `spec.grpc.nodePort` | Applied only when `expose: NodePort` **and** the value is > 0. The chart ships no default — leave it unset and the kernel picks a port you cannot configure into a client ahead of the install. |
## REST · WebSocket [#rest--websocket]
The HTTP/WebSocket transport, which also hosts the shared HTTP server (MCP, A2A,
CloudEvents). Enabled by default on both targets.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------- | ------------ | --------------------- | ----------------------------------------- | ---------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Enable / disable | bool | enabled (`true`) | true / false | `connectors.rest.enable` · `CONNECTORS_REST_ENABLE` | `spec.rest.disabled` | Inverted boolean. Disabling REST removes the REST routes (they return `404`) but **leaves MCP, A2A and CloudEvents serving** — the shared HTTP server starts if *any* of the four is enabled. To take the whole listener down, disable all four. |
| Port | int (string) | `9090` | 1–65535 | `connectors.rest.port` · `CONNECTORS_REST_PORT` | `spec.rest.port` | String port; rejected if non-numeric or out of range. |
| Body limit | string / int | server `"100M"` | Echo size string or bytes | `connectors.rest.bodylimit` · `CONNECTORS_REST_BODY_LIMIT` | `spec.rest.bodyLimit` | Server size-string (`"100M"`); CRD `int32` bytes, emitted only when non-zero. Chart example sets `1000000`. |
| Sub buffer size | int | `100` | ≥ 0 | `connectors.rest.subbuffsize` · `CONNECTORS_REST_SUB_BUFF_SIZE` | `spec.rest.bufferSize` | Negative rejected. **Name divergence:** `subBuffSize` ↔ `bufferSize`. |
| Read timeout | int (s) | `60` | ≥ 0 (CRD ≥ 1) | `connectors.rest.readtimeout` · `CONNECTORS_REST_READ_TIMEOUT` | `spec.rest.readTimeout` | Seconds. Server rejects negative; CRD enforces `Minimum=1`. |
| Write timeout | int (s) | `60` | ≥ 0 (CRD ≥ 1) | `connectors.rest.writetimeout` · `CONNECTORS_REST_WRITE_TIMEOUT` | `spec.rest.writeTimeout` | Seconds. Server rejects negative; CRD enforces `Minimum=1`. |
| Service exposure | enum | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | (`-p` host port) | `spec.rest.expose` | Kubernetes `Service` type; empty ⇒ `ClusterIP`. The chart ships **no** `expose` value. On Docker use `-p`. |
| NodePort | int | `0` (kernel-assigned) | 30000–32767 | (`-p`) | `spec.rest.nodePort` | Applied only when `expose: NodePort` **and** the value is > 0. The chart ships no default. |
### REST CORS [#rest-cors]
CORS policy for the REST transport. On the CRD it is a first-class `spec.rest.cors.*`
sub-object; on Docker/env the keys ride the `CONNECTORS_REST_CORS_*` prefix.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------- | --------- | ---------------- | ------------ | ---------------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------- |
| Allow origins | string\[] | `["*"]` | origin list | `connectors.rest.cors.alloworigins` · `CONNECTORS_REST_CORS_ALLOW_ORIGINS` | `spec.rest.cors.allowOrigins` | **Must be non-empty** — validation rejects an empty list. |
| Allow methods | string\[] | `["GET","POST"]` | method list | `connectors.rest.cors.allowmethods` · `CONNECTORS_REST_CORS_ALLOW_METHODS` | `spec.rest.cors.allowMethods` | **Must be non-empty.** |
| Allow headers | string\[] | `[]` | header list | `connectors.rest.cors.allowheaders` · `CONNECTORS_REST_CORS_ALLOW_HEADERS` | `spec.rest.cors.allowHeaders` | Empty list allowed. |
| Allow credentials | bool | `false` | true / false | `connectors.rest.cors.allowcredentials` · `CONNECTORS_REST_CORS_ALLOW_CREDENTIALS` | `spec.rest.cors.allowCredentials` | |
| Expose headers | string\[] | `[]` | header list | `connectors.rest.cors.exposeheaders` · `CONNECTORS_REST_CORS_EXPOSE_HEADERS` | `spec.rest.cors.exposeHeaders` | |
| Max age | int (s) | `0` | ≥ 0 | `connectors.rest.cors.maxage` · `CONNECTORS_REST_CORS_MAX_AGE` | `spec.rest.cors.maxAge` | Negative rejected. |
**REST → shared-HTTP inheritance.** When you explicitly set `Rest.ReadTimeout`,
`Rest.BodyLimit`, `Rest.Cors.AllowOrigins`, or `Rest.Cors.AllowMethods` (and don't set the
matching `Http.*` key), the value propagates to the shared HTTP server. `Http.Port` always
inherits `Rest.Port` when `Http.Port` is unset. The shared server's distinct CORS defaults
(the `MCP-*`/`OPTIONS`/`DELETE` headers below) are preserved whenever you did **not**
override the REST side, so MCP and A2A keep working.
## Management API [#management-api]
The management/dashboard API. Enabled by default on both targets.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------------- | --------- | ------------------------------- | ----------------------------------------- | ---------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Enable / disable | bool | enabled (`true`) | true / false | `api.enable` · `API_ENABLE` | `spec.api.disabled` | Inverted boolean. |
| Port | int | `8080` | 1–65535 | `api.port` · `API_PORT` | `spec.api.port` | Validated only when the API is enabled. |
| Bind address | string | `127.0.0.1` (empty → `0.0.0.0`) | IP address | `api.bindaddress` · `API_BIND_ADDRESS` | — | Config/env only; no typed CRD field. An empty value is normalized to `0.0.0.0`. |
| Allow origins (CORS) | string\[] | `["*"]` | origin list | `api.alloworigins` · `API_ALLOW_ORIGINS` | `spec.api.allowOrigins` | CRD joins the list with commas into the env var. **When API auth is enabled, `"*"` and an empty list are rejected** (see below). |
| Service exposure | enum | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | (`-p` host port) | `spec.api.expose` | Kubernetes `Service` type; empty ⇒ `ClusterIP`. The chart ships **no** `expose` value. On Docker use `-p`. |
| NodePort | int | `0` (kernel-assigned) | 30000–32767 | (`-p`) | `spec.api.nodePort` | Applied only when `expose: NodePort` **and** the value is > 0. The chart ships no default. |
### Management API authentication [#management-api-authentication]
Opt-in authentication for the management API + web dashboard (`[Api.Auth]`). **Disabled by
default.** The data plane (gRPC/REST messaging) is unaffected. On the CRD it lives under
`spec.api.auth.*`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------------- | ------ | --------------------- | ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | `false` | true / false | `api.auth.enable` · `API_AUTH_ENABLE` | `spec.api.auth.enable` | Gates the whole section. |
| Session idle minutes | int | `30` | > 0 when enabled | `api.auth.sessionidleminutes` · `API_AUTH_SESSION_IDLE_MINUTES` | `spec.api.auth.sessionIdleMinutes` | Idle-session timeout. Must be positive. |
| Session absolute hours | int | `8` | > 0 when enabled | `api.auth.sessionabsolutehours` · `API_AUTH_SESSION_ABSOLUTE_HOURS` | `spec.api.auth.sessionAbsoluteHours` | Absolute session lifetime. Must be positive. |
| Store path | string | `""` → main store dir | filesystem path | `api.auth.storepath` · `API_AUTH_STORE_PATH` | `spec.api.auth.storePath` | When empty and auth enabled, defaults to `Store.StorePath` (the main store directory). |
| Trusted TLS proxy | bool | `false` | true / false | `api.auth.trustedtlsproxy` · `API_AUTH_TRUSTED_TLS_PROXY` | `spec.api.auth.trustedTLSProxy` | Set `true` when a TLS-terminating reverse proxy fronts the API port; otherwise cookie/session login is refused on plain HTTP. |
| Admin username | string | `admin` | username | `KUBEMQ_API_ADMIN_USERNAME` (os env) | `spec.api.auth.adminUsername` | **Env/secret only — not a viper config-file field.** Read directly from the environment. |
| Admin password | string | — | password | `KUBEMQ_API_ADMIN_PASSWORD` / `KUBEMQ_API_ADMIN_PASSWORD_FILE` (os env) | `spec.api.auth.adminSecretRef` · `adminSecretKey` | **Secret only** — never a config field. The operator injects the password into the pod from a Secret; on Docker set the env var (or `_FILE`) yourself. Cluster mode requires it when auth is enabled. |
#### The account model [#the-account-model]
The two settings above (`adminUsername` / `adminPassword`) bootstrap the **first** account.
They are not the whole model — once auth is on, the control plane has three roles and two
account types, and only the first admin is configured through the server config at all.
Every other account is created through the API or dashboard.
**Three roles**, each a superset of the one below it:
| Role | Can do |
| ------------ | ------------------------------------------------------------------------- |
| `read_only` | Read dashboards, stats, snapshots, audit logs |
| `read_write` | Everything in `read_only`, plus send/receive messages and subscribe |
| `admin` | Everything in `read_write`, plus manage accounts and revert configuration |
**Two account types:**
| Type | Authenticates with | Notes |
| --------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user` | Username + password, cookie session | Subject to the idle and absolute session limits above. |
| `service` | A bearer API key, `kmq__` | Sent as `Authorization: Bearer …`. For scripts, agents, and CI. **A service account cannot hold the `admin` role** — that combination is rejected at creation. |
Two behaviors worth knowing before you turn auth on:
* **The bootstrap admin must rotate its password on first login.** The login response
carries `must_change_password`, and the account stays in that state until the password
is changed — an automation that logs in with the bootstrap credentials and ignores the
flag will not get far.
* **Cookie-authenticated mutations require an `X-KubeMQ-CSRF` header.** Bearer-authenticated
service accounts are exempt: there is no ambient credential for a malicious site to
replay, so the guard applies to session cookies only.
**A `read_only` account is a real account, and it can read configuration.** If you are
handing out dashboard access, `read_only` is the right default — but treat it as a
principal with visibility into server settings, not as a view of nothing sensitive.
**Wildcard CORS is refused when API auth is enabled.** With `api.auth.enable: true`, the
server rejects boot if `api.alloworigins` is empty or contains `"*"` — credentialed auth
requires a concrete origin list (e.g. `["https://app.example.com"]`). The
`sessionIdleMinutes`/`sessionAbsoluteHours` values must both be positive.
## Shared HTTP server [#shared-http-server]
The shared HTTP server hosts MCP, A2A, and CloudEvents on the REST port. On Kubernetes it
is a first-class `spec.http.*` group; its CORS lives under `spec.http.cors.*`. It has no
enable/disable toggle — it is active whenever REST is, and the CRD emits its env vars only
for the `spec.http.*` fields you set.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------------- | --------- | ------------------------------------------------------------------------------------------ | ---------------- | ---------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Port | string | inherits `Rest.Port` (`9090`) | 1–65535 | `connectors.http.port` · `CONNECTORS_HTTP_PORT` | `spec.http.port` | Empty ⇒ inherits the REST port. A differing explicit value logs a warning; the HTTP server uses `Http.Port`. |
| Read timeout | int (s) | `60` | ≥ 0 (CRD ≥ 1) | `connectors.http.readtimeout` · `CONNECTORS_HTTP_READ_TIMEOUT` | `spec.http.readTimeout` | Negative rejected. |
| Body limit | string | `"100M"` | Echo size string | `connectors.http.bodylimit` · `CONNECTORS_HTTP_BODY_LIMIT` | `spec.http.bodyLimit` | CRD field is a string. |
| Base URL | string | `""` | URL | `connectors.http.baseurl` · `CONNECTORS_HTTP_BASE_URL` | `spec.http.baseUrl` | Server field is `BaseURL`; env `..._BASE_URL`. |
| CORS allow origins | string\[] | `["*"]` | origin list | `connectors.http.cors.alloworigins` · `CONNECTORS_HTTP_CORS_ALLOW_ORIGINS` | `spec.http.cors.allowOrigins` | |
| CORS allow methods | string\[] | `GET, POST, DELETE, OPTIONS` | method list | `connectors.http.cors.allowmethods` · `CONNECTORS_HTTP_CORS_ALLOW_METHODS` | `spec.http.cors.allowMethods` | Wider than REST — MCP/A2A need `DELETE`/`OPTIONS`. |
| CORS allow headers | string\[] | `Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id, Last-Event-ID, Accept` | header list | `connectors.http.cors.allowheaders` · `CONNECTORS_HTTP_CORS_ALLOW_HEADERS` | `spec.http.cors.allowHeaders` | Includes the `MCP-*` headers. |
| CORS allow credentials | bool | `false` | true / false | `connectors.http.cors.allowcredentials` · `CONNECTORS_HTTP_CORS_ALLOW_CREDENTIALS` | `spec.http.cors.allowCredentials` | |
| CORS expose headers | string\[] | `MCP-Session-Id, MCP-Protocol-Version` | header list | `connectors.http.cors.exposeheaders` · `CONNECTORS_HTTP_CORS_EXPOSE_HEADERS` | `spec.http.cors.exposeHeaders` | |
| CORS max age | int (s) | `86400` | ≥ 0 | `connectors.http.cors.maxage` · `CONNECTORS_HTTP_CORS_MAX_AGE` | `spec.http.cors.maxAge` | Negative rejected. |
**Version floor:** the shared-HTTP `spec.http.*` fields are present throughout the current
GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with
`kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA
release predates this reference: upgrade to the current line rather than trying to work out
which pre-GA build carried which field. On Docker the `connectors.http.*` keys are
available regardless of chart version.
## Example [#example]
Set the gRPC port on each target. On Kubernetes `spec.grpc.port` moves the in-pod listener,
the `Service` port/`targetPort`, and the container port together; on Docker it moves the
listener and you publish it with `-p`. This is a
single-setting snippet — see the [Docker guide](/configure/docker) and the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="config.yaml"
connectors:
grpc:
port: "50000"
```
```yaml title="values.yaml"
grpc:
port: 50000
```
For the full Docker delivery methods and `docker run` port publishing see the
[Docker guide](/configure/docker); for `values.yaml` mapped to the
`KubemqCluster` spec and `Service` exposure see the
[Kubernetes guide](/configure/kubernetes).
# Observability (/configure/reference/observability)
KubeMQ reports on itself through three domains: **OpenTelemetry** traces and metrics, an
**audit** log of operations, and **notifications** about server events. Each setting is
shown for both deployment targets — Docker single-node (`config.yaml` key · env var) and
Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting is
not available on that surface; a dash in the env-var slot means the field has **no
environment binding at all** (config-file/secret-only).
This page is the **settings reference** for telemetry, audit, and notifications. For
concepts, how-to guides, the full metric series, the audit event catalog, and the
management API, see **[Observability](/operate/observability)**.
**Version floor:** the `spec.telemetry.*` and `spec.audit.*` fields are present throughout
the current GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**)
with `kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 /
2.0.0 GA release predates this reference and will reject these fields; upgrade to the
current line. On Docker the `telemetry.*` and `audit.*` keys are available regardless of
chart version.
## Telemetry (OpenTelemetry) [#telemetry-opentelemetry]
KubeMQ exports traces and metrics over OTLP. The scalar fields are a first-class
`spec.telemetry.*` group on Kubernetes. The master `enable` flag is **off by default**;
turn it on, then optionally tune the traces, metrics, and exporter sub-blocks. Every
sub-block only takes effect while the master switch is on.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------------- | ------------------ | ---------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Enable (master) | bool | `false` | true / false | `telemetry.enable` · `TELEMETRY_ENABLE` | `spec.telemetry.enable` | Master switch — off disables all telemetry (`Validate` returns early). |
| Service name | string | `kubemq` | non-empty service name | `telemetry.servicename` · `TELEMETRY_SERVICE_NAME` | `spec.telemetry.serviceName` | Reported as the OTLP `service.name`. Empty is coerced back to `kubemq` at load. |
| Traces enable | bool | `true` | true / false | `telemetry.traces.enable` · `TELEMETRY_TRACES_ENABLE` | `spec.telemetry.traces.enable` | Applies only when the master switch is on. |
| Traces sampling ratio | float64 | `1.0` | `0`–`1` | `telemetry.traces.samplingratio` · `TELEMETRY_TRACES_SAMPLING_RATIO` | `spec.telemetry.traces.samplingRatio` | Fraction of traces sampled. Out-of-range values are coerced back to `1.0`. |
| Traces sampler | enum | `parent_based` | `always_on` / `always_off` / `trace_id_ratio` / `parent_based` | `telemetry.traces.sampler` · `TELEMETRY_TRACES_SAMPLER` | `spec.telemetry.traces.sampler` | Sampling strategy. Any other value is coerced to `parent_based`. |
| Metrics enable | bool | `true` | true / false | `telemetry.metrics.enable` · `TELEMETRY_METRICS_ENABLE` | `spec.telemetry.metrics.enable` | Applies only when the master switch is on. |
| Metrics export interval | duration | `30s` | Go duration string | `telemetry.metrics.exportinterval` · `TELEMETRY_METRICS_EXPORT_INTERVAL` | `spec.telemetry.metrics.exportInterval` | How often metrics are pushed. Unparseable values are coerced to `30s`. |
| Exporter protocol | enum | `grpc` | `grpc` / `http` | `telemetry.exporter.protocol` · `TELEMETRY_EXPORTER_PROTOCOL` | `spec.telemetry.exporter.protocol` | OTLP wire protocol. Any other value is coerced to `grpc`. |
| Exporter endpoint | string | `localhost:4317` | host:port | `telemetry.exporter.endpoint` · `TELEMETRY_EXPORTER_ENDPOINT` | `spec.telemetry.exporter.endpoint` | OTLP collector endpoint. Empty is coerced to `localhost:4317`. |
| Exporter insecure | bool | `true` | true / false | `telemetry.exporter.insecure` · `TELEMETRY_EXPORTER_INSECURE` | `spec.telemetry.exporter.insecure` | Disable TLS to the collector. |
| Exporter compression | enum | `gzip` | `gzip` / `none` | `telemetry.exporter.compression` · `TELEMETRY_EXPORTER_COMPRESSION` | `spec.telemetry.exporter.compression` | Payload compression. Any other value is coerced to `gzip`. |
| Exporter timeout | duration | `10s` | Go duration string | `telemetry.exporter.timeout` · `TELEMETRY_EXPORTER_TIMEOUT` | `spec.telemetry.exporter.timeout` | Export request timeout. Unparseable values are coerced to `10s`. |
| Exporter headers | map\[string]string | `{}` | key/value map | `telemetry.exporter.headers` · ⚠️ `TELEMETRY_EXPORTER_HEADERS` (**do not use — see callout**) | — | **Config-file-only in practice.** A `TELEMETRY_EXPORTER_HEADERS` env binding exists but is a landmine (below). Not exposed on the CRD. |
| Resource attributes | map\[string]string | `{}` | key/value map | `telemetry.resource` · — (no env binding) | — | **Config-file-only.** No `bindViperEnv` call and no CRD field — set only in the mounted `config.yaml`. |
**Do not set `TELEMETRY_EXPORTER_HEADERS` — it corrupts the entire running config.**
`Telemetry.Exporter.Headers` is a `map[string]string`, and viper/mapstructure has no
default string→map decode hook. Setting the env var makes `viper.Unmarshal` fail, and the
loader's error path **discards the whole config and falls back to pure defaults** — every
other file setting and env var is silently lost, with only a stderr line as evidence. The
env name appears in the golden key list, but treat it as unusable: configure exporter
headers only via the `telemetry.exporter.headers` map in the mounted `config.yaml`. The
sibling `telemetry.resource` map is safer — it has **no** env binding at all, so it is
cleanly config-file-only. Neither map has a CRD path.
## Audit [#audit]
The audit log records server operations and retains them for a configurable window. It is a
first-class `spec.audit.*` group on Kubernetes and is **enabled by default**. The CRD models
`enable` as `*bool` deliberately — because audit defaults on server-side, a plain bool could
never emit `false` to turn it off.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ---------------------- | ---- | ------- | ------------ | ----------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | `true` | true / false | `audit.enable` · `AUDIT_ENABLE` | `spec.audit.enable` | Audit is on by default. CRD field is `*bool` so it can emit `false`. |
| Retention hours | int | `720` | ≥ 1 | `audit.retentionhours` · `AUDIT_RETENTION_HOURS` | `spec.audit.retentionHours` | How long audit records are kept (720h = 30 days). Values \< 1 are coerced to `720`. CRD type is `int32` with `Minimum=1`. |
| Cleanup interval (min) | int | `60` | ≥ 1 | `audit.cleanupintervalminutes` · `AUDIT_CLEANUP_INTERVAL_MINUTES` | `spec.audit.cleanupIntervalMinutes` | How often expired records are purged. Values \< 1 are coerced to `60`. CRD type is `int32` with `Minimum=1`. |
## Notifications [#notifications]
Server-event notifications are published to an internal channel. The toggle and prefix have
**name divergences** between the two targets — read the Notes column carefully. The server
struct has no `mapstructure` tags; viper binds these by field path (case-insensitive).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------- | ------ | --------------- | ---------------- | ------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------ |
| Enable | bool | `false` | true / false | `notification.enable` · `NOTIFICATION_ENABLE` | `spec.notification.enabled` | **Name divergence:** Docker `enable` ↔ Helm `enabled`. |
| Channel prefix | string | `notifications` | non-empty prefix | `notification.reportchannelprefix` · `NOTIFICATION_REPORT_CHANNEL_PREFIX` | `spec.notification.prefix` | **Name divergence:** `reportchannelprefix` ↔ `prefix`. |
| Log notifications | bool | `false` | true / false | `notification.log` · `NOTIFICATION_LOG` | `spec.notification.log` | Also write notifications to the server log. |
**Cross-field rule:** when notifications are enabled, if `log` is `false` **and** the
channel prefix is empty, the server fails validation with
`bad notification configuration: missing parameters`. Either keep a non-empty prefix
(the default `notifications` satisfies this) or set `log: true`.
## Example [#example]
Enable telemetry on each target. This is a single-setting snippet — see the
[Docker guide](/configure/docker) and the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="config.yaml"
telemetry:
enable: true
```
```yaml title="values.yaml"
telemetry:
enable: true
```
For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG`
variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to
the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes).
# Security (Auth · TLS) (/configure/reference/security)
KubeMQ secures the server with three independent layers: **authentication** (verify who
is connecting, via JWT or OIDC), **authorization** (policy-based access control), and
**TLS/mTLS** (transport encryption). Each setting is shown for both deployment targets —
Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A
dash (`—`) in the Helm/CRD column means the setting is not available on that surface
(it is `config.yaml`/env-only).
**Version floor:** the aligned `spec.authentication.enable`, `spec.authentication.type`,
`spec.authentication.signatureType`, `spec.authentication.key`, and
`spec.authentication.oidc` fields are present throughout the current GA chart line —
`kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with `kubemq-controller`
**2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA release predates
this reference and will reject these fields; upgrade to the current line. On Docker the
corresponding `authentication.*` keys are available regardless of chart version.
## Authentication [#authentication]
Authentication is **off by default** and opt-in (`enable: true`). The `type` field is the
**mode selector**: the literal value `oidc` delegates verification to an OIDC provider;
**any other value (including empty or `jwt`) selects JWT mode**, which validates a signed
token with a configured key and signature algorithm. There is no wire-connector
opt-in/opt-out toggle here — authentication uses the plain `enable` flag on both surfaces.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------------- | -------------------- | ------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | `false` | true / false | `authentication.enable` · `AUTHENTICATION_ENABLE` | `spec.authentication.enable` | Off by default. When `false` the server ignores every other auth field (`authentication.go:100`). Operator emits from the `Enable` pointer. |
| Type (mode selector) | string | `""` | `oidc`, or empty ⇒ JWT | `authentication.type` · `AUTHENTICATION_TYPE` | `spec.authentication.type` | Only the exact value `oidc` triggers OIDC; empty or any other value ⇒ JWT (`authentication.go:103`). |
| JWT signature type | string | `""` | `HS256`·`HS384`·`HS512`·`RS256`·`RS384`·`RS512`·`ES256`·`ES384`·`ES512` | `authentication.jwtconfig.signaturetype` · `AUTHENTICATION_JWT_CONFIG_SIGNATURE_TYPE` | `spec.authentication.signatureType` | Required when JWT is enabled (`authentication.go:19`). The 9 algorithms come from `pkg/authentication/jwt.go:10-20`. Name divergence `jwtconfig.signaturetype` ↔ `signatureType`. Operator stores it in a **Secret**. |
| JWT key | string | `""` | HMAC secret / PEM public key | `authentication.jwtconfig.key` · `AUTHENTICATION_JWT_CONFIG_KEY` | `spec.authentication.key` | Verification key, read verbatim. Either `key` or `filePath` is required (`authentication.go:22`). Name divergence `jwtconfig.key` ↔ `key`. Operator stores it in a **Secret**. |
| JWT key file | string | `""` | file path | `authentication.jwtconfig.filepath` · `AUTHENTICATION_JWT_CONFIG_FILE_PATH` | — | Alternative to the inline key; validated as a filename (`authentication.go:25`). **`config.yaml`/env-only** — superseded by the inline `key` on the CRD (allowlist). |
| OIDC config | string (base64 JSON) | `""` | base64-encoded OIDC JSON | `authentication.config` · `AUTHENTICATION_CONFIG` | `spec.authentication.oidc` | Docker takes **base64-encoded** OIDC JSON, which the server base64-decodes (`authentication.go:74`). Helm takes a first-class `oidc` block that the operator encodes for you. Required when `type: oidc` (`authentication.go:104`). |
### OIDC block fields [#oidc-block-fields]
These live inside the OIDC config: on Docker they are keys of the base64-encoded JSON in
`AUTHENTICATION_CONFIG`; on Helm they are typed fields under `spec.authentication.oidc`.
They have **no individual env bindings** — the whole block travels as the single
`AUTHENTICATION_CONFIG` value (config-file/secret-only per field).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------------------------- | ------ | ------- | ---------------- | -------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Issuer | string | `""` | issuer URL | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.issuer` | **Required** (`authentication.go:51`). |
| Client ID | string | `""` | OAuth2 client id | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.clientID` | Required unless `skipClientIDCheck` is set (`authentication.go:54`). |
| Skip client-ID check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.skipClientIDCheck` | Disables audience validation. |
| Skip expiry check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.skipExpiryCheck` | **Insecure** — accepts expired tokens; logs a warning (`authentication.go:64`). |
| Skip issuer check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.skipIssuerCheck` | **Insecure** — accepts any issuer; logs a warning (`authentication.go:67`). |
| Insecure skip signature check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.insecureSkipSignatureCheck` | **Insecure** — token signatures are NOT verified; logs a warning (`authentication.go:61`). |
**OIDC hard rejection:** a config that disables **all four** checks at once
(`insecureSkipSignatureCheck` + `skipExpiryCheck` + `skipIssuerCheck` + `skipClientIDCheck`)
is **rejected** — at least one check must remain enabled (`authentication.go:58`).
**`spec.authentication.oidc` supersedes the legacy `spec.configData` OIDC block.** A CR
that sets **both** JWT fields (`key` / `signatureType`) **and** `oidc` is **rejected** —
pick one authentication mode (k8s `config/authentication.go:100`). Note that
`spec.configData` is a raw string carrying **only** an OIDC block (k8s
`config/config_data.go`); it is **not** a generic `config.yaml` passthrough.
The rejection happens twice and is loud: **at admission**, by a CEL rule on the
`KubemqCluster` CRD shipped in charts **3.2.0**, and again **at reconcile** by the
operator (**v2.3.0**), which raises a `ReconcileError` condition and a `Warning` event and
**leaves running pods untouched**.
**`ReconcileError` clears on its own once you fix the CR.** From operator v2.3.0 the
condition flips to `False` with reason `LastReconcileCycleSucceeded` on the next reconcile
that completes — it is not deleted, so alerts keyed on the condition's *presence* keep working
and the recovery carries its own transition time. On older operators the condition stayed
`True` with the original stale message indefinitely; if you are reading a cluster that has
never been through a v2.3.0 reconcile, compare `observedGeneration` against `generation`
before believing it.
**If you already have a cluster with both modes set, it is serving traffic
unauthenticated right now.** Before operator v2.3.0 that combination was accepted and
produced a broker with **no authentication at all** — no `AUTHENTICATION_*` variable
reached the pod, the pod was `Running` and ready, and the CR reported `Deployed` with no
conditions. Nothing surfaced the problem.
Two things follow. **Fix the CR before you upgrade** — on operator v2.3.0 an affected
cluster stops reconciling until it is fixed (running pods keep serving; new changes stop
being applied). And **treat the window as an exposure**: the broker was reachable without
credentials for as long as that CR was live.
Find affected clusters:
```bash
kubectl get kubemqclusters.core.k8s.kubemq.io -A -o json \
| jq -r '.items[]
| select(.spec.authentication.oidc != null
and (((.spec.authentication.key // "") != "")
or ((.spec.authentication.signatureType // "") != "")))
| "\(.metadata.namespace)/\(.metadata.name)"'
```
Then remove **one** of the two blocks — either `oidc`, or the `key` / `signatureType`
pair — so exactly one authentication mode remains.
## Authorization [#authorization]
Policy-based access control, **off by default**. Supply the policy inline (`policy`) or by
URL (`url`), with optional periodic auto-reload. When enabled, **exactly one** of policy
data, policy file, or URL must be present (`authorization.go:34`).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| --------------------- | --------------- | ------- | ------------------------------ | -------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Enable | bool | `false` | true / false | `authorization.enable` · `AUTHORIZATION_ENABLE` | — (auto-derived) | Docker opt-in. On the CRD **there is no enable field** — the operator sets `AUTHORIZATION_ENABLE=true` automatically whenever `policy` or `url` is set (k8s `config/authorization.go:20-25`). |
| Policy data | string (base64) | `""` | base64-encoded policy document | `authorization.policydata` · `AUTHORIZATION_POLICY_DATA` | `spec.authorization.policy` | **Base64-encoded** — the server base64-decodes the policy content. Name divergence `policydata` ↔ `policy`. Operator emits it base64 (k8s `config/authorization.go:27`). |
| Policy URL | string | `""` | http(s) URL | `authorization.url` · `AUTHORIZATION_URL` | `spec.authorization.url` | **Raw, NOT base64** — the server hands it verbatim to `http.Get`; `validateURL` rejects a base64 blob (`authorization.go:51`, k8s `config/authorization.go:32`). `config.yaml` key is `authorization.url` (server field `Authorization.Url`). |
| Auto-reload (seconds) | int | `0` | ≥ 0 (`0` = disabled) | `authorization.autoreload` · `AUTHORIZATION_AUTO_RELOAD` | `spec.authorization.autoReload` | Reload interval in **seconds** (`services/authorization/authorization.go:50`). Negative is rejected (`authorization.go:37`). Name divergence `autoreload` ↔ `autoReload`. Omitted from CRD env when `0`. |
| Policy file | string | `""` | file path | `authorization.filepath` · `AUTHORIZATION_FILE_PATH` | — | Validated as a filename (`authorization.go:44`). **`config.yaml`/env-only** — superseded by the inline `policy` on the CRD (allowlist). |
## TLS / mTLS [#tls--mtls]
Transport encryption for the interfaces. There is **no enable flag** — the mode is
**auto-derived** from which artifacts are present (`security.go:76`): **none** (omit all),
**TLS** (server `cert` + `key`), and **mTLS** (additionally a client `ca`, so both peers
authenticate). The Docker `config.yaml` group is `security.*`; the Helm/CRD group is
`spec.tls.*` — a **name divergence**. On Docker each artifact accepts inline `data` or a
`filename`, and inline `data` takes precedence over `filename` (`resource.go:19`).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ----------- | ------ | ------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Server cert | string | `""` | PEM block (`data`) / file path (`filename`) | `security.cert.data` / `security.cert.filename` · `SECURITY_CERT_DATA` / `SECURITY_CERT_FILENAME` | `spec.tls.cert` | **Name divergence:** `security` ↔ `tls`. Required for TLS and mTLS (`security.go:48`). `*_DATA` is a raw PEM; the operator stores it in a **Secret**. |
| Server key | string | `""` | PEM block (`data`) / file path (`filename`) | `security.key.data` / `security.key.filename` · `SECURITY_KEY_DATA` / `SECURITY_KEY_FILENAME` | `spec.tls.key` | Required for TLS and mTLS (`security.go:52`). Secret on the CRD. |
| CA (mTLS) | string | `""` | PEM block (`data`) / file path (`filename`) | `security.ca.data` / `security.ca.filename` · `SECURITY_CA_DATA` / `SECURITY_CA_FILENAME` | `spec.tls.ca` | Presence promotes the mode to **mTLS** (client-certificate verification, `security.go:79`). Secret on the CRD. |
On the CRD only the inline **data** fields (`spec.tls.cert` / `.key` / `.ca`) are exposed.
The `SECURITY_*_FILENAME` keys are **`config.yaml`/env-only** and are superseded by the
inline data on the CRD (allowlist). The same holds for `AUTHENTICATION_JWT_CONFIG_FILE_PATH`
and `AUTHORIZATION_FILE_PATH`.
## Example [#example]
Supply a TLS server certificate on each target. This is a single-setting snippet — see the
[Docker guide](/configure/docker) and the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="config.yaml"
security:
cert:
filename: /certs/server.crt
key:
filename: /certs/server.key
```
```yaml title="values.yaml"
tls:
cert: |
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
key: |
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
```
For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG`
variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to
the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes).
# Storage Engines (/configure/reference/storage-engines)
KubeMQ ships **two persistence engines**: `next` and `legacy`. The engine
governs **only the persistence plane** — the Events Store and Queues. Ephemeral patterns
(Events pub/sub, Commands/Queries RPC) ride the same internal messaging core in both modes
and behave **identically** regardless of which engine a cluster runs.
## The two engines [#the-two-engines]
| | `legacy` | `next` (resolved on a clean store) |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Persistence | the legacy persistence engine (file store) | owned segment log + Dragonboat raft |
| Durability on ack | accepted into the legacy file store; fsync on a timer (`Broker.DiskSyncSeconds`, default 5s) | **selectable.** `fast` (**the default**): ack after quorum-replication, fsync follows in a bounded background window. `strict`: ack only after quorum-replication **and** fsync (zero acked-loss). See [Ack policy](#ack-policy) |
| Cluster consensus | file-store raft over the cluster mesh | Dragonboat raft over a dedicated replication listener (optional mTLS, default off) |
| Kafka compaction | not supported (Kafka is `next`-only) | supported — unlocks Kafka Connect and Kafka Streams |
| Native store limits & retention (`store.max*`) | enforced | **not consumed** — native channels have no age/size/count cap ([details](#native-retention-scope)) |
| Status | **feature-frozen** (maintenance/security/bug fixes only); opt-in — name it explicitly at creation | what a clean store resolves to; receives all new durability and compaction features |
## Choosing an engine [#choosing-an-engine]
* **A new deployment on a clean store comes up on `next`.** There is nothing to set — see
[Zero-config engine selection](#zero-config-engine-selection) below.
* **`next` is also required for Kafka** compacted topics, and it is the only engine that
can give you quorum-fsynced durability — **but not by default**: that needs
`store.nextackpolicy: strict`. See [Ack policy](#ack-policy).
* **To run `legacy`, name it explicitly at creation** — `store.engine: legacy` (Docker) or
`spec.store.engine: legacy` (Kubernetes). It is no longer what you get by omission.
* **An existing cluster keeps the engine it was born with.** There is no migration path
between engines, and nothing about the change above rolls or reinterprets a running
cluster.
**If you have a page, script, or values file that assumes `legacy` is what you get by
default, it is now wrong.** A fresh cluster with a clean store resolves to `next`. This
changed in operator **v2.3.0** on Kubernetes; on Docker the server has always resolved a
clean store to `next` when the engine was left unset.
## Zero-config engine selection [#zero-config-engine-selection]
An explicit engine always wins and skips every probe below: `store.engine` (Docker
`config.yaml`), `STORE_ENGINE` (env), or `spec.store.engine` (Kubernetes).
Otherwise **the server resolves the engine itself at boot**, by probing the store
directory:
* **The directory already holds a store** → the engine that wrote it.
* **The directory is clean** → `next`.
The resolved engine is named in a **boot NOTICE** in the server log, along with the
deciding signal and the directory that was probed. When the choice was delegated rather
than pinned, **that log line is the only place the running engine is reported** — see
[Which engine am I actually on?](#which-engine-am-i-actually-on) below.
### On Kubernetes the operator delegates, with `STORE_ENGINE=auto` [#on-kubernetes-the-operator-delegates-with-store_engineauto]
From operator **v2.3.0**, a cluster with **no engine on record** is created with
`STORE_ENGINE=auto` and the server resolves it as above. Established clusters are
untouched — their engine stays named explicitly and nothing rolls.
Three consequences worth holding onto:
* **`status.engine` and the `core.k8s.kubemq.io/established-engine` annotation both read
`auto`** on such a cluster. That value records *the delegation*, not the engine running.
* **Setting `spec.store.engine` later on an `auto` cluster is allowed.** `auto` records
that the cluster delegated the choice, not an established engine, so pinning afterwards
is a first choice rather than an engine change. **Pin the engine the server actually
resolved** — a pin that disagrees with the data on disk is refused by the server at
boot, and the server never deletes a datadir.
* **On an established cluster the annotation is authoritative and outranks
`spec.store.engine`.** A mismatch is refused, not silently applied. See the
[engine-establishment guard](/configure/reference/deployment#engine-establishment-guard).
### Which engine am I actually on? [#which-engine-am-i-actually-on]
| Cluster | Where to read the engine |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Engine named explicitly | `spec.store.engine`, `status.engine`, or the annotation — all agree |
| Engine delegated (`auto`) | **the server's boot NOTICE in the pod log**, or the two API fields below. `status.engine` and the annotation both say `auto`, which is the delegation, not the answer |
```bash
kubectl logs -n | grep -i 'store engine'
```
The management API's config view carries the same answer in two read-only fields — they
exist precisely because this question was previously unanswerable on a delegating
deployment. Neither is an operator input, and neither is ever written back into a saved
config file:
| Field | What it holds |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `engineResolved` | The engine this node is **actually running** — always a concrete `legacy` or `next`, never `auto`. |
| `engineResolvedSignal` | **Why** it holds that, in plain words: `clean store (auto default)`, `explicitly configured (detection did not run)`, `clean store (CleanStore=true)`, or the on-disk evidence the probe matched. |
The signal matters as much as the engine. A node reporting `legacy` may have been pinned by
an operator, or may have detected legacy data on disk — and the remedy differs.
### Kafka [#kafka]
Kafka runs on `next` only, and needs no manual engine step:
* **Clean store + Kafka enabled + engine unset** → resolves to `next` (NOTICE logged) —
the same clean-store outcome as without Kafka.
* **Existing `legacy` store data + Kafka enabled** → fails closed with a config error
naming the conflicting store directory. Kafka never ran on `legacy`, so the server
refuses to mix engines rather than silently reinterpreting existing data.
* **Explicit `store.engine: legacy` + Kafka enabled** → rejected at boot. Kafka requires
the `next` engine; naming `legacy` alongside it is a configuration error.
* **Naming `next` explicitly** skips the probe entirely and always wins — the predictable
choice for IaC/GitOps that shouldn't depend on probe-time filesystem state.
## Mode isolation [#mode-isolation]
A cluster is **born one mode and stays there**. The data directory records a mode marker
at first boot. Every later boot compares that marker against the configured engine — a
**mismatch is a fatal boot error**: the server refuses to start, and it **never wipes the
directory**. There is no in-place engine migration and no cross-format compatibility
between `legacy` and `next` data.
## Durability guarantees [#durability-guarantees]
Three distinct dimensions govern durability, and they should not be conflated:
1. **Ack meaning.** On `legacy`, a publish is acknowledged after the write lands in the
file store; the store is fsynced on a timer (`Broker.DiskSyncSeconds`, default 5s) —
so an ack can precede the fsync. On `next`, **it depends on the ack policy** below:
under `strict` a publish is acknowledged only after the write is quorum-replicated
**and** fsynced; under `fast` — the shipped default — it is acknowledged once
quorum-replicated, with the fsync following in a bounded background window.
2. **Loss window.** "Zero acked-loss" describes **`next` with `strict` only**. It means an
**acked** message is guaranteed to survive. Under the default `fast`, an ack does
**not** imply the record is on disk: writes acknowledged inside the flush window can be
lost to power loss on a whole-quorum failure. And on any engine or policy, "zero
acked-loss" never means zero loss overall — unacked, in-flight work can still be lost
if the client never receives the ack.
3. **Consistency under failover.** On `next`, per-channel sequence numbers are assigned
from the committed raft apply order — they are **gap-free, monotonic, and
restart-stable**, even across a leader election.
## Next-engine settings [#next-engine-settings]
Six `next`-engine settings live on the store config. All are `config.yaml` / env-only —
none has a typed CRD field, so on Kubernetes they travel through `spec.env` or a mounted
config. Every one of them parses and validates on **both** engines; they are simply inert
under `legacy`, so a config that would be wrong after an engine switch fails when it is
written rather than when it becomes load-bearing.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| --------------------- | ------------- | ----------------------------- | ------------------ | --------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ack policy | string (enum) | `fast` | `fast` \| `strict` | `store.nextackpolicy` · `STORE_NEXT_ACK_POLICY` | — | The ack durability contract — see [Ack policy](#ack-policy) below. **Exact match only**: `Fast`, `STRICT` and `true` are all rejected. |
| Raft address | string | `""` → `127.0.0.1:` | `host:port` | `store.nextraftaddress` · `STORE_NEXT_RAFT_ADDRESS` | — | The engine's Dragonboat replication-listener bind address. Empty computes a single-node loopback default. A standalone node pins the listener to loopback regardless — it must not be routable. |
| Segment size (bytes) | int64 | `0` → engine default (64 MiB) | ≥ `0` | `store.nextsegmentsize` · `STORE_NEXT_SEGMENT_SIZE` | — | Per-channel segment-log size, the analog of Kafka's `segment.bytes`. A smaller value rolls segments sooner, which is what lets compaction reclaim space promptly on low-volume topics. |
| Shard-pool size | int | `1` | `1`–`16` | `store.nextshards` · `STORE_NEXT_SHARDS` | — | Number of independent raft groups the persistent data plane is partitioned across, with channels hash-mapped onto them. `1` is the GA layout and is behavior-identical to it. `0` normalizes to `1`; above `16` is rejected. |
| Balance shard leaders | bool | `false` | true / false | `store.nextbalanceleaders` · `STORE_NEXT_BALANCE_LEADERS` | — | Opt-in: spreads shard leaders across cluster nodes instead of pinning them all to one. Meaningless without a shard pool > 1 — rejected unless the cluster is clustered `next` with `nextshards` > 1. `false` is the shipped GA behavior. |
### Ack policy [#ack-policy]
**The shipped default is `fast`, and under `fast` an ack does NOT mean the record is on
disk.** A fresh `next` cluster does not give you the quorum-fsynced ack unless you ask for
it.
| | `fast` (**default**) | `strict` |
| ----------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Ack means | the entry is **quorum-replicated**; the raft log fsync follows in the background, within a bounded window | the entry is quorum-replicated **and fsynced on a quorum** of raft logs |
| Loss window | writes acked inside the flush window can be lost to a whole-quorum power loss | none — this is the "zero acked-loss" contract |
| Cost | lower publish latency | fsync on the publish path |
If a compliance requirement or an audit answer depends on *"an ack means it is on disk"*,
set it explicitly:
```yaml title="config.yaml"
store:
nextackpolicy: strict
```
On Kubernetes, via `spec.env`:
```yaml title="values.yaml"
env:
STORE_NEXT_ACK_POLICY: "strict"
```
## Native retention scope [#native-retention-scope]
The store-level limit and retention settings — **Max channels**, **Max channel size**,
**Max messages**, **Message retention**, and **Purge inactive** (see
[Storage & Queues](/configure/reference/storage-queues)) — are enforced by the
**`legacy` engine only**. The `next` engine does not consume them: a native Events Store
or Queues channel on `next` has **no age, size, or count cap** and grows unbounded under
a slow or absent consumer. The queue ack-wait timeout governs redelivery of in-flight
messages — it is not a backlog-eviction bound.
On `next`, the only age-evicted channels are **Kafka topic channels**, via the Kafka
connector's `retention.ms` (see
[Durability & Retention](/connectors/kafka/concepts/durability-and-retention#retention-time-and-size)).
Size native Events Store / Queues workloads on `next` by disk capacity, or use Kafka
topic channels where age eviction matters.
**This now applies to new clusters by default.** Because a clean store resolves to `next`,
a freshly installed cluster has **no age, size, or count cap** on native Events Store and
Queues channels unless you set one — the `store.max*` limits are `legacy`-only. Size by
disk, and set a `spec.volume.size` you can live with.
## Support & deprecation [#support--deprecation]
Both engines are **fully supported today**. `legacy` is **feature-frozen** — it receives
maintenance, security, and bug fixes only. `next` receives all new durability and
compaction features going forward. The deprecation policy for `legacy` is **explicitly
TBD** — no sunset date has been set, and both engines are supported until one is.
## Compaction [#compaction]
Kafka log compaction is **`next`-only**. The Kafka connector's `cleanup.policy` accepts
`delete` (default), `compact`, or `compact,delete`. Compaction keeps only the latest
record per key and reaps tombstones after `delete.retention.ms` (default 24h) — it never
renumbers surviving offsets. Compaction is scoped to Kafka topic channels only; native
Queues and Events Store channels never compact. This is the feature that unlocks Kafka
Connect (its internal topics require compaction) and Kafka Streams changelogs.
## Clustered next-engine replication [#clustered-next-engine-replication]
The `Cluster.Replication.*` block configures the `next` engine's Dragonboat replication
listener — a second membership plane a `next`-mode cluster runs alongside the cluster
mesh. It is meaningful **only** when `Store.Engine` is `next` **and** `Cluster.Enable` is
`true`. This block is Docker / `config.yaml` and env-only — there is **no Helm/CRD path**.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------------- | ------ | ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Replica ID | uint64 | `0` | ≥ 0 | `cluster.replication.replicaid` · `CLUSTER_REPLICATION_REPLICA_ID` | — | This node's Dragonboat replica id (1..N); must be a key in `Peers`. May be left `0` when `POD_NAME` is present — see [ReplicaID auto-derive](#replicaid-auto-derive). |
| Raft address | string | `""` | `host:port` | `cluster.replication.raftaddress` · `CLUSTER_REPLICATION_RAFT_ADDRESS` | — | This node's **advertised** raft address. When set it wins over the address derived from `Peers` for what this node announces to its peers — set it when the address peers must dial differs from the one this node binds. |
| Peers | string | `""` | `id@host:port,...` | `cluster.replication.peers` · `CLUSTER_REPLICATION_PEERS` | — | Full initial-members map — every initial member, including self. Byte-identical on every pod. |
| Join | bool | `false` | true / false | `cluster.replication.join` · `CLUSTER_REPLICATION_JOIN` | — | Boot as a later-added replica, with an empty members map. |
| RTT (ms) | uint64 | `200` | ≥ 0 | `cluster.replication.rttmillisecond` · `CLUSTER_REPLICATION_RTT_MILLISECOND` | — | Dragonboat logical-clock tick, in milliseconds. |
| Election RTT | uint64 | `10` | ≥ 0 | `cluster.replication.electionrtt` · `CLUSTER_REPLICATION_ELECTION_RTT` | — | Must be greater than `2 × HeartbeatRTT`. |
| Heartbeat RTT | uint64 | `1` | ≥ 0 | `cluster.replication.heartbeatrtt` · `CLUSTER_REPLICATION_HEARTBEAT_RTT` | — | |
| Snapshot entries | uint64 | `10000` | ≥ 0 | `cluster.replication.snapshotentries` · `CLUSTER_REPLICATION_SNAPSHOT_ENTRIES` | — | Committed entries between automatic snapshots. |
| Compaction overhead | uint64 | `2000` | ≥ 0 | `cluster.replication.compactionoverhead` · `CLUSTER_REPLICATION_COMPACTION_OVERHEAD` | — | Log entries retained past a snapshot. |
| Boot timeout (s) | int | `60` | ≥ 0 | `cluster.replication.boottimeoutseconds` · `CLUSTER_REPLICATION_BOOT_TIMEOUT_SECONDS` | — | Clustered-boot readiness-probe budget, in seconds. |
| Mutual TLS | bool | `false` | true / false | `cluster.replication.mutualtls` · `CLUSTER_REPLICATION_MUTUAL_TLS` | — | Optional mTLS trust domain. Default **off** (plaintext) — see the security callout below. |
| CA / Cert / Key file | string | `""` | file path | `cluster.replication.cafile` / `cluster.replication.certfile` / `cluster.replication.keyfile` · `CLUSTER_REPLICATION_CA_FILE` / `CLUSTER_REPLICATION_CERT_FILE` / `CLUSTER_REPLICATION_KEY_FILE` | — | A **dedicated** trust domain — not reused from `Security`. Required (fail-closed) when `Mutual TLS` is `true`. |
When **Mutual TLS** is left at its default `false`, a clustered `next` node's Dragonboat
replication listener runs an **unauthenticated, FSM-writing raft port**. This is
acceptable only on a trusted pod network, bounded by a `NetworkPolicy` — never expose this
listener beyond the cluster's own pod network.
## ReplicaID auto-derive [#replicaid-auto-derive]
On a `next`-engine cluster with clustering enabled, `Peers` set, `Replica ID` left at `0`,
and `POD_NAME` present in the environment, the server derives
`ReplicaID = ordinal(POD_NAME) + 1` — the integer after the last `-` in the pod name, plus
one (StatefulSet ordinals are 0-based, so replica ids run `1..N`).
* An **explicit non-zero** `Replica ID` always wins — the derive path is skipped.
* An **unparseable** `POD_NAME` (no `-` suffix) fails closed.
* The operator obligation: the host at id `ordinal + 1` in `Peers` must be the DNS name
of the pod at that ordinal — a mismatch is a DNS-time failure (the cluster boots but
never forms quorum), not a config-validation error.
This lets `Peers` be byte-identical across every pod in a StatefulSet, with no per-pod
templating required.
## Kubernetes [#kubernetes]
On Kubernetes, the persistence engine is established once, at cluster creation, by the
operator's engine-establishment guard — it is never changed for a live cluster. See
[Deployment & High Availability](/configure/reference/deployment) for the operator-side
behavior.
## Example [#example]
Set the persistence engine on each target. This is a single-setting snippet — see the
[Docker guide](/configure/docker) and the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="config.yaml"
store:
engine: next
```
```yaml title="values.yaml"
store:
engine: next
```
## See Also [#see-also]
# Storage & Queues (/configure/reference/storage-queues)
KubeMQ persists messages through an embedded **persistent store** and serves pull-based
delivery through its **queues**. The store controls how much is retained and for how long;
the queue settings control visibility, wait, delay, expiration, and retry behavior. Each
setting is shown for both deployment targets — Docker single-node (`config.yaml` key · env
var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the
setting is not available on that surface.
Store and queue tuning is **opt-out**: the `store` and `queue` blocks are internal
server sections (not wire connectors), so they are always active with the defaults below —
there is no `enable`/`disabled` toggle. Override only the fields you need.
## Persistent store [#persistent-store]
Limits and retention for the persistent store. The **persistence engine** is chosen once
at cluster creation — see [Storage Engines](/configure/reference/storage-engines) for
the full engine model, durability guarantees, and clustering. Several store fields carry
**name divergences** between the Docker `config.yaml` key and the Helm/CRD field — the
Notes column flags each one. The ten fields below are all CRD-settable under
`spec.store.*`; the store config carries **five more** — the `next`-engine settings
(`nextackpolicy`, `nextraftaddress`, `nextsegmentsize`, `nextshards`,
`nextbalanceleaders`), which are `config.yaml`/env-only and documented on
[Storage Engines](/configure/reference/storage-engines#next-engine-settings).
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| ------------------------ | ------ | ------------------------------------------------------------ | ---------------------------- | -------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Persistence engine | string | *unset* → resolved from the store directory (clean ⇒ `next`) | `legacy` \| `next` \| `auto` | `store.engine` · `STORE_ENGINE` | `spec.store.engine` (typed CRD enum) | Chosen **once, at cluster creation** — a cluster is born one mode and stays there; no in-place migration. **Unset (or the explicit `auto`) does not mean `legacy`:** the server probes the store directory and resolves the engine that wrote it, or `next` for a clean one. To get `legacy` you must name it. See [Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection). |
| Clean store on start | bool | `false` | true / false | `store.cleanstore` · `STORE_CLEAN_STORE` | `spec.store.clean` | ⚠️ **Destructive — wipes the store on every boot.** See the warning below. Name divergence: `cleanstore` ↔ `clean`. CRD emits `STORE_CLEAN_STORE=true` only when `clean: true`; otherwise unset. |
| Store path | string | `./store` | non-empty file path | `store.storepath` · `STORE_STORE_PATH` | `spec.store.path` | Name divergence: `storepath` ↔ `path`. Empty string is rejected. **On the `legacy` engine, absolute paths are rewritten to relative:** a leading `/` is prefixed with `.` (so `/data` becomes `./data`). **On the `next` engine, an absolute `StorePath` is honored verbatim** (e.g. a mounted PVC path like `/store`) — no rewrite. This is server-process behavior; on Kubernetes the operator supplies the mount and rejects a leading `/` in `spec.store.path` outright (see [Deployment](/configure/reference/deployment)) — the two layers aren't in conflict. |
| Max channels | int | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxqueues` · `STORE_MAX_QUEUES` | `spec.store.maxChannels` | **Name divergence:** `store.maxqueues` ↔ `spec.store.maxChannels`. Negative rejected. `0` logs a stderr WARNING (unbounded). |
| Max channel size (bytes) | int64 | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxqueuesize` · `STORE_MAX_QUEUE_SIZE` | `spec.store.maxChannelSize` | **Name divergence:** `store.maxqueuesize` ↔ `spec.store.maxChannelSize`. **Type divergence:** server field is `int64`; the CRD field is `*int32`, so via Helm the max is \~2.1 GB — set larger caps through `config.yaml`/env. `0` logs a stderr WARNING. |
| Max messages / channel | int | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxmessages` · `STORE_MAX_MESSAGES` | `spec.store.maxMessages` | Negative rejected. `0` logs a stderr WARNING. |
| Max subscribers | int | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxsubscribers` · `STORE_MAX_SUBSCRIBERS` | `spec.store.maxSubscribers` | Negative rejected. `0` logs a stderr WARNING. |
| Message retention (min) | int | `1440` | ≥ 0 (minutes) | `store.maxretention` · `STORE_MAX_RETENTION` | `spec.store.messagesRetentionMinutes` | **Name divergence:** `store.maxretention` ↔ `spec.store.messagesRetentionMinutes`. Negative rejected. |
| Purge inactive (min) | int | `1440` | ≥ 0 (minutes) | `store.maxpurgeinactive` · `STORE_MAX_PURGE_INACTIVE` | `spec.store.purgeInactiveMinutes` | **Name divergence:** `store.maxpurgeinactive` ↔ `spec.store.purgeInactiveMinutes`. Negative rejected. |
| Idle prune cutoff (hrs) | int | `24` | ≥ 1 | `store.idleprunecutoffhours` · `STORE_IDLE_PRUNE_CUTOFF_HOURS` | `spec.store.idlePruneCutoffHours` | Must be **at least 1** (server rejects `0`; CRD schema enforces `minimum: 1`). |
**`store.cleanstore: true` deletes the persistent store on every single boot — not once.**
It is not a one-shot reset. Left in a `values.yaml` or a `config.yaml`, every pod roll,
node drain, crash-restart, and routine upgrade wipes production data — **and every restart
looks completely healthy**, because deleting the store is exactly what you asked for.
Use it for a deliberate, supervised reset and **take it back out immediately**. There is
no confirmation, no dry-run, and no undo.
Two things bound the damage, neither of which is a safety net you should rely on: the
engine-mode guard runs **before** the wipe, so a mode-mismatched or unrecognized directory
fails fast and is never deleted; and on a clustered `next` cluster the server **refuses**
to wipe a member's datadir outright, telling you to remove the member from the cluster
first. A standalone node has neither protection.
Leaving any of **Max channels / Max channel size / Max messages / Max subscribers** at
its default of `0` means *unlimited* and prints a startup WARNING to stderr — the store
places no bound on that dimension, which can drive unbounded memory/disk use in
production. Set explicit ceilings for production workloads.
**These limits are enforced by the `legacy` engine only — and `next` is what a new
cluster gets.** A fresh install on a clean store resolves to `next`, so **the default
deployment enforces no retention at all**, including the `1440`-minute Message retention
and Purge inactive values printed in the table above. Those two rows describe `legacy`.
On the `next` engine, **Max channels / Max channel size / Max messages / Message retention
/ Purge inactive** are not consumed — a native Events Store or Queues channel has no age, size, or
count cap and grows unbounded under a slow or absent consumer. Size `next` deployments by
disk capacity, or use Kafka topic channels (whose `retention.ms` *is* age-enforced on
`next`) where eviction matters. See
[Storage Engines](/configure/reference/storage-engines#native-retention-scope).
## Queues [#queues]
Delivery defaults and ceilings for pull-based queues. Two Docker keys diverge from the
Helm/CRD field names — `queue.maxreceivecount` ↔ `spec.queue.maxReQueues` (the retry
ceiling) and `queue.maxnumberofmessages` ↔ `spec.queue.maxReceiveMessagesRequest` (the
per-request batch). All ten queue fields are fully CRD-settable under `spec.queue.*`.
| Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes |
| -------------------------- | ----- | ------- | ------------ | ------------------------------------------------------------------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Default visibility (s) | int32 | `60` | > 0 | `queue.defaultvisibilityseconds` · `QUEUE_DEFAULT_VISIBILITY_SECONDS` | `spec.queue.defaultVisibilitySeconds` | |
| Default wait timeout (s) | int32 | `1` | > 0 | `queue.defaultwaittimeoutseconds` · `QUEUE_DEFAULT_WAIT_TIMEOUT_SECONDS` | `spec.queue.defaultWaitTimeoutSeconds` | |
| Max visibility (s) | int32 | `43200` | > 0 | `queue.maxvisibilityseconds` · `QUEUE_MAX_VISIBILITY_SECONDS` | `spec.queue.maxVisibilitySeconds` | 43200 s = 12 h. |
| Max wait timeout (s) | int32 | `3600` | > 0 | `queue.maxwaittimeoutseconds` · `QUEUE_MAX_WAIT_TIMEOUT_SECONDS` | `spec.queue.maxWaitTimeoutSeconds` | |
| Max delay (s) | int32 | `43200` | > 0 | `queue.maxdelayseconds` · `QUEUE_MAX_DELAY_SECONDS` | `spec.queue.maxDelaySeconds` | 43200 s = 12 h. |
| Max expiration (s) | int32 | `43200` | > 0 | `queue.maxexpirationseconds` · `QUEUE_MAX_EXPIRATION_SECONDS` | `spec.queue.maxExpirationSeconds` | 43200 s = 12 h. |
| Retry ceiling (→ DLQ) | int32 | `1024` | > 0 | `queue.maxreceivecount` · `QUEUE_MAX_RECEIVE_COUNT` | `spec.queue.maxReQueues` | **Name divergence:** `queue.maxreceivecount` ↔ `spec.queue.maxReQueues`. Max redeliveries before dead-lettering. |
| Max messages / receive req | int32 | `1024` | > 0 | `queue.maxnumberofmessages` · `QUEUE_MAX_NUMBER_OF_MESSAGES` | `spec.queue.maxReceiveMessagesRequest` | **Name divergence:** `queue.maxnumberofmessages` ↔ `spec.queue.maxReceiveMessagesRequest`. Per-request batch ceiling. |
| Max inflight | int32 | `2048` | > 0 | `queue.maxinflight` · `QUEUE_MAX_INFLIGHT` | `spec.queue.maxInflight` | Max unacked in-flight messages per queue. |
| Pub-ack wait (s) | int32 | `60` | > 0 | `queue.pubackwaitseconds` · `QUEUE_PUB_ACK_WAIT_SECONDS` | `spec.queue.pubAckWaitSeconds` | Publish-ack wait before the send is considered failed. |
**Every queue field must be greater than `0` — the server rejects `0` at startup.** The
CRD schema already enforces this with `minimum: 1` on all ten queue fields, so a Helm
value of `0` is rejected by the API server *and* would fail server validation. Treat the
effective minimum for every queue setting as **`1`**.
## Example [#example]
Set message retention on each target. This is a single-setting snippet — see the
[Docker guide](/configure/docker) and the
[Kubernetes guide](/configure/kubernetes) for complete, runnable configurations.
```yaml title="config.yaml"
store:
maxretention: 1440
```
```yaml title="values.yaml"
store:
messagesRetentionMinutes: 1440
```
For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG`
variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to
the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes).
# AMQP 1.0 (/connectors/amqp)
Point your AMQP 1.0 application at KubeMQ by changing only the connection string and
node address. The **AMQP 1.0 connector** is a built-in, wire-protocol bridge inside
kubemq-server that speaks the OASIS AMQP 1.0 dialect natively — any standard AMQP 1.0
client (Qpid, go-amqp, AMQPNetLite, rhea) talks to KubeMQ's Queues, Events,
Events-Store, Commands, and Queries with no KubeMQ SDK, no library swap, and no code
rewrite.
## What is the AMQP 1.0 connector [#what-is-the-amqp-10-connector]
The AMQP 1.0 connector exposes [AMQP 1.0](https://www.amqp.org/) (OASIS / ISO-IEC 19464)
natively over a dedicated port for **all five KubeMQ messaging patterns**. The leading
segment of the address a client attaches to selects the pattern: attaching to
`queues/orders` binds to a KubeMQ Queue, `events/telemetry` to Events, and so on. The
connector is a *gateway*, not a client library — your application only needs a stock
AMQP 1.0 client.
AMQP 1.0 is a peer-to-peer [link](/connectors/amqp/concepts/architecture#connection--session--link)
protocol, distinct from the AMQP 0-9-1 dialect the [RabbitMQ connector](/connectors/rabbitmq)
speaks: a link attaches to a *node* (an address), flow is governed by *credit*, and
delivery is resolved by a *delivery state* (accepted / released / modified / rejected) —
there are no exchanges, bindings, routing keys, or publisher-confirms. See
[Architecture](/connectors/amqp/concepts/architecture) for the full model, or
[Migrating from ActiveMQ](/connectors/how-to/migration/from-activemq) if you're
coming from 0-9-1.
Key capabilities:
* **All five patterns over one wire** — Queues, Events, Events-Store, Commands, and
Queries, selected by the address prefix.
* **Address-driven pattern routing** — `queues/`, `events/`, `events-store/`,
`commands/`, `queries/` prefixes map a link to a KubeMQ pattern by longest-prefix
match.
* **Native settlement and credit** — at-least-once (unsettled) or at-most-once
(pre-settled) delivery, credit-driven flow control, and the standard AMQP dispositions.
* **Cross-protocol interop** — a message sent over AMQP 1.0 to `queues/orders` is
consumable by a gRPC or REST KubeMQ client on the same channel, and vice-versa.
## How it works [#how-it-works]
An AMQP 1.0 client connects to the connector and attaches a link to a node address. The
connector resolves the address to a KubeMQ `(pattern, channel)` pair, hands the message to
the message broker, and consumers on the same channel — over AMQP 1.0 or any other KubeMQ
transport — receive it.
*The shared `amqpmux` front door classifies the connection by its 8-byte protocol header and dispatches it to the AMQP 1.0 engine, which maps the node address onto a KubeMQ pattern and channel.*
## Ports & protocol surface [#ports--protocol-surface]
| Port | Transport | Protocol | Notes |
| ------ | ---------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `5672` | Plain TCP (SASL ANONYMOUS / PLAIN) | AMQP 1.0 (OASIS / ISO-IEC 19464) | Default plain listener. **Shared with the RabbitMQ (AMQP 0-9-1) connector** via the internal `amqpmux`. |
| `5671` | TLS over TCP | AMQP 1.0 | Binds only when the server-global `Security` block is configured. Shared TLS listener with AMQP 0-9-1. |
A single `amqpmux` listener accepts every connection on `5672`/`5671`, reads the 8-byte
AMQP protocol header, and routes it to the matching dialect engine — so AMQP 1.0 and
AMQP 0-9-1 coexist on the same ports. There is **no vhost**: the OPEN `hostname` field is
accepted and ignored. See [Architecture](/connectors/amqp/concepts/architecture) for the
dispatch detail.
## Send a message [#send-a-message]
The example below produces one message to `queues/` over a stock AMQP 1.0 client.
Each send is unsettled (at-least-once): it blocks until the connector returns an `accepted`
disposition, confirming the broker stored the message. Every client reads the broker
endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`).
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
amqp "github.com/Azure/go-amqp"
)
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://localhost:5672"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
addr := "queues/amqp10.examples.basic"
// OPEN: SASL ANONYMOUS by default (no userinfo in the URL).
conn, err := amqp.Dial(ctx, amqpURL(), nil)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
session, err := conn.NewSession(ctx, nil)
if err != nil {
log.Fatalf("new session: %v", err)
}
// ATTACH a sender (server-receiver link) and send one unsettled message.
sender, err := session.NewSender(ctx, addr, nil)
if err != nil {
log.Fatalf("new sender: %v", err)
}
if err := sender.Send(ctx, amqp.NewMessage([]byte("hello from AMQP 1.0")), nil); err != nil {
log.Fatalf("send: %v", err)
}
_ = sender.Close(ctx)
fmt.Printf("sent 1 message to %s (accepted)\n", addr)
}
```
```python
import os
from proton import Message
from proton.utils import BlockingConnection
def amqp_url() -> str:
return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")
def main() -> None:
addr = "queues/amqp10.examples.basic"
# OPEN: SASL ANONYMOUS by default (no userinfo in the URL).
conn = BlockingConnection(amqp_url())
try:
# ATTACH a sender; each send blocks for the accepted disposition.
sender = conn.create_sender(addr)
sender.send(Message(body="hello from AMQP 1.0"))
sender.close()
print(f"sent 1 message to {addr} (accepted)")
finally:
conn.close()
if __name__ == "__main__":
main()
```
```java
import javax.jms.Connection;
import javax.jms.MessageProducer;
import javax.jms.Queue;
import javax.jms.Session;
import javax.jms.TextMessage;
import org.apache.qpid.jms.JmsConnectionFactory;
public final class Main {
public static void main(String[] args) throws Exception {
String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672");
String address = "queues/amqp10.examples.basic";
// OPEN: SASL ANONYMOUS by default. The JMS destination name IS the
// connector node address.
JmsConnectionFactory factory = new JmsConnectionFactory(url);
try (Connection connection = factory.createConnection()) {
connection.start();
try (Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE)) {
Queue queue = session.createQueue(address);
try (MessageProducer producer = session.createProducer(queue)) {
TextMessage msg = session.createTextMessage("hello from AMQP 1.0");
producer.send(msg); // blocks until the accepted DISPOSITION
}
System.out.printf("sent 1 message to %s (accepted)%n", address);
}
}
}
}
```
```csharp
using System.Text;
using Amqp;
using Amqp.Framing;
static string AmqpUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
? v
: "amqp://localhost:5672";
var addr = "queues/amqp10.examples.basic";
// OPEN: SASL ANONYMOUS by default (no userinfo in the URL).
var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl()));
try
{
var session = new Session(connection);
// ATTACH a sender (server-receiver link) and send one unsettled message.
var sender = new SenderLink(session, "basic-sender", addr);
var message = new Message
{
BodySection = new Data { Binary = Encoding.UTF8.GetBytes("hello from AMQP 1.0") },
};
sender.Send(message, TimeSpan.FromSeconds(15)); // blocks for the accepted DISPOSITION
await sender.CloseAsync();
await session.CloseAsync();
Console.WriteLine($"sent 1 message to {addr} (accepted)");
}
finally
{
await connection.CloseAsync();
}
```
```typescript
import { Connection, type ConnectionOptions } from "rhea-promise";
function connectionOptions(): ConnectionOptions {
const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672");
return {
host: url.hostname,
port: url.port ? Number(url.port) : 5672,
// The connector requires a non-empty container-id; rhea sends one by default.
container_id: `kubemq-amqp10-js-${process.pid}`,
reconnect: false,
};
}
async function main(): Promise {
const address = "queues/amqp10.examples.basic";
// OPEN: SASL ANONYMOUS by default (no username/password).
const connection = new Connection(connectionOptions());
await connection.open();
try {
// Attach an AwaitableSender; send() resolves on the accepted disposition.
const sender = await connection.createAwaitableSender({ target: { address } });
await sender.send({ body: "hello from AMQP 1.0" }, { timeoutInSeconds: 15 });
await sender.close();
console.log(`sent 1 message to ${address} (accepted)`);
} finally {
await connection.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```rust
use fe2o3_amqp::{Connection, Sender, Session};
use fe2o3_amqp_types::definitions::SenderSettleMode;
fn amqp_url() -> String {
std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let addr = "queues/amqp10.examples.basic";
// OPEN: SASL ANONYMOUS by default; a non-empty container-id is required.
let mut connection = Connection::open("kubemq-amqp10-rust", amqp_url().as_str()).await?;
let mut session = Session::begin(&mut connection).await?;
// ATTACH a sender pinned to Unsettled (at-least-once); the connector
// rejects the AMQP default `mixed`.
let mut sender = Sender::builder()
.name("basic-sender")
.target(addr)
.sender_settle_mode(SenderSettleMode::Unsettled)
.attach(&mut session)
.await?;
let outcome = sender.send("hello from AMQP 1.0").await?;
if !outcome.is_accepted() {
return Err(format!("unexpected outcome {outcome:?}").into());
}
sender.close().await?;
println!("sent 1 message to {addr} (accepted)");
session.end().await?;
connection.close().await?;
Ok(())
}
```
## Supported languages [#supported-languages]
The connector speaks standard AMQP 1.0, so any conformant client works. The examples
pin one native AMQP 1.0 client per language — there is no KubeMQ SDK, no proto bindings,
and no published package.
| Language | Client library | Notes |
| ----------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------- |
| Go | [`github.com/Azure/go-amqp`](https://github.com/Azure/go-amqp) | The connector's reference client. |
| Python | [`python-qpid-proton`](https://qpid.apache.org/proton/) | Sync `BlockingConnection`. |
| Java | [`org.apache.qpid:qpid-jms-client`](https://qpid.apache.org/components/jms/) | `javax.jms` (not Jakarta). |
| C# / .NET | [`AMQPNetLite.Core`](https://github.com/Azure/amqpnetlite) | Task-based async. |
| JavaScript / TypeScript | [`rhea`](https://github.com/amqp/rhea) + [`rhea-promise`](https://github.com/amqp/rhea-promise) | Event-driven, promise-wrapped. |
| Rust | [`fe2o3-amqp`](https://github.com/minghuaw/fe2o3-amqp) | async/await on Tokio. |
Apache Qpid JMS (Java) cannot drive the anonymous-terminus link — it has no API to force
a raw null-target link, and the connector advertises no `ANONYMOUS-RELAY` capability. Use
per-pattern senders instead. See [Address mapping](/connectors/amqp/reference/address-mapping).
## Next steps [#next-steps]
# AWS (SQS & SNS) (/connectors/aws)
Point your AWS SQS / SNS application at KubeMQ by changing only the endpoint URL. The
**AWS connector** is a built-in, wire-protocol bridge inside kubemq-server that speaks the
genuine AWS SQS and SNS HTTP protocols on a dedicated second listener — any standard,
unmodified AWS SDK (boto3, `aws-sdk-go-v2`, the AWS SDK for Java/JS/.NET/Ruby/Rust) talks
to KubeMQ with no LocalStack, no library swap, and no KubeMQ SDK.
## What is the AWS connector [#what-is-the-aws-connector]
The connector is **one binary with two service surfaces** that map onto two distinct
KubeMQ models:
* **SQS → KubeMQ Queue.** Every SQS queue maps onto a native KubeMQ **Queue** channel
`sqs.{name}`. AWS producers and native gRPC/REST consumers share the same messages on
that channel. A FIFO group fans onto its own channel `sqs.{name}.fifo.g.{enc(group)}`.
* **SNS → virtual fan-out.** SNS topics are **virtual** — a registry replicated across
cluster nodes, with no native channel. At publish time a topic fans out to every
confirmed subscription: subscribed SQS queues (a batch send) and HTTP/HTTPS webhooks (a
delivery engine).
Because SQS is point-to-point and SNS is publish/subscribe — neither is request/reply —
there is **no RPC**: no Commands, no Queries, no gRPC responder anywhere. The connector
exposes the queue and fan-out surfaces only.
The AWS connector is **opt-in (disabled by default)** — enabling it opens a **new HTTP
listener on port 4566** that is not bound until you set `CONNECTORS_AWS_ENABLE=true`. Unlike
the other wire-protocol connectors, a stock server does **not** serve AWS until you turn it
on. See [Getting started](/connectors/aws/tutorials/getting-started).
## How it works [#how-it-works]
An AWS SDK client sends a signed SQS or SNS request to the connector's endpoint. The
connector detects the protocol, verifies the SigV4 signature shape, and dispatches: SQS
operations land on the KubeMQ Queue channel `sqs.{name}` through the message broker; SNS
publishes resolve the virtual topic registry and fan out to the subscribed targets.
*SQS requests map onto the KubeMQ Queue channel `sqs.{name}` through the message broker; SNS publishes resolve the virtual topic registry and fan out to subscribed SQS queues and HTTP/HTTPS webhooks.*
## Ports & protocol surface [#ports--protocol-surface]
| Port | Transport | Protocol | Notes |
| ------ | ------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `4566` | Plain HTTP (SigV4) | AWS SQS JSON + SNS Query | A dedicated second listener (the LocalStack convention). **Bound only when the connector is enabled** (`CONNECTORS_AWS_ENABLE=true`), and must differ from the gRPC/REST/HTTP ports. |
| — | HTTPS | AWS SQS JSON + SNS Query | TLS is provided by the server-wide `Security` block — there is **no AWS-specific TLS option**. SigV4 over plain HTTP is unencrypted on the wire; production deployments should use the HTTPS listener. |
The listener accepts both `POST /` and `GET /` on a single AWS-style endpoint — there are
no per-route REST paths. SQS uses the AWS **JSON protocol** (`X-Amz-Target: AmazonSQS.{Op}`)
with a Query-protocol fallback; SNS uses the AWS **Query protocol** (form body / GET query →
XML). See [Architecture](/connectors/aws/concepts/architecture) for the dispatch detail.
## Send a message [#send-a-message]
The example below runs the full SQS round-trip — `CreateQueue` → `GetQueueUrl` →
`SendMessage` → `ReceiveMessage` → `DeleteMessage` — over a stock AWS SDK. The only change
versus a real-AWS app is the **endpoint override**: each client points at `KUBEMQ_AWS_URL`
(default `http://localhost:4566`). **Dummy credentials are still required** so the SDK forms
a valid SigV4 signature; the connector's default accept-any mode checks the signature shape,
not its value.
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
func awsURL() string {
if v := os.Getenv("KUBEMQ_AWS_URL"); v != "" {
return v
}
return "http://localhost:4566"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Dummy static credentials are required so the SDK forms a valid SigV4
// request; the connector's accept-any mode does not verify their value.
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
if err != nil {
log.Fatalf("load config: %v", err)
}
// Override ONLY the endpoint URL — everything else is a normal AWS SDK app.
client := sqs.NewFromConfig(cfg, func(o *sqs.Options) {
o.BaseEndpoint = aws.String(awsURL())
})
// CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders".
created, err := client.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("orders")})
if err != nil {
log.Fatalf("CreateQueue: %v", err)
}
queueURL := aws.ToString(created.QueueUrl)
if _, err := client.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(queueURL),
MessageBody: aws.String("hello from the AWS SDK"),
}); err != nil {
log.Fatalf("SendMessage: %v", err)
}
recv, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 1,
WaitTimeSeconds: 5,
})
if err != nil || len(recv.Messages) != 1 {
log.Fatalf("ReceiveMessage: %v (got %d)", err, len(recv.Messages))
}
msg := recv.Messages[0]
fmt.Printf("received: %q\n", aws.ToString(msg.Body))
// DeleteMessage acks the message off the queue by its receipt handle.
if _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: msg.ReceiptHandle,
}); err != nil {
log.Fatalf("DeleteMessage: %v", err)
}
}
```
```python
import os
import boto3
QUEUE = "orders"
def make_sqs():
# Override ONLY the endpoint URL; dummy credentials are still required so
# boto3 forms a valid SigV4 request (accept-any mode checks shape only).
return boto3.client(
"sqs",
endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
def main() -> None:
sqs = make_sqs()
# CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders".
queue_url = sqs.create_queue(QueueName=QUEUE)["QueueUrl"]
sqs.send_message(QueueUrl=queue_url, MessageBody="hello from boto3")
recv = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=5)
msg = recv["Messages"][0]
print(f"received: {msg['Body']!r}")
# DeleteMessage acks the message off the queue by its receipt handle.
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"])
if __name__ == "__main__":
main()
```
```java
import java.net.URI;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.Message;
public final class Main {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566");
// endpointOverride is the only change versus a real-AWS app; dummy
// credentials are still required to form a valid SigV4 request.
try (SqsClient sqs = SqsClient.builder()
.endpointOverride(URI.create(url))
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")))
.build()) {
// CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders".
String queueUrl = sqs.createQueue(b -> b.queueName("orders")).queueUrl();
sqs.sendMessage(b -> b.queueUrl(queueUrl).messageBody("hello from the AWS SDK for Java"));
Message msg = sqs.receiveMessage(b -> b
.queueUrl(queueUrl)
.maxNumberOfMessages(1)
.waitTimeSeconds(5))
.messages().get(0);
System.out.printf("received: %s%n", msg.body());
// DeleteMessage acks the message off the queue by its receipt handle.
sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(msg.receiptHandle()));
}
}
}
```
```typescript
import {
SQSClient,
CreateQueueCommand,
SendMessageCommand,
ReceiveMessageCommand,
DeleteMessageCommand,
} from "@aws-sdk/client-sqs";
const QUEUE = "orders";
// Override ONLY the endpoint; dummy credentials are still required so the SDK
// forms a valid SigV4 request (accept-any mode checks the signature shape).
const sqs = new SQSClient({
endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566",
region: "us-east-1",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
});
async function main(): Promise {
// CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders".
const created = await sqs.send(new CreateQueueCommand({ QueueName: QUEUE }));
const queueUrl = created.QueueUrl!;
await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "hello from the AWS SDK v3" }));
const recv = await sqs.send(
new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1, WaitTimeSeconds: 5 }),
);
const msg = recv.Messages![0];
console.log(`received: ${msg.Body}`);
// DeleteMessage acks the message off the queue by its receipt handle.
await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: msg.ReceiptHandle! }));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using Amazon.Runtime;
using Amazon.SQS;
using Amazon.SQS.Model;
var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566";
// ServiceURL carries the full http://host:port; dummy credentials are still
// required to form a valid SigV4 request (accept-any mode checks shape only).
var config = new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" };
using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"), config);
const string queueName = "orders";
// CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders".
var created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = queueName });
var queueUrl = created.QueueUrl;
await sqs.SendMessageAsync(new SendMessageRequest
{
QueueUrl = queueUrl,
MessageBody = "hello from AWSSDK.NET",
});
var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = queueUrl,
MaxNumberOfMessages = 1,
WaitTimeSeconds = 5,
});
var msg = recv.Messages[0];
Console.WriteLine($"received: {msg.Body}");
// DeleteMessage acks the message off the queue by its receipt handle.
await sqs.DeleteMessageAsync(new DeleteMessageRequest
{
QueueUrl = queueUrl,
ReceiptHandle = msg.ReceiptHandle,
});
```
```ruby
# frozen_string_literal: true
require "aws-sdk-sqs"
# The Ruby SQS plugin rewrites the request endpoint to the full QueueUrl path,
# which the single-endpoint connector rejects — remove it so requests stay on
# the configured base endpoint (as boto3 and aws-sdk-go-v2 do).
Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls)
url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566")
# Override ONLY the endpoint; dummy credentials are still required so the SDK
# forms a valid SigV4 request (accept-any mode checks the signature shape).
sqs = Aws::SQS::Client.new(
endpoint: url,
region: "us-east-1",
credentials: Aws::Credentials.new("test", "test")
)
# CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders".
queue_url = sqs.create_queue(queue_name: "orders").queue_url
sqs.send_message(queue_url: queue_url, message_body: "hello from aws-sdk-ruby")
recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 5)
msg = recv.messages.first
puts "received: #{msg.body.inspect}"
# DeleteMessage acks the message off the queue by its receipt handle.
sqs.delete_message(queue_url: queue_url, receipt_handle: msg.receipt_handle)
```
```rust
use aws_config::BehaviorVersion;
use aws_sdk_sqs::config::Credentials;
use aws_sdk_sqs::config::Region;
use std::error::Error;
fn aws_url() -> String {
std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box> {
// Override ONLY the endpoint; dummy credentials are still required so the
// SDK forms a valid SigV4 request (accept-any mode checks shape only).
let creds = Credentials::new("test", "test", None, None, "kubemq-aws");
let conf = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(creds)
.endpoint_url(aws_url())
.load()
.await;
let sqs = aws_sdk_sqs::Client::new(&conf);
// CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders".
sqs.create_queue().queue_name("orders").send().await?;
let url = sqs
.get_queue_url()
.queue_name("orders")
.send()
.await?
.queue_url
.ok_or("GetQueueUrl returned no URL")?;
sqs.send_message()
.queue_url(&url)
.message_body("hello from aws-sdk-rust")
.send()
.await?;
let received = sqs
.receive_message()
.queue_url(&url)
.max_number_of_messages(1)
.wait_time_seconds(5)
.send()
.await?;
let msg = &received.messages()[0];
println!("received: {}", msg.body().unwrap_or_default());
// DeleteMessage acks the message off the queue by its receipt handle.
let handle = msg.receipt_handle().ok_or("no receipt handle")?;
sqs.delete_message().queue_url(&url).receipt_handle(handle).send().await?;
Ok(())
}
```
## Supported languages [#supported-languages]
The connector speaks the genuine AWS SQS and SNS wire protocols, so any standard AWS SDK
works — you only override the endpoint URL. There is no KubeMQ SDK, no proto bindings, and
no published package; the examples pin one native AWS SDK per language.
| Language | AWS SDK / client library | Endpoint override |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| Go | [`aws-sdk-go-v2`](https://github.com/aws/aws-sdk-go-v2) (`service/sqs`, `service/sns`) | `config.WithBaseEndpoint` / `o.BaseEndpoint` |
| Python | [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) (`client('sqs')`, `client('sns')`) | `endpoint_url=` per client |
| Java | [AWS SDK for Java v2](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html) (`sqs`, `sns`) | `.endpointOverride(URI.create(...))` |
| JavaScript / TypeScript | [AWS SDK v3](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/welcome.html) (`@aws-sdk/client-sqs`, `@aws-sdk/client-sns`) | `{ endpoint }` |
| C# / .NET | [AWS SDK for .NET](https://docs.aws.amazon.com/sdk-for-net/latest/developer-guide/welcome.html) (`AWSSDK.SQS`, `AWSSDK.SimpleNotificationService`) | `ServiceURL` |
| Ruby | [AWS SDK for Ruby v3](https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/welcome.html) (`aws-sdk-sqs`, `aws-sdk-sns`) | `endpoint:` per client |
| Rust | [AWS SDK for Rust](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/welcome.html) (`aws-sdk-sqs`, `aws-sdk-sns`) | `.endpoint_url(...)` |
Only `aws-sdk-go-v2` is proven by the KubeMQ server's integration tests; the other six SDKs
are wire-compatible and the connector's example suite is their proof. The Ruby SQS client
needs its `QueueUrls` plugin removed (shown above) so requests stay on the configured base
endpoint. See [Connections endpoint](/connectors/aws/reference/connections-endpoint).
## Next steps [#next-steps]
# Observability (/connectors/concepts/observability)
The connectors emit Prometheus metrics and OpenTelemetry traces on the same observability surface as the rest of kubemq-server, plus a dedicated **AI dashboard** for agents and MCP — so you watch connector traffic with the tools you already run.
## Overview [#overview]
Every gateway on the [shared HTTP server](/connectors/concepts/shared-http-server) shares one observability story. CloudEvents is documented here under Connectors; the AI gateways — [A2A and MCP](/aiway) — are documented under Aiway, but they run on the same server and report through this same surface:
* **Prometheus metrics** for A2A requests, MCP tool calls, registry operations, and active SSE streams, scraped from the management API port (`:8080`).
* **OpenTelemetry tracing** for every HTTP request through the connector middleware chain.
* A web **AI dashboard** (Agents and MCP) that reads cluster-aggregated metrics.
No extra configuration turns connector telemetry on — it follows the server's existing metrics and tracing setup.
## How it works [#how-it-works]
Connectors record metrics inline as they handle requests; the metrics exporter publishes them on the management API port (`:8080`) for Prometheus to scrape, while the dashboard reads the same data through the internal API.
*Connectors report metrics to the exporter on the management API port (`:8080`); Prometheus scrapes it and the AI dashboard reads the internal API.*
## Prometheus metrics [#prometheus-metrics]
Connector metrics are exposed on the **management API port (`:8080`)** at `/metrics`, alongside the core KubeMQ messaging metrics. Scrape it the same way:
```bash
curl http://localhost:8080/metrics
```
The connectors emit `kubemq_mcp_*` and `kubemq_a2a_*` series; the full series — names, types, and labels — is documented in [Observability → Prometheus Metrics](/operate/observability/metrics).
The A2A `method` label is sanitized against a fixed allowlist of known A2A methods. Any unrecognized method is recorded as `unknown` so a malformed or malicious request stream cannot explode Prometheus label cardinality.
In a cluster, these counters are **aggregated across all nodes** so the totals you see reflect the whole deployment rather than a single replica.
## OpenTelemetry tracing [#opentelemetry-tracing]
Every request that enters the shared HTTP server passes through OpenTelemetry instrumentation in the middleware chain, traced under the service name `kubemq-http`. When tracing is enabled on the server, connector requests appear in your traces automatically — there is nothing connector-specific to configure.
## The AI dashboard [#the-ai-dashboard]
The KubeMQ web dashboard includes an **AI** area for the agent platform:
* **Agents** — every registered agent and its live request stats (total requests, errors, average latency, last-seen time).
* **MCP** — tool usage drawn from the MCP metrics above.
The dashboard reads from an internal management API on the management API port (`:8080`):
| Method | Path | Description |
| ------ | ----------------- | -------------------------------------- |
| `GET` | `/api/agents` | List registered agents with pagination |
| `GET` | `/api/agents/:id` | Agent detail plus a stats snapshot |
```bash
curl http://localhost:8080/api/agents
```
The full `/api/agents` and `/api/agents/:id` reference — parameters, response envelope, and stats fields — is documented in [Observability → Dashboard Endpoints](/operate/observability/api-reference/dashboard-endpoints). Per-agent stats are populated from the same counters that feed `kubemq_a2a_requests_total`.
## Related [#related]
# Shared HTTP Server (/connectors/concepts/shared-http-server)
Three built-in gateways run on **one shared HTTP server** inside kubemq-server:
CloudEvents (documented here under Connectors) plus the AI gateways
[A2A and MCP](/aiway) (documented under Aiway). There is no separate port or
process per gateway: they register their routes on the same server, pass through the
same middleware chain, and inherit the same auth, CORS, and TLS configuration.
## One server, many connectors [#one-server-many-connectors]
When kubemq-server starts, each enabled connector registers its routes on the shared
HTTP server, and the server is started once. Requests to `/a2a/*`, `/mcp`, and `/ce/*`
all land on the same listener and flow through the same middleware pipeline before
reaching the connector that owns the route.
*A request passes through the middleware chain in order before reaching a connector route.*
## Port 9090 [#port-9090]
The shared HTTP server listens on **port 9090**. The port is configured by
`Connectors.Http.Port`; if it is left unset, it **inherits from `Rest.Port`** (which
defaults to `9090`). A warning is logged if both are set to different values.
Connector metrics are exposed separately on **port 8080** (`/metrics`), alongside the
internal dashboard API. See [Observability](/connectors/concepts/observability) for the
metrics surface and the AI dashboard.
Health and readiness probes are public and unauthenticated:
```bash
curl http://localhost:9090/ping
curl http://localhost:9090/health
curl http://localhost:9090/ready
```
## The middleware chain [#the-middleware-chain]
Every request flows through the same middleware stack, applied outermost-first:
| Order | Middleware | Purpose |
| ----- | ---------------- | ------------------------------------------------------------------------------- |
| 1 | **Recovery** | Catches panics so one bad request cannot crash the server. |
| 2 | **Traffic Gate** | Rejects requests with HTTP `503` while the broker is not ready. |
| 3 | **OTel Tracing** | OpenTelemetry instrumentation (`kubemq-http`), when telemetry is enabled. |
| 4 | **CORS** | Configurable cross-origin policy via `HttpConfig.Cors`. |
| 5 | **Auth** | Extracts a JWT Bearer token and sets claims; anonymous claims when auth is off. |
| 6 | **Body Limit** | Caps request body size (default `100M`). |
| 7 | **Logging** | Debug-level request start/end logging. |
The **traffic gate** is wired to the broker's readiness: the server automatically
starts accepting traffic when the broker becomes ready and rejects it (with `503`)
when it is not. This is why a connector can be enabled yet briefly return `503` during
startup.
Auth and TLS/mTLS are shared across all connectors and documented once in
[Auth & security](/connectors/reference/auth-and-security).
### SSE and request timeouts [#sse-and-request-timeouts]
The server's `WriteTimeout` is set to `0` so long-lived **Server-Sent Events** streams
(A2A `message/stream`, CloudEvents SSE subscriptions) stay open indefinitely. Non-SSE
route groups instead apply a per-route `TimeoutMiddleware` — default **60 seconds**,
returning HTTP `504 Gateway Timeout` if the deadline is exceeded before the response is
committed.
For requests that proxy to a downstream agent, a `GatewayTimeoutBuffer` of **10
seconds** is added on top of the caller-specified timeout, so the gateway does not time
out before the agent it is waiting on.
## Enable model: on by default [#enable-model-on-by-default]
All three gateways are **enabled by default**. Start kubemq-server and `/a2a/*`,
`/mcp`, and `/ce/*` are live — there is **no flag to turn them on**. (The A2A and MCP
gateways are documented under [Aiway](/aiway); their enable vars are listed here
because they share this server's enable model.) To turn one off, set its enable env var
to `false`:
| Connector | Disable with |
| ----------- | ----------------------------- |
| A2A | `CONNECTORSA2_A_ENABLE=false` |
| MCP | `CONNECTORSMCP_ENABLE=false` |
| CloudEvents | `CONNECTORSCE_ENABLE=false` |
For example, to run with MCP disabled:
**The enable var names are irregular by design.** Environment variables are derived
from the dotted config keys (`Connectors.A2A.Enable`, `Connectors.MCP.Enable`,
`Connectors.CE.Enable`) by a snake-casing transform that splits on letter-case
boundaries, strips dots, and uppercases. The boundaries fall in unexpected places — so
A2A becomes `CONNECTORSA2_A_ENABLE` (the `2`→`A` boundary inserts an underscore), while
MCP and CE join into `CONNECTORSMCP_ENABLE` and `CONNECTORSCE_ENABLE` with **no**
underscore before the connector name. Use these exact names; never invent a
`=true` flag to enable a connector, and note that **`CONNECTORS_CE_ENABLE` (with an
underscore) does not work** — the live binding is `CONNECTORSCE_ENABLE`.
This differs from older KubeMQ behavior, where these gateways were off by default and
opted in. That framing is stale: today they ship on.
## Reserved channel prefix [#reserved-channel-prefix]
The shared server reserves the **`_AGENTS_.`** channel prefix for internal agent
platform subjects (agent request/reply, SSE stream relays, and registry replication).
Any user operation targeting a channel that begins with `_AGENTS_.` is **rejected** by
`IsReservedChannel`. Pick channel names outside this prefix for your own queues,
events, commands, and queries.
## Related [#related]
# CloudEvents (/connectors/cloudevents)
The **CloudEvents (CE) connector** is a built-in protocol gateway in kubemq-server
that speaks the [CNCF CloudEvents](https://cloudevents.io/) specification over HTTP.
It lets any HTTP client publish and subscribe to KubeMQ using a standard, interoperable
event envelope — no KubeMQ SDK required.
## What is the CloudEvents connector [#what-is-the-cloudevents-connector]
[CloudEvents](https://cloudevents.io/) is a CNCF specification that describes event
metadata in a common format: a standard envelope of `specversion`, `type`, `source`,
`id`, `subject`, `time`, `data`, and extensions that travels consistently across
platforms and languages.
The CE connector exposes this format natively over HTTP for **all five KubeMQ
messaging patterns** — Events, Events Store, Queues, Commands, and Queries. A
CloudEvent posted to `/ce/send/event` becomes a native KubeMQ message; a message
delivered over Server-Sent Events (SSE) is reconstructed back into a CloudEvent. The
connector is a *gateway*, not a client library — your application only needs an HTTP
client (and, optionally, a CloudEvents SDK to build the envelope for you).
Key capabilities:
* **All messaging patterns** — pub/sub events, persistent events store, durable
queues, and request/reply commands and queries, all over CloudEvents HTTP.
* **Structured and binary content modes** — send the whole event as a JSON body
(`application/cloudevents+json`) or carry attributes in `ce-*` HTTP headers; the
connector detects either automatically.
* **CESQL attribute routing** — route events to channels using CloudEvents SQL
expressions evaluated server-side against event attributes.
* **SSE subscriptions with replay** — subscribe to a long-lived event stream and
resume an events-store subscription from a chosen position using `Last-Event-ID`.
The CE connector runs on the **shared HTTP server (port 9090)** alongside the REST,
MCP, and A2A connectors and is **enabled by default** — start kubemq-server and
`/ce/*` is live. See [Shared HTTP server](/connectors/concepts/shared-http-server) for the
port, middleware chain, and the disable model.
## How it works [#how-it-works]
A CloudEvents client sends events to the CE connector, which maps them into native
KubeMQ messages and hands them to the broker; subscribers receive the same events
back as CloudEvents over SSE.
*The CloudEvents connector translates CloudEvents HTTP requests into native KubeMQ messages and streams them back to subscribers over SSE.*
## Endpoint surface [#endpoint-surface]
| Endpoint | Method | Pattern |
| ---------------------------- | --------- | ------------------------------------------------------ |
| `/ce/send/event` | POST | Events (fire-and-forget pub/sub) |
| `/ce/send/event-store` | POST | Events Store (persistent, replayable) |
| `/ce/send/command` | POST | Command request |
| `/ce/send/query` | POST | Query request |
| `/ce/send/response` | POST | Response to a command/query (`?request_id=`) |
| `/ce/queue/send` | POST | Queue send |
| `/ce/queue/receive` | POST | Queue receive (control op) |
| `/ce/queue/ack_all` | POST | Queue ack-all (control op) |
| `/ce/subscribe/events` | GET (SSE) | Subscribe to events |
| `/ce/subscribe/events-store` | GET (SSE) | Subscribe to events store (replay via `Last-Event-ID`) |
| `/ce/subscribe/commands` | GET (SSE) | Subscribe to commands |
| `/ce/subscribe/queries` | GET (SSE) | Subscribe to queries |
Every send endpoint accepts a CloudEvent in either structured or binary content mode.
Channels resolve from the CloudEvent `subject` attribute, falling back to a `?channel=`
query parameter. See [CE ↔ KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping)
for the full attribute table and resolution rules.
## Send a CloudEvent [#send-a-cloudevent]
Publish a fire-and-forget event with `POST /ce/send/event`. The example below sends a
structured-mode CloudEvent whose `subject` (`notifications`) becomes the KubeMQ
channel. A successful send returns HTTP 202 with `is_error: false`.
```bash
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"subject": "notifications",
"datacontenttype": "application/json",
"data": {"order_id": "12345", "amount": 99.99}
}'
```
```csharp
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
var base_ = "http://localhost:9090";
var channel = "notifications";
// Build and publish a CloudEvent (structured mode).
var formatter = new JsonEventFormatter();
var cloudEvent = new CloudEvent
{
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.events.sent",
Source = new Uri("urn:kubemq-ce-csharp-example"),
Subject = channel,
DataContentType = "application/json",
Data = new { message = "Hello from C# CloudEvents example!" },
};
cloudEvent.SetAttributeFromString("time", DateTimeOffset.UtcNow.ToString("O"));
var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType);
using var httpClient = new HttpClient();
using var content = new ByteArrayContent(eventBytes.ToArray());
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString());
var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content);
Console.WriteLine($"Published: status={resp.StatusCode}");
```
```go
import (
"encoding/json"
"net/http"
"strings"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
base := "http://localhost:9090"
channel := "notifications"
// Build and send CloudEvent (structured mode).
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.events.sent")
event.SetSource("kubemq-ce-go-example")
event.SetSubject(channel) // subject = KubeMQ channel
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{
"message": "Hello from Go CloudEvents example!",
})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
String base = "http://localhost:9090";
String channel = "notifications";
ObjectMapper mapper = new ObjectMapper();
// Build CloudEvent (structured mode).
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.events.sent")
.withSource(URI.create("kubemq-ce-java-example"))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
mapper.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!")))
.build();
byte[] body = format.serialize(event);
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.header("Content-Type", "application/cloudevents+json")
.build();
HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
System.out.printf("Published: status=%d%n", response.statusCode());
```
```typescript
import { CloudEvent, HTTP } from 'cloudevents';
const base = 'http://localhost:9090';
const channel = 'notifications';
// Build and publish CloudEvent (structured mode).
const event = new CloudEvent({
type: 'com.kubemq.examples.events.sent',
source: 'kubemq-ce-js-example',
subject: channel,
datacontenttype: 'application/json',
data: { message: 'Hello from JavaScript/TypeScript CloudEvents example!' },
});
const message = HTTP.structured(event);
const resp = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: message.headers as Record,
body: message.body as string,
});
const result = (await resp.json()) as { is_error: boolean };
console.log(`Published: status=${resp.status} is_error=${result.is_error}`);
```
```python
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
base = "http://localhost:9090"
channel = "notifications"
# Build and send CloudEvent (structured mode).
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.events.sent",
"source": "kubemq-ce-python-example",
"subject": channel,
"datacontenttype": "application/json",
},
data={"message": "Hello from Python CloudEvents example!"},
)
headers, body = to_structured(event)
resp = requests.post(
f"{base}/ce/send/event",
data=body,
headers=dict(headers),
timeout=10,
)
result = resp.json()
print(f"Published: status={resp.status_code} is_error={result.get('is_error')}")
```
```ruby
require "net/http"
require "uri"
require "json"
require "securerandom"
require "cloud_events"
base = "http://localhost:9090"
channel = "notifications"
# Build and publish CloudEvent (structured mode).
sdk = CloudEvents::HttpBinding.default
event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid,
type: "com.kubemq.examples.events.sent",
source: URI("urn:kubemq-ce-ruby-example"),
subject: channel,
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" })
)
headers, body = sdk.encode_event(event, structured_format: "json")
uri = URI("#{base}/ce/send/event")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = headers["Content-Type"]
req.body = body
res = http.request(req)
result = JSON.parse(res.body)
puts "Published: status=#{res.code} is_error=#{result['is_error']}"
end
```
```rust
use cloudevents::{EventBuilder, EventBuilderV10};
use reqwest::Client;
use serde_json::{json, Value};
use uuid::Uuid;
let base = "http://localhost:9090";
let channel = "notifications";
let client = Client::new();
// Build CloudEvent (structured mode using cloudevents-sdk).
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.events.sent")
.source("urn:kubemq-ce-rust-example")
.subject(channel)
.data(
"application/json",
json!({"message": "Hello from Rust CloudEvents example!"}),
)
.build()?;
// Serialize to structured mode JSON.
let body = serde_json::to_string(&event)?;
let resp = client
.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/cloudevents+json")
.body(body)
.send()
.await?;
let result: Value = resp.json().await?;
println!("Published: is_error={}", result["is_error"]);
```
## Supported languages [#supported-languages]
The CE connector is plain HTTP, so any language with an HTTP client works. The
examples in this section use each language's official CloudEvents SDK to build the
envelope:
| Language | CloudEvents SDK | Version | Runtime |
| ----------------------- | ---------------------------------- | ------- | ------------ |
| Go | `github.com/cloudevents/sdk-go/v2` | 2.16.0 | Go 1.21+ |
| Python | `cloudevents` | 2.0.0 | Python 3.10+ |
| JavaScript / TypeScript | `cloudevents` | 10.0.0 | Node.js 18+ |
| Java | `io.cloudevents:cloudevents-core` | 4.0.1 | Java 21+ |
| C# | `CloudNative.CloudEvents` | 2.8.0 | .NET 8+ |
| Ruby | `cloud_events` | 0.9 | Ruby 3.1+ |
| Rust | `cloudevents-sdk` | 0.9 | Rust 1.75+ |
The CloudEvents SDK is a convenience for building and parsing the envelope — it is
not required. The structured-mode body and binary-mode `ce-*` headers are plain HTTP,
so curl and any raw HTTP client work just as well. See
[Content modes](/connectors/cloudevents/how-to/content-modes) for both wire formats.
## Next steps [#next-steps]
# Google Cloud Pub/Sub (/connectors/gcp-pub-sub)
Point your Google Cloud Pub/Sub application at KubeMQ by setting **one environment
variable** — `PUBSUB_EMULATOR_HOST`. The **Google Cloud Pub/Sub connector** is a built-in,
wire-protocol bridge inside kubemq-server that speaks the genuine Pub/Sub v1 gRPC services on
a dedicated gRPC listener (default port **8085**, the Pub/Sub emulator convention). Any
standard, unmodified Pub/Sub client — the Go, Python, Java, Node.js, C#, and Ruby first-party
Google clients, plus `gcloud pubsub` — talks to KubeMQ exactly as it would to Google's local
emulator, with **no code changes, no library swap, and no KubeMQ SDK**.
## What is the Pub/Sub connector [#what-is-the-pubsub-connector]
The connector is a **single gRPC listener** that implements the real Pub/Sub v1 wire
protocol — **38 RPCs** across four services:
* **`google.pubsub.v1.Publisher`** (9 RPCs) — topics and publish.
* **`google.pubsub.v1.Subscriber`** (16 RPCs) — subscriptions, pull, streaming pull, ack,
snapshots, and seek.
* **`google.pubsub.v1.SchemaService`** (10 RPCs) — Avro and Protobuf schemas.
* **`google.iam.v1.IAMPolicy`** (3 permissive stubs) — emulator-parity IAM.
Every official Pub/Sub client library honours the `PUBSUB_EMULATOR_HOST` environment
variable: when it is set, the SDK **clears credentials, skips Google auth, and dials insecure
gRPC** — exactly as it would against Google's local emulator. **The connector *is* the
emulator** — there is no separate emulator to install, no LocalStack, and no boot-the-server
step beyond running kubemq-server.
Two KubeMQ primitives back the model: a **topic** maps onto a native KubeMQ **Events Store**
log `gcp.{topic}` (the authoritative, replayable source of truth), and each **subscription**
maps onto a native **Queue** channel `gcp.sub.{subscription}`. A publish is written **once**
to the topic log, then fanned out to one queue copy per subscription — so Pub/Sub producers
and native gRPC/REST consumers interoperate on the same messages.
The Pub/Sub connector is **opt-in (disabled by default)**. A stock kubemq-server does **not**
bind gRPC port 8085 until you enable it with `CONNECTORS_GCP_ENABLE=true` (Docker) or
`spec.gcp.enabled: true` (Kubernetes). See
[Getting started](/connectors/gcp-pub-sub/tutorials/getting-started).
## How it works [#how-it-works]
A Pub/Sub SDK client dials the connector's gRPC endpoint. A `Publish` is written once to the
topic's Events Store log `gcp.{topic}` through the message broker, then fanned out to one
Queue copy per subscription `gcp.sub.{subscription}` (applying each subscription's filter).
`Pull` / `StreamingPull` lease each delivered message under an ack-deadline, and `Acknowledge`
removes it from the subscription queue.
*A publish writes once to the Events Store log `gcp.{topic}` and fans out one Queue copy per subscription on `gcp.sub.{subscription}`, all backed by the message broker; consumers pull each message under an ack-deadline lease.*
## Ports & protocol surface [#ports--protocol-surface]
| Port | Transport | Protocol | Notes |
| ------ | ------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `8085` | Insecure gRPC | Pub/Sub v1 gRPC (emulator mode) | The Pub/Sub emulator convention. **Opt-in** — bound only when `CONNECTORS_GCP_ENABLE=true`. Must differ from the gRPC/REST/HTTP and AWS-connector ports. No auth, no TLS — exactly like Google's local emulator. |
| — | gRPC over TLS | Pub/Sub v1 gRPC | TLS is provided by the server-wide `Security` block — there is **no Pub/Sub-specific TLS option**. The default emulator path is unencrypted; do not expose port 8085 to untrusted networks. |
The connector is **gRPC only** — there is **no REST/JSON v1** surface (no grpc-gateway).
Clients and tools that only speak the Pub/Sub REST API will not work; use a gRPC client
library or `gcloud` (which uses gRPC for the emulator). See
[Architecture](/connectors/gcp-pub-sub/concepts/architecture) for the dispatch detail.
## Send a message [#send-a-message]
The example below runs the full round-trip — `CreateTopic` → `CreateSubscription` →
`Publish` → `Pull` → `Acknowledge` — over a stock Google Cloud Pub/Sub client. The only
change versus a real-GCP app is the **emulator endpoint**: each client reads
`PUBSUB_EMULATOR_HOST` (default `localhost:8085`) and a project id from `PUBSUB_PROJECT_ID`
(any value — the project segment is parsed but ignored).
```go
package main
import (
"context"
"fmt"
"log"
"os"
"sync"
"time"
"cloud.google.com/go/pubsub"
)
func projectID() string {
if v := os.Getenv("PUBSUB_PROJECT_ID"); v != "" {
return v
}
return "my-project" // any id; the project segment is parsed but ignored.
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// PUBSUB_EMULATOR_HOST (default localhost:8085) routes the official client at
// the connector over insecure gRPC with credentials cleared — no code change.
client, err := pubsub.NewClient(ctx, projectID())
if err != nil {
log.Fatalf("NewClient: %v", err)
}
defer client.Close()
// CreateTopic "orders" maps to Events Store log "gcp.orders".
topic, err := client.CreateTopic(ctx, "orders")
if err != nil {
log.Fatalf("CreateTopic: %v", err)
}
defer topic.Stop()
// CreateSubscription maps to Queue channel "gcp.sub.sub-orders".
sub, err := client.CreateSubscription(ctx, "sub-orders", pubsub.SubscriptionConfig{
Topic: topic,
AckDeadline: 10 * time.Second, // connector default; valid range 10..600s.
})
if err != nil {
log.Fatalf("CreateSubscription: %v", err)
}
id, err := topic.Publish(ctx, &pubsub.Message{Data: []byte("hello from cloud.google.com/go/pubsub")}).Get(ctx)
if err != nil {
log.Fatalf("Publish: %v", err)
}
fmt.Printf("published: %s\n", id)
recvCtx, recvCancel := context.WithTimeout(ctx, 15*time.Second)
defer recvCancel()
var once sync.Once
err = sub.Receive(recvCtx, func(_ context.Context, m *pubsub.Message) {
fmt.Printf("received: %q\n", string(m.Data))
m.Ack() // Acknowledge by ack_id under its lease.
once.Do(recvCancel)
})
if err != nil && recvCtx.Err() == nil {
log.Fatalf("Receive: %v", err)
}
}
```
```python
import os
from google.cloud import pubsub_v1
def project_id() -> str:
# Any id works — the project segment is parsed but ignored.
return os.environ.get("PUBSUB_PROJECT_ID", "my-project")
def main() -> None:
# Both clients honour PUBSUB_EMULATOR_HOST (default localhost:8085): they clear
# credentials, skip Google auth, and dial insecure gRPC.
publisher = pubsub_v1.PublisherClient()
subscriber = pubsub_v1.SubscriberClient()
proj = project_id()
topic_path = publisher.topic_path(proj, "orders") # -> gcp.orders
sub_path = subscriber.subscription_path(proj, "sub-orders") # -> gcp.sub.sub-orders
publisher.create_topic(request={"name": topic_path})
subscriber.create_subscription(request={"name": sub_path, "topic": topic_path})
future = publisher.publish(topic_path, b"hello from google-cloud-pubsub")
print(f"published: {future.result(timeout=15)}")
resp = subscriber.pull(request={"subscription": sub_path, "max_messages": 1}, timeout=20)
msg = resp.received_messages[0]
print(f"received: {msg.message.data.decode()!r}")
# Acknowledge by ack_id; the message leaves the subscription queue.
subscriber.acknowledge(request={"subscription": sub_path, "ack_ids": [msg.ack_id]})
subscriber.close()
if __name__ == "__main__":
main()
```
```java
import com.google.api.gax.core.NoCredentialsProvider;
import com.google.api.gax.grpc.GrpcTransportChannel;
import com.google.api.gax.rpc.FixedTransportChannelProvider;
import com.google.api.gax.rpc.TransportChannelProvider;
import com.google.cloud.pubsub.v1.SubscriptionAdminClient;
import com.google.cloud.pubsub.v1.SubscriptionAdminSettings;
import com.google.cloud.pubsub.v1.TopicAdminClient;
import com.google.cloud.pubsub.v1.TopicAdminSettings;
import com.google.cloud.pubsub.v1.stub.GrpcSubscriberStub;
import com.google.cloud.pubsub.v1.stub.SubscriberStubSettings;
import com.google.protobuf.ByteString;
import com.google.pubsub.v1.AcknowledgeRequest;
import com.google.pubsub.v1.PublishRequest;
import com.google.pubsub.v1.PubsubMessage;
import com.google.pubsub.v1.PullRequest;
import com.google.pubsub.v1.PullResponse;
import com.google.pubsub.v1.PushConfig;
import com.google.pubsub.v1.ReceivedMessage;
import com.google.pubsub.v1.SubscriptionName;
import com.google.pubsub.v1.TopicName;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
public final class Main {
public static void main(String[] args) throws Exception {
String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085");
String projectId = System.getenv().getOrDefault("PUBSUB_PROJECT_ID", "my-project");
// The Go/Python/Node clients auto-detect the emulator; Java points a plaintext
// gRPC channel at the host explicitly with NoCredentialsProvider.
ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build();
TransportChannelProvider channelProvider =
FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel));
NoCredentialsProvider noCreds = NoCredentialsProvider.create();
TopicName topic = TopicName.of(projectId, "orders"); // -> gcp.orders
SubscriptionName sub = SubscriptionName.of(projectId, "sub-orders"); // -> gcp.sub.sub-orders
try (TopicAdminClient topicAdmin = TopicAdminClient.create(TopicAdminSettings.newBuilder()
.setTransportChannelProvider(channelProvider).setCredentialsProvider(noCreds).build());
SubscriptionAdminClient subAdmin = SubscriptionAdminClient.create(
SubscriptionAdminSettings.newBuilder()
.setTransportChannelProvider(channelProvider)
.setCredentialsProvider(noCreds).build());
GrpcSubscriberStub subStub = GrpcSubscriberStub.create(SubscriberStubSettings.newBuilder()
.setTransportChannelProvider(channelProvider)
.setCredentialsProvider(noCreds).build())) {
topicAdmin.createTopic(topic);
subAdmin.createSubscription(sub, topic, PushConfig.getDefaultInstance(), 10);
topicAdmin.publish(PublishRequest.newBuilder()
.setTopic(topic.toString())
.addMessages(PubsubMessage.newBuilder()
.setData(ByteString.copyFromUtf8("hello from google-cloud-pubsub")).build())
.build());
PullResponse resp = subStub.pullCallable().call(PullRequest.newBuilder()
.setSubscription(sub.toString()).setMaxMessages(1).build());
ReceivedMessage got = resp.getReceivedMessages(0);
System.out.printf("received: %s%n", got.getMessage().getData().toStringUtf8());
// Acknowledge by ack_id.
subStub.acknowledgeCallable().call(AcknowledgeRequest.newBuilder()
.setSubscription(sub.toString()).addAckIds(got.getAckId()).build());
} finally {
channel.shutdown();
}
}
}
```
```typescript
import { PubSub, v1 } from "@google-cloud/pubsub";
const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project";
// The high-level PubSub client reads PUBSUB_EMULATOR_HOST (default localhost:8085)
// and resolves the insecure emulator transport; reuse its options for the v1 clients.
const baseOptions = new PubSub({ projectId }).options;
const options = { ...baseOptions, port: baseOptions.port === undefined ? undefined : Number(baseOptions.port) };
const publisher = new v1.PublisherClient(options);
const subscriber = new v1.SubscriberClient(options);
async function main(): Promise {
const topic = publisher.projectTopicsPath(projectId, "orders"); // -> gcp.orders
const sub = subscriber.subscriptionPath(projectId, "sub-orders"); // -> gcp.sub.sub-orders
await publisher.createTopic({ name: topic });
await subscriber.createSubscription({ name: sub, topic, ackDeadlineSeconds: 10 });
const [published] = await publisher.publish({
topic,
messages: [{ data: Buffer.from("hello from @google-cloud/pubsub") }],
});
console.log(`published: ${published.messageIds?.[0]}`);
const [pull] = await subscriber.pull({ subscription: sub, maxMessages: 1 });
const received = pull.receivedMessages![0];
console.log(`received: ${Buffer.from(received.message!.data as Uint8Array).toString("utf8")}`);
// Acknowledge by ack_id.
await subscriber.acknowledge({ subscription: sub, ackIds: [received.ackId!] });
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using Google.Api.Gax;
using Google.Cloud.PubSub.V1;
using Google.Protobuf;
var projectId = Environment.GetEnvironmentVariable("PUBSUB_PROJECT_ID") ?? "my-project";
var topicName = TopicName.FromProjectTopic(projectId, "orders"); // -> gcp.orders
var subName = SubscriptionName.FromProjectSubscription(projectId, "sub-orders"); // -> gcp.sub.sub-orders
// The .NET client does NOT auto-detect the emulator — set EmulatorOnly so each
// client reads PUBSUB_EMULATOR_HOST (default localhost:8085) and dials insecurely.
var publisher = await new PublisherServiceApiClientBuilder
{
EmulatorDetection = EmulatorDetection.EmulatorOnly,
}.BuildAsync();
var subscriber = await new SubscriberServiceApiClientBuilder
{
EmulatorDetection = EmulatorDetection.EmulatorOnly,
}.BuildAsync();
await publisher.CreateTopicAsync(topicName);
await subscriber.CreateSubscriptionAsync(subName, topicName, pushConfig: null, ackDeadlineSeconds: 10);
var publishResponse = await publisher.PublishAsync(topicName, new[]
{
new PubsubMessage { Data = ByteString.CopyFromUtf8("hello from Google.Cloud.PubSub.V1") },
});
Console.WriteLine($"published: {publishResponse.MessageIds[0]}");
var pull = await subscriber.PullAsync(subName, maxMessages: 1);
var received = pull.ReceivedMessages[0];
Console.WriteLine($"received: {received.Message.Data.ToStringUtf8()}");
// Acknowledge by ack_id.
await subscriber.AcknowledgeAsync(subName, new[] { received.AckId });
```
```ruby
# frozen_string_literal: true
require "google/cloud/pubsub"
project_id = ENV["PUBSUB_PROJECT_ID"] || "my-project"
# Ruby needs the emulator host passed explicitly (it does not always read the env var).
emulator_host = ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085"
pubsub = Google::Cloud::PubSub.new(project_id: project_id, emulator_host: emulator_host)
topic_admin = pubsub.topic_admin
sub_admin = pubsub.subscription_admin
topic_path = pubsub.topic_path("orders") # -> gcp.orders
sub_path = pubsub.subscription_path("sub-orders") # -> gcp.sub.sub-orders
topic = topic_admin.create_topic(name: topic_path)
sub_admin.create_subscription(name: sub_path, topic: topic_path, ack_deadline_seconds: 10)
publisher = pubsub.publisher(topic.name)
msg = publisher.publish("hello from google-cloud-pubsub")
puts "published: #{msg.message_id}"
subscriber = pubsub.subscriber(sub_path)
received = subscriber.pull(immediate: false, max: 1)
rcv = received.first
puts "received: #{rcv.data.inspect}"
# Acknowledge by ack_id; the message leaves the subscription queue.
rcv.acknowledge!
```
## Supported languages [#supported-languages]
The connector speaks the genuine Pub/Sub v1 wire protocol, so any first-party Google Cloud
Pub/Sub client works — you only set `PUBSUB_EMULATOR_HOST`. There is no KubeMQ SDK, no proto
bindings, and no published package; the examples pin one official Google client per language.
| Language | Google Cloud Pub/Sub client | Emulator construction |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Go | [`cloud.google.com/go/pubsub`](https://pkg.go.dev/cloud.google.com/go/pubsub) | `pubsub.NewClient(ctx, projectID)` — auto-detects `PUBSUB_EMULATOR_HOST`, dials insecurely |
| Python | [`google-cloud-pubsub`](https://cloud.google.com/python/docs/reference/pubsub/latest) (via **uv**) | `pubsub_v1.PublisherClient()` — honours `PUBSUB_EMULATOR_HOST` |
| Java | [`com.google.cloud:google-cloud-pubsub`](https://cloud.google.com/java/docs/reference/google-cloud-pubsub/latest/overview) (BOM) | plaintext `ManagedChannel` to the emulator host + `NoCredentialsProvider` |
| JavaScript / TypeScript | [`@google-cloud/pubsub`](https://cloud.google.com/nodejs/docs/reference/pubsub/latest) | `new PubSub({ projectId })` — auto-detects the emulator; run via `tsx` |
| C# / .NET | [`Google.Cloud.PubSub.V1`](https://cloud.google.com/dotnet/docs/reference/Google.Cloud.PubSub.V1/latest) (.NET 8) | `…Builder { EmulatorDetection = EmulatorDetection.EmulatorOnly }.Build()` |
| Ruby | [`google-cloud-pubsub`](https://cloud.google.com/ruby/docs/reference/google-cloud-pubsub/latest) | `Google::Cloud::PubSub.new(project_id:, emulator_host:)` |
There is **no Rust tab** — Google ships no first-party Pub/Sub client for Rust. The connector's
verified example suite is the six languages above. Most clients auto-detect the emulator from
`PUBSUB_EMULATOR_HOST`; **C#** needs `EmulatorDetection.EmulatorOnly`, **Ruby** needs an
explicit `emulator_host:`, and **Java** points a plaintext channel at the host — see
[Connectivity & emulator mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode).
## Next steps [#next-steps]
# Kafka (/connectors/kafka)
Point your Kafka clients at KubeMQ by repointing `bootstrap.servers` — no client-library
swap, no code change. The **Kafka connector** is a built-in, wire-protocol bridge inside
kubemq-server that speaks the real Kafka request/response protocol — length-prefixed
frames, flexible versions, the `kmsg` codec — over plain TCP and TLS. Any off-the-shelf
Kafka client — Java `kafka-clients`/AdminClient/Spring, librdkafka/`kcat`/`confluent-kafka`,
or `franz-go`/`sarama`/`segmentio` — connects to KubeMQ exactly as it would to a real Kafka
broker.
## What is the Kafka connector [#what-is-the-kafka-connector]
The Kafka connector implements Kafka's Produce/Fetch/group-coordinator/admin request
surface directly inside kubemq-server — there's no separate broker process to run and no
protocol-translation layer for your application to configure around. Every produced record
lands in a persistent, ordered, replayable **Events Store** channel whose `Sequence`
maps one-to-one onto a Kafka offset: durable, restart-stable, and identical across every
node of a cluster.
Key capabilities:
* **Native produce/consume with classic consumer groups** — the large majority of everyday
Kafka usage, a straight repoint with no client changes.
* **OAUTHBEARER/OIDC federated auth**, alongside SASL/PLAIN, SASL/SCRAM, mTLS client
certificates, and Kafka ACL enforcement.
* **Compacted topics** (`cleanup.policy=compact`), which unlock the compaction-dependent
ecosystem — Kafka Connect and Kafka Streams both run against it.
* **Transactions and exactly-once semantics (EOS)** — a transactional producer with
`read_committed` isolation and producer fencing.
* **Static membership** (`group.instance.id`, KIP-345), so a consumer restart rejoins its
group without triggering a rebalance.
**Opt-in — disabled by default.** A stock kubemq-server does **not** bind ports `9092` /
`9093` until you turn the connector on with `CONNECTORS_KAFKA_ENABLE=true` (Docker) or
`spec.kafka.enabled: true` (Kubernetes). See
[Getting started](/connectors/kafka/tutorials/getting-started).
## How it works [#how-it-works]
A Kafka client dials the connector's TCP listener and issues the same request types it
would send to a real broker. A `Produce` request is appended to the topic's Events Store
log; a `Fetch` request — including one driven by a consumer group's assigned partitions —
reads back from that same log. Because each Kafka offset maps one-to-one onto the log's durable `Sequence`, both
directions stay durable and ordered through the message broker underneath.
*A `Produce` request from a Kafka client is appended to the topic's Events Store log `kafka.{topic}` through the message broker; a `Fetch` request — including one driven by a consumer group's assigned partitions — reads back from that same log, so each Kafka offset maps one-to-one onto the log's durable `Sequence`.*
**Runs on the auto-selected `next` storage engine.** Kafka's headline capabilities —
compacted topics and the quorum-fsynced ack contract — exist only on KubeMQ's `next`
storage engine. On a **fresh** store, enabling Kafka **auto-selects `next`** with no
manual `store.engine` step. See
[Storage Engines → Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection)
for the full selection rules, including what happens on an existing store.
## Ports & protocol surface [#ports--protocol-surface]
| Port | Transport | Protocol | Notes |
| ------ | ------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `9092` | Plain TCP | Kafka wire protocol — `PLAINTEXT` / `SASL_PLAINTEXT` | Default listener. **Opt-in** — bound only when `CONNECTORS_KAFKA_ENABLE=true`. Disable by setting `CONNECTORS_KAFKA_PORT=""`. Must differ from the TLS port. |
| `9093` | TLS over TCP | Kafka wire protocol — `SSL` / `SASL_SSL` | Reuses the server-wide `Security` block (cert + key) — there is no Kafka-specific TLS option. **Required for OAUTHBEARER** (refused on plaintext). |
Clients read one setting, `bootstrap.servers` (or the client's equivalent), pointed at
`localhost:9092` for the plaintext listener. See
[Configuration](/connectors/kafka/concepts/configuration) for the opt-in flag, the
advertised-host/port pair, and where the full field-by-field settings reference lives.
## Send & receive a message [#send--receive-a-message]
The example below produces one message to the `orders` topic and reads it back on
`localhost:9092` — the only change versus a real Kafka client is the
**`bootstrap.servers` value**. No KubeMQ SDK, no code change: every tab uses a stock Kafka
client library.
```bash
# Produce one message to topic "orders"
echo "hello from kcat" | kcat -P -b localhost:9092 -t orders
# Consume from the beginning and exit after one message
kcat -C -b localhost:9092 -t orders -o beginning -c 1
```
```go
package main
import (
"context"
"fmt"
"log"
"github.com/twmb/franz-go/pkg/kgo"
)
func main() {
ctx := context.Background()
// bootstrap.servers repoint only — no KubeMQ SDK, no code change.
cl, err := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
kgo.ConsumeTopics("orders"),
kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()),
)
if err != nil {
log.Fatal(err)
}
defer cl.Close()
record := &kgo.Record{Topic: "orders", Value: []byte("hello from franz-go")}
if err := cl.ProduceSync(ctx, record).FirstErr(); err != nil {
log.Fatalf("produce: %v", err)
}
fmt.Println("produced to orders")
fetches := cl.PollFetches(ctx)
fetches.EachRecord(func(r *kgo.Record) {
fmt.Printf("received: %s\n", string(r.Value))
})
}
```
```python
from confluent_kafka import Consumer, Producer
BOOTSTRAP = "localhost:9092"
def main() -> None:
producer = Producer({"bootstrap.servers": BOOTSTRAP})
producer.produce("orders", value=b"hello from confluent-kafka")
producer.flush()
print("produced to orders")
consumer = Consumer({
"bootstrap.servers": BOOTSTRAP,
"group.id": "orders-consumer",
"auto.offset.reset": "earliest",
})
consumer.subscribe(["orders"])
msg = consumer.poll(timeout=10.0)
if msg is not None and msg.error() is None:
print(f"received: {msg.value().decode()!r}")
consumer.close()
if __name__ == "__main__":
main()
```
```java
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
public final class Main {
public static void main(String[] args) {
String bootstrap = "localhost:9092";
Properties producerProps = new Properties();
producerProps.put("bootstrap.servers", bootstrap);
producerProps.put("key.serializer", StringSerializer.class.getName());
producerProps.put("value.serializer", StringSerializer.class.getName());
try (KafkaProducer producer = new KafkaProducer<>(producerProps)) {
producer.send(new ProducerRecord<>("orders", "hello from kafka-clients"));
producer.flush();
System.out.println("produced to orders");
}
Properties consumerProps = new Properties();
consumerProps.put("bootstrap.servers", bootstrap);
consumerProps.put("group.id", "orders-consumer");
consumerProps.put("key.deserializer", StringDeserializer.class.getName());
consumerProps.put("value.deserializer", StringDeserializer.class.getName());
consumerProps.put("auto.offset.reset", "earliest");
try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) {
consumer.subscribe(Collections.singletonList("orders"));
ConsumerRecords records = consumer.poll(Duration.ofSeconds(10));
for (ConsumerRecord record : records) {
System.out.printf("received: %s%n", record.value());
}
}
}
}
```
```typescript
import { Kafka } from "kafkajs";
const kafka = new Kafka({
clientId: "kubemq-kafka-example",
brokers: ["localhost:9092"],
});
async function main(): Promise {
const producer = kafka.producer();
await producer.connect();
await producer.send({
topic: "orders",
messages: [{ value: "hello from kafkajs" }],
});
console.log("produced to orders");
await producer.disconnect();
const consumer = kafka.consumer({ groupId: "orders-consumer" });
await consumer.connect();
await consumer.subscribe({ topic: "orders", fromBeginning: true });
await consumer.run({
eachMessage: async ({ message }) => {
console.log(`received: ${message.value?.toString()}`);
await consumer.disconnect();
process.exit(0);
},
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using Confluent.Kafka;
const string bootstrapServers = "localhost:9092";
const string topic = "orders";
var producerConfig = new ProducerConfig { BootstrapServers = bootstrapServers };
using (var producer = new ProducerBuilder(producerConfig).Build())
{
await producer.ProduceAsync(topic, new Message { Value = "hello from Confluent.Kafka" });
Console.WriteLine("produced to orders");
}
var consumerConfig = new ConsumerConfig
{
BootstrapServers = bootstrapServers,
GroupId = "orders-consumer",
AutoOffsetReset = AutoOffsetReset.Earliest,
};
using var consumer = new ConsumerBuilder(consumerConfig).Build();
consumer.Subscribe(topic);
var result = consumer.Consume(TimeSpan.FromSeconds(10));
if (result is not null)
Console.WriteLine($"received: {result.Message.Value}");
consumer.Close();
```
```ruby
require "rdkafka"
BOOTSTRAP = "localhost:9092"
producer_config = Rdkafka::Config.new("bootstrap.servers" => BOOTSTRAP)
producer = producer_config.producer
producer.produce(topic: "orders", payload: "hello from rdkafka-ruby").wait
puts "produced to orders"
producer.close
consumer_config = Rdkafka::Config.new(
"bootstrap.servers" => BOOTSTRAP,
"group.id" => "orders-consumer",
"auto.offset.reset" => "earliest"
)
consumer = consumer_config.consumer
consumer.subscribe("orders")
consumer.each do |message|
puts "received: #{message.payload}"
break
end
consumer.close
```
```rust
use rdkafka::config::ClientConfig;
use rdkafka::consumer::{BaseConsumer, Consumer};
use rdkafka::message::Message;
use rdkafka::producer::{BaseProducer, BaseRecord, Producer};
use std::time::Duration;
fn main() {
let bootstrap = "localhost:9092";
let producer: BaseProducer = ClientConfig::new()
.set("bootstrap.servers", bootstrap)
.create()
.expect("producer creation failed");
producer
.send(
BaseRecord::to("orders")
.payload("hello from rust-rdkafka")
.key("order-1"),
)
.expect("produce failed");
producer.flush(Duration::from_secs(10)).expect("flush failed");
println!("produced to orders");
let consumer: BaseConsumer = ClientConfig::new()
.set("bootstrap.servers", bootstrap)
.set("group.id", "orders-consumer")
.set("auto.offset.reset", "earliest")
.create()
.expect("consumer creation failed");
consumer.subscribe(&["orders"]).expect("subscribe failed");
if let Some(result) = consumer.poll(Duration::from_secs(10)) {
let msg = result.expect("poll failed");
if let Some(payload) = msg.payload() {
println!("received: {}", String::from_utf8_lossy(payload));
}
}
}
```
## Supported clients [#supported-clients]
The connector speaks the genuine Kafka wire protocol, so any conformant client library
works — there is no KubeMQ SDK and no proto bindings. The examples above pin one client
per language; alternates are noted where the ecosystem commonly uses more than one.
| Language | Client library | Notes |
| ----------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| kcat | [`kcat`](https://github.com/edenhill/kcat) (librdkafka CLI) | The canonical quick-check Kafka client — no code, just a bootstrap address. |
| Go | [`github.com/twmb/franz-go`](https://github.com/twmb/franz-go) | Matches the connector's own conformance-harness client; `sarama` and `segmentio/kafka-go` also work. |
| Python | [`confluent-kafka`](https://github.com/confluentinc/confluent-kafka-python) (librdkafka bindings) | |
| Java | [`org.apache.kafka:kafka-clients`](https://kafka.apache.org/documentation/#api) | AdminClient and Spring Kafka run on top of the same client unchanged. |
| JavaScript / TypeScript | [`kafkajs`](https://kafka.js.org/) | |
| C# / .NET | [`Confluent.Kafka`](https://github.com/confluentinc/confluent-kafka-dotnet) (librdkafka bindings) | |
| Ruby | [`rdkafka`](https://github.com/karafka/rdkafka-ruby) (librdkafka bindings) | |
| Rust | [`rdkafka`](https://github.com/fede1024/rust-rdkafka) (librdkafka bindings) | |
**Share groups (KIP-932) are supported in preview, not GA.** Queue-style acquire/acknowledge
consumption is implemented and advertised, but only **franz-go** and Java's preview
`KafkaShareConsumer` ship a share-consumer API today — GA is pending the multi-client
conformance matrix. See the [fitness matrix](/connectors/kafka/reference/fitness-matrix)
for the full verdict.
Not every capability above is proven to the same tier — see the
[fitness matrix](/connectors/kafka/reference/fitness-matrix) for the full
supported / caveat / roadmap / unsupported breakdown before you commit to a migration. Every
Kafka setting — ports, advertised host/port, SASL mechanisms, OAUTHBEARER, and the advanced
tuning knobs — is documented field-by-field in
[the Kafka settings reference](/configure/reference/connectors#kafka). Moving an
*existing* Kafka, MSK, or Confluent workload onto KubeMQ starts with the read-only
`kmq assess kafka` command, then the `kmq migrate` tool —
[Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) walks through the
full assess → replicate → translate → cutover playbook, and the full
[`kmq` CLI reference](/operate/kmq-cli) documents both commands.
## Next steps [#next-steps]
# MQTT (/connectors/mqtt)
Point your MQTT 3.1.1 / 5.0 application at KubeMQ by changing only the broker address.
The **MQTT connector** is a built-in, wire-protocol bridge inside kubemq-server: an
embedded MQTT broker that speaks the standard protocol natively, so any off-the-shelf
MQTT client (paho, MQTT.js, MQTTnet, rumqttc) reaches KubeMQ's Events, Events-Store,
Queues, Commands, and Queries with no KubeMQ SDK, no library swap, and no code rewrite.
## What is the MQTT connector [#what-is-the-mqtt-connector]
[MQTT](https://mqtt.org/) is a lightweight publish/subscribe protocol built around
*topics*: a client connects, subscribes to topic filters, and publishes messages with a
chosen *Quality of Service* (QoS 0/1/2). The KubeMQ MQTT connector exposes this protocol
on its own dedicated ports and maps it onto **all five KubeMQ messaging patterns**.
The **first segment of the MQTT topic** selects the KubeMQ pattern, and the remaining
segments become the KubeMQ channel with `/` translated to `.`. A publish to
`events/site1/temp` lands on the Events channel `site1.temp`; `queues/jobs` targets a
KubeMQ Queue; `commands/restart` and `queries/status` drive RPC. The connector is a
*gateway*, not a client library — your application only needs a stock MQTT client.
Key capabilities:
* **All five patterns over one wire** — Events, Events-Store, Queues, Commands, and
Queries, selected by the topic prefix.
* **Topic-driven pattern routing** — `events/`, `store/`, `queues/`, `commands/`,
`queries/` prefixes map a topic to a KubeMQ pattern; a bare (prefixless) topic routes
to the configured `DefaultPattern` (`events` by default).
* **MQTT 3.1.1 and 5.0** — both protocol levels on the same listener; MQTT 5.0 unlocks
User-Properties (carried as KubeMQ Tags) and RPC (Commands / Queries).
* **Cross-protocol interop** — a message published over MQTT to `events/it/cross` is
consumable by a gRPC or REST KubeMQ client on channel `it.cross`, and vice-versa.
**Retain is silently dropped.** The connector forces `RetainAvailable=0` in `CONNACK`. A
runtime publish with the retain flag set returns a success PUBACK (`0x00`) but the message
is stripped and **never delivered or stored**. A *Will*-retain requested at CONNECT time is
rejected outright with CONNACK `0x9A`. There are also **no durable subscriptions** — MQTT
sessions are in-memory and node-local. See
[QoS and sessions](/connectors/mqtt/concepts/qos-and-sessions).
## How it works [#how-it-works]
An MQTT client connects to the connector and publishes to (or subscribes to) a topic. The
connector resolves the topic prefix to a KubeMQ `(pattern, channel)` pair, hands the
message to the message broker, and consumers on the same channel — over MQTT or any other
KubeMQ transport — receive it.
*The embedded broker accepts a standard MQTT connection, the topic mapper resolves the prefix to a KubeMQ pattern and channel, and the message broker fans it out to consumers on any transport.*
## Ports & protocol surface [#ports--protocol-surface]
| Port | Transport | Protocol | Notes |
| ------ | -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `1883` | Plain TCP | MQTT 3.1.1 & 5.0 | Default listener. Disable by setting `CONNECTORSMQTT_PORT=""`. |
| `8883` | TLS over TCP | MQTT 3.1.1 & 5.0 | Binds only when the server-global `Security` block (cert + key) is configured; otherwise the port is open but the listener is inactive. |
| `8083` | WebSocket (path `/`) | MQTT 3.1.1 & 5.0 | MQTT-over-WebSocket at path `/`. Upgraded to `wss://` when `Security` is configured. |
Clients select the transport by URL scheme: `tcp://host:1883`, `tls://host:8883`, or
`ws://host:8083/`. The examples read one environment variable, `KUBEMQ_MQTT_URL` (default
`tcp://localhost:1883`). MQTT 3.1 (protocol level 3) is always rejected; the minimum
accepted level is 3.1.1. See [Configuration](/connectors/mqtt/concepts/configuration) for the
listener and capability settings.
## Publish an event [#publish-an-event]
The example below connects an MQTT 5.0 client and publishes one message to
`events/` at QoS 1. The topic prefix `events/` selects the Events pattern, and
`/` becomes `.` in the channel, so `events/demo/x` lands on the KubeMQ channel `demo.x`.
Every client reads the broker endpoint from `KUBEMQ_MQTT_URL` (default
`tcp://localhost:1883`).
```go
package main
import (
"context"
"fmt"
"log"
"net"
"os"
"strings"
"time"
"github.com/eclipse/paho.golang/paho"
)
func brokerURL() string {
if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" {
return u
}
return "tcp://localhost:1883"
}
// tcpAddr strips the scheme prefix to obtain "host:port".
func tcpAddr(rawURL string) string {
for _, pfx := range []string{"tcp://", "ws://", "tls://"} {
if strings.HasPrefix(rawURL, pfx) {
return rawURL[len(pfx):]
}
}
return rawURL
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Prefix "events/" selects the Events pattern; '/' becomes '.' so
// "events/demo/x" maps to the KubeMQ channel "demo.x".
const topic = "events/demo/x"
conn, err := net.Dial("tcp", tcpAddr(brokerURL()))
if err != nil {
log.Fatalf("dial: %v", err)
}
client := paho.NewClient(paho.ClientConfig{Conn: conn})
connAck, err := client.Connect(ctx, &paho.Connect{
ClientID: "go-mqtt-events-pub",
KeepAlive: 30,
CleanStart: true,
})
if err != nil {
log.Fatalf("connect: %v", err)
}
if connAck.ReasonCode != 0 {
log.Fatalf("CONNACK reason=0x%02X", connAck.ReasonCode)
}
// QoS 1 returns a PUBACK. Do NOT set Retain — a retained publish is
// silently dropped (PUBACK 0x00, message never delivered).
pubAck, err := client.Publish(ctx, &paho.Publish{
Topic: topic,
QoS: 1,
Payload: []byte("hello from MQTT"),
Properties: &paho.PublishProperties{
// MQTT 5.0 User Properties round-trip as KubeMQ Tags.
User: []paho.UserProperty{{Key: "sensor", Value: "thermometer"}},
},
})
if err != nil {
log.Fatalf("publish: %v", err)
}
if pubAck.ReasonCode != 0 {
log.Fatalf("PUBACK reason=0x%02X", pubAck.ReasonCode)
}
fmt.Printf("published to %q (channel demo.x)\n", topic)
_ = client.Disconnect(&paho.Disconnect{ReasonCode: 0})
}
```
```python
import os
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.packettypes import PacketTypes
def parse_url(url: str) -> tuple[str, int]:
scheme, rest = url.split("://", 1)
host_port = rest.rstrip("/")
if ":" in host_port:
host, port = host_port.rsplit(":", 1)
return host, int(port)
return host_port, {"tcp": 1883, "tls": 8883, "ws": 8083}.get(scheme, 1883)
def main() -> None:
url = os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883")
host, port = parse_url(url)
# Prefix "events/" selects the Events pattern; "/" becomes "." so
# "events/demo/x" maps to the KubeMQ channel "demo.x".
topic = "events/demo/x"
# paho-mqtt 2.x requires an explicit callback API version.
client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id="python-mqtt-events-pub",
protocol=mqtt.MQTTv5,
)
client.connect(host, port, keepalive=30, clean_start=True)
client.loop_start()
# MQTT 5.0 User Properties round-trip as KubeMQ Tags.
props = Properties(PacketTypes.PUBLISH)
props.UserProperty = [("sensor", "thermometer")]
# Do not set retain=True — a retained publish is silently dropped.
info = client.publish(topic, payload=b"hello from MQTT", qos=1, properties=props)
info.wait_for_publish(timeout=10)
print(f"published to {topic!r} (channel demo.x)")
client.loop_stop()
client.disconnect()
if __name__ == "__main__":
main()
```
```java
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.eclipse.paho.mqttv5.client.MqttAsyncClient;
import org.eclipse.paho.mqttv5.client.MqttConnectionOptions;
import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence;
import org.eclipse.paho.mqttv5.common.MqttMessage;
import org.eclipse.paho.mqttv5.common.packet.MqttProperties;
import org.eclipse.paho.mqttv5.common.packet.UserProperty;
public final class Main {
public static void main(String[] args) throws Exception {
String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883");
// Prefix "events/" selects the Events pattern; "/" becomes "." so
// "events/demo/x" maps to the KubeMQ channel "demo.x".
String topic = "events/demo/x";
MqttConnectionOptions opts = new MqttConnectionOptions();
opts.setCleanStart(true);
opts.setKeepAliveInterval(30);
// org.eclipse.paho.mqttv5.client always uses MQTT protocol level 5.
MqttAsyncClient client = new MqttAsyncClient(broker, "java-mqtt-events-pub", new MemoryPersistence());
client.connect(opts).waitForCompletion(10_000);
MqttMessage msg = new MqttMessage("hello from MQTT".getBytes(StandardCharsets.UTF_8));
msg.setQos(1);
// Do NOT call msg.setRetained(true) — a retained publish is silently dropped.
// MQTT 5.0 User Properties round-trip as KubeMQ Tags.
MqttProperties props = new MqttProperties();
props.setUserProperties(List.of(new UserProperty("sensor", "thermometer")));
msg.setProperties(props);
client.publish(topic, msg).waitForCompletion(10_000);
System.out.printf("published to %s (channel demo.x)%n", topic);
client.disconnect().waitForCompletion(5_000);
client.close();
}
}
```
```typescript
import mqtt, { type MqttClient } from "mqtt";
function brokerUrl(): string {
return process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883";
}
async function main(): Promise {
// Prefix "events/" selects the Events pattern; "/" becomes "." so
// "events/demo/x" maps to the KubeMQ channel "demo.x".
const topic = "events/demo/x";
const client: MqttClient = mqtt.connect(brokerUrl(), {
clientId: "js-mqtt-events-pub",
protocolVersion: 5,
clean: true,
keepalive: 30,
});
await new Promise((resolve, reject) => {
client.once("connect", () => resolve());
client.once("error", reject);
});
await new Promise((resolve, reject) => {
client.publish(
topic,
"hello from MQTT",
{
qos: 1,
retain: false, // retain is NOT supported; a retained publish is silently dropped.
// MQTT 5.0 User Properties round-trip as KubeMQ Tags.
properties: { userProperties: { sensor: "thermometer" } },
},
(err) => (err ? reject(err) : resolve()),
);
});
console.log(`published to "${topic}" (channel demo.x)`);
await client.endAsync();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using System.Text;
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
static (string host, int port) ParseEndpoint(string url)
{
foreach (var prefix in new[] { "tcp://", "tls://", "ws://" })
if (url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
url = url[prefix.Length..];
var parts = url.TrimEnd('/').Split(':');
return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883);
}
var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883";
var (host, port) = ParseEndpoint(url);
// Prefix "events/" selects the Events pattern; "/" becomes "." so
// "events/demo/x" maps to the KubeMQ channel "demo.x".
const string topic = "events/demo/x";
var factory = new MqttFactory();
using var client = factory.CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer(host, port)
.WithClientId("csharp-mqtt-events-pub")
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
.WithCleanSession(true)
.Build();
await client.ConnectAsync(options);
var pubResult = await client.PublishAsync(
new MqttApplicationMessageBuilder()
.WithTopic(topic)
.WithPayload(Encoding.UTF8.GetBytes("hello from MQTT"))
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
// MQTT 5.0 User Property round-trips as a KubeMQ Tag.
// Do NOT call WithRetainFlag(true) — a retained publish is silently dropped.
.WithUserProperty("sensor", "thermometer")
.Build());
if (pubResult.ReasonCode != MqttClientPublishReasonCode.Success)
throw new Exception($"PUBACK reason: {pubResult.ReasonCode}");
Console.WriteLine($"published to '{topic}' (channel demo.x)");
await client.DisconnectAsync();
```
```ruby
# The Ruby `mqtt` gem speaks MQTT 3.1.1 only (no User Properties, no v5 RPC).
require "mqtt"
require "uri"
uri = URI.parse(ENV.fetch("KUBEMQ_MQTT_URL", "tcp://localhost:1883"))
# Prefix "events/" selects the Events pattern; "/" becomes "." so
# "events/demo/x" maps to the KubeMQ channel "demo.x".
topic = "events/demo/x"
MQTT::Client.connect(
host: uri.host,
port: uri.port,
ssl: uri.scheme == "tls",
client_id: "ruby-mqtt-events-pub",
clean_session: true,
keep_alive: 30
) do |client|
# retain=false is mandatory — the broker silently drops retained publishes.
client.publish(topic, "hello from MQTT", false, 1)
puts "published to '#{topic}' (channel demo.x)"
end
```
```rust
use rumqttc::v5::mqttbytes::v5::PublishProperties;
use rumqttc::v5::mqttbytes::QoS;
use rumqttc::v5::{AsyncClient, MqttOptions};
use std::env;
use std::time::Duration;
fn parse_host_port(url: &str) -> (String, u16) {
let stripped = url
.trim_start_matches("tcp://")
.trim_start_matches("tls://")
.trim_start_matches("ws://");
let host_port = stripped.split('/').next().unwrap_or(stripped);
let mut parts = host_port.splitn(2, ':');
let host = parts.next().unwrap_or("localhost").to_string();
let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883);
(host, port)
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string());
let (host, port) = parse_host_port(&url);
// Prefix "events/" selects the Events pattern; '/' becomes '.' so
// "events/demo/x" maps to the KubeMQ channel "demo.x".
let topic = "events/demo/x";
let mut opts = MqttOptions::new("rust-mqtt-events-pub", host, port);
opts.set_keep_alive(Duration::from_secs(30));
let (client, mut eventloop) = AsyncClient::new(opts, 10);
// Drive the event loop so the PUBLISH is flushed and the PUBACK is processed.
tokio::spawn(async move { while eventloop.poll().await.is_ok() {} });
// MQTT 5.0 User Properties round-trip as KubeMQ Tags.
let props = PublishProperties {
user_properties: vec![("sensor".to_string(), "thermometer".to_string())],
..Default::default()
};
// retain = false — a retained publish is silently dropped.
client
.publish_with_properties(topic, QoS::AtLeastOnce, false, b"hello from MQTT".as_ref(), props)
.await?;
println!("published to '{topic}' (channel demo.x)");
Ok(())
}
```
## Supported languages [#supported-languages]
The connector speaks standard MQTT, so any conformant client works. The examples pin one
native MQTT client per language — there is no KubeMQ SDK and no proto bindings.
| Language | Client library | Protocol | Notes |
| ----------------------- | ----------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------- |
| Go | [`eclipse/paho.golang`](https://github.com/eclipse/paho.golang) (`paho.mqtt.golang` for v3.1.1) | MQTT 5.0 | The connector's reference client. |
| Python | [`paho-mqtt`](https://pypi.org/project/paho-mqtt/) ≥ 2.1 | MQTT 5.0 | Callback-API v2. |
| Java | [Eclipse Paho `mqttv5`](https://eclipse.dev/paho/) | MQTT 5.0 | `org.eclipse.paho.mqttv5.client`. |
| JavaScript / TypeScript | [`mqtt.js`](https://github.com/mqttjs/MQTT.js) ≥ 5.15 | MQTT 5.0 | Works over TCP, TLS, and WebSocket. |
| C# / .NET | [`MQTTnet`](https://github.com/dotnet/MQTTnet) ≥ 4.3.7 | MQTT 5.0 | Task-based async. |
| Ruby | [`mqtt` gem](https://github.com/njh/ruby-mqtt) ≥ 0.6 | MQTT 3.1.1 | v3.1.1 subset only — no User Properties, no RPC, no WebSocket. |
| Rust | [`rumqttc`](https://github.com/bytebeamio/rumqtt) ≥ 0.24 | MQTT 5.0 | async/await on Tokio. |
The Ruby `mqtt` gem implements **MQTT 3.1.1 only**. The MQTT 5.0-only features — RPC
(Commands / Queries), shared-subscription queue consume, and User-Properties — are not
available from Ruby. Use a v5 client (Go, Python, Java, JavaScript, C#, Rust) for those
patterns. See [Topic grammar](/connectors/mqtt/reference/topic-grammar).
## Next steps [#next-steps]
# Auth & Security (/connectors/reference/auth-and-security)
Every gateway on the [shared HTTP server](/connectors/concepts/shared-http-server) sits
behind the same security middleware. CloudEvents is documented here under Connectors;
the AI gateways — [A2A and MCP](/aiway) — are documented under Aiway, but they run
on the same server and inherit this identical security model. The authentication, CORS,
origin-validation, and TLS rules described here apply uniformly to `/a2a/*`, `/mcp`, and
`/ce/*`; each gateway's own auth guide simply points back to this page.
## How requests are secured [#how-requests-are-secured]
A request crosses three security stages before it reaches the broker: TLS terminates
the transport, CORS and origin validation screen browser callers, and the auth
middleware verifies the Bearer token and attaches identity claims. Public routes skip
authentication entirely.
*Transport security, browser screening, and JWT auth guard every connector request.*
## Authentication [#authentication]
The auth middleware extracts a **`Bearer` token** from the `Authorization` header and
verifies it against KubeMQ's authentication singleton. On success it attaches the
caller's claims (including `ClientID`) to the request context, where the connector and
the broker use them for authorization and identity propagation.
When server authentication is **disabled**, the middleware injects synthetic anonymous
claims (`ClientID: "anonymous"`) so requests still carry an identity. When it is
**enabled**, an unverified or missing token is rejected.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"1","method":"tools/list"}'
```
### How auth failures are reported [#how-auth-failures-are-reported]
The error shape depends on the endpoint family. JSON-RPC connectors return a protocol
error object; the REST-style CloudEvents endpoints return a standard HTTP status.
| Endpoint | On auth failure |
| --------------------------------- | ------------------------------------------ |
| JSON-RPC (`/mcp`, `/a2a/*`) | JSON-RPC error with code **`-32010`** |
| HTTP / REST (`/ce/*`, management) | **HTTP 401 Unauthorized** |
### Public routes [#public-routes]
A small set of routes bypass authentication entirely so health probes and agent
discovery work without credentials:
| Route | Purpose |
| ------------------------------- | --------------------------------------------- |
| `/ping` | Liveness probe |
| `/health` | Health check |
| `/ready` | Readiness probe |
| `*/.well-known/agent-card.json` | Agent card discovery (platform and per-agent) |
The platform card `GET /.well-known/agent-card.json` and any per-agent card path
ending in `/.well-known/agent-card.json` are public by design — A2A discovery must be
reachable before a caller has a token.
## CORS [#cors]
Browser-based callers are governed by the connectors' CORS configuration on
`Connectors.Http.Cors`. The defaults are permissive on origin but explicit about the
headers connectors need — in particular the MCP session/protocol headers and the
CloudEvents replay header.
| Setting | Default |
| ------------------ | ------------------------------------------------------------------------------------------ |
| `AllowOrigins` | `["*"]` |
| `AllowMethods` | `GET, POST, DELETE, OPTIONS` |
| `AllowHeaders` | `Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id, Last-Event-ID, Accept` |
| `ExposeHeaders` | `MCP-Session-Id, MCP-Protocol-Version` |
| `AllowCredentials` | `false` |
| `MaxAge` | `86400` (preflight cache, seconds) |
A browser preflight is answered by the CORS middleware before the request reaches the
connector:
```bash
curl -i -X OPTIONS http://localhost:9090/mcp \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Authorization, MCP-Session-Id'
```
## Origin validation [#origin-validation]
In addition to CORS, browser requests are checked by **origin validation** against a
list of trusted origins. This is independent of CORS and guards against
cross-site request forgery from untrusted pages.
| `TrustedOrigins` value | Behavior |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `auto` (default) | Matches localhost variants (`localhost`, `127.0.0.1`, and for MCP also `::1`, `[::1]`, `0.0.0.0`) plus the server's bind address |
| `*` | Allows all origins |
| *(custom list)* | Allows exactly the listed origins |
An **empty `Origin` header** — which non-browser clients such as curl, SDKs, and
server-to-server calls send — is **always allowed**. MCP applies its own origin check
(`McpConfig.TrustedOrigins`) in addition to the shared HTTP origin middleware; A2A
uses `A2aConfig.TrustedOrigins`. Both default to `["auto"]`.
## TLS and mTLS [#tls-and-mtls]
Transport security is read from the server's `SecurityConfig` and applies to the
shared HTTP listener that fronts every connector. Three modes are supported:
| Mode | Behavior |
| ------------------ | -------------------------------------------------------- |
| `SecurityModeNone` | Plain TCP listener (no TLS) |
| `SecurityModeTLS` | TLS with a server certificate |
| `SecurityModeMTLS` | Mutual TLS — clients must present a verified certificate |
TLS here secures the **inbound** connection from callers to the connectors. The A2A
gateway's **outbound** calls to agent URLs are a separate concern, controlled by
`AgentTLSSkipVerify` — see the [A2A configuration](/aiway/a2a/configuration).
## Reserved channel prefix [#reserved-channel-prefix]
Channel-name validation rejects any user channel beginning with the reserved
**`_AGENTS_.`** prefix. This prefix is owned by the agent platform's internal
channels (registry, streaming, discovery); rejecting it prevents user operations from
colliding with platform traffic. Attempting to send to or subscribe on such a channel
through any connector is refused.
## Related [#related]
# RabbitMQ (AMQP 0-9-1) (/connectors/rabbitmq)
Point a RabbitMQ app at KubeMQ by changing only the connection string. The **RabbitMQ
(AMQP 0-9-1) connector** is a built-in, wire-protocol bridge inside kubemq-server that
speaks the RabbitMQ wire dialect natively — any standard AMQP 0-9-1 client (`amqp091-go`,
`pika`, `amqp-client`, `amqplib`, `RabbitMQ.Client`, `bunny`, `lapin`) talks to KubeMQ's
Queues with no KubeMQ SDK, no library swap, and no code rewrite.
## What is the RabbitMQ connector [#what-is-the-rabbitmq-connector]
[AMQP 0-9-1](https://www.rabbitmq.com/tutorials/amqp-concepts) is the wire protocol that
RabbitMQ popularized: a client opens a connection, multiplexes *channels* over it, declares
*queues* and *exchanges*, and publishes messages routed by exchange type and routing key.
The KubeMQ connector accepts every one of these operations from a stock client and bridges
them onto KubeMQ — it is a *gateway*, not a client library, so your application only needs
its existing AMQP 0-9-1 client.
**Mental model — everything is a Queue.** Every AMQP queue maps to exactly one KubeMQ
**Queue** channel named `amqp.{vhost}.{queue}`. Exchanges and bindings are **virtual**,
connector-side routing metadata resolved at publish time — not data stores. AMQP only ever
touches the KubeMQ Queue primitive, so the connector's "patterns" mirror AMQP routing
concepts (work queues, pub/sub, routing, topics, RPC), not KubeMQ's five messaging
patterns.
Key capabilities:
* **Drop-in connection-string migration** — keep your RabbitMQ client and code; change only
the broker host in the URL.
* **Everything is a Queue** — every AMQP queue is a durable KubeMQ Queue channel
`amqp.{vhost}.{queue}`; exchanges (default, direct, fanout, topic, headers) route to those
queues virtually at publish time.
* **Native RPC** — request/reply uses RabbitMQ's `amq.rabbitmq.reply-to` (direct reply-to);
there is no gRPC responder.
* **Cross-protocol interop** — a message published over AMQP to `amqp.default.orders` is
consumable by a gRPC or REST KubeMQ client on the same channel, and vice-versa.
## How it works [#how-it-works]
An AMQP client publishes to an exchange with a routing key. The connector resolves the
exchange routing (default / direct / fanout / topic / headers) to a set of target queues
*at publish time*, then writes each message to that queue's KubeMQ Queue channel through the
message broker. A consumer on the same queue — over AMQP or any other KubeMQ transport —
receives it.
*A publish to queue `orders` on vhost `/` resolves to the KubeMQ Queue channel `amqp.default.orders`; any consumer on that channel — AMQP or gRPC/REST — receives the message.*
## Ports & protocol surface [#ports--protocol-surface]
| Port | Transport | Protocol | Notes |
| ------ | ---------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `5672` | Plain TCP (SASL PLAIN) | AMQP 0-9-1 (RabbitMQ wire dialect) | Default plain listener. **Shared with the AMQP 1.0 connector** via the internal `amqpmux`. |
| `5671` | TLS / AMQPS over TCP | AMQP 0-9-1 | Binds only when the server-global `Security` block is configured. Shared TLS listener with AMQP 1.0. |
A single `amqpmux` listener accepts every connection on `5672`/`5671`, reads the 8-byte AMQP
protocol header, and routes it to the matching dialect engine — so AMQP 0-9-1 and AMQP 1.0
coexist on the same ports. SASL is **PLAIN only**. The AMQP vhost `/` maps to the
connector's configured `DefaultVhost` segment (literal `"default"`); see
[Architecture](/connectors/rabbitmq/concepts/architecture) for the dispatch detail.
## Publish to a queue [#publish-to-a-queue]
The example below declares a queue, publishes one `text/plain` message to it through the
default exchange (routing key = queue name), and consumes it back. Queue `hello` on vhost
`/` lands on the KubeMQ Queue channel `amqp.default.hello`. Every client reads the broker
endpoint from `KUBEMQ_AMQP_URL` (default `amqp://guest:guest@localhost:5672/`).
This is the same round trip as the [Getting started](/connectors/rabbitmq/tutorials/getting-started)
tutorial, shown here inline for reference. For a step-by-step walkthrough — enabling the
connector, running a local broker, and verifying each step — use Getting started instead.
```go
package main
import (
"context"
"log"
"os"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://guest:guest@localhost:5672/"
}
func main() {
conn, err := amqp.Dial(amqpURL())
if err != nil {
log.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
ch, err := conn.Channel()
if err != nil {
log.Fatalf("channel: %v", err)
}
defer func() { _ = ch.Close() }()
// Declare "hello" → KubeMQ Queue channel amqp.default.hello.
q, err := ch.QueueDeclare("hello", false, false, false, false, nil)
if err != nil {
log.Fatalf("declare queue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Publish on the default exchange — routing key = queue name.
if err := ch.PublishWithContext(ctx, "", q.Name, false, false, amqp.Publishing{
ContentType: "text/plain",
Body: []byte("Hello World!"),
}); err != nil {
log.Fatalf("publish: %v", err)
}
log.Printf(" [x] Sent %q", "Hello World!")
// Consume with auto-ack and print the exact body.
msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil)
if err != nil {
log.Fatalf("consume: %v", err)
}
select {
case d := <-msgs:
log.Printf(" [x] Received %q", string(d.Body))
case <-ctx.Done():
log.Fatalf("timed out: %v", ctx.Err())
}
}
```
```python
import os
import pika
URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")
QUEUE = "hello" # → KubeMQ Queue channel amqp.default.hello
def main() -> None:
connection = pika.BlockingConnection(pika.URLParameters(URL))
channel = connection.channel()
# Default (nameless) exchange: routing key == queue name.
channel.queue_declare(queue=QUEUE, durable=False, exclusive=False, auto_delete=False)
channel.basic_publish(
exchange="",
routing_key=QUEUE,
body=b"Hello World!",
properties=pika.BasicProperties(content_type="text/plain"),
)
print(" [x] Sent 'Hello World!'")
method, _props, body = channel.basic_get(queue=QUEUE, auto_ack=True)
if method is None:
raise SystemExit("no message received from queue")
print(f" [x] Received {body.decode()!r}")
channel.close()
connection.close()
if __name__ == "__main__":
main()
```
```java
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.DeliverCallback;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
public final class Main {
private static final String QUEUE = "hello"; // → amqp.default.hello
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
factory.setUri(System.getenv().getOrDefault(
"KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/"));
try (Connection connection = factory.newConnection();
Channel channel = connection.createChannel()) {
// queue.declare: non-durable, non-exclusive, no auto-delete.
channel.queueDeclare(QUEUE, false, false, false, null);
// Publish via the default exchange ("") with routing key = queue name.
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.contentType("text/plain")
.build();
channel.basicPublish("", QUEUE, props, "Hello World!".getBytes("UTF-8"));
System.out.println("[x] Sent 'Hello World!'");
BlockingQueue received = new ArrayBlockingQueue<>(1);
DeliverCallback onDeliver = (tag, delivery) ->
received.offer(new String(delivery.getBody(), "UTF-8"));
channel.basicConsume(QUEUE, true, onDeliver, tag -> { });
String got = received.poll(15, TimeUnit.SECONDS);
if (got == null) {
throw new IllegalStateException("timed out waiting for the message");
}
System.out.println("[x] Received '" + got + "'");
}
}
}
```
```javascript
import amqp from "amqplib";
// amqplib reads the URL path as the vhost; the bare trailing "/" in the dev URL
// resolves to the default vhost (KubeMQ segment "default").
const URL = process.env.KUBEMQ_AMQP_URL ?? "amqp://guest:guest@localhost:5672/";
const QUEUE = "hello"; // → KubeMQ Queue channel amqp.default.hello
async function main() {
const conn = await amqp.connect(URL);
const ch = await conn.createChannel();
await ch.assertQueue(QUEUE, { durable: false, autoDelete: false, exclusive: false });
const received = new Promise((resolve) => {
ch.consume(
QUEUE,
(msg) => {
if (msg === null) return;
console.log(`[x] Received: ${msg.content.toString()}`);
resolve();
},
{ noAck: true },
);
});
// Default exchange ("") routes by queue name.
ch.publish("", QUEUE, Buffer.from("Hello World!"), { contentType: "text/plain" });
console.log("[x] Sent: Hello World!");
await received;
await ch.close();
await conn.close();
}
main().catch((err) => {
console.error("publish failed:", err);
process.exit(1);
});
```
```csharp
using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
const string queueName = "hello"; // → KubeMQ Queue channel amqp.default.hello
var factory = new ConnectionFactory
{
Uri = new Uri(Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL")
?? "amqp://guest:guest@localhost:5672/"),
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var ct = cts.Token;
await using var connection = await factory.CreateConnectionAsync(ct);
await using var channel = await connection.CreateChannelAsync(cancellationToken: ct);
// Non-durable, not exclusive, not auto-delete — a plain shared queue.
await channel.QueueDeclareAsync(queueName, durable: false, exclusive: false,
autoDelete: false, arguments: null, cancellationToken: ct);
var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += (_, ea) =>
{
received.TrySetResult(Encoding.UTF8.GetString(ea.Body.Span));
return Task.CompletedTask;
};
await channel.BasicConsumeAsync(queueName, autoAck: true, consumer: consumer, cancellationToken: ct);
// Publish to the default exchange with routing key = queue name.
var props = new BasicProperties { ContentType = "text/plain" };
await channel.BasicPublishAsync(exchange: "", routingKey: queueName, mandatory: false,
basicProperties: props, body: Encoding.UTF8.GetBytes("Hello World!"), cancellationToken: ct);
Console.WriteLine("[x] Sent 'Hello World!'");
var message = await received.Task.WaitAsync(ct);
Console.WriteLine($"[x] Received '{message}'");
```
```ruby
# frozen_string_literal: true
require "bunny"
URL = ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")
QUEUE = "hello" # → KubeMQ Queue channel amqp.default.hello
conn = Bunny.new(URL)
conn.start
ch = conn.create_channel
# Publisher confirms: wait for the broker to accept and route the publish before
# reading it back, so a fire-and-forget publish is never lost in flight.
ch.confirm_select
queue = ch.queue(QUEUE, durable: false, auto_delete: false, exclusive: false)
# Default ("") exchange routes by queue name.
ch.default_exchange.publish("Hello World!", routing_key: queue.name, content_type: "text/plain")
ch.wait_for_confirms
puts " [x] Sent 'Hello World!'"
body = nil
deadline = Time.now + 5
while body.nil? && Time.now < deadline
_info, _props, body = queue.pop(manual_ack: false)
sleep 0.2 if body.nil?
end
puts " [x] Received '#{body}'"
conn.close
```
```rust
use futures_lite::StreamExt;
use lapin::{
options::{BasicConsumeOptions, BasicPublishOptions, QueueDeclareOptions},
types::FieldTable,
BasicProperties, Connection, ConnectionProperties,
};
const QUEUE: &str = "hello"; // → KubeMQ Queue channel amqp.default.hello
// lapin reads the URL path as the vhost and treats a bare trailing "/" as an
// empty vhost, which the connector rejects. The default "/" vhost must be
// percent-encoded as "%2f".
fn amqp_url() -> String {
let url = std::env::var("KUBEMQ_AMQP_URL")
.unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into());
let host = url.rsplit_once('@').map_or(url.as_str(), |(_, h)| h);
if host.ends_with('/') && !host.ends_with("/%2f") {
format!("{url}%2f")
} else {
url
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let conn = Connection::connect(&amqp_url(), ConnectionProperties::default()).await?;
let channel = conn.create_channel().await?;
channel
.queue_declare(QUEUE, QueueDeclareOptions::default(), FieldTable::default())
.await?;
// Publish to the default exchange, routing key = queue name.
channel
.basic_publish(
"",
QUEUE,
BasicPublishOptions::default(),
b"Hello World!",
BasicProperties::default().with_content_type("text/plain".into()),
)
.await?
.await?;
println!("[x] Sent 'Hello World!'");
let mut consumer = channel
.basic_consume(
QUEUE,
"hello-consumer",
BasicConsumeOptions { no_ack: true, ..Default::default() },
FieldTable::default(),
)
.await?;
if let Some(delivery) = consumer.next().await {
let delivery = delivery?;
println!("[x] Received '{}'", String::from_utf8_lossy(&delivery.data));
}
conn.close(0, "done").await?;
Ok(())
}
```
## Supported languages [#supported-languages]
The connector speaks standard AMQP 0-9-1, so any conformant RabbitMQ client works. The
examples pin one native client per language — there is no KubeMQ SDK, no proto bindings,
and no published package.
| Language | Client library | Notes |
| ----------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------- |
| Go | [`github.com/rabbitmq/amqp091-go`](https://github.com/rabbitmq/amqp091-go) | The RabbitMQ team's Go client. |
| Python | [`pika`](https://pika.readthedocs.io/) | `BlockingConnection` with `URLParameters`. |
| Java | [`com.rabbitmq:amqp-client`](https://www.rabbitmq.com/client-libraries/java-api-guide) | The official RabbitMQ Java client. |
| JavaScript / TypeScript | [`amqplib`](https://github.com/amqp-node/amqplib) | Encode the default `/` vhost as `%2f` in the URL. |
| C# / .NET | [`RabbitMQ.Client`](https://www.rabbitmq.com/client-libraries/dotnet-api-guide) | Task-based async API (v7+). |
| Ruby | [`bunny`](https://github.com/ruby-amqp/bunny) | Use publisher confirms before consuming. |
| Rust | [`lapin`](https://github.com/amqp-rs/lapin) | async/await on Tokio; percent-encode the `/` vhost. |
## Next steps [#next-steps]
# STOMP (/connectors/stomp)
Point your existing STOMP application at KubeMQ by changing only the broker address. The
**STOMP connector** is a built-in, wire-protocol bridge inside kubemq-server — an embedded
STOMP server with its own dedicated TCP/TLS listeners and a hand-rolled frame codec. Any
standard, unmodified STOMP client (go-stomp, stomp.py, Stomp.Net, Spring) talks to KubeMQ's
Queues, Events, Events-Store, Commands, and Queries with no code change, no library swap, and
no KubeMQ SDK.
## What is the STOMP connector [#what-is-the-stomp-connector]
[STOMP](https://stomp.github.io/) (Simple Text Oriented Messaging Protocol) is a frame-based
text protocol: a client opens a connection with a `CONNECT` frame, then `SEND`s to and
`SUBSCRIBE`s on **destinations**. The KubeMQ STOMP connector negotiates STOMP **1.0, 1.1, and
1.2** (it picks the highest common version; the examples default to `1.2`) over raw TCP, and
maps the STOMP wire protocol onto KubeMQ's five native messaging patterns by **destination
prefix**.
The first path segment of a destination selects the pattern; the remaining segments are joined
with `.` into the KubeMQ channel — `/topic/orders/new` becomes Events channel `orders.new`. The
connector is a *gateway*, not a client library: your application only needs a stock STOMP
client.
Key capabilities:
* **All five patterns over one wire** — Queues, Events, Events-Store, Commands, and Queries,
selected by the destination prefix.
* **ActiveMQ-style primary names, MQTT-style aliases** — lead with `/queue/`, `/topic/`,
`/topic-store/`, `/command/`, `/query/`; the aliases `/queues/`, `/events/`, `/store/`,
`/commands/`, `/queries/` resolve to the same patterns, and egress always canonicalizes back
to the primary name.
* **RPC requester-only** — a STOMP client `SEND`s to `/command/` or `/query/` and receives the
reply on a connection-local `/reply/` subscription; the responder runs on the KubeMQ (gRPC)
side.
* **Cross-protocol interop** — a message sent over STOMP to `/topic/orders/new` is consumable
by a gRPC or REST KubeMQ client on channel `orders.new`, and vice-versa.
## How it works [#how-it-works]
A STOMP client connects to the connector and `SEND`s to a destination. The connector resolves
the destination to a KubeMQ `(pattern, channel)` pair, hands the message to the message broker,
and consumers on the same channel — over STOMP or any other KubeMQ transport — receive it.
*The connector parses the destination prefix into a KubeMQ pattern and joins the remaining segments (slash→dot) into the channel `orders.new`, then bridges onto the shared KubeMQ array.*
## Ports & protocol surface [#ports--protocol-surface]
| Port | Transport | Protocol | Notes |
| ------- | ------------ | --------------------- | ----------------------------------------------------------------------------------------------------------- |
| `61613` | Plain TCP | STOMP 1.0 / 1.1 / 1.2 | Default plain listener; binds all interfaces. |
| `61614` | TLS over TCP | STOMP 1.0 / 1.1 / 1.2 | Binds only when the server-wide `Security` block resolves to TLS. **Must differ from the plain port.** |
There is **no STOMP-over-WebSocket listener** — the connector speaks raw TCP only, so a
WebSocket-only client (such as `@stomp/stompjs`) cannot drive it. TLS has no STOMP-specific
configuration: certificate material, mTLS, and the minimum TLS version come from the
server-wide `Security` block. Connecting over TLS is purely a transport swap
(`tls://host:61614`); the STOMP frames on top are identical. See
[Architecture](/connectors/stomp/concepts/architecture) for the protocol stack.
## Send a message [#send-a-message]
The example below produces one message to a Queue over a stock STOMP client. The `/queue/`
prefix selects the **Queues** pattern (competing consumer, at-least-once); the remaining
segments become the KubeMQ channel with `/` translated to `.` — `/queue/orders/new` lands on
channel `orders.new`. Every client reads the broker endpoint from `KUBEMQ_STOMP_URL` (default
`tcp://localhost:61613`); the scheme selects the transport.
```go
package main
import (
"fmt"
"log"
"os"
"github.com/go-stomp/stomp/v3"
)
func stompURL() string {
if v := os.Getenv("KUBEMQ_STOMP_URL"); v != "" {
return v
}
return "tcp://localhost:61613"
}
func main() {
// The scheme in KUBEMQ_STOMP_URL selects the transport; strip it for net.Dial.
addr := stompURL()[len("tcp://"):]
const destination = "/queue/orders/new" // Queues pattern, channel "orders.new"
// CONNECT: accept-version 1.2, default heart-beat, no-auth.
conn, err := stomp.Dial("tcp", addr,
stomp.ConnOpt.AcceptVersion(stomp.V12),
stomp.ConnOpt.Login("my-app", ""))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer conn.Disconnect() //nolint:errcheck
// SEND one message; the receipt blocks until KubeMQ accepts the frame.
if err := conn.Send(destination, "text/plain",
[]byte("hello from STOMP"), stomp.SendOpt.Receipt); err != nil {
log.Fatalf("send: %v", err)
}
fmt.Printf("sent 1 message to %s (channel orders.new)\n", destination)
}
```
```python
import os
import stomp
def stomp_endpoint() -> tuple[str, int]:
url = os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613")
host_port = url.split("://", 1)[1]
host, port = host_port.split(":", 1)
return host, int(port)
def main() -> None:
destination = "/queue/orders/new" # Queues pattern, channel "orders.new"
host, port = stomp_endpoint()
# CONNECT: accept-version 1.2, default heart-beat, no-auth.
conn = stomp.Connection([(host, port)], heartbeats=(10000, 10000))
conn.connect(login="my-app", passcode="", wait=True)
try:
conn.send(destination=destination, body="hello from STOMP",
content_type="text/plain")
print(f"sent 1 message to {destination} (channel orders.new)")
finally:
conn.disconnect()
if __name__ == "__main__":
main()
```
```java
import java.net.URI;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.messaging.tcp.reactor.ReactorNettyTcpClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient;
public final class Main {
public static void main(String[] args) throws Exception {
String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613");
URI uri = URI.create(url);
String destination = "/queue/orders/new"; // Queues pattern, channel "orders.new"
// CONNECT over raw TCP; Spring negotiates STOMP 1.2 by default.
ReactorNettyTcpStompClient client =
new ReactorNettyTcpStompClient(uri.getHost(), uri.getPort());
StompSession session =
client.connect(new StompSessionHandlerAdapter() {}).get();
StompHeaders headers = new StompHeaders();
headers.setDestination(destination);
headers.add("content-type", "text/plain");
session.send(headers, "hello from STOMP".getBytes());
System.out.printf("sent 1 message to %s (channel orders.new)%n", destination);
session.disconnect();
client.shutdown();
}
}
```
```typescript
import { connect, type Client } from "stompit";
function endpoint(): { host: string; port: number } {
const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613");
return { host: url.hostname, port: Number(url.port) || 61613 };
}
async function main(): Promise {
const destination = "/queue/orders/new"; // Queues pattern, channel "orders.new"
const { host, port } = endpoint();
// CONNECT over raw TCP — stompit, NOT @stomp/stompjs (which is WebSocket-only).
const client: Client = await new Promise((resolve, reject) => {
connect({ host, port, connectHeaders: { "accept-version": "1.2",
"heart-beat": "10000,10000", login: "my-app" } },
(err, c) => (err ? reject(err) : resolve(c)));
});
const frame = client.send({ destination, "content-type": "text/plain" });
frame.write("hello from STOMP");
frame.end();
console.log(`sent 1 message to ${destination} (channel orders.new)`);
client.disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using System.Text;
using Stomp.Net;
static string BrokerUri()
{
var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613";
var u = new Uri(url);
// Stomp.Net dispatches on the outer scheme: tcp:// or ssl://.
var transport = u.Scheme == "tls" ? "ssl" : "tcp";
return $"{transport}://{u.Host}:{(u.Port > 0 ? u.Port : 61613)}";
}
const string destination = "/queue/orders/new"; // Queues pattern, channel "orders.new"
// CONNECT: Stomp.Net performs the STOMP 1.2 handshake; no-auth default.
var factory = new ConnectionFactory(BrokerUri(), new StompConnectionSettings());
using var connection = factory.CreateConnection();
connection.Start();
using var session = connection.CreateSession(AcknowledgementMode.AutoAcknowledge);
using var producer = session.CreateProducer(session.GetQueue(destination));
var message = session.CreateBytesMessage(Encoding.UTF8.GetBytes("hello from STOMP"));
message.StompType = "text/plain";
producer.Send(message);
Console.WriteLine($"sent 1 message to {destination} (channel orders.new)");
```
```ruby
require "stomp"
require "uri"
uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613"))
destination = "/queue/orders/new" # Queues pattern, channel "orders.new"
# CONNECT: accept-version 1.2, default heart-beat, no-auth.
client = Stomp::Client.new(
hosts: [{ host: uri.host, port: uri.port }],
connect_headers: { "accept-version" => "1.2", "heart-beat" => "10000,10000",
"login" => "my-app", "passcode" => "" },
)
client.publish(destination, "hello from STOMP", { "content-type" => "text/plain" })
puts "sent 1 message to #{destination} (channel orders.new)"
client.close
```
```rust
use async_stomp::client::Connector;
use async_stomp::ToServer;
use futures::SinkExt;
#[tokio::main]
async fn main() -> Result<(), Box> {
let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into());
let host_port = url.split("://").nth(1).unwrap_or("localhost:61613");
let destination = "/queue/orders/new"; // Queues pattern, channel "orders.new"
// CONNECT over raw TCP; async-stomp negotiates STOMP 1.2.
let mut conn = Connector::builder()
.server(host_port)
.login("my-app".to_string())
.passcode(String::new())
.connect()
.await?;
conn.send(ToServer::Send {
destination: destination.to_string(),
transaction: None,
headers: Some(vec![("content-type".to_string(), "text/plain".to_string())]),
body: Some(b"hello from STOMP".to_vec()),
})
.await?;
println!("sent 1 message to {destination} (channel orders.new)");
conn.send(ToServer::Disconnect { receipt: None }).await?;
Ok(())
}
```
## Supported languages [#supported-languages]
The connector speaks standard STOMP over raw TCP, so any conformant native STOMP client works.
The examples pin one client per language — there is no KubeMQ SDK, no proto bindings, and no
published package. Only `go-stomp/v3` and `stomp.py` are proven by kubemq-server integration
tests; the others are wire-compatible.
| Language | Client library | Notes |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| Go | [`github.com/go-stomp/stomp/v3`](https://github.com/go-stomp/stomp) | The connector's reference client. |
| Python | [`stomp.py`](https://github.com/jasonrbriggs/stomp.py) | Listener-based; install via `uv`. |
| Java | Spring [`ReactorNettyTcpStompClient`](https://docs.spring.io/spring-framework/reference/web/websocket/stomp.html) (spring-messaging) | Raw-TCP STOMP over Reactor Netty. |
| JavaScript / TypeScript | [`stompit`](https://github.com/gdaws/stompit) | Raw TCP — **not** `@stomp/stompjs` (WebSocket-only). |
| C# / .NET | [`Stomp.Net`](https://github.com/DaveSenn/Stomp.Net) | NMS-style API over STOMP 1.2. |
| Ruby | [`stomp`](https://github.com/stompgem/stomp) gem | Native STOMP 1.0/1.1/1.2 client. |
| Rust | [`async-stomp`](https://docs.rs/async-stomp) | async/await on Tokio. |
**`@stomp/stompjs` is WebSocket-only and cannot drive the STOMP connector.** The connector
listens on raw TCP (61613/61614) with no WebSocket upgrade, so the JavaScript/TypeScript
examples use `stompit` (raw TCP). See
[Connectivity and security](/connectors/stomp/how-to/connectivity-and-security).
## Next steps [#next-steps]
# Channels & Routing (/learn/concepts/channels-and-routing)
Think of a channel like the address on an envelope. You do not hand a letter directly to a person — you drop it in the mail with an address, and the postal system delivers it to whoever picks up mail at that address. Senders and receivers never need to know about each other; they only need to agree on the address.
A **channel** is that address: a named destination that a broker uses to route messages. Publishers send to a channel name; subscribers express interest in a channel name. The broker connects the two. This indirection is what makes messaging *loosely coupled* — you can add, remove, or move services without either side changing the other's code.
## Channels — the idea [#channels--the-idea]
A channel is just a string name, chosen by you. Publishers attach that name to each message; the broker matches it against the names subscribers asked for and delivers accordingly. Nothing is hard-wired — the channel is created the moment something uses it.
Good channel names follow a **hierarchy**, read left to right from broad to specific, with a separator between each level. A common convention is `{domain}.{entity}.{action}`:
```text
orders.created
orders.updated
orders.us-east.created
payments.completed
inventory.reserved
```
That hierarchy is not decoration. It is what makes the next idea — wildcards — possible.
*Channels are named destinations; the broker matches a publisher's channel name to the subscribers that asked for it.*
## Wildcards — subscribing to a pattern [#wildcards--subscribing-to-a-pattern]
Naming channels one by one works until you have dozens of them. A monitoring service that needs *every* order event would have to subscribe to `orders.created`, `orders.updated`, `orders.shipped`, and remember to add a new subscription every time someone invents `orders.cancelled`.
A **wildcard subscription** solves this: instead of naming exact channels, the subscriber names a *pattern*, and the broker delivers every message whose channel matches. The hierarchy makes the pattern meaningful — each level can be matched broadly or exactly.
Two wildcard tokens are common, and KubeMQ uses both:
| Token | Matches | Example |
| ----- | ------------------ | ------------------------------------------------------------------ |
| `*` | exactly one level | `orders.*` matches `orders.created`, not `orders.us-east.created` |
| `>` | one or more levels | `orders.>` matches `orders.created` *and* `orders.us-east.created` |
A standalone `>` subscribes to everything — a firehose for audit or debugging.
*One pattern subscription captures many channels: `orders.*` matches single-level order events, while `>` captures everything.*
**Pitfall:** wildcards belong to *subscriptions*, not *publishes*. A publisher must always send to a concrete channel name — `orders.created`, never `orders.*`. The `*` and `>` characters are illegal in a publish channel.
## Routing — one publish, many channels [#routing--one-publish-many-channels]
Wildcards let one subscriber listen to many channels. **Multicast routing** is the mirror image: it lets one *publish* reach many channels at once, without the publisher looping or opening multiple connections.
The publisher encodes a list of targets into the channel name, and the broker fans the message out to each. This is how you tee a single business event to several consumers with different needs — a live feed *and* a durable copy *and* a work queue — from one call.
*A single publish fans out to channels of different types at once — a real-time feed, a durable store, and a reliable work queue.*
## Precise definition [#precise-definition]
* **Channel** — a named destination string. Created on first use, addressed by publishers and subscribers; the unit the broker routes on.
* **Hierarchical naming** — a dot-separated convention (`domain.entity.action`) that gives channels structure so patterns can match levels.
* **Wildcard subscription** — a subscription whose channel is a *pattern* using `*` (one level) or `>` (one or more levels). The broker delivers every message whose channel matches.
* **Multicast routing** — a single publish addressed to multiple channels at once, encoded in the channel string; the broker delivers a copy to each target.
## Trade-offs [#trade-offs]
| When it helps | When it bites |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| A consumer needs a whole category of channels — one `orders.>` beats a dozen explicit subscriptions. | A too-broad pattern (`>`) hauls in traffic you do not need, wasting bandwidth and processing. |
| You want to tee one event to several patterns (live + durable + queue) without publisher-side loops. | Routing to many targets multiplies broker work per publish; very wide fan-outs add latency. |
| A naming hierarchy lets new channels appear without changing subscribers. | Inconsistent naming breaks wildcards — `order.created` and `orders.created` will not match the same pattern. |
| Routing keeps publishers simple — they do not track who consumes what. | Routing hides destinations in a string; an inspectable, documented naming scheme is essential to avoid surprise delivery. |
## In KubeMQ [#in-kubemq]
KubeMQ channels are exactly the named destinations above — a plain string you choose, created on first use. **Wildcard subscriptions** are supported for **Events** (Pub/Sub): subscribe to `orders.*` or `>` and the broker matches every event channel against your pattern. **Multicast routing** is encoded directly in the channel string: `;` separates targets and a `type:` prefix selects the pattern.
| Routing string | Effect |
| ---------------------------------------------- | ---------------------------------------------- |
| `events:order-live;events_store:order-archive` | one publish → a live feed *and* a durable copy |
| `events:notify;queues:order-fulfilment` | broadcast *and* a reliable work queue |
The snippet below is the canonical wildcard subscribe from the Events tutorial — one subscription to `orders.*` captures every single-level order channel.
```go title="orders_monitor.go"
sub, err := client.SubscribeToEvents(ctx, "orders.*", "",
kubemq.WithOnEvent(func(event *kubemq.Event) {
fmt.Printf("[Orders Monitor] channel=%s body=%s\n",
event.Channel, string(event.Body))
}),
kubemq.WithOnError(func(err error) {
log.Println("Error:", err)
}),
)
```
```python title="orders_monitor.py"
client.subscribe_to_events(
subscription=EventsSubscription(
channel="orders.*",
on_receive_event_callback=lambda e: print(
f"[Orders Monitor] channel={e.channel} body={e.body.decode()}"
),
on_error_callback=lambda e: print(f"Error: {e}"),
),
cancel=CancellationToken(),
)
```
```javascript title="orders_monitor.js"
client.subscribeToEvents({
channel: "orders.*",
onEvent: (msg) =>
console.log(
`[Orders Monitor] channel=${msg.channel} body=${Buffer.from(msg.body).toString()}`
),
onError: (err) => console.error("Error:", err.message),
});
```
```java title="OrdersMonitor.java"
client.subscribeToEvents(EventsSubscription.builder()
.channel("orders.*")
.onReceiveEventCallback(event ->
System.out.printf("[Orders Monitor] channel=%s body=%s%n",
event.getChannel(), new String(event.getBody())))
.onErrorCallback(err ->
System.err.println("Error: " + err.getMessage()))
.build());
```
```csharp title="OrdersMonitor.cs"
await foreach (var msg in client.SubscribeToEventsAsync(
new EventsSubscription { Channel = "orders.*" }))
{
Console.WriteLine($"[Orders Monitor] channel={msg.Channel} "
+ $"body={Encoding.UTF8.GetString(msg.Body.Span)}");
}
```
```kotlin title="OrdersMonitor.kt"
client.subscribeToEvents(
channel = "orders.*",
onEvent = { event ->
println("[Orders Monitor] channel=${event.channel} body=${String(event.body)}")
},
onError = { err -> System.err.println("Error: ${err.message}") }
)
```
```cpp title="orders_monitor.cpp"
client.subscribeToEvents("orders.*", "",
[](const kubemq::Event& event) {
std::cout << "[Orders Monitor] channel=" << event.channel
<< " body=" << event.body << std::endl;
},
[](const std::string& err) {
std::cerr << "Error: " << err << std::endl;
}
);
```
```rust title="orders_monitor.rs"
// A single wildcard subscription matches every channel under the pattern.
let sub = client
.subscribe_to_events(
"orders.*",
"",
|event| {
Box::pin(async move {
println!(
"[Orders Monitor] channel={}, body={}",
event.channel,
String::from_utf8_lossy(&event.body)
);
})
},
None,
)
.await?;
```
```ruby title="orders_monitor.rb"
cancel = KubeMQ::CancellationToken.new
sub = KubeMQ::PubSub::EventsSubscription.new(channel: "orders.*")
client.subscribe_to_events(sub, cancellation_token: cancel,
on_error: ->(e) { puts "Error: #{e.message}" }) do |event|
puts "[Orders Monitor] channel=#{event.channel} body=#{event.body}"
end
```
```elixir title="orders_monitor.exs"
{:ok, sub} =
KubeMQ.Client.subscribe_to_events(client, "orders.*",
on_event: fn event ->
IO.puts("[Orders Monitor] channel=#{event.channel} body=#{event.body}")
end
)
```
**In KubeMQ:** wildcard subscriptions are an **Events** feature. Events Store, Queues, and RPC use exact channel names — they do not match `*` or `>` patterns. Multicast routing, by contrast, works across patterns: one routing string can tee an event into Events, Events Store, and Queues at the same time.
### How KubeMQ does this → [#how-kubemq-does-this-]
# Delivery Guarantees (/learn/concepts/delivery-guarantees)
Imagine mailing a contract. You could drop it in a mailbox and hope it arrives — fast, but if it gets lost you never know. You could send it certified mail, where the courier keeps trying until someone signs for it — nothing is lost, but a frazzled courier might leave two copies. Or you could number every envelope and have the recipient ignore duplicates — slower and more bookkeeping, but each contract lands exactly once.
Those three choices are the three **delivery guarantees** every messaging system makes you pick between. The guarantee decides what happens when something fails — a subscriber is offline, a network blips, a consumer crashes mid-work. There is no "always perfect" option for free: stronger guarantees cost latency, storage, and complexity. This page explains the three guarantees, the acknowledgement mechanics that make the stronger ones possible, and the safety nets (idempotency and dead-letter queues) that keep them honest.
## Delivery guarantees — the idea [#delivery-guarantees--the-idea]
A delivery guarantee is a promise about how many times a message is processed by a consumer in the face of failure. There are three:
* **at-most-once** — deliver the message, never retry. A message is processed zero or one times. Fastest and cheapest; messages can be silently lost.
* **at-least-once** — keep delivering until the consumer confirms success. A message is processed one or more times. Nothing is lost, but duplicates are possible.
* **exactly-once** — the message is processed once and only once, even across failures. No loss, no duplicates. The strongest promise and the most expensive to provide.
The pivot between them is the **acknowledgement** — a small signal the consumer sends back after handling a message. Whether the system waits for an ack, and what it does when an ack never arrives, is what separates the three guarantees.
### at-most-once: fire and forget [#at-most-once-fire-and-forget]
The broker hands the message to whoever is listening right now and immediately forgets it. There is no ack, no retry, no stored copy. If a subscriber is offline or the message is dropped in transit, it is gone.
*at-most-once: the broker delivers to active subscribers only; an offline subscriber misses the message and it is never retried.*
### at-least-once: acknowledge or redeliver [#at-least-once-acknowledge-or-redeliver]
The broker keeps the message until the consumer **acks** it. If the consumer fails to ack — it crashes, times out, or sends a **nack** (negative acknowledgement) — the broker redelivers. Nothing is lost, but a consumer that did the work and then crashed *before* acking will see the same message again.
*at-least-once: with no ack inside the window, the broker redelivers; the message is never lost but may arrive more than once.*
### exactly-once: acknowledge, then deduplicate [#exactly-once-acknowledge-then-deduplicate]
You reach effectively-once by combining at-least-once delivery with **deduplication** on the consumer side. The consumer records which message IDs it has already handled; a redelivered duplicate is recognized and skipped before it has any effect. The work happens once even though the message may be delivered twice.
*exactly-once (effectively-once): at-least-once delivery plus a dedup check on the message ID means the redelivered duplicate has no effect.*
## Precise definition [#precise-definition]
A **delivery guarantee** is the contract a messaging system upholds for how many times a given message is successfully processed when failures occur. It is realized through three mechanics:
* **Acknowledgement (ack)** — a signal from the consumer that a message was processed successfully and can be discarded by the broker. A **negative acknowledgement (nack)** signals failure and asks for redelivery.
* **Redelivery window** — the time the broker waits for an ack before assuming failure and redelivering. (When a consumer holds a message during processing, this is the **visibility timeout**.)
* **Idempotency** — a processing operation is *idempotent* if applying it twice has the same effect as applying it once. Idempotent consumers turn at-least-once delivery into effectively-once results, because duplicates do no extra harm.
A **dead letter queue (DLQ)** is the final safety net: after a message fails and is redelivered up to a configured maximum number of times, the broker stops retrying and moves it to a separate channel for inspection. This prevents a single "poison" message from being redelivered forever and blocking the queue behind it.
> True end-to-end **exactly-once** across independent systems is impossible in the general case (a consumer cannot atomically both ack the broker and commit a side effect). In practice, "exactly-once" means **at-least-once delivery + idempotent processing (or dedup)** — often called **effectively-once**.
## Trade-offs [#trade-offs]
| Guarantee | Loss? | Duplicates? | Cost | Use when |
| ----------------- | -------- | -------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- |
| **at-most-once** | Possible | Never | Lowest latency, no storage | Telemetry, live dashboards, cache invalidation — a missed message is harmless |
| **at-least-once** | Never | Possible | Storage + ack round-trip | Orders, payments, jobs — losing a message is unacceptable; you can tolerate (or dedup) a repeat |
| **exactly-once** | Never | Never (effect) | All of the above + dedup/idempotency | Financial postings, inventory decrements — a duplicate would corrupt state |
**Pitfall:** "at-least-once" guarantees delivery, not single processing. If your consumer is **not idempotent** — for example it blindly increments a balance — a redelivered duplicate will double-charge. Make the handler idempotent (key side effects by message ID, or upsert instead of insert) before relying on at-least-once for anything stateful.
## In KubeMQ [#in-kubemq]
**In KubeMQ:** the guarantee is a property of the **pattern you choose**, not a per-message flag. Events are fire-and-forget (at-most-once). Events Store persists every message so durable subscribers never lose one (at-least-once on the delivery path). Queues give each consumer an explicit **ack / nack** decision plus a **dead-letter queue** after `maxReceiveCount` retries — the basis for exactly-once-processing when your handler is idempotent. RPC is request/reply: the response *is* the acknowledgement.
Each KubeMQ pattern occupies a different point on the guarantee spectrum:
| Delivery guarantee | KubeMQ pattern | How it is provided |
| --------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **at-most-once** | [Events](/learn/events) | Fire-and-forget pub/sub — delivered to active subscribers only, no persistence, no retry |
| **at-least-once** | [Events Store](/learn/events-store) | Every message is persisted; durable subscriptions track their position and replay anything missed |
| **exactly-once-processing** | [Queues](/learn/queues) | Explicit ack/nack settlement + redelivery on nack/timeout + a dead-letter queue; pair with an idempotent handler for effectively-once |
| **request/reply** | [RPC](/learn/rpc) | The reply confirms processing synchronously; a Command returns an ack, a Query returns data — no separate ack step |
The clearest place to see ack/nack in code is the Queues consumer. After receiving a message, you decide its fate: **ack** removes it, **nack** returns it for redelivery. If it is nacked past `maxReceiveCount`, KubeMQ routes it to the configured dead-letter queue.
```go title="settle.go"
resp, err := client.PollQueue(ctx, &kubemq.PollRequest{
Channel: "orders",
MaxItems: 1,
WaitTimeoutSeconds: 5,
})
if err != nil {
log.Fatal(err)
}
for _, m := range resp.Messages {
if err := process(m.Message.Body); err != nil {
m.NAck() // failed — return to queue for redelivery
continue
}
m.Ack() // success — remove from queue
}
```
```python title="settle.py"
response = client.receive_queue_messages(
channel="orders",
max_messages=1,
wait_timeout_in_seconds=5,
)
for msg in response.messages:
try:
process(msg.body)
msg.ack() # success — remove from queue
except Exception:
msg.nack() # failed — return to queue for redelivery
```
```typescript title="settle.ts"
const messages = await client.receiveQueueMessages({
channel: 'orders',
maxMessages: 1,
waitTimeoutSeconds: 5,
});
for (const msg of messages) {
try {
await process(msg.body);
await msg.ack(); // success — remove from queue
} catch {
await msg.nack(); // failed — return to queue for redelivery
}
}
```
```java title="Settle.java"
ReceiveQueueMessagesResponse response = client.receiveQueueMessages(
ReceiveQueueMessagesRequest.builder()
.channel("orders")
.maxMessages(1)
.waitTimeoutSeconds(5)
.build());
for (QueueMessageReceived msg : response.getMessages()) {
try {
process(msg.getBody());
msg.ack(); // success — remove from queue
} catch (Exception e) {
msg.nack(); // failed — return to queue for redelivery
}
}
```
```csharp title="Settle.cs"
var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest
{
Channel = "orders",
MaxMessages = 1,
WaitTimeoutSeconds = 5,
});
foreach (var msg in response.Messages)
{
try
{
Process(msg.Body);
await msg.AckAsync(); // success — remove from queue
}
catch
{
await msg.NAckAsync(); // failed — return to queue for redelivery
}
}
```
```kotlin title="Settle.kt"
val response = client.receiveQueueMessages(
channel = "orders",
maxMessages = 1,
waitTimeoutSeconds = 5
)
for (msg in response.messages) {
try {
process(msg.body)
msg.ack() // success — remove from queue
} catch (e: Exception) {
msg.nack() // failed — return to queue for redelivery
}
}
```
```cpp title="settle.cpp"
auto response = client.receiveQueueMessages("orders", 1, 5);
for (const auto& msg : response.messages) {
try {
process(msg.body);
msg.ack(); // success — remove from queue
} catch (const std::exception&) {
msg.nack(); // failed — return to queue for redelivery
}
}
```
```rust title="settle.rs"
let mut receiver = client.new_queue_downstream_receiver().await?;
let response = receiver
.poll(PollRequest {
channel: "orders".to_string(),
max_items: 1,
wait_timeout_seconds: 5,
auto_ack: false,
})
.await?;
for msg in &response.messages {
match process(&msg.message.body) {
Ok(_) => msg.ack().await?, // success — remove from queue
Err(_) => msg.nack().await?, // failed — return to queue for redelivery
}
}
```
```ruby title="settle.rb"
receiver = client.create_downstream_receiver
request = KubeMQ::Queues::QueuePollRequest.new(
channel: 'orders', max_items: 1, wait_timeout: 5
)
response = receiver.poll(request)
response.messages.each do |m|
begin
process(m.body)
m.ack # success — remove from queue
rescue StandardError
m.nack # failed — return to queue for redelivery
end
end
```
```elixir title="settle.exs"
{:ok, poll} =
KubeMQ.Client.poll_queue(client,
channel: "orders",
max_items: 1,
wait_timeout: 5_000
)
# Settle the whole transaction: ack on success, nack to redeliver
case process(poll.messages) do
:ok -> KubeMQ.PollResponse.ack_all(poll)
_ -> KubeMQ.PollResponse.nack_all(poll)
end
```
*The same receive-then-settle loop in every SDK: ack a processed message, nack a failed one. After `maxReceiveCount` nacks, KubeMQ moves the message to the dead-letter queue.*
### How KubeMQ does this → [#how-kubemq-does-this-]
# Messaging Fundamentals (/learn/concepts)
Two services need to work together: an Orders service takes a customer's order, and a dozen other things have to happen because of it — charge a card, reserve stock, email a receipt, update a dashboard. How do those services *talk*? You can have the Orders service call each of the others directly, or you can put something in the middle that carries the messages for it. That "something in the middle" is what this whole track is about.
Think of it like a busy office. People could walk to each other's desks every time they need something — fast when the other person is there, useless when they're out. Or they could drop notes in a central mailroom that sorts and delivers them. The mailroom never forgets to deliver, doesn't care who's at their desk right now, and lets one announcement reach a hundred people at once. **Messaging** is software's mailroom.
## Synchronous vs asynchronous — the idea [#synchronous-vs-asynchronous--the-idea]
The first choice in any conversation between two services is *whether the sender waits*.
**Synchronous** communication is a phone call. You dial, the other person picks up, you talk, you get an answer, and only then do you hang up and move on. The caller is blocked the whole time — if the other side is slow or down, the caller is stuck. It is simple and immediate, and you get an answer right away.
**Asynchronous** communication is leaving a voicemail or sending a letter. You say your piece and move on with your day; the recipient picks it up when they can and acts on it later. The sender is not blocked, the recipient does not have to be available at the same moment, and the work happens in the background.
*Top: a synchronous call — the caller waits for a reply. Bottom: an asynchronous message — the sender hands off and continues; delivery happens when the receiver is ready.*
Neither is "better." A phone call is right when you genuinely need the answer *now* to continue (checking whether a credit card is valid). A voicemail is right when you just need the other side to *eventually* know something (a receipt should be emailed). Most real systems use both.
## Tight vs loose coupling — the idea [#tight-vs-loose-coupling--the-idea]
When the Orders service calls the Payments service directly, it has to know Payments exists, where it lives, that it is up, and how to talk to it. If Payments moves, scales, slows down, or fails, Orders feels it immediately. That is **tight coupling**: the two are wired straight to each other, and a change or failure in one ripples into the other.
Now add a fifth, sixth, and seventh thing that must happen on every order — fulfillment, analytics, fraud checks, loyalty points. With direct calls, the Orders service grows a hard-wired dependency on each one, and every new consumer means editing and redeploying Orders.
Put a broker in the middle and the picture changes. Orders publishes "an order was placed" to a channel and stops caring who listens. Payments, fulfillment, and analytics each subscribe on their own terms. Orders does not know they exist; they do not know Orders exists. That is **loose coupling**: services depend on a shared *channel*, not on each other. New consumers slot in without touching the producer, and one service being down no longer takes the sender down with it.
*Left: direct calls — the producer is wired to every consumer and must change when the set of consumers changes. Right: via a broker — the producer publishes to one channel; consumers come and go independently.*
**Pitfall:** loose coupling is not free. Asynchronous, broker-mediated messaging adds a hop, makes end-to-end flows harder to trace, and means "done" no longer means "everyone who cares has finished." You trade immediate, all-or-nothing simplicity for independence and resilience. Reach for it when services must scale, fail, and evolve separately — not for a single call that needs an answer right now.
## What a message broker is [#what-a-message-broker-is]
A **message broker** is the piece of infrastructure in the middle. Its job is narrow and important: accept messages from producers, hold them in named **channels**, and deliver them to the right consumers — then get out of the way.
A broker does the work that every messaging system would otherwise reinvent:
* **Decouples** producers from consumers in space (they need not know each other's location), in time (they need not run at the same moment), and in number (one producer, many consumers — or the reverse).
* **Buffers** bursts so a fast producer does not overwhelm a slow consumer.
* **Routes** each message to the consumers that asked for it, by channel name and pattern.
* **Applies delivery rules** — try once, try until acknowledged, preserve order, allow replay — depending on the channel type.
*A simple topology: producers send to channels through one broker, which delivers to the consumers that subscribed — over whatever transport each client speaks.*
## Why distinct messaging patterns exist [#why-distinct-messaging-patterns-exist]
If a broker just "delivers messages," why does this track have four different patterns? Because **one size does not fit all**. Different jobs need different delivery contracts, and trying to serve them all with one mechanism makes every job worse.
Consider what changes from job to job:
| Question | Notifications | Order processing | Audit log | "Is the card valid?" |
| ------------------------------------------------------------ | ------------- | ---------------- | ------------------ | -------------------- |
| Does the sender need a reply? | No | No | No | **Yes, now** |
| Must every message survive a crash? | No | **Yes** | **Yes** | No |
| Should each message go to *one* worker or *all* subscribers? | All | **One** | Replayable by many | One responder |
| Does order matter? | No | Often | **Yes** | N/A |
| Can old messages be replayed later? | No | No | **Yes** | No |
No single delivery rule answers all of these well. A pattern that guarantees nothing is lost and lets you replay history is overkill (and slower) for a fleeting "user is typing" notification. A fire-and-forget broadcast is dangerous for a payment that must not be processed twice. So messaging gives you a small set of **patterns**, each a deliberate trade-off between speed, durability, ordering, and shape of delivery. Picking the right one is most of the skill — and the rest of this track teaches you how.
## In KubeMQ [#in-kubemq]
**In KubeMQ:** KubeMQ *is* the broker — a single engine that hosts every channel type. Your services connect once, then publish to and subscribe from named channels using one client SDK. The same connection speaks Events, Events Store, Queues, and RPC; the channel type you choose decides the delivery contract. The channels in the topology above are just KubeMQ channels of different types behind one address (`localhost:50000`).
Connecting and publishing a single message is the smallest possible "hello, broker." Here the Orders service sends one order event to a channel — it does not know or care who is listening.
```go title="publish.go"
package main
import (
"context"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
err = client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("order-placed").
SetBody([]byte(`{"orderId":"ORD-1234","status":"placed"}`)),
)
if err != nil {
log.Fatal(err)
}
log.Println("Message sent — Orders does not wait for any consumer")
}
```
```python title="publish.py"
from kubemq.pubsub import Client as PubSubClient
from kubemq.pubsub import EventMessage
client = PubSubClient(address="localhost:50000")
client.send_event(
EventMessage(
channel="order-placed",
body=b'{"orderId":"ORD-1234","status":"placed"}',
)
)
print("Message sent — Orders does not wait for any consumer")
client.close()
```
```javascript title="publish.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
await client.sendEvent({
channel: "order-placed",
body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "placed" })),
});
console.log("Message sent — Orders does not wait for any consumer");
```
```java title="Publish.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("orders-service")
.build();
client.sendEventsMessage(EventMessage.builder()
.channel("order-placed")
.body("{\"orderId\":\"ORD-1234\",\"status\":\"placed\"}".getBytes())
.build());
System.out.println("Message sent — Orders does not wait for any consumer");
client.close();
```
```csharp title="Publish.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
await client.SendEventAsync(new EventMessage
{
Channel = "order-placed",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"status\":\"placed\"}")
});
Console.WriteLine("Message sent — Orders does not wait for any consumer");
```
```kotlin title="Publish.kt"
val client = PubSubClient("localhost:50000")
client.sendEvent(EventMessage(
channel = "order-placed",
body = """{"orderId":"ORD-1234","status":"placed"}""".toByteArray()
))
println("Message sent — Orders does not wait for any consumer")
client.close()
```
```cpp title="publish.cpp"
#include
#include
auto client = kubemq::PubSubClient("localhost:50000");
kubemq::EventMessage event;
event.channel = "order-placed";
event.body = R"({"orderId":"ORD-1234","status":"placed"})";
client.sendEvent(event);
std::cout << "Message sent — Orders does not wait for any consumer" << std::endl;
```
```rust title="publish.rs"
use kubemq::prelude::*;
use kubemq::EventBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let event = EventBuilder::new()
.channel("order-placed")
.body(br#"{"orderId":"ORD-1234","status":"placed"}"#.to_vec())
.build();
client.send_event(event).await?;
println!("Message sent — Orders does not wait for any consumer");
client.close().await?;
Ok(())
}
```
```ruby title="publish.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "orders-service")
msg = KubeMQ::PubSub::EventMessage.new(
channel: "order-placed",
body: '{"orderId":"ORD-1234","status":"placed"}'
)
client.send_event(msg)
puts "Message sent — Orders does not wait for any consumer"
client.close
```
```elixir title="publish.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "orders-service")
event = KubeMQ.Event.new(channel: "order-placed", body: ~s({"orderId":"ORD-1234","status":"placed"}))
case KubeMQ.Client.send_event(client, event) do
:ok -> IO.puts("Message sent — Orders does not wait for any consumer")
{:error, err} -> IO.puts("Send failed: #{err.message}")
end
KubeMQ.Client.close(client)
```
That same `client` connection can also persist events, queue work for a single worker, or make a request and wait for a reply — each is one of the four patterns below.
### How KubeMQ does this → [#how-kubemq-does-this-]
## Where to go next [#where-to-go-next]
You now have the vocabulary: synchronous vs asynchronous, tight vs loose coupling, what a broker does, and why patterns differ. Next, learn the small set of shapes every messaging system reduces to — then how delivery, ordering, scaling, and routing actually work.
# Interaction Styles (/learn/concepts/interaction-styles)
Pick apart any messaging system and you find the same three conversation shapes underneath. A pattern's name and its bells and whistles vary, but the way a message travels from sender to receiver always reduces to one of three styles: **one-to-many** (pub/sub), **one-to-one-of-many** (point-to-point), or **there-and-back** (request/reply).
Think of how people communicate. A speaker at a conference addresses the whole room at once — everyone listening hears it (pub/sub). A help desk has a single ticket line feeding several agents — your ticket goes to whichever agent is free, and to exactly one of them (point-to-point). A phone call is a back-and-forth — you ask, you wait, you get an answer (request/reply). Learn these three shapes and every messaging pattern becomes a variation on a theme you already understand.
## Pub/Sub — one sender, many receivers [#pubsub--one-sender-many-receivers]
In **pub/sub** (publish/subscribe), a sender publishes a message to a named destination and **every** active receiver subscribed to that destination gets its own copy. The sender does not know or care who is listening — it could be zero receivers or a thousand. This shape is called **fan-out**: one message in, many copies out.
The sender and receivers are decoupled. You can add a new subscriber tomorrow without touching the publisher, and a slow or absent subscriber does not hold anyone else up.
*One published message is copied to every active subscriber — fan-out, one-to-many.*
**Reach for pub/sub when** every interested party needs the same message: broadcasting state changes, notifying multiple services of an event, feeding live dashboards, or invalidating caches across a fleet.
## Point-to-Point — one sender, one-of-many receivers [#point-to-point--one-sender-one-of-many-receivers]
In **point-to-point**, a sender puts a message on a shared queue and **exactly one** receiver consumes it. When several receivers read from the same queue, they form a pool of **competing consumers** — the queue hands each message to whichever consumer is free, spreading the work across all of them. One message in, delivered once, to one worker.
This is how you scale a workload horizontally. Add more workers and throughput goes up; each message is still processed exactly once, and no two workers do the same job.
*Each queued message goes to exactly one of the competing workers — load-balanced, one-to-one-of-many. The dotted line is the acknowledgment that removes the message.*
**Reach for point-to-point when** each message represents a unit of work that should be done once: order processing, background jobs, task distribution, or anything where you want to add workers to handle more load.
**Pub/sub vs point-to-point** is the most consequential choice you make. Pub/sub *copies* a message to everyone; point-to-point *hands* a message to one worker. Same starting point, opposite outcomes.
## Request/Reply — there and back [#requestreply--there-and-back]
In **request/reply**, a sender issues a request and **waits** for a response from a receiver before continuing. It is the synchronous shape: the round-trip is part of the flow, and the sender blocks (up to a timeout) until the answer arrives or it gives up. One request out, one matching response back.
Unlike the other two styles, the sender expects a reply and is coupled to it in time — if the receiver is down or slow, the sender waits. That tight coupling is the point: you want the result *now*, before moving on.
*The sender blocks until the response returns through the broker — synchronous, there-and-back. A request that returns data is a query; one that only confirms an action is a command.*
**Reach for request/reply when** the sender needs an answer to proceed: looking up data, calling a service-to-service API, confirming a write succeeded, or any classic remote-procedure call.
## The three styles at a glance [#the-three-styles-at-a-glance]
| | Pub/Sub | Point-to-Point | Request/Reply |
| ------------------------- | -------------------------------- | ------------------------------ | -------------------------------- |
| **Direction** | one → many | one → one-of-many | one ↔ one |
| **Receivers per message** | every subscriber | exactly one consumer | one responder |
| **Coupling** | loose (sender ignores receivers) | loose (sender ignores workers) | tight (sender waits for reply) |
| **Timing** | asynchronous | asynchronous | synchronous |
| **Adds receivers to…** | reach more listeners (fan-out) | share more work (scale) | distribute load behind one reply |
| **Typical use** | broadcasts, notifications | jobs, task queues | lookups, RPC, confirmations |
**Pitfall:** don't force a synchronous request/reply where a one-way style fits. Blocking a sender on a slow downstream service is a common cause of cascading timeouts — if you only need to *tell* someone something, publish an event or enqueue a job and move on.
## In KubeMQ [#in-kubemq]
KubeMQ implements all three interaction styles natively, so you choose the conversation shape rather than wiring it together yourself:
| Interaction style | KubeMQ pattern | Why |
| ------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pub/Sub (fan-out) | **Events** and **Events Store** | A publish is copied to every active subscriber on the channel. Events is at-most-once and in-memory; Events Store persists messages so subscribers can also replay. |
| Point-to-Point (competing consumers) | **Queues** | Each queued message is delivered to one consumer and removed on acknowledgment; multiple consumers on a channel compete for messages and share the load. |
| Request/Reply (round-trip) | **RPC** (Commands & Queries) | The sender blocks until a responder answers or the timeout expires. A Query returns a payload; a Command returns an execution acknowledgment. |
The snippets below show the *send* side of each style against `localhost:50000`, using the same e-commerce orders domain. They are deliberately minimal — see the pattern pages for full subscribe/receive/respond flows.
### Pub/Sub — publish an event [#pubsub--publish-an-event]
```go title="publish.go"
err = client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("order-events").
SetMetadata("order.created").
SetBody([]byte(`{"orderId":"ORD-1234","status":"created"}`)),
)
```
```python title="publish.py"
client.send_event(
EventMessage(
channel="order-events",
metadata="order.created",
body=b'{"orderId":"ORD-1234","status":"created"}',
)
)
```
```javascript title="publish.js"
await client.sendEvent({
channel: "order-events",
metadata: "order.created",
body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "created" })),
});
```
```java title="Publish.java"
client.sendEventsMessage(EventMessage.builder()
.channel("order-events")
.metadata("order.created")
.body("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}".getBytes())
.build());
```
```csharp title="Publish.cs"
await client.SendEventAsync(new EventMessage
{
Channel = "order-events",
Metadata = "order.created",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}")
});
```
```kotlin title="Publish.kt"
client.sendEvent(EventMessage(
channel = "order-events",
metadata = "order.created",
body = """{"orderId":"ORD-1234","status":"created"}""".toByteArray()
))
```
```cpp title="publish.cpp"
kubemq::EventMessage event;
event.channel = "order-events";
event.metadata = "order.created";
event.body = R"({"orderId":"ORD-1234","status":"created"})";
client.sendEvent(event);
```
```rust title="publish.rs"
let event = EventBuilder::new()
.channel("order-events")
.metadata("order.created")
.body(br#"{"orderId":"ORD-1234","status":"created"}"#.to_vec())
.build();
client.send_event(event).await?;
```
```ruby title="publish.rb"
msg = KubeMQ::PubSub::EventMessage.new(
channel: "order-events",
metadata: "order.created",
body: '{"orderId":"ORD-1234","status":"created"}'
)
client.send_event(msg)
```
```elixir title="publish.exs"
event = KubeMQ.Event.new(
channel: "order-events",
metadata: "order.created",
body: ~s({"orderId":"ORD-1234","status":"created"})
)
KubeMQ.Client.send_event(client, event)
```
### Point-to-Point — enqueue a job [#point-to-point--enqueue-a-job]
```go title="enqueue.go"
msg := kubemq.NewQueueMessage().
SetChannel("order-jobs").
SetBody([]byte(`{"orderId":"ORD-1234","total":99.99}`))
result, err := client.SendQueueMessage(ctx, msg)
```
```python title="enqueue.py"
result = client.send_queue_message(
QueueMessage(
channel="order-jobs",
body=b'{"orderId":"ORD-1234","total":99.99}',
)
)
```
```typescript title="enqueue.ts"
const result = await client.sendQueueMessage(
createQueueMessage({
channel: "order-jobs",
body: JSON.stringify({ orderId: "ORD-1234", total: 99.99 }),
}),
);
```
```java title="Enqueue.java"
SendQueueMessageResult result = client.sendQueueMessage(
QueueMessage.builder()
.channel("order-jobs")
.body("{\"orderId\":\"ORD-1234\",\"total\":99.99}".getBytes())
.build());
```
```csharp title="Enqueue.cs"
var result = await client.SendQueueMessageAsync(new QueueMessage
{
Channel = "order-jobs",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"total\":99.99}")
});
```
```kotlin title="Enqueue.kt"
val result = client.sendQueueMessage(QueueMessage(
channel = "order-jobs",
body = """{"orderId":"ORD-1234","total":99.99}""".toByteArray()
))
```
```cpp title="enqueue.cpp"
kubemq::QueueMessage msg;
msg.channel = "order-jobs";
msg.body = R"({"orderId":"ORD-1234","total":99.99})";
auto result = client.sendQueueMessage(msg);
```
```rust title="enqueue.rs"
let msg = QueueMessageBuilder::new()
.channel("order-jobs")
.body(br#"{"orderId":"ORD-1234","total":99.99}"#.to_vec())
.build();
let result = client.send_queue_message(msg).await?;
```
```ruby title="enqueue.rb"
msg = KubeMQ::Queues::QueueMessage.new(
channel: "order-jobs",
body: '{"orderId":"ORD-1234","total":99.99}'
)
result = client.send_queue_message(msg)
```
```elixir title="enqueue.exs"
msg = KubeMQ.QueueMessage.new(
channel: "order-jobs",
body: ~s({"orderId":"ORD-1234","total":99.99})
)
{:ok, result} = KubeMQ.Client.send_queue_message(client, msg)
```
### Request/Reply — send a command and wait [#requestreply--send-a-command-and-wait]
```go title="send_command.go"
resp, err := client.SendCommand(ctx, kubemq.NewCommand().
SetChannel("orders.process").
SetBody([]byte(`{"action":"create","orderId":"ORD-1234"}`)).
SetTimeout(10 * time.Second))
log.Printf("executed: %v", resp.Executed)
```
```python title="send_command.py"
response = client.send_command(
CommandMessage(
channel="orders.process",
body=b'{"action":"create","orderId":"ORD-1234"}',
timeout_in_seconds=10,
)
)
print(f"executed: {response.is_executed}")
```
```javascript title="send_command.js"
const response = await client.sendCommand({
channel: "orders.process",
body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-1234" })),
timeoutInSeconds: 10,
});
console.log("executed:", response.isExecuted);
```
```java title="SendCommand.java"
CommandResponseMessage response = client.sendCommandRequest(
CommandMessage.builder()
.channel("orders.process")
.body("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}".getBytes())
.timeout(10000)
.build());
System.out.println("executed: " + response.isExecuted());
```
```csharp title="SendCommand.cs"
var response = await client.SendCommandAsync(new CommandMessage
{
Channel = "orders.process",
Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}"),
Timeout = TimeSpan.FromSeconds(10)
});
Console.WriteLine($"executed: {response.IsExecuted}");
```
```kotlin title="SendCommand.kt"
val response = client.sendCommand(CommandMessage(
channel = "orders.process",
body = """{"action":"create","orderId":"ORD-1234"}""".toByteArray(),
timeout = 10000
))
println("executed: ${response.isExecuted}")
```
```cpp title="send_command.cpp"
kubemq::CommandMessage cmd;
cmd.channel = "orders.process";
cmd.body = R"({"action":"create","orderId":"ORD-1234"})";
cmd.timeout = 10000;
auto response = client.sendCommand(cmd);
std::cout << "executed: " << response.isExecuted << std::endl;
```
```rust title="send_command.rs"
let command = CommandBuilder::new()
.channel("orders.process")
.body(br#"{"action":"create","orderId":"ORD-1234"}"#.to_vec())
.timeout(Duration::from_secs(10))
.build();
let response = client.send_command(command).await?;
println!("executed: {}", response.executed);
```
```ruby title="send_command.rb"
msg = KubeMQ::CQ::CommandMessage.new(
channel: "orders.process",
timeout: 10,
body: '{"action":"create","orderId":"ORD-1234"}'
)
result = client.send_command(msg)
puts "executed: #{result.executed}"
```
```elixir title="send_command.exs"
command = KubeMQ.Command.new(
channel: "orders.process",
body: ~s({"action":"create","orderId":"ORD-1234"}),
timeout: 10_000
)
{:ok, response} = KubeMQ.Client.send_command(client, command)
IO.puts("executed: #{response.executed}")
```
### How KubeMQ does this → [#how-kubemq-does-this-]
# Ordering & Replay (/learn/concepts/ordering-and-replay)
Imagine a deli counter that hands out numbered tickets. Everyone is served in the order they arrived — ticket 41 before 42 before 43 — and the numbers never repeat or skip. Now imagine the deli also kept a logbook of every ticket it ever served. A clerk arriving for the late shift could open the book, find where the morning clerk left off, and pick up from exactly that ticket — no customer served twice, none missed.
Those two ideas — **serving in a fixed order** and **rewinding the log to any point** — are *ordering* and *replay*. They are what separate a fleeting stream of notifications from a durable, rebuildable record of what happened.
## Ordering — the idea [#ordering--the-idea]
**Ordering** is the guarantee about *what sequence consumers observe messages in*. The strongest common form is **FIFO** (first-in, first-out): messages come out in exactly the order they went in. A queue gives you this naturally — like a single-file line, the message enqueued first is delivered first.
*A FIFO queue delivers messages in the exact order they were enqueued.*
Global FIFO across an entire channel is simple but limits throughput — only one consumer can safely process at a time without reordering. In practice most systems offer **per-key ordering** instead: messages that share a *partition key* (an order ID, a user ID) stay strictly ordered relative to each other, while unrelated keys flow in parallel. You get order where it matters and parallelism everywhere else.
## Replay — the idea [#replay--the-idea]
A real-time channel is a PA announcement: hear it now or miss it forever. A **replayable** channel is a recording. To replay, the system has to do two things:
1. **Number every message** with a monotonically increasing **sequence number** (also called an **offset**) — a stable address for each message in the log.
2. **Persist the log** so messages survive after delivery, and let a consumer say *"start me at offset N"* instead of always "start me at the newest."
*Every message is numbered and written to a persistent log; a late subscriber rewinds to any offset and re-reads history, while a live subscriber follows the tail.*
Because the log keeps the sequence intact, replay and ordering reinforce each other: re-reading from offset 42 always returns 42, 43, 44… in the same order, every time. That determinism is what makes a log trustworthy as a system of record.
### The event-sourcing idea [#the-event-sourcing-idea]
If the log is the source of truth, you do not need to store the *current state* of anything — you can **rebuild it by replaying the events that produced it**. This is **event sourcing**: instead of saving "account balance = $80," you save the sequence of facts (`Deposited $100`, `Withdrew $20`) and replay them to compute the balance on demand. A new service, a rebuilt cache, or a bug fix that needs to reprocess history all start the same way: replay from the beginning.
**Concept:** A sequence number (offset) is just a message's permanent position in the log. "Replay" means asking the log to start delivering from a chosen position instead of from the newest message.
## Precise definition [#precise-definition]
* **Ordering** — a delivery guarantee that consumers observe messages in a defined sequence. **FIFO** orders an entire channel; **per-key ordering** orders only messages sharing a partition key, allowing parallel processing across keys.
* **Sequence number / offset** — a monotonically increasing integer assigned to each message as it is persisted, giving every message a stable, addressable position in the log.
* **Replay** — re-reading messages from a persisted log starting at a chosen **start position** (a sequence number, a timestamp, the first message, or the last), rather than receiving only messages published after subscribing.
* **Event sourcing** — modeling state as the ordered log of events that produced it, and reconstructing current state by replaying that log from the start.
## Trade-offs [#trade-offs]
| Property | When it helps | When it bites |
| ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| **Strict FIFO** | Steps that must happen in order (state machines, financial postings) | Caps throughput — one in-flight consumer per ordered stream |
| **Per-key ordering** | Order per entity (per order, per user) plus parallelism across entities | Requires choosing a good key; a hot key still serializes |
| **Persistent log + offsets** | Late joiners, audit trails, reprocessing, event sourcing | Costs disk and retention management; the log grows |
| **Replay from a position** | Recovery, backfills, rebuilding state from history | Re-delivering old events can re-trigger side effects if consumers are not idempotent |
**Pitfall:** Replaying history re-delivers messages a consumer may have already handled. If processing a message has side effects — charging a card, sending an email — make consumers **idempotent** (safe to run twice for the same message), keyed on the sequence number or a message ID. Otherwise a replay double-charges. See [delivery guarantees](/learn/concepts/delivery-guarantees) for idempotency.
## In KubeMQ [#in-kubemq]
**In KubeMQ:** **Queues** preserve FIFO order — messages are delivered in the order they were sent. **Events Store** persists every message with a **sequence number** and lets a subscriber choose a **start position**: `StartNewOnly`, `StartFromFirst`, `StartFromLast`, `StartAtSequence`, `StartAtTime`, or `StartAtTimeDelta`. Pointing a subscriber at `StartFromFirst` and rebuilding state from the result is exactly event sourcing.
The snippet below subscribes to a persisted channel from **sequence 3** — replaying every stored event at or after that offset, then streaming new ones as they arrive. Swapping `StartAtSequence` for `StartFromFirst` replays the entire history; `StartAtTimeDelta` replays a recent time window.
```go title="replay_from_sequence.go"
sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "",
kubemq.StartAtSequence(3),
kubemq.WithOnEvent(func(event *kubemq.Event) {
fmt.Printf("[replay] seq=%d body=%s\n",
event.Sequence, string(event.Body))
}),
kubemq.WithOnError(func(err error) { log.Println(err) }),
)
```
```python title="replay_from_sequence.py"
client.subscribe_to_events_store(
subscription=EventsStoreSubscription(
channel="orders.events",
start_position=EventStoreStartPosition.StartAtSequence,
start_position_value=3,
on_receive_event_callback=lambda e: print(
f"[replay] seq={e.sequence} body={e.body.decode('utf-8')}"
),
on_error_callback=lambda e: print(f"Error: {e}"),
),
cancel=CancellationToken(),
)
```
```typescript title="replay_from_sequence.ts"
client.subscribeToEventsStore({
channel: 'orders.events',
startPosition: EventStoreStartPosition.StartAtSequence,
startPositionValue: 3,
onEvent: (msg) =>
console.log(`[replay] seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`),
onError: (err) => console.error(err.message),
});
```
```java title="ReplayFromSequence.java"
client.subscribeToEventsStore(EventsStoreSubscription.builder()
.channel("orders.events")
.startPosition(EventStoreStartPosition.StartAtSequence)
.startPositionValue(3)
.onReceiveEventCallback(event ->
System.out.printf("[replay] seq=%d body=%s%n",
event.getSequence(), new String(event.getBody())))
.onErrorCallback(err -> System.err.println(err.getMessage()))
.build());
```
```csharp title="ReplayFromSequence.cs"
await foreach (var msg in client.SubscribeToEventsStoreAsync(
new EventsStoreSubscription
{
Channel = "orders.events",
StartPosition = EventStoreStartPosition.StartAtSequence,
StartPositionValue = 3,
}))
{
Console.WriteLine($"[replay] seq={msg.Sequence} body={Encoding.UTF8.GetString(msg.Body.Span)}");
}
```
```kotlin title="ReplayFromSequence.kt"
client.subscribeToEventsStore {
channel = "orders.events"
startPosition = StartPosition.StartAtSequence
startPositionValue = 3
}.collect { msg ->
println("[replay] seq=${msg.sequence} body=${String(msg.body)}")
}
```
```cpp title="replay_from_sequence.cc"
client->SubscribeToEventsStore("orders.events", "",
kubemq::StartPosition::StartAtSequence, 3,
[](const kubemq::EventStoreReceived& msg) {
std::cout << "[replay] seq=" << msg.sequence()
<< " body=" << msg.body() << std::endl;
},
[](const std::string& err) { std::cerr << err << std::endl; });
```
```rust title="replay_from_sequence.rs"
use kubemq::prelude::*;
use kubemq::EventsStoreSubscription;
let sub = client
.subscribe_to_events_store(
"orders.events",
"",
EventsStoreSubscription::StartAtSequence(3),
|event| {
Box::pin(async move {
println!(
"[replay] seq={} body={}",
event.sequence,
String::from_utf8_lossy(&event.body)
);
})
},
None,
)
.await?;
```
```ruby title="replay_from_sequence.rb"
sub = KubeMQ::PubSub::EventsStoreSubscription.new(
channel: "orders.events",
start_position: KubeMQ::PubSub::EventStoreStartPosition::START_AT_SEQUENCE,
start_position_value: 3
)
client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e|
puts "Error: #{e.message}"
}) do |event|
puts "[replay] seq=#{event.sequence} body=#{event.body}"
end
```
```elixir title="replay_from_sequence.exs"
{:ok, sub} =
KubeMQ.Client.subscribe_to_events_store(client, "orders.events",
start_at: {:start_at_sequence, 3},
on_event: fn event ->
IO.puts("[replay] seq #{event.sequence}: #{event.body}")
end
)
```
Queues deliver in FIFO order with no start-position parameter — order is inherent to the queue. The start positions above apply to **Events Store**, where the persistent log makes any offset addressable.
### How KubeMQ does this → [#how-kubemq-does-this-]
# Scaling & Flow Control (/learn/concepts/scaling-and-flow)
When work piles up faster than one worker can handle it, you have two completely different levers — and reaching for the wrong one quietly breaks your system. This page is about telling them apart: **scaling out** (adding workers to share the load) and **flow control** (keeping a fast producer from drowning a slow consumer).
Picture a busy coffee shop. To serve more customers, you put more baristas behind **one** counter — each drink order goes to whichever barista is free. That is scaling by *competing consumers*. Now picture the shop's radio: every barista hears the same announcement, no matter how many you hire. That is *fan-out*. Adding baristas speeds up the counter; it does nothing to the radio. Confusing the two is how you end up processing every order three times — or building a "load balancer" that never balances.
## Competing consumers vs fan-out — the idea [#competing-consumers-vs-fan-out--the-idea]
Both shapes start with one stream of messages and several consumers. The difference is **who gets each message**.
**Competing consumers** (point-to-point): the consumers share a single logical destination, and each message is handed to **exactly one** of them. Add a consumer and total throughput goes up, because the work is split. This is how you scale processing.
**Fan-out** (pub/sub): every consumer is an independent subscriber, and each message is **copied to all** of them. Add a subscriber and you get one more *full copy* of the stream — useful for independent reactions, not for sharing load.
*Competing consumers: one destination, each message to exactly one worker — add workers to share the load.*
*Fan-out: one channel, every subscriber gets its own copy — add subscribers for independent reactions, not for throughput.*
### Consumer groups: getting both from one channel [#consumer-groups-getting-both-from-one-channel]
What if you want load-balancing **and** broadcast on the same channel? A **consumer group** is the bridge. Each consumer declares a group name when it subscribes. Within a group, members compete — a message goes to exactly one of them. Across groups, each group gets its own copy.
So three workers in group `billing` split the stream between them, while a separate `analytics` group (its own members) receives the full stream in parallel. One channel, two behaviors, decided entirely by group membership.
The group mechanics on this page apply to both Events and Events Store, but durability does not: **Events** groups are fire-and-forget — a member that is offline simply misses messages, with nothing to replay. **Events Store** groups are durable and position-tracked — the store remembers each group's progress, so a member that reconnects resumes from where it left off instead of losing messages. The example below uses plain Events; see [In KubeMQ](#in-kubemq) for the Events Store distinction in full.
*Group `billing` competes for messages (load-balanced); group `analytics` gets its own full copy — group name decides the behavior.*
## Backpressure and slow consumers [#backpressure-and-slow-consumers]
Scaling out assumes consumers can keep up. When they cannot — a producer bursts, a downstream API slows down, a worker stalls on a long job — the system needs a way to push back. That feedback is **backpressure**: signalling upstream to slow down (or buffer, or shed load) so a fast producer does not overwhelm a slow consumer.
Without backpressure, the gap has to go *somewhere*, and every option is bad: an unbounded in-memory buffer grows until the process runs out of memory; a fixed buffer overflows and silently drops messages; or the producer blocks and the whole pipeline stalls. The healthy outcome is the producer feeling resistance and easing off — exactly like water pressure backing up a pipe when the drain is too small.
*A buffer absorbs short bursts; when it fills, backpressure flows back to the producer so it eases off instead of overflowing.*
A durable queue is itself a form of backpressure-by-buffering: it absorbs bursts on disk so producers never block on slow consumers, and you drain the backlog by adding more competing workers. A broadcast (fire-and-forget) channel has no such buffer — a subscriber that cannot keep up simply misses messages.
### Visibility timeout and in-flight messages [#visibility-timeout-and-in-flight-messages]
There is a subtler flow-control problem hiding inside competing consumers: what happens to a message **while** a worker is processing it? If the destination handed the same message to a second worker, you would process it twice. If it deleted the message immediately on delivery, a crash mid-processing would lose it.
The standard answer is the **visibility timeout**. When a worker receives a message, the message is not deleted — it is hidden from other consumers for a bounded window and counts as **in-flight**. The worker has until the timeout to finish and acknowledge (ack), which deletes it. If the worker crashes or the timeout expires first, the message becomes visible again and is redelivered to another worker.
*Visibility timeout: a received message is in-flight and hidden; ack within the window deletes it, otherwise it reappears for another worker.*
The timeout is a balance. Too short, and a legitimately slow job gets redelivered (and processed twice) before it finishes. Too long, and a crashed worker's messages sit invisible for ages before anyone retries them. The limit on in-flight messages also caps real concurrency: a destination only lets so many messages be in-flight at once, which is itself a backpressure knob.
## Precise definitions [#precise-definitions]
* **Competing consumers (point-to-point):** a distribution model where multiple consumers read from one shared destination and each message is delivered to exactly one consumer. Throughput scales with the number of consumers.
* **fan-out (pub/sub):** a distribution model where each message is copied to every independent subscriber on a channel. Adding subscribers adds parallel copies, not shared load.
* **Consumer Group:** a named set of consumers on one channel that compete as a unit — each message goes to one member of the group, while every group on the channel receives its own copy.
* **backpressure:** flow control that signals a producer to slow down (or buffer, or shed) when consumers cannot keep up, preventing overflow and loss.
* **Visibility timeout:** the bounded window during which a received-but-unacknowledged message is hidden from other consumers and counts as in-flight; on expiry without an ack it is redelivered.
* **In-flight message:** a message that has been delivered to a consumer but not yet acknowledged — held, not deleted, so it can be redelivered if processing fails.
## Trade-offs [#trade-offs]
| Goal | Reach for | Why | Watch out for |
| ------------------------------- | ----------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
| Process more, faster | Competing consumers / a group | Each message done once; add workers to scale | One slow worker holds its in-flight messages until timeout |
| React independently in N places | fan-out / separate groups | Every consumer sees every message | Adds load, not throughput — N copies of the work |
| Absorb bursts without dropping | Durable queue (buffer) | Disk soaks up the spike; drain with more workers | Backlog grows if consumers stay too slow — monitor depth |
| Don't overwhelm a slow consumer | Backpressure | Producer eases off instead of overflowing | Fire-and-forget channels have no buffer — slow subscribers miss messages |
| Survive crashes mid-processing | Visibility timeout + ack | Unacked work reappears for another worker | Wrong timeout → double-processing (too short) or stalls (too long) |
**Pitfall — "scaling" a broadcast.** Adding subscribers to a fire-and-forget channel does **not** share the load: every subscriber still receives every message, so you multiply the work instead of dividing it. To actually scale processing, put the consumers in the same group so they compete for messages.
**Pitfall — designing for exactly-once consumers.** Visibility-timeout redelivery means a consumer can see the same message more than once (a slow job, a crash, an expired timeout). Make handlers **idempotent** so a redelivery is harmless rather than betting on never seeing a duplicate.
## In KubeMQ [#in-kubemq]
KubeMQ exposes both levers directly:
* **Fan-out vs competing consumers is one parameter.** On Events and Events Store, subscribers that pass the **same group name** compete (each message to one member); subscribers with **no group** (or different groups) each get a full copy. Same channel, different `group` argument.
* **Queues give you the buffer and the visibility timeout.** A queue durably stores messages, so producers never block on slow consumers — you scale by running more receivers, and each received message is hidden for its **visibility timeout** until you ack it, then redelivered if you don't.
The snippet below is the same load-balanced subscribe from the [Events Consumer Groups](/learn/events/tutorials/consumer-groups) tutorial: several consumers join one **group** on `order-events`, and KubeMQ delivers each event to exactly one of them. Drop the group name and the very same subscribers turn into fan-out.
```go title="grouped_worker.go"
sub, err := client.SubscribeToEvents(ctx, "order-events", "workers",
kubemq.WithOnEvent(func(event *kubemq.Event) {
fmt.Printf("Processing: %s\n", string(event.Body))
}),
)
if err != nil {
log.Fatal(err)
}
defer sub.Unsubscribe()
// Members sharing group "workers" compete; pass "" for fan-out instead.
```
```python title="grouped_worker.py"
from kubemq.pubsub import Client as PubSubClient
from kubemq.pubsub import EventsSubscription, CancellationToken
client = PubSubClient(address="localhost:50000")
client.subscribe_to_events(
subscription=EventsSubscription(
channel="order-events",
group="workers", # same group -> competing consumers; omit for fan-out
on_receive_event_callback=lambda e: print(f"Processing: {e.body.decode()}"),
),
cancel=CancellationToken(),
)
```
```javascript title="grouped_worker.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
client.subscribeToEvents({
channel: "order-events",
group: "workers", // same group -> competing consumers; omit for fan-out
onEvent: (msg) =>
console.log(`Processing: ${Buffer.from(msg.body).toString()}`),
onError: (err) => console.error(err.message),
});
```
```java title="GroupedWorker.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("worker-1")
.build();
client.subscribeToEvents(EventsSubscription.builder()
.channel("order-events")
.group("workers") // same group -> competing consumers; omit for fan-out
.onReceiveEventCallback(event ->
System.out.printf("Processing: %s%n", new String(event.getBody())))
.build());
```
```csharp title="GroupedWorker.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
await foreach (var msg in client.SubscribeToEventsAsync(
new EventsSubscription { Channel = "order-events", Group = "workers" }))
{
// same Group -> competing consumers; leave Group unset for fan-out
Console.WriteLine($"Processing: {Encoding.UTF8.GetString(msg.Body.Span)}");
}
```
```kotlin title="GroupedWorker.kt"
val client = PubSubClient("localhost:50000")
client.subscribeToEvents(
channel = "order-events",
group = "workers", // same group -> competing consumers; omit for fan-out
onEvent = { event -> println("Processing: ${String(event.body)}") },
onError = { err -> System.err.println(err.message) },
)
```
```cpp title="grouped_worker.cpp"
auto client = kubemq::PubSubClient("localhost:50000");
// same group ("workers") -> competing consumers; pass "" for fan-out
client.subscribeToEvents("order-events", "workers",
[](const kubemq::Event& event) {
std::cout << "Processing: " << event.body << std::endl;
},
[](const std::string& err) { std::cerr << err << std::endl; }
);
```
```rust title="grouped_worker.rs"
let group = "workers"; // same group -> competing consumers; "" for fan-out
let sub = client
.subscribe_to_events(
"order-events",
group,
|event| {
Box::pin(async move {
println!("Processing: {}", String::from_utf8_lossy(&event.body));
})
},
None,
)
.await?;
```
```ruby title="grouped_worker.rb"
client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "worker-1")
cancel = KubeMQ::CancellationToken.new
# group: -> competing consumers; omit group: for fan-out
sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-events", group: "workers")
client.subscribe_to_events(sub, cancellation_token: cancel) do |event|
puts "Processing: #{event.body}"
end
```
```elixir title="grouped_worker.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "worker-1")
# group: -> competing consumers; omit group: for fan-out
{:ok, _sub} =
KubeMQ.Client.subscribe_to_events(client, "order-events",
group: "workers",
on_event: fn event -> IO.puts("Processing: #{event.body}") end
)
```
The same group switch applies to **Events Store** (durable, position-tracked groups) and to **Queues**, where competing receivers share the buffer and each delivery is governed by a visibility timeout you ack to clear.
### How KubeMQ does this [#how-kubemq-does-this]
# Getting Started with Events (/learn/events/getting-started)
**Prerequisites:** KubeMQ server running on `localhost:50000` and your SDK installed. See [Getting Started](/deploy) for setup.
## What You Will Build [#what-you-will-build]
A notification publisher that sends order events and a subscriber that receives them in real time.
The publisher fans out one event to every connected subscriber. Because Events are **at-most-once**, a subscriber that is offline (Subscriber C) misses the message — there is no replay.
## Steps [#steps]
### Install the SDK [#install-the-sdk]
```bash
go get github.com/kubemq-io/kubemq-go/v2
```
```bash
pip install kubemq
```
```bash
npm install kubemq-js
```
```xml
io.kubemq.sdk
kubemq-sdk-Java
3.1.1
```
```xml
```
```kotlin
implementation("io.kubemq.sdk:kubemq-sdk-kotlin:1.0.1")
```
```bash
vcpkg install kubemq
```
```toml
[dependencies]
kubemq = "1.0"
tokio = { version = "1", features = ["full"] }
```
```bash
gem install kubemq
```
```elixir
# mix.exs
def deps do
[{:kubemq, "~> 1.0"}]
end
```
### Create a Subscriber [#create-a-subscriber]
Start the subscriber first. Events are fire-and-forget, so the subscriber must be connected before the publisher sends events.
```go title="subscriber.go"
package main
import (
"context"
"fmt"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
sub, err := client.SubscribeToEvents(ctx, "order-notifications", "",
kubemq.WithOnEvent(func(event *kubemq.Event) {
fmt.Printf("Received: %s\n", string(event.Body))
}),
kubemq.WithOnError(func(err error) {
log.Println("Subscription error:", err)
}),
)
if err != nil {
log.Fatal(err)
}
defer sub.Unsubscribe()
log.Println("Subscriber listening on 'order-notifications'...")
<-ctx.Done()
}
```
```python title="subscriber.py"
import time
from kubemq.pubsub import Client as PubSubClient
from kubemq.pubsub import EventsSubscription, CancellationToken
def on_event(event):
print(f"Received: {event.body.decode('utf-8')}")
def on_error(err):
print(f"Subscription error: {err}")
client = PubSubClient(address="localhost:50000")
cancel = CancellationToken()
client.subscribe_to_events(
subscription=EventsSubscription(
channel="order-notifications",
on_receive_event_callback=on_event,
on_error_callback=on_error,
),
cancel=cancel,
)
print("Subscriber listening on 'order-notifications'...")
time.sleep(120)
client.close()
```
```javascript title="subscriber.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
client.subscribeToEvents({
channel: "order-notifications",
onEvent: (msg) =>
console.log(`Received: ${Buffer.from(msg.body).toString()}`),
onError: (err) => console.error("Subscription error:", err.message),
});
console.log("Subscriber listening on 'order-notifications'...");
```
```java title="Subscriber.java"
import io.kubemq.sdk.pubsub.PubSubClient;
import io.kubemq.sdk.pubsub.EventsSubscription;
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("order-subscriber")
.build();
client.subscribeToEvents(EventsSubscription.builder()
.channel("order-notifications")
.onReceiveEventCallback(event ->
System.out.println("Received: " + new String(event.getBody())))
.onErrorCallback(err ->
System.err.println("Subscription error: " + err.getMessage()))
.build());
System.out.println("Subscriber listening on 'order-notifications'...");
Thread.sleep(120_000);
client.close();
```
```csharp title="Subscriber.cs"
using KubeMQ.Sdk.Client;
using KubeMQ.Sdk.Events;
using System.Text;
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
Console.WriteLine("Subscriber listening on 'order-notifications'...");
await foreach (var msg in client.SubscribeToEventsAsync(
new EventsSubscription { Channel = "order-notifications" }))
{
Console.WriteLine($"Received: {Encoding.UTF8.GetString(msg.Body.Span)}");
}
```
```kotlin title="Subscriber.kt"
import io.kubemq.sdk.client.KubeMQClient
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val pubsub = KubeMQClient.pubSub {
address = "localhost:50000"
clientId = "order-subscriber"
}
println("Subscriber listening on 'order-notifications'...")
pubsub.subscribeToEvents {
channel = "order-notifications"
}.collect { event ->
println("Received: ${String(event.body)}")
}
}
```
```cpp title="subscriber.cpp"
#include
#include
auto client = kubemq::PubSubClient("localhost:50000");
client.subscribeToEvents("order-notifications", "",
[](const kubemq::Event& event) {
std::cout << "Received: " << event.body << std::endl;
},
[](const std::string& err) {
std::cerr << "Subscription error: " << err << std::endl;
}
);
std::cout << "Subscriber listening on 'order-notifications'..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(120));
```
```rust title="subscriber.rs"
use kubemq::prelude::*;
use kubemq::Subscription;
use std::time::Duration;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let sub: Subscription = client
.subscribe_to_events(
"order-notifications",
"",
|event| {
Box::pin(async move {
println!("Received: {}", String::from_utf8_lossy(&event.body));
})
},
None,
)
.await?;
println!("Subscriber listening on 'order-notifications'...");
tokio::time::sleep(Duration::from_secs(120)).await;
sub.unsubscribe().await;
client.close().await?;
Ok(())
}
```
```ruby title="subscriber.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-subscriber")
cancel = KubeMQ::CancellationToken.new
sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-notifications")
client.subscribe_to_events(sub, cancellation_token: cancel,
on_error: ->(e) { puts "Subscription error: #{e.message}" }) do |event|
puts "Received: #{event.body}"
end
puts "Subscriber listening on 'order-notifications'..."
sleep 120
cancel.cancel
client.close
```
```elixir title="subscriber.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-subscriber")
{:ok, sub} =
KubeMQ.Client.subscribe_to_events(client, "order-notifications",
on_event: fn event -> IO.puts("Received: #{event.body}") end,
on_error: fn err -> IO.puts("Subscription error: #{err.message}") end
)
IO.puts("Subscriber listening on 'order-notifications'...")
Process.sleep(120_000)
KubeMQ.Subscription.cancel(sub)
KubeMQ.Client.close(client)
```
### Create a Publisher [#create-a-publisher]
In a separate terminal, run the publisher. The subscriber receives the event instantly.
```go title="publisher.go"
package main
import (
"context"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
err = client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("order-notifications").
SetBody([]byte(`{"orderId":"ORD-1234","status":"created"}`)),
)
if err != nil {
log.Fatal(err)
}
log.Println("Event published to 'order-notifications'")
}
```
```python title="publisher.py"
from kubemq.pubsub import Client as PubSubClient
from kubemq.pubsub import EventMessage
client = PubSubClient(address="localhost:50000")
client.send_event(
EventMessage(
channel="order-notifications",
body=b'{"orderId":"ORD-1234","status":"created"}',
)
)
print("Event published to 'order-notifications'")
client.close()
```
```javascript title="publisher.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
await client.sendEvent({
channel: "order-notifications",
body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "created" })),
});
console.log("Event published to 'order-notifications'");
```
```java title="Publisher.java"
import io.kubemq.sdk.pubsub.PubSubClient;
import io.kubemq.sdk.pubsub.EventMessage;
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("order-publisher")
.build();
client.sendEventsMessage(EventMessage.builder()
.channel("order-notifications")
.body("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}".getBytes())
.build());
System.out.println("Event published to 'order-notifications'");
client.close();
```
```csharp title="Publisher.cs"
using KubeMQ.Sdk.Client;
using KubeMQ.Sdk.Events;
using System.Text;
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
await client.SendEventAsync(new EventMessage
{
Channel = "order-notifications",
Body = Encoding.UTF8.GetBytes(
"{\"orderId\":\"ORD-1234\",\"status\":\"created\"}")
});
Console.WriteLine("Event published to 'order-notifications'");
```
```kotlin title="Publisher.kt"
import io.kubemq.sdk.client.KubeMQClient
import io.kubemq.sdk.pubsub.eventMessage
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val pubsub = KubeMQClient.pubSub {
address = "localhost:50000"
clientId = "order-publisher"
}
pubsub.publishEvent(eventMessage {
channel = "order-notifications"
body = """{"orderId":"ORD-1234","status":"created"}""".toByteArray()
})
println("Event published to 'order-notifications'")
}
```
```cpp title="publisher.cpp"
#include
#include
auto client = kubemq::PubSubClient("localhost:50000");
kubemq::EventMessage event;
event.channel = "order-notifications";
event.body = R"({"orderId":"ORD-1234","status":"created"})";
client.sendEvent(event);
std::cout << "Event published to 'order-notifications'" << std::endl;
```
```rust title="publisher.rs"
use kubemq::prelude::*;
use kubemq::EventBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let event = EventBuilder::new()
.channel("order-notifications")
.body(br#"{"orderId":"ORD-1234","status":"created"}"#.to_vec())
.build();
client.send_event(event).await?;
println!("Event published to 'order-notifications'");
client.close().await?;
Ok(())
}
```
```ruby title="publisher.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-publisher")
client.send_event(KubeMQ::PubSub::EventMessage.new(
channel: "order-notifications",
body: '{"orderId":"ORD-1234","status":"created"}'
))
puts "Event published to 'order-notifications'"
client.close
```
```elixir title="publisher.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher")
event = KubeMQ.Event.new(
channel: "order-notifications",
body: ~s({"orderId":"ORD-1234","status":"created"})
)
:ok = KubeMQ.Client.send_event(client, event)
IO.puts("Event published to 'order-notifications'")
KubeMQ.Client.close(client)
```
### Run the Example [#run-the-example]
1. Start the **subscriber** in one terminal
2. Run the **publisher** in a separate terminal
3. The subscriber receives the event in real time
### Verify [#verify]
The subscriber terminal should display:
```text
Subscriber listening on 'order-notifications'...
Received: {"orderId":"ORD-1234","status":"created"}
```
The publisher terminal should display:
```text
Event published to 'order-notifications'
```
## What Just Happened [#what-just-happened]
1. The **subscriber** connected to KubeMQ and registered interest in `order-notifications`
2. The **publisher** sent an event containing an order payload to the same channel
3. KubeMQ delivered the event to the subscriber in real time
4. Because Events are fire-and-forget, the publisher does not wait for acknowledgment
Events have **at-most-once** delivery. If the subscriber was not connected when the event was published, the message would be lost. For persistent delivery with replay, use [Events Store](/learn/events-store/getting-started).
## Next Steps [#next-steps]
# Events — Real-Time Pub/Sub (/learn/events)
Think of Events like a PA system in a building — when an announcement is made, everyone currently listening hears it. If you step outside, you miss the announcement. There's no recording, no replay.
KubeMQ Events implement fire-and-forget publish/subscribe with at-most-once delivery. Publishers send messages to a named channel, and all active subscribers receive the message in real time. There is no persistence — if a subscriber is offline, the message is lost for that subscriber.
## The concept it implements [#the-concept-it-implements]
Events is KubeMQ's implementation of two fundamental ideas. The interaction style is **[pub/sub](/learn/concepts/interaction-styles)** (fan-out) — one publisher, many subscribers, each receiving every message. The delivery guarantee is **[at-most-once](/learn/concepts/delivery-guarantees)** — messages reach only the subscribers connected at publish time and are never persisted or redelivered. New to these terms? Start with the [Fundamentals](/learn/concepts) track.
## Key Properties [#key-properties]
| Property | This pattern | Learn the concept |
| ------------------ | --------------------------------------------- | ---------------------------------------------------------- |
| Interaction style | pub/sub (fan-out) | [Interaction styles](/learn/concepts/interaction-styles) |
| Delivery guarantee | at-most-once | [Delivery guarantees](/learn/concepts/delivery-guarantees) |
| Persistence | None — fire-and-forget | [Ordering & replay](/learn/concepts/ordering-and-replay) |
| Ordering | Not guaranteed | [Ordering & replay](/learn/concepts/ordering-and-replay) |
| Scaling | Fan-out, or load-balance with consumer groups | [Scaling & flow](/learn/concepts/scaling-and-flow) |
| Addressing | Named channels, wildcards, multicast routing | [Channels & routing](/learn/concepts/channels-and-routing) |
## Key Features [#key-features]
* **At-most-once delivery** — messages delivered to active subscribers only
* **Lowest latency** — no disk I/O or acknowledgment overhead
* **Multicast delivery** — every subscriber on the channel receives every message (fan-out)
* **Channel groups** — load balance across subscribers in a named group
* **Multicast routing** — publish to multiple channels using routing syntax
* **Stream publishing** — high-throughput batched delivery via bidirectional streaming
## How It Works [#how-it-works]
*Fan-out: a publisher broadcasts to the channel, every connected subscriber receives the message, and offline subscribers miss it.*
## Quick Example [#quick-example]
```go title="publish.go"
package main
import (
"context"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
err = client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("order-notifications").
SetMetadata("order.created").
SetBody([]byte(`{"orderId":"ORD-1234","status":"created"}`)),
)
if err != nil {
log.Fatal(err)
}
log.Println("Event sent successfully")
}
```
```python title="publish.py"
from kubemq.pubsub import Client as PubSubClient
from kubemq.pubsub import EventMessage
client = PubSubClient(address="localhost:50000")
client.send_event(
EventMessage(
channel="order-notifications",
metadata="order.created",
body=b'{"orderId":"ORD-1234","status":"created"}',
)
)
print("Event sent successfully")
client.close()
```
```javascript title="publish.js"
import { KubeMQClient } from 'kubemq-js';
const client = await KubeMQClient.create({ address: 'localhost:50000' });
await client.sendEvent({
channel: "order-notifications",
metadata: "order.created",
body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "created" })),
});
console.log("Event sent successfully");
```
```java title="Publish.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("order-publisher")
.build();
client.sendEventsMessage(EventMessage.builder()
.channel("order-notifications")
.metadata("order.created")
.body("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}".getBytes())
.build());
System.out.println("Event sent successfully");
client.close();
```
```csharp title="Publish.cs"
using KubeMQ.Sdk.Client;
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
await client.SendEventAsync(new EventMessage
{
Channel = "order-notifications",
Metadata = "order.created",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}")
});
Console.WriteLine("Event sent successfully");
```
```kotlin title="Publish.kt"
val client = PubSubClient("localhost:50000")
client.sendEvent(EventMessage(
channel = "order-notifications",
metadata = "order.created",
body = """{"orderId":"ORD-1234","status":"created"}""".toByteArray()
))
println("Event sent successfully")
client.close()
```
```cpp title="publish.cpp"
#include
auto client = kubemq::PubSubClient("localhost:50000");
kubemq::EventMessage event;
event.channel = "order-notifications";
event.metadata = "order.created";
event.body = R"({"orderId":"ORD-1234","status":"created"})";
client.sendEvent(event);
std::cout << "Event sent successfully" << std::endl;
```
```rust title="publish.rs"
use kubemq::prelude::*;
use kubemq::EventBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let event = EventBuilder::new()
.channel("order-notifications")
.metadata("order.created")
.body(br#"{"orderId":"ORD-1234","status":"created"}"#.to_vec())
.build();
client.send_event(event).await?;
println!("Event sent successfully");
client.close().await?;
Ok(())
}
```
```ruby title="publish.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-publisher")
client.send_event(KubeMQ::PubSub::EventMessage.new(
channel: "order-notifications",
metadata: "order.created",
body: '{"orderId":"ORD-1234","status":"created"}'
))
puts "Event sent successfully"
client.close
```
```elixir title="publish.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher")
event = KubeMQ.Event.new(
channel: "order-notifications",
metadata: "order.created",
body: ~s({"orderId":"ORD-1234","status":"created"})
)
:ok = KubeMQ.Client.send_event(client, event)
IO.puts("Event sent successfully")
KubeMQ.Client.close(client)
```
## When to Use Events [#when-to-use-events]
| Scenario | Events | Events Store |
| ----------------------- | ---------------------- | ----------------------------- |
| Real-time notifications | ✅ Best choice | Overkill |
| Log/metric streaming | ✅ Best choice | Use if logs must not be lost |
| Live dashboards | ✅ Best choice | Use if historical data needed |
| Cache invalidation | ✅ Best choice | Not needed |
| Audit trails | ❌ Messages can be lost | ✅ Use Events Store |
| Event sourcing | ❌ No persistence | ✅ Use Events Store |
Need guaranteed delivery or replay capability? Use [Events Store](/learn/events-store) instead.
Events are also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/events) — use any language with a CloudEvents SDK, no KubeMQ client library needed.
## Learn More [#learn-more]
# Events Reference (/learn/events/reference)
## Message Structure [#message-structure]
### Event Message (Send) [#event-message-send]
### Event Receive (Subscribe) [#event-receive-subscribe]
Unlike Events Store, plain Events do not include `Timestamp` or `Sequence` fields because there is no persistence layer.
### Send Result [#send-result]
## Subscription Options [#subscription-options]
### Consumer Groups [#consumer-groups]
When multiple subscribers specify the same `group` value on the same channel:
* Each event is delivered to exactly **one** member of the group (round-robin)
* When `group` is empty, every subscriber receives every event (standard fan-out)
* Groups are independent per channel
* There is no limit on the number of group members
See [Consumer Groups](/learn/events/tutorials/consumer-groups) and [Scale Subscribers](/learn/events/how-to/scale-subscribers) for examples.
## Channel Naming Rules [#channel-naming-rules]
| Rule | Constraint | Error Code |
| ---------------------- | ----------------------------------------- | ---------- |
| Required | Cannot be empty | 102 |
| No trailing dot | Cannot end with `.` | 119 |
| No whitespace | Cannot contain spaces | 108 |
| No wildcards (publish) | Cannot contain `*` or `>` when publishing | 107 |
| Max length | 256 characters | — |
**Valid channel regex (publish):** `^[^\s*>]+[^.]$`
Wildcards (`*` and `>`) are allowed in **subscription** channel patterns only. See [Wildcard Subscriptions](/learn/events/tutorials/wildcard-subscriptions).
### Channel Naming Conventions [#channel-naming-conventions]
Use dot-separated hierarchical names for best results with wildcard subscriptions:
```text
{domain}.{entity}.{action}
orders.created
orders.us-east.shipped
payments.completed
inventory.reserved
```
## Routing (Multicast) [#routing-multicast]
Events support multicast publishing through special channel syntax:
| Character | Purpose | Example |
| --------- | ---------------------------------- | ---------------------------------- |
| `;` | Separate channels of the same type | `orders;notifications` |
| `:` | Specify target pattern type | `events:orders;events_store:audit` |
### Pattern Type Prefixes [#pattern-type-prefixes]
| Prefix | Target Pattern |
| --------------- | ---------------------------- |
| `events:` | Events (fire-and-forget) |
| `events_store:` | Events Store (persistent) |
| `queues:` | Queues (guaranteed delivery) |
Routed messages are automatically tagged with `X-KUBEMQ-ROUTED=true`. See [Multicast Events](/learn/events/tutorials/multicast).
## Transport Protocols [#transport-protocols]
### gRPC [#grpc]
* **Publish:** `SendEvent(pb.Event)` — unary RPC
* **Publish Stream:** `SendEventsStream()` — bidirectional streaming for high-throughput publishing
* **Subscribe:** `SubscribeToEvents(pb.Subscribe)` — server-streaming RPC
* Default port: `50000`
* Max message size: \~1 GB (configurable)
### REST [#rest]
* **Publish:** `POST /send` with JSON body
* **Subscribe:** WebSocket upgrade for streaming delivery
* Default port: `9090`
* Max body size: 100 MB (configurable)
### WebSocket [#websocket]
* Publish and subscribe over persistent WebSocket connections
* JSON-encoded messages
* Read limit: 1 MB
## Configuration [#configuration]
### Server Configuration [#server-configuration]
| Setting | Default | Description |
| --------------------------- | ------- | --------------------- |
| `Connectors.Grpc.BodyLimit` | \~1 GB | Max gRPC message size |
| `Connectors.Rest.BodyLimit` | 100 MB | Max REST body size |
### Client Configuration [#client-configuration]
## Slow Consumer Handling [#slow-consumer-handling]
When a subscriber's receive buffer is full, KubeMQ waits up to the **write deadline** (default 2 seconds) for the buffer to clear. If the deadline expires:
* The event is **dropped** for that subscriber
* A warning is logged server-side with the channel, event ID, and metadata
* Other subscribers are not affected
See [Handle Slow Consumers](/learn/events/how-to/handle-slow-consumers) for mitigation strategies.
## Delivery Semantics [#delivery-semantics]
| Aspect | Behavior |
| ------------------ | ------------------------------------------------- |
| Delivery guarantee | **At-most-once** |
| Persistence | None — events flow through memory only |
| Ordering | Events are delivered in publish order per channel |
| Duplicates | No duplicates (single delivery attempt) |
| Acknowledgment | None — fire-and-forget |
| Retry | None — failed deliveries are not retried |
## Events vs Events Store [#events-vs-events-store]
| Feature | Events | Events Store |
| ---------------------- | ---------------------------------- | --------------------------------- |
| Persistence | No | Yes (disk-backed) |
| Replay | No | Yes (from offset, time, sequence) |
| Delivery guarantee | At-most-once | At-least-once |
| Wildcard subscriptions | Yes (`*`, `>`) | No |
| Consumer groups | Yes (round-robin) | Yes (durable) |
| Latency | Lowest | Slightly higher (disk write) |
| Use cases | Real-time notifications, streaming | Audit trails, event sourcing |
## Error Codes [#error-codes]
| Code | Error | Description |
| ---- | ------------------------- | ------------------------------------------ |
| 101 | Invalid ClientID | ClientID is empty |
| 102 | Invalid Channel | Channel is empty |
| 107 | Invalid Wildcards | Channel contains `*` or `>` (publish only) |
| 108 | Invalid Whitespace | Channel contains spaces |
| 110 | Invalid Message | Both `Body` and `Metadata` are empty |
| 119 | Invalid Channel Separator | Channel ends with `.` |
### Runtime Errors [#runtime-errors]
| Error | Cause | Resolution |
| -------------------------- | ------------------------------------ | ---------------------------------------- |
| `ErrShutdownMode` | Server is shutting down | Reconnect after server restart |
| `ErrConnectionNoAvailable` | broker connection is down | SDK auto-reconnects; check server health |
| Authorization denied | Casbin policy rejected the operation | Verify client permissions |
## SDK Quick Reference [#sdk-quick-reference]
```go
// Publish
client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("ch").SetBody([]byte("data")))
// Subscribe
client.SubscribeToEvents(ctx, "ch", "group",
kubemq.WithOnEvent(handler),
kubemq.WithOnError(errHandler))
// Stream Publish
streamCh := make(chan *kubemq.Event, 100)
resultCh := make(chan *kubemq.EventSendResult, 100)
go client.StreamEvents(ctx, streamCh, resultCh)
```
```python
# Publish
client.send_event(EventMessage(channel="ch", body=b"data"))
# Subscribe
client.subscribe_to_events(
EventsSubscription(channel="ch", group="group",
on_receive_event_callback=handler,
on_error_callback=err_handler),
cancel=CancellationToken())
# Stream Publish
stream = client.open_events_stream()
stream.send(EventMessage(channel="ch", body=b"data"))
```
```javascript
// Publish
await client.sendEvent({ channel: "ch", body: Buffer.from("data") });
// Subscribe
client.subscribeToEvents({
channel: "ch", group: "group",
onEvent: handler, onError: errHandler
});
// Stream Publish
const stream = client.createEventStream();
stream.send({ channel: "ch", body: Buffer.from("data") });
```
```java
// Publish
client.sendEventsMessage(EventMessage.builder()
.channel("ch").body("data".getBytes()).build());
// Subscribe
client.subscribeToEvents(EventsSubscription.builder()
.channel("ch").group("group")
.onReceiveEventCallback(handler)
.onErrorCallback(errHandler).build());
// Stream Publish
EventsStream stream = client.openEventsStream();
stream.send(EventMessage.builder()
.channel("ch").body("data".getBytes()).build());
```
```csharp
// Publish
await client.SendEventAsync(new EventMessage {
Channel = "ch", Body = Encoding.UTF8.GetBytes("data") });
// Subscribe
await foreach (var msg in client.SubscribeToEventsAsync(
new EventsSubscription { Channel = "ch", Group = "group" })) { }
// Stream Publish
var stream = client.OpenEventsStream();
await stream.SendAsync(new EventMessage {
Channel = "ch", Body = Encoding.UTF8.GetBytes("data") });
```
```kotlin
// Publish
client.sendEvent(EventMessage(
channel = "ch", body = "data".toByteArray()))
// Subscribe
client.subscribeToEvents(
channel = "ch", group = "group",
onEvent = handler, onError = errHandler)
// Stream Publish
val stream = client.openEventsStream()
stream.send(EventMessage(channel = "ch", body = "data".toByteArray()))
```
```cpp
// Publish
kubemq::EventMessage event;
event.channel = "ch";
event.body = "data";
client.sendEvent(event);
// Subscribe
client.subscribeToEvents("ch", "group", handler, errHandler);
// Stream Publish
auto stream = client.openEventsStream();
stream.send(event);
```
```rust
// Publish
let event = EventBuilder::new()
.channel("ch").body(b"data".to_vec()).build();
client.send_event(event).await?;
// Subscribe
let sub = client.subscribe_to_events("ch", "group",
|event| Box::pin(async move { handle(event).await }),
None).await?;
// Stream Publish
let mut stream = client.send_event_stream().await?;
stream.send(EventBuilder::new()
.channel("ch").body(b"data".to_vec()).build()).await?;
```
```ruby
# Publish
client.send_event(KubeMQ::PubSub::EventMessage.new(
channel: "ch", body: "data"))
# Subscribe
sub = KubeMQ::PubSub::EventsSubscription.new(channel: "ch", group: "group")
client.subscribe_to_events(sub, cancellation_token: cancel,
on_error: ->(e) { handle_error(e) }) { |event| handle(event) }
# Stream Publish
sender = client.create_events_sender
sender.publish(KubeMQ::PubSub::EventMessage.new(
channel: "ch", body: "data"))
```
```elixir
# Publish
event = KubeMQ.Event.new(channel: "ch", body: "data")
KubeMQ.Client.send_event(client, event)
# Subscribe
{:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "ch",
group: "group", on_event: fn event -> handle(event) end)
# Stream Publish
{:ok, handle} = KubeMQ.Client.send_event_stream(client)
KubeMQ.EventStreamHandle.send(handle,
KubeMQ.Event.new(channel: "ch", body: "data"))
```
## Related [#related]
* [Getting Started with Events](/learn/events/getting-started)
* [Events Store Reference](/learn/events-store/reference) for the persistent variant
* [Queues Reference](/learn/queues/reference) for guaranteed delivery
# Getting Started with Events Store (/learn/events-store/getting-started)
This guide walks you through publishing persistent events and subscribing with replay. By the end, you will see how Events Store preserves messages for subscribers that connect after the event was published.
This is the **quickstart** — a single publisher and subscriber in 5 minutes. For the deep-dive covering multiple subscriber types (audit replay vs. real-time-only) and durable subscriptions, see [Persistent Publish & Subscribe](/learn/events-store/tutorials/persistent-publish-subscribe).
## Prerequisites [#prerequisites]
* **KubeMQ server** running on `localhost:50000`
* One of the supported SDKs installed
Need to install KubeMQ? Run it with Docker in seconds:
## What You Will Build [#what-you-will-build]
An order tracking system where:
* A **publisher** stores order events to a persistent channel
* A **subscriber** connects later and replays the full order history using `StartFromFirst`
*Events are stored on publish; a later subscriber replays the full history, then continues live.*
## Step-by-Step Guide [#step-by-step-guide]
### Install the SDK [#install-the-sdk]
```bash
go get github.com/kubemq-io/kubemq-go/v2
```
```bash
pip install kubemq
```
```bash
npm install kubemq-js
```
```xml
io.kubemq.sdk
kubemq-sdk-Java
2.1.1
```
```bash
dotnet add package KubeMQ.SDK.CSharp
```
```kotlin
implementation("io.kubemq.sdk:kubemq-sdk-kotlin:2.1.0")
```
```bash
vcpkg install kubemq
```
```bash
cargo add kubemq
```
```bash
gem install kubemq
```
```elixir
# mix.exs
def deps do
[{:kubemq, "~> 1.0"}]
end
```
### Publish Persistent Events [#publish-persistent-events]
Publish several order events **before** starting the subscriber. With Events Store, messages are persisted and available for replay.
```go title="publisher.go"
package main
import (
"context"
"fmt"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
orders := []string{
`{"action":"order.created","orderId":"ORD-1001","total":149.99}`,
`{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}`,
`{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}`,
}
for i, body := range orders {
result, err := client.SendEventStore(ctx, kubemq.NewEvent().
SetChannel("orders.events").
SetBody([]byte(body)),
)
if err != nil {
log.Printf("Failed to store event %d: %v", i+1, err)
continue
}
log.Printf("Stored event %d: ID=%s", i+1, result.EventID)
}
}
```
```python title="publisher.py"
from kubemq import PubSubClient, EventStoreMessage
orders = [
b'{"action":"order.created","orderId":"ORD-1001","total":149.99}',
b'{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}',
b'{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}',
]
with PubSubClient(address="localhost:50000") as client:
for i, body in enumerate(orders, 1):
result = client.publish_event_store(
EventStoreMessage(channel="orders.events", body=body)
)
print(f"Stored event {i}: ID={result.id}")
```
```typescript title="publisher.ts"
import { KubeMQClient, createEventStoreMessage } from 'kubemq-js';
const client = await KubeMQClient.create({ address: 'localhost:50000' });
const orders = [
'{"action":"order.created","orderId":"ORD-1001","total":149.99}',
'{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}',
'{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}',
];
for (const [i, body] of orders.entries()) {
const result = await client.sendEventStore(
createEventStoreMessage({ channel: 'orders.events', body })
);
console.log(`Stored event ${i + 1}: ID=${result.id}`);
}
```
```java title="Publisher.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("order-publisher")
.build();
String[] orders = {
"{\"action\":\"order.created\",\"orderId\":\"ORD-1001\",\"total\":149.99}",
"{\"action\":\"order.paid\",\"orderId\":\"ORD-1001\",\"method\":\"credit_card\"}",
"{\"action\":\"order.shipped\",\"orderId\":\"ORD-1001\",\"carrier\":\"fedex\"}"
};
for (int i = 0; i < orders.length; i++) {
EventSendResult result = client.sendEventsStoreMessage(
EventStoreMessage.builder()
.channel("orders.events")
.body(orders[i].getBytes())
.build());
System.out.printf("Stored event %d: ID=%s%n", i + 1, result.getId());
}
client.close();
```
```csharp title="Publisher.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
string[] orders = {
"{\"action\":\"order.created\",\"orderId\":\"ORD-1001\",\"total\":149.99}",
"{\"action\":\"order.paid\",\"orderId\":\"ORD-1001\",\"method\":\"credit_card\"}",
"{\"action\":\"order.shipped\",\"orderId\":\"ORD-1001\",\"carrier\":\"fedex\"}"
};
for (var i = 0; i < orders.Length; i++)
{
var result = await client.SendEventStoreAsync(new EventStoreMessage
{
Channel = "orders.events",
Body = Encoding.UTF8.GetBytes(orders[i]),
});
Console.WriteLine($"Stored event {i + 1}: ID={result.Id}");
}
```
```kotlin title="Publisher.kt"
val client = KubeMQClient.pubSub {
address = "localhost:50000"
clientId = "order-publisher"
}
val orders = listOf(
"""{"action":"order.created","orderId":"ORD-1001","total":149.99}""",
"""{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}""",
"""{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}""",
)
client.use {
orders.forEachIndexed { i, body ->
val result = client.sendEventStore(eventStoreMessage {
channel = "orders.events"
this.body = body.toByteArray()
})
println("Stored event ${i + 1}: ID=${result.id}")
}
}
```
```cpp title="publisher.cc"
kubemq::ClientOptions options;
options.set_address("localhost", 50000);
options.set_client_id("order-publisher");
auto client = kubemq::Client::Create(options).value();
std::vector orders = {
R"({"action":"order.created","orderId":"ORD-1001","total":149.99})",
R"({"action":"order.paid","orderId":"ORD-1001","method":"credit_card"})",
R"({"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"})"
};
for (size_t i = 0; i < orders.size(); ++i) {
kubemq::EventStoreMessage msg;
msg.set_channel("orders.events");
msg.set_body(orders[i]);
auto result = client->SendEventStore(msg);
if (result.ok()) {
std::cout << "Stored event " << i + 1 << ": ID=" << result->id() << std::endl;
}
}
```
```rust title="publisher.rs"
use kubemq::prelude::*;
use kubemq::EventStoreBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let orders = [
r#"{"action":"order.created","orderId":"ORD-1001","total":149.99}"#,
r#"{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}"#,
r#"{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}"#,
];
for (i, body) in orders.iter().enumerate() {
let event = EventStoreBuilder::new()
.channel("orders.events")
.body(body.as_bytes().to_vec())
.build();
let result = client.send_event_store(event).await?;
println!("Stored event {}: id={}", i + 1, result.id);
}
client.close().await?;
Ok(())
}
```
```ruby title="publisher.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-publisher')
orders = [
'{"action":"order.created","orderId":"ORD-1001","total":149.99}',
'{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}',
'{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}'
]
orders.each_with_index do |body, i|
msg = KubeMQ::PubSub::EventStoreMessage.new(channel: 'orders.events', body: body)
result = client.send_event_store(msg)
puts "Stored event #{i + 1}: sent=#{result.sent}"
end
client.close
```
```elixir title="publisher.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher")
orders = [
~s({"action":"order.created","orderId":"ORD-1001","total":149.99}),
~s({"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}),
~s({"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"})
]
orders
|> Enum.with_index(1)
|> Enum.each(fn {body, i} ->
event = KubeMQ.EventStore.new(channel: "orders.events", body: body)
{:ok, result} = KubeMQ.Client.send_event_store(client, event)
IO.puts("Stored event #{i}: sent=#{result.sent}")
end)
KubeMQ.Client.close(client)
```
### Subscribe with Replay from Beginning [#subscribe-with-replay-from-beginning]
Start the subscriber **after** all events have been published. Using `StartFromFirst`, it replays the entire history.
```go title="subscriber.go"
package main
import (
"context"
"fmt"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "",
kubemq.StartFromFirst(),
kubemq.WithOnEvent(func(event *kubemq.Event) {
fmt.Printf("Received: seq=%d body=%s\n",
event.Sequence, string(event.Body))
}),
kubemq.WithOnError(func(err error) {
log.Println("Error:", err)
}),
)
if err != nil {
log.Fatal(err)
}
defer sub.Unsubscribe()
log.Println("Replaying order history...")
<-ctx.Done()
}
```
```python title="subscriber.py"
import time
from kubemq import (
PubSubClient, EventsStoreSubscription,
EventStoreStartPosition, CancellationToken,
)
def on_event(event):
print(f"Received: seq={event.sequence} body={event.body.decode('utf-8')}")
with PubSubClient(address="localhost:50000") as client:
client.subscribe_to_events_store(
subscription=EventsStoreSubscription(
channel="orders.events",
start_position=EventStoreStartPosition.StartFromFirst,
on_receive_event_callback=on_event,
on_error_callback=lambda e: print(f"Error: {e}"),
),
cancel=CancellationToken(),
)
print("Replaying order history...")
time.sleep(120)
```
```typescript title="subscriber.ts"
import { KubeMQClient, EventStoreStartPosition } from 'kubemq-js';
const client = await KubeMQClient.create({ address: 'localhost:50000' });
client.subscribeToEventsStore({
channel: 'orders.events',
startPosition: EventStoreStartPosition.StartFromFirst,
onEvent: (msg) =>
console.log(
`Received: seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`
),
onError: (err) => console.error('Error:', err.message),
});
console.log('Replaying order history...');
```
```java title="Subscriber.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("order-subscriber")
.build();
client.subscribeToEventsStore(EventsStoreSubscription.builder()
.channel("orders.events")
.startPosition(EventStoreStartPosition.StartFromFirst)
.onReceiveEventCallback(event ->
System.out.printf("Received: seq=%d body=%s%n",
event.getSequence(), new String(event.getBody())))
.onErrorCallback(err ->
System.err.println("Error: " + err.getMessage()))
.build());
System.out.println("Replaying order history...");
Thread.sleep(120_000);
client.close();
```
```csharp title="Subscriber.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
Console.WriteLine("Replaying order history...");
await foreach (var msg in client.SubscribeToEventsStoreAsync(
new EventsStoreSubscription
{
Channel = "orders.events",
StartPosition = EventStoreStartPosition.StartFromFirst,
}))
{
Console.WriteLine($"Received: seq={msg.Sequence} "
+ $"body={Encoding.UTF8.GetString(msg.Body.Span)}");
}
```
```kotlin title="Subscriber.kt"
val client = KubeMQClient.pubSub {
address = "localhost:50000"
clientId = "order-subscriber"
}
client.use {
val flow = client.subscribeToEventsStore {
channel = "orders.events"
startPosition = StartPosition.StartFromFirst
}
println("Replaying order history...")
flow.collect { msg ->
println("Received: seq=${msg.sequence} body=${String(msg.body)}")
}
}
```
```cpp title="subscriber.cc"
kubemq::ClientOptions options;
options.set_address("localhost", 50000);
options.set_client_id("order-subscriber");
auto client = kubemq::Client::Create(options).value();
std::cout << "Replaying order history..." << std::endl;
client->SubscribeToEventsStore(
"orders.events", "",
kubemq::StartPosition::StartFromFirst,
[](const kubemq::EventStoreReceived& msg) {
std::cout << "Received: seq=" << msg.sequence()
<< " body=" << msg.body() << std::endl;
},
[](const std::string& err) {
std::cerr << "Error: " << err << std::endl;
});
```
```rust title="subscriber.rs"
use kubemq::prelude::*;
use kubemq::EventsStoreSubscription;
use std::time::Duration;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
println!("Replaying order history...");
let sub = client
.subscribe_to_events_store(
"orders.events",
"",
EventsStoreSubscription::StartFromFirst,
|event| {
Box::pin(async move {
println!(
"Received: seq={} body={}",
event.sequence,
String::from_utf8_lossy(&event.body)
);
})
},
None,
)
.await?;
tokio::time::sleep(Duration::from_secs(120)).await;
sub.unsubscribe().await;
client.close().await?;
Ok(())
}
```
```ruby title="subscriber.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-subscriber')
cancel = KubeMQ::CancellationToken.new
sub = KubeMQ::PubSub::EventsStoreSubscription.new(
channel: 'orders.events',
start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST
)
client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e|
puts "Error: #{e.message}"
}) do |event|
puts "Received: seq=#{event.sequence} body=#{event.body}"
end
puts 'Replaying order history...'
sleep 120
cancel.cancel
client.close
```
```elixir title="subscriber.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-subscriber")
IO.puts("Replaying order history...")
{:ok, _sub} =
KubeMQ.Client.subscribe_to_events_store(client, "orders.events",
start_at: :start_from_first,
on_event: fn event ->
IO.puts("Received: seq=#{event.sequence} body=#{event.body}")
end
)
Process.sleep(120_000)
KubeMQ.Client.close(client)
```
### Verify the Output [#verify-the-output]
The subscriber receives all 3 previously published events, then continues waiting for new ones:
```text
Replaying order history...
Received: seq=1 body={"action":"order.created","orderId":"ORD-1001","total":149.99}
Received: seq=2 body={"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}
Received: seq=3 body={"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}
```
Any new events published after the subscriber connects are delivered in real time.
## Understanding What Happened [#understanding-what-happened]
*The store assigns each event a sequence number; a `StartFromFirst` subscriber replays the full history, then receives live events.*
1. The **publisher** stored 3 events in the `orders.events` channel
2. Each event received a **sequence number** (1, 2, 3) and a **server timestamp**
3. The **subscriber** connected later and requested `StartFromFirst`
4. KubeMQ replayed the entire history, then continues delivering new events
5. The subscription is **durable** — if the subscriber disconnects and reconnects with the same client ID and group, it resumes from the last delivered sequence
With plain [Events](/learn/events), the subscriber would have received nothing because Events are not persisted. Events Store is essential when subscribers must not miss messages.
## What's Next [#whats-next]
# Events Store — Persistent Pub/Sub (/learn/events-store)
Think of Events Store as a **DVR for your messages**. Just like a DVR records live TV so you can watch it later, Events Store records every event published to a channel. Subscribers can rewind to the beginning, fast-forward to a specific point, or jump in live — all from the same persistent stream.
KubeMQ Events Store implements persistent publish/subscribe with at-least-once delivery. Publishers send messages to a named channel; KubeMQ writes each event to disk with a monotonically increasing sequence number, and subscribers choose a start position to receive historical events, new events, or both. Because events are durable, a subscriber that was offline at publish time can still replay everything it missed.
## The concept it implements [#the-concept-it-implements]
Events Store is KubeMQ's implementation of two fundamental ideas. The interaction style is **[pub/sub](/learn/concepts/interaction-styles)** (fan-out) — one publisher, many subscribers — extended with durable storage so subscribers can **[replay from any position](/learn/concepts/ordering-and-replay)**. The delivery guarantee is **[at-least-once](/learn/concepts/delivery-guarantees)** — events are persisted and durable subscriptions track their position, so a message is redelivered until the subscriber has processed it. New to these terms? Start with the [Fundamentals](/learn/concepts) track.
## Key Properties [#key-properties]
| Property | This pattern | Learn the concept |
| ------------------ | ----------------------------------------------------- | ---------------------------------------------------------- |
| Interaction style | pub/sub (fan-out) with replay | [Interaction styles](/learn/concepts/interaction-styles) |
| Delivery guarantee | at-least-once | [Delivery guarantees](/learn/concepts/delivery-guarantees) |
| Persistence | Disk-backed — survives restarts | [Ordering & replay](/learn/concepts/ordering-and-replay) |
| Ordering | Sequenced per channel (sequence number + timestamp) | [Ordering & replay](/learn/concepts/ordering-and-replay) |
| Scaling | Fan-out, or load-balance with durable consumer groups | [Scaling & flow](/learn/concepts/scaling-and-flow) |
| Addressing | Named channels | [Channels & routing](/learn/concepts/channels-and-routing) |
## Key Features [#key-features]
* **Persistent storage** — events are written to disk and survive server restarts
* **Replay from any point** — subscribe from the first message, last message, a specific sequence number, an absolute timestamp, or a relative time delta
* **Durable subscriptions** — subscribers resume from their last position after reconnecting
* **Consumer groups** — distribute event processing across multiple consumers with automatic position tracking
* **Sequenced messages** — every stored event receives a monotonically increasing sequence number and a server timestamp
* **Stream publishing** — high-throughput bidirectional streaming with per-event acknowledgment
## How It Works [#how-it-works]
*A publisher persists events to a durable channel; a live subscriber streams new events while a late subscriber replays missed history from a chosen offset.*
1. A **publisher** sends an event to a named channel with persistence enabled
2. KubeMQ writes the event to the **store** on disk
3. Each event receives a **sequence number** and **timestamp**
4. **Subscribers** connect and specify a start position — they receive historical and/or new events based on that position
5. **Durable subscriptions** track the subscriber's position so reconnections resume automatically
For fire-and-forget pub/sub without persistence, use [Events](/learn/events) instead.
## Quick Example [#quick-example]
```go title="publish_store.go"
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.SendEventStore(ctx, kubemq.NewEvent().
SetChannel("orders.events").
SetBody([]byte(`{"action":"order.created","orderId":"ORD-1001"}`)),
)
if err != nil {
log.Fatal(err)
}
log.Printf("Event stored: ID=%s, Sent=%v", result.EventID, result.Sent)
```
```python title="publish_store.py"
from kubemq import PubSubClient, EventStoreMessage
with PubSubClient(address="localhost:50000") as client:
result = client.publish_event_store(
EventStoreMessage(
channel="orders.events",
body=b'{"action":"order.created","orderId":"ORD-1001"}',
)
)
print(f"Event stored: ID={result.id}, Sent={result.sent}")
```
```typescript title="publish_store.ts"
import { KubeMQClient, createEventStoreMessage } from 'kubemq-js';
const client = await KubeMQClient.create({ address: 'localhost:50000' });
const result = await client.sendEventStore(
createEventStoreMessage({
channel: 'orders.events',
body: '{"action":"order.created","orderId":"ORD-1001"}',
})
);
console.log(`Event stored: ID=${result.id}, Sent=${result.sent}`);
```
```java title="PublishStore.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("order-publisher")
.build();
EventSendResult result = client.sendEventsStoreMessage(
EventStoreMessage.builder()
.channel("orders.events")
.body("{\"action\":\"order.created\",\"orderId\":\"ORD-1001\"}".getBytes())
.build());
System.out.println("Event stored: " + result.getId());
client.close();
```
```csharp title="PublishStore.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
var result = await client.SendEventStoreAsync(new EventStoreMessage
{
Channel = "orders.events",
Body = Encoding.UTF8.GetBytes(
"{\"action\":\"order.created\",\"orderId\":\"ORD-1001\"}")
});
Console.WriteLine($"Event stored: ID={result.Id}, Sent={result.Sent}");
```
```kotlin title="PublishStore.kt"
val client = KubeMQClient.pubSub {
address = "localhost:50000"
clientId = "order-publisher"
}
client.use {
val result = client.sendEventStore(eventStoreMessage {
channel = "orders.events"
body = """{"action":"order.created","orderId":"ORD-1001"}""".toByteArray()
})
println("Event stored: ID=${result.id}, Sent=${result.sent}")
}
```
```cpp title="publish_store.cc"
kubemq::ClientOptions options;
options.set_address("localhost", 50000);
options.set_client_id("order-publisher");
auto client = kubemq::Client::Create(options).value();
kubemq::EventStoreMessage msg;
msg.set_channel("orders.events");
msg.set_body(R"({"action":"order.created","orderId":"ORD-1001"})");
auto result = client->SendEventStore(msg);
if (result.ok()) {
std::cout << "Event stored: " << result->id() << std::endl;
}
```
```rust title="publish_store.rs"
use kubemq::prelude::*;
use kubemq::EventStoreBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let event = EventStoreBuilder::new()
.channel("orders.events")
.body(br#"{"action":"order.created","orderId":"ORD-1001"}"#.to_vec())
.build();
let result = client.send_event_store(event).await?;
println!("Event stored: id={}, sent={}", result.id, result.sent);
client.close().await?;
Ok(())
}
```
```ruby title="publish_store.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-publisher')
msg = KubeMQ::PubSub::EventStoreMessage.new(
channel: 'orders.events',
body: '{"action":"order.created","orderId":"ORD-1001"}'
)
result = client.send_event_store(msg)
puts "Event stored: id=#{result.id}, sent=#{result.sent}"
client.close
```
```elixir title="publish_store.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher")
event =
KubeMQ.EventStore.new(
channel: "orders.events",
body: ~s({"action":"order.created","orderId":"ORD-1001"})
)
{:ok, result} = KubeMQ.Client.send_event_store(client, event)
IO.puts("Event stored: sent=#{result.sent}")
KubeMQ.Client.close(client)
```
## Subscription Start Positions [#subscription-start-positions]
| Position | Description | Use Case |
| ------------------ | ------------------------------------------- | ----------------------------------- |
| `StartNewOnly` | Only new events published after subscribing | Live monitoring, real-time alerting |
| `StartFromFirst` | Replay all events from the beginning | State rebuild, full audit replay |
| `StartFromLast` | Start from the last stored event, then new | Resume from most recent |
| `StartAtSequence` | Start from a specific sequence number | Checkpoint-based recovery |
| `StartAtTime` | Start from a specific timestamp | Point-in-time recovery |
| `StartAtTimeDelta` | Start from N seconds ago | Recent history replay |
## When to Use Events Store [#when-to-use-events-store]
| Scenario | Events | Events Store |
| ---------------------------- | -------------------------------------- | ----------------------------- |
| Audit trails | ❌ Messages can be lost | ✅ Best choice |
| Event sourcing | ❌ No persistence | ✅ Best choice |
| Late or offline subscribers | ❌ Miss everything published while away | ✅ Replay missed history |
| Real-time notifications | ✅ Best choice | Overkill |
| Live dashboards (no history) | ✅ Best choice | Use if historical data needed |
| Wildcard subscriptions | ✅ Supported | ❌ Not supported |
**When not to use Events Store:** if you never need replay, persistence, or guaranteed delivery — fire-and-forget [Events](/learn/events) have lower latency and support wildcard subscriptions. If a message must be processed by exactly one of several competing workers (work distribution, not fan-out), reach for [Queues](/learn/queues) instead.
### Events vs Events Store at a glance [#events-vs-events-store-at-a-glance]
| Feature | Events | Events Store |
| ---------------------- | --------------- | ----------------------- |
| Persistence | No | Yes (disk-backed) |
| Replay | No | Yes (6 start positions) |
| Delivery guarantee | at-most-once | at-least-once |
| Wildcard subscriptions | Yes | No |
| Consumer groups | Yes (ephemeral) | Yes (durable) |
| Sequence numbers | No | Yes |
| Timestamps | No | Yes (server-assigned) |
| Latency | Lowest | Slightly higher |
Events Store is also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/events-store) — use any language with a CloudEvents SDK, no KubeMQ client library needed.
## Learn More [#learn-more]
# Events Store Reference (/learn/events-store/reference)
This reference documents every aspect of the KubeMQ Events Store messaging pattern.
## Message Structure [#message-structure]
### Event Store Message (Publish) [#event-store-message-publish]
### Event Store Receive (Subscription Callback) [#event-store-receive-subscription-callback]
The `Sequence` and `Timestamp` fields are assigned by the server at persistence time and are not set by the publisher. They are available only on received events.
### Send Result [#send-result]
## Subscription Start Positions [#subscription-start-positions]
## Subscribe Request [#subscribe-request]
## Channel Naming Rules [#channel-naming-rules]
| Rule | Constraint | Error Code |
| --------------- | ------------------------- | ---------- |
| Required | Cannot be empty | 102 |
| No trailing dot | Cannot end with `.` | 119 |
| No whitespace | Cannot contain spaces | 108 |
| No wildcards | Cannot contain `*` or `>` | 107 |
**Valid channel regex:** `^[^\s*>]+[^.]$`
Events Store does **not** support wildcard subscriptions. Both publish and subscribe channel names must be exact. For wildcard support, use plain [Events](/learn/events).
## Durable Subscriptions [#durable-subscriptions]
Every Events Store subscription creates a durable name:
```text
DurableName = "{channel}-{group}"
```
* On first connection, the `EventsStoreTypeData` determines the start position
* On subsequent connections with the same durable name, the start position is **ignored** — the subscription resumes from the last tracked position
* To force a fresh replay, use a different `group` name
## Consumer Groups [#consumer-groups]
When multiple subscribers specify the same `Group` on the same channel:
* Each event is delivered to exactly **one** member (round-robin)
* The group's position is tracked durably
* Adding or removing members rebalances delivery automatically
* Groups are independent per channel
## Routing (Multicast) [#routing-multicast]
| Character | Purpose | Example |
| --------- | ---------------------------------- | ------------------------------------------------- |
| `;` | Separate channels of the same type | `audit;compliance` |
| `:` | Specify target pattern type | `events_store:audit;events:notify;queues:process` |
For Events Store publish, channels without a prefix default to `events_store`. Routed messages are tagged with `X-KUBEMQ-ROUTED=true`.
## Storage Configuration [#storage-configuration]
## File Store Configuration [#file-store-configuration]
## Storage Utilization Thresholds [#storage-utilization-thresholds]
| Utilization | Level | Polling Interval | Publishing |
| ----------- | -------- | ---------------- | ------------------------- |
| 0-80% | Normal | 5 seconds | Allowed |
| 80-90% | Warning | 3 seconds | Allowed (warnings logged) |
| 90-95% | Critical | 2 seconds | Allowed (errors logged) |
| Above 95% | Disabled | 1 second | **Blocked** |
Publishing automatically resumes when utilization drops below 95%.
## Transport Protocols [#transport-protocols]
### gRPC [#grpc]
* **Publish:** `SendEvent(pb.Event)` with `Store=true`
* **Publish Stream:** `SendEventsStream()` — bidirectional streaming, result sent for every store event
* **Subscribe:** `SubscribeToEvents(pb.Subscribe)` with `SubscribeType=EventsStore`
* Default port: `50000`
### REST [#rest]
* **Publish:** `POST /send` with `isEvents=false`
* **Subscribe:** WebSocket with `subscribe_type=events_store`
* Default port: `9090`
## Delivery Semantics [#delivery-semantics]
| Aspect | Behavior |
| ------------------ | ----------------------------------------------------- |
| Delivery guarantee | **At-least-once** |
| Persistence | Disk-backed file store |
| Ordering | Events delivered in sequence order per channel |
| Sequence numbers | Monotonically increasing, starting from 1 per channel |
| Acknowledgment | Asynchronous publish ack |
| Durable names | Position tracked across reconnections |
| Replay | Yes (6 start positions) |
## Events Store vs Events [#events-store-vs-events]
| Feature | Events Store | Events |
| -------------------------- | ---------------------------- | ---------------- |
| Persistence | Yes (disk-backed) | No (memory only) |
| Replay | Yes (6 start positions) | No |
| Delivery guarantee | At-least-once | At-most-once |
| Wildcard subscriptions | No | Yes (`*`, `>`) |
| Consumer groups | Durable | Ephemeral |
| Sequence numbers | Yes | No |
| Server timestamps | Yes | No |
| Latency | Slightly higher (disk write) | Lowest |
| Storage utilization limits | Yes (80/90/95% thresholds) | No |
## SDK Quick Reference [#sdk-quick-reference]
```go
client.SendEventStore(ctx, event)
client.SendEventsStoreStream(ctx, opts...)
client.SubscribeToEventsStore(ctx, channel, group, startPos, opts...)
```
```python
client.publish_event_store(message)
client.open_events_store_stream(on_result, on_error)
client.subscribe_to_events_store(subscription, cancel)
```
```typescript
client.sendEventStore(message)
client.sendEventsStoreStream(opts)
client.subscribeToEventsStore(opts)
```
```java
client.sendEventsStoreMessage(message)
client.openEventsStoreStream(onResult, onError)
client.subscribeToEventsStore(subscription)
```
```csharp
client.SendEventStoreAsync(message)
client.OpenEventsStoreStream(onResult, onError)
client.SubscribeToEventsStoreAsync(subscription)
```
```kotlin
client.sendEventStore(message)
client.openEventsStoreStream(onResult, onError)
client.subscribeToEventsStore { ... }
```
```cpp
client->SendEventStore(message)
client->OpenEventsStoreStream(onResult, onError)
client->SubscribeToEventsStore(channel, group, startPos, onEvent, onError)
```
```rust
client.send_event_store(event).await
client.send_event_store_stream().await // returns a stream; stream.send(event)
client.subscribe_to_events_store(channel, group, EventsStoreSubscription::StartFromFirst, on_event, None).await
```
```ruby
client.send_event_store(msg)
client.create_events_store_sender # sender.publish(msg)
client.subscribe_to_events_store(subscription, cancellation_token:, on_error:) { |event| ... }
```
```elixir
KubeMQ.Client.send_event_store(client, event)
KubeMQ.Client.send_event_store(client, event) # stream-style: send in rapid succession
KubeMQ.Client.subscribe_to_events_store(client, channel, start_at: :start_new_only, on_event: fn event -> ... end)
```
## Error Codes [#error-codes]
### Validation Errors [#validation-errors]
| Code | Error | Description |
| ---- | ------------------------- | ------------------------------------------------------------------ |
| 101 | Invalid ClientID | ClientID is empty |
| 102 | Invalid Channel | Channel is empty |
| 107 | Invalid Wildcards | Channel contains `*` or `>` |
| 108 | Invalid Whitespace | Channel contains spaces |
| 110 | Invalid Message | Both `Body` and `Metadata` are empty |
| 111 | Invalid Subscription Type | `EventsStoreTypeData` is `Undefined` (0) |
| 112 | Invalid Sequence Value | `StartAtSequence` value is 0 or negative |
| 113 | Invalid Time Value | `StartAtTime` value is 0 or negative |
| 114 | Invalid Time Delta Value | `StartAtTimeDelta` value is 0 or negative |
| 118 | Wrong Subscribe Type | Event store parameters set but subscribe type is not events\_store |
| 119 | Invalid Channel Separator | Channel ends with `.` |
### Runtime Errors [#runtime-errors]
| Error | Cause | Resolution |
| ---------------------------- | ------------------------------------ | -------------------------------------------- |
| `ErrShutdownMode` | Server is shutting down | Reconnect after server restart |
| `ErrConnectionNoAvailable` | Connection is down | SDK auto-reconnects; check server health |
| Storage utilization exceeded | Disk usage above 95% | Free disk space or adjust retention settings |
| Authorization denied | Casbin policy rejected the operation | Verify client permissions |
## Related [#related]
* [Getting Started with Events Store](/learn/events-store/getting-started)
* [Events Reference](/learn/events/reference) for the non-persistent variant
* Core Concepts for foundational KubeMQ concepts
# Channel Management (/learn/guides/channel-management)
For the conceptual model behind channels and how messages reach subscribers, see [Channels & Routing](/learn/concepts/channels-and-routing) in Fundamentals.
## Overview [#overview]
KubeMQ channels are created implicitly when a message is first published or subscribed to. However, you can also manage channels explicitly — creating, listing, deleting, and purging them through the SDK.
## Create Channel [#create-channel]
Create a channel before publishing to pre-register it with the server. The channel type must be specified: `events`, `events_store`, or `queues`.
```go title="create_channel.go"
package main
import (
"context"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
err = client.CreateChannel(ctx, "queues", "order-processing")
if err != nil {
log.Fatal(err)
}
log.Println("Channel created: order-processing")
}
```
```python title="create_channel.py"
from kubemq.queues import Client as QueuesClient
client = QueuesClient(address="localhost:50000")
client.create_channel("order-processing")
print("Channel created: order-processing")
client.close()
```
```javascript title="create_channel.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
await client.createChannel("queues", "order-processing");
console.log("Channel created: order-processing");
```
```java title="CreateChannel.java"
QueuesClient client = QueuesClient.builder()
.address("localhost:50000")
.clientId("admin-client")
.build();
client.createChannel("order-processing");
System.out.println("Channel created: order-processing");
client.close();
```
```csharp title="CreateChannel.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions
{
Address = "localhost:50000",
});
await client.ConnectAsync();
await client.CreateChannelAsync("queues", "order-processing");
Console.WriteLine("Channel created: order-processing");
```
```kotlin title="CreateChannel.kt"
val client = KubeMQClient.queues {
address = "localhost:50000"
clientId = "admin-client"
}
client.createChannel("order-processing")
println("Channel created: order-processing")
client.close()
```
```cpp title="create_channel.cpp"
#include
#include
kubemq::QueuesClient client("localhost:50000");
client.createChannel("order-processing");
std::cout << "Channel created: order-processing" << std::endl;
```
```rust title="create_channel.rs"
use kubemq::prelude::*;
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
client.create_queues_channel("order-processing").await?;
println!("Channel created: order-processing");
```
```ruby title="create_channel.rb"
require "kubemq"
client = KubeMQ::QueuesClient.new(address: "localhost:50000", client_id: "admin-client")
client.create_queues_channel(channel_name: "order-processing")
puts "Channel created: order-processing"
client.close
```
```elixir title="create_channel.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "admin-client")
:ok = KubeMQ.Client.create_channel(client, "order-processing", :queues)
IO.puts("Channel created: order-processing")
KubeMQ.Client.close(client)
```
## Delete Channel [#delete-channel]
Remove a channel and its configuration from the server. Messages in the channel are discarded.
Deleting a channel is irreversible. All pending messages in a queue channel are lost. Active subscriptions on the channel will receive an error.
```go title="delete_channel.go"
err = client.DeleteChannel(ctx, "queues", "order-processing")
if err != nil {
log.Fatal(err)
}
log.Println("Channel deleted: order-processing")
```
```python title="delete_channel.py"
client.delete_channel("order-processing")
print("Channel deleted: order-processing")
```
```javascript title="delete_channel.js"
await client.deleteChannel("queues", "order-processing");
console.log("Channel deleted: order-processing");
```
```java title="DeleteChannel.java"
client.deleteChannel("order-processing");
System.out.println("Channel deleted: order-processing");
```
```csharp title="DeleteChannel.cs"
await client.DeleteChannelAsync("queues", "order-processing");
Console.WriteLine("Channel deleted: order-processing");
```
```kotlin title="DeleteChannel.kt"
client.deleteChannel("order-processing")
println("Channel deleted: order-processing")
```
```cpp title="delete_channel.cpp"
client.deleteChannel("order-processing");
std::cout << "Channel deleted: order-processing" << std::endl;
```
```rust title="delete_channel.rs"
client.delete_queues_channel("order-processing").await?;
println!("Channel deleted: order-processing");
```
```ruby title="delete_channel.rb"
client.delete_queues_channel(channel_name: "order-processing")
puts "Channel deleted: order-processing"
```
```elixir title="delete_channel.exs"
:ok = KubeMQ.Client.delete_channel(client, "order-processing", :queues)
IO.puts("Channel deleted: order-processing")
```
## List Channels [#list-channels]
Query the server for all active channels, optionally filtering by channel type or name pattern.
```go title="list_channels.go"
channels, err := client.ListChannels(ctx, "queues", "order")
if err != nil {
log.Fatal(err)
}
for _, ch := range channels {
log.Printf("Channel: %s | Type: %s | Subscribers: %d | Messages: %d",
ch.Name, ch.Type, ch.LastActivity, ch.Incoming)
}
```
```python title="list_channels.py"
channels = client.list_channels("order")
for ch in channels:
print(f"Channel: {ch.name} | Type: {ch.type} | Messages: {ch.incoming}")
```
```javascript title="list_channels.js"
const channels = await client.listChannels("queues", "order");
for (const ch of channels) {
console.log(`Channel: ${ch.name} | Type: ${ch.type} | Messages: ${ch.incoming}`);
}
```
```java title="ListChannels.java"
List channels = client.listChannels("order");
for (ChannelInfo ch : channels) {
System.out.printf("Channel: %s | Type: %s | Messages: %d%n",
ch.getName(), ch.getType(), ch.getIncoming());
}
```
```csharp title="ListChannels.cs"
var channels = await client.ListChannelsAsync("queues", "order");
foreach (var ch in channels)
{
Console.WriteLine($"Channel: {ch.Name} | Type: {ch.Type} | Messages: {ch.Incoming}");
}
```
```kotlin title="ListChannels.kt"
val channels = client.listChannels("order")
for (ch in channels) {
println("Channel: ${ch.name} | Type: ${ch.type} | Messages: ${ch.incoming}")
}
```
```cpp title="list_channels.cpp"
auto channels = client.listChannels("order");
for (const auto& ch : channels) {
std::cout << "Channel: " << ch.name
<< " | Type: " << ch.type
<< " | Messages: " << ch.incoming << std::endl;
}
```
```rust title="list_channels.rs"
use kubemq::channel_type;
let channels = client.list_channels(channel_type::QUEUES, "order").await?;
for ch in &channels {
println!("Channel: {} | Active: {} | Last activity: {}",
ch.name, ch.is_active, ch.last_activity);
}
```
```ruby title="list_channels.rb"
channels = client.list_queues_channels(search: "order")
channels.each do |ch|
puts "Channel: #{ch.name} | Active: #{ch.is_active} | Messages: #{ch.incoming}"
end
```
```elixir title="list_channels.exs"
{:ok, channels} = KubeMQ.Client.list_queues_channels(client, "order")
Enum.each(channels, fn ch ->
IO.puts("Channel: #{ch.name} | Active: #{ch.is_active} | Messages: #{ch.incoming}")
end)
```
## Purge Queue [#purge-queue]
Remove all pending messages from a queue channel without deleting the channel itself. This is useful for clearing a backlog during development or after recovering from a failure.
Purge permanently removes all messages from the queue. This cannot be undone.
```go title="purge_queue.go"
resp, err := client.PurgeQueue(ctx, "order-processing")
if err != nil {
log.Fatal(err)
}
log.Printf("Purged %d messages from order-processing", resp.MessagesCount)
```
```python title="purge_queue.py"
result = client.purge_queue("order-processing")
print(f"Purged {result.messages_count} messages from order-processing")
```
```javascript title="purge_queue.js"
const result = await client.purgeQueue("order-processing");
console.log(`Purged ${result.messagesCount} messages from order-processing`);
```
```java title="PurgeQueue.java"
PurgeResult result = client.purgeQueue("order-processing");
System.out.printf("Purged %d messages from order-processing%n", result.getMessagesCount());
```
```csharp title="PurgeQueue.cs"
var result = await client.PurgeQueueAsync("order-processing");
Console.WriteLine($"Purged {result.MessagesCount} messages from order-processing");
```
```kotlin title="PurgeQueue.kt"
val result = client.purgeQueue("order-processing")
println("Purged ${result.messagesCount} messages from order-processing")
```
```cpp title="purge_queue.cpp"
auto result = client.purgeQueue("order-processing");
std::cout << "Purged " << result.messagesCount << " messages from order-processing"
<< std::endl;
```
```rust title="purge_queue.rs"
// The Rust SDK purges a queue by acking all pending messages.
client.ack_all_queue_messages("order-processing").await?;
println!("Purged all messages from order-processing");
```
```ruby title="purge_queue.rb"
client.purge_queue_channel(channel_name: "order-processing")
puts "Purged all messages from order-processing"
```
```elixir title="purge_queue.exs"
:ok = KubeMQ.Client.purge_queue_channel(client, "order-processing")
IO.puts("Purged all messages from order-processing")
```
## Key Points [#key-points]
* **Implicit creation** — channels are automatically created on first use (publish or subscribe)
* **Explicit management** — use the SDK for administrative operations like cleanup or provisioning
* **Type-scoped** — create and list operations require specifying the channel type (`events`, `events_store`, or `queues`)
* **Purge is queue-only** — only queue channels support purging; events and events\_store channels have different lifecycle semantics
## Next Steps [#next-steps]
# Channel Routing (/learn/guides/channel-routing)
New to channel naming and fan-out? Start with [Channels & Routing](/learn/concepts/channels-and-routing) in Fundamentals to understand how channels and patterns work before applying the routing syntax below.
## Routing Syntax [#routing-syntax]
KubeMQ supports publishing to multiple channels and patterns in a single operation using a routing syntax built into the channel name:
* **`;`** separates multiple channel targets
* **`:`** prefixes the pattern type (`events`, `events_store`, `queues`)
| Syntax | Meaning |
| ----------------------------------- | ------------------------------- |
| `events:channel-a;events:channel-b` | Two event channels |
| `events:live;events_store:archive` | Event + persistent copy |
| `events:notify;queues:process` | Broadcast + reliable work queue |
| `events:a;events_store:b;queues:c` | All three patterns |
Routing is transparent to subscribers — each target channel receives the message as if it were published directly.
## Examples [#examples]
### Events + Events Store [#events--events-store]
Publish an order event to a live channel and persist a copy to an archive channel in a single call.
```go title="route_events_store.go"
package main
import (
"context"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("order-router"),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
err = client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("events:order-live;events_store:order-archive").
SetMetadata("order.created").
SetBody([]byte(`{"orderId":"ORD-100","amount":59.99}`)),
)
if err != nil {
log.Fatal(err)
}
log.Println("Routed to events + events_store")
}
```
```python title="route_events_store.py"
from kubemq.pubsub import Client as PubSubClient, EventMessage
client = PubSubClient(address="localhost:50000", client_id="order-router")
client.send_event(
EventMessage(
channel="events:order-live;events_store:order-archive",
metadata="order.created",
body=b'{"orderId":"ORD-100","amount":59.99}',
)
)
print("Routed to events + events_store")
client.close()
```
```javascript title="route_events_store.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000", clientId: "order-router" });
await client.sendEvent({
channel: "events:order-live;events_store:order-archive",
metadata: "order.created",
body: Buffer.from('{"orderId":"ORD-100","amount":59.99}'),
});
console.log("Routed to events + events_store");
```
```java title="RouteEventsStore.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("order-router")
.build();
client.sendEventsMessage(EventMessage.builder()
.channel("events:order-live;events_store:order-archive")
.metadata("order.created")
.body("{\"orderId\":\"ORD-100\",\"amount\":59.99}".getBytes())
.build());
System.out.println("Routed to events + events_store");
client.close();
```
```csharp title="RouteEventsStore.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions
{
Address = "localhost:50000",
ClientId = "order-router"
});
await client.ConnectAsync();
await client.SendEventAsync(new EventMessage
{
Channel = "events:order-live;events_store:order-archive",
Metadata = "order.created",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-100\",\"amount\":59.99}"),
});
Console.WriteLine("Routed to events + events_store");
```
```kotlin title="RouteEventsStore.kt"
val client = PubSubClient("localhost:50000")
client.sendEvent(EventMessage(
channel = "events:order-live;events_store:order-archive",
metadata = "order.created",
body = """{"orderId":"ORD-100","amount":59.99}""".toByteArray(),
))
println("Routed to events + events_store")
client.close()
```
```cpp title="route_events_store.cpp"
#include
#include
auto client = kubemq::PubSubClient("localhost:50000");
kubemq::EventMessage event;
event.channel = "events:order-live;events_store:order-archive";
event.metadata = "order.created";
event.body = R"({"orderId":"ORD-100","amount":59.99})";
client.sendEvent(event);
std::cout << "Routed to events + events_store" << std::endl;
```
```rust title="route_events_store.rs"
use kubemq::prelude::*;
use kubemq::EventBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.client_id("order-router")
.build()
.await?;
let event = EventBuilder::new()
.channel("events:order-live;events_store:order-archive")
.metadata("order.created")
.body(br#"{"orderId":"ORD-100","amount":59.99}"#.to_vec())
.build();
client.send_event(event).await?;
println!("Routed to events + events_store");
client.close().await?;
Ok(())
}
```
```ruby title="route_events_store.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-router')
msg = KubeMQ::PubSub::EventMessage.new(
channel: 'events:order-live;events_store:order-archive',
metadata: 'order.created',
body: '{"orderId":"ORD-100","amount":59.99}'
)
client.send_event(msg)
puts 'Routed to events + events_store'
client.close
```
```elixir title="route_events_store.exs"
{:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-router")
event = KubeMQ.Event.new(
channel: "events:order-live;events_store:order-archive",
metadata: "order.created",
body: ~s({"orderId":"ORD-100","amount":59.99})
)
:ok = KubeMQ.Client.send_event(client, event)
IO.puts("Routed to events + events_store")
KubeMQ.Client.close(client)
```
### Events + Queue [#events--queue]
Broadcast an event for real-time subscribers and queue a copy for reliable processing.
```go title="route_events_queue.go"
err = client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("events:order-notify;queues:order-process").
SetMetadata("order.created").
SetBody([]byte(`{"orderId":"ORD-200","amount":129.00}`)),
)
```
```python title="route_events_queue.py"
client.send_event(
EventMessage(
channel="events:order-notify;queues:order-process",
metadata="order.created",
body=b'{"orderId":"ORD-200","amount":129.00}',
)
)
```
```javascript title="route_events_queue.js"
await client.sendEvent({
channel: "events:order-notify;queues:order-process",
metadata: "order.created",
body: Buffer.from('{"orderId":"ORD-200","amount":129.00}'),
});
```
```java title="RouteEventsQueue.java"
client.sendEventsMessage(EventMessage.builder()
.channel("events:order-notify;queues:order-process")
.metadata("order.created")
.body("{\"orderId\":\"ORD-200\",\"amount\":129.00}".getBytes())
.build());
```
```csharp title="RouteEventsQueue.cs"
await client.SendEventAsync(new EventMessage
{
Channel = "events:order-notify;queues:order-process",
Metadata = "order.created",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-200\",\"amount\":129.00}"),
});
```
```kotlin title="RouteEventsQueue.kt"
client.sendEvent(EventMessage(
channel = "events:order-notify;queues:order-process",
metadata = "order.created",
body = """{"orderId":"ORD-200","amount":129.00}""".toByteArray(),
))
```
```cpp title="route_events_queue.cpp"
kubemq::EventMessage event;
event.channel = "events:order-notify;queues:order-process";
event.metadata = "order.created";
event.body = R"({"orderId":"ORD-200","amount":129.00})";
client.sendEvent(event);
```
```rust title="route_events_queue.rs"
let event = EventBuilder::new()
.channel("events:order-notify;queues:order-process")
.metadata("order.created")
.body(br#"{"orderId":"ORD-200","amount":129.00}"#.to_vec())
.build();
client.send_event(event).await?;
```
```ruby title="route_events_queue.rb"
msg = KubeMQ::PubSub::EventMessage.new(
channel: 'events:order-notify;queues:order-process',
metadata: 'order.created',
body: '{"orderId":"ORD-200","amount":129.00}'
)
client.send_event(msg)
```
```elixir title="route_events_queue.exs"
event = KubeMQ.Event.new(
channel: "events:order-notify;queues:order-process",
metadata: "order.created",
body: ~s({"orderId":"ORD-200","amount":129.00})
)
:ok = KubeMQ.Client.send_event(client, event)
```
### All Three Patterns [#all-three-patterns]
Route a single publish to an event channel, persistent store, and a work queue simultaneously.
```go title="route_all.go"
err = client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("events:order-live;events_store:order-archive;queues:order-fulfill").
SetMetadata("order.created").
SetBody([]byte(`{"orderId":"ORD-300","amount":349.99}`)),
)
```
```python title="route_all.py"
client.send_event(
EventMessage(
channel="events:order-live;events_store:order-archive;queues:order-fulfill",
metadata="order.created",
body=b'{"orderId":"ORD-300","amount":349.99}',
)
)
```
```javascript title="route_all.js"
await client.sendEvent({
channel: "events:order-live;events_store:order-archive;queues:order-fulfill",
metadata: "order.created",
body: Buffer.from('{"orderId":"ORD-300","amount":349.99}'),
});
```
```java title="RouteAll.java"
client.sendEventsMessage(EventMessage.builder()
.channel("events:order-live;events_store:order-archive;queues:order-fulfill")
.metadata("order.created")
.body("{\"orderId\":\"ORD-300\",\"amount\":349.99}".getBytes())
.build());
```
```csharp title="RouteAll.cs"
await client.SendEventAsync(new EventMessage
{
Channel = "events:order-live;events_store:order-archive;queues:order-fulfill",
Metadata = "order.created",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-300\",\"amount\":349.99}"),
});
```
```kotlin title="RouteAll.kt"
client.sendEvent(EventMessage(
channel = "events:order-live;events_store:order-archive;queues:order-fulfill",
metadata = "order.created",
body = """{"orderId":"ORD-300","amount":349.99}""".toByteArray(),
))
```
```cpp title="route_all.cpp"
kubemq::EventMessage event;
event.channel = "events:order-live;events_store:order-archive;queues:order-fulfill";
event.metadata = "order.created";
event.body = R"({"orderId":"ORD-300","amount":349.99})";
client.sendEvent(event);
```
```rust title="route_all.rs"
let event = EventBuilder::new()
.channel("events:order-live;events_store:order-archive;queues:order-fulfill")
.metadata("order.created")
.body(br#"{"orderId":"ORD-300","amount":349.99}"#.to_vec())
.build();
client.send_event(event).await?;
```
```ruby title="route_all.rb"
msg = KubeMQ::PubSub::EventMessage.new(
channel: 'events:order-live;events_store:order-archive;queues:order-fulfill',
metadata: 'order.created',
body: '{"orderId":"ORD-300","amount":349.99}'
)
client.send_event(msg)
```
```elixir title="route_all.exs"
event = KubeMQ.Event.new(
channel: "events:order-live;events_store:order-archive;queues:order-fulfill",
metadata: "order.created",
body: ~s({"orderId":"ORD-300","amount":349.99})
)
:ok = KubeMQ.Client.send_event(client, event)
```
## How Routing Works Internally [#how-routing-works-internally]
When the server receives a channel string containing `;` or `:` delimiters, it splits the targets and fans the message out to each pattern and channel independently.
Each target is processed independently — a failure on one target does not affect the others. The server returns an error only if the routing syntax itself is invalid.
## Key Points [#key-points]
* **Atomic fan-out** — a single SDK call routes to all targets without client-side loops
* **Mixed patterns** — combine events, events\_store, and queues in any combination
* **Independent delivery** — each target processes the message according to its own pattern semantics
* **Subscriber transparency** — subscribers see messages as if published directly to their channel
# Choosing a Messaging Pattern (/learn/guides/choosing-a-pattern)
Every messaging pattern is a variation on one of three [interaction styles](/learn/concepts/interaction-styles) — pub/sub fan-out, point-to-point work distribution, or request/reply round-trips — tuned to a particular [delivery guarantee](/learn/concepts/delivery-guarantees). Choosing a pattern is really two questions: *what shape is the conversation*, and *how much can you afford to lose*. Answer those and the pattern falls out.
## Decision Guide [#decision-guide]
Answer a few questions to find the right messaging pattern for your use case.
### Decision Flowchart [#decision-flowchart]
The same logic as a flowchart. The first fork is the interaction style (does the sender wait for a reply?); the second is the delivery guarantee (can a message be lost?).
*Decision flowchart: the first fork is whether the sender waits for a reply, the second is whether a message can be lost, and the leaves are the five patterns.*
The two branches map straight onto the Fundamentals:
* **The sender waits → request/reply.** [Commands](/learn/concepts/interaction-styles) and Queries are the there-and-back style. Commands carry a write that returns only success/failure; Queries carry a read that returns a full body.
* **The sender hands off and moves on → pub/sub or point-to-point.** Now the [delivery guarantee](/learn/concepts/delivery-guarantees) decides: at-most-once fire-and-forget (Events), or persisted delivery you can replay (Events Store). If each message must be handled by exactly one worker, that is point-to-point work distribution (Queues).
## Pattern Comparison [#pattern-comparison]
| Feature | Events | Events Store | Queues | Commands | Queries |
| ----------------- | ------------ | ------------- | -------------- | ------------- | ------------- |
| Interaction style | Pub/sub | Pub/sub | Point-to-point | Request/reply | Request/reply |
| Delivery | At-most-once | At-least-once | Exactly-once | At-most-once | At-most-once |
| Persistence | No | Yes | Yes | No | No |
| Response | No | No | No | Executed only | Full body |
| Ordering | No | Sequenced | FIFO | N/A | N/A |
| Replay | No | 6 positions | No | No | No |
| DLQ | No | No | Yes | No | No |
| Caching | No | No | No | No | Yes |
| Groups | Yes | Yes | N/A | Yes | Yes |
## When to Use Each Pattern [#when-to-use-each-pattern]
### Events — Real-Time Broadcasting [#events--real-time-broadcasting]
Pub/sub fan-out with [at-most-once](/learn/concepts/delivery-guarantees) delivery. Best for real-time notifications, log streaming, live dashboards, and cache invalidation where occasional message loss is acceptable.
**Trade-offs:** Fastest (no disk I/O), but messages are lost if no subscriber is connected — there is no acknowledgement and nothing to replay.
*Events: the publisher fires once, every connected subscriber gets the message, and anyone offline simply misses it.*
### Events Store — Persistent Pub/Sub [#events-store--persistent-pubsub]
Pub/sub fan-out with [at-least-once](/learn/concepts/delivery-guarantees) delivery, persisted to disk. Best for audit trails, event sourcing, cross-service state sync, and any scenario where messages must not be lost.
**Trade-offs:** Higher latency than Events (disk I/O), but subscribers can replay from any point — a late subscriber can read the whole history.
*Events Store: messages are persisted to disk as they are delivered live, so a subscriber that connects later can replay the full history.*
### Queues — Work Distribution [#queues--work-distribution]
Point-to-point competing consumers with [exactly-once](/learn/concepts/delivery-guarantees) processing. Best for order processing, background jobs, webhook delivery, and any task where each message must be handled by exactly one worker.
**Trade-offs:** Requires an explicit ack/nack, but in return you get the full reliability toolkit — DLQ, delayed delivery, visibility timeout, and retry.
*Queues: one consumer receives the message and acknowledges it, and only then is the message removed from the queue.*
### RPC — Request/Reply [#rpc--requestreply]
The there-and-back interaction style. Best for service-to-service communication, API gateways, CQRS, and device command/control.
**Trade-offs:** Synchronous (sender blocks), but provides a direct response. Commands strip the response body (write semantics), Queries preserve it (read semantics).
*RPC: the sender blocks while KubeMQ routes the request to a responder and returns the single response back.*
## Combining Patterns [#combining-patterns]
Real systems rarely pick one pattern. The same message often fans out across several styles at once — broadcast for visibility, queue for reliable work. KubeMQ does this with [channel routing](/learn/concepts/channels-and-routing): one send, multiple destinations.
### Event-Driven with Work Queue [#event-driven-with-work-queue]
Broadcast events for monitoring (pub/sub, loss OK), and route critical work to a queue for reliable point-to-point processing.
*Event-driven with work queue: a single send fans out to a dashboard for monitoring and to a queue for reliable point-to-point processing.*
### CQRS with Events [#cqrs-with-events]
Commands write data (request/reply), events propagate the change (pub/sub), queries read projections (request/reply).
*CQRS with events: commands write through the event store, the read service subscribes to build projections, and clients query that read side.*
### Fan-Out with Routing [#fan-out-with-routing]
Publish once, deliver to multiple patterns using channel routing syntax.
| Syntax | Meaning |
| ----------------------------------- | ------------------------- |
| `events:channel-a;events:channel-b` | Two event channels |
| `events:live;events_store:archive` | Event + persistent copy |
| `events:notify;queues:process` | Broadcast + reliable work |
## Decision Matrix [#decision-matrix]
| Requirement | Recommended Pattern |
| --------------------------- | -------------------- |
| Real-time notifications | Events |
| Log/metric streaming | Events |
| Cache invalidation | Events |
| Audit trail | Events Store |
| Event sourcing | Events Store |
| Cross-service state sync | Events Store |
| Order processing | Queues |
| Background jobs | Queues |
| Scheduled/delayed tasks | Queues |
| Webhook delivery with retry | Queues |
| Service-to-service calls | RPC Commands/Queries |
| API gateway backend | RPC Commands/Queries |
| Device command & control | RPC Commands |
| Cached lookups | RPC Queries |
# Connect with TLS & mTLS (/learn/guides/connect-with-tls)
Securing connections applies across every messaging pattern. See the [Messaging Patterns Fundamentals](/learn/concepts) for the conceptual model these guides build on.
## Prerequisites [#prerequisites]
* KubeMQ server configured with TLS certificates
* CA certificate file (for TLS and mTLS)
* Client certificate and key files (for mTLS only)
Server-side TLS configuration is managed in the KubeMQ deployment.
## TLS Connection [#tls-connection]
Server-side TLS encrypts all traffic between the client and KubeMQ. The client verifies the server's identity using a CA certificate, preventing man-in-the-middle attacks.
### Configure TLS [#configure-tls]
```go title="tls_connect.go"
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("kubemq.example.com", 50000),
kubemq.WithClientId("secure-client"),
kubemq.WithTLS("path/to/ca-cert.pem"),
)
if err != nil {
log.Fatalf("TLS connection failed: %v", err)
}
defer client.Close()
info, err := client.Ping(ctx)
if err != nil {
log.Fatalf("Ping failed: %v", err)
}
fmt.Printf("TLS connected: host=%s version=%s\n", info.Host, info.Version)
}
```
```python title="tls_connect.py"
from kubemq import TLSConfig
from kubemq.pubsub import Client as PubSubClient
tls_config = TLSConfig(
enabled=True,
ca_file="/path/to/ca.pem",
)
with PubSubClient(
address="kubemq.example.com:50000",
client_id="secure-client",
tls=tls_config,
) as client:
info = client.ping()
print(f"TLS connected: host={info.host}")
```
```typescript title="tls_connect.ts"
import { KubeMQClient, ConnectionError } from "kubemq-js";
try {
const client = await KubeMQClient.create({
address: "kubemq.example.com:50000",
clientId: "secure-client",
tls: {
enabled: true,
caCert: "/path/to/ca-cert.pem",
},
});
console.log("TLS connected:", client.state);
await client.close();
} catch (err) {
if (err instanceof ConnectionError) {
console.error("TLS connection failed:", err.message);
}
}
```
```java title="TlsConnect.java"
QueuesClient client = QueuesClient.builder()
.address("kubemq.example.com:50000")
.clientId("secure-client")
.tls(true)
.caCertFile("/path/to/ca.pem")
.build();
ServerInfo info = client.ping();
System.out.println("TLS connected: " + info);
client.close();
```
```csharp title="TlsConnect.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions
{
Address = "kubemq.example.com:50000",
ClientId = "secure-client",
Tls = new TlsOptions
{
Enabled = true,
CaFile = "/path/to/ca.pem",
},
});
await client.ConnectAsync();
var info = await client.PingAsync();
Console.WriteLine($"TLS connected: {info}");
```
```kotlin title="TlsConnect.kt"
val client = KubeMQClient.queues {
address = "kubemq.example.com:50000"
clientId = "secure-client"
tls {
caCertFile = "/path/to/ca.pem"
}
}
client.use {
val info = it.ping()
println("TLS connected: host=${info.host}")
}
```
```cpp title="tls_connect.cpp"
#include
#include
kubemq::ClientOptions opts;
opts.address = "kubemq.example.com:50000";
opts.client_id = "secure-client";
opts.set_tls_config(kubemq::TlsConfig::FromCertFile("/path/to/ca-cert.pem"));
kubemq::PubSubClient client(opts);
auto info = client.ping();
std::cout << "TLS connected: " << info.host << std::endl;
```
```rust title="tls_connect.rs"
use kubemq::prelude::*;
use kubemq::TlsConfig;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let tls = TlsConfig {
ca_cert_file: Some("/path/to/ca.pem".to_string()),
..Default::default()
};
let client = KubemqClient::builder()
.host("kubemq.example.com")
.port(50000)
.tls_config(tls)
.build()
.await?;
let info = client.ping().await?;
println!("TLS connected. Server version: {}", info.version);
client.close().await?;
Ok(())
}
```
```ruby title="tls_connect.rb"
require 'kubemq'
tls = KubeMQ::TLSConfig.new(enabled: true, ca_file: '/path/to/ca.pem')
client = KubeMQ::PubSubClient.new(
address: 'kubemq.example.com:50000',
client_id: 'secure-client',
tls: tls
)
info = client.ping
puts "TLS connected: host=#{info.host}, version=#{info.version}"
client.close
```
```elixir title="tls_connect.exs"
{:ok, client} =
KubeMQ.Client.start_link(
address: "kubemq.example.com:50000",
client_id: "secure-client",
tls: [cacertfile: "/path/to/ca.pem"]
)
{:ok, info} = KubeMQ.Client.ping(client)
IO.puts("TLS connected: version=#{info.version}")
KubeMQ.Client.close(client)
```
### Verify Connection [#verify-connection]
After establishing a TLS connection, call `Ping` (or equivalent) to confirm the secure channel is working. A successful ping confirms both network connectivity and certificate validation.
## Mutual TLS (mTLS) [#mutual-tls-mtls]
mTLS extends standard TLS by requiring both the client and server to present certificates. The server verifies the client's identity, and the client verifies the server — establishing bidirectional trust.
### Configure mTLS [#configure-mtls]
```go title="mtls_connect.go"
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("kubemq.example.com", 50000),
kubemq.WithClientId("mtls-client"),
kubemq.WithMTLS(
"path/to/client-cert.pem",
"path/to/client-key.pem",
"path/to/ca-cert.pem",
),
)
if err != nil {
log.Fatalf("mTLS connection failed: %v", err)
}
defer client.Close()
info, err := client.Ping(ctx)
if err != nil {
log.Fatalf("Ping failed: %v", err)
}
fmt.Printf("mTLS connected: host=%s\n", info.Host)
```
```python title="mtls_connect.py"
from kubemq import TLSConfig
from kubemq.pubsub import Client as PubSubClient
tls_config = TLSConfig(
enabled=True,
cert_file="/path/to/client-cert.pem",
key_file="/path/to/client-key.pem",
ca_file="/path/to/ca.pem",
)
with PubSubClient(
address="kubemq.example.com:50000",
client_id="mtls-client",
tls=tls_config,
) as client:
info = client.ping()
print(f"mTLS connected: host={info.host}")
```
```typescript title="mtls_connect.ts"
const client = await KubeMQClient.create({
address: "kubemq.example.com:50000",
clientId: "mtls-client",
tls: {
enabled: true,
caCert: "/path/to/ca-cert.pem",
clientCert: "/path/to/client-cert.pem",
clientKey: "/path/to/client-key.pem",
},
});
console.log("mTLS connected:", client.state);
await client.close();
```
```java title="MtlsConnect.java"
QueuesClient client = QueuesClient.builder()
.address("kubemq.example.com:50000")
.clientId("mtls-client")
.tls(true)
.caCertFile("/path/to/ca.pem")
.tlsCertFile("/path/to/client-cert.pem")
.tlsKeyFile("/path/to/client-key.pem")
.build();
ServerInfo info = client.ping();
System.out.println("mTLS connected: " + info);
client.close();
```
```csharp title="MtlsConnect.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions
{
Address = "kubemq.example.com:50000",
ClientId = "mtls-client",
Tls = new TlsOptions
{
Enabled = true,
CaFile = "/path/to/ca.pem",
CertFile = "/path/to/client-cert.pem",
KeyFile = "/path/to/client-key.pem",
},
});
await client.ConnectAsync();
var info = await client.PingAsync();
Console.WriteLine($"mTLS connected: {info}");
```
```kotlin title="MtlsConnect.kt"
val client = KubeMQClient.queues {
address = "kubemq.example.com:50000"
clientId = "mtls-client"
tls {
caCertFile = "/path/to/ca.pem"
certFile = "/path/to/client-cert.pem"
keyFile = "/path/to/client-key.pem"
}
}
client.use {
val info = it.ping()
println("mTLS connected: host=${info.host}")
}
```
```cpp title="mtls_connect.cpp"
kubemq::ClientOptions opts;
opts.address = "kubemq.example.com:50000";
opts.client_id = "mtls-client";
opts.set_tls_config(kubemq::TlsConfig::FromMutualTls(
"/path/to/client-cert.pem",
"/path/to/client-key.pem",
"/path/to/ca-cert.pem"
));
kubemq::PubSubClient client(opts);
auto info = client.ping();
std::cout << "mTLS connected: " << info.host << std::endl;
```
```rust title="mtls_connect.rs"
use kubemq::prelude::*;
use kubemq::TlsConfig;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let tls = TlsConfig {
ca_cert_file: Some("/path/to/ca.pem".to_string()),
cert_file: Some("/path/to/client-cert.pem".to_string()),
key_file: Some("/path/to/client-key.pem".to_string()),
..Default::default()
};
let client = KubemqClient::builder()
.host("kubemq.example.com")
.port(50000)
.tls_config(tls)
.build()
.await?;
let info = client.ping().await?;
println!("mTLS connected. Server version: {}", info.version);
client.close().await?;
Ok(())
}
```
```ruby title="mtls_connect.rb"
require 'kubemq'
tls = KubeMQ::TLSConfig.new(
enabled: true,
cert_file: '/path/to/client-cert.pem',
key_file: '/path/to/client-key.pem',
ca_file: '/path/to/ca.pem'
)
client = KubeMQ::PubSubClient.new(
address: 'kubemq.example.com:50000',
client_id: 'mtls-client',
tls: tls
)
info = client.ping
puts "mTLS connected: host=#{info.host}, version=#{info.version}"
client.close
```
```elixir title="mtls_connect.exs"
{:ok, client} =
KubeMQ.Client.start_link(
address: "kubemq.example.com:50000",
client_id: "mtls-client",
tls: [
cacertfile: "/path/to/ca.pem",
certfile: "/path/to/client-cert.pem",
keyfile: "/path/to/client-key.pem",
verify: :verify_peer
]
)
{:ok, info} = KubeMQ.Client.ping(client)
IO.puts("mTLS connected: version=#{info.version}")
KubeMQ.Client.close(client)
```
### When to Use mTLS [#when-to-use-mtls]
| Scenario | TLS | mTLS |
| -------------------------- | ---------- | ----------- |
| Encrypt traffic in transit | Yes | Yes |
| Verify server identity | Yes | Yes |
| Verify client identity | No | Yes |
| Zero-trust network | — | Recommended |
| Multi-tenant deployment | — | Recommended |
| Internal trusted network | Sufficient | Optional |
## Pattern-Specific Notes [#pattern-specific-notes]
TLS and mTLS configuration is **identical for Events, Events Store, Queues, and RPC**. The secure connection is established once at client creation and applies to all operations performed through that client. You do not need separate TLS configuration per pattern.
## Troubleshooting [#troubleshooting]
Verify the CA certificate matches the server's certificate issuer:
```bash
openssl verify -CAfile ca-cert.pem server-cert.pem
```
Common causes: expired certificates, wrong CA file, or intermediate CA missing from the chain.
Verify the client certificate and key are a matching pair:
```bash
openssl x509 -noout -modulus -in client-cert.pem | openssl md5
openssl rsa -noout -modulus -in client-key.pem | openssl md5
```
Both commands should output the same MD5 hash. If they differ, the cert and key do not match.
TLS typically uses a different port than plaintext. Confirm the server port in your deployment configuration. The default TLS port varies by deployment method.
## Next Steps [#next-steps]
# Error Handling Patterns (/learn/guides/error-handling)
Retries and dead-lettering build on KubeMQ's delivery semantics. Review [Delivery Guarantees](/learn/concepts/delivery-guarantees) in Fundamentals to understand at-least-once delivery and acknowledgement before designing your error-handling strategy.
## Error Categories [#error-categories]
KubeMQ errors fall into four categories. Handling differs by category, not by messaging pattern.
| Category | Cause | Example | Recovery |
| ----------------- | --------------------------------------------------- | ---------------------------------------------- | ------------------------------- |
| **Validation** | Invalid input before the message reaches the server | Empty channel name, body + metadata both empty | Fix the input — do not retry |
| **Connection** | Network or server unavailable | DNS failure, server restart, TLS mismatch | Reconnect with backoff |
| **Timeout** | Operation exceeded its deadline | RPC request timeout, queue poll timeout | Retry or increase timeout |
| **Authorization** | Missing or invalid credentials | Bad auth token, expired certificate | Refresh credentials, then retry |
## Connection Error Handling [#connection-error-handling]
Detect connection failures and attempt reconnection. All SDKs expose connection state or error callbacks.
```go title="connection_error.go"
package main
import (
"context"
"log"
"time"
"github.com/kubemq-io/kubemq-go/v2"
)
func connectWithRetry(ctx context.Context) *kubemq.Client {
var client *kubemq.Client
var err error
backoff := time.Second
for {
client, err = kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("resilient-client"),
)
if err == nil {
if _, pingErr := client.Ping(ctx); pingErr == nil {
log.Println("Connected to KubeMQ")
return client
}
client.Close()
}
log.Printf("Connection failed: %v — retrying in %v", err, backoff)
time.Sleep(backoff)
if backoff < 30*time.Second {
backoff *= 2
}
}
}
```
```python title="connection_error.py"
import time
from kubemq.pubsub import Client as PubSubClient
def connect_with_retry(address: str) -> PubSubClient:
backoff = 1.0
while True:
try:
client = PubSubClient(
address=address,
client_id="resilient-client",
)
client.ping()
print("Connected to KubeMQ")
return client
except Exception as e:
print(f"Connection failed: {e} — retrying in {backoff}s")
time.sleep(backoff)
backoff = min(backoff * 2, 30)
```
```javascript title="connection_error.js"
const { KubeMQClient } = require("kubemq-js");
async function connectWithRetry(address) {
let backoff = 1000;
while (true) {
try {
const client = new KubeMQClient({
address,
clientId: "resilient-client",
});
await client.ping();
console.log("Connected to KubeMQ");
return client;
} catch (err) {
console.error(`Connection failed: ${err.message} — retrying in ${backoff}ms`);
await new Promise((r) => setTimeout(r, backoff));
backoff = Math.min(backoff * 2, 30000);
}
}
}
```
```java title="ConnectionError.java"
PubSubClient connectWithRetry(String address) throws InterruptedException {
long backoff = 1000;
while (true) {
try {
PubSubClient client = PubSubClient.builder()
.address(address)
.clientId("resilient-client")
.build();
client.ping();
System.out.println("Connected to KubeMQ");
return client;
} catch (Exception e) {
System.err.printf("Connection failed: %s — retrying in %dms%n",
e.getMessage(), backoff);
Thread.sleep(backoff);
backoff = Math.min(backoff * 2, 30000);
}
}
}
```
```csharp title="ConnectionError.cs"
async Task ConnectWithRetryAsync(string address)
{
var backoff = TimeSpan.FromSeconds(1);
while (true)
{
try
{
var client = new KubeMQClient(new KubeMQClientOptions
{
Address = address,
ClientId = "resilient-client",
});
await client.ConnectAsync();
await client.PingAsync();
Console.WriteLine("Connected to KubeMQ");
return client;
}
catch (Exception ex)
{
Console.WriteLine($"Connection failed: {ex.Message} — retrying in {backoff}");
await Task.Delay(backoff);
if (backoff < TimeSpan.FromSeconds(30))
backoff *= 2;
}
}
}
```
```kotlin title="ConnectionError.kt"
import io.kubemq.sdk.pubsub.PubSubClient
import kotlinx.coroutines.delay
suspend fun connectWithRetry(address: String): PubSubClient {
var backoff = 1000L
while (true) {
try {
val client = PubSubClient(address)
client.ping()
println("Connected to KubeMQ")
return client
} catch (e: Exception) {
println("Connection failed: ${e.message} — retrying in ${backoff}ms")
delay(backoff)
backoff = minOf(backoff * 2, 30000)
}
}
}
```
```cpp title="connection_error.cpp"
#include
#include
#include
#include
kubemq::PubSubClient connectWithRetry(const std::string& address) {
int backoff = 1000;
while (true) {
try {
kubemq::PubSubClient client(address);
client.ping();
std::cout << "Connected to KubeMQ" << std::endl;
return client;
} catch (const std::exception& e) {
std::cerr << "Connection failed: " << e.what()
<< " — retrying in " << backoff << "ms" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(backoff));
backoff = std::min(backoff * 2, 30000);
}
}
}
```
```rust title="connection_error.rs"
use kubemq::prelude::*;
use kubemq::RetryPolicy;
use std::time::Duration;
async fn connect_with_retry(host: &str, port: u16) -> KubemqClient {
let mut backoff = Duration::from_secs(1);
loop {
// The builder applies its own retry policy on transient failures;
// check_connection verifies the connection before returning.
let result = KubemqClient::builder()
.host(host)
.port(port)
.client_id("resilient-client")
.check_connection(true)
.retry_policy(RetryPolicy {
max_retries: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(10),
multiplier: 2.0,
..Default::default()
})
.build()
.await;
match result {
Ok(client) => {
println!("Connected to KubeMQ");
return client;
}
Err(e) => {
println!("Connection failed: {e} — retrying in {backoff:?}");
tokio::time::sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, Duration::from_secs(30));
}
}
}
}
```
```ruby title="connection_error.rb"
require 'kubemq'
def connect_with_retry(address)
backoff = 1.0
loop do
begin
client = KubeMQ::PubSubClient.new(
address: address,
client_id: 'resilient-client'
)
client.ping
puts 'Connected to KubeMQ'
return client
rescue KubeMQ::Error, StandardError => e
puts "Connection failed: #{e.message} — retrying in #{backoff}s"
sleep backoff
backoff = [backoff * 2, 30].min
end
end
end
```
```elixir title="connection_error.exs"
# KubeMQ.Client.start_link/1 returns {:ok, client} or {:error, reason}.
# Retry with exponential backoff until the connection succeeds.
defmodule Resilient do
def connect_with_retry(address, backoff \\ 1_000) do
case KubeMQ.Client.start_link(address: address, client_id: "resilient-client") do
{:ok, client} ->
IO.puts("Connected to KubeMQ")
client
{:error, reason} ->
IO.puts("Connection failed: #{inspect(reason)} — retrying in #{backoff}ms")
Process.sleep(backoff)
connect_with_retry(address, min(backoff * 2, 30_000))
end
end
end
```
## Retry with Backoff [#retry-with-backoff]
Wrap send operations with exponential backoff and a maximum retry count to handle transient failures without overwhelming the server.
*A message moves from received to processing; on failure it backs off and is requeued for redelivery, and once retries are exhausted it lands in the dead-letter queue.*
```go title="retry_backoff.go"
func sendWithRetry(ctx context.Context, client *kubemq.Client, event *kubemq.Event, maxRetries int) error {
backoff := 100 * time.Millisecond
for attempt := 0; attempt <= maxRetries; attempt++ {
err := client.SendEvent(ctx, event)
if err == nil {
return nil
}
if attempt == maxRetries {
return fmt.Errorf("failed after %d retries: %w", maxRetries, err)
}
log.Printf("Attempt %d failed: %v — retrying in %v", attempt+1, err, backoff)
time.Sleep(backoff)
backoff *= 2
}
return nil
}
```
```python title="retry_backoff.py"
import time
def send_with_retry(client, event, max_retries=3):
backoff = 0.1
for attempt in range(max_retries + 1):
try:
client.send_event(event)
return
except Exception as e:
if attempt == max_retries:
raise RuntimeError(f"Failed after {max_retries} retries") from e
print(f"Attempt {attempt + 1} failed: {e} — retrying in {backoff}s")
time.sleep(backoff)
backoff *= 2
```
```javascript title="retry_backoff.js"
async function sendWithRetry(client, event, maxRetries = 3) {
let backoff = 100;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await client.sendEvent(event);
return;
} catch (err) {
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} retries: ${err.message}`);
}
console.warn(`Attempt ${attempt + 1} failed: ${err.message} — retrying in ${backoff}ms`);
await new Promise((r) => setTimeout(r, backoff));
backoff *= 2;
}
}
}
```
```java title="RetryBackoff.java"
void sendWithRetry(PubSubClient client, EventMessage event, int maxRetries)
throws Exception {
long backoff = 100;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
client.sendEventsMessage(event);
return;
} catch (Exception e) {
if (attempt == maxRetries) {
throw new RuntimeException("Failed after " + maxRetries + " retries", e);
}
System.err.printf("Attempt %d failed: %s — retrying in %dms%n",
attempt + 1, e.getMessage(), backoff);
Thread.sleep(backoff);
backoff *= 2;
}
}
}
```
```csharp title="RetryBackoff.cs"
async Task SendWithRetryAsync(KubeMQClient client, EventMessage message, int maxRetries = 3)
{
var backoff = TimeSpan.FromMilliseconds(100);
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
await client.SendEventAsync(message);
return;
}
catch (Exception ex)
{
if (attempt == maxRetries)
throw new InvalidOperationException($"Failed after {maxRetries} retries", ex);
Console.WriteLine($"Attempt {attempt + 1} failed: {ex.Message} — retrying in {backoff}");
await Task.Delay(backoff);
backoff *= 2;
}
}
}
```
```kotlin title="RetryBackoff.kt"
suspend fun sendWithRetry(client: PubSubClient, event: EventMessage, maxRetries: Int = 3) {
var backoff = 100L
for (attempt in 0..maxRetries) {
try {
client.sendEvent(event)
return
} catch (e: Exception) {
if (attempt == maxRetries) {
throw RuntimeException("Failed after $maxRetries retries", e)
}
println("Attempt ${attempt + 1} failed: ${e.message} — retrying in ${backoff}ms")
delay(backoff)
backoff *= 2
}
}
}
```
```cpp title="retry_backoff.cpp"
void sendWithRetry(kubemq::PubSubClient& client, kubemq::EventMessage& event, int maxRetries = 3) {
int backoff = 100;
for (int attempt = 0; attempt <= maxRetries; ++attempt) {
try {
client.sendEvent(event);
return;
} catch (const std::exception& e) {
if (attempt == maxRetries) {
throw std::runtime_error(
"Failed after " + std::to_string(maxRetries) + " retries: " + e.what());
}
std::cerr << "Attempt " << attempt + 1 << " failed: " << e.what()
<< " — retrying in " << backoff << "ms" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(backoff));
backoff *= 2;
}
}
}
```
```rust title="retry_backoff.rs"
use kubemq::prelude::*;
use kubemq::Event;
use std::time::Duration;
async fn send_with_retry(
client: &KubemqClient,
event: Event,
max_retries: u32,
) -> kubemq::Result<()> {
let mut backoff = Duration::from_millis(100);
for attempt in 0..=max_retries {
match client.send_event(event.clone()).await {
Ok(()) => return Ok(()),
Err(e) => {
if attempt == max_retries {
return Err(e);
}
println!("Attempt {} failed: {e} — retrying in {backoff:?}", attempt + 1);
tokio::time::sleep(backoff).await;
backoff *= 2;
}
}
}
Ok(())
}
```
```ruby title="retry_backoff.rb"
def send_with_retry(client, message, max_retries = 3)
backoff = 0.1
(0..max_retries).each do |attempt|
begin
client.send_event(message)
return
rescue KubeMQ::Error, StandardError => e
raise "Failed after #{max_retries} retries: #{e.message}" if attempt == max_retries
puts "Attempt #{attempt + 1} failed: #{e.message} — retrying in #{backoff}s"
sleep backoff
backoff *= 2
end
end
end
```
```elixir title="retry_backoff.exs"
# send_event/2 returns :ok or {:error, err}. Retry transient failures
# with exponential backoff, raising once retries are exhausted.
defmodule Retry do
def send_with_retry(client, event, max_retries \\ 3, backoff \\ 100, attempt \\ 0) do
case KubeMQ.Client.send_event(client, event) do
:ok ->
:ok
{:error, err} when attempt >= max_retries ->
raise "Failed after #{max_retries} retries: #{err.message}"
{:error, err} ->
IO.puts("Attempt #{attempt + 1} failed: #{err.message} — retrying in #{backoff}ms")
Process.sleep(backoff)
send_with_retry(client, event, max_retries, backoff * 2, attempt + 1)
end
end
end
```
## Graceful Shutdown [#graceful-shutdown]
Close client connections cleanly to flush pending messages and release server resources.
```go title="graceful_shutdown.go"
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sig
log.Println("Shutting down...")
cancel()
client.Close()
os.Exit(0)
}()
log.Println("Running — press Ctrl+C to stop")
<-ctx.Done()
}
```
```python title="graceful_shutdown.py"
import signal
import sys
from kubemq.pubsub import Client as PubSubClient
client = PubSubClient(address="localhost:50000")
def shutdown(signum, frame):
print("Shutting down...")
client.close()
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
print("Running — press Ctrl+C to stop")
signal.pause()
```
```javascript title="graceful_shutdown.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
async function shutdown() {
console.log("Shutting down...");
await client.close();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
console.log("Running — press Ctrl+C to stop");
```
```java title="GracefulShutdown.java"
PubSubClient client = PubSubClient.builder()
.address("localhost:50000")
.clientId("shutdown-demo")
.build();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Shutting down...");
client.close();
}));
System.out.println("Running — press Ctrl+C to stop");
Thread.currentThread().join();
```
```csharp title="GracefulShutdown.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions
{
Address = "localhost:50000",
});
await client.ConnectAsync();
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Console.WriteLine("Shutting down...");
cts.Cancel();
};
Console.WriteLine("Running — press Ctrl+C to stop");
try { await Task.Delay(Timeout.Infinite, cts.Token); }
catch (OperationCanceledException) { }
```
```kotlin title="GracefulShutdown.kt"
val client = PubSubClient("localhost:50000")
Runtime.getRuntime().addShutdownHook(Thread {
println("Shutting down...")
client.close()
})
println("Running — press Ctrl+C to stop")
Thread.currentThread().join()
```
```cpp title="graceful_shutdown.cpp"
#include
#include
#include
#include
std::atomic running{true};
kubemq::PubSubClient* globalClient = nullptr;
void signalHandler(int) {
std::cout << "Shutting down..." << std::endl;
running = false;
if (globalClient) globalClient->close();
}
int main() {
kubemq::PubSubClient client("localhost:50000");
globalClient = &client;
std::signal(SIGINT, signalHandler);
std::signal(SIGTERM, signalHandler);
std::cout << "Running — press Ctrl+C to stop" << std::endl;
while (running) {
std::this_thread::sleep_for(std::chrono::seconds(1));
}
return 0;
}
```
```rust title="graceful_shutdown.rs"
use kubemq::prelude::*;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
println!("Running — press Ctrl+C to stop");
// Wait for a shutdown signal, then close the client cleanly.
tokio::signal::ctrl_c().await.ok();
println!("Shutting down...");
// close() flushes pending work and cancels active subscriptions.
client.close().await?;
println!("Client closed — shutdown complete");
Ok(())
}
```
```ruby title="graceful_shutdown.rb"
require 'kubemq'
client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'shutdown-demo')
shutdown = proc do
puts 'Shutting down...'
client.close
exit 0
end
Signal.trap('INT', &shutdown)
Signal.trap('TERM', &shutdown)
puts 'Running — press Ctrl+C to stop'
sleep
```
```elixir title="graceful_shutdown.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "shutdown-demo")
# Trap exits so the process can close the client before terminating.
Process.flag(:trap_exit, true)
IO.puts("Running — press Ctrl+C to stop")
receive do
{:EXIT, _from, _reason} ->
IO.puts("Shutting down...")
KubeMQ.Client.close(client)
IO.puts("Client closed — shutdown complete")
end
```
## Error Code Reference [#error-code-reference]
Common error codes returned by the KubeMQ server across all patterns.
| Code | Category | Description |
| ---- | ------------- | -------------------------------------------------------------- |
| 100 | Validation | Message body and metadata are both empty |
| 107 | Validation | Channel name contains wildcard characters |
| 108 | Validation | Channel name contains whitespace |
| 119 | Validation | Channel name ends with a dot |
| 120 | Validation | Channel name is empty |
| 200 | Connection | Server unavailable or connection refused |
| 201 | Connection | Connection timeout exceeded |
| 300 | Timeout | Request timeout (RPC commands/queries) |
| 301 | Timeout | Queue poll wait timeout (not an error — no messages available) |
| 400 | Authorization | Authentication token missing or invalid |
| 401 | Authorization | Client not authorized for the requested channel |
Code 301 (queue poll timeout) is expected behavior when no messages are available. Do not treat it as a failure in your error handling logic.
# OpenTelemetry Integration (/learn/guides/opentelemetry)
## Overview [#overview]
KubeMQ supports OpenTelemetry for distributed tracing and metrics. Instrumenting your producers and consumers gives you end-to-end visibility across messaging boundaries — regardless of which pattern (Events, Events Store, Queues, or RPC) you use.
Tracing is how you observe message flow under load. See [Scaling & Flow](/learn/concepts/scaling-and-flow) in Fundamentals for the concepts behind throughput, backpressure, and consumer groups that these traces will surface.
*Producer and consumer spans flow through the OTel Collector to a tracing backend, giving end-to-end visibility across the KubeMQ message path.*
## Setup [#setup]
Configure an OpenTelemetry tracer and meter provider in your application before creating KubeMQ clients.
```go title="otel_setup.go"
package main
import (
"context"
"log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)
func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("localhost:4317"),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("order-service"),
)),
)
otel.SetTracerProvider(tp)
return tp, nil
}
```
```python title="otel_setup.py"
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
resource = Resource.create({"service.name": "order-service"})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(endpoint="localhost:4317", insecure=True)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("order-service")
```
```typescript title="otel_setup.ts"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
import { Resource } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
const provider = new NodeTracerProvider({
resource: new Resource({ [ATTR_SERVICE_NAME]: "order-service" }),
});
provider.addSpanProcessor(
new BatchSpanProcessor(
new OTLPTraceExporter({ url: "http://localhost:4317" })
)
);
provider.register();
const tracer = provider.getTracer("order-service");
```
```java title="OtelSetup.java"
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import io.opentelemetry.semconv.ResourceAttributes;
Resource resource = Resource.getDefault()
.merge(Resource.create(
io.opentelemetry.api.common.Attributes.of(
ResourceAttributes.SERVICE_NAME, "order-service")));
OtlpGrpcSpanExporter exporter = OtlpGrpcSpanExporter.builder()
.setEndpoint("http://localhost:4317")
.build();
SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(exporter).build())
.setResource(resource)
.build();
OpenTelemetry otel = OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.build();
Tracer tracer = otel.getTracer("order-service");
```
```csharp title="OtelSetup.cs"
using OpenTelemetry;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("order-service"))
.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri("http://localhost:4317");
})
.Build();
var tracer = tracerProvider.GetTracer("order-service");
```
```kotlin title="OtelSetup.kt"
import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.trace.Tracer
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter
import io.opentelemetry.sdk.OpenTelemetrySdk
import io.opentelemetry.sdk.resources.Resource
import io.opentelemetry.sdk.trace.SdkTracerProvider
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor
import io.opentelemetry.semconv.ResourceAttributes
val resource = Resource.getDefault()
.merge(Resource.create(
io.opentelemetry.api.common.Attributes.of(
ResourceAttributes.SERVICE_NAME, "order-service")))
val exporter = OtlpGrpcSpanExporter.builder()
.setEndpoint("http://localhost:4317")
.build()
val tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(exporter).build())
.setResource(resource)
.build()
val otel: OpenTelemetry = OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.build()
val tracer: Tracer = otel.getTracer("order-service")
```
```cpp title="otel_setup.cpp"
#include
#include
#include
#include
#include
namespace trace_sdk = opentelemetry::sdk::trace;
namespace otlp = opentelemetry::exporter::otlp;
namespace resource = opentelemetry::sdk::resource;
auto exporter = std::make_unique(
otlp::OtlpGrpcExporterOptions{"localhost:4317"});
auto processor = std::make_unique(
std::move(exporter));
auto provider = std::make_shared(
std::move(processor),
resource::Resource::Create({{"service.name", "order-service"}}));
opentelemetry::trace::Provider::SetTracerProvider(provider);
auto tracer = provider->GetTracer("order-service");
```
```rust title="otel_setup.rs"
use kubemq::prelude::*;
use opentelemetry::global;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{trace::TracerProvider, Resource};
use opentelemetry::KeyValue;
// Register a global TracerProvider before building the client. Once a
// provider is registered globally, the KubeMQ Rust client automatically
// instruments its gRPC calls — no per-call wiring required.
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint("http://localhost:4317")
.build()?;
let provider = TracerProvider::builder()
.with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
.with_resource(Resource::new(vec![
KeyValue::new("service.name", "order-service"),
]))
.build();
global::set_tracer_provider(provider);
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
```
```ruby title="otel_setup.rb"
require 'kubemq'
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
# Configure the global OpenTelemetry SDK before creating the client. With a
# provider configured, the KubeMQ Ruby client's gRPC calls are instrumented
# through the standard OpenTelemetry gRPC instrumentation.
OpenTelemetry::SDK.configure do |c|
c.service_name = 'order-service'
c.use_all
end
tracer = OpenTelemetry.tracer_provider.tracer('order-service')
client = KubeMQ::PubSubClient.new(
address: 'localhost:50000',
client_id: 'order-service'
)
```
```elixir title="otel_setup.exs"
# The KubeMQ Elixir client emits :telemetry events for every operation
# rather than OpenTelemetry spans directly. Bridge those telemetry events
# into OpenTelemetry with the opentelemetry_telemetry library, or attach a
# handler to forward measurements to your tracer.
:telemetry.attach_many(
"kubemq-otel-bridge",
[
[:kubemq, :client, :send_event, :start],
[:kubemq, :client, :send_event, :stop],
[:kubemq, :client, :send_event, :exception]
],
fn event, measurements, metadata, _config ->
[_, _, action, phase] = event
# Forward to your OpenTelemetry tracer here, e.g. via OpenTelemetry.Tracer.
IO.puts("[otel] #{action}:#{phase} #{inspect(metadata[:channel])}")
end,
nil
)
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-service")
```
The Elixir client exposes observability through Erlang/Elixir `:telemetry` events (`[:kubemq, :client, :send_event, :start | :stop | :exception]`, and equivalents for commands, queries, and queues) rather than emitting OpenTelemetry spans directly. Use [`opentelemetry_telemetry`](https://hex.pm/packages/opentelemetry_telemetry) to convert these into spans, or attach your own handler.
## Tracing Messaging Operations [#tracing-messaging-operations]
Wrap your publish and subscribe operations in spans to trace message flow across services. The pattern is the same for Events, Events Store, Queues, and RPC.
```go title="traced_publish.go"
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(ctx, "publish-order",
trace.WithAttributes(
attribute.String("messaging.system", "kubemq"),
attribute.String("messaging.destination", "order-events"),
attribute.String("messaging.operation", "publish"),
),
)
defer span.End()
err := client.SendEvent(ctx, kubemq.NewEvent().
SetChannel("order-events").
SetBody([]byte(`{"orderId":"ORD-500"}`)),
)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
```
```python title="traced_publish.py"
with tracer.start_as_current_span(
"publish-order",
attributes={
"messaging.system": "kubemq",
"messaging.destination": "order-events",
"messaging.operation": "publish",
},
) as span:
try:
client.send_event(
EventMessage(
channel="order-events",
body=b'{"orderId":"ORD-500"}',
)
)
except Exception as e:
span.record_exception(e)
span.set_status(StatusCode.ERROR, str(e))
raise
```
```typescript title="traced_publish.ts"
await tracer.startActiveSpan("publish-order", {
attributes: {
"messaging.system": "kubemq",
"messaging.destination": "order-events",
"messaging.operation": "publish",
},
}, async (span) => {
try {
await client.sendEvent({
channel: "order-events",
body: Buffer.from('{"orderId":"ORD-500"}'),
});
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
} finally {
span.end();
}
});
```
```java title="TracedPublish.java"
Span span = tracer.spanBuilder("publish-order")
.setAttribute("messaging.system", "kubemq")
.setAttribute("messaging.destination", "order-events")
.setAttribute("messaging.operation", "publish")
.startSpan();
try (Scope scope = span.makeCurrent()) {
client.sendEventsMessage(EventMessage.builder()
.channel("order-events")
.body("{\"orderId\":\"ORD-500\"}".getBytes())
.build());
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
```
```csharp title="TracedPublish.cs"
using var span = tracer.StartActiveSpan("publish-order");
span.SetAttribute("messaging.system", "kubemq");
span.SetAttribute("messaging.destination", "order-events");
span.SetAttribute("messaging.operation", "publish");
try
{
await client.SendEventAsync(new EventMessage
{
Channel = "order-events",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-500\"}"),
});
}
catch (Exception ex)
{
span.RecordException(ex);
span.SetStatus(Status.Error.WithDescription(ex.Message));
throw;
}
```
```kotlin title="TracedPublish.kt"
val span = tracer.spanBuilder("publish-order")
.setAttribute("messaging.system", "kubemq")
.setAttribute("messaging.destination", "order-events")
.setAttribute("messaging.operation", "publish")
.startSpan()
try {
span.makeCurrent().use {
client.sendEvent(EventMessage(
channel = "order-events",
body = """{"orderId":"ORD-500"}""".toByteArray(),
))
}
} catch (e: Exception) {
span.recordException(e)
span.setStatus(StatusCode.ERROR, e.message ?: "unknown error")
throw e
} finally {
span.end()
}
```
```cpp title="traced_publish.cpp"
auto span = tracer->StartSpan("publish-order", {
{"messaging.system", "kubemq"},
{"messaging.destination", "order-events"},
{"messaging.operation", "publish"},
});
auto scope = tracer->WithActiveSpan(span);
try {
kubemq::EventMessage event;
event.channel = "order-events";
event.body = R"({"orderId":"ORD-500"})";
client.sendEvent(event);
} catch (const std::exception& e) {
span->AddEvent("exception", {{"exception.message", e.what()}});
span->SetStatus(opentelemetry::trace::StatusCode::kError, e.what());
throw;
}
span->End();
```
```rust title="traced_publish.rs"
use kubemq::EventBuilder;
use opentelemetry::trace::{Span, Status, Tracer};
use opentelemetry::{global, KeyValue};
let tracer = global::tracer("order-service");
let mut span = tracer.start("publish-order");
span.set_attribute(KeyValue::new("messaging.system", "kubemq"));
span.set_attribute(KeyValue::new("messaging.destination", "order-events"));
span.set_attribute(KeyValue::new("messaging.operation", "publish"));
let event = EventBuilder::new()
.channel("order-events")
.body(br#"{"orderId":"ORD-500"}"#.to_vec())
.build();
match client.send_event(event).await {
Ok(_) => {}
Err(e) => {
span.set_status(Status::error(e.to_string()));
span.record_error(&e);
}
}
span.end();
```
```ruby title="traced_publish.rb"
tracer.in_span(
'publish-order',
attributes: {
'messaging.system' => 'kubemq',
'messaging.destination' => 'order-events',
'messaging.operation' => 'publish'
}
) do |span|
begin
msg = KubeMQ::PubSub::EventMessage.new(
channel: 'order-events',
body: '{"orderId":"ORD-500"}'
)
client.send_event(msg)
rescue KubeMQ::Error => e
span.record_exception(e)
span.status = OpenTelemetry::Trace::Status.error(e.message)
raise
end
end
```
```elixir title="traced_publish.exs"
require OpenTelemetry.Tracer, as: Tracer
Tracer.with_span "publish-order" do
Tracer.set_attributes([
{"messaging.system", "kubemq"},
{"messaging.destination", "order-events"},
{"messaging.operation", "publish"}
])
event =
KubeMQ.Event.new(channel: "order-events", body: ~s({"orderId":"ORD-500"}))
case KubeMQ.Client.send_event(client, event) do
:ok ->
:ok
{:error, err} ->
Tracer.set_status(OpenTelemetry.status(:error, err.message))
end
end
```
## Metrics [#metrics]
Instrument your messaging clients with OpenTelemetry metrics to track throughput, latency, and error rates.
### Recommended Metrics [#recommended-metrics]
| Metric | Type | Description |
| ---------------------------- | --------- | --------------------------------------- |
| `messaging.publish.duration` | Histogram | Time to publish a message (ms) |
| `messaging.process.duration` | Histogram | Time to process a received message (ms) |
| `messaging.publish.messages` | Counter | Total messages published |
| `messaging.receive.messages` | Counter | Total messages received |
| `messaging.publish.errors` | Counter | Failed publish attempts |
### Attribute Conventions [#attribute-conventions]
Follow OpenTelemetry semantic conventions for messaging attributes:
| Attribute | Example | Description |
| ----------------------- | --------------------- | --------------------------- |
| `messaging.system` | `kubemq` | Messaging system identifier |
| `messaging.destination` | `order-events` | Channel name |
| `messaging.operation` | `publish` / `receive` | Operation type |
| `messaging.message.id` | `uuid` | Message identifier |
These conventions align with the [OpenTelemetry Semantic Conventions for Messaging](https://opentelemetry.io/docs/specs/semconv/messaging/). Following them ensures compatibility with observability platforms like Jaeger, Grafana Tempo, and Datadog.
## Next Steps [#next-steps]
# Production Checklist (/learn/guides/production-checklist)
Before working this checklist, ground yourself in the [Messaging Patterns Fundamentals](/learn/concepts) — especially [Delivery Guarantees](/learn/concepts/delivery-guarantees), since most production decisions (retries, dead-lettering, idempotency) follow from your chosen delivery semantics.
## Deployment Checklist [#deployment-checklist]
Follow these steps before deploying KubeMQ messaging to production. Each step covers a critical area — complete all that apply to your deployment.
### Connection & Security [#connection--security]
* [ ] TLS enabled for all client connections ([Connect with TLS](/learn/guides/connect-with-tls))
* [ ] mTLS enabled for zero-trust or multi-tenant environments
* [ ] Connection timeouts configured to match network conditions
* [ ] Reconnection logic with exponential backoff implemented ([Error Handling](/learn/guides/error-handling))
* [ ] Client IDs set to unique, descriptive values for traceability
* [ ] Authentication tokens rotated and not hardcoded
### Observability [#observability]
* [ ] OpenTelemetry configured for distributed traces and metrics ([OpenTelemetry](/learn/guides/opentelemetry))
* [ ] Structured logging with correlation IDs linking traces to logs
* [ ] Alert thresholds set for queue depth, error rates, and latency
* [ ] Dashboard monitoring KubeMQ server health (CPU, memory, connections)
* [ ] Log retention policy configured for compliance requirements
### Queues [#queues]
* [ ] Dead letter queues configured for all critical queues ([Dead Letter Queue](/learn/queues/tutorials/dead-letter-queue))
* [ ] `maxReceiveCount` set to a reasonable retry limit (default 1024 is typically too high)
* [ ] Visibility timeout set to match expected processing time ([Visibility Timeout](/learn/queues/how-to/visibility-timeout))
* [ ] Message expiration (TTL) set for time-sensitive work
* [ ] Retry strategy with exponential backoff implemented
* [ ] Queue depth monitoring and alerting in place
### Events Store [#events-store]
* [ ] Retention policy configured — time, size, or message count
* [ ] Storage utilization monitored with alerts before 90% capacity
* [ ] Durable subscription names set for consumer recovery after restart
* [ ] Replay strategy documented — consumers know which offset to start from
* [ ] Storage backend appropriate for workload (memory vs. disk)
### RPC (Commands & Queries) [#rpc-commands--queries]
* [ ] Request timeouts set on all commands and queries — never use infinite timeout
* [ ] Circuit breaker in place for downstream service failures
* [ ] Query caching enabled for slow-changing data to reduce responder load
* [ ] Timeout values documented and agreed upon between caller and responder
* [ ] Load balancing strategy validated for multi-responder deployments
### All Patterns [#all-patterns]
* [ ] Error handling covers all four categories: validation, connection, timeout, authorization ([Error Handling](/learn/guides/error-handling))
* [ ] Graceful shutdown implemented — close connections cleanly on SIGTERM
* [ ] Idempotent message processing where applicable (at-least-once delivery)
* [ ] Message serialization format agreed upon (JSON, Protobuf, etc.)
* [ ] Channel naming convention documented and enforced
* [ ] Load testing completed under expected peak throughput
* [ ] Disaster recovery plan documented — what happens when KubeMQ restarts
## Quick Reference [#quick-reference]
| Area | Key Config | Default | Recommended |
| ------------ | ------------------ | -------------------- | ------------------------------- |
| Connection | TLS | Disabled | Enable in production |
| Connection | Reconnect backoff | None (SDK-dependent) | 1s initial, 30s max |
| Queues | Visibility timeout | 60s | Match your processing time |
| Queues | Max receive count | 1024 | 3–5 for most workloads |
| Queues | Message expiration | None | Set for time-sensitive messages |
| Events Store | Retention | Unlimited | Set based on storage budget |
| RPC | Request timeout | 10s | Set per operation |
| All | Client ID | Auto-generated | Set explicitly for tracing |
## Next Steps [#next-steps]
# Getting Started with Queues (/learn/queues/getting-started)
**Prerequisites:** KubeMQ server running on `localhost:50000` and your SDK installed. See [Getting Started](/deploy) for setup.
## What You Will Build [#what-you-will-build]
An order message sender that places a task on a queue, and a receiver that pulls the task, processes it, and acknowledges completion.
## Steps [#steps]
### Install the SDK [#install-the-sdk]
```bash
go get github.com/kubemq-io/kubemq-go/v2
```
```bash
pip install kubemq
```
```bash
npm install kubemq-js
```
```xml
io.kubemq.sdk
kubemq-sdk-Java
3.1.1
```
```bash
dotnet add package KubeMQ.SDK.CSharp --version 3.0.1
```
```kotlin
implementation("io.kubemq.sdk:kubemq-sdk-kotlin:1.0.1")
```
```bash
vcpkg install kubemq
```
```toml
[dependencies]
kubemq = "1.0.1"
tokio = { version = "1", features = ["full"] }
```
```bash
gem install kubemq
```
```elixir
def deps do
[{:kubemq, "~> 1.0"}]
end
```
### Create the Sender [#create-the-sender]
Connect to KubeMQ and send an order message to the `orders` queue.
```go title="sender.go"
package main
import (
"context"
"fmt"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("order-sender"),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
msg := kubemq.NewQueueMessage().
SetChannel("orders").
SetBody([]byte(`{"orderId":"ORD-1234","total":99.99}`)).
SetMetadata("order.created")
result, err := client.SendQueueMessage(ctx, msg)
if err != nil {
log.Fatal(err)
}
if result.IsError {
log.Fatalf("Send failed: %s", result.Error)
}
fmt.Printf("Sent: id=%s\n", result.MessageID)
}
```
```python title="sender.py"
from kubemq.queues import Client as QueuesClient
from kubemq import QueueMessage
client = QueuesClient(
address="localhost:50000",
client_id="order-sender",
)
result = client.send_queue_message(
QueueMessage(
channel="orders",
body=b'{"orderId":"ORD-1234","total":99.99}',
metadata="order.created",
)
)
print(f"Sent: id={result.id}")
client.close()
```
```typescript title="sender.ts"
import { KubeMQClient, createQueueMessage } from 'kubemq-js';
const client = await KubeMQClient.create({
address: 'localhost:50000',
clientId: 'order-sender',
});
const result = await client.sendQueueMessage(
createQueueMessage({
channel: 'orders',
body: JSON.stringify({ orderId: 'ORD-1234', total: 99.99 }),
metadata: 'order.created',
}),
);
console.log('Sent:', result.messageId);
await client.close();
```
```java title="Sender.java"
import io.kubemq.sdk.queues.QueuesClient;
import io.kubemq.sdk.queues.QueueMessage;
import io.kubemq.sdk.queues.QueueSendResult;
QueuesClient client = QueuesClient.builder()
.address("localhost:50000")
.clientId("order-sender")
.build();
QueueMessage msg = QueueMessage.builder()
.channel("orders")
.body("{\"orderId\":\"ORD-1234\",\"total\":99.99}".getBytes())
.metadata("order.created")
.build();
QueueSendResult result = client.sendQueueMessage(msg);
System.out.println("Sent: id=" + result.getId());
client.close();
```
```csharp title="Sender.cs"
using System.Text;
using KubeMQ.Sdk.Client;
using KubeMQ.Sdk.Queues;
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
var result = await client.SendQueueMessageAsync(new QueueMessage
{
Channel = "orders",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"total\":99.99}"),
Metadata = "order.created"
});
Console.WriteLine($"Sent: id={result.MessageId}");
```
```kotlin title="Sender.kt"
import io.kubemq.sdk.client.KubeMQClient
import io.kubemq.sdk.queues.QueueMessage
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = KubeMQClient.queues {
address = "localhost:50000"
clientId = "order-sender"
}
val result = client.sendQueuesMessage(QueueMessage(
channel = "orders",
body = """{"orderId":"ORD-1234","total":99.99}""".toByteArray(),
metadata = "order.created"
))
println("Sent: id=${result.messageId}")
client.close()
}
```
```cpp title="sender.cpp"
#include
#include
auto client = kubemq::QueuesClient("localhost:50000");
kubemq::QueueMessage msg;
msg.channel = "orders";
msg.body = R"({"orderId":"ORD-1234","total":99.99})";
msg.metadata = "order.created";
auto result = client.sendQueueMessage(msg);
std::cout << "Sent: id=" << result.messageId << std::endl;
```
```rust title="sender.rs"
use kubemq::prelude::*;
use kubemq::QueueMessageBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.client_id("order-sender")
.build()
.await?;
let msg = QueueMessageBuilder::new()
.channel("orders")
.body(br#"{"orderId":"ORD-1234","total":99.99}"#.to_vec())
.metadata("order.created")
.build();
let result = client.send_queue_message(msg).await?;
println!("Sent: id={}", result.message_id);
client.close().await?;
Ok(())
}
```
```ruby title="sender.rb"
require 'kubemq'
client = KubeMQ::QueuesClient.new(
address: 'localhost:50000',
client_id: 'order-sender'
)
msg = KubeMQ::Queues::QueueMessage.new(
channel: 'orders',
body: '{"orderId":"ORD-1234","total":99.99}',
metadata: 'order.created'
)
result = client.send_queue_message(msg)
puts "Sent: id=#{result.id}"
client.close
```
```elixir title="sender.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-sender")
msg =
KubeMQ.QueueMessage.new(
channel: "orders",
body: ~s({"orderId":"ORD-1234","total":99.99}),
metadata: "order.created"
)
{:ok, result} = KubeMQ.Client.send_queue_message(client, msg)
IO.puts("Sent: id=#{result.message_id}")
KubeMQ.Client.close(client)
```
### Create the Receiver [#create-the-receiver]
In a separate terminal, receive the message and acknowledge it.
```go title="receiver.go"
package main
import (
"context"
"fmt"
"log"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("order-receiver"),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
resp, err := client.PollQueue(ctx, &kubemq.PollRequest{
Channel: "orders",
MaxItems: 1,
WaitTimeoutSeconds: 10,
AutoAck: false,
})
if err != nil {
log.Fatal(err)
}
for _, m := range resp.Messages {
fmt.Printf("Received: %s\n", string(m.Message.Body))
fmt.Printf("Metadata: %s\n", m.Message.Metadata)
}
if err := resp.AckAll(); err != nil {
log.Fatal(err)
}
fmt.Println("All messages acknowledged")
}
```
```python title="receiver.py"
from kubemq.queues import Client as QueuesClient
client = QueuesClient(
address="localhost:50000",
client_id="order-receiver",
)
response = client.receive_queue_messages(
channel="orders",
max_messages=1,
wait_timeout_in_seconds=10,
)
for msg in response.messages:
print(f"Received: {msg.body.decode('utf-8')}")
print(f"Metadata: {msg.metadata}")
msg.ack()
print("Message acknowledged")
client.close()
```
```typescript title="receiver.ts"
import { KubeMQClient } from 'kubemq-js';
const client = await KubeMQClient.create({
address: 'localhost:50000',
clientId: 'order-receiver',
});
const messages = await client.receiveQueueMessages({
channel: 'orders',
maxMessages: 1,
waitTimeoutSeconds: 10,
});
for (const msg of messages) {
console.log('Received:', new TextDecoder().decode(msg.body));
console.log('Metadata:', msg.metadata);
await msg.ack();
console.log('Message acknowledged');
}
await client.close();
```
```java title="Receiver.java"
import io.kubemq.sdk.queues.QueuesClient;
import io.kubemq.sdk.queues.QueuesPollRequest;
import io.kubemq.sdk.queues.QueuesPollResponse;
import io.kubemq.sdk.queues.QueueMessageReceived;
QueuesClient client = QueuesClient.builder()
.address("localhost:50000")
.clientId("order-receiver")
.build();
QueuesPollResponse response = client.receiveQueueMessages(
QueuesPollRequest.builder()
.channel("orders")
.pollMaxMessages(1)
.pollWaitTimeoutInSeconds(10)
.autoAckMessages(false)
.build());
for (QueueMessageReceived msg : response.getMessages()) {
System.out.println("Received: " + new String(msg.getBody()));
System.out.println("Metadata: " + msg.getMetadata());
msg.ack();
System.out.println("Message acknowledged");
}
client.close();
```
```csharp title="Receiver.cs"
using System.Text;
using KubeMQ.Sdk.Client;
using KubeMQ.Sdk.Queues;
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
var response = await client.ReceiveQueueMessagesAsync(new QueuePollRequest
{
Channel = "orders",
MaxMessages = 1,
WaitTimeoutSeconds = 10,
AutoAck = false,
});
foreach (var msg in response.Messages)
{
Console.WriteLine($"Received: {Encoding.UTF8.GetString(msg.Body.Span)}");
Console.WriteLine($"Metadata: {msg.Metadata}");
await msg.AckAsync();
Console.WriteLine("Message acknowledged");
}
```
```kotlin title="Receiver.kt"
import io.kubemq.sdk.client.KubeMQClient
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = KubeMQClient.queues {
address = "localhost:50000"
clientId = "order-receiver"
}
val response = client.receiveQueuesMessages {
channel = "orders"
maxItems = 1
waitTimeoutMs = 10_000
autoAck = false
}
for (msg in response.messages) {
println("Received: ${String(msg.body)}")
println("Metadata: ${msg.metadata}")
msg.ack()
println("Message acknowledged")
}
client.close()
}
```
```cpp title="receiver.cpp"
#include
#include
auto client = kubemq::QueuesClient("localhost:50000");
auto response = client.receiveQueueMessages("orders", 1, 10);
for (const auto& msg : response.messages) {
std::cout << "Received: " << msg.body << std::endl;
std::cout << "Metadata: " << msg.metadata << std::endl;
msg.ack();
std::cout << "Message acknowledged" << std::endl;
}
```
```rust title="receiver.rs"
use kubemq::prelude::*;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.client_id("order-receiver")
.build()
.await?;
let (response, _receiver) = client
.poll_queue(PollRequest {
channel: "orders".to_string(),
max_items: 1,
wait_timeout_seconds: 10,
auto_ack: false,
})
.await?;
for m in &response.messages {
println!("Received: {}", String::from_utf8_lossy(&m.message.body));
println!("Metadata: {}", m.message.metadata);
}
response.ack_all().await?;
println!("All messages acknowledged");
client.close().await?;
Ok(())
}
```
```ruby title="receiver.rb"
require 'kubemq'
client = KubeMQ::QueuesClient.new(
address: 'localhost:50000',
client_id: 'order-receiver'
)
messages = client.receive_queue_messages(
channel: 'orders',
max_messages: 1,
wait_timeout_seconds: 10
)
messages.each do |msg|
puts "Received: #{msg.body}"
puts "Metadata: #{msg.metadata}"
end
client.close
```
```elixir title="receiver.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-receiver")
{:ok, result} =
KubeMQ.Client.receive_queue_messages(client, "orders",
max_messages: 1,
wait_timeout: 10_000
)
Enum.each(result.messages, fn msg ->
IO.puts("Received: #{msg.body}")
IO.puts("Metadata: #{msg.metadata}")
end)
KubeMQ.Client.close(client)
```
### Verify [#verify]
Run the sender first, then the receiver. You should see:
```text
Sent: id=
Received: {"orderId":"ORD-1234","total":99.99}
Metadata: order.created
Message acknowledged
```
## What Just Happened [#what-just-happened]
*Send → durable queue → poll & deliver to one receiver → acknowledge to remove.*
The full round-trip, step by step:
1. The **sender** connected to KubeMQ and placed an order message on the `orders` queue
2. The message was **stored durably** — it persists even if no receiver is connected yet
3. The **receiver** polled the queue and received the message (hidden from other consumers)
4. After processing, the receiver **acknowledged** the message, permanently removing it from the queue
Unlike Events, queue messages persist until acknowledged. If the receiver crashes before acknowledging, the message becomes available again after the visibility timeout expires.
## Next Steps [#next-steps]
# Queues — Point-to-Point Messaging (/learn/queues)
Think of Queues like a mailbox — you drop a letter in, and it waits safely until the recipient picks it up. Even if the recipient is away, the letter doesn't disappear. Each letter is delivered to exactly one person, and they confirm receipt by opening it.
KubeMQ Queues provide durable, point-to-point messaging with guaranteed delivery. Each message is persisted to a named queue and delivered to exactly one consumer, where it remains until that consumer explicitly acknowledges it. When several consumers poll the same queue, they form a pool of *competing consumers* — the broker hands each message to just one of them, load-balancing the work. Queues are ideal for task distribution, job processing, and any workflow where reliable, ordered, exactly-once processing is required.
## The concept it implements [#the-concept-it-implements]
Queues realize the **point-to-point** interaction style with **exactly-once processing**. To understand the underlying ideas first, see the Fundamentals layer:
* [Interaction styles → point-to-point](/learn/concepts/interaction-styles) — competing consumers, one message → one consumer.
* [Delivery guarantees → exactly-once](/learn/concepts/delivery-guarantees) — manual ack/nack, redelivery, and idempotency.
* [Scaling & flow → competing consumers](/learn/concepts/scaling-and-flow) — visibility timeout, backpressure, and adding consumers to drain a queue faster.
## Key Features [#key-features]
* **Guaranteed delivery** — messages persist in the queue until a consumer acknowledges them. No message is lost, even if consumers restart.
* **Exactly-once processing** — each message is delivered to a single consumer with manual acknowledgment, preventing duplicate processing.
* **FIFO ordering** — messages are delivered in the order they were sent, maintaining strict first-in, first-out semantics.
* **Visibility timeout** — while a consumer processes a message, it is hidden from other consumers. If not acknowledged within the timeout, the message becomes available again.
* **Dead Letter Queue (DLQ)** — messages that exceed a configurable retry count are automatically routed to a DLQ for inspection and recovery.
* **Delayed delivery** — schedule messages to become available after a specified delay, enabling deferred processing and retry patterns.
* **Batch operations** — send and receive multiple messages in a single request for high-throughput scenarios.
* **Peek without consuming** — inspect queue contents without removing messages, useful for monitoring and debugging.
* **Message expiration (TTL)** — set time-to-live on messages so unprocessed items auto-expire.
## How It Works [#how-it-works]
A queue is a durable, first-in-first-out buffer. Producers append messages to a named queue; a pool of consumers polls the same queue and competes for messages. The broker delivers each message to **exactly one** consumer, hides it from the others while it is being processed, and removes it only after that consumer acknowledges it.
*Producers append to the queue; competing consumers each receive a different message and confirm with a dotted acknowledgment.*
## Key Properties [#key-properties]
| Property | Behavior | Learn more |
| ---------------- | ---------------------------------------- | --------------------------------------------------------------- |
| Delivery | Exactly one consumer per message | [Send & Receive](/learn/queues/tutorials/send-receive) |
| Settlement | Manual `ack` / `nack` / requeue | [Ack, Nack & Requeue](/learn/queues/tutorials/ack-nack-requeue) |
| Visibility | Hidden from others during processing | [Visibility timeout](/learn/queues/how-to/visibility-timeout) |
| Failure handling | Redeliver, then route to DLQ | [Dead Letter Queue](/learn/queues/tutorials/dead-letter-queue) |
| Scheduling | Delay before a message becomes available | [Delayed Messages](/learn/queues/tutorials/delayed-messages) |
| Expiration | TTL auto-discards unprocessed messages | [Message expiration](/learn/queues/how-to/message-expiration) |
## Message Lifecycle [#message-lifecycle]
*A message moves from queued to delivered to acked; on failure it is requeued or, after exhausting retries, routed to a dead letter queue.*
## When to Use Queues [#when-to-use-queues]
| Scenario | Queues | Events / Events Store |
| ------------------------------------- | -------------------------- | ----------------------------------------- |
| Task & job distribution | ✅ Best choice | ❌ No load balancing per message |
| Exactly-once work processing | ✅ Best choice | ❌ At-most/at-least-once |
| Order-sensitive pipelines (FIFO) | ✅ Best choice | Use Events Store for replay |
| Retry with backoff & dead-letter | ✅ Best choice | ❌ Not built in |
| Real-time fan-out to many subscribers | ❌ One consumer per message | ✅ Use [Events](/learn/events) |
| Durable history / replay | ❌ Messages removed on ack | ✅ Use [Events Store](/learn/events-store) |
Need to broadcast every message to **all** listeners instead of distributing work? Use [Events](/learn/events). Need a durable, replayable log? Use [Events Store](/learn/events-store).
## Quick Example [#quick-example]
```go title="main.go"
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("queue-demo"),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
msg := kubemq.NewQueueMessage().
SetChannel("orders").
SetBody([]byte(`{"orderId":"ORD-1234","total":99.99}`))
result, err := client.SendQueueMessage(ctx, msg)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Sent: id=%s\n", result.MessageID)
resp, err := client.PollQueue(ctx, &kubemq.PollRequest{
Channel: "orders",
MaxItems: 1,
WaitTimeoutSeconds: 5,
AutoAck: false,
})
if err != nil {
log.Fatal(err)
}
for _, m := range resp.Messages {
fmt.Printf("Received: %s\n", string(m.Message.Body))
}
resp.AckAll()
}
```
```python title="main.py"
from kubemq.queues import Client as QueuesClient
from kubemq import QueueMessage
client = QueuesClient(
address="localhost:50000",
client_id="queue-demo",
)
result = client.send_queue_message(
QueueMessage(
channel="orders",
body=b'{"orderId":"ORD-1234","total":99.99}',
)
)
print(f"Sent: id={result.id}")
response = client.receive_queue_messages(
channel="orders",
max_messages=1,
wait_timeout_in_seconds=5,
)
for msg in response.messages:
print(f"Received: {msg.body.decode('utf-8')}")
msg.ack()
client.close()
```
```typescript title="main.ts"
import { KubeMQClient, createQueueMessage } from 'kubemq-js';
const client = await KubeMQClient.create({
address: 'localhost:50000',
clientId: 'queue-demo',
});
const result = await client.sendQueueMessage(
createQueueMessage({
channel: 'orders',
body: JSON.stringify({ orderId: 'ORD-1234', total: 99.99 }),
}),
);
console.log('Sent:', result.messageId);
const messages = await client.receiveQueueMessages({
channel: 'orders',
maxMessages: 1,
waitTimeoutSeconds: 5,
});
for (const msg of messages) {
console.log('Received:', new TextDecoder().decode(msg.body));
await msg.ack();
}
await client.close();
```
```java title="Main.java"
QueuesClient client = QueuesClient.builder()
.address("localhost:50000")
.clientId("queue-demo")
.build();
QueueMessage msg = QueueMessage.builder()
.channel("orders")
.body("{\"orderId\":\"ORD-1234\",\"total\":99.99}".getBytes())
.build();
SendQueueMessageResult result = client.sendQueueMessage(msg);
System.out.println("Sent: id=" + result.getMessageId());
ReceiveQueueMessagesResponse response = client.receiveQueueMessages(
ReceiveQueueMessagesRequest.builder()
.channel("orders")
.maxMessages(1)
.waitTimeoutSeconds(5)
.build());
for (QueueMessageReceived m : response.getMessages()) {
System.out.println("Received: " + new String(m.getBody()));
m.ack();
}
client.close();
```
```csharp title="Program.cs"
using KubeMQ.Sdk.Client;
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
var result = await client.SendQueueMessageAsync(new QueueMessage
{
Channel = "orders",
Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"total\":99.99}")
});
Console.WriteLine($"Sent: id={result.MessageId}");
var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest
{
Channel = "orders",
MaxMessages = 1,
WaitTimeoutSeconds = 5,
});
foreach (var msg in response.Messages)
{
Console.WriteLine($"Received: {Encoding.UTF8.GetString(msg.Body.Span)}");
await msg.AckAsync();
}
```
```kotlin title="Main.kt"
val client = QueuesClient("localhost:50000")
val result = client.sendQueueMessage(QueueMessage(
channel = "orders",
body = """{"orderId":"ORD-1234","total":99.99}""".toByteArray()
))
println("Sent: id=${result.messageId}")
val response = client.receiveQueueMessages(
channel = "orders",
maxMessages = 1,
waitTimeoutSeconds = 5
)
for (msg in response.messages) {
println("Received: ${String(msg.body)}")
msg.ack()
}
client.close()
```
```cpp title="main.cpp"
#include
#include
auto client = kubemq::QueuesClient("localhost:50000");
kubemq::QueueMessage msg;
msg.channel = "orders";
msg.body = R"({"orderId":"ORD-1234","total":99.99})";
auto result = client.sendQueueMessage(msg);
std::cout << "Sent: id=" << result.messageId << std::endl;
auto response = client.receiveQueueMessages("orders", 1, 5);
for (const auto& m : response.messages) {
std::cout << "Received: " << m.body << std::endl;
m.ack();
}
```
```rust title="main.rs"
use kubemq::prelude::*;
use kubemq::QueueMessageBuilder;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.client_id("queue-demo")
.build()
.await?;
let msg = QueueMessageBuilder::new()
.channel("orders")
.body(br#"{"orderId":"ORD-1234","total":99.99}"#.to_vec())
.build();
let result = client.send_queue_message(msg).await?;
println!("Sent: id={}", result.message_id);
// receive_queue_messages(channel, max_messages, wait_timeout_secs, auto_ack)
let messages = client
.receive_queue_messages("orders", 1, 5, false)
.await?;
for m in &messages {
println!("Received: {}", String::from_utf8_lossy(&m.body));
m.ack().await?;
}
client.close().await?;
Ok(())
}
```
```ruby title="main.rb"
require 'kubemq'
client = KubeMQ::QueuesClient.new(address: 'localhost:50000', client_id: 'queue-demo')
msg = KubeMQ::Queues::QueueMessage.new(
channel: 'orders',
body: '{"orderId":"ORD-1234","total":99.99}'
)
result = client.send_queue_message(msg)
puts "Sent: id=#{result.id}"
messages = client.receive_queue_messages(channel: 'orders', max_messages: 1, wait_timeout_seconds: 5)
messages.each do |m|
puts "Received: #{m.body}"
m.ack
end
client.close
```
```elixir title="main.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "queue-demo")
msg =
KubeMQ.QueueMessage.new(
channel: "orders",
body: ~s({"orderId":"ORD-1234","total":99.99})
)
{:ok, result} = KubeMQ.Client.send_queue_message(client, msg)
IO.puts("Sent: id=#{result.message_id}")
{:ok, response} =
KubeMQ.Client.receive_queue_messages(client, "orders",
max_messages: 1,
wait_timeout: 5_000
)
Enum.each(response.messages, fn m ->
IO.puts("Received: #{m.body}")
KubeMQ.Client.ack_queue_message(client, m)
end)
KubeMQ.Client.close(client)
```
Queues are also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/queues) — use any language with a CloudEvents SDK, no KubeMQ client library needed.
## Learn More [#learn-more]
# Queue Reference (/learn/queues/reference)
This reference documents every aspect of the KubeMQ Queues messaging pattern.
## Message Structure [#message-structure]
### Send Request [#send-request]
### Message Policy Fields [#message-policy-fields]
Policy fields control delivery behavior and are set per message at send time.
### Send Response [#send-response]
### Receive Response (per message) [#receive-response-per-message]
## Receive Request [#receive-request]
## Channel Name Rules [#channel-name-rules]
| Rule | Constraint | Error Code |
| --------------- | ------------------------- | ---------- |
| Required | Cannot be empty | 120 |
| No trailing dot | Cannot end with `.` | 119 |
| No whitespace | Cannot contain spaces | 108 |
| No wildcards | Cannot contain `*` or `>` | 107 |
Valid channel name pattern: `^[^\s*>]+[^.]$`
**Examples:**
* `orders` — valid
* `payments.us-east` — valid
* `orders processing` — invalid (contains space)
* `orders.` — invalid (trailing dot)
* `orders.*` — invalid (contains wildcard)
## Server Configuration [#server-configuration]
| Transport | Default Limit | Setting |
| --------- | ------------- | --------------------------- |
| gRPC | \~1 GB | `Connectors.Grpc.BodyLimit` |
| REST | 100 MB | `Connectors.Rest.BodyLimit` |
## Message Lifecycle [#message-lifecycle]
*A queue message moves through delay, availability, processing, and settlement — ending in removal, redelivery, expiration, or the dead letter queue.*
## Dead Letter Queue Behavior [#dead-letter-queue-behavior]
When a message exceeds the maximum receive count:
1. If `maxReceiveQueue` is set, the message is copied to the DLQ channel with:
* `reRouted` set to `true`
* `reRoutedFromQueue` set to the original channel name
* `receiveCount` reset to `0`
* Policy reset to server defaults
2. If `maxReceiveQueue` is not set, the message is silently discarded
3. The original message is acknowledged (removed from the source queue)
## Delayed Message Behavior [#delayed-message-behavior]
Messages with `delaySeconds > 0` follow this path:
1. Message is published to an internal delay channel (`_QUEUE_DELAY_`)
2. A background processor checks for expired delays every 500ms
3. When the delay elapses, the message is moved to the target queue
4. The `delaySeconds` and `delayedTo` attributes are cleared before delivery
5. If both delay and expiration are set, the expiration clock starts after the delay
## Error Codes [#error-codes]
### Queue-Specific Errors [#queue-specific-errors]
| Code | Error | Cause |
| ---- | ------------------------------------ | ------------------------------------------------- |
| 120 | Invalid queue name | Empty channel or invalid characters |
| 122 | Max messages exceeded config limit | Receive request exceeds `MaxNumberOfMessages` |
| 125 | Ack sequence cannot be 0 or negative | Invalid sequence in stream ack |
| 127 | Invalid visibility time | Visibility timeout exceeds `MaxVisibilitySeconds` |
| 133 | Invalid expiration seconds | Expiration exceeds `MaxExpirationSeconds` |
| 134 | Invalid max receive count | Max receive count exceeds server config |
| 135 | Invalid delay seconds | Delay exceeds `MaxDelaySeconds` |
| 136 | Invalid wait timeout | Wait timeout exceeds `MaxWaitTimeoutSeconds` |
| 139 | Invalid max items | Value must be at least 1 |
| 140 | Invalid wait time | Value must be at least 1 second |
### General Errors [#general-errors]
| Code | Error | Applies To |
| ---- | -------------------------------------- | ------------------------------ |
| 101 | Invalid clientID, cannot be empty | All patterns |
| 107 | Invalid channel, no wildcards allowed | All patterns |
| 108 | Invalid channel, no whitespace allowed | All patterns |
| 110 | Invalid message, cannot be empty | Body and metadata both missing |
| 119 | Invalid channel, cannot end with dot | All patterns |
## SDK Quick Reference [#sdk-quick-reference]
```go
// go get github.com/kubemq-io/kubemq-go/v2
msg := kubemq.NewQueueMessage().
SetChannel("orders").
SetBody([]byte("payload")).
SetMetadata("metadata").
SetTags(map[string]string{"key": "value"}).
SetDelaySeconds(10).
SetExpirationSeconds(300).
SetMaxReceiveCount(3).
SetMaxReceiveQueue("orders.dlq")
result, err := client.SendQueueMessage(ctx, msg)
results, err := client.SendQueueMessages(ctx, msgs)
resp, err := client.PollQueue(ctx, &kubemq.PollRequest{
Channel: "orders", MaxItems: 10, WaitTimeoutSeconds: 5,
AutoAck: false, VisibilitySeconds: 120, IsPeek: false,
})
resp.AckAll()
resp.NAckAll()
resp.ReQueueAll("other-channel")
```
```python
# pip install kubemq
from kubemq.queues import Client as QueuesClient
from kubemq import QueueMessage
result = client.send_queue_message(QueueMessage(
channel="orders", body=b"payload", metadata="metadata",
tags={"key": "value"}, delay_in_seconds=10,
expiration_in_seconds=300, max_receive_count=3,
max_receive_queue="orders.dlq",
))
response = client.receive_queue_messages(
channel="orders", max_messages=10, wait_timeout_in_seconds=5,
is_peek=False, visibility_seconds=120,
)
msg.ack()
msg.nack()
msg.requeue("other-channel")
```
```typescript
// npm install kubemq-js
import { KubeMQClient, createQueueMessage } from 'kubemq-js';
const result = await client.sendQueueMessage(createQueueMessage({
channel: 'orders', body: 'payload', metadata: 'metadata',
tags: { key: 'value' },
policy: { delaySeconds: 10, expirationSeconds: 300,
maxReceiveCount: 3, maxReceiveQueue: 'orders.dlq' },
}));
const msgs = await client.receiveQueueMessages({
channel: 'orders', maxMessages: 10, waitTimeoutSeconds: 5,
isPeek: false, visibilitySeconds: 120,
});
await msg.ack();
await msg.nack();
await msg.requeue('other-channel');
```
```java
// io.kubemq.sdk:kubemq-sdk-Java:2.1.1
QueueMessage msg = QueueMessage.builder()
.channel("orders").body("payload".getBytes())
.metadata("metadata").tags(Map.of("key", "value"))
.delaySeconds(10).expirationSeconds(300)
.maxReceiveCount(3).maxReceiveQueue("orders.dlq").build();
SendQueueMessageResult result = client.sendQueueMessage(msg);
ReceiveQueueMessagesResponse response = client.receiveQueueMessages(
ReceiveQueueMessagesRequest.builder()
.channel("orders").maxMessages(10).waitTimeoutSeconds(5)
.isPeek(false).visibilitySeconds(120).build());
msg.ack();
msg.nack();
msg.requeue("other-channel");
```
```csharp
// dotnet add package KubeMQ.SDK.CSharp
var result = await client.SendQueueMessageAsync(new QueueMessage {
Channel = "orders", Body = Encoding.UTF8.GetBytes("payload"),
Metadata = "metadata", Tags = new() { ["key"] = "value" },
DelaySeconds = 10, ExpirationSeconds = 300,
MaxReceiveCount = 3, MaxReceiveQueue = "orders.dlq"
});
var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest {
Channel = "orders", MaxMessages = 10, WaitTimeoutSeconds = 5,
IsPeek = false, VisibilitySeconds = 120,
});
await msg.AckAsync();
await msg.NAckAsync();
await msg.ReQueueAsync("other-channel");
```
```kotlin
// io.kubemq.sdk:kubemq-sdk-kotlin:2.1.0
val result = client.sendQueueMessage(QueueMessage(
channel = "orders", body = "payload".toByteArray(),
metadata = "metadata", tags = mapOf("key" to "value"),
delaySeconds = 10, expirationSeconds = 300,
maxReceiveCount = 3, maxReceiveQueue = "orders.dlq"
))
val response = client.receiveQueueMessages(
channel = "orders", maxMessages = 10, waitTimeoutSeconds = 5,
isPeek = false, visibilitySeconds = 120)
msg.ack()
msg.nack()
msg.requeue("other-channel")
```
```cpp
// vcpkg install kubemq
kubemq::QueueMessage msg;
msg.channel = "orders";
msg.body = "payload";
msg.metadata = "metadata";
msg.tags = {{"key", "value"}};
msg.delaySeconds = 10;
msg.expirationSeconds = 300;
msg.maxReceiveCount = 3;
msg.maxReceiveQueue = "orders.dlq";
auto result = client.sendQueueMessage(msg);
auto response = client.receiveQueueMessages("orders", 10, 5);
msg.ack();
msg.nack();
msg.requeue("other-channel");
```
```rust
// cargo add kubemq
use kubemq::prelude::*;
use kubemq::{PollRequest, QueueMessageBuilder};
let msg = QueueMessageBuilder::new()
.channel("orders")
.body(b"payload".to_vec())
.metadata("metadata")
.tags([("key", "value")])
.delay_seconds(10)
.expiration_seconds(300)
.max_receive_count(3)
.max_receive_queue("orders.dlq")
.build();
let result = client.send_queue_message(msg).await?;
// receive_queue_messages(channel, max_items, wait_timeout_seconds, is_peek)
let messages = client.receive_queue_messages("orders", 10, 5, false).await?;
// Stream poll for explicit settlement (ack / nack / re-queue)
let mut receiver = client.new_queue_downstream_receiver().await?;
let response = receiver.poll(PollRequest {
channel: "orders".to_string(),
max_items: 10, wait_timeout_seconds: 5, auto_ack: false,
}).await?;
response.ack_all().await?;
response.nack_all().await?;
for m in &response.messages { m.re_queue("other-channel").await?; }
```
```ruby
# gem install kubemq
require 'kubemq'
policy = KubeMQ::Queues::QueueMessagePolicy.new(
delay_seconds: 10, expiration_seconds: 300,
max_receive_count: 3, max_receive_queue: 'orders.dlq'
)
msg = KubeMQ::Queues::QueueMessage.new(
channel: 'orders', body: 'payload', metadata: 'metadata',
tags: { 'key' => 'value' }, policy: policy
)
result = client.send_queue_message(msg)
messages = client.receive_queue_messages(
channel: 'orders', max_messages: 10, wait_timeout_seconds: 5
)
# Stream poll for explicit settlement (ack / nack / requeue)
receiver = client.create_downstream_receiver
request = KubeMQ::Queues::QueuePollRequest.new(
channel: 'orders', max_items: 10, wait_timeout: 5
)
response = receiver.poll(request)
response.messages.each(&:ack)
response.messages.each(&:nack)
response.requeue_all(channel: 'other-channel')
```
```elixir
# {:kubemq, "~> 1.0"} in mix.exs
msg = KubeMQ.QueueMessage.new(
channel: "orders", body: "payload", metadata: "metadata",
tags: %{"key" => "value"},
policy: KubeMQ.QueuePolicy.new(
delay_seconds: 10, expiration_seconds: 300,
max_receive_count: 3, max_receive_queue: "orders.dlq"
)
)
{:ok, result} = KubeMQ.Client.send_queue_message(client, msg)
{:ok, received} = KubeMQ.Client.receive_queue_messages(client, "orders",
max_messages: 10, wait_timeout: 5_000)
# Stream poll for explicit settlement (ack / nack / requeue)
{:ok, poll} = KubeMQ.Client.poll_queue(client,
channel: "orders", max_items: 10, wait_timeout: 5_000)
KubeMQ.PollResponse.ack_all(poll)
KubeMQ.PollResponse.nack_all(poll)
KubeMQ.PollResponse.requeue_all(poll, "other-channel")
```
## Related Pages [#related-pages]
* [Queues Overview](/learn/queues) — Introduction and key features
* [Getting Started](/learn/queues/getting-started) — Step-by-step first message guide
* [Ack, Nack & Requeue](/learn/queues/tutorials/ack-nack-requeue) — Settlement options
* [Dead Letter Queue](/learn/queues/tutorials/dead-letter-queue) — DLQ routing and monitoring
* [Delayed Messages](/learn/queues/tutorials/delayed-messages) — Schedule future delivery
* [Visibility Timeout](/learn/queues/how-to/visibility-timeout) — Processing time configuration
* [Batch Operations](/learn/queues/tutorials/batch-operations) — High-throughput send and receive
# Getting Started with RPC (/learn/rpc/getting-started)
**Prerequisites:** KubeMQ server running on `localhost:50000` and your SDK installed. See [Getting Started](/deploy) for setup.
## What You Will Build [#what-you-will-build]
A command sender that creates an order and a query sender that retrieves order status — each paired with a responder that handles the request and returns a response.
## Steps [#steps]
### Install the SDK [#install-the-sdk]
```bash
go get github.com/kubemq-io/kubemq-go/v2
```
```bash
pip install kubemq
```
```bash
npm install kubemq-js
```
```xml
io.kubemq.sdk
kubemq-sdk-Java
2.1.1
```
```bash
dotnet add package KubeMQ.SDK.CSharp
```
```kotlin
implementation("io.kubemq.sdk:kubemq-sdk-kotlin:2.1.0")
```
```bash
vcpkg install kubemq
```
```toml
[dependencies]
kubemq = "1.0"
tokio = { version = "1", features = ["full"] }
```
```bash
gem install kubemq
```
```elixir
def deps do
[{:kubemq, "~> 1.0"}]
end
```
### Create a Command Responder [#create-a-command-responder]
Start the responder first. It subscribes to incoming commands on `orders.process`, processes them, and sends back an execution status.
```go title="command_responder.go"
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
sub, err := client.SubscribeToCommands(ctx, "orders.process", "",
kubemq.WithOnCommandReceive(func(cmd *kubemq.CommandReceive) {
fmt.Printf("Received command: %s\n", cmd.Body)
resp := kubemq.NewCommandReply().
SetRequestId(cmd.Id).
SetResponseTo(cmd.ResponseTo).
SetExecutedAt(time.Now())
_ = client.SendCommandResponse(ctx, resp)
}),
kubemq.WithOnError(func(err error) {
log.Println("Error:", err)
}),
)
if err != nil {
log.Fatal(err)
}
defer sub.Unsubscribe()
fmt.Println("Command responder listening on 'orders.process'...")
<-ctx.Done()
}
```
```python title="command_responder.py"
import time
from kubemq.cq import Client as CQClient
from kubemq.cq import CommandsSubscription, CommandReceived, CommandResponse, CancellationToken
def on_command(request: CommandReceived) -> None:
print(f"Received command: {request.body.decode('utf-8')}")
client.send_response_message(
CommandResponse(command_received=request, is_executed=True)
)
def on_error(err):
print(f"Error: {err}")
client = CQClient(address="localhost:50000")
cancel = CancellationToken()
client.subscribe_to_commands(
subscription=CommandsSubscription(
channel="orders.process",
on_receive_command_callback=on_command,
on_error_callback=on_error,
),
cancel=cancel,
)
print("Command responder listening on 'orders.process'...")
time.sleep(3600)
```
```javascript title="command_responder.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
client.subscribeToCommands({
channel: "orders.process",
onCommand: (cmd) => {
console.log("Received command:", Buffer.from(cmd.body).toString());
client.sendCommandResponse({ requestId: cmd.id, isExecuted: true });
},
onError: (err) => console.error("Error:", err.message),
});
console.log("Command responder listening on 'orders.process'...");
```
```java title="CommandResponder.java"
CQClient client = CQClient.builder()
.address("localhost:50000")
.clientId("order-responder")
.build();
client.subscribeToCommands(CommandsSubscription.builder()
.channel("orders.process")
.onReceiveCommandCallback(cmd -> {
System.out.println("Received: " + new String(cmd.getBody()));
return CommandResponseMessage.builder()
.requestId(cmd.getId())
.isExecuted(true)
.build();
})
.onErrorCallback(err -> System.err.println("Error: " + err.getMessage()))
.build());
System.out.println("Command responder listening on 'orders.process'...");
Thread.sleep(3600000);
client.close();
```
```csharp title="CommandResponder.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
Console.WriteLine("Command responder listening on 'orders.process'...");
await foreach (var cmd in client.SubscribeToCommandsAsync(
new CommandsSubscription { Channel = "orders.process" }))
{
Console.WriteLine($"Received: {Encoding.UTF8.GetString(cmd.Body.Span)}");
await client.SendCommandResponseAsync(new CommandResponse
{
RequestId = cmd.Id,
IsExecuted = true
});
}
```
```kotlin title="CommandResponder.kt"
val client = CQClient("localhost:50000")
client.subscribeToCommands(
channel = "orders.process",
onCommand = { cmd ->
println("Received command: ${String(cmd.body)}")
client.sendCommandResponse(
requestId = cmd.id, isExecuted = true
)
},
onError = { err -> System.err.println("Error: ${err.message}") }
)
println("Command responder listening on 'orders.process'...")
Thread.sleep(3600000)
client.close()
```
```cpp title="command_responder.cpp"
#include
#include
#include
auto client = kubemq::CQClient("localhost:50000");
client.subscribeToCommands("orders.process", "",
[&client](const kubemq::CommandReceive& cmd) {
std::cout << "Received command: " << cmd.body << std::endl;
client.sendCommandResponse(cmd.id, true);
},
[](const std::string& err) {
std::cerr << "Error: " << err << std::endl;
}
);
std::cout << "Command responder listening on 'orders.process'..." << std::endl;
std::this_thread::sleep_for(std::chrono::hours(1));
```
```rust title="command_responder.rs"
use kubemq::prelude::*;
use kubemq::CommandReplyBuilder;
use std::time::Duration;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let rc = client.clone();
let sub = client
.subscribe_to_commands(
"orders.process",
"",
move |cmd| {
let c = rc.clone();
Box::pin(async move {
println!("Received command: {}", String::from_utf8_lossy(&cmd.body));
let reply = CommandReplyBuilder::new()
.request_id(&cmd.id)
.response_to(&cmd.response_to)
.build();
tokio::spawn(async move {
let _ = c.send_command_response(reply).await;
});
})
},
None,
)
.await?;
println!("Command responder listening on 'orders.process'...");
tokio::signal::ctrl_c().await.ok();
sub.unsubscribe().await;
client.close().await?;
Ok(())
}
```
```ruby title="command_responder.rb"
require 'kubemq'
client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-responder')
cancel = KubeMQ::CancellationToken.new
sub = KubeMQ::CQ::CommandsSubscription.new(channel: 'orders.process')
client.subscribe_to_commands(sub, cancellation_token: cancel,
on_error: ->(e) { puts "Error: #{e.message}" }) do |cmd|
puts "Received command: #{cmd.body}"
response = KubeMQ::CQ::CommandResponseMessage.new(
request_id: cmd.id,
reply_channel: cmd.reply_channel,
executed: true
)
client.send_response(response)
end
puts "Command responder listening on 'orders.process'..."
cancel.wait
```
```elixir title="command_responder.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-responder")
{:ok, _sub} =
KubeMQ.Client.subscribe_to_commands(client, "orders.process",
on_command: fn cmd ->
IO.puts("Received command: #{cmd.body}")
KubeMQ.CommandReply.new(
request_id: cmd.id,
response_to: cmd.reply_channel,
executed: true
)
end,
on_error: fn err -> IO.puts("Error: #{err.message}") end
)
IO.puts("Command responder listening on 'orders.process'...")
Process.sleep(:infinity)
```
### Send a Command [#send-a-command]
In a separate terminal, send a command. The sender blocks until the responder replies or the timeout expires.
```go title="send_command.go"
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
resp, err := client.SendCommand(ctx, kubemq.NewCommand().
SetChannel("orders.process").
SetBody([]byte(`{"action":"create","orderId":"ORD-1234"}`)).
SetTimeout(10 * time.Second))
if err != nil {
log.Fatal(err)
}
log.Printf("Executed: %v", resp.Executed)
```
```python title="send_command.py"
from kubemq.cq import Client as CQClient
from kubemq.cq import CommandMessage
with CQClient(address="localhost:50000") as client:
response = client.send_command(
CommandMessage(
channel="orders.process",
body=b'{"action":"create","orderId":"ORD-1234"}',
timeout_in_seconds=10,
)
)
print(f"Executed: {response.is_executed}")
```
```javascript title="send_command.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
const response = await client.sendCommand({
channel: "orders.process",
body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-1234" })),
timeoutInSeconds: 10,
});
console.log("Executed:", response.isExecuted);
```
```java title="SendCommand.java"
CQClient client = CQClient.builder()
.address("localhost:50000")
.clientId("order-sender")
.build();
CommandResponseMessage response = client.sendCommandRequest(
CommandMessage.builder()
.channel("orders.process")
.body("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}".getBytes())
.timeout(10000)
.build());
System.out.println("Executed: " + response.isExecuted());
client.close();
```
```csharp title="SendCommand.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
var response = await client.SendCommandAsync(new CommandMessage
{
Channel = "orders.process",
Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}"),
Timeout = TimeSpan.FromSeconds(10)
});
Console.WriteLine($"Executed: {response.IsExecuted}");
```
```kotlin title="SendCommand.kt"
val client = CQClient("localhost:50000")
val response = client.sendCommand(CommandMessage(
channel = "orders.process",
body = """{"action":"create","orderId":"ORD-1234"}""".toByteArray(),
timeout = 10000
))
println("Executed: ${response.isExecuted}")
client.close()
```
```cpp title="send_command.cpp"
#include
#include
auto client = kubemq::CQClient("localhost:50000");
kubemq::CommandMessage cmd;
cmd.channel = "orders.process";
cmd.body = R"({"action":"create","orderId":"ORD-1234"})";
cmd.timeout = 10000;
auto response = client.sendCommand(cmd);
std::cout << "Executed: " << response.isExecuted << std::endl;
```
```rust title="send_command.rs"
use kubemq::prelude::*;
use kubemq::CommandBuilder;
use std::time::Duration;
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let command = CommandBuilder::new()
.channel("orders.process")
.body(br#"{"action":"create","orderId":"ORD-1234"}"#.to_vec())
.timeout(Duration::from_secs(10))
.build();
let response = client.send_command(command).await?;
println!("Executed: {}", response.executed);
client.close().await?;
```
```ruby title="send_command.rb"
require 'kubemq'
client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-sender')
msg = KubeMQ::CQ::CommandMessage.new(
channel: 'orders.process',
timeout: 10,
body: '{"action":"create","orderId":"ORD-1234"}'
)
result = client.send_command(msg)
puts "Executed: #{result.executed}"
client.close
```
```elixir title="send_command.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-sender")
command =
KubeMQ.Command.new(
channel: "orders.process",
body: ~s({"action":"create","orderId":"ORD-1234"}),
timeout: 10_000
)
{:ok, response} = KubeMQ.Client.send_command(client, command)
IO.puts("Executed: #{response.executed}")
KubeMQ.Client.close(client)
```
**Expected output (sender):** `Executed: true`
**Expected output (responder):** `Received command: {"action":"create","orderId":"ORD-1234"}`
### Create a Query Responder [#create-a-query-responder]
Queries work like commands but the responder can include data in the response body. Set up a query responder on `orders.lookup`.
```go title="query_responder.go"
sub, err := client.SubscribeToQueries(ctx, "orders.lookup", "",
kubemq.WithOnQueryReceive(func(query *kubemq.QueryReceive) {
fmt.Printf("Query for: %s\n", query.Body)
resp := kubemq.NewQueryReply().
SetRequestId(query.Id).
SetResponseTo(query.ResponseTo).
SetBody([]byte(`{"orderId":"ORD-1234","status":"shipped","total":99.99}`)).
SetExecutedAt(time.Now())
_ = client.SendQueryResponse(ctx, resp)
}),
kubemq.WithOnError(func(err error) {
log.Println("Error:", err)
}),
)
```
```python title="query_responder.py"
from kubemq.cq import QueriesSubscription, QueryReceived, QueryResponse
def on_query(request: QueryReceived) -> None:
print(f"Query for: {request.body.decode('utf-8')}")
client.send_response_message(
QueryResponse(
query_received=request,
is_executed=True,
body=b'{"orderId":"ORD-1234","status":"shipped","total":99.99}',
)
)
client.subscribe_to_queries(
subscription=QueriesSubscription(
channel="orders.lookup",
on_receive_query_callback=on_query,
on_error_callback=on_error,
),
cancel=cancel,
)
```
```javascript title="query_responder.js"
client.subscribeToQueries({
channel: "orders.lookup",
onQuery: (query) => {
console.log("Query for:", Buffer.from(query.body).toString());
client.sendQueryResponse({
requestId: query.id,
isExecuted: true,
body: Buffer.from(
JSON.stringify({ orderId: "ORD-1234", status: "shipped", total: 99.99 })
),
});
},
onError: (err) => console.error("Error:", err.message),
});
```
```java title="QueryResponder.java"
client.subscribeToQueries(QueriesSubscription.builder()
.channel("orders.lookup")
.onReceiveQueryCallback(query -> {
System.out.println("Query for: " + new String(query.getBody()));
return QueryResponseMessage.builder()
.requestId(query.getId())
.isExecuted(true)
.body("{\"orderId\":\"ORD-1234\",\"status\":\"shipped\",\"total\":99.99}".getBytes())
.build();
})
.onErrorCallback(err -> System.err.println("Error: " + err.getMessage()))
.build());
```
```csharp title="QueryResponder.cs"
await foreach (var query in client.SubscribeToQueriesAsync(
new QueriesSubscription { Channel = "orders.lookup" }))
{
Console.WriteLine($"Query for: {Encoding.UTF8.GetString(query.Body.Span)}");
await client.SendQueryResponseAsync(new QueryResponse
{
RequestId = query.Id,
IsExecuted = true,
Body = Encoding.UTF8.GetBytes(
"{\"orderId\":\"ORD-1234\",\"status\":\"shipped\",\"total\":99.99}")
});
}
```
```kotlin title="QueryResponder.kt"
client.subscribeToQueries(
channel = "orders.lookup",
onQuery = { query ->
println("Query for: ${String(query.body)}")
client.sendQueryResponse(
requestId = query.id,
isExecuted = true,
body = """{"orderId":"ORD-1234","status":"shipped","total":99.99}""".toByteArray()
)
},
onError = { err -> System.err.println("Error: ${err.message}") }
)
```
```cpp title="query_responder.cpp"
client.subscribeToQueries("orders.lookup", "",
[&client](const kubemq::QueryReceive& query) {
std::cout << "Query for: " << query.body << std::endl;
client.sendQueryResponse(query.id, true,
R"({"orderId":"ORD-1234","status":"shipped","total":99.99})");
},
[](const std::string& err) {
std::cerr << "Error: " << err << std::endl;
}
);
```
```rust title="query_responder.rs"
use kubemq::QueryReplyBuilder;
let rc = client.clone();
let sub = client
.subscribe_to_queries(
"orders.lookup",
"",
move |query| {
let c = rc.clone();
Box::pin(async move {
println!("Query for: {}", String::from_utf8_lossy(&query.body));
let reply = QueryReplyBuilder::new()
.request_id(&query.id)
.response_to(&query.response_to)
.body(br#"{"orderId":"ORD-1234","status":"shipped","total":99.99}"#.to_vec())
.build();
tokio::spawn(async move {
let _ = c.send_query_response(reply).await;
});
})
},
None,
)
.await?;
```
```ruby title="query_responder.rb"
sub = KubeMQ::CQ::QueriesSubscription.new(channel: 'orders.lookup')
client.subscribe_to_queries(sub, cancellation_token: cancel,
on_error: ->(e) { puts "Error: #{e.message}" }) do |query|
puts "Query for: #{query.body}"
response = KubeMQ::CQ::QueryResponseMessage.new(
request_id: query.id,
reply_channel: query.reply_channel,
executed: true,
body: '{"orderId":"ORD-1234","status":"shipped","total":99.99}'
)
client.send_response(response)
end
```
```elixir title="query_responder.exs"
{:ok, _sub} =
KubeMQ.Client.subscribe_to_queries(client, "orders.lookup",
on_query: fn query ->
IO.puts("Query for: #{query.body}")
KubeMQ.QueryReply.new(
request_id: query.id,
response_to: query.reply_channel,
executed: true,
body: ~s({"orderId":"ORD-1234","status":"shipped","total":99.99})
)
end,
on_error: fn err -> IO.puts("Error: #{err.message}") end
)
```
### Send a Query [#send-a-query]
Send a query to retrieve order data. Unlike commands, the response body is preserved.
```go title="send_query.go"
resp, err := client.SendQuery(ctx, kubemq.NewQuery().
SetChannel("orders.lookup").
SetBody([]byte("ORD-1234")).
SetTimeout(10 * time.Second))
if err != nil {
log.Fatal(err)
}
log.Printf("Order data: %s", resp.Body)
```
```python title="send_query.py"
from kubemq.cq import Client as CQClient
from kubemq.cq import QueryMessage
with CQClient(address="localhost:50000") as client:
response = client.send_query(
QueryMessage(
channel="orders.lookup",
body=b"ORD-1234",
timeout_in_seconds=10,
)
)
print(f"Order data: {response.body.decode('utf-8')}")
```
```javascript title="send_query.js"
const response = await client.sendQuery({
channel: "orders.lookup",
body: Buffer.from("ORD-1234"),
timeoutInSeconds: 10,
});
console.log("Order data:", Buffer.from(response.body).toString());
```
```java title="SendQuery.java"
QueryResponseMessage response = client.sendQueryRequest(
QueryMessage.builder()
.channel("orders.lookup")
.body("ORD-1234".getBytes())
.timeout(10000)
.build());
System.out.println("Order data: " + new String(response.getBody()));
```
```csharp title="SendQuery.cs"
var response = await client.SendQueryAsync(new QueryMessage
{
Channel = "orders.lookup",
Body = Encoding.UTF8.GetBytes("ORD-1234"),
Timeout = TimeSpan.FromSeconds(10)
});
Console.WriteLine($"Order data: {Encoding.UTF8.GetString(response.Body.Span)}");
```
```kotlin title="SendQuery.kt"
val response = client.sendQuery(QueryMessage(
channel = "orders.lookup",
body = "ORD-1234".toByteArray(),
timeout = 10000
))
println("Order data: ${String(response.body)}")
```
```cpp title="send_query.cpp"
kubemq::QueryMessage query;
query.channel = "orders.lookup";
query.body = "ORD-1234";
query.timeout = 10000;
auto response = client.sendQuery(query);
std::cout << "Order data: " << response.body << std::endl;
```
```rust title="send_query.rs"
use kubemq::QueryBuilder;
use std::time::Duration;
let query = QueryBuilder::new()
.channel("orders.lookup")
.body(b"ORD-1234".to_vec())
.timeout(Duration::from_secs(10))
.build();
let response = client.send_query(query).await?;
println!("Order data: {}", String::from_utf8_lossy(&response.body));
```
```ruby title="send_query.rb"
msg = KubeMQ::CQ::QueryMessage.new(
channel: 'orders.lookup',
timeout: 10,
body: 'ORD-1234'
)
result = client.send_query(msg)
puts "Order data: #{result.body}"
```
```elixir title="send_query.exs"
query =
KubeMQ.Query.new(
channel: "orders.lookup",
body: "ORD-1234",
timeout: 10_000
)
{:ok, response} = KubeMQ.Client.send_query(client, query)
IO.puts("Order data: #{response.body}")
```
Unlike commands, query responses preserve the full response body and metadata. This makes queries ideal for data retrieval operations.
### Run the Example [#run-the-example]
1. Start the **responder** in one terminal (handles both commands and queries)
2. Run the **command sender** in a separate terminal
3. Run the **query sender** in a separate terminal
4. Observe the responses in each terminal
## What Just Happened [#what-just-happened]
1. The **responder** subscribed to both command and query channels
2. The **sender** sent a command — the responder processed it and returned `Executed: true` (response body stripped)
3. The **sender** sent a query — the responder returned the full order data in the response body (preserved)
## Commands vs Queries Summary [#commands-vs-queries-summary]
| Aspect | Command | Query |
| --------------------- | -------------------------- | ------------------------------ |
| **Response body** | Stripped (always `nil`) | Preserved |
| **Response metadata** | Stripped | Preserved |
| **CacheHit field** | Stripped | Preserved |
| **Use for** | Writes, mutations, actions | Reads, lookups, data retrieval |
## Next Steps [#next-steps]
# RPC — Commands & Queries (/learn/rpc)
Think of RPC like a phone call — you dial a number, ask a question, and wait on the line for an answer. If nobody picks up within a set time, you hang up and try again. KubeMQ RPC brings this synchronous request-reply model to messaging infrastructure.
KubeMQ implements RPC through two complementary operation types following the CQRS (Command Query Responsibility Segregation) principle: **Commands** for writes and **Queries** for reads.
**The concept it implements.** RPC is KubeMQ's realization of the [request/reply interaction style](/learn/concepts/interaction-styles) — the sender blocks on a single matched response. The timeout-and-retry behavior maps to the [delivery guarantees](/learn/concepts/delivery-guarantees) you choose at the application level: a request either gets exactly one answer or a timeout error.
## Commands vs Queries [#commands-vs-queries]
| Aspect | Commands | Queries |
| --------------------- | ------------------------------------------------ | ------------------------------------------------- |
| **Purpose** | State-changing operations (writes, mutations) | Read-only operations (data lookups) |
| **Response body** | Stripped — sender receives only execution status | Preserved — sender receives full response payload |
| **Response metadata** | Stripped | Preserved |
| **Caching** | Not supported | Supported via `CacheKey` / `CacheTTL` |
| **Use cases** | Order placement, device control, config changes | Data lookups, status checks, service reads |
Commands tell the system to **do something** and return only a success/failure indicator. Queries **ask for data** and return the full response body and metadata.
## How It Works [#how-it-works]
*Request/reply: the sender blocks while KubeMQ routes the request to a responder and delivers the single matched response back.*
The sender publishes a request to a named channel with a timeout. KubeMQ routes the request to a subscribed responder (or load-balances across a group of responders). The responder processes the request and sends a response back through KubeMQ. If no response arrives before the timeout expires, the sender receives a timeout error.
## Key Features [#key-features]
* **Synchronous request-reply** — sender blocks until a response arrives or timeout expires
* **Commands and Queries** — separate semantics for writes and reads following CQRS
* **Response caching** — server-side caching for queries with configurable TTL
* **Load balancing** — distribute requests across multiple responders using queue groups
* **Configurable timeouts** — per-request timeout in milliseconds
* **gRPC and REST** — use any transport protocol
## Quick Example [#quick-example]
```go title="send_command.go"
package main
import (
"context"
"log"
"time"
"github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx := context.Background()
client, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
resp, err := client.SendCommand(ctx, kubemq.NewCommand().
SetChannel("orders.process").
SetBody([]byte(`{"action":"create","orderId":"ORD-1234"}`)).
SetTimeout(10 * time.Second))
if err != nil {
log.Fatal(err)
}
log.Printf("Command executed: %v", resp.Executed)
}
```
```python title="send_command.py"
from kubemq.cq import Client as CQClient
from kubemq.cq import CommandMessage
client = CQClient(address="localhost:50000")
response = client.send_command(
CommandMessage(
channel="orders.process",
body=b'{"action":"create","orderId":"ORD-1234"}',
timeout_in_seconds=10,
)
)
print(f"Command executed: {response.is_executed}")
client.close()
```
```javascript title="send_command.js"
const { KubeMQClient } = require("kubemq-js");
const client = new KubeMQClient({ address: "localhost:50000" });
const response = await client.sendCommand({
channel: "orders.process",
body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-1234" })),
timeoutInSeconds: 10,
});
console.log("Command executed:", response.isExecuted);
```
```java title="SendCommand.java"
CQClient client = CQClient.builder()
.address("localhost:50000")
.clientId("order-service")
.build();
CommandResponseMessage response = client.sendCommandRequest(
CommandMessage.builder()
.channel("orders.process")
.body("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}".getBytes())
.timeout(10000)
.build());
System.out.println("Executed: " + response.isExecuted());
client.close();
```
```csharp title="SendCommand.cs"
await using var client = new KubeMQClient(new KubeMQClientOptions());
await client.ConnectAsync();
var response = await client.SendCommandAsync(new CommandMessage
{
Channel = "orders.process",
Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}"),
Timeout = TimeSpan.FromSeconds(10)
});
Console.WriteLine($"Executed: {response.IsExecuted}");
```
```kotlin title="SendCommand.kt"
val client = CQClient("localhost:50000")
val response = client.sendCommand(CommandMessage(
channel = "orders.process",
body = """{"action":"create","orderId":"ORD-1234"}""".toByteArray(),
timeout = 10000
))
println("Command executed: ${response.isExecuted}")
client.close()
```
```cpp title="send_command.cpp"
#include
#include
auto client = kubemq::CQClient("localhost:50000");
kubemq::CommandMessage cmd;
cmd.channel = "orders.process";
cmd.body = R"({"action":"create","orderId":"ORD-1234"})";
cmd.timeout = 10000;
auto response = client.sendCommand(cmd);
std::cout << "Command executed: " << response.isExecuted << std::endl;
```
```rust title="send_command.rs"
use kubemq::prelude::*;
use kubemq::CommandBuilder;
use std::time::Duration;
#[tokio::main]
async fn main() -> kubemq::Result<()> {
let client = KubemqClient::builder()
.host("localhost")
.port(50000)
.build()
.await?;
let command = CommandBuilder::new()
.channel("orders.process")
.body(br#"{"action":"create","orderId":"ORD-1234"}"#.to_vec())
.timeout(Duration::from_secs(10))
.build();
let response = client.send_command(command).await?;
println!("Command executed: {}", response.executed);
client.close().await?;
Ok(())
}
```
```ruby title="send_command.rb"
require 'kubemq'
client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-service')
msg = KubeMQ::CQ::CommandMessage.new(
channel: 'orders.process',
body: '{"action":"create","orderId":"ORD-1234"}',
timeout: 10
)
result = client.send_command(msg)
puts "Command executed: #{result.executed}"
client.close
```
```elixir title="send_command.exs"
{:ok, client} =
KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-service")
command =
KubeMQ.Command.new(
channel: "orders.process",
body: ~s({"action":"create","orderId":"ORD-1234"}),
timeout: 10_000
)
case KubeMQ.Client.send_command(client, command) do
{:ok, response} -> IO.puts("Command executed: #{response.executed}")
{:error, err} -> IO.puts("Command failed: #{err.message}")
end
KubeMQ.Client.close(client)
```
## When to Use RPC [#when-to-use-rpc]
| Scenario | RPC | Events / Queues |
| ---------------------------- | -------------------- | ---------------------- |
| Service-to-service API calls | ✅ Best choice | Not suitable |
| Data lookups and reads | ✅ Queries | Possible but awkward |
| Write confirmations | ✅ Commands | Queues with ack |
| CQRS implementation | ✅ Commands + Queries | Events for projections |
| Fire-and-forget broadcasts | ❌ Blocks on response | ✅ Use Events |
| Reliable async processing | ❌ Blocks on response | ✅ Use Queues |
Need fire-and-forget delivery? Use [Events](/learn/events) for broadcasts or [Queues](/learn/queues) for reliable processing.
Commands and queries are also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/commands-queries) — use any language with a CloudEvents SDK, no KubeMQ client library needed.
## Learn More [#learn-more]
# Commands & Queries Reference (/learn/rpc/reference)
## Request Model [#request-model]
Commands and queries share the same request structure. The `RequestTypeData` field determines whether the request is treated as a command or query.
## Response Model [#response-model]
## Command vs Query Response Differences [#command-vs-query-response-differences]
| Response Field | Command | Query |
| -------------- | ------------------------- | ---------------------- |
| `Body` | Always `nil` (stripped) | Preserved |
| `Metadata` | Always `""` (stripped) | Preserved |
| `CacheHit` | Always `false` (stripped) | Preserved |
| `ReplyChannel` | Always `""` (stripped) | Always `""` (stripped) |
| `Executed` | Preserved | Preserved |
| `Error` | Preserved | Preserved |
## Validation Rules [#validation-rules]
### Channel Name [#channel-name]
| Rule | Constraint | Error Code |
| --------------- | ------------------------- | ---------- |
| Required | Cannot be empty | 102 |
| No trailing dot | Cannot end with `.` | 119 |
| No whitespace | Cannot contain spaces | 108 |
| No wildcards | Cannot contain `*` or `>` | 107 |
Valid channel name regex: `^[^\s*>]+[^.]$`
### Client ID [#client-id]
| Rule | Constraint | Error Code |
| ------------ | ----------------------------- | ---------- |
| Required | Cannot be empty | 101 |
| Alphanumeric | Must match `^[a-zA-Z0-9_-]+$` | — |
### Request Content [#request-content]
At least one of `Body` or `Metadata` must be provided. If both are empty, the request is rejected with error code 115.
### Timeout [#timeout]
| Rule | Constraint | Error Code |
| -------- | ------------------------- | ---------- |
| Required | Must be greater than zero | 109 |
### Cache (Queries Only) [#cache-queries-only]
| Rule | Constraint | Error Code |
| ----------------- | -------------------------------------------- | ---------- |
| CacheTTL required | If `CacheKey` is set, `CacheTTL` must be > 0 | 116 |
## Subscription Model [#subscription-model]
### Consumer Groups [#consumer-groups]
When multiple responders specify the same `group` value on the same channel:
* Each request is delivered to exactly **one** member of the group (round-robin)
* When `group` is empty, every responder receives every request (fan-out)
* Groups are independent per channel
* There is no limit on the number of group members
See [Load Balancing](/learn/rpc/how-to/load-balancing) for examples.
## Caching Configuration [#caching-configuration]
KubeMQ provides server-side response caching for queries. When a query includes a `CacheKey` and `CacheTTL`:
1. KubeMQ checks the in-memory cache for an entry matching `CacheKey`
2. **Cache hit:** Returns the cached response with `CacheHit: true` (responder is not called)
3. **Cache miss:** Routes the query to a responder, caches the response, and returns it with `CacheHit: false`
| Setting | Value |
| ------------------ | -------------------------------------------------- |
| Storage | In-memory TTL cache |
| Default expiration | Per-entry, specified by `CacheTTL` in milliseconds |
| Cleanup interval | Every 10 seconds |
| Persistence | None — cache is cleared on server restart |
Commands do **not** support caching — the `CacheKey` and `CacheTTL` fields are ignored for command requests.
See [Query Caching](/learn/rpc/tutorials/query-caching) for a step-by-step tutorial.
## Transport Protocols [#transport-protocols]
### gRPC [#grpc]
| Method | Type | Description |
| ------------------------------------------------- | ------------- | -------------------------------- |
| `SendRequest(Request) → Response` | Unary | Send command or query |
| `SendResponse(Response) → Empty` | Unary | Send response back to requester |
| `SubscribeToRequests(Subscribe) → stream Request` | Server stream | Subscribe to commands or queries |
Default port: `50000`
### REST [#rest]
| Method | Path | Description |
| ------ | --------------------- | ------------------------------------------- |
| `POST` | `/send/request` | Send command or query |
| `POST` | `/send/response` | Send RPC response |
| `GET` | `/subscribe/requests` | WebSocket: subscribe to commands or queries |
Default port: `9090`
REST subscription query parameters:
| Parameter | Description | Example |
| ---------------- | ----------------------- | ---------------- |
| `client_id` | Client identifier | `my-responder` |
| `channel` | Channel name | `orders.process` |
| `group` | Load balancing group | `workers` |
| `subscribe_type` | `commands` or `queries` | `commands` |
## Internal Channel Mapping [#internal-channel-mapping]
| Pattern | Channel Prefix | Example |
| -------- | -------------- | ---------------------------- |
| Commands | `_COMMANDS_.` | `_COMMANDS_.orders.process` |
| Queries | `_QUERIES_.` | `_QUERIES_.inventory.lookup` |
## Middleware Chain [#middleware-chain]
### Command Sender [#command-sender]
```text
Request → Logging → Monitor → Metrics → broker request
```
### Query Sender [#query-sender]
```text
Request → Logging → Monitor → Cache → Metrics → broker request
```
### Receiver (Commands and Queries) [#receiver-commands-and-queries]
```text
broker queue-subscribe → Logging → reqCh delivery
```
## Error Codes [#error-codes]
### Input Validation [#input-validation]
| Code | Error | Description |
| ---- | ----------------- | ------------------------------------------------ |
| 101 | Invalid ClientID | ClientID is empty |
| 102 | Invalid Channel | Channel is empty |
| 107 | Invalid Channel | Channel contains wildcards (`*` or `>`) |
| 108 | Invalid Channel | Channel contains whitespace |
| 109 | Invalid Timeout | Timeout is zero or negative |
| 115 | Invalid Request | Both body and metadata are empty |
| 116 | Invalid CacheTTL | CacheKey is set but CacheTTL is zero or negative |
| 117 | Invalid RequestID | RequestID is empty (on response) |
| 119 | Invalid Channel | Channel ends with `.` |
### Runtime Errors [#runtime-errors]
| Code | Error | Description |
| ---- | ----------------------- | ------------------------------------------------ |
| 206 | Invalid Request Type | Request type is not Command or Query |
| 207 | Invalid Subscribe Type | Subscribe type is not Commands or Queries |
| 301 | Request Timeout | No reply received before timeout expired |
| 302 | Connection Unavailable | broker connection is down |
| 303 | Invalid Response Format | Reply data cannot be unmarshaled |
| 409 | Shutdown Mode | Server is shutting down, all operations rejected |
| 412 | Access Denied | Authorization denied for the resource |
## Delivery Semantics [#delivery-semantics]
| Aspect | Behavior |
| ------------------ | ----------------------------------------------------------------- |
| Delivery guarantee | **At-most-once** (request is sent once, no automatic retry) |
| Ordering | Requests are independent (no ordering guarantee between requests) |
| Timeout | Sender blocks until response arrives or timeout expires |
| Acknowledgment | Implicit — response is the acknowledgment |
## SDK Quick Reference [#sdk-quick-reference]
```go
// Send Command
client.SendCommand(ctx, kubemq.NewCommand().
SetChannel("ch").SetBody([]byte("data")).SetTimeout(10*time.Second))
// Send Query
client.SendQuery(ctx, kubemq.NewQuery().
SetChannel("ch").SetBody([]byte("data")).SetTimeout(10*time.Second))
// Send Query with Cache
client.SendQuery(ctx, kubemq.NewQuery().
SetChannel("ch").SetBody([]byte("data")).SetTimeout(10*time.Second).
SetCacheKey("key").SetCacheTTL(60*time.Second))
// Subscribe to Commands
client.SubscribeToCommands(ctx, "ch", "group",
kubemq.WithOnCommandReceive(handler),
kubemq.WithOnError(errHandler))
// Subscribe to Queries
client.SubscribeToQueries(ctx, "ch", "group",
kubemq.WithOnQueryReceive(handler),
kubemq.WithOnError(errHandler))
```
```python
# Send Command
client.send_command(CommandMessage(
channel="ch", body=b"data", timeout_in_seconds=10))
# Send Query
client.send_query(QueryMessage(
channel="ch", body=b"data", timeout_in_seconds=10))
# Send Query with Cache
client.send_query(QueryMessage(
channel="ch", body=b"data", timeout_in_seconds=10,
cache_key="key", cache_ttl_in_seconds=60))
# Subscribe to Commands
client.subscribe_to_commands(CommandsSubscription(
channel="ch", group="group",
on_receive_command_callback=handler,
on_error_callback=err_handler), cancel=cancel)
# Subscribe to Queries
client.subscribe_to_queries(QueriesSubscription(
channel="ch", group="group",
on_receive_query_callback=handler,
on_error_callback=err_handler), cancel=cancel)
```
```javascript
// Send Command
await client.sendCommand({
channel: "ch", body: Buffer.from("data"), timeoutInSeconds: 10 });
// Send Query
await client.sendQuery({
channel: "ch", body: Buffer.from("data"), timeoutInSeconds: 10 });
// Send Query with Cache
await client.sendQuery({
channel: "ch", body: Buffer.from("data"), timeoutInSeconds: 10,
cacheKey: "key", cacheTTL: 60000 });
// Subscribe to Commands
client.subscribeToCommands({
channel: "ch", group: "group",
onCommand: handler, onError: errHandler });
// Subscribe to Queries
client.subscribeToQueries({
channel: "ch", group: "group",
onQuery: handler, onError: errHandler });
```
```java
// Send Command
client.sendCommandRequest(CommandMessage.builder()
.channel("ch").body("data".getBytes()).timeout(10000).build());
// Send Query
client.sendQueryRequest(QueryMessage.builder()
.channel("ch").body("data".getBytes()).timeout(10000).build());
// Send Query with Cache
client.sendQueryRequest(QueryMessage.builder()
.channel("ch").body("data".getBytes()).timeout(10000)
.cacheKey("key").cacheTTL(60000).build());
// Subscribe to Commands
client.subscribeToCommands(CommandsSubscription.builder()
.channel("ch").group("group")
.onReceiveCommandCallback(handler)
.onErrorCallback(errHandler).build());
// Subscribe to Queries
client.subscribeToQueries(QueriesSubscription.builder()
.channel("ch").group("group")
.onReceiveQueryCallback(handler)
.onErrorCallback(errHandler).build());
```
```csharp
// Send Command
await client.SendCommandAsync(new CommandMessage {
Channel = "ch", Body = Encoding.UTF8.GetBytes("data"),
Timeout = TimeSpan.FromSeconds(10) });
// Send Query
await client.SendQueryAsync(new QueryMessage {
Channel = "ch", Body = Encoding.UTF8.GetBytes("data"),
Timeout = TimeSpan.FromSeconds(10) });
// Send Query with Cache
await client.SendQueryAsync(new QueryMessage {
Channel = "ch", Body = Encoding.UTF8.GetBytes("data"),
Timeout = TimeSpan.FromSeconds(10),
CacheKey = "key", CacheTTL = TimeSpan.FromSeconds(60) });
// Subscribe to Commands
await foreach (var cmd in client.SubscribeToCommandsAsync(
new CommandsSubscription { Channel = "ch", Group = "group" })) { }
// Subscribe to Queries
await foreach (var q in client.SubscribeToQueriesAsync(
new QueriesSubscription { Channel = "ch", Group = "group" })) { }
```
```kotlin
// Send Command
client.sendCommand(CommandMessage(
channel = "ch", body = "data".toByteArray(), timeout = 10000))
// Send Query
client.sendQuery(QueryMessage(
channel = "ch", body = "data".toByteArray(), timeout = 10000))
// Send Query with Cache
client.sendQuery(QueryMessage(
channel = "ch", body = "data".toByteArray(), timeout = 10000,
cacheKey = "key", cacheTTL = 60000))
// Subscribe to Commands
client.subscribeToCommands(
channel = "ch", group = "group",
onCommand = handler, onError = errHandler)
// Subscribe to Queries
client.subscribeToQueries(
channel = "ch", group = "group",
onQuery = handler, onError = errHandler)
```
```cpp
// Send Command
kubemq::CommandMessage cmd;
cmd.channel = "ch"; cmd.body = "data"; cmd.timeout = 10000;
client.sendCommand(cmd);
// Send Query
kubemq::QueryMessage query;
query.channel = "ch"; query.body = "data"; query.timeout = 10000;
client.sendQuery(query);
// Send Query with Cache
query.cacheKey = "key"; query.cacheTTL = 60000;
client.sendQuery(query);
// Subscribe to Commands
client.subscribeToCommands("ch", "group", handler, errHandler);
// Subscribe to Queries
client.subscribeToQueries("ch", "group", handler, errHandler);
```
```rust
// Send Command
let command = CommandBuilder::new()
.channel("ch").body(b"data".to_vec())
.timeout(Duration::from_secs(10)).build();
client.send_command(command).await?;
// Send Query
let query = QueryBuilder::new()
.channel("ch").body(b"data".to_vec())
.timeout(Duration::from_secs(10)).build();
client.send_query(query).await?;
// Send Query with Cache
let query = QueryBuilder::new()
.channel("ch").body(b"data".to_vec())
.timeout(Duration::from_secs(10))
.cache_key("key").cache_ttl(Duration::from_secs(60)).build();
client.send_query(query).await?;
// Subscribe to Commands
client.subscribe_to_commands("ch", "group", handler, None).await?;
// Subscribe to Queries
client.subscribe_to_queries("ch", "group", handler, None).await?;
```
```ruby
# Send Command
msg = KubeMQ::CQ::CommandMessage.new(
channel: "ch", body: "data", timeout: 10)
client.send_command(msg)
# Send Query
msg = KubeMQ::CQ::QueryMessage.new(
channel: "ch", body: "data", timeout: 10)
client.send_query(msg)
# Send Query with Cache
msg = KubeMQ::CQ::QueryMessage.new(
channel: "ch", body: "data", timeout: 10,
cache_key: "key", cache_ttl: 60)
client.send_query(msg)
# Subscribe to Commands
sub = KubeMQ::CQ::CommandsSubscription.new(channel: "ch", group: "group")
client.subscribe_to_commands(sub, cancellation_token: cancel,
on_error: err_handler) { |cmd| handler.call(cmd) }
# Subscribe to Queries
sub = KubeMQ::CQ::QueriesSubscription.new(channel: "ch", group: "group")
client.subscribe_to_queries(sub, cancellation_token: cancel,
on_error: err_handler) { |query| handler.call(query) }
```
```elixir
# Send Command
command = KubeMQ.Command.new(
channel: "ch", body: "data", timeout: 10_000)
KubeMQ.Client.send_command(client, command)
# Send Query
query = KubeMQ.Query.new(
channel: "ch", body: "data", timeout: 10_000)
KubeMQ.Client.send_query(client, query)
# Send Query with Cache
query = KubeMQ.Query.new(
channel: "ch", body: "data", timeout: 10_000,
cache_key: "key", cache_ttl: 60_000)
KubeMQ.Client.send_query(client, query)
# Subscribe to Commands
KubeMQ.Client.subscribe_to_commands(client, "ch",
group: "group", on_command: handler, on_error: err_handler)
# Subscribe to Queries
KubeMQ.Client.subscribe_to_queries(client, "ch",
group: "group", on_query: handler, on_error: err_handler)
```
## Related [#related]
* [Getting Started](/learn/rpc/getting-started) — send your first command and query
* [Configure Timeouts](/learn/rpc/how-to/timeout-configuration) — per-request timeouts and retries
* [Load Balancing](/learn/rpc/how-to/load-balancing) — distribute requests across responders
* [Events Reference](/learn/events/reference) — for the fire-and-forget pattern
* [Queues Reference](/learn/queues/reference) — for guaranteed delivery
# .NET Aspire (/integrations/aspire)
The KubeMQ .NET Aspire integration wires the [KubeMQ](https://kubemq.io) message broker into the [.NET Aspire](https://learn.microsoft.com/dotnet/aspire/) application model. It follows the standard Aspire two-package model: `KubeMQ.Aspire.Hosting` provisions a KubeMQ container in your AppHost, and `KubeMQ.Aspire.Client` configures an `IKubeMQClient` in each consuming service — complete with health checks, OpenTelemetry, and keyed dependency injection. The client is a **native gRPC SDK client** that connects to the broker on port `50000`; there is no connector to enable.
New to the idea? See [what is an integration](/integrations#what-an-integration-is) for how SDK-level integrations differ from server-side [connectors](/connectors).
## Why KubeMQ + .NET Aspire? [#why-kubemq--net-aspire]
* **Container auto-provisioning** — `AddKubeMQ("messaging")` in the AppHost runs a KubeMQ broker container with the gRPC, REST, and Dashboard endpoints already mapped
* **Zero-config connection strings** — `WithReference(messaging)` injects the broker address into each service as an Aspire connection string; `AddKubeMQClient("messaging")` reads it back automatically, so no host or port is hard-coded
* **Built-in health checks** — readiness and liveness checks are registered out of the box with `ready` and `live` tags, surfacing the SDK's connection state to the Aspire dashboard and Kubernetes probes
* **OpenTelemetry on by default** — the SDK's `KubeMQ.Sdk` tracing source and meter are registered automatically, so traces and metrics flow into the Aspire telemetry pipeline with no extra code
* **Keyed DI for multiple brokers** — `AddKeyedKubeMQClient` registers a distinct `IKubeMQClient` per named broker, resolved with `[FromKeyedServices(...)]`
## The two packages [#the-two-packages]
| Package | Project | Responsibility |
| ----------------------- | ------- | ------------------------------------------------------------------------- |
| `KubeMQ.Aspire.Hosting` | AppHost | Provision KubeMQ containers in the Aspire AppHost |
| `KubeMQ.Aspire.Client` | Service | Configure `IKubeMQClient` with health checks, OpenTelemetry, and keyed DI |
## Install [#install]
Add the hosting package to your **AppHost** project:
```bash
dotnet add package KubeMQ.Aspire.Hosting
```
Add the client package to each **service** project that talks to KubeMQ:
```bash
dotnet add package KubeMQ.Aspire.Client
```
## Quick start [#quick-start]
Provision the broker in the AppHost and reference it from a service. The connection string flows from the container resource into the service automatically.
```csharp title="AppHost/Program.cs"
var builder = DistributedApplication.CreateBuilder(args);
var kubemqKey = builder.AddParameter("kubemq-key", secret: true);
var messaging = builder.AddKubeMQ("messaging")
.WithLicenseKey(kubemqKey)
.WithDataVolume();
builder.AddProject("webapi")
.WithReference(messaging)
.WaitFor(messaging);
builder.Build().Run();
```
```csharp title="MyWebApi/Program.cs"
var builder = WebApplication.CreateBuilder(args);
builder.AddKubeMQClient("messaging");
var app = builder.Build();
app.Run();
```
`AddKubeMQClient` registers `IKubeMQClient` as a singleton and resolves the broker address from the `"messaging"` connection string injected by `WithReference`. For the full walkthrough — parameters, secrets, and verifying the connection — see [Getting Started](/integrations/aspire/tutorials/getting-started).
## Architecture [#architecture]
The AppHost provisions the KubeMQ container and injects its address as a connection string into each referencing service. In the service, `AddKubeMQClient` reads that connection string, constructs an `IKubeMQClient` that speaks gRPC to the broker on port `50000`, and registers the health checks and OpenTelemetry instrumentation as side outputs into the Aspire pipeline.
*The AppHost provisions the broker container and injects its `host:port` address; the service resolves it by connection name, builds an `IKubeMQClient`, and gets health checks and telemetry for free.*
## What the integration provisions [#what-the-integration-provisions]
The hosting package provisions a real KubeMQ container; the client package layers Aspire conventions on top of the KubeMQ .NET SDK.
| Feature | Detail |
| ----------------------- | --------------------------------------------------------------------------------------- |
| **Container image** | `AddKubeMQ` runs `europe-docker.pkg.dev/kubemq/images/kubemq:2.5.0` |
| **gRPC endpoint** | Target port `50000` (TCP) — the primary client connection |
| **REST endpoint** | Target port `9090` (HTTP) |
| **Dashboard endpoint** | Target port `8080` (HTTP) |
| **Persistent lifetime** | The container uses `ContainerLifetime.Persistent`, so it survives AppHost restarts |
| **Persistent storage** | `WithDataVolume()` binds a volume to `/store` for durable messages |
| **License key** | `WithLicenseKey()` sets the `KUBEMQ_TOKEN` environment variable from a secret parameter |
| **Image override** | `WithImageTag()` overrides the default image tag |
| **Health checks** | Readiness (`ready`) and liveness (`live`) checks reporting the SDK connection state |
| **OpenTelemetry** | Tracing source and meter `KubeMQ.Sdk` registered by default |
| **Keyed DI** | `AddKeyedKubeMQClient` for multiple named brokers in one service |
## Supported runtime [#supported-runtime]
| Requirement | Version |
| ------------------- | --------------------------------------------- |
| Language | C# (.NET) |
| .NET runtime | .NET 8.0 or .NET 9.0 |
| .NET Aspire | 9.0+ |
| `KubeMQ.SDK.CSharp` | 3.0.1 |
| KubeMQ broker | gRPC on `:50000` (always on — no enable flag) |
## Messaging patterns [#messaging-patterns]
Once `IKubeMQClient` is injected, it speaks the full KubeMQ messaging surface through the native SDK. The capability pages document the Aspire-specific wiring and link out to the core pattern docs for the send/receive semantics.
## Quick links [#quick-links]
**Requirements** — .NET 8.0 or .NET 9.0, .NET Aspire 9.0 or later, a KubeMQ license key set via `WithLicenseKey()`, and Docker for local development with Aspire.
New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for core concepts like Events, Queues, and RPC before wiring up Aspire.
# Celery (/integrations/celery)
[`kubemq-celery`](https://pypi.org/project/kubemq-celery/) is a KubeMQ transport and queue-peek result backend for [Celery](https://docs.celeryq.dev/) task queues. It lets you run Celery on KubeMQ with a one-line configuration change — `broker="kubemq://localhost:50000"` — making KubeMQ the only Kubernetes-native, in-cluster Celery broker. The transport plugs into Kombu (Celery's messaging layer) and registers the `kubemq://` URL scheme, so your existing tasks, workers, and tooling keep working unchanged.
New to the idea? See [what is an integration](/integrations#what-an-integration-is) for how SDK-level integrations differ from the server-side [connectors](/connectors).
## Supported versions [#supported-versions]
| Requirement | Supported versions |
| ------------- | ------------------------------------------ |
| Language | Python `>= 3.10` |
| Celery | `>= 5.4` |
| Kombu | `>= 5.4` |
| KubeMQ SDK | `kubemq >= 4.1.5` |
| KubeMQ broker | Reachable over native gRPC on port `50000` |
| Package | `kubemq-celery` `1.1.0` (MIT) |
## Why KubeMQ over Redis/RabbitMQ? [#why-kubemq-over-redisrabbitmq]
KubeMQ replaces visibility-timeout heuristics and broker plugins with native gRPC primitives, and runs as a first-class workload inside your Kubernetes cluster.
| | Redis | RabbitMQ | KubeMQ |
| ------------------------ | -------------------- | ----------------- | --------------------------- |
| **Acknowledgment** | Visibility timeout | Native AMQP ack | Native gRPC ack |
| **Delayed delivery** | Client-side polling | Plugin required | Native `delay_in_seconds` |
| **Kubernetes** | External StatefulSet | External + Erlang | K8s-native, auto-clustering |
| **Connection stability** | TCP reset under load | Stable | gRPC keep-alive |
| **Setup complexity** | Moderate | High | Low |
* **No visibility-timeout bugs** — messages are explicitly acknowledged or rejected over a gRPC stream, instead of relying on a timeout window that causes duplicates when set too low and stalls reprocessing when set too high.
* **Native delayed delivery** — `countdown` and `eta` map directly to KubeMQ's `delay_in_seconds` with zero polling overhead and no broker plugin.
* **Kubernetes-native** — an in-cluster broker with auto-clustering, gRPC keep-alive for long-lived connections, and KEDA-driven autoscaling, instead of an external StatefulSet or an Erlang-backed service.
## Install [#install]
Install from PyPI with `pip` or [uv](https://docs.astral.sh/uv/):
```bash
# pip
pip install kubemq-celery
# uv (recommended)
uv add kubemq-celery
```
Switching an existing app is a one-line change — `kubemq-celery` is a drop-in replacement for Redis or RabbitMQ:
```python title="tasks.py"
import kubemq_celery # registers the kubemq:// transport
from celery import Celery
app = Celery("myapp", broker="kubemq://localhost:50000")
@app.task
def add(x, y):
return x + y
```
`import kubemq_celery` must run before Celery resolves the broker URL — it registers the `kubemq://` scheme with Kombu. Without it, Celery raises an "unknown transport" error.
Start a worker and send a task:
```bash
celery -A tasks worker --loglevel=info
```
```python
from tasks import add
add.delay(4, 6)
```
For the full walkthrough — including a local Docker broker and the result backend — see [Getting Started](/integrations/celery/tutorials/getting-started).
## Features [#features]
| Feature | Description |
| ---------------------- | -------------------------------------------------------------------------------- |
| **One-line setup** | `broker="kubemq://host:50000"` — drop-in replacement for Redis/RabbitMQ |
| **Native ack/nack** | Messages are explicitly acknowledged over gRPC — no visibility-timeout bugs |
| **Delayed delivery** | `countdown` and `eta` map to KubeMQ's native `delay_in_seconds` (no polling) |
| **Per-message TTL** | `message_expiration` transport option for automatic message expiration |
| **Dead letter queue** | Built-in DLQ via `max_receive_count` + `dead_letter_queue` transport options |
| **Batch receive** | `max_batch_size` fetches multiple messages per gRPC call for higher throughput |
| **Queue-peek results** | Optional result backend using non-destructive peek — no external Redis/DB needed |
| **Full monitoring** | Flower, `celery inspect`, `celery control` — all work via KubeMQ Events fanout |
| **TLS + mTLS** | `kubemq+tls://` for encrypted gRPC connections, mTLS for mutual authentication |
| **Async transport** | `kubemq+async://` for native asyncio I/O with `--pool=asyncio` workers |
| **KEDA autoscaling** | Kubernetes-native broker with auto-clustering and KEDA-driven scaling |
| **Auto-registration** | `import kubemq_celery` registers the `kubemq://` URL scheme automatically |
## Architecture [#architecture]
A Celery worker talks to KubeMQ through Kombu's virtual transport layer. `kubemq-celery` implements a `Channel` that extends Kombu's `virtual.Channel` (with `supports_fanout = True`) and maps Kombu's storage primitives onto KubeMQ's gRPC API on port `50000`. Task messages travel over KubeMQ **Queues** (with native ack/nack and `delay_in_seconds`), while pidbox and monitoring traffic — Flower, `celery inspect`, `celery control` — use KubeMQ **Events** fanout. When the optional result backend is enabled, results are written as Queue messages and read back with a non-destructive peek, so multiple callers can fetch the same result without consuming it.
*A Celery worker speaks `kubemq://` through Kombu; tasks and peek-read results ride KubeMQ Queues, while pidbox and monitoring traffic fan out over KubeMQ Events.*
## Usage [#usage]
## Quick Links [#quick-links]
**Requirements** — Python `>= 3.10`, Celery `>= 5.4`, Kombu `>= 5.4`, and the `kubemq` SDK `>= 4.1.5`. Current version: `1.1.0`. You also need a reachable KubeMQ broker (Docker, Kubernetes, or standalone).
New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for core concepts like Queues and Events before wiring up Celery.
# FastStream (/integrations/faststream)
[`kubemq-faststream`](https://github.com/kubemq-io/kubemq-faststream) is a KubeMQ
broker adapter for the [FastStream](https://github.com/airtai/faststream) async
messaging framework. It registers `KubeMQBroker` as a first-class FastStream broker, so
all five KubeMQ patterns — **Events**, **Events Store**, **Queues**, **Commands**, and
**Queries** — become ordinary `@broker.subscriber(...)` endpoints, fully wired into
FastStream's lifecycle, dependency injection, middleware, and testing infrastructure.
New to the idea? See [what is an integration](/integrations#what-an-integration-is)
for how SDK-level integrations differ from server-side [connectors](/connectors).
## Why FastStream + KubeMQ [#why-faststream--kubemq]
* **All five patterns as FastStream endpoints** — one keyword on the subscriber
(`events=`, `events_store=`, `queues=`, `commands=`, `queries=`) selects the pattern;
no per-pattern client wiring.
* **Idiomatic decorator API** — register handlers with `@broker.subscriber(...)` and
auto-publish results with `@broker.publisher(...)`, exactly like every other FastStream
broker.
* **Full FastStream pipeline** — parser, decoder, broker and subscriber middleware, and
FastDepends dependency injection run for every message.
* **In-memory testing** — `TestKubeMQBroker` routes published messages to matching
subscribers without a live broker, exercising the real parse/decode/middleware path.
* **Runs inside your web app** — drop the broker into a FastAPI, Starlette, Django, or
Flask process and consume KubeMQ messages on the same event loop.
## Installation [#installation]
```bash
uv add kubemq-faststream
```
```bash
pip install kubemq-faststream
```
**Requirements**: Python 3.11+ and a running [KubeMQ](https://kubemq.io/) broker.
| Requirement | Supported versions |
| ----------- | ------------------------- |
| Language | Python 3.11, 3.12, 3.13 |
| FastStream | >= 0.6.7, \< 0.7.0 |
| KubeMQ SDK | >= 4.1.5, \< 5 |
| Package | `kubemq-faststream` 0.1.0 |
Start a broker locally with Docker:
`kubemq-faststream` is a native gRPC SDK client: it talks to KubeMQ over the gRPC port
`50000`, the same transport the native SDKs use. It is **always on** — there is no
server-side connector to enable and no HTTP flag to set. Port `9090` is the shared HTTP
server (REST and the HTTP connectors) and is not used by FastStream.
## Architecture [#architecture]
When it connects, `KubeMQBroker` creates three KubeMQ SDK async clients — one per
transport family — each on its own gRPC channel and all sharing a single set of
connection settings (URL, client ID, auth token, TLS, message-size limits, keepalive).
Your subscriber keyword routes each `publish`, `request`, and handler registration to the
matching client.
*`KubeMQBroker` fans your handlers across three SDK clients, all reaching the KubeMQ broker over gRPC on `:50000`.*
A minimal app wires the broker into `FastStream`, registers a subscriber, and publishes
after startup:
```python title="app.py"
import asyncio
from faststream import FastStream
from kubemq_faststream import KubeMQBroker
broker = KubeMQBroker("kubemq://localhost:50000")
app = FastStream(broker)
@broker.subscriber(queues="orders")
async def handle_order(order: dict) -> None:
print(f"Processing order: {order}")
@app.after_startup
async def publish() -> None:
await broker.publish({"id": "ORD-001", "item": "Widget"}, queues="orders")
if __name__ == "__main__":
asyncio.run(app.run())
```
## Messaging patterns [#messaging-patterns]
Each KubeMQ pattern maps to a subscriber keyword. The pages below document the FastStream
API for each — the decorators, options, and `broker.publish`/`broker.request` calls — and
link to the underlying KubeMQ concept.
## Capabilities [#capabilities]
* **`KubeMQBroker` over native gRPC** — connect with `kubemq://host:50000` (or
`kubemq+tls://` for TLS) straight to the KubeMQ gRPC port.
* **`KubeMQRouter` composition** — group handlers into routers whose `prefix` propagates
to every channel.
* **`@broker.publisher` auto-publish** — stack on a subscriber to publish its return value
to another channel.
* **`AckPolicy` settlement** — choose how queue messages are acked, nacked, or rejected.
* **`StartPosition` replay** — replay Events Store streams from first, a sequence, or a
point in time.
* **Server-side query caching** — pass `cache_key` and `cache_ttl` on a query request.
* **Health check** — `await broker.ping()` verifies broker connectivity.
## Next steps [#next-steps]
New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for
the core concepts behind these patterns.
# MassTransit (/integrations/masstransit)
[MassTransit](https://masstransit.io) is the open-source distributed-application framework for .NET. **MassTransit.KubeMQ** is a MassTransit *transport* that runs the bus over KubeMQ via gRPC: you register it the same way you would RabbitMQ or Azure Service Bus, and your existing consumers, sagas, and message contracts work unchanged on KubeMQ.
Because the transport plugs in below the MassTransit abstractions, adopting KubeMQ is a configuration change rather than a rewrite. Message contracts, `IConsumer` implementations, sagas, the middleware pipeline, serialization, retry/redelivery policies, the EF Core outbox, OpenTelemetry, and DI registration (`AddConsumer`, `AddSaga`) all behave identically — only where messages physically travel changes.
New to the idea? See [what is an integration](/integrations#what-an-integration-is) for how SDK-level integrations differ from the server-side [connectors](/connectors).
## Why MassTransit + KubeMQ [#why-masstransit--kubemq]
* **Drop-in transport swap** — change `UsingRabbitMq`, `UsingAzureServiceBus`, or `UsingAmazonSqs` to `UsingKubeMQ`; contracts and consumers stay the same.
* **Native request/response** — KubeMQ's CQ pattern provides built-in request-reply with no temporary reply queues (unlike RabbitMQ/SQS, which simulate it).
* **Fire-and-forget or durable-replayable publish** — `Publish()` maps to KubeMQ Events for fan-out, or to EventsStore for persistent, replayable pub/sub.
* **Built-in priority queues and consumer groups** — weighted priority channels and competing consumers without extra infrastructure.
* **Full observability** — W3C trace context is propagated via tags, and the bus integrates with ASP.NET Core health checks and a `MassTransit.KubeMQ` metrics meter.
## Install [#install]
```bash title="terminal"
dotnet add package MassTransit.KubeMQ
```
The transport targets `net8.0` and requires MassTransit `>= 8.5.0`.
## Supported versions [#supported-versions]
| Requirement | Supported versions |
| ------------- | ------------------------------------------ |
| Language | C# / .NET (`net8.0` target) |
| Runtime | .NET SDK `8.0+` |
| MassTransit | `>= 8.5.0` |
| KubeMQ broker | Reachable over native gRPC on port `50000` |
| Package | `MassTransit.KubeMQ` `1.0.0` |
The transport is a **native gRPC client** that connects to KubeMQ on port `50000`. It is not an HTTP connector, so there is no `CONNECTORS*_ENABLE` flag to set — exposing the gRPC port is all that is needed. KubeMQ channels are created automatically on first use; there is no exchange, binding, virtual host, or topic-subscription topology to provision.
## Architecture [#architecture]
Your application talks to MassTransit abstractions as usual. The transport layers KubeMQ onto an InMemory base bus (for the `IBusControl` lifecycle) plus a **KubeMQ rider** that owns the connections and receive transports. The rider bridges those abstractions to the broker over gRPC on port `50000`, where KubeMQ dispatches to its four native subsystems.
*The KubeMQ rider maps MassTransit's transport-agnostic operations onto native KubeMQ Queues, Events, EventsStore, and CQ over gRPC.*
## Pattern mapping [#pattern-mapping]
MassTransit's transport-agnostic verbs map to KubeMQ's native messaging patterns. The mapping determines delivery semantics and which KubeMQ subsystem backs each message.
| MassTransit verb | KubeMQ pattern | Delivery | Capability page |
| --------------------- | --------------------- | ------------------------------------------------- | ----------------------------------------------------------------------- |
| **Send** | Queues | Point-to-point (exactly one consumer) | [Queues](/integrations/masstransit/how-to/queues) |
| **Publish** | Events | Fan-out (all active subscribers, fire-and-forget) | [Events](/integrations/masstransit/how-to/events) |
| **Publish** (durable) | EventsStore | Fan-out with persistence and replay | [Events Store](/integrations/masstransit/how-to/events-store) |
| **Request/Response** | Commands/Queries (CQ) | Native request-reply | [Commands & Queries](/integrations/masstransit/how-to/commands-queries) |
## Capabilities [#capabilities]
## Next steps [#next-steps]
New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for the core messaging concepts.
# KEDA (/integrations/keda)
The **KubeMQ KEDA external scaler** brings Kubernetes-native autoscaling to KubeMQ
queue consumers. It is a standalone gRPC service that reads the live `Waiting` message
count from a KubeMQ queue channel and exposes it to [KEDA](https://keda.sh) as a single
metric, so a `ScaledObject` can scale queue-consuming workloads — GPU inference workers,
batch processors, task consumers — up and down (and all the way to zero) on real backlog
rather than CPU or memory.
## What it is [#what-it-is]
KEDA is the Kubernetes Event-Driven Autoscaler. It scales workloads from external signals
through *scalers* — small gRPC services that implement KEDA's `ExternalScaler` interface.
The KubeMQ scaler is one such service: it answers KEDA's `IsActive` / `GetMetrics` calls by
querying the broker's `ListQueuesChannels` API and reporting the channel's `Outgoing.Waiting`
count.
Unlike the framework adapters in this section, KEDA is **not a messaging client your app
embeds** — it is an autoscaler that runs alongside your consumers. Your consumers connect to
KubeMQ with the [native gRPC SDK](/deploy) on port `50000` as usual; the scaler
observes the same queues over that API and tells KEDA how many replicas to run. The metric it
exposes is queue depth, so the underlying concept is [Queues](/learn/queues) — the scaler reads
the same `Waiting` count you would see when introspecting a queue channel.
## Why KEDA + KubeMQ [#why-keda--kubemq]
* **Queue-depth-driven autoscaling** — scale on the live `Waiting` count of a [queue](/learn/queues), not on CPU or memory.
* **Scale-to-zero** — drop a deployment to zero replicas when its queue is empty and scale back up the moment work arrives.
* **Never-fake-zero** — KubeMQ errors map to gRPC status codes so KEDA applies its `fallback` strategy on a broker outage instead of scaling a healthy workload to zero.
* **Push or poll** — `external` (poll) and `external-push` (long-lived stream) trigger types, the latter for faster scale-from-zero detection.
* **Lightweight** — a single Go service requesting just `50m` CPU and `64Mi` memory, hardened with a non-root, read-only-root-filesystem `securityContext`.
## Architecture [#architecture]
The scaler is a standalone gRPC `ExternalScaler` server (default port `9090`). The KEDA
operator calls it to read queue depth; the scaler in turn dials the KubeMQ broker's gRPC API
(`50000`), calls `ListQueuesChannels`, and reports the `Waiting` count so KEDA can scale the
target deployment.
*The KEDA operator reads queue depth from the scaler, which polls the KubeMQ broker and drives the target deployment's replica count.*
## The metric and the RPCs [#the-metric-and-the-rpcs]
The scaler exposes exactly one metric, `kubemq-queue-waiting`, whose value is the queue
channel's `Outgoing.Waiting` count. It implements the four RPCs of KEDA's `ExternalScaler`
service:
| RPC | Purpose |
| ---------------- | ----------------------------------------------------------------------------------------------------- |
| `IsActive` | Returns `true` when `Waiting > activationTargetWaiting` — the scale-from-zero gate. |
| `StreamIsActive` | Server-streaming. Pushes active status immediately, then re-polls every `5s`. Drives `external-push`. |
| `GetMetricSpec` | Returns the target value for `kubemq-queue-waiting`, set from `targetWaiting`. |
| `GetMetrics` | Returns the current `Waiting` count as the metric value. |
KEDA supports two trigger types against the same scaler — `external` (poll-based, default) and
`external-push` (a long-lived `StreamIsActive` stream for faster scale-from-zero). See
[Concepts](/integrations/keda/concepts) for the full model.
## Prerequisites [#prerequisites]
* **Kubernetes 1.27+** — required for the native gRPC liveness/readiness probes the scaler uses.
* **KEDA 2.10+** — installed in the cluster.
* **A KubeMQ broker** — reachable in-cluster on its gRPC port `50000` at the address you pass in `kubemqAddress`.
## Supported runtime [#supported-runtime]
The scaler is a standalone Go service, not a language SDK you embed — your consumers can be written in any language and connect to the broker over native gRPC as usual.
| Requirement | Version |
| ----------------------------------- | --------------------------------------------- |
| KEDA | 2.10+ |
| Kubernetes | 1.27+ |
| Scaler image | `kubemq/kubemq-keda-scaler:1.0.0` |
| `github.com/kubemq-io/kubemq-go/v2` | v2.0.3 |
| Go (to build from source) | 1.25+ |
| KubeMQ broker | gRPC on `:50000` (always on — no enable flag) |
## A ScaledObject at a glance [#a-scaledobject-at-a-glance]
A `ScaledObject` targets your consumer `Deployment` and points an `external` trigger at both the
scaler Service and your broker:
```yaml title="scaled-object-basic.yaml"
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: kubemq-queue-scaler
spec:
scaleTargetRef:
name: my-queue-consumer
pollingInterval: 15
cooldownPeriod: 60
minReplicaCount: 1
maxReplicaCount: 10
triggers:
- type: external
metadata:
scalerAddress: kubemq-keda-scaler.default.svc.cluster.local:9090
kubemqAddress: kubemq.default.svc.cluster.local:50000
queueName: my-queue
targetWaiting: "10"
```
## Explore [#explore]
New to integrations? See [what an integration is](/integrations#what-an-integration-is)
for the mental model, then the [Queues](/learn/queues) concept and the
[Getting Started guide](/deploy) for the core SDK your consumers use. The scaler
observes queues over native gRPC on port `50000` — it is not a [connector](/connectors).
# NestJS (/integrations/nestjs)
[`@kubemq/nestjs-transport`](https://www.npmjs.com/package/@kubemq/nestjs-transport) is a
custom [NestJS](https://nestjs.com/) transport that integrates KubeMQ into the NestJS
microservices ecosystem. It surfaces all five KubeMQ messaging patterns — Commands, Queries,
Events, Events Store, and Queues — through idiomatic `@*Handler` decorators and a standard
`ClientProxy`, so a Nest app sends and handles KubeMQ messages with the same DI, modules, and
testing patterns it already uses for HTTP. It is built on the native
[`kubemq-js`](https://www.npmjs.com/package/kubemq-js) SDK and speaks gRPC directly to the broker
on port `50000`.
## Why KubeMQ + NestJS [#why-kubemq--nestjs]
* **Idiomatic decorators** — `@CommandHandler`, `@QueryHandler`, `@EventHandler`,
`@EventStoreHandler`, and `@QueueHandler` replace manual `@MessagePattern` / `@EventPattern`
metadata wiring; the right KubeMQ pattern is attached for you.
* **One transport, five patterns** — a single `KubeMQServer` strategy (inbound) and
`KubeMQClientProxy` (outbound) cover every pattern; the `KubeMQRecord` builder re-targets the
message type with `.asQuery()`, `.asEventStore()`, or `.asQueue()`.
* **Dynamic-module DI** — `forRoot` / `forRootAsync` / `register` / `registerAsync` /
`forFeature` / `forTest` register the connection and named clients through NestJS dependency
injection.
* **Distributed CQRS** — the [CQRS bridge](/integrations/nestjs/how-to/cqrs-bridge) routes `@nestjs/cqrs` `CommandBus`,
`QueryBus`, and `EventBus` traffic across services over KubeMQ channels.
* **TypeScript-first** — full type safety with an ESM + CJS dual build and per-pattern context
types.
* **Broker-free tests** — `MockKubeMQClient`, `MockKubeMQServer`, and `KubeMQModule.forTest()`
exercise services and handlers without a live broker.
## Installation [#installation]
```bash
npm install @kubemq/nestjs-transport kubemq-js
```
Most peer dependencies already ship with a typical NestJS project; install any that are missing:
```bash
npm install @nestjs/common @nestjs/core @nestjs/microservices rxjs reflect-metadata
```
Optional peer dependencies enable specific features — `@nestjs/terminus` for the health-check
indicator and `@nestjs/cqrs` for the [CQRS bridge](/integrations/nestjs/how-to/cqrs-bridge):
```bash
npm install @nestjs/terminus # health checks
npm install @nestjs/cqrs # CQRS bridge
```
**Prerequisites:** Node.js 20.11.0 or later and a running KubeMQ broker (default
`localhost:50000`). The transport is a native gRPC SDK client — there is **no connector flag to
enable**; the gRPC API on `50000` is always on. Start a local broker with Docker (gRPC on `50000`,
the shared HTTP server and dashboard on `9090`):
## Supported versions [#supported-versions]
| Requirement | Supported versions |
| ----------- | ---------------------------------------- |
| Language | TypeScript `5.5+` (ESM + CJS dual build) |
| Runtime | Node.js `>= 20.11.0` |
| NestJS | `10.x` or `11.x` |
| KubeMQ SDK | `kubemq-js` `^3.0.1` |
| Package | `@kubemq/nestjs-transport` `1.0.0` |
## Architecture [#architecture]
A KubeMQ-backed NestJS app is a
[hybrid application](https://docs.nestjs.com/faq/hybrid-application): the HTTP app stays as-is
while the KubeMQ transport is attached as a microservice. Inbound handlers run through the
`KubeMQServer` strategy passed to `app.connectMicroservice({ strategy })`; outbound clients are
`KubeMQClientProxy` instances registered with `KubeMQModule`. Both wrap a `kubemq-js` client and
speak gRPC to the broker on port `50000`.
*Decorated handlers and injected client proxies both wrap `kubemq-js` and reach the broker over native gRPC.*
## Capabilities [#capabilities]
The transport plugs into NestJS DI and dispatch. Each capability below documents the
integration's own API surface and links to the underlying KubeMQ concept rather than re-teaching
it.
| Capability | Surface | KubeMQ concept |
| ------------------ | ---------------------------------------------------- | ----------------------------------- |
| Commands / Queries | `@CommandHandler` / `@QueryHandler`, `client.send()` | [RPC](/learn/rpc) |
| Events | `@EventHandler`, `client.emit()` | [Events](/learn/events) |
| Events Store | `@EventStoreHandler`, `.asEventStore()` | [Events Store](/learn/events-store) |
| Queues | `@QueueHandler`, `.asQueue()`, manual ack | [Queues](/learn/queues) |
| Module DI | `forRoot` / `register` / `forFeature` / `forTest` | — |
| Distributed CQRS | `KubeMQCqrsModule` over `@nestjs/cqrs` buses | — |
## Next steps [#next-steps]
New to integrations? See [what an integration is](/integrations#what-an-integration-is) for
the mental model, or start with the [KubeMQ Getting Started guide](/deploy) for core
broker concepts. This transport speaks native gRPC on port `50000` — it is a direct SDK client,
not a [connector](/connectors).
# Ray Serve (/integrations/rayserve)
`kubemq-rayserve` is a Python package that plugs [KubeMQ](https://kubemq.io/) into
[Ray Serve](https://docs.ray.io/en/latest/serve/index.html) as a task backend. It provides
`KubeMQTaskProcessorAdapter` — the first non-Celery adapter for Ray Serve's
`TaskProcessorAdapter` framework — so a Ray Serve deployment can run **queue-based asynchronous**
and **blocking synchronous** ML inference, with KubeMQ acting as the message broker, the result
store, the autoscaling signal, and the progress event bus all at once.
The adapter is a native gRPC SDK client. It connects to a KubeMQ broker on port `50000` and is
always available — there is no connector to enable on the server. You install one package, point
it at a broker, and Ray Serve drives the rest of the lifecycle.
Ray Serve is an [integration](/integrations#what-an-integration-is): a client-side library that
embeds a KubeMQ SDK, distinct from the server-side [connectors](/connectors) (MCP, A2A,
CloudEvents) that run inside the broker.
## Why KubeMQ + Ray Serve [#why-kubemq--ray-serve]
* **One Kubernetes-native broker** — a single binary carries the task queue, the synchronous query
channel, the result store, and the progress event stream. No Erlang, Redis, or RabbitMQ to
operate alongside Ray.
* **Built-in sync inference** — `query_task_sync` gives blocking request-response inference that the
reference Celery adapter does not offer.
* **Built-in progress tracking** — `report_progress` streams real-time task progress over KubeMQ
Events; Celery requires custom signals.
* **Queue-peek result backend** — results live in KubeMQ itself, with no separate Redis or database
to provision.
* **Native autoscaling** — `kubemq_queue_depth_policy` scales Ray Serve replicas from live queue
depth, and the same depth drives [KEDA](/integrations/keda) for cluster-level scaling.
* **One-dependency setup** — only KubeMQ is required, versus a broker plus a separate result
backend.
## Install [#install]
```bash title="terminal"
uv pip install kubemq-rayserve
```
| Requirement | Version |
| ------------------------ | --------- |
| Python | >= 3.10 |
| Ray Serve (`ray[serve]`) | >= 2.50.0 |
| `kubemq` (Python SDK) | >= 4.1.5 |
| `pydantic` | >= 2.0 |
A running KubeMQ broker is required (default address `localhost:50000`). Start one locally with
Docker:
Port `50000` is KubeMQ's gRPC endpoint — the only port the adapter's SDK clients use. Port `9090`
is the shared HTTP server (REST and the AI-agent connectors); the dashboard runs separately on
port `8080`. The adapter and the `kubemq_queue_depth_policy` autoscaler both speak gRPC on `50000`.
## Architecture [#architecture]
A producer enqueues a serialized task onto a KubeMQ Queue. Inside the Ray Serve deployment, the
`KubeMQTaskProcessorAdapter` runs a consumer thread that polls the queue, dispatches each task to a
registered handler, and writes the result back into the queue-peek result backend. A parallel Query
channel serves blocking sync inference, and an Events channel carries progress updates — all over
the same gRPC connection to port `50000`.
*The adapter maps Ray Serve's task model onto three KubeMQ primitives — Queues, Queries, and Events — over one gRPC connection.*
The adapter never re-teaches KubeMQ messaging: async tasks ride [Queues](/learn/queues), sync
inference rides request-response [RPC](/learn/rpc) (Queries), and progress rides
[Events](/learn/events). Each capability page documents the adapter API and links to the underlying
concept.
## Capabilities [#capabilities]
`KubeMQTaskProcessorAdapter` exposes eight capabilities, grouped into three inference models plus
the operational features that surround them.
| Capability | What it does | KubeMQ primitive |
| ----------------- | ------------------------------------------------------------------ | --------------------------- |
| Async inference | Enqueue a task, poll the result backend for the outcome | [Queues](/learn/queues) |
| Sync inference | Blocking request-response inference via `query_task_sync` | [RPC](/learn/rpc) (Queries) |
| Progress tracking | Stream live `report_progress` updates to subscribers | [Events](/learn/events) |
| Cancellation | Soft-cancel a task — overwrite its result with `CANCELLED` | Queues |
| DLQ monitoring | `on_dlq` callback alerts on permanently failed tasks | Queues |
| Autoscaling | Scale replicas on queue depth (`kubemq_queue_depth_policy` / KEDA) | Queues |
| Result backend | Queue-peek result storage with purge-then-write and TTL | Queues |
| Metrics | An 8-metric dict (depth, in-flight, DLQ, counters, durations) | — |
## Metrics at a glance [#metrics-at-a-glance]
`get_metrics_sync()` returns a single dict with eight entries — three live gauges read from KubeMQ
on each call, plus in-memory counters and a histogram. See the
[metrics guide](/integrations/rayserve/how-to/metrics) for collection patterns and the
[API reference](/integrations/rayserve/reference/api#metrics) for the full schema.
| Metric | Type | Reports |
| ---------------------------------- | --------- | ----------------------------------------------------------- |
| `queue_depth` | Gauge | Waiting messages in the task queue (the autoscaling signal) |
| `in_flight` | Gauge | Tasks currently being processed |
| `dlq_depth` | Gauge | Waiting messages in the dead-letter queue |
| `tasks_enqueued_total` | Counter | Tasks enqueued since startup |
| `tasks_completed_total` | Counter | Completed counts by status (`SUCCESS` / `FAILURE`) |
| `task_processing_duration_seconds` | Histogram | `min` / `max` / `avg` / `count` of handler durations |
| `result_storage_retries_total` | Counter | Result-storage retry attempts |
| `consumer_poll_latency_seconds` | Gauge | Duration of the last queue poll |
## How it compares to the Celery adapter [#how-it-compares-to-the-celery-adapter]
Ray Serve's reference `TaskProcessorAdapter` is Celery-backed. KubeMQ collapses the broker, result
backend, and scaler into one component.
| Feature | kubemq-rayserve | CeleryTaskProcessorAdapter |
| ------------------ | ---------------------------- | ------------------------------------- |
| Message broker | KubeMQ (Kubernetes-native) | Redis / RabbitMQ |
| Sync inference | Built-in (`query_task_sync`) | Not available |
| Progress tracking | Built-in (`report_progress`) | Requires custom signals |
| Autoscaling policy | `kubemq_queue_depth_policy` | Manual HPA configuration |
| DLQ monitoring | `on_dlq` callback | Requires Celery signals + custom code |
| Result backend | Queue-peek (no extra infra) | Requires separate Redis / DB |
| Setup complexity | 1 dependency (KubeMQ) | 2+ dependencies (broker + backend) |
## Next steps [#next-steps]
New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for core
concepts.
# Watermill (/integrations/watermill)
[`kubemq-watermill`](https://github.com/kubemq-io/kubemq-watermill) is a production-ready
[Watermill](https://watermill.io/) pub/sub plugin for KubeMQ. It implements Watermill's
`message.Publisher` and `message.Subscriber` interfaces across three KubeMQ messaging
patterns — Events, EventsStore, and Queues — plus a native `CQPublisher` for Commands and
Queries (request-reply). Drop it into an existing Watermill application and your Router,
handlers, and middleware run unchanged on top of KubeMQ.
It is a **native gRPC client** built on the `kubemq-go/v2` SDK — not an HTTP connector.
If you need protocol bridging instead (REST, MCP, A2A, CloudEvents), see the
[Connectors](/connectors) overview. For the messaging model behind the patterns,
see the core docs: [Events](/learn/events), [Events Store](/learn/events-store),
[Queues](/learn/queues), and [RPC](/learn/rpc).
## Why Watermill on KubeMQ [#why-watermill-on-kubemq]
* **Three messaging patterns** — Events (fire-and-forget), EventsStore (persistent with
replay), and Queues (reliable with explicit ack/nack), each behind the same Watermill
`Publisher`/`Subscriber` interfaces.
* **Native CQPublisher** — wraps KubeMQ's Commands and Queries APIs directly for
low-latency request-reply, with execution confirmation and query caching.
* **Full Watermill compatibility** — works with the Watermill Router and the entire
standard middleware stack (Retry, Throttle, CorrelationID, Poison Queue, CircuitBreaker).
* **Ack/Nack bridged to KubeMQ** — Watermill `msg.Ack()`/`msg.Nack()` map to KubeMQ queue
settlement; a Nack returns the message to the queue for redelivery.
* **Built-in DLQ, OTel, and metrics** — native dead-lettering via `QueueMessagePolicy`,
W3C Trace Context propagation through KubeMQ Tags, and Watermill's Prometheus component.
* **Verified compatibility** — passes the full Watermill `pubsub/tests.TestPubSub` suite.
## Architecture [#architecture]
Your Watermill Router, Publisher, and Subscriber call the plugin, which uses
`kubemq-go/v2` to open a gRPC connection to the KubeMQ broker on port `50000`. There is no
HTTP connector and no server-side translation layer — the broker's gRPC server is always
on, so the plugin works as soon as a broker is reachable.
*The plugin maps Watermill's Publisher/Subscriber model onto KubeMQ patterns over the native gRPC SDK on port 50000.*
## Capabilities [#capabilities]
Each Publisher and Subscriber serves exactly one KubeMQ pattern, selected with the
`Pattern` field. Commands and Queries use the standalone `CQPublisher`.
## Pattern selection [#pattern-selection]
KubeMQ supports three messaging patterns. Each Publisher/Subscriber instance serves
exactly one — pick the one that matches your delivery and persistence needs:
| Pattern | Delivery | Ack/Nack | Persistence | Best for |
| --------------- | ------------- | -------------------- | --------------------------- | ---------------------------------------------- |
| **Events** | At-most-once | No (fire-and-forget) | No | Real-time notifications, metrics, logs |
| **EventsStore** | At-least-once | Offset auto-advance | Yes (replay from any point) | Event sourcing, audit trails, stream replay |
| **Queues** | At-least-once | Explicit ack/nack | Yes (until acked) | Task queues, job processing, reliable delivery |
For request-reply, reach for the native `CQPublisher` (single hop, lower latency) or
Watermill's `requestreply` component over Queues (middleware-compatible) — see
[Commands & Queries](/integrations/watermill/how-to/commands-queries).
## Supported runtime [#supported-runtime]
| Requirement | Version |
| ------------------------------------ | --------------------------------------------- |
| Go | 1.25+ |
| `github.com/ThreeDotsLabs/watermill` | v1.5.1 |
| `github.com/kubemq-io/kubemq-go/v2` | v2.0.3 |
| KubeMQ broker | gRPC on `:50000` (always on — no enable flag) |
Install the plugin with `go get`:
```bash
go get github.com/kubemq-io/watermill-kubemq
```
## Next steps [#next-steps]
New to KubeMQ? Start with the [What is an integration?](/integrations#what-an-integration-is)
mental model, then the [KubeMQ Getting Started guide](/deploy).
# Spring Boot (/integrations/spring-boot)
The KubeMQ Spring Boot Starter is a production-quality Spring Boot 3.x and Spring Cloud Stream integration for the [KubeMQ](https://kubemq.io) message broker. It brings KubeMQ into the Spring programming model through auto-configuration, a thread-safe `KubeMQTemplate` send API, and annotation-driven listeners — all built on the native KubeMQ Java SDK clients so your application speaks gRPC to the broker directly.
## Why Spring Boot + KubeMQ? [#why-spring-boot--kubemq]
* **Native Spring idioms** — auto-configuration, `@ConfigurationProperties` bound under `kubemq.*`, Spring Boot Actuator health contributors, and Micrometer metrics and observations
* **Annotation-driven listeners** — `@KubeMQEventListener`, `@KubeMQEventStoreListener`, `@KubeMQQueueListener`, `@KubeMQCommandHandler`, and `@KubeMQQueryHandler` turn beans into message consumers
* **`KubeMQTemplate` send API** — one injectable template covers all five messaging patterns (Events, Events Store, Queues, Commands, Queries) with sync, async, and fluent-builder variants
* **Kotlin support** — coroutine extensions, `Flow` adapters, and a configuration DSL via the Kotlin starter
* **Spring Cloud Stream binder** — bind Events, Events Store, and Queues through the Spring Cloud Stream programming model
* **First-class test harness** — `MockKubeMQServer`, TestContainers support, and a test starter for exercising producers and consumers without a live broker
## Requirements [#requirements]
The starter targets the JVM and a running **KubeMQ broker** reachable over native gRPC on port `50000`. Supported languages and versions:
| Requirement | Version |
| -------------------------- | ------------- |
| Java | 17+ |
| Kotlin (Kotlin starter) | JVM target 17 |
| Spring Boot | 3.2.0+ |
| Spring Cloud (binder BOM) | 2023.0.0 |
| KubeMQ Spring Boot Starter | 1.0.0 |
The Spring Cloud Stream binder pulls in the **Spring Cloud 2023.0.0** BOM; add it only when you use the binder.
## Installation [#installation]
Add the starter dependency — it aggregates the auto-configuration and the KubeMQ Java SDK:
```kotlin title="build.gradle.kts"
dependencies {
implementation("io.kubemq:kubemq-spring-boot-starter:1.0.0")
}
```
Then point the application at your broker. The properties are bound under `kubemq.*`:
```yaml title="application.yml"
kubemq:
address: localhost:50000
client-id: my-app
```
Inject `KubeMQTemplate` to send, and annotate a bean method to receive:
```java title="OrderService.java"
@Service
public class OrderService {
private final KubeMQTemplate template;
public OrderService(KubeMQTemplate template) {
this.template = template;
}
public void placeOrder(Order order) {
template.sendEvent("orders", order);
}
}
```
```java title="OrderConsumer.java"
@Component
public class OrderConsumer {
@KubeMQEventListener(channels = "orders")
public void onOrder(EventMessageReceived event) {
// process event
}
}
```
Start a local broker with Docker — the gRPC API the starter connects to listens on `50000`, and the shared HTTP/REST and dashboard endpoints on `9090`:
## Modules [#modules]
The project is a multi-module Gradle build. Add `kubemq-spring-boot-starter` to your project; the rest are pulled in transitively or added when you need Spring Cloud Stream, Kotlin, or testing support.
| Module | Description |
| ----------------------------------- | --------------------------------------------------------------------------- |
| `kubemq-spring-boot-autoconfigure` | Auto-configuration, `KubeMQTemplate`, listener annotations, health, metrics |
| `kubemq-spring-boot-starter` | Dependency aggregator — add this to your project |
| `kubemq-spring-cloud-stream-binder` | Spring Cloud Stream binder for Events, Events Store, and Queues |
| `kubemq-spring-boot-starter-kotlin` | Kotlin coroutine extensions, `Flow` adapters, and DSL |
| `kubemq-spring-boot-starter-test` | `MockKubeMQServer`, TestContainers, and test harness |
## Architecture [#architecture]
`KubeMQAutoConfiguration` wires the native KubeMQ Java SDK clients — `PubSubClient`, `QueuesClient`, and `CQClient` — from your `kubemq.*` properties. The `KubeMQTemplate` send API and the `@KubeMQ*Listener` / `@KubeMQ*Handler` beans both delegate to those clients, which speak gRPC to the broker on port `50000`. There is no connector to enable — the starter is a native SDK client that connects directly.
*Auto-configuration binds the three SDK clients from `kubemq.*` properties; the template and listener beans delegate to them over gRPC on port 50000.*
## Capabilities [#capabilities]
Each capability page documents the Spring API surface (`KubeMQTemplate` send methods and the `@KubeMQ*Listener` / `@KubeMQ*Handler` annotations) and links to the underlying KubeMQ pattern so you learn the concept once.
## Quick Links [#quick-links]
New to integrations? See [what an integration is](/integrations#what-an-integration-is) for the mental model, or start with the [KubeMQ Getting Started guide](/deploy) for core broker concepts. This starter speaks native gRPC on port `50000` — it is a direct SDK client, not a [connector](/connectors).
# C++ SDK (/sdks/cpp)
The KubeMQ C++ SDK provides a high-performance, type-safe C++17 client for all messaging patterns. It requires C++17 (GCC 9+, Clang 9+, MSVC 2019+), CMake 3.16+, gRPC v1.78+, and Protobuf v5.29+.
## Installation [#installation]
### vcpkg (Recommended) [#vcpkg-recommended]
```bash
vcpkg install grpc protobuf nlohmann-json
cmake -B build -S . \
-DCMAKE_TOOLCHAIN_FILE=[vcpkg-root]/scripts/buildsystems/vcpkg.cmake
cmake --build build --parallel
```
### CMake FetchContent [#cmake-fetchcontent]
```cmake title="CMakeLists.txt"
include(FetchContent)
FetchContent_Declare(
kubemq-cpp
GIT_REPOSITORY https://github.com/kubemq-io/kubemq-cpp.git
GIT_TAG v1.0.0
)
FetchContent_MakeAvailable(kubemq-cpp)
target_link_libraries(your_app PRIVATE kubemq_static)
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/cpp/tutorials/first-message) to connect a client and publish and receive your first message.
# Audit Logging (/operate/observability/audit)
KubeMQ records a built-in audit trail of security, control-plane, and data-plane error
events. Every audit event follows the [CloudEvents v1.0](https://cloudevents.io/)
specification, so the trail is interoperable with any CloudEvents-aware tooling, and it is
queryable through a REST API on the management API port (`:8080`).
## Overview [#overview]
Audit logging is **on by default**. Events are persisted to a local durable store on each
node and synchronized across cluster nodes over the cluster mesh, so every node holds the
full audit trail for the entire cluster. Each event is a CloudEvents v1.0 envelope carrying
a structured `data` payload that captures who did what, on which channel, over which
transport, and whether it succeeded.
Three kinds of events are recorded:
* **Control events** — security, connection lifecycle, and administrative operations
(authentication, authorization, client connect/disconnect, subscriptions, channel
create/delete, cluster membership, server lifecycle).
* **Data error events** — failures in message send and receive operations across the
patterns.
* **System error events** — infrastructure-level failures such as storage or broker errors.
## Configure it [#configure-it]
Audit logging has three settings: a master `enable` toggle, the retention window in hours,
and how often expired records are purged.
```yaml title="config.yaml"
audit:
enable: true # on by default
retentionHours: 720 # retain events for 30 days
cleanupIntervalMinutes: 60 # purge expired events hourly
```
```yaml title="values.yaml"
spec:
audit:
enable: true # on by default
retentionHours: 720 # retain events for 30 days
cleanupIntervalMinutes: 60 # purge expired events hourly
```
**Version floor:** the `spec.audit.*` fields are present throughout the current GA chart
line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with
`kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA
release predates this reference and will reject these fields; upgrade to the current line.
On Docker the `audit.*` keys are available regardless of chart version.
For the full settings table — types, defaults, valid values, and the Docker/Helm naming for
each key — see the [Observability settings reference](/configure/reference/observability).
## Event format [#event-format]
Each audit event is a CloudEvents v1.0 envelope. The envelope carries the standard
CloudEvents attributes; the audit-specific payload lives in `data`.
```json
{
"specversion": "1.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"source": "kubemq://my-cluster/node-1",
"type": "io.kubemq.audit.auth.success",
"time": "2026-03-26T12:34:56.789Z",
"datacontenttype": "application/json",
"data": {
"category": "control",
"subcategory": "success",
"client_id": "my-producer",
"source_ip": "10.0.0.5",
"transport": "grpc",
"channel": "orders",
"action": "auth.success",
"outcome": "success",
"metadata": {
"method": "SendEvent"
}
}
}
```
### Envelope fields [#envelope-fields]
| Field | Description |
| ----------------- | ---------------------------------------------- |
| `specversion` | Always `"1.0"`. |
| `id` | Unique UUID for each event. |
| `source` | `kubemq://{cluster_name}/{node_id}`. |
| `type` | `io.kubemq.audit.{event_type}`. |
| `time` | ISO 8601 timestamp with millisecond precision. |
| `datacontenttype` | Always `"application/json"`. |
### Data fields [#data-fields]
| Field | Type | Description |
| ---------------- | ------ | ----------------------------------------------- |
| `category` | string | Event category: `control`, `data`, or `system`. |
| `subcategory` | string | Sub-category (e.g. `success`, `error`). |
| `client_id` | string | Client identifier (if applicable). |
| `source_ip` | string | Client IP address (if applicable). |
| `transport` | string | Transport layer (`grpc`, `rest`). |
| `channel` | string | Channel name (if applicable). |
| `message_id` | string | Message, event, or request ID (if applicable). |
| `action` | string | Event type name (same as the `type` suffix). |
| `outcome` | string | `success` or `error`. |
| `error` | string | Error message (only when `outcome` is `error`). |
| `error_category` | string | Error classification (if applicable). |
| `metadata` | map | Additional key-value metadata. |
## Event catalog [#event-catalog]
### Control events [#control-events]
Control-plane events track security, connection lifecycle, and administrative operations.
These events carry `category: "control"`.
| Event type | Description |
| ---------------------- | -------------------------------------------------- |
| `server.started` | Server completed startup. |
| `server.stopped` | Server shutting down. |
| `server.ready` | Server ready to accept traffic. |
| `auth.success` | Authentication succeeded. |
| `auth.failure` | Authentication failed. |
| `authz.denied` | Authorization denied. |
| `client.connected` | Client established a connection. |
| `client.disconnected` | Client disconnected. |
| `client.connect_error` | Client connection failed. |
| `subscription.created` | Subscription established. |
| `subscription.deleted` | Subscription removed. |
| `subscription.error` | Subscription failed. |
| `channel.created` | Channel created (detected by the channel monitor). |
| `channel.deleted` | Channel deleted (detected by the channel monitor). |
| `stream.opened` | Bidirectional stream opened. |
| `stream.closed` | Bidirectional stream closed. |
| `cluster.node_joined` | Cluster node joined. |
| `cluster.node_left` | Cluster node left. |
A channel monitor watches for channels appearing and disappearing and emits
`channel.created` / `channel.deleted` control events accordingly.
### Data error events [#data-error-events]
Data-plane error events track failures in message send and receive operations. These events
carry `category: "data"`.
| Event type | Description |
| ------------------------- | ---------------------------- |
| `events.send_error` | Event publish failed. |
| `events_store.send_error` | Events Store publish failed. |
| `command.send_error` | Command send failed. |
| `query.send_error` | Query send failed. |
| `queue.send_error` | Queue message send failed. |
| `queue.batch_send_error` | Queue batch send failed. |
| `queue.receive_error` | Queue receive failed. |
### System error events [#system-error-events]
System-level errors from infrastructure components. These events carry
`category: "system"`.
| Event type | Description |
| ---------------------- | --------------------------- |
| `system.storage_error` | Persistent storage failure. |
| `system.broker_error` | Message broker error. |
## Retention & cleanup [#retention--cleanup]
Audit events are retained for the window set by `retentionHours` (default `720` hours = 30
days). A background cleanup runs at the interval set by `cleanupIntervalMinutes` (default
`60` minutes) and deletes every event older than the retention window. Both settings accept
a minimum of `1`. Tune `retentionHours` to your compliance window and lower
`cleanupIntervalMinutes` only if you need expired events purged more aggressively.
Retention applies per node, but because the trail is synchronized over the cluster mesh,
every node holds — and prunes — the same cluster-wide set of events.
## Query the audit log (REST) [#query-the-audit-log-rest]
The audit trail is queryable through the management API port (`:8080`). Two endpoints are
available: one to list matching events and one to aggregate them.
### List events [#list-events]
```text
GET /api/v1/audit
```
| Parameter | Type | Default | Description |
| ------------- | -------------- | ---------- | ------------------------------------------------- |
| `from` | RFC3339 string | 1 hour ago | Start time. |
| `to` | RFC3339 string | now | End time. |
| `category` | string | (all) | Filter by category (`control`, `data`, `system`). |
| `subcategory` | string | (all) | Filter by subcategory. |
| `event_type` | string | (all) | Filter by event type (e.g. `auth.success`). |
| `channel` | string | (all) | Filter by channel name. |
| `client_id` | string | (all) | Filter by client ID. |
| `limit` | int | 50 | Results per page (1–1000). |
| `offset` | int | 0 | Pagination offset. |
```bash
curl "http://localhost:8080/api/v1/audit?category=control&event_type=auth.failure&limit=10"
```
### Aggregate statistics [#aggregate-statistics]
```text
GET /api/v1/audit/stats
```
| Parameter | Type | Default | Description |
| ---------- | -------------- | ------------ | ------------------- |
| `from` | RFC3339 string | 1 hour ago | Start time. |
| `to` | RFC3339 string | now | End time. |
| `group_by` | string | `event_type` | Grouping dimension. |
```bash
curl "http://localhost:8080/api/v1/audit/stats?group_by=category&from=2026-03-25T00:00:00Z"
```
### Error responses [#error-responses]
| HTTP code | Error | Description |
| --------- | ---------------------- | ----------------------------------------------- |
| 404 | `ErrAuditDisabled` | Audit service is disabled or not initialized. |
| 400 | `ErrAuditInvalidParam` | Invalid query parameter (e.g. bad date format). |
| 500 | `ErrAuditQueryFailed` | Query failed. |
## Access control [#access-control]
When control-plane authentication is enabled, the audit query endpoints require at least the
**ReadOnly** role, like the rest of the management API. See the
[Management API](/operate/observability/api-reference) overview for the response envelope,
status codes, and access-control model.
## Feed a SIEM [#feed-a-siem]
The REST query API makes it straightforward to feed audit events into an external SIEM. Poll
`GET /api/v1/audit` on an interval, advancing the `from` / `to` window each pass, and forward
the returned CloudEvents records into Splunk, Elastic, or any log-management platform. Because
the trail is synchronized cluster-wide, you can poll a single node and still capture every
event from the cluster.
## Related [#related]
* [Observability settings reference](/configure/reference/observability) — the audit
configuration keys with their Docker and Helm naming, defaults, and valid values.
* [Management API](/operate/observability/api-reference) — the response envelope, status codes,
and access-control model shared by the audit query endpoints.
# Observability (/operate/observability)
KubeMQ reports on itself across several complementary surfaces. An always-on Prometheus
exporter, opt-in OpenTelemetry traces and metrics, structured JSON logs, a CloudEvents
audit trail, a built-in web dashboard, and an HTTP/WebSocket management API all expose
what the server is doing right now and what it has done. This section is the operator's
home for monitoring KubeMQ in production — how to scrape metrics, wire up tracing, read
the logs, query the audit log, and consume the management API.
Each surface answers a different question: Prometheus metrics give you the quantitative
time series, tracing shows you the path of a single message across patterns, logs and the
audit trail record discrete events, the dashboard renders the live picture, and the
management API is the programmatic interface behind it all.
## The observability surfaces [#the-observability-surfaces]
| Surface | What it gives you | Where it's served | Always-on? |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------- |
| Prometheus metrics | Counters, gauges, and histograms for every messaging pattern, queues, RPC latency, cluster health, and the agent platform | `GET /metrics` (`:8080`) | Yes |
| OpenTelemetry tracing | Distributed traces and OTel metrics over OTLP to Jaeger, Grafana Tempo, or Datadog | OTLP export to an external collector | No (opt-in) |
| Structured logging | JSON log lines on stdout — levels, the field set, and trace correlation | stdout | Yes |
| Audit logging | A CloudEvents v1.0 trail of auth, lifecycle, and data-plane error events | `GET /api/v1/audit` (`:8080`) | Yes |
| Built-in dashboard | A live web view of channels, clients, queues, the patterns, cluster topology, and agents | `:8080` | Yes |
| Management API | The HTTP and WebSocket interface for health, stats, snapshots, audit query, and dashboard actions | `:8080` | Yes |
## Push vs pull [#push-vs-pull]
KubeMQ uses a **pull-first** model for metrics and a **push-first** model for traces, and
the two can run at the same time. Logs are written to stdout and captured by your runtime.
| Signal | Model | Consumer |
| --------------------- | ------------------------------------------------------ | ----------------------------------- |
| Prometheus metrics | Pull — Prometheus scrapes `GET /metrics` on demand | Prometheus / Grafana |
| Dashboard snapshots | Pull — the dashboard polls the snapshot endpoints | Built-in dashboard |
| OpenTelemetry traces | Push — the OTLP exporter pushes to a collector | Jaeger / Tempo / Datadog |
| OpenTelemetry metrics | Push — the meter provider exports periodically | OTLP-compatible metrics backend |
| Audit events | Pull — the REST query API | Operator / SIEM |
| Logs | Push — JSON written to stdout, captured by the runtime | Log aggregator (Loki, Splunk, etc.) |
The Prometheus exporter always runs — it also powers the built-in dashboard. OpenTelemetry
is opt-in and is enabled through the telemetry settings.
## Where each surface is served [#where-each-surface-is-served]
The management API port (`:8080`) is the same in-process port that serves the dashboard,
the metrics endpoint, the stats and snapshot endpoints, and the audit query API. When
control-plane auth is enabled, every endpoint below requires at least the **ReadOnly**
role.
| Surface | Endpoint | Access (control-plane auth on) |
| ------------------------ | -------------------------------------------------------------- | ------------------------------ |
| Prometheus scrape | `GET /metrics` (`:8080`) | ReadOnly+ |
| Dashboard snapshots | `GET /api/snapshot`, `GET /api/cluster-snapshot` | ReadOnly+ |
| Audit query | `GET /api/v1/audit`, `GET /api/v1/audit/stats` | ReadOnly+ |
| Channel and client stats | `GET /v1/stats/channels`, `GET /v1/stats/clients` | ReadOnly+ |
| Live message monitor | WebSocket `/api/monitor` | ReadOnly+ |
| Real-time cluster stream | WebSocket `/api/connection` | ReadOnly+ |
| Billing summary | `GET /billing` | ReadOnly+ |
| OTLP export | OTLP gRPC (`:4317`) or HTTP (`:4318`) to an external collector | Collector-side |
| Application logs | stdout (JSON) | — |
## Start here [#start-here]
## Configure it [#configure-it]
The raw configuration keys for telemetry, audit, and server-event notifications — with
their Docker and Helm naming, defaults, and valid values — live in the
[Observability settings reference](/configure/reference/observability). That page
is the source of truth for the settings; this section covers the concepts, how-to, and the
full management API.
# Structured Logging (/operate/observability/logging)
KubeMQ writes **structured JSON logs to stdout**. Every log line is a single JSON object, which
makes the output easy to capture and parse with any container log aggregator — Loki, Splunk,
Datadog, Fluentd, and others — with no extra log files or rotation to manage.
## Log format [#log-format]
Every log line is one JSON object on stdout:
```json
{
"level": "INFO",
"time": "2026-06-29T12:34:56.789Z",
"component": "grpc",
"caller": "grpc/grpc.go:144",
"msg": "starting gRPC server",
"host": "kubemq-node-1"
}
```
The field set:
| JSON key | Description |
| ----------- | --------------------------------------------------------------------------------------------------------------- |
| `msg` | The log message string. |
| `level` | The level in ALL-CAPS: `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`. |
| `time` | ISO 8601 timestamp with milliseconds. |
| `component` | The logger name (the KubeMQ component that emitted the line, e.g. `grpc`, `array`). |
| `caller` | The trimmed `file:line` source location. |
| `stack` | A stack trace — present on `ERROR` and above when configured. |
| `host` | The node hostname. |
| `trace_id` | Trace identifier — present only when [tracing](/operate/observability/tracing) is enabled and a span is active. |
| `span_id` | Span identifier — present only when tracing is enabled and a span is active. |
Components enrich their loggers with extra context fields (such as `client_id` or `channel`)
that appear as additional key/value pairs on the relevant lines.
## Log levels [#log-levels]
KubeMQ maps six application levels onto the JSON `level` field. `Info` is the default.
| Level | JSON `level` |
| ----- | ---------------- |
| Trace | `DEBUG` |
| Debug | `DEBUG` |
| Info | `INFO` (default) |
| Warn | `WARN` |
| Error | `ERROR` |
| Fatal | `FATAL` |
Trace and Debug both emit at the `DEBUG` JSON level. The numeric values used in configuration
are `0=Trace`, `1=Debug`, `2=Info`, `3=Warn`, `4=Error`, `5=Fatal`.
## Configure the level [#configure-the-level]
Set the log level at startup with the `log.level` config key (or its environment-variable
binding). The default is `2` (Info).
```yaml title="config.yaml"
log:
level: 2 # 0=Trace 1=Debug 2=Info 3=Warn 4=Error 5=Fatal
```
Or set it with an environment variable, which overrides the config-file value at startup:
```bash
LOGLEVEL=1 # Debug
```
```yaml title="values.yaml"
log:
level: 2 # 0=Trace 1=Debug 2=Info 3=Warn 4=Error 5=Fatal
```
## Change the level at runtime [#change-the-level-at-runtime]
The log level can be changed **without restarting the server**. The management API exposes a
`set_log_level` action on its unified request endpoint; a single call takes effect immediately
across every component, because all loggers share one level. See the
[Action Endpoint](/operate/observability/api-reference/actions) for how requests are sent to the
management API.
## Correlate logs with traces [#correlate-logs-with-traces]
When [OpenTelemetry tracing](/operate/observability/tracing) is enabled, log lines emitted during a
traced operation carry `trace_id` and `span_id` fields. This lets you pivot from a log line to
the matching trace (and back) in your backend — Jaeger, Grafana Tempo, Datadog, and others.
```json
{
"level": "ERROR",
"time": "2026-06-29T12:34:56.789Z",
"component": "array",
"caller": "array/events_sender.go:42",
"msg": "publish pub/sub event error",
"host": "kubemq-node-1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"error": "context deadline exceeded"
}
```
These two fields appear **only when tracing is on and a span is active** for the operation being
logged. With tracing disabled, the fields are simply absent and logging behaves exactly as
before. Correlation therefore requires OpenTelemetry — see the
[Distributed Tracing](/operate/observability/tracing) page to enable it.
## Ship logs [#ship-logs]
Because KubeMQ logs JSON to stdout, any log shipper that captures container stdout works without
extra configuration — **Loki**, **Splunk**, **Datadog**, and **Fluentd** all ingest the lines as
structured records.
To correlate logs and traces in Grafana, link a Loki datasource to a Tempo datasource:
1. Ship KubeMQ's JSON logs to Loki (for example via Promtail or the Grafana Agent).
2. In the Loki datasource settings, configure **Derived Fields** with a regex that extracts
`trace_id`, pointing it at the Tempo datasource.
3. Log lines from traced operations then show a clickable **Tempo** button that opens the trace
in the same time range.
For Datadog, the `trace_id` and `span_id` field names already match the fields Datadog expects
for log–trace correlation, once your log pipeline parses them from the JSON body.
## Related [#related]
# Prometheus Metrics (/operate/observability/metrics)
KubeMQ exposes an always-on Prometheus exporter that reports message counts, byte volumes,
client counts, queue health, RPC latency, cluster state, and agent-platform activity. It
needs no configuration — it is running the moment KubeMQ starts.
## Overview [#overview]
The Prometheus exporter is **always on**. The same in-process metrics also power the
built-in dashboard and its snapshot system, so the numbers
you scrape with Prometheus and the numbers you see in the dashboard come from one source.
Metrics are served in-process by the [management API](/operate/observability/api-reference) at
`GET /metrics` on the management API port (`:8080`) — the same port that serves health
probes, stats, and the dashboard. There is no separate listener and no extra setup; the
endpoint returns standard Prometheus text format.
Prometheus is the **pull** path and is always on. KubeMQ also supports an opt-in
**push** path — OpenTelemetry metrics over OTLP — covered on the
[Distributed Tracing](/operate/observability/tracing) page. The OTel `pending` instrument
(active in-flight messages) is exported only over OTLP; there is no equivalent Prometheus
series.
## Scraping KubeMQ [#scraping-kubemq]
Read the endpoint directly with `curl`:
```bash
curl http://localhost:8080/metrics
```
Point Prometheus at the same endpoint with a `scrape_config`:
```yaml title="prometheus.yml"
scrape_configs:
- job_name: kubemq
metrics_path: /metrics
static_configs:
- targets: ["kubemq:8080"]
```
## Labels [#labels]
Core messaging metrics carry a shared label set. The `type` and `side` label values are
determined by the messaging pattern.
| Label | Values | Description |
| ----------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `node` | host name | The KubeMQ node that recorded the metric. |
| `client_id` | client id | The connected client. **Aggregated away on store series** — the per-channel store collectors drop `client_id` at collect time, so queue, RPC-latency, and similar series carry only `node`, `type`, `side`, `channel`. |
| `type` | `events`, `events_store`, `commands`, `queries`, `queues` | The messaging pattern. |
| `side` | `send`, `receive` | Whether the metric is for the producing or consuming side of the channel. |
| `channel` | channel name | The message channel the metric belongs to. |
## Core messaging metrics [#core-messaging-metrics]
These collectors track message activity across all five patterns. They share the label set
above.
| Prometheus name | Type | Description |
| --------------------------- | ------- | ----------------------------------------- |
| `kubemq_messages_count` | Counter | Total messages sent/received per channel. |
| `kubemq_messages_volume` | Counter | Total byte volume per channel. |
| `kubemq_messages_last_seen` | Gauge | Unix timestamp (ms) of last activity. |
| `kubemq_messages_delayed` | Counter | Number of delayed queue messages. |
| `kubemq_messages_expired` | Counter | Total expired queue messages. |
| `kubemq_messages_waiting` | Gauge | Queue messages waiting for consumers. |
| `kubemq_clients_count` | Gauge | Connected client count per channel. |
| `kubemq_errors_count` | Counter | Total errors per channel. |
| `kubemq_messages_responses` | Counter | Total RPC responses per channel. |
## Queue health metrics [#queue-health-metrics]
These series give queue operators visibility into redelivery, dead-letter dispositions,
backlog age, and dwell time. They use the standard four-label set (`node`, `type`, `side`,
`channel`) — `client_id` is aggregated away — except where a row notes an extra label.
| Prometheus name | Type | Description |
| ----------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kubemq_queue_redelivery_total` | Counter | Total queue redeliveries per channel — a receive whose post-increment receive count is greater than one — attributed to the source queue channel on the `receive` side. Uses the standard four labels only (no extra label). |
| `kubemq_queue_dlq_total` | Counter | Total queue dead-letter dispositions per channel, attributed to the source channel on the `receive` side. Carries one **extra `reason` label**: `routed` (re-published to the configured dead-letter queue) or `dropped` (no DLQ configured or a marshal error — silent data loss made visible). The `reason` label is on `dlq_total` only, not on `redelivery_total`. |
| `kubemq_queue_backlog_age_seconds` | Gauge | Age (seconds) of the oldest undelivered message in the backlog, per channel (`queues`/`receive`). A live gauge recomputed each 5-second snapshot cycle; `0` when the backlog is empty or fully delivered, and `0` after a restart until the next cycle rebuilds it. |
| `kubemq_queue_dwell_seconds_sum` | Counter | Cumulative sum of per-message dwell times (seconds) on the `receive` side — the time from when a message entered the queue until it was delivered to a consumer. Survives restarts. Divide by `kubemq_queue_dwell_messages_total` for the average dwell. |
| `kubemq_queue_dwell_messages_total` | Counter | Cumulative count of messages whose dwell time was recorded. Survives restarts. The denominator for the average dwell time. |
## RPC latency metrics [#rpc-latency-metrics]
These series measure command and query round-trip latency on the `send` side. They use the
standard four-label set.
| Prometheus name | Type | Description |
| -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kubemq_rpc_latency_seconds_sum` | Counter | Cumulative sum of RPC round-trip times (seconds) on the `send` side of a command or query channel — measured from request dispatch until the response is received. Survives restarts. Recorded only when a response is received (errored or timed-out requests with no response are excluded). Divide by `kubemq_rpc_latency_calls_total` for the average. |
| `kubemq_rpc_latency_calls_total` | Counter | Cumulative count of RPC calls whose round-trip latency was recorded (`type ∈ {commands, queries}`, `side = send`). Survives restarts. The denominator for the average RPC latency. |
**Cache-served queries are excluded.** The query cache short-circuits before the latency is
recorded, so these series measure real backend round-trips (cache misses), not local cache
lookups. Commands have no cache and are always recorded.
## Latency histograms [#latency-histograms]
Three histogram instruments expose latency **distributions** (so you can compute percentiles
with `histogram_quantile`), labeled by `node`, `type`, and `side`.
| Prometheus name | Type | Description |
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kubemq_rpc_latency_histogram_seconds` | Histogram | RPC round-trip latency distribution (commands and queries, `send` side). Same gate as `kubemq_rpc_latency_seconds_sum` — recorded only when a response is received; cache-served queries are excluded. |
| `kubemq_queue_dwell_histogram_seconds` | Histogram | Queue dwell-time distribution (`queues`, `receive` side). Its population matches `kubemq_queue_dwell_messages_total`. |
| `kubemq_operation_duration_seconds` | Histogram | Per-operation send duration for patterns with no round-trip: `type ∈ {events, events_store, queues}` on the `send` side. Commands and queries use `kubemq_rpc_latency_histogram_seconds` for their percentile source. |
All three histograms share one bucket set (seconds), giving a consistent latency vocabulary
across `/metrics`:
```text
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60
```
## Cluster & infra metrics [#cluster--infra-metrics]
These gauges expose node health, cluster role, and configured peer state, labeled by `node`
(and `role` where noted).
| Prometheus name | Type | Description |
| ------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kubemq_cluster_leader` | Gauge | `1` if this node is the cluster leader **or** a standalone node (both act as the authoritative message processor); `0` otherwise. |
| `kubemq_cluster_role` | Gauge | Info-gauge: emits `1` for the active `role` label only, `0` for the rest. `role ∈ {leader, follower, candidate, standalone}`. Filter with `kubemq_cluster_role == 1`. |
| `kubemq_cluster_ready` | Gauge | `1` when the node is ready to accept traffic; `0` during startup or unhealthy states. |
| `kubemq_cluster_healthy` | Gauge | `1` when the node is healthy. Distinct from ready — a node can be healthy but not yet ready. |
| `kubemq_cluster_state_seconds` | Gauge | Seconds since the last cluster state change. For clustered nodes: seconds since the last ready-settle (leadership election or follower sync). For standalone nodes: seconds since server startup. |
| `kubemq_cluster_peers` | Gauge | The configured bootstrap peer count, including self (minimum `1` for standalone). This is the configured bootstrap peer list, **not** live cluster membership — on a running cluster, live membership may differ during leader transitions. |
On a single-node (standalone) server, `kubemq_cluster_leader = 1`,
`kubemq_cluster_role{role="standalone"} = 1`, `kubemq_cluster_ready = 1` once ready, and
`kubemq_cluster_peers = 1`. On a multi-node cluster, exactly one node has
`kubemq_cluster_leader = 1` at any given time.
## Agent-platform metrics [#agent-platform-metrics]
KubeMQ's [agent platform](/aiway) emits its own series for MCP tool calls and A2A agent
traffic. These use domain-specific labels rather than the core messaging label set.
| Prometheus name | Type | Labels | Description |
| -------------------------------------- | --------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `kubemq_mcp_tool_calls_total` | Counter | `tool`, `status` | Total MCP tool calls. |
| `kubemq_mcp_tool_duration_seconds` | Histogram | `tool` | Duration of MCP tool calls (seconds). |
| `kubemq_a2a_requests_total` | Counter | `agent_id`, `method`, `status` | Total A2A requests routed to agents (any path — HTTP gateway and MCP bridge). |
| `kubemq_a2a_request_duration_seconds` | Histogram | `agent_id` | Duration of A2A requests routed to agents (any path — HTTP gateway and MCP bridge). |
| `kubemq_a2a_errors_total` | Counter | `agent_id`, `error_code` | Total A2A errors for requests routed to agents (any path — HTTP gateway and MCP bridge). |
| `kubemq_a2a_stream_events_total` | Counter | `agent_id` | Total A2A stream events delivered to SSE clients (receive side). |
| `kubemq_a2a_stream_outcomes_total` | Counter | `agent_id`, `outcome` | Total A2A streams by terminal `outcome` (`done`, `error`, `idle_timeout`, `canceled`) — exactly one per stream. |
| `kubemq_a2a_registry_operations_total` | Counter | `op`, `status` | Total agent registry operations (register, deregister, heartbeat, expire). |
| `kubemq_a2a_sse_streams_active` | Gauge | — | Current number of active A2A SSE streams. |
The MCP tool-call histogram (`kubemq_mcp_tool_duration_seconds`) uses the bucket set
`0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`; the A2A request-duration histogram
(`kubemq_a2a_request_duration_seconds`) uses `0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60`.
**The `agent_id` label is bounded.** The first \~1000 distinct agent IDs each get their own
label value; every subsequent agent ID collapses into `agent_id="other"`. This is cardinality
protection — for exact per-agent leaderboards across the whole fleet, use the dashboard rather
than Prometheus.
**The `method` label is sanitized.** Each A2A method is checked against a fixed allowlist of
known methods; anything not in the list is recorded as `method="unknown"`. This keeps a
malformed or malicious request stream from exploding Prometheus label cardinality.
In a cluster, these counters are aggregated across all nodes, so the totals reflect the whole
deployment rather than a single replica.
## PromQL examples [#promql-examples]
```text
# RPC p99 latency (commands) — rolling 5-minute window
histogram_quantile(0.99,
rate(kubemq_rpc_latency_histogram_seconds_bucket{type="commands"}[5m])
)
# Queue dwell p95 across all nodes
histogram_quantile(0.95,
sum(rate(kubemq_queue_dwell_histogram_seconds_bucket[5m])) by (le)
)
# Alert: no leader exists across the cluster
sum(kubemq_cluster_leader) == 0
# Alert: a node has been not-ready for more than 60 seconds
kubemq_cluster_ready == 0 and kubemq_cluster_state_seconds > 60
```
## Grafana [#grafana]
Point Grafana at the Prometheus instance scraping KubeMQ and build dashboards on the series
above — message rates from `kubemq_messages_count`, queue health from the
`kubemq_queue_*` series, latency percentiles from the histogram buckets, and cluster state
from the `kubemq_cluster_*` gauges.
## Internal channels [#internal-channels]
Internal cluster channels are filtered out of the user-facing metrics, so they never appear
in the series above.
# Distributed Tracing (/operate/observability/tracing)
KubeMQ integrates with [OpenTelemetry](https://opentelemetry.io/) (OTel) for distributed
tracing and metrics export. It is **opt-in** — off by default and turned on with a single
`telemetry.enable` flag. When enabled, KubeMQ instruments every messaging operation (Events,
Events Store, Queues, Commands, Queries) with spans and exports them, along with a set of OTel
metric instruments, over the OTLP protocol to any compatible backend (Jaeger, Grafana Tempo,
Datadog, and others).
Tracing is **complementary** to the [always-on Prometheus exporter](/operate/observability/metrics).
Prometheus gives you a pull-based metric series for dashboards and alerting; OpenTelemetry adds
distributed traces with context propagation across services, plus a parallel push-based metric
stream over OTLP.
## Enable it [#enable-it]
Telemetry is controlled by the master `telemetry.enable` switch (off by default). Turn it on,
then tune the traces, metrics, and exporter sub-blocks as needed.
```yaml title="config.yaml"
telemetry:
enable: true
```
```yaml title="values.yaml"
telemetry:
enable: true
```
**Version floor:** the `spec.telemetry.*` fields are present throughout the current GA
chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with
`kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA
release predates this reference and will reject these fields; upgrade to the current line.
On Docker the `telemetry.*` keys are available regardless of chart version.
The full set of telemetry settings — service name, traces/metrics sub-blocks, exporter
options, and the Docker/Helm key and environment-variable mappings — lives in the
[Configuration reference](/configure/reference/observability). This page covers what
those settings do and what you get when telemetry is on.
## OTLP exporter [#otlp-exporter]
Traces and metrics are exported over OTLP to a collector or backend. The exporter is configured
under `telemetry.exporter`.
| Setting | Default | Description |
| ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `protocol` | `grpc` | OTLP wire protocol — `grpc` (default endpoint `:4317`) or `http` (default endpoint `:4318`). |
| `endpoint` | `localhost:4317` | OTLP collector endpoint (`host:port`). |
| `insecure` | `true` | When `true`, the connection skips TLS. Set `false` to use TLS 1.2+ to the collector. |
| `compression` | `gzip` | Payload compression — `gzip` or `none`. |
| `timeout` | `10s` | Export request timeout (Go duration). |
| `headers` | `{}` | Custom headers sent with each export request (a `key/value` map). **`config.yaml`-only** — there is no environment-variable or CRD path for this field. |
| Protocol | Default endpoint | Notes |
| -------- | ---------------- | -------------------------------------------------- |
| `grpc` | `localhost:4317` | OTLP over gRPC. Supports TLS and gzip compression. |
| `http` | `localhost:4318` | OTLP over HTTP. Supports TLS and gzip compression. |
## Samplers [#samplers]
The sampler decides which traces are recorded. It is set with `telemetry.traces.sampler`; the
ratio-based samplers read `telemetry.traces.samplingRatio` (a fraction from `0.0` to `1.0`).
| Sampler | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------- |
| `always_on` | Sample every trace. |
| `always_off` | Sample no traces. |
| `trace_id_ratio` | Sample a fraction of traces based on `samplingRatio` (e.g. `0.1` = 10% of traces). |
| `parent_based` | Use the parent span's sampling decision; falls back to `trace_id_ratio` for root spans. The default. |
## Environment variable overrides [#environment-variable-overrides]
Standard OTel environment variables are read after the config file loads and **override** the
file values. These are the recommended way to set exporter and sampler options from outside the
container.
| Environment variable | Overrides | Notes |
| -------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `OTEL_SERVICE_NAME` | Service name | The OTLP `service.name` resource attribute. |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Exporter endpoint | |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | Exporter protocol | `grpc` or `http`. |
| `OTEL_EXPORTER_OTLP_INSECURE` | Exporter insecure | `"true"` / `"false"`. |
| `OTEL_EXPORTER_OTLP_COMPRESSION` | Exporter compression | `gzip` or `none`. |
| `OTEL_EXPORTER_OTLP_TIMEOUT` | Exporter timeout | Integer milliseconds (e.g. `"10000"`). |
| `OTEL_EXPORTER_OTLP_HEADERS` | Exporter headers | Comma-separated `key=value` pairs. |
| `OTEL_TRACES_SAMPLER` | Traces sampler | OTel sampler names: `always_on`, `always_off`, `traceidratio`, `parentbased_traceidratio`, `parentbased_always_on`, `parentbased_always_off`. |
| `OTEL_TRACES_SAMPLER_ARG` | Traces sampling ratio | Float string (e.g. `"0.1"`). |
KubeMQ also derives its own `TELEMETRY_*` environment variables from the config keys (for
example `TELEMETRY_ENABLE`, `TELEMETRY_EXPORTER_ENDPOINT`). Where both exist, **prefer the
standard `OTEL_*` variables** above — they are the conventional way to configure an OTel
exporter and are read after the KubeMQ-specific values.
## What gets traced [#what-gets-traced]
When tracing is on, every messaging pattern is instrumented. Each operation produces a span
whose name encodes the operation and channel.
| Span name | Span kind | Pattern |
| -------------------------------- | --------- | ---------------- |
| `process events.{channel}` | Consumer | Events |
| `process events-store.{channel}` | Consumer | Events Store |
| `process commands.{channel}` | Consumer | Commands |
| `process queries.{channel}` | Consumer | Queries |
| `process queues.{channel}` | Consumer | Queues (single) |
| `publish-batch queues.{channel}` | Consumer | Queues (batch) |
| `deliver queues.{channel}` | Producer | Queues (receive) |
In addition, the shared HTTP server applies automatic **transport-level tracing** to all
HTTP-based connectors (REST, A2A, MCP, and CloudEvents) under the service name `kubemq-http`.
When telemetry is enabled, each HTTP request handled by these connectors generates a span with
the standard HTTP attributes:
| Attribute | Description |
| ------------------ | ------------------------------ |
| `http.method` | HTTP method (GET, POST, etc.). |
| `http.route` | Matched route pattern. |
| `http.status_code` | Response status code. |
| `http.target` | Request path. |
The A2A and MCP connectors and the agent registry have **no application-level OTel
instrumentation** — they rely on this transport-level tracing for HTTP spans (method, route,
status, duration) and on [Prometheus metrics](/operate/observability/metrics) for per-operation
detail. Per-operation spans (which agent was invoked, tool execution time, JSON-RPC method)
are not currently traced.
## Span attributes [#span-attributes]
Every messaging span carries a standard set of attributes:
| Attribute | Description | Example |
| ------------------------------- | ------------------------------ | --------------------------------------------------------- |
| `messaging.system` | Always `kubemq`. | `kubemq` |
| `messaging.operation.type` | Operation type. | `process`, `receive` |
| `messaging.destination.name` | Channel name. | `orders` |
| `messaging.message.id` | Message, event, or request ID. | `abc123` |
| `messaging.client.id` | Client identifier. | `my-producer` |
| `messaging.kubemq.channel_type` | Channel type. | `events`, `events_store`, `commands`, `queries`, `queues` |
Queue spans add these extra attributes:
| Attribute | Description |
| ------------------------------------ | ---------------------------------------------------------------------- |
| `messaging.kubemq.expiration` | Message expiration (e.g. `30s`). |
| `messaging.kubemq.delay` | Message delay (e.g. `10s`). |
| `messaging.kubemq.max_receive` | Max receive count before a message is routed to the dead-letter queue. |
| `messaging.batch.message_count` | Number of messages in a batch. |
| `messaging.kubemq.batch_error_count` | Number of failed messages in a batch. |
| `messaging.kubemq.messages_received` | Messages received in a receive operation. |
| `messaging.kubemq.messages_expired` | Messages expired during a receive. |
## Context propagation [#context-propagation]
KubeMQ propagates trace context through message tags using the W3C **TraceContext** and
**Baggage** propagators:
1. **Inject** — when a message passes through the tracing middleware, the current trace context
is injected into the message's `Tags` map.
2. **Extract** — when the message arrives at the message broker, the trace headers are read back
out of the `Tags` map to link the consumer span to the producer.
This enables end-to-end tracing across **producer → the message broker → consumer**, even when
the producer and consumer use different SDKs.
## Error recording [#error-recording]
When a send or receive operation fails, the tracing middleware:
1. Records the error on the span.
2. Sets the span status to `Error`.
3. Increments the `messaging.kubemq.errors.count` metric (see below).
## OTel metrics [#otel-metrics]
In addition to traces, KubeMQ exports a set of OTel metric instruments over OTLP. These are a
parallel push-based path to the [pull-based Prometheus series](/operate/observability/metrics) and
are useful when your backend already ingests OTLP metrics (Mimir, Datadog, and others). All
instruments share the labels `node`, `type` (channel type), `side` (`send` / `receive`), and
`channel`.
| Instrument | Type | Unit | Description |
| ------------------------------------- | ------------- | ----- | ------------------------------ |
| `messaging.kubemq.messages.count` | Counter | — | Total messages processed. |
| `messaging.kubemq.messages.volume` | Counter | bytes | Total message volume in bytes. |
| `messaging.kubemq.errors.count` | Counter | — | Total errors. |
| `messaging.kubemq.messages.pending` | UpDownCounter | — | Pending messages. |
| `messaging.kubemq.messages.delayed` | UpDownCounter | — | Delayed queue messages. |
| `messaging.kubemq.messages.expired` | Counter | — | Expired messages. |
| `messaging.kubemq.messages.waiting` | UpDownCounter | — | Queue messages waiting. |
| `messaging.kubemq.clients.count` | UpDownCounter | — | Connected clients. |
| `messaging.kubemq.messages.last_seen` | Gauge | — | Last activity timestamp. |
| `messaging.kubemq.responses.count` | Counter | — | RPC responses. |
| `messaging.kubemq.operation.duration` | Histogram | s | Per-operation latency. |
| `messaging.kubemq.message.size` | Histogram | bytes | Message payload size. |
| `messaging.kubemq.queue.depth` | UpDownCounter | — | Queue depth per channel. |
The OTel `messaging.kubemq.operation.duration` histogram uses its **own** bucket boundaries
(in seconds), which are **distinct** from the Prometheus histogram buckets on the
[Metrics page](/operate/observability/metrics) — do not assume they match:
```text
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10
```
## Export to a backend [#export-to-a-backend]
Point the exporter at any OTLP-compatible collector or backend. The example below runs Jaeger
locally and exports traces to it over OTLP gRPC.
```yaml title="docker-compose.yml"
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "4317:4317" # OTLP gRPC
- "16686:16686" # Jaeger UI
environment:
- COLLECTOR_OTLP_ENABLED=true
```
```yaml title="config.yaml"
telemetry:
enable: true
serviceName: "kubemq-production"
traces:
enable: true
sampler: "parent_based"
samplingRatio: 0.1 # sample 10% of traces
metrics:
enable: true
exportInterval: "30s"
exporter:
protocol: "grpc"
endpoint: "jaeger:4317"
insecure: true
compression: "gzip"
```
The same exporter block works for **Grafana Tempo** and **Datadog** — point `endpoint` at the
relevant OTLP receiver, switch `protocol` to `http` if the backend expects OTLP/HTTP, and supply
any required auth headers via `OTEL_EXPORTER_OTLP_HEADERS`.
## Correlate with logs [#correlate-with-logs]
When tracing is on, KubeMQ adds `trace_id` and `span_id` fields to the log lines emitted during
a traced operation, so you can pivot between a log line and its trace in your backend. See
[Structured Logging](/operate/observability/logging) for the log field set and a Grafana
Loki ↔ Tempo derived-fields setup.
## Related [#related]
# C# SDK (/sdks/csharp)
The KubeMQ .NET SDK provides an async-first C# client for all messaging patterns with dependency injection support, auto-reconnection, and retry policies. It requires .NET 8.0 (LTS).
## Installation [#installation]
```bash
dotnet add package KubeMQ.SDK.CSharp
```
Or via PackageReference:
```xml title="csproj"
```
**Prerequisites:** .NET 8.0 (LTS), KubeMQ server v3.0+.
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/csharp/tutorials/first-message) to connect a client and publish and receive your first message.
# Elixir SDK (/sdks/elixir)
The KubeMQ Elixir SDK provides an OTP-native GenServer client for all messaging patterns. It requires Elixir 1.15+ and communicates with KubeMQ over gRPC.
## Installation [#installation]
Add `kubemq` to your `mix.exs` dependencies:
```elixir title="mix.exs"
defp deps do
[
{:kubemq, "~> 1.0"}
]
end
```
Then fetch:
```bash
mix deps.get
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/elixir/tutorials/first-message) to connect a client and publish and receive your first message.
# Go SDK (/sdks/go)
The KubeMQ Go SDK provides a high-performance gRPC client for all messaging patterns. It requires Go 1.25 or later and is compatible with KubeMQ server v2.2+.
## Installation [#installation]
```bash
go get github.com/kubemq-io/kubemq-go/v2
```
## Quick connect [#quick-connect]
```go
import "github.com/kubemq-io/kubemq-go/v2"
// Connect to a local KubeMQ server on the default gRPC port.
client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000))
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/go/tutorials/first-message) to connect a client and publish and receive your first message.
# Java SDK (/sdks/java)
The KubeMQ Java SDK provides a type-safe client for all messaging patterns over gRPC transport with built-in TLS, authentication, and reconnection support. It requires Java 11+ (LTS releases 11, 17, and 21 are tested).
## Installation [#installation]
### Maven [#maven]
```xml title="pom.xml"
io.kubemq.sdk
kubemq-sdk-Java
3.1.1
```
### Gradle [#gradle]
```groovy title="build.gradle"
implementation 'io.kubemq.sdk:kubemq-sdk-Java:3.1.1'
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/java/tutorials/first-message) to connect a client and publish and receive your first message.
# Kotlin SDK (/sdks/kotlin)
The KubeMQ Kotlin SDK provides a coroutine-first client for all messaging patterns over gRPC transport with DSL builders, Flow-based subscriptions, TLS, authentication, and automatic reconnection. It requires Kotlin 2.0+ and JDK 11, 17, or 21.
## Installation [#installation]
### Gradle (Kotlin DSL) [#gradle-kotlin-dsl]
```kotlin title="build.gradle.kts"
dependencies {
implementation("io.kubemq.sdk:kubemq-sdk-kotlin:1.0.1")
}
```
Or use the BOM for version alignment:
```kotlin title="build.gradle.kts"
dependencies {
implementation(platform("io.kubemq.sdk:kubemq-sdk-kotlin-bom:1.0.1"))
implementation("io.kubemq.sdk:kubemq-sdk-kotlin")
}
```
### Maven [#maven]
```xml title="pom.xml"
io.kubemq.sdk
kubemq-sdk-kotlin
1.0.1
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/kotlin/tutorials/first-message) to connect a client and publish and receive your first message.
# Node.js SDK (/sdks/nodejs)
The KubeMQ Node.js SDK is a TypeScript-first client with auto-retry, structured error handling, and OpenTelemetry integration. It requires Node.js 20+ and communicates with KubeMQ over gRPC.
## Installation [#installation]
```bash
npm install kubemq-js
```
**Prerequisites:** Node.js 20+ (22, 24 also supported).
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/nodejs/tutorials/first-message) to connect a client and publish and receive your first message.
# Ruby SDK (/sdks/ruby)
The KubeMQ Ruby SDK provides a thread-safe gRPC client for all messaging patterns. It requires Ruby 3.1 or later and is compatible with KubeMQ server v2.2+.
## Installation [#installation]
```bash
gem install kubemq
```
Or add to your `Gemfile`:
```ruby title="Gemfile"
gem 'kubemq'
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/ruby/tutorials/first-message) to connect a client and publish and receive your first message.
# Python SDK (/sdks/python)
The KubeMQ Python SDK provides async clients for all messaging patterns. It requires Python 3.9+ and communicates with KubeMQ over gRPC.
## Installation [#installation]
```bash
pip install kubemq
```
For optional features:
```bash
pip install kubemq[otel] # OpenTelemetry integration
```
## Quick connect [#quick-connect]
```python
from kubemq import AsyncPubSubClient
# Connect to a local KubeMQ server on the default gRPC port.
async with AsyncPubSubClient(address="localhost:50000") as client:
...
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/python/tutorials/first-message) to connect a client and publish and receive your first message.
# Rust SDK (/sdks/rust)
The KubeMQ Rust SDK provides an async gRPC client for all messaging patterns. It is built on Tokio and Tonic, supports all five KubeMQ patterns (Events, Events Store, Queues, Commands, Queries), and is compatible with KubeMQ server v2.2+.
## Installation [#installation]
```bash
cargo add kubemq
```
The SDK requires an async runtime. Add Tokio if you haven't already:
```bash
cargo add tokio --features full
```
## Next: send your first message [#next-send-your-first-message]
With the SDK installed, continue to the [first-message tutorial](/sdks/rust/tutorials/first-message) to connect a client and publish and receive your first message.
# Authentication (/aiway/a2a/guides/authentication)
The A2A connector is guarded by the same JWT Bearer authentication as every other
KubeMQ connector. You authenticate to the **gateway** (`/a2a/*`) and the **registry**
(`/agents/*`) with an `Authorization: Bearer` header; KubeMQ verifies the token,
records who you are, and propagates your identity to the agent as
`X-KubeMQ-Caller-ID` — without ever forwarding your token downstream.
## Overview [#overview]
Authentication for A2A is the connector-wide model described in
[Auth & Security](/connectors/reference/auth-and-security), applied to two route
families:
* **Gateway** — `POST /a2a/` and the SSE stream endpoint route requests to
agents. A verified token identifies the caller; that identity becomes the agent's
`X-KubeMQ-Caller-ID`.
* **Registry** — `POST /agents/register`, `/agents/heartbeat`, `/agents/deregister`,
and `DELETE /agents/` are owned operations. The principal in your token
becomes the agent's `registered_by`, and only that principal may later modify or
delete it.
When server authentication is **disabled**, every caller is treated as the synthetic
`anonymous` principal and no token is required. When it is **enabled**, an unverified
or missing token is rejected — as a JSON-RPC `-32010` error on `/a2a/*`, or HTTP 401
on the REST registry endpoints.
A2A discovery is intentionally public: `GET /.well-known/agent-card.json` and any
per-agent `/.well-known/agent-card.json` path bypass authentication so a caller can
read an [agent card](/aiway/a2a/agent-cards) before it has a token.
## How it works [#how-it-works]
The token is verified once, at the shared auth middleware, before the request reaches
the gateway. The middleware attaches your claims (including `ClientID`) to the request;
the gateway uses that identity for registry ownership and stamps it onto the agent call
as `X-KubeMQ-Caller-ID`. The agent receives the caller's identity — never the raw
token.
*KubeMQ verifies the token at the edge and forwards the caller's identity — not the token — to the agent.*
The `Authorization` header is **stripped** before the gateway calls the agent — along
with `Cookie`, `Proxy-Authorization`, and other sensitive or hop-by-hop headers. Agents
must trust `X-KubeMQ-Caller-ID` for the caller's identity, not a forwarded token. If an
agent needs its own credential, register it with the agent's URL and let the agent
authenticate downstream itself.
## Authenticating gateway calls [#authenticating-gateway-calls]
Add an `Authorization: Bearer ` header to any `POST /a2a/` request. The
header carrying the token is consumed by KubeMQ; the rest of the request — including
any `X-*` headers — is forwarded to the agent. These snippets send a `message/send`
with a Bearer token; on success the agent echoes its `received_headers` and you can see
that `Authorization` is absent and `X-KubeMQ-Caller-ID` is present.
```bash
curl -X POST http://localhost:9090/a2a/echo-agent-01 \
-H "Authorization: Bearer $KUBEMQ_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Authenticated call"}]}
}
}'
```
```csharp
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
var token = Environment.GetEnvironmentVariable("KUBEMQ_TOKEN");
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Authenticated call" })
}
}
};
using var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}")
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {token}");
var resp = await client.SendAsync(request);
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
var received = data["result"]?["received_headers"];
Console.WriteLine($"Status: {(int)resp.StatusCode}");
Console.WriteLine($"Caller ID seen by agent: {received?["X-KubeMQ-Caller-ID"]?.GetValue()}");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
)
func main() {
token := os.Getenv("KUBEMQ_TOKEN")
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Authenticated call"}},
},
},
}
data, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", kubemqURL+"/a2a/"+agentID, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
received, _ := result["result"].(map[string]interface{})["received_headers"].(map[string]interface{})
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Printf("Caller ID seen by agent: %v\n", received["X-KubeMQ-Caller-ID"])
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var token = System.getenv("KUBEMQ_TOKEN");
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Authenticated call")))
)
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
var received = data.path("result").path("received_headers");
System.out.println("Status: " + resp.statusCode());
System.out.println("Caller ID seen by agent: " + received.path("X-KubeMQ-Caller-ID").asText());
}
}
```
```python
import json
import os
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Authenticated call"}]},
},
}
token = os.environ["KUBEMQ_TOKEN"]
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()
received = data.get("result", {}).get("received_headers", {})
print(f"Status: {resp.status_code}")
print(f"Caller ID seen by agent: {received.get('X-KubeMQ-Caller-ID')}")
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
const token = process.env.KUBEMQ_TOKEN;
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: { parts: [{ text: "Authenticated call" }] },
},
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(request),
});
const data = await resp.json();
const received = data.result?.received_headers || {};
console.log(`Status: ${resp.status}`);
console.log(`Caller ID seen by agent: ${received["x-kubemq-caller-id"]}`);
```
## Authenticating registry calls [#authenticating-registry-calls]
The registry uses the **same** `Authorization: Bearer` header. The difference is what
KubeMQ does with your identity: `POST /agents/register` records the token's principal
as the agent's `registered_by`, and ownership-protected operations
(`heartbeat`, `deregister`, `DELETE`) verify that the caller's principal matches.
```bash
curl -X POST http://localhost:9090/agents/register \
-H "Authorization: Bearer $KUBEMQ_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"url": "http://echo-agent.internal:8000/",
"skill_tags": ["echo"]
}'
```
```csharp
const string KubeMqUrl = "http://localhost:9090";
var token = Environment.GetEnvironmentVariable("KUBEMQ_TOKEN");
var card = new JsonObject
{
["agent_id"] = "echo-agent-01",
["name"] = "Echo Agent",
["url"] = "http://echo-agent.internal:8000/",
["skill_tags"] = new JsonArray("echo")
};
using var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/agents/register")
{
Content = new StringContent(card.ToJsonString(), Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {token}");
var resp = await client.SendAsync(request);
var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
Console.WriteLine($"registered_by: {data["registered_by"]?.GetValue()}");
```
```go
token := os.Getenv("KUBEMQ_TOKEN")
card := map[string]interface{}{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"url": "http://echo-agent.internal:8000/",
"skill_tags": []string{"echo"},
}
data, _ := json.Marshal(card)
req, _ := http.NewRequest("POST", "http://localhost:9090/agents/register", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
fmt.Printf("registered_by: %v\n", result["registered_by"])
```
```java
var token = System.getenv("KUBEMQ_TOKEN");
var card = Map.of(
"agent_id", "echo-agent-01",
"name", "Echo Agent",
"url", "http://echo-agent.internal:8000/",
"skill_tags", List.of("echo")
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/register"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
System.out.println("registered_by: " + data.path("registered_by").asText());
```
```python
token = os.environ["KUBEMQ_TOKEN"]
card = {
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"url": "http://echo-agent.internal:8000/",
"skill_tags": ["echo"],
}
async with httpx.AsyncClient() as client:
resp = await client.post(
"http://localhost:9090/agents/register",
json=card,
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()
print(f"registered_by: {data.get('registered_by')}")
```
```typescript
const token = process.env.KUBEMQ_TOKEN;
const card = {
agent_id: "echo-agent-01",
name: "Echo Agent",
url: "http://echo-agent.internal:8000/",
skill_tags: ["echo"],
};
const resp = await fetch(`${KUBEMQ_URL}/agents/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(card),
});
const data = await resp.json();
console.log(`registered_by: ${data.registered_by}`);
```
## Agent ownership [#agent-ownership]
When authentication is enabled, the registry enforces ownership so one principal cannot
hijack or delete another principal's agents.
* On `POST /agents/register`, the agent's `registered_by` is set automatically from the
token's principal — clients cannot spoof it in the body.
* `heartbeat`, `deregister`, and `DELETE /agents/` succeed only when the caller's
principal matches the agent's `registered_by`. A mismatch returns **403 Forbidden**
(ownership conflict).
* If an agent's `registered_by` is **blank** while auth is enabled, the ownership check
**fails closed** — no principal can modify or delete it. Re-register the agent with a
token to take ownership.
| Operation | Auth | Ownership check |
| ------------------------------------------------ | -------------------------- | --------------------------------------------------------- |
| `GET /.well-known/agent-card.json` | Public | — |
| `GET /agents`, `GET /agents/` | Bearer (when auth enabled) | None (read-only) |
| `POST /agents/register` | Bearer | Sets `registered_by`; rejects cross-principal re-register |
| `POST /agents/heartbeat` | Bearer | Caller must own the agent |
| `POST /agents/deregister`, `DELETE /agents/` | Bearer | Caller must own the agent |
| `POST /a2a/` (gateway) | Bearer | None (any authenticated caller may message any agent) |
## Caller identity reaches the agent [#caller-identity-reaches-the-agent]
Because the gateway strips `Authorization` before calling the agent, the agent learns
who is calling from the **`X-KubeMQ-Caller-ID`** header, which the virtual subscriber
always injects with the caller's `ClientID`. This holds regardless of which transport
originated the call (A2A HTTP, gRPC, REST, or the MCP bridge), so an agent can apply its
own per-caller logic without parsing tokens.
| Header | Set by | Visible to agent | Contains |
| ----------------------- | ------------------ | ----------------- | ---------------------------------- |
| `Authorization: Bearer` | Caller | **No** (stripped) | The caller's JWT |
| `X-KubeMQ-Caller-ID` | Virtual subscriber | **Yes** | The caller's `ClientID` (identity) |
When server authentication is disabled, the injected `X-KubeMQ-Caller-ID` reflects the
synthetic `anonymous` identity — agents that gate on caller identity should account for
this in non-secured deployments.
## Related [#related]
# Building Agents (/aiway/a2a/guides/building-agents)
An A2A agent is a **plain HTTP server** that speaks JSON-RPC 2.0. It runs anywhere,
needs no KubeMQ SDK, and joins the platform by registering its URL with the
[registry](/aiway/a2a/registry). The gateway's per-agent virtual subscriber
does all the broker work — your agent just answers HTTP POSTs.
## Overview [#overview]
A compliant agent has to do three things:
1. **Listen** on a URL, e.g. `http://localhost:18080/`.
2. **Answer** `POST` requests whose body is a JSON-RPC 2.0 request, returning a
JSON-RPC 2.0 response (or an SSE stream for `message/stream`).
3. **Register** its [agent card](/aiway/a2a/agent-cards) — including the
absolute `url` — with KubeMQ so the gateway can route to it.
That is the entire contract. Your agent never connects to the broker, never imports a
KubeMQ client, and never deals with protobuf. KubeMQ's virtual subscriber subscribes
to Queries on `_AGENTS_.agents/` on your behalf, POSTs each request to
your registered URL, and relays the reply back through the broker.
This replaces the previous SDK-based model. Agents no longer subscribe to
Queries through the gRPC SDK — the `AgentCard.URL` field is now **required** and must
be an absolute `http://` or `https://` URL. See [Migration](#migration-from-the-sdk-model).
## How it works [#how-it-works]
When a caller targets your agent, the request flows through the gateway and your
agent's virtual subscriber, which turns it into an ordinary HTTP POST to your server.
*Your agent is a plain HTTP server; the gateway's virtual subscriber bridges the broker to HTTP POST.*
The virtual subscriber always injects an `X-KubeMQ-Caller-ID` header carrying the
originating caller's identity, and forwards caller `X-*` headers (stripping
hop-by-hop and sensitive ones). Your agent reads them like any other HTTP header.
## Build the agent server [#build-the-agent-server]
The minimal agent is an HTTP server with one `POST /` handler that parses the
JSON-RPC body, processes it, and returns a JSON-RPC response with the same `id`. The
example below is an **echo agent** that starts its server, then registers its card
with KubeMQ.
Always start the HTTP server **before** registering. KubeMQ may route a request to
your agent the moment registration succeeds.
```csharp
using System.Text.Json;
using System.Text.Json.Nodes;
public static class Agent
{
private const string KubeMqUrl = "http://localhost:9090";
private const string AgentId = "echo-agent-01";
private const int AgentPort = 18080;
public static async Task RunAsync()
{
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
var app = builder.Build();
app.MapPost("/", async (HttpContext context) =>
{
var body = (await JsonSerializer.DeserializeAsync(context.Request.Body))!;
var response = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = body["id"]?.DeepClone(),
["result"] = new JsonObject { ["echo"] = body.DeepClone() }
};
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(response.ToJsonString());
});
app.Urls.Add($"http://0.0.0.0:{AgentPort}");
await app.StartAsync();
Console.WriteLine($"Agent listening on port {AgentPort}");
await RegisterAgentAsync();
await app.WaitForShutdownAsync();
}
private static async Task RegisterAgentAsync()
{
var card = new JsonObject
{
["agent_id"] = AgentId,
["name"] = "Echo Agent",
["description"] = "A simple echo agent for testing",
["version"] = "1.0.0",
["url"] = $"http://localhost:{AgentPort}/",
["skills"] = new JsonArray(new JsonObject
{
["id"] = "echo", ["name"] = "Echo",
["description"] = "Echoes back the received message",
["tags"] = new JsonArray("test", "echo")
}),
["defaultInputModes"] = new JsonArray("text"),
["defaultOutputModes"] = new JsonArray("text"),
["protocolVersions"] = new JsonArray("1.0")
};
using var client = new HttpClient();
var resp = await client.PostAsync(
$"{KubeMqUrl}/agents/register",
new StringContent(card.ToJsonString(), System.Text.Encoding.UTF8, "application/json"));
Console.WriteLine($"Registered: {(int)resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
Console.WriteLine(JsonSerializer.Serialize(JsonNode.Parse(body), new JsonSerializerOptions { WriteIndented = true }));
}
}
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "echo-agent-01"
agentPort = 18080
)
func handler(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]interface{}{
"jsonrpc": "2.0",
"id": body["id"],
"result": map[string]interface{}{"echo": body},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func registerAgent() {
card := map[string]interface{}{
"agent_id": agentID,
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": fmt.Sprintf("http://localhost:%d/", agentPort),
"skills": []map[string]interface{}{
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": []string{"test", "echo"},
},
},
"defaultInputModes": []string{"text"},
"defaultOutputModes": []string{"text"},
"protocolVersions": []string{"1.0"},
}
data, _ := json.Marshal(card)
resp, err := http.Post(kubemqURL+"/agents/register", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Registration failed: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Registered: %d\n", resp.StatusCode)
var pretty bytes.Buffer
json.Indent(&pretty, body, "", " ")
fmt.Println(pretty.String())
}
func main() {
http.HandleFunc("/", handler)
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", agentPort))
if err != nil {
fmt.Fprintf(os.Stderr, "Listen failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Agent listening on port %d\n", agentPort)
go http.Serve(ln, nil)
registerAgent()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
<-sig
fmt.Println("\nShutting down")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class Agent {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "echo-agent-01";
static final int AGENT_PORT = 18080;
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(AGENT_PORT), 0);
server.createContext("/", exchange -> {
if (!"POST".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
var body = MAPPER.readTree(exchange.getRequestBody());
var response = MAPPER.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", body.get("id"));
response.putObject("result").set("echo", body);
byte[] out = MAPPER.writeValueAsBytes(response);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, out.length);
try (OutputStream os = exchange.getResponseBody()) { os.write(out); }
});
server.start();
System.out.println("Agent listening on port " + AGENT_PORT);
var card = Map.of(
"agent_id", AGENT_ID,
"name", "Echo Agent",
"description", "A simple echo agent for testing",
"version", "1.0.0",
"url", "http://localhost:" + AGENT_PORT + "/",
"skills", List.of(Map.of(
"id", "echo", "name", "Echo",
"description", "Echoes back the received message",
"tags", List.of("test", "echo")
)),
"defaultInputModes", List.of("text"),
"defaultOutputModes", List.of("text"),
"protocolVersions", List.of("1.0")
);
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/register"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Registered: " + resp.statusCode());
System.out.println(MAPPER.writerWithDefaultPrettyPrinter()
.writeValueAsString(MAPPER.readTree(resp.body())));
Thread.currentThread().join();
}
}
```
```python
"""Echo agent that registers with KubeMQ."""
import asyncio
import json
import signal
import httpx
from aiohttp import web
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"
AGENT_PORT = 18080
async def handle_request(request: web.Request) -> web.Response:
body = await request.json()
return web.json_response({
"jsonrpc": "2.0",
"id": body.get("id"),
"result": {"echo": body},
})
async def register_agent() -> None:
card = {
"agent_id": AGENT_ID,
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": f"http://localhost:{AGENT_PORT}/",
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Echoes back the received message",
"tags": ["test", "echo"],
}
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"],
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card)
print(f"Registered: {resp.status_code}")
print(json.dumps(resp.json(), indent=2))
async def main() -> None:
app = web.Application()
app.router.add_post("/", handle_request)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", AGENT_PORT)
await site.start()
print(f"Agent listening on port {AGENT_PORT}")
await register_agent()
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
await stop.wait()
await runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
import express from "express";
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
const AGENT_PORT = 18080;
const app = express();
app.use(express.json());
app.post("/", (req, res) => {
const body = req.body;
console.log("Received request:", JSON.stringify(body));
res.json({
jsonrpc: "2.0",
id: body.id,
result: { echo: body },
});
});
app.listen(AGENT_PORT, async () => {
console.log(`Agent listening on port ${AGENT_PORT}`);
const card = {
agent_id: AGENT_ID,
name: "Echo Agent",
description: "A simple echo agent for testing",
version: "1.0.0",
url: `http://localhost:${AGENT_PORT}/`,
skills: [
{
id: "echo",
name: "Echo",
description: "Echoes back the received message",
tags: ["test", "echo"],
},
],
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
protocolVersions: ["1.0"],
};
const resp = await fetch(`${KUBEMQ_URL}/agents/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(card),
});
const data = await resp.json();
console.log("Registered:", JSON.stringify(data, null, 2));
});
```
You can register the same way with plain curl — useful for a sidecar agent or a
language not shown above:
```bash
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"description": "A simple echo agent for testing",
"version": "1.0.0",
"url": "http://localhost:18080/",
"skills": [{"id": "echo", "name": "Echo", "tags": ["test", "echo"]}],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"protocolVersions": ["1.0"]
}'
```
## Handle streaming (message/stream) [#handle-streaming-messagestream]
If your agent produces incremental output, support the `message/stream` method.
Inspect the request's `method`; when it is `message/stream`, respond with
`Content-Type: text/event-stream` and write SSE events instead of a single JSON body.
Each event uses an `event:` name and a JSON `data:` line, and the stream ends with a
terminal `task.done` (or `task.error`) event.
```text
event: task.status
data: {"type": "status_update", "payload": {"status": "working", "progress": 1, "total": 5}}
event: task.done
data: {"type": "done", "payload": {"final_result": "completed"}}
```
The handler below extends the echo agent: it emits five `task.status` events, then a
`task.done`, for `message/stream` requests, and falls back to a plain JSON-RPC reply
for everything else.
```csharp
app.MapPost("/", async (HttpContext context) =>
{
var body = (await JsonSerializer.DeserializeAsync(context.Request.Body))!;
var method = body["method"]?.GetValue() ?? "";
if (method == "message/stream")
{
context.Response.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
for (int i = 1; i <= 5; i++)
{
var ev = JsonSerializer.Serialize(new { type = "status_update", payload = new { status = "working", progress = i, total = 5 } });
await context.Response.WriteAsync($"event: task.status\ndata: {ev}\n\n");
await context.Response.Body.FlushAsync();
await Task.Delay(500);
}
var done = JsonSerializer.Serialize(new { type = "done", payload = new { final_result = "completed", event_count = 5 } });
await context.Response.WriteAsync($"event: task.done\ndata: {done}\n\n");
await context.Response.Body.FlushAsync();
return;
}
var response = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = body["id"]?.DeepClone(),
["result"] = new JsonObject { ["echo"] = body.DeepClone() }
};
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(response.ToJsonString());
});
```
```go
func handleStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
for i := 1; i <= 5; i++ {
event, _ := json.Marshal(map[string]interface{}{
"type": "status_update",
"payload": map[string]interface{}{
"status": "working", "progress": i, "total": 5,
},
})
fmt.Fprintf(w, "event: task.status\ndata: %s\n\n", event)
flusher.Flush()
time.Sleep(500 * time.Millisecond)
}
done, _ := json.Marshal(map[string]interface{}{
"type": "done",
"payload": map[string]interface{}{"final_result": "completed", "event_count": 5},
})
fmt.Fprintf(w, "event: task.done\ndata: %s\n\n", done)
flusher.Flush()
}
func handler(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if method, _ := body["method"].(string); method == "message/stream" {
handleStream(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0",
"id": body["id"],
"result": map[string]interface{}{"echo": body},
})
}
```
```java
server.createContext("/", exchange -> {
if (!"POST".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
var body = MAPPER.readTree(exchange.getRequestBody());
var method = body.path("method").asText("");
if ("message/stream".equals(method)) {
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
exchange.sendResponseHeaders(200, 0);
try (OutputStream os = exchange.getResponseBody()) {
for (int i = 1; i <= 5; i++) {
var event = MAPPER.writeValueAsString(Map.of(
"type", "status_update",
"payload", Map.of("status", "working", "progress", i, "total", 5)
));
os.write(("event: task.status\ndata: " + event + "\n\n").getBytes());
os.flush();
try { Thread.sleep(500); } catch (InterruptedException ie) { break; }
}
var done = MAPPER.writeValueAsString(Map.of(
"type", "done",
"payload", Map.of("final_result", "completed", "event_count", 5)
));
os.write(("event: task.done\ndata: " + done + "\n\n").getBytes());
os.flush();
}
return;
}
var response = MAPPER.createObjectNode();
response.put("jsonrpc", "2.0");
response.set("id", body.get("id"));
response.putObject("result").set("echo", body);
byte[] out = MAPPER.writeValueAsBytes(response);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, out.length);
try (OutputStream os = exchange.getResponseBody()) { os.write(out); }
});
```
```python
async def handle_stream(request: web.Request) -> web.StreamResponse:
resp = web.StreamResponse(
status=200,
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
)
await resp.prepare(request)
for i in range(1, 6):
event = json.dumps({"type": "status_update", "payload": {"status": "working", "progress": i, "total": 5}})
await resp.write(f"event: task.status\ndata: {event}\n\n".encode())
await asyncio.sleep(0.5)
done = json.dumps({"type": "done", "payload": {"final_result": "completed", "event_count": 5}})
await resp.write(f"event: task.done\ndata: {done}\n\n".encode())
await resp.write_eof()
return resp
async def handle_request(request: web.Request) -> web.Response:
body = await request.json()
method = body.get("method", "")
if method == "message/stream":
return await handle_stream(request)
return web.json_response({"jsonrpc": "2.0", "id": body.get("id"), "result": {"echo": body}})
```
```typescript
app.post("/", async (req, res) => {
if (req.body.method === "message/stream") {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
for (let i = 1; i <= 5; i++) {
const event = {
type: "status_update",
payload: { status: "working", progress: i, total: 5 },
};
res.write(`event: task.status\ndata: ${JSON.stringify(event)}\n\n`);
await new Promise((r) => setTimeout(r, 500));
}
const done = { type: "done", payload: { final_result: "completed", event_count: 5 } };
res.write(`event: task.done\ndata: ${JSON.stringify(done)}\n\n`);
res.end();
} else {
res.json({ jsonrpc: "2.0", id: req.body.id, result: { echo: req.body } });
}
});
```
The gateway maps your SSE event names to caller-facing envelopes: `task.status`,
`task.artifact`, `task.done`, and `task.error`. See
[SSE behavior](/aiway/a2a/guides/sse-behavior) for the full wire format,
keepalive, and cancel-on-disconnect rules.
## Agent lifecycle [#agent-lifecycle]
Beyond serving requests, an agent manages its presence in the registry:
| Stage | Endpoint | When |
| ---------- | ------------------------- | ----------------------------------------------------- |
| Register | `POST /agents/register` | After the HTTP server is listening, on startup. |
| Heartbeat | `POST /agents/heartbeat` | Every 30–60s to refresh `last_seen` and beat the TTL. |
| Deregister | `POST /agents/deregister` | On graceful shutdown, before stopping the server. |
If an agent stops sending heartbeats, the registry expires it after
`AgentTTLSeconds` (default 300) and tears down its virtual subscriber. Refresh
liveness with a periodic heartbeat:
```bash
curl -X POST http://localhost:9090/agents/heartbeat \
-H "Content-Type: application/json" \
-d '{"agent_id": "echo-agent-01"}'
```
On shutdown, deregister first so callers stop being routed to the agent, then drain
in-flight requests and stop the server:
```bash
curl -X POST http://localhost:9090/agents/deregister \
-H "Content-Type: application/json" \
-d '{"agent_id": "echo-agent-01"}'
```
When authentication is enabled, only the principal that registered an agent can
heartbeat or deregister it. See [Authentication](/aiway/a2a/guides/authentication).
## Migration from the SDK model [#migration-from-the-sdk-model]
Earlier KubeMQ versions required receiving agents to use the gRPC SDK to subscribe to
Queries on `_AGENTS_.agents/`. That path is gone — replaced by the
virtual-subscriber architecture, where agents are plain HTTP servers.
**What changed:**
* Agents no longer connect to KubeMQ via the gRPC SDK to receive tasks, and no longer
call `SubscribeToQueries()` on agent channels.
* The `AgentCard.URL` field is now **required** and must be an absolute `http://` or
`https://` URL. Relative paths (e.g. `/a2a/`) are rejected.
* The old default URL auto-assignment (`/a2a/` when `url` was empty) has
been removed.
**To migrate an existing agent:**
1. Deploy it as a standard HTTP server that accepts `POST` requests with
`Content-Type: application/json`.
2. Implement a JSON-RPC 2.0 handler at the root URL (or any path).
3. Update registration to include the full absolute URL, e.g.
`"url": "http://my-agent:8080/"`.
4. Remove all KubeMQ SDK dependencies from the agent.
5. For streaming agents, implement SSE responses for `message/stream`.
The **caller side is unchanged** — callers can keep using any KubeMQ transport (gRPC,
REST, the A2A HTTP gateway, or the MCP bridge) to reach agents.
## Related [#related]
# Concurrency & Limits (/aiway/a2a/guides/concurrency)
The A2A gateway protects itself and your agents with three hard limits: a **per-agent
concurrency cap**, a **response-size cap**, and **timeout capping**. Each is enforced by
the agent's virtual subscriber, returns a predictable error when crossed, and is tunable
through [`A2aConfig`](/aiway/a2a/configuration). This guide explains the
behavior of each limit and shows how to observe it from a client.
## Overview [#overview]
Every registered agent runs behind a **virtual subscriber** — the bridge that turns an
inbound JSON-RPC request into an outbound HTTP POST to the agent's URL. That subscriber is
where the limits live, so they apply **per agent**, not per client. Multiple callers
hitting the same `agent_id` share the same budget.
| Limit | Config field | Default | What happens when exceeded |
| ----------------------------- | --------------------------------------------- | ------------------ | ---------------------------------------------------------------------------- |
| Concurrent in-flight requests | `AgentMaxConcurrency` | `100` | Overflow request rejected immediately — `Executed: false`, JSON-RPC `-32603` |
| Agent response body size | `AgentMaxResponseBytes` | `10485760` (10 MB) | Response dropped — `Executed: false`, JSON-RPC `-32603` |
| Per-request timeout | `DefaultTimeoutSeconds` / `MaxTimeoutSeconds` | `300` / `3600` | Capped silently, or `-32001` (agent timeout) on expiry |
All three surface as **transport errors** (`Executed: false`) — the request never reached
your agent's business logic, so a retry is generally safe. See
[Error handling](/aiway/a2a/error-handling) for the transport-vs-application
distinction.
## Per-agent concurrency limit [#per-agent-concurrency-limit]
Each agent's virtual subscriber holds a buffered-channel **semaphore** sized to
`AgentMaxConcurrency` (default **100**). The count covers **both** synchronous
(`message/send`) and streaming (`message/stream`) requests in flight at the same time.
| Request # | Behavior |
| --------- | ---------------------------------------------------------------------- |
| 1–100 | A handler is spawned and the request is proxied to the agent |
| 101+ | Immediately rejected — no goroutine spawned, the agent is never called |
When the semaphore is full, the overflow request comes back as a JSON-RPC `-32603` error:
```json
{
"jsonrpc": "2.0",
"id": 101,
"error": {
"code": -32603,
"message": "internal error: concurrency limit exceeded"
}
}
```
At the transport layer this is recorded as `Executed: false` with the reason
`server busy: concurrency limit reached`. Because the request was never processed,
retrying after a short backoff is safe.
The limit is **per agent**, not per client. To raise headroom for a busy agent, increase
`AgentMaxConcurrency` in [configuration](/aiway/a2a/configuration) — or run more
agent instances behind distinct `agent_id`s and route across them.
### Observe the limit [#observe-the-limit]
Fire more than `AgentMaxConcurrency` requests at one agent simultaneously and at least one
comes back with `-32603`. The snippets below send **101** concurrent `message/send`
requests and count how many were rejected.
```bash
# Fire 101 requests in parallel; at least one returns -32603 "concurrency limit exceeded".
for i in $(seq 1 101); do
curl -s -X POST http://localhost:9090/a2a/concurrency-agent-01 \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"message/send\",\"params\":{\"message\":{\"parts\":[{\"text\":\"Request #$i\"}]}}}" &
done | grep -c -- -32603
wait
# => prints the number of requests rejected by the concurrency limit (>= 1)
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "concurrency-agent-01";
const int NumRequests = 101;
async Task SendRequest(HttpClient httpClient, int requestId)
{
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = requestId,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = $"Request #{requestId}" })
}
}
};
var resp = await httpClient.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
return JsonNode.Parse(await resp.Content.ReadAsStringAsync());
}
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Console.WriteLine($"Sending {NumRequests} concurrent requests (limit is 100)...");
var tasks = Enumerable.Range(1, NumRequests)
.Select(i => SendRequest(client, i))
.ToArray();
var results = await Task.WhenAll(tasks);
int successes = 0, concurrencyErrors = 0, otherErrors = 0;
foreach (var r in results)
{
if (r?["result"] != null)
successes++;
else if (r?["error"]?["code"]?.GetValue() == -32603)
concurrencyErrors++;
else
otherErrors++;
}
Console.WriteLine($" Successes: {successes}");
Console.WriteLine($" Concurrency errors: {concurrencyErrors} (code -32603)");
Console.WriteLine($" Other errors: {otherErrors}");
if (concurrencyErrors >= 1)
Console.WriteLine($"\nConcurrency limit enforced — {concurrencyErrors} request(s) rejected!");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "concurrency-agent-01"
numRequests = 101
)
type result struct {
data map[string]interface{}
err error
}
func sendRequest(id int, client *http.Client, wg *sync.WaitGroup, results chan<- result) {
defer wg.Done()
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": fmt.Sprintf("Request #%d", id)}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
results <- result{err: err}
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var r map[string]interface{}
json.Unmarshal(body, &r)
results <- result{data: r}
}
func main() {
fmt.Printf("Sending %d concurrent requests (limit is 100)...\n", numRequests)
client := &http.Client{Timeout: 30 * time.Second}
results := make(chan result, numRequests)
var wg sync.WaitGroup
for i := 1; i <= numRequests; i++ {
wg.Add(1)
go sendRequest(i, client, &wg, results)
}
wg.Wait()
close(results)
successes, concurrencyErrors, otherErrors := 0, 0, 0
for r := range results {
if r.err != nil {
otherErrors++
} else if _, ok := r.data["result"]; ok {
successes++
} else if e, ok := r.data["error"].(map[string]interface{}); ok {
if code, _ := e["code"].(float64); int(code) == -32603 {
concurrencyErrors++
} else {
otherErrors++
}
}
}
fmt.Printf(" Successes: %d\n", successes)
fmt.Printf(" Concurrency errors: %d (code -32603)\n", concurrencyErrors)
fmt.Printf(" Other errors: %d\n", otherErrors)
if concurrencyErrors >= 1 {
fmt.Printf("\nConcurrency limit enforced — %d request(s) rejected!\n", concurrencyErrors)
}
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "concurrency-agent-01";
static final int NUM_REQUESTS = 101;
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
System.out.println("Sending " + NUM_REQUESTS + " concurrent requests (limit is 100)...");
List>> futures = new ArrayList<>();
for (int i = 1; i <= NUM_REQUESTS; i++) {
var payload = Map.of(
"jsonrpc", "2.0",
"id", i,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Request #" + i)))
)
);
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
futures.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofString()));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
int successes = 0, concurrencyErrors = 0, otherErrors = 0;
for (var future : futures) {
var data = MAPPER.readTree(future.get().body());
if (data.has("result")) {
successes++;
} else if (data.has("error")) {
if (data.path("error").path("code").asInt() == -32603) concurrencyErrors++;
else otherErrors++;
}
}
System.out.println(" Successes: " + successes);
System.out.println(" Concurrency errors: " + concurrencyErrors + " (code -32603)");
System.out.println(" Other errors: " + otherErrors);
if (concurrencyErrors >= 1)
System.out.println("\nConcurrency limit enforced — " + concurrencyErrors + " request(s) rejected!");
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "concurrency-agent-01"
NUM_REQUESTS = 101
async def send_request(client: httpx.AsyncClient, request_id: int) -> dict:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": "message/send",
"params": {
"message": {"parts": [{"text": f"Request #{request_id}"}]},
},
}
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
return resp.json()
async def main() -> None:
limits = httpx.Limits(max_connections=200)
async with httpx.AsyncClient(timeout=30, limits=limits) as client:
print(f"Sending {NUM_REQUESTS} concurrent requests (limit is 100)...")
tasks = [send_request(client, i) for i in range(1, NUM_REQUESTS + 1)]
results = await asyncio.gather(*tasks)
successes = sum(1 for r in results if "result" in r)
concurrency_errors = sum(1 for r in results if r.get("error", {}).get("code") == -32603)
other_errors = len(results) - successes - concurrency_errors
print(f" Successes: {successes}")
print(f" Concurrency errors: {concurrency_errors} (code -32603)")
print(f" Other errors: {other_errors}")
assert concurrency_errors >= 1, "Expected at least 1 concurrency limit error"
print(f"\nConcurrency limit enforced — {concurrency_errors} request(s) rejected!")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "concurrency-agent-01";
const NUM_REQUESTS = 101;
async function sendRequest(id: number): Promise<{ ok: boolean; errorCode?: number }> {
const request = {
jsonrpc: "2.0",
id,
method: "message/send",
params: { message: { parts: [{ text: `Request ${id}` }] } },
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
if (data.error) {
return { ok: false, errorCode: data.error.code };
}
return { ok: true };
}
async function main() {
console.log(`Sending ${NUM_REQUESTS} concurrent requests (limit is 100)...`);
const promises = Array.from({ length: NUM_REQUESTS }, (_, i) => sendRequest(i + 1));
const results = await Promise.all(promises);
const succeeded = results.filter((r) => r.ok).length;
const rejected = results.filter((r) => r.errorCode === -32603).length;
const otherErrors = results.length - succeeded - rejected;
console.log(` Succeeded: ${succeeded} (expect <=100)`);
console.log(` Rejected -32603: ${rejected} (expect >=1)`);
console.log(` Other errors: ${otherErrors}`);
if (rejected > 0) {
console.log(`\nConcurrency limit enforced correctly.`);
}
}
main().catch(console.error);
```
## Response-size limit [#response-size-limit]
The gateway caps the agent's HTTP response body at `AgentMaxResponseBytes` (default
**10 MB**). When an agent returns more than that — for a synchronous reply, or for a single
SSE event's accumulated data lines on a stream — the gateway aborts the relay to protect
itself from memory exhaustion and returns `-32603`:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32603,
"message": "internal error: response too large"
}
}
```
At the transport layer this is `Executed: false` with the reason
`agent response too large`. Unlike a timeout, retrying the **same** request will fail
identically — the agent is producing an oversized body. Fix it by paginating the agent's
output, streaming the result via [`message/stream`](/aiway/a2a/streaming), or
raising `AgentMaxResponseBytes` if the payload is legitimately large.
### Observe the limit [#observe-the-limit-1]
Target an agent that returns more than 10 MB and inspect the `error` object.
```bash
curl -s -X POST http://localhost:9090/a2a/oversize-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {"message": {"parts": [{"text": "Give me a large response"}]}}
}'
# => {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"internal error: response too large"}}
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "oversize-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Give me a large response" })
}
}
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Console.WriteLine("Requesting oversized response (>10MB)...");
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
if (data["error"] != null)
{
var error = data["error"]!;
Console.WriteLine($"Error code: {error["code"]}");
Console.WriteLine($"Error message: {error["message"]}");
Console.WriteLine("\nResponse size limit enforced!");
}
else
{
Console.WriteLine($"Status: {(int)resp.StatusCode}");
Console.WriteLine("Note: Response was accepted (check AgentMaxResponseBytes)");
}
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "oversize-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Give me a large response"}},
},
},
}
data, _ := json.Marshal(payload)
fmt.Println("Requesting oversized response (>10MB)...")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
if errorObj, ok := result["error"].(map[string]interface{}); ok {
code, _ := errorObj["code"].(float64)
msg, _ := errorObj["message"].(string)
fmt.Printf("Error code: %.0f\n", code)
fmt.Printf("Error message: %s\n", msg)
fmt.Println("\nResponse size limit enforced!")
} else {
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Println("Note: Response was accepted (check AgentMaxResponseBytes)")
}
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "oversize-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Give me a large response")))
)
);
var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
System.out.println("Requesting oversized response (>10MB)...");
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
if (data.has("error")) {
var error = data.path("error");
System.out.println("Error code: " + error.path("code").asInt());
System.out.println("Error message: " + error.path("message").asText());
System.out.println("\nResponse size limit enforced!");
} else {
System.out.println("Status: " + resp.statusCode());
System.out.println("Note: Response was accepted (check AgentMaxResponseBytes)");
}
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "oversize-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Give me a large response"}]},
},
}
async with httpx.AsyncClient(timeout=30) as client:
print("Requesting oversized response (>10MB)...")
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
data = resp.json()
if "error" in data:
error = data["error"]
print(f"Error code: {error.get('code')}")
print(f"Error message: {error.get('message')}")
print("\nResponse size limit enforced!")
else:
print(f"Status: {resp.status_code}")
print("Note: Response was accepted (check AgentMaxResponseBytes)")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "oversize-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: { parts: [{ text: "Give me a large response" }] },
},
};
console.log("Requesting oversized response (>10MB)...");
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
if (data.error) {
console.log(`Error code: ${data.error.code}`);
console.log(`Error message: ${data.error.message}`);
console.log("\nResponse size limit enforced!");
} else {
console.log("Unexpected: got success response. Response may have been under the limit.");
}
}
main().catch(console.error);
```
## Timeout capping [#timeout-capping]
Each call carries a deadline. When a client sets `params.configuration.timeout` (in
seconds), the gateway clamps it to the configured window before proxying to the agent:
* If no timeout is supplied, the server applies `DefaultTimeoutSeconds` (**300s**).
* Any value above `MaxTimeoutSeconds` (**3600s**) is **silently reduced** to that ceiling.
* When the deadline passes before the agent replies, the call returns
[`-32001` (agent timeout)](/aiway/a2a/error-handling).
| `params.configuration.timeout` | Effective deadline |
| ------------------------------ | ------------------------------------- |
| not set | `DefaultTimeoutSeconds` (300s) |
| `30` | 30s |
| `3600` | 3600s |
| `99999` | 3600s (capped to `MaxTimeoutSeconds`) |
A **gateway buffer** of \~10 seconds (`GatewayTimeoutBuffer`) is added on top of the
effective deadline before the gateway times out its proxy request, so the gateway never
gives up before the agent's own deadline. Give your HTTP client a slightly longer timeout
than the value you request (a 15s pad is typical) so the client does not abort before the
gateway returns the `-32001` envelope.
## Monitoring [#monitoring]
Concurrency and limit pressure are visible in the Prometheus metrics exported on port 8080
— see [Observability](/connectors/concepts/observability).
| Metric | What it tells you |
| ------------------------------- | ---------------------------------------------------------------------------------- |
| `kubemq_a2a_requests_total` | Total requests per agent and method — rising rejections track concurrency pressure |
| `kubemq_a2a_errors_total` | Error count per agent — `-32603` spikes signal a saturated agent |
| `kubemq_a2a_sse_streams_active` | Active SSE streams per agent (each counts against the concurrency budget) |
## Related [#related]
# SSE Behavior (/aiway/a2a/guides/sse-behavior)
A2A streaming rides on **Server-Sent Events (SSE)**: the gateway proxies a long-lived
`text/event-stream` response from the agent back to the caller. This guide is the
ground-truth reference for *how that stream behaves on the wire* — the exact event
names, the JSON envelope each event carries, the keepalive cadence, when the gateway
closes a stream, and what happens when the caller hangs up.
For the task-level walkthrough of *using* streaming, see
[Streaming (SSE)](/aiway/a2a/streaming). This page focuses on protocol details
you need when writing or debugging an SSE client.
## Overview [#overview]
A stream is opened either by `POST /a2a/:agent_id` with `method: "message/stream"`, or by
`GET /a2a/:agent_id/stream`. In both cases the gateway responds with
`Content-Type: text/event-stream` and relays events the agent emits, framed in the
standard SSE format, until a terminal event arrives or the stream is torn down.
The gateway is a transparent relay: the agent produces the events, KubeMQ's per-agent
**virtual subscriber** bridges them from the agent's HTTP SSE connection onto a temporary
Events channel (`_AGENTS_.stream/`), and the A2A connector replays them to
the caller. The caller only ever sees a normal SSE stream.
## How it works [#how-it-works]
The diagram below traces a single streaming request through the gateway and virtual
subscriber and back to the caller's SSE reader.
*The gateway and virtual subscriber relay the agent's SSE events to the caller; a `task.done` or `task.error` envelope ends the stream.*
## Wire format [#wire-format]
Each SSE message is an `event:` line and a `data:` line, terminated by a blank line. The
`data:` payload is a single-line JSON envelope:
```text
event: task.status
data: {"stream_id":"...","type":"status_update","payload":{"status":"working","progress":3,"total":10}}
```
Multi-line JSON is not used — every envelope is serialized to one line so it fits a single
`data:` field. Responses carry `Content-Type: text/event-stream`.
## Event types [#event-types]
The agent's envelope `type` maps to a named SSE `event`. `task.done` and `task.error` are
**terminal** — after either, stop reading; the gateway closes the connection and the
`kubemq_a2a_sse_streams_active` gauge decrements.
| SSE event | Envelope `type` | Meaning | Terminal |
| --------------- | --------------- | ----------------------------------------------- | -------- |
| `task.status` | `status_update` | Progress update (`status`, `progress`, `total`) | No |
| `task.artifact` | `artifact` | Intermediate artifact delivery | No |
| `task.done` | `done` | Successful completion | Yes |
| `task.error` | `error` | Failure (carries `code` and `message`) | Yes |
| `message` | (default) | Any envelope without a recognized type | No |
Example payloads:
```json
{"type": "status_update", "payload": {"status": "working", "progress": 3, "total": 10}}
{"type": "artifact", "payload": {"name": "result.json", "data": {"key": "value"}}}
{"type": "done", "payload": {"final_result": "completed", "event_count": 10}}
{"type": "error", "payload": {"code": -32001, "message": "agent timeout"}}
```
## Keepalive comments [#keepalive-comments]
To keep proxies and load balancers from dropping an idle connection, the gateway emits an
SSE **comment** line every 30 seconds (`sseKeepaliveInterval`):
```text
: keepalive
```
Comment lines start with `:`, carry no `event:` or `data:` line, and are not stream events.
Standard SSE client libraries ignore them automatically. If you parse the stream by hand,
skip any line beginning with `:`.
## Idle timeout [#idle-timeout]
If no events flow for `MaxSSEIdleSeconds` (default **300s**, set on `A2aConfig`), the
gateway closes the stream. Before closing it sends a terminal `task.error` with code
`-32001` and message `"stream idle timeout"`, then issues a best-effort cancel to the
agent. Agents handling long-running work should emit periodic `task.status` events to keep
the stream alive.
The idle timer measures time *between events*, not total stream duration. A stream can run
indefinitely as long as the agent keeps emitting events (including `task.status`
heartbeats) more often than `MaxSSEIdleSeconds`.
## Client disconnect and cancellation [#client-disconnect-and-cancellation]
When the caller disconnects from an open stream, the gateway detects it and propagates the
cancellation to the agent rather than leaking the upstream connection:
1. The gateway detects the closed caller connection.
2. It sends a Query to the agent's virtual subscriber on `_AGENTS_.agents/`
with `a2a_method: "stream_cancel"` and the `stream_id` (10s timeout).
3. The virtual subscriber cancels its SSE relay goroutine, closing the HTTP SSE connection
to the agent.
4. `kubemq_a2a_sse_streams_active` decrements.
This means closing your SSE reader is a real cancellation signal — the agent is told to
stop, freeing its work and the agent's concurrency slot.
## Reconnection [#reconnection]
A2A streams are **not resumable**. There is no event ID and no `Last-Event-ID` support — a
dropped stream cannot be resumed from where it stopped, and any undelivered events are lost.
To recover, start a fresh stream.
Do not rely on automatic SSE reconnection to continue a task. Because there is no replay,
a reconnect starts a brand-new request. For long-running work, make the agent idempotent
and correlate retries with `context_id`.
For resilience across reconnects:
* Reuse `context_id` so the agent can correlate the new stream with the original request.
* Make agent-side processing idempotent.
* Where supported, check task status before re-streaming so you do not duplicate work.
## Concurrent streams [#concurrent-streams]
Multiple SSE streams can be open at once:
* Multiple streams to the **same agent** are allowed; each one counts against that agent's
`AgentMaxConcurrency` limit (default 100).
* Streams to **different agents** are fully independent.
* `kubemq_a2a_sse_streams_active` tracks all active streams.
## Consuming the stream [#consuming-the-stream]
Read the stream line by line, track the most recent `event:`, parse each `data:` line as
JSON, and stop when you see `task.done` or `task.error`. The snippets below read a
`message/stream` response end to end.
```bash
curl -N -X POST http://localhost:9090/a2a/stream-agent-01 \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": { "message": { "parts": [{ "text": "Stream me some updates" }] } }
}'
```
```csharp
using System.Text;
using System.Text.Json;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "stream-agent-01";
var payload = new
{
jsonrpc = "2.0",
id = 1,
method = "message/stream",
@params = new { message = new { parts = new[] { new { text = "Stream me some updates" } } } }
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
request.Headers.Add("Accept", "text/event-stream");
Console.WriteLine("Connecting to SSE stream...");
using var resp = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? eventType = null;
int eventCount = 0;
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
if (line.StartsWith("event: "))
eventType = line[7..];
else if (line.StartsWith("data: ") && eventType != null)
{
eventCount++;
var data = line[6..];
Console.WriteLine($"[{eventType}] {data}");
if (eventType is "task.done" or "task.error")
break;
}
else if (line.Length == 0)
eventType = null;
}
Console.WriteLine($"\nReceived {eventCount} events");
```
```go
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "stream-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Stream me some updates"}},
},
},
}
data, err := json.Marshal(payload)
if err != nil {
fmt.Fprintf(os.Stderr, "Marshal failed: %v\n", err)
os.Exit(1)
}
req, err := http.NewRequest(http.MethodPost, kubemqURL+"/a2a/"+agentID, bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request build failed: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
fmt.Println("Connecting to SSE stream...")
eventCount := 0
eventType := ""
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
eventType = strings.TrimPrefix(line, "event: ")
} else if strings.HasPrefix(line, "data: ") {
eventCount++
dataStr := strings.TrimPrefix(line, "data: ")
fmt.Printf("[%s] %s\n", eventType, dataStr)
if eventType == "task.done" || eventType == "task.error" {
break
}
}
}
fmt.Printf("\nReceived %d events\n", eventCount)
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "stream-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/stream",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Stream me some updates")))
)
);
var client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(60))
.build();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
System.out.println("Connecting to SSE stream...");
var resp = client.send(req, HttpResponse.BodyHandlers.ofLines());
int eventCount = 0;
String currentEvent = null;
for (var it = resp.body().iterator(); it.hasNext(); ) {
String line = it.next();
if (line.startsWith("event: ")) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith("data: ")) {
eventCount++;
String data = line.substring(6);
System.out.println("[" + currentEvent + "] " + data);
if ("task.done".equals(currentEvent) || "task.error".equals(currentEvent)) {
break;
}
}
}
System.out.println("\nReceived " + eventCount + " events");
}
}
```
```python
import asyncio
import json
import httpx
from httpx_sse import aconnect_sse
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "stream-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"message": {"parts": [{"text": "Stream me some updates"}]},
},
}
async with httpx.AsyncClient(timeout=60) as client:
print("Connecting to SSE stream...")
async with aconnect_sse(
client,
"POST",
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json=payload,
headers={"Accept": "text/event-stream"},
) as event_source:
event_count = 0
async for event in event_source.aiter_sse():
event_count += 1
data = json.loads(event.data)
print(f"[{event.event}] {json.dumps(data)}")
if event.event in ("task.done", "task.error"):
break
print(f"\nReceived {event_count} events")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "stream-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/stream",
params: { message: { parts: [{ text: "Stream me some updates" }] } },
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(request),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let eventCount = 0;
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
if (!frame.trim()) continue;
let eventType = "";
let eventData = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event: ")) eventType = line.slice(7).trim();
else if (line.startsWith("data: ")) eventData = line.slice(6).trim();
}
if (!eventType) continue;
eventCount++;
const payload = JSON.parse(eventData);
console.log(`[${eventType}] ${JSON.stringify(payload)}`);
if (eventType === "task.done" || eventType === "task.error") {
console.log(`\nStream complete. Total events: ${eventCount}`);
reader.cancel();
return;
}
}
}
}
main().catch(console.error);
```
Always break on `task.done` or `task.error`. Closing the reader after a terminal event is
how the gateway and agent learn the stream is finished — and, mid-stream, how a caller
cancels the agent's work (see [Client disconnect and cancellation](#client-disconnect-and-cancellation)).
## Related [#related]
# Multi-Agent Gateway (/aiway/a2a/scenarios/multi-agent-gateway)
This scenario stands up **three independent agents** — echo, translate, and summarize —
behind a single A2A gateway, then drives them from one client: discover the right agent
by **skill tag**, and dispatch a `message/send` to it by **`agent_id`**. It ties together
the [registry](/aiway/a2a/registry) and
[synchronous messaging](/aiway/a2a/sync-messaging).
## The setup [#the-setup]
Each agent is a plain HTTP server registered by URL — no KubeMQ SDK runs on any of them.
The gateway keeps one [virtual subscriber](/aiway/a2a/architecture) per agent,
so a caller reaches any agent through the same `POST /a2a/{agent_id}` surface. Callers
never address an agent's HTTP URL directly; they address its `agent_id` and the gateway
routes the request.
*One gateway fronts many agents; the client discovers by skill tag and routes by `agent_id`.*
## Step 1 — Register the agents [#step-1--register-the-agents]
Each agent registers its own [agent card](/aiway/a2a/agent-cards) with a unique
`agent_id`, its HTTP `url`, and a `skills` list. The `tags` on each skill are what make the
agent discoverable later. Agents that share a capability (here, `translate` and `summarize`
both carry the `nlp` tag) can be found together.
```bash
# Register three agents with distinct skills.
# (Each agent process registers itself on start; shown here as explicit calls.)
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "echo-agent-01",
"name": "Echo Agent",
"url": "http://localhost:18081/",
"skills": [{"id": "echo", "name": "Echo", "tags": ["echo"]}]
}'
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "translate-agent-01",
"name": "Translate Agent",
"url": "http://localhost:18082/",
"skills": [{"id": "translate", "name": "Translate", "tags": ["translate", "nlp"]}]
}'
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "summarize-agent-01",
"name": "Summarize Agent",
"url": "http://localhost:18083/",
"skills": [{"id": "summarize", "name": "Summarize", "tags": ["summarize", "nlp"]}]
}'
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
using var client = new HttpClient();
var agents = new[]
{
("echo-agent-01", "Echo Agent", 18081, "echo", new[] { "echo" }),
("translate-agent-01", "Translate Agent", 18082, "translate", new[] { "translate", "nlp" }),
("summarize-agent-01", "Summarize Agent", 18083, "summarize", new[] { "summarize", "nlp" }),
};
foreach (var (id, name, port, skillId, tags) in agents)
{
var card = new JsonObject
{
["agent_id"] = id,
["name"] = name,
["url"] = $"http://localhost:{port}/",
["skills"] = new JsonArray(new JsonObject
{
["id"] = skillId,
["name"] = skillId,
["tags"] = new JsonArray(tags.Select(t => (JsonNode)t!).ToArray())
})
};
var resp = await client.PostAsync(
$"{KubeMqUrl}/agents/register",
new StringContent(card.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"Registered {id}: {(int)resp.StatusCode}");
}
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const kubemqURL = "http://localhost:9090"
func main() {
agents := []map[string]interface{}{
{
"agent_id": "echo-agent-01", "name": "Echo Agent",
"url": "http://localhost:18081/",
"skills": []map[string]interface{}{{"id": "echo", "name": "Echo", "tags": []string{"echo"}}},
},
{
"agent_id": "translate-agent-01", "name": "Translate Agent",
"url": "http://localhost:18082/",
"skills": []map[string]interface{}{{"id": "translate", "name": "Translate", "tags": []string{"translate", "nlp"}}},
},
{
"agent_id": "summarize-agent-01", "name": "Summarize Agent",
"url": "http://localhost:18083/",
"skills": []map[string]interface{}{{"id": "summarize", "name": "Summarize", "tags": []string{"summarize", "nlp"}}},
},
}
for _, card := range agents {
data, _ := json.Marshal(card)
resp, err := http.Post(kubemqURL+"/agents/register", "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("Register %v failed: %v\n", card["agent_id"], err)
continue
}
resp.Body.Close()
fmt.Printf("Registered %v: %d\n", card["agent_id"], resp.StatusCode)
}
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient();
var agents = List.of(
Map.of("agent_id", "echo-agent-01", "name", "Echo Agent",
"url", "http://localhost:18081/",
"skills", List.of(Map.of("id", "echo", "name", "Echo", "tags", List.of("echo")))),
Map.of("agent_id", "translate-agent-01", "name", "Translate Agent",
"url", "http://localhost:18082/",
"skills", List.of(Map.of("id", "translate", "name", "Translate", "tags", List.of("translate", "nlp")))),
Map.of("agent_id", "summarize-agent-01", "name", "Summarize Agent",
"url", "http://localhost:18083/",
"skills", List.of(Map.of("id", "summarize", "name", "Summarize", "tags", List.of("summarize", "nlp"))))
);
for (var card : agents) {
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents/register"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card)))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("Registered " + card.get("agent_id") + ": " + resp.statusCode());
}
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENTS = [
{"agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://localhost:18081/",
"skills": [{"id": "echo", "name": "Echo", "tags": ["echo"]}]},
{"agent_id": "translate-agent-01", "name": "Translate Agent", "url": "http://localhost:18082/",
"skills": [{"id": "translate", "name": "Translate", "tags": ["translate", "nlp"]}]},
{"agent_id": "summarize-agent-01", "name": "Summarize Agent", "url": "http://localhost:18083/",
"skills": [{"id": "summarize", "name": "Summarize", "tags": ["summarize", "nlp"]}]},
]
async def main() -> None:
async with httpx.AsyncClient() as client:
for card in AGENTS:
resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card)
print(f"Registered {card['agent_id']}: {resp.status_code}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENTS = [
{ agent_id: "echo-agent-01", name: "Echo Agent", url: "http://localhost:18081/",
skills: [{ id: "echo", name: "Echo", tags: ["echo"] }] },
{ agent_id: "translate-agent-01", name: "Translate Agent", url: "http://localhost:18082/",
skills: [{ id: "translate", name: "Translate", tags: ["translate", "nlp"] }] },
{ agent_id: "summarize-agent-01", name: "Summarize Agent", url: "http://localhost:18083/",
skills: [{ id: "summarize", name: "Summarize", tags: ["summarize", "nlp"] }] },
];
async function main() {
for (const card of AGENTS) {
const resp = await fetch(`${KUBEMQ_URL}/agents/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(card),
});
console.log(`Registered ${card.agent_id}: ${resp.status}`);
}
}
main().catch(console.error);
```
## Step 2 — Discover agents by skill [#step-2--discover-agents-by-skill]
The router doesn't hardcode `agent_id`s — it asks the registry which agents have a needed
skill. `GET /agents?skill_tags=...` returns only agents whose skills carry **all** of the
requested tags (comma-separated). Filtering `nlp` returns both the translate and summarize
agents; filtering `echo` returns just one.
```bash
# Agents that can do NLP work
curl "http://localhost:9090/agents?skill_tags=nlp"
# Agents that can echo
curl "http://localhost:9090/agents?skill_tags=echo"
```
```csharp
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
using var client = new HttpClient();
async Task> Discover(string tag)
{
var resp = await client.GetAsync($"{KubeMqUrl}/agents?skill_tags={tag}");
var root = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
var agents = root is JsonArray arr ? arr : root["agents"]!.AsArray();
return agents.Select(a => a!["agent_id"]!.GetValue()).ToList();
}
Console.WriteLine($"nlp: [{string.Join(", ", await Discover("nlp"))}]");
Console.WriteLine($"echo: [{string.Join(", ", await Discover("echo"))}]");
```
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
const kubemqURL = "http://localhost:9090"
func discover(tag string) []string {
resp, err := http.Get(kubemqURL + "/agents?skill_tags=" + tag)
if err != nil {
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var wrapper map[string]interface{}
json.Unmarshal(body, &wrapper)
agents, _ := wrapper["agents"].([]interface{})
ids := make([]string, 0, len(agents))
for _, a := range agents {
ids = append(ids, a.(map[string]interface{})["agent_id"].(string))
}
return ids
}
func main() {
fmt.Printf("nlp: %v\n", discover("nlp"))
fmt.Printf("echo: %v\n", discover("echo"))
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final ObjectMapper MAPPER = new ObjectMapper();
static final HttpClient CLIENT = HttpClient.newHttpClient();
static List discover(String tag) throws Exception {
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/agents?skill_tags=" + tag))
.GET().build();
var resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
var root = MAPPER.readTree(resp.body());
var agents = root.isArray() ? root : root.get("agents");
var ids = new ArrayList();
for (var agent : agents) ids.add(agent.get("agent_id").asText());
return ids;
}
public static void main(String[] args) throws Exception {
System.out.println("nlp: " + discover("nlp"));
System.out.println("echo: " + discover("echo"));
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
async def discover(client: httpx.AsyncClient, tag: str) -> list[str]:
resp = await client.get(f"{KUBEMQ_URL}/agents", params={"skill_tags": tag})
data = resp.json()
agents = data.get("agents", data) if isinstance(data, dict) else data
return [a["agent_id"] for a in agents]
async def main() -> None:
async with httpx.AsyncClient() as client:
print(f"nlp: {await discover(client, 'nlp')}")
print(f"echo: {await discover(client, 'echo')}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
async function discover(tag: string): Promise {
const resp = await fetch(`${KUBEMQ_URL}/agents?skill_tags=${tag}`);
const data = await resp.json();
const agents = Array.isArray(data) ? data : (data.agents || []);
return agents.map((a: { agent_id: string }) => a.agent_id);
}
async function main() {
console.log("nlp: ", await discover("nlp"));
console.log("echo:", await discover("echo"));
}
main().catch(console.error);
```
## Step 3 — Route a request by agent\_id [#step-3--route-a-request-by-agent_id]
Once the router has picked an agent, it sends a normal
[`message/send`](/aiway/a2a/sync-messaging) to `POST /a2a/{agent_id}`. The same
client can fan a workload across agents by choosing a different `agent_id` per call — the
gateway routes each request to the matching agent's virtual subscriber.
```bash
# Route to the translate agent
curl -X POST http://localhost:9090/a2a/translate-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {"message": {"parts": [{"text": "Translate: hello"}]}}
}'
# Route to the summarize agent
curl -X POST http://localhost:9090/a2a/summarize-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "message/send",
"params": {"message": {"parts": [{"text": "Summarize this report..."}]}}
}'
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
using var client = new HttpClient();
async Task RouteTo(string agentId, string text)
{
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = text })
}
}
};
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{agentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
Console.WriteLine($"{agentId} -> {(int)resp.StatusCode}");
}
await RouteTo("translate-agent-01", "Translate: hello");
await RouteTo("summarize-agent-01", "Summarize this report...");
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const kubemqURL = "http://localhost:9090"
func routeTo(agentID, text string) {
payload := map[string]interface{}{
"jsonrpc": "2.0", "id": 1, "method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": text}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Printf("%s -> error: %v\n", agentID, err)
return
}
defer resp.Body.Close()
fmt.Printf("%s -> %d\n", agentID, resp.StatusCode)
}
func main() {
routeTo("translate-agent-01", "Translate: hello")
routeTo("summarize-agent-01", "Summarize this report...")
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final ObjectMapper MAPPER = new ObjectMapper();
static final HttpClient CLIENT = HttpClient.newHttpClient();
static void routeTo(String agentId, String text) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0", "id", 1, "method", "message/send",
"params", Map.of("message", Map.of("parts", List.of(Map.of("text", text))))
);
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + agentId))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
var resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(agentId + " -> " + resp.statusCode());
}
public static void main(String[] args) throws Exception {
routeTo("translate-agent-01", "Translate: hello");
routeTo("summarize-agent-01", "Summarize this report...");
}
}
```
```python
import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
async def route_to(client: httpx.AsyncClient, agent_id: str, text: str) -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {"message": {"parts": [{"text": text}]}},
}
resp = await client.post(f"{KUBEMQ_URL}/a2a/{agent_id}", json=payload)
print(f"{agent_id} -> {resp.status_code}")
async def main() -> None:
async with httpx.AsyncClient() as client:
await route_to(client, "translate-agent-01", "Translate: hello")
await route_to(client, "summarize-agent-01", "Summarize this report...")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
async function routeTo(agentId: string, text: string) {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: { message: { parts: [{ text }] } },
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${agentId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
console.log(`${agentId} -> ${resp.status}`);
}
async function main() {
await routeTo("translate-agent-01", "Translate: hello");
await routeTo("summarize-agent-01", "Summarize this report...");
}
main().catch(console.error);
```
## How routing works [#how-routing-works]
The gateway is a thin router, not a load balancer — `agent_id` selects exactly one agent.
* **Addressing.** The `{agent_id}` path segment names the target. The gateway validates it
against the registry, then issues a Query on `_AGENTS_.agents/`, which only
that agent's virtual subscriber consumes.
* **Discovery vs. routing.** Skill tags are a **discovery** convenience for picking an
`agent_id`; they never auto-route. The caller (or its own routing logic) decides which
`agent_id` to send to.
* **Isolation.** Each agent has its own [concurrency cap](/aiway/a2a/guides/concurrency)
(`AgentMaxConcurrency`, default 100) and its own liveness/TTL — one busy or expired agent
does not affect the others.
* **Scaling out.** Want two interchangeable translators? Register them under different
`agent_id`s with the same `translate` tag, then let your router pick between the matches
returned by `GET /agents?skill_tags=translate`.
Skill-tag filtering matches agents that carry **all** requested tags, and the filter runs
in memory after the registry fetch. To group agents for discovery, give them a shared tag
(like `nlp` above) in addition to their specific skill.
## Related [#related]
# Streaming Task Pipeline (/aiway/a2a/scenarios/streaming-task-pipeline)
This scenario streams a **long-running agent task** end to end. A single client opens a
`message/stream` request, the gateway relays the agent's progress as Server-Sent Events,
and the client consumes a sequence of **`task.*` envelopes** — interim `task.status`
updates, one or more `task.artifact` results, and a terminal `task.done`. It builds on
[streaming (SSE)](/aiway/a2a/streaming) and the per-agent
[virtual subscriber](/aiway/a2a/architecture).
## The setup [#the-setup]
The agent is a plain HTTP server registered by URL — no KubeMQ SDK runs on it. When the
client `POST`s a `message/stream` request to `/a2a/{agent_id}`, the gateway opens an SSE
relay through the agent's virtual subscriber: it sends a Query carrying the stream
channel, the agent streams SSE events back, and the gateway relays each one to the caller
as a `task.*` event. The client reads the stream until it sees the terminal `task.done`
(or `task.error`), and **cancelling is just disconnecting** — when the caller's connection
drops, the gateway sends a `stream_cancel` query to the agent and tears the relay down.
*The gateway relays the agent's SSE task events back to the caller and cancels the agent when the caller disconnects.*
## The task envelopes [#the-task-envelopes]
Each SSE frame names an event type and carries a JSON envelope. The agent emits four
envelope kinds; the gateway maps them to these SSE event names:
| SSE event | Envelope `type` | Meaning |
| --------------- | --------------- | ---------------------------------------------------- |
| `task.status` | `status_update` | Interim progress (`status`, `progress`, `total`) |
| `task.artifact` | `artifact` | A produced result (`name`, `data`) |
| `task.done` | `done` | Terminal success (`final_result`) — stream closes |
| `task.error` | `error` | Terminal failure (`code`, `message`) — stream closes |
The wire frame for a status update looks like this:
```text
event: task.status
data: {"stream_id":"","type":"status_update","payload":{"status":"working","progress":1,"total":3}}
```
A comment keepalive (`: keepalive`) arrives every 30 seconds on an otherwise idle stream,
and the stream closes on the first `task.done` or `task.error`.
## Step 1 — Open the stream [#step-1--open-the-stream]
Send a `message/stream` JSON-RPC request to the agent. The gateway responds with
`Content-Type: text/event-stream` and begins relaying the agent's `task.*` events. Pass
`Accept: text/event-stream` and read the response body line by line.
```bash
# -N disables curl buffering so events print as they arrive.
curl -N -X POST http://localhost:9090/a2a/task-events-agent-01 \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {"message": {"parts": [{"text": "Show me all event types"}]}}
}'
```
```csharp
using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "task-events-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/stream",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Show me all event types" })
}
}
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}")
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
Console.WriteLine("Connecting to SSE stream...");
using var resp = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
// ...continue reading events in Step 2
```
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "task-events-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Show me all event types"}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
fmt.Println("Connecting to SSE stream...")
// ...continue reading events in Step 2
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "task-events-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/stream",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Show me all event types")))
)
);
var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(60)).build();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
System.out.println("Connecting to SSE stream...");
var resp = client.send(req, HttpResponse.BodyHandlers.ofLines());
// ...continue reading events in Step 2
}
}
```
```python
"""Stream a long-running task and consume its task.* envelopes."""
import asyncio
import json
from collections import Counter
import httpx
from httpx_sse import aconnect_sse
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "task-events-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {"message": {"parts": [{"text": "Show me all event types"}]}},
}
async with httpx.AsyncClient(timeout=60) as client:
print("Connecting to SSE stream...")
async with aconnect_sse(
client, "POST", f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload
) as event_source:
... # consume events in Step 2
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "task-events-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/stream",
params: { message: { parts: [{ text: "Send me task events" }] } },
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(request),
});
const reader = resp.body!.getReader();
// ...continue reading events in Step 2
}
main().catch(console.error);
```
## Step 2 — Consume the task.\* envelopes [#step-2--consume-the-task-envelopes]
Read the stream frame by frame, dispatch on the SSE event name, and pull the result out of
`payload.payload`. Keep a count of each event type for a summary, and **break on the
terminal envelope** (`task.done` or `task.error`) — that frame closes the stream.
```text
event: task.status
data: {"stream_id":"...","type":"status_update","payload":{"status":"working","progress":1,"total":3}}
event: task.status
data: {"stream_id":"...","type":"status_update","payload":{"status":"working","progress":2,"total":3}}
event: task.artifact
data: {"stream_id":"...","type":"artifact","payload":{"name":"result.json","data":{"key":"value"}}}
event: task.done
data: {"stream_id":"...","type":"done","payload":{"final_result":"completed"}}
```
```csharp
var eventTypes = new Dictionary();
string? eventType = null;
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
if (line.StartsWith("event: "))
eventType = line[7..];
else if (line.StartsWith("data: ") && eventType != null)
{
eventTypes[eventType] = eventTypes.GetValueOrDefault(eventType) + 1;
var data = JsonNode.Parse(line[6..])!;
if (eventType == "task.status")
{
var p = data["payload"]!["payload"]!;
Console.WriteLine($" [STATUS] progress={p["progress"]}/{p["total"]}");
}
else if (eventType == "task.artifact")
Console.WriteLine($" [ARTIFACT] name={data["payload"]!["payload"]!["name"]}");
else if (eventType == "task.done")
Console.WriteLine($" [DONE] result={data["payload"]!["payload"]!["final_result"]}");
else if (eventType == "task.error")
Console.WriteLine($" [ERROR] {data["payload"]!["payload"]}");
if (eventType is "task.done" or "task.error")
break;
}
else if (line.Length == 0)
eventType = null;
}
Console.WriteLine($"\n--- Event Summary ---");
foreach (var (et, count) in eventTypes.OrderBy(kv => kv.Key))
Console.WriteLine($" {et}: {count}");
Console.WriteLine($" Total: {eventTypes.Values.Sum()}");
```
```go
scanner := bufio.NewScanner(resp.Body)
eventType := ""
counts := map[string]int{}
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
eventType = strings.TrimPrefix(line, "event: ")
} else if strings.HasPrefix(line, "data: ") {
counts[eventType]++
dataStr := strings.TrimPrefix(line, "data: ")
var d map[string]interface{}
json.Unmarshal([]byte(dataStr), &d)
payload, _ := d["payload"].(map[string]interface{})
inner, _ := payload["payload"].(map[string]interface{})
if inner == nil {
inner = payload
}
switch eventType {
case "task.status":
fmt.Printf(" [STATUS] progress=%.0f/%.0f\n", inner["progress"], inner["total"])
case "task.artifact":
fmt.Printf(" [ARTIFACT] name=%v\n", inner["name"])
case "task.done":
fmt.Printf(" [DONE] result=%v\n", inner["final_result"])
case "task.error":
fmt.Printf(" [ERROR] %v\n", inner)
}
if eventType == "task.done" || eventType == "task.error" {
break
}
}
}
```
```java
Map eventTypes = new LinkedHashMap<>();
String currentEvent = null;
for (var it = resp.body().iterator(); it.hasNext(); ) {
String line = it.next();
if (line.startsWith("event: ")) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith("data: ") && currentEvent != null) {
var data = MAPPER.readTree(line.substring(6));
eventTypes.merge(currentEvent, 1, Integer::sum);
var inner = data.path("payload").path("payload");
switch (currentEvent) {
case "task.status" ->
System.out.println(" [STATUS] progress=" + inner.path("progress") + "/" + inner.path("total"));
case "task.artifact" ->
System.out.println(" [ARTIFACT] name=" + inner.path("name").asText());
case "task.done" ->
System.out.println(" [DONE] result=" + inner.path("final_result").asText());
case "task.error" ->
System.out.println(" [ERROR] " + inner);
}
if ("task.done".equals(currentEvent) || "task.error".equals(currentEvent)) break;
}
}
```
```python
event_types: Counter[str] = Counter()
async for event in event_source.aiter_sse():
data = json.loads(event.data)
event_types[event.event] += 1
if event.event == "task.status":
inner = data["payload"]["payload"]
print(f" [STATUS] progress={inner['progress']}/{inner['total']}")
elif event.event == "task.artifact":
inner = data["payload"]["payload"]
print(f" [ARTIFACT] name={inner['name']}")
elif event.event == "task.done":
inner = data["payload"]["payload"]
print(f" [DONE] result={inner['final_result']}")
elif event.event == "task.error":
print(f" [ERROR] {data['payload']['payload']}")
if event.event in ("task.done", "task.error"):
break
print(f"\n--- Event Summary ---")
for event_type, count in sorted(event_types.items()):
print(f" {event_type}: {count}")
print(f" Total: {sum(event_types.values())}")
```
```typescript
function parseSSE(chunk: string): Array<{ event: string; data: string }> {
const events: Array<{ event: string; data: string }> = [];
let currentEvent = "";
let currentData = "";
for (const line of chunk.split("\n")) {
if (line.startsWith("event: ")) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
currentData = line.slice(6).trim();
} else if (line === "" && currentEvent) {
events.push({ event: currentEvent, data: currentData });
currentEvent = "";
currentData = "";
}
}
return events;
}
const decoder = new TextDecoder();
const counts: Record = {};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const events = parseSSE(decoder.decode(value, { stream: true }));
for (const evt of events) {
counts[evt.event] = (counts[evt.event] || 0) + 1;
const payload = JSON.parse(evt.data);
const inner = payload.payload?.payload ?? payload.payload;
switch (evt.event) {
case "task.status":
console.log(`[STATUS] progress=${inner.progress}/${inner.total} status=${inner.status}`);
break;
case "task.artifact":
console.log(`[ARTIFACT] name=${inner.name} data=${JSON.stringify(inner.data)}`);
break;
case "task.done":
console.log(`[DONE] result=${inner.final_result}`);
break;
case "task.error":
console.log(`[ERROR] ${inner.message}`);
break;
}
if (evt.event === "task.done" || evt.event === "task.error") {
console.log("\n=== Event Summary ===");
for (const [type, count] of Object.entries(counts)) {
console.log(` ${type}: ${count}`);
}
reader.cancel();
return;
}
}
}
```
A complete run prints the interim status, the artifact, the terminal result, and a summary:
```text
Connecting to SSE stream...
[STATUS] progress=1/3
[STATUS] progress=2/3
[ARTIFACT] name=result.json
[DONE] result=completed
--- Event Summary ---
task.artifact: 1
task.done: 1
task.status: 2
Total: 4
```
## Step 3 — Cancel by disconnecting [#step-3--cancel-by-disconnecting]
There is no explicit "cancel" RPC for the caller — **cancelling a streaming task is just
closing the connection**. When the client stops reading and disconnects before the terminal
envelope, the gateway detects the dropped connection and sends a `stream_cancel` query to
the agent's virtual subscriber (on `_AGENTS_.agents/{agent_id}`, with the `stream_id` and a
10-second timeout). The virtual subscriber closes its HTTP SSE connection to the agent and
tears down the relay, so the agent stops doing work.
```bash
# Read only the first events, then Ctrl-C (or pipe through head) to disconnect.
# The gateway sends stream_cancel to the agent on disconnect.
curl -N -X POST http://localhost:9090/a2a/slow-stream-agent-01 \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/stream",
"params":{"message":{"parts":[{"text":"I will disconnect early"}]}}}' \
| head -n 6
```
```go
// Break out of the read loop early and close the body — the gateway
// detects the disconnect and cancels the agent's stream.
resp, _ := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
count := 0
eventType := ""
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
eventType = strings.TrimPrefix(line, "event: ")
} else if strings.HasPrefix(line, "data: ") {
count++
var d map[string]interface{}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
p, _ := d["payload"].(map[string]interface{})
fmt.Printf(" Event %d: [%s] progress=%.0f\n", count, eventType, p["progress"])
if count >= maxEvents {
fmt.Printf("\nDisconnecting after %d events...\n", maxEvents)
break
}
}
}
resp.Body.Close() // closing the connection triggers stream_cancel
fmt.Println("KubeMQ will detect the disconnect and clean up the stream.")
```
```python
"""Disconnect from the SSE stream after a couple of events to cancel the task."""
MAX_EVENTS = 2
async with httpx.AsyncClient(timeout=30) as client:
async with aconnect_sse(
client, "POST", f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload
) as event_source:
count = 0
async for event in event_source.aiter_sse():
count += 1
data = json.loads(event.data)
print(f" Event {count}: [{event.event}] progress={data['payload'].get('progress')}")
if count >= MAX_EVENTS:
print(f"\nDisconnecting after {MAX_EVENTS} events...")
break # leaving the context closes the connection -> stream_cancel
print("Client disconnected.")
print("KubeMQ will detect the disconnect and clean up the stream.")
```
The gateway also closes the stream on its own when the idle timer fires
(`MaxSSEIdleSeconds`, default 300s): it emits a `task.error` with code `-32001` and message
`"stream idle timeout"`, then best-effort cancels the agent. See
[SSE behavior](/aiway/a2a/guides/sse-behavior) for the full wire-level rules.
## Related [#related]
# Endpoints (/aiway/mcp/reference/endpoints)
The MCP connector exposes a single HTTP path, `/mcp`, on the shared HTTP server (port 9090). All tool discovery and invocation flow through it as JSON-RPC 2.0 over the Streamable HTTP transport.
The connector is **enabled by default** — start kubemq-server and `/mcp` is live; no `=true` flag is needed. To disable it, set `CONNECTORSMCP_ENABLE=false` (see [Shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default) for the enable model and the irregular var name).
## HTTP endpoints [#http-endpoints]
| Method | Path | Description |
| ------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/mcp` | JSON-RPC 2.0 request handler — a single request or a JSON array (batch). Carries `initialize`, `ping`, `tools/list`, `tools/call`, and notifications. |
| `GET` | `/mcp` | SSE keepalive stream. Holds an event-stream connection open; clients that prefer a long-lived channel use it alongside the stateless `POST`. |
Both routes pass through origin validation; `POST /mcp` additionally runs the per-route timeout middleware.
## JSON-RPC methods [#json-rpc-methods]
`POST /mcp` accepts the following methods:
| Method | Type | Description |
| --------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `initialize` | Request | Start a session and negotiate capabilities. Returns `protocolVersion`, `capabilities`, `serverInfo`, and `_meta.sessionId`. |
| `notifications/initialized` | Notification | Client confirms initialization is complete. No `id` field; the server replies `200 OK` with body `{"jsonrpc":"2.0","result":null,"id":null}`. |
| `ping` | Request | Health check. Returns an empty result `{}`. |
| `tools/list` | Request | Discover the available tools with their `inputSchema` (11 core tools, plus 4 agent-bridge tools when the agent registry is available). |
| `tools/call` | Request | Invoke a tool by name with its `arguments`. |
The full tool catalog and per-tool arguments live in [Tools reference](/aiway/mcp/reference/tools-reference). For the JSON-RPC and tool-level error model, see [Error codes](/aiway/mcp/reference/error-codes).
## Headers [#headers]
| Header | Direction | Description |
| ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `Content-Type` | Request | Must be `application/json`. |
| `MCP-Session-Id` | Both | Session identifier. The server sets it in the `initialize` response (`_meta.sessionId`); the client echoes it on every subsequent request. |
| `MCP-Protocol-Version` | Response | Negotiated protocol version — always `2025-11-25`. |
| `Authorization` | Request | `Bearer ` when JWT auth is enabled. See [Authentication](/aiway/mcp/guides/authentication). |
## Request shape [#request-shape]
A JSON-RPC 2.0 request carries an `id` so the client can correlate the response:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "",
"params": { }
}
```
* `jsonrpc` — must be `"2.0"`.
* `id` — request identifier; the response echoes this value.
* `method` — one of the JSON-RPC methods above.
* `params` — method-specific parameters (object). Optional when the method takes no arguments, such as `ping`.
## Notification shape [#notification-shape]
Notifications omit the `id` field. The server processes them but returns no JSON-RPC result body:
```json
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
```
In a batch (`POST` with a JSON array), notification entries are executed but produce no response entry.
## initialize response [#initialize-response]
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": false },
"resources": null,
"prompts": null
},
"serverInfo": {
"name": "kubemq",
"version": ""
},
"_meta": {
"sessionId": ""
}
}
}
```
Save `result._meta.sessionId` and send it back as the `MCP-Session-Id` header on every later request. See [Session management](/aiway/mcp/guides/session-management) for the full lifecycle.
## HTTP status codes [#http-status-codes]
| Status | Meaning |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200` | Success — covers successful results, notifications (body `{"jsonrpc":"2.0","result":null,"id":null}`), and JSON-RPC error responses. A protocol error — including an auth failure (code `-32010`) — still returns `200` with an `error` object in the body, never `401`. See [Error codes](/aiway/mcp/reference/error-codes). |
## Endpoint signatures [#endpoint-signatures]
The following `curl` calls cover every method on the endpoint.
```bash
# initialize — start a session, capture _meta.sessionId
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "curl", "version": "1.0.0" }
}
}'
# notifications/initialized — confirm; server replies 200 with {"jsonrpc":"2.0","result":null,"id":null}
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-Id: " \
-d '{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}'
# ping — health check, returns an empty result {}
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-Id: " \
-d '{ "jsonrpc": "2.0", "id": 2, "method": "ping" }'
# tools/list — discover tools and their inputSchema
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-Id: " \
-d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/list" }'
# tools/call — invoke a tool by name
curl -X POST http://localhost:9090/mcp \
-H "Content-Type: application/json" \
-H "MCP-Session-Id: " \
-d '{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": { "channel": "my-queue", "body": "Hello from MCP" }
}
}'
# GET /mcp — open the SSE keepalive stream
curl -N http://localhost:9090/mcp \
-H "Accept: text/event-stream" \
-H "MCP-Session-Id: "
```
## Related [#related]
# Error Codes (/aiway/mcp/reference/error-codes)
The MCP connector surfaces failures across three layers: **JSON-RPC protocol errors** (in the response `error` field), **tool-execution errors** (in a normal `result` with `isError: true`), and **HTTP status codes** from the shared HTTP server. This page is the authoritative catalog of every code. For task-focused guidance on detecting and recovering from these layers in client code, see the [Error Handling guide](/aiway/mcp/guides/error-handling).
## The three error layers [#the-three-error-layers]
A single failed `tools/call` can fail at exactly one of three levels. Check them in order — protocol first, then tool result, then transport — because a higher-layer failure short-circuits the lower layers.
| Layer | Where it appears | Example cause |
| ----------------------- | --------------------------- | ------------------------------------------------------------ |
| JSON-RPC protocol error | `error` field (no `result`) | Malformed JSON, unknown method, missing params, auth failure |
| Tool-execution error | `result.isError: true` | Reserved channel, agent not found, command timeout |
| HTTP status | HTTP response status line | Broker not ready (503), gateway timeout (504) |
## JSON-RPC protocol error codes [#json-rpc-protocol-error-codes]
Protocol-level errors mean the request itself was malformed or could not be dispatched. They are returned in the `error` field — there is **no** `result` field. The HTTP status is still `200` (per JSON-RPC convention); the failure is encoded in the body.
| Code | Constant | Name | Trigger |
| -------- | ----------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- |
| `-32700` | `JSONRPCParseError` | Parse Error | Malformed JSON body, or wrong `Content-Type` (e.g. `text/plain` instead of `application/json`) |
| `-32600` | `JSONRPCInvalidRequest` | Invalid Request | Empty `method` field, or `jsonrpc` version is not `"2.0"` |
| `-32601` | `JSONRPCMethodNotFound` | Method Not Found | Unknown method name (e.g. `tools/unknown`) |
| `-32602` | `JSONRPCInvalidParams` | Invalid Params | `params` is not an object, or a required tool argument is missing |
| `-32603` | `JSONRPCInternalError` | Internal Error | Unexpected server-side failure while dispatching the request |
| `-32010` | `jsonrpcAuthError` | Authentication Failure | Bearer token missing or invalid on the JSON-RPC endpoint (`/mcp`) |
`-32700` through `-32603` are the standard JSON-RPC 2.0 reserved codes. `-32010` is a KubeMQ server-defined code shared by the JSON-RPC endpoints (`/mcp`, `/a2a`).
### Error response format [#error-response-format]
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32601,
"message": "Method not found"
}
}
```
A parse error that occurs before the request `id` can be read returns `"id": null`:
```json
{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32700,
"message": "Parse error"
}
}
```
### Authentication failures (-32010) [#authentication-failures--32010]
When JWT auth is enabled and a request to `/mcp` carries a missing or invalid `Authorization: Bearer` token, the connector returns the JSON-RPC auth code `-32010` with HTTP status `200` — **not** HTTP `401`:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32010,
"message": "authentication required"
}
}
```
The `-32010` code applies only to JSON-RPC endpoints (`/mcp`, `/a2a`). Standard HTTP/REST endpoints return HTTP `401` for the same auth failure. See [Auth & security](/connectors/reference/auth-and-security) for the shared auth model.
## Tool-execution errors (`isError`) [#tool-execution-errors-iserror]
Tool-level errors are **not** JSON-RPC errors. The request was valid JSON-RPC and was dispatched successfully, but the underlying messaging operation failed. The response carries a normal `result` field with `isError: true`; the human-readable cause is in `result.content[].text`.
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "Agent 'nonexistent' not found" }],
"isError": true
}
}
```
This is why robust clients **check for `error` first, then `result.isError`** — a successful protocol response can still describe a failed tool operation.
### Common tool-execution errors [#common-tool-execution-errors]
| Error | Affected tools | Example message |
| -------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
| Reserved channel rejection | `queue_send`, `events_publish`, `events_store_publish`, `command_send`, `query_send` | `"Channel '_AGENTS_reserved' uses a reserved prefix"` |
| Non-existent agent | `agent_info`, `agent_send`, `agent_query` | `"Agent 'nonexistent' not found"` |
| Timeout exceeded | `command_send`, `query_send`, `agent_send`, `agent_query` | `"Command timed out: no subscriber on channel 'ch' within 10s"` |
| No subscriber available | `command_send`, `query_send` | `"Command timed out: no subscriber on channel 'ch' within 10s"` |
| Non-existent channel | `channel_info` | `"Channel 'nonexistent' of type 'queues' not found"` |
Reserved-channel rejection comes from the shared `_AGENTS_.` prefix guard — see [Channel resolution](/aiway/mcp/guides/channel-resolution).
## HTTP status codes [#http-status-codes]
For a well-formed request, the connector returns HTTP `200` even when the body contains a JSON-RPC error. Other statuses originate from the shared HTTP server layer (middleware chain) before or around tool dispatch.
| Status | Meaning | When |
| ------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200` | OK | All JSON-RPC responses, including error responses and notifications (e.g. `notifications/initialized`, which returns body `{"jsonrpc":"2.0","result":null,"id":null}`) |
| `503` | Service Unavailable | The broker is not ready; the traffic gate rejects the request |
| `504` | Gateway Timeout | The request deadline elapsed before a response was committed |
HTTP `200` is returned even for JSON-RPC error responses. The error is encoded in the JSON body, not the HTTP status line — always parse the body before treating a `200` as success.
## Resolution order [#resolution-order]
When handling a response, evaluate the layers top-down:
1. **Inspect the HTTP status.** A `503` means the broker is not ready (retry with backoff); a `504` means the request timed out.
2. **Check for `error`.** A present `error` field is a JSON-RPC protocol failure — fix the request (or token for `-32010`). Do not look for `result`.
3. **Check `result.isError`.** If `true`, the tool ran but the operation failed; read the cause from `result.content[0].text`.
4. **Otherwise the call succeeded** — the payload is in `result.content`.
## Related [#related]
# Tools Reference (/aiway/mcp/reference/tools-reference)
The authoritative catalog of every tool the MCP connector exposes through `tools/call`: 11 core messaging tools plus 4 agent-bridge tools. For each tool you get its arguments, defaults, JSON Schema, a ready-to-run curl request, and the response shape.
## Overview [#overview]
The MCP connector advertises **15 tools** in its `tools/list` response and runs them through the single `POST /mcp` endpoint on the [shared HTTP server](/connectors/concepts/shared-http-server) (port `9090`, protocol version `2025-11-25`). The 11 core tools are always registered; the 4 agent-bridge tools appear only when the [A2A agent registry](/aiway/a2a) is present.
This page is the canonical argument and schema reference. The per-tool pages under [Tools](/aiway/mcp/tools) carry the same operations with examples in all nine languages. For the wire-level request/response forms see [Endpoints](/aiway/mcp/reference/endpoints); for the failure codes see [Error codes](/aiway/mcp/reference/error-codes).
## Tool summary [#tool-summary]
| # | Tool | Category | Required args | Optional args |
| -- | -------------------------- | --------------- | ------------------------- | --------------------------------------------------------------------------------------------------- |
| 1 | `queue_send` | Queue | `channel`, `body` | `metadata`, `tags`, `delay_seconds`, `expiration_seconds`, `max_receive_count`, `dead_letter_queue` |
| 2 | `queue_receive` | Queue | `channel`, `max_messages` | `wait_timeout_seconds` |
| 3 | `queue_peek` | Queue | `channel`, `max_messages` | *(none)* |
| 4 | `events_publish` | Events | `channel`, `body` | `metadata`, `tags` |
| 5 | `events_store_publish` | Events | `channel`, `body` | `metadata`, `tags` |
| 6 | `events_store_read` | Events | `channel`, `max_messages` | `from_sequence`, `from_time` |
| 7 | `events_store_read_latest` | Events | `channel` | `count` |
| 8 | `command_send` | Command / Query | `channel`, `body` | `timeout_seconds`, `metadata`, `tags` |
| 9 | `query_send` | Command / Query | `channel`, `body` | `timeout_seconds`, `metadata`, `tags` |
| 10 | `channel_list` | Channel | *(none)* | `type`, `pattern` |
| 11 | `channel_info` | Channel | `channel`, `type` | *(none)* |
| 12 | `agent_list` | Agent bridge | *(none)* | `skill_tags` |
| 13 | `agent_info` | Agent bridge | `agent_id` | *(none)* |
| 14 | `agent_send` | Agent bridge | `agent_id`, `message` | `blocking`, `context_id`, `timeout_seconds` |
| 15 | `agent_query` | Agent bridge | `agent_id`, `method` | `params` |
## Calling convention [#calling-convention]
Every tool is invoked the same way: a `tools/call` JSON-RPC request naming the tool and passing its `arguments` object. Only `name` and `arguments` change between tools.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "",
"arguments": { }
}
}'
```
Every successful `tools/call` returns a result with a `content` array of typed blocks — KubeMQ uses `text` blocks carrying the operation result as a string (often a JSON string). A tool-level failure sets `isError: true` in the same envelope; the JSON-RPC response itself still succeeds with HTTP `200`.
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "..." }],
"isError": false
}
}
```
A `channel` that starts with the reserved prefix `_AGENTS_.` is rejected — see [Channel resolution](/aiway/mcp/guides/channel-resolution). Missing required arguments return a `-32602` Invalid Params JSON-RPC error, not an `isError` result.
## Queue tools [#queue-tools]
Durable, point-to-point queue messaging. See [Queue tools](/aiway/mcp/tools/queues) for language examples.
### queue\_send [#queue_send]
Send a message to a queue channel.
| Argument | Type | Required | Default | Description |
| -------------------- | ------- | -------- | ------- | ------------------------------------------------------------------------- |
| `channel` | string | Yes | — | Target queue channel. Must not start with the reserved prefix `_AGENTS_.` |
| `body` | string | Yes | — | Message body content |
| `metadata` | string | No | `""` | Optional message metadata string |
| `tags` | object | No | `{}` | Key-value tags for message classification |
| `delay_seconds` | integer | No | `0` | Delay before the message becomes visible. `0` = immediately available |
| `expiration_seconds` | integer | No | `0` | TTL in seconds. `0` = no expiration |
| `max_receive_count` | integer | No | `0` | Max receives before dead-letter. `0` = unlimited |
| `dead_letter_queue` | string | No | `""` | Dead-letter queue channel name |
```json
{
"type": "object",
"required": ["channel", "body"],
"properties": {
"channel": { "type": "string" },
"body": { "type": "string" },
"metadata": { "type": "string", "default": "" },
"tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} },
"delay_seconds": { "type": "integer", "minimum": 0, "default": 0 },
"expiration_seconds": { "type": "integer", "minimum": 0, "default": 0 },
"max_receive_count": { "type": "integer", "minimum": 0, "default": 0 },
"dead_letter_queue": { "type": "string", "default": "" }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": {
"channel": "example-queue",
"body": "Hello from MCP",
"metadata": "example-metadata",
"tags": { "env": "dev", "source": "mcp-example" }
}
}
}'
```
**Response** — a confirmation message in a single text block:
```json
{
"content": [{ "type": "text", "text": "Message sent successfully to queue 'example-queue'" }],
"isError": false
}
```
**Errors:** reserved channel → `isError: true`; missing `channel`/`body` → `-32602`.
### queue\_receive [#queue_receive]
Receive and consume messages from a queue channel. This is a destructive read — returned messages are removed from the queue.
| Argument | Type | Required | Default | Description |
| ---------------------- | ------- | -------- | ------- | ------------------------------------------------ |
| `channel` | string | Yes | — | Source queue channel |
| `max_messages` | integer | Yes | `1` | Max messages to receive in a single call (1–100) |
| `wait_timeout_seconds` | integer | No | `5` | Long-poll wait time in seconds (1–60) |
```json
{
"type": "object",
"required": ["channel", "max_messages"],
"properties": {
"channel": { "type": "string" },
"max_messages": { "type": "integer", "minimum": 1, "maximum": 100, "default": 1 },
"wait_timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "queue_receive",
"arguments": { "channel": "example-queue", "max_messages": 5 }
}
}'
```
**Response** — a JSON array of received messages, serialized as the text payload:
```json
{
"content": [{ "type": "text", "text": "[{\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\",\"tags\":{\"env\":\"dev\",\"source\":\"mcp-example\"}}]" }],
"isError": false
}
```
**Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result (no error).
### queue\_peek [#queue_peek]
Peek at messages without consuming them — a non-destructive read; messages stay in the queue.
| Argument | Type | Required | Default | Description |
| -------------- | ------- | -------- | ------- | ---------------------------------------------- |
| `channel` | string | Yes | — | Source queue channel |
| `max_messages` | integer | Yes | `1` | Max messages to peek without consuming (1–100) |
```json
{
"type": "object",
"required": ["channel", "max_messages"],
"properties": {
"channel": { "type": "string" },
"max_messages": { "type": "integer", "minimum": 1, "maximum": 100, "default": 1 }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "queue_peek",
"arguments": { "channel": "example-queue", "max_messages": 5 }
}
}'
```
**Response** — same shape as `queue_receive`, but the messages remain in the queue:
```json
{
"content": [{ "type": "text", "text": "[{\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\",\"tags\":{\"env\":\"dev\"}}]" }],
"isError": false
}
```
**Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result (no error).
## Events tools [#events-tools]
Pub/sub and the persistent events store. See [Events tools](/aiway/mcp/tools/events) for language examples.
### events\_publish [#events_publish]
Publish an ephemeral event (fire-and-forget; no persistence).
| Argument | Type | Required | Default | Description |
| ---------- | ------ | -------- | ------- | --------------------------------------- |
| `channel` | string | Yes | — | Target events channel |
| `body` | string | Yes | — | Event body content |
| `metadata` | string | No | `""` | Optional event metadata string |
| `tags` | object | No | `{}` | Key-value tags for event classification |
```json
{
"type": "object",
"required": ["channel", "body"],
"properties": {
"channel": { "type": "string" },
"body": { "type": "string" },
"metadata": { "type": "string", "default": "" },
"tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "events_publish",
"arguments": {
"channel": "example-events",
"body": "Event data",
"metadata": "event-meta",
"tags": { "source": "mcp-example" }
}
}
}'
```
**Response:**
```json
{
"content": [{ "type": "text", "text": "Event published successfully to channel 'example-events'" }],
"isError": false
}
```
**Errors:** reserved channel → `isError: true`; missing `channel`/`body` → `-32602`.
### events\_store\_publish [#events_store_publish]
Publish a persistent event to the events store. Stored events receive a monotonic sequence number.
| Argument | Type | Required | Default | Description |
| ---------- | ------ | -------- | ------- | --------------------------------------- |
| `channel` | string | Yes | — | Target events-store channel |
| `body` | string | Yes | — | Event body content to store |
| `metadata` | string | No | `""` | Optional event metadata string |
| `tags` | object | No | `{}` | Key-value tags for event classification |
```json
{
"type": "object",
"required": ["channel", "body"],
"properties": {
"channel": { "type": "string" },
"body": { "type": "string" },
"metadata": { "type": "string", "default": "" },
"tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {
"name": "events_store_publish",
"arguments": {
"channel": "example-events-store",
"body": "Stored event data",
"metadata": "store-meta",
"tags": { "source": "mcp-example" }
}
}
}'
```
**Response:**
```json
{
"content": [{ "type": "text", "text": "Event published successfully to events store channel 'example-events-store'" }],
"isError": false
}
```
**Errors:** reserved channel → `isError: true`; missing `channel`/`body` → `-32602`.
### events\_store\_read [#events_store_read]
Read stored events starting from a sequence number or a timestamp.
| Argument | Type | Required | Default | Description |
| --------------- | ------- | -------- | ------- | --------------------------------------------------------------------------- |
| `channel` | string | Yes | — | Source events-store channel |
| `from_sequence` | integer | No | — | Start from this sequence number. Mutually exclusive with `from_time` |
| `from_time` | string | No | — | Start from this ISO 8601 timestamp. Mutually exclusive with `from_sequence` |
| `max_messages` | integer | Yes | — | Maximum number of messages to return (1–100) |
`from_sequence` and `from_time` are mutually exclusive — supply at most one.
```json
{
"type": "object",
"required": ["channel", "max_messages"],
"properties": {
"channel": { "type": "string" },
"from_sequence": { "type": "integer", "minimum": 1 },
"from_time": { "type": "string", "format": "date-time" },
"max_messages": { "type": "integer", "minimum": 1, "maximum": 100 }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "events_store_read",
"arguments": { "channel": "example-events-store", "from_sequence": 1, "max_messages": 10 }
}
}'
```
**Response** — a JSON array of stored events, each with its `sequence` and `timestamp`:
```json
{
"content": [{ "type": "text", "text": "[{\"body\":\"Stored event data\",\"metadata\":\"store-meta\",\"sequence\":1,\"timestamp\":\"2026-06-08T12:00:00Z\"}]" }],
"isError": false
}
```
**Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result; missing `channel` → `-32602`.
### events\_store\_read\_latest [#events_store_read_latest]
Read the N most recent stored events.
| Argument | Type | Required | Default | Description |
| --------- | ------- | -------- | ------- | -------------------------------------- |
| `channel` | string | Yes | — | Source events-store channel |
| `count` | integer | No | `1` | Number of most recent events to return |
```json
{
"type": "object",
"required": ["channel"],
"properties": {
"channel": { "type": "string" },
"count": { "type": "integer", "minimum": 1, "default": 1 }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 8,
"method": "tools/call",
"params": {
"name": "events_store_read_latest",
"arguments": { "channel": "example-events-store", "count": 3 }
}
}'
```
**Response** — the most recent events, newest first:
```json
{
"content": [{ "type": "text", "text": "[{\"body\":\"Stored event 3\",\"sequence\":3},{\"body\":\"Stored event 2\",\"sequence\":2},{\"body\":\"Stored event 1\",\"sequence\":1}]" }],
"isError": false
}
```
**Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result; missing `channel` → `-32602`.
## Command & query tools [#command--query-tools]
Synchronous request/reply. See [Command & query tools](/aiway/mcp/tools/commands-queries) for language examples.
### command\_send [#command_send]
Send a command and wait for acknowledgment from a subscriber.
| Argument | Type | Required | Default | Description |
| ----------------- | ------- | -------- | ------- | ------------------------------------------- |
| `channel` | string | Yes | — | Target command channel |
| `body` | string | Yes | — | Command body content |
| `timeout_seconds` | integer | No | `10` | Timeout in seconds waiting for the response |
| `metadata` | string | No | `""` | Optional command metadata string |
| `tags` | object | No | `{}` | Key-value tags for command classification |
```json
{
"type": "object",
"required": ["channel", "body"],
"properties": {
"channel": { "type": "string" },
"body": { "type": "string" },
"timeout_seconds": { "type": "integer", "minimum": 1, "default": 10 },
"metadata": { "type": "string", "default": "" },
"tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "command_send",
"arguments": {
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
"metadata": "cmd-meta",
"tags": { "action": "process" }
}
}
}'
```
**Response (success):**
```json
{
"content": [{ "type": "text", "text": "Command executed successfully on channel 'example-commands'" }],
"isError": false
}
```
**Errors:** reserved channel, no subscriber (timeout), or subscriber rejection → `isError: true`; missing `channel`/`body` → `-32602`.
### query\_send [#query_send]
Send a query and receive a data response from a subscriber.
| Argument | Type | Required | Default | Description |
| ----------------- | ------- | -------- | ------- | ------------------------------------------- |
| `channel` | string | Yes | — | Target query channel |
| `body` | string | Yes | — | Query body content |
| `timeout_seconds` | integer | No | `30` | Timeout in seconds waiting for the response |
| `metadata` | string | No | `""` | Optional query metadata string |
| `tags` | object | No | `{}` | Key-value tags for query classification |
```json
{
"type": "object",
"required": ["channel", "body"],
"properties": {
"channel": { "type": "string" },
"body": { "type": "string" },
"timeout_seconds": { "type": "integer", "minimum": 1, "default": 30 },
"metadata": { "type": "string", "default": "" },
"tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "query_send",
"arguments": {
"channel": "example-queries",
"body": "get-data",
"timeout_seconds": 30,
"metadata": "qry-meta",
"tags": { "action": "lookup" }
}
}
}'
```
**Response (success)** — the subscriber's reply payload as the text block:
```json
{
"content": [{ "type": "text", "text": "{\"data\":\"query response payload from subscriber\"}" }],
"isError": false
}
```
**Errors:** reserved channel, no subscriber (timeout), or subscriber rejection → `isError: true`; missing `channel`/`body` → `-32602`.
## Channel tools [#channel-tools]
Discovery and inspection of channels. See [Channel tools](/aiway/mcp/tools/channel-management) for language examples.
### channel\_list [#channel_list]
List channels, optionally filtered by type or name pattern. An empty list is a normal successful result.
| Argument | Type | Required | Default | Description |
| --------- | ------ | -------- | ------- | ---------------------------------------------------------------------------------- |
| `type` | string | No | — | Filter by channel type (`queues`, `events`, `events_store`, `commands`, `queries`) |
| `pattern` | string | No | — | Filter by channel name pattern or prefix |
```json
{
"type": "object",
"required": [],
"properties": {
"type": { "type": "string" },
"pattern": { "type": "string" }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "channel_list",
"arguments": {}
}
}'
```
**Response** — a JSON array of channel descriptors:
```json
{
"content": [{ "type": "text", "text": "[{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true},{\"name\":\"example-events\",\"type\":\"events\",\"is_active\":true}]" }],
"isError": false
}
```
**Errors:** invalid arguments → `-32602`. No tool-specific failures otherwise.
### channel\_info [#channel_info]
Get metadata for a specific channel.
| Argument | Type | Required | Default | Description |
| --------- | ------ | -------- | ------- | ------------------------------------------------------------------------ |
| `channel` | string | Yes | — | Channel name |
| `type` | string | Yes | — | Channel type (`queues`, `events`, `events_store`, `commands`, `queries`) |
```json
{
"type": "object",
"required": ["channel", "type"],
"properties": {
"channel": { "type": "string" },
"type": { "type": "string" }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "channel_info",
"arguments": { "channel": "example-queue", "type": "queues" }
}
}'
```
**Response (success)** — channel metadata with traffic counters:
```json
{
"content": [{ "type": "text", "text": "{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true,\"incoming\":5,\"outgoing\":3}" }],
"isError": false
}
```
**Errors:** non-existent channel → `isError: true`.
## Agent-bridge tools [#agent-bridge-tools]
These 4 tools appear in `tools/list` **only when the A2A agent registry is present**. They turn an MCP client into an A2A caller. See [Agent-bridge tools](/aiway/mcp/tools/agent-bridge) for language examples and the [A2A connector](/aiway/a2a) for the agent model.
### agent\_list [#agent_list]
List registered agents, optionally filtered by skill tags. An empty list is a normal successful result.
| Argument | Type | Required | Default | Description |
| ------------ | --------------- | -------- | ------- | --------------------------- |
| `skill_tags` | array of string | No | — | Filter agents by skill tags |
```json
{
"type": "object",
"required": [],
"properties": {
"skill_tags": { "type": "array", "items": { "type": "string" } }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 13,
"method": "tools/call",
"params": {
"name": "agent_list",
"arguments": {}
}
}'
```
**Response** — a JSON array of agents with their skills:
```json
{
"content": [{ "type": "text", "text": "[{\"agent_id\":\"echo-01\",\"name\":\"Echo Agent 01\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\",\"tags\":[\"test\",\"echo\"]}]}]" }],
"isError": false
}
```
**Errors:** invalid arguments → `-32602`. No tool-specific failures otherwise.
### agent\_info [#agent_info]
Get detailed metadata for a specific agent.
| Argument | Type | Required | Default | Description |
| ---------- | ------ | -------- | ------- | --------------------------- |
| `agent_id` | string | Yes | — | Agent identifier to look up |
```json
{
"type": "object",
"required": ["agent_id"],
"properties": {
"agent_id": { "type": "string" }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 14,
"method": "tools/call",
"params": {
"name": "agent_info",
"arguments": { "agent_id": "echo-01" }
}
}'
```
**Response (success)** — the agent's card, including its registered HTTP `url` and skills:
```json
{
"content": [{ "type": "text", "text": "{\"agent_id\":\"echo-01\",\"name\":\"Echo Agent 01\",\"description\":\"echo agent\",\"version\":\"1.0.0\",\"url\":\"http://localhost:18080/\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\",\"tags\":[\"test\",\"echo\"]}]}" }],
"isError": false
}
```
**Errors:** non-existent agent → `isError: true`.
### agent\_send [#agent_send]
Send a message to an agent. The connector builds a `message/send` envelope and forwards it over the broker as a Query to `_AGENTS_.agents/`.
| Argument | Type | Required | Default | Description |
| ----------------- | ------- | -------- | ------- | ----------------------------------------------------------------- |
| `agent_id` | string | Yes | — | Target agent identifier |
| `message` | string | Yes | — | Message content to send to the agent |
| `blocking` | boolean | No | `true` | Wait for the agent response if `true`; fire-and-forget if `false` |
| `context_id` | string | No | — | Conversation context ID for multi-turn interactions |
| `timeout_seconds` | integer | No | — | Timeout in seconds. The server adds a +10s `GatewayTimeoutBuffer` |
```json
{
"type": "object",
"required": ["agent_id", "message"],
"properties": {
"agent_id": { "type": "string" },
"message": { "type": "string" },
"blocking": { "type": "boolean", "default": true },
"context_id": { "type": "string" },
"timeout_seconds": { "type": "integer", "minimum": 1 }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 15,
"method": "tools/call",
"params": {
"name": "agent_send",
"arguments": { "agent_id": "echo-01", "message": "hello from MCP" }
}
}'
```
**Response (success)** — the agent's reply payload as the text block:
```json
{
"content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"message/send\",\"params\":{\"message\":\"hello from MCP\"}},\"received_headers\":{}}" }],
"isError": false
}
```
**Errors:** non-existent agent or timeout exceeded → `isError: true`.
### agent\_query [#agent_query]
Query an agent with a specific JSON-RPC method.
| Argument | Type | Required | Default | Description |
| ---------- | ------ | -------- | ------- | ------------------------------------------------------------------------ |
| `agent_id` | string | Yes | — | Target agent identifier |
| `method` | string | Yes | — | Query method to invoke (`tasks/get`, `tasks/cancel`, or a custom method) |
| `params` | object | No | — | Method-specific parameters |
```json
{
"type": "object",
"required": ["agent_id", "method"],
"properties": {
"agent_id": { "type": "string" },
"method": { "type": "string" },
"params": { "type": "object" }
}
}
```
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 16,
"method": "tools/call",
"params": {
"name": "agent_query",
"arguments": { "agent_id": "echo-01", "method": "tasks/get" }
}
}'
```
**Response (success):**
```json
{
"content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"tasks/get\",\"params\":{}},\"received_headers\":{}}" }],
"isError": false
}
```
**Errors:** non-existent agent or timeout exceeded → `isError: true`.
## Related [#related]
# Authentication (/aiway/mcp/guides/authentication)
The MCP connector is guarded by the same JWT Bearer authentication as every other
KubeMQ connector. You authenticate to `POST /mcp` with an `Authorization: Bearer`
header; KubeMQ verifies the token, attaches your identity claims, and lets the tool
call through. This guide shows how to attach that header through each MCP SDK's
transport — the place an SDK client differs from a raw `curl` call — plus how MCP
reports auth failures and validates request origins.
## Overview [#overview]
Authentication for MCP is the connector-wide model described in
[Auth & Security](/connectors/reference/auth-and-security), applied to the single MCP
endpoint:
* **Endpoint** — `POST /mcp` (and the `GET /mcp` keepalive stream) carry every
JSON-RPC method: `initialize`, `tools/list`, `tools/call`, and `ping`. A verified
token identifies the caller; that `ClientID` flows through to the broker and, for the
[agent-bridge tools](/aiway/mcp/tools/agent-bridge), to the agent as
`X-KubeMQ-Caller-ID`.
When server authentication is **disabled** (the default for local development), every
caller is treated as the synthetic `anonymous` principal and no token is required. When
it is **enabled**, a missing or unverified token is rejected as a JSON-RPC `-32010`
error.
Authentication is **disabled by default** so MCP works out of the box for local
development. Enable it before exposing the endpoint beyond a trusted network — see
[Production recommendations](#production-recommendations).
## How it works [#how-it-works]
The token is verified once, at the shared auth middleware, before the request reaches
the MCP connector. The middleware extracts the `Bearer` token from the `Authorization`
header, verifies it against KubeMQ's authentication singleton, and attaches the caller's
claims (including `ClientID`) to the request context. The connector then resolves the
tool to a KubeMQ operation under that identity.
*KubeMQ verifies the Bearer token at the edge, then runs the tool call under the caller's identity.*
## Attaching the token [#attaching-the-token]
Every MCP request must carry an `Authorization: Bearer ` header when server auth is
enabled. With raw JSON-RPC the header goes straight onto the HTTP request; with an MCP
SDK you attach it through the **streamable-HTTP transport** so the header rides on every
call in the session — the `initialize` handshake, `tools/list`, and each `tools/call`.
These snippets store the token in the `KUBEMQ_MCP_AUTH_TOKEN` environment variable —
the same variable the KubeMQ MCP examples use — and send it on a `tools/list` request.
```bash
curl -X POST http://localhost:9090/mcp \
-H "Authorization: Bearer $KUBEMQ_MCP_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
```csharp
using ModelContextProtocol.Client;
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var token = Environment.GetEnvironmentVariable("KUBEMQ_MCP_AUTH_TOKEN");
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri($"{url}/mcp"),
AdditionalHeaders = new Dictionary
{
["Authorization"] = $"Bearer {token}",
},
});
await using var client = await McpClientFactory.CreateAsync(transport);
var tools = await client.ListToolsAsync();
Console.WriteLine($"Tools: {tools.Count}");
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
token := os.Getenv("KUBEMQ_MCP_AUTH_TOKEN")
c, err := client.NewStreamableHttpClient(url+"/mcp",
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + token,
}),
)
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
if _, err := c.Initialize(ctx, mcp.InitializeRequest{}); err != nil {
log.Fatal(err)
}
tools, err := c.ListTools(ctx, mcp.ListToolsRequest{})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Tools: %d\n", len(tools.Tools))
}
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
public class Auth {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
String token = System.getenv("KUBEMQ_MCP_AUTH_TOKEN");
var transport = HttpClientStreamableHttpTransport.builder(url)
.endpoint("/mcp")
.httpRequestCustomizer((builder, method, endpoint, body, context) ->
builder.header("Authorization", "Bearer " + token))
.build();
var client = McpClient.sync(transport).build();
client.initialize();
var tools = client.listTools();
System.out.println("Tools: " + tools.tools().size());
client.closeGracefully();
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.sse.*
import io.ktor.client.request.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val token = System.getenv("KUBEMQ_MCP_AUTH_TOKEN")
val httpClient = HttpClient {
install(SSE)
defaultRequest { header("Authorization", "Bearer $token") }
}
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val tools = client.listTools()
println("Tools: ${tools?.tools?.size}")
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
TOKEN = os.environ.get("KUBEMQ_MCP_AUTH_TOKEN")
async def main():
headers = {"Authorization": f"Bearer {TOKEN}"} if TOKEN else None
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp", headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print(f"Tools: {len(tools.tools)}")
if __name__ == "__main__":
asyncio.run(main())
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
token = ENV["KUBEMQ_MCP_AUTH_TOKEN"]
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new(
"#{url}/mcp",
headers: { "Authorization" => "Bearer #{token}" }
),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
tools = client.list_tools
puts "Tools: #{tools.size}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::transport::streamable_http::client::StreamableHttpClientTransportConfig;
use rmcp::service::RunService;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let token = std::env::var("KUBEMQ_MCP_AUTH_TOKEN").unwrap_or_default();
let http = reqwest::Client::builder()
.default_headers({
let mut h = reqwest::header::HeaderMap::new();
h.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {token}").parse()?,
);
h
})
.build()?;
let transport = StreamableHttpClientTransport::with_client(
http,
StreamableHttpClientTransportConfig::with_uri(format!("{url}/mcp")),
);
let client = ().serve(transport).await?;
let tools = client.list_all_tools().await?;
println!("Tools: {}", tools.len());
Ok(())
}
```
```swift
import Foundation
import MCP
@main
struct Auth {
static func main() async throws {
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let token = ProcessInfo.processInfo.environment["KUBEMQ_MCP_AUTH_TOKEN"] ?? ""
let transport = HTTPClientTransport(
endpoint: URL(string: "\(url)/mcp")!,
streaming: true,
requestModifier: { request in
var modified = request
modified.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return modified
}
)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let (tools, _) = try await client.listTools()
print("Tools: \(tools.count)")
}
}
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
const token = process.env.KUBEMQ_MCP_AUTH_TOKEN;
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`),
{
requestInit: {
headers: { Authorization: `Bearer ${token}` },
},
}
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const tools = await client.listTools();
console.log(`Tools: ${tools.tools.length}`);
await client.close();
```
The token is set on the **transport**, not on a single request, so it is sent with the
`initialize` handshake and every subsequent `tools/call` in the session. Set
`KUBEMQ_MCP_AUTH_TOKEN` in your environment rather than hard-coding the JWT in source.
## How auth failures are reported [#how-auth-failures-are-reported]
MCP is a JSON-RPC endpoint, so authentication errors come back as a JSON-RPC error
object — not an HTTP 401. The HTTP status stays `200`; the failure is encoded in the
`error` field with code `-32010`.
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32010,
"message": "Authentication failed"
}
}
```
| Condition | Result |
| -------------------------------------- | ----------------------------------------- |
| Auth disabled (default) | Request runs as the `anonymous` principal |
| Auth enabled, valid token | Request runs as the token's `ClientID` |
| Auth enabled, missing or invalid token | JSON-RPC error **`-32010`** |
`-32010` is distinct from the standard JSON-RPC codes (`-32700` … `-32603`) and from
tool-level errors, which return a normal result with `isError: true`. A `-32010` is an
**authentication** failure, never a tool failure. The full code list is in the
[error codes reference](/aiway/mcp/reference/error-codes).
## Origin validation [#origin-validation]
Beyond the shared HTTP server's CORS and origin middleware, MCP applies its **own**
origin check (`validateOrigin`) against `McpConfig.TrustedOrigins`. This guards the
endpoint against DNS-rebinding and cross-site requests from untrusted browser pages.
| `TrustedOrigins` value | Behavior |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `auto` (default) | Matches localhost variants — `localhost`, `127.0.0.1`, `::1`, `[::1]`, `0.0.0.0` — plus the server's bind address |
| `*` | Allows all origins |
| *(custom list)* | Allows exactly the listed origins |
An **empty `Origin` header** — which non-browser clients such as `curl`, the MCP SDKs,
and server-to-server calls send — is **always allowed**. Only browser callers, which set
an `Origin`, are screened. Configure the list with `CONNECTORSMCP_TRUSTED_ORIGINS` (see
[Configuration](/aiway/mcp/configuration)); for example, to allow a single web
app:
```bash title="env.sh"
export CONNECTORSMCP_TRUSTED_ORIGINS=https://app.example.com
```
A rejected origin is refused before the JSON-RPC method runs.
## Production recommendations [#production-recommendations]
Authentication is off by default for convenience; turn it on before the endpoint is
reachable from anywhere untrusted.
* **Enable authentication** in any non-local deployment and require a Bearer token on
every MCP call.
* **Use TLS** (`https://`) for all production MCP endpoints — see
[Auth & Security → TLS and mTLS](/connectors/reference/auth-and-security#tls-and-mtls).
* **Prefer short-lived, rotated tokens** to limit the blast radius of a leaked JWT.
* **Restrict `TrustedOrigins`** to the exact web origins that need browser access instead
of leaving `auto` or `*` in place.
* **Restrict network access** to the endpoint with firewall rules or Kubernetes network
policies as defense in depth.
## Related [#related]
# Channel Resolution (/aiway/mcp/guides/channel-resolution)
Every KubeMQ MCP tool acts on a **channel** — the addressable destination a message is
sent to or read from. This guide explains how channels are named, the five channel types
each tool family targets, the single reserved prefix you must avoid, and how a model
discovers channels at runtime before it acts.
## Overview [#overview]
A channel is just a string. There is no registry to provision and no create step: a
channel comes into existence the first time a tool references it, and disappears from
discovery when it no longer carries traffic. This makes the MCP surface
self-describing — a model can name a channel on the fly (`orders.us-west`), and the same
name resolves consistently across every tool that uses it.
Each tool argument named `channel` is resolved against one of the five KubeMQ messaging
patterns. The tool you call determines the pattern; the channel string determines the
destination within it.
## How tools map to channels [#how-tools-map-to-channels]
There is no separate "channel resolution" step in the protocol — the **tool name selects
the channel type**, and the `channel` argument names the destination. The model never has
to declare a type alongside a send; calling `queue_send` *is* the declaration that the
channel is a queue.
*The tool family selects the channel type; the `channel` argument names the destination.*
## Channel naming [#channel-naming]
* Channels are arbitrary strings — for example `my-app.orders`, `notifications`, or
`user-events`.
* Channels are **created implicitly on first use**. There is no explicit creation step.
* **Convention:** use dot-separated hierarchical names to organize a namespace, such as
`orders.us-west` or `events.user.signup`.
Because channels are created on demand, a typo creates a new (empty) channel rather than
raising an error. Use [`channel_list`](#discovering-channels) to confirm a channel exists
and carries traffic before relying on it.
## Channel types [#channel-types]
Each channel belongs to exactly one of five types — one per messaging pattern — and each
type is served by a specific set of tools:
| Type | Description | Associated tools |
| -------------- | ------------------------------------------ | ----------------------------------------------------------------------- |
| `queues` | Point-to-point durable queue channels | `queue_send`, `queue_receive`, `queue_peek` |
| `events` | Ephemeral fire-and-forget pub/sub channels | `events_publish` |
| `events_store` | Persistent pub/sub channels with replay | `events_store_publish`, `events_store_read`, `events_store_read_latest` |
| `commands` | Request/reply command channels | `command_send` |
| `queries` | Request/reply query channels | `query_send` |
The same string can name distinct channels under different types — `orders` as a queue and
`orders` as an events channel are unrelated. That is why `channel_info` requires both the
`channel` name **and** the `type`.
Only `queues` and `events_store` channels are backed by broker monitoring, so
`channel_list` and `channel_info` report live message statistics for them. The ephemeral
types (`events`, `commands`, `queries`) exist only while subscribers are connected and are
returned as type descriptions rather than per-channel stats.
## Reserved prefix [#reserved-prefix]
One prefix is reserved by the broker for the agent bridge and **cannot be targeted by the
direct messaging tools**.
| Prefix | Purpose | Restriction |
| ----------- | ----------------------------------- | -------------------------------------- |
| `_AGENTS_.` | Agent-bridge internal communication | Rejected by all direct messaging tools |
A channel is reserved when its name begins with the literal prefix `_AGENTS_.` (the
trailing dot is part of the prefix). Passing such a channel to `queue_send`,
`events_publish`, `events_store_publish`, `command_send`, or `query_send` returns a tool
error (`isError: true`) at the tool layer — the message is never published.
To reach an agent, use the agent-bridge tools `agent_send` and `agent_query` instead of
addressing `_AGENTS_.*` channels directly. The bridge manages the reserved channels on
your behalf — see [Agent-bridge tools](/aiway/mcp/tools/agent-bridge).
## Discovering channels [#discovering-channels]
Two read-only tools let a model explore the namespace at runtime instead of hard-coding
channel names: `channel_list` enumerates channels (optionally filtered), and
`channel_info` returns metadata for one channel.
### Listing and filtering [#listing-and-filtering]
`channel_list` returns every known channel, or a subset filtered by `type`, by a name
`pattern`, or both. The `pattern` filter is a **prefix match** — `"example-"` matches
`example-queue` and `example-events` but not `my-example`. With no arguments it returns
all channels.
```bash
# List all channels (no filter)
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "channel_list",
"arguments": {}
}
}'
# Filter by type: "arguments": { "type": "queues" }
# Filter by prefix: "arguments": { "pattern": "example-" }
```
```go
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "channel_list",
Arguments: map[string]any{},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```python
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# List all channels (no filter)
result = await session.call_tool("channel_list", {})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
# To filter by type, use: {"type": "queues"}
# To filter by pattern, use: {"pattern": "example-"}
```
```typescript
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
// List all channels (no filter)
const result = await client.callTool({
name: "channel_list",
arguments: {},
});
console.log(JSON.stringify(result, null, 2));
// To filter by type: { type: "queues" }
// To filter by pattern: { pattern: "example-" }
await client.close();
```
```java
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
// List all channels (no filter)
var result = client.callTool(new CallToolRequest(
"channel_list",
Map.of()
));
System.out.println(result);
// To filter by type: Map.of("type", "queues")
// To filter by pattern: Map.of("pattern", "example-")
client.closeGracefully();
```
```csharp
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("channel_list", new Dictionary());
Console.WriteLine($"Result: {result}");
```
```kotlin
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("channel_list", emptyMap())
println("Result: $result")
client.close()
httpClient.close()
```
```ruby
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("channel_list", {})
puts "Result: #{result}"
client.close
```
```rust
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("channel_list", json!({})).await?;
println!("Result: {result:#?}");
```
```swift
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("channel_list", arguments: [:])
print("Result: \(result)")
```
A successful `channel_list` returns a JSON array in the standard `content[]`/`isError`
envelope. An empty array (`[]`) is a normal result, not an error:
```json
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"content": [
{
"type": "text",
"text": "[{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true},{\"name\":\"example-events\",\"type\":\"events\",\"is_active\":true}]"
}
],
"isError": false
}
}
```
### Inspecting one channel [#inspecting-one-channel]
`channel_info` confirms a single channel's type and live state. Both the `channel` name and
its `type` are required — the type scopes the lookup to the correct messaging pattern.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "channel_info",
"arguments": {
"channel": "example-queue",
"type": "queues"
}
}
}'
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "channel_info",
Arguments: map[string]any{
"channel": "example-queue",
"type": "queues",
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```python
result = await session.call_tool("channel_info", {
"channel": "example-queue",
"type": "queues",
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```typescript
const result = await client.callTool({
name: "channel_info",
arguments: {
channel: "example-queue",
type: "queues",
},
});
console.log(JSON.stringify(result, null, 2));
```
```java
var result = client.callTool(new CallToolRequest(
"channel_info",
Map.of(
"channel", "example-queue",
"type", "queues"
)
));
System.out.println(result);
```
```csharp
var result = await client.CallToolAsync("channel_info", new Dictionary
{
["channel"] = "example-queue",
["type"] = "queues",
});
Console.WriteLine($"Result: {result}");
```
```kotlin
val result = client.callTool("channel_info", mapOf(
"channel" to "example-queue",
"type" to "queues"
))
println("Result: $result")
```
```ruby
result = client.call_tool("channel_info", {
"channel" => "example-queue",
"type" => "queues",
})
puts "Result: #{result}"
```
```rust
let result = client.call_tool("channel_info", json!({
"channel": "example-queue",
"type": "queues"
})).await?;
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("channel_info", arguments: [
"channel": "example-queue",
"type": "queues",
])
print("Result: \(result)")
```
For `queues` and `events_store` channels the result includes live `incoming`/`outgoing`
message counts; for the ephemeral types it returns the channel's type and active state:
```json
{
"jsonrpc": "2.0",
"id": 12,
"result": {
"content": [
{
"type": "text",
"text": "{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true,\"incoming\":5,\"outgoing\":3}"
}
],
"isError": false
}
}
```
## Resolution failures [#resolution-failures]
| Condition | Result |
| --------------------------------------------------------------- | ------------------------------------------ |
| Channel name begins with `_AGENTS_.` on a direct messaging tool | Tool error — `isError: true` |
| Invalid `type` value (not one of the five) | JSON-RPC `-32602` Invalid Params |
| `channel_info` missing `channel` or `type` | JSON-RPC `-32602` Invalid Params |
| `channel_list` with a pattern that matches nothing | Successful result with an empty array `[]` |
For the full JSON-RPC error catalog, see
[Error codes](/aiway/mcp/reference/error-codes).
## Related [#related]
# Client Setup (/aiway/mcp/guides/client-setup)
The MCP connector speaks the [Model Context Protocol](/aiway/mcp) (version `2025-11-25`) as JSON-RPC 2.0 over the **Streamable HTTP transport** at `POST /mcp`. Any compliant MCP client — Claude Desktop, an official MCP SDK, or a hand-rolled JSON-RPC caller — connects the same way: point it at the endpoint, run the `initialize` handshake, and start calling tools. This guide covers each path in depth. For the 5-minute quick start, see [Getting started](/aiway/mcp/getting-started).
## Overview [#overview]
Every MCP client connects to a single URL and goes through the same three-step lifecycle before any tool call:
1. **`initialize`** — negotiate the protocol version and open a session.
2. **`notifications/initialized`** — acknowledge the handshake.
3. **`tools/list` / `tools/call`** — discover and invoke tools.
The official SDKs and Claude Desktop perform steps 1 and 2 automatically and track the [session ID](/aiway/mcp/guides/session-management) for you. With raw `curl` you drive each step yourself.
## How it works [#how-it-works]
The Streamable HTTP transport carries every JSON-RPC message as a standalone `POST /mcp`. The server returns an `MCP-Session-Id` on `initialize`, and the client echoes it on all subsequent requests. A separate `GET /mcp` opens a stateless keepalive stream that emits a `: keepalive` comment every 30 seconds.
*One session, established once, carries every later request via the `MCP-Session-Id` header.*
## Claude Desktop [#claude-desktop]
Claude Desktop connects to KubeMQ over the HTTP transport. Add a server entry to `claude_desktop_config.json` (Claude Desktop → Settings → Developer → Edit Config), then restart Claude Desktop.
```json title="claude_desktop_config.json"
{
"mcpServers": {
"kubemq": {
"url": "http://localhost:9090/mcp"
}
}
}
```
On restart, Claude Desktop runs the `initialize` handshake, discovers all 15 KubeMQ [tools](/aiway/mcp/tools), and exposes them in the conversation. The `url` must include the `/mcp` path — it is the only required field for an unauthenticated server.
### Authenticated servers [#authenticated-servers]
If the [shared HTTP server](/connectors/concepts/shared-http-server) has JWT auth enabled, add an `Authorization` header so Claude Desktop sends a Bearer token with every request:
```json title="claude_desktop_config.json"
{
"mcpServers": {
"kubemq": {
"url": "https://kubemq.example.com/mcp",
"headers": {
"Authorization": "Bearer "
}
}
}
}
```
Use `https://` for any remote endpoint. See [Authentication](/aiway/mcp/guides/authentication) for how the connector validates the token and returns `-32010` on failure.
The MCP connector also validates the request `Origin` against `McpConfig.TrustedOrigins` (default `["auto"]`, which matches `localhost`, `127.0.0.1`, `::1`, `[::1]`, `0.0.0.0`, and the server's bind address). When connecting from a remote host or a custom origin, add it to the trusted-origins list in [Configuration](/aiway/mcp/configuration) or origin validation will reject the connection.
## Generic JSON-RPC client [#generic-json-rpc-client]
Any HTTP client can speak MCP directly — no SDK required. You manage the handshake, the `MCP-Session-Id` header, and request IDs yourself. This is the lowest-level path and mirrors exactly what an SDK does internally.
### Open a session with `initialize` [#open-a-session-with-initialize]
`POST` an `initialize` request announcing the protocol version (`2025-11-25`) and your client identity:
```bash title="terminal"
curl -i -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "my-agent", "version": "1.0.0" }
}
}'
```
Capture the session ID from `result._meta.sessionId` in the body — it is also returned in the `MCP-Session-Id` response header (use `-i` to see headers):
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": { "tools": {} },
"serverInfo": { "name": "kubemq", "version": "..." },
"_meta": { "sessionId": "" }
}
}
```
### Acknowledge with `notifications/initialized` [#acknowledge-with-notificationsinitialized]
Send the `notifications/initialized` notification, carrying the session ID. It has no `id`, so the server replies with HTTP `200 OK` and body `{"jsonrpc":"2.0","result":null,"id":null}`:
```bash title="terminal"
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: ' \
-d '{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}'
```
### Call tools with the session header [#call-tools-with-the-session-header]
Every later request — `tools/list`, `tools/call`, `ping` — includes the `MCP-Session-Id` header:
```bash title="terminal"
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: ' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": { "channel": "my-queue", "body": "Hello from MCP" }
}
}'
```
### Required headers [#required-headers]
| Header | Direction | When | Description |
| ---------------------- | --------- | ------------------ | ----------------------------------------------- |
| `Content-Type` | Request | Always | Must be `application/json`. |
| `MCP-Session-Id` | Request | After `initialize` | The session ID returned by the handshake. |
| `MCP-Session-Id` | Response | Always | Echoed back by the server. |
| `MCP-Protocol-Version` | Response | Always | The protocol version, `2025-11-25`. |
| `Authorization` | Request | When auth is on | `Bearer ` for authenticated servers. |
A stateless `GET /mcp` opens an SSE keepalive stream that emits a `: keepalive` comment every 30 seconds and carries no MCP messages — it only keeps an idle connection alive and closes when the client disconnects. See [Session management](/aiway/mcp/guides/session-management) for the full lifecycle and batching.
## Official MCP SDKs [#official-mcp-sdks]
Each official MCP SDK wraps the Streamable HTTP transport: it performs the `initialize` handshake, sends `notifications/initialized`, and tracks the session ID automatically — you construct the transport with the endpoint URL and call tools. All examples read `KUBEMQ_MCP_URL` (default `http://localhost:9090`) and append `/mcp`.
```bash
# Raw JSON-RPC: initialize, then call a tool with the returned session ID.
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: ' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": {
"channel": "example-queue",
"body": "Hello from MCP",
"metadata": "example-metadata",
"tags": { "env": "dev", "source": "mcp-example" }
}
}
}'
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "queue_send",
Arguments: map[string]any{
"channel": "example-queue",
"body": "Hello from Go MCP",
"metadata": "example-metadata",
"tags": map[string]any{"env": "dev", "source": "mcp-example"},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: queue_send")
fmt.Printf("Result: %+v\n", result)
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("queue_send", {
"channel": "example-queue",
"body": "Hello from Python MCP",
"metadata": "example-metadata",
"tags": {"env": "dev", "source": "mcp-example"},
})
print(f"Tool: queue_send")
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
if __name__ == "__main__":
asyncio.run(main())
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
async function main() {
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({
name: "queue_send",
arguments: {
channel: "example-queue",
body: "Hello from TypeScript MCP",
metadata: "example-metadata",
tags: { env: "dev", source: "mcp-example" },
},
});
console.log(JSON.stringify(result, null, 2));
await client.close();
}
main().catch(console.error);
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import java.util.Map;
public class QueueSend {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(new CallToolRequest(
"queue_send",
Map.of(
"channel", "example-queue",
"body", "Hello from Java MCP",
"metadata", "example-metadata",
"tags", Map.of("env", "dev", "source", "mcp-example")
)
));
System.out.println(result);
client.closeGracefully();
}
}
```
```csharp
using ModelContextProtocol.Client;
class QueueSend
{
static async Task Main(string[] args)
{
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("queue_send", new Dictionary
{
["channel"] = "example-queue",
["body"] = "Hello from C# MCP",
["metadata"] = "example-metadata",
["tags"] = new Dictionary { ["env"] = "dev", ["source"] = "mcp-example" },
});
Console.WriteLine($"Tool: queue_send");
Console.WriteLine($"Result: {result}");
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("queue_send", mapOf(
"channel" to "example-queue",
"body" to "Hello from Kotlin MCP",
"metadata" to "example-metadata",
"tags" to mapOf("env" to "dev", "source" to "mcp-example")
))
println("Tool: queue_send")
println("Result: $result")
client.close()
httpClient.close()
}
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("queue_send", {
"channel" => "example-queue",
"body" => "Hello from Ruby MCP",
"metadata" => "example-metadata",
"tags" => { "env" => "dev", "source" => "mcp-example" },
})
puts "Tool: queue_send"
puts "Result: #{result}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("queue_send", json!({
"channel": "example-queue",
"body": "Hello from Rust MCP",
"metadata": "example-metadata",
"tags": {"env": "dev", "source": "mcp-example"}
})).await?;
println!("Tool: queue_send");
println!("Result: {result:#?}");
Ok(())
}
```
```swift
import Foundation
import MCP
@main
struct QueueSend {
static func main() async throws {
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("queue_send", arguments: [
"channel": "example-queue",
"body": "Hello from Swift MCP",
"metadata": "example-metadata",
"tags": ["env": "dev", "source": "mcp-example"],
])
print("Tool: queue_send")
print("Result: \(result)")
}
}
```
For an authenticated server, pass the JWT through the SDK's transport options (an `Authorization: Bearer ` header) — the same mechanism Claude Desktop uses. The standard environment variables are `KUBEMQ_MCP_URL`, `KUBEMQ_MCP_TIMEOUT` (client-side HTTP timeout, default `30`s), and `KUBEMQ_MCP_AUTH_TOKEN`.
## Related [#related]
# Error Handling (/aiway/mcp/guides/error-handling)
A `tools/call` can fail in three structurally different ways, and each one shows up
in a different part of the response. A client that treats them as one bucket will
either crash on a recoverable failure or silently swallow a malformed request. This
guide shows how to detect each layer and branch on them in the right order; for the
exhaustive list of codes and messages, see the
[Error codes reference](/aiway/mcp/reference/error-codes).
## Overview [#overview]
When you call a tool over `/mcp`, the request passes through three checkpoints
before you get a result back:
1. **HTTP / auth layer** — the shared HTTP server validates the origin and the
`Authorization` header *before* any JSON-RPC is parsed. Failures here surface as
the JSON-RPC auth error `-32010` (the body still parses as JSON-RPC).
2. **JSON-RPC protocol layer** — the connector parses the envelope and resolves the
method. A malformed body, an unknown method, or bad params produce a JSON-RPC
`error` object **with no `result`**.
3. **Tool execution layer** — the envelope was valid and a tool ran, but the
operation failed (reserved channel, missing agent, timeout). The response is a
normal `result` carrying `isError: true`.
The decisive split is between layers 2 and 3: a **protocol error** means the tool
never ran, so retrying the same request fails identically — the fix is to correct
the request. A **tool error** means a valid request ran and failed for an
operational reason, which may be transient (a timeout) and worth retrying.
Every JSON-RPC response — success *or* error — comes back as **HTTP 200**. The
outcome is encoded in the JSON body, not the status line. Do not branch on the HTTP
status code for JSON-RPC calls.
## How it works [#how-it-works]
Branch on the layers in a fixed order so a recoverable tool failure is never
mistaken for a protocol failure, and vice versa.
*A `tools/call` clears three checkpoints; each failure surfaces in a different field, so inspect them in order.*
## Layer 1 — HTTP and auth [#layer-1--http-and-auth]
Authentication and origin checks run in the shared HTTP server, ahead of JSON-RPC
parsing. When JWT auth is enabled and the `Authorization: Bearer` header is missing
or invalid, the `/mcp` endpoint returns the JSON-RPC auth error `-32010` — the
response is still valid JSON-RPC, so you read it the same way as any other error
object:
```json title="Auth failure (-32010)"
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32010,
"message": "authentication failed"
}
}
```
Origin validation can also reject the request before a tool runs. Auth and origin
behavior are shared across all connectors and documented once in
[Auth & security](/connectors/reference/auth-and-security); the MCP-specific
`validateOrigin`/`TrustedOrigins` rules are covered in
[Authentication](/aiway/mcp/guides/authentication).
## Layer 2 — JSON-RPC protocol errors [#layer-2--json-rpc-protocol-errors]
Protocol errors mean the envelope itself was rejected: malformed JSON, the wrong
`Content-Type`, an unknown method, or invalid params. They appear in the `error`
field and there is **no `result`** field at all.
```json title="Protocol error (-32601)"
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32601,
"message": "Method not found"
}
}
```
| Code | Trigger |
| -------- | ---------------------------------------------------------------------------------------------- |
| `-32700` | Malformed JSON body, or wrong `Content-Type` (e.g. `text/plain` instead of `application/json`) |
| `-32600` | Empty `method` field, or `jsonrpc` is not `"2.0"` |
| `-32601` | Unknown method name (e.g. `tools/unknown`) |
| `-32602` | `params` is not an object, or required tool arguments are missing |
Retrying an identical request after a protocol error fails the same way — correct
the request instead of retrying. The full catalog, with response examples for each
code, lives in the [Error codes reference](/aiway/mcp/reference/error-codes).
## Layer 3 — tool execution errors [#layer-3--tool-execution-errors]
A tool error means the envelope was valid and a tool was invoked, but the operation
failed. The response is structurally a **success** — HTTP 200, a populated
`result` — with `isError: true` and a human-readable cause in
`result.content[].text`. A client that only checks the `error` field treats this as
a successful call, so always inspect `result.isError` as a second step.
```json title="Tool error (result.isError)"
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"content": [{ "type": "text", "text": "Command timed out: no subscriber on channel 'example-commands' within 10s" }],
"isError": true
}
}
```
Common tool-level failures and the tools that raise them:
| Failure | Affected tools |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| Reserved-channel rejection (channel starts with `_AGENTS_.`) | `queue_send`, `events_publish`, `events_store_publish`, `command_send`, `query_send` |
| Non-existent agent | `agent_info`, `agent_send`, `agent_query` |
| Timeout exceeded / no subscriber | `command_send`, `query_send`, `agent_send`, `agent_query` |
| Non-existent channel | `channel_info` |
Channels beginning with `_AGENTS_.` are reserved for the agent bridge, so any
publish or request tool targeting one is rejected at the tool layer. To reach an
agent, use the [agent-bridge tools](/aiway/mcp/tools/agent-bridge)
(`agent_send`, `agent_query`) instead.
## Handling timeouts [#handling-timeouts]
The synchronous tools — `command_send`, `query_send`, `agent_send`, `agent_query` —
block until a responder replies or the timeout elapses. When no responder is
listening, the call does **not** raise a protocol error; it returns a normal result
with `isError: true` and a timeout message. Detect a timeout by reading
`result.isError`, not by catching a transport exception.
* The per-call timeout is the `timeout_seconds` argument (default 10 for commands,
30 for queries, 60 for the agent-bridge tools), capped at **300**.
* The connector also enforces a server-side `ToolTimeoutSeconds` ceiling
(default **300**) — see [Configuration](/aiway/mcp/configuration).
* A timeout may be transient (the responder was briefly absent). It is one of the
few tool errors that is reasonable to **retry** with backoff — unlike a
reserved-channel or invalid-params error, which will fail identically.
## Detecting both layers in code [#detecting-both-layers-in-code]
Branch in a fixed order: check the `error` field first, then `result.isError`. The
example below calls `command_send` (which surfaces a timeout as a tool error) and
inspects the result. Each snippet assumes an initialized MCP session — for the
connect-and-initialize handshake, see
[Client setup](/aiway/mcp/guides/client-setup).
```bash
# The JSON-RPC envelope always returns HTTP 200. Inspect the body:
# .error -> protocol error (no .result)
# .result.isError -> tool execution error
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "command_send",
"arguments": {
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10
}
}
}'
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "command_send",
Arguments: map[string]any{
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
},
},
})
if err != nil {
// Layer 1/2: transport or JSON-RPC protocol error — the tool never ran.
log.Fatal(err)
}
// Layer 3: a valid call that failed at the tool layer (e.g. timeout).
if result.IsError {
fmt.Printf("tool error: %+v\n", result.Content)
return
}
fmt.Printf("Result: %+v\n", result)
```
```python
result = await session.call_tool("command_send", {
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
})
# Layer 3: tool execution error (timeout, reserved channel, ...).
# Layer 1/2 protocol errors raise an exception before reaching here.
if result.isError:
print(f"tool error: {result.content[0].text}")
else:
print(f"Result: {result.content[0].text}")
```
```typescript
const result = await client.callTool({
name: "command_send",
arguments: {
channel: "example-commands",
body: "do-work",
timeout_seconds: 10,
},
});
// Layer 3: inspect isError before trusting the content.
if (result.isError) {
console.error("tool error:", result.content);
} else {
console.log(JSON.stringify(result, null, 2));
}
```
```java
var result = client.callTool(new CallToolRequest(
"command_send",
Map.of(
"channel", "example-commands",
"body", "do-work",
"timeout_seconds", 10
)
));
// Layer 3: a valid call that failed at the tool layer.
if (Boolean.TRUE.equals(result.isError())) {
System.out.println("tool error: " + result.content());
} else {
System.out.println(result);
}
```
```csharp
var result = await client.CallToolAsync("command_send", new Dictionary
{
["channel"] = "example-commands",
["body"] = "do-work",
["timeout_seconds"] = 10,
});
// Layer 3: tool execution error (e.g. timeout).
if (result.IsError)
{
Console.WriteLine($"tool error: {result.Content}");
}
else
{
Console.WriteLine($"Result: {result}");
}
```
```kotlin
val result = client.callTool("command_send", mapOf(
"channel" to "example-commands",
"body" to "do-work",
"timeout_seconds" to 10
))
// Layer 3: inspect isError before using the content.
if (result.isError == true) {
println("tool error: ${result.content}")
} else {
println("Result: $result")
}
```
```ruby
result = client.call_tool("command_send", {
"channel" => "example-commands",
"body" => "do-work",
"timeout_seconds" => 10,
})
# Layer 3: tool execution error (e.g. timeout).
if result.is_error
puts "tool error: #{result.content}"
else
puts "Result: #{result}"
end
```
```rust
let result = client.call_tool("command_send", json!({
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10
})).await?;
// Layer 3: a valid call that failed at the tool layer.
if result.is_error.unwrap_or(false) {
eprintln!("tool error: {:#?}", result.content);
} else {
println!("Result: {result:#?}");
}
```
```swift
let result = try await client.callTool("command_send", arguments: [
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
])
// Layer 3: inspect isError before trusting the content.
if result.isError == true {
print("tool error: \(result.content)")
} else {
print("Result: \(result)")
}
```
A timeout is reported through `result.isError`, **not** as a JSON-RPC error and
**not** as a non-200 HTTP status. Code that only catches transport exceptions or
checks the status code will silently treat a timed-out command as a success.
## Best practices [#best-practices]
1. **Check `error` first, then `result.isError`.** A protocol error has no
`result`; a tool error is structurally a success with `isError: true`.
2. **Do not branch on the HTTP status for JSON-RPC calls** — every response is
HTTP 200 (auth/origin rejections excepted, which return `-32010` in the body).
3. **Retry only transient tool errors** (timeouts, no-subscriber). Protocol errors
and reserved-channel/invalid-params errors fail identically on retry — fix the
request instead.
4. **Log the full response** when debugging, not just the field you branched on.
## Related [#related]
# Session Management (/aiway/mcp/guides/session-management)
Every MCP interaction with the KubeMQ connector runs inside a **session**. The session is opened by an `initialize` handshake, identified by an `MCP-Session-Id` header, and reused across every tool call until the client disconnects.
## Overview [#overview]
The MCP connector speaks JSON-RPC 2.0 over the shared HTTP server on port 9090. A single endpoint — `POST /mcp` — handles `initialize`, `tools/list`, `tools/call`, and `ping`. A companion `GET /mcp` endpoint provides an SSE keepalive stream.
A session is **server-managed**: the server issues a session ID during `initialize`, and the client echoes it on every subsequent request. Multiple tool calls share one session, and each session keeps its own request context. You rarely build the handshake by hand — every official MCP SDK performs it for you when you connect. This page shows both: the raw protocol so you understand what travels on the wire, and the SDK call that establishes the session for you.
The session model is part of the [shared HTTP server](/connectors/concepts/shared-http-server). For who may open a session and how auth applies, see [Auth & security](/connectors/reference/auth-and-security).
## How it works [#how-it-works]
The handshake is three steps: the client sends `initialize`, the server returns its capabilities plus a session ID, and the client acknowledges with a `notifications/initialized` notification. After that, every request carries the `MCP-Session-Id` header.
*The initialize handshake opens a session; the session ID is replayed on each later request.*
## The handshake [#the-handshake]
### Step 1 — initialize [#step-1--initialize]
Send an `initialize` request with the protocol version, your capabilities, and `clientInfo`:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "my-agent", "version": "1.0.0" }
}
}
```
### Step 2 — receive the session ID [#step-2--receive-the-session-id]
The server responds with its `protocolVersion`, `capabilities`, `serverInfo`, and the session ID under `result._meta.sessionId`:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "kubemq", "version": "" },
"_meta": { "sessionId": "abc123-def456" }
}
}
```
The session ID is also returned in the `MCP-Session-Id` response header, alongside `MCP-Protocol-Version: 2025-11-25`.
### Step 3 — send the initialized notification [#step-3--send-the-initialized-notification]
Acknowledge with a `notifications/initialized` notification. A notification has **no `id` field** and the server returns **HTTP 200** with body `{"jsonrpc":"2.0","result":null,"id":null}`:
```json
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
```
## Establishing a session [#establishing-a-session]
Below, `curl` walks the raw three-step handshake; the SDK tabs perform the same handshake transparently when you connect, then reuse the session for every tool call.
```bash
# 1. initialize — capture the session ID from the MCP-Session-Id response header
curl -i -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0.0"}}}'
# 2. acknowledge — replay the session ID; server returns 200 with {"jsonrpc":"2.0","result":null,"id":null}
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: abc123-def456' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. call a tool inside the session
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: abc123-def456' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"queue_send","arguments":{"channel":"example-queue","body":"Hello"}}}'
```
```csharp
using ModelContextProtocol.Client;
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
// CreateAsync runs the initialize handshake and holds the session for reuse.
await using var client = await McpClientFactory.CreateAsync(transport);
// Every later call rides the same session.
var result = await client.CallToolAsync("queue_send", new Dictionary
{
["channel"] = "example-queue",
["body"] = "Hello from C# MCP",
});
Console.WriteLine($"Result: {result}");
```
```go
package main
import (
"context"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
// Start performs the initialize handshake and binds the session.
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
// The same session is reused for every CallTool.
_, err = c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "queue_send",
Arguments: map[string]any{"channel": "example-queue", "body": "Hello from Go MCP"},
},
})
if err != nil {
log.Fatal(err)
}
}
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import java.util.Map;
public class Session {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
// initialize() performs the handshake and opens the session.
client.initialize();
// The client reuses the session for each call.
var result = client.callTool(new CallToolRequest(
"queue_send",
Map.of("channel", "example-queue", "body", "Hello from Java MCP")
));
System.out.println(result);
client.closeGracefully();
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin", version = "1.0.0"))
// connect() runs the initialize handshake and holds the session.
client.connect(transport)
client.callTool("queue_send", mapOf("channel" to "example-queue", "body" to "Hello from Kotlin MCP"))
client.close()
httpClient.close()
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
# initialize() performs the 3-step handshake and stores the session ID.
await session.initialize()
# Every call_tool on this session replays the same MCP-Session-Id.
result = await session.call_tool("queue_send", {
"channel": "example-queue",
"body": "Hello from Python MCP",
})
print(f"IsError: {result.isError}")
if __name__ == "__main__":
asyncio.run(main())
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby",
version: "1.0.0"
)
# initialize_handshake performs the handshake and opens the session.
client.initialize_handshake
# The session is reused for each call_tool.
result = client.call_tool("queue_send", {
"channel" => "example-queue",
"body" => "Hello from Ruby MCP",
})
puts "Result: #{result}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
// serve() runs the initialize handshake and binds the session.
let client = ().serve(transport).await?;
// The client reuses the session for each call.
let _ = client.call_tool("queue_send", json!({
"channel": "example-queue",
"body": "Hello from Rust MCP"
})).await?;
Ok(())
}
```
```swift
import Foundation
import MCP
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift", version: "1.0.0")
// connect() performs the initialize handshake and opens the session.
try await client.connect(transport: transport)
// The session is reused for each callTool.
let result = try await client.callTool("queue_send", arguments: [
"channel": "example-queue",
"body": "Hello from Swift MCP",
])
print("Result: \(result)")
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
const transport = new StreamableHTTPClientTransport(new URL(`${KUBEMQ_MCP_URL}/mcp`));
const client = new Client({ name: "kubemq-mcp-ts", version: "1.0.0" });
// connect() runs the initialize handshake and holds the session for reuse.
await client.connect(transport);
// Every callTool on this client rides the same session.
const result = await client.callTool({
name: "queue_send",
arguments: { channel: "example-queue", body: "Hello from TypeScript MCP" },
});
console.log(JSON.stringify(result, null, 2));
await client.close();
```
## Session headers [#session-headers]
| Header | Direction | Required | Description |
| ---------------------- | --------- | ------------------ | ------------------------------------------------- |
| `Content-Type` | Request | Always | Must be `application/json` |
| `MCP-Session-Id` | Request | After `initialize` | Session identifier from the `initialize` response |
| `MCP-Session-Id` | Response | Always | Echoed back by the server |
| `MCP-Protocol-Version` | Response | Always | Protocol version `2025-11-25` |
## Session lifecycle [#session-lifecycle]
* Sessions are **server-managed** — the server mints the session ID during `initialize`.
* **Multiple tool calls share one session**; each maintains its own request context.
* A session persists until the client disconnects or a server-side inactivity timeout occurs.
* Reusing the connection (and the `MCP-Session-Id`) avoids re-running the handshake on every call.
## Batch requests [#batch-requests]
`POST /mcp` accepts a **JSON array** of JSON-RPC requests and processes each one **sequentially**. Requests carrying an `id` produce a response entry; notifications (no `id`) are executed but produce no entry. Send the batch with the same `MCP-Session-Id` as any single request.
```bash
# Two tool calls in a single batched POST — one response entry per id.
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Session-Id: abc123-def456' \
-d '[
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"queue_send","arguments":{"channel":"a","body":"one"}}},
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"queue_send","arguments":{"channel":"b","body":"two"}}}
]'
```
```python
# SDKs typically issue calls individually over one session rather than
# constructing a raw JSON-RPC array. The session is reused for each call:
await session.call_tool("queue_send", {"channel": "a", "body": "one"})
await session.call_tool("queue_send", {"channel": "b", "body": "two"})
```
```typescript
// SDKs reuse the open session per call; the connector batches at the HTTP
// layer when a raw JSON-RPC array is posted.
await client.callTool({ name: "queue_send", arguments: { channel: "a", body: "one" } });
await client.callTool({ name: "queue_send", arguments: { channel: "b", body: "two" } });
```
## Keepalive stream — GET /mcp [#keepalive-stream--get-mcp]
`GET /mcp` opens an SSE stream that emits a `: keepalive` comment every **30 seconds**. It is a **stateless keepalive only** — no MCP messages travel over it, and the stream closes when the client disconnects. Use it to hold a long-lived connection open through intermediaries; all real work still goes through `POST /mcp`.
```bash
# Hold an SSE keepalive open (a ": keepalive" comment arrives every 30s)
curl -N http://localhost:9090/mcp
```
## Related [#related]
# Agent-bridge tools (/aiway/mcp/tools/agent-bridge)
The four **agent-bridge tools** turn an MCP client into an [A2A](/aiway/a2a)
caller: it can list registered agents, read an agent's card, and send messages or
forward JSON-RPC methods to an agent — all without leaving the Model Context Protocol.
## Overview [#overview]
The MCP connector exposes 15 tools. Eleven are core messaging tools that always
reach the broker directly. The remaining four — `agent_list`, `agent_info`,
`agent_send`, and `agent_query` — are **bridge tools**: they forward to the A2A
[agent registry](/aiway/a2a) instead of to a messaging channel.
| Tool | Purpose | Required arguments | Optional arguments |
| ------------- | ------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- |
| `agent_list` | List registered agents | (none) | `skill_tags` (array of strings) |
| `agent_info` | Get an agent's details | `agent_id` | (none) |
| `agent_send` | Send a message to an agent via A2A | `agent_id`, `message` | `blocking` (default `true`), `timeout_seconds` (default `60`, max `300`), `context_id` |
| `agent_query` | Forward a JSON-RPC method to an agent | `agent_id`, `method` | `params` (object), `timeout_seconds` (default `60`, max `300`) |
The bridge tools appear in `tools/list` **only when the A2A agent registry is
injected** into the MCP connector. If the A2A connector is not running, an MCP
client sees only the 11 core tools. See [Tools overview](/aiway/mcp/tools).
## How it works [#how-it-works]
`agent_send` builds an A2A `message/send` JSON-RPC envelope and forwards it over a
Query to `_AGENTS_.agents/`, where the agent's
[virtual subscriber](/aiway/a2a) delivers it as an HTTP POST. `agent_query`
forwards an arbitrary JSON-RPC method to the same destination. Both add the gateway
timeout buffer on top of the caller's `timeout_seconds`.
*Bridge tools route discovery to the registry and messages to the agent's internal channel, which a virtual subscriber delivers over HTTP.*
## agent\_list [#agent_list]
Lists every registered agent. Pass `skill_tags` to filter agents whose
[agent card](/aiway/a2a/agent-cards) advertises matching skill tags.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 13,
"method": "tools/call",
"params": {
"name": "agent_list",
"arguments": {}
}
}'
```
```go
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "agent_list",
Arguments: map[string]any{},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: agent_list")
fmt.Printf("Result: %+v\n", result)
```
```python
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# List all agents (no filter)
result = await session.call_tool("agent_list", {})
print(f"Tool: agent_list")
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
# To filter by skill tags, use: {"skill_tags": ["echo"]}
```
```typescript
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
// List all agents (no filter)
const result = await client.callTool({
name: "agent_list",
arguments: {},
});
console.log(JSON.stringify(result, null, 2));
// To filter by skill tags: { skill_tags: ["echo"] }
await client.close();
```
```java
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
// List all agents (no filter)
var result = client.callTool(new CallToolRequest(
"agent_list",
Map.of()
));
System.out.println(result);
// To filter by skill tags: Map.of("skill_tags", List.of("echo"))
client.closeGracefully();
```
```csharp
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("agent_list", new Dictionary());
Console.WriteLine($"Tool: agent_list");
Console.WriteLine($"Result: {result}");
```
```kotlin
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("agent_list", emptyMap())
println("Tool: agent_list")
println("Result: $result")
client.close()
httpClient.close()
```
```ruby
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("agent_list", {})
puts "Tool: agent_list"
puts "Result: #{result}"
client.close
```
```rust
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("agent_list", json!({})).await?;
println!("Tool: agent_list");
println!("Result: {result:#?}");
```
```swift
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("agent_list", arguments: [:])
print("Tool: agent_list")
print("Result: \(result)")
```
The result text is a JSON array of agent summaries:
```json
{
"jsonrpc": "2.0",
"id": 13,
"result": {
"content": [{ "type": "text", "text": "[{\"agent_id\":\"echo-01\",\"name\":\"Echo Agent 01\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\",\"tags\":[\"test\",\"echo\"]}]}]" }],
"isError": false
}
}
```
## agent\_info [#agent_info]
Returns the full [agent card](/aiway/a2a/agent-cards) for one agent —
name, description, version, URL, and skills.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 14,
"method": "tools/call",
"params": {
"name": "agent_info",
"arguments": { "agent_id": "example-agent" }
}
}'
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "agent_info",
Arguments: map[string]any{
"agent_id": "example-agent",
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: agent_info")
fmt.Printf("Result: %+v\n", result)
```
```python
result = await session.call_tool("agent_info", {
"agent_id": "example-agent",
})
print(f"Tool: agent_info")
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```typescript
const result = await client.callTool({
name: "agent_info",
arguments: {
agent_id: "example-agent",
},
});
console.log(JSON.stringify(result, null, 2));
```
```java
var result = client.callTool(new CallToolRequest(
"agent_info",
Map.of("agent_id", "example-agent")
));
System.out.println(result);
```
```csharp
var result = await client.CallToolAsync("agent_info", new Dictionary
{
["agent_id"] = "example-agent",
});
Console.WriteLine($"Tool: agent_info");
Console.WriteLine($"Result: {result}");
```
```kotlin
val result = client.callTool("agent_info", mapOf(
"agent_id" to "example-agent"
))
println("Tool: agent_info")
println("Result: $result")
```
```ruby
result = client.call_tool("agent_info", {
"agent_id" => "example-agent",
})
puts "Tool: agent_info"
puts "Result: #{result}"
```
```rust
let result = client.call_tool("agent_info", json!({
"agent_id": "example-agent"
})).await?;
println!("Tool: agent_info");
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("agent_info", arguments: [
"agent_id": "example-agent",
])
print("Tool: agent_info")
print("Result: \(result)")
```
A successful call returns the agent card as a JSON string; an unknown `agent_id`
returns `isError: true` with `Agent 'example-agent' not found`:
```json
{
"jsonrpc": "2.0",
"id": 14,
"result": {
"content": [{ "type": "text", "text": "{\"agent_id\":\"example-agent\",\"name\":\"Example Agent\",\"description\":\"example agent\",\"version\":\"1.0.0\"}" }],
"isError": false
}
}
```
## agent\_send [#agent_send]
Sends a message to an agent. The bridge wraps it in an A2A `message/send` envelope.
By default the call is **blocking** — it waits up to `timeout_seconds` for the
agent's reply. Pass `blocking: false` for fire-and-forget, or `context_id` to thread
the message into an existing conversation.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 15,
"method": "tools/call",
"params": {
"name": "agent_send",
"arguments": { "agent_id": "example-agent", "message": "hello from MCP" }
}
}'
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "agent_send",
Arguments: map[string]any{
"agent_id": "example-agent",
"message": "hello",
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: agent_send")
fmt.Printf("Result: %+v\n", result)
```
```python
result = await session.call_tool("agent_send", {
"agent_id": "example-agent",
"message": "hello from MCP",
})
print(f"Tool: agent_send")
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```typescript
const result = await client.callTool({
name: "agent_send",
arguments: {
agent_id: "example-agent",
message: "hello from MCP",
},
});
console.log(JSON.stringify(result, null, 2));
```
```java
var result = client.callTool(new CallToolRequest(
"agent_send",
Map.of(
"agent_id", "example-agent",
"message", "hello from MCP"
)
));
System.out.println(result);
```
```csharp
var result = await client.CallToolAsync("agent_send", new Dictionary
{
["agent_id"] = "example-agent",
["message"] = "hello",
});
Console.WriteLine($"Tool: agent_send");
Console.WriteLine($"Result: {result}");
```
```kotlin
val result = client.callTool("agent_send", mapOf(
"agent_id" to "example-agent",
"message" to "hello"
))
println("Tool: agent_send")
println("Result: $result")
```
```ruby
result = client.call_tool("agent_send", {
"agent_id" => "example-agent",
"message" => "hello from MCP",
})
puts "Tool: agent_send"
puts "Result: #{result}"
```
```rust
let result = client.call_tool("agent_send", json!({
"agent_id": "example-agent",
"message": "hello from MCP"
})).await?;
println!("Tool: agent_send");
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("agent_send", arguments: [
"agent_id": "example-agent",
"message": "hello",
])
print("Tool: agent_send")
print("Result: \(result)")
```
The agent's reply is returned in the `content` text. If the agent is not registered,
the call returns `isError: true`:
```json
{
"jsonrpc": "2.0",
"id": 15,
"result": {
"content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"message/send\",\"params\":{\"message\":\"hello from MCP\"}},\"received_headers\":{}}" }],
"isError": false
}
}
```
## agent\_query [#agent_query]
Forwards an arbitrary JSON-RPC `method` to an agent — useful for A2A methods beyond
`message/send`, such as `tasks/get`. Pass a `params` object to supply method
arguments.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 16,
"method": "tools/call",
"params": {
"name": "agent_query",
"arguments": { "agent_id": "example-agent", "method": "tasks/get" }
}
}'
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "agent_query",
Arguments: map[string]any{
"agent_id": "example-agent",
"method": "tasks/get",
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: agent_query")
fmt.Printf("Result: %+v\n", result)
```
```python
result = await session.call_tool("agent_query", {
"agent_id": "example-agent",
"method": "tasks/get",
})
print(f"Tool: agent_query")
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```typescript
const result = await client.callTool({
name: "agent_query",
arguments: {
agent_id: "example-agent",
method: "tasks/get",
},
});
console.log(JSON.stringify(result, null, 2));
```
```java
var result = client.callTool(new CallToolRequest(
"agent_query",
Map.of(
"agent_id", "example-agent",
"method", "tasks/get"
)
));
System.out.println(result);
```
```csharp
var result = await client.CallToolAsync("agent_query", new Dictionary
{
["agent_id"] = "example-agent",
["method"] = "tasks/get",
});
Console.WriteLine($"Tool: agent_query");
Console.WriteLine($"Result: {result}");
```
```kotlin
val result = client.callTool("agent_query", mapOf(
"agent_id" to "example-agent",
"method" to "tasks/get"
))
println("Tool: agent_query")
println("Result: $result")
```
```ruby
result = client.call_tool("agent_query", {
"agent_id" => "example-agent",
"method" => "tasks/get",
})
puts "Tool: agent_query"
puts "Result: #{result}"
```
```rust
let result = client.call_tool("agent_query", json!({
"agent_id": "example-agent",
"method": "tasks/get"
})).await?;
println!("Tool: agent_query");
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("agent_query", arguments: [
"agent_id": "example-agent",
"method": "tasks/get",
])
print("Tool: agent_query")
print("Result: \(result)")
```
```json
{
"jsonrpc": "2.0",
"id": 16,
"result": {
"content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"tasks/get\",\"params\":{}},\"received_headers\":{}}" }],
"isError": false
}
}
```
## Errors [#errors]
A missing or unknown `agent_id` returns a tool-level error — `isError: true` with the
message in the `content` block — not a JSON-RPC protocol error:
```json
{
"jsonrpc": "2.0",
"id": 15,
"result": {
"content": [{ "type": "text", "text": "Agent 'example-agent' not found" }],
"isError": true
}
}
```
The bridge applies the gateway timeout buffer on top of the caller's
`timeout_seconds` (default `60`, max `300`). See
[Error handling](/aiway/mcp/guides/error-handling) for the three failure
layers and [Error codes](/aiway/mcp/reference/error-codes) for the catalog.
## Related [#related]
# Channel Management Tools (/aiway/mcp/tools/channel-management)
The channel-management tools let an AI model **discover what messaging surfaces exist**
on a KubeMQ server before it sends, publishes, or queries anything. `channel_list`
enumerates channels (optionally filtered), and `channel_info` returns live metadata for
one channel.
## Overview [#overview]
Most MCP tools act on a channel you already know — `queue_send` needs a queue name,
`events_publish` needs an events channel. The two **read-only** channel-management tools
close that gap: they let the model explore the broker's namespace and confirm a channel's
type and activity before acting on it.
| Tool | Purpose | Required arguments |
| -------------- | ---------------------------------------------------------- | ------------------ |
| `channel_list` | List channels, optionally filtered by type or name pattern | *(none)* |
| `channel_info` | Return metadata for one specific channel | `channel`, `type` |
A **channel type** is one of `queues`, `events`, `events_store`, `commands`, or
`queries` — the five KubeMQ messaging patterns. Both tools accept the type to scope the
lookup. Reserved channels (those under the `_AGENTS_.` prefix used by the agent bridge)
are internal and are not addressable through these tools.
Both tools are **read-only discovery operations** — they never create, delete, or modify
a channel. Channels in KubeMQ are created implicitly on first use, so `channel_list`
reflects channels that already carry traffic.
## channel\_list [#channel_list]
List all channels on the server, or narrow the result by channel `type`, by a name
`pattern`, or both. With no arguments it returns every known channel.
### Input schema [#input-schema]
```json
{
"type": "object",
"required": [],
"properties": {
"type": {
"type": "string",
"description": "Filter by channel type (queues, events, events_store, commands, queries)."
},
"pattern": {
"type": "string",
"description": "Filter by channel name pattern or prefix."
}
}
}
```
| Argument | Type | Required | Default | Description |
| --------- | ------ | -------- | ------- | --------------------------------------------------------------------------------- |
| `type` | string | no | — | Filter by channel type: `queues`, `events`, `events_store`, `commands`, `queries` |
| `pattern` | string | no | — | Filter by channel name pattern or prefix |
### Output [#output]
The tool result wraps a JSON **array** of channel summaries in the standard MCP
`content[]`/`isError` envelope. Each element carries the channel `name`, `type`, and
`is_active` flag.
```json
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"content": [
{
"type": "text",
"text": "[{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true},{\"name\":\"example-events\",\"type\":\"events\",\"is_active\":true}]"
}
],
"isError": false
}
}
```
An empty list (`[]`) is a normal successful result, not an error.
### Usage [#usage]
```bash
# List all channels (no filter)
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "channel_list",
"arguments": {}
}
}'
# Filter by type
# "arguments": { "type": "queues" }
# Filter by name pattern
# "arguments": { "pattern": "example-" }
```
```go
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "channel_list",
Arguments: map[string]any{},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```python
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# List all channels (no filter)
result = await session.call_tool("channel_list", {})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
# To filter by type, use: {"type": "queues"}
# To filter by pattern, use: {"pattern": "example-"}
```
```typescript
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
// List all channels (no filter)
const result = await client.callTool({
name: "channel_list",
arguments: {},
});
console.log(JSON.stringify(result, null, 2));
// To filter by type: { type: "queues" }
// To filter by pattern: { pattern: "example-" }
await client.close();
```
```java
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
// List all channels (no filter)
var result = client.callTool(new CallToolRequest(
"channel_list",
Map.of()
));
System.out.println(result);
// To filter by type: Map.of("type", "queues")
// To filter by pattern: Map.of("pattern", "example-")
client.closeGracefully();
```
```csharp
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("channel_list", new Dictionary());
Console.WriteLine($"Result: {result}");
```
```kotlin
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("channel_list", emptyMap())
println("Result: $result")
client.close()
httpClient.close()
```
```ruby
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("channel_list", {})
puts "Result: #{result}"
client.close
```
```rust
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("channel_list", json!({})).await?;
println!("Result: {result:#?}");
```
```swift
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("channel_list", arguments: [:])
print("Result: \(result)")
```
## channel\_info [#channel_info]
Return metadata for a single channel. Both `channel` (the name) and `type` are required —
the type scopes the lookup to the right messaging pattern.
### Input schema [#input-schema-1]
```json
{
"type": "object",
"required": ["channel", "type"],
"properties": {
"channel": {
"type": "string",
"description": "Channel name to get information for."
},
"type": {
"type": "string",
"description": "Channel type (queues, events, events_store, commands, queries)."
}
}
}
```
| Argument | Type | Required | Default | Description |
| --------- | ------ | -------- | ------- | ----------------------------------------------------------------------- |
| `channel` | string | yes | — | Channel name to inspect |
| `type` | string | yes | — | Channel type: `queues`, `events`, `events_store`, `commands`, `queries` |
### Output [#output-1]
The result wraps a single JSON **object** describing the channel. Alongside `name`,
`type`, and `is_active`, queue-style channels report live `incoming`/`outgoing` message
counts.
```json
{
"jsonrpc": "2.0",
"id": 12,
"result": {
"content": [
{
"type": "text",
"text": "{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true,\"incoming\":5,\"outgoing\":3}"
}
],
"isError": false
}
}
```
Requesting a channel that does not exist returns a tool error (`isError: true`) rather
than a transport-level failure.
### Usage [#usage-1]
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "channel_info",
"arguments": {
"channel": "example-queue",
"type": "queues"
}
}
}'
```
```go
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "channel_info",
Arguments: map[string]any{
"channel": "example-queue",
"type": "queues",
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```python
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("channel_info", {
"channel": "example-queue",
"type": "queues",
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```typescript
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({
name: "channel_info",
arguments: {
channel: "example-queue",
type: "queues",
},
});
console.log(JSON.stringify(result, null, 2));
await client.close();
```
```java
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(new CallToolRequest(
"channel_info",
Map.of(
"channel", "example-queue",
"type", "queues"
)
));
System.out.println(result);
client.closeGracefully();
```
```csharp
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("channel_info", new Dictionary
{
["channel"] = "example-queue",
["type"] = "queues",
});
Console.WriteLine($"Result: {result}");
```
```kotlin
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("channel_info", mapOf(
"channel" to "example-queue",
"type" to "queues"
))
println("Result: $result")
client.close()
httpClient.close()
```
```ruby
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("channel_info", {
"channel" => "example-queue",
"type" => "queues",
})
puts "Result: #{result}"
client.close
```
```rust
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("channel_info", json!({
"channel": "example-queue",
"type": "queues"
})).await?;
println!("Result: {result:#?}");
```
```swift
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("channel_info", arguments: [
"channel": "example-queue",
"type": "queues",
])
print("Result: \(result)")
```
## Error handling [#error-handling]
| Condition | Result |
| ---------------------------------------- | ------------------------------------------ |
| Invalid or malformed arguments | JSON-RPC `-32602` Invalid Params |
| `channel_info` on a non-existent channel | Tool error — `isError: true` in the result |
| `channel_list` with no matches | Successful result with an empty array `[]` |
`channel_list` has no tool-specific failures: an empty list is a normal, successful
response. For the full JSON-RPC error catalog, see
[Error codes](/aiway/mcp/reference/error-codes).
## Related [#related]
# Command & Query Tools (/aiway/mcp/tools/commands-queries)
The `command_send` and `query_send` tools let an MCP client make **synchronous
request/reply** calls into KubeMQ. A command triggers an action and waits for an
acknowledgement; a query asks for data and waits for a response payload.
## Overview [#overview]
Both tools are part of the [11 core messaging tools](/aiway/mcp/tools) —
they are always available at `/mcp`, no agent registry required. Each maps to one
KubeMQ Request/Reply operation:
* **`command_send`** — sends a command to a channel and blocks until a responder
acknowledges it (success or error). Use it for *mutating* actions where you only
need to know whether the work was accepted.
* **`query_send`** — sends a query to a channel and blocks until a responder returns
a payload. Use it for *read* operations that produce data the model needs back.
Both are synchronous: the connector holds the `tools/call` open until the responder
replies or the timeout elapses. They require an **active subscriber** on the target
channel — with no responder, the call times out and returns an error result.
## How it works [#how-it-works]
The connector translates the tool call into a native KubeMQ command or query, routes
it to a responder over the [Array](/connectors), waits for the reply, and hands
the result back inside the `tools/call` response.
*Both tools hold the `tools/call` open for the round trip; a query returns data, a command returns an acknowledgement.*
## Input schema [#input-schema]
Both tools share the same core arguments. The only difference is the default timeout.
| Argument | Type | Required | Default | Description |
| ----------------- | ------- | -------- | ----------------------------- | ------------------------------------------------------------------ |
| `channel` | string | yes | — | Target channel. Cannot start with the reserved `_AGENTS_.` prefix. |
| `body` | string | yes | — | Request payload sent to the responder. |
| `metadata` | string | no | — | Optional metadata string carried alongside the body. |
| `tags` | object | no | — | Optional string key/value tags attached to the request. |
| `timeout_seconds` | integer | no | `10` (command) · `30` (query) | Seconds to wait for a reply. Maximum **300**. |
`timeout_seconds` is capped at **300** for both tools. The connector also adds a
small gateway buffer on top of the caller-specified timeout. See
[Configuration](/aiway/mcp/configuration) for `ToolTimeoutSeconds`.
## Output schema [#output-schema]
Both tools return the standard `tools/call` result with a `content` array of `text`
blocks. `command_send` returns an acknowledgement string; `query_send` returns the
responder's payload (often JSON) as text.
A successful command:
```json
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"content": [{ "type": "text", "text": "Command executed successfully on channel 'example-commands'" }],
"isError": false
}
}
```
A successful query:
```json
{
"jsonrpc": "2.0",
"id": 10,
"result": {
"content": [{ "type": "text", "text": "{\"data\":\"query response payload from subscriber\"}" }],
"isError": false
}
}
```
When no responder is listening, the call returns `isError: true` with a timeout
message — the JSON-RPC envelope itself still succeeds:
```json
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"content": [{ "type": "text", "text": "Command timed out: no subscriber on channel 'example-commands' within 10s" }],
"isError": true
}
}
```
A timeout is reported through `isError`, not as a JSON-RPC error. See
[Error handling](/aiway/mcp/guides/error-handling) for the three failure
layers.
## command\_send [#command_send]
Send a command and wait for an acknowledgement.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "command_send",
"arguments": {
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
"metadata": "cmd-meta",
"tags": { "action": "process" }
}
}
}'
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "command_send",
Arguments: map[string]any{
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
"metadata": "cmd-meta",
"tags": map[string]any{"action": "process"},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: command_send")
fmt.Printf("Result: %+v\n", result)
```
```python
result = await session.call_tool("command_send", {
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
"metadata": "cmd-meta",
"tags": {"action": "process"},
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```typescript
const result = await client.callTool({
name: "command_send",
arguments: {
channel: "example-commands",
body: "do-work",
timeout_seconds: 10,
metadata: "cmd-meta",
tags: { action: "process" },
},
});
console.log(JSON.stringify(result, null, 2));
```
```java
var result = client.callTool(new CallToolRequest(
"command_send",
Map.of(
"channel", "example-commands",
"body", "do-work",
"timeout_seconds", 10,
"metadata", "cmd-meta",
"tags", Map.of("action", "process")
)
));
System.out.println(result);
```
```csharp
var result = await client.CallToolAsync("command_send", new Dictionary
{
["channel"] = "example-commands",
["body"] = "do-work",
["timeout_seconds"] = 10,
["metadata"] = "cmd-meta",
["tags"] = new Dictionary { ["action"] = "process" },
});
Console.WriteLine($"Result: {result}");
```
```kotlin
val result = client.callTool("command_send", mapOf(
"channel" to "example-commands",
"body" to "do-work",
"timeout_seconds" to 10
))
println("Result: $result")
```
```ruby
result = client.call_tool("command_send", {
"channel" => "example-commands",
"body" => "do-work",
"timeout_seconds" => 10,
})
puts "Result: #{result}"
```
```rust
let result = client.call_tool("command_send", json!({
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
"metadata": "cmd-meta",
"tags": {"action": "process"}
})).await?;
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("command_send", arguments: [
"channel": "example-commands",
"body": "do-work",
"timeout_seconds": 10,
])
print("Result: \(result)")
```
## query\_send [#query_send]
Send a query and wait for a response payload.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "query_send",
"arguments": {
"channel": "example-queries",
"body": "get-data",
"timeout_seconds": 30,
"metadata": "qry-meta",
"tags": { "action": "lookup" }
}
}
}'
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "query_send",
Arguments: map[string]any{
"channel": "example-queries",
"body": "get-data",
"timeout_seconds": 30,
"metadata": "qry-meta",
"tags": map[string]any{"action": "lookup"},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Tool: query_send")
fmt.Printf("Result: %+v\n", result)
```
```python
result = await session.call_tool("query_send", {
"channel": "example-queries",
"body": "get-data",
"timeout_seconds": 30,
"metadata": "qry-meta",
"tags": {"action": "lookup"},
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```typescript
const result = await client.callTool({
name: "query_send",
arguments: {
channel: "example-queries",
body: "get-data",
timeout_seconds: 30,
metadata: "qry-meta",
tags: { action: "lookup" },
},
});
console.log(JSON.stringify(result, null, 2));
```
```java
var result = client.callTool(new CallToolRequest(
"query_send",
Map.of(
"channel", "example-queries",
"body", "get-data",
"timeout_seconds", 30,
"metadata", "qry-meta",
"tags", Map.of("action", "lookup")
)
));
System.out.println(result);
```
```csharp
var result = await client.CallToolAsync("query_send", new Dictionary
{
["channel"] = "example-queries",
["body"] = "get-data",
["timeout_seconds"] = 30,
["metadata"] = "qry-meta",
["tags"] = new Dictionary { ["action"] = "lookup" },
});
Console.WriteLine($"Result: {result}");
```
```kotlin
val result = client.callTool("query_send", mapOf(
"channel" to "example-queries",
"body" to "get-data",
"timeout_seconds" to 30
))
println("Result: $result")
```
```ruby
result = client.call_tool("query_send", {
"channel" => "example-queries",
"body" => "get-data",
"timeout_seconds" => 30,
})
puts "Result: #{result}"
```
```rust
let result = client.call_tool("query_send", json!({
"channel": "example-queries",
"body": "get-data",
"timeout_seconds": 30,
"metadata": "qry-meta",
"tags": {"action": "lookup"}
})).await?;
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("query_send", arguments: [
"channel": "example-queries",
"body": "get-data",
"timeout_seconds": 30,
])
print("Result: \(result)")
```
The snippets above assume an initialized MCP session (`client` / `session`). For the
full connect-and-initialize handshake in each language, see
[Client setup](/aiway/mcp/guides/client-setup).
## Related [#related]
# Events Tools (/aiway/mcp/tools/events)
The events tools let an AI model publish events to KubeMQ and read them back from the
events store, all over the Model Context Protocol. They cover both the **fire-and-forget**
pub/sub pattern and the **persistent, replayable** events store.
## Overview [#overview]
The MCP connector exposes four events tools, split across two delivery models:
* **`events_publish`** — fire-and-forget pub/sub. The event is delivered to whatever
subscribers are live at that instant and is **not stored**; if no one is listening,
it is lost.
* **`events_store_publish`** — persistent publish. The event is appended to the events
store with a sequence number and can be re-read later.
* **`events_store_read`** — read stored events starting from a sequence number or a
timestamp.
* **`events_store_read_latest`** — return the most recent N stored events.
Use `events_publish` for live notifications where missed messages are acceptable, and
the events-store tools when a model needs durable history it can replay — for example,
reading recent events to build context before acting.
## How it works [#how-it-works]
Every tool is a `tools/call` JSON-RPC request. The connector translates the call into
a native KubeMQ events or events-store operation over the [Array](/connectors),
then returns the result in the standard `content[]` envelope.
*Ephemeral events fan out to live subscribers; events-store events are appended with a sequence number and read back on demand.*
## events\_publish [#events_publish]
Publish a fire-and-forget event to an events channel. The call returns as soon as the
event is accepted — there is no stored copy and no per-subscriber acknowledgement.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "events_publish",
"arguments": {
"channel": "example-events",
"body": "Event data",
"metadata": "event-meta",
"tags": {"source": "mcp-example"}
}
}
}'
```
```csharp
using ModelContextProtocol.Client;
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("events_publish", new Dictionary
{
["channel"] = "example-events",
["body"] = "Event data",
["metadata"] = "event-meta",
["tags"] = new Dictionary { ["source"] = "mcp-example" },
});
Console.WriteLine($"Result: {result}");
```
```go
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "events_publish",
Arguments: map[string]any{
"channel": "example-events",
"body": "Event data",
"metadata": "event-meta",
"tags": map[string]any{"source": "mcp-example"},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```java
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(new CallToolRequest(
"events_publish",
Map.of(
"channel", "example-events",
"body", "Event data",
"metadata", "event-meta",
"tags", Map.of("source", "mcp-example")
)
));
System.out.println(result);
client.closeGracefully();
```
```kotlin
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("events_publish", mapOf(
"channel" to "example-events",
"body" to "Event data"
))
println("Result: $result")
client.close()
httpClient.close()
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("events_publish", {
"channel": "example-events",
"body": "Event data",
"metadata": "event-meta",
"tags": {"source": "mcp-example"},
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
if __name__ == "__main__":
asyncio.run(main())
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("events_publish", {
"channel" => "example-events",
"body" => "Event data",
})
puts "Result: #{result}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("events_publish", json!({
"channel": "example-events",
"body": "Event data",
"metadata": "event-meta",
"tags": {"source": "mcp-example"}
})).await?;
println!("Result: {result:#?}");
Ok(())
}
```
```swift
import Foundation
import MCP
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("events_publish", arguments: [
"channel": "example-events",
"body": "Event data",
])
print("Result: \(result)")
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({
name: "events_publish",
arguments: {
channel: "example-events",
body: "Event data",
metadata: "event-meta",
tags: { source: "mcp-example" },
},
});
console.log(JSON.stringify(result, null, 2));
await client.close();
```
A successful publish returns a confirmation message in the standard envelope:
```json
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"content": [{ "type": "text", "text": "Event published successfully to channel 'example-events'" }],
"isError": false
}
}
```
## events\_store\_publish [#events_store_publish]
Publish a **persistent** event. The event is appended to the events store, assigned a
sequence number, and remains available for re-reading by later `events_store_read` and
`events_store_read_latest` calls.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {
"name": "events_store_publish",
"arguments": {
"channel": "example-events-store",
"body": "Stored event data",
"metadata": "store-meta",
"tags": {"source": "mcp-example"}
}
}
}'
```
```csharp
var result = await client.CallToolAsync("events_store_publish", new Dictionary
{
["channel"] = "example-events-store",
["body"] = "Stored event data",
["metadata"] = "store-meta",
["tags"] = new Dictionary { ["source"] = "mcp-example" },
});
Console.WriteLine($"Result: {result}");
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "events_store_publish",
Arguments: map[string]any{
"channel": "example-events-store",
"body": "Stored event data",
"metadata": "store-meta",
"tags": map[string]any{"source": "mcp-example"},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```java
var result = client.callTool(new CallToolRequest(
"events_store_publish",
Map.of(
"channel", "example-events-store",
"body", "Stored event data",
"metadata", "store-meta",
"tags", Map.of("source", "mcp-example")
)
));
System.out.println(result);
```
```kotlin
val result = client.callTool("events_store_publish", mapOf(
"channel" to "example-events-store",
"body" to "Stored event"
))
println("Result: $result")
```
```python
result = await session.call_tool("events_store_publish", {
"channel": "example-events-store",
"body": "Stored event data",
"metadata": "store-meta",
"tags": {"source": "mcp-example"},
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```ruby
result = client.call_tool("events_store_publish", {
"channel" => "example-events-store",
"body" => "Stored event",
})
puts "Result: #{result}"
```
```rust
let result = client.call_tool("events_store_publish", json!({
"channel": "example-events-store",
"body": "Stored event data",
"metadata": "store-meta",
"tags": {"source": "mcp-example"}
})).await?;
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("events_store_publish", arguments: [
"channel": "example-events-store",
"body": "Stored event",
])
print("Result: \(result)")
```
```typescript
const result = await client.callTool({
name: "events_store_publish",
arguments: {
channel: "example-events-store",
body: "Stored event data",
metadata: "store-meta",
tags: { source: "mcp-example" },
},
});
console.log(JSON.stringify(result, null, 2));
```
```json
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"content": [{ "type": "text", "text": "Event published successfully to events store channel 'example-events-store'" }],
"isError": false
}
}
```
## events\_store\_read [#events_store_read]
Read stored events starting from a position. Provide `from_sequence` to start at a
sequence number, or `from_time` to start at an RFC 3339 timestamp, and cap the result
with `max_messages`.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "events_store_read",
"arguments": {
"channel": "example-events-store",
"from_sequence": 1,
"max_messages": 10
}
}
}'
```
```csharp
var result = await client.CallToolAsync("events_store_read", new Dictionary
{
["channel"] = "example-events-store",
["from_sequence"] = 1,
["max_messages"] = 10,
});
Console.WriteLine($"Result: {result}");
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "events_store_read",
Arguments: map[string]any{
"channel": "example-events-store",
"from_sequence": 1,
"max_messages": 10,
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```java
var result = client.callTool(new CallToolRequest(
"events_store_read",
Map.of(
"channel", "example-events-store",
"from_sequence", 1,
"max_messages", 10
)
));
System.out.println(result);
```
```kotlin
val result = client.callTool("events_store_read", mapOf(
"channel" to "example-events-store",
"from_sequence" to 1,
"max_messages" to 10
))
println("Result: $result")
```
```python
result = await session.call_tool("events_store_read", {
"channel": "example-events-store",
"from_sequence": 1,
"max_messages": 10,
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```ruby
result = client.call_tool("events_store_read", {
"channel" => "example-events-store",
"from_sequence" => 1,
"max_messages" => 10,
})
puts "Result: #{result}"
```
```rust
let result = client.call_tool("events_store_read", json!({
"channel": "example-events-store",
"from_sequence": 1,
"max_messages": 10
})).await?;
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("events_store_read", arguments: [
"channel": "example-events-store",
"from_sequence": 1,
"max_messages": 10,
])
print("Result: \(result)")
```
```typescript
const result = await client.callTool({
name: "events_store_read",
arguments: {
channel: "example-events-store",
from_sequence: 1,
max_messages: 10,
},
});
console.log(JSON.stringify(result, null, 2));
```
The result text is a JSON array of stored events, each carrying its `body`, `metadata`,
`sequence`, and `timestamp`:
```json
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [{ "type": "text", "text": "[{\"body\":\"Stored event data\",\"metadata\":\"store-meta\",\"sequence\":1,\"timestamp\":\"2026-04-06T12:00:00Z\"}]" }],
"isError": false
}
}
```
## events\_store\_read\_latest [#events_store_read_latest]
Return the most recent events from the store. Set `count` to choose how many to read
back, newest first.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 8,
"method": "tools/call",
"params": {
"name": "events_store_read_latest",
"arguments": {
"channel": "example-events-store",
"count": 3
}
}
}'
```
```csharp
var result = await client.CallToolAsync("events_store_read_latest", new Dictionary
{
["channel"] = "example-events-store",
["count"] = 3,
});
Console.WriteLine($"Result: {result}");
```
```go
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "events_store_read_latest",
Arguments: map[string]any{
"channel": "example-events-store",
"count": 3,
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
```
```java
var result = client.callTool(new CallToolRequest(
"events_store_read_latest",
Map.of(
"channel", "example-events-store",
"count", 3
)
));
System.out.println(result);
```
```kotlin
val result = client.callTool("events_store_read_latest", mapOf(
"channel" to "example-events-store",
"count" to 3
))
println("Result: $result")
```
```python
result = await session.call_tool("events_store_read_latest", {
"channel": "example-events-store",
"count": 3,
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
```
```ruby
result = client.call_tool("events_store_read_latest", {
"channel" => "example-events-store",
"count" => 3,
})
puts "Result: #{result}"
```
```rust
let result = client.call_tool("events_store_read_latest", json!({
"channel": "example-events-store",
"count": 3
})).await?;
println!("Result: {result:#?}");
```
```swift
let result = try await client.callTool("events_store_read_latest", arguments: [
"channel": "example-events-store",
"count": 3,
])
print("Result: \(result)")
```
```typescript
const result = await client.callTool({
name: "events_store_read_latest",
arguments: {
channel: "example-events-store",
count: 3,
},
});
console.log(JSON.stringify(result, null, 2));
```
```json
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"content": [{ "type": "text", "text": "[{\"body\":\"Stored event 3\",\"sequence\":3},{\"body\":\"Stored event 2\",\"sequence\":2},{\"body\":\"Stored event 1\",\"sequence\":1}]" }],
"isError": false
}
}
```
## Parameters [#parameters]
### events\_publish [#events_publish-1]
| Argument | Type | Required | Default | Description |
| ---------- | ------ | -------- | ------- | ----------------------------------------------- |
| `channel` | string | yes | — | Events channel to publish to. |
| `body` | string | yes | — | Event payload. |
| `metadata` | string | no | — | Optional metadata string attached to the event. |
| `tags` | object | no | — | Optional key/value string tags. |
### events\_store\_publish [#events_store_publish-1]
| Argument | Type | Required | Default | Description |
| ---------- | ------ | -------- | ------- | ----------------------------------------------- |
| `channel` | string | yes | — | Events store channel to append to. |
| `body` | string | yes | — | Event payload. |
| `metadata` | string | no | — | Optional metadata string attached to the event. |
| `tags` | object | no | — | Optional key/value string tags. |
### events\_store\_read [#events_store_read-1]
| Argument | Type | Required | Default | Description |
| --------------- | ------ | -------- | ------- | ----------------------------------------- |
| `channel` | string | yes | — | Events store channel to read from. |
| `max_messages` | number | yes | — | Maximum number of events to return. |
| `from_sequence` | number | no | — | Start reading at this sequence number. |
| `from_time` | string | no | — | Start reading at this RFC 3339 timestamp. |
### events\_store\_read\_latest [#events_store_read_latest-1]
| Argument | Type | Required | Default | Description |
| --------- | ------ | -------- | ------- | --------------------------------------------------- |
| `channel` | string | yes | — | Events store channel to read from. |
| `count` | number | no | `10` | Number of most-recent events to return (max `100`). |
Channel names beginning with the reserved `_AGENTS_.` prefix are rejected — see
[Channel resolution](/aiway/mcp/guides/channel-resolution).
## Response [#response]
Publish tools return a single text confirmation in the `content[]` envelope. Read tools
return a text block whose `text` is a JSON array of stored events. A failed call sets
`isError: true` and carries the message in the same block — see
[Error handling](/aiway/mcp/guides/error-handling) for the three failure
layers.
## Related [#related]
# Tools Overview (/aiway/mcp/tools)
KubeMQ exposes its messaging operations to AI models as **MCP tools**. A client
discovers them with `tools/list` and invokes them with `tools/call` — there is no
KubeMQ-specific client library involved, just the Model Context Protocol.
## Overview [#overview]
The MCP connector publishes **15 tools** in two families:
* **11 core messaging tools** — always available. They cover queues, events, the
events store, commands/queries, and channel discovery.
* **4 agent-bridge tools** — available only when the [A2A agent registry](/aiway/a2a)
is present. They let an MCP client discover and message registered agents,
bridging MCP to the A2A gateway.
Every tool maps onto a single KubeMQ messaging operation. The model calls the tool
by name with a JSON arguments object; the connector translates it into a native
KubeMQ call over the [Array](/connectors) and returns the result.
## How it works [#how-it-works]
The connector advertises each tool through `tools/list`, then routes each
`tools/call` to the matching KubeMQ operation. Core tools reach the broker
directly; bridge tools forward through the agent registry to an external agent.
*One MCP connector fans out to core messaging tools and, when the registry is present, agent-bridge tools.*
## Core messaging tools [#core-messaging-tools]
These 11 tools are registered unconditionally — start kubemq-server and they are
live at `/mcp`.
| Category | Tools | Page |
| ------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Queues | `queue_send`, `queue_receive`, `queue_peek` | [Queue tools](/aiway/mcp/tools/queues) |
| Events | `events_publish`, `events_store_publish`, `events_store_read`, `events_store_read_latest` | [Events tools](/aiway/mcp/tools/events) |
| Commands & Queries | `command_send`, `query_send` | [Command & query tools](/aiway/mcp/tools/commands-queries) |
| Channels | `channel_list`, `channel_info` | [Channel tools](/aiway/mcp/tools/channel-management) |
## Agent-bridge tools [#agent-bridge-tools]
These 4 tools appear in `tools/list` **only when the A2A agent registry is
injected** into the MCP connector. They turn an MCP client into an A2A caller:
`agent_send` builds a `message/send` envelope and forwards it over Query to
`_AGENTS_.agents/`, while `agent_query` forwards an arbitrary JSON-RPC
method to the agent.
| Category | Tools | Page |
| ------------ | ------------------------------------------------------- | --------------------------------------------------- |
| Agent bridge | `agent_list`, `agent_info`, `agent_send`, `agent_query` | [Agent-bridge tools](/aiway/mcp/tools/agent-bridge) |
The bridge connects MCP to the [A2A connector](/aiway/a2a). If A2A is not
running, only the 11 core tools are listed.
## Calling a tool [#calling-a-tool]
Every tool is invoked the same way — a `tools/call` JSON-RPC request naming the
tool and passing its `arguments` object. The skeleton below works for any of the 15
tools; only `name` and `arguments` change.
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": {
"channel": "example-queue",
"body": "Hello from MCP"
}
}
}'
```
List the available tools and their input schemas first with `tools/list` — see
[Endpoints](/aiway/mcp/reference/endpoints). The per-tool pages document
each tool's arguments and provide ready-to-run examples in all nine languages.
## Response shape [#response-shape]
`tools/call` always returns a result with a `content` array. Each entry is a typed
block — KubeMQ uses `text` blocks carrying the operation result as a JSON string.
A successful call:
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "Message sent successfully to queue 'example-queue'" }],
"isError": false
}
}
```
A failed call sets `isError: true` and carries the error message in the same
`content` block — the JSON-RPC envelope itself still succeeds:
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "error: channel name uses reserved prefix '_AGENTS_.'" }],
"isError": true
}
}
```
`isError` is **not** a protocol failure. A malformed JSON-RPC request (missing
`name`, bad params) returns a JSON-RPC error instead — see
[Error handling](/aiway/mcp/guides/error-handling) for the three failure
layers and [Error codes](/aiway/mcp/reference/error-codes) for the catalog.
## Related [#related]
# Queue Tools (/aiway/mcp/tools/queues)
The queue tools expose KubeMQ's durable, point-to-point queue messaging as MCP tools, so an AI model can enqueue work, consume it, and inspect a backlog without removing it.
## Overview [#overview]
KubeMQ's [queue messaging](/learn/queues) is a durable, at-least-once, single-consumer channel: a message is held until exactly one consumer receives it. The MCP connector surfaces three queue operations as tools, all invoked through the standard `tools/call` method against the single `POST /mcp` endpoint on the [shared HTTP server](/connectors/concepts/shared-http-server) (port `9090`). Channels are created automatically on first use.
| Tool | Operation | Idempotent |
| --------------- | ------------------------------------------------------------- | ---------- |
| `queue_send` | Enqueue a message for durable, single-consumer delivery | No |
| `queue_receive` | Receive and consume messages (destructive read) | No |
| `queue_peek` | Inspect messages without removing them (non-destructive read) | Yes |
The MCP connector is **enabled by default** — start kubemq-server and `/mcp` is live. To disable it, set `CONNECTORSMCP_ENABLE=false`. See [Configuration](/aiway/mcp/configuration) for details.
## How it works [#how-it-works]
A `tools/call` request flows through the MCP connector, which translates the tool arguments into a native KubeMQ queue operation against the broker.
*An AI model sends and receives queue messages through the MCP connector, which bridges to the KubeMQ broker.*
A `channel` that starts with the reserved prefix `_AGENTS_.` is rejected with a `-32602` Invalid Params error. See [Channel resolution](/aiway/mcp/guides/channel-resolution).
## queue\_send [#queue_send]
> Send a message to a KubeMQ queue channel. Use for reliable, persistent messaging with at-least-once delivery. Each call enqueues a new message (not idempotent).
The channel is created automatically if it does not exist. Optional policy fields delay visibility (`delay_seconds`), set a time-to-live (`expiration_seconds`), and route poison messages to a dead-letter queue (`max_receive_count` plus `dead_letter_queue`). Both `channel` and `body` are required — omitting either returns a `-32602` Invalid Params error.
### Input schema [#input-schema]
| Argument | Type | Required | Default | Description |
| -------------------- | ------- | -------- | ------- | ----------------------------------------------------------------------- |
| `channel` | string | Yes | — | Queue channel name. Must not start with the reserved prefix `_AGENTS_.` |
| `body` | string | Yes | — | Message body (string) |
| `metadata` | string | No | `""` | Optional metadata |
| `tags` | object | No | `{}` | Optional key-value tags (string values) |
| `delay_seconds` | integer | No | `0` | Delay delivery by N seconds (`0` = immediately visible) |
| `expiration_seconds` | integer | No | `0` | Message expiry in seconds (`0` = no expiration) |
| `max_receive_count` | integer | No | `0` | Max receive attempts before dead-letter (`0` = unlimited) |
| `dead_letter_queue` | string | No | `""` | Dead-letter queue channel name |
### Usage [#usage]
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "queue_send",
"arguments": {
"channel": "example-queue",
"body": "Hello from MCP",
"metadata": "example-metadata",
"tags": { "env": "dev", "source": "mcp-example" }
}
}
}'
```
```csharp
using ModelContextProtocol.Client;
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("queue_send", new Dictionary
{
["channel"] = "example-queue",
["body"] = "Hello from C# MCP",
["metadata"] = "example-metadata",
["tags"] = new Dictionary { ["env"] = "dev", ["source"] = "mcp-example" },
});
Console.WriteLine($"Result: {result}");
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "queue_send",
Arguments: map[string]any{
"channel": "example-queue",
"body": "Hello from Go MCP",
"metadata": "example-metadata",
"tags": map[string]any{"env": "dev", "source": "mcp-example"},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
}
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import java.util.Map;
public class QueueSend {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(new CallToolRequest(
"queue_send",
Map.of(
"channel", "example-queue",
"body", "Hello from Java MCP",
"metadata", "example-metadata",
"tags", Map.of("env", "dev", "source", "mcp-example")
)
));
System.out.println(result);
client.closeGracefully();
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("queue_send", mapOf(
"channel" to "example-queue",
"body" to "Hello from Kotlin MCP",
"metadata" to "example-metadata",
"tags" to mapOf("env" to "dev", "source" to "mcp-example")
))
println("Result: $result")
client.close()
httpClient.close()
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("queue_send", {
"channel": "example-queue",
"body": "Hello from Python MCP",
"metadata": "example-metadata",
"tags": {"env": "dev", "source": "mcp-example"},
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
if __name__ == "__main__":
asyncio.run(main())
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("queue_send", {
"channel" => "example-queue",
"body" => "Hello from Ruby MCP",
"metadata" => "example-metadata",
"tags" => { "env" => "dev", "source" => "mcp-example" },
})
puts "Result: #{result}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("queue_send", json!({
"channel": "example-queue",
"body": "Hello from Rust MCP",
"metadata": "example-metadata",
"tags": {"env": "dev", "source": "mcp-example"}
})).await?;
println!("Result: {result:#?}");
Ok(())
}
```
```swift
import Foundation
import MCP
@main
struct QueueSend {
static func main() async throws {
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("queue_send", arguments: [
"channel": "example-queue",
"body": "Hello from Swift MCP",
"metadata": "example-metadata",
"tags": ["env": "dev", "source": "mcp-example"],
])
print("Result: \(result)")
}
}
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
async function main() {
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({
name: "queue_send",
arguments: {
channel: "example-queue",
body: "Hello from TypeScript MCP",
metadata: "example-metadata",
tags: { env: "dev", source: "mcp-example" },
},
});
console.log(JSON.stringify(result, null, 2));
await client.close();
}
main().catch(console.error);
```
### Response [#response]
The tool result wraps a JSON string in the standard MCP `content[]` / `isError` envelope. The text payload reports the enqueue result:
```json
{
"content": [
{
"type": "text",
"text": "{\"message_id\":\"a1b2c3...\",\"sent_at\":\"2026-06-08T10:00:00Z\",\"is_error\":false}"
}
],
"isError": false
}
```
## queue\_receive [#queue_receive]
> Receive messages from a KubeMQ queue channel. Messages are auto-acknowledged on receipt (destructive read). Not idempotent — failed processing requires re-enqueue.
This is a destructive read: returned messages are removed from the queue, so each message is delivered to exactly one consumer. Set `max_messages` to drain a batch (clamped to the `1–100` range), and `wait_timeout_seconds` to long-poll for messages that have not yet arrived. Receiving from a non-existent channel is not an error — it returns an empty `messages` array. Because the read is not idempotent, do not blindly retry: a retry may consume *additional* messages rather than re-fetch the same ones.
### Input schema [#input-schema-1]
| Argument | Type | Required | Default | Description |
| ---------------------- | ------- | -------- | ------- | -------------------------------------------- |
| `channel` | string | Yes | — | Queue channel name |
| `max_messages` | integer | Yes | `1` | Max messages to receive (clamped to `1–100`) |
| `wait_timeout_seconds` | integer | No | `5` | Long-poll wait timeout in seconds (`1–60`) |
### Usage [#usage-1]
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "queue_receive",
"arguments": {
"channel": "example-queue",
"max_messages": 5
}
}
}'
```
```csharp
using ModelContextProtocol.Client;
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("queue_receive", new Dictionary
{
["channel"] = "example-queue",
["max_messages"] = 5,
});
Console.WriteLine($"Result: {result}");
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "queue_receive",
Arguments: map[string]any{
"channel": "example-queue",
"max_messages": 5,
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
}
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import java.util.Map;
public class QueueReceive {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(new CallToolRequest(
"queue_receive",
Map.of(
"channel", "example-queue",
"max_messages", 5
)
));
System.out.println(result);
client.closeGracefully();
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("queue_receive", mapOf(
"channel" to "example-queue",
"max_messages" to 5
))
println("Result: $result")
client.close()
httpClient.close()
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("queue_receive", {
"channel": "example-queue",
"max_messages": 5,
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
if __name__ == "__main__":
asyncio.run(main())
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("queue_receive", {
"channel" => "example-queue",
"max_messages" => 5,
})
puts "Result: #{result}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("queue_receive", json!({
"channel": "example-queue",
"max_messages": 5
})).await?;
println!("Result: {result:#?}");
Ok(())
}
```
```swift
import Foundation
import MCP
@main
struct QueueReceive {
static func main() async throws {
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("queue_receive", arguments: [
"channel": "example-queue",
"max_messages": 5,
])
print("Result: \(result)")
}
}
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
async function main() {
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({
name: "queue_receive",
arguments: {
channel: "example-queue",
max_messages: 5,
},
});
console.log(JSON.stringify(result, null, 2));
await client.close();
}
main().catch(console.error);
```
### Response [#response-1]
The text payload is a JSON object holding the received messages and a count. Each message carries its `id`, `channel`, `body`, `metadata`, and (when present) `timestamp`, `sequence`, and `tags`:
```json
{
"content": [
{
"type": "text",
"text": "{\"messages\":[{\"id\":\"...\",\"channel\":\"example-queue\",\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\",\"tags\":{\"env\":\"dev\",\"source\":\"mcp-example\"}}],\"messages_count\":1,\"is_error\":false}"
}
],
"isError": false
}
```
## queue\_peek [#queue_peek]
> Peek at messages in a KubeMQ queue without removing them. Idempotent — does not modify queue state.
Peek is a non-destructive, idempotent read: inspected messages remain in the queue and can still be consumed later by `queue_receive`. Use it for monitoring, debugging, or letting an agent reason about pending work before deciding to consume it. Set `max_messages` to control how many messages to inspect (clamped to `1–100`). As with receive, peeking a non-existent channel returns an empty result rather than an error.
### Input schema [#input-schema-2]
| Argument | Type | Required | Default | Description |
| -------------- | ------- | -------- | ------- | ----------------------------------------- |
| `channel` | string | Yes | — | Queue channel name |
| `max_messages` | integer | Yes | `1` | Max messages to peek (clamped to `1–100`) |
### Usage [#usage-2]
```bash
curl -X POST http://localhost:9090/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "queue_peek",
"arguments": {
"channel": "example-queue",
"max_messages": 5
}
}
}'
```
```csharp
using ModelContextProtocol.Client;
var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090";
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") });
await using var client = await McpClientFactory.CreateAsync(transport);
var result = await client.CallToolAsync("queue_peek", new Dictionary
{
["channel"] = "example-queue",
["max_messages"] = 5,
});
Console.WriteLine($"Result: {result}");
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
url := os.Getenv("KUBEMQ_MCP_URL")
if url == "" {
url = "http://localhost:9090"
}
c, err := client.NewStreamableHttpClient(url + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
ctx := context.Background()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "queue_peek",
Arguments: map[string]any{
"channel": "example-queue",
"max_messages": 5,
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Result: %+v\n", result)
}
```
```java
import io.modelcontextprotocol.sdk.McpClient;
import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import java.util.Map;
public class QueuePeek {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090");
var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build();
var client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(new CallToolRequest(
"queue_peek",
Map.of(
"channel", "example-queue",
"max_messages", 5
)
));
System.out.println(result);
client.closeGracefully();
}
}
```
```kotlin
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090"
val httpClient = HttpClient { install(SSE) }
val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp")
val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0"))
client.connect(transport)
val result = client.callTool("queue_peek", mapOf(
"channel" to "example-queue",
"max_messages" to 5
))
println("Result: $result")
client.close()
httpClient.close()
}
```
```python
import asyncio
import os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090")
async def main():
async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("queue_peek", {
"channel": "example-queue",
"max_messages": 5,
})
print(f"IsError: {result.isError}")
for content in result.content:
print(f"Result: {content.text}")
if __name__ == "__main__":
asyncio.run(main())
```
```ruby
require "mcp"
url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090")
client = MCP::Client.new(
transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"),
name: "kubemq-mcp-ruby-example",
version: "1.0.0"
)
client.initialize_handshake
result = client.call_tool("queue_peek", {
"channel" => "example-queue",
"max_messages" => 5,
})
puts "Result: #{result}"
client.close
```
```rust
use rmcp::transport::streamable_http::StreamableHttpClientTransport;
use rmcp::service::RunService;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("KUBEMQ_MCP_URL")
.unwrap_or_else(|_| "http://localhost:9090".to_string());
let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?;
let client = ().serve(transport).await?;
let result = client.call_tool("queue_peek", json!({
"channel": "example-queue",
"max_messages": 5
})).await?;
println!("Result: {result:#?}");
Ok(())
}
```
```swift
import Foundation
import MCP
@main
struct QueuePeek {
static func main() async throws {
let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090"
let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true)
let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0")
try await client.connect(transport: transport)
let result = try await client.callTool("queue_peek", arguments: [
"channel": "example-queue",
"max_messages": 5,
])
print("Result: \(result)")
}
}
```
```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090";
async function main() {
const transport = new StreamableHTTPClientTransport(
new URL(`${KUBEMQ_MCP_URL}/mcp`)
);
const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({
name: "queue_peek",
arguments: {
channel: "example-queue",
max_messages: 5,
},
});
console.log(JSON.stringify(result, null, 2));
await client.close();
}
main().catch(console.error);
```
### Response [#response-2]
Same envelope as `queue_receive`, but the messages stay in the queue. Peeked messages omit `tags` and report `messages_count`:
```json
{
"content": [
{
"type": "text",
"text": "{\"messages\":[{\"id\":\"...\",\"channel\":\"example-queue\",\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\"}],\"messages_count\":1}"
}
],
"isError": false
}
```
## Receive vs peek [#receive-vs-peek]
| Behavior | `queue_receive` | `queue_peek` |
| ------------------------------- | ----------------------------------- | --------------------- |
| Removes messages from the queue | Yes | No |
| Idempotent | No | Yes |
| Long-poll wait | `wait_timeout_seconds` (default 5s) | Fixed 1s internally |
| Typical use | Processing work items | Monitoring, debugging |
## Related [#related]
# Connect an MCP host (/deploy/scenarios/agents/connect-mcp-host)
KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps
1–2 get you there; instant if it's already up).
KubeMQ's MCP gateway is already live on `:9090` the moment the broker starts — there's no
connector to enable and no flag to flip. Point any MCP host at the endpoint and it can
discover and call KubeMQ's tools right away.
## 1 · Point your MCP host at KubeMQ [#1--point-your-mcp-host-at-kubemq]
Add a server entry to your MCP host's config. For Claude Desktop this is
`claude_desktop_config.json` (Claude Desktop → Settings → Developer → Edit Config); Cursor
and other hosts use an equivalent MCP server list:
```json title="claude_desktop_config.json"
{
"mcpServers": {
"kubemq": {
"url": "http://localhost:9090/mcp"
}
}
}
```
The `url` must include the `/mcp` path — it's the only required field for an
unauthenticated server. Restart the host so it picks up the new entry.
## 2 · Verify [#2--verify]
Cross-check from both ends:
* **From the server** — `kmq mcp list` prints every MCP tool KubeMQ has registered.
* **From the host** — start a new conversation and ask it what tools it has; KubeMQ's
tools (for example `queue_send`) should be in the list.
Then make one call as proof: ask the host to send a message to a queue channel — for
example, "send `hello` to the `orders` queue." A successful call returns the tool's result
text with no error.
## Didn't work? [#didnt-work]
* **Wrong endpoint/URL** — confirm the `url` is exactly `http://localhost:9090/mcp` (the
`/mcp` path is required) and that port `9090` is reachable.
* **Host config** — most MCP hosts need a full restart, not just a reload, to pick up a
new or changed server entry; check the host's developer/logs panel for a connection
error.
* **Auth mismatch** — if the server has JWT auth enabled, add an `Authorization: Bearer ` header to the host's server entry; without it, calls fail even though the
endpoint is reachable.
## Go deeper [#go-deeper]
# Drive KubeMQ from an LLM (/deploy/scenarios/agents/drive-from-llm)
KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps
1–2 get you there; instant if it's already up).
The MCP gateway is already live on `http://localhost:9090/mcp` — no enable step. Once an
MCP host is pointed at it and at least one agent is registered, the same conversation can
enqueue durable work **and** call an agent — messaging and agents, one fabric, one session.
If you haven't done these yet: [connect an MCP host](/deploy/scenarios/agents/connect-mcp-host)
to `http://localhost:9090/mcp`, and [register an agent](/deploy/scenarios/agents/register-first-agent)
for it to call. Both take a couple of minutes.
## 1 · One prompt, two tool calls [#1--one-prompt-two-tool-calls]
Ask your connected LLM host something that needs both capabilities, for example:
> "Enqueue order 1042 for the fulfillment worker, then ask the order-status agent for
> an ETA."
The host resolves that into two MCP tool calls against the same `/mcp` session — one
against KubeMQ's durable queue, one against the agent bridge:
```json title="queue_send"
{ "name": "queue_send", "arguments": { "channel": "orders", "body": "{\"id\":1042}" } }
```
```json title="agent_send"
{ "name": "agent_send", "arguments": { "agent_id": "order-status-agent", "message": "What is the ETA for order 1042?" } }
```
## 2 · Verify [#2--verify]
You should see two results come back in the conversation, both round-tripped through
the same MCP session — no separate connection, no glue code.
`queue_send` returns the MCP queue envelope with a generated message ID (and the
`orders` channel ticks in the dashboard):
```json title="queue_send result"
{
"content": [
{ "type": "text", "text": "{\"message_id\":\"a1b2c3...\",\"sent_at\":\"2026-06-08T10:00:00Z\",\"is_error\":false}" }
],
"isError": false
}
```
`agent_send` returns the order-status agent's reply, bridged from its A2A response:
```json title="agent_send result"
{
"content": [
{ "type": "text", "text": "{\"status\":\"in-transit\",\"eta\":\"2 days\"}" }
],
"isError": false
}
```
## Didn't work? [#didnt-work]
* **MCP host has no tools** — confirm it completed the `initialize` handshake against
`http://localhost:9090/mcp`; see [Connect an MCP host](/deploy/scenarios/agents/connect-mcp-host).
* **`agent_send` returns "Agent not found"** — register the agent first; the `agent_id`
must match exactly.
* **`queue_send` returns a `-32602` error** — the channel name starts with the reserved
`_AGENTS_.` prefix, or `channel`/`body` is missing.
## Go deeper [#go-deeper]
# Build on the AI-agent fabric (/deploy/scenarios/agents)
KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps
1–2 get you there; instant if it's already up).
KubeMQ's **Agent-to-Agent (A2A)** and **Model Context Protocol (MCP)** gateways are live
the moment KubeMQ runs — both ship on by default on the shared HTTP server, port `9090`.
Unlike the seven wire-protocol connectors under [Replace your messaging
stack](/deploy/scenarios/replace), which are opt-in and need an enable flag, there's
no `CONNECTORS_*_ENABLE` step here: start the broker and the fabric is already listening.
## Three ways in [#three-ways-in]
## What the fabric gives you [#what-the-fabric-gives-you]
Both doors open into the same fabric, not two bolted-together servers:
* A shared discovery and registry layer so callers find agents by capability
* Streaming responses over **Server-Sent Events (SSE)** for long-running work
* An **HA agent registry** replicated to every cluster node so a node failure doesn't strand an agent
* Guardrails — TTL liveness, request timeouts, and per-agent concurrency caps — that keep a whole agent fleet production-safe rather than a demo
The full picture, including the enterprise envelope and both getting-started guides, lives on [KubeMQ Aiway](/aiway).
**Didn't work?**
* **Nothing answers on `:9090`** — the fabric is only live while KubeMQ itself is
running; confirm with `docker ps` or `kmq status`, or work through
[Quickstart](/deploy/quickstart) if you haven't started it yet.
* **Port 9090 not published** — if you started KubeMQ with a custom `docker run` (or
``), make sure `9090` is exposed.
* **No license key** — a standalone server refuses to boot without one; see [Get a
license key](/deploy/license-key).
# Register your first agent (/deploy/scenarios/agents/register-first-agent)
KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps
1–2 get you there; instant if it's already up).
The A2A gateway is already live on `:9090` — no enable step. Any existing HTTP
service becomes a callable agent by registering its URL; the service itself needs no
KubeMQ SDK, no new dependency, and no code change beyond answering a POST.
## 1 · Start a throwaway agent [#1--start-a-throwaway-agent]
You need something listening on the other end before you register it. This \~15-line
Python HTTP server plays the part of your existing service for this walkthrough — it
accepts the gateway's forwarded JSON-RPC request and answers with a valid JSON-RPC 2.0
`result`, no KubeMQ SDK involved:
```python title="order_status_agent.py"
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class Agent(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers["Content-Length"])
req = json.loads(self.rfile.read(n))
reply = json.dumps({
"jsonrpc": "2.0",
"id": req.get("id"),
"result": {"status": "in-transit", "eta": "2 days"},
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(reply)
HTTPServer(("0.0.0.0", 8090), Agent).serve_forever()
```
Run it in its own terminal and leave it running:
```bash
python3 order_status_agent.py
```
## 2 · Register your agent [#2--register-your-agent]
POST a minimal agent card — just an id, a name, and the URL of the service you want to
expose — to `/agents/register`. KubeMQ runs inside a container, so the `url` needs to
reach your host machine, not the container's own loopback — `host.docker.internal` is
the Docker Desktop hostname that does exactly that:
```bash
curl -X POST http://localhost:9090/agents/register \
-H "Content-Type: application/json" \
-d '{
"agent_id": "order-status-agent",
"name": "Order Status Agent",
"url": "http://host.docker.internal:8090/"
}'
```
A `200` response means it's registered — pointed at the toy agent you just started.
## 3 · Discover it's live [#3--discover-its-live]
Fetch the platform agent card to confirm the A2A gateway is up and serving cards:
```bash
curl http://localhost:9090/.well-known/agent-card.json
```
A `200` with `"name":"kubemq"` means the gateway — and everything you just registered
against it — is reachable.
## 4 · Invoke it [#4--invoke-it]
Route a JSON-RPC `message/send` request through the gateway to your agent with
`POST /a2a/`:
```bash
curl -X POST http://localhost:9090/a2a/order-status-agent \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"text": "What is the status of order 42?"}]
}
}
}'
```
## Verify [#verify]
The response comes back with your toy agent's `result` inside it, relayed unchanged
through the gateway:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"status": "in-transit",
"eta": "2 days"
}
}
```
A response with an `error` field instead means the gateway couldn't reach your service;
see **Didn't work?** below.
## Didn't work? [#didnt-work]
* **404 on register or invoke** — confirm KubeMQ is running and `9090` is published.
* **Agent not found** — the `agent_id` in the invoke URL must exactly match the one you
registered.
* **Error instead of result** — the `url` you registered isn't reachable from inside the
container network. `host.docker.internal` resolves automatically on Docker Desktop
(macOS/Windows); on Linux without Desktop, either add
`--add-host=host.docker.internal:host-gateway` to the `docker run` command, or run
KubeMQ with `--network host` and register a plain `localhost` URL instead.
## Using your own service [#using-your-own-service]
The toy agent above exists only to prove the round trip end-to-end. Swap the registered
`url` for wherever your own service already listens — nothing about that service has to
change: no KubeMQ SDK, no new dependency, just an existing HTTP endpoint that accepts
the gateway's JSON-RPC POST and answers with a `result` (or an `error`) in the same
shape.
## Go deeper [#go-deeper]
# Replace your messaging stack (/deploy/scenarios/replace)
Already running one of these brokers? Point your existing client at KubeMQ instead of
rewriting it — change one connection string (or set an environment variable), keep your
code, and your app keeps speaking the same wire protocol it already speaks.
## Pick your broker [#pick-your-broker]
## Drop-in levels [#drop-in-levels]
Each card above states its **drop-in level** — how much application change the move
requires: **endpoint-only** — change the host/port or an env var; client + code
unchanged. **client-swap** — swap the client library; app code mostly unchanged.
**partial-rewrite** — some app-level changes beyond the client/endpoint.
Full definitions and the cross-protocol matrix live in the
[migration hub](/connectors/how-to/migration).
# Replace JMS, ActiveMQ, or AMQP 1.0 with KubeMQ (/deploy/scenarios/replace/replace-amqp-1-0)
You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account).
[Get one](/deploy/license-key)
. Step 1 below starts the broker with the connector enabled.
JMS applications, ActiveMQ's Java clients, and native AMQP 1.0 clients all reach KubeMQ
through the **same connector** — one AMQP 1.0 listener on ports 5672 (plain) and 5671
(TLS). Enable it once, then jump to the section for your client.
**Drop-in level:** varies by client — JMS is a **client-swap**, native AMQP 1.0 is
**endpoint/client**, and ActiveMQ is **client-swap** (Java) or **endpoint** (STOMP,
MQTT) — see the [drop-in levels legend](/deploy/scenarios/replace#drop-in-levels).
## 1 · Enable the connector [#1--enable-the-connector]
The AMQP 1.0 connector is disabled by default — enable it and publish its ports:
## JMS (Qpid JMS) [#jms]
**Client:** Apache Qpid JMS **2.x** (jakarta namespace), or the **1.x** line if your
codebase still targets `javax.jms`.
Swap only the `ConnectionFactory` — the JMS calls you already wrote (`createSession`,
`createProducer`, `createConsumer`, and so on) do not change.
```java
// Before — any other JMS provider
// ConnectionFactory cf = new ActiveMQConnectionFactory("tcp://old-broker:61616");
// After — Qpid JMS over KubeMQ's AMQP 1.0 connector
ConnectionFactory cf = new JmsConnectionFactory("amqp://kubemq-host:5672");
```
**Verify:** send a `TextMessage` to `queues/orders` and receive it back with
`AUTO_ACKNOWLEDGE`. Open the KubeMQ dashboard's AMQP 1.0 page — you should see one
connection, one sender link, and one receiver link.
## Native AMQP 1.0 (go-amqp) [#native-amqp-1-0]
**Client:** `github.com/Azure/go-amqp` v1.7.0 (AMQP.NET Lite and Apache Qpid Proton
clients migrate the same way — change only the endpoint).
```go
// Before
// conn, err := amqp.Dial(ctx, "amqp://old-broker:5672", nil)
// After
conn, err := amqp.Dial(ctx, "amqp://kubemq-host:5672",
&amqp.ConnOptions{SASLType: amqp.SASLTypePlain("svc-orders", "")})
```
**Verify:** attach a sender to `/queues/orders`, send one message, then attach a
receiver and accept it. Expected output:
```text
received: {"id":"1","item":"widget"}
```
## ActiveMQ [#activemq]
**Client:** Apache Qpid JMS — the same client as the [JMS](#jms) section above.
ActiveMQ's own **OpenWire** protocol is **not supported**; a Java/JMS ActiveMQ
application migrates by swapping its `ConnectionFactory` to Qpid JMS, exactly like the
JMS path.
```java
// Before — ActiveMQ Classic or Artemis (OpenWire)
// ConnectionFactory cf = new ActiveMQConnectionFactory("tcp://activemq:61616");
// After — Qpid JMS over KubeMQ
ConnectionFactory cf = new JmsConnectionFactory("amqp://kubemq-host:5672");
```
**Verify:** same as [JMS](#jms) — send and receive a `TextMessage` on `queues/orders`.
Not a Java client? ActiveMQ's STOMP and MQTT clients don't ride this connector —
repoint them at KubeMQ's own [STOMP](/deploy/scenarios/replace/replace-stomp) or
[MQTT](/deploy/scenarios/replace/replace-mqtt) on-ramp instead.
## What carries over — and what doesn't [#what-carries-over--and-what-doesnt]
Queues, Events, and durable Events Store subscriptions carry over cleanly on every path
above. JMS/AMQP transactions (including XA) and a client-settable dead-letter queue do
not — a poison message past `MaxReceiveCount` is silently dropped, not dead-lettered.
## Didn't work? [#didnt-work]
* **Connection refused on 5672** — the AMQP 1.0 connector is opt-in; confirm
`CONNECTORS_AMQP10_ENABLE=true` was set on the container that's running (the literal
`10` stays attached to `AMQP` with no underscore — `CONNECTORS_AMQP_1_0_ENABLE` does
not bind).
* **`JMSSecurityException` / SASL auth failure** — if the server has authentication
enabled, pass a KubeMQ JWT as the SASL PLAIN password; the username is audit-only.
* **ActiveMQ client won't connect** — check it isn't still using OpenWire
(`tcp://...:61616`); OpenWire has no KubeMQ equivalent, so Java clients move to Qpid
JMS and non-Java clients move to the STOMP or MQTT on-ramp.
# Replace Google Cloud Pub/Sub with KubeMQ (/deploy/scenarios/replace/replace-gcp-pubsub)
You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account).
[Get one](/deploy/license-key)
. Step 1 below starts the broker with the connector enabled.
Point your existing Google Cloud Pub/Sub client at KubeMQ by setting one environment
variable — the connection changes, your topics, subscriptions, and application code
don't.
**Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels))
## 1 · Enable the connector [#1--enable-the-connector]
The GCP Pub/Sub connector is disabled by default. Start KubeMQ with it turned on:
## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq]
Set `PUBSUB_EMULATOR_HOST` to the connector's gRPC port. The SDK clears its Google
credentials and dials insecure gRPC automatically — no code change.
Before (real Google Cloud):
```bash
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
export GOOGLE_CLOUD_PROJECT=my-project
```
After (KubeMQ):
```bash
export PUBSUB_EMULATOR_HOST=localhost:8085
export PUBSUB_PROJECT_ID=my-project # arbitrary; the connector ignores the project segment
unset GOOGLE_APPLICATION_CREDENTIALS
```
## 3 · Smoke test [#3--smoke-test]
Run a publish → pull → acknowledge round-trip against the emulator endpoint:
```python
import os, time
from google.cloud import pubsub_v1
os.environ["PUBSUB_EMULATOR_HOST"] = "localhost:8085"
os.environ["PUBSUB_PROJECT_ID"] = "smoke-test"
pub = pubsub_v1.PublisherClient()
sub = pubsub_v1.SubscriberClient()
t = pub.topic_path("smoke-test", "smoke-topic") # -> gcp.smoke-topic
s = sub.subscription_path("smoke-test", "smoke-sub") # -> gcp.sub.smoke-sub
pub.create_topic(request={"name": t})
sub.create_subscription(request={"name": s, "topic": t})
future = pub.publish(t, data=b"smoke-payload")
print(f"published id={future.result()}")
time.sleep(0.5)
resp = sub.pull(request={"subscription": s, "max_messages": 1})
assert len(resp.received_messages) == 1
sub.acknowledge(request={"subscription": s, "ack_ids": [resp.received_messages[0].ack_id]})
print("smoke test PASSED — message received and acked")
```
Expected output:
```text
published id=1
smoke test PASSED — message received and acked
```
(adapted from the [full verification smoke
test](/connectors/gcp-pub-sub/reference/migration-from-gcp#verification-smoke-test))
## What carries over — and what doesn't [#what-carries-over--and-what-doesnt]
Your Pub/Sub client code, topic/subscription calls, and message attributes carry over
unchanged. GCP auth, IAM, and TLS don't — the connector runs in emulator mode with no
authentication — and message ordering is node-local, not cluster-wide.
## Didn't work? [#didnt-work]
* **Wrong port** — the connector listens on `8085`, not Google's default. Confirm
`PUBSUB_EMULATOR_HOST` points at `localhost:8085`.
* **Connector not enabled** — a stock server doesn't bind port `8085` until
`CONNECTORS_GCP_ENABLE=true` is set. Check `docker logs kubemq` for a bind error.
* **Client still tries real GCP auth** — if `PUBSUB_EMULATOR_HOST` isn't set (or
credentials weren't cleared), the SDK authenticates against Google instead of the
connector.
# Replace Kafka with KubeMQ (/deploy/scenarios/replace/replace-kafka)
You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account).
[Get one](/deploy/license-key)
. Step 1 below starts the broker with the connector enabled.
Your Kafka client keeps its library, its code, and the real Kafka wire protocol — only
`bootstrap.servers` changes, from your Kafka cluster to a local KubeMQ server.
**Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels))
## 1 · Enable the connector [#1--enable-the-connector]
The Kafka connector is disabled by default — enable it and publish its ports:
## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq]
```text title="bootstrap.servers"
# Before (Kafka)
bootstrap.servers=your-kafka-cluster.example.com:9092
# After (KubeMQ)
bootstrap.servers=localhost:9092
```
Same client library, same producer/consumer code — only the seed broker address moves.
## 3 · Smoke test [#3--smoke-test]
Produce and consume one record with `kcat` (the librdkafka CLI needs no client code at all):
```bash title="produce"
echo "hello kubemq" | kcat -b localhost:9092 -t orders -P
```
```bash title="consume"
kcat -b localhost:9092 -G orders-group -o beginning -c 1 orders
```
`-o beginning` reads from the start of the topic, so a brand-new consumer group still
sees the record you just produced (without it, a fresh group starts at the latest offset
and the consumer would block waiting for the next message).
The producer is silent on success; the consumer prints the record body (kcat also
logs consumer-group rebalance lines to stderr). You should see:
```text
hello kubemq
```
This is the same round-trip as [Getting Started](/connectors/kafka/tutorials/getting-started),
which also covers the same produce/consume flow in Go, Python, Java, JavaScript, C#, Ruby,
and Rust — no `kcat` required.
## What carries over — and what doesn't [#what-carries-over--and-what-doesnt]
Your client library, code, topic and consumer-group names, and produce/consume semantics all
carry over unchanged — only the broker address moves. Bringing an *existing* cluster's topic
history and consumer offsets across (rather than starting fresh) is a separate step covered
by the migration guide's `kmq migrate` tool.
## Didn't work? [#didnt-work]
* **Wrong port** — confirm `9092` (plain) or `9093` (TLS) is published and matches
`bootstrap.servers`.
* **Connector not enabled** — a stock server doesn't bind the Kafka listener until
`CONNECTORS_KAFKA_ENABLE=true` is set.
* **Auth mismatch** — if you enabled authentication, confirm your client's SASL/JWT
credentials match.
# Replace MQTT with KubeMQ (/deploy/scenarios/replace/replace-mqtt)
You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account).
[Get one](/deploy/license-key)
. Step 1 below starts the broker with the connector enabled.
Your existing MQTT client keeps its library, its code, and its publish/subscribe
calls — only the **broker host** changes. MQTT 3.1.1 and 5.0 clients both connect
unchanged (MQTT 3.1 is rejected at CONNECT).
**Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels))
## 1 · Enable the connector [#1--enable-the-connector]
The MQTT connector is disabled by default — enable it and publish its ports:
**The enable variable is `CONNECTORSMQTT_ENABLE`** — no underscore between
`CONNECTORS` and `MQTT`. `CONNECTORS_MQTT_ENABLE` is silently ignored.
## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq]
```bash title="Before — existing MQTT broker"
mqtt://broker.example.com:1883
mqtts://broker.example.com:8883
```
```bash title="After — KubeMQ"
mqtt://localhost:1883
mqtts://localhost:8883
```
Only the host:port changes. Topics keep working as-is if they don't start with a
reserved prefix (`events/`, `store/`, `queues/`, `commands/`, `queries/`) — those
prefixes select a KubeMQ messaging pattern; everything else routes through the
default pattern.
## 3 · Smoke test [#3--smoke-test]
Adapted from the MQTT connector's
[verification smoke test](/connectors/mqtt/scenarios/migration#verification-smoke-test):
```bash title="Terminal 1 — subscribe"
mosquitto_sub -h localhost -p 1883 -t 'events/smoke/test'
```
```bash title="Terminal 2 — publish"
mosquitto_pub -h localhost -p 1883 -t 'events/smoke/test' -m '{"ok":true}'
```
You should see, in Terminal 1:
```text
{"ok":true}
```
## What carries over — and what doesn't [#what-carries-over--and-what-doesnt]
Your MQTT library, connection code, and publish/subscribe calls carry over
unchanged for both 3.1.1 and 5.0. Retained messages are rejected (not silently
dropped), MQTT 3.1 is rejected at CONNECT, and RPC over MQTT only works on 5.0 —
see the full deviations list below.
## Didn't work? [#didnt-work]
* **Connector not enabled** — the MQTT connector is opt-in; confirm
`CONNECTORSMQTT_ENABLE=true` (exact spelling, no underscore before `MQTT`) was set
when the container started.
* **CONNACK rejected the connection** — check your client isn't sending MQTT 3.1
(protocol level 3); use 3.1.1 or 5.0.
* **Message never arrives** — subscribe before you publish (Events is
fire-and-forget), and check your topic's first segment isn't unintentionally
colliding with a reserved prefix (`events/`, `store/`, `queues/`, `commands/`,
`queries/`).
# Replace RabbitMQ with KubeMQ (/deploy/scenarios/replace/replace-rabbitmq)
You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account).
[Get one](/deploy/license-key)
. Step 1 below starts the broker with the connector enabled.
Your RabbitMQ client keeps its library and its code — only the AMQP connection string
changes, from your RabbitMQ host to a local KubeMQ server.
**Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels))
## 1 · Enable the connector [#1--enable-the-connector]
The RabbitMQ (AMQP 0-9-1) connector is disabled by default — enable it and publish its ports:
## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq]
```text title="AMQP URI swap"
# Before (RabbitMQ)
amqp://user:password@rabbitmq.example.com:5672/
# After (KubeMQ)
amqp://user:password@localhost:5672/
```
Same `pika` (or any AMQP 0-9-1 client library) code — `queue_declare`, `basic_publish`, and
`basic_consume` all work unchanged.
## 3 · Smoke test [#3--smoke-test]
Publish then consume one message with `pika`:
```python title="publish"
import pika
conn = pika.BlockingConnection(pika.URLParameters("amqp://user:pass@localhost:5672/"))
ch = conn.channel()
ch.queue_declare(queue="smoke-test", durable=True)
ch.basic_publish(exchange="", routing_key="smoke-test", body=b"hello-kubemq")
conn.close()
print("published ok")
```
```python title="consume"
import pika
conn = pika.BlockingConnection(pika.URLParameters("amqp://user:pass@localhost:5672/"))
ch = conn.channel()
method, props, body = ch.basic_get(queue="smoke-test", auto_ack=True)
assert body == b"hello-kubemq", f"unexpected body: {body!r}"
conn.close()
print("consume ok:", body)
```
You should see:
```text
published ok
consume ok: b'hello-kubemq'
```
This is the same publish/consume check as [Migrating from RabbitMQ → Verification Smoke
Test](/connectors/rabbitmq/reference/migration-from-rabbitmq#verification-smoke-test).
## What carries over — and what doesn't [#what-carries-over--and-what-doesnt]
Your client library, code, queue and exchange declarations, publisher confirms, and DLX all
carry over unchanged — only the connection string moves. AMQP transactions don't carry over
(use publisher confirms instead), and a handful of declare arguments are accepted but inert —
see the migration guide for the full deviation list.
## Didn't work? [#didnt-work]
* **Wrong port** — confirm `5672` (plain) or `5671` (TLS) is published and matches your
connection string.
* **Connector not enabled** — a stock server doesn't bind the AMQP listener until
`CONNECTORS_AMQP_ENABLE=true` is set.
* **Auth mismatch** — if authentication is enabled, the SASL PLAIN password must be a valid
KubeMQ JWT.
# Replace AWS SQS & SNS with KubeMQ (/deploy/scenarios/replace/replace-sqs-sns)
You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account).
[Get one](/deploy/license-key)
. Step 1 below starts the broker with the connector enabled.
Your AWS SDK (or an existing LocalStack-style setup) already talks SQS and SNS over
HTTP — point its **endpoint** at KubeMQ and everything else — SDK, code, request
shapes — stays the same.
**Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels))
## 1 · Enable the connector [#1--enable-the-connector]
The AWS connector is disabled by default — enable it and publish its port:
Port `4566` is the AWS SDK `endpoint_url` / LocalStack-style convention
(`Connectors.Aws.Port` on the client side) — not a fixed broker listener, so it
doesn't appear in KubeMQ's shared port tables. That's not a typo.
## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq]
```bash title="Before — real AWS"
# No endpoint override; the SDK talks to the regional AWS endpoint.
```
```bash title="After — KubeMQ"
export AWS_ENDPOINT_URL_SQS=http://localhost:4566
export AWS_ENDPOINT_URL_SNS=http://localhost:4566
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1 # any value works; not enforced
```
Or override the endpoint per-client in boto3:
```python
import boto3
sqs = boto3.client(
"sqs",
endpoint_url="http://localhost:4566",
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
```
Dummy credentials are still required — the connector's accept-any mode doesn't
verify the signature value, but the SDK must still form a syntactically valid SigV4
request.
## 3 · Smoke test [#3--smoke-test]
Adapted from the AWS connector's
[verification smoke test](/connectors/aws/reference/migration-from-aws#verification-smoke-test):
```python
import boto3
sqs = boto3.client(
"sqs",
endpoint_url="http://localhost:4566",
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
queue_url = sqs.create_queue(QueueName="smoke-test")["QueueUrl"]
sqs.send_message(QueueUrl=queue_url, MessageBody="smoke-test-payload")
resp = sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5)
msg = resp["Messages"][0]
assert msg["Body"] == "smoke-test-payload"
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"])
print("Smoke test PASSED.")
```
You should see:
```text
Smoke test PASSED.
```
## What carries over — and what doesn't [#what-carries-over--and-what-doesnt]
Your AWS SDK/CLI, code, and the SQS/SNS wire protocol carry over unchanged — only
the endpoint moves. Existing queues/topics are **not** auto-migrated (recreate them
against KubeMQ) and a few AWS features (SNS email/SMS/Lambda subscriptions,
connector-side TLS) aren't supported — see the deviations list below.
## Didn't work? [#didnt-work]
* **Connection refused** — the connector is opt-in; confirm `CONNECTORS_AWS_ENABLE=true`
was set when the container started.
* **"missing credentials" from the SDK** — accept-any mode still requires a
syntactically valid SigV4 request; set dummy `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY` / region even though their values aren't checked.
* **Wrong port** — the AWS connector listens on `4566`, not a broker port you may
already have mapped (gRPC `50000`, REST `9090`, dashboard `8080`).
# Replace STOMP with KubeMQ (/deploy/scenarios/replace/replace-stomp)
You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account).
[Get one](/deploy/license-key)
. Step 1 below starts the broker with the connector enabled.
Point your existing STOMP client at KubeMQ by changing the broker host — the connection
changes, your STOMP client library and destination code don't.
**Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels))
## 1 · Enable the connector [#1--enable-the-connector]
The STOMP connector is disabled by default. Start KubeMQ with it turned on:
The enable variable is `CONNECTORS_STOMP_ENABLE` — with the underscore between
`CONNECTORS` and `STOMP`. `CONNECTORSSTOMP_ENABLE` (no underscore) is silently ignored.
## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq]
Change the broker host to KubeMQ's plain-TCP port. Everything else — the client
library, `SEND`/`SUBSCRIBE` calls, destination names — stays the same.
Before:
```python
import stomp
conn = stomp.Connection([("stomp-host", 61613)])
conn.connect("user", "password", wait=True)
```
After (KubeMQ):
```python
import stomp
conn = stomp.Connection([("localhost", 61613)])
conn.connect("user", "password", wait=True) # any login/passcode when auth is disabled
```
## 3 · Smoke test [#3--smoke-test]
In one terminal, subscribe to a queue destination:
```python
# consumer.py
import stomp, time
class Listener(stomp.ConnectionListener):
def __init__(self, conn):
self._conn = conn
def on_message(self, frame):
print("RECEIVED:", frame.body)
self._conn.ack(frame.headers["ack"])
conn = stomp.Connection([("localhost", 61613)])
conn.set_listener("", Listener(conn))
conn.connect(wait=True)
conn.subscribe(destination="/queue/smoke-test", id="s1", ack="client-individual")
time.sleep(10)
conn.disconnect()
```
In a second terminal, publish one message:
```python
# producer.py
import stomp
conn = stomp.Connection([("localhost", 61613)])
conn.connect(wait=True)
conn.send(destination="/queue/smoke-test", body="hello from stomp")
conn.disconnect()
print("sent")
```
Expected output in the consumer terminal:
```text
RECEIVED: hello from stomp
```
(from the [full verification smoke
test](/connectors/stomp/scenarios/migration-from-stomp#verification-smoke-test))
## What carries over — and what doesn't [#what-carries-over--and-what-doesnt]
Your STOMP client library, connection API, and all five destination types (`/queue`,
`/topic`, `/topic-store`, `/command`, `/query`) carry over unchanged. STOMP transactions
and message selectors don't — both are rejected outright.
## Didn't work? [#didnt-work]
* **Wrong port** — plain TCP is `61613`; TLS is `61614`. Confirm your client dials the
one you enabled.
* **Connector not enabled** — a stock server doesn't bind the STOMP listener until
`CONNECTORS_STOMP_ENABLE=true` is set (verbatim, with the underscore). Check `docker
logs kubemq` for a bind error.
* **Auth mismatch** — if `Authentication.Enable = true` on the server, the CONNECT
`passcode` must carry a valid KubeMQ JWT; with auth disabled, any value works.
# Authentication (/connectors/amqp/how-to/authentication)
This guide explains how a native AMQP 1.0 client proves *who it is* to the KubeMQ AMQP 1.0
connector (authentication / SASL) and *what it may do* once attached (authorization). It covers
the three SASL mechanisms, how the client identity (`ClientID`) is derived, why `container-id`
matters, and the audit events the connector emits.
On a stock dev broker, authentication is **off** and clients connect **ANONYMOUS** — so the
examples clone-and-run with no credentials. SASL **PLAIN** with a KubeMQ JWT is the one
credentialed mechanism that also runs on a stock broker; SASL **EXTERNAL** requires mTLS (see
[TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls)).
## The three SASL mechanisms [#the-three-sasl-mechanisms]
The connector computes the SASL mechanism list **per connection** from the auth and TLS context
and offers it in a fixed order: **EXTERNAL → PLAIN → ANONYMOUS**.
| Mechanism | Offered when | Credential | Identity (`ClientID`) becomes |
| --------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| **`PLAIN`** | **always** | RFC 4616 `authzid\x00authcid\x00passwd`; the **password is the KubeMQ JWT** | auth on → `ClientID` from the JWT; auth off → sanitized SASL username, else the `container-id` |
| **`ANONYMOUS`** | only when the auth service is **disabled** | none | sanitized `container-id` |
| **`EXTERNAL`** | only on an **mTLS** connection with a *verified* client certificate | the certificate itself (no JWT) | the cert **Subject CN**, sanitized |
### PLAIN — the documented contract [#plain--the-documented-contract]
PLAIN is the mechanism to use when authentication is enabled. The credential layout is the
standard RFC 4616 triple `authzid \x00 authcid \x00 passwd`:
* **`passwd` (password) = the KubeMQ JWT.** It is validated server-side; on success the
connector takes the `ClientID` from the JWT's claims.
* **`authcid` (username) is audit-only / informational when auth is on.** It does **not** become
the identity — the JWT does. (When auth is *off*, the username is used as a convenience
identity; see precedence below.)
* The SASL initial-response binary is capped at **16 KiB**. A larger response is rejected as a
malformed/hostile peer. A KubeMQ JWT fits comfortably.
* **No SCRAM-SHA-256, GSSAPI, or Azure CBS.** PLAIN is the only credentialed mechanism.
Most clients accept a `(username, password)` pair — put the JWT in the **password** slot:
```go
// Azure/go-amqp — SASL PLAIN: username is audit-only, password is the KubeMQ JWT.
conn, err := amqp.Dial(ctx, "amqp://broker:5672", &amqp.ConnOptions{
SASLType: amqp.SASLTypePlain("audit-username", os.Getenv("KUBEMQ_AMQP_JWT")),
})
if err != nil {
log.Fatalf("dial (bad/expired JWT? auth-disabled broker?): %v", err)
}
defer conn.Close()
```
```python
# python-qpid-proton — SASL PLAIN: username audit-only, password is the KubeMQ JWT.
# allow_insecure_mechs permits PLAIN over plaintext amqp://; use amqps:// in production.
conn = BlockingConnection(
"amqp://broker:5672",
user="audit-username",
password=os.environ["KUBEMQ_AMQP_JWT"],
allowed_mechs="PLAIN",
allow_insecure_mechs=True,
)
```
```java
// qpid-jms — the username/password passed to createConnection become the SASL
// PLAIN authcid/password (password = the KubeMQ JWT). Pin PLAIN on the URI.
String url = "amqp://broker:5672?amqp.saslMechanisms=PLAIN";
JmsConnectionFactory factory = new JmsConnectionFactory(url);
Connection connection = factory.createConnection(
"audit-username", System.getenv("KUBEMQ_AMQP_JWT"));
connection.start();
```
```csharp
// AMQPNetLite — an Address carrying User + Password makes the client negotiate PLAIN
// (password = the KubeMQ JWT). The host/port/user/password ctor avoids URL-encoding it.
var baseAddress = new Address("amqp://broker:5672");
var connectAddress = new Address(
baseAddress.Host, baseAddress.Port,
"audit-username", Environment.GetEnvironmentVariable("KUBEMQ_AMQP_JWT"),
"/", baseAddress.Scheme);
var connection = await Connection.Factory.CreateAsync(connectAddress);
```
```typescript
// rhea / rhea-promise — setting both username + password makes rhea select PLAIN
// (password = the KubeMQ JWT).
const connection = await container.connect({
host: "broker",
port: 5672,
container_id: `kubemq-amqp10-js-${process.pid}`,
username: "audit-username",
password: process.env.KUBEMQ_AMQP_JWT,
});
```
```rust
// fe2o3-amqp — SASL PLAIN: username audit-only, password is the KubeMQ JWT.
let mut connection = Connection::builder()
.container_id("amqp10-client")
.sasl_profile(SaslProfile::Plain {
username: "audit-username".to_string(),
password: std::env::var("KUBEMQ_AMQP_JWT")?,
})
.open("amqp://broker:5672")
.await?;
```
### ANONYMOUS — the auth-off default [#anonymous--the-auth-off-default]
When the broker's authentication service is disabled, the connector offers `ANONYMOUS`. This is
the mechanism the runnable examples rely on: no credential is sent and the identity falls back to
the `container-id`. **ANONYMOUS is only offered when auth is off** — requesting it against an
auth-enabled broker is rejected as an auth failure (it was never advertised).
### EXTERNAL — mTLS, cert CN → ClientID [#external--mtls-cert-cn--clientid]
`EXTERNAL` is offered **only** when the TLS handshake presented a client certificate that the
listener *verified* (the mTLS listener). The identity is the client certificate's **Subject
CN**, sanitized to a valid `ClientID`; no JWT is needed. An empty CN is rejected. Because EXTERNAL
depends on mTLS, it is covered alongside TLS — see
[TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls).
## Identity precedence [#identity-precedence]
The connector resolves the client identity in this order, depending on the negotiated mechanism
and whether auth is enabled:
1. **PLAIN + auth on** → the JWT's `ClientID` claim.
2. **PLAIN + auth off** → the sanitized SASL username (`authcid`); if empty, the `container-id`.
3. **EXTERNAL** → the client certificate's Subject CN (sanitized).
4. **ANONYMOUS** → the sanitized `container-id`.
5. **bare (no SASL)** → the sanitized `container-id`; if empty, a generated `amqp10-`.
Sanitization caps the value at **256** characters and maps every character outside
`[a-zA-Z0-9_-]` to `_`.
## `container-id` is required — and must be stable for durable subscribers [#container-id-is-required--and-must-be-stable-for-durable-subscribers]
Every AMQP 1.0 `OPEN` **must** carry a non-empty `container-id`. An empty value is rejected with
`CLOSE(amqp:invalid-field, "open.container-id is required")`. The value is sanitized
(`[a-zA-Z0-9_-]`, ≤ 256).
`container-id` matters for two reasons beyond identity:
* It becomes the `ClientID` whenever there is **no SASL identity** (ANONYMOUS / bare / auth-off
PLAIN-with-empty-username).
* It is **half of the durable-subscription identity**. A durable events-store subscriber that
reconnects with a *different* `container-id` will not resume its old position — it becomes a
different durable identity.
**Set a stable `container-id` for any durable subscriber.** Because the container-id is half the
durable identity, a subscriber that lets its container-id drift across reconnects (e.g. a
randomly generated one) will never resume — it creates a new subscription each time. See
[Reliability](/connectors/amqp/how-to/reliability).
## Authorization — enforced at attach, per resource [#authorization--enforced-at-attach-per-resource]
Once authenticated, the connector enforces a Casbin policy **at `ATTACH` time**, against the
`ClientID`, the resolved pattern (mapped to a resource name), the channel, and the link role. If
no authorizer is wired (auth off), nothing is enforced.
| Link the client attaches | Server role | Permission enforced |
| ---------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------- |
| **Receiver** from `/` (client *consumes*) | server-sender | **Read** on `(ClientID, resource, channel)` |
| **Sender** to a fixed `/` (client *produces*) | server-receiver | **Write** on `(ClientID, resource, channel)` |
| **Sender** with a **null (anonymous) target** | server-receiver | **deferred** — each message is authorized **per-message with Write** on its `properties.to` |
| **`/responses/`** (RPC reply token) | server-receiver | **not enforced** — connection-scoped reply token |
* The resource name maps the pattern: `events-store` → `events_store`; `queues` / `events` /
`commands` / `queries` map to themselves.
* **Anonymous-terminus links** cannot be checked at attach because there is no fixed channel yet.
Instead, the transfer layer authorizes **each message** with a **Write** check on the message's
`to`, backed by a short-lived LRU cache. See
[Addressing](/connectors/amqp/concepts/addressing).
A denied authorization closes the link with `DETACH(amqp:unauthorized-access)` and a generic
description. **No policy internals leak.** Your client should surface the
`amqp:unauthorized-access` condition and treat it as a permission error, not retry it as
transient.
## Audit events — exactly two [#audit-events--exactly-two]
The connector's audit surface for AMQP 1.0 lives entirely in the SASL layer and emits **only two
event types**:
| Audit event | Emitted on | Fields |
| ------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------- |
| **`auth.success`** | successful SASL authentication | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Metadata{mechanism}` |
| **`auth.failure`** | failed/rejected SASL authentication | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Error` (sanitized), `Metadata{mechanism}` |
The AMQP 1.0 connector does **not** emit `client.connected` or `client.disconnected` audit
events. Do not build alerting, dashboards, or compliance reporting that depends on
connection-lifecycle audit events from this connector — they are not produced. The only audit
signal is authentication success/failure (with the SASL mechanism and source IP). For
connection/link visibility, use the dashboard API and Prometheus metrics — see
[Connections endpoint](/connectors/amqp/reference/connections-endpoint).
## Quick decision guide [#quick-decision-guide]
| You want… | Do this |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| Clone-and-run on a stock dev broker | Connect ANONYMOUS (no credentials) |
| Authenticate with KubeMQ identity | SASL **PLAIN**, JWT in the **password** slot; username is audit-only |
| Authenticate with a client certificate | mTLS + SASL **EXTERNAL** (cert CN → ClientID) — see [TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls) |
| Resume a durable events-store subscription | Set a **stable `container-id`** (it is half the durable identity) |
| Diagnose a permission failure | Look for `DETACH(amqp:unauthorized-access)`; check the Read/Write policy for that channel |
## Related [#related]
# Flow Control (/connectors/amqp/how-to/flow-control)
In AMQP 1.0, **link credit** is what publisher confirms and consumer prefetch/QoS are in other
protocols: it is the unit of flow control. A sender may not transmit a `TRANSFER` until the
receiver has granted `link-credit > 0` via a `FLOW`. This guide is the practical playbook for
driving the KubeMQ AMQP 1.0 connector's credit machinery — and, first and foremost, for avoiding
the two ways credit mismanagement silently loses data.
For the conceptual credit model, see the
[Events](/connectors/amqp/concepts/events) and
[Events Store](/connectors/amqp/concepts/events-store) pattern pages.
## The two data-loss footguns — read this first [#the-two-data-loss-footguns--read-this-first]
These are the two most expensive mistakes you can make against this connector. Both lose
messages **silently** — no error, no `DISPOSITION`, no `DETACH` (footgun A) — because the events
and events-store patterns deliver **pre-settled** (at-most-once). Internalize them before you
write a subscriber.
**Footgun A — Events at 0 credit are silently DROPPED.** On an `events/` consume link, a
message that arrives while your **link-credit is 0 is silently dropped and counted** — there is
no error and no `DISPOSITION`. This is true at-most-once: the message is simply gone. It bites a
slow consumer that lets its credit drain, or a subscriber that attaches *after* a publish (events
have no replay). **Defense: grant credit continuously.** Open the receiver with a healthy standing
credit and replenish *eagerly*, well before it hits 0. Watch
`kubemq_amqp10_events_dropped_no_credit_total` — a non-zero value is silent data loss.
**Footgun B — Events-Store stalled credit loses the buffered, already-acked window.** An
`events-store/` consume link fronts the durable subscription with a **deliver-first ring
buffer** (cap `MaxUnsettledPerLink` ≈ 1024) so the broker callback never stalls. The buffer's
positions are **auto-acked *before* you take delivery**. If the buffer fills while your credit
stays at 0, the link `DETACH`es with `amqp:resource-limit-exceeded` (`"credit stalled"`) and the
**entire buffered window — already auto-acked — is lost**; a durable re-attach resumes *after*
it. **Defense: size `MaxUnsettledPerLink` to your real prefetch and replenish credit
aggressively** so the buffer never fills with credit at zero. The lost window is counted in
`kubemq_amqp10_events_store_dropped_stalled_total`.
**Why queues are safe by contrast.** A queue consume link never drops on low credit: when you
stop granting, KubeMQ simply stops delivering and the messages wait in the queue. The drop
footguns are specific to the **pre-settled pub/sub** patterns (events, events-store).
## Who grants credit — the rule that governs everything [#who-grants-credit--the-rule-that-governs-everything]
The single most important distinction: **on a *consume* (server-sender) link, YOU must grant
credit; on a *produce* (server-receiver) link, the *server* grants credit.**
| Your link | Server role | Who grants credit | If credit is 0… |
| -------------------------------------------------- | --------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Receiver** from `/` (you *consume*) | server-sender | **you (the client)** must `FLOW` `link-credit > 0` | **queues**: delivery pauses, messages wait. **events**: dropped (footgun A). **events-store**: buffered then stalled-`DETACH` (footgun B) |
| **Sender** to `/` (you *produce*) | server-receiver | **the server** grants you credit | you may not `TRANSFER` until you receive the server's `FLOW` |
### Produce path — the server grants you credit [#produce-path--the-server-grants-you-credit]
On attach, a server-receiver link **immediately emits a `FLOW`** granting
`link-credit = MaxUnsettledPerLink` (default 1024, clamped `[1, 1<<20]`; fallback 256 if unset).
You **MUST NOT** send a `TRANSFER` before you receive that credit. As you complete deliveries the
server **replenishes** the window (a fresh `FLOW`) when remaining credit falls below half, so a
steady producer never stalls. The session `incoming-window` (2048) is a secondary bound.
### Consume path — you grant the server credit [#consume-path--you-grant-the-server-credit]
A server-sender link delivers **nothing** until you send a `FLOW` with `link-credit > 0`. The
effective credit the server may use is
`(flow.delivery-count + flow.link-credit) − server.delivery-count`, clamped ≥ 0.
* For **automatic** credit, grant a modest standing credit (e.g. **100–1000**, but **≤
`MaxUnsettledPerLink` = 1024**) when you open the receiver, and let your client replenish on
settlement.
* For **manual** credit control (`IssueCredit` / `DrainCredit`), open the receiver with
**`Credit: -1`** — otherwise your client auto-manages credit and your manual calls fight it.
## Prefetch, `GetBatchSize`, and `MaxUnsettledPerLink` [#prefetch-getbatchsize-and-maxunsettledperlink]
These three knobs shape how the connector pulls from the broker and how much it will hold
unsettled:
| Knob | Default | Meaning |
| -------------------------- | ---------- | ------------------------------------------------------------------------------------------------ |
| **link-credit (prefetch)** | you choose | the standing credit you grant a consume link = your prefetch depth |
| **`GetBatchSize`** | `32` | the per-`Get` ceiling on a **queue** consume link |
| **`MaxUnsettledPerLink`** | `1024` | the per-link unsettled / pub-sub buffer cap; also drives the **inbound** (produce) credit window |
For a queue consume link, each `Get` reserves
`min(credit, GetBatchSize=32, MaxUnsettledPerLink − unsettled)` and issues a downstream `Get`
with a 1000 ms long-poll. So even with high standing credit, a single `Get` pulls at most 32
messages — credit controls overall in-flight depth, `GetBatchSize` controls batch granularity.
`MaxUnsettledPerLink` is the dial that protects you from footgun B — it is the events-store
ring-buffer cap. It is a server config field (`CONNECTORS_AMQP10_MAX_UNSETTLED_PER_LINK`); you
respect it client-side by keeping your standing credit ≤ it and replenishing eagerly. See
[Configuration](/connectors/amqp/concepts/configuration).
## Drain [#drain]
Drain is how a consumer says "give me everything you have, then stop." Send a `FLOW` with
`drain=true`. The server then:
1. **advances `delivery-count`** by the remaining credit,
2. **zeroes the credit**, and
3. **echoes a `FLOW`** with `link-credit=0, drain=true` (the drain response).
Drain completes **promptly** — it does not hang. A held remainder (messages that didn't fit the
drained credit) **resumes on a fresh `IssueCredit`**. Two requirements your client must honor:
* A `FLOW` **MUST carry `next-incoming-id`** once the session is established.
* To drive drain manually, the receiver must be in manual-credit mode (`Credit: -1`).
## Putting it together [#putting-it-together]
| Pattern | Consume-side credit hygiene |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Queues** | Safe: grant standing credit; if you stop granting, messages wait. No drop. |
| **Events** | **Footgun A.** Grant generous standing credit, replenish *before* it hits 0, subscribe before publishing. A gap at 0 credit = silent loss. |
| **Events-Store** | **Footgun B.** Size `MaxUnsettledPerLink` to your prefetch; replenish aggressively. A genuine stall overflows the deliver-first buffer and loses the already-acked window. |
| **Commands / Queries (RPC)** | The responder pump runs under credit; grant the responder credit and grant the dynamic reply node credit so replies can land. |
## Monitoring [#monitoring]
Watch these metrics — a non-zero value on the first two **is silent data loss**:
| Metric | Meaning |
| -------------------------------------------------- | ------------------------------------------------------------------------ |
| `kubemq_amqp10_events_dropped_no_credit_total` | **Footgun A** — events dropped at 0 credit |
| `kubemq_amqp10_events_store_dropped_stalled_total` | **Footgun B** — events-store buffered window lost to a credit stall |
| `kubemq_amqp10_transfers_in_dropped_total` | inbound transfers dropped (oversize / no-consumer / pre-settled failure) |
See [Connections endpoint](/connectors/amqp/reference/connections-endpoint) for the full
metric and dashboard surface.
## Related [#related]
# Reliability (/connectors/amqp/how-to/reliability)
AMQP 1.0 reliability is **settlement modes + delivery state**, not publisher confirms and not
numeric reason codes. This guide is the practical playbook for getting the delivery guarantee you
want from the KubeMQ AMQP 1.0 connector: which settlement modes exist (and which is rejected), how
each delivery-state outcome maps to a KubeMQ queue action, how durable subscriptions resume,
what happens on disconnect (nothing is lost), and why there is no connector dead-letter exchange.
For the credit machinery that governs *when* deliveries arrive — and the pre-settled-pattern
data-loss footguns — see [Flow control](/connectors/amqp/how-to/flow-control).
## Settlement modes — pick your guarantee at ATTACH [#settlement-modes--pick-your-guarantee-at-attach]
Settlement is negotiated when the link attaches.
### `snd-settle-mode` (the produce / out path) [#snd-settle-mode-the-produce--out-path]
| Requested | Server behavior | Guarantee |
| ------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------ |
| **`settled`** | honored — each outbound `TRANSFER` carries `settled=true` and the server acks it immediately at send | **at-most-once** (pre-settled) |
| `unsettled` / `mixed` / absent | server uses **`unsettled`** — sends deliveries unsettled, tracks them, waits for your `DISPOSITION` | **at-least-once** (default) |
* **At-least-once (default):** leave `snd-settle-mode` unset (or `unsettled`). The server keeps
each delivery tracked until you settle it; if you never do (you disconnect), it is requeued.
* **At-most-once:** request `snd-settle-mode=settled`. Use this for high-throughput
fire-and-forget where occasional loss is acceptable. (Events are *always* pre-settled
regardless — see [Events](/connectors/amqp/concepts/events).)
### `rcv-settle-mode` (the consume / in path) [#rcv-settle-mode-the-consume--in-path]
* The server **always** replies `rcv-settle-mode=first`.
* **`rcv-settle-mode=second` is NOT implemented.** Requesting it closes the link with
`DETACH(amqp:not-implemented)` **before the link is built**. Pin `first`.
There is no two-stage (`second`) receiver settlement. Your consumer sends a `DISPOSITION` with a
terminal delivery state and that settles the delivery in one step.
## Delivery-state outcomes → KubeMQ actions [#delivery-state-outcomes--kubemq-actions]
When your client is the **receiver** (consuming a queue), it settles each delivery by sending a
`DISPOSITION` carrying a terminal **delivery state**. The connector maps that state to a KubeMQ
queue `AckRange` (settle/remove) or `NAckRange` (requeue):
| Client delivery state | KubeMQ action | Queue request | Effect on the message |
| ---------------------------------------------------------------- | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **`accepted`** | settle / consume | **AckRange** | removed from the queue |
| **`rejected`** | discard | **AckRange** | discarded; **poison handled by the broker `MaxReceiveQueue` policy — there is NO connector DLX** |
| **`released`** | requeue to tail | **NAckRange** | redelivered with a grown `delivery-count`, `first-acquirer=false`; **increments receive-count** toward `MaxReceiveCount` |
| **`modified{delivery-failed}` / `modified{undeliverable-here}`** | requeue to tail | **NAckRange** | requeued (no per-consumer exclusion) |
| **nil state** (settled, no outcome) | treat as success | **AckRange** | removed |
| **unknown terminal state** | conservatively requeue | **NAckRange** | requeued — **never silently dropped** |
* A `DISPOSITION` may cover a `first..last` delivery-id range; the connector resolves it against
the per-link unsettled map, groups by `RefTransactionId`, and emits one `AckRange`/`NAckRange`
per group. Re-settling an already-settled id is **idempotent**.
* **`rejected` does NOT dead-letter through the connector.** It discards (AckRanges) the message;
whether a repeatedly-failing message is moved aside is a **broker-side `MaxReceiveQueue` poison
policy**, not an AMQP-controllable per-link feature.
**`released` / `modified` increment the receive-count.** Every NAck-for-redelivery bumps
`ReceiveCount`, so a message you keep releasing will eventually hit the broker's `MaxReceiveCount`
cap and be removed even though you never `rejected` it. There is **no requeue-without-increment**.
## Body sections [#body-sections]
A message body must be one of two AMQP sections:
* **`Data`** (binary) — the default; multiple `Data` sections concatenate.
* **`AmqpValue`** (a typed value) — use for typed bodies.
An **empty body is valid** (it becomes an empty `Data` body downstream).
**`AmqpSequence` bodies are rejected.** Only `Data` and `AmqpValue` are supported. A message
carrying an `AmqpSequence` body section gets a `rejected` `DISPOSITION` then a `DETACH` with
`amqp:not-implemented`. Emit `Data` by default and `AmqpValue` for typed bodies; never
`AmqpSequence`.
## Confirming a produce [#confirming-a-produce]
When your client is the **sender**, the *server* is the receiver and settles your delivery for
you:
* On broker success it emits a settled `DISPOSITION(role=receiver, accepted)`.
* On failure it emits `rejected{condition}`: broker-not-ready → `amqp:not-allowed`; decode error
→ `amqp:decode-error`; translate error → `amqp:invalid-field`; array/broker error →
`amqp:internal-error`.
* A **pre-settled** inbound delivery gets **no** disposition; a failure is dropped, logged, and
counted in `kubemq_amqp10_transfers_in_dropped_total`. If you need to know a produce succeeded,
do **not** pre-settle — send unsettled and read the server's `DISPOSITION`.
## Durable subscriptions [#durable-subscriptions]
The **events-store** pattern is a durable, replayable subscription. To consume durably, attach a
**receiver** from `events-store/` with terminus **`expiry-policy = never`**, a **stable
container-id**, and a **stable link `Name`**. On reconnect with the same identity, the
subscription resumes where it left off.
The durable identity is derived from your `container-id` and link name. The practical
consequence: **to resume, reconnect with the same container-id AND the same link name.** Change
either and you get a *different* durable subscription that starts fresh.
Durable subscriptions (and dynamic reply nodes) are **node-local**: a durable subscription
created on node A is not visible from node B. Cluster-wide *uniqueness* of the durable identity is
still enforced, but a durable subscriber must reconnect to the **same node** to resume — use
load-balancer session affinity or a sticky connection.
A durable receiver's start position is set with the link property **`x-opt-kubemq-start`** (it
applies only to `events-store`):
| `x-opt-kubemq-start` value | Meaning |
| ------------------------------ | ----------------------------------------------------------------------- |
| `""` or `new-only` | only messages published **after** the subscription starts (the default) |
| `first` | replay from the **beginning** of stored history |
| `last` | start from the **most recent** stored message |
| `sequence:` | start at sequence number `` (1-based, non-negative) |
| `time:` | start at a wall-clock time |
| `time-delta:` | start `` ago from now |
There is **no "last N by count"** — use `sequence:`, `time:`, or `time-delta:` to bound a replay.
A malformed value (`sequence:abc`, `time:not-a-time`, or an unknown token) is rejected at attach
with `DETACH(amqp:invalid-field)` naming the offending token.
## Redelivery and teardown NAck-all [#redelivery-and-teardown-nack-all]
`released`, `modified`, and **disconnect-with-unsettled** all requeue the delivery to the tail. A
fresh consumer recovers it. A redelivered copy carries a grown `header.delivery-count` and
`first-acquirer=false`, so your consumer can detect a redelivery.
**On link detach, connection close, or graceful shutdown, every unsettled delivery is
`NAckRange`'d exactly once** — returned to the queue tail (the client experiences it as
`released`). A disconnecting at-least-once consumer **loses nothing**: whatever it had not yet
`accepted` is simply redelivered to the next consumer. This guarantee applies to **queue** consume
links — events and events-store are pre-settled and have their own footguns (see
[Flow control](/connectors/amqp/how-to/flow-control)).
## No connector DLX, no visibility timeout [#no-connector-dlx-no-visibility-timeout]
The connector has **no dead-letter exchange and no visibility/redelivery timeout**:
* `rejected` discards via `AckRange`; it does **not** route to a dead-letter destination.
* There is no per-link "make this message invisible for N seconds" verb. Queue receive is
**destructive, credit-based consume only** — no peek, no browse.
* **Poison handling is entirely broker-side**: the broker's `MaxReceiveQueue` / `MaxReceiveCount`
policy removes a message redelivered too many times.
Design your consumer accordingly: `accept` on success, `reject` for a permanently-bad message
(discarded, with the broker policy as your only poison backstop), and `release` / `modify` for a
transient failure you want retried — knowing each retry bumps the receive-count toward the
broker's cap.
## Decision guide [#decision-guide]
| You want… | Do this |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| At-least-once consume (no loss on disconnect) | Consume **unsettled** (default `rcv-settle-mode=first`); `accept` on success; rely on teardown NAck-all |
| At-most-once produce (fire-and-forget) | Request `snd-settle-mode=settled` |
| Confirm a produce succeeded | Send **unsettled** and read the server's `DISPOSITION(accepted/rejected)` |
| Retry a transient failure | `release` (or `modify`) — but each retry **increments the receive-count** |
| Discard a permanently-bad message | `reject` — discarded via AckRange; broker `MaxReceiveQueue` is the poison backstop |
| Resume after reconnect | events-store + stable `container-id` + stable link name + `expiry-policy=never` |
## Related [#related]
# TLS and mTLS (/connectors/amqp/how-to/tls-and-mtls)
The KubeMQ AMQP 1.0 connector exposes TLS on `amqps://:5671`, mutual TLS (mTLS), and SASL
**EXTERNAL** so you can harden it for production. This guide documents how to configure them.
TLS is configured server-side from the top-level **`Security`** block (not from any AMQP-specific
field). The runnable examples all use plain `amqp://` against a stock dev broker; to use
`amqps://`, supply your own certificates and configure the `Security` block — see
[Configuration](/connectors/amqp/concepts/configuration). For the shared TLS/security model across
KubeMQ connectors, see [Auth & security](/connectors/reference/auth-and-security).
## Ports and schemes [#ports-and-schemes]
The connector listens on two ports, both **shared with the AMQP 0-9-1 connector** through the
`amqpmux` listener:
| Scheme | Port | When active |
| ------------------- | --------------------- | ---------------------------------------------------------- |
| `amqp://host:5672` | `5672` (plain / SASL) | always (unless `CONNECTORS_AMQP10_PORT=0`) |
| `amqps://host:5671` | `5671` (TLS) | **only when the top-level `Security` block is configured** |
The TLS port is controlled by `CONNECTORS_AMQP10_TLS_PORT` (default `5671`; `0` disables it). The
certificate material, the CA, and the mode all come from the **top-level `Security` block**. There
is **no AMQP-over-WebSocket**: raw TCP and TLS only.
## TLS server-auth vs mTLS [#tls-server-auth-vs-mtls]
The connector derives its TLS configuration from the `Security` mode.
### Server authentication only [#server-authentication-only]
* The server presents its certificate; the minimum protocol is **TLS 1.2**.
* **No client certificate is requested or verified.**
* The client authenticates separately, at the SASL layer (PLAIN with a JWT, or ANONYMOUS if auth
is off). Use `amqps://` for the transport and SASL PLAIN for identity.
```text
# conceptual: server-auth TLS + SASL PLAIN (JWT in password)
amqps://broker:5671 + SASL PLAIN("audit-user", "")
```
### Mutual TLS [#mutual-tls]
* The server presents its certificate **and** requires a client certificate
(`RequireAndVerifyClientCert`), with the client-CA pool built from `Security.Ca`. The minimum
protocol is **TLS 1.2**.
* A verified client certificate is the **precondition for SASL EXTERNAL**.
## SASL EXTERNAL — cert CN → ClientID [#sasl-external--cert-cn--clientid]
`EXTERNAL` is offered **only** when the connection is mTLS **and** the client presented a
*verified* client certificate. When you authenticate with EXTERNAL:
* **No JWT is sent.** The certificate *is* the credential.
* The client identity (`ClientID`) becomes the client certificate's **Subject CN**, sanitized to a
valid `ClientID` (`[a-zA-Z0-9_-]`, ≤ 256). An empty CN is rejected as an auth failure.
* Authorization (Read/Write at attach) then runs against that CN-derived `ClientID` exactly as for
PLAIN — see [Authentication](/connectors/amqp/how-to/authentication).
```text
# conceptual: mTLS + SASL EXTERNAL (identity = client cert CN, no JWT)
amqps://broker:5671 + client cert (CN=order-service) + SASL EXTERNAL
# resolved ClientID = "order-service"
```
**EXTERNAL is available only when `Security.Mode == mtls`.** Plain TLS (server-auth only) does
not present a verified client certificate, so EXTERNAL is not offered there — fall back to PLAIN
(JWT) or ANONYMOUS. The offered-mechanism order is **EXTERNAL → PLAIN → ANONYMOUS**, so on an mTLS
connection a spec-conformant client that supports EXTERNAL picks it first.
## How the TLS listener shares the `amqpmux` port [#how-the-tls-listener-shares-the-amqpmux-port]
Both AMQP dialects (0-9-1 and 1.0) coexist on the **same** ports. The `amqpmux` listener accepts
every connection, reads the **8-byte AMQP protocol header**, and dispatches by dialect:
| Header bytes | Meaning |
| ---------------------- | ------------------------------------------------------------- |
| `AMQP\x00\x00\x09\x01` | AMQP 0-9-1 |
| `AMQP\x00\x01\x00\x00` | AMQP 1.0, plain (bare/AMQP layer) |
| `AMQP\x03\x01\x00\x00` | AMQP 1.0, SASL layer |
| `AMQP\x02\x01\x00\x00` | AMQP 1.0, **TLS** token — only meaningful on the TLS listener |
For TLS, the connection is wrapped in the `Security`-block TLS configuration **before** the header
is interpreted. The upshot for clients: point an `amqps://` AMQP 1.0 client at `:5671`, and the
same listener that serves 0-9-1 routes you to the 1.0 engine.
## Production checklist [#production-checklist]
| Goal | Configuration |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
| Encrypt transport, authenticate clients with JWT | `Security` mode `tls` + `amqps://:5671` + SASL PLAIN (JWT in password) |
| Authenticate clients with certificates (no JWT) | `Security` mode `mtls` + `amqps://:5671` + SASL EXTERNAL (CN → ClientID) |
| Keep plain `amqp://` for local/dev | leave `CONNECTORS_AMQP10_PORT=5672`; the examples use this |
| Disable the TLS port | `CONNECTORS_AMQP10_TLS_PORT=0` (or leave the `Security` block unset) |
## Related [#related]
# Addressing (/connectors/amqp/concepts/addressing)
The terminus address is the single most client-load-bearing fact about the KubeMQ AMQP 1.0
connector: it tells the connector **which KubeMQ pattern and channel** a link talks to. This
guide is the practical playbook for the `/` grammar — always use explicit
prefixes — plus channel validation, longest-prefix matching, dynamic and anonymous addresses,
and the absence of vhosts.
For the address validation rules in table form, see
[Address mapping](/connectors/amqp/reference/address-mapping).
## Where the address goes [#where-the-address-goes]
AMQP 1.0 has no exchanges or routing keys. You choose the destination by setting a terminus
address on the link — and *where* it goes depends on the link role:
| You are… | The address goes in… |
| ------------------------------------------ | -------------------------------------------------------- |
| a **receiver** (you consume) | the link **source** address |
| a **sender** to a fixed node (you produce) | the link **target** address |
| an **anonymous sender** | `properties.to` on **each message** (the target is null) |
## The grammar — always use explicit prefixes [#the-grammar--always-use-explicit-prefixes]
```text
address := [ "/" ] pattern "/" channel # leading "/" is optional: queues/x ≡ /queues/x
| bare # no recognized prefix → JMS hint, else DefaultPattern
| "/responses/" RequestID # RPC reply token (reply path only; server-receiver only)
| # source.dynamic / target.dynamic → _amqp10.tmp..
pattern := "queues" | "events" | "events-store" | "commands" | "queries"
```
| Terminus address | KubeMQ pattern | Channel |
| ---------------------------- | -------------------------------------------------------------------------------- | ---------------------- |
| `queues/` | queues | `` |
| `events/` | events | `` |
| `events-store/` | events-store | `` |
| `commands/` | commands | `` |
| `queries/` | queries | `` |
| `responses/` | responses (synthetic; RPC reply path only) | the opaque reply token |
| bare (no prefix, no `/`) | JMS node-capability hint (`queue`→queues, `topic`→events), else `DefaultPattern` | the bare string |
| null target on a sender | anonymous — routed per-message by `properties.to` | (per message) |
| anything else containing `/` | **error** → `DETACH(amqp:not-found, "unknown address prefix")` | — |
The leading slash is optional and stripped: `queues/orders` ≡ `/queues/orders`. The prefix is
**stripped, never prepended** — the resolved channel (`orders`) is what the broker sees, so it
always passes the underlying channel validation.
**Always emit the explicit `/` prefix.** It makes the destination
deterministic and self-documenting: `queues/orders`, `events/telemetry`,
`events-store/audit`, `commands/provision`, `queries/lookup`. Do not rely on bare addressing
in fresh application code (see below).
## Longest-prefix matching [#longest-prefix-matching]
The connector matches prefixes **longest-first**: `events-store/` is tested **before**
`events/`. So `events-store/audit` resolves to the **events-store** pattern with channel
`audit` — it is never mis-read as the **events** pattern with channel `store/audit`. You never
need to escape or disambiguate; just write the full prefix. The full matching order is
`events-store → queues → events → commands → queries → responses`.
## Bare addressing — a Qpid-JMS migration convenience only [#bare-addressing--a-qpid-jms-migration-convenience-only]
A bare address (no recognized prefix and no `/`) is resolved **non-deterministically**:
1. If the terminus carries a JMS **node-capability hint**, it selects the pattern: `queue` →
queues, `topic` → events.
2. Otherwise the connector's configured **`DefaultPattern`** applies (default `queues`).
This exists so a **migrating Qpid-JMS / ActiveMQ app** can point at KubeMQ by changing only the
connection string and the destination name — a JMS `Queue("orders")` carries the `queue`
capability and lands on `queues/orders` without an explicit prefix.
Bare addressing is **non-deterministic and config-dependent**: the same bare name resolves
differently depending on the client's capability hint and the broker's `DefaultPattern`. Treat
it as a migration aid, not a design choice. For any fresh code, **use the explicit prefix** so
the destination is unambiguous and survives a `DefaultPattern` change. A bare value that still
contains a `/` is treated as an **unknown prefix**, not a bare channel →
`DETACH(amqp:not-found, "unknown address prefix")`.
## Channel validation — stricter than the native layer [#channel-validation--stricter-than-the-native-layer]
After the prefix is stripped, the connector validates the channel **more strictly than the
underlying KubeMQ array layer**. A channel that works on the gRPC/native side can be rejected
over AMQP. A violation closes the link with `DETACH(amqp:not-found)`:
| Rejected channel | Example |
| ------------------------------ | -------------------------- |
| empty | `queues/` |
| longer than **255** characters | `queues/<256+ chars>` |
| has a **trailing `.`** | `queues/orders.` |
| contains **whitespace** | `queues/my orders` |
| contains `*` or `>` wildcards | `queues/orders.*` |
| contains `;` or `:` | `queues/a:b`, `queues/a;b` |
A channel name that works on the native KubeMQ side can be **rejected over AMQP** because of
the extra `;`, `:`, and trailing-`.` restrictions. Keep AMQP channel names to `[a-zA-Z0-9._-]`,
≤ 255 chars, with no trailing dot, and you are never surprised.
## Dynamic and anonymous addresses [#dynamic-and-anonymous-addresses]
These are special address forms the connector mints or routes specially.
### Dynamic nodes (`source.dynamic` / `target.dynamic`) [#dynamic-nodes-sourcedynamic--targetdynamic]
Attach a receiver with **`DynamicAddress: true`** (a dynamic source) and the connector creates a
fresh node and echoes its address in the reply `ATTACH`: `_amqp10.tmp..`. This is
the mechanism for an **RPC reply node** — a requester opens a dynamic receiver, reads back its
echoed address, and stamps it as `reply-to`. See
[Commands](/connectors/amqp/concepts/commands).
Dynamic nodes are **node-local**: a temp node created on node A is not reachable from node B.
RPC *replies* are still cluster-safe (they travel the broker reply path), but a direct
cross-node send to a dynamic address is not.
### Anonymous terminus (null target sender) [#anonymous-terminus-null-target-sender]
A sender opened with a **null target** (`NewSender("", nil)`) is an *anonymous* sender: it has no
fixed destination, and each message selects its destination via **`properties.to`** (which itself
uses the `/` grammar). Because there is no fixed channel at attach,
authorization is deferred to a **per-message Write check** on each message's `to` (see
[Authentication](/connectors/amqp/how-to/authentication)). A bad `to` →
`amqp:precondition-failed`; a missing `to` → a Send error.
The connector advertises **no `ANONYMOUS-RELAY` capability** — anonymous-terminus routing works
by the connector inspecting `properties.to`, not by a capability handshake. Clients whose
anonymous-producer API depends on `ANONYMOUS-RELAY` negotiation (notably **Qpid JMS**) cannot
emit the single null-target link the connector routes on, so they cannot drive the anonymous
terminus — use explicit per-pattern senders there instead.
### `/responses/` reply tokens [#responses-reply-tokens]
`/responses/` is a synthetic RPC reply address. It is **valid only as a
server-receiver attach** (the reply path); a **receiver** attach on `/responses/` →
`DETACH(amqp:not-allowed)`. You do not construct these yourself in normal use — the RPC layer
manages them, and they are connection-scoped (not authorized via Casbin).
## No vhost [#no-vhost]
AMQP 1.0 has **no vhost**. The `OPEN` `hostname` field (which a client carrying over a 0-9-1
mental model might set) is **logged then ignored**. There is no vhost option to expose and no
namespace scoping by hostname.
## Reaching AMQP 0-9-1 data [#reaching-amqp-0-9-1-data]
The AMQP 1.0 and AMQP 0-9-1 connectors **do not share a namespace**. AMQP 0-9-1 queues live on
KubeMQ channels named `amqp..`. To reach the same data from an AMQP 1.0 client,
use the explicit queues prefix over that channel name:
```text
/queues/amqp..
```
Cross-protocol equivalence holds the other way too: an AMQP 1.0 `queues/` maps to the bare
KubeMQ channel ``, so a gRPC/native client producing to `` interoperates with an AMQP
1.0 consumer of `queues/`.
## Quick reference [#quick-reference]
| Destination | Address to use |
| ---------------------------- | --------------------------------------------------------------------- |
| Queue `orders` | `queues/orders` |
| Event stream `telemetry` | `events/telemetry` |
| Durable event stream `audit` | `events-store/audit` |
| Command channel `provision` | `commands/provision` |
| Query channel `lookup` | `queries/lookup` |
| RPC reply node | dynamic receiver (`DynamicAddress: true`); never a hand-built address |
| Route per-message | anonymous sender + `properties.to = "queues/"` etc. |
| AMQP 0-9-1 queue | `/queues/amqp..` |
| Fresh app code | **always the explicit prefix** — never rely on bare addressing |
## Related [#related]
# Architecture (/connectors/amqp/concepts/architecture)
The KubeMQ **AMQP 1.0 connector** is an embedded, wire-protocol bridge inside
kubemq-server. It speaks the AMQP 1.0 dialect on plain port **5672** (TCP / SASL) and TLS
port **5671**. The connector is **opt-in (disabled by default)** — enable it with
`CONNECTORS_AMQP10_ENABLE=true` (Docker) or `spec.amqp10.enabled: true` (Kubernetes). Any
standard AMQP 1.0 client connects to it with only a connection-string change — no code
rewrite, no library swap, no KubeMQ SDK.
Unlike the RabbitMQ (AMQP 0-9-1) connector — which bridges onto exactly one KubeMQ
primitive (the Queue) — the AMQP 1.0 connector bridges onto **all five** KubeMQ patterns:
Queues, Events, Events-Store, Commands, and Queries. The leading segment of the node
address selects which pattern a link is bound to. That single fact drives the whole mental
model.
AMQP 1.0 is a peer-to-peer link protocol — there are **no exchanges, no bindings, no
routing keys, and no publisher-confirms** here. Those are AMQP 0-9-1 concepts. In AMQP 1.0
a *link* is attached to a *node* (an address), message flow is governed by *credit*, and
delivery is resolved by *delivery state* (accepted / released / modified / rejected). If
you are migrating from 0-9-1 or ActiveMQ, see
[Migrating from ActiveMQ](/connectors/how-to/migration/from-activemq).
## How AMQP 1.0 maps to KubeMQ [#how-amqp-10-maps-to-kubemq]
The connector binds a link to a KubeMQ pattern by the **leading segment of the node
address**. The grammar is `[/]/` — the prefix selects the pattern and
the remainder is the KubeMQ channel.
*The address prefix selects the KubeMQ pattern; `events-store/` is matched before `events/` so it never collides.*
| Address prefix | KubeMQ pattern | Produce (client sender → target) | Consume (client receiver ← source) |
| ------------------- | ------------------ | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `queues/` | **Queues** | at-least-once enqueue; the server dispositions `accepted` per send | credit-driven destructive consume; `accept` / `release` / `modify` / `reject` |
| `events/` | **Events** | pre-settled fan-out (at-most-once) | standing-credit fan-out; **0-credit → silent drop** |
| `events-store/` | **Events-Store** | persisted append | durable replay/resume; start positions via `x-opt-kubemq-start` |
| `commands/` | **Commands (RPC)** | request + dynamic reply node; reply carries `x-opt-kubemq-executed` / `-error` | responder consumes, replies to `/responses/` |
| `queries/` | **Queries (RPC)** | request + dynamic reply node; reply = body + metadata only | responder consumes, replies to `/responses/` |
Resolution rules:
* **Longest-prefix wins.** `events-store/` is matched before `events/`, so
`events-store/orders` never collides with the `events/` arm. The matching order is
`events-store → queues → events → commands → queries → responses`.
* **Leading slash is optional.** At most one leading `/` is stripped before matching, so
`queues/orders` and `/queues/orders` resolve identically.
* **Bare addresses** (no recognized prefix) resolve by a JMS node-capability hint
(`queue` → `queues`, `topic` → `events`) or fall back to the configured `DefaultPattern`
(`queues` by default). Best practice is to always emit the explicit prefix.
* **`/responses/`** is the write-only RPC reply path. A receiver attach on it is
refused with `amqp:not-allowed`.
* **Channel charset** is stricter than the array layer: non-empty, ≤255 chars, no trailing
`.`, no whitespace, no `*` `>` `;` `:`. A violation returns `amqp:not-found`.
See [Address mapping](/connectors/amqp/reference/address-mapping) for the master table
and [Addressing](/connectors/amqp/concepts/addressing) for narrative guidance.
## The shared front door: `amqpmux` [#the-shared-front-door-amqpmux]
KubeMQ ships two embedded AMQP dialects — 0-9-1 (RabbitMQ) and 1.0 — and they **share the
same listeners**. A single mux per `(port, tlsPort)` group accepts every connection, reads
the **8-byte AMQP protocol header**, and dispatches the raw connection to the engine that
speaks the matching dialect. The mux never speaks AMQP itself; once it classifies a
connection it hands the connection plus the consumed header to the engine, which resumes
the protocol exactly where the header left off.
*The mux classifies each connection by its 8-byte protocol header and routes it to the matching dialect engine.*
The 8-byte header is `"AMQP"` followed by a 4-byte `(protocol-id, major, minor, revision)`
tuple. The mux recognizes:
| Header bytes | Meaning | Listener | Dispatched to |
| ---------------------- | --------------------- | -------- | ---------------------------------- |
| `AMQP\x00\x00\x09\x01` | AMQP 0-9-1 | any | 0-9-1 engine |
| `AMQP\x00\x01\x00\x00` | AMQP 1.0 (bare) | any | 1.0 engine |
| `AMQP\x03\x01\x00\x00` | AMQP 1.0 (SASL layer) | any | 1.0 engine |
| `AMQP\x02\x01\x00\x00` | AMQP 1.0 (TLS token) | TLS only | 1.0 engine (after TLS termination) |
Key consequences:
* **Plain port `5672` and TLS port `5671` are shared with the AMQP 0-9-1 connector.**
Setting `CONNECTORS_AMQP10_PORT` equal to the 0-9-1 port is intentionally accepted — the
mux dedupes the bind, so the two dialects coexist on one listener.
* **Version negotiation:** if a client presents an AMQP 1.0-family header but no live 1.0
engine is available, the mux writes back the 0-9-1 header and closes (and vice-versa). A
client that cannot even send a header gets nothing back — the connection is closed
silently.
* **The connector advertises no negotiated capabilities** (see
[What the server advertises](#what-the-server-advertises)).
## Connection → Session → Link [#connection--session--link]
AMQP 1.0 is a three-level container model. The connector implements the **server side** of
each finite-state machine, so the peer roles invert relative to your client: a client
**sender** is a server **receiver** (you produce to a `target`), and a client **receiver**
is a server **sender** (you consume from a `source`).
| Level | Client performative | Connector behavior |
| -------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Connection** | `OPEN` | `container-id` is required and non-empty (empty → `amqp:invalid-field`), sanitized to `[a-zA-Z0-9_-]`, capped at 256 chars. It becomes the ClientID when there is no SASL identity and is **half the durable-subscription identity — so it must be stable across reconnects** for durable subscribers. The OPEN `hostname` (vhost) is accepted but **ignored**. |
| **Session** | `BEGIN` | The server advertises `channel-max = min(client, SessionMax-1)` (255 with defaults). A session window violation closes with `amqp:session:window-violation`; an unattached or in-use handle is `amqp:session:errant-link`. |
| **Link** | `ATTACH` | The peer role inverts (sender ↔ receiver). The address resolves to a `(pattern, channel)` pair. Receivers grant credit via `FLOW`; the server never delivers without it. The receive settle mode is `first` (only). |
## What the server advertises [#what-the-server-advertises]
On `OPEN` the connector sends back **only** `container-id` (`"KubeMQ"`), `max-frame-size`,
`channel-max`, and `idle-time-out`. It sets **no offered/desired connection or link
capabilities** — in particular **no `ANONYMOUS-RELAY`** and no `queue`/`topic` node
capabilities. Clients must not depend on capability negotiation.
This is why the **anonymous terminus** (a sender with a null target that routes per-message
by `properties.to`) is driven entirely by the *null target address*, not by an advertised
capability. It is also why Apache Qpid JMS cannot drive the anonymous-terminus path — it
has no API to force a raw null-target link, and there is no capability to trigger its
anonymous-producer path. See [Capabilities](/connectors/amqp/reference/capabilities).
## The metadata envelope and type markers [#the-metadata-envelope-and-type-markers]
KubeMQ messages carry a JSON `Metadata` string. The connector serializes the full AMQP
message context into a single canonical envelope keyed by **`amqp10`**:
```json
{
"amqp10": {
"props": { "...": "original-form AMQP properties (message_id, correlation_id, to, reply_to, subject, content_type, group_id, ttl, ...)" },
"app": { "...": "application-properties, type-preserved" },
"annotations": { "x-opt-...": "message-annotations" },
"delivery_annotations": { "...": "opaque pass-through" },
"footer": { "...": "opaque pass-through" },
"body_section": "data"
}
}
```
* The envelope is **always present** — even `{"amqp10":{}}` for an empty/property-less
message — so every message carries non-empty `Metadata`.
* `body_section` discriminates the body: **`"data"`** (binary `Data` section) or
**`"value"`** (`AmqpValue` section). An `AmqpSequence` body is **rejected** with
`amqp:not-implemented`.
* Treat the envelope as **opaque** from a client's point of view. Set standard AMQP
properties natively (message-id, correlation-id, content-type, ttl, group-id) and let the
connector derive the `amqp10.*` tags; do not hand-build the envelope.
### Type markers [#type-markers]
JSON has no native unsigned, 64-bit, binary, or timestamp types, so the codec wraps AMQP
scalar values that would otherwise lose fidelity using a `$`-prefixed marker. Integers
within ±2^53 are emitted as plain JSON numbers; only out-of-range or type-ambiguous values
are wrapped, and egress restores the exact AMQP type the client sent.
| Marker | AMQP type → JSON form |
| ------------------------------------------------- | --------------------------- |
| `$int64` | int64 beyond ±2^53 → string |
| `$u64` | uint64 → string |
| `$ts` | timestamp → Unix seconds |
| `$bin` | binary → base64 |
| `$uuid` | UUID → RFC-4122 string |
| `$u8` / `$i8` / `$i16` / `$u16` / `$i32` / `$u32` | sized integers |
| `$f32` / `$f64` | 32- / 64-bit floats |
Receiver-set link properties (on the consuming ATTACH, not the message) carry pattern
options: `x-opt-kubemq-group` (consumer group for events / events-store / queues) and
`x-opt-kubemq-start` (the events-store start position, `new-only` by default). Inert
sections are accepted but not acted on: message `priority`, `group-id` ordering, and
`footer` are pass-through only.
## Cross-protocol interop [#cross-protocol-interop]
Because every pattern is backed by a normal KubeMQ channel, a message sent over AMQP 1.0
to `queues/orders` is consumable by a gRPC or REST queue client on the same channel, and
vice-versa. The connector asserts this equivalence: `queues/` over AMQP 1.0 maps to the
bare channel `` over gRPC.
*The same KubeMQ channel backs both connectors, so an AMQP 1.0 client and a gRPC/REST client interoperate transparently.*
The RabbitMQ (AMQP 0-9-1) connector uses a **different** namespace
(`amqp..`), so the two AMQP connectors do **not** share an address space. To
reach 0-9-1 queue data from AMQP 1.0, address it explicitly through the queues pattern:
`queues/amqp..` (a naming convention, not a real vhost — AMQP 1.0 has none).
## Related [#related]
# Commands (/connectors/amqp/concepts/commands)
Commands are **native, in-protocol request/reply** over the AMQP 1.0 connector. Attach a link to a node whose address begins with `commands/` and the connector binds it to the KubeMQ **Commands** pattern. RPC here is **fully native** — there is **no gRPC responder and no KubeMQ SDK**; a responder is just a normal AMQP consumer that publishes a reply.
This page documents the shared RPC mechanics — the dynamic reply node, the anonymous responder, and correlation matching. [Queries](/connectors/amqp/concepts/queries) use the **same** request path and defer to this page; the only differences are the reply shape and the failure contract.
## Overview [#overview]
A requester opens a dynamic reply node, sends a request to `commands/` naming that node as `reply-to`, and matches the reply by `correlation-id`. The connector fires `SendCommand`; the responder consumes `commands/`, does the work, and sends a reply back.
| Step | Who | Action |
| --------------- | --------- | -------------------------------------------------------------------------------------------- |
| Open reply node | Requester | Attach a receiver with `source.dynamic = true`; the server mints `_amqp10.tmp..` |
| Send request | Requester | `TRANSFER` to `commands/` with `reply-to` = the minted node + a `correlation-id` |
| Route | Connector | Verifies reply-to ownership, then `SendCommand` |
| Reply | Responder | Anonymous sender with `properties.to = /responses/` + the echoed `correlation-id` |
| Match | Requester | Correlate the reply to its request by `correlation-id` |
The reply's `correlation-id` is the request's `correlation-id`, or — when the request carried none — its **`message-id`** (the Qpid JMS convention). Set one or the other on every request and match the reply on it.
## How it works [#how-it-works]
*The requester's dynamic reply node receives the responder's reply out-of-band; the requester matches it by `correlation-id`.*
**`reply-to` must name a node this connection owns (snooping guard).** A requester cannot point `reply-to` at an arbitrary or foreign node — that would let it direct a response to another client's node. A **missing** `reply-to` returns `amqp:not-allowed` ("request missing reply-to"); a `reply-to` naming a node **this connection does not own** returns `amqp:not-allowed` ("reply-to is not a node this connection owns"). Always create a dynamic reply node per requester and use its echoed `_amqp10.tmp.*` address.
## Commands vs Queries — the failure contract [#commands-vs-queries--the-failure-contract]
Both patterns share the dynamic-reply path, but their reply shape and failure behavior differ:
| | **Commands** | **Queries** |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Reply body | optional | the result **body + metadata** |
| Reply app-properties | `x-opt-kubemq-executed` (bool) **always**; `x-opt-kubemq-error` (string) when non-empty | **none** |
| On success | `executed=true` | body + metadata returned |
| **On failure** | a reply **is** delivered with **`executed=false`** (+ error text) — the requester is never left waiting | **nothing is delivered** — the requester **times out** (\~30 s) |
In short: a **command** always answers (success or `executed=false`); a query answers on success and goes silent on failure. Choose commands when you need a positive failure signal.
## Request and reply [#request-and-reply]
Each example runs a responder and a requester (separate connections, so the snooping guard is honored), sends a successful command (`executed=true`) and a failing one (`executed=false`), and shows that **both** round-trip — neither hangs. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`).
```go
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"sync"
"time"
amqp "github.com/Azure/go-amqp"
)
const channel = "amqp10.examples.commands"
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://localhost:5672"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
addr := "commands/" + channel // commands/ prefix → KubeMQ Commands pattern
ready := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); runResponder(ctx, addr, ready) }()
<-ready
runRequester(ctx, addr)
cancel()
wg.Wait()
}
// Responder: consume commands/, reply via an anonymous sender.
func runResponder(ctx context.Context, addr string, ready chan<- struct{}) {
conn, _ := amqp.Dial(ctx, amqpURL(), nil)
defer func() { _ = conn.Close() }()
session, _ := conn.NewSession(ctx, nil)
rcv, _ := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10})
snd, _ := session.NewSender(ctx, "", nil) // anonymous sender (null target)
close(ready)
for {
req, err := rcv.Receive(ctx, nil)
if err != nil {
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
return
}
return
}
_ = rcv.AcceptMessage(context.Background(), req)
if req.Properties == nil || req.Properties.ReplyTo == nil {
continue
}
body := string(req.GetData())
// A command body of "fail" is rejected (executed=false); both paths reply.
ok := body != "fail"
errText := ""
if !ok {
errText = "command rejected by handler"
}
replyTo := *req.Properties.ReplyTo
reply := amqp.NewMessage([]byte("ack:" + body))
reply.Properties = &amqp.MessageProperties{To: &replyTo}
if req.Properties.CorrelationID != nil {
reply.Properties.CorrelationID = req.Properties.CorrelationID
} else {
reply.Properties.CorrelationID = req.Properties.MessageID
}
// A COMMAND reply carries the execution outcome as application-properties.
reply.ApplicationProperties = map[string]any{
"x-opt-kubemq-executed": ok,
"x-opt-kubemq-error": errText,
}
_ = snd.Send(ctx, reply, nil)
}
}
// Requester: dynamic reply node + sender on commands/; correlate replies.
func runRequester(ctx context.Context, addr string) {
conn, _ := amqp.Dial(ctx, amqpURL(), nil)
defer func() { _ = conn.Close() }()
session, _ := conn.NewSession(ctx, nil)
// DYNAMIC reply node: empty source + DynamicAddress:true → server echoes its address.
replyRcv, _ := session.NewReceiver(ctx, "", &amqp.ReceiverOptions{DynamicAddress: true, Credit: 5})
replyNode := replyRcv.Address()
snd, _ := session.NewSender(ctx, addr, nil)
doRequest(ctx, snd, replyRcv, replyNode, "reboot-node-7", "corr-cmd-1") // executed=true
doRequest(ctx, snd, replyRcv, replyNode, "fail", "corr-cmd-2") // executed=false
}
func doRequest(ctx context.Context, snd *amqp.Sender, replyRcv *amqp.Receiver, replyNode, body, corr string) {
req := amqp.NewMessage([]byte(body))
req.Properties = &amqp.MessageProperties{
ReplyTo: &replyNode, // MUST name a node this connection owns (snooping guard)
CorrelationID: corr,
}
if err := snd.Send(ctx, req, nil); err != nil {
log.Fatalf("send command: %v", err)
}
// A command ALWAYS replies (success or failure), so this never times out.
reply, err := replyRcv.Receive(ctx, nil)
if err != nil {
log.Fatalf("await reply: %v", err)
}
_ = replyRcv.AcceptMessage(context.Background(), reply)
executed, _ := reply.ApplicationProperties["x-opt-kubemq-executed"].(bool)
errText, _ := reply.ApplicationProperties["x-opt-kubemq-error"].(string)
fmt.Printf("reply for %q: executed=%v error=%q\n", body, executed, errText)
}
```
```python
import os
import threading
from proton import Message
from proton.utils import BlockingConnection
CHANNEL = "amqp10.examples.commands"
EXECUTED_PROP = "x-opt-kubemq-executed"
ERROR_PROP = "x-opt-kubemq-error"
def amqp_url() -> str:
return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")
def run_responder(addr: str, ready: threading.Event, stop: threading.Event) -> None:
conn = BlockingConnection(amqp_url())
try:
rcv = conn.create_receiver(addr, credit=10)
snd = conn.create_sender(None) # anonymous reply sender (null target)
ready.set()
while not stop.is_set():
try:
req = rcv.receive(timeout=1.0)
except Exception:
continue
rcv.accept()
if not req.reply_to:
continue
body = str(req.body)
# A command body of "fail" is rejected (executed=false); both paths reply.
ok = body != "fail"
err_text = "" if ok else "command rejected by handler"
reply = Message(body="ack:" + body)
reply.address = req.reply_to
reply.correlation_id = req.correlation_id if req.correlation_id else req.id
# A COMMAND reply carries the execution outcome as application-properties.
reply.properties = {EXECUTED_PROP: ok, ERROR_PROP: err_text}
snd.send(reply)
finally:
conn.close()
def do_request(snd, reply_rcv, reply_node: str, body: str, corr: str) -> None:
req = Message(body=body)
req.reply_to = reply_node # MUST name a node this connection owns (snooping guard)
req.correlation_id = corr
snd.send(req)
# A command ALWAYS replies (success or failure), so this never times out.
reply = reply_rcv.receive(timeout=30.0)
reply_rcv.accept()
props = reply.properties or {}
print(f"reply for {body!r}: executed={bool(props.get(EXECUTED_PROP))} error={str(props.get(ERROR_PROP, ''))!r}")
def run_requester(addr: str) -> None:
conn = BlockingConnection(amqp_url())
try:
# DYNAMIC reply node: dynamic=True → server echoes its address.
reply_rcv = conn.create_receiver(None, dynamic=True, credit=5)
reply_node = reply_rcv.link.remote_source.address
snd = conn.create_sender(addr)
do_request(snd, reply_rcv, reply_node, "reboot-node-7", "corr-cmd-1") # executed=true
do_request(snd, reply_rcv, reply_node, "fail", "corr-cmd-2") # executed=false
finally:
conn.close()
def main() -> None:
addr = "commands/" + CHANNEL # commands/ prefix → KubeMQ Commands pattern
ready, stop = threading.Event(), threading.Event()
responder = threading.Thread(target=run_responder, args=(addr, ready, stop), daemon=True)
responder.start()
ready.wait(timeout=30.0)
try:
run_requester(addr)
finally:
stop.set()
responder.join(timeout=10.0)
if __name__ == "__main__":
main()
```
```java
import javax.jms.Connection;
import javax.jms.Destination;
import javax.jms.Message;
import javax.jms.MessageConsumer;
import javax.jms.MessageProducer;
import javax.jms.Queue;
import javax.jms.Session;
import javax.jms.TemporaryQueue;
import javax.jms.TextMessage;
import org.apache.qpid.jms.JmsConnectionFactory;
public final class Main {
private static final String CHANNEL = "amqp10.examples.commands";
private static final String PROP_EXECUTED = "x-opt-kubemq-executed";
private static final String PROP_ERROR = "x-opt-kubemq-error";
public static void main(String[] args) throws Exception {
String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672");
String address = "commands/" + CHANNEL; // commands/ prefix → KubeMQ Commands
// jms.validatePropertyNames=false lets us set/read the hyphenated
// x-opt-kubemq-executed / -error application-properties.
JmsConnectionFactory factory = new JmsConnectionFactory(url);
factory.setValidatePropertyNames(false);
Thread responder = new Thread(() -> runResponder(factory, address), "responder");
responder.setDaemon(true);
responder.start();
Thread.sleep(1_000); // let the responder attach before sending
runRequester(factory, address);
}
// Responder: consume commands/, reply to each request's JMSReplyTo.
private static void runResponder(JmsConnectionFactory factory, String address) {
try (Connection connection = factory.createConnection()) {
connection.start();
try (Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE)) {
Queue commands = session.createQueue(address);
try (MessageConsumer consumer = session.createConsumer(commands);
MessageProducer replyProducer = session.createProducer(null)) { // unidentified
while (true) {
Message req = consumer.receive(1_000);
if (req == null) continue;
Destination replyTo = req.getJMSReplyTo();
if (replyTo == null) continue;
String body = (req instanceof TextMessage) ? ((TextMessage) req).getText() : "";
boolean ok = !"fail".equals(body);
String errText = ok ? "" : "command rejected by handler";
TextMessage reply = session.createTextMessage("ack:" + body);
reply.setJMSCorrelationID(req.getJMSCorrelationID());
reply.setBooleanProperty(PROP_EXECUTED, ok);
reply.setStringProperty(PROP_ERROR, errText);
replyProducer.send(replyTo, reply);
}
}
}
} catch (Exception e) {
// connection torn down on shutdown
}
}
// Requester: a JMS temporary queue is the dynamic reply node.
private static void runRequester(JmsConnectionFactory factory, String address) throws Exception {
try (Connection connection = factory.createConnection()) {
connection.start();
try (Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE)) {
TemporaryQueue replyNode = session.createTemporaryQueue();
Queue commands = session.createQueue(address);
try (MessageConsumer replyConsumer = session.createConsumer(replyNode);
MessageProducer producer = session.createProducer(commands)) {
doRequest(session, producer, replyConsumer, replyNode, "reboot-node-7", "corr-cmd-1");
doRequest(session, producer, replyConsumer, replyNode, "fail", "corr-cmd-2");
}
}
}
}
private static void doRequest(Session session, MessageProducer producer, MessageConsumer replyConsumer,
TemporaryQueue replyNode, String body, String corr) throws Exception {
TextMessage req = session.createTextMessage(body);
req.setJMSReplyTo(replyNode); // MUST name a node this connection owns (snooping guard)
req.setJMSCorrelationID(corr);
producer.send(req);
Message reply = replyConsumer.receive(30_000); // a command always replies
if (reply == null) throw new IllegalStateException("timed out awaiting reply");
System.out.printf("reply for \"%s\": executed=%b error=\"%s\"%n",
body, reply.getBooleanProperty(PROP_EXECUTED), reply.getStringProperty(PROP_ERROR));
}
}
```
```csharp
using System.Text;
using Amqp;
using Amqp.Framing;
const string channel = "amqp10.examples.commands";
static string AmqpUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
? v
: "amqp://localhost:5672";
var addr = "commands/" + channel; // commands/ prefix → KubeMQ Commands pattern
using var responderDone = new CancellationTokenSource();
var responderReady = new TaskCompletionSource();
var responderTask = Task.Run(() => RunResponder(addr, responderReady, responderDone.Token));
await responderReady.Task.WaitAsync(TimeSpan.FromSeconds(20));
await RunRequester(addr);
responderDone.Cancel();
await responderTask;
// Responder: consume commands/, reply via an anonymous sender.
static void RunResponder(string addr, TaskCompletionSource ready, CancellationToken stop)
{
var connection = Connection.Factory.CreateAsync(new Address(AmqpUrl())).GetAwaiter().GetResult();
try
{
var session = new Session(connection);
var receiver = new ReceiverLink(session, "command-responder", addr);
receiver.SetCredit(10, autoRestore: true);
var anonAttach = new Attach { Source = new Source(), Target = null }; // anonymous sender
var sender = new SenderLink(session, "command-reply-sender", anonAttach, null);
ready.TrySetResult();
while (!stop.IsCancellationRequested)
{
var req = receiver.Receive(TimeSpan.FromSeconds(1));
if (req is null) continue;
receiver.Accept(req);
if (req.Properties?.ReplyTo is not { Length: > 0 } replyTo) continue;
var body = BodyString(req);
var ok = body != "fail";
var errText = ok ? "" : "command rejected by handler";
var reply = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes("ack:" + body) } };
reply.Properties = new Properties { To = replyTo };
var corr = req.Properties.GetCorrelationId() ?? req.Properties.GetMessageId();
if (corr is not null) reply.Properties.SetCorrelationId(corr);
// A COMMAND reply carries the execution outcome as application-properties.
reply.ApplicationProperties = new ApplicationProperties();
reply.ApplicationProperties.Map["x-opt-kubemq-executed"] = ok;
reply.ApplicationProperties.Map["x-opt-kubemq-error"] = errText;
sender.Send(reply, TimeSpan.FromSeconds(10));
}
}
catch (Exception ex) when (stop.IsCancellationRequested) { _ = ex; }
finally { try { connection.CloseAsync().Wait(2000); } catch (AmqpException) { } }
}
// Requester: dynamic reply node + sender on commands/; correlate replies.
static async Task RunRequester(string addr)
{
var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl()));
try
{
var session = new Session(connection);
// DYNAMIC reply node: Source.Dynamic = true; the server echoes the address
// via the OnAttached callback.
string? replyNode = null;
using var attached = new SemaphoreSlim(0, 1);
var dynAttach = new Attach { Source = new Source { Dynamic = true }, Target = new Target() };
var replyRcv = new ReceiverLink(session, "command-reply-node", dynAttach, (link, attach) =>
{
if (attach.Source is Source s) replyNode = s.Address;
attached.Release();
});
replyRcv.SetCredit(5, autoRestore: true);
await attached.WaitAsync(TimeSpan.FromSeconds(10));
var sender = new SenderLink(session, "command-requester", addr);
DoRequest(sender, replyRcv, replyNode!, "reboot-node-7", "corr-cmd-1"); // executed=true
DoRequest(sender, replyRcv, replyNode!, "fail", "corr-cmd-2"); // executed=false
await sender.CloseAsync();
await replyRcv.CloseAsync();
await session.CloseAsync();
}
finally { await connection.CloseAsync(); }
}
static void DoRequest(SenderLink sender, ReceiverLink replyRcv, string replyNode, string body, string corr)
{
var req = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes(body) } };
req.Properties = new Properties { ReplyTo = replyNode }; // snooping guard: must own this node
req.Properties.SetCorrelationId(corr);
sender.Send(req, TimeSpan.FromSeconds(15));
var reply = replyRcv.Receive(TimeSpan.FromSeconds(30)) // a command always replies
?? throw new InvalidOperationException("await reply: timed out");
replyRcv.Accept(reply);
var executed = reply.ApplicationProperties?.Map["x-opt-kubemq-executed"] as bool? ?? false;
var errText = reply.ApplicationProperties?.Map["x-opt-kubemq-error"] as string ?? "";
Console.WriteLine($"reply for \"{body}\": executed={executed} error=\"{errText}\"");
}
static string BodyString(Message message) => message.BodySection switch
{
Data d => Encoding.UTF8.GetString(d.Binary),
AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes),
AmqpValue { Value: string str } => str,
AmqpValue v => v.Value?.ToString() ?? string.Empty,
_ => string.Empty,
};
```
```typescript
import {
Connection,
ReceiverEvents,
type AwaitableSender,
type ConnectionOptions,
type EventContext,
type Receiver,
} from "rhea-promise";
const channel = "amqp10.examples.commands";
function connectionOptions(suffix: string): ConnectionOptions {
const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672");
return {
host: url.hostname,
port: url.port ? Number(url.port) : 5672,
container_id: `kubemq-amqp10-js-commands-${suffix}-${process.pid}`,
reconnect: false,
};
}
function bodyToString(body: unknown): string {
if (Buffer.isBuffer(body)) return body.toString("utf8");
if (typeof body === "string") return body;
return "";
}
// Responder: consume commands/, reply via an anonymous sender.
async function startResponder(address: string): Promise<{ stop: () => Promise }> {
const connection = new Connection(connectionOptions("responder"));
await connection.open();
const receiver = await connection.createReceiver({
source: { address }, credit_window: 0, autoaccept: false, autosettle: false,
});
const replySender = await connection.createAwaitableSender({ target: {} }); // null target
receiver.on(ReceiverEvents.message, (ctx: EventContext) => {
void onRequest(replySender, ctx).catch((err) => console.error(err));
});
receiver.addCredit(10);
return {
stop: async () => {
await replySender.close();
await receiver.close();
await connection.close();
},
};
}
async function onRequest(replySender: AwaitableSender, ctx: EventContext): Promise {
const req = ctx.message;
ctx.delivery?.accept();
if (!req || req.reply_to === undefined || req.reply_to === null) return;
const body = bodyToString(req.body);
const ok = body !== "fail";
const errText = ok ? "" : "command rejected by handler";
await replySender.send(
{
body: `ack:${body}`,
to: req.reply_to,
correlation_id: req.correlation_id ?? req.message_id,
// A COMMAND reply carries the execution outcome as application-properties.
application_properties: { "x-opt-kubemq-executed": ok, "x-opt-kubemq-error": errText },
},
{ timeoutInSeconds: 10 },
);
}
// Requester: dynamic reply node + sender on commands/; correlate replies.
async function runRequester(address: string): Promise {
const connection = new Connection(connectionOptions("requester"));
await connection.open();
try {
const replyReceiver = await connection.createReceiver({
source: { address: "", dynamic: true }, // server names the node
credit_window: 0, autoaccept: false, autosettle: false,
});
replyReceiver.addCredit(5);
const replyNode = replyReceiver.address || replyReceiver.source.address;
const sender = await connection.createAwaitableSender({ target: { address } });
await doRequest(sender, replyReceiver, replyNode!, "reboot-node-7", "corr-cmd-1"); // executed=true
await doRequest(sender, replyReceiver, replyNode!, "fail", "corr-cmd-2"); // executed=false
await sender.close();
await replyReceiver.close();
} finally {
await connection.close();
}
}
async function doRequest(
sender: AwaitableSender, replyReceiver: Receiver, replyNode: string, body: string, corr: string,
): Promise {
const replyPromise = awaitReply(replyReceiver, 30_000); // arm before sending
await sender.send(
{ body, reply_to: replyNode, correlation_id: corr }, // snooping guard: must own replyNode
{ timeoutInSeconds: 15 },
);
const reply = await replyPromise; // a command always replies
const props = (reply.application_properties ?? {}) as Record;
const executed = props["x-opt-kubemq-executed"] === true;
const errText = typeof props["x-opt-kubemq-error"] === "string" ? props["x-opt-kubemq-error"] : "";
console.log(`reply for "${body}": executed=${executed} error="${errText}"`);
}
interface ReplyMessage {
application_properties?: Record;
}
function awaitReply(replyReceiver: Receiver, timeoutMs: number): Promise {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
replyReceiver.removeListener(ReceiverEvents.message, handler);
reject(new Error("timed out awaiting reply"));
}, timeoutMs);
const handler = (ctx: EventContext): void => {
clearTimeout(timer);
replyReceiver.removeListener(ReceiverEvents.message, handler);
ctx.delivery?.accept();
replyReceiver.addCredit(1);
resolve({ application_properties: ctx.message?.application_properties as Record | undefined });
};
replyReceiver.on(ReceiverEvents.message, handler);
});
}
async function main(): Promise {
const address = `commands/${channel}`; // commands/ prefix → KubeMQ Commands pattern
const responder = await startResponder(address);
try {
await runRequester(address);
} finally {
await responder.stop();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```rust
use std::time::Duration;
use fe2o3_amqp::link::receiver::CreditMode;
use fe2o3_amqp::{Connection, Receiver, Sender, Session};
use fe2o3_amqp_types::definitions::SenderSettleMode;
use fe2o3_amqp_types::messaging::{
ApplicationProperties, Body, Message, MessageId, Properties, Source, Target,
};
use fe2o3_amqp_types::primitives::{SimpleValue, Value};
use tokio::sync::oneshot;
const CHANNEL: &str = "amqp10.examples.commands";
const EXECUTED_PROP: &str = "x-opt-kubemq-executed";
const ERROR_PROP: &str = "x-opt-kubemq-error";
fn amqp_url() -> String {
std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string())
}
fn body_string(body: &Body) -> String {
let bytes = match body {
Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(),
Body::Value(v) => match &v.0 {
Value::Binary(b) => b.to_vec(),
Value::String(s) => s.clone().into_bytes(),
other => format!("{other:?}").into_bytes(),
},
_ => Vec::new(),
};
String::from_utf8_lossy(&bytes).into_owned()
}
fn message_id_string(id: &Option) -> Option {
match id {
Some(MessageId::String(s)) => Some(s.clone()),
Some(other) => Some(format!("{other:?}")),
None => None,
}
}
fn command_outcome(msg: &Message>) -> (bool, String) {
let Some(props) = &msg.application_properties else {
return (false, String::new());
};
let executed = matches!(props.get(EXECUTED_PROP), Some(SimpleValue::Bool(true)));
let error = match props.get(ERROR_PROP) {
Some(SimpleValue::String(s)) => s.clone(),
_ => String::new(),
};
(executed, error)
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let url = amqp_url();
let addr = format!("commands/{CHANNEL}"); // commands/ prefix → KubeMQ Commands
let (ready_tx, ready_rx) = oneshot::channel::<()>();
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let (rurl, raddr) = (url.clone(), addr.clone());
let responder = tokio::spawn(async move { run_responder(&rurl, &raddr, ready_tx, stop_rx).await });
ready_rx.await.map_err(|_| "responder failed to become ready")?;
run_requester(&url, &addr).await?;
let _ = stop_tx.send(());
responder.await.map_err(|e| Box::new(e) as Box)??;
Ok(())
}
// Responder: consume commands/, reply via an anonymous sender.
async fn run_responder(
url: &str, addr: &str, ready: oneshot::Sender<()>, mut stop: oneshot::Receiver<()>,
) -> Result<(), Box> {
let mut connection = Connection::open("amqp10-examples-commands-responder", url).await?;
let mut session = Session::begin(&mut connection).await?;
let mut rcv = Receiver::builder()
.name("commands-responder-receiver")
.source(addr)
.credit_mode(CreditMode::Auto(10))
.attach(&mut session)
.await?;
// Anonymous sender (null target). Senders must set an explicit settle-mode
// (the connector rejects the AMQP default `mixed`).
let mut snd = Sender::builder()
.name("commands-responder-anon-sender")
.target(Target::builder().build())
.sender_settle_mode(SenderSettleMode::Unsettled)
.attach(&mut session)
.await?;
let _ = ready.send(());
loop {
let delivery = tokio::select! {
biased;
_ = &mut stop => break,
res = rcv.recv::>() => match res { Ok(d) => d, Err(_) => break },
};
rcv.accept(&delivery).await?;
let msg = delivery.message();
let Some(reply_to) = msg.properties.as_ref().and_then(|p| p.reply_to.clone()) else {
continue;
};
let body = body_string(&msg.body);
let ok = body != "fail";
let err_text = if ok { String::new() } else { "command rejected by handler".to_string() };
let corr = msg.properties.as_ref()
.and_then(|p| p.correlation_id.clone().or_else(|| p.message_id.clone()));
let mut props = Properties::builder().to(reply_to);
if let Some(c) = corr {
props = props.correlation_id(c);
}
let reply = Message::builder()
.properties(props.build())
.application_properties(
ApplicationProperties::builder()
.insert(EXECUTED_PROP, ok)
.insert(ERROR_PROP, err_text.as_str())
.build(),
)
.data(format!("ack:{body}").into_bytes())
.build();
snd.send(reply).await?;
}
snd.close().await?;
rcv.close().await?;
session.end().await?;
connection.close().await?;
Ok(())
}
// Requester: dynamic reply node + sender on commands/; correlate replies.
async fn run_requester(url: &str, addr: &str) -> Result<(), Box> {
let mut connection = Connection::open("amqp10-examples-commands-requester", url).await?;
let mut session = Session::begin(&mut connection).await?;
// DYNAMIC reply node: empty source + dynamic(true); the server echoes the address.
let mut reply_rcv = Receiver::builder()
.name("commands-requester-reply-node")
.source(Source::builder().dynamic(true).build())
.credit_mode(CreditMode::Auto(5))
.attach(&mut session)
.await?;
let reply_node = reply_rcv.source().as_ref()
.and_then(|s| s.address.clone())
.ok_or("server did not assign a dynamic reply-node address")?;
let mut snd = Sender::builder()
.name("commands-requester-sender")
.target(addr)
.sender_settle_mode(SenderSettleMode::Unsettled)
.attach(&mut session)
.await?;
do_request(&mut snd, &mut reply_rcv, &reply_node, "reboot-node-7", "corr-cmd-1").await?; // executed=true
do_request(&mut snd, &mut reply_rcv, &reply_node, "fail", "corr-cmd-2").await?; // executed=false
snd.close().await?;
reply_rcv.close().await?;
session.end().await?;
connection.close().await?;
Ok(())
}
async fn do_request(
snd: &mut Sender, reply_rcv: &mut Receiver, reply_node: &str, body: &str, corr: &str,
) -> Result<(), Box> {
let req = Message::builder()
.properties(
Properties::builder()
.reply_to(reply_node.to_string()) // snooping guard: must own this node
.correlation_id(corr.to_string())
.build(),
)
.data(body.as_bytes().to_vec())
.build();
let outcome = snd.send(req).await?;
if !outcome.is_accepted() {
return Err(format!("send command {body:?}: unexpected outcome {outcome:?}").into());
}
// A command ALWAYS replies (success or failure), so this never times out.
let reply = match tokio::time::timeout(Duration::from_secs(30), reply_rcv.recv::>()).await {
Ok(Ok(r)) => r,
_ => return Err(format!("await reply for {body:?}: timed out").into()),
};
reply_rcv.accept(&reply).await?;
let (executed, err_text) = command_outcome(reply.message());
let _ = message_id_string(&reply.message().properties.as_ref().and_then(|p| p.correlation_id.clone()));
println!("reply for {body:?}: executed={executed} error={err_text:?}");
Ok(())
}
```
**`RpcMaxPending` bounds in-flight requests.** The default cap (512 per connection) limits outstanding requests; a request that cannot reserve a slot returns `amqp:resource-limit-exceeded` ("rpc pending limit reached"). Bound your concurrency accordingly. Dynamic reply nodes are node-local, but RPC **replies travel the broker reply path and are cluster-safe** — request/reply works across a cluster even though the reply node lives on the requester's node.
## Related [#related]
# Configuration (/connectors/amqp/concepts/configuration)
The AMQP 1.0 connector is configured server-side through **14 settings** under the
`Connectors.Amqp10.*` namespace of the KubeMQ server config. The connector is **opt-in
(disabled by default)** — you must explicitly enable it. It ships with sensible production
defaults, so most deployments only override a value when they need a larger frame size, a
higher connection cap, or a different RPC timeout.
The only thing **clients** configure is the broker endpoint via the `KUBEMQ_AMQP_URL`
environment variable (default `amqp://localhost:5672`). Everything below is broker-side
server configuration. These settings are **not hot-reloadable** — changing any of them
requires a server restart.
## Enable / disable [#enable--disable]
Enable the connector with its enable variable:
To turn it **off** again:
**The enable variable is `CONNECTORS_AMQP10_ENABLE` — the literal `10` stays attached to
`AMQP` with no underscore.** The config key `Connectors.Amqp10.Enable` snake-cases by
stripping the dots and upper-casing, so `Amqp10` becomes `AMQP10`. Variants like
`CONNECTORS_AMQP_1_0_ENABLE` or `CONNECTORS_AMQP10ENABLE` do **not** bind to the field and
are silently ignored. When `Enable` is `false`, all other AMQP 1.0 validation is skipped
and no listener binds.
### `DefaultPattern` and the bare-address fallback [#defaultpattern-and-the-bare-address-fallback]
`DefaultPattern` only matters for **bare addresses** — a node address with no recognized
`/` prefix and no JMS node-capability hint (`queue` → `queues`, `topic` →
`events`). Because best practice is to always emit the **explicit prefix**
(`queues/orders`, `events/telemetry`), `DefaultPattern` rarely affects real traffic. Treat
bare addressing as a migration convenience only — it is non-deterministic by design. See
[Addressing](/connectors/amqp/concepts/addressing).
## TLS [#tls]
TLS for the AMQP 1.0 connector is driven entirely by the **server-global `Security`
block** (the same one the gRPC and AMQP 0-9-1 listeners use), not by an AMQP-1.0-specific
field. When `Security` is configured, the shared `amqpmux` TLS listener binds on `TlsPort`
with a minimum of TLS 1.2; mTLS adds the client CA pool and requires a verified client
certificate (mapped to SASL EXTERNAL). See
[TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls) and
[Auth & security](/connectors/reference/auth-and-security).
For the full field list, validation rules, and TOML/environment/Docker configuration
examples, see [Configuration reference](../reference/configuration).
## Related [#related]
# Events Store (/connectors/amqp/concepts/events-store)
Events Store is **durable, replayable** pub/sub over the AMQP 1.0 connector. Attach a link to a node whose address begins with `events-store/` and the connector binds it to the KubeMQ **Events Store** pattern. Unlike plain [Events](/connectors/amqp/concepts/events) (fire-hose, no replay), an Events Store subscriber can **resume** where it left off after a disconnect and can **replay** history from a chosen start position.
## Overview [#overview]
The `events-store/` prefix selects the Events Store pattern (longest-prefix match, evaluated **before** `events/`). A producer attaches a **sender** to `events-store/`; each `TRANSFER` becomes a KubeMQ `SendEventsStore` with `Store=true`, so the message is persisted. A consumer subscribes **durably** by attaching a **receiver** with terminus `expiry-policy = never`, a **stable container-id**, and a **stable link `Name`** — on reconnect with the same identity, the subscription resumes.
| Operation | AMQP action | KubeMQ mapping |
| ----------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- |
| Produce | Attach a **sender** to `events-store/`, `TRANSFER` | `SendEventsStore` (`Store=true`) — persisted |
| Subscribe durably | **Receiver** with `expiry-policy = never` + stable container-id + stable link name | Durable, resumable subscription |
| Set replay start | Receiver link property `x-opt-kubemq-start` | Start cursor (first / last / sequence / time …) |
### Durable identity [#durable-identity]
A durable subscription is identified by the pair **`(container-id, link name)`**. To resume, reconnect with the **same container-id AND the same link name** — change either and you get a *different* durable subscription that starts fresh. The connector derives a stable id from both:
```text
durableID = sanitize40(containerID) + "_" + sanitize40(linkName) + "_" + fnv1a32hex(containerID + "|" + linkName)
```
**A stable container-id is mandatory.** Because the container-id is half the durable identity, a durable subscriber that lets its container-id drift across reconnects (for example, a randomly generated one) will never resume — it creates a brand-new subscription each time. Pin a stable container-id.
## Start positions [#start-positions]
A durable receiver's start position is set with the link property **`x-opt-kubemq-start`**. It applies only to `events-store` (it is ignored on plain Events and on RPC, which have no replay). The grammar:
| `x-opt-kubemq-start` value | Meaning |
| ------------------------------ | ----------------------------------------------------------------------- |
| `""` or `new-only` | only messages published **after** the subscription starts (the default) |
| `first` | replay from the **beginning** of stored history |
| `last` | start from the **most recent** stored message |
| `sequence:` | start at sequence number `` (**1-based**, non-negative) |
| `time:` | start at a wall-clock time |
| `time-delta:` | start `` ago from now |
Notes:
* **Time granularity is seconds; the store keeps nanoseconds.** You send `time:` as RFC3339 or whole seconds and the connector converts to the store's nanosecond resolution. `time-delta:` is whole seconds, used verbatim.
* **No "last N by count".** There is no way to ask for "the last N messages" — bound a replay with `sequence:`, `time:`, or `time-delta:`.
* **Malformed values are rejected at attach.** `sequence:abc`, `time:not-a-time`, or an unknown token returns `DETACH(amqp:invalid-field)` naming the offending token.
For **filtered replay** — replaying only the subset of stored messages matching a selector — combine a start position with a selector filter on the receiver; see [Address mapping](/connectors/amqp/reference/address-mapping).
## How it works [#how-it-works]
A durable subscriber attaches with a stable identity and a non-expiring source. On a clean disconnect the connector preserves the cursor; re-attaching with the same `(container-id, link name)` resumes and delivers every event published while the subscriber was away.
*Persisted events are replayed to a durable subscriber from its chosen start position; the cursor survives disconnects so the subscription resumes exactly where it left off.*
**Events-store stalled credit loses the buffered window.** A consume link fronts the subscription with a deliver-first ring buffer (≈1024 by default) that is auto-acked in the store *before* you take delivery. If the buffer fills while your credit stays at 0, the link detaches with `amqp:resource-limit-exceeded` ("credit stalled") and the entire buffered, already-acked window is lost — a durable re-attach resumes *after* it. Replenish credit aggressively so the buffer never fills at zero credit. The only signal is the metric `kubemq_amqp10_events_store_dropped_stalled_total`.
## Durable subscribe and resume [#durable-subscribe-and-resume]
Each example publishes 3 events to a live durable subscriber, disconnects, publishes 5 more while the subscriber is away, then re-attaches with the **same** durable identity and receives exactly the 5 missed events — no loss, no re-delivery of the already-consumed first 3. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`).
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
amqp "github.com/Azure/go-amqp"
)
const channel = "amqp10.examples.durable"
// The durable identity = (containerID, linkName). Both MUST be stable to resume.
const (
containerID = "amqp10-examples-durable-container"
linkName = "durable-sub"
)
const standingCredit = 100
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://localhost:5672"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
addr := "events-store/" + channel // events-store/ prefix → KubeMQ Events Store
// Producer — a plain connection (no stable id needed) that publishes throughout.
prodConn, _ := amqp.Dial(ctx, amqpURL(), nil)
defer func() { _ = prodConn.Close() }()
prodSess, _ := prodConn.NewSession(ctx, nil)
sender, _ := prodSess.NewSender(ctx, addr, nil)
// 1. DURABLE SUBSCRIBE (first attach): stable container-id + link name +
// non-expiring source + start=new-only.
durRcv, durConn := attachDurable(ctx, "first attach")
publish(ctx, sender, 0, 3)
first := drain(ctx, durRcv, 3, 30*time.Second)
fmt.Printf("first attach received %d events: %v\n", len(first), first)
// 2. DISCONNECT — the connector preserves the durable cursor.
_ = durConn.Close()
time.Sleep(time.Second)
// 3. PUBLISH 5 more while away.
publish(ctx, sender, 3, 8)
// 4. RE-ATTACH with the SAME identity → resumes and delivers the 5 missed events.
durRcv2, durConn2 := attachDurable(ctx, "re-attach")
defer func() { _ = durConn2.Close() }()
resumed := drain(ctx, durRcv2, 5, 30*time.Second)
fmt.Printf("re-attach resumed and received the %d events published while away: %v\n", len(resumed), resumed)
}
func attachDurable(ctx context.Context, phase string) (*amqp.Receiver, *amqp.Conn) {
conn, err := amqp.Dial(ctx, amqpURL(), &amqp.ConnOptions{ContainerID: containerID})
if err != nil {
log.Fatalf("[%s] dial durable: %v", phase, err)
}
session, _ := conn.NewSession(ctx, nil)
rcv, err := session.NewReceiver(ctx, "events-store/"+channel, &amqp.ReceiverOptions{
Credit: standingCredit,
SourceExpiryPolicy: amqp.ExpiryPolicyNever, // durable signal
Name: linkName, // stable link name
Properties: map[string]any{"x-opt-kubemq-start": "new-only"}, // start cursor
})
if err != nil {
log.Fatalf("[%s] attach durable receiver: %v", phase, err)
}
time.Sleep(750 * time.Millisecond) // let the subscription pump go live
return rcv, conn
}
func publish(ctx context.Context, sender *amqp.Sender, lo, hi int) {
for i := lo; i < hi; i++ {
if err := sender.Send(ctx, amqp.NewMessage([]byte(fmt.Sprintf("es-%03d", i))), nil); err != nil {
log.Fatalf("publish: %v", err)
}
}
}
func drain(ctx context.Context, rcv *amqp.Receiver, max int, window time.Duration) []string {
out := make([]string, 0, max)
deadline := time.Now().Add(window)
for len(out) < max && time.Now().Before(deadline) {
rcvCtx, cancel := context.WithTimeout(ctx, time.Until(deadline))
msg, err := rcv.Receive(rcvCtx, nil)
cancel()
if err != nil {
break
}
_ = rcv.AcceptMessage(ctx, msg)
out = append(out, string(msg.GetData()))
}
return out
}
```
```python
import os
import time
from proton import Message, Terminus, symbol
from proton.reactor import Container, ReceiverOption
from proton.utils import BlockingConnection
CHANNEL = "amqp10.examples.durable"
# The durable identity = (container-id, link-name). Both MUST be stable to resume.
CONTAINER_ID = "amqp10-examples-durable-container"
LINK_NAME = "durable-sub"
START_PROP = "x-opt-kubemq-start"
STANDING_CREDIT = 100
def amqp_url() -> str:
return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")
class DurableSource(ReceiverOption):
"""Make the source durable + non-expiring and stamp x-opt-kubemq-start."""
def __init__(self, start: str) -> None:
self.start = start
def apply(self, receiver) -> None:
receiver.source.durability = Terminus.DELIVERIES
receiver.source.expiry_policy = Terminus.EXPIRE_NEVER # durable signal
receiver.properties = {symbol(START_PROP): self.start} # start cursor (link prop)
def attach_durable(phase: str):
container = Container()
container.container_id = CONTAINER_ID # half the durable identity
conn = BlockingConnection(amqp_url(), container=container)
receiver = conn.create_receiver(
"events-store/" + CHANNEL,
credit=STANDING_CREDIT,
name=LINK_NAME, # the other half of the identity
options=DurableSource("new-only"),
)
time.sleep(0.75) # let the subscription pump go live
return conn, receiver
def publish(sender, lo: int, hi: int) -> None:
for i in range(lo, hi):
sender.send(Message(body=f"es-{i:03d}"))
def drain(receiver, want: int, window: float) -> list[str]:
out: list[str] = []
deadline = time.monotonic() + window
while len(out) < want and time.monotonic() < deadline:
try:
msg = receiver.receive(timeout=max(0.0, deadline - time.monotonic()))
except Exception:
break
if receiver.fetcher.unsettled:
receiver.accept()
out.append(str(msg.body))
return out
def main() -> None:
addr = "events-store/" + CHANNEL # events-store/ prefix → KubeMQ Events Store
prod_conn = BlockingConnection(amqp_url())
sender = prod_conn.create_sender(addr)
# 1. DURABLE SUBSCRIBE (first attach).
dur_conn, dur_rcv = attach_durable("first attach")
publish(sender, 0, 3)
first = drain(dur_rcv, 3, 30.0)
print(f"first attach received {len(first)} events: {first}")
# 2. DISCONNECT — the connector preserves the durable cursor.
dur_conn.close()
time.sleep(1.0)
# 3. PUBLISH 5 more while away.
publish(sender, 3, 8)
# 4. RE-ATTACH with the SAME identity → resumes and delivers the 5 missed events.
dur_conn2, dur_rcv2 = attach_durable("re-attach")
resumed = drain(dur_rcv2, 5, 30.0)
print(f"re-attach resumed and received the {len(resumed)} events published while away: {resumed}")
dur_conn2.close()
sender.close()
prod_conn.close()
if __name__ == "__main__":
main()
```
```java
import java.util.HashSet;
import java.util.Set;
import javax.jms.Connection;
import javax.jms.Message;
import javax.jms.MessageConsumer;
import javax.jms.MessageProducer;
import javax.jms.Session;
import javax.jms.Topic;
import org.apache.qpid.jms.JmsConnectionFactory;
public final class Main {
private static final String CHANNEL = "amqp10.examples.durable";
// The durable identity = (JMS clientID, subscription name). Both MUST be stable.
private static final String CLIENT_ID = "amqp10-examples-durable-container";
private static final String SUB_NAME = "durable-sub";
public static void main(String[] args) throws Exception {
String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672");
String address = "events-store/" + CHANNEL; // events-store/ → KubeMQ Events Store
JmsConnectionFactory factory = new JmsConnectionFactory(url);
// Producer — a separate connection (no stable clientID needed).
try (Connection prodConn = factory.createConnection();
Session prodSession = prodConn.createSession(false, Session.AUTO_ACKNOWLEDGE)) {
prodConn.start();
Topic topic = prodSession.createTopic(address);
try (MessageProducer producer = prodSession.createProducer(topic)) {
// 1. DURABLE SUBSCRIBE (first attach): clientID + durable consumer.
try (Connection durConn = factory.createConnection()) {
durConn.setClientID(CLIENT_ID);
durConn.start();
try (Session durSession = durConn.createSession(false, Session.CLIENT_ACKNOWLEDGE)) {
Topic durTopic = durSession.createTopic(address);
MessageConsumer durable = durSession.createDurableConsumer(durTopic, SUB_NAME);
Thread.sleep(750);
publish(prodSession, producer, 0, 3);
Set first = drain(durable, 3, 30_000);
System.out.printf("first attach received %d events: %s%n", first.size(), first);
durable.close(); // detach but KEEP the durable
}
}
Thread.sleep(1_000);
// 3. PUBLISH 5 more while away.
publish(prodSession, producer, 3, 8);
// 4. RE-ATTACH with the SAME identity → resumes the subscription.
try (Connection durConn2 = factory.createConnection()) {
durConn2.setClientID(CLIENT_ID);
durConn2.start();
try (Session durSession2 = durConn2.createSession(false, Session.CLIENT_ACKNOWLEDGE)) {
Topic durTopic2 = durSession2.createTopic(address);
MessageConsumer durable2 = durSession2.createDurableConsumer(durTopic2, SUB_NAME);
Set resumed = drain(durable2, 5, 30_000);
System.out.printf(
"re-attach resumed and received the %d events published while away: %s%n",
resumed.size(), resumed);
durable2.close();
durSession2.unsubscribe(SUB_NAME); // clean teardown of the durable
}
}
}
}
}
private static void publish(Session session, MessageProducer producer, int lo, int hi) throws Exception {
for (int i = lo; i < hi; i++) {
producer.send(session.createTextMessage(String.format("es-%03d", i)));
}
}
private static Set drain(MessageConsumer consumer, int max, long timeoutMillis) throws Exception {
Set out = new HashSet<>();
long deadline = System.currentTimeMillis() + timeoutMillis;
while (out.size() < max) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) break;
Message msg = consumer.receive(remaining);
if (msg == null) break;
msg.acknowledge();
out.add(msg.getBody(String.class));
}
return out;
}
}
```
```csharp
using System.Text;
using Amqp;
using Amqp.Framing;
using Amqp.Types;
const string channel = "amqp10.examples.durable";
// The durable identity = (containerID, linkName). Both MUST be stable to resume.
const string containerId = "amqp10-examples-durable-container";
const string linkName = "durable-sub";
const int standingCredit = 100;
var expiryNever = new Symbol("never"); // terminus-expiry-policy = never (durable signal)
static string AmqpUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
? v
: "amqp://localhost:5672";
var addr = "events-store/" + channel; // events-store/ prefix → KubeMQ Events Store
// Producer — a plain connection (no stable id needed) that publishes throughout.
var prodConnection = await Connection.Factory.CreateAsync(new Address(AmqpUrl()));
var prodSession = new Session(prodConnection);
var sender = new SenderLink(prodSession, "durable-producer", addr);
void Publish(int lo, int hi)
{
for (var i = lo; i < hi; i++)
{
var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"es-{i:D3}") } };
sender.Send(message, TimeSpan.FromSeconds(15));
}
}
(ReceiverLink Receiver, Connection Connection) AttachDurable(string phase)
{
var factory = new ConnectionFactory();
factory.AMQP.ContainerId = containerId; // half the durable identity
var connection = factory.CreateAsync(new Address(AmqpUrl())).GetAwaiter().GetResult();
var session = new Session(connection);
var attach = new Attach
{
Source = new Source { Address = addr, ExpiryPolicy = expiryNever },
Target = new Target(),
LinkName = linkName, // the other half of the durable identity
Properties = new Fields { { new Symbol("x-opt-kubemq-start"), "new-only" } },
};
var receiver = new ReceiverLink(session, linkName, attach, null);
receiver.SetCredit(standingCredit, autoRestore: true);
Thread.Sleep(750); // let the subscription pump go live
return (receiver, connection);
}
List Drain(ReceiverLink receiver, int max, TimeSpan window)
{
var outp = new List(max);
var deadline = DateTime.UtcNow + window;
while (outp.Count < max && DateTime.UtcNow < deadline)
{
var message = receiver.Receive(TimeSpan.FromSeconds(2));
if (message is null) continue;
receiver.Accept(message);
outp.Add(BodyString(message));
}
return outp;
}
try
{
// 1. DURABLE SUBSCRIBE (first attach).
var (durRcv, durConn) = AttachDurable("first attach");
Publish(0, 3);
var first = Drain(durRcv, 3, TimeSpan.FromSeconds(30));
Console.WriteLine($"first attach received {first.Count} events: [{string.Join(" ", first)}]");
// 2. DISCONNECT — the connector preserves the durable cursor.
await durConn.CloseAsync();
await Task.Delay(1000);
// 3. PUBLISH 5 more while away.
Publish(3, 8);
// 4. RE-ATTACH with the SAME identity → resumes the subscription.
var (durRcv2, durConn2) = AttachDurable("re-attach");
try
{
var resumed = Drain(durRcv2, 5, TimeSpan.FromSeconds(30));
Console.WriteLine($"re-attach resumed and received the {resumed.Count} events published while away: [{string.Join(" ", resumed)}]");
await durRcv2.CloseAsync();
}
finally
{
await durConn2.CloseAsync();
}
}
finally
{
await sender.CloseAsync();
await prodSession.CloseAsync();
await prodConnection.CloseAsync();
}
static string BodyString(Message message) => message.BodySection switch
{
Data d => Encoding.UTF8.GetString(d.Binary),
AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes),
AmqpValue { Value: string str } => str,
AmqpValue v => v.Value?.ToString() ?? string.Empty,
_ => string.Empty,
};
```
```typescript
import {
Connection,
ReceiverEvents,
type ConnectionOptions,
type EventContext,
type Receiver,
} from "rhea-promise";
const channel = "amqp10.examples.durable";
// The durable identity = (containerID, linkName). Both MUST be stable to resume.
const containerId = "amqp10-examples-durable-container";
const linkName = "durable-sub";
const standingCredit = 100;
function brokerEndpoint(): { host: string; port: number } {
const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672");
return { host: url.hostname, port: url.port ? Number(url.port) : 5672 };
}
function durableOptions(): ConnectionOptions {
const { host, port } = brokerEndpoint();
return { host, port, container_id: containerId, reconnect: false }; // STABLE id
}
function bodyToString(body: unknown): string {
return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
}
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface DurableAttach {
connection: Connection;
receiver: Receiver;
}
async function attachDurable(): Promise {
const connection = new Connection(durableOptions());
await connection.open();
const receiver = await connection.createReceiver({
name: linkName, // half the durable identity
source: {
address: `events-store/${channel}`,
expiry_policy: "never", // durable signal
},
properties: { "x-opt-kubemq-start": "new-only" }, // start cursor
credit_window: 0,
autoaccept: false,
autosettle: false,
});
await sleep(750); // let the subscription pump go live
return { connection, receiver };
}
function drain(receiver: Receiver, max: number, windowMs: number): Promise {
return new Promise((resolve, reject) => {
const out: string[] = [];
const timer = setTimeout(() => {
receiver.removeListener(ReceiverEvents.message, handler);
resolve(out);
}, windowMs);
const handler = (ctx: EventContext): void => {
try {
ctx.delivery?.accept(); // accept advances the durable cursor
out.push(bodyToString(ctx.message?.body));
if (out.length >= max) {
clearTimeout(timer);
receiver.removeListener(ReceiverEvents.message, handler);
resolve(out);
return;
}
receiver.addCredit(1);
} catch (err) {
clearTimeout(timer);
receiver.removeListener(ReceiverEvents.message, handler);
reject(err instanceof Error ? err : new Error(String(err)));
}
};
receiver.on(ReceiverEvents.message, handler);
receiver.addCredit(standingCredit);
});
}
async function main(): Promise {
const { host, port } = brokerEndpoint();
const address = `events-store/${channel}`; // events-store/ → KubeMQ Events Store
// Producer — a plain connection (no stable id needed) that publishes throughout.
const prodConnection = new Connection({
host, port, container_id: `kubemq-amqp10-js-durable-prod-${process.pid}`, reconnect: false,
});
await prodConnection.open();
const sender = await prodConnection.createAwaitableSender({ target: { address } });
const publish = async (lo: number, hi: number): Promise => {
for (let i = lo; i < hi; i++) {
await sender.send({ body: `es-${String(i).padStart(3, "0")}` }, { timeoutInSeconds: 15 });
}
};
try {
// 1. DURABLE SUBSCRIBE (first attach).
const first = await attachDurable();
await publish(0, 3);
const firstBodies = await drain(first.receiver, 3, 30_000);
console.log(`first attach received ${firstBodies.length} events: [${firstBodies.join(" ")}]`);
// 2. DISCONNECT — the connector preserves the durable cursor.
await first.connection.close();
await sleep(1_000);
// 3. PUBLISH 5 more while away.
await publish(3, 8);
// 4. RE-ATTACH with the SAME identity → resumes the subscription.
const second = await attachDurable();
try {
const resumed = await drain(second.receiver, 5, 30_000);
console.log(`re-attach resumed and received the ${resumed.length} events published while away: [${resumed.join(" ")}]`);
} finally {
await second.connection.close();
}
} finally {
await sender.close();
await prodConnection.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```rust
use std::collections::HashSet;
use std::time::Duration;
use fe2o3_amqp::connection::ConnectionHandle;
use fe2o3_amqp::link::receiver::CreditMode;
use fe2o3_amqp::session::SessionHandle;
use fe2o3_amqp::{Connection, Receiver, Sender, Session};
use fe2o3_amqp_types::definitions::SenderSettleMode;
use fe2o3_amqp_types::messaging::{Body, Source, TerminusExpiryPolicy};
use fe2o3_amqp_types::primitives::{OrderedMap, Symbol, Value};
const CHANNEL: &str = "amqp10.examples.durable";
// The durable identity = (container-id, link name). Both MUST be stable to resume.
const CONTAINER_ID: &str = "amqp10-examples-durable-container";
const LINK_NAME: &str = "durable-sub";
const STANDING_CREDIT: u32 = 100;
const START_PROP: &str = "x-opt-kubemq-start";
fn amqp_url() -> String {
std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string())
}
fn body_string(body: &Body) -> String {
let bytes = match body {
Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(),
Body::Value(v) => match &v.0 {
Value::Binary(b) => b.to_vec(),
Value::String(s) => s.clone().into_bytes(),
other => format!("{other:?}").into_bytes(),
},
_ => Vec::new(),
};
String::from_utf8_lossy(&bytes).into_owned()
}
fn durable_source(addr: &str) -> Source {
Source::builder()
.address(addr)
.expiry_policy(TerminusExpiryPolicy::Never) // durable signal
.build()
}
fn start_props(start: &str) -> OrderedMap {
let mut props = OrderedMap::new();
props.insert(Symbol::from(START_PROP), Value::String(start.to_string()));
props
}
#[allow(clippy::type_complexity)]
async fn attach_durable(
url: &str,
addr: &str,
) -> Result<(Receiver, SessionHandle<()>, ConnectionHandle<()>), Box> {
let mut connection = Connection::builder()
.container_id(CONTAINER_ID) // half the durable identity
.open(url)
.await?;
let mut session = Session::begin(&mut connection).await?;
let receiver = Receiver::builder()
.name(LINK_NAME) // the other half of the identity
.source(durable_source(addr))
.properties(start_props("new-only")) // start cursor
.credit_mode(CreditMode::Auto(STANDING_CREDIT))
.attach(&mut session)
.await?;
tokio::time::sleep(Duration::from_millis(750)).await; // let the pump go live
Ok((receiver, session, connection))
}
async fn publish(sender: &mut Sender, lo: usize, hi: usize) -> Result<(), Box> {
for i in lo..hi {
let outcome = sender.send(format!("es-{i:03}")).await?;
if !outcome.is_accepted() {
return Err(format!("publish es-{i:03}: unexpected outcome {outcome:?}").into());
}
}
Ok(())
}
async fn drain(receiver: &mut Receiver, max: usize, window: Duration) -> Result, Box> {
let mut out = Vec::with_capacity(max);
let deadline = tokio::time::Instant::now() + window;
while out.len() < max {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, receiver.recv::>()).await {
Ok(Ok(delivery)) => {
receiver.accept(&delivery).await?;
out.push(body_string(&delivery.message().body));
}
_ => break,
}
}
Ok(out)
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let url = amqp_url();
let addr = format!("events-store/{CHANNEL}"); // events-store/ → KubeMQ Events Store
// Producer — a plain connection (no stable id needed) that publishes throughout.
let mut prod_conn = Connection::open("amqp10-examples-durable-producer", url.as_str()).await?;
let mut prod_sess = Session::begin(&mut prod_conn).await?;
let mut sender = Sender::builder()
.name("durable-replay-producer")
.target(addr.as_str())
.sender_settle_mode(SenderSettleMode::Unsettled)
.attach(&mut prod_sess)
.await?;
// 1. DURABLE SUBSCRIBE (first attach).
let (mut dur_rcv, mut dur_sess, mut dur_conn) = attach_durable(url.as_str(), addr.as_str()).await?;
publish(&mut sender, 0, 3).await?;
let first = drain(&mut dur_rcv, 3, Duration::from_secs(30)).await?;
println!("first attach received {} events: {first:?}", first.len());
// 2. DISCONNECT — the connector preserves the durable cursor.
dur_rcv.close().await?;
dur_sess.end().await?;
dur_conn.close().await?;
tokio::time::sleep(Duration::from_secs(1)).await;
// 3. PUBLISH 5 more while away.
publish(&mut sender, 3, 8).await?;
// 4. RE-ATTACH with the SAME identity → resumes the subscription.
let (mut dur_rcv2, mut dur_sess2, mut dur_conn2) = attach_durable(url.as_str(), addr.as_str()).await?;
let resumed = drain(&mut dur_rcv2, 5, Duration::from_secs(30)).await?;
let resumed_set: HashSet = resumed.iter().cloned().collect();
println!("re-attach resumed and received the {} events published while away: {resumed:?}", resumed_set.len());
dur_rcv2.close().await?;
dur_sess2.end().await?;
dur_conn2.close().await?;
sender.close().await?;
prod_sess.end().await?;
prod_conn.close().await?;
Ok(())
}
```
**Durable subscriptions are node-local.** A durable identity may have at most **one live attach per node**; a second live attach of the same identity returns `DETACH(amqp:not-allowed, "durable subscription in use")`. In a cluster the durable cursor lives on the node that owned the original attach, so a durable subscriber must reconnect to the **same node** to resume — use load-balancer session affinity or a sticky connection. (RPC replies travel the broker reply path and are cluster-safe; durable subscriptions are not.)
## Related [#related]
# Events (/connectors/amqp/concepts/events)
Events are **fire-and-forget** pub/sub over the AMQP 1.0 connector. Attach a sender or receiver to a node whose address begins with `events/` and the connector binds the link to the KubeMQ **Events** pattern. Every active subscriber receives a **copy** of every message; there is no persistence and no replay.
## Overview [#overview]
The `events/` prefix selects the Events pattern (longest-prefix match, evaluated before `events-store/`). A producer attaches a **sender** to `events/`; each `TRANSFER` becomes a KubeMQ `SendEvents` with `Store=false`. A consumer attaches a **receiver** to the same node and grants credit; deliveries are **always pre-settled** (`settled=true`) — at-most-once, with no `DISPOSITION` round-trip.
Use Events for real-time notifications, telemetry, and broadcast where a missed message is acceptable. When you need durability and replay, use [Events Store](/connectors/amqp/concepts/events-store) instead.
| Operation | AMQP action | KubeMQ mapping |
| -------------- | --------------------------------------------------------------------------------- | ------------------------------------------- |
| Produce | Attach a **sender** to `events/`, `TRANSFER` (pre-settle for fire-and-forget) | `SendEvents` (`Store=false`) |
| Consume | Attach a **receiver** to `events/`, grant credit | Pre-settled fan-out delivery (at-most-once) |
| Consumer group | Set link property `x-opt-kubemq-group` on the receiver | Load-balanced subset of the stream |
## How it works [#how-it-works]
A published event fans out to every connected receiver on the channel. Receivers that share an `x-opt-kubemq-group` link property split the stream load-balanced; a receiver with no group is a plain fan-out subscriber.
*Each event is copied to every plain subscriber; members sharing an `x-opt-kubemq-group` split the stream between them.*
**Events at 0 credit are silently dropped.** A message that arrives at a receiver whose link credit is 0 is discarded with no error and no `DISPOSITION` — that is what at-most-once means here. Grant a standing credit and replenish it eagerly, and **subscribe before you publish** (there is no replay to catch up from). The connector counts every drop in `kubemq_amqp10_events_dropped_no_credit_total`.
## Publish and subscribe [#publish-and-subscribe]
Each example below subscribes **first** (a receiver with a large standing credit), waits \~750 ms for the connector's subscription pump to go live, then publishes pre-settled events and drains them. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`).
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
amqp "github.com/Azure/go-amqp"
)
const channel = "amqp10.examples.pubsub"
const total = 20
const standingCredit = 100 // never let credit reach 0 — a 0-credit event is dropped
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://localhost:5672"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
addr := "events/" + channel // events/ prefix → KubeMQ Events pattern
conn, err := amqp.Dial(ctx, amqpURL(), nil)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
session, err := conn.NewSession(ctx, nil)
if err != nil {
log.Fatalf("new session: %v", err)
}
// 1. SUBSCRIBE FIRST with standing credit. Events have no replay — a publish
// that beats the subscription is lost forever.
receiver, err := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: standingCredit})
if err != nil {
log.Fatalf("new receiver: %v", err)
}
// The attach reply confirms the link, not that the subscription pump is live.
time.Sleep(750 * time.Millisecond)
// 2. PUBLISH pre-settled (fire-and-forget) — no DISPOSITION to await.
sender, err := session.NewSender(ctx, addr, &amqp.SenderOptions{
SettlementMode: amqp.SenderSettleModeSettled.Ptr(),
})
if err != nil {
log.Fatalf("new sender: %v", err)
}
for i := 0; i < total; i++ {
if err := sender.Send(ctx, amqp.NewMessage([]byte(fmt.Sprintf("event-%03d", i))), nil); err != nil {
log.Fatalf("publish: %v", err)
}
}
_ = sender.Close(ctx)
// 3. RECEIVE. Standing credit drains every event; accept is a no-op on
// pre-settled fan-out but harmless.
seen := make(map[string]struct{}, total)
for len(seen) < total {
msg, err := receiver.Receive(ctx, nil)
if err != nil {
log.Fatalf("receive: %v", err)
}
_ = receiver.AcceptMessage(ctx, msg)
seen[string(msg.GetData())] = struct{}{}
}
fmt.Printf("received all %d events\n", len(seen))
_ = receiver.Close(ctx)
}
```
```python
import os
import time
from proton import Message
from proton.reactor import AtMostOnce
from proton.utils import BlockingConnection
CHANNEL = "amqp10.examples.pubsub"
TOTAL = 20
STANDING_CREDIT = 100 # never let credit reach 0 — a 0-credit event is dropped
def amqp_url() -> str:
return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")
def accept_if_unsettled(receiver) -> None:
# Events fan-out deliveries are pre-settled, so accept() on a settled delivery
# raises IndexError. This makes accept a true no-op on pre-settled pub/sub.
if receiver.fetcher.unsettled:
receiver.accept()
def main() -> None:
addr = "events/" + CHANNEL # events/ prefix → KubeMQ Events pattern
conn = BlockingConnection(amqp_url())
try:
# 1. SUBSCRIBE FIRST with standing credit (events have no replay).
receiver = conn.create_receiver(addr, credit=STANDING_CREDIT)
time.sleep(0.75) # let the subscription pump go live before publishing
# 2. PUBLISH pre-settled (AtMostOnce) — fire-and-forget, no DISPOSITION.
sender = conn.create_sender(addr, options=AtMostOnce())
for i in range(TOTAL):
sender.send(Message(body=f"event-{i:03d}"))
sender.close()
# 3. RECEIVE. Standing credit drains every event.
seen: set[str] = set()
while len(seen) < TOTAL:
msg = receiver.receive(timeout=30.0)
accept_if_unsettled(receiver)
seen.add(str(msg.body))
print(f"received all {len(seen)} events")
receiver.close()
finally:
conn.close()
if __name__ == "__main__":
main()
```
```java
import java.util.HashSet;
import java.util.Set;
import javax.jms.Connection;
import javax.jms.DeliveryMode;
import javax.jms.Message;
import javax.jms.MessageConsumer;
import javax.jms.MessageProducer;
import javax.jms.Session;
import javax.jms.Topic;
import org.apache.qpid.jms.JmsConnectionFactory;
public final class Main {
private static final String CHANNEL = "amqp10.examples.pubsub";
private static final int TOTAL = 20;
public static void main(String[] args) throws Exception {
String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672");
String address = "events/" + CHANNEL; // events/ prefix → KubeMQ Events pattern
JmsConnectionFactory factory = new JmsConnectionFactory(url);
try (Connection connection = factory.createConnection()) {
connection.start();
try (Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE)) {
Topic topic = session.createTopic(address);
// 1. SUBSCRIBE FIRST (Qpid JMS grants a standing prefetch credit).
try (MessageConsumer consumer = session.createConsumer(topic)) {
Thread.sleep(750); // let the subscription pump go live
// 2. PUBLISH NON_PERSISTENT (fire-and-forget, pre-settled).
try (MessageProducer producer = session.createProducer(topic)) {
producer.setDeliveryMode(DeliveryMode.NON_PERSISTENT);
for (int i = 0; i < TOTAL; i++) {
producer.send(session.createTextMessage(String.format("event-%03d", i)));
}
}
// 3. RECEIVE. The connector re-emits the body as a Data section;
// getBody(String.class) decodes either type as UTF-8.
Set seen = new HashSet<>();
while (seen.size() < TOTAL) {
Message msg = consumer.receive(30_000);
if (msg == null) throw new IllegalStateException("timed out");
seen.add(msg.getBody(String.class));
}
System.out.printf("received all %d events%n", seen.size());
}
}
}
}
}
```
```csharp
using System.Text;
using Amqp;
using Amqp.Framing;
const string channel = "amqp10.examples.pubsub";
const int total = 20;
const int standingCredit = 100; // never let credit reach 0 — a 0-credit event is dropped
static string AmqpUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
? v
: "amqp://localhost:5672";
var addr = "events/" + channel; // events/ prefix → KubeMQ Events pattern
var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl()));
try
{
var session = new Session(connection);
// 1. SUBSCRIBE FIRST with standing credit (autoRestore replenishes on settle).
var receiver = new ReceiverLink(session, "pubsub-receiver", addr);
receiver.SetCredit(standingCredit, autoRestore: true);
await Task.Delay(750); // let the subscription pump go live before publishing
// 2. PUBLISH pre-settled — SndSettleMode.Settled marks every TRANSFER settled.
var senderAttach = new Attach
{
Source = new Source(),
Target = new Target { Address = addr },
SndSettleMode = SenderSettleMode.Settled,
};
var sender = new SenderLink(session, "pubsub-sender", senderAttach, null);
for (var i = 0; i < total; i++)
{
var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"event-{i:D3}") } };
sender.Send(message, TimeSpan.FromSeconds(15));
}
// 3. RECEIVE. Standing credit drains every event; Accept is a no-op here.
var seen = new HashSet();
while (seen.Count < total)
{
var message = receiver.Receive(TimeSpan.FromSeconds(30))
?? throw new InvalidOperationException("receive timed out");
receiver.Accept(message);
seen.Add(BodyString(message));
}
Console.WriteLine($"received all {seen.Count} events");
await sender.CloseAsync();
await receiver.CloseAsync();
await session.CloseAsync();
}
finally
{
await connection.CloseAsync();
}
static string BodyString(Message message) => message.BodySection switch
{
Data d => Encoding.UTF8.GetString(d.Binary),
AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes),
AmqpValue { Value: string str } => str,
AmqpValue v => v.Value?.ToString() ?? string.Empty,
_ => string.Empty,
};
```
```typescript
import {
Connection,
ReceiverEvents,
type EventContext,
type Receiver,
} from "rhea-promise";
const channel = "amqp10.examples.pubsub";
const total = 20;
const standingCredit = 100; // never let credit reach 0 — a 0-credit event is dropped
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function bodyToString(body: unknown): string {
return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
}
async function main(): Promise {
const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672");
const address = `events/${channel}`; // events/ prefix → KubeMQ Events pattern
const connection = new Connection({
host: url.hostname,
port: url.port ? Number(url.port) : 5672,
container_id: `kubemq-amqp10-js-pubsub-${process.pid}`,
reconnect: false,
});
await connection.open();
try {
// 1. SUBSCRIBE FIRST. Register the handler before granting credit so no
// early delivery is missed (events have no replay).
const receiver = await connection.createReceiver({
source: { address },
credit_window: 0,
autoaccept: false,
autosettle: false,
});
const seen = new Set();
const received = drainEvents(receiver, (ctx) => {
ctx.delivery?.accept(); // no-op for pre-settled fan-out, but harmless
seen.add(bodyToString(ctx.message?.body));
return seen.size >= total;
}, standingCredit, 30_000);
await sleep(750); // let the subscription pump go live before publishing
// 2. PUBLISH pre-settled (snd_settle_mode: 1) — fire-and-forget.
const sender = await connection.createSender({
target: { address },
snd_settle_mode: 1,
autosettle: true,
});
for (let i = 0; i < total; i++) {
sender.send({ body: `event-${String(i).padStart(3, "0")}` });
}
await sender.close();
await received;
console.log(`received all ${seen.size} events`);
await receiver.close();
} finally {
await connection.close();
}
}
// Grants standing credit and tops it back up as messages arrive so the
// subscriber is never starved (a 0-credit event is silently dropped).
function drainEvents(
receiver: Receiver,
onMessage: (ctx: EventContext) => boolean,
credit: number,
timeoutMs: number,
): Promise {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
receiver.removeListener(ReceiverEvents.message, handler);
reject(new Error("timed out waiting for events"));
}, timeoutMs);
const handler = (ctx: EventContext): void => {
if (onMessage(ctx)) {
clearTimeout(timer);
receiver.removeListener(ReceiverEvents.message, handler);
resolve();
return;
}
receiver.addCredit(1); // replenish so standing credit never drains to 0
};
receiver.on(ReceiverEvents.message, handler);
receiver.addCredit(credit);
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```rust
use std::collections::HashSet;
use std::time::Duration;
use fe2o3_amqp::link::delivery::Delivery;
use fe2o3_amqp::link::receiver::CreditMode;
use fe2o3_amqp::{Connection, Receiver, Sender, Session};
use fe2o3_amqp_types::definitions::SenderSettleMode;
use fe2o3_amqp_types::messaging::{Body, Message};
use fe2o3_amqp_types::primitives::Value;
const CHANNEL: &str = "amqp10.examples.pubsub";
const TOTAL: usize = 20;
const STANDING_CREDIT: u32 = 100; // never let credit reach 0 — a 0-credit event is dropped
fn amqp_url() -> String {
std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string())
}
fn body_string(msg: &Message>) -> String {
let bytes = match &msg.body {
Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(),
Body::Value(v) => match &v.0 {
Value::Binary(b) => b.to_vec(),
Value::String(s) => s.clone().into_bytes(),
other => format!("{other:?}").into_bytes(),
},
_ => Vec::new(),
};
String::from_utf8_lossy(&bytes).into_owned()
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let addr = format!("events/{CHANNEL}"); // events/ prefix → KubeMQ Events pattern
let mut connection = Connection::open("amqp10-examples-pubsub", amqp_url().as_str()).await?;
let mut session = Session::begin(&mut connection).await?;
// 1. SUBSCRIBE FIRST with standing credit (CreditMode::Auto auto-replenishes).
let mut receiver = Receiver::builder()
.name("basic-pubsub-receiver")
.source(addr.as_str())
.credit_mode(CreditMode::Auto(STANDING_CREDIT))
.attach(&mut session)
.await?;
tokio::time::sleep(Duration::from_millis(750)).await; // let the pump go live
// 2. PUBLISH pre-settled (SenderSettleMode::Settled) — fire-and-forget.
let mut sender = Sender::builder()
.name("basic-pubsub-sender")
.target(addr.as_str())
.sender_settle_mode(SenderSettleMode::Settled)
.attach(&mut session)
.await?;
for i in 0..TOTAL {
sender.send(format!("event-{i:03}")).await?;
}
sender.close().await?;
// 3. RECEIVE. Standing credit drains every event.
let mut seen: HashSet = HashSet::with_capacity(TOTAL);
while seen.len() < TOTAL {
let delivery: Delivery> = receiver.recv().await?;
let _ = receiver.accept(&delivery).await; // no-op on pre-settled, harmless
seen.insert(body_string(delivery.message()));
}
println!("received all {} events", seen.len());
receiver.close().await?;
session.end().await?;
connection.close().await?;
Ok(())
}
```
## Consumer groups [#consumer-groups]
A receiver with **no** group is a plain fan-out subscriber — it receives every event (the KubeMQ default). Set the link property **`x-opt-kubemq-group`** on a receiver's `ATTACH` to join a consumer group: within one group the stream is **load-balanced** across members (each message goes to exactly one member), while different groups each get the full stream independently.
```text
events/orders
├── group "g1": receiver-A ┐ (split — no duplicate within g1)
│ receiver-B ┘
└── group "g2": receiver-C (full stream)
```
In Go, set it on the receiver's link properties at attach:
```go
receiver, err := session.NewReceiver(ctx, "events/orders", &amqp.ReceiverOptions{
Credit: 100,
Properties: map[string]any{"x-opt-kubemq-group": "g1"},
})
```
The `x-opt-kubemq-group` property is honored on `events`, `events-store`, and the RPC consume patterns. The Go, Python, C#, JavaScript, and Rust clients can all set it on the receiver's `ATTACH`.
**Qpid JMS (Java) cannot join a consumer group today.** The connector advertises no `SHARED-SUBS` capability, so `createSharedConsumer` / `createSharedDurableConsumer` throws *"Remote peer does not support shared subscriptions"*, and Qpid JMS exposes no API to set the `x-opt-kubemq-group` link property directly. Java is fan-out only on Events; the other five languages support groups fully.
## Related [#related]
# Queries (/connectors/amqp/concepts/queries)
Queries are **native, in-protocol request/reply** over the AMQP 1.0 connector, used to **fetch a value**. Attach a link to a node whose address begins with `queries/` and the connector binds it to the KubeMQ **Queries** pattern. A query returns a **result body + metadata**; on failure it delivers **nothing**, and the requester detects failure by **timeout**.
## Overview [#overview]
The request path is **identical** to [Commands](/connectors/amqp/concepts/commands): the requester opens a **dynamic reply node** (`source.dynamic = true`), sends to `queries/` with `reply-to` = the minted node + a `correlation-id`, and the responder replies via an **anonymous sender** to `properties.to = /responses/` carrying the echoed `correlation-id`. The same **snooping guard** (`reply-to` must name a connection-owned node) and the same correlation-id-with-message-id-fallback rule apply.
**See [Commands](/connectors/amqp/concepts/commands) for the full RPC mechanics** — dynamic reply nodes, the anonymous responder, the snooping guard, correlation matching, and the `RpcMaxPending` cap. This page covers only what differs for queries.
## How Queries differ from Commands [#how-queries-differ-from-commands]
| | **Commands** | **Queries** |
| -------------------- | ------------------------------------------------------------ | --------------------------------------------------- |
| Reply body | optional | the result **body + metadata** |
| Reply app-properties | `x-opt-kubemq-executed` + `x-opt-kubemq-error` | **none** |
| On success | `executed=true` | body + metadata returned |
| **On failure** | a reply with `executed=false` (+ error) — never left waiting | **nothing delivered** — the requester **times out** |
A query is a "fetch a value" call: there is **no executed/error envelope**. When a query fails, times out, or the responder ignores it, the connector delivers no reply — so the requester's timeout **is** the failure signal. The connector's default per-request timeout is \~30 s; set the request's `header.ttl` (ms) to choose a per-request budget. Choose queries when a missing reply is an acceptable failure mode; choose commands when you need a positive failure signal.
## Request and reply [#request-and-reply]
Each example runs a responder and a requester (separate connections), sends a successful query (the reply round-trips with the result body) and a query the responder **ignores** (no reply, so the requester times out). Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`).
```go
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"sync"
"time"
amqp "github.com/Azure/go-amqp"
)
const channel = "amqp10.examples.queries"
// A short per-request deadline so the "no reply" leg surfaces a timeout quickly.
// The connector's own default RPC timeout is ~30s.
const demoTimeout = 5 * time.Second
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://localhost:5672"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
addr := "queries/" + channel // queries/ prefix → KubeMQ Queries pattern
ready := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); runResponder(ctx, addr, ready) }()
<-ready
runRequester(ctx, addr)
cancel()
wg.Wait()
}
// Responder: a query whose body is "ignore" gets NO reply (the requester times out).
func runResponder(ctx context.Context, addr string, ready chan<- struct{}) {
conn, _ := amqp.Dial(ctx, amqpURL(), nil)
defer func() { _ = conn.Close() }()
session, _ := conn.NewSession(ctx, nil)
rcv, _ := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10})
snd, _ := session.NewSender(ctx, "", nil) // anonymous reply sender (null target)
close(ready)
for {
req, err := rcv.Receive(ctx, nil)
if err != nil {
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
return
}
return
}
_ = rcv.AcceptMessage(context.Background(), req)
if req.Properties == nil || req.Properties.ReplyTo == nil {
continue
}
body := string(req.GetData())
if body == "ignore" {
continue // send NOTHING — the requester times out
}
// A QUERY reply carries ONLY the body + metadata — no executed/error props.
replyTo := *req.Properties.ReplyTo
reply := amqp.NewMessage([]byte("result:" + body))
reply.Properties = &amqp.MessageProperties{To: &replyTo}
if req.Properties.CorrelationID != nil {
reply.Properties.CorrelationID = req.Properties.CorrelationID
} else {
reply.Properties.CorrelationID = req.Properties.MessageID
}
_ = snd.Send(ctx, reply, nil)
}
}
// Requester: dynamic reply node + sender on queries/; correlate replies.
func runRequester(ctx context.Context, addr string) {
conn, _ := amqp.Dial(ctx, amqpURL(), nil)
defer func() { _ = conn.Close() }()
session, _ := conn.NewSession(ctx, nil)
// DYNAMIC reply node (see the Commands page for the shared mechanics).
replyRcv, _ := session.NewReceiver(ctx, "", &amqp.ReceiverOptions{DynamicAddress: true, Credit: 5})
replyNode := replyRcv.Address()
snd, _ := session.NewSender(ctx, addr, nil)
// 1. A SUCCESSFUL query: round-trips, body intact.
sendQuery(ctx, snd, replyNode, "get-temp-sensor-3", "corr-qry-1")
rcvCtx, c1 := context.WithTimeout(ctx, demoTimeout)
reply, err := replyRcv.Receive(rcvCtx, nil)
c1()
if err != nil {
log.Fatalf("await reply: %v", err)
}
_ = replyRcv.AcceptMessage(context.Background(), reply)
fmt.Printf("reply for %q: body=%q\n", "get-temp-sensor-3", string(reply.GetData()))
// 2. A query the responder ignores: NOTHING is delivered → the requester TIMES OUT.
// The absence of a reply IS the failure signal for queries.
sendQuery(ctx, snd, replyNode, "ignore", "corr-qry-2")
rcvCtx2, c2 := context.WithTimeout(ctx, demoTimeout)
_, err = replyRcv.Receive(rcvCtx2, nil)
c2()
if err == nil {
log.Fatal("expected NO reply for \"ignore\"")
}
fmt.Printf("no reply for %q within %s — query timed out (expected)\n", "ignore", demoTimeout)
}
func sendQuery(ctx context.Context, snd *amqp.Sender, replyNode, body, corr string) {
req := amqp.NewMessage([]byte(body))
req.Properties = &amqp.MessageProperties{
ReplyTo: &replyNode, // MUST name a node this connection owns (snooping guard)
CorrelationID: corr,
}
if err := snd.Send(ctx, req, nil); err != nil {
log.Fatalf("send query: %v", err)
}
}
```
```python
import os
import threading
from proton import Message
from proton.utils import BlockingConnection
CHANNEL = "amqp10.examples.queries"
# A short per-request deadline so the "no reply" leg surfaces a timeout quickly.
# The connector's own default RPC timeout is ~30s.
DEMO_TIMEOUT = 5.0
def amqp_url() -> str:
return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")
def run_responder(addr: str, ready: threading.Event, stop: threading.Event) -> None:
# A query whose body is "ignore" gets NO reply (the requester times out).
conn = BlockingConnection(amqp_url())
try:
rcv = conn.create_receiver(addr, credit=10)
snd = conn.create_sender(None) # anonymous reply sender (null target)
ready.set()
while not stop.is_set():
try:
req = rcv.receive(timeout=1.0)
except Exception:
continue
rcv.accept()
if not req.reply_to:
continue
body = str(req.body)
if body == "ignore":
continue # send NOTHING — the requester times out
# A QUERY reply carries ONLY the body + metadata — no executed/error props.
reply = Message(body="result:" + body)
reply.address = req.reply_to
reply.correlation_id = req.correlation_id if req.correlation_id else req.id
snd.send(reply)
finally:
conn.close()
def run_requester(addr: str) -> None:
conn = BlockingConnection(amqp_url())
try:
# DYNAMIC reply node (see the Commands page for the shared mechanics).
reply_rcv = conn.create_receiver(None, dynamic=True, credit=5)
reply_node = reply_rcv.link.remote_source.address
snd = conn.create_sender(addr)
# 1. A SUCCESSFUL query: round-trips, body intact.
send_query(snd, reply_node, "get-temp-sensor-3", "corr-qry-1")
reply = reply_rcv.receive(timeout=DEMO_TIMEOUT)
reply_rcv.accept()
print(f"reply for 'get-temp-sensor-3': body={str(reply.body)!r}")
# 2. A query the responder ignores: NOTHING is delivered → the requester
# TIMES OUT. The absence of a reply IS the failure signal for queries.
send_query(snd, reply_node, "ignore", "corr-qry-2")
try:
reply_rcv.receive(timeout=DEMO_TIMEOUT)
except Exception:
print(f"no reply for 'ignore' within {DEMO_TIMEOUT}s — query timed out (expected)")
else:
raise SystemExit("expected NO reply for 'ignore'")
finally:
conn.close()
def send_query(snd, reply_node: str, body: str, corr: str) -> None:
req = Message(body=body)
req.reply_to = reply_node # MUST name a node this connection owns (snooping guard)
req.correlation_id = corr
snd.send(req)
def main() -> None:
addr = "queries/" + CHANNEL # queries/ prefix → KubeMQ Queries pattern
ready, stop = threading.Event(), threading.Event()
responder = threading.Thread(target=run_responder, args=(addr, ready, stop), daemon=True)
responder.start()
ready.wait(timeout=30.0)
try:
run_requester(addr)
finally:
stop.set()
responder.join(timeout=10.0)
if __name__ == "__main__":
main()
```
The other languages (Java, C#, JavaScript, Rust) drive queries with the **same** code shape as [Commands](/connectors/amqp/concepts/commands) — the only changes are the `queries/` address prefix, dropping the `x-opt-kubemq-executed` / `x-opt-kubemq-error` application-properties on the reply, and treating a missing reply (timeout) as the failure signal. Adapt the Commands example for those languages.
## Related [#related]
# Queues (/connectors/amqp/concepts/queues)
Queues are durable, **competing-consumer** work queues over the AMQP 1.0 connector. Attach a link to a node whose address begins with `queues/` and the connector binds it to the KubeMQ **Queues** pattern. A message goes to **exactly one** consumer (it is *moved*, not copied), survives until it is settled, and is delivered **at-least-once** by default.
## Overview [#overview]
The `queues/` prefix selects the Queues pattern. A producer attaches a **sender** to `queues/`; each `TRANSFER` becomes a KubeMQ `SendQueueMessage`. A consumer attaches a **receiver** and grants credit; the server runs a credit-driven `Get` long-poll and frames each returned message as a `TRANSFER`. Do the work, then `accept` — the connector emits an `AckRange` and removes the message from the queue.
Many consumers can attach to the same `queues/`: the broker hands each message to one of them (competing-consumer move semantics — not fan-out). Add consumers to scale throughput; each message is still processed once.
| Operation | AMQP action | KubeMQ mapping |
| --------- | ------------------------------------------------------------------------------ | ------------------------------------- |
| Produce | Attach a **sender** to `queues/`, `TRANSFER` (unsettled for at-least-once) | `SendQueueMessage` |
| Consume | Attach a **receiver**, grant credit, `Receive` | Credit-driven `Get` long-poll |
| Accept | `accepted` / `rejected` DISPOSITION | `AckRange` — message removed |
| Requeue | `released` / `modified` DISPOSITION | `NAckRange` — redelivered to the tail |
## How it works [#how-it-works]
A producer enqueues unsettled messages (each blocks for the server's `accepted` disposition); competing consumers grant credit, receive, do the work, and accept — the message is removed from the queue.
*Each queued message is moved to exactly one competing consumer; an `accepted` disposition acks the message and removes it from the queue.*
## Send and receive [#send-and-receive]
Each example produces 10 unsettled messages (every send blocks until the server returns an `accepted` disposition, confirming the broker stored it), then consumes and accepts each one, and finally confirms the queue is empty. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`).
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
amqp "github.com/Azure/go-amqp"
)
const channel = "amqp10.examples.basic"
const total = 10
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://localhost:5672"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
addr := "queues/" + channel // queues/ prefix → KubeMQ Queues pattern
conn, err := amqp.Dial(ctx, amqpURL(), nil)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
session, _ := conn.NewSession(ctx, nil)
// 1. Produce — each Send is unsettled and blocks for the accepted DISPOSITION.
sender, _ := session.NewSender(ctx, addr, nil)
for i := 0; i < total; i++ {
if err := sender.Send(ctx, amqp.NewMessage([]byte(fmt.Sprintf("msg-%03d", i))), nil); err != nil {
log.Fatalf("send: %v", err)
}
}
_ = sender.Close(ctx)
// 2. Consume — grant credit, Receive, AcceptMessage (⇒ AckRange, removed).
receiver, _ := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10})
seen := make(map[string]struct{}, total)
for len(seen) < total {
msg, err := receiver.Receive(ctx, nil)
if err != nil {
log.Fatalf("receive: %v", err)
}
if err := receiver.AcceptMessage(ctx, msg); err != nil {
log.Fatalf("accept: %v", err)
}
seen[string(msg.GetData())] = struct{}{}
}
fmt.Printf("consumed and accepted %d messages\n", len(seen))
// 3. Assert the queue is empty — a further Receive must time out.
emptyCtx, emptyCancel := context.WithTimeout(ctx, 2*time.Second)
if _, err := receiver.Receive(emptyCtx, nil); err == nil {
log.Fatal("expected an empty queue")
}
emptyCancel()
_ = receiver.Close(ctx)
}
```
```python
import os
from proton import Message
from proton.utils import BlockingConnection
CHANNEL = "amqp10.examples.basic"
TOTAL = 10
def amqp_url() -> str:
return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")
def main() -> None:
addr = "queues/" + CHANNEL # queues/ prefix → KubeMQ Queues pattern
conn = BlockingConnection(amqp_url())
try:
# 1. Produce — each send is unsettled and blocks for the accepted DISPOSITION.
sender = conn.create_sender(addr)
for i in range(TOTAL):
sender.send(Message(body=f"msg-{i:03d}"))
sender.close()
# 2. Consume — grant credit, receive, accept (⇒ AckRange, removed).
receiver = conn.create_receiver(addr, credit=10)
seen: set[str] = set()
while len(seen) < TOTAL:
msg = receiver.receive(timeout=30.0)
receiver.accept()
seen.add(str(msg.body))
print(f"consumed and accepted {len(seen)} messages")
# 3. Assert the queue is empty — a further receive must time out.
try:
receiver.receive(timeout=2.0)
except Exception:
pass # the EXPECTED idle timeout on an empty queue
else:
raise SystemExit("expected an empty queue")
receiver.close()
finally:
conn.close()
if __name__ == "__main__":
main()
```
```java
import java.util.HashSet;
import java.util.Set;
import javax.jms.Connection;
import javax.jms.Message;
import javax.jms.MessageConsumer;
import javax.jms.MessageProducer;
import javax.jms.Queue;
import javax.jms.Session;
import javax.jms.TextMessage;
import org.apache.qpid.jms.JmsConnectionFactory;
public final class Main {
private static final String CHANNEL = "amqp10.examples.basic";
private static final int TOTAL = 10;
public static void main(String[] args) throws Exception {
String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672");
String address = "queues/" + CHANNEL; // queues/ prefix → KubeMQ Queues pattern
JmsConnectionFactory factory = new JmsConnectionFactory(url);
try (Connection connection = factory.createConnection()) {
connection.start();
try (Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE)) {
Queue queue = session.createQueue(address);
// 1. Produce — a MessageProducer is a server-receiver link; each send
// blocks for the accepted DISPOSITION (unsettled, at-least-once).
try (MessageProducer producer = session.createProducer(queue)) {
for (int i = 0; i < TOTAL; i++) {
producer.send(session.createTextMessage(String.format("msg-%03d", i)));
}
}
// 2. Consume — a CLIENT_ACKNOWLEDGE consumer; acknowledge() settles
// `accepted` ⇒ AckRange (removed from the queue).
try (MessageConsumer consumer = session.createConsumer(queue)) {
Set seen = new HashSet<>();
while (seen.size() < TOTAL) {
Message msg = consumer.receive(30_000);
if (msg == null) throw new IllegalStateException("timed out");
String body = msg.getBody(String.class);
msg.acknowledge();
seen.add(body);
}
System.out.printf("consumed and accepted %d messages%n", seen.size());
// 3. Assert the queue is empty — a further receive times out.
if (consumer.receive(2_000) != null) {
throw new IllegalStateException("expected an empty queue");
}
}
}
}
}
}
```
```csharp
using System.Text;
using Amqp;
using Amqp.Framing;
const string channel = "amqp10.examples.basic";
const int total = 10;
static string AmqpUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
? v
: "amqp://localhost:5672";
var addr = "queues/" + channel; // queues/ prefix → KubeMQ Queues pattern
var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl()));
try
{
var session = new Session(connection);
// 1. Produce — each Send is unsettled and blocks for the accepted DISPOSITION.
var sender = new SenderLink(session, "basic-sender", addr);
for (var i = 0; i < total; i++)
{
var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"msg-{i:D3}") } };
sender.Send(message, TimeSpan.FromSeconds(15));
}
// 2. Consume — grant credit, Receive, Accept (⇒ AckRange, removed).
// Keep the sender open through the consume phase (see the README gotcha).
var receiver = new ReceiverLink(session, "basic-receiver", addr);
receiver.SetCredit(10, autoRestore: true);
var seen = new HashSet();
while (seen.Count < total)
{
var message = receiver.Receive(TimeSpan.FromSeconds(30))
?? throw new InvalidOperationException("receive timed out");
receiver.Accept(message);
seen.Add(BodyString(message));
}
Console.WriteLine($"consumed and accepted {seen.Count} messages");
// 3. Assert the queue is empty — a further Receive must time out (null).
if (receiver.Receive(TimeSpan.FromSeconds(2)) is not null)
throw new InvalidOperationException("expected an empty queue");
await receiver.CloseAsync();
await sender.CloseAsync();
await session.CloseAsync();
}
finally
{
await connection.CloseAsync();
}
static string BodyString(Message message) => message.BodySection switch
{
Data d => Encoding.UTF8.GetString(d.Binary),
AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes),
AmqpValue { Value: string str } => str,
AmqpValue v => v.Value?.ToString() ?? string.Empty,
_ => string.Empty,
};
```
```typescript
import {
Connection,
ReceiverEvents,
type EventContext,
type Receiver,
} from "rhea-promise";
const channel = "amqp10.examples.basic";
const total = 10;
function bodyToString(body: unknown): string {
return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
}
async function main(): Promise {
const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672");
const address = `queues/${channel}`; // queues/ prefix → KubeMQ Queues pattern
const connection = new Connection({
host: url.hostname,
port: url.port ? Number(url.port) : 5672,
container_id: `kubemq-amqp10-js-basic-${process.pid}`,
reconnect: false,
});
await connection.open();
try {
// 1. Produce — an AwaitableSender; each send() resolves on the accepted DISPOSITION.
const sender = await connection.createAwaitableSender({ target: { address } });
for (let i = 0; i < total; i++) {
await sender.send({ body: `msg-${String(i).padStart(3, "0")}` }, { timeoutInSeconds: 15 });
}
await sender.close();
// 2. Consume — grant credit manually, settle manually. Register the handler
// BEFORE addCredit so early deliveries are not missed.
const receiver = await connection.createReceiver({
source: { address },
credit_window: 0,
autoaccept: false,
autosettle: false,
});
const seen = new Set();
await receiveUntil(receiver, (ctx) => {
ctx.delivery?.accept(); // accept ⇒ AckRange ⇒ removed from the queue
seen.add(bodyToString(ctx.message?.body));
return seen.size >= total;
}, total + 1, 30_000);
console.log(`consumed and accepted ${seen.size} messages`);
await receiver.close();
} finally {
await connection.close();
}
}
function receiveUntil(
receiver: Receiver,
onMessage: (ctx: EventContext) => boolean,
credit: number,
timeoutMs: number,
): Promise {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
receiver.removeListener(ReceiverEvents.message, handler);
reject(new Error("timed out waiting for messages"));
}, timeoutMs);
const handler = (ctx: EventContext): void => {
if (onMessage(ctx)) {
clearTimeout(timer);
receiver.removeListener(ReceiverEvents.message, handler);
resolve();
}
};
receiver.on(ReceiverEvents.message, handler);
receiver.addCredit(credit);
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```rust
use std::collections::HashSet;
use std::time::Duration;
use fe2o3_amqp::link::delivery::Delivery;
use fe2o3_amqp::link::receiver::CreditMode;
use fe2o3_amqp::{Connection, Receiver, Sender, Session};
use fe2o3_amqp_types::definitions::SenderSettleMode;
use fe2o3_amqp_types::messaging::{Body, Message};
use fe2o3_amqp_types::primitives::Value;
const CHANNEL: &str = "amqp10.examples.basic";
const TOTAL: usize = 10;
fn amqp_url() -> String {
std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string())
}
fn body_bytes(msg: &Message>) -> Vec {
match &msg.body {
Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(),
Body::Value(v) => match &v.0 {
Value::Binary(b) => b.to_vec(),
Value::String(s) => s.clone().into_bytes(),
other => format!("{other:?}").into_bytes(),
},
_ => Vec::new(),
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let addr = format!("queues/{CHANNEL}"); // queues/ prefix → KubeMQ Queues pattern
let mut connection = Connection::open("amqp10-examples-basic", amqp_url().as_str()).await?;
let mut session = Session::begin(&mut connection).await?;
// 1. Produce — pin SenderSettleMode::Unsettled (the connector rejects the AMQP
// default `mixed`); each send is at-least-once and blocks for the Accepted outcome.
let mut sender = Sender::builder()
.name("basic-send-receive-sender")
.target(addr.as_str())
.sender_settle_mode(SenderSettleMode::Unsettled)
.attach(&mut session)
.await?;
for i in 0..TOTAL {
let outcome = sender.send(format!("msg-{i:03}")).await?;
if !outcome.is_accepted() {
return Err(format!("send msg-{i:03}: unexpected outcome {outcome:?}").into());
}
}
sender.close().await?;
// 2. Consume — CreditMode::Auto(10) issues 10 credits and replenishes on settle;
// accept ⇒ AckRange ⇒ removed from the queue.
let mut receiver = Receiver::builder()
.name("basic-send-receive-receiver")
.source(addr.as_str())
.credit_mode(CreditMode::Auto(10))
.attach(&mut session)
.await?;
let mut seen: HashSet = HashSet::with_capacity(TOTAL);
while seen.len() < TOTAL {
let delivery: Delivery> = receiver.recv().await?;
let body = String::from_utf8_lossy(&body_bytes(delivery.message())).into_owned();
receiver.accept(&delivery).await?;
seen.insert(body);
}
println!("consumed and accepted {} messages", seen.len());
// 3. Assert the queue is empty — a further recv must time out.
if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), receiver.recv::>()).await {
return Err("expected an empty queue".into());
}
receiver.close().await?;
session.end().await?;
connection.close().await?;
Ok(())
}
```
## Delivery guarantees [#delivery-guarantees]
Settlement mode is negotiated at `ATTACH` and decides the produce/consume guarantee:
| Mode | How | Guarantee | Use when |
| ------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------- |
| **At-least-once (default)** | unsettled deliveries; you `accept` after the work succeeds | survives a crash; may be **redelivered** | the work must not be lost |
| **Pre-settled (at-most-once)** | `snd-settle-mode=settled` on the sender / consume | fast, no DISPOSITION round-trip; a failed publish/consume is dropped | loss is acceptable for speed |
At-least-once means a consumer crash before `accept` requeues the message, so workers **must be idempotent** — a message can arrive more than once. The receiver settle mode is always replied as `first`; requesting `second` returns `DETACH(amqp:not-implemented)`.
### Delivery-state outcome mapping [#delivery-state-outcome-mapping]
When you consume, you settle each delivery by sending a `DISPOSITION` with a delivery state. The connector maps that state onto the KubeMQ queue Ack/NAck machinery:
| Your DISPOSITION | Client call (typical) | KubeMQ action | Effect |
| ------------------------------- | ---------------------- | ------------- | ----------------------------------------------------------------------------- |
| `accepted` | `AcceptMessage` | **AckRange** | message removed |
| `rejected` | `RejectMessage` | **AckRange** | discarded; poison handled by the broker's `MaxReceiveQueue` policy |
| `released` | `ReleaseMessage` | **NAckRange** | redelivered to the tail; `delivery-count` grows; **increments receive-count** |
| `modified{...}` | `ModifyMessage` | **NAckRange** | requeued to the tail |
| nil state (settled, no outcome) | settle without a state | **AckRange** | treated as success |
| unknown terminal state | — | **NAckRange** | conservatively requeued — never silently dropped |
A redelivered message carries `header.delivery-count = ReceiveCount − 1` and `first-acquirer = (ReceiveCount == 1)` — use these to detect and de-duplicate redeliveries. On detach, connection close, or shutdown, **every unsettled delivery is NAcked exactly once** (returned to the queue tail), so a disconnecting worker loses nothing — a fresh consumer recovers the in-flight work.
**`released` / `modified` increment the receive-count.** Every release/modify for redelivery bumps `ReceiveCount` toward the broker's `MaxReceiveQueue` cap. A message you keep NAcking eventually hits that cap and is removed **even though you never `rejected` it** — there is no requeue-without-increment. To genuinely discard, `reject`; to retry, understand the count climbs.
### Body sections [#body-sections]
A message body must be `Data` (binary — the default; multiple `Data` sections concatenate) or `AmqpValue` (a typed value). An empty body is valid. An `AmqpSequence` body is **rejected** (`rejected` DISPOSITION then `DETACH(amqp:not-implemented)`).
## What queues do not have [#what-queues-do-not-have]
The Queues pattern is deliberately minimal — none of these exist:
* **No peek / browse / visibility-timeout.** Receive is destructive credit-based consume only; there is no "look without taking".
* **No connector dead-letter exchange.** A `rejected` message is discarded; poison handling is the broker-side `MaxReceiveQueue` policy, not a per-link DLX.
* **No `copy` distribution-mode.** Requesting `copy` on a `queues/` link returns `DETACH(amqp:invalid-field)` — queues are move-only.
* **No selectors.** A selector filter on a `queues/` link returns `amqp:not-implemented` (selectors are pub/sub only).
* **No transactions.** Reliability comes from settlement, not `SESSION_TRANSACTED`.
## Related [#related]
# Address Mapping (/connectors/amqp/reference/address-mapping)
This is the master reference for how the embedded KubeMQ AMQP 1.0 connector maps an
AMQP 1.0 terminus **address** to a KubeMQ **(pattern, channel)** pair — which server
link role results from a peer's source/target choice, and the channel-naming rules,
anonymous-routing behavior, and RPC reply token that each pattern carries.
**One rule above all — use the explicit prefix.** Always address a node with its full
`/` form (`queues/orders`, `events/telemetry`,
`events-store/audit`). The bare-address / `DefaultPattern` fallback exists, but explicit
prefixes are unambiguous and portable. See
[Addressing](/connectors/amqp/concepts/addressing).
## Address grammar [#address-grammar]
```text
address = [ "/" ] ( prefixed-node | bare-node )
prefixed-node = pattern-prefix channel
pattern-prefix = "queues/" | "events/" | "events-store/"
| "commands/" | "queries/" | "responses/"
bare-node = channel ; no "/" — resolved via node-cap hint or DefaultPattern
channel = 1*255 VCHAR ; connector charset rules (see Channel validation)
```
* The **leading slash is optional**. The resolver strips at most one leading `/` before
matching, so `queues/orders` and `/queues/orders` resolve identically.
* Each pattern prefix **includes its trailing slash** (`"events/"`, `"events-store/"`),
so the prefix match is unambiguous.
* A null/empty address is **not** an error in itself — on a server-receiver link it
selects the **anonymous terminus** (see below); on a dynamic terminus it asks the
server to **mint a node**.
## The master mapping table [#the-master-mapping-table]
The pattern is taken from the address prefix. The **server link role** is the inverse of
the peer's role: a peer **receiver** attaches against a **source** address (the server
*sends*), and a peer **sender** attaches against a **target** address (the server
*receives*).
| Pattern | Send to (peer → server, **target**) | Receive from (server → peer, **source**) | Server link role | Settlement & credit | Filters / link-props |
| ------------------------------- | -------------------------------------------------------- | ---------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Queues** | `queues/` | `queues/` | produce = receiver; consume = sender | at-least-once (unsettled) **or** at-most-once (pre-settled `snd-settle-mode=settled` + `rcv-settle-mode=first`); consumer grants link credit; `accept`/`release`/`modify`/`reject` dispositions; **`copy` distribution-mode rejected → `amqp:invalid-field`** (queues are move-only) | **no** selector (selector on a queue → `amqp:not-implemented`); no link-props |
| **Events** | `events/` | `events/` | produce = receiver; consume = sender | at-most-once fan-out; **continuous credit required** — a transfer with 0 link credit is **silently dropped** | consume link may carry a **selector** (`apache.org:selector-filter:string`) and/or `x-opt-kubemq-group` (consumer group) |
| **Events Store** | `events-store/` | `events-store/` | produce = receiver; consume = sender | durable replay; durable identity from a **stable container-id + link name**; a second live attach of the same identity → `amqp:not-allowed` | consume link may carry a **selector**, **`x-opt-kubemq-start`** (start position), and `x-opt-kubemq-group` |
| **Commands** (RPC) | `commands/` | — (requester reads its **dynamic reply node**) | request = receiver | native RPC; reply carries body + `x-opt-kubemq-executed` / `x-opt-kubemq-error`; failure → `executed=false` reply | `reply-to` + `correlation-id` on the request message |
| **Queries** (RPC) | `queries/` | — (requester reads its **dynamic reply node**) | request = receiver | native RPC; reply carries **body + metadata only** (no executed/error); failure → no reply (requester times out) | `reply-to` + `correlation-id` on the request message |
| **Responses** (RPC reply token) | `responses/` (peer **sender** writes a reply) | — | receiver only | connection-scoped reply token; **write-only** | none |
Notes carried by the table:
* The same `/` resolves to the **same KubeMQ channel** regardless of
link direction — only the server role (and therefore the authorization check) differs.
* `commands/`, `queries/`, and `responses/` are RPC machinery. A requester's *reply* link
is a **dynamic node**, not a `/` address (see below).
* `responses/` is valid **only** as a server-receiver link (the peer is a
sender writing the reply). A **receiver** attach against `responses/` is rejected with
`amqp:not-allowed`.
## Longest-prefix discipline [#longest-prefix-discipline]
`events-store/` and `events/` share a common stem. The resolver matches `events-store/`
**before** `events/`, so `events-store/audit` is never mis-classified as the `events`
pattern with channel `store/audit`. The prefix checks run in this fixed order:
```text
events-store/ — checked FIRST
queues/
events/
commands/
queries/
responses/
```
A bare address that still contains a `/` but matches **no** recognized prefix is an
**unknown prefix → `amqp:not-found`** — a bare KubeMQ channel may use `.` segments but
never the reserved `/` separator.
## Special rows — bare, dynamic, anonymous, responses [#special-rows--bare-dynamic-anonymous-responses]
| Row | What the client does | How the connector resolves it |
| ---------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Bare address** | attaches to a channel with no `pattern/` prefix (e.g. `orders`) | a JMS **node-capability** hint on the terminus selects the pattern: `queue` → `queues`, `topic` → `events`; otherwise the configured `DefaultPattern` applies (degrading to `queues` if misconfigured) |
| **Dynamic terminus** | attaches with `source.dynamic=true` (receiver) or `target.dynamic=true` (sender) and an **empty address** | the server mints a transient node `_amqp10.tmp..`, echoes it in the reply ATTACH, and backs it with an in-memory node-local mailbox; **no broker channel, no §2 resolution, no attach-time authz** (the node is connection-private by its unguessable address) |
| **Anonymous terminus** | a **server-receiver** link with a **null target address**; routes per-message by `properties.to` | the link binds to no fixed channel; each transfer's `to` is resolved with the **full mapping table** (including `responses/` tokens and dynamic nodes) and authorized **per message** for Write. A missing/invalid `to` or an unreachable node → `amqp:precondition-failed`; an authz denial → `amqp:unauthorized-access` |
| **Responses token** | a peer **sender** writes a reply to `responses/` | resolves to `(responses, RequestID)`; the RequestID is opaque (validated by the RPC layer against the pending-reply map, not as a broker channel — only emptiness is rejected). A **receiver** attach → `amqp:not-allowed` |
**Anonymous terminus is null-target-driven, not capability-driven.** The connector
advertises **no `ANONYMOUS-RELAY` capability** (see [Capabilities](/connectors/amqp/reference/capabilities)).
A client gets anonymous routing by attaching a sender link with a **null target**, never
by negotiating a capability. **Qpid JMS cannot drive the anonymous terminus** — it gates
the single anonymous producer link on the peer's `ANONYMOUS-RELAY` capability, finds none,
and falls back to per-destination sender links. Native clients
(`Azure/go-amqp`, AMQPNetLite, qpid-proton, fe2o3-amqp, rhea) can open a null-target sender
directly.
## Server role and authorization [#server-role-and-authorization]
The peer's link role and the chosen terminus side together fix the server role and the
permission required at attach:
| Peer attaches as | Terminus side read | Server role | Permission enforced at attach |
| -------------------------------------- | ------------------ | --------------- | ----------------------------------------------------- |
| **receiver** (peer reads) | `source` | server-sender | **Read** on `(pattern, channel)` |
| **sender** (peer writes), fixed target | `target` | server-receiver | **Write** on `(pattern, channel)` |
| **sender**, null target (anonymous) | — | server-receiver | deferred — **per-message Write** on the resolved `to` |
| **sender** to `responses/` | `target` | server-receiver | **none** (connection-scoped reply token) |
The pattern → authorization resource map treats `events-store` as the resource
`events_store`, and `commands` / `queries` as `commands` / `queries`. See
[Authentication](/connectors/amqp/how-to/authentication).
## Channel validation (connector charset) [#channel-validation-connector-charset]
The channel component (everything after the pattern prefix) is validated by a connector
charset rule that is **stricter** than the array-layer validation, so a channel that
passes here always passes downstream. Any violation maps to **`amqp:not-found`** (a bad
address is "not found"):
| Rule | Rejected example | Reason |
| ---------------------- | ------------------ | --------------------------------------------- |
| not empty | `queues/` | empty channel |
| ≤ 255 bytes | 300-char channel | channel exceeds 255 chars |
| no trailing `.` | `queues/orders.` | channel has trailing `.` |
| no whitespace | `queues/my orders` | channel contains whitespace (space/tab/CR/LF) |
| no `*` or `>` wildcard | `queues/orders.*` | channel contains wildcard |
| no `;` or `:` | `queues/a:b` | channel contains `;` or `:` |
**The channel charset is stricter than you may expect.** `*`, `>`, `;`, `:`, whitespace,
and a trailing `.` are all rejected at attach with `amqp:not-found`. Use `.` only as a
path separator inside the channel (e.g. `queues/region.eu.orders`).
There is **no vhost / virtual host**. The OPEN `hostname` field is accepted but ignored —
the address space is flat and global. For AMQP 0-9-1 interop the channel convention is
`queues/amqp..` (a naming convention, not a real vhost).
## Quick examples [#quick-examples]
| AMQP address, attached as | Peer role | Resolves to | Server role |
| --------------------------------- | --------- | -------------------------------------------- | ------------------------ |
| `queues/orders` | sender | `(queues, orders)` | receiver (Write) |
| `queues/orders` | receiver | `(queues, orders)` | sender (Read) |
| `/events/telemetry` | receiver | `(events, telemetry)` | sender (Read) |
| `events-store/audit` | receiver | `(events-store, audit)` | sender (Read) |
| `commands/dispatch` | sender | `(commands, dispatch)` | receiver (Write) |
| `responses/abc123` | sender | `(responses, abc123)` | receiver (no authz) |
| `responses/abc123` | receiver | **DETACH** `amqp:not-allowed` | — |
| `orders` (bare, node-cap `queue`) | sender | `(queues, orders)` | receiver (Write) |
| null target | sender | anonymous terminus, routes by `to` | receiver (per-msg Write) |
| `source.dynamic=true`, empty addr | receiver | minted `_amqp10.tmp..` | sender (no authz) |
| `events/x;y` | receiver | **DETACH** `amqp:not-found` (`;` in channel) | — |
## Related [#related]
# Capabilities (/connectors/amqp/reference/capabilities)
This reference defines exactly what the embedded KubeMQ AMQP 1.0 connector **supports**,
what it **rejects**, and — critically — what it **does not advertise**. Use it to decide
which client features are safe to rely on and which ones will be refused at attach,
transfer, or connection time.
## The connector advertises no capabilities [#the-connector-advertises-no-capabilities]
**The connector advertises no offered or desired connection capabilities and no offered or
desired link/terminus capabilities.** There is **no `ANONYMOUS-RELAY`**, no `queue`/`topic`
node capability, no `DELAYED_DELIVERY`, no `SHARED-SUBS`, no `sole-connection-for-container`
— nothing. Clients **must not** depend on AMQP capability negotiation with this connector.
The connector's reply OPEN performative sets **only four fields** — `ContainerID`,
`MaxFrameSize`, `ChannelMax`, and `IdleTimeout`:
```go
serverOpen := &frames.PerformOpen{
ContainerID: "KubeMQ",
MaxFrameSize: c.maxFrameSize,
ChannelMax: c.channelMax,
IdleTimeout: advertisedIdle,
}
```
`OfferedCapabilities` and `DesiredCapabilities` are **never set** on the OPEN reply, and
the attach-reply path likewise sets no terminus capabilities. The connector *reads* a
peer's terminus capabilities only to honor a bare-address `queue`/`topic` hint — it never
echoes or offers capabilities back.
### What this means in practice [#what-this-means-in-practice]
* **Anonymous senders work, but not via `ANONYMOUS-RELAY`.** A client gets anonymous
routing by attaching a sender link with a **null target address**; the connector then
routes each transfer by its `properties.to`. It is **null-target-driven**, not
capability-driven.
* **This is the root cause of the Java / Qpid JMS anonymous-terminus limitation.** Qpid
JMS decides whether to use a single anonymous producer link by checking the peer's
offered `ANONYMOUS-RELAY` capability. Because the connector offers none, Qpid JMS falls
back to **per-destination sender links** and exposes no API to force the raw null-target
ATTACH the connector routes on. Native clients (`Azure/go-amqp`, AMQPNetLite,
qpid-proton, fe2o3-amqp, rhea) can open a null-target sender directly and use anonymous
routing. See [Address Mapping](/connectors/amqp/reference/address-mapping).
* Do **not** branch your client logic on a negotiated capability — there will be none.
Drive behavior from the address (`/`) and link-properties instead.
## Supported features [#supported-features]
| Feature | Support | Notes |
| ---------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------- |
| Message body `Data` (binary) | Yes | the primary body section |
| Message body `AmqpValue` | Yes | string / map / list / scalar values |
| Empty body | Yes | accepted |
| Settlement `accepted` / `released` / `modified` / `rejected` | Yes | mapped to KubeMQ Ack/NAck; `released`/`modified` requeue and **increment receive-count** |
| Sender settle-mode `unsettled` (at-least-once) | Yes | the default; the queues durability path |
| Sender settle-mode `settled` (pre-settled, at-most-once) | Yes | paired with `rcv-settle-mode=first` |
| Receiver settle-mode `first` | Yes | the **only** supported receiver settle mode |
| Link credit + drain (FLOW) | Yes | manual or windowed credit; drain to exhaust-then-stop |
| Multi-frame transfers (fragmentation/reassembly) | Yes | `More:true…More:false`; bit-exact reassembly up to the 100 MiB cap |
| Dynamic nodes (`dynamic=true`, null address) | Yes | minted `_amqp10.tmp..`; node-local, no TTL/persistence |
| Anonymous terminus (null target, route by `to`) | Yes | per-message Write authz; **not** `ANONYMOUS-RELAY` |
| JMS/SQL-92 selector on `events/` & `events-store/` consume links | Yes | `apache.org:selector-filter:string` (3-valued logic) |
| Consumer groups (`x-opt-kubemq-group`) | Yes | link property on the consume link |
| Durable subscriptions (events-store, expiry `never`) | Yes | durable identity = stable container-id + link name; **node-local** |
| Start positions (`x-opt-kubemq-start`) | Yes | `first` / `new-only` / `last` / `sequence:` / `time:` / `time-delta:` |
| Native RPC (commands / queries) | Yes | dynamic reply node + `reply-to` + `correlation-id`; **no gRPC, no KubeMQ SDK** |
| SASL `ANONYMOUS` / `PLAIN` (JWT) / `EXTERNAL` (mTLS) | Yes | see [Authentication](/connectors/amqp/how-to/authentication) |
| TLS / mTLS on `amqps://:5671` | Yes | see [TLS & mTLS](/connectors/amqp/how-to/tls-and-mtls) |
## Rejected / unsupported features [#rejected--unsupported-features]
These are **non-goals** — they are refused deterministically, not silently ignored. Each
maps to a wire condition from the [error conditions](/connectors/amqp/reference/error-conditions).
| Feature | Behavior | Condition / mechanism |
| ---------------------------------------------------------- | ---------------- | -------------------------------------------------------- |
| `rcv-settle-mode=second` | DETACH at attach | `amqp:not-implemented` |
| Selector on a `queues/` link | DETACH at attach | `amqp:not-implemented` (queues are move-only) |
| `copy` distribution-mode on `queues/` | DETACH at attach | `amqp:invalid-field` |
| `AmqpSequence` body section | rejected | `errAMQPSequenceUnsupported` |
| AMQP **transactions** (txn coordinator) | not implemented | no coordinator node in the connector |
| **Exactly-once** delivery | not provided | at-least-once (queues) or at-most-once (events) only |
| **Link resumption** / delivery-state recovery on re-attach | not supported | re-attach is a fresh link |
| Message **peek / browse / FIFO ordering guarantee** | not provided | competing-consumer move semantics only |
| **Dead-letter exchange (DLX)** | none | the broker's `MaxReceiveQueue` caps backlog; no DLX |
| Second live attach of the same durable identity | DETACH | `amqp:not-allowed` (durable subscription in use) |
| Receiver attach on `responses/` | DETACH | `amqp:not-allowed` |
| AMQP-over-**WebSocket** | not supported | raw TCP / TLS only |
| **vhost** / virtual host | none | OPEN `hostname` accepted but ignored; flat address space |
| Config **hot-reload** | not supported | connector config is read at start |
| Capability negotiation (offered/desired) | none advertised | drive behavior from address + link-props |
The **`AmqpSequence` body section is rejected.** Send `Data` (binary / `BytesMessage`) or
`AmqpValue` (text / object) instead — an `AmqpSequence` body fails translation. This is one
of two Qpid-JMS-relevant limitations, alongside the anonymous-terminus restriction in
[Address Mapping](/connectors/amqp/reference/address-mapping).
## Inert / accepted-but-ignored [#inert--accepted-but-ignored]
These are accepted on the wire without error but carry **no connector semantics**
(documented so you do not expect behavior that is not there):
* **Message priority** — accepted, not honored for ordering.
* **`group-id` / `group-sequence`** — accepted, not used for grouping/ordering.
* **`footer` section** — accepted, passed through where applicable, not interpreted.
* **OPEN `hostname`** — accepted, ignored (no vhost).
* **Idle-timeout from the client** — honored by the connector emitting empty frames at
half the client's advertised interval, but it is not a feature you negotiate via
capabilities.
## Limits & forced caps [#limits--forced-caps]
| Limit | Value | Enforcement / condition |
| -------------------------------- | -------------------------- | -------------------------------------------- |
| Max channel length | 255 bytes | charset validation → `amqp:not-found` |
| Max multi-frame message size | 100 MiB | oversize → `amqp:link:message-size-exceeded` |
| Receiver settle modes | `first` only | `second` → `amqp:not-implemented` |
| Connection / session / link caps | from config | over-cap → `amqp:resource-limit-exceeded` |
| Durable identity components | 40 chars each + FNV suffix | sanitized to a stable durable id |
See [Configuration](/connectors/amqp/concepts/configuration) for the `CONNECTORS_AMQP10_*`
knobs behind these caps, and
[Connections & Observability](/connectors/amqp/reference/connections-endpoint) for
the metrics that count limit breaches.
## Related [#related]
# Configuration (/connectors/amqp/reference/configuration)
Field-by-field reference for the 14 settings under `Connectors.Amqp10.*`, their
validation rules, and the equivalent TOML, environment variable, and Docker forms. For
the enable/disable flow and the `DefaultPattern` bare-address fallback, see
[Configuration concepts](../concepts/configuration).
## Configuration fields [#configuration-fields]
All fields live under `[Connectors.Amqp10]`. Defaults are taken verbatim from the server's
`Amqp10Config` struct.
## Validation rules [#validation-rules]
Validation is **skipped entirely when `Enable` is `false`** — a disabled connector is
always valid regardless of the other fields. When the connector is enabled, the server
rejects an invalid config at startup with these rules:
| Field | Rule |
| -------------------------- | -------------------------------------------------------------------------------------------------- |
| `Port` / `TlsPort` | each in `0..65535`. If **both** are `0`, validation fails — at least one listener must be enabled. |
| `MaxFrameSize` | `>= 512` |
| `MaxMessageSize` | `> 0` |
| `SessionMax` | `1..65535` |
| `MaxLinksPerSession` | `>= 1` |
| `MaxConnections` | `>= 0` (`0` = unlimited) |
| `IdleTimeoutSeconds` | `>= 0` (`0` = disabled) |
| `DefaultPattern` | exactly one of `queues`, `events`, `events-store`, `commands`, `queries` |
| `GetBatchSize` | `1..1024` |
| `MaxUnsettledPerLink` | `>= 1` |
| `DefaultRpcTimeoutSeconds` | `>= 1` |
| `RpcMaxPending` | `>= 1` |
`Port` and `TlsPort` are **shared with the RabbitMQ (AMQP 0-9-1) connector**. Setting
`Port` equal to the 0-9-1 port is intentionally accepted — the validation step does
**not** cross-check the two, and the `amqpmux` dedupes the bind so both dialects coexist
on one listener. See [Architecture](/connectors/amqp/concepts/architecture) for the dispatch
detail. The TLS listener binds only when the server-global `Security` block is configured.
## Configuring the connector [#configuring-the-connector]
The same settings can be supplied through a TOML config file, environment variables, or
`docker run` flags. Each environment variable is derived from its dotted config key by
snake-casing the field, stripping the `.` separators, and upper-casing — so
`Connectors.Amqp10.TlsPort` becomes `CONNECTORS_AMQP10_TLS_PORT`.
```toml title="config.toml"
[Connectors.Amqp10]
Enable = true
Port = 5672
TlsPort = 5671
MaxFrameSize = 131072
MaxMessageSize = 104857600
SessionMax = 256
MaxLinksPerSession = 256
MaxConnections = 1000
IdleTimeoutSeconds = 120
DefaultPattern = "queues"
GetBatchSize = 32
MaxUnsettledPerLink = 1024
DefaultRpcTimeoutSeconds = 30
RpcMaxPending = 512
```
```bash title="amqp10.env"
CONNECTORS_AMQP10_ENABLE=true
CONNECTORS_AMQP10_PORT=5672
CONNECTORS_AMQP10_TLS_PORT=5671
CONNECTORS_AMQP10_MAX_FRAME_SIZE=131072
CONNECTORS_AMQP10_MAX_MESSAGE_SIZE=104857600
CONNECTORS_AMQP10_SESSION_MAX=256
CONNECTORS_AMQP10_MAX_LINKS_PER_SESSION=256
CONNECTORS_AMQP10_MAX_CONNECTIONS=1000
CONNECTORS_AMQP10_IDLE_TIMEOUT_SECONDS=120
CONNECTORS_AMQP10_DEFAULT_PATTERN=queues
CONNECTORS_AMQP10_GET_BATCH_SIZE=32
CONNECTORS_AMQP10_MAX_UNSETTLED_PER_LINK=1024
CONNECTORS_AMQP10_DEFAULT_RPC_TIMEOUT_SECONDS=30
CONNECTORS_AMQP10_RPC_MAX_PENDING=512
```
The Docker example includes `CONNECTORS_AMQP10_ENABLE=true` — without it the connector stays
disabled and port 5672 is not bound. Set `CONNECTORS_AMQP10_ENABLE=false` only when you
want to turn the connector off.
## Related [#related]
# Connections & Observability (/connectors/amqp/reference/connections-endpoint)
This reference documents the AMQP 1.0 connector's observability surface: the **HTTP detail
endpoints**, the **11 Prometheus metric families**, the **SSE metrics group**, and the
**audit events** the connector emits.
## HTTP detail endpoints [#http-detail-endpoints]
The connector registers two read-only JSON routes on the internal API port (`8080`),
network-protected like all `/api/*` routes. They are registered up front and nil-check the
live provider, so they respond `200` with **empty lists** until the connector is wired
(and even when AMQP 1.0 is disabled), returning `503` only while the API service is not yet
ready.
| Method & path | Returns |
| ----------------------------- | --------------------------------------------------------- |
| `GET /api/amqp10/connections` | `{ "connections": [Amqp10ConnectionDTO…], "total": }` |
| `GET /api/amqp10/links` | `{ "links": [Amqp10LinkDTO…], "total": }` |
### `Amqp10ConnectionDTO` [#amqp10connectiondto]
| JSON field | Type | Meaning |
| -------------- | ------ | ------------------------------------------------- |
| `client_id` | string | auth ClientID, or the container-id when anonymous |
| `container_id` | string | OPEN container-id (sanitized) |
| `product` | string | from OPEN properties, if the client sent them |
| `version` | string | from OPEN properties, if sent |
| `sessions` | int | live sessions on the connection |
| `links` | int | live links across all sessions |
| `connected_at` | string | RFC3339 timestamp |
| `source_ip` | string | peer IP |
| `sasl` | string | `plain` / `anonymous` / `external` / `none` |
| `tls` | bool | TLS-terminated connection |
### `Amqp10LinkDTO` [#amqp10linkdto]
| JSON field | Type | Meaning |
| ----------- | ------ | ----------------------------------------------------------------- |
| `client_id` | string | owning connection's ClientID |
| `name` | string | peer-assigned link name |
| `role` | string | `sender` / `receiver` (**server** perspective) |
| `address` | string | resolved terminus address |
| `pattern` | string | `queues` / `events` / `events-store` / `commands` / `queries` / … |
| `channel` | string | resolved KubeMQ channel |
| `credit` | int64 | current link credit (server view) |
| `unsettled` | int64 | deliveries awaiting settlement |
| `durable` | bool | durable subscription link |
| `dynamic` | bool | dynamic-node terminus link |
AMQP 1.0 has no exchange/binding topology, so there is **no topology endpoint** (unlike the
AMQP 0-9-1 / RabbitMQ connector). The link list *is* the topology view.
## Prometheus metrics — 11 families [#prometheus-metrics--11-families]
The connector exposes **11** `kubemq_amqp10_*` metric families: **3 gauges + 8 counters**.
All are scraped from the standard KubeMQ metrics endpoint.
### Gauges (3) [#gauges-3]
| Metric | Labels | Meaning |
| --------------------------- | ---------------------------- | ------------------------------------- |
| `kubemq_amqp10_connections` | — | current open connector connections |
| `kubemq_amqp10_sessions` | — | current open connector sessions |
| `kubemq_amqp10_links` | `role` (`sender`/`receiver`) | current attached links by server role |
### Counters (8) [#counters-8]
| Metric | Labels | Meaning |
| -------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `kubemq_amqp10_transfers_total` | `direction` (`in`/`out`), `pattern` | transfers by direction and KubeMQ pattern |
| `kubemq_amqp10_dispositions_total` | `outcome` (`accepted`/`released`/`modified`/`rejected`) | terminal dispositions by outcome |
| `kubemq_amqp10_rpc_requests_total` | `pattern` (`commands`/`queries`) | RPC requests routed through the connector |
| `kubemq_amqp10_errors_total` | `scope` (`conn`/`session`/`link`) | connector error conditions by scope |
| `kubemq_amqp10_transfers_in_dropped_total` | — | **inbound transfers dropped: oversize / no-consumer / pre-settled failure** |
| `kubemq_amqp10_events_dropped_no_credit_total` | — | events dropped on an outbound link with **no credit** (fire-hose semantics) |
| `kubemq_amqp10_events_store_dropped_stalled_total` | — | events-store messages dropped when the **credit-0 buffer stalled** (link DETACH) |
| `kubemq_amqp10_rpc_late_responses_total` | — | RPC responses arriving **after the requester went away** |
### The two drop counters to watch [#the-two-drop-counters-to-watch]
These are the connector's **data-loss footgun signals** — on a healthy
producer/consumer they should stay at **0**:
* **`kubemq_amqp10_events_dropped_no_credit_total`** — increments every time the connector
has an `events/` message to deliver but the consumer link has **zero credit**. Events
are at-most-once fire-hose: with no credit the message is **silently dropped**. Grant
standing credit continuously.
* **`kubemq_amqp10_events_store_dropped_stalled_total`** — increments when a durable
events-store consumer stops granting credit and the credit-0 bounded buffer overflows,
forcing a **DETACH with lost messages**. Replenish credit eagerly.
**`kubemq_amqp10_transfers_in_dropped_total` covers three causes, not one.** Its help text
is *"inbound AMQP 1.0 transfers dropped (oversize, no consumer, pre-settled failure)"*. It
counts an **oversize** inbound transfer, a transfer with **no consumer**, **and** a
**pre-settled routing failure** — do not read it as pre-settled-only.
## SSE metrics group [#sse-metrics-group]
The live dashboard streams a snapshot of these metrics as an `api.Amqp10MetricsGroup` over
the metrics SSE channel. The store mirrors the same gauges and counters as the Prometheus
families above (the store clamps gauges at 0 and the Prometheus mirror follows the clamp,
so the two never diverge).
## Audit events [#audit-events]
**The AMQP 1.0 connector emits exactly two audit events: `auth.success` and
`auth.failure`.** It does **not** emit `client.connected` or `client.disconnected` — those
are **not** part of this connector's audit surface.
Both come from the SASL layer:
| Event | When | Fields |
| -------------- | --------------- | ------------------------------------------------------------------------------------------------ |
| `auth.success` | SASL outcome OK | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Metadata{mechanism}` |
| `auth.failure` | SASL rejected | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Error` (sanitized reason), `Metadata{mechanism}` |
The `mechanism` metadata is `plain` / `anonymous` / `external`, and `SourceIP` is the peer
IP. The **full failure reason** lives only in the `auth.failure` audit record — the SASL
**wire** outcome carries the code alone (the no-leak rule; see
[Error Conditions](/connectors/amqp/reference/error-conditions)).
To observe connection lifecycle (count, age, source IP, SASL mechanism), poll
`GET /api/amqp10/connections` or scrape `kubemq_amqp10_connections` — **not** the audit log.
## Related [#related]
# Error Conditions (/connectors/amqp/reference/error-conditions)
The embedded KubeMQ AMQP 1.0 connector reports every failure with an **AMQP 1.0 symbolic
error condition** carried on a `DETACH`, `END`, `CLOSE`, or rejected-disposition
performative. There are exactly **13** of them, and the connector **never emits a condition
outside this set** — it is a pinned, greppable, testable vocabulary.
**AMQP 1.0 only — no numeric reason codes.** This connector is the AMQP **1.0** dialect.
It does **not** use the numeric reason codes of the AMQP 0-9-1 / RabbitMQ connector. If you
are migrating mental models from 0-9-1, replace "reply-code 312/404/406…" with the
**`amqp:*` symbols** below. Numeric codes appear **nowhere** in this connector.
## The 13 conditions [#the-13-conditions]
| # | Symbol | Meaning | Typical trigger |
| -- | --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `amqp:internal-error` | unexpected server-side failure | a broker send error or other internal fault; the description carries the **sanitized broker message only** |
| 2 | `amqp:not-found` | unknown address / bad channel at attach | unrecognized address prefix, or a channel that violates the connector charset (empty, `>255`, trailing `.`, whitespace, `*`/`>`, `;`/`:`) — see [Address Mapping](/connectors/amqp/reference/address-mapping) |
| 3 | `amqp:unauthorized-access` | authorization denial | attach denied (Read on consume, Write on produce), or a per-message anonymous-terminus Write denial |
| 4 | `amqp:decode-error` | malformed frame / codec failure | a corrupt or invalid AMQP frame on the wire |
| 5 | `amqp:resource-limit-exceeded` | capacity breach | connection / session / link / RPC cap reached, **idle timeout**, or events-store **stalled-credit** buffer overflow |
| 6 | `amqp:not-allowed` | protocol / FSM violation | duplicate link name, receiver attach on `responses/`, **duplicate durable subscription identity**, broker-not-ready reject, link re-attach |
| 7 | `amqp:invalid-field` | invalid link property | a malformed **`x-opt-kubemq-start`** start position, an unparseable **selector**, or `copy` distribution-mode on a queue |
| 8 | `amqp:not-implemented` | well-formed but unsupported request | a **selector on a `queues/` link**, or **`rcv-settle-mode=second`** |
| 9 | `amqp:precondition-failed` | missing/invalid anonymous-terminus `to` | an anonymous sender transfer with no `to`, an unknown prefix in `to`, or a dynamic node the connection cannot reach |
| 10 | `amqp:link:message-size-exceeded` | oversize multi-frame transfer (link scope) | a message body over the **100 MiB** reassembly cap |
| 11 | `amqp:session:window-violation` | session incoming/outgoing window breach | the peer sent more transfers than the advertised incoming-window allowed |
| 12 | `amqp:session:errant-link` | unattached-handle / handle-in-use session error | a TRANSFER/DISPOSITION on an unknown handle, or an ATTACH reusing a live handle |
| 13 | `amqp:connection:forced` | server-initiated CLOSE | graceful shutdown or broker-down — the connection is forced closed |
## Scopes [#scopes]
The condition prefix tells you the AMQP scope on which it is delivered:
* `amqp:*` — **link or message** scope (delivered on `DETACH` or a rejected disposition):
conditions 1–10.
* `amqp:session:*` — **session** scope (delivered on `END`): conditions 11–12.
* `amqp:connection:*` — **connection** scope (delivered on `CLOSE`): condition 13.
## Message sanitization (the no-leak rule) [#message-sanitization-the-no-leak-rule]
Every wire `description` is **sanitized to at most 512 characters** and carries **only a
broker error message** — never a file path, internal channel, stack trace, or policy
internal. This matches the gRPC connector's sanitization. A SASL auth failure takes this
further: the **wire** SASL outcome conveys only the failure code, while the full reason is
kept in the **audit record** (see
[Connections & Observability](/connectors/amqp/reference/connections-endpoint)) —
the cleartext error never crosses the wire.
So a client should treat the `description` as a short, human-readable hint and branch its
logic on the **symbolic condition**, not on the description string.
## Client handling guidance [#client-handling-guidance]
| Condition | Recommended client response |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `amqp:internal-error` | retry with backoff; the broker hit a transient fault |
| `amqp:not-found` | fix the address / channel — it does not exist or breaks the charset; do not retry unchanged |
| `amqp:unauthorized-access` | re-authenticate (refresh the JWT) or request a policy grant; do not retry unchanged |
| `amqp:decode-error` | a client/codec bug — inspect the encoded frame; do not blind-retry |
| `amqp:resource-limit-exceeded` | back off and reconnect (caps), grant credit faster (stalled events-store), or send keepalives (idle) |
| `amqp:not-allowed` | resolve the conflict — release the durable identity, use a fresh link name, attach `responses/` as a sender |
| `amqp:invalid-field` | fix the link property — correct the `x-opt-kubemq-start` grammar or the selector expression |
| `amqp:not-implemented` | the feature is a documented non-goal — use the supported alternative (selector on `events/` not `queues/`; `rcv-settle-mode=first`) |
| `amqp:precondition-failed` | set a valid `to` on the anonymous-terminus message |
| `amqp:link:message-size-exceeded` | split the payload or stay under the 100 MiB cap |
| `amqp:session:window-violation` | respect the advertised session window; grant credit before sending |
| `amqp:session:errant-link` | a handle-management bug in the client — do not reuse live handles |
| `amqp:connection:forced` | reconnect; the server is shutting down or the broker went away |
## Related [#related]
# Getting Started (/connectors/amqp/tutorials/getting-started)
Get a message flowing through the KubeMQ AMQP 1.0 connector in minutes. You point a
standard AMQP 1.0 client at the broker, attach a sender to `queues/`, produce a
few messages, then attach a receiver and consume them back — all over the native AMQP 1.0
wire, with no KubeMQ SDK. This walkthrough takes you from a running server to a verified
at-least-once round-trip.
## Prerequisites [#prerequisites]
* A running **kubemq-server** with the AMQP 1.0 connector **enabled** and reachable on **port 5672**
(plain TCP). The connector is **opt-in (disabled by default)** — see the enable step below.
* One of the AMQP 1.0 clients below for your language (the examples pin a native client
per language — there is no KubeMQ SDK).
## Enable the connector [#enable-the-connector]
The AMQP 1.0 connector is **disabled by default** — a stock kubemq-server does **not** bind
the AMQP 1.0 listener until you turn it on. Enable it with its enable variable:
The enable variable is **`CONNECTORS_AMQP10_ENABLE`** — the literal `10` stays attached to
`AMQP` with no underscore. `CONNECTORS_AMQP_1_0_ENABLE` and `CONNECTORS_AMQP10ENABLE` do
**not** bind. For Kubernetes, set `spec.amqp10.enabled: true` in the `KubemqCluster` CR.
Bring up a throwaway local broker with AMQP 1.0 enabled:
Every example reads a single environment variable for the broker endpoint. A URL with no
userinfo negotiates **SASL ANONYMOUS**, so a stock dev broker is clone-and-run with no
credentials:
```bash
# default: amqp://localhost:5672
export KUBEMQ_AMQP_URL="amqp://localhost:5672"
```
To **disable** the AMQP 1.0 connector after enabling it, set its enable variable to `false`:
When `Enable` is `false`, all other AMQP 1.0 validation is skipped and no listener binds. See
[Configuration](/connectors/amqp/concepts/configuration) for the full settings list.
## How it works [#how-it-works]
A producer attaches a sender link to a node address; the connector resolves the address
prefix to a KubeMQ pattern and channel and enqueues each message. A consumer attaches a
receiver link to the same address, grants credit, and the connector delivers and removes
each accepted message.
*A sender produces unsettled messages to a Queue channel; a receiver grants credit, and the connector delivers and removes each accepted message.*
## Steps [#steps]
### Connect to the broker [#connect-to-the-broker]
Open an AMQP 1.0 connection to the endpoint in `KUBEMQ_AMQP_URL`. A URL with no userinfo
negotiates SASL ANONYMOUS; the client sends a non-empty `container-id` automatically (the
connector requires one). One session carries both the producer and consumer links in the
steps below.
The language tabs across all three steps run the **complete** round-trip from a single
program: connect, produce 10 messages to `queues/amqp10.examples.basic`, consume and
accept each, and confirm the queue drains to empty.
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
amqp "github.com/Azure/go-amqp"
)
const channel = "amqp10.examples.basic"
const total = 10
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://localhost:5672"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
addr := "queues/" + channel
// CONNECT: OPEN (SASL ANONYMOUS) + BEGIN one session.
conn, err := amqp.Dial(ctx, amqpURL(), nil)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
session, err := conn.NewSession(ctx, nil)
if err != nil {
log.Fatalf("new session: %v", err)
}
// SEND: attach a sender; each unsettled Send blocks for the accepted disposition.
sender, err := session.NewSender(ctx, addr, nil)
if err != nil {
log.Fatalf("new sender: %v", err)
}
for i := 0; i < total; i++ {
body := fmt.Sprintf("msg-%03d", i)
if err := sender.Send(ctx, amqp.NewMessage([]byte(body)), nil); err != nil {
log.Fatalf("send %s: %v", body, err)
}
}
_ = sender.Close(ctx)
fmt.Printf("[send] Produced %d messages to %s\n", total, addr)
// RECEIVE: attach a receiver with credit; accept each => removed from the queue.
receiver, err := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10})
if err != nil {
log.Fatalf("new receiver: %v", err)
}
seen := make(map[string]struct{}, total)
for len(seen) < total {
msg, err := receiver.Receive(ctx, nil)
if err != nil {
log.Fatalf("receive: %v", err)
}
if err := receiver.AcceptMessage(ctx, msg); err != nil {
log.Fatalf("accept: %v", err)
}
seen[string(msg.GetData())] = struct{}{}
}
fmt.Printf("[recv] Consumed and accepted %d messages (no loss)\n", len(seen))
_ = receiver.Close(ctx)
}
```
```python
import os
from proton import Message
from proton.utils import BlockingConnection
CHANNEL = "amqp10.examples.basic"
TOTAL = 10
def amqp_url() -> str:
return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")
def main() -> None:
addr = "queues/" + CHANNEL
# CONNECT: OPEN (SASL ANONYMOUS) — proton sends a non-empty container-id.
conn = BlockingConnection(amqp_url())
try:
# SEND: attach a sender; each send blocks for the accepted disposition.
sender = conn.create_sender(addr)
for i in range(TOTAL):
sender.send(Message(body=f"msg-{i:03d}"))
sender.close()
print(f"[send] Produced {TOTAL} messages to {addr}")
# RECEIVE: attach a receiver with credit; accept each => removed from the queue.
receiver = conn.create_receiver(addr, credit=10)
seen: set[str] = set()
while len(seen) < TOTAL:
msg = receiver.receive(timeout=30.0)
receiver.accept()
seen.add(str(msg.body))
print(f"[recv] Consumed and accepted {len(seen)} messages (no loss)")
receiver.close()
finally:
conn.close()
if __name__ == "__main__":
main()
```
```java
import java.util.HashSet;
import java.util.Set;
import javax.jms.Connection;
import javax.jms.Message;
import javax.jms.MessageConsumer;
import javax.jms.MessageProducer;
import javax.jms.Queue;
import javax.jms.Session;
import org.apache.qpid.jms.JmsConnectionFactory;
public final class Main {
private static final String CHANNEL = "amqp10.examples.basic";
private static final int TOTAL = 10;
public static void main(String[] args) throws Exception {
String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672");
String address = "queues/" + CHANNEL;
// CONNECT: OPEN (SASL ANONYMOUS). The JMS destination name IS the node address.
JmsConnectionFactory factory = new JmsConnectionFactory(url);
try (Connection connection = factory.createConnection()) {
connection.start();
try (Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE)) {
Queue queue = session.createQueue(address);
// SEND: a producer is a server-receiver link; send blocks for accepted.
try (MessageProducer producer = session.createProducer(queue)) {
for (int i = 0; i < TOTAL; i++) {
producer.send(session.createTextMessage(String.format("msg-%03d", i)));
}
}
System.out.printf("[send] Produced %d messages to %s%n", TOTAL, address);
// RECEIVE: a CLIENT_ACKNOWLEDGE consumer; acknowledge() settles accepted.
try (MessageConsumer consumer = session.createConsumer(queue)) {
Set seen = new HashSet<>();
while (seen.size() < TOTAL) {
Message msg = consumer.receive(30_000);
if (msg == null) {
throw new IllegalStateException("timed out before draining the queue");
}
String body = msg.getBody(String.class);
msg.acknowledge();
seen.add(body);
}
System.out.printf("[recv] Consumed and accepted %d messages (no loss)%n", seen.size());
}
}
}
}
}
```
```csharp
using System.Text;
using Amqp;
using Amqp.Framing;
const string channel = "amqp10.examples.basic";
const int total = 10;
static string AmqpUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
? v
: "amqp://localhost:5672";
var addr = "queues/" + channel;
// CONNECT: OPEN (SASL ANONYMOUS) + one session.
var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl()));
try
{
var session = new Session(connection);
// SEND: a SenderLink is a server-receiver link; Send blocks for accepted.
var sender = new SenderLink(session, "basic-sender", addr);
for (var i = 0; i < total; i++)
{
var message = new Message
{
BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"msg-{i:D3}") },
};
sender.Send(message, TimeSpan.FromSeconds(15));
}
Console.WriteLine($"[send] Produced {total} messages to {addr}");
// RECEIVE: grant credit; Accept each => removed from the queue. Keep the
// sender open through the consume phase (detaching it early can stall delivery).
var receiver = new ReceiverLink(session, "basic-receiver", addr);
receiver.SetCredit(10, autoRestore: true);
var seen = new HashSet();
while (seen.Count < total)
{
var message = receiver.Receive(TimeSpan.FromSeconds(30))
?? throw new InvalidOperationException("receive timed out");
receiver.Accept(message);
seen.Add(BodyString(message));
}
Console.WriteLine($"[recv] Consumed and accepted {seen.Count} messages (no loss)");
await receiver.CloseAsync();
await sender.CloseAsync();
await session.CloseAsync();
}
finally
{
await connection.CloseAsync();
}
static string BodyString(Message message) => message.BodySection switch
{
Data d => Encoding.UTF8.GetString(d.Binary),
AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes),
AmqpValue { Value: string str } => str,
AmqpValue v => v.Value?.ToString() ?? string.Empty,
_ => string.Empty,
};
```
```typescript
import {
Connection,
ReceiverEvents,
type ConnectionOptions,
type EventContext,
type Receiver,
} from "rhea-promise";
const channel = "amqp10.examples.basic";
const total = 10;
function connectionOptions(): ConnectionOptions {
const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672");
return {
host: url.hostname,
port: url.port ? Number(url.port) : 5672,
container_id: `kubemq-amqp10-js-${process.pid}`,
reconnect: false,
};
}
function bodyToString(body: unknown): string {
return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
}
async function main(): Promise {
const address = `queues/${channel}`;
// CONNECT: OPEN (SASL ANONYMOUS).
const connection = new Connection(connectionOptions());
await connection.open();
try {
// SEND: an AwaitableSender; each send() resolves on the accepted disposition.
const sender = await connection.createAwaitableSender({ target: { address } });
for (let i = 0; i < total; i++) {
await sender.send({ body: `msg-${String(i).padStart(3, "0")}` }, { timeoutInSeconds: 15 });
}
await sender.close();
console.log(`[send] Produced ${total} messages to ${address}`);
// RECEIVE: manual credit + manual settle; accept() removes from the queue.
const receiver = await connection.createReceiver({
source: { address },
credit_window: 0,
autoaccept: false,
autosettle: false,
});
const seen = new Set();
await new Promise((resolve, reject) => {
receiver.on(ReceiverEvents.message, (ctx: EventContext) => {
ctx.delivery?.accept();
seen.add(bodyToString(ctx.message?.body));
if (seen.size >= total) resolve();
});
receiver.on(ReceiverEvents.receiverError, (ctx: EventContext) =>
reject(ctx.receiver?.error ?? new Error("receiver error")));
(receiver as Receiver).addCredit(total);
});
console.log(`[recv] Consumed and accepted ${seen.size} messages (no loss)`);
await receiver.close();
} finally {
await connection.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```rust
use std::collections::HashSet;
use fe2o3_amqp::link::delivery::Delivery;
use fe2o3_amqp::link::receiver::CreditMode;
use fe2o3_amqp::{Connection, Receiver, Sender, Session};
use fe2o3_amqp_types::definitions::SenderSettleMode;
use fe2o3_amqp_types::messaging::{Body, Message};
use fe2o3_amqp_types::primitives::Value;
const CHANNEL: &str = "amqp10.examples.basic";
const TOTAL: usize = 10;
fn amqp_url() -> String {
std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string())
}
fn body_string(msg: &Message>) -> String {
match &msg.body {
Body::Data(batch) => batch.iter().flat_map(|d| d.0.iter().copied()).collect::>(),
Body::Value(v) => match &v.0 {
Value::Binary(b) => b.to_vec(),
Value::String(s) => s.clone().into_bytes(),
other => format!("{other:?}").into_bytes(),
},
_ => Vec::new(),
}
.into_iter()
.map(|b| b as char)
.collect()
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let addr = format!("queues/{CHANNEL}");
// CONNECT: OPEN (SASL ANONYMOUS) + BEGIN one session.
let mut connection = Connection::open("amqp10-examples-basic", amqp_url().as_str()).await?;
let mut session = Session::begin(&mut connection).await?;
// SEND: a sender pinned to Unsettled (at-least-once); the default `mixed` is rejected.
let mut sender = Sender::builder()
.name("basic-sender")
.target(addr.as_str())
.sender_settle_mode(SenderSettleMode::Unsettled)
.attach(&mut session)
.await?;
for i in 0..TOTAL {
let outcome = sender.send(format!("msg-{i:03}")).await?;
if !outcome.is_accepted() {
return Err(format!("send {i}: unexpected outcome {outcome:?}").into());
}
}
sender.close().await?;
println!("[send] Produced {TOTAL} messages to {addr}");
// RECEIVE: client-granted auto credit; accept each => removed from the queue.
let mut receiver = Receiver::builder()
.name("basic-receiver")
.source(addr.as_str())
.credit_mode(CreditMode::Auto(10))
.attach(&mut session)
.await?;
let mut seen: HashSet = HashSet::with_capacity(TOTAL);
while seen.len() < TOTAL {
let delivery: Delivery> = receiver.recv().await?;
receiver.accept(&delivery).await?;
seen.insert(body_string(delivery.message()));
}
println!("[recv] Consumed and accepted {} messages (no loss)", seen.len());
receiver.close().await?;
session.end().await?;
connection.close().await?;
Ok(())
}
```
### Send messages [#send-messages]
The send phase in the program above attaches a **sender** link with the target address
`queues/amqp10.examples.basic` and produces 10 messages. Because the sends are *unsettled*,
each call blocks until the connector returns an `accepted` disposition — confirmation that
the broker stored the message (at-least-once). Always use the **explicit pattern prefix**
(`queues/…`); never rely on the connector's bare-address fallback.
### Receive and verify [#receive-and-verify]
The receive phase attaches a **receiver** link to the same address and grants link credit;
the connector delivers each message, and `accept` settles it — emitting an `AckRange` that
removes it from the queue. After consuming all 10, a further receive times out, proving the
queue drained with no loss:
```text
[send] Produced 10 messages to queues/amqp10.examples.basic
[recv] Consumed and accepted 10 messages (no loss)
[recv] Queue drained to empty (no further messages)
```
Behind that, the full AMQP 1.0 handshake ran end-to-end: **OPEN** → **BEGIN** →
**ATTACH** (sender, then receiver) → **TRANSFER / FLOW / DISPOSITION** → **DETACH /
CLOSE**.
Two flow-control footguns to internalize early: **Events** drop silently at zero credit
(keep a consumer's credit topped up), and **Events-Store** stalls and loses its window if
a durable consumer's credit is not replenished. Both are covered in
[Flow control](/connectors/amqp/how-to/flow-control).
## Next steps [#next-steps]
# Architecture (/connectors/aws/concepts/architecture)
The KubeMQ **AWS SQS + SNS connector** is an embedded, wire-protocol bridge inside
kubemq-server that speaks the genuine AWS SQS and SNS HTTP protocols on a dedicated second
HTTP listener (default TCP **4566**, the LocalStack convention). It is built **only when the
connector is enabled** (`CONNECTORS_AWS_ENABLE=true`), because enabling it opens a new port.
Any standard AWS SDK connects to it with **only an endpoint-URL change** — no code changes,
no library swap, no LocalStack.
It is **one connector binary with two service surfaces** that map onto two distinct KubeMQ
models:
* **SQS** maps each queue onto a native KubeMQ **Queue** channel `sqs.{name}`, so AWS
producers and native gRPC/REST consumers share the same messages.
* **SNS** topics are **virtual** registry entries — replicated across cluster nodes — that
fan out at publish time to subscribed SQS queues (a batch send) and HTTP/HTTPS webhooks (a
delivery engine).
Unlike connectors that touch every KubeMQ pattern, the AWS connector touches the **Queue
primitive** (SQS) and a **topic registry** (SNS) — it does **not** map onto KubeMQ's
events / events-store / commands / queries patterns, and there is **no RPC** anywhere. This
single fact drives the mental model.
## How SQS & SNS map to KubeMQ [#how-sqs--sns-map-to-kubemq]
A request arrives at the single AWS-style endpoint; the connector detects the protocol,
verifies the SigV4 signature, and dispatches. SQS operations land on the KubeMQ Queue channel
`sqs.{name}` through the message broker. SNS publishes resolve the virtual topic registry and
fan out to every confirmed, filter-matching subscription.
*SQS operations land on the KubeMQ Queue channel `sqs.{name}`; SNS publishes resolve the virtual, cluster-replicated registry and fan out to subscribed SQS queues and HTTP/HTTPS webhooks. The Queue channel is backed by the message broker's durable store.*
The channel mapping is the single most important mental model:
| Concept | Behavior |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Listener** | A dedicated second HTTP server on `Connectors.Aws.Port` (default **4566**). `POST /` and `GET /` both dispatch; there is no per-route REST path — everything is a single AWS-style endpoint. |
| **Protocol detection** | SQS = AWS **JSON protocol** (`X-Amz-Target: AmazonSQS.{Op}`) with a Query-protocol fallback. SNS = AWS **Query protocol** only (form body / GET query → XML). |
| **SQS queue → channel** | SQS queue `orders` ↔ native KubeMQ **Queue** channel `sqs.orders` (`channelPrefix = "sqs."`). AWS producers and native gRPC/REST consumers share the same messages. |
| **FIFO group → per-group channel** | A FIFO queue `{name}.fifo` fans each message group onto its own channel `sqs.{name}.fifo.g.{enc(group)}`, where `enc` percent-encodes bytes outside `[a-zA-Z0-9_-]`. |
| **SNS topic = virtual** | SNS topics have **no native channel** — they are registry entries replicated across cluster nodes. The authorization pseudo-resource is `sns.{topic}`. Fan-out resolves to target SQS channels + HTTP/HTTPS webhooks at publish time. |
| **Registry is authoritative** | Only resources created via the AWS API are visible. A native `sqs.foo` channel never `CreateQueue`d returns `NonExistentQueue`. |
| **ARNs / URLs** | `arn:aws:sqs:{Region}:{AccountId}:{name}`; queue URL form `{scheme}://{host}/{AccountId}/{name}`. Resolution parses **path only** → addressing is **path-style**, so stale hosts in saved URLs still work. |
Region defaults to the ARN segment `kubemq` and is **NOT enforced** in SigV4; AccountId
defaults to `000000000000`. See
[Channel mapping](/connectors/aws/reference/channel-mapping) and
[Configuration](/connectors/aws/concepts/configuration).
## SQS → KubeMQ internally [#sqs--kubemq-internally]
Each SQS operation maps onto a KubeMQ Queue operation:
| AWS operation | KubeMQ internal |
| ------------------------------------------------ | -------------------------------------------------------------- |
| `SendMessage` / `SendMessageBatch` | `SendQueueMessage` / `SendQueueMessagesBatch` |
| `ReceiveMessage` | a downstream `Get` with `AutoAck=false` |
| `DeleteMessage` | fire-and-forget `AckRange(seq)` |
| visibility expiry / `ChangeMessageVisibility(0)` | `NAckRange(seq)` (message visible at the tail) |
| `PurgeQueue` | `AckAllQueueMessages` (60 s cooldown → `PurgeQueueInProgress`) |
Received-but-not-deleted messages are tracked **node-locally** (per-queue maps + a global
visibility-deadline min-heap); a 250 ms sweeper NAcks expired entries back to the tail. A
receipt handle minted on one node is rejected on another, so clustered deployments need a
**sticky load balancer**. See [SQS queues](/connectors/aws/how-to/sqs-queues) and
[SQS queues and consumers](/connectors/aws/how-to/sqs-queues-and-consumers).
## SNS → KubeMQ internally [#sns--kubemq-internally]
Topics and subscriptions live **only** in the registry (replicated across cluster nodes) —
they have no native channel. At publish time the connector resolves every **confirmed**,
filter-matching subscription and:
* for `sqs` subscriptions, sends all targets of one publish in a single
`SendQueueMessagesBatch` onto each target queue's `sqs.{queue}` channel;
* for `http`/`https` subscriptions, hands the body to the in-memory webhook delivery engine.
**One `MessageId` per publish** is shared across all deliveries. A publish with zero matching
subscriptions **succeeds** (the message is dropped). Because the topic registry is replicated
across cluster nodes, a topic created on one node is visible on the others; the cluster uses a
registry-sync conflict resolution to converge concurrent writes. See
[Fan-out](/connectors/aws/how-to/fan-out) and
[SNS fan-out](/connectors/aws/how-to/sns-fan-out).
## Cross-protocol interop [#cross-protocol-interop]
Because every SQS queue is a normal KubeMQ Queue channel, an SQS `SendMessage` to `sqs.orders`
is consumable by a gRPC/REST queue client on the same channel — and vice-versa. This lets you
migrate one side at a time, or run AWS-SDK producers alongside native KubeMQ consumers.
*The same KubeMQ Queue channel `sqs.orders` backs both sides, so an AWS SDK client and a gRPC/REST client interoperate transparently.*
A message produced by a **native** KubeMQ client on `sqs.*` lacks the connector's `sqs_*`
tags. On the SQS receive side its `MessageId` falls back to the broker MessageID, it has no
`SenderId`, and no policy stamping is applied. This is harmless for interop — the body and
tags round-trip — but do not assume an SQS-style `MessageId`/`SenderId` on natively-produced
messages. See [Cross-protocol interop](/connectors/aws/concepts/cross-protocol-interop).
## Related [#related]
# Configuration (/connectors/aws/concepts/configuration)
The AWS connector is configured server-side under the `Connectors.Aws` block of the KubeMQ
server config, exposed as **ten `CONNECTORS_AWS_*` environment variables**. Unlike the other
wire-protocol connectors, it is **opt-in — disabled by default** — because enabling it opens
a new HTTP listener. A stock server does **not** serve AWS until you turn it on. See the
[Configuration reference](../reference/configuration) for the full field table and the
TOML/Env/Docker equivalents.
The only thing **clients** configure is the endpoint override via the `KUBEMQ_AWS_URL`
environment variable (default `http://localhost:4566`), mapped to `AWS_ENDPOINT_URL_SQS` /
`AWS_ENDPOINT_URL_SNS`. Everything below is broker-side server configuration.
## Enable the connector [#enable-the-connector]
The connector is **off until you enable it.** Set its enable variable to `true`:
**Enabling the connector opens a new HTTP listener on port 4566.** That port is **not bound
until** `CONNECTORS_AWS_ENABLE=true`, and it **must differ** from the server's enabled
gRPC/REST/HTTP ports — a collision aborts startup. This is precisely why the connector is
opt-in rather than on by default: like all six wire-protocol connectors, it does not bind
a listener until explicitly enabled. The enable variable is `CONNECTORS_AWS_ENABLE` — the
prefix carries the underscore (`CONNECTORS_AWS_*`), unlike the MQTT connector's
`CONNECTORSMQTT_*`. To disable it again, set
`CONNECTORS_AWS_ENABLE=false` (a config-only rollback; no data migration).
## Credentials: accept-any vs static [#credentials-accept-any-vs-static]
The connector supports two credential postures. See
[Authentication](/connectors/aws/how-to/authentication) for the full treatment.
### Accept-any mode (default — no credentials configured) [#accept-any-mode-default--no-credentials-configured]
When **no** credentials are configured, the credential store is empty: the connector parses
the `AccessKeyId` and uses it as the ClientID, and logs once that it is running without
credential verification.
**Dummy credentials are still required.** Even in accept-any mode the request must carry a
**syntactically valid** SigV4 signature with an `sqs`/`sns` credential scope. An unsigned
request is rejected with `IncompleteSignature` — the only SigV4-exempt action is SNS
`ConfirmSubscription`. The signature is not cryptographically verified, but its presence and
shape are. So the SDK must be given an access key and secret (any non-empty value).
### Static credentials (`CONNECTORS_AWS_CREDENTIALS_DATA`) [#static-credentials-connectors_aws_credentials_data]
Set `CONNECTORS_AWS_CREDENTIALS_DATA` to a JSON (optionally base64-encoded) array of
credentials. Each entry carries an `AccessKeyId`, a `SecretAccessKey`, and an optional
`ClientID` (defaulting to the `AccessKeyId`):
```json
[
{
"AccessKeyId": "AKIA...",
"SecretAccessKey": "secret...",
"ClientID": "billing"
}
]
```
When credentials are configured, SigV4 is **fully verified**: the access key must match a
configured credential, and the signature is checked with a constant-time compare. The
authenticated `ClientID` becomes the identity used for per-channel `write`/`read`
authorization and the stamped `sqs_sender_id` tag. An empty key or secret is a configuration
error.
SigV4 over plain HTTP is **unencrypted on the wire.** HTTPS is provided by the server-wide
`Security` block — there is **no AWS-specific TLS option**. Production deployments should use
the server's HTTPS listener. See
[Connectivity and security](/connectors/aws/how-to/connectivity-and-security) and
[Auth & security](/connectors/reference/auth-and-security).
## Related [#related]
# Cross-Protocol Interop (/connectors/aws/concepts/cross-protocol-interop)
Because every SQS queue is a **normal KubeMQ Queue channel** (`sqs.{name}`), an AWS SDK application and a native KubeMQ gRPC/REST client can work the **same channel**. A message sent by `boto3` to `sqs.shared` is consumable by a native `kubemq-go` queue client on `sqs.shared` — and a message produced natively is receivable by an AWS SDK `ReceiveMessage`. This lets you **migrate one side at a time**, or run AWS-SDK producers alongside native KubeMQ consumers.
## Overview [#overview]
Both directions work on the same channel. The AWS-SDK side speaks the SQS HTTP protocol to the connector (port 4566); the native side speaks gRPC/REST to the KubeMQ broker directly. The channel — and therefore the message body — is shared.
| Direction | Producer | Consumer | What carries over |
| ---------------- | ---------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- |
| AWS SDK → native | SQS `SendMessage` on `sqs.shared` | native gRPC `ReceiveQueueMessages` | Body + `sqs_*` tags (`sqs_message_id`, `sqs_sender_id`, attribute tags) |
| Native → AWS SDK | native gRPC `Send` on `sqs.shared` | SQS `ReceiveMessage` | Body + native tags; **no** `sqs_*` tags (see caveat) |
## How it works [#how-it-works]
The AWS connector registers `sqs.shared` and the native client connects to the same channel through the broker. A produce on either side is visible to a consume on the other.
*The SQS queue `shared` and the native channel `sqs.shared` are the same KubeMQ Queue channel; an AWS-SDK producer and a native gRPC consumer share its messages — and vice versa.*
## The AWS-SDK side [#the-aws-sdk-side]
The AWS half is ordinary SQS code — `CreateQueue`, `SendMessage`, `ReceiveMessage`, `DeleteMessage` against `sqs.shared`. The queue must be **created via the AWS API first** (the registry is authoritative — a native channel never `CreateQueue`d returns `NonExistentQueue`). Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies dummy static credentials.
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
url := os.Getenv("KUBEMQ_AWS_URL")
if url == "" {
url = "http://localhost:4566"
}
cfg, _ := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
sdk := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) })
// The channel sqs.shared must exist in the registry — create it via the API.
created, err := sdk.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("shared")})
if err != nil {
log.Fatalf("CreateQueue: %v", err)
}
queueURL := aws.ToString(created.QueueUrl)
fmt.Printf("CreateQueue: %s (native channel: sqs.shared)\n", queueURL)
// Direction 1: SDK produces; a native gRPC client consumes on sqs.shared.
if _, err := sdk.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(queueURL),
MessageBody: aws.String("from the AWS SDK"),
}); err != nil {
log.Fatalf("SendMessage: %v", err)
}
fmt.Println("[SDK -> native] sent \"from the AWS SDK\" to sqs.shared")
// Direction 2: the AWS SDK receives a message a native client produced.
recv, err := sdk.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
WaitTimeSeconds: 10,
})
if err != nil {
log.Fatalf("ReceiveMessage: %v", err)
}
if len(recv.Messages) == 1 {
m := recv.Messages[0]
fmt.Printf("[native -> SDK] received %q MessageId=%s\n", aws.ToString(m.Body), aws.ToString(m.MessageId))
_, _ = sdk.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL), ReceiptHandle: m.ReceiptHandle,
})
}
}
```
```python
import os
import boto3
sqs = boto3.client(
"sqs",
endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
# The channel sqs.shared must exist in the registry — create it via the API.
url = sqs.create_queue(QueueName="shared")["QueueUrl"]
print(f"CreateQueue -> {url} (native channel: sqs.shared)")
# Direction 1: SDK produces; a native gRPC client consumes on sqs.shared.
sqs.send_message(QueueUrl=url, MessageBody="from-aws-sdk")
print("[SDK -> native] sent 'from-aws-sdk' to sqs.shared")
# Direction 2: the AWS SDK receives a message a native client produced.
recv = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=1, WaitTimeSeconds=10)
for m in recv.get("Messages", []):
print(f"[native -> SDK] received {m['Body']!r} MessageId={m['MessageId']}")
sqs.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"])
```
```java
import java.net.URI;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.Message;
public final class Main {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566");
try (SqsClient sqs = SqsClient.builder()
.endpointOverride(URI.create(url)).region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")))
.build()) {
// The channel sqs.shared must exist in the registry — create it via the API.
String queueUrl = sqs.createQueue(b -> b.queueName("shared")).queueUrl();
System.out.println("CreateQueue -> " + queueUrl + " (native channel: sqs.shared)");
// Direction 1: SDK produces; a native gRPC client consumes on sqs.shared.
sqs.sendMessage(b -> b.queueUrl(queueUrl).messageBody("from the AWS SDK"));
System.out.println("[SDK -> native] sent to sqs.shared");
// Direction 2: the AWS SDK receives a message a native client produced.
var recv = sqs.receiveMessage(b -> b.queueUrl(queueUrl).waitTimeSeconds(10));
for (Message m : recv.messages()) {
System.out.println("[native -> SDK] received '" + m.body()
+ "' MessageId=" + m.messageId());
sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(m.receiptHandle()));
}
}
}
}
```
```typescript
import {
SQSClient,
CreateQueueCommand,
SendMessageCommand,
ReceiveMessageCommand,
DeleteMessageCommand,
} from "@aws-sdk/client-sqs";
const sqs = new SQSClient({
endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566",
region: "us-east-1",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
});
async function main(): Promise {
// The channel sqs.shared must exist in the registry — create it via the API.
const queueUrl = (await sqs.send(new CreateQueueCommand({ QueueName: "shared" }))).QueueUrl!;
console.log(`CreateQueue -> ${queueUrl} (native channel: sqs.shared)`);
// Direction 1: SDK produces; a native gRPC client consumes on sqs.shared.
await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "order from AWS SDK" }));
console.log("[SDK -> native] sent to sqs.shared");
// Direction 2: the AWS SDK receives a message a native client produced.
const recv = await sqs.send(new ReceiveMessageCommand({ QueueUrl: queueUrl, WaitTimeSeconds: 10 }));
for (const m of recv.Messages ?? []) {
console.log(`[native -> SDK] received "${m.Body}" MessageId=${m.MessageId}`);
await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: m.ReceiptHandle! }));
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using Amazon.Runtime;
using Amazon.SQS;
using Amazon.SQS.Model;
var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566";
using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"),
new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" });
// The channel sqs.shared must exist in the registry — create it via the API.
var queueUrl = (await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "shared" })).QueueUrl;
Console.WriteLine($"CreateQueue -> {queueUrl} (native channel: sqs.shared)");
// Direction 1: SDK produces; a native gRPC client consumes on sqs.shared.
await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = "from the AWS SDK" });
Console.WriteLine("[SDK -> native] sent to sqs.shared");
// Direction 2: the AWS SDK receives a message a native client produced.
var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = queueUrl, WaitTimeSeconds = 10 });
foreach (var m in recv.Messages)
{
Console.WriteLine($"[native -> SDK] received '{m.Body}' MessageId={m.MessageId}");
await sqs.DeleteMessageAsync(new DeleteMessageRequest { QueueUrl = queueUrl, ReceiptHandle = m.ReceiptHandle });
}
```
```ruby
# frozen_string_literal: true
require "aws-sdk-sqs"
Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls)
url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566")
sqs = Aws::SQS::Client.new(
endpoint: url, region: "us-east-1", access_key_id: "test", secret_access_key: "test"
)
# The channel sqs.shared must exist in the registry — create it via the API.
queue_url = sqs.create_queue(queue_name: "shared").queue_url
puts "CreateQueue -> #{queue_url} (native channel: sqs.shared)"
# Direction 1: SDK produces; a native gRPC client consumes on sqs.shared.
sqs.send_message(queue_url: queue_url, message_body: "from the AWS SDK")
puts "[SDK -> native] sent to sqs.shared"
# Direction 2: the AWS SDK receives a message a native client produced.
recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 10)
recv.messages.each do |m|
puts "[native -> SDK] received #{m.body.inspect} MessageId=#{m.message_id}"
sqs.delete_message(queue_url: queue_url, receipt_handle: m.receipt_handle)
end
```
```rust
use aws_config::{BehaviorVersion, Region};
use aws_credential_types::Credentials;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box> {
let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into());
let conf = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(Credentials::new("test", "test", None, None, "static"))
.endpoint_url(url)
.load()
.await;
let sqs = aws_sdk_sqs::Client::new(&conf);
// The channel sqs.shared must exist in the registry — create it via the API.
let queue_url = sqs.create_queue().queue_name("shared").send().await?.queue_url.unwrap();
println!("CreateQueue -> {queue_url} (native channel: sqs.shared)");
// Direction 1: SDK produces; a native gRPC client consumes on sqs.shared.
sqs.send_message().queue_url(&queue_url).message_body("from the AWS SDK").send().await?;
println!("[SDK -> native] sent to sqs.shared");
// Direction 2: the AWS SDK receives a message a native client produced.
let recv = sqs.receive_message().queue_url(&queue_url).wait_time_seconds(10).send().await?;
for m in recv.messages() {
println!("[native -> SDK] received '{}' MessageId={}",
m.body().unwrap_or_default(), m.message_id().unwrap_or(""));
if let Some(handle) = m.receipt_handle() {
sqs.delete_message().queue_url(&queue_url).receipt_handle(handle).send().await?;
}
}
Ok(())
}
```
## The native consumer [#the-native-consumer]
The other side of the channel is an ordinary KubeMQ **Queue** client talking gRPC to the broker (default `localhost:50000`) on the `sqs.shared` channel — no AWS SDK involved. A message the AWS SDK sent carries the connector's `sqs_*` tags (`sqs_message_id`, attribute tags); a message the native client sends is later receivable by an SQS `ReceiveMessage`.
```go
package main
import (
"context"
"fmt"
"log"
"time"
kubemq "github.com/kubemq-io/kubemq-go"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Native KubeMQ gRPC queue client on the shared channel sqs.shared.
native, err := kubemq.NewQueuesClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("kubemq-aws-interop-native"),
kubemq.WithTransportType(kubemq.TransportTypeGRPC),
)
if err != nil {
log.Fatalf("connect native gRPC: %v", err)
}
defer func() { _ = native.Close() }()
// Consume a message the AWS SDK produced on sqs.shared.
pull, err := native.Pull(ctx, kubemq.NewReceiveQueueMessagesRequest().
SetClientId("kubemq-aws-interop-native").
SetChannel("sqs.shared").
SetMaxNumberOfMessages(1).
SetWaitTimeSeconds(10))
if err != nil || pull.IsError {
log.Fatalf("native Pull: %v %s", err, pull.Error)
}
for _, m := range pull.Messages {
fmt.Printf("native received %q (sqs_message_id=%s)\n", string(m.Body), m.Tags["sqs_message_id"])
}
// Produce a message the AWS SDK can ReceiveMessage on sqs.shared.
if _, err := native.Send(ctx, kubemq.NewQueueMessage().
SetChannel("sqs.shared").
SetBody([]byte("from native gRPC"))); err != nil {
log.Fatalf("native Send: %v", err)
}
fmt.Println("native sent \"from native gRPC\" to sqs.shared")
}
```
```python
import os
from kubemq import QueueMessage, QueuesClient
CHANNEL = "sqs.shared"
GRPC_ADDRESS = os.environ.get("KUBEMQ_GRPC_ADDRESS", "localhost:50000")
# Native KubeMQ gRPC queue client on the shared channel sqs.shared.
with QueuesClient(address=GRPC_ADDRESS, client_id="kubemq-aws-interop-python") as native:
# Consume a message the AWS SDK produced on sqs.shared.
resp = native.receive_queue_messages(channel=CHANNEL, max_messages=1, wait_timeout_in_seconds=10)
for msg in resp.messages:
body = msg.body.decode("utf-8")
print(f"native received {body!r} sqs_message_id={msg.tags.get('sqs_message_id')}")
msg.ack()
# Produce a message the AWS SDK can ReceiveMessage on sqs.shared.
result = native.send_queue_message(
QueueMessage(channel=CHANNEL, body=b"from-native-grpc", tags={"origin": "native"})
)
print(f"native sent -> id={result.id}")
```
```typescript
import { KubeMQClient, createQueueMessage, bytesToString } from "kubemq-js";
const CHANNEL = "sqs.shared";
const address = process.env["KUBEMQ_BROKER_ADDRESS"] ?? "localhost:50000";
async function main(): Promise {
// Native KubeMQ gRPC queue client on the shared channel sqs.shared.
const native = await KubeMQClient.create({ address, clientId: "kubemq-aws-interop-js" });
try {
// Consume a message the AWS SDK produced on sqs.shared.
const msgs = await native.receiveQueueMessages({ channel: CHANNEL, maxMessages: 1, waitTimeoutSeconds: 10 });
for (const m of msgs) {
console.log(`native received "${bytesToString(m.body)}" sqs_message_id=${m.tags["sqs_message_id"] ?? ""}`);
}
// Produce a message the AWS SDK can ReceiveMessage on sqs.shared.
await native.sendQueueMessage(createQueueMessage({ channel: CHANNEL, body: "from native gRPC" }));
console.log("native sent to sqs.shared");
} finally {
await native.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
The native consumer is the **only** place a KubeMQ SDK appears in the AWS connector examples — the AWS-SDK half above is idiomatic AWS code in every language. The native half ships in all seven languages; where a language's native queue client is less mature, it can fall back to a `kubemq-go` sidecar or a REST queue call.
## Native-producer MessageId fallback [#native-producer-messageid-fallback]
A message produced by a **native** KubeMQ client on `sqs.*` lacks the connector's `sqs_*` tags. On the SQS receive side this means:
* its `MessageId` **falls back to the broker message id** (not a connector-minted UUID);
* it has **no `SenderId`** (there is no authenticated `sqs_sender_id`);
* **no policy stamping** is applied (no `RedrivePolicy` `MaxReceiveCount` / source-queue tags).
This is expected and harmless for interop — the body and tags round-trip — but do not assume an SQS-style `MessageId` / `SenderId` on messages that entered the channel natively.
## Cluster caveat [#cluster-caveat]
**Receipt handles are node-local.** When an AWS SDK consumer and a native client share a channel across cluster nodes, the AWS consumer's receipt handle is only valid on the node that issued it. Use a **sticky load balancer** (session affinity) so a consumer's receive, delete, and visibility-change calls all land on the same node. See the [connectivity and security guide](/connectors/aws/how-to/connectivity-and-security).
## No RPC responder [#no-rpc-responder]
SQS and SNS are queue / pub-sub, **not** request/reply — there is **no gRPC RPC responder** anywhere in the AWS connector. Cross-protocol interop is a native **queue** client (produce / consume), not an RPC responder.
## Related [#related]
# Authentication (/connectors/aws/how-to/authentication)
The AWS connector authenticates every request with a **hand-rolled AWS Signature V4 (SigV4)**
verifier (standard-library crypto only — no AWS SDK at runtime). It supports two postures:
**accept-any** (the zero-friction local-dev default) and **configured static credentials** (a
secured connector). Exactly one action — SNS `ConfirmSubscription` — is SigV4-exempt.
## SigV4 verification [#sigv4-verification]
The verifier accepts both signature styles your SDK can produce:
* **header-style** — `Authorization: AWS4-HMAC-SHA256 Credential=…, SignedHeaders=…, Signature=…`;
* **query-style / presigned** — `X-Amz-Algorithm=AWS4-HMAC-SHA256` plus the `X-Amz-*` query params.
The verification rules are the same in both styles:
| Rule | Behavior |
| ---------------------------------------- | -------------------------------------------------------- |
| Signature compare | constant-time (`hmac.Equal`). |
| Clock skew | ±15 minutes on `X-Amz-Date`. |
| Credential scope **service** | must be `sqs` or `sns`. |
| Credential scope **region** | **NOT enforced** — any region signs successfully. |
| `X-Amz-Content-Sha256: UNSIGNED-PAYLOAD` | honored verbatim. |
| `X-Amz-Security-Token` | accepted and ignored (no STS / session-token semantics). |
**Region is not enforced.** Any `AWS_REGION` your SDK signs with is accepted; the connector's
default ARN region segment is `kubemq`. The examples use `us-east-1` purely for familiarity. See
[Migration from AWS](/connectors/aws/reference/migration-from-aws).
## Accept-any mode (the default) [#accept-any-mode-the-default]
When **no credentials are configured**, the credential store is empty: the verifier only parses
the `AccessKeyId` and uses it as the `ClientID`. The middleware logs once on startup —
`aws connector running without credential verification`. This is the default, intended for
zero-friction local development.
**Dummy credentials are still required.** Even in accept-any mode the request must carry a
**syntactically valid** SigV4 signature whose credential-scope service is `sqs` or `sns`. An
**unsigned** request is rejected with `IncompleteSignature` — the only SigV4-exempt action is SNS
`ConfirmSubscription`. The signature is not cryptographically checked, but its presence and shape
are. So your SDK must be given an access key and secret (**any value**) to form the signature.
Omitting them yields a "missing credentials" SDK error, not a connector error.
```bash
export AWS_ACCESS_KEY_ID="x" # any value — used as the ClientID in accept-any mode
export AWS_SECRET_ACCESS_KEY="x" # any value — the SDK needs it to sign
export AWS_REGION="us-east-1" # not enforced
```
## Configured static credentials [#configured-static-credentials]
When `Credentials` / `CredentialsData` are set, SigV4 is **fully verified**:
* the presented access key must match a configured `AwsCredential.AccessKeyId`;
* the secret must match (a constant-time compare of the signature);
* the authenticated `ClientID` becomes `AwsCredential.ClientID` (defaults to the `AccessKeyId`).
`CredentialsData` is a JSON (optionally base64-encoded) credential array — the env / operator
path. See [Configuration](/connectors/aws/concepts/configuration) for the JSON shape. This is a
getting-started / auth-banner toggle, not a separate example program.
## The ConfirmSubscription exemption [#the-confirmsubscription-exemption]
A request with `Action=ConfirmSubscription`, **no** `Authorization` header, and **no**
`X-Amz-Algorithm` query param bypasses SigV4 entirely (its `ClientID` becomes `sns-confirmation`).
This is the **only** SigV4-exempt action — it exists so the `SubscribeURL` confirmation `GET`
embedded in an SNS `SubscriptionConfirmation` envelope is usable as-is. A **signed**
`ConfirmSubscription` is still verified normally. See
[SNS fan-out](/connectors/aws/how-to/sns-fan-out).
## Casbin authorization [#casbin-authorization]
On top of SigV4, the connector applies per-channel Casbin checks:
* SQS data-plane operations authorize `write` / `read` on `sqs.{queue}`;
* SNS topic management authorizes `write` on the pseudo-resource `sns.{topic}`;
* SNS fan-out applies a per-target `write` check on each `sqs.{queue}` it delivers to.
A denied SQS data-plane operation (for example `SendMessage`) returns `AccessDeniedException`
(403).
## Failure mapping [#failure-mapping]
| Trigger | AWS error code | HTTP |
| ------------------------------------------------------------------------- | ----------------------- | ---- |
| Malformed SigV4 (including an unsigned non-`ConfirmSubscription` request) | `IncompleteSignature` | 400 |
| Unknown access key (configured-credentials mode) | `InvalidClientTokenId` | 403 |
| Bad signature / clock skew / tampered body | `SignatureDoesNotMatch` | 403 |
| Casbin deny on a data-plane operation | `AccessDeniedException` | 403 |
All authentication failures are audited as `aws.auth.failure`. See
[Error codes](/connectors/aws/reference/error-codes).
**SigV4 over plain HTTP transmits the request unencrypted.** Accept-any mode is for local
development only. For production, terminate over the server's HTTPS listener — there is no
AWS-specific TLS option; TLS comes from the shared server `Security` block. See
[Connectivity and security](/connectors/aws/how-to/connectivity-and-security).
## Related [#related]
# Connectivity and security (/connectors/aws/how-to/connectivity-and-security)
This guide covers how SQS and SNS clients reach the connector: the endpoint override, the opt-in
enable, path-style queue URLs, the SigV4-over-HTTP transport, HTTPS via the server's shared
`Security` block, the sticky-load-balancer caveat for clusters, and the region/account model.
## The connector is opt-in [#the-connector-is-opt-in]
**The AWS connector is disabled by default.** Set `CONNECTORS_AWS_ENABLE=true` to turn it on.
Enabling it **opens a new HTTP listener on port 4566** that is not bound until enabled, and that
port must differ from the gRPC / REST / HTTP server ports. Unlike the other wire-protocol
connectors — all six wire-protocol connectors are opt-in, ports not bound until enabled. See
[Configuration](/connectors/aws/concepts/configuration).
## Endpoint override [#endpoint-override]
The connector is a single TCP HTTP listener (default port **4566**, the LocalStack convention).
Clients reach it by overriding **only the endpoint URL** — no code changes:
* set `AWS_ENDPOINT_URL_SQS` and `AWS_ENDPOINT_URL_SNS`, or
* set the SDK's `BaseEndpoint` / `endpointOverride` / `ServiceURL` / `endpoint` / `.endpoint_url()`.
The examples expose a single convenience variable, `KUBEMQ_AWS_URL` (default
`http://localhost:4566`), and map it to both endpoint variables:
```bash
export KUBEMQ_AWS_URL="http://localhost:4566"
export AWS_ENDPOINT_URL_SQS="$KUBEMQ_AWS_URL"
export AWS_ENDPOINT_URL_SNS="$KUBEMQ_AWS_URL"
```
Both `POST /` and `GET /` dispatch (the `GET` only for `Action=ConfirmSubscription`); there is no
per-route REST path. See [Getting started](/connectors/aws/tutorials/getting-started) and
[Configuration](/connectors/aws/concepts/configuration).
## Path-style queue URLs [#path-style-queue-urls]
Queue URLs are **path-style**: `{scheme}://{host}/{AccountId}/{name}`. The host comes from the
configured `AdvertisedUrl` when set, otherwise the request `Host`. URL **resolution parses the
path only**, so a stale host in a saved queue URL still works — the connector addresses by
`/{account}/{queue}`.
## SigV4 over HTTP [#sigv4-over-http]
Every request is verified with hand-rolled SigV4 (see
[Authentication](/connectors/aws/how-to/authentication)). In the default **accept-any**
posture the signature shape is required but not cryptographically checked; with **static
credentials** it is fully verified.
**SigV4 over plain HTTP transmits the request unencrypted.** Accept-any mode is for local
development. For production, terminate over HTTPS (below).
## HTTPS / TLS [#https--tls]
**There is no AWS-specific TLS option.** The connector reuses the shared `httpserver`
infrastructure, so HTTPS/mTLS is available via the **server-global `Security` block** — the same
one that secures gRPC and REST — passed in when the listener is created. There is no
`CONNECTORS_AWS_TLS_*` field.
When the server-global `Security` block is configured, the connector listens over HTTPS; point the
SDK at `https://host:4566` (or whatever the deployment exposes) and keep the same code. TLS is
therefore a connectivity / production callout, not a built example variant. For the shared TLS/mTLS
model across connectors, see [Auth & security](/connectors/reference/auth-and-security).
## Sticky-LB caveat (cluster) [#sticky-lb-caveat-cluster]
**Node-local state needs a sticky load balancer.** Three pieces of connector state are
**node-local**:
* SQS **receipt handles** — a handle minted on one node yields `ReceiptHandleIsInvalid` on another;
* SQS **in-flight tracking** — per-queue / per-node maps plus the sweeper;
* SNS **HTTP delivery state** — pending retries live on the publishing node and are lost on its
restart.
Cluster deployments must put a **sticky load balancer** (session affinity) in front of the
connector so each client sticks to one node for the lifetime of its in-flight messages and
subscriptions. Single-node deployments are unaffected. See
[Reliability](/connectors/aws/how-to/reliability).
The registry itself is BoltDB, **synced across cluster nodes**, so queue / topic / subscription
**existence** is cluster-wide; only the in-flight / receipt / delivery **state** is node-local.
## Region and account [#region-and-account]
* **Region is not enforced.** Any `AWS_REGION` signs successfully; the connector's default ARN
region segment is `kubemq`. Use any familiar region (the examples use `us-east-1`).
* **AccountId** is a single configurable 12-digit value (default `000000000000`). There is **no
cross-account support** — `QueueOwnerAWSAccountId` is accepted and ignored.
## Traffic gate [#traffic-gate]
While the message broker is not yet ready, the connector returns an **AWS-shaped 503** (rather
than a raw error), so SDKs surface it as a retryable service error. See
[Connections endpoint](/connectors/aws/reference/connections-endpoint).
## Related [#related]
# Fan-Out (SNS → SQS) (/connectors/aws/how-to/fan-out)
**Fan-out** delivers one published message to many subscribers. An SNS `Publish` resolves, at publish time, to every **confirmed**, filter-matching subscription and delivers a copy to each — SQS queues (in a single batch send) and HTTP/HTTPS webhooks. This is the classic "one event, many consumers" pattern: an order-placed event reaches the billing queue, the shipping queue, and an analytics webhook in a single publish.
## Overview [#overview]
`CreateQueue` each target queue, `CreateTopic`, then `Subscribe(Protocol=sqs, Endpoint=)` for each — an `sqs` subscription **auto-confirms** immediately. `Publish` then fans out to every matching subscription.
| Step | Action | Behavior |
| ---------------- | ------------------------------- | --------------------------------------------------------- |
| Targets | `CreateQueue` ×N | Each becomes channel `sqs.{name}` |
| Topic | `CreateTopic` | Virtual registry entry |
| Subscribe | `Subscribe(Protocol=sqs)` | Auto-confirmed; `http`/`https` need `ConfirmSubscription` |
| Publish | `Publish` / `PublishBatch` | One `MessageId`, shared across all deliveries |
| SQS delivery | Single `SendQueueMessagesBatch` | All `sqs` targets of that publish in one batch |
| Webhook delivery | In-memory delivery engine | Retry → circuit breaker → DLQ |
Fan-out semantics:
* **One `MessageId` per publish**, shared across all deliveries.
* **Zero matching subscriptions → the publish still succeeds** and the message is dropped (no error).
* **Per-target failures** (deleted queue, unauthorized, oversize, FIFO mismatch) are dropped with a metric and do **not** fail the publish; a per-target authorization check applies on each `sqs.{queue}`.
## How it works [#how-it-works]
A single publish resolves to the set of confirmed, filter-matching subscriptions; all SQS targets go out in one batch send and webhooks go through the delivery engine. Every delivery shares the same `MessageId`.
*One publish fans out to every confirmed subscription — SQS queues in a single batch send and HTTP/HTTPS webhooks through the delivery engine — all sharing one `MessageId`; a filtered subscription receives the copy only when its `FilterPolicy` matches.*
## Fan one publish to many queues [#fan-one-publish-to-many-queues]
Subscribe two SQS queues to a topic, publish once, and watch both queues receive the same `MessageId`. Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies dummy static credentials.
```go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sns"
"github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
url := os.Getenv("KUBEMQ_AWS_URL")
if url == "" {
url = "http://localhost:4566"
}
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
if err != nil {
log.Fatalf("load config: %v", err)
}
snsClient := sns.NewFromConfig(cfg, func(o *sns.Options) { o.BaseEndpoint = aws.String(url) })
sqsClient := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) })
topic, _ := snsClient.CreateTopic(ctx, &sns.CreateTopicInput{Name: aws.String("notify")})
topicArn := aws.ToString(topic.TopicArn)
// Two target queues, each subscribed (auto-confirmed).
urlA, arnA := makeQueue(ctx, sqsClient, "q-a")
urlB, arnB := makeQueue(ctx, sqsClient, "q-b")
subscribe(ctx, snsClient, topicArn, arnA)
subscribe(ctx, snsClient, topicArn, arnB)
// One Publish fans out to both queues with a shared MessageId.
pub, err := snsClient.Publish(ctx, &sns.PublishInput{
TopicArn: aws.String(topicArn),
Message: aws.String("hello fan-out"),
})
if err != nil {
log.Fatalf("Publish: %v", err)
}
msgID := aws.ToString(pub.MessageId)
fmt.Printf("Publish: MessageId=%s\n", msgID)
for name, qURL := range map[string]string{"q-a": urlA, "q-b": urlB} {
env := receiveEnvelope(ctx, sqsClient, qURL)
if env.MessageId != msgID {
log.Fatalf("FAIL: %s MessageId=%q != publish %q", name, env.MessageId, msgID)
}
fmt.Printf("%s received the publish (MessageId=%s)\n", name, env.MessageId)
}
}
func makeQueue(ctx context.Context, c *sqs.Client, name string) (url, arn string) {
created, _ := c.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String(name)})
url = aws.ToString(created.QueueUrl)
out, _ := c.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
QueueUrl: aws.String(url),
AttributeNames: []sqstypes.QueueAttributeName{sqstypes.QueueAttributeNameQueueArn},
})
return url, out.Attributes[string(sqstypes.QueueAttributeNameQueueArn)]
}
func subscribe(ctx context.Context, c *sns.Client, topicArn, queueArn string) {
if _, err := c.Subscribe(ctx, &sns.SubscribeInput{
TopicArn: aws.String(topicArn), Protocol: aws.String("sqs"),
Endpoint: aws.String(queueArn), ReturnSubscriptionArn: true,
}); err != nil {
log.Fatalf("Subscribe: %v", err)
}
}
type notification struct{ MessageId, Message string }
func receiveEnvelope(ctx context.Context, c *sqs.Client, queueURL string) notification {
recv, _ := c.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL), WaitTimeSeconds: 5, MaxNumberOfMessages: 1,
})
var env notification
_ = json.Unmarshal([]byte(aws.ToString(recv.Messages[0].Body)), &env)
return env
}
```
```python
import json
import os
import boto3
def make(service: str):
return boto3.client(
service,
endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
def make_queue(sqs, name: str):
url = sqs.create_queue(QueueName=name)["QueueUrl"]
arn = sqs.get_queue_attributes(QueueUrl=url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"]
return url, arn
def main() -> None:
sns = make("sns")
sqs = make("sqs")
topic_arn = sns.create_topic(Name="notify")["TopicArn"]
# Two target queues, each subscribed (auto-confirmed).
url_a, arn_a = make_queue(sqs, "q-a")
url_b, arn_b = make_queue(sqs, "q-b")
for arn in (arn_a, arn_b):
sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=arn, ReturnSubscriptionArn=True)
# One Publish fans out to both queues with a shared MessageId.
msg_id = sns.publish(TopicArn=topic_arn, Message="hello fan-out")["MessageId"]
print(f"Publish -> MessageId={msg_id}")
for name, url in (("q-a", url_a), ("q-b", url_b)):
recv = sqs.receive_message(QueueUrl=url, WaitTimeSeconds=5, MaxNumberOfMessages=1)
env = json.loads(recv["Messages"][0]["Body"])
assert env["MessageId"] == msg_id # same MessageId across deliveries
print(f"{name} received the publish (MessageId={env['MessageId']})")
if __name__ == "__main__":
main()
```
```java
import java.net.URI;
import java.util.List;
import java.util.Map;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sns.SnsClient;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.QueueAttributeName;
public final class Main {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566");
StaticCredentialsProvider creds = StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test"));
try (SnsClient sns = SnsClient.builder().endpointOverride(URI.create(url))
.region(Region.US_EAST_1).credentialsProvider(creds).build();
SqsClient sqs = SqsClient.builder().endpointOverride(URI.create(url))
.region(Region.US_EAST_1).credentialsProvider(creds).build()) {
String topicArn = sns.createTopic(b -> b.name("notify")).topicArn();
// Two target queues, each subscribed (auto-confirmed).
Map queues = Map.of("q-a", "", "q-b", "");
for (String name : List.of("q-a", "q-b")) {
String queueUrl = sqs.createQueue(b -> b.queueName(name)).queueUrl();
String queueArn = sqs.getQueueAttributes(b -> b
.queueUrl(queueUrl).attributeNames(QueueAttributeName.QUEUE_ARN))
.attributes().get(QueueAttributeName.QUEUE_ARN);
sns.subscribe(b -> b.topicArn(topicArn).protocol("sqs").endpoint(queueArn)
.returnSubscriptionArn(true));
queues = new java.util.HashMap<>(queues);
queues.put(name, queueUrl);
}
// One Publish fans out to both queues with a shared MessageId.
String messageId = sns.publish(b -> b.topicArn(topicArn).message("hello fan-out"))
.messageId();
System.out.println("Publish -> MessageId=" + messageId);
for (Map.Entry e : queues.entrySet()) {
var recv = sqs.receiveMessage(b -> b.queueUrl(e.getValue())
.waitTimeSeconds(5).maxNumberOfMessages(1));
System.out.println(e.getKey() + " received: " + recv.messages().get(0).body());
}
}
}
}
```
```typescript
import { SNSClient, CreateTopicCommand, SubscribeCommand, PublishCommand } from "@aws-sdk/client-sns";
import {
SQSClient,
CreateQueueCommand,
GetQueueAttributesCommand,
ReceiveMessageCommand,
} from "@aws-sdk/client-sqs";
const url = process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566";
const opts = { endpoint: url, region: "us-east-1", credentials: { accessKeyId: "test", secretAccessKey: "test" } };
const sns = new SNSClient(opts);
const sqs = new SQSClient(opts);
async function makeQueue(name: string): Promise<{ url: string; arn: string }> {
const url = (await sqs.send(new CreateQueueCommand({ QueueName: name }))).QueueUrl!;
const arn = (
await sqs.send(new GetQueueAttributesCommand({ QueueUrl: url, AttributeNames: ["QueueArn"] }))
).Attributes!["QueueArn"]!;
return { url, arn };
}
async function main(): Promise {
const topicArn = (await sns.send(new CreateTopicCommand({ Name: "notify" }))).TopicArn!;
// Two target queues, each subscribed (auto-confirmed).
const a = await makeQueue("q-a");
const b = await makeQueue("q-b");
for (const q of [a, b]) {
await sns.send(new SubscribeCommand({ TopicArn: topicArn, Protocol: "sqs", Endpoint: q.arn, ReturnSubscriptionArn: true }));
}
// One Publish fans out to both queues with a shared MessageId.
const msgId = (await sns.send(new PublishCommand({ TopicArn: topicArn, Message: "hello fan-out" }))).MessageId;
console.log(`Publish -> MessageId=${msgId}`);
for (const [name, q] of [["q-a", a], ["q-b", b]] as const) {
const recv = await sqs.send(new ReceiveMessageCommand({ QueueUrl: q.url, WaitTimeSeconds: 5, MaxNumberOfMessages: 1 }));
const env = JSON.parse(recv.Messages![0].Body!);
console.log(`${name} received the publish (MessageId=${env.MessageId})`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using System.Text.Json;
using Amazon.Runtime;
using Amazon.SimpleNotificationService;
using Amazon.SimpleNotificationService.Model;
using Amazon.SQS;
using Amazon.SQS.Model;
var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566";
var creds = new BasicAWSCredentials("test", "test");
using var sns = new AmazonSimpleNotificationServiceClient(creds,
new AmazonSimpleNotificationServiceConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" });
using var sqs = new AmazonSQSClient(creds,
new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" });
var topicArn = (await sns.CreateTopicAsync(new CreateTopicRequest { Name = "notify" })).TopicArn;
async Task<(string url, string arn)> MakeQueue(string name)
{
var queueUrl = (await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = name })).QueueUrl;
var arn = (await sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest
{
QueueUrl = queueUrl,
AttributeNames = ["QueueArn"],
})).QueueARN;
return (queueUrl, arn);
}
// Two target queues, each subscribed (auto-confirmed).
var a = await MakeQueue("q-a");
var b = await MakeQueue("q-b");
foreach (var q in new[] { a, b })
{
await sns.SubscribeAsync(new SubscribeRequest
{
TopicArn = topicArn, Protocol = "sqs", Endpoint = q.arn, ReturnSubscriptionArn = true,
});
}
// One Publish fans out to both queues with a shared MessageId.
var msgId = (await sns.PublishAsync(new PublishRequest { TopicArn = topicArn, Message = "hello fan-out" })).MessageId;
Console.WriteLine($"Publish -> MessageId={msgId}");
foreach (var (name, q) in new[] { ("q-a", a), ("q-b", b) })
{
var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = q.url, WaitTimeSeconds = 5, MaxNumberOfMessages = 1,
});
var env = JsonDocument.Parse(recv.Messages[0].Body).RootElement;
Console.WriteLine($"{name} received the publish (MessageId={env.GetProperty("MessageId")})");
}
```
```ruby
# frozen_string_literal: true
require "aws-sdk-sns"
require "aws-sdk-sqs"
require "json"
Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls)
url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566")
opts = { endpoint: url, region: "us-east-1", access_key_id: "test", secret_access_key: "test" }
sns = Aws::SNS::Client.new(opts)
sqs = Aws::SQS::Client.new(opts)
topic_arn = sns.create_topic(name: "notify").topic_arn
# Two target queues, each subscribed (auto-confirmed).
def make_queue(sqs, name)
url = sqs.create_queue(queue_name: name).queue_url
arn = sqs.get_queue_attributes(queue_url: url, attribute_names: ["QueueArn"]).attributes["QueueArn"]
[url, arn]
end
url_a, arn_a = make_queue(sqs, "q-a")
url_b, arn_b = make_queue(sqs, "q-b")
[arn_a, arn_b].each do |arn|
sns.subscribe(topic_arn: topic_arn, protocol: "sqs", endpoint: arn, return_subscription_arn: true)
end
# One Publish fans out to both queues with a shared MessageId.
msg_id = sns.publish(topic_arn: topic_arn, message: "hello fan-out").message_id
puts "Publish -> MessageId=#{msg_id}"
{ "q-a" => url_a, "q-b" => url_b }.each do |name, queue_url|
recv = sqs.receive_message(queue_url: queue_url, wait_time_seconds: 5, max_number_of_messages: 1)
env = JSON.parse(recv.messages.first.body)
puts "#{name} received the publish (MessageId=#{env['MessageId']})"
end
```
```rust
use aws_config::{BehaviorVersion, Region};
use aws_credential_types::Credentials;
use std::error::Error;
async fn config() -> aws_config::SdkConfig {
let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into());
aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(Credentials::new("test", "test", None, None, "static"))
.endpoint_url(url)
.load()
.await
}
async fn make_queue(sqs: &aws_sdk_sqs::Client, name: &str) -> (String, String) {
let url = sqs.create_queue().queue_name(name).send().await.unwrap().queue_url.unwrap();
let arn = sqs
.get_queue_attributes()
.queue_url(&url)
.attribute_names(aws_sdk_sqs::types::QueueAttributeName::QueueArn)
.send()
.await
.unwrap()
.attributes
.and_then(|a| a.get(&aws_sdk_sqs::types::QueueAttributeName::QueueArn).cloned())
.unwrap();
(url, arn)
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let conf = config().await;
let sns = aws_sdk_sns::Client::new(&conf);
let sqs = aws_sdk_sqs::Client::new(&conf);
let topic_arn = sns.create_topic().name("notify").send().await?.topic_arn.unwrap();
// Two target queues, each subscribed (auto-confirmed).
let (url_a, arn_a) = make_queue(&sqs, "q-a").await;
let (url_b, arn_b) = make_queue(&sqs, "q-b").await;
for arn in [arn_a, arn_b] {
sns.subscribe()
.topic_arn(&topic_arn)
.protocol("sqs")
.endpoint(arn)
.return_subscription_arn(true)
.send()
.await?;
}
// One Publish fans out to both queues with a shared MessageId.
let msg_id = sns.publish().topic_arn(&topic_arn).message("hello fan-out").send().await?
.message_id.unwrap_or_default();
println!("Publish -> MessageId={msg_id}");
for (name, url) in [("q-a", url_a), ("q-b", url_b)] {
let recv = sqs.receive_message().queue_url(&url).wait_time_seconds(5).max_number_of_messages(1)
.send().await?;
println!("{name} received: {}", recv.messages().first().and_then(|m| m.body()).unwrap_or_default());
}
Ok(())
}
```
## Filtered fan-out [#filtered-fan-out]
Attach a `FilterPolicy` (on the `MessageAttributes` scope) to a subscription so it receives only the publishes it cares about. A matching publish is delivered; a non-matching one is suppressed — and a publish that matches **no** subscription still succeeds.
```python
# Only deliver publishes whose "eventType" attribute is "order".
sns.subscribe(
TopicArn=topic_arn,
Protocol="sqs",
Endpoint=queue_arn,
Attributes={
"FilterPolicy": json.dumps({"eventType": ["order"]}),
"FilterPolicyScope": "MessageAttributes",
},
ReturnSubscriptionArn=True,
)
sns.publish(TopicArn=topic_arn, Message="an order event",
MessageAttributes={"eventType": {"DataType": "String", "StringValue": "order"}})
sns.publish(TopicArn=topic_arn, Message="a metric event",
MessageAttributes={"eventType": {"DataType": "String", "StringValue": "metric"}})
# Only "an order event" is delivered; the metric publish is suppressed.
```
**Filtering works on `MessageAttributes` scope only.** Setting `FilterPolicyScope = MessageBody` is rejected with `InvalidParameter`. Put the values you filter on into message attributes, not the body.
## Raw vs enveloped delivery [#raw-vs-enveloped-delivery]
`RawMessageDelivery` controls the delivered shape per subscription:
* **Enveloped (default):** the SQS message body is the SNS `Notification` JSON (`MessageId`, `TopicArn`, `Message`, `UnsubscribeURL`, `MessageAttributes`, `SignatureVersion: "1"`, and an empty `Signature`).
* **Raw:** for SQS, the bare body plus `sns_topic_arn` / `sns_subject` tags and the attribute codec.
```python
# One subscription raw, one enveloped, on the same topic.
sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=raw_arn,
Attributes={"RawMessageDelivery": "true"}, ReturnSubscriptionArn=True)
sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=env_arn,
ReturnSubscriptionArn=True) # default = enveloped
```
**Delivered SNS notifications are unsigned.** The `Notification` envelope carries `SignatureVersion: "1"` but its `Signature` and `SigningCertURL` are **empty** — a receiver cannot verify the message signature.
## HTTP/HTTPS webhooks [#httphttps-webhooks]
A topic can also fan out to `http` / `https` endpoints. Such a subscription goes **pending** until the subscriber calls `ConfirmSubscription` (the only SigV4-exempt action, so the embedded `SubscribeURL` works as an unsigned GET). Once confirmed, deliveries run through an in-memory engine: 8 workers, a bounded job queue, a default 51-attempt retry schedule (overridable by a stored `DeliveryPolicy`), a per-endpoint circuit breaker that opens after 5 consecutive failures (30 s), and a redrive to the subscription's `RedrivePolicy` DLQ on exhaustion. Raw HTTP delivery maps attributes to `x-amz-sns-attr-{name}` headers. See the [reliability guide](/connectors/aws/how-to/reliability) and the [SNS fan-out guide](/connectors/aws/how-to/sns-fan-out).
**SNS HTTP delivery state is in-memory on the publishing node.** A node restart loses pending retries, and the bounded job queue drops on overflow. This is part of the node-local / sticky-load-balancer family of caveats.
## Related [#related]
# Reliability (/connectors/aws/how-to/reliability)
This guide covers the reliability mechanisms across both surfaces: SQS visibility and in-flight
tracking, FIFO ordering and deduplication, SQS DLQ/redrive, the SNS HTTP delivery
retry → circuit-breaker → DLQ pipeline, and at-least-once delivery. Several behaviors have
node-local caveats — read the callouts.
## SQS visibility and in-flight [#sqs-visibility-and-in-flight]
A received message is hidden for a **visibility window** before it becomes receivable again.
Precedence: per-request `VisibilityTimeout` (0–43200) **>** the queue default **>** 30 s.
`ChangeMessageVisibility(timeout>0)` moves the deadline; `ChangeMessageVisibility(0)` NAcks the
message so it becomes visible again at the **tail**. A 250 ms sweeper NAcks expired in-flight
entries back to the tail, and `ApproximateReceiveCount` increments on each re-receive.
Received-but-not-deleted messages count against a per-queue / per-node **in-flight cap**,
`MaxInflightPerQueue` (default 20,000). Exceeding it returns `OverLimit`. Full SQS basics —
send/receive/delete, long polling, batch — are in
[SQS queues and consumers](/connectors/aws/how-to/sqs-queues-and-consumers).
## SQS FIFO ordering and deduplication [#sqs-fifo-ordering-and-deduplication]
A `.fifo` queue (`FifoQueue` is immutable post-create) gives per-group ordering and dedup:
* **`MessageGroupId` is required** on send (1–128 printable ASCII characters); per-message
`DelaySeconds` is **rejected**; `ReceiveRequestAttemptId` is accepted and ignored.
* Each group maps to its own channel `sqs.{name}.fifo.g.{enc(group)}`.
* **Deduplication** uses an explicit `MessageDeduplicationId`, or the SHA-256 of the body when
`ContentBasedDeduplication` is on. A **5-minute LRU** window is keyed `{queue}:{dedupId}` (or
`{queue}:{group}:{dedupId}` under `messageGroup` scope). A duplicate returns the **original**
`MessageId` / `SequenceNumber` **without re-publishing**.
* **Ordering** allows at most one un-acked downstream `Get` per group at a time (a per-group lock);
`ReceiveMessage` drains groups round-robin, ≤ 10 total.
**`SequenceNumber` differs between send and receive.** The 20-digit `SequenceNumber` is the broker
**send-timestamp (UnixNano)** on send and the **true broker sequence** on receive; it is still
strictly increasing per group for serialized sends. See
[Channel mapping](/connectors/aws/reference/channel-mapping).
## SQS DLQ / redrive [#sqs-dlq--redrive]
Set a queue `RedrivePolicy` of `{deadLetterTargetArn, maxReceiveCount}`. The connector stamps each
message with `MaxReceiveCount` / `MaxReceiveQueue=sqs.{dlq}` at send. The message broker moves a
message to the DLQ when its receive count **exceeds** `maxReceiveCount`, surfacing
`DeadLetterQueueSourceArn` on the redriven message. `ListDeadLetterSourceQueues` reverse-resolves
which queues redrive into a given DLQ.
## SNS delivery: retry → circuit-breaker → DLQ [#sns-delivery-retry--circuit-breaker--dlq]
HTTP / HTTPS subscriptions are delivered by an in-memory engine: **8 workers**, a bounded job queue
of **10,000** (overflow drops the message, increments a metric, and logs a `WARN`), and a 10 s
per-request timeout.
The **default retry schedule** (overridable by a stored `DeliveryPolicy` — subscription wins over
topic wins over default) is: 4 immediate attempts + 2 @ 10 s + 10 exponential (1 s → 60 s) + 35 @
60 s = **50 retries / 51 attempts ≈ 39 minutes**. Retries fire on 5xx / 429 / timeout / connection
errors; other 4xx responses are **terminal**.
The **circuit breaker** is per-endpoint: **5 consecutive failures → open for 30 s**.
**On retry exhaustion**, the body is **redriven** to the subscription's `RedrivePolicy` DLQ queue;
if there is none, it is dropped and a metric increments.
Outgoing delivery headers are `x-amz-sns-message-type`, `x-amz-sns-message-id`,
`x-amz-sns-topic-arn`, and `x-amz-sns-subscription-arn`; the Content-Type is
`text/plain; charset=UTF-8`.
**SNS HTTP delivery state is in-memory on the publishing node.** Pending retries live only on the
node that accepted the `Publish`; a node **restart loses** them, and the bounded job queue (10,000)
drops on overflow with a metric. This is part of the **node-local / sticky-LB** family — see the
cluster caveat below.
**SNS notifications are unsigned.** Delivered envelopes carry an empty `Signature` /
`SigningCertURL`, so a webhook **cannot** verify the signature. Do not build delivery
authentication on SNS message-signature verification. See
[SNS fan-out](/connectors/aws/how-to/sns-fan-out).
## At-least-once delivery [#at-least-once-delivery]
Unacked SQS deliveries are NAcked back to the queue **tail** by the 250 ms sweeper at visibility
expiry, or on graceful shutdown (≤ 10 s). The registry survives restart (a shared `StorePath`). So
no message is lost on a graceful path, but a message may be **redelivered**
(`ApproximateReceiveCount` increments) — make consumers **idempotent**. **Exactly-once is not
provided.**
## Node-local caveat (cluster) [#node-local-caveat-cluster]
**Node-local state needs a sticky load balancer.** SQS receipt handles and in-flight tracking, and
SNS HTTP delivery state, are all **node-local**. In a cluster, a consumer's receipt handle is only
valid on the node that issued it (`ReceiptHandleIsInvalid` elsewhere), and pending SNS retries are
lost if that node restarts. Use a **sticky load balancer** (session affinity). Single-node
deployments are unaffected. See
[Connectivity and security](/connectors/aws/how-to/connectivity-and-security) and
[Migration from AWS](/connectors/aws/reference/migration-from-aws).
## Error quick reference [#error-quick-reference]
| Trigger | Result |
| -------------------------------------------------- | --------------------------------------------------------------------- |
| FIFO duplicate within the 5-minute window | returns the **original** `MessageId`, not re-enqueued |
| Receive without delete, visibility expires | redelivery; `ApproximateReceiveCount` increments |
| Reach `maxReceiveCount` (`RedrivePolicy`) | moved to DLQ + `DeadLetterQueueSourceArn` |
| SNS HTTP endpoint 5xx / timeout / connection error | retried; breaker opens after 5 failures; redrive to DLQ on exhaustion |
| Graceful shutdown with in-flight messages | NAcked back to the queue tail; registry survives restart |
## Related [#related]
# SNS fan-out (/connectors/aws/how-to/sns-fan-out)
This guide covers the SNS surface: topic management, subscriptions and the confirmation flow,
message filtering, raw vs enveloped delivery, `PublishBatch`, FIFO topics, and
`MessageStructure=json`. SNS topics are **virtual** BoltDB registry entries (no native channel)
that fan out, at publish time, to SQS subscriptions and HTTP/HTTPS webhooks. The SNS surface ships
**17 actions** (see [Capabilities](/connectors/aws/reference/capabilities)).
## Topic management [#topic-management]
* `CreateTopic` — idempotent on an existing name; a **FIFO** topic uses the `.fifo` suffix.
* `DeleteTopic` — cascades its subscriptions.
* `ListTopics` — ARN-sorted, 100 per page; **not** authorization-filtered.
* `GetTopicAttributes` / `SetTopicAttributes` — the only writable attributes are `DisplayName` and
`DeliveryPolicy`.
**`Policy` is rejected.** Setting a topic `Policy` returns `InvalidParameter`. **Topic-level
`ContentBasedDeduplication` is not supported** — a set returns `InvalidParameter` and a get always
returns `"false"`. For FIFO topics, pass an explicit `MessageDeduplicationId` (see below).
## Subscriptions [#subscriptions]
`Subscribe` accepts three protocols only:
| Protocol | Behavior |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sqs` | The endpoint must be a **registry queue ARN**; the subscription is **auto-confirmed** immediately (`Confirmed=true`). |
| `http` / `https` | Goes **pending** with a 48 h confirmation token; a `SubscriptionConfirmation` envelope is POSTed best-effort; expired pending subscriptions are swept hourly. Confirm via `ConfirmSubscription`. |
| anything else | `email` / `email-json` / `sms` / `lambda` / `application` / `firehose` → `InvalidParameter` "protocol not supported". |
The writable **subscription attributes** are `RawMessageDelivery` (bool), `FilterPolicy` (JSON,
parsed and validated at set), `FilterPolicyScope`, `RedrivePolicy` (JSON), and `DeliveryPolicy`.
The `http`/`https` confirmation `GET` is **SigV4-exempt** so the `SubscribeURL` embedded in a
`SubscriptionConfirmation` envelope is usable as-is — see
[Authentication](/connectors/aws/how-to/authentication).
## Message filtering [#message-filtering]
A `FilterPolicy` filters which subscriptions receive a publish. All 8 AWS operators are supported:
exact string/number/bool, prefix, suffix, anything-but, numeric comparison/range, exists
true/false, and CIDR. Keys are ANDed, values within a key ORed; an empty policy matches
everything. Write-time limits: ≤ 5 keys, ≤ 150 value combinations. Binary attributes match only
`exists:true`.
**`MessageBody`-scope filtering is unsupported.** Only `FilterPolicyScope = MessageAttributes`
works. Setting `FilterPolicyScope = MessageBody` is **rejected** at the attribute setter with
`InvalidParameter` "MessageBody scope is not supported". Filter only on `MessageAttributes`. See
[Capabilities](/connectors/aws/reference/capabilities).
## Raw vs enveloped delivery [#raw-vs-enveloped-delivery]
`RawMessageDelivery` controls the delivered shape:
* **Enveloped** (`false`, the default) — the body is the SNS `Notification` JSON:
`{Type:"Notification", MessageId, TopicArn, Subject?, Message, Timestamp, SignatureVersion:"1",
Signature:"", SigningCertURL:"", UnsubscribeURL, MessageAttributes?}`. Each `MessageAttributes`
entry is `{Type, Value}` (Binary is base64).
* **Raw** (`true`) — for an SQS subscription, the bare message bytes plus the attribute tag codec
and `sns_topic_arn` / `sns_subject` tags; for an HTTP subscription, the bare payload with
attributes mapped to `x-amz-sns-attr-{name}` headers.
**SNS notifications are unsigned.** `Signature` and `SigningCertURL` are present in the envelope
but **empty** (`SignatureVersion` is `"1"`). No SDK-side signature verification can be performed.
Do not rely on verifying SNS message signatures. See
[Reliability](/connectors/aws/how-to/reliability).
**Raw-HTTP attribute deviation.** Real AWS drops message attributes for raw HTTP delivery; this
connector instead maps them to `x-amz-sns-attr-{name}` headers. See
[Channel mapping](/connectors/aws/reference/channel-mapping).
## Fan-out semantics [#fan-out-semantics]
`Publish` / `PublishBatch` fan out to every **confirmed**, filter-matching subscription:
* there is **one `MessageId` per publish**, shared across all deliveries;
* all `sqs` deliveries of one publish go out in a single `SendQueueMessagesBatch`;
* per-target failures (deleted queue, unauthorized, oversize, FIFO mismatch) are **dropped with a
metric and do not fail the publish**;
* **zero matching subscriptions → the publish succeeds** and the message is dropped;
* a per-target Casbin `write` check applies on each `sqs.{queue}`.
`MessageStructure=json` is supported: pass a JSON object with a string `default` key plus optional
per-protocol string overrides.
## FIFO topics [#fifo-topics]
A `.fifo` topic restricts `Subscribe` to the `sqs` protocol onto a `.fifo` queue (`http` / `https`
are rejected). `Publish` / `PublishBatch`:
* **require `MessageGroupId`** (and **reject** it on a standard topic);
* treat `MessageDeduplicationId` as optional — topic-level content-based dedup is unsupported, so
pass it explicitly.
Group and dedup ids propagate into the FIFO queue-message build. See
[Reliability](/connectors/aws/how-to/reliability) for the full FIFO ordering and dedup detail.
## Error quick reference [#error-quick-reference]
| Trigger | AWS error code |
| --------------------------------------------------------------------- | ---------------------------------- |
| `Subscribe` with `email` / `sms` / `lambda` / `firehose` / … protocol | `InvalidParameter` |
| `Subscribe` with `FilterPolicyScope=MessageBody` | `InvalidParameter` |
| `Publish` with `MessageGroupId` to a **standard** topic | `InvalidParameter` |
| FIFO topic `Subscribe` with `http` / a non-FIFO queue | `InvalidParameter` |
| Set a topic `Policy` / topic-level `ContentBasedDeduplication` | `InvalidParameter` |
| `Publish` with `TargetArn` / `PhoneNumber` | `InvalidParameter` |
| Topic / subscription not in the registry | `NotFound` (404) |
| `Publish` to a topic with zero matching subscriptions | *none — succeeds, message dropped* |
## Related [#related]
# SNS Topics (/connectors/aws/how-to/sns-topics)
An **SNS topic** is publish/subscribe: a publisher sends one message to a topic, and the topic fans it out to every confirmed subscription. In the AWS connector, topics are **virtual** — they have no backing channel of their own. Each topic is a registry entry (the registry is replicated across cluster nodes), and its authorization pseudo-resource is `sns.{topic}`. Fan-out resolves to target SQS channels (a single batch send) and HTTP/HTTPS webhooks **at publish time**. Topics are routing metadata, not data stores.
## Overview [#overview]
`CreateTopic` registers the topic; `Subscribe` attaches an endpoint; `Publish` / `PublishBatch` fan out to every confirmed, filter-matching subscription. The connector implements 17 SNS actions.
| SNS operation | Behavior | Notes |
| -------------------------------- | ---------------------------------------------- | ---------------------------------------- |
| `CreateTopic("hooks")` | Registers a virtual topic | Idempotent; `.fifo` suffix makes it FIFO |
| `Subscribe(Protocol=sqs)` | Attaches a registry queue, **auto-confirmed** | Endpoint is the queue ARN |
| `Subscribe(Protocol=http/https)` | Goes **pending**; 48 h confirmation token | Confirm via `ConfirmSubscription` |
| `Subscribe(other protocols)` | Rejected with `InvalidParameter` | No `email` / `sms` / `lambda` |
| `Publish` | Fan out to all matching subscriptions | One `MessageId` per publish, shared |
| `PublishBatch` | Batched publish (≤ 10 entries) | Per-entry success/failure |
| `ConfirmSubscription` | Activates a pending HTTP/HTTPS subscription | The only SigV4-exempt action |
| `SetSubscriptionAttributes` | Set `FilterPolicy`, `RawMessageDelivery`, etc. | `MessageAttributes` filter scope only |
## How it works [#how-it-works]
A publish resolves, at publish time, to every confirmed subscription. SQS targets are delivered in one batch send; HTTP/HTTPS targets go through an in-memory delivery engine. A single `MessageId` is shared across all deliveries.
*A topic is a virtual registry entry; at publish time the connector resolves it to every confirmed subscription — SQS queues in one batch send, HTTP/HTTPS webhooks through the delivery engine — sharing one `MessageId`.*
## Create, subscribe, and publish [#create-subscribe-and-publish]
Create a topic, subscribe an SQS queue (auto-confirmed), then publish. Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies dummy static credentials.
```go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sns"
"github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
)
func loadCfg(ctx context.Context) aws.Config {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
if err != nil {
log.Fatalf("load config: %v", err)
}
return cfg
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
url := os.Getenv("KUBEMQ_AWS_URL")
if url == "" {
url = "http://localhost:4566"
}
cfg := loadCfg(ctx)
snsClient := sns.NewFromConfig(cfg, func(o *sns.Options) { o.BaseEndpoint = aws.String(url) })
sqsClient := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) })
// 1. CreateTopic (virtual; idempotent).
topic, err := snsClient.CreateTopic(ctx, &sns.CreateTopicInput{Name: aws.String("notify")})
if err != nil {
log.Fatalf("CreateTopic: %v", err)
}
topicArn := aws.ToString(topic.TopicArn)
fmt.Printf("CreateTopic: %s\n", topicArn)
// 2. Subscribe an SQS queue — auto-confirmed immediately.
q, err := sqsClient.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("notify-q")})
if err != nil {
log.Fatalf("CreateQueue: %v", err)
}
queueURL := aws.ToString(q.QueueUrl)
attrs, err := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
QueueUrl: aws.String(queueURL),
AttributeNames: []sqstypes.QueueAttributeName{sqstypes.QueueAttributeNameQueueArn},
})
if err != nil {
log.Fatalf("GetQueueAttributes: %v", err)
}
if _, err := snsClient.Subscribe(ctx, &sns.SubscribeInput{
TopicArn: aws.String(topicArn),
Protocol: aws.String("sqs"),
Endpoint: aws.String(attrs.Attributes[string(sqstypes.QueueAttributeNameQueueArn)]),
ReturnSubscriptionArn: true,
}); err != nil {
log.Fatalf("Subscribe: %v", err)
}
fmt.Println("Subscribe(sqs): auto-confirmed")
// 3. Publish — fans out to the subscribed queue.
pub, err := snsClient.Publish(ctx, &sns.PublishInput{
TopicArn: aws.String(topicArn),
Message: aws.String("hello fan-out"),
})
if err != nil {
log.Fatalf("Publish: %v", err)
}
fmt.Printf("Publish: MessageId=%s\n", aws.ToString(pub.MessageId))
// The queue receives the SNS Notification envelope (default delivery).
recv, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
WaitTimeSeconds: 5,
})
if err != nil {
log.Fatalf("ReceiveMessage: %v", err)
}
var env struct{ Type, Message, MessageId string }
_ = json.Unmarshal([]byte(aws.ToString(recv.Messages[0].Body)), &env)
fmt.Printf("Received: Type=%s Message=%q\n", env.Type, env.Message)
}
```
```python
import json
import os
import boto3
def make(service: str):
return boto3.client(
service,
endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
def main() -> None:
sns = make("sns")
sqs = make("sqs")
# 1. CreateTopic (virtual; idempotent).
topic_arn = sns.create_topic(Name="notify")["TopicArn"]
print(f"CreateTopic -> {topic_arn}")
# 2. Subscribe an SQS queue — auto-confirmed immediately.
queue_url = sqs.create_queue(QueueName="notify-q")["QueueUrl"]
queue_arn = sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])[
"Attributes"
]["QueueArn"]
sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn, ReturnSubscriptionArn=True)
print("Subscribe(sqs) -> auto-confirmed")
# 3. Publish — fans out to the subscribed queue (one MessageId).
pub = sns.publish(TopicArn=topic_arn, Message="hello fan-out")
print(f"Publish -> MessageId={pub['MessageId']}")
# The queue receives the SNS Notification envelope (default delivery).
recv = sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5)
env = json.loads(recv["Messages"][0]["Body"])
print(f"Received -> Type={env['Type']} Message={env['Message']!r}")
if __name__ == "__main__":
main()
```
```java
import java.net.URI;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sns.SnsClient;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.QueueAttributeName;
public final class Main {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566");
StaticCredentialsProvider creds = StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test"));
try (SnsClient sns = SnsClient.builder()
.endpointOverride(URI.create(url)).region(Region.US_EAST_1)
.credentialsProvider(creds).build();
SqsClient sqs = SqsClient.builder()
.endpointOverride(URI.create(url)).region(Region.US_EAST_1)
.credentialsProvider(creds).build()) {
// 1. CreateTopic (virtual; idempotent).
String topicArn = sns.createTopic(b -> b.name("notify")).topicArn();
System.out.println("CreateTopic -> " + topicArn);
// 2. Subscribe an SQS queue — auto-confirmed immediately.
String queueUrl = sqs.createQueue(b -> b.queueName("notify-q")).queueUrl();
String queueArn = sqs.getQueueAttributes(b -> b
.queueUrl(queueUrl)
.attributeNames(QueueAttributeName.QUEUE_ARN))
.attributes().get(QueueAttributeName.QUEUE_ARN);
sns.subscribe(b -> b
.topicArn(topicArn).protocol("sqs").endpoint(queueArn)
.returnSubscriptionArn(true));
System.out.println("Subscribe(sqs) -> auto-confirmed");
// 3. Publish — fans out to the subscribed queue (one MessageId).
String messageId = sns.publish(b -> b.topicArn(topicArn).message("hello fan-out"))
.messageId();
System.out.println("Publish -> MessageId=" + messageId);
// The queue receives the SNS Notification envelope (default delivery).
var recv = sqs.receiveMessage(b -> b.queueUrl(queueUrl).waitTimeSeconds(5));
System.out.println("Received -> " + recv.messages().get(0).body());
}
}
}
```
```typescript
import { SNSClient, CreateTopicCommand, SubscribeCommand, PublishCommand } from "@aws-sdk/client-sns";
import {
SQSClient,
CreateQueueCommand,
GetQueueAttributesCommand,
ReceiveMessageCommand,
} from "@aws-sdk/client-sqs";
const url = process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566";
const opts = {
endpoint: url,
region: "us-east-1",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
};
async function main(): Promise {
const sns = new SNSClient(opts);
const sqs = new SQSClient(opts);
// 1. CreateTopic (virtual; idempotent).
const topic = await sns.send(new CreateTopicCommand({ Name: "notify" }));
const topicArn = topic.TopicArn!;
console.log(`CreateTopic -> ${topicArn}`);
// 2. Subscribe an SQS queue — auto-confirmed immediately.
const queueUrl = (await sqs.send(new CreateQueueCommand({ QueueName: "notify-q" }))).QueueUrl!;
const queueArn = (
await sqs.send(new GetQueueAttributesCommand({ QueueUrl: queueUrl, AttributeNames: ["QueueArn"] }))
).Attributes!["QueueArn"]!;
await sns.send(
new SubscribeCommand({ TopicArn: topicArn, Protocol: "sqs", Endpoint: queueArn, ReturnSubscriptionArn: true }),
);
console.log("Subscribe(sqs) -> auto-confirmed");
// 3. Publish — fans out to the subscribed queue (one MessageId).
const pub = await sns.send(new PublishCommand({ TopicArn: topicArn, Message: "hello fan-out" }));
console.log(`Publish -> MessageId=${pub.MessageId}`);
// The queue receives the SNS Notification envelope (default delivery).
const recv = await sqs.send(new ReceiveMessageCommand({ QueueUrl: queueUrl, WaitTimeSeconds: 5 }));
const env = JSON.parse(recv.Messages![0].Body!);
console.log(`Received -> Type=${env.Type} Message="${env.Message}"`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using System.Text.Json;
using Amazon.Runtime;
using Amazon.SimpleNotificationService;
using Amazon.SimpleNotificationService.Model;
using Amazon.SQS;
using Amazon.SQS.Model;
var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566";
var creds = new BasicAWSCredentials("test", "test");
using var sns = new AmazonSimpleNotificationServiceClient(creds,
new AmazonSimpleNotificationServiceConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" });
using var sqs = new AmazonSQSClient(creds,
new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" });
// 1. CreateTopic (virtual; idempotent).
var topic = await sns.CreateTopicAsync(new CreateTopicRequest { Name = "notify" });
Console.WriteLine($"CreateTopic -> {topic.TopicArn}");
// 2. Subscribe an SQS queue — auto-confirmed immediately.
var queueUrl = (await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "notify-q" })).QueueUrl;
var queueArn = (await sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest
{
QueueUrl = queueUrl,
AttributeNames = ["QueueArn"],
})).QueueARN;
await sns.SubscribeAsync(new SubscribeRequest
{
TopicArn = topic.TopicArn,
Protocol = "sqs",
Endpoint = queueArn,
ReturnSubscriptionArn = true,
});
Console.WriteLine("Subscribe(sqs) -> auto-confirmed");
// 3. Publish — fans out to the subscribed queue (one MessageId).
var pub = await sns.PublishAsync(new PublishRequest { TopicArn = topic.TopicArn, Message = "hello fan-out" });
Console.WriteLine($"Publish -> MessageId={pub.MessageId}");
// The queue receives the SNS Notification envelope (default delivery).
var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = queueUrl, WaitTimeSeconds = 5 });
var env = JsonDocument.Parse(recv.Messages[0].Body).RootElement;
Console.WriteLine($"Received -> Type={env.GetProperty("Type")} Message={env.GetProperty("Message")}");
```
```ruby
# frozen_string_literal: true
require "aws-sdk-sns"
require "aws-sdk-sqs"
require "json"
# Remove the SQS-only path-rewriting plugin so requests reach the connector at "/".
Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls)
url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566")
opts = { endpoint: url, region: "us-east-1", access_key_id: "test", secret_access_key: "test" }
sns = Aws::SNS::Client.new(opts)
sqs = Aws::SQS::Client.new(opts)
# 1. CreateTopic (virtual; idempotent).
topic_arn = sns.create_topic(name: "notify").topic_arn
puts "CreateTopic -> #{topic_arn}"
# 2. Subscribe an SQS queue — auto-confirmed immediately.
queue_url = sqs.create_queue(queue_name: "notify-q").queue_url
queue_arn = sqs.get_queue_attributes(queue_url: queue_url, attribute_names: ["QueueArn"])
.attributes["QueueArn"]
sns.subscribe(topic_arn: topic_arn, protocol: "sqs", endpoint: queue_arn, return_subscription_arn: true)
puts "Subscribe(sqs) -> auto-confirmed"
# 3. Publish — fans out to the subscribed queue (one MessageId).
pub = sns.publish(topic_arn: topic_arn, message: "hello fan-out")
puts "Publish -> MessageId=#{pub.message_id}"
# The queue receives the SNS Notification envelope (default delivery).
recv = sqs.receive_message(queue_url: queue_url, wait_time_seconds: 5)
env = JSON.parse(recv.messages.first.body)
puts "Received -> Type=#{env['Type']} Message=#{env['Message'].inspect}"
```
```rust
use aws_config::{BehaviorVersion, Region};
use aws_credential_types::Credentials;
use std::error::Error;
async fn config() -> aws_config::SdkConfig {
let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into());
aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(Credentials::new("test", "test", None, None, "static"))
.endpoint_url(url)
.load()
.await
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let conf = config().await;
let sns = aws_sdk_sns::Client::new(&conf);
let sqs = aws_sdk_sqs::Client::new(&conf);
// 1. CreateTopic (virtual; idempotent).
let topic_arn = sns.create_topic().name("notify").send().await?.topic_arn.unwrap();
println!("CreateTopic -> {topic_arn}");
// 2. Subscribe an SQS queue — auto-confirmed immediately.
let queue_url = sqs.create_queue().queue_name("notify-q").send().await?.queue_url.unwrap();
let queue_arn = sqs
.get_queue_attributes()
.queue_url(&queue_url)
.attribute_names(aws_sdk_sqs::types::QueueAttributeName::QueueArn)
.send()
.await?
.attributes
.and_then(|a| a.get(&aws_sdk_sqs::types::QueueAttributeName::QueueArn).cloned())
.ok_or("no queue ARN")?;
sns.subscribe()
.topic_arn(&topic_arn)
.protocol("sqs")
.endpoint(queue_arn)
.return_subscription_arn(true)
.send()
.await?;
println!("Subscribe(sqs) -> auto-confirmed");
// 3. Publish — fans out to the subscribed queue (one MessageId).
let pub_out = sns.publish().topic_arn(&topic_arn).message("hello fan-out").send().await?;
println!("Publish -> MessageId={}", pub_out.message_id().unwrap_or(""));
// The queue receives the SNS Notification envelope (default delivery).
let recv = sqs.receive_message().queue_url(&queue_url).wait_time_seconds(5).send().await?;
println!("Received -> {}", recv.messages().first().and_then(|m| m.body()).unwrap_or_default());
Ok(())
}
```
## Subscription confirmation (HTTP/HTTPS) [#subscription-confirmation-httphttps]
An `sqs` subscription auto-confirms immediately. An `http`/`https` subscription goes **pending** with a 48-hour token: the connector POSTs a `SubscriptionConfirmation` envelope (carrying a `SubscribeURL` and `Token`) to the endpoint, and the subscriber activates it by calling `ConfirmSubscription` — the **only SigV4-exempt action**, so the embedded `SubscribeURL` works as an unsigned GET. Expired pending subscriptions are swept hourly. See [fan-out](/connectors/aws/how-to/fan-out) for the webhook delivery walkthrough.
## Filtering (MessageAttributes scope only) [#filtering-messageattributes-scope-only]
A subscription `FilterPolicy` decides which publishes it receives, using all eight AWS operators (exact, prefix, suffix, anything-but, numeric comparison/range, exists, CIDR). Keys are ANDed and values ORed; up to 5 keys / 150 combinations.
```python
# Deliver only publishes whose "eventType" message attribute is "order".
sub = sns.subscribe(
TopicArn=topic_arn,
Protocol="sqs",
Endpoint=queue_arn,
Attributes={
"FilterPolicy": json.dumps({"eventType": ["order"]}),
"FilterPolicyScope": "MessageAttributes",
},
ReturnSubscriptionArn=True,
)
# A publish with eventType=order is delivered; eventType=metric is suppressed.
```
**Only `MessageAttributes`-scope filtering is supported.** Setting `FilterPolicyScope = MessageBody` is rejected with `InvalidParameter` — you can only filter on a publish's `MessageAttributes`, never on its body.
## PublishBatch [#publishbatch]
`PublishBatch` sends up to 10 entries in one call and returns per-entry success/failure, mirroring `SendMessageBatch` semantics for SQS.
```python
batch = sns.publish_batch(
TopicArn=topic_arn,
PublishBatchRequestEntries=[
{"Id": "1", "Message": "event-1"},
{"Id": "2", "Message": "event-2"},
],
)
print(f"PublishBatch -> {len(batch.get('Successful', []))} Successful, "
f"{len(batch.get('Failed', []))} Failed")
```
## FIFO topics [#fifo-topics]
A `.fifo` topic restricts subscriptions to the `sqs` protocol onto `.fifo` queues, requires `MessageGroupId` on every publish, and propagates the group id and a 20-digit `SequenceNumber` into the FIFO queue message. Topic-level `ContentBasedDeduplication` is unsupported — pass an explicit `MessageDeduplicationId`. A standard topic **rejects** `MessageGroupId`, and a FIFO topic **rejects** `http`/`https` subscriptions.
```python
# A FIFO topic fanning out to a FIFO queue; group order is preserved.
topic_arn = sns.create_topic(Name="orders.fifo", Attributes={"FifoTopic": "true"})["TopicArn"]
queue_url = sqs.create_queue(
QueueName="orders-q.fifo",
Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "false"},
)["QueueUrl"]
queue_arn = sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"]
sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn, ReturnSubscriptionArn=True)
for i in range(1, 4):
sns.publish(
TopicArn=topic_arn,
Message=f"order-{i}",
MessageGroupId="tenant-1",
MessageDeduplicationId=f"dedup-{i}",
)
# orders-q.fifo receives order-1, order-2, order-3 in group order, each carrying
# MessageGroupId and a 20-digit SequenceNumber.
```
## Delivery, retry, and unsigned notifications [#delivery-retry-and-unsigned-notifications]
HTTP/HTTPS deliveries run through an in-memory engine (8 workers, a bounded job queue, a default 51-attempt retry schedule overridable by a stored `DeliveryPolicy`, a per-endpoint circuit breaker that opens after 5 consecutive failures, and a redrive to the subscription's `RedrivePolicy` DLQ on exhaustion). See the [reliability guide](/connectors/aws/how-to/reliability).
**SNS HTTP delivery state is in-memory on the publishing node.** A node restart loses pending retries, and the bounded job queue drops on overflow (recorded as a metric). This is part of the node-local / sticky-load-balancer family of caveats.
**Delivered SNS notifications are unsigned.** The `Notification` envelope's `Signature` and `SigningCertURL` fields are present but **empty**, so a webhook cannot verify the message signature. Do not rely on SNS signature verification on the receiving side.
## What SNS topics do not have [#what-sns-topics-do-not-have]
The connector implements 17 SNS actions but is deliberately scoped:
* **No RPC.** SNS is publish/subscribe, not request/reply.
* **No `email` / `sms` / `lambda` subscription protocols** — only `sqs`, `http`, and `https`; other protocols are rejected with `InvalidParameter`.
* **No KMS / server-side encryption (SSE).**
* **No `MessageBody`-scope filtering** — only `MessageAttributes` scope.
* **Topic-level `ContentBasedDeduplication` is unsupported** on FIFO topics — pass an explicit `MessageDeduplicationId`.
* **Body size limit ≤ 256 KiB**; **region not enforced**; **single AccountId**.
## Related [#related]
# SQS queues and consumers (/connectors/aws/how-to/sqs-queues-and-consumers)
This guide covers the SQS surface end to end: queue lifecycle, send/receive/delete, visibility
timeouts, the in-flight cap, receipt handles, long polling, batch operations, message attributes,
FIFO queues, and DLQ/redrive. Every SQS queue is a native KubeMQ **Queue** channel `sqs.{name}`
(see [Channel mapping](/connectors/aws/reference/channel-mapping)). The SQS surface ships
**18 actions** (see [Capabilities](/connectors/aws/reference/capabilities)).
## Queue lifecycle [#queue-lifecycle]
* `CreateQueue` — idempotent on an existing name with the same attributes; a **FIFO** queue uses
the `.fifo` name suffix. Re-creating the same name with **different** attributes returns
`QueueNameExists`.
* `GetQueueUrl` — returns the path-style URL `{scheme}://{host}/{AccountId}/{name}`.
* `ListQueues` — pagination plus `QueueNamePrefix`; results are **not** authorization-filtered.
* `DeleteQueue` — removes the registry record, best-effort broker purge, drops node-local
in-flight.
* `PurgeQueue` — an `AckAllQueueMessages`; a **60 s cooldown** applies (a second purge within the
window returns `PurgeQueueInProgress`).
* `GetQueueAttributes` / `SetQueueAttributes` — see below.
* `TagQueue` / `UntagQueue` / `ListQueueTags` — ≤ 50 tags.
* `ListDeadLetterSourceQueues` — reverse-resolves queues whose `RedrivePolicy` names this queue.
**The registry is authoritative.** Only queues created via the AWS API are visible; operating on a
native `sqs.foo` channel that was never `CreateQueue`d returns `NonExistentQueue`.
## Queue attributes [#queue-attributes]
**Writable** (`SetQueueAttributes` is a partial-update overlay; it is **not** retroactive to
messages already in the queue):
| Attribute | Range |
| ---------------------------------- | ---------------------------------------------------------------------- |
| `DelaySeconds` | 0–900 |
| `MaximumMessageSize` | 1024–262144 |
| `MessageRetentionPeriod` | 60–1209600 |
| `VisibilityTimeout` | 0–43200 |
| `ReceiveMessageWaitTimeSeconds` | 0–20 |
| `RedrivePolicy` | raw JSON `{deadLetterTargetArn, maxReceiveCount}` (registry-validated) |
| `FifoQueue` (create-only) | must match the `.fifo` suffix |
| `ContentBasedDeduplication` (FIFO) | bool |
| `DeduplicationScope` (FIFO) | `queue` \| `messageGroup` |
An unknown attribute name, or a KMS/SSE attribute (`KmsMasterKeyId`, `Policy`, …), on a `Set`
returns `InvalidAttributeName`.
**Read-only on `Get`:** `QueueArn`, `ApproximateNumberOfMessages` (broker stats, 2 s TTL),
`ApproximateNumberOfMessagesNotVisible` (node-local in-flight), `ApproximateNumberOfMessagesDelayed`
(**always `"0"`**), `CreatedTimestamp`, `LastModifiedTimestamp`; FIFO adds `FifoQueue` /
`ContentBasedDeduplication` / `DeduplicationScope`.
**`ApproximateNumberOfMessagesDelayed` is always `"0"`.** The connector does not track
delayed-message counts. See [Capabilities](/connectors/aws/reference/capabilities).
## Send / receive / delete [#send--receive--delete]
The round-trip is three actions:
1. `SendMessage` — returns `MessageId`, `MD5OfBody`, and (if attributes are present)
`MD5OfMessageAttributes`.
2. `ReceiveMessage` — returns the message body plus a **receipt handle**.
3. `DeleteMessage(receiptHandle)` — acks the message off the queue (`AckRange`). It is
**idempotent**: an unknown receipt handle returns success.
### Visibility timeout [#visibility-timeout]
A received message is hidden for a visibility window before it becomes receivable again.
Precedence: per-request `VisibilityTimeout` (0–43200) **>** the queue default **>** 30 s.
`ChangeMessageVisibility(timeout>0)` moves the deadline; `ChangeMessageVisibility(0)` NAcks the
message so it becomes visible again at the **tail**. A 250 ms sweeper NAcks expired in-flight
entries back to the tail, and `ApproximateReceiveCount` increments on re-receive.
### Receipt handles are node-local [#receipt-handles-are-node-local]
A receipt handle is an opaque `base64url(json)` token carrying
`{version, node, txnId, queue, seq, receivedMs, salt}`. Decoding validates `version==1` **and** the
**node** match.
**Receipt handles are node-local → a sticky LB is required in clusters.** A receipt handle minted
on one node is rejected on another (`ReceiptHandleIsInvalid`). In-flight tracking and SNS delivery
state are node-local too, so cluster deployments need a **sticky load balancer** (session
affinity). Single-node deployments are unaffected. See
[Connectivity and security](/connectors/aws/how-to/connectivity-and-security) and
[Migration from AWS](/connectors/aws/reference/migration-from-aws).
### In-flight cap [#in-flight-cap]
Received-but-not-deleted messages count against a per-queue / per-node cap, `MaxInflightPerQueue`
(default 20,000). Exceeding it returns `OverLimit`.
## Long polling [#long-polling]
* `WaitTimeSeconds` is 0–20; `MaxNumberOfMessages` is 1–10.
* A parked long-poll slot pool (`MaxConcurrentPolls`, default 1024); when it is exhausted a request
**degrades to a short poll** (it does not error).
* Wire waits are chunked into ≤ 2 s sub-Gets for shutdown responsiveness.
**An empty-queue short poll has a \~1 s latency floor.** Because the broker wait granularity is
integer seconds (1 s minimum), a `ReceiveMessage` on an **empty** queue returns within \~1 s, not
instantly. Queues with available messages respond at once.
## Batch operations [#batch-operations]
`SendMessageBatch` / `DeleteMessageBatch` / `ChangeMessageVisibilityBatch` take ≤ 10 entries.
`SendMessageBatch` returns per-entry `Successful[]` / `Failed[]`:
* a single oversize entry → that entry is `Failed` with `InvalidParameterValue` (SenderFault), the
rest `Successful`;
* an **aggregate** body+attributes over 262,144 bytes → the **whole batch** is rejected with
`BatchRequestTooLong`.
## Message attributes [#message-attributes]
* Up to 10 message attributes; the name is ≤ 256 chars with no `AWS.` / `Amazon.` prefix; the
`DataType` is `String` / `Number` / `Binary` (plus custom subtypes like `String.x`). They
round-trip losslessly through the tag codec `sqs_attr_{Name} = {DataType}|{value}` (Binary is
base64).
* **Message system attributes:** only `AWSTraceHeader` (DataType `String`) is accepted; anything
else returns `InvalidParameterValue`. It is stored as `sqs_trace_header`.
See [Channel mapping](/connectors/aws/reference/channel-mapping) for the full mapping and the
MD5 algorithms.
## FIFO queues [#fifo-queues]
A `.fifo` suffix makes a queue FIFO (`FifoQueue` is immutable post-create):
* **`MessageGroupId` is required** on a FIFO send (1–128 printable ASCII characters) — omitting it
returns `InvalidParameterValue`;
* per-message **`DelaySeconds` is rejected** → `InvalidParameterValue`;
* `ReceiveRequestAttemptId` is accepted and ignored;
* each group maps to its own channel `sqs.{name}.fifo.g.{enc(group)}`;
* **deduplication** uses an explicit `MessageDeduplicationId`, or the SHA-256 of the body when
`ContentBasedDeduplication` is on; a duplicate within the **5-minute** LRU window returns the
**original** `MessageId` / `SequenceNumber` **without re-publishing**;
* **per-group ordering** allows at most one un-acked downstream `Get` per group; `ReceiveMessage`
drains groups round-robin, ≤ 10 total.
**`SequenceNumber` differs between send and receive.** The 20-digit zero-padded `SequenceNumber`
is the broker **send-timestamp (UnixNano)** on send and the **true broker sequence** on receive. It
is still strictly increasing per group for serialized sends. See
[Channel mapping](/connectors/aws/reference/channel-mapping).
Full FIFO reliability detail is in [Reliability](/connectors/aws/how-to/reliability).
## DLQ / redrive [#dlq--redrive]
Set a queue `RedrivePolicy` of `{deadLetterTargetArn, maxReceiveCount}`. The connector stamps each
message with `MaxReceiveCount` / `MaxReceiveQueue=sqs.{dlq}` at send; the message broker moves the
message to the DLQ when the receive count **exceeds** `maxReceiveCount`, surfacing
`DeadLetterQueueSourceArn` on the redriven message. `ListDeadLetterSourceQueues` reverse-resolves.
See [Reliability](/connectors/aws/how-to/reliability).
## At-least-once and graceful shutdown [#at-least-once-and-graceful-shutdown]
Unacked deliveries are NAcked back to the queue tail by the 250 ms sweeper or on graceful shutdown
(≤ 10 s); the registry survives restart (a shared `StorePath`). **Exactly-once is not provided** —
design consumers to be idempotent.
## Error quick reference [#error-quick-reference]
| Trigger | AWS error code |
| ---------------------------------------------------------------------------- | ------------------------ |
| Operate on a queue not in the registry | `NonExistentQueue` |
| `CreateQueue` same name + different attributes | `QueueNameExists` |
| `PurgeQueue` within the 60 s cooldown | `PurgeQueueInProgress` |
| Unknown / KMS attribute on `SetQueueAttributes` | `InvalidAttributeName` |
| Oversize batch entry / bad attribute / non-`AWSTraceHeader` system attribute | `InvalidParameterValue` |
| `SendMessageBatch` aggregate over 262,144 B | `BatchRequestTooLong` |
| Receipt handle from another node / malformed | `ReceiptHandleIsInvalid` |
| In-flight over `MaxInflightPerQueue` | `OverLimit` |
| FIFO send without `MessageGroupId` / with per-message `DelaySeconds` | `InvalidParameterValue` |
## Related [#related]
# SQS Queues (/connectors/aws/how-to/sqs-queues)
An **SQS queue** is point-to-point messaging: a producer sends messages, one or more consumers receive them, and each received message is **hidden** (its visibility window) until the consumer deletes it or the timeout expires and it is redelivered. The AWS connector maps this directly onto the KubeMQ **Queues** primitive — SQS queue `orders` becomes KubeMQ channel `sqs.orders`. Your AWS SDK code does not change; you only override the endpoint to point at the connector.
## Overview [#overview]
`CreateQueue` registers the queue in the connector's registry and binds it to a KubeMQ Queue channel. `SendMessage` writes to that channel; `ReceiveMessage` returns the message plus an opaque **receipt handle** and hides it for the visibility window; `DeleteMessage(receiptHandle)` acks it off the queue. If a consumer never deletes, a sweeper NAcks the message back to the **tail** at visibility expiry and `ApproximateReceiveCount` increments.
| SQS operation | KubeMQ mapping | Notes |
| ----------------------------- | ------------------------------------- | ---------------------------------------------------------- |
| `CreateQueue("orders")` | Register channel `sqs.orders` | `.fifo` suffix makes it FIFO |
| `SendMessage` | `SendQueueMessage` | Returns `MessageId`, `MD5OfBody`, `MD5OfMessageAttributes` |
| `SendMessageBatch` | Batched send (≤ 10 entries) | Per-entry success/failure |
| `ReceiveMessage` | Credit-driven `Get` long-poll | Receipt handle; hidden for the visibility window |
| `DeleteMessage` | `AckRange` — message removed | By receipt handle |
| `DeleteMessageBatch` | Batched ack (≤ 10 entries) | Per-entry success/failure |
| `ChangeMessageVisibility` | Extend / shorten the hidden window | Per-message override |
| visibility expiry (no delete) | `NAckRange` — redelivered to the tail | `ApproximateReceiveCount` increments |
Long polling (`WaitTimeSeconds` up to 20) waits for messages; `MaxNumberOfMessages` pulls up to 10 at once. Message attributes (`String` / `Number` / `Binary`, ≤ 10) round-trip losslessly, and the only accepted system attribute is `AWSTraceHeader`.
## How it works [#how-it-works]
A producer sends to a queue; the connector writes each message to the backing KubeMQ Queue channel. A consumer receives a message (which is then hidden for its visibility window) and deletes it by receipt handle to ack it off the queue.
*A received message is hidden for its visibility window; deleting it by receipt handle acks it off the queue, while letting the window expire NAcks it back to the tail for redelivery.*
## Send and receive [#send-and-receive]
The full lifecycle: `CreateQueue` → `GetQueueUrl` → `SendMessage` → `ReceiveMessage` → `DeleteMessage`. Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies **dummy static credentials** — the connector's default accept-any mode does not cryptographically verify the SigV4 signature, but the SDK must still form a syntactically valid signed request. The region is not enforced.
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
func sqsClient(ctx context.Context) *sqs.Client {
url := "http://localhost:4566"
if v := os.Getenv("KUBEMQ_AWS_URL"); v != "" {
url = v
}
// Dummy static credentials are mandatory even in accept-any mode: the SDK
// must form a valid SigV4 request. The region is not enforced.
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
if err != nil {
log.Fatalf("load config: %v", err)
}
return sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) })
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client := sqsClient(ctx)
// 1. CreateQueue → registers channel sqs.orders (idempotent on identical attrs).
created, err := client.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("orders")})
if err != nil {
log.Fatalf("CreateQueue: %v", err)
}
queueURL := aws.ToString(created.QueueUrl)
fmt.Printf("CreateQueue: %s\n", queueURL)
// 2. SendMessage.
sent, err := client.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(queueURL),
MessageBody: aws.String("order #4242 — 3x widget"),
})
if err != nil {
log.Fatalf("SendMessage: %v", err)
}
fmt.Printf("SendMessage: MessageId=%s MD5OfBody=%s\n",
aws.ToString(sent.MessageId), aws.ToString(sent.MD5OfMessageBody))
// 3. ReceiveMessage (long-poll a few seconds so the message is ready).
recv, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 1,
WaitTimeSeconds: 5,
})
if err != nil {
log.Fatalf("ReceiveMessage: %v", err)
}
if len(recv.Messages) != 1 {
log.Fatalf("expected 1 message, got %d", len(recv.Messages))
}
msg := recv.Messages[0]
fmt.Printf("ReceiveMessage: body=%q\n", aws.ToString(msg.Body))
// 4. DeleteMessage by receipt handle (acks the message off the queue).
if _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: msg.ReceiptHandle,
}); err != nil {
log.Fatalf("DeleteMessage: %v", err)
}
fmt.Println("DeleteMessage: ok (acked by receipt handle)")
}
```
```python
import os
import boto3
def sqs_client():
url = os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566")
# Dummy credentials are mandatory even in accept-any mode (the SDK must form
# a valid SigV4 request); the region is not enforced.
return boto3.client(
"sqs",
endpoint_url=url,
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
def main() -> None:
sqs = sqs_client()
# 1. CreateQueue → registers channel sqs.orders.
queue_url = sqs.create_queue(QueueName="orders")["QueueUrl"]
print(f"CreateQueue -> {queue_url}")
# 2. SendMessage.
body = "hello from boto3"
send = sqs.send_message(QueueUrl=queue_url, MessageBody=body)
print(f"SendMessage -> MessageId={send['MessageId']} MD5OfBody={send['MD5OfMessageBody']}")
# 3. ReceiveMessage (long-poll so the message is ready).
recv = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=5)
messages = recv.get("Messages", [])
if len(messages) != 1:
raise SystemExit(f"expected 1 message, got {len(messages)}")
msg = messages[0]
print(f"ReceiveMessage-> body={msg['Body']!r}")
# 4. DeleteMessage by receipt handle (acks it off the queue).
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"])
print("DeleteMessage -> acknowledged by receipt handle")
if __name__ == "__main__":
main()
```
```java
import java.net.URI;
import java.util.List;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.Message;
import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse;
import software.amazon.awssdk.services.sqs.model.SendMessageResponse;
public final class Main {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566");
// Dummy credentials are mandatory even in accept-any mode (a valid SigV4
// request must be formed); the region is not enforced.
try (SqsClient sqs = SqsClient.builder()
.endpointOverride(URI.create(url))
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")))
.build()) {
// 1. CreateQueue → registers channel sqs.orders.
String queueUrl = sqs.createQueue(b -> b.queueName("orders")).queueUrl();
System.out.println("CreateQueue -> " + queueUrl);
// 2. SendMessage.
SendMessageResponse sent = sqs.sendMessage(b -> b
.queueUrl(queueUrl)
.messageBody("order #1001"));
System.out.println("SendMessage -> MessageId=" + sent.messageId()
+ " MD5OfBody=" + sent.md5OfMessageBody());
// 3. ReceiveMessage (long-poll so the message is ready).
ReceiveMessageResponse recv = sqs.receiveMessage(b -> b
.queueUrl(queueUrl)
.maxNumberOfMessages(1)
.waitTimeSeconds(5));
List messages = recv.messages();
if (messages.size() != 1) {
throw new IllegalStateException("expected 1 message, got " + messages.size());
}
Message msg = messages.get(0);
System.out.println("ReceiveMessage-> body='" + msg.body() + "'");
// 4. DeleteMessage by receipt handle (acks it off the queue).
sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(msg.receiptHandle()));
System.out.println("DeleteMessage -> deleted by receipt handle");
}
}
}
```
```typescript
import {
SQSClient,
CreateQueueCommand,
SendMessageCommand,
ReceiveMessageCommand,
DeleteMessageCommand,
} from "@aws-sdk/client-sqs";
function sqsClient(): SQSClient {
// Dummy credentials are mandatory even in accept-any mode (the SDK must form a
// valid SigV4 request); the region is not enforced.
return new SQSClient({
endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566",
region: "us-east-1",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
});
}
async function main(): Promise {
const sqs = sqsClient();
// 1. CreateQueue → registers channel sqs.orders.
const created = await sqs.send(new CreateQueueCommand({ QueueName: "orders" }));
const queueUrl = created.QueueUrl!;
console.log(`CreateQueue -> ${queueUrl}`);
// 2. SendMessage.
const sent = await sqs.send(
new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "order #42: 3x widgets" }),
);
console.log(`SendMessage -> MessageId=${sent.MessageId} MD5OfBody=${sent.MD5OfMessageBody}`);
// 3. ReceiveMessage (long-poll so the message is ready).
const recv = await sqs.send(
new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1, WaitTimeSeconds: 5 }),
);
const msg = recv.Messages?.[0];
if (!msg) throw new Error("expected 1 message, got 0");
console.log(`ReceiveMessage -> body="${msg.Body}"`);
// 4. DeleteMessage by receipt handle (acks it off the queue).
await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: msg.ReceiptHandle! }));
console.log("DeleteMessage -> acked by receipt handle");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using Amazon.Runtime;
using Amazon.SQS;
using Amazon.SQS.Model;
// Dummy credentials are mandatory even in accept-any mode (a valid SigV4 request
// must be formed); the region is not enforced. ServiceURL carries the full
// http://host:port, so leave UseHttp=false to avoid the port being dropped.
var config = new AmazonSQSConfig
{
ServiceURL = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566",
AuthenticationRegion = "us-east-1",
};
using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"), config);
// 1. CreateQueue → registers channel sqs.orders.
var created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "orders" });
Console.WriteLine($"CreateQueue -> {created.QueueUrl}");
// 2. SendMessage.
var sent = await sqs.SendMessageAsync(new SendMessageRequest
{
QueueUrl = created.QueueUrl,
MessageBody = "order #1001",
});
Console.WriteLine($"SendMessage -> MessageId={sent.MessageId} MD5OfBody={sent.MD5OfMessageBody}");
// 3. ReceiveMessage (long-poll so the message is ready).
var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = created.QueueUrl,
MaxNumberOfMessages = 1,
WaitTimeSeconds = 5,
});
if (recv.Messages.Count != 1)
throw new InvalidOperationException($"expected 1 message, got {recv.Messages.Count}");
var msg = recv.Messages[0];
Console.WriteLine($"ReceiveMessage -> body='{msg.Body}'");
// 4. DeleteMessage by receipt handle (acks it off the queue).
await sqs.DeleteMessageAsync(new DeleteMessageRequest
{
QueueUrl = created.QueueUrl,
ReceiptHandle = msg.ReceiptHandle,
});
Console.WriteLine("DeleteMessage -> deleted by receipt handle");
```
```ruby
# frozen_string_literal: true
require "aws-sdk-sqs"
url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566")
# Dummy credentials are mandatory even in accept-any mode (the SDK must form a
# valid SigV4 request); the region is not enforced.
sqs = Aws::SQS::Client.new(
endpoint: url,
region: "us-east-1",
access_key_id: "test",
secret_access_key: "test"
)
# 1. CreateQueue → registers channel sqs.orders.
queue_url = sqs.create_queue(queue_name: "orders").queue_url
puts "CreateQueue -> #{queue_url}"
# 2. SendMessage.
send_resp = sqs.send_message(queue_url: queue_url, message_body: "order #1138 — 2 widgets")
puts "SendMessage -> MessageId=#{send_resp.message_id} MD5OfBody=#{send_resp.md5_of_message_body}"
# 3. ReceiveMessage (long-poll so the message is ready).
recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 5)
raise "no message received" if recv.messages.empty?
msg = recv.messages.first
puts "ReceiveMessage-> Body=#{msg.body.inspect}"
# 4. DeleteMessage by receipt handle (acks it off the queue).
sqs.delete_message(queue_url: queue_url, receipt_handle: msg.receipt_handle)
puts "DeleteMessage -> ok (acked)"
```
The AWS SDK for Ruby ships an SQS-only plugin that rewrites the request path to the full queue URL. Because the connector dispatches only on `POST /` and `GET /` (it carries the action in the request, not the path), remove that plugin once at startup so send/receive/delete reach the connector: `Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls)`. `CreateQueue`/`GetQueueUrl` carry no queue URL and work either way.
```rust
use aws_config::{BehaviorVersion, Region};
use aws_credential_types::Credentials;
use std::error::Error;
async fn sqs_client() -> aws_sdk_sqs::Client {
let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into());
// Dummy credentials are mandatory even in accept-any mode (a valid SigV4
// request must be formed); the region is not enforced.
let conf = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(Credentials::new("test", "test", None, None, "static"))
.endpoint_url(url)
.load()
.await;
aws_sdk_sqs::Client::new(&conf)
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let sqs = sqs_client().await;
// 1. CreateQueue → registers channel sqs.orders.
let url = sqs
.create_queue()
.queue_name("orders")
.send()
.await?
.queue_url
.ok_or("CreateQueue returned no URL")?;
println!("CreateQueue -> {url}");
// 2. SendMessage.
let sent = sqs.send_message().queue_url(&url).message_body("order-42").send().await?;
println!(
"SendMessage -> MessageId={} MD5OfBody={}",
sent.message_id().unwrap_or(""),
sent.md5_of_message_body().unwrap_or("")
);
// 3. ReceiveMessage (long-poll so the message is ready).
let recv = sqs
.receive_message()
.queue_url(&url)
.max_number_of_messages(1)
.wait_time_seconds(5)
.send()
.await?;
let messages = recv.messages();
let msg = messages.first().ok_or("expected 1 message, got 0")?;
println!("ReceiveMessage-> body='{}'", msg.body().unwrap_or_default());
// 4. DeleteMessage by receipt handle (acks it off the queue).
let handle = msg.receipt_handle().ok_or("message has no receipt handle")?;
sqs.delete_message().queue_url(&url).receipt_handle(handle).send().await?;
println!("DeleteMessage -> ok (acked)");
Ok(())
}
```
**Receipt handles and in-flight messages are node-local.** A receipt handle minted on one node is rejected on another (`ReceiptHandleIsInvalid`). In a clustered deployment, place a **sticky load balancer** (session affinity) in front of the connector so a consumer's receive, delete, and visibility-change calls all land on the same node. Single-node deployments are unaffected.
## Batch and message attributes [#batch-and-message-attributes]
`SendMessageBatch` and `DeleteMessageBatch` take up to 10 entries and return per-entry success/failure, so an oversize or malformed entry fails on its own without failing the whole batch. Combine batch sends with long polling (`WaitTimeSeconds=20`, `MaxNumberOfMessages=10`) to drain efficiently.
```python
import os
import boto3
sqs = boto3.client(
"sqs",
endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
url = sqs.create_queue(QueueName="events")["QueueUrl"]
# Send a batch of 9 entries. Batch results are per-entry: a single bad entry
# comes back in Failed without failing the others.
entries = [{"Id": f"m{i}", "MessageBody": f"event-{i}"} for i in range(9)]
batch = sqs.send_message_batch(QueueUrl=url, Entries=entries)
print(f"SendMessageBatch -> {len(batch.get('Successful', []))} Successful, "
f"{len(batch.get('Failed', []))} Failed")
# Long-poll-drain up to 10 at a time.
drained: list[dict] = []
while len(drained) < 9:
recv = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=20)
msgs = recv.get("Messages", [])
if not msgs:
break
drained.extend(msgs)
# Delete them in one batch.
del_entries = [{"Id": f"d{i}", "ReceiptHandle": m["ReceiptHandle"]} for i, m in enumerate(drained)]
result = sqs.delete_message_batch(QueueUrl=url, Entries=del_entries)
print(f"DeleteMessageBatch -> {len(result.get('Successful', []))} deleted")
```
Typed message attributes (`String` / `Number` / `Binary`, up to 10) round-trip losslessly, and `MD5OfMessageAttributes` is returned alongside `MD5OfBody`. The only accepted system attribute is `AWSTraceHeader`; other system attributes are rejected. The full attribute-to-tag mapping is in the [channel mapping reference](/connectors/aws/reference/channel-mapping).
**Empty-queue short polls have a \~1 s latency floor**, and `ApproximateNumberOfMessagesDelayed` is always `"0"` (the connector does not track delayed counts). Use `WaitTimeSeconds` for long polling rather than tight short-poll loops.
## Visibility timeout and redelivery [#visibility-timeout-and-redelivery]
A received message is hidden for the visibility window (per-request `VisibilityTimeout`, else the queue default, else 30 s). If you do not delete it before the window expires, a sweeper NAcks it back to the **tail** and `ApproximateReceiveCount` increments — the message is redelivered. `ChangeMessageVisibility` extends or shortens the window for an in-flight message.
```python
import time
# Receive with a 1-second visibility window, do NOT delete, then receive again:
# the message is redelivered and ApproximateReceiveCount goes from 1 to 2.
first = sqs.receive_message(
QueueUrl=url,
MaxNumberOfMessages=1,
VisibilityTimeout=1,
AttributeNames=["ApproximateReceiveCount"],
)["Messages"][0]
print(f"receive #1: count={first['Attributes']['ApproximateReceiveCount']}")
time.sleep(2) # let the visibility window expire → redelivery
second = sqs.receive_message(
QueueUrl=url,
MaxNumberOfMessages=1,
WaitTimeSeconds=5,
AttributeNames=["ApproximateReceiveCount"],
)["Messages"][0]
print(f"receive #2: count={second['Attributes']['ApproximateReceiveCount']}") # → 2 (redelivered)
```
## FIFO queues, ordering, and dedup [#fifo-queues-ordering-and-dedup]
A queue whose name ends in `.fifo` is a FIFO queue. `MessageGroupId` is **required** on send and preserves per-group order; `MessageDeduplicationId` (or content-based dedup) suppresses duplicates within a 5-minute window. Each FIFO group maps to its own per-group channel `sqs.{name}.fifo.g.{enc(group)}`, where `enc` percent-encodes bytes outside `[a-zA-Z0-9_-]`. Per-message `DelaySeconds` is **rejected** on FIFO queues.
```python
import os
import boto3
from botocore.exceptions import ClientError
sqs = boto3.client(
"sqs",
endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
# A .fifo queue with content-based dedup. MessageGroupId is required on send.
url = sqs.create_queue(
QueueName="tasks.fifo",
Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "true"},
)["QueueUrl"]
sent_ids = []
for i in range(1, 4):
resp = sqs.send_message(
QueueUrl=url,
MessageBody=f"task-{i}",
MessageGroupId="group-A",
MessageDeduplicationId=f"dedup-{i}",
)
sent_ids.append(resp["MessageId"])
# SequenceNumber is 20 digits. On send it is a UnixNano timestamp; on receive
# it is the true broker sequence — both strictly increasing per group.
print(f"sent task-{i} SequenceNumber={resp['SequenceNumber']}")
# Re-sending with the same MessageDeduplicationId returns the ORIGINAL MessageId
# and does not re-enqueue.
dup = sqs.send_message(
QueueUrl=url, MessageBody="task-1", MessageGroupId="group-A", MessageDeduplicationId="dedup-1"
)
assert dup["MessageId"] == sent_ids[0] # duplicate suppressed
# Per-message DelaySeconds is rejected on FIFO queues.
try:
sqs.send_message(
QueueUrl=url, MessageBody="late", MessageGroupId="group-A",
MessageDeduplicationId="dedup-late", DelaySeconds=5,
)
except ClientError as e:
print(f"DelaySeconds on FIFO -> {e.response['Error']['Code']}") # InvalidParameterValue
```
The FIFO `SequenceNumber` differs between send and receive: on **send** it is a UnixNano timestamp, on **receive** it is the true broker sequence. Both are strictly increasing within a group — do not compare a send-side number against a receive-side one.
## DLQ and redrive [#dlq-and-redrive]
Set a `RedrivePolicy` on a queue (a `deadLetterTargetArn` plus a `maxReceiveCount`) and the broker moves a message to the dead-letter queue once it has been received `maxReceiveCount` times without being deleted. The DLQ message carries a `DeadLetterQueueSourceArn` identifying the source. See the [reliability guide](/connectors/aws/how-to/reliability) for the full redrive walkthrough.
```python
# Main queue + DLQ; redrive after 2 failed receives.
dlq_url = sqs.create_queue(QueueName="work-dlq")["QueueUrl"]
dlq_arn = sqs.get_queue_attributes(QueueUrl=dlq_url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"]
work_url = sqs.create_queue(QueueName="work")["QueueUrl"]
sqs.set_queue_attributes(
QueueUrl=work_url,
Attributes={"RedrivePolicy": f'{{"deadLetterTargetArn":"{dlq_arn}","maxReceiveCount":2}}'},
)
```
## What SQS queues do not have [#what-sqs-queues-do-not-have]
The connector implements 18 SQS actions but is deliberately scoped — these AWS features are **not** supported:
* **No RPC.** SQS is point-to-point queueing, not request/reply; there is no gRPC responder.
* **No KMS / server-side encryption (SSE).** Encryption attributes are not honored.
* **No `MessageBody`-scope filtering** (that is an SNS subscription feature; only `MessageAttributes` scope is supported there).
* **Body size limit ≤ 256 KiB** for the aggregate request, as on AWS.
* **Region is not enforced**, and there is a **single AccountId** (`000000000000`); `QueueOwnerAWSAccountId` is accepted and ignored (no cross-account access).
## Related [#related]
# Getting Started (/connectors/aws/tutorials/getting-started)
Get a message flowing through the KubeMQ AWS connector in minutes. You enable the connector,
point a standard AWS SDK at the connector's endpoint, create an SQS queue, send a message,
and receive it back — all over the genuine AWS SQS wire protocol, with no LocalStack and no
KubeMQ SDK. The only change versus a real-AWS app is the **endpoint override**.
## Prerequisites [#prerequisites]
* A running **kubemq-server** with the AWS connector **enabled** and reachable on **port
4566** (the enable step is below — all six wire-protocol connectors are opt-in).
* One of the AWS SDKs below for your language. There is no KubeMQ SDK; you use the native
AWS SDK with only its endpoint overridden.
* **Dummy AWS credentials and a region.** The connector's default accept-any mode does not
verify the signature value, but the SDK must still form a valid SigV4 request — so an
access key, secret, and region are required even though their values are not checked.
Every example reads a single convenience variable for the connector endpoint, which maps to
both the SQS and SNS endpoint overrides:
```bash
export KUBEMQ_AWS_URL="http://localhost:4566" # default; mapped to AWS_ENDPOINT_URL_SQS / _SNS
# Dummy credentials + region — STILL required even in accept-any mode.
export AWS_ACCESS_KEY_ID="test"
export AWS_SECRET_ACCESS_KEY="test"
export AWS_REGION="us-east-1" # NOT enforced (default ARN segment "kubemq")
```
## Enable the connector [#enable-the-connector]
The AWS connector is **opt-in — disabled by default.** A stock kubemq-server does **not**
serve AWS until you turn it on. Enable it with its enable variable:
**Enabling the connector opens a new HTTP listener on port 4566.** That port is **not bound
until** you set `CONNECTORS_AWS_ENABLE=true`, and it **must differ** from the server's
gRPC/REST/HTTP ports — this is exactly why the connector is opt-in rather than on by default.
Until you enable it, no AWS endpoint exists and the SDK cannot connect. To turn it off again,
set `CONNECTORS_AWS_ENABLE=false` (a config-only rollback; no data migration). See
[Configuration](/connectors/aws/concepts/configuration) for the full settings list.
## How it works [#how-it-works]
You override only the endpoint URL on a standard AWS SDK client. `CreateQueue("orders")`
registers the queue and maps it to the KubeMQ Queue channel `sqs.orders`; `SendMessage`
writes to that channel through the message broker; `ReceiveMessage` returns the message plus
a receipt handle; `DeleteMessage` acks it off the queue.
*The SQS queue `orders` maps to the KubeMQ Queue channel `sqs.orders`; the broker stores the message and the receive returns it with a node-local receipt handle.*
## Steps [#steps]
### Point the SDK at the connector [#point-the-sdk-at-the-connector]
Build a standard AWS SDK SQS client and override only the endpoint URL to the connector's
endpoint in `KUBEMQ_AWS_URL` (default `http://localhost:4566`). Supply dummy credentials and
a region so the SDK forms a valid SigV4 request.
The language tabs run the **complete** round-trip from a single program: create the queue,
send one message, receive it, and delete it by receipt handle.
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
func awsURL() string {
if v := os.Getenv("KUBEMQ_AWS_URL"); v != "" {
return v
}
return "http://localhost:4566"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Dummy static credentials form a valid SigV4 request; accept-any mode
// checks the signature shape, not its value.
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
if err != nil {
log.Fatalf("load config: %v", err)
}
// Override ONLY the endpoint URL.
client := sqs.NewFromConfig(cfg, func(o *sqs.Options) {
o.BaseEndpoint = aws.String(awsURL())
})
// 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders".
created, err := client.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("orders")})
if err != nil {
log.Fatalf("CreateQueue: %v", err)
}
queueURL := aws.ToString(created.QueueUrl)
fmt.Printf("queue ready: %s\n", queueURL)
// 2. SendMessage.
if _, err := client.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(queueURL),
MessageBody: aws.String("hello from the AWS SDK"),
}); err != nil {
log.Fatalf("SendMessage: %v", err)
}
// 3. ReceiveMessage (long-poll a few seconds for the message).
recv, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 1,
WaitTimeSeconds: 5,
})
if err != nil || len(recv.Messages) != 1 {
log.Fatalf("ReceiveMessage: %v (got %d)", err, len(recv.Messages))
}
msg := recv.Messages[0]
fmt.Printf("received: %q\n", aws.ToString(msg.Body))
// 4. DeleteMessage by receipt handle.
if _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: msg.ReceiptHandle,
}); err != nil {
log.Fatalf("DeleteMessage: %v", err)
}
fmt.Println("deleted; round-trip complete")
}
```
```python
import os
import boto3
def make_sqs():
# Override ONLY the endpoint URL; dummy credentials form a valid SigV4
# request (accept-any mode checks the signature shape, not its value).
return boto3.client(
"sqs",
endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
)
def main() -> None:
sqs = make_sqs()
# 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders".
queue_url = sqs.create_queue(QueueName="orders")["QueueUrl"]
print(f"queue ready: {queue_url}")
# 2. SendMessage.
sqs.send_message(QueueUrl=queue_url, MessageBody="hello from boto3")
# 3. ReceiveMessage (long-poll a few seconds).
recv = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=5)
msg = recv["Messages"][0]
print(f"received: {msg['Body']!r}")
# 4. DeleteMessage by receipt handle.
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"])
print("deleted; round-trip complete")
if __name__ == "__main__":
main()
```
```java
import java.net.URI;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.Message;
public final class Main {
public static void main(String[] args) {
String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566");
// endpointOverride is the only change; dummy credentials form a valid
// SigV4 request (accept-any mode checks the signature shape).
try (SqsClient sqs = SqsClient.builder()
.endpointOverride(URI.create(url))
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")))
.build()) {
// 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders".
String queueUrl = sqs.createQueue(b -> b.queueName("orders")).queueUrl();
System.out.println("queue ready: " + queueUrl);
// 2. SendMessage.
sqs.sendMessage(b -> b.queueUrl(queueUrl).messageBody("hello from the AWS SDK for Java"));
// 3. ReceiveMessage (long-poll a few seconds).
Message msg = sqs.receiveMessage(b -> b
.queueUrl(queueUrl)
.maxNumberOfMessages(1)
.waitTimeSeconds(5))
.messages().get(0);
System.out.printf("received: %s%n", msg.body());
// 4. DeleteMessage by receipt handle.
sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(msg.receiptHandle()));
System.out.println("deleted; round-trip complete");
}
}
}
```
```typescript
import {
SQSClient,
CreateQueueCommand,
SendMessageCommand,
ReceiveMessageCommand,
DeleteMessageCommand,
} from "@aws-sdk/client-sqs";
// Override ONLY the endpoint; dummy credentials form a valid SigV4 request
// (accept-any mode checks the signature shape, not its value).
const sqs = new SQSClient({
endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566",
region: "us-east-1",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
});
async function main(): Promise {
// 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders".
const created = await sqs.send(new CreateQueueCommand({ QueueName: "orders" }));
const queueUrl = created.QueueUrl!;
console.log(`queue ready: ${queueUrl}`);
// 2. SendMessage.
await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "hello from the AWS SDK v3" }));
// 3. ReceiveMessage (long-poll a few seconds).
const recv = await sqs.send(
new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1, WaitTimeSeconds: 5 }),
);
const msg = recv.Messages![0];
console.log(`received: ${msg.Body}`);
// 4. DeleteMessage by receipt handle.
await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: msg.ReceiptHandle! }));
console.log("deleted; round-trip complete");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using Amazon.Runtime;
using Amazon.SQS;
using Amazon.SQS.Model;
var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566";
// ServiceURL carries the full http://host:port; dummy credentials form a valid
// SigV4 request (accept-any mode checks the signature shape, not its value).
var config = new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" };
using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"), config);
// 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders".
var created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "orders" });
var queueUrl = created.QueueUrl;
Console.WriteLine($"queue ready: {queueUrl}");
// 2. SendMessage.
await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = "hello from AWSSDK.NET" });
// 3. ReceiveMessage (long-poll a few seconds).
var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = queueUrl,
MaxNumberOfMessages = 1,
WaitTimeSeconds = 5,
});
var msg = recv.Messages[0];
Console.WriteLine($"received: {msg.Body}");
// 4. DeleteMessage by receipt handle.
await sqs.DeleteMessageAsync(new DeleteMessageRequest { QueueUrl = queueUrl, ReceiptHandle = msg.ReceiptHandle });
Console.WriteLine("deleted; round-trip complete");
```
```ruby
# frozen_string_literal: true
require "aws-sdk-sqs"
# The Ruby SQS plugin rewrites the request endpoint to the full QueueUrl path,
# which the single-endpoint connector rejects — remove it so requests stay on
# the configured base endpoint (as boto3 and aws-sdk-go-v2 do).
Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls)
url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566")
# Override ONLY the endpoint; dummy credentials form a valid SigV4 request
# (accept-any mode checks the signature shape, not its value).
sqs = Aws::SQS::Client.new(
endpoint: url,
region: "us-east-1",
credentials: Aws::Credentials.new("test", "test")
)
# 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders".
queue_url = sqs.create_queue(queue_name: "orders").queue_url
puts "queue ready: #{queue_url}"
# 2. SendMessage.
sqs.send_message(queue_url: queue_url, message_body: "hello from aws-sdk-ruby")
# 3. ReceiveMessage (long-poll a few seconds).
recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 5)
msg = recv.messages.first
puts "received: #{msg.body.inspect}"
# 4. DeleteMessage by receipt handle.
sqs.delete_message(queue_url: queue_url, receipt_handle: msg.receipt_handle)
puts "deleted; round-trip complete"
```
```rust
use aws_config::BehaviorVersion;
use aws_sdk_sqs::config::Credentials;
use aws_sdk_sqs::config::Region;
use std::error::Error;
fn aws_url() -> String {
std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box> {
// Override ONLY the endpoint; dummy credentials form a valid SigV4 request
// (accept-any mode checks the signature shape, not its value).
let creds = Credentials::new("test", "test", None, None, "kubemq-aws");
let conf = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(creds)
.endpoint_url(aws_url())
.load()
.await;
let sqs = aws_sdk_sqs::Client::new(&conf);
// 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders".
sqs.create_queue().queue_name("orders").send().await?;
let url = sqs
.get_queue_url()
.queue_name("orders")
.send()
.await?
.queue_url
.ok_or("GetQueueUrl returned no URL")?;
println!("queue ready: {url}");
// 2. SendMessage.
sqs.send_message()
.queue_url(&url)
.message_body("hello from aws-sdk-rust")
.send()
.await?;
// 3. ReceiveMessage (long-poll a few seconds).
let received = sqs
.receive_message()
.queue_url(&url)
.max_number_of_messages(1)
.wait_time_seconds(5)
.send()
.await?;
let msg = &received.messages()[0];
println!("received: {}", msg.body().unwrap_or_default());
// 4. DeleteMessage by receipt handle.
let handle = msg.receipt_handle().ok_or("no receipt handle")?;
sqs.delete_message().queue_url(&url).receipt_handle(handle).send().await?;
println!("deleted; round-trip complete");
Ok(())
}
```
### Create an SQS queue and send a message [#create-an-sqs-queue-and-send-a-message]
`CreateQueue("orders")` registers the queue in the connector's registry and maps it to the
KubeMQ Queue channel `sqs.orders`. The returned queue URL is path-style —
`{scheme}://{host}/{AccountId}/orders` (the default `AccountId` is `000000000000`).
`SendMessage` writes the body to that channel and returns a `MessageId` and an `MD5OfBody`
the connector computes exactly like AWS.
### Receive and acknowledge [#receive-and-acknowledge]
`ReceiveMessage` returns the message plus a **receipt handle**, and hides the message for its
visibility window. `DeleteMessage(receiptHandle)` acks it off the queue. A successful run
prints:
```text
queue ready: http://localhost:4566/000000000000/orders
received: "hello from the AWS SDK"
deleted; round-trip complete
```
Receipt handles and in-flight tracking are **node-local** — a handle minted on one node is
rejected on another. In a clustered deployment, put the connector behind a **sticky load
balancer** (session affinity). See
[Connectivity and security](/connectors/aws/how-to/connectivity-and-security).
## Next steps [#next-steps]
# Capabilities (/connectors/aws/reference/capabilities)
This reference defines exactly what the embedded KubeMQ AWS connector **supports**, what it
**accepts-and-ignores**, and what it **rejects**. Use it to decide which AWS SDK calls are safe
to rely on and which ones will be refused. Every action below is backed by the connector's SQS
and SNS dispatch tables.
## Supported SQS actions (18) [#supported-sqs-actions-18]
The SQS dispatch table ships exactly **18** actions:
| # | Action | Notes |
| -- | ------------------------------ | ----------------------------------------------------------------------------- |
| 1 | `CreateQueue` | FIFO via `.fifo` suffix; same name + different attributes → `QueueNameExists` |
| 2 | `DeleteQueue` | removes the registry record, best-effort broker purge |
| 3 | `GetQueueUrl` | path-style URL; `QueueOwnerAWSAccountId` accepted and ignored |
| 4 | `ListQueues` | pagination + `QueueNamePrefix`; not authorization-filtered |
| 5 | `PurgeQueue` | 60 s cooldown → `PurgeQueueInProgress` |
| 6 | `GetQueueAttributes` | see the read-only attributes below |
| 7 | `SetQueueAttributes` | partial-update overlay; not retroactive |
| 8 | `TagQueue` | ≤ 50 tags |
| 9 | `UntagQueue` | |
| 10 | `ListQueueTags` | |
| 11 | `ListDeadLetterSourceQueues` | reverse-resolves `RedrivePolicy` sources |
| 12 | `SendMessage` | |
| 13 | `SendMessageBatch` | ≤ 10 entries; aggregate > 262,144 B → `BatchRequestTooLong` |
| 14 | `ReceiveMessage` | long poll, visibility, in-flight cap |
| 15 | `DeleteMessage` | idempotent (an unknown handle still succeeds) |
| 16 | `DeleteMessageBatch` | ≤ 10 entries |
| 17 | `ChangeMessageVisibility` | timeout > 0 moves the deadline; 0 NAcks (visible at tail) |
| 18 | `ChangeMessageVisibilityBatch` | ≤ 10 entries |
### SQS queue attributes [#sqs-queue-attributes]
**Writable:** `DelaySeconds` (0–900), `MaximumMessageSize` (1024–262144),
`MessageRetentionPeriod` (60–1209600), `VisibilityTimeout` (0–43200),
`ReceiveMessageWaitTimeSeconds` (0–20), `RedrivePolicy`; FIFO-only `FifoQueue` (create-only),
`ContentBasedDeduplication`, `DeduplicationScope` (`queue` | `messageGroup`).
**Read-only on Get:** `QueueArn`, `ApproximateNumberOfMessages` (broker stats, 2 s TTL),
`ApproximateNumberOfMessagesNotVisible` (node-local in-flight),
`ApproximateNumberOfMessagesDelayed` (**always `"0"`**), `CreatedTimestamp`,
`LastModifiedTimestamp`; FIFO adds `FifoQueue` / `ContentBasedDeduplication` / `DeduplicationScope`.
## Supported SNS actions (17) [#supported-sns-actions-17]
The SNS dispatch table ships exactly **17** actions:
| # | Action | Notes |
| -- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| 1 | `CreateTopic` | FIFO via `.fifo` suffix; idempotent on an existing name |
| 2 | `DeleteTopic` | cascades to subscriptions |
| 3 | `ListTopics` | ARN-sorted, 100/page; not authorization-filtered |
| 4 | `GetTopicAttributes` | |
| 5 | `SetTopicAttributes` | only `DisplayName` / `DeliveryPolicy` writable; `Policy` → `InvalidParameter` |
| 6 | `Subscribe` | protocols `sqs` / `http` / `https` only |
| 7 | `ConfirmSubscription` | the only GET action; SigV4-exempt |
| 8 | `Unsubscribe` | |
| 9 | `GetSubscriptionAttributes` | |
| 10 | `SetSubscriptionAttributes` | `RawMessageDelivery`, `FilterPolicy`, `FilterPolicyScope` (MessageAttributes only), `RedrivePolicy`, `DeliveryPolicy` |
| 11 | `ListSubscriptions` | not authorization-filtered |
| 12 | `ListSubscriptionsByTopic` | |
| 13 | `Publish` | rejects `TargetArn` / `PhoneNumber` |
| 14 | `PublishBatch` | |
| 15 | `TagResource` | topic OR subscription ARN; ≤ 50 tags |
| 16 | `UntagResource` | |
| 17 | `ListTagsForResource` | |
### SNS topic attributes [#sns-topic-attributes]
**Writable:** `DisplayName`, `DeliveryPolicy`. **Surfaced on Get:** `TopicArn`, `Owner`,
`DisplayName`, `SubscriptionsConfirmed`, `SubscriptionsPending`, `SubscriptionsDeleted`
(always 0), `EffectiveDeliveryPolicy`, optional `DeliveryPolicy`; FIFO adds `FifoTopic=true` +
`ContentBasedDeduplication=false`.
**Topic-level `ContentBasedDeduplication` is not supported.** Setting it → `InvalidParameter`;
getting it → always `"false"`. Pass an explicit `MessageDeduplicationId` on each `Publish` to a
FIFO topic instead.
## Out-of-scope operations [#out-of-scope-operations]
These are documented non-goals. They are **never** used as working examples, and most are
actively rejected by the connector with the [error code](/connectors/aws/reference/error-codes)
shown:
| Feature | Behavior |
| ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **`FilterPolicyScope=MessageBody`** | rejected at the attribute setter → `InvalidParameter` ("MessageBody scope is not supported"); only `MessageAttributes` scope works |
| **KMS / SSE** (`KmsMasterKeyId`, `Policy`, …) | `InvalidAttributeName` on a queue-attribute set |
| **`AddPermission` / `RemovePermission`** (SQS + SNS) | `InvalidAction` |
| **SQS message-move tasks** (`StartMessageMoveTask` / `CancelMessageMoveTask` / `ListMessageMoveTasks`) | `InvalidAction` |
| **SNS `email` / `email-json` / `sms` / `lambda` / `application` / `firehose` protocols** + mobile-push / SMS / data-protection ops | `InvalidParameter` / `InvalidAction` |
| **`Publish` with `TargetArn` / `PhoneNumber`** | `InvalidParameter` |
| **Signed SNS notification verification** | notifications are **unsigned** — `Signature` / `SigningCertURL` are present but empty; no SDK-side signature verification is possible |
| **Extended client > 256 KiB** | the aggregate body + attributes is capped at 262,144 bytes |
| **CloudWatch metrics emulation** | none emitted (Prometheus is the metrics surface — see [Connections & Observability](/connectors/aws/reference/connections-endpoint)) |
| **Cross-account semantics** | `QueueOwnerAWSAccountId` accepted and ignored; a single configurable AccountId only |
| **Topic-level `ContentBasedDeduplication`** | set → `InvalidParameter`, get → always `"false"` |
## Inert / always-fixed values [#inert--always-fixed-values]
These are accepted on the wire but carry no behavior (documented so you do not expect what is
not there):
* `ApproximateNumberOfMessagesDelayed` always `"0"`.
* `SubscriptionsDeleted` always `0`.
* `X-Amz-Security-Token` accepted and ignored.
* The SigV4 credential-scope **region is not enforced** — any region signs successfully; the ARN
region segment defaults to `kubemq`.
## The eight gotchas [#the-eight-gotchas]
These behaviors deviate from real AWS and are easy to miss until a corner case hits production.
Each is documented in depth where shown:
| # | Gotcha | Where documented |
| - | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Receipt handles + in-flight are node-local → sticky LB required in clusters** (SNS delivery state too) | [SQS queues & consumers](/connectors/aws/how-to/sqs-queues-and-consumers), [Connectivity & security](/connectors/aws/how-to/connectivity-and-security), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) |
| 2 | **Region is not enforced** — any region signs; the ARN segment defaults to `kubemq` | [Authentication](/connectors/aws/how-to/authentication), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) |
| 3 | **Dummy credentials still required in accept-any mode** — the SDK must form a SigV4 request; an unsigned request is rejected (except `ConfirmSubscription`) | [Getting Started](/connectors/aws/tutorials/getting-started), [Authentication](/connectors/aws/how-to/authentication) |
| 4 | **`MessageBody`-scope filtering unsupported** — only `MessageAttributes` scope works | [SNS fan-out](/connectors/aws/how-to/sns-fan-out), this page |
| 5 | **Empty-queue short-poll \~1 s latency floor**; `ApproximateNumberOfMessagesDelayed` always `"0"` | [SQS queues & consumers](/connectors/aws/how-to/sqs-queues-and-consumers), this page |
| 6 | **Unsigned SNS notifications** — `Signature` / `SigningCertURL` present but empty | [SNS fan-out](/connectors/aws/how-to/sns-fan-out), [Fan-out](/connectors/aws/how-to/fan-out), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) |
| 7 | **Native-producer MessageId fallback** — native producers on `sqs.*` get a broker-id MessageId, no SenderId, no policy stamping | [Cross-protocol interop](/connectors/aws/concepts/cross-protocol-interop), [Channel Mapping](/connectors/aws/reference/channel-mapping) |
| 8 | **SNS HTTP delivery state is in-memory on the publishing node** — a restart loses pending retries; bounded job queue (10,000) overflow drops | [Reliability](/connectors/aws/how-to/reliability), [SNS topics](/connectors/aws/how-to/sns-topics), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) |
Two further documented deviations are surfaced in the reference docs but are not headline
gotchas: the FIFO **`SequenceNumber` send-vs-receive** difference and the **raw-HTTP
attribute-drop**. Both live in [Channel Mapping](/connectors/aws/reference/channel-mapping).
## Related [#related]
# Channel Mapping (/connectors/aws/reference/channel-mapping)
This is the master reference for how the embedded KubeMQ AWS connector maps SQS queues and SNS
topics onto KubeMQ. An SQS queue is backed by exactly one KubeMQ **Queue** channel; an SNS topic
is **virtual** — a registry entry whose publish fans out to subscribed queues and webhooks.
## SQS queue grammar [#sqs-queue-grammar]
Every SQS queue maps to exactly one KubeMQ Queue channel:
```text
sqs.{name}
└┬─┘ └──┬──┘
│ └─ the SQS queue name (the same name you pass to CreateQueue)
└─ fixed connector prefix
```
| SQS queue | KubeMQ channel |
| ---------- | -------------- |
| `orders` | `sqs.orders` |
| `events` | `sqs.events` |
| `work-dlq` | `sqs.work-dlq` |
## FIFO group grammar [#fifo-group-grammar]
A FIFO queue `{name}.fifo` fans each **message group** onto its own per-group channel:
```text
sqs.{name}.fifo.g.{enc(group)}
└─────┬────────┘ └┬┘ └───┬────┘
│ │ └─ the MessageGroupId, percent-encoded
│ └─ fixed ".g." group separator
└─ the FIFO queue channel (the name includes the ".fifo" suffix)
```
`enc` percent-encodes any byte outside `[a-zA-Z0-9_-]`.
| FIFO queue | MessageGroupId | KubeMQ channel |
| ------------ | -------------- | ----------------------------- |
| `tasks.fifo` | `g1` | `sqs.tasks.fifo.g.g1` |
| `tasks.fifo` | `order/42` | `sqs.tasks.fifo.g.order%2F42` |
## SNS topics are virtual [#sns-topics-are-virtual]
SNS topics have **no native channel**. A topic is a registry entry, **synced/replicated across
cluster nodes**; its authorization pseudo-resource is `sns.{topic}`. Fan-out resolves to the
target SQS channels (one batched send) plus HTTP/HTTPS webhooks at publish time. See
[Fan-out](/connectors/aws/how-to/fan-out).
## ARNs & queue URLs [#arns--queue-urls]
| Form | Value |
| --------- | ---------------------------------------------------------------------------------------------------- |
| SQS ARN | `arn:aws:sqs:{Region}:{AccountId}:{name}` |
| SNS ARN | `arn:aws:sns:{Region}:{AccountId}:{name}` |
| Queue URL | `{scheme}://{host}/{AccountId}/{name}` (path-style; host from `AdvertisedUrl` or the request `Host`) |
`Region` defaults to `kubemq` (not enforced); `AccountId` defaults to `000000000000`. Resolution
parses the **path only**, so a stale host in a saved URL still works.
## The registry is authoritative [#the-registry-is-authoritative]
Only resources created through the AWS API are visible. Operating on a native `sqs.foo` channel
that was never `CreateQueue`d returns `NonExistentQueue`.
## Cross-protocol interoperability [#cross-protocol-interoperability]
Because the backing store is a normal KubeMQ Queue channel, an SQS `SendMessage` to `sqs.orders`
is consumable by a gRPC/REST queue client on the same channel, and vice-versa.
**Native-interop caveat (gotcha #7).** A message produced by a **native** KubeMQ client on
`sqs.*` lacks the `sqs_*` tags, so its `MessageId` falls back to the broker MessageID, it has no
`SenderId`, and no policy stamping is applied. See
[Cross-protocol interop](/connectors/aws/concepts/cross-protocol-interop).
## Message attribute ⇄ tag mapping [#message-attribute--tag-mapping]
SQS message attributes round-trip losslessly through the KubeMQ **Tags** codec.
| AWS field | KubeMQ Tag | Notes |
| --------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Message attribute `{Name}` | `sqs_attr_{Name}` | value is `{DataType}\|{value}`; Binary is base64; ≤ 10 attributes, name ≤ 256 chars, no `AWS.`/`Amazon.` prefix; DataType `String` / `Number` / `Binary` (+ subtypes `String.x`) |
| System attribute `AWSTraceHeader` | `sqs_trace_header` | the **only** accepted system attribute; anything else → `InvalidParameterValue` |
| *(connector-stamped)* `MessageId` | `sqs_message_id` | generated UUID |
| *(connector-stamped)* `SenderId` | `sqs_sender_id` | the authenticated ClientID |
| FIFO `MessageGroupId` | `sqs_group_id` | |
| FIFO `MessageDeduplicationId` | `sqs_dedup_id` | |
### Receive-side decoded system attributes [#receive-side-decoded-system-attributes]
`SentTimestamp`, `ApproximateReceiveCount`, `ApproximateFirstReceiveTimestamp` (node-local
approx), `SenderId`, `DeadLetterQueueSourceArn` (redriven messages), and FIFO `MessageGroupId` /
`SequenceNumber` / `MessageDeduplicationId`. Attribute-name filtering on receive supports `All`,
exact names, and `prefix.*`.
## SNS raw / enveloped tags [#sns-raw--enveloped-tags]
| Context | Mapping |
| -------------------------- | -------------------------------------------------------------------------------------------------------- |
| SQS **enveloped** delivery | the SNS `Notification` JSON carries `MessageAttributes` as `{Type, Value}` per attribute (Binary base64) |
| SQS **raw** delivery | bare body + the attribute tag codec + `sns_topic_arn` / `sns_subject` tags |
| HTTP **raw** delivery | bare payload with attributes mapped to `x-amz-sns-attr-{name}` headers |
**Raw-HTTP attribute-drop deviation.** AWS drops message attributes for raw HTTP delivery; this
connector instead surfaces them as `x-amz-sns-attr-{name}` headers.
## MD5 [#md5]
| Field | Algorithm |
| ------------------------------ | ----------------------------------------- |
| `MD5OfMessageBody` | MD5 of the body bytes |
| `MD5OfMessageAttributes` | AWS length-prefixed message-attribute MD5 |
| `MD5OfMessageSystemAttributes` | present only when `AWSTraceHeader` is set |
## FIFO SequenceNumber deviation [#fifo-sequencenumber-deviation]
The 20-digit zero-padded `SequenceNumber` is the broker **send-timestamp (UnixNano)** on SEND and
the **true broker sequence** on RECEIVE. It is still strictly increasing per group for serialized
sends. See [Reliability](/connectors/aws/how-to/reliability).
## Related [#related]
# Configuration (/connectors/aws/reference/configuration)
Field-by-field reference for the KubeMQ AWS connector's `Connectors.Aws` config block. See
[Configuration concepts](../concepts/configuration) for why the connector is opt-in and how
the two credential postures work.
## Configuration fields [#configuration-fields]
All values below are verified against the connector source (`AwsConfig` struct and
`defaultAwsConfig`). Compound camelCase fields are snake-split in their env form — e.g.
`MaxInflightPerQueue` → `CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE`, `AccountId` →
`CONNECTORS_AWS_ACCOUNT_ID`.
| Env var | Default | Type | Meaning / validation |
| --------------------------------------- | ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `CONNECTORS_AWS_ENABLE` | `false` | bool | Opt-in. `true` opens the listener on `Port`; `false` skips the connector entirely. |
| `CONNECTORS_AWS_PORT` | `"4566"` | string | The listener port (the LocalStack convention). Must differ from any enabled Grpc/Rest/Http port. A single TCP listener; `POST /` and `GET /` both dispatch. |
| `CONNECTORS_AWS_REGION` | `"kubemq"` | string | The ARN region segment; informational only. **NOT enforced** in SigV4 — any region in the credential scope signs successfully. |
| `CONNECTORS_AWS_ACCOUNT_ID` | `"000000000000"` | string | A single configurable 12-digit value (validated). There is **no cross-account support**; `QueueOwnerAWSAccountId` is accepted and ignored. |
| `CONNECTORS_AWS_ADVERTISED_URL` | `""` | string | Overrides the host in returned queue URLs; must be `scheme://host[:port]`. When empty, the request `Host` is used. Resolution parses the **path only**, so stale hosts in saved URLs still work. |
| `CONNECTORS_AWS_CREDENTIALS_DATA` | `""` | string | A JSON (optionally base64-encoded) array of static SigV4 credentials — the env/operator path for a secured connector. When set, SigV4 is fully verified. |
| `CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE` | `20000` | int | Per-queue / per-node cap on received-but-not-deleted (in-flight) messages; exceeding it returns `OverLimit`. |
| `CONNECTORS_AWS_MAX_CONCURRENT_POLLS` | `1024` | int | The parked long-poll slot pool. When exhausted, a `ReceiveMessage` **degrades to a short poll** rather than erroring. |
| `CONNECTORS_AWS_READ_TIMEOUT` | `60` | int | Per-action context deadline in seconds for synchronous ops. `ReceiveMessage` is exempt and gets at least a \~25 s budget. |
| `CONNECTORS_AWS_BODY_LIMIT` | `"2M"` | string | Request body size cap. |
The `AwsConfig` struct has **11 fields, of which 10 are env-bound.** The 11th, the structured
`Credentials` array, has **no env binding** — it is file/structured-config only.
`CONNECTORS_AWS_CREDENTIALS_DATA` is the env path for static credentials, and merges with any
file-only `Credentials` (Data wins on duplicates).
## Configuring the connector [#configuring-the-connector]
The same settings can be supplied through a TOML config file, environment variables, or
`docker run` flags. Every environment variable uses the `CONNECTORS_AWS_` prefix (with the
underscore between `CONNECTORS` and `AWS`).
```toml title="config.toml"
[Connectors.Aws]
Enable = true
Port = "4566"
Region = "kubemq"
AccountId = "000000000000"
AdvertisedUrl = ""
CredentialsData = ""
MaxInflightPerQueue = 20000
MaxConcurrentPolls = 1024
ReadTimeout = 60
BodyLimit = "2M"
```
```bash title="aws.env"
CONNECTORS_AWS_ENABLE=true
CONNECTORS_AWS_PORT=4566
CONNECTORS_AWS_REGION=kubemq
CONNECTORS_AWS_ACCOUNT_ID=000000000000
CONNECTORS_AWS_ADVERTISED_URL=
CONNECTORS_AWS_CREDENTIALS_DATA=
CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE=20000
CONNECTORS_AWS_MAX_CONCURRENT_POLLS=1024
CONNECTORS_AWS_READ_TIMEOUT=60
CONNECTORS_AWS_BODY_LIMIT=2M
```
Because the connector is opt-in (as are all six wire-protocol connectors), the Docker example
**must** include `-e CONNECTORS_AWS_ENABLE=true` — without it, no AWS listener binds. Set
`=false` to turn it off again.
## Related [#related]
# Connections & Observability (/connectors/aws/reference/connections-endpoint)
This reference documents the AWS connector's observability surface: the **read-only management
API**, the **Prometheus metrics**, the **dashboard page**, and the **audit events**. The
management APIs live on the **internal API port** and are network-protected.
## Management API [#management-api]
A **read-only** management API summarizes connector activity:
| Endpoint | Returns |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/aws/overview` | aggregate counts (queues / topics / subscriptions / in-flight / active long polls) + waiting-message counts (fed by the 2 s stats cache) |
| `GET /api/aws/operations` | per-operation counters keyed `{service}/{op}` (e.g. `sqs/send_message`, `sns/publish`) |
| `GET /api/aws/operations/:service/:op` | a single operation's counters (e.g. `/api/aws/operations/sqs/send_message`) |
Use the overview to confirm the connector accepted traffic after an upgrade.
## Prometheus metrics [#prometheus-metrics]
### Counters [#counters]
| Metric | Meaning |
| ------------------------------------------------------- | ----------------------------------------------------------- |
| `kubemq_aws_operations_total{service,operation,status}` | operations, by service / op / status |
| `kubemq_aws_sns_deliveries_total{result}` | SNS deliveries, by result |
| `kubemq_aws_sns_delivery_dropped_total` | SNS deliveries dropped (job-queue overflow / unrecoverable) |
### Histogram [#histogram]
| Metric | Meaning |
| --------------------------------------- | --------------------- |
| `kubemq_aws_operation_duration_seconds` | per-operation latency |
### Gauges [#gauges]
| Metric | Meaning |
| ------------------------------ | --------------------------------------------- |
| `kubemq_aws_queues` | registered queues |
| `kubemq_aws_topics` | registered topics |
| `kubemq_aws_subscriptions` | registered subscriptions |
| `kubemq_aws_inflight` | in-flight (received-but-not-deleted) messages |
| `kubemq_aws_active_long_polls` | parked long-poll slots in use |
There is **no CloudWatch metrics emulation** — Prometheus is the metrics surface. See
[Capabilities](/connectors/aws/reference/capabilities).
## Dashboard [#dashboard]
The KubeMQ dashboard has an `/aws` page backed by the management API above.
## Audit events [#audit-events]
The connector emits audit events for control-plane operations (data-plane SQS sends/receives are
**not** audited):
| Event | When |
| ----------------------- | ------------------------------------------------------------ |
| `aws.auth.failure` | a SigV4 / authorization failure |
| `aws.queue.*` | queue lifecycle (create / delete / tag / purge) |
| `aws.topic.*` | topic lifecycle |
| `aws.sub.*` | subscription lifecycle (subscribe / confirm / unsubscribe) |
| `aws.registry.conflict` | a registry conflict (e.g. registry-sync conflict resolution) |
## Stats cache [#stats-cache]
The `ApproximateNumberOfMessages` attribute and the overview waiting counts are fed by a stats
cache that wraps the broker queue stats with a **2 s TTL** plus duplicate-call suppression.
## Related [#related]
# Error Codes (/connectors/aws/reference/error-codes)
The connector emits **genuine AWS SQS/SNS error codes** over the AWS JSON (SQS) and Query → XML
(SNS) wire protocols, so standard AWS SDKs surface them as the normal typed exceptions. You handle
them with the same `try`/`catch` and error-type checks you already use against real AWS.
## Error code table [#error-code-table]
| AWS error code | HTTP | Trigger |
| -------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MissingAction` | 400 | no / unparseable `Action` |
| `InvalidAction` | 400 | unknown action (including all out-of-scope ops: `AddPermission`, message-move, mobile/SMS, etc.); a GET with `Action ≠ ConfirmSubscription` |
| `IncompleteSignature` | 400 | malformed SigV4 (including an unsigned non-`ConfirmSubscription` request) |
| `InvalidClientTokenId` | 403 | unknown access key (configured-credentials mode) |
| `SignatureDoesNotMatch` | 403 | bad signature / clock skew / tampered body |
| `AccessDeniedException` | 403 | an authorization deny on a data-plane op |
| `NonExistentQueue` (`AWS.SimpleQueueService.NonExistentQueue`) | 400 | queue not in the registry |
| `QueueNameExists` | 400 | `CreateQueue` with the same name + different attributes |
| `PurgeQueueInProgress` | 403 | `PurgeQueue` within the 60 s cooldown |
| `InvalidAttributeName` | 400 | unknown / KMS attribute on `SetQueueAttributes` |
| `InvalidParameterValue` | 400 | bad attribute / oversize batch entry / non-`AWSTraceHeader` system attribute / FIFO send without `MessageGroupId` / FIFO per-message `DelaySeconds` |
| `BatchRequestTooLong` | 400 | `SendMessageBatch` aggregate > 262,144 B |
| `ReceiptHandleIsInvalid` | 400 | receipt handle from another node / malformed |
| `OverLimit` | 403 | in-flight > `MaxInflightPerQueue` |
| `InvalidParameter` (SNS) | 400 | unsupported SNS protocol / `Policy` set / `TargetArn` / `PhoneNumber` / `FilterPolicyScope=MessageBody` / topic-level `ContentBasedDeduplication` / `MessageGroupId` on a standard topic / FIFO-topic non-`sqs` subscribe |
| `NotFound` (SNS) | 404 | topic / subscription not in the registry |
## Common triggers by scenario [#common-triggers-by-scenario]
| Scenario | Code |
| -------------------------------------------------------------------- | ------------------------------------------------------------ |
| `ReceiveMessage` on an empty queue (short poll) | *none — returns within \~1 s, no messages* |
| `SendMessageBatch` with one oversize entry | per-entry `InvalidParameterValue` (9 ok, 1 failed) |
| `SendMessageBatch` aggregate > 262,144 B | `BatchRequestTooLong` (whole batch) |
| Receive without delete, visibility expires | *none — redelivery; `ApproximateReceiveCount`++* |
| `DeleteMessage` with an unknown receipt handle | *none — idempotent success* |
| Receipt handle from another cluster node | `ReceiptHandleIsInvalid` |
| In-flight > `MaxInflightPerQueue` | `OverLimit` |
| FIFO send without `MessageGroupId` / with per-message `DelaySeconds` | `InvalidParameterValue` |
| FIFO duplicate within the 5-min window | *none — the original MessageId is returned, not re-enqueued* |
| `Subscribe` with `FilterPolicyScope=MessageBody` | `InvalidParameter` |
| `Subscribe` with an `email` / `sms` / `lambda` / … protocol | `InvalidParameter` |
| `Publish` to a topic with zero matching subscriptions | *none — succeeds, message dropped* |
| `Publish` with `MessageGroupId` to a standard topic | `InvalidParameter` |
| FIFO topic `Subscribe` with `http` / a non-FIFO queue | `InvalidParameter` |
| Set a KMS / SSE queue attribute | `InvalidAttributeName` |
| `CreateQueue` with the same name + different attributes | `QueueNameExists` |
| `PurgeQueue` within the 60 s cooldown | `PurgeQueueInProgress` |
| Operate on a queue / topic not in the registry | `NonExistentQueue` / SNS `NotFound` |
| Unknown access key (configured-credentials mode) | `InvalidClientTokenId` |
| Wrong secret / tampered body / clock skew | `SignatureDoesNotMatch` |
| Unsigned request (non-`ConfirmSubscription`) | `IncompleteSignature` |
| An authorization deny on a data-plane op | `AccessDeniedException` |
| Unknown / out-of-scope action (`AddPermission`, message-move, etc.) | `InvalidAction` |
| Broker not ready (traffic gate) | an AWS-shaped 503 |
## Related [#related]
# Migrating from AWS SQS/SNS (/connectors/aws/reference/migration-from-aws)
**Point your existing AWS SQS/SNS application at KubeMQ by changing only the endpoint URL.**
Same AWS SDK, same code, same SQS and SNS wire protocols. There is no SDK to adopt, no proto,
no data migration — SQS data lives in normal KubeMQ Queue channels. Rollback is **config-only**
(`CONNECTORS_AWS_ENABLE=false`).
If you already run an SDK against a **LocalStack** endpoint, the switch is the same single
variable — point it at the KubeMQ AWS connector instead of LocalStack.
But several connector behaviors **deviate from real AWS**. Read the deviations below before you
migrate; most are invisible until a corner case hits production.
## Overview [#overview]
The AWS connector exposes the real AWS SQS and SNS wire protocols (the AWS JSON and Query
protocols) over HTTP, so unmodified AWS SDK clients — `boto3`, the AWS SDK for Go/JavaScript,
the AWS CLI — talk to KubeMQ without any code changes. An endpoint-override environment
variable is all that changes on the client side.
SQS queues map onto native KubeMQ Queue channels (`sqs.{name}`), making AWS producers and
native gRPC/REST consumers interoperable on the same messages. SNS topics are virtual
(registry-only) and fan out to SQS subscriptions and HTTP/HTTPS webhook endpoints. Requests are
authenticated with AWS Signature V4.
* **Canonical client:** AWS SDK `boto3` 1.x (Python).
* **Port:** `4566` (HTTP).
* **Drop-in level:** endpoint-only — change the SDK endpoint override; no application code
changes.
Port `4566` is a **client-side endpoint convention** (the LocalStack / SDK default), not a fixed
KubeMQ listener — the actual connector port is `Connectors.Aws.Port` and can be changed. That is
why it does not appear in KubeMQ's shared broker-ports tables, which track fixed listeners only.
The connector is **opt-in — disabled by default** (`Connectors.Aws.Enable = false`). Enable it
with its enable variable; this opens the HTTP listener on port `4566`:
## Compatibility Matrix [#compatibility-matrix]
The column below is the AWS SQS/SNS slice of the
[master cross-protocol matrix](/connectors/how-to/migration).
| Dimension | AWS SQS/SNS |
| -------------------------------------- | --------------------------------------------------------------------- |
| **Drop-in level** | endpoint-only |
| **Point-to-point queues** | ✅ SQS |
| **Pub/sub (non-durable)** | ✅ SNS |
| **Durable / persistent subscriptions** | ✅ (SQS durable) |
| **Request/reply (RPC)** | N/A (no RPC) |
| **Ordering guarantee** | ✅ FIFO |
| **Transactions** | N/A |
| **Dead-letter / redrive** | ✅ redrive + move-task |
| **Selectors / filtering / wildcards** | ✅ SNS filter policies¹ |
| **Auth model** | SigV4 / accept-any |
| **TLS / mTLS** | ❌ connector HTTP-only² |
| **Top unsupported** | SNS email/SMS/Lambda/push; queue/topic IAM policies; TLS at connector |
¹ SNS filter policies work with the `MessageAttributes` scope only; the `MessageBody`
scope is rejected (`InvalidParameter`).
² The connector listens on plain HTTP. Terminate TLS at a reverse proxy (see
[Security](#tls--terminate-at-a-reverse-proxy)).
## Connection / Endpoint Migration [#connection--endpoint-migration]
No code changes are required. Set the AWS SDK endpoint-override environment variables to point
at the KubeMQ host:
```bash title="Terminal"
# Before (real AWS): no override; the SDK uses the regional AWS endpoint.
# After (KubeMQ):
export AWS_ENDPOINT_URL_SQS=http://kubemq-host:4566
export AWS_ENDPOINT_URL_SNS=http://kubemq-host:4566
export AWS_ACCESS_KEY_ID=AKIAEXAMPLE
export AWS_SECRET_ACCESS_KEY=secret
export AWS_DEFAULT_REGION=kubemq # any region value works; the region segment is not enforced
```
All current AWS SDKs and the AWS CLI honor `AWS_ENDPOINT_URL_SQS` / `AWS_ENDPOINT_URL_SNS`. If
your SDK version predates these variables, use the per-client endpoint override instead:
```python
import boto3
sqs = boto3.client(
"sqs",
endpoint_url="http://kubemq-host:4566",
region_name="kubemq",
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key="secret",
)
```
In the default **accept-any** mode the connector does not cryptographically verify the
signature — but the SDK must still form a **syntactically valid** SigV4 request whose
credential-scope service is `sqs` or `sns`. So you must give the SDK a dummy access key, secret,
and region (any values); omitting them yields a "missing credentials" SDK error. The only
SigV4-exempt action is SNS `ConfirmSubscription`.
### Credential mapping [#credential-mapping]
For each `AccessKeyId` your application uses, add a credential entry to the KubeMQ
configuration. The `ClientID` field maps that key to a KubeMQ identity for authorization and
audit:
```toml title="config.toml"
[Connectors.Aws]
Enable = true
Port = "4566"
[[Connectors.Aws.Credentials]]
AccessKeyId = "AKIAEXAMPLE"
SecretAccessKey = "secret"
ClientID = "billing-service" # optional; defaults to AccessKeyId
```
### Recreate resources [#recreate-resources]
Queues, topics, subscriptions, and their attributes/tags must be recreated through the AWS API
against KubeMQ. The registry is authoritative — existing SQS/SNS resources from real AWS are
**not** migrated automatically.
## Concept & Destination Mapping [#concept--destination-mapping]
| AWS concept | KubeMQ pattern | KubeMQ channel |
| ---------------------- | ----------------------- | ------------------------------------------------- |
| SQS standard queue | Queues | `sqs.{queue-name}` |
| SQS FIFO queue | Queues (per group) | `sqs.{queue-name}` per `MessageGroupId` |
| SNS topic | Virtual (registry-only) | `sns.{topic-name}` (authorization pseudo-channel) |
| SNS subscription → SQS | Queues (fan-out) | `sqs.{target-queue-name}` |
**FIFO ordering:** each `MessageGroupId` maps to its own KubeMQ Queue channel, preserving
per-group ordering. `MessageGroupId` is required on every FIFO send.
**Message attributes** round-trip losslessly through KubeMQ message Tags
(`sqs_attr_{Name}` = `{DataType}|{value}`). The connector also stamps `sqs_message_id`,
`sqs_sender_id` (the authenticated `ClientID`), and `sqs_trace_header` when present.
**Native interop:** native KubeMQ gRPC/REST clients can produce and consume on `sqs.*` channels
directly. Natively produced messages lack `sqs_*` tags; `MessageId` falls back to the broker
MessageID and no per-message redrive policy is stamped (see [deviations](#native-producers-bypass-sqs-policy-stamping)).
**ARNs** use the configured `Region` (default `kubemq`) and `AccountId` (default
`000000000000`). Set `Connectors.Aws.Region` / `Connectors.Aws.AccountId` if your tooling
validates ARN format.
## Canonical Client Example [#canonical-client-example]
The examples use the AWS SDK `boto3` 1.x: `boto3.client`, `create_queue`, `send_message`,
`receive_message`, `delete_message`, `create_topic`, `subscribe`, and `publish`.
### SQS — create, send, receive, delete [#sqs--create-send-receive-delete]
```python
import boto3
# boto3 1.x — point the SDK at KubeMQ
sqs = boto3.client(
"sqs",
endpoint_url="http://kubemq-host:4566",
region_name="kubemq",
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key="secret",
)
# Create a standard queue (idempotent — returns the URL if it already exists)
resp = sqs.create_queue(QueueName="orders")
queue_url = resp["QueueUrl"]
# Send a message
sqs.send_message(
QueueUrl=queue_url,
MessageBody='{"orderId": "A-001", "amount": 99.95}',
MessageAttributes={
"source": {"DataType": "String", "StringValue": "checkout-service"},
},
)
# Receive (long-poll up to 20 s)
resp = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=1,
WaitTimeSeconds=20,
MessageAttributeNames=["All"],
)
for msg in resp.get("Messages", []):
print(f"Received: {msg['Body']}")
# Acknowledge by deleting
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"])
```
### FIFO queue [#fifo-queue]
```python
# Create a FIFO queue (name must end in .fifo)
resp = sqs.create_queue(
QueueName="orders.fifo",
Attributes={
"FifoQueue": "true",
"ContentBasedDeduplication": "true",
},
)
fifo_url = resp["QueueUrl"]
# Send to a specific message group (preserves ordering per group)
sqs.send_message(
QueueUrl=fifo_url,
MessageBody='{"orderId": "A-002"}',
MessageGroupId="region-us-east",
)
```
### Dead-letter queue (redrive) [#dead-letter-queue-redrive]
```python
import json
# 1. Create the DLQ
dlq_resp = sqs.create_queue(QueueName="orders-dlq")
dlq_url = dlq_resp["QueueUrl"]
dlq_arn = sqs.get_queue_attributes(
QueueUrl=dlq_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
# 2. Attach a redrive policy to the source queue
sqs.set_queue_attributes(
QueueUrl=queue_url,
Attributes={
"RedrivePolicy": json.dumps({
"deadLetterTargetArn": dlq_arn,
"maxReceiveCount": "3",
}),
},
)
# After 3 failed receives the broker automatically moves the message to orders-dlq.
```
### SNS fan-out (topic → SQS) [#sns-fan-out-topic--sqs]
```python
sns = boto3.client(
"sns",
endpoint_url="http://kubemq-host:4566",
region_name="kubemq",
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key="secret",
)
# Create the topic and subscribe the SQS queue
topic_arn = sns.create_topic(Name="product-events")["TopicArn"]
orders_arn = sqs.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
sns.subscribe(
TopicArn=topic_arn,
Protocol="sqs", # only 'sqs' and 'http'/'https' are supported
Endpoint=orders_arn,
)
# Publish — the message fans out to all confirmed subscriptions
sns.publish(
TopicArn=topic_arn,
Message='{"event": "product.created", "id": "P-100"}',
MessageAttributes={
"category": {"DataType": "String", "StringValue": "electronics"},
},
)
```
## Security [#security]
### Authentication — SigV4 [#authentication--sigv4]
The connector verifies AWS Signature V4 (`Authorization: AWS4-HMAC-SHA256 …`). Header-style and
query-string-style signatures are both supported, with a ±15-minute clock-skew window.
* Credentials are configured under `Connectors.Aws.Credentials` (or `CredentialsData` for
environment / Kubernetes Secret injection).
* **Accept-any mode:** if no credentials are configured the connector only parses the
`AccessKeyId` and uses it as the `ClientID`. This is intended for local development only and is
logged once at startup.
* Failure cases: unknown key → HTTP 403 `InvalidClientTokenId`; bad signature / clock skew →
HTTP 403 `SignatureDoesNotMatch`; malformed header → HTTP 400 `IncompleteSignature`.
* `ConfirmSubscription` requests with no `Authorization` header bypass SigV4 — the single-purpose
confirmation token is the authenticator (AWS parity). No other action is exempt.
### TLS — terminate at a reverse proxy [#tls--terminate-at-a-reverse-proxy]
The connector listens on plain **HTTP only**. It does **not** support TLS natively. To secure
traffic in transit, place a TLS-terminating reverse proxy (nginx, Envoy, HAProxy, an AWS ALB, …)
in front of the connector port, and configure clients to target the proxy's HTTPS endpoint.
### Optional SNS message signing (off by default) [#optional-sns-message-signing-off-by-default]
By default (`Connectors.Aws.MessageSigning = false`) SNS notification envelopes are unsigned.
Set `Connectors.Aws.MessageSigning = true` to emit SigV2 RSA-SHA256 signatures. Note that the
signing certificate is self-signed and not Amazon-rooted; SDK verifiers that pin Amazon's cert
chain will still reject the signature. See also
[SNS notification signatures are unsigned by default](#sns-notification-signatures-are-unsigned-by-default).
### Authorization (Casbin) [#authorization-casbin]
When the KubeMQ authorization service is enabled, every channel-mapped operation is enforced
against the existing Casbin policy:
| Operation class | Casbin check |
| ------------------------------------------------------------------------- | ------------------------ |
| `SendMessage`, `SendMessageBatch`, SNS `Publish` (per matched SQS target) | `write` on `sqs.{queue}` |
| `ReceiveMessage`, `DeleteMessage`, `ChangeMessageVisibility` | `read` on `sqs.{queue}` |
| Queue management (Create / Delete / Purge / Set / Tag) | `write` on `sqs.{queue}` |
| Topic & subscription management | `write` on `sns.{topic}` |
`ListQueues`, `ListTopics`, `ListSubscriptions`, and `GetQueueUrl` are allowed for any
authenticated principal and return unfiltered results. See
[Authentication & security](/connectors/reference/auth-and-security) for policy configuration.
## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations]
### Unsupported SNS subscription protocols [#unsupported-sns-subscription-protocols]
Only `sqs` and `http`/`https` subscription protocols are supported. The following AWS SNS
delivery targets are **not supported** and return `InvalidParameter`:
* Email / email-JSON
* SMS
* AWS Lambda
* Mobile push (APNs, GCM, ADM, Baidu)
FIFO topics additionally restrict subscriptions to `sqs` only (`http`/`https` are rejected for
`.fifo` topics).
### No TLS at the connector [#no-tls-at-the-connector]
The connector is HTTP-only (see [TLS — terminate at a reverse proxy](#tls--terminate-at-a-reverse-proxy)).
In particular, do not configure clients to send to `https://kubemq-host:4566` directly.
### Queue/topic IAM policies ignored [#queuetopic-iam-policies-ignored]
`Policy` fields on `SetQueueAttributes` and `SetTopicAttributes` are accepted but **not
enforced**. Authorization is handled by KubeMQ's Casbin engine (see above).
### FIFO `SequenceNumber` is broker-derived [#fifo-sequencenumber-is-broker-derived]
FIFO `SequenceNumber` is derived from the broker-assigned timestamp and zero-padded to 20
digits. On send it reflects the broker send-timestamp; on receive it reflects the true broker
sequence (still per-group increasing). It is **not** a monotonic counter matching AWS semantics —
do not use it for ordering comparisons across producers.
### FIFO topic `ContentBasedDeduplication` not supported [#fifo-topic-contentbaseddeduplication-not-supported]
Topic-level `ContentBasedDeduplication` on a FIFO topic is unsupported: setting it returns
`InvalidParameter` and getting it always reads `"false"`. Deduplication is enforced on the target
FIFO queues, not at the topic level — pass an explicit `MessageDeduplicationId` instead.
### Message retention clamped [#message-retention-clamped]
`MessageRetentionPeriod` is clamped to `Connectors.Aws.MaxExpirationSeconds` at send time.
Changes to `SetQueueAttributes` retention are **not** retroactive to messages already in the
queue (AWS applies them retroactively).
### SNS notification signatures are unsigned by default [#sns-notification-signatures-are-unsigned-by-default]
By default (`Connectors.Aws.MessageSigning = false`) the SNS notification envelope is unsigned
(`SignatureVersion: "1"`, empty `Signature` / `SigningCertURL`) — webhook consumers must skip
verification. Set `Connectors.Aws.MessageSigning = true` to emit `SignatureVersion: "2"` SigV2
RSA-SHA256 signatures (self-signed cert; not Amazon-rooted, so SDK verifiers that pin Amazon's
cert chain still won't validate it).
### Receipt handles and the in-flight tracker are node-local [#receipt-handles-and-the-in-flight-tracker-are-node-local]
In a cluster, `DeleteMessage` and `ChangeMessageVisibility` must reach the **same node** that
served the `ReceiveMessage`. The receipt handle encodes the node ID; other nodes return
`ReceiptHandleIsInvalid`. Use session-sticky (source-IP or connection-sticky) load balancing, or
pin each consumer to one node.
### Hard crash loses in-flight messages and pending webhook retries [#hard-crash-loses-in-flight-messages-and-pending-webhook-retries]
KubeMQ's downstream read is destructive. Only the graceful-shutdown path returns in-flight
messages to their queues; SNS delivery/retry state is in-memory on the publishing node, so a
hard kill loses it.
### Native producers bypass SQS policy stamping [#native-producers-bypass-sqs-policy-stamping]
Messages published to `sqs.*` channels by native KubeMQ gRPC/REST clients carry no per-message
retention or redrive policy and no `sqs_*` identity tags.
### Empty-queue short-poll latency [#empty-queue-short-poll-latency]
A `ReceiveMessage` with `WaitTimeSeconds=0` on an empty queue returns within \~1 second instead of
immediately (the broker's downstream wait granularity has a 1-second minimum).
### Other out-of-scope operations [#other-out-of-scope-operations]
The following simply **won't work** (see
[Capabilities](/connectors/aws/reference/capabilities)): KMS / SSE,
`AddPermission` / `RemovePermission`, SQS message-move tasks, signed-notification verification,
extended-client messages over 256 KiB, and CloudWatch metrics emulation. Cross-account is
unsupported — `QueueOwnerAWSAccountId` is accepted and ignored; there is a single configurable
`AccountId`.
## Verification Smoke Test [#verification-smoke-test]
Run this after enabling the connector (`CONNECTORS_AWS_ENABLE=true`) and recreating any needed
queues. It is the same `boto3` 1.x code as a one-pass send → receive → delete confirmation:
```python
import boto3
ENDPOINT = "http://kubemq-host:4566"
CREDS = dict(
endpoint_url=ENDPOINT,
region_name="kubemq",
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key="secret",
)
sqs = boto3.client("sqs", **CREDS)
# 1. Ensure the queue exists
queue_url = sqs.create_queue(QueueName="smoke-test")["QueueUrl"]
# 2. Publish one message
sqs.send_message(QueueUrl=queue_url, MessageBody="smoke-test-payload")
print("Sent: smoke-test-payload")
# 3. Receive and confirm arrival
resp = sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5)
msgs = resp.get("Messages", [])
assert msgs, "ERROR: no message received"
assert msgs[0]["Body"] == "smoke-test-payload", f"Unexpected body: {msgs[0]['Body']}"
print(f"Received: {msgs[0]['Body']}")
# 4. Acknowledge
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msgs[0]["ReceiptHandle"])
print("Acknowledged. Smoke test PASSED.")
```
## See Also [#see-also]
# Configuration model (/connectors/cloudevents/concepts/configuration-model)
The CloudEvents connector is configured through the `Connectors.CE` section of the
KubeMQ server config. It is **enabled by default** and ships with sensible
production defaults, so most deployments only override a value when they need a
larger SSE buffer or a hard cap on concurrent subscriptions. This page explains the
enable model and how config keys map to environment variable names — for the full
field list, validation rules, and TOML/Env/Docker examples, see the
[full field reference](../reference/configuration).
## Enabled by default [#enabled-by-default]
The CloudEvents connector is **on as soon as kubemq-server starts** — the `/ce/*`
routes on the [shared HTTP server](/connectors/concepts/shared-http-server) are live
with no flag to set. You never enable it with `=true`; you only set its enable
variable to `false` to turn it **off**.
**The enable variable is `CONNECTORSCE_ENABLE` — with no underscore between
`CONNECTORS` and `CE`.** Older KubeMQ docs and the configuration reference list
`CONNECTORS_CE_ENABLE` (with an underscore), which **does not bind** to the
`Connectors.CE.Enable` field and is silently ignored. The correct name is derived
from the server's env-var transform — see
[Environment variable names](#environment-variable-names) below and the
[shared HTTP server enable model](/connectors/concepts/shared-http-server#enable-model-on-by-default).
## Environment variable names [#environment-variable-names]
KubeMQ derives each environment variable from its dotted config key by inserting
underscores at word boundaries, dropping the dots, and upper-casing. Acronyms with
no lowercase boundary (like `CE`) stay joined to the preceding segment — which is
why the enable variable is `CONNECTORSCE_ENABLE` and **not**
`CONNECTORS_CE_ENABLE`.
The full config key to environment variable mapping table is in the
[configuration reference](../reference/configuration#environment-variable-names).
The CloudEvents connector shares the HTTP server's port (`9090`), body limit, CORS,
and TLS settings. Those are configured under `Connectors.Http` and documented once
in [Shared HTTP server](/connectors/concepts/shared-http-server) — not repeated here.
# Authentication (/connectors/cloudevents/how-to/authentication)
The CloudEvents connector has no authentication of its own. It shares the
[auth middleware](/connectors/reference/auth-and-security) of the shared HTTP server with
the REST, MCP, and A2A connectors, so a single JWT Bearer token secures every
`/ce/*` endpoint.
## Overview [#overview]
When authentication is enabled on the KubeMQ server, every CloudEvents request —
both publishing (`POST /ce/send/*`, `POST /ce/queue/*`) and subscribing
(`GET /ce/subscribe/*`) — must carry a valid JWT in the `Authorization` header:
```http
Authorization: Bearer
```
Because `/ce/*` endpoints are standard HTTP (not JSON-RPC), an authentication
failure returns **HTTP 401 Unauthorized**, unlike the MCP and A2A gateways, which
return the JSON-RPC `-32010` error code. When authentication is disabled, requests
are accepted with synthetic anonymous claims and no header is required.
Token issuance and validation (shared secret vs OIDC provider, claim schema) are
configured server-wide. See [Auth & security](/connectors/reference/auth-and-security)
for the full model — public routes, CORS, origin validation, and TLS/mTLS.
## How it works [#how-it-works]
A CloudEvents request passes through the shared auth middleware before reaching the
CE handler; the verified `ClientID` claim flows downstream and becomes the KubeMQ
message identity.
*The shared auth middleware verifies the Bearer token, then hands the resolved ClientID to the CloudEvents connector.*
## ClientID resolution [#clientid-resolution]
The KubeMQ `ClientID` attached to a published message is resolved with authentication
taking priority over the event payload:
| Authentication | ClientID source |
| --------------------- | ---------------------------------------------- |
| Disabled | CloudEvent `source` attribute |
| Enabled (valid token) | JWT claims `ClientID` — **overrides** `source` |
When auth is enabled, the CloudEvent `source` is still preserved as the `ce_source`
tag (so the event round-trips intact), but the KubeMQ `ClientID` is set from the
verified token. This ensures identity is controlled by the authentication system
rather than by client-supplied data. The same override applies to the `client_id`
query parameter on queue and subscribe endpoints.
## Authenticated publish [#authenticated-publish]
Add the `Authorization` header to any send request. The token format and header are
identical across all CloudEvents endpoints — events, events-store, queues, commands,
and queries.
```bash
curl -X POST http://localhost:9090/ce/send/event \
-H "Authorization: Bearer $JWT_TOKEN" \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"subject": "orders",
"data": {"order_id": "12345", "amount": 99.99}
}'
```
```go
req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("JWT_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send event:", err)
}
defer resp.Body.Close()
```
```python
headers, body = to_structured(event)
headers["Authorization"] = f"Bearer {os.environ['JWT_TOKEN']}"
resp = requests.post(
f"{base}/ce/send/event",
data=body,
headers=dict(headers),
timeout=10,
)
```
```typescript
const message = HTTP.structured(event);
const resp = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: {
...(message.headers as Record),
Authorization: `Bearer ${process.env.JWT_TOKEN}`,
},
body: message.body as string,
});
```
```java
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.header("Content-Type", "application/cloudevents+json")
.header("Authorization", "Bearer " + System.getenv("JWT_TOKEN"))
.build();
HttpResponse response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
```
```csharp
using var content = new ByteArrayContent(eventBytes.ToArray());
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString());
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("JWT_TOKEN"));
var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content);
```
```ruby
headers, body = sdk.encode_event(event, structured_format: "json")
uri = URI("#{base}/ce/send/event")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = headers["Content-Type"]
req["Authorization"] = "Bearer #{ENV.fetch('JWT_TOKEN')}"
req.body = body
res = http.request(req)
end
```
```rust
let body = serde_json::to_string(&event)?;
let resp = client
.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/cloudevents+json")
.header("Authorization", format!("Bearer {}", env::var("JWT_TOKEN")?))
.body(body)
.send()
.await?;
```
## Authenticated subscribe [#authenticated-subscribe]
SSE subscriptions are long-lived `GET` requests; the Bearer token is sent once when
the stream is opened.
```bash
curl -N "http://localhost:9090/ce/subscribe/events?client_id=my-client&channel=orders" \
-H "Authorization: Bearer $JWT_TOKEN" \
-H "Accept: text/event-stream"
```
```go
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Authorization", "Bearer "+os.Getenv("JWT_TOKEN"))
resp, err := http.DefaultClient.Do(req)
```
```python
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream",
"Cache-Control": "no-cache",
"Authorization": f"Bearer {os.environ['JWT_TOKEN']}"}) as resp:
for raw_line in resp.iter_lines(decode_unicode=True):
...
```
```typescript
const es = new EventSource(sseUrl, {
headers: { Authorization: `Bearer ${process.env.JWT_TOKEN}` },
});
es.addEventListener('cloudevent', (evt: MessageEvent) => {
console.log(JSON.parse(evt.data));
});
```
```java
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("Cache-Control", "no-cache");
conn.setRequestProperty("Authorization", "Bearer " + System.getenv("JWT_TOKEN"));
conn.setDoInput(true);
```
```csharp
using var request = new HttpRequestMessage(HttpMethod.Get, sseUrl);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("JWT_TOKEN"));
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
```
```ruby
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri)
req["Accept"] = "text/event-stream"
req["Cache-Control"] = "no-cache"
req["Authorization"] = "Bearer #{ENV.fetch('JWT_TOKEN')}"
http.request(req) do |resp|
...
end
end
```
```rust
let stream = client
.get(&sub_url)
.header("Accept", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Authorization", format!("Bearer {}", env::var("JWT_TOKEN")?))
.send()
.await?
.bytes_stream();
```
## Running without authentication [#running-without-authentication]
When authentication is disabled in the server configuration, the CloudEvents
connector accepts every request with synthetic anonymous claims:
* No `Authorization` header is required.
* The CloudEvent `source` attribute is used directly as the KubeMQ `ClientID`.
* The examples throughout the CloudEvents docs assume this mode unless stated otherwise.
Running without authentication is for development and trusted networks only. Enable
JWT authentication before exposing the CloudEvents connector in production.
## Related [#related]
# CESQL Routing (/connectors/cloudevents/how-to/cesql-routing)
CESQL (CloudEvents SQL) lets the KubeMQ routing table make fan-out decisions from a CloudEvent's **attributes** — its `type`, `source`, `subject`, and extensions — instead of matching on the channel name. Publishers send to one channel; the server evaluates CESQL rules against the `ce_*` tags and routes each event to the matching destinations.
## Overview [#overview]
The CloudEvents connector maps every CE attribute onto a KubeMQ message tag with a `ce_` prefix (`type` → `ce_type`, `source` → `ce_source`, and so on). The server-side **routing table** can carry CESQL rules alongside regular regex rules: each rule with `keyType: "cesql"` is a boolean expression evaluated against those attributes. When the expression returns `true`, the rule's `routes` are applied — potentially fanning the event across events, events store, and queues at once.
This is **content-based routing**: the decision comes from the message's attributes, not its body or channel name. It is configured entirely on the server (no client code change), and any message carrying `ce_*` tags — including gRPC or REST messages that set `ce_specversion` — is eligible, not just CE-connector traffic.
| Concept | Detail |
| ----------------- | --------------------------------------------------------------------------------------- |
| Rule type | `keyType: "cesql"` entry in the routing table |
| Evaluated against | CloudEvent attributes reconstructed from `ce_*` tags |
| Eligible messages | Any message with a `ce_specversion` tag |
| Destinations | Standard routing syntax (`events:`, `events_store:`, `queues:`) with `{ce_*}` templates |
| Failure mode | Fail-open — a failing rule is skipped, delivery continues |
CESQL routing is part of the **server-wide routing engine**, not a CE-only feature. This guide covers the CESQL-specific behavior.
## How it works [#how-it-works]
A publisher sends a CloudEvent to a source channel; the connector tags it with `ce_*` attributes and hands it to the routing engine, which evaluates each CESQL rule and fans the event out to every matching destination channel.
*The routing table evaluates each CESQL rule against the event's attributes and fans it out to every matching channel.*
## Configuration [#configuration]
CESQL rules live in the routing table. Set `keyType` to `"cesql"` and put a CESQL expression in `key`; the `routes` field uses the standard routing syntax and supports `{ce_*}` templates.
```json
[
{
"key": "type = 'com.example.order.created'",
"keyType": "cesql",
"routes": "events_store:order-archive;queues:order-processing"
},
{
"key": "source = 'audit-service'",
"keyType": "cesql",
"routes": "events_store:{ce_source}-log"
},
{
"key": "type LIKE 'com.example.%' AND source = 'critical-service'",
"keyType": "cesql",
"routes": "events:alerts"
}
]
```
Enable routing and supply the table inline, from a file, or from a URL:
```toml
[Routing]
Enable = true
# Inline JSON (note the escaped single quotes inside the CESQL expression):
Data = '[{"key":"type = '\''com.example.order.created'\''","keyType":"cesql","routes":"queues:order-processing"}]'
# Or load from a file:
# FilePath = "/etc/kubemq/routes.json"
# Or from a config service:
# URL = "http://config-service/routes"
AutoReload = 0
```
In CESQL expressions, reference attributes by their **plain CloudEvents name** (`type`, `source`, `subject`, `id`) with **no** `ce_` prefix. The `ce_` prefix only appears in `{ce_*}` template placeholders on the `routes` side.
## Supported operators [#supported-operators]
KubeMQ uses the CloudEvents SDK CESQL parser. The following operators and functions are available:
| Category | Operators / Functions | Example |
| ---------------- | -------------------------------------------------------------------------- | ------------------------------------ |
| Comparison | `=`, `!=`, `<>`, `<`, `<=`, `>`, `>=` | `type = 'order.created'` |
| Logical | `AND`, `OR`, `NOT` | `type = 'order' AND source = '/app'` |
| String match | `LIKE` (with `%` wildcard) | `type LIKE 'order.%'` |
| Existence | `EXISTS` | `EXISTS priority` |
| Set | `IN` | `type IN ('a', 'b', 'c')` |
| String functions | `CONCAT`, `LENGTH`, `LOWER`, `UPPER`, `TRIM`, `LEFT`, `RIGHT`, `SUBSTRING` | `LENGTH(type) > 10` |
| Type checks | `IS_BOOL`, `IS_INT` | `IS_INT(source)` |
| Math | `+`, `-`, `*`, `/`, `%` | integer attribute comparisons |
## Template substitution [#template-substitution]
Route destinations can embed CE attributes with `{ce_*}` placeholders, letting one rule fan out to dynamically named channels:
| Placeholder | Substituted with |
| -------------- | ------------------------------------------- |
| `{ce_type}` | The CloudEvent `type` attribute |
| `{ce_source}` | The CloudEvent `source` attribute |
| `{ce_subject}` | The CloudEvent `subject` attribute |
| `{ce_id}` | The CloudEvent `id` attribute |
| `{ce_*}` | Any CE attribute or extension from the tags |
For example, this rule routes each event to a channel named after its type:
```json
{
"key": "type LIKE 'com.example.%'",
"keyType": "cesql",
"routes": "queues:{ce_type}"
}
```
A message with `type: "com.example.order.created"` is routed to `queues:com.example.order.created`. Semicolons in attribute values are stripped during substitution to prevent route injection.
## Evaluation scope [#evaluation-scope]
CESQL rules are evaluated against **any message that carries `ce_*` tags**, regardless of which connector produced it:
* Messages from the CE connector automatically have `ce_*` tags and are evaluated.
* Messages sent over gRPC or REST that include `ce_specversion` and other `ce_*` tags are also evaluated.
* Messages **without** `ce_*` tags skip CESQL rules entirely — they are only matched against regex rules.
CESQL and regex rules coexist in the same table. Each message is evaluated against all rules in order: CESQL rules match on attributes, regex rules match on the channel name.
## Error behavior [#error-behavior]
CESQL evaluation is **fail-open**:
* If an expression fails to evaluate (for example, a type mismatch), that rule is skipped, a warning is logged, and the remaining rules still run.
* Invalid expressions are caught when the routing table is loaded; the offending entry is skipped with a warning.
* The original message delivery is never blocked by a routing failure.
## Usage [#usage]
CESQL routing is configured on the server, so the client side is just a normal CloudEvent publish. The examples below publish events whose `type` matches the rules above, then subscribe to the destination channels to confirm the routing took effect.
These examples require KubeMQ to be running **with the CESQL routing rules configured** (the three-rule table shown in the comments below). They publish to a source channel and read from the routed destination channels.
```bash
# Publish a CloudEvent — the server's CESQL rules route it by `type`.
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.kubemq.examples.routing.order",
"source": "kubemq-ce-curl-cesql",
"subject": "routing-source",
"datacontenttype": "application/json",
"data": {"description": "Event of type order"}
}'
# Read the routed destination channel (CESQL routed `order` -> events:order-archive).
curl -N "http://localhost:9090/ce/subscribe/events?client_id=curl-order-sub&channel=order-archive"
```
```csharp
// Example: routing/CesqlRouting — publish events for server-side CESQL routing.
// Requires KubeMQ configured with CESQL rules (see Go example for config).
// Run: dotnet run
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var formatter = new JsonEventFormatter();
Console.WriteLine("CESQL Routing Example — C#");
Console.WriteLine("Requires KubeMQ with CESQL routing configured.\n");
var results = new System.Collections.Concurrent.ConcurrentQueue();
// Subscribe to routed destination channels.
async Task StartSubscriber(string ch, string clientId, int maxEvents)
{
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var url = $"{base_}/ce/subscribe/events?client_id={clientId}&channel={Uri.EscapeDataString(ch)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8);
string? evType = null, data = null, line; int count = 0;
while ((line = await reader.ReadLineAsync()) != null && count < maxEvents)
{
if (line == "") {
if (evType == "cloudevent" && data != null) {
var ce = JsonSerializer.Deserialize(data);
results.Enqueue($" [{ch}] type={ce.GetProperty("type")}");
count++;
}
evType = null; data = null;
}
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
}
var subTasks = new List {
Task.Run(() => StartSubscriber("order-archive", "csharp-order-sub", 1)),
Task.Run(() => StartSubscriber("alert-stream", "csharp-alert-sub", 1)),
Task.Run(() => StartSubscriber("all-events", "csharp-all-sub", 3)),
};
await Task.Delay(500);
using var httpClient = new HttpClient();
var eventTypes = new[] {
"com.kubemq.examples.routing.order",
"com.kubemq.examples.routing.alert",
"com.kubemq.examples.routing.info",
};
foreach (var evType in eventTypes)
{
var ev = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = evType,
Source = new Uri("urn:kubemq-ce-csharp-cesql"),
Subject = "routing-source",
DataContentType = "application/json",
Data = new { description = $"Event of type {evType}" },
};
var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct);
using var c = new ByteArrayContent(bytes.ToArray());
c.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString());
var r = await httpClient.PostAsync($"{base_}/ce/send/event", c);
Console.WriteLine($"Published type={evType} (status={r.StatusCode})");
}
await Task.Delay(2000);
while (results.TryDequeue(out var msg)) Console.WriteLine(msg);
Console.WriteLine("\nCESQL routing demonstration complete.");
```
```go
// Example: routing/cesql-routing
//
// Demonstrates KubeMQ CESQL routing with CloudEvents.
// CESQL routing is SERVER-SIDE configuration — this example shows how to
// publish events whose attributes match CESQL expressions, then verifies
// routing worked by subscribing to the target channels.
//
// Configure KubeMQ with the following routing rules before running:
//
// [Routing]
// Enable = true
// Data = '[
// {"key":"type = '\''com.kubemq.examples.routing.order'\''","keyType":"cesql","routes":"events:order-archive"},
// {"key":"type = '\''com.kubemq.examples.routing.alert'\''","keyType":"cesql","routes":"events:alert-stream"},
// {"key":"type LIKE '\''com.kubemq.examples.routing.%'\''","keyType":"cesql","routes":"events:all-events"}
// ]'
//
// Run: go run ./routing/cesql-routing/main.go
package main
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
func sendEvent(base, evType, channel string) {
event := cloudevents.NewEvent()
event.SetType(evType)
event.SetSource("kubemq-ce-go-cesql")
event.SetSubject(channel)
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{
"description": fmt.Sprintf("Event of type %s", evType),
})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("send error: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("Published type=%s to channel=%s (status=%d)\n", evType, channel, resp.StatusCode)
}
func subscribeAndPrint(base, channel, clientID string, maxMsgs int) {
sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s",
base, clientID, channel)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
client := &http.Client{Timeout: 0}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[%s] subscribe error: %v\n", channel, err)
return
}
defer resp.Body.Close()
count := 0
scanner := bufio.NewScanner(resp.Body)
var evType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if evType == "cloudevent" && data != "" {
var ce map[string]interface{}
_ = json.Unmarshal([]byte(data), &ce)
fmt.Printf(" [%s] type=%v\n", channel, ce["type"])
count++
if count >= maxMsgs {
return
}
}
evType, data = "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "event:") {
evType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
}
func main() {
base := serverURL()
// Subscribe to routed destination channels.
go subscribeAndPrint(base, "order-archive", "go-cesql-order-sub", 1)
go subscribeAndPrint(base, "alert-stream", "go-cesql-alert-sub", 1)
go subscribeAndPrint(base, "all-events", "go-cesql-all-sub", 3)
time.Sleep(500 * time.Millisecond)
// Publish events — CESQL rules on the server route them to target channels.
sendEvent(base, "com.kubemq.examples.routing.order", "routing-source")
sendEvent(base, "com.kubemq.examples.routing.alert", "routing-source")
sendEvent(base, "com.kubemq.examples.routing.info", "routing-source")
// Allow routing to complete.
time.Sleep(2 * time.Second)
fmt.Println("\nCESQL routing demonstration complete.")
}
```
```java
// Example: routing/cesql-routing
//
// Demonstrates server-side CESQL routing. Events with different type values
// are published to the source channel. KubeMQ routes them based on CESQL
// expressions to different destination channels.
//
// Requires KubeMQ configured with CESQL routing rules, e.g.:
// [Routing]
// Enable = true
// Data = '[{"key":"type = ''com.kubemq.examples.routing.order''","keyType":"cesql","routes":"events:order-archive"},...]'
//
// Run: mvn compile exec:java
package io.kubemq.examples.routing.cesqlrouting;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
public class Main {
static String serverUrl() {
String u = System.getenv("KUBEMQ_CE_URL");
return (u != null && !u.isEmpty()) ? u : "http://localhost:9090";
}
static final ObjectMapper MAPPER = new ObjectMapper();
static void startSubscriber(String base, String channel, String clientId,
int maxEvents, BlockingQueue results) {
String sseUrl = base + "/ce/subscribe/events?client_id=" + clientId + "&channel=" + channel;
Thread.ofVirtual().start(() -> {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(15_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null; int count = 0;
while ((line = reader.readLine()) != null && count < maxEvents) {
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
Map, ?> ce = MAPPER.readValue(data, Map.class);
results.offer(" [" + channel + "] type=" + ce.get("type"));
count++;
}
evType = null; data = null;
} else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
} catch (Exception e) { /* read ended */ }
});
}
public static void main(String[] args) throws Exception {
String base = serverUrl();
System.out.println("CESQL Routing Example — Java");
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
HttpClient httpClient = HttpClient.newHttpClient();
// Subscribe to routed destination channels.
BlockingQueue results = new ArrayBlockingQueue<>(20);
startSubscriber(base, "order-archive", "java-order-sub", 1, results);
startSubscriber(base, "alert-stream", "java-alert-sub", 1, results);
startSubscriber(base, "all-events", "java-all-sub", 3, results);
Thread.sleep(500);
// Publish events with different type values; CESQL rules route them.
List eventTypes = List.of(
"com.kubemq.examples.routing.order",
"com.kubemq.examples.routing.alert",
"com.kubemq.examples.routing.info"
);
for (String evType : eventTypes) {
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType(evType)
.withSource(URI.create("kubemq-ce-java-cesql"))
.withSubject("routing-source")
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("description", "Event of type " + evType)))
.build();
HttpResponse resp = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(event)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
System.out.println("Published type=" + evType + " (status=" + resp.statusCode() + ")");
}
// Collect routed events (up to 5 with timeout).
Thread.sleep(2000);
for (int i = 0; i < 5; i++) {
String msg = results.poll(100, TimeUnit.MILLISECONDS);
if (msg != null) System.out.println(msg);
}
System.out.println("\nCESQL routing demonstration complete.");
}
}
```
```typescript
/**
* Example: routing/cesql-routing — publish events for CESQL server-side routing.
* Requires KubeMQ configured with CESQL rules (see Go example for config).
* Run: npx tsx routing/cesql-routing/index.ts
*/
import { CloudEvent, HTTP } from 'cloudevents';
import EventSource from 'eventsource';
function serverUrl(): string {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
function subscribeAndCollect(
base: string, channel: string, clientId: string, max: number, results: string[],
): void {
const url = `${base}/ce/subscribe/events?client_id=${clientId}&channel=${encodeURIComponent(channel)}`;
const es = new EventSource(url);
let count = 0;
es.addEventListener('cloudevent', (evt: MessageEvent) => {
const ce = JSON.parse(evt.data) as Record;
results.push(` [${channel}] type=${ce.type}`);
count++;
if (count >= max) es.close();
});
es.addEventListener('error', (err) => {
console.error('SSE error:', err);
es.close();
});
}
async function main(): Promise {
const base = serverUrl();
console.log('CESQL Routing Example — JavaScript/TypeScript');
const results: string[] = [];
subscribeAndCollect(base, 'order-archive', 'js-order-sub', 1, results);
subscribeAndCollect(base, 'alert-stream', 'js-alert-sub', 1, results);
subscribeAndCollect(base, 'all-events', 'js-all-sub', 3, results);
await new Promise((r) => setTimeout(r, 500));
for (const evType of [
'com.kubemq.examples.routing.order',
'com.kubemq.examples.routing.alert',
'com.kubemq.examples.routing.info',
]) {
const event = new CloudEvent({
type: evType,
source: 'kubemq-ce-js-cesql',
subject: 'routing-source',
datacontenttype: 'application/json',
data: { description: `Event of type ${evType}` },
});
const msg = HTTP.structured(event);
const resp = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: msg.headers as Record,
body: msg.body as string,
});
console.log(`Published type=${evType} (status=${resp.status})`);
}
await new Promise((r) => setTimeout(r, 2000));
for (const r of results) console.log(r);
console.log('\nCESQL routing demonstration complete.');
}
main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
```
```python
# Example: routing/cesql_routing — publish events for server-side CESQL routing.
#
# CESQL routing is SERVER-SIDE configuration. Required KubeMQ config (TOML):
# [Routing]
# Enable = true
# Data = '[
# {"key":"type = '\''com.kubemq.examples.routing.order'\''","keyType":"cesql","routes":"events:order-archive"},
# {"key":"type = '\''com.kubemq.examples.routing.alert'\''","keyType":"cesql","routes":"events:alert-stream"},
# {"key":"type LIKE '\''com.kubemq.examples.routing.%'\''","keyType":"cesql","routes":"events:all-events"}
# ]'
from __future__ import annotations
import json
import os
import threading
import time
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def subscribe_and_print(base: str, channel: str, client_id: str,
results: list[str], max_msgs: int) -> None:
sse_url = (f"{base}/ce/subscribe/events"
f"?client_id={client_id}&channel={channel}")
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
ev_type = data = ""
count = 0
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
ce = json.loads(data)
results.append(f" [{channel}] type={ce.get('type')}")
count += 1
if count >= max_msgs:
return
ev_type = data = ""
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
def main() -> None:
base = server_url()
print("CESQL Routing Example — Python\n")
results: list[str] = []
# Subscribe to routed channels.
for ch, cid, n in [("order-archive", "py-order-sub", 1),
("alert-stream", "py-alert-sub", 1),
("all-events", "py-all-sub", 3)]:
t = threading.Thread(
target=subscribe_and_print,
args=(base, ch, cid, results, n),
daemon=True,
)
t.start()
time.sleep(0.5)
for ev_type in [
"com.kubemq.examples.routing.order",
"com.kubemq.examples.routing.alert",
"com.kubemq.examples.routing.info",
]:
event = CloudEvent(
attributes={
"type": ev_type,
"source": "kubemq-ce-python-cesql",
"subject": "routing-source",
"datacontenttype": "application/json",
},
data={"description": f"Event of type {ev_type}"},
)
headers, body = to_structured(event)
resp = requests.post(f"{base}/ce/send/event", data=body,
headers=dict(headers), timeout=10)
print(f"Published type={ev_type} (status={resp.status_code})")
time.sleep(2)
for r in results:
print(r)
print("\nCESQL routing demonstration complete.")
if __name__ == "__main__":
main()
```
```ruby
# Example: routing/cesql_routing — publish events for server-side CESQL routing.
# Requires KubeMQ configured with CESQL rules (see Go example for config).
require "net/http"; require "uri"; require "json"; require "timeout"; require "securerandom"; require "cloud_events"
def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
base = server_url; sdk = CloudEvents::HttpBinding.default
puts "CESQL Routing Example — Ruby\n"
results = Queue.new
[["order-archive","ruby-order-sub",1],["alert-stream","ruby-alert-sub",1],["all-events","ruby-all-sub",3]].each do |ch, cid, max|
Thread.new do
uri = URI("#{base}/ce/subscribe/events?client_id=#{cid}&channel=#{URI.encode_www_form_component(ch)}")
count = 0
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
http.request(req) do |resp|
ev_type = nil; data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
ce = JSON.parse(data)
results.push(" [#{ch}] type=#{ce['type']}")
count += 1
Thread.exit if count >= max
end
ev_type = nil; data = nil
elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip
elsif line.start_with?("data:") then data = line.sub("data:","").strip
end
end
end
end
end
end
end
sleep 0.5
%w[com.kubemq.examples.routing.order com.kubemq.examples.routing.alert com.kubemq.examples.routing.info].each do |ev_type|
ev = CloudEvents::Event::V1.new(
id: SecureRandom.uuid, type: ev_type,
source: URI("urn:kubemq-ce-ruby-cesql"), subject: "routing-source",
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ description: "Event of type #{ev_type}" }))
enc_h, enc_b = sdk.encode_event(ev, structured_format: "json")
uri = URI("#{base}/ce/send/event")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri); enc_h.each{|k,v|req[k]=v}; req.body=enc_b
res = http.request(req)
puts "Published type=#{ev_type} (status=#{res.code})"
end
end
sleep 2
5.times { r = begin; Timeout.timeout(0.1) { results.pop }; rescue Timeout::Error; nil; end; puts r if r }
puts "\nCESQL routing demonstration complete."
```
```rust
//! Example: routing/cesql-routing
//!
//! Publishes events with different type values for server-side CESQL routing.
//! Requires KubeMQ configured with CESQL routing rules.
//!
//! Run: cargo run -p cesql-routing
use bytes::Bytes;
use cloudevents::{EventBuilder, EventBuilderV10};
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use std::env;
use tokio::sync::mpsc;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
async fn start_subscriber(client: Client, base: String, channel: String, client_id: String, max: usize, tx: mpsc::Sender) {
let url = format!("{}/ce/subscribe/events?client_id={}&channel={}", base, client_id, channel);
let stream = client.get(&url)
.header("Accept", "text/event-stream")
.send().await.expect("SSE connect").bytes_stream();
let mut stream = Box::pin(stream);
let mut ev_type = String::new(); let mut data = String::new();
let mut buffer = String::new(); let mut count = 0usize;
while let Some(chunk) = stream.next().await {
let chunk: Bytes = chunk.unwrap_or_default();
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data.is_empty() {
let ce: Value = serde_json::from_str(&data).unwrap_or(Value::Null);
let _ = tx.send(format!(" [{}] type={}", channel, ce["type"])).await;
count += 1; if count >= max { return; }
}
ev_type.clear(); data.clear();
} else if line.starts_with(':') {
} else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); }
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let client = Client::new();
println!("CESQL Routing Example — Rust");
let (tx, mut rx) = mpsc::channel::(20);
// Subscribe to routed destination channels.
tokio::spawn(start_subscriber(client.clone(), base.clone(), "order-archive".to_string(), "rust-order-sub".to_string(), 1, tx.clone()));
tokio::spawn(start_subscriber(client.clone(), base.clone(), "alert-stream".to_string(), "rust-alert-sub".to_string(), 1, tx.clone()));
tokio::spawn(start_subscriber(client.clone(), base.clone(), "all-events".to_string(), "rust-all-sub".to_string(), 3, tx.clone()));
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Publish events with different type values.
let event_types = [
"com.kubemq.examples.routing.order",
"com.kubemq.examples.routing.alert",
"com.kubemq.examples.routing.info",
];
for ev_type in &event_types {
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty(*ev_type)
.source("urn:kubemq-ce-rust-cesql")
.subject("routing-source")
.data("application/json", json!({"description": format!("Event of type {}", ev_type)}))
.build()?;
let body = serde_json::to_string(&event)?;
let resp = client.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await?;
println!("Published type={} (status={})", ev_type, resp.status());
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
while let Ok(msg) = rx.try_recv() { println!("{}", msg); }
println!("\nCESQL routing demonstration complete.");
Ok(())
}
```
## Related [#related]
# Channel Resolution (/connectors/cloudevents/how-to/channel-resolution)
Every CloudEvent the connector receives must land on exactly one KubeMQ **channel**, and is attributed to one **ClientID**. The connector derives both from the event itself — no separate addressing layer — so a well-formed CloudEvent is self-routing.
## Overview [#overview]
The CloudEvents connector accepts events over plain HTTP without any KubeMQ-specific addressing fields. Instead, it reads two things from each incoming event:
* the **target channel** — which KubeMQ channel the message is published to, and
* the **ClientID** — the identity recorded on the message.
Both are resolved on a fixed priority order, with sensible auto-generation for the optional `id` and `time` attributes. Getting this mapping right keeps your producers portable: the same CloudEvent works against KubeMQ or any other CloudEvents endpoint.
## Channel resolution [#channel-resolution]
The destination channel is resolved in priority order:
1. **CE `subject` attribute** — if the event sets `subject`, its value is used directly as the channel name.
2. **`?channel=` query parameter** — used only when `subject` is absent (or empty).
3. **HTTP 400** — if neither is present, the request is rejected with `{"is_error": true, "message": "channel is required"}`.
This applies to every send endpoint (`/ce/send/event`, `/ce/send/event-store`, `/ce/send/command`, `/ce/send/query`, `/ce/queue/send`). SSE subscribe endpoints always take the channel from the required `?channel=` query parameter instead.
### How it works [#how-it-works]
A `subject` on the event wins; otherwise the connector falls back to the `?channel=` query parameter, and rejects the request when neither is set.
*The `subject` attribute resolves the channel first; `?channel=` is the fallback, and a missing channel is a 400.*
## Using `subject` (recommended) [#using-subject-recommended]
The `subject` attribute is part of the standard CloudEvents envelope and carries semantic meaning — what the event is about. Using it for channel resolution makes the event **self-describing** and portable: the same event routes correctly without any KubeMQ-specific query string.
The native examples below all set `subject` to the channel name (`event.SetSubject(channel)`, `.withSubject(channel)`, `subject=channel`, …), publish one CloudEvent, and confirm the subscriber received it on that channel.
```bash
# subject sets the KubeMQ channel (structured mode)
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"subject": "orders",
"datacontenttype": "application/json",
"data": {"order_id": "12345"}
}'
# binary mode — subject travels in the ce-subject header
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/json" \
-H "ce-specversion: 1.0" \
-H "ce-type: com.example.order.created" \
-H "ce-source: order-service" \
-H "ce-subject: orders" \
-d '{"order_id": "12345"}'
```
```csharp
// Example: events/BasicPubSub
// Run: dotnet run
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
static string ServerUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var channel = "csharp-ce-events.basic-pubsub";
var clientId = "kubemq-ce-csharp-example";
// Build and publish a CloudEvent (structured mode).
var formatter = new JsonEventFormatter();
var cloudEvent = new CloudEvent
{
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.events.sent",
Source = new Uri($"urn:{clientId}"),
Subject = channel, // subject = KubeMQ channel
DataContentType = "application/json",
Data = new { message = "Hello from C# CloudEvents example!" },
};
var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType);
using var httpClient = new HttpClient();
using var content = new ByteArrayContent(eventBytes.ToArray());
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString());
var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content);
Console.WriteLine($"Published to channel '{channel}': status={resp.StatusCode}");
```
```go
// Example: events/basic-pubsub
// Run: go run ./events/basic-pubsub/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
func main() {
base := serverURL()
channel := "go-ce-events.basic-pubsub"
// Build and send CloudEvent (structured mode).
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.events.sent")
event.SetSource("kubemq-ce-go-example")
event.SetSubject(channel) // subject = KubeMQ channel
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{
"message": "Hello from Go CloudEvents example!",
})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send event:", err)
}
defer resp.Body.Close()
fmt.Printf("Published to channel %q: status=%d\n", channel, resp.StatusCode)
}
```
```java
// Example: events/basic-pubsub
// Run: mvn compile exec:java
package io.kubemq.examples.events.basicpubsub;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
public class Main {
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
String base = System.getenv().getOrDefault("KUBEMQ_CE_URL", "http://localhost:9090");
String channel = "java-ce-events.basic-pubsub";
String clientId = "kubemq-ce-java-example";
// Build CloudEvent (structured mode).
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.events.sent")
.withSource(URI.create(clientId))
.withSubject(channel) // subject = KubeMQ channel
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!")))
.build();
byte[] body = format.serialize(event);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.header("Content-Type", "application/cloudevents+json")
.build();
HttpResponse response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.printf("Published to channel '%s': status=%d%n", channel, response.statusCode());
}
}
```
```typescript
// Example: events/basic-pubsub
// Run: npx tsx events/basic-pubsub/index.ts
import { CloudEvent, HTTP } from 'cloudevents';
function serverUrl(): string {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
async function main(): Promise {
const base = serverUrl();
const channel = 'js-ce-events.basic-pubsub';
const clientId = 'kubemq-ce-js-example';
// Build and publish CloudEvent (structured mode).
const event = new CloudEvent({
type: 'com.kubemq.examples.events.sent',
source: clientId,
subject: channel, // subject = KubeMQ channel
datacontenttype: 'application/json',
data: { message: 'Hello from JavaScript/TypeScript CloudEvents example!' },
});
const message = HTTP.structured(event);
const resp = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: message.headers as Record,
body: message.body as string,
});
console.log(`Published to channel '${channel}': status=${resp.status}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```python
# Example: events/basic_pubsub
# Run: python events/basic_pubsub/main.py
from __future__ import annotations
import os
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def main() -> None:
base = server_url()
channel = "python-ce-events.basic-pubsub"
client_id = "kubemq-ce-python-example"
# Build and send CloudEvent (structured mode).
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.events.sent",
"source": client_id,
"subject": channel, # subject = KubeMQ channel
"datacontenttype": "application/json",
},
data={"message": "Hello from Python CloudEvents example!"},
)
headers, body = to_structured(event)
resp = requests.post(f"{base}/ce/send/event", data=body,
headers=dict(headers), timeout=10)
print(f"Published to channel '{channel}': status={resp.status_code}")
if __name__ == "__main__":
main()
```
```ruby
# Example: events/basic_pubsub
# Run: ruby events/basic_pubsub/main.rb
require "net/http"
require "uri"
require "json"
require "securerandom"
require "cloud_events"
base = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
channel = "ruby-ce-events.basic-pubsub"
client_id = "kubemq-ce-ruby-example"
# Build and publish CloudEvent (structured mode).
sdk = CloudEvents::HttpBinding.default
event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid,
type: "com.kubemq.examples.events.sent",
source: URI("urn:#{client_id}"),
subject: channel, # subject = KubeMQ channel
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" })
)
headers, body = sdk.encode_event(event, structured_format: "json")
uri = URI("#{base}/ce/send/event")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = headers["Content-Type"]
req.body = body
res = http.request(req)
puts "Published to channel '#{channel}': status=#{res.code}"
end
```
```rust
//! Example: events/basic-pubsub
//! Run: cargo run -p basic-pubsub
use cloudevents::{EventBuilder, EventBuilderV10};
use reqwest::Client;
use serde_json::json;
use std::env;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let channel = "rust-ce-events.basic-pubsub";
let client_id = "kubemq-ce-rust-example";
// Build CloudEvent (structured mode using cloudevents-sdk).
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.events.sent")
.source(format!("urn:{}", client_id))
.subject(channel) // subject = KubeMQ channel
.data(
"application/json",
json!({"message": "Hello from Rust CloudEvents example!"}),
)
.build()?;
let body = serde_json::to_string(&event)?;
let resp = Client::new()
.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/cloudevents+json")
.body(body)
.send()
.await?;
println!("Published to channel '{}': status={}", channel, resp.status());
Ok(())
}
```
If `subject` is present but **empty**, the connector treats it as absent and falls through to the `?channel=` query parameter.
## Using `?channel=` [#using-channel]
The `?channel=` query parameter is the fallback for cases where `subject` is unavailable or reserved for a different meaning. It works in both structured and binary content modes.
```bash
# channel via query parameter — no subject on the event
curl -X POST "http://localhost:9090/ce/send/event?channel=orders" \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"data": {"order_id": "12345"}
}'
```
Use `?channel=` when:
* the CloudEvent schema reserves `subject` for a different semantic purpose;
* you are integrating with external CE producers that do not set `subject`; or
* you need to route the same event payload to different channels per deployment.
Avoid setting `subject` to a value different from the intended channel. Downstream [CESQL routing](/connectors/cloudevents/how-to/cesql-routing) can read CE attributes including `subject`, so a mismatch produces confusing routing behavior. When neither `subject` nor `?channel=` is supplied, the connector returns HTTP 400 with `{"is_error": true, "message": "channel is required"}`.
## ClientID resolution [#clientid-resolution]
Every message also carries a KubeMQ **ClientID**. The connector resolves it as follows:
1. **Auth claims** — when authentication is enabled and the request carries valid credentials, the authenticated `ClientID` from the JWT claims **overrides** every other source.
2. **CE `source` attribute** — used as the default ClientID when auth is disabled, or when the authenticated claim is `anonymous`.
So with auth off, the event's `source` becomes the ClientID; with auth on, the verified caller identity always wins, regardless of what `source` says. See [Authentication](/connectors/cloudevents/how-to/authentication) for how claims are established.
| Condition | ClientID used |
| ------------------------------------ | ------------------------------------ |
| Auth enabled, valid claims | Authenticated `ClientID` from claims |
| Auth disabled (or claim `anonymous`) | CE `source` attribute |
## Auto-generated attributes [#auto-generated-attributes]
Two optional CloudEvent attributes are filled in by the connector before the event is processed, so you may omit them:
| Attribute | Auto-generated when | Value |
| --------- | ------------------- | ---------------------------------- |
| `id` | empty or missing | a new UUID v4 |
| `time` | zero or missing | the current UTC time (RFC3339Nano) |
The required attributes — `specversion`, `type`, and `source` — are never auto-generated; omitting any of them is a validation error. For the full attribute-to-tag mapping (including how every CE attribute is stored as a `ce_*` tag), see the [CE to KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping) reference.
## Best practices [#best-practices]
* **Prefer `subject`** for channel resolution — it makes the CloudEvent self-describing and portable across environments.
* Use `?channel=` only for compatibility with external CE sources that cannot set `subject`.
* Keep channel names consistent across publishers and subscribers to avoid silent routing mismatches.
* When authentication is enabled, rely on the authenticated ClientID rather than `source` for identity-sensitive logic — claims always override `source`.
## Related [#related]
# Commands & Queries (/connectors/cloudevents/how-to/commands-queries)
Commands and queries give you **synchronous request-response (RPC)** over the CloudEvents HTTP interface. The sender publishes a CloudEvent and **blocks** until a responder processes the request and replies. Use it when the caller needs confirmation that an action ran (a command) or needs data back before continuing (a query).
## Overview [#overview]
A responder subscribes to a command or query channel over SSE, receives each request, and sends a CloudEvent response back through `POST /ce/send/response`, correlated by a request ID. The sender's original `POST` stays open and returns the result once the responder replies.
The two operations differ only in what comes back:
| | Commands | Queries |
| ------------------------- | -------------------------------------- | ------------------------------- |
| **Intent** | Execute an action, confirm it ran | Request data, get a result back |
| **Response payload** | Acknowledgment (e.g. `executed: true`) | Data (e.g. an inventory count) |
| **Send endpoint** | `POST /ce/send/command` | `POST /ce/send/query` |
| **Subscribe endpoint** | `GET /ce/subscribe/commands` | `GET /ce/subscribe/queries` |
| **Sender success status** | `202` | `200` |
Both are timed out by the connector's `TimeoutSeconds` (default 60s) — if no responder replies in time, the send returns HTTP 504.
## How it works [#how-it-works]
The responder subscribes first; the sender then issues a blocking `POST`, the connector delivers the request over SSE, and the responder posts a correlated response that the connector routes back to the waiting sender.
*A command round-trip: the responder subscribes, the sender blocks, and the response is correlated back by request ID.*
## Endpoints [#endpoints]
| Method | Endpoint | Description | Success status |
| ------ | ---------------------------------------------- | ----------------------------------------------- | -------------- |
| `POST` | `/ce/send/command` | Send a command; blocks until a response arrives | `202` |
| `POST` | `/ce/send/query` | Send a query; blocks until a response arrives | `200` |
| `GET` | `/ce/subscribe/commands?client_id=X&channel=Y` | Subscribe to commands via SSE | SSE stream |
| `GET` | `/ce/subscribe/queries?client_id=X&channel=Y` | Subscribe to queries via SSE | SSE stream |
| `POST` | `/ce/send/response?request_id=X` | Send a response to a received command or query | `202` |
## Response correlation [#response-correlation]
When a command or query is delivered over SSE, the connector adds two correlation fields the responder must echo back. For **CloudEvent** messages (`event: cloudevent`), they are merged into the CE JSON with an underscore prefix:
* **`_kubemq_request_id`** — pass this as the `request_id` query parameter on `POST /ce/send/response`.
* **`_kubemq_reply_channel`** — set this as the `subject` of the response CloudEvent so it routes back to the original sender.
For non-CE messages (`event: message`), the same values appear as top-level `request_id` and `reply_channel` keys without the `_kubemq_` prefix. See [CE-to-KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping) for the full attribute table.
## Command round-trip [#command-round-trip]
A complete command cycle: the responder subscribes over SSE, receives the command, and posts an acknowledgment. The sender blocks until the ack arrives.
**curl** — in one terminal, subscribe to the command channel:
```bash
curl -N "http://localhost:9090/ce/subscribe/commands?client_id=responder&channel=device-commands"
```
In a second terminal, send a command. The connector blocks the response until the responder replies:
```bash
curl -X POST http://localhost:9090/ce/send/command \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.device.reboot",
"source": "control-plane",
"subject": "device-commands",
"data": {"device_id": "sensor-42", "action": "reboot"}
}'
```
The subscriber receives the command with `_kubemq_request_id`. Use it to send the response back:
```bash
curl -X POST "http://localhost:9090/ce/send/response?request_id=THE_REQUEST_ID" \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.device.reboot.result",
"source": "device-agent",
"subject": "device-commands",
"data": {"executed": true, "status": "rebooting"}
}'
```
The following examples run the responder and sender together, using the CloudEvents SDK to build and encode each event:
```go
// Example: commands/round-trip
//
// Demonstrates an RPC command round-trip:
// - Responder goroutine subscribes via SSE GET /ce/subscribe/commands
// - Sender goroutine sends a command via POST /ce/send/command
// - Responder extracts _kubemq_request_id and _kubemq_reply_channel
// - Responder sends back a response via POST /ce/send/response?request_id=...
// - Sender receives the execution acknowledgement in the POST response body
//
// Run: go run ./commands/round-trip/main.go
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
type CEResponse struct {
IsError bool `json:"is_error"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// startResponder subscribes to commands and replies to each one.
func startResponder(base, channel string, ready chan<- struct{}, wg *sync.WaitGroup) {
defer wg.Done()
sseURL := fmt.Sprintf("%s/ce/subscribe/commands?client_id=go-cmd-responder&channel=%s",
base, channel)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
client := &http.Client{Timeout: 0}
resp, err := client.Do(req)
if err != nil {
log.Fatal("responder SSE connect:", err)
}
defer resp.Body.Close()
close(ready) // signal that SSE is connected
scanner := bufio.NewScanner(resp.Body)
var evType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if evType == "cloudevent" && data != "" {
// Parse command to extract KubeMQ correlation fields.
var raw map[string]interface{}
_ = json.Unmarshal([]byte(data), &raw)
requestID, _ := raw["_kubemq_request_id"].(string)
replyChannel, _ := raw["_kubemq_reply_channel"].(string)
fmt.Printf("[responder] command received: type=%v request_id=%s\n",
raw["type"], requestID)
// Send response back.
sendResponse(base, requestID, replyChannel)
return
}
if evType == "error" {
log.Printf("[responder] SSE error: %s", data)
return
}
evType, data = "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "event:") {
evType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
}
func sendResponse(base, requestID, replyChannel string) {
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.commands.response")
event.SetSource("go-cmd-responder")
event.SetSubject(replyChannel) // subject = reply channel
_ = event.SetData(cloudevents.ApplicationJSON, map[string]interface{}{
"executed": true,
"status": "command processed successfully",
})
body, _ := json.Marshal(event)
url := fmt.Sprintf("%s/ce/send/response?request_id=%s", base, requestID)
req, _ := http.NewRequest("POST", url, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send response:", err)
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("[responder] response sent: is_error=%v\n", result.IsError)
}
func sendCommand(base, channel string) {
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.commands.reboot")
event.SetSource("kubemq-ce-go-sender")
event.SetSubject(channel)
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{
"device_id": "sensor-42",
"action": "reboot",
})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/command", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
fmt.Println("[sender] sending command...")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send command:", err)
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("[sender] command ack received: status=%d is_error=%v data=%s\n",
resp.StatusCode, result.IsError, string(result.Data))
}
func main() {
base := serverURL()
channel := "go-ce-commands.round-trip"
ready := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go startResponder(base, channel, ready, &wg)
// Wait for SSE to be established.
select {
case <-ready:
case <-time.After(5 * time.Second):
log.Fatal("Timed out waiting for responder to connect")
}
time.Sleep(100 * time.Millisecond)
// Send command — blocks until response arrives or timeout.
sendCommand(base, channel)
wg.Wait()
}
```
```python
"""Example: commands/round_trip — RPC command with execution ack."""
from __future__ import annotations
import json
import os
import threading
import time
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def responder(base: str, channel: str, ready: threading.Event) -> None:
sse_url = (f"{base}/ce/subscribe/commands"
f"?client_id=python-cmd-responder&channel={channel}")
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
ready.set()
ev_type = data = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
raw = json.loads(data)
request_id = raw.get("_kubemq_request_id", "")
reply_channel = raw.get("_kubemq_reply_channel", "")
print(f"[responder] command received: type={raw.get('type')} "
f"request_id={request_id}")
# Send response.
response_event = CloudEvent(
attributes={
"type": "com.kubemq.examples.commands.response",
"source": "python-cmd-responder",
"subject": reply_channel,
"datacontenttype": "application/json",
},
data={"executed": True, "status": "command processed"},
)
headers, body = to_structured(response_event)
r = requests.post(
f"{base}/ce/send/response",
params={"request_id": request_id},
data=body, headers=dict(headers), timeout=10,
)
print(f"[responder] response sent: is_error={r.json().get('is_error')}")
return
ev_type = data = ""
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
def main() -> None:
base = server_url()
channel = "python-ce-commands.round-trip"
ready = threading.Event()
t = threading.Thread(target=responder, args=(base, channel, ready), daemon=True)
t.start()
ready.wait(timeout=5)
time.sleep(0.1)
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.commands.reboot",
"source": "kubemq-ce-python-sender",
"subject": channel,
"datacontenttype": "application/json",
},
data={"device_id": "sensor-42", "action": "reboot"},
)
headers, body = to_structured(event)
print("[sender] sending command...")
resp = requests.post(f"{base}/ce/send/command", data=body,
headers=dict(headers), timeout=30)
result = resp.json()
print(f"[sender] command ack: status={resp.status_code} is_error={result.get('is_error')}")
print(f"[sender] response data: {result.get('data')}")
t.join(timeout=5)
if __name__ == "__main__":
main()
```
```typescript
/**
* Example: commands/round-trip — RPC command with execution ack.
* Run: npx tsx commands/round-trip/index.ts
*/
import { CloudEvent, HTTP } from 'cloudevents';
import EventSource from 'eventsource';
function serverUrl(): string {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
function startResponder(base: string, channel: string): Promise {
return new Promise((resolve) => {
const url = `${base}/ce/subscribe/commands?client_id=js-cmd-responder&channel=${encodeURIComponent(channel)}`;
const es = new EventSource(url);
es.addEventListener('cloudevent', async (evt: MessageEvent) => {
es.close();
const raw = JSON.parse(evt.data) as Record;
const requestId = raw._kubemq_request_id as string;
const replyChannel = raw._kubemq_reply_channel as string;
console.log(`[responder] command received: type=${raw.type} request_id=${requestId}`);
const response = new CloudEvent({
type: 'com.kubemq.examples.commands.response',
source: 'js-cmd-responder',
subject: replyChannel,
datacontenttype: 'application/json',
data: { executed: true, status: 'command processed' },
});
const msg = HTTP.structured(response);
const r = await fetch(`${base}/ce/send/response?request_id=${requestId}`, {
method: 'POST',
headers: msg.headers as Record,
body: msg.body as string,
});
const result = await r.json() as { is_error: boolean };
console.log(`[responder] response sent: is_error=${result.is_error}`);
resolve();
});
es.addEventListener('error', (evt: MessageEvent) => {
if (evt.data) {
const err = JSON.parse(evt.data) as { message: string };
console.error('[responder] SSE error:', err.message);
es.close();
}
});
});
}
async function main(): Promise {
const base = serverUrl();
const channel = 'js-ce-commands.round-trip';
const responderDone = startResponder(base, channel);
await new Promise((r) => setTimeout(r, 500));
const event = new CloudEvent({
type: 'com.kubemq.examples.commands.reboot',
source: 'kubemq-ce-js-sender',
subject: channel,
datacontenttype: 'application/json',
data: { device_id: 'sensor-42', action: 'reboot' },
});
const msg = HTTP.structured(event);
console.log('[sender] sending command...');
const resp = await fetch(`${base}/ce/send/command`, {
method: 'POST',
headers: msg.headers as Record,
body: msg.body as string,
});
const result = await resp.json() as { is_error: boolean; data: unknown };
console.log(`[sender] command ack: status=${resp.status} is_error=${result.is_error}`);
await responderDone;
}
main().catch(console.error);
```
```java
package io.kubemq.examples.commands.roundtrip;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
/**
* Example: commands/round-trip
*
* A responder subscribes to commands via SSE, receives the command, and sends
* a CE response. A sender publishes a command via POST /ce/send/command
* (which blocks until ack is received).
*
* Run: mvn compile exec:java
*/
public class Main {
static String serverUrl() {
String u = System.getenv("KUBEMQ_CE_URL");
return (u != null && !u.isEmpty()) ? u : "http://localhost:9090";
}
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
String base = serverUrl();
String channel = "java-ce-commands.round-trip";
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
HttpClient httpClient = HttpClient.newHttpClient();
BlockingQueue responderReady = new ArrayBlockingQueue<>(1);
// Start command responder.
String sseUrl = base + "/ce/subscribe/commands?client_id=java-cmd-responder&channel=" + channel;
Thread.ofVirtual().start(() -> {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(30_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null;
boolean ready = false;
while ((line = reader.readLine()) != null) {
if (!ready) { responderReady.offer(true); ready = true; }
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
Map, ?> raw = MAPPER.readValue(data, Map.class);
String requestId = (String) raw.get("_kubemq_request_id");
String replyChannel = (String) raw.get("_kubemq_reply_channel");
System.out.println("[responder] command received: type=" + raw.get("type")
+ " request_id=" + requestId);
CloudEvent respEvent = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.commands.response")
.withSource(URI.create("kubemq-ce-java-responder"))
.withSubject(replyChannel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("executed", true, "status", "command processed")))
.build();
String responseUrl = base + "/ce/send/response?request_id=" + requestId;
HttpResponse r = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(responseUrl))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(respEvent)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
Map, ?> rResult = MAPPER.readValue(r.body(), Map.class);
System.out.println("[responder] response sent: is_error=" + rResult.get("is_error"));
return;
}
evType = null; data = null;
} else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
} catch (Exception e) { System.err.println("SSE error: " + e.getMessage()); }
});
responderReady.poll(5, TimeUnit.SECONDS);
Thread.sleep(100);
// Send command.
CloudEvent cmd = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.commands.reboot")
.withSource(URI.create("kubemq-ce-java-sender"))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("device_id", "sensor-42", "action", "reboot")))
.build();
System.out.println("[sender] sending command...");
HttpResponse resp = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/command"))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(cmd)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
Map, ?> result = MAPPER.readValue(resp.body(), Map.class);
System.out.println("[sender] command ack: status=" + resp.statusCode() + " is_error=" + result.get("is_error"));
}
}
```
```csharp
// Example: commands/RoundTrip — RPC command with execution ack.
// Run: dotnet run
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var channel = "csharp-ce-commands.round-trip";
var formatter = new JsonEventFormatter();
async Task RunResponder()
{
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var url = $"{base_}/ce/subscribe/commands?client_id=csharp-cmd-responder&channel={Uri.EscapeDataString(channel)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream, Encoding.UTF8);
string? evType = null, data = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "")
{
if (evType == "cloudevent" && data != null)
{
var raw = JsonSerializer.Deserialize(data);
var requestId = raw.GetProperty("_kubemq_request_id").GetString()!;
var replyChannel = raw.GetProperty("_kubemq_reply_channel").GetString()!;
Console.WriteLine($"[responder] command received: type={raw.GetProperty("type")} request_id={requestId}");
var responseEvent = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.commands.response",
Source = new Uri("urn:csharp-cmd-responder"),
Subject = replyChannel,
DataContentType = "application/json",
Data = new { executed = true, status = "command processed" },
};
var bytes = formatter.EncodeStructuredModeMessage(responseEvent, out var ct);
using var rc = new ByteArrayContent(bytes.ToArray());
rc.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString());
using var sendHttp = new HttpClient();
var r = await sendHttp.PostAsync($"{base_}/ce/send/response?request_id={requestId}", rc);
var rj = JsonSerializer.Deserialize(await r.Content.ReadAsStringAsync());
Console.WriteLine($"[responder] response sent: is_error={rj.GetProperty("is_error")}");
return;
}
evType = null; data = null;
}
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
}
var responderTask = RunResponder();
await Task.Delay(500);
var cmdEvent = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.commands.reboot",
Source = new Uri("urn:kubemq-ce-csharp-sender"),
Subject = channel,
DataContentType = "application/json",
Data = new { device_id = "sensor-42", action = "reboot" },
};
var cmdBytes = formatter.EncodeStructuredModeMessage(cmdEvent, out var cmdCt);
using var cmdContent = new ByteArrayContent(cmdBytes.ToArray());
cmdContent.Headers.ContentType = MediaTypeHeaderValue.Parse(cmdCt.ToString());
using var senderHttp = new HttpClient();
Console.WriteLine("[sender] sending command...");
var cmdResp = await senderHttp.PostAsync($"{base_}/ce/send/command", cmdContent);
var cmdResult = JsonSerializer.Deserialize(await cmdResp.Content.ReadAsStringAsync());
Console.WriteLine($"[sender] command ack: status={cmdResp.StatusCode} is_error={cmdResult.GetProperty("is_error")}");
await responderTask;
```
```ruby
# Example: commands/round_trip — RPC command with execution ack.
require "net/http"; require "uri"; require "json"; require "timeout"; require "securerandom"; require "cloud_events"
def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
base = server_url; channel = "ruby-ce-commands.round-trip"
sdk = CloudEvents::HttpBinding.default
ready = Queue.new
responder = Thread.new do
uri = URI("#{base}/ce/subscribe/commands?client_id=ruby-cmd-responder&channel=#{URI.encode_www_form_component(channel)}")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
http.request(req) do |resp|
ready.push(true) # signal that SSE connection is established
ev_type = nil; data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
raw = JSON.parse(data)
request_id = raw["_kubemq_request_id"]
reply_channel = raw["_kubemq_reply_channel"]
puts "[responder] command received: type=#{raw['type']} request_id=#{request_id}"
resp_event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid, type: "com.kubemq.examples.commands.response",
source: URI("urn:ruby-cmd-responder"), subject: reply_channel,
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ executed: true, status: "command processed" }))
resp_h, resp_b = sdk.encode_event(resp_event, structured_format: "json")
ruri = URI("#{base}/ce/send/response?request_id=#{URI.encode_www_form_component(request_id)}")
Net::HTTP.start(ruri.host, ruri.port) do |rhttp|
rreq = Net::HTTP::Post.new(ruri); resp_h.each{|k,v|rreq[k]=v}; rreq.body=resp_b
rr = rhttp.request(rreq)
puts "[responder] response sent: is_error=#{JSON.parse(rr.body)['is_error']}"
end
Thread.exit
end
ev_type = nil; data = nil
elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip
elsif line.start_with?("data:") then data = line.sub("data:","").strip
end
end
end
end
end
end
Timeout.timeout(5) { ready.pop }
sleep 0.1
cmd_event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid, type: "com.kubemq.examples.commands.reboot",
source: URI("urn:kubemq-ce-ruby-sender"), subject: channel, spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ device_id: "sensor-42", action: "reboot" }))
cmd_h, cmd_b = sdk.encode_event(cmd_event, structured_format: "json")
uri = URI("#{base}/ce/send/command")
puts "[sender] sending command..."
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri); cmd_h.each{|k,v|req[k]=v}; req.body=cmd_b
res = http.request(req)
r = JSON.parse(res.body)
puts "[sender] command ack: status=#{res.code} is_error=#{r['is_error']}"
end
responder.join
```
```rust
//! Example: commands/round-trip — RPC command with execution ack.
use bytes::Bytes;
use cloudevents::{EventBuilder, EventBuilderV10};
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use std::env;
use tokio::sync::oneshot;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
async fn run_responder(base: String, channel: String, ready_tx: oneshot::Sender<()>) {
let client = Client::new();
let url = format!("{}/ce/subscribe/commands?client_id=rust-cmd-responder&channel={}", base, channel);
let resp = client.get(&url)
.header("Accept", "text/event-stream")
.send().await.expect("SSE connect");
// Signal ready as soon as the SSE connection is established
let _ = ready_tx.send(());
let mut stream = Box::pin(resp.bytes_stream());
let mut buffer = String::new();
let mut ev_type = String::new();
let mut data_str = String::new();
while let Some(chunk) = stream.next().await {
let chunk: Bytes = chunk.unwrap();
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data_str.is_empty() {
let raw: Value = serde_json::from_str(&data_str).unwrap();
let request_id = raw["_kubemq_request_id"].as_str().unwrap_or("").to_string();
let reply_channel = raw["_kubemq_reply_channel"].as_str().unwrap_or("").to_string();
println!("[responder] command received: type={} request_id={}", raw["type"], request_id);
let resp_event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.commands.response")
.source("urn:rust-cmd-responder")
.subject(reply_channel.as_str())
.data("application/json", json!({"executed": true, "status": "command processed"}))
.build().unwrap();
let body = serde_json::to_string(&resp_event).unwrap();
let r = client.post(format!("{}/ce/send/response?request_id={}", base, request_id))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await.unwrap();
let rj: Value = r.json().await.unwrap();
println!("[responder] response sent: is_error={}", rj["is_error"]);
return;
}
ev_type.clear(); data_str.clear();
} else if line.starts_with(':') {
} else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data_str = v.trim().to_string(); }
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let channel = "rust-ce-commands.round-trip".to_string();
let client = Client::new();
let (ready_tx, ready_rx) = oneshot::channel::<()>();
let base_clone = base.clone();
let channel_clone = channel.clone();
tokio::spawn(async move { run_responder(base_clone, channel_clone, ready_tx).await });
tokio::time::timeout(tokio::time::Duration::from_secs(5), ready_rx).await??;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.commands.reboot")
.source("urn:kubemq-ce-rust-sender")
.subject(channel.as_str())
.data("application/json", json!({"device_id": "sensor-42", "action": "reboot"}))
.build()?;
let body = serde_json::to_string(&event)?;
println!("[sender] sending command...");
let resp = client.post(format!("{}/ce/send/command", base))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await?;
let result: Value = resp.json().await?;
println!("[sender] command ack: status=202 is_error={}", result["is_error"]);
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
Ok(())
}
```
## Query round-trip [#query-round-trip]
A complete query cycle: the responder subscribes over SSE, receives the query, performs a lookup, and sends back a **data response**. The sender blocks and receives the data payload directly in the HTTP response.
**curl** — in one terminal, subscribe to the query channel:
```bash
curl -N "http://localhost:9090/ce/subscribe/queries?client_id=responder&channel=inventory-queries"
```
In a second terminal, send a query. The connector returns `200` with the responder's reply in `data`:
```bash
curl -X POST http://localhost:9090/ce/send/query \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.inventory.check",
"source": "web-frontend",
"subject": "inventory-queries",
"data": {"sku": "WIDGET-100"}
}'
```
The subscriber receives the query with `_kubemq_request_id` and replies with the result:
```bash
curl -X POST "http://localhost:9090/ce/send/response?request_id=THE_REQUEST_ID" \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.inventory.result",
"source": "inventory-service",
"subject": "inventory-queries",
"data": {"sku": "WIDGET-100", "quantity": 42}
}'
```
The following examples run a responder that looks up an inventory and a sender that prints the returned data:
```go
// Example: queries/round-trip
//
// Demonstrates an RPC query round-trip:
// - Responder subscribes via SSE GET /ce/subscribe/queries
// - Sender sends a query via POST /ce/send/query (blocks for response)
// - Responder extracts _kubemq_request_id and sends data response
// - Sender receives the data payload in the query response
//
// Run: go run ./queries/round-trip/main.go
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
type CEResponse struct {
IsError bool `json:"is_error"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func startQueryResponder(base, channel string, ready chan<- struct{}, wg *sync.WaitGroup) {
defer wg.Done()
sseURL := fmt.Sprintf("%s/ce/subscribe/queries?client_id=go-query-responder&channel=%s",
base, channel)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
client := &http.Client{Timeout: 0}
resp, err := client.Do(req)
if err != nil {
log.Fatal("query responder SSE:", err)
}
defer resp.Body.Close()
close(ready)
scanner := bufio.NewScanner(resp.Body)
var evType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if evType == "cloudevent" && data != "" {
var raw map[string]interface{}
_ = json.Unmarshal([]byte(data), &raw)
requestID, _ := raw["_kubemq_request_id"].(string)
replyChannel, _ := raw["_kubemq_reply_channel"].(string)
// Extract the query payload.
queryData, _ := raw["data"].(map[string]interface{})
sku, _ := queryData["sku"].(string)
fmt.Printf("[responder] query received: sku=%s request_id=%s\n", sku, requestID)
// Send data response.
sendQueryResponse(base, requestID, replyChannel, sku)
return
}
evType, data = "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "event:") {
evType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
}
func sendQueryResponse(base, requestID, replyChannel, sku string) {
// Simulate database lookup.
inventory := map[string]int{"WIDGET-100": 42, "GADGET-200": 7}
qty := inventory[sku]
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.queries.inventory-result")
event.SetSource("go-query-responder")
event.SetSubject(replyChannel)
_ = event.SetData(cloudevents.ApplicationJSON, map[string]interface{}{
"sku": sku,
"quantity": qty,
})
body, _ := json.Marshal(event)
url := fmt.Sprintf("%s/ce/send/response?request_id=%s", base, requestID)
req, _ := http.NewRequest("POST", url, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send query response:", err)
}
defer resp.Body.Close()
fmt.Println("[responder] response sent.")
}
func sendQuery(base, channel string) {
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.queries.inventory-check")
event.SetSource("kubemq-ce-go-sender")
event.SetSubject(channel)
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{"sku": "WIDGET-100"})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/query", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
fmt.Println("[sender] sending query for sku=WIDGET-100...")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send query:", err)
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
// The response data contains the CE response from the responder.
fmt.Printf("[sender] query response: status=%d is_error=%v\n",
resp.StatusCode, result.IsError)
fmt.Printf("[sender] response data: %s\n", string(result.Data))
}
func main() {
base := serverURL()
channel := "go-ce-queries.round-trip"
ready := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go startQueryResponder(base, channel, ready, &wg)
select {
case <-ready:
case <-time.After(5 * time.Second):
log.Fatal("Timed out waiting for query responder")
}
time.Sleep(100 * time.Millisecond)
sendQuery(base, channel)
wg.Wait()
}
```
```python
"""Example: queries/round_trip — RPC query with data response."""
from __future__ import annotations
import json
import os
import threading
import time
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def responder(base: str, channel: str, ready: threading.Event) -> None:
sse_url = (f"{base}/ce/subscribe/queries"
f"?client_id=python-query-responder&channel={channel}")
inventory = {"WIDGET-100": 42, "GADGET-200": 7}
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
ready.set()
ev_type = data = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
raw = json.loads(data)
request_id = raw.get("_kubemq_request_id", "")
reply_channel = raw.get("_kubemq_reply_channel", "")
query_data = raw.get("data", {})
sku = query_data.get("sku", "")
qty = inventory.get(sku, 0)
print(f"[responder] query sku={sku} qty={qty} request_id={request_id}")
response_event = CloudEvent(
attributes={
"type": "com.kubemq.examples.queries.inventory-result",
"source": "python-query-responder",
"subject": reply_channel,
"datacontenttype": "application/json",
},
data={"sku": sku, "quantity": qty},
)
headers, body = to_structured(response_event)
requests.post(
f"{base}/ce/send/response",
params={"request_id": request_id},
data=body, headers=dict(headers), timeout=10,
)
print("[responder] response sent.")
return
ev_type = data = ""
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
def main() -> None:
base = server_url()
channel = "python-ce-queries.round-trip"
ready = threading.Event()
t = threading.Thread(target=responder, args=(base, channel, ready), daemon=True)
t.start()
ready.wait(timeout=5)
time.sleep(0.1)
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.queries.inventory-check",
"source": "kubemq-ce-python-sender",
"subject": channel,
"datacontenttype": "application/json",
},
data={"sku": "WIDGET-100"},
)
headers, body = to_structured(event)
print("[sender] sending query for sku=WIDGET-100...")
resp = requests.post(f"{base}/ce/send/query", data=body,
headers=dict(headers), timeout=30)
result = resp.json()
print(f"[sender] query response: status={resp.status_code} is_error={result.get('is_error')}")
print(f"[sender] response data: {result.get('data')}")
t.join(timeout=5)
if __name__ == "__main__":
main()
```
```typescript
/**
* Example: queries/round-trip — RPC query with data response.
* Run: npx tsx queries/round-trip/index.ts
*/
import { CloudEvent, HTTP } from 'cloudevents';
import EventSource from 'eventsource';
function serverUrl(): string {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
const INVENTORY: Record = { 'WIDGET-100': 42, 'GADGET-200': 7 };
function startQueryResponder(base: string, channel: string): Promise {
return new Promise((resolve) => {
const url = `${base}/ce/subscribe/queries?client_id=js-query-responder&channel=${encodeURIComponent(channel)}`;
const es = new EventSource(url);
es.addEventListener('cloudevent', async (evt: MessageEvent) => {
es.close();
const raw = JSON.parse(evt.data) as Record;
const requestId = raw._kubemq_request_id as string;
const replyChannel = raw._kubemq_reply_channel as string;
const queryData = raw.data as Record;
const sku = queryData.sku;
const qty = INVENTORY[sku] ?? 0;
console.log(`[responder] query sku=${sku} qty=${qty} request_id=${requestId}`);
const response = new CloudEvent({
type: 'com.kubemq.examples.queries.inventory-result',
source: 'js-query-responder',
subject: replyChannel,
datacontenttype: 'application/json',
data: { sku, quantity: qty },
});
const msg = HTTP.structured(response);
await fetch(`${base}/ce/send/response?request_id=${requestId}`, {
method: 'POST',
headers: msg.headers as Record,
body: msg.body as string,
});
console.log('[responder] response sent.');
resolve();
});
es.addEventListener('error', (evt: MessageEvent) => {
if (evt.data) {
const err = JSON.parse(evt.data) as { message: string };
console.error('[responder] SSE error:', err.message);
es.close();
}
});
});
}
async function main(): Promise {
const base = serverUrl();
const channel = 'js-ce-queries.round-trip';
const responderDone = startQueryResponder(base, channel);
await new Promise((r) => setTimeout(r, 500));
const event = new CloudEvent({
type: 'com.kubemq.examples.queries.inventory-check',
source: 'kubemq-ce-js-sender',
subject: channel,
datacontenttype: 'application/json',
data: { sku: 'WIDGET-100' },
});
const msg = HTTP.structured(event);
console.log('[sender] sending query for sku=WIDGET-100...');
const resp = await fetch(`${base}/ce/send/query`, {
method: 'POST',
headers: msg.headers as Record,
body: msg.body as string,
});
const result = await resp.json() as { is_error: boolean; data: unknown };
console.log(`[sender] query response: status=${resp.status} is_error=${result.is_error}`);
console.log(`[sender] response data: ${JSON.stringify(result.data)}`);
await responderDone;
}
main().catch(console.error);
```
```java
package io.kubemq.examples.queries.roundtrip;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
/**
* Example: queries/round-trip
*
* A responder subscribes to queries, receives a query, looks up inventory,
* and sends a CE response. A sender publishes a query via POST /ce/send/query
* and prints the response returned directly in the HTTP response body.
*
* Run: mvn compile exec:java
*/
public class Main {
static String serverUrl() {
String u = System.getenv("KUBEMQ_CE_URL");
return (u != null && !u.isEmpty()) ? u : "http://localhost:9090";
}
static final ObjectMapper MAPPER = new ObjectMapper();
static final Map INVENTORY = Map.of("WIDGET-100", 42, "GADGET-200", 7);
public static void main(String[] args) throws Exception {
String base = serverUrl();
String channel = "java-ce-queries.round-trip";
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
HttpClient httpClient = HttpClient.newHttpClient();
BlockingQueue responderReady = new ArrayBlockingQueue<>(1);
// Start query responder.
String sseUrl = base + "/ce/subscribe/queries?client_id=java-query-responder&channel=" + channel;
Thread.ofVirtual().start(() -> {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(30_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null;
boolean ready = false;
while ((line = reader.readLine()) != null) {
if (!ready) { responderReady.offer(true); ready = true; }
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
Map, ?> raw = MAPPER.readValue(data, Map.class);
String requestId = (String) raw.get("_kubemq_request_id");
String replyChannel = (String) raw.get("_kubemq_reply_channel");
Map, ?> qdata = (Map, ?>) raw.get("data");
String sku = qdata != null ? (String) qdata.get("sku") : "";
int qty = INVENTORY.getOrDefault(sku, 0);
System.out.println("[responder] query sku=" + sku + " qty=" + qty);
CloudEvent respEvent = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.queries.inventory-result")
.withSource(URI.create("kubemq-ce-java-responder"))
.withSubject(replyChannel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("sku", sku, "quantity", qty)))
.build();
String responseUrl = base + "/ce/send/response?request_id=" + requestId;
httpClient.send(
HttpRequest.newBuilder().uri(URI.create(responseUrl))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(respEvent)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
System.out.println("[responder] response sent.");
return;
}
evType = null; data = null;
} else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
} catch (Exception e) { System.err.println("SSE error: " + e.getMessage()); }
});
responderReady.poll(5, TimeUnit.SECONDS);
Thread.sleep(100);
// Send query.
CloudEvent query = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.queries.inventory-check")
.withSource(URI.create("kubemq-ce-java-sender"))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json", MAPPER.writeValueAsBytes(Map.of("sku", "WIDGET-100")))
.build();
System.out.println("[sender] sending query for sku=WIDGET-100...");
HttpResponse resp = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/query"))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(query)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
Map, ?> result = MAPPER.readValue(resp.body(), Map.class);
System.out.println("[sender] query response: status=" + resp.statusCode() + " is_error=" + result.get("is_error"));
System.out.println("[sender] response data: " + result.get("data"));
}
}
```
```csharp
// Example: queries/RoundTrip — RPC query with data response.
// Run: dotnet run
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var channel = "csharp-ce-queries.round-trip";
var formatter = new JsonEventFormatter();
var inventory = new Dictionary { ["WIDGET-100"] = 42, ["GADGET-200"] = 7 };
var responderDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
async Task RunResponder()
{
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var url = $"{base_}/ce/subscribe/queries?client_id=csharp-query-responder&channel={Uri.EscapeDataString(channel)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8);
string? evType = null, data = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "") {
if (evType == "cloudevent" && data != null) {
var raw = JsonSerializer.Deserialize(data);
var requestId = raw.GetProperty("_kubemq_request_id").GetString()!;
var replyChannel = raw.GetProperty("_kubemq_reply_channel").GetString()!;
var sku = raw.GetProperty("data").GetProperty("sku").GetString()!;
var qty = inventory.GetValueOrDefault(sku, 0);
Console.WriteLine($"[responder] query sku={sku} qty={qty} request_id={requestId}");
var respEvent = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.queries.inventory-result",
Source = new Uri("urn:csharp-query-responder"),
Subject = replyChannel,
DataContentType = "application/json",
Data = new { sku, quantity = qty },
};
var bytes = formatter.EncodeStructuredModeMessage(respEvent, out var ct);
using var rc = new ByteArrayContent(bytes.ToArray());
rc.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString());
using var sendHttp = new HttpClient();
await sendHttp.PostAsync($"{base_}/ce/send/response?request_id={requestId}", rc);
Console.WriteLine("[responder] response sent.");
responderDone.TrySetResult();
return;
}
evType = null; data = null;
}
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
}
var responderTask = RunResponder();
await Task.Delay(500);
// Send query.
var query = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.queries.inventory-check",
Source = new Uri("urn:kubemq-ce-csharp-sender"),
Subject = channel,
DataContentType = "application/json",
Data = new { sku = "WIDGET-100" },
};
var qBytes = formatter.EncodeStructuredModeMessage(query, out var qCt);
using var qContent = new ByteArrayContent(qBytes.ToArray());
qContent.Headers.ContentType = MediaTypeHeaderValue.Parse(qCt.ToString());
using var senderHttp = new HttpClient();
Console.WriteLine("[sender] sending query for sku=WIDGET-100...");
var queryResp = await senderHttp.PostAsync($"{base_}/ce/send/query", qContent);
var queryJ = JsonSerializer.Deserialize(await queryResp.Content.ReadAsStringAsync());
Console.WriteLine($"[sender] query response: status={queryResp.StatusCode} is_error={queryJ.GetProperty("is_error")}");
Console.WriteLine($"[sender] response data: {queryJ.GetProperty("data")}");
await responderDone.Task;
```
```ruby
# Example: queries/round_trip — RPC query with data response.
require "net/http"; require "uri"; require "json"; require "timeout"; require "securerandom"; require "cloud_events"
def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
base = server_url; channel = "ruby-ce-queries.round-trip"
sdk = CloudEvents::HttpBinding.default
inventory = { "WIDGET-100" => 42, "GADGET-200" => 7 }
ready = Queue.new
responder = Thread.new do
uri = URI("#{base}/ce/subscribe/queries?client_id=ruby-query-responder&channel=#{URI.encode_www_form_component(channel)}")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
http.request(req) do |resp|
ready.push(true) # signal that SSE connection is established
ev_type = nil; data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
raw = JSON.parse(data)
request_id = raw["_kubemq_request_id"]
reply_channel = raw["_kubemq_reply_channel"]
raw_data = raw["data"]
raw_data = JSON.parse(raw_data) if raw_data.is_a?(String)
sku = raw_data.is_a?(Hash) ? raw_data["sku"] : nil
qty = inventory[sku] || 0
puts "[responder] query sku=#{sku} qty=#{qty} request_id=#{request_id}"
resp_ev = CloudEvents::Event::V1.new(
id: SecureRandom.uuid,
type: "com.kubemq.examples.queries.inventory-result",
source: URI("urn:ruby-query-responder"), subject: reply_channel,
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ sku: sku, quantity: qty }))
resp_h, resp_b = sdk.encode_event(resp_ev, structured_format: "json")
ruri = URI("#{base}/ce/send/response?request_id=#{URI.encode_www_form_component(request_id)}")
Net::HTTP.start(ruri.host, ruri.port) do |rhttp|
rreq = Net::HTTP::Post.new(ruri); resp_h.each{|k,v|rreq[k]=v}; rreq.body=resp_b
rhttp.request(rreq)
end
puts "[responder] response sent."
Thread.exit
end
ev_type = nil; data = nil
elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip
elsif line.start_with?("data:") then data = line.sub("data:","").strip
end
end
end
end
end
end
Timeout.timeout(5) { ready.pop }; sleep 0.1
ev = CloudEvents::Event::V1.new(
id: SecureRandom.uuid, type: "com.kubemq.examples.queries.inventory-check",
source: URI("urn:kubemq-ce-ruby-sender"), subject: channel, spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ sku: "WIDGET-100" }))
ev_h, ev_b = sdk.encode_event(ev, structured_format: "json")
uri = URI("#{base}/ce/send/query")
puts "[sender] sending query for sku=WIDGET-100..."
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri); ev_h.each{|k,v|req[k]=v}; req.body=ev_b
res = http.request(req)
r = JSON.parse(res.body)
puts "[sender] query response: status=#{res.code} is_error=#{r['is_error']}"
puts "[sender] response data: #{r['data']}"
end
responder.join
```
```rust
//! Example: queries/round-trip
//!
//! Responder subscribes to queries, receives a query, sends a CE response.
//! Sender publishes via POST /ce/send/query (blocks until response arrives).
//!
//! Run: cargo run -p round-trip-queries
use bytes::Bytes;
use cloudevents::{EventBuilder, EventBuilderV10};
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use std::{collections::HashMap, env};
use tokio::sync::oneshot;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
async fn run_responder(base: String, channel: String, ready_tx: oneshot::Sender<()>) {
let client = Client::new();
let url = format!("{}/ce/subscribe/queries?client_id=rust-query-responder&channel={}", base, channel);
let resp = client.get(&url)
.header("Accept", "text/event-stream")
.send().await.expect("SSE connect");
// Signal ready as soon as the SSE connection is established
let _ = ready_tx.send(());
let mut stream = Box::pin(resp.bytes_stream());
let mut buffer = String::new();
let mut ev_type = String::new(); let mut data_str = String::new();
let inventory: HashMap<&str, u32> = [("WIDGET-100", 42), ("GADGET-200", 7)].into();
while let Some(chunk) = stream.next().await {
let chunk: Bytes = chunk.unwrap_or_default();
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data_str.is_empty() {
let raw: Value = serde_json::from_str(&data_str).unwrap();
let request_id = raw["_kubemq_request_id"].as_str().unwrap_or("").to_string();
let reply_channel = raw["_kubemq_reply_channel"].as_str().unwrap_or("").to_string();
let sku = raw["data"]["sku"].as_str().unwrap_or("");
let qty = *inventory.get(sku).unwrap_or(&0);
println!("[responder] query sku={} qty={} request_id={}", sku, qty, request_id);
let resp_event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.queries.inventory-result")
.source("urn:rust-query-responder")
.subject(reply_channel.as_str())
.data("application/json", json!({"sku": sku, "quantity": qty}))
.build().unwrap();
let body = serde_json::to_string(&resp_event).unwrap();
let r = client.post(format!("{}/ce/send/response?request_id={}", base, request_id))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await.unwrap();
let rj: Value = r.json().await.unwrap();
println!("[responder] response sent: is_error={}", rj["is_error"]);
return;
}
ev_type.clear(); data_str.clear();
} else if line.starts_with(':') {
} else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data_str = v.trim().to_string(); }
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let channel = "rust-ce-queries.round-trip".to_string();
let client = Client::new();
let (ready_tx, ready_rx) = oneshot::channel::<()>();
let base_clone = base.clone();
let channel_clone = channel.clone();
tokio::spawn(async move { run_responder(base_clone, channel_clone, ready_tx).await });
tokio::time::timeout(tokio::time::Duration::from_secs(5), ready_rx).await??;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.queries.inventory-check")
.source("urn:kubemq-ce-rust-sender")
.subject(channel.as_str())
.data("application/json", json!({"sku": "WIDGET-100"}))
.build()?;
let body = serde_json::to_string(&event)?;
println!("[sender] sending query for sku=WIDGET-100...");
let resp = client.post(format!("{}/ce/send/query", base))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await?;
let result: Value = resp.json().await?;
println!("[sender] query response: status=200 is_error={}", result["is_error"]);
println!("[sender] response data: {}", result["data"]);
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
Ok(())
}
```
Commands and queries are synchronous. The responder must be online and reply within the connector's `TimeoutSeconds` (default 60s), or the send fails with HTTP 504.
The `?group=` load-balancing parameter is only available for [Events](/connectors/cloudevents/how-to/events) subscriptions. Command, query, and events-store subscriptions do not accept `group`.
## Related [#related]
# Content Modes (/connectors/cloudevents/how-to/content-modes)
A CloudEvent can be encoded two ways on the wire: **structured mode**, where every attribute lives in a single `application/cloudevents+json` body, and **binary mode**, where attributes ride in `ce-*` HTTP headers and the body carries only the data. The KubeMQ CloudEvents connector accepts both interchangeably and detects the mode from each request independently.
## Overview [#overview]
The [CloudEvents HTTP Protocol Binding](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/bindings/http-protocol-binding.md) defines two content modes. Both carry the same event — the same `type`, `source`, `subject`, and `data` — they differ only in how those attributes are placed in the HTTP request.
| Mode | Content-Type | Attributes | Data |
| ---------- | --------------------------------------------------- | ---------------------- | ----------------------------------------- |
| Structured | `application/cloudevents+json` | In the JSON body | In the JSON body (`data` / `data_base64`) |
| Binary | the data's own media type (e.g. `application/json`) | In `ce-*` HTTP headers | The raw request body |
There is no client-side handshake or configuration: every CloudEvents send endpoint (`/ce/send/event`, `/ce/send/event-store`, `/ce/send/command`, `/ce/send/query`, `/ce/queue/send`) accepts either mode on any request. Choose structured mode for human-readable, single-serialization sends — the default for most use cases — and binary mode when your payload is already in its native format (protobuf, an image, plain text) and you want to preserve its content type without re-wrapping it.
## How it works [#how-it-works]
Both encodings flow into the same connector, which uses the CloudEvents SDK to detect the mode and produce one identical KubeMQ message.
*Structured and binary requests are both decoded by the CloudEvents SDK into one identical KubeMQ message.*
The connector calls `cehttp.NewEventFromHTTPRequest`, which inspects the request to choose a mode:
| `Content-Type` | `ce-specversion` header | Detected mode |
| ------------------------------ | ----------------------- | ---------------------- |
| `application/cloudevents+json` | *(ignored)* | Structured |
| Any other value | Present | Binary |
| Any other value | Absent | HTTP `400 Bad Request` |
A request that is neither — no CloudEvents content type and no `ce-*` headers — is rejected with `400` (`invalid CloudEvent`).
## Structured mode [#structured-mode]
All attributes and data are serialized into a single JSON document sent with `Content-Type: application/cloudevents+json`.
```bash title="Structured mode — wire format"
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"id": "550e8400-e29b-41d4-a716-446655440000",
"subject": "orders",
"time": "2026-03-29T10:30:00Z",
"datacontenttype": "application/json",
"data": {"order_id": "12345", "amount": 99.99}
}'
```
## Binary mode [#binary-mode]
Attributes are carried in `ce-*` HTTP headers and the body contains only the event data. `Content-Type` reflects the data's own media type.
```bash title="Binary mode — wire format"
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/json" \
-H "ce-specversion: 1.0" \
-H "ce-type: com.example.order.created" \
-H "ce-source: order-service" \
-H "ce-id: 550e8400-e29b-41d4-a716-446655440000" \
-H "ce-subject: orders" \
-H "ce-time: 2026-03-29T10:30:00Z" \
-d '{"order_id": "12345", "amount": 99.99}'
```
## Sending both modes [#sending-both-modes]
Each CloudEvents SDK builds one event and serializes it to either mode with a single helper call — `to_structured` / `to_binary`, `HTTP.structured` / `HTTP.binary`, and equivalents. The example below posts the same event to `/ce/send/event` first as structured, then as binary; both return `HTTP 202 Accepted`.
```bash
# Structured mode — application/cloudevents+json body
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.kubemq.examples.events.content-mode",
"source": "kubemq-ce-curl-example",
"subject": "ce-events.content-modes",
"datacontenttype": "application/json",
"data": {"message": "structured mode payload"}
}'
# Binary mode — ce-* headers, data-only body
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/json" \
-H "ce-specversion: 1.0" \
-H "ce-type: com.kubemq.examples.events.content-mode" \
-H "ce-source: kubemq-ce-curl-example" \
-H "ce-subject: ce-events.content-modes" \
-d '{"message": "binary mode payload"}'
```
```csharp
// Example: events/ContentModes — structured and binary mode.
// Run: dotnet run
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text.Json;
static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var formatter = new JsonEventFormatter();
using var httpClient = new HttpClient();
var cloudEvent = new CloudEvent
{
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.events.content-mode",
Source = new Uri("urn:kubemq-ce-csharp-example"),
Subject = "csharp-ce-events.content-modes",
DataContentType = "application/json",
Data = new { message = "hello content modes" },
};
// Structured mode
Console.WriteLine("Sending in structured mode:");
var structuredBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var structuredCt);
using var sc = new ByteArrayContent(structuredBytes.ToArray());
sc.Headers.ContentType = MediaTypeHeaderValue.Parse(structuredCt.ToString());
var r1 = await httpClient.PostAsync($"{base_}/ce/send/event", sc);
var j1 = JsonSerializer.Deserialize(await r1.Content.ReadAsStringAsync());
Console.WriteLine($"[structured] status={r1.StatusCode} is_error={j1.GetProperty("is_error")}");
// Binary mode
Console.WriteLine("\nSending in binary mode:");
using var binaryContent = new StringContent(
JsonSerializer.Serialize(new { message = "hello binary mode" }),
System.Text.Encoding.UTF8, "application/json");
using var binaryRequest = new HttpRequestMessage(HttpMethod.Post, $"{base_}/ce/send/event") { Content = binaryContent };
binaryRequest.Headers.Add("ce-specversion", "1.0");
binaryRequest.Headers.Add("ce-type", cloudEvent.Type);
binaryRequest.Headers.Add("ce-source", cloudEvent.Source!.ToString());
binaryRequest.Headers.Add("ce-id", cloudEvent.Id);
binaryRequest.Headers.Add("ce-subject", cloudEvent.Subject);
binaryRequest.Headers.Add("ce-time", DateTimeOffset.UtcNow.ToString("O"));
var r2 = await httpClient.SendAsync(binaryRequest);
var j2 = JsonSerializer.Deserialize(await r2.Content.ReadAsStringAsync());
Console.WriteLine($"[binary] status={r2.StatusCode} is_error={j2.GetProperty("is_error")}");
Console.WriteLine("\nBoth content modes accepted.");
```
```go
// Example: events/content-modes
// Sends the same event payload in both CloudEvents content modes:
// - Structured: Content-Type: application/cloudevents+json, all attrs in JSON body
// - Binary: ce-* HTTP headers, raw data body
// Run: go run ./events/content-modes/main.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
type CEResponse struct {
IsError bool `json:"is_error"`
Message string `json:"message"`
}
func sendStructured(base string, event cloudevents.Event) error {
body, err := json.Marshal(event)
if err != nil {
return err
}
req, err := http.NewRequest("POST", base+"/ce/send/event", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("[structured] status=%d is_error=%v message=%s\n",
resp.StatusCode, result.IsError, result.Message)
return nil
}
func sendBinary(base string, event cloudevents.Event) error {
// In binary mode, CE attributes go into ce-* HTTP headers.
// The body contains only the event data.
dataJSON, err := json.Marshal(map[string]string{"message": "binary mode payload"})
if err != nil {
return err
}
req, err := http.NewRequest("POST", base+"/ce/send/event", bytes.NewReader(dataJSON))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("ce-specversion", "1.0")
req.Header.Set("ce-type", event.Type())
req.Header.Set("ce-source", event.Source())
req.Header.Set("ce-subject", event.Subject())
req.Header.Set("ce-id", event.ID())
req.Header.Set("ce-time", event.Time().Format(time.RFC3339Nano))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("[binary] status=%d is_error=%v message=%s\n",
resp.StatusCode, result.IsError, result.Message)
return nil
}
func main() {
base := serverURL()
// Build a CloudEvent to send in both modes.
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.events.content-mode")
event.SetSource("kubemq-ce-go-example")
event.SetSubject("go-ce-events.content-modes")
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{
"message": "structured mode payload",
})
fmt.Println("Sending in structured mode (Content-Type: application/cloudevents+json):")
if err := sendStructured(base, event); err != nil {
log.Fatal("structured send:", err)
}
fmt.Println("\nSending in binary mode (ce-* HTTP headers):")
if err := sendBinary(base, event); err != nil {
log.Fatal("binary send:", err)
}
fmt.Println("\nBoth content modes accepted by KubeMQ CE connector.")
}
```
```java
// Example: events/content-modes — structured and binary mode.
// Run: mvn compile exec:java
package io.kubemq.examples.events.contentmodes;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
public class Main {
static String serverUrl() {
String u = System.getenv("KUBEMQ_CE_URL");
return (u != null && !u.isEmpty()) ? u : "http://localhost:9090";
}
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
String base = serverUrl();
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
HttpClient httpClient = HttpClient.newHttpClient();
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.events.content-mode")
.withSource(URI.create("kubemq-ce-java-example"))
.withSubject("java-ce-events.content-modes")
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("message", "hello content modes")))
.build();
// --- Structured mode ---
System.out.println("Sending in structured mode:");
byte[] structuredBody = format.serialize(event);
HttpResponse r1 = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(structuredBody))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
Map, ?> res1 = MAPPER.readValue(r1.body(), Map.class);
System.out.printf("[structured] status=%d is_error=%s%n", r1.statusCode(), res1.get("is_error"));
// --- Binary mode ---
System.out.println("\nSending in binary mode:");
byte[] data = event.getData() != null ? event.getData().toBytes() : new byte[0];
HttpResponse r2 = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(data))
.header("Content-Type", "application/json")
.header("ce-specversion", "1.0")
.header("ce-type", event.getType())
.header("ce-source", event.getSource().toString())
.header("ce-id", event.getId())
.header("ce-subject", event.getSubject())
.header("ce-time", event.getTime().toString())
.build(),
HttpResponse.BodyHandlers.ofString());
Map, ?> res2 = MAPPER.readValue(r2.body(), Map.class);
System.out.printf("[binary] status=%d is_error=%s%n", r2.statusCode(), res2.get("is_error"));
System.out.println("\nBoth content modes accepted.");
}
}
```
```typescript
// Example: events/content-modes
// Sends the same event in structured and binary mode.
// Run: npx tsx events/content-modes/index.ts
import { CloudEvent, HTTP } from 'cloudevents';
function serverUrl(): string {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
async function main(): Promise {
const base = serverUrl();
const event = new CloudEvent({
type: 'com.kubemq.examples.events.content-mode',
source: 'kubemq-ce-js-example',
subject: 'js-ce-events.content-modes',
datacontenttype: 'application/json',
data: { message: 'hello content modes' },
});
// Structured mode
console.log('Sending in structured mode:');
const structured = HTTP.structured(event);
const r1 = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: structured.headers as Record,
body: structured.body as string,
});
const res1 = await r1.json() as { is_error: boolean };
console.log(`[structured] status=${r1.status} is_error=${res1.is_error}`);
// Binary mode
console.log('\nSending in binary mode:');
const binary = HTTP.binary(event);
const r2 = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: binary.headers as Record,
body: binary.body as string,
});
const res2 = await r2.json() as { is_error: boolean };
console.log(`[binary] status=${r2.status} is_error=${res2.is_error}`);
console.log('\nBoth content modes accepted.');
}
main().catch(console.error);
```
```python
# Example: events/content_modes — structured vs binary CloudEvents mode.
# Run: python events/content_modes/main.py
from __future__ import annotations
import os
import requests
from cloudevents.v1.conversion import to_binary, to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def main() -> None:
base = server_url()
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.events.content-mode",
"source": "kubemq-ce-python-example",
"subject": "python-ce-events.content-modes",
"datacontenttype": "application/json",
},
data={"message": "hello content modes"},
)
# --- Structured mode ---
print("Sending in structured mode (Content-Type: application/cloudevents+json):")
headers, body = to_structured(event)
resp = requests.post(f"{base}/ce/send/event", data=body,
headers=dict(headers), timeout=10)
result = resp.json()
print(f"[structured] status={resp.status_code} is_error={result.get('is_error')}")
# --- Binary mode ---
print("\nSending in binary mode (ce-* HTTP headers):")
headers, body = to_binary(event)
resp = requests.post(f"{base}/ce/send/event", data=body,
headers=dict(headers), timeout=10)
result = resp.json()
print(f"[binary] status={resp.status_code} is_error={result.get('is_error')}")
print("\nBoth content modes accepted by KubeMQ CE connector.")
if __name__ == "__main__":
main()
```
```ruby
# Example: events/content_modes — structured and binary mode.
# Run: ruby events/content_modes/main.rb
require "net/http"
require "uri"
require "json"
require "time"
require "securerandom"
require "cloud_events"
def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
base = server_url
sdk = CloudEvents::HttpBinding.default
event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid,
type: "com.kubemq.examples.events.content-mode",
source: URI("urn:kubemq-ce-ruby-example"),
subject: "ruby-ce-events.content-modes",
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ message: "hello content modes" })
)
def post_event(base, headers_hash, body)
uri = URI("#{base}/ce/send/event")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri)
headers_hash.each { |k, v| req[k] = v }
req.body = body
res = http.request(req)
JSON.parse(res.body).merge("status" => res.code)
end
end
# Structured mode
puts "Sending in structured mode:"
enc_headers, enc_body = sdk.encode_event(event, structured_format: "json")
r1 = post_event(base, enc_headers, enc_body)
puts "[structured] status=#{r1['status']} is_error=#{r1['is_error']}"
# Binary mode — CE attrs in headers, data as body
puts "\nSending in binary mode:"
binary_headers = {
"Content-Type" => "application/json",
"ce-specversion" => "1.0",
"ce-type" => event.type,
"ce-source" => event.source.to_s,
"ce-id" => event.id,
"ce-subject" => event.subject,
"ce-time" => Time.now.utc.iso8601(9),
}
r2 = post_event(base, binary_headers, event.data)
puts "[binary] status=#{r2['status']} is_error=#{r2['is_error']}"
puts "\nBoth content modes accepted."
```
```rust
//! Example: events/content-modes — structured and binary mode.
//! Run: cargo run -p content-modes
use cloudevents::{AttributesReader, EventBuilder, EventBuilderV10};
use reqwest::Client;
use serde_json::{json, Value};
use std::env;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let client = Client::new();
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.events.content-mode")
.source("urn:kubemq-ce-rust-example")
.subject("rust-ce-events.content-modes")
.data("application/json", json!({"message": "hello content modes"}))
.build()?;
// Structured mode
println!("Sending in structured mode:");
let body = serde_json::to_string(&event)?;
let r1 = client.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/cloudevents+json")
.body(body)
.send().await?;
let j1: Value = r1.json().await?;
println!("[structured] status=202 is_error={}", j1["is_error"]);
// Binary mode — CE attrs in ce-* headers, JSON data as body
println!("\nSending in binary mode:");
let data_body = serde_json::to_string(&json!({"message": "binary mode payload"}))?;
let r2 = client.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/json")
.header("ce-specversion", "1.0")
.header("ce-type", event.ty())
.header("ce-source", event.source().as_str())
.header("ce-id", event.id())
.header("ce-subject", event.subject().unwrap_or(""))
.body(data_body)
.send().await?;
let j2: Value = r2.json().await?;
println!("[binary] status=202 is_error={}", j2["is_error"]);
println!("\nBoth content modes accepted.");
Ok(())
}
```
The SDK helpers (`to_structured` / `to_binary` and their per-language equivalents) build the correct headers and body for each mode from one `CloudEvent` object. Reach for them rather than assembling `ce-*` headers by hand — they keep attribute names and `time` formatting spec-compliant.
## Choosing a mode [#choosing-a-mode]
| Structured | Binary |
| -------------------------------------------- | --------------------------------------------- |
| One JSON body — simplest to read and debug | Preserves the data's native content type |
| Single serialization step | Best for binary or non-JSON payloads |
| Recommended default | Use when data is already in its native format |
| Slightly larger requests (attributes inline) | One HTTP header per attribute |
Structured mode is the recommended default. Reach for binary mode when your payload is non-JSON — an image, protobuf, or plain text — and you want to keep its native `Content-Type` rather than wrap it in a CloudEvents envelope.
## Outbound encoding: data vs data\_base64 [#outbound-encoding-data-vs-data_base64]
The mode you send in does not dictate how the connector represents the event when it later delivers it to subscribers (over SSE or a queue receive). On the way out, the connector always reconstructs a structured CloudEvent JSON object and chooses where the payload goes based on whether the stored body is valid JSON:
* **Valid JSON** → placed inline in the `data` attribute
* **Non-JSON** (binary, plain text) → base64-encoded into the `data_base64` attribute
```json title="Inline JSON payload"
{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"subject": "orders",
"data": {"order_id": "12345", "amount": 99.99}
}
```
```json title="Base64-encoded binary payload"
{
"specversion": "1.0",
"type": "com.example.sensor.reading",
"source": "sensor-gateway",
"subject": "telemetry",
"data_base64": "SGVsbG8gV29ybGQ="
}
```
This follows the [CloudEvents JSON format](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md), where `data_base64` is the standard carrier for non-JSON payloads.
## Related [#related]
# Events Store (/connectors/cloudevents/how-to/events-store)
The events-store pattern persists every CloudEvent to a durable, sequenced log so a subscriber can replay history, start at a specific point, or resume after a disconnect. It is the CloudEvents-over-HTTP equivalent of KubeMQ's [Events Store](/learn/events-store) messaging pattern.
## Overview [#overview]
Where a plain [events](/connectors/cloudevents/how-to/events) channel is fire-and-forget, an events-store channel writes each event to disk with a monotonically increasing **sequence number**. Subscribers choose a **start position** when they connect — receive only new events, replay everything from the beginning, jump to a sequence number, or seek by time. Because every Server-Sent Events (SSE) frame carries the sequence number in its `id:` field, a subscriber that drops its connection can reconnect with the `Last-Event-ID` header and the server resumes from `sequence + 1`, automatically replaying whatever it missed.
You send persistent events to `POST /ce/send/event-store` and subscribe over SSE at `GET /ce/subscribe/events-store`. Both accept the same CloudEvent structured and binary [content modes](/connectors/cloudevents/how-to/content-modes) as a non-persistent event.
## How it works [#how-it-works]
A publisher persists CloudEvents to the durable channel; a subscriber connects with a start position and the connector replays stored events, then streams new ones — each frame tagged with its sequence number for resume.
*Persisted events get a sequence number; subscribers choose where to start and resume from the last sequence they saw.*
## Start positions [#start-positions]
The `GET /ce/subscribe/events-store` endpoint accepts an `events_store_type` query parameter (default `1`). Types `4`, `5`, and `6` also read `events_store_value`.
| `events_store_type` | Name | `events_store_value` | Behavior |
| ------------------- | ---------------- | -------------------- | --------------------------------------------------- |
| `1` | StartNewOnly | — | Only events that arrive after subscribing (default) |
| `2` | StartFromFirst | — | Replay from the first stored event |
| `3` | StartFromLast | — | Start from the last stored event |
| `4` | StartAtSequence | sequence number | Start at a specific sequence number |
| `5` | StartAtTime | Unix seconds | Start at an absolute timestamp |
| `6` | StartAtTimeDelta | seconds | Start at a time delta back from now |
Only events-store subscriptions emit an `id:` field on each SSE frame (the sequence number), because only persisted events can be replayed. Plain `events` subscriptions never emit `id:`.
## Resume with Last-Event-ID [#resume-with-last-event-id]
When a subscriber reconnects with the `Last-Event-ID` header set to the last sequence number it processed, the server resumes the stream from `sequence + 1`. This is the standard SSE reconnection protocol — browsers and `EventSource` clients send `Last-Event-ID` automatically.
If both `Last-Event-ID` and `events_store_type` are present, the **query parameter wins**. To use `Last-Event-ID` for reconnection, omit `events_store_type` from the reconnect URL.
## Send a persistent event [#send-a-persistent-event]
Publish a CloudEvent to an events-store channel. The `subject` attribute resolves to the channel; the connector responds with HTTP 202.
```bash
curl -X POST http://localhost:9090/ce/send/event-store \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.audit.entry",
"source": "audit-service",
"subject": "audit-log",
"datacontenttype": "application/json",
"data": {"action": "user.login", "user": "admin"}
}'
```
```csharp
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
var base_ = "http://localhost:9090";
var channel = "audit-log";
var formatter = new JsonEventFormatter();
using var httpClient = new HttpClient();
var ev = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.eventsstore.stored",
Source = new Uri("urn:kubemq-ce-csharp-example"),
Subject = channel,
DataContentType = "application/json",
Data = new { msg = "hello events-store from C#!" },
};
var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct);
using var content = new ByteArrayContent(bytes.ToArray());
content.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString());
var resp = await httpClient.PostAsync($"{base_}/ce/send/event-store", content);
Console.WriteLine($"Published to events-store: status={resp.StatusCode}");
```
```go
import (
"encoding/json"
"net/http"
"strings"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func sendEventStore(base, channel, msg string) error {
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.eventsstore.stored")
event.SetSource("kubemq-ce-go-example")
event.SetSubject(channel)
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{"msg": msg})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/event-store", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
return resp.Body.Close()
}
```
```java
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.eventsstore.stored")
.withSource(URI.create("kubemq-ce-java-example"))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("msg", "hello events-store from Java!")))
.build();
HttpClient httpClient = HttpClient.newHttpClient();
HttpResponse resp = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event-store"))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(event)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
System.out.println("Published to events-store: status=" + resp.statusCode());
```
```typescript
import { CloudEvent, HTTP } from 'cloudevents';
const base = 'http://localhost:9090';
const channel = 'audit-log';
const event = new CloudEvent({
type: 'com.kubemq.examples.eventsstore.stored',
source: 'kubemq-ce-js-example',
subject: channel,
datacontenttype: 'application/json',
data: { msg: 'hello events-store from JS!' },
});
const msg = HTTP.structured(event);
const resp = await fetch(`${base}/ce/send/event-store`, {
method: 'POST',
headers: msg.headers as Record,
body: msg.body as string,
});
console.log(`Published to events-store: status=${resp.status}`);
```
```python
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
base = "http://localhost:9090"
channel = "audit-log"
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.eventsstore.stored",
"source": "kubemq-ce-python-example",
"subject": channel,
"datacontenttype": "application/json",
},
data={"msg": "hello events-store from Python!"},
)
headers, body = to_structured(event)
resp = requests.post(f"{base}/ce/send/event-store", data=body,
headers=dict(headers), timeout=10)
print(f"Published to events-store: status={resp.status_code}")
```
```ruby
require "net/http"; require "uri"; require "json"; require "cloud_events"; require "securerandom"
base = "http://localhost:9090"; channel = "audit-log"
sdk = CloudEvents::HttpBinding.default
event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid, type: "com.kubemq.examples.eventsstore.stored",
source: URI("urn:kubemq-ce-ruby-example"), subject: channel,
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ msg: "hello events-store from Ruby!" })
)
enc_h, enc_b = sdk.encode_event(event, structured_format: "json")
uri = URI("#{base}/ce/send/event-store")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri); enc_h.each { |k, v| req[k] = v }; req.body = enc_b
res = http.request(req)
puts "Published to events-store: status=#{res.code}"
end
```
```rust
use cloudevents::{EventBuilder, EventBuilderV10};
use reqwest::Client;
use serde_json::json;
use uuid::Uuid;
let base = "http://localhost:9090";
let channel = "audit-log";
let client = Client::new();
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.eventsstore.stored")
.source("urn:kubemq-ce-rust-example")
.subject(channel)
.data("application/json", json!({"msg": "hello events-store from Rust!"}))
.build()?;
let body = serde_json::to_string(&event)?;
let resp = client.post(format!("{}/ce/send/event-store", base))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await?;
println!("Published to events-store: status={}", resp.status());
```
## Subscribe with StartNewOnly [#subscribe-with-startnewonly]
The default subscription (`events_store_type=1`) delivers only events that arrive after the connection opens. The SSE handler reads `event: cloudevent` frames and parses each `data:` payload as a reconstructed CloudEvent.
```bash
curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=1"
```
```csharp
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var url = $"{base_}/ce/subscribe/events-store?client_id=csharp-es-sub&channel={Uri.EscapeDataString(channel)}&events_store_type=1";
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8);
string? evType = null, data = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "") {
if (evType == "cloudevent" && data != null) {
var ce = JsonSerializer.Deserialize(data);
Console.WriteLine($"Received: type={ce.GetProperty("type")} data={ce.GetProperty("data")}");
return;
}
evType = null; data = null;
}
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
```
```go
// events_store_type=1 = StartNewOnly — only messages arriving after subscribe.
sseURL := fmt.Sprintf(
"%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=1",
base, clientID, channel)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := (&http.Client{Timeout: 0}).Do(req)
if err != nil {
log.Fatal("SSE connect:", err)
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
var evType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if evType == "cloudevent" && data != "" {
var ce map[string]interface{}
_ = json.Unmarshal([]byte(data), &ce)
fmt.Printf("Received: type=%v data=%v\n", ce["type"], ce["data"])
return
}
evType, data = "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "event:") {
evType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
```
```java
// events_store_type=1 = StartNewOnly
String sseUrl = base + "/ce/subscribe/events-store?client_id=java-es-sub&channel="
+ channel + "&events_store_type=1";
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(15_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println("Received: type=" + ce.get("type") + " data=" + ce.get("data"));
return;
}
evType = null; data = null;
} else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
```
```typescript
import EventSource from 'eventsource';
const url = `${base}/ce/subscribe/events-store?client_id=js-es-sub&channel=${encodeURIComponent(channel)}&events_store_type=1`;
const es = new EventSource(url);
es.addEventListener('cloudevent', (evt: MessageEvent) => {
const ce = JSON.parse(evt.data) as Record;
console.log(`Received: type=${ce.type} data=${JSON.stringify(ce.data)}`);
es.close();
});
es.addEventListener('error', (err) => {
console.error('SSE error:', err);
es.close();
});
```
```python
import json
import requests
# events_store_type=1 = StartNewOnly
sse_url = (f"{base}/ce/subscribe/events-store"
f"?client_id=python-es-sub&channel={channel}&events_store_type=1")
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
ev_type = data = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
ce = json.loads(data)
print(f"Received: type={ce.get('type')} data={ce.get('data')}")
break
ev_type = data = ""
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
```
```ruby
require "net/http"; require "uri"; require "json"
uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-sub&channel=#{URI.encode_www_form_component(channel)}&events_store_type=1")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
http.request(req) do |resp|
ev_type = nil; data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
ce = JSON.parse(data)
puts "Received: type=#{ce['type']} data=#{ce['data']}"
end
ev_type = nil; data = nil
elsif line.start_with?(":") then # keepalive
elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip
elsif line.start_with?("data:") then data = line.sub("data:", "").strip
end
end
end
end
end
```
```rust
use futures_util::StreamExt;
// events_store_type=1 = StartNewOnly
let sub_url = format!(
"{}/ce/subscribe/events-store?client_id=rust-es-sub&channel={}&events_store_type=1",
base, channel
);
let stream = client.get(&sub_url)
.header("Accept", "text/event-stream")
.send().await?.bytes_stream();
let mut stream = Box::pin(stream);
let mut ev_type = String::new();
let mut data = String::new();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
buffer.push_str(&String::from_utf8_lossy(&chunk?));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data.is_empty() {
let ce: serde_json::Value = serde_json::from_str(&data)?;
println!("Received: type={} data={}", ce["type"], ce["data"]);
return Ok(());
}
ev_type.clear(); data.clear();
} else if line.starts_with(':') {
} else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); }
}
}
```
## Replay from the first event [#replay-from-the-first-event]
Set `events_store_type=2` (StartFromFirst) to replay every stored event from the beginning of the log before streaming new ones.
```bash
curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=2"
```
```csharp
// events_store_type=2 = StartFromFirst — replay all stored events.
var url = $"{base_}/ce/subscribe/events-store?client_id=csharp-es-replay&channel={Uri.EscapeDataString(channel)}&events_store_type=2";
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8);
string? evType = null, data = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "") {
if (evType == "cloudevent" && data != null) {
var ce = JsonSerializer.Deserialize(data);
Console.WriteLine($" replayed data={ce.GetProperty("data")}");
}
evType = null; data = null;
}
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
```
```go
// events_store_type=2 = StartFromFirst: replay all stored events.
sseURL := fmt.Sprintf(
"%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=2",
base, clientID, channel)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := (&http.Client{Timeout: 0}).Do(req)
if err != nil {
log.Fatal("SSE connect:", err)
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
var evType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if evType == "cloudevent" && data != "" {
var ce map[string]interface{}
_ = json.Unmarshal([]byte(data), &ce)
fmt.Printf(" replayed data=%v\n", ce["data"])
}
evType, data = "", ""
continue
}
if strings.HasPrefix(line, "event:") {
evType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
```
```java
// events_store_type=2 = StartFromFirst
String sseUrl = base + "/ce/subscribe/events-store?client_id=java-es-replay&channel="
+ channel + "&events_store_type=2";
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(15_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println(" replayed data=" + ce.get("data"));
}
evType = null; data = null;
} else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
```
```typescript
import EventSource from 'eventsource';
// events_store_type=2 = StartFromFirst — replay all stored events.
const url = `${base}/ce/subscribe/events-store?client_id=js-replay-sub&channel=${encodeURIComponent(channel)}&events_store_type=2`;
const es = new EventSource(url);
es.addEventListener('cloudevent', (evt: MessageEvent) => {
const ce = JSON.parse(evt.data) as Record;
console.log(` replayed data=${JSON.stringify(ce.data)}`);
});
es.addEventListener('error', (err) => {
console.error('SSE error:', err);
es.close();
});
```
```python
import json
import requests
# events_store_type=2 = StartFromFirst
sse_url = (f"{base}/ce/subscribe/events-store"
f"?client_id=python-replay-sub&channel={channel}&events_store_type=2")
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
ev_type = data = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
ce = json.loads(data)
print(f" replayed data={ce.get('data')}")
ev_type = data = ""
continue
if line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
```
```ruby
require "net/http"; require "uri"; require "json"
# events_store_type=2 = StartFromFirst
uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-replay&channel=#{URI.encode_www_form_component(channel)}&events_store_type=2")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
http.request(req) do |resp|
ev_type = nil; data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
ce = JSON.parse(data)
ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String)
puts " replayed seq=#{ce.dig('data', 'seq')}"
end
ev_type = nil; data = nil
elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip
elsif line.start_with?("data:") then data = line.sub("data:", "").strip
end
end
end
end
end
```
```rust
use futures_util::StreamExt;
// events_store_type=2 = StartFromFirst
let sub_url = format!(
"{}/ce/subscribe/events-store?client_id=rust-es-replay&channel={}&events_store_type=2",
base, channel
);
let stream = client.get(&sub_url)
.header("Accept", "text/event-stream")
.send().await?.bytes_stream();
let mut stream = Box::pin(stream);
let mut ev_type = String::new();
let mut data = String::new();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
buffer.push_str(&String::from_utf8_lossy(&chunk?));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data.is_empty() {
let ce: serde_json::Value = serde_json::from_str(&data)?;
println!(" replayed data={}", ce["data"]);
}
ev_type.clear(); data.clear();
} else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); }
}
}
```
## Replay at a sequence number [#replay-at-a-sequence-number]
Set `events_store_type=4` (StartAtSequence) and `events_store_value=` to start the stream at a specific sequence number. The example below publishes 10 events and then replays from sequence 5.
```bash
curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=4&events_store_value=5"
```
```csharp
// events_store_type=4 (StartAtSequence) + events_store_value=startAtSeq
long startAtSeq = 5;
var url = $"{base_}/ce/subscribe/events-store?client_id=csharp-es-seq&channel={Uri.EscapeDataString(channel)}&events_store_type=4&events_store_value={startAtSeq}";
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8);
string? evType = null, data = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "") {
if (evType == "cloudevent" && data != null) {
var ce = JsonSerializer.Deserialize(data);
Console.WriteLine($" Received: seq={ce.GetProperty("data").GetProperty("seq")}");
}
evType = null; data = null;
}
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
```
```go
// events_store_type=4 (StartAtSequence), events_store_value=5
const startSeq = 5
sseURL := fmt.Sprintf(
"%s/ce/subscribe/events-store?client_id=go-replay-seq-sub&channel=%s&events_store_type=4&events_store_value=%d",
base, channel, startSeq)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := (&http.Client{Timeout: 0}).Do(req)
if err != nil {
log.Fatal("SSE connect:", err)
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
var evType, data, sseID string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if evType == "cloudevent" && data != "" {
var ce map[string]interface{}
_ = json.Unmarshal([]byte(data), &ce)
fmt.Printf(" [seq=%s] data=%v\n", sseID, ce["data"])
}
evType, data, sseID = "", "", ""
continue
}
if strings.HasPrefix(line, "id:") {
sseID = strings.TrimSpace(strings.TrimPrefix(line, "id:"))
} else if strings.HasPrefix(line, "event:") {
evType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
```
```java
// events_store_type=4 = StartAtSequence, events_store_value=startAtSeq
long startAtSeq = 5;
String sseUrl = base + "/ce/subscribe/events-store?client_id=java-es-seq&channel="
+ channel + "&events_store_type=4&events_store_value=" + startAtSeq;
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(15_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println(" Received: seq=" + ((Map, ?>) ce.get("data")).get("seq"));
}
evType = null; data = null;
} else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
```
```typescript
import EventSource from 'eventsource';
// events_store_type=4 (StartAtSequence) + events_store_value
const startSeq = 5;
const url = `${base}/ce/subscribe/events-store?client_id=js-seq-sub&channel=${encodeURIComponent(channel)}&events_store_type=4&events_store_value=${startSeq}`;
const es = new EventSource(url);
es.addEventListener('cloudevent', (evt: MessageEvent & { lastEventId: string }) => {
const ce = JSON.parse(evt.data) as Record;
console.log(` [seq=${evt.lastEventId}] data=${JSON.stringify(ce.data)}`);
});
es.addEventListener('error', (err) => {
console.error('SSE error:', err);
es.close();
});
```
```python
import json
import requests
# events_store_type=4 (StartAtSequence) + events_store_value
start_seq = 5
sse_url = (f"{base}/ce/subscribe/events-store"
f"?client_id=python-seq-sub&channel={channel}"
f"&events_store_type=4&events_store_value={start_seq}")
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
ev_type = data = sse_id = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
ce = json.loads(data)
print(f" [seq={sse_id}] data={ce.get('data')}")
ev_type = data = sse_id = ""
continue
if line.startswith("id:"):
sse_id = line[3:].strip()
elif line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
```
```ruby
require "net/http"; require "uri"; require "json"
# events_store_type=4 (StartAtSequence), events_store_value=start_at_seq
start_at_seq = 5
uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-seq&channel=#{URI.encode_www_form_component(channel)}&events_store_type=4&events_store_value=#{start_at_seq}")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
http.request(req) do |resp|
ev_type = nil; data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
ce = JSON.parse(data)
ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String)
puts " Received: seq=#{ce.dig('data', 'seq')}"
end
ev_type = nil; data = nil
elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip
elsif line.start_with?("data:") then data = line.sub("data:", "").strip
end
end
end
end
end
```
```rust
use futures_util::StreamExt;
// events_store_type=4 (StartAtSequence) + events_store_value
let start_at_seq: u32 = 5;
let sub_url = format!(
"{}/ce/subscribe/events-store?client_id=rust-es-seq&channel={}&events_store_type=4&events_store_value={}",
base, channel, start_at_seq
);
let stream = client.get(&sub_url)
.header("Accept", "text/event-stream")
.send().await?.bytes_stream();
let mut stream = Box::pin(stream);
let mut ev_type = String::new();
let mut data = String::new();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
buffer.push_str(&String::from_utf8_lossy(&chunk?));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data.is_empty() {
let ce: serde_json::Value = serde_json::from_str(&data)?;
println!(" Received: seq={}", ce["data"]["seq"]);
}
ev_type.clear(); data.clear();
} else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); }
}
}
```
## Reconnect and resume with Last-Event-ID [#reconnect-and-resume-with-last-event-id]
To resume after a dropped connection, capture the `id:` field of each frame as you process it, then reconnect with that value in the `Last-Event-ID` header. Omit `events_store_type` on the reconnect so the header takes effect — the server resumes from `sequence + 1`.
```bash
# Resume after the last sequence you processed (e.g. 42).
# Omit events_store_type so Last-Event-ID takes precedence.
curl -N -H "Last-Event-ID: 42" \
"http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log"
```
```csharp
// Omit events_store_type when reconnecting with Last-Event-ID.
async Task<(List events, string lastId)> Subscribe(
string clientId, string? lastEventId, int maxEvents)
{
var events = new List();
var lastId = "";
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var url = lastEventId == null
? $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}&events_store_type=2"
: $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (lastEventId != null) req.Headers.Add("Last-Event-ID", lastEventId);
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8);
string? evType = null, data = null, id = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "") {
if (evType == "cloudevent" && data != null) {
if (id != null) lastId = id;
events.Add(JsonSerializer.Deserialize(data));
if (events.Count >= maxEvents) break;
}
evType = null; data = null; id = null;
}
else if (line.StartsWith("id:")) id = line[3..].Trim();
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
return (events, lastId);
}
var (first, lastId) = await Subscribe("csharp-es-reconnect-1", null, 2);
var (second, _) = await Subscribe("csharp-es-reconnect-2", lastId, 2);
```
```go
func openSSE(base, channel, clientID, lastEventID string) (*http.Response, error) {
// When reconnecting, omit events_store_type so Last-Event-ID takes precedence.
var sseURL string
if lastEventID == "" {
sseURL = fmt.Sprintf(
"%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=2",
base, clientID, channel)
} else {
sseURL = fmt.Sprintf(
"%s/ce/subscribe/events-store?client_id=%s&channel=%s",
base, clientID, channel)
}
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
if lastEventID != "" {
req.Header.Set("Last-Event-ID", lastEventID)
}
return (&http.Client{Timeout: 0}).Do(req)
}
// 1) First connection: read a batch and capture the last SSE id.
// 2) Reconnect: openSSE(base, channel, clientID, lastID) resumes from lastID+1.
```
```java
// Omit events_store_type when reconnecting with Last-Event-ID.
String sseUrl = base + "/ce/subscribe/events-store?client_id=" + clientId + "&channel=" + channel;
if (lastEventId == null) {
sseUrl += "&events_store_type=2"; // initial connection: start from first
}
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(10_000);
if (lastEventId != null) {
conn.setRequestProperty("Last-Event-ID", lastEventId);
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null, id = null, lastId = "";
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
if (id != null) lastId = id; // capture sequence for resume
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println(" seq=" + ((Map, ?>) ce.get("data")).get("seq"));
}
evType = null; data = null; id = null;
} else if (line.startsWith("id:")) id = line.substring(3).trim();
else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
```
```typescript
// EventSource sends Last-Event-ID automatically on reconnect, but custom-header
// reconnects use fetch. Capture evt.lastEventId, then resume with the header.
const headers: Record = { 'Last-Event-ID': lastEventId };
const url = `${base}/ce/subscribe/events-store?client_id=js-reconnect-sub&channel=${encodeURIComponent(channel)}`;
const resp = await fetch(url, { headers });
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buffer = '', evType = '', data = '', lastId = lastEventId;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line === '') {
if (evType === 'cloudevent' && data) {
const ce = JSON.parse(data) as Record;
console.log(` id=${lastId} data=${JSON.stringify(ce.data)}`);
}
evType = data = '';
} else if (line.startsWith('id:')) lastId = line.slice(3).trim();
else if (line.startsWith('event:')) evType = line.slice(6).trim();
else if (line.startsWith('data:')) data = line.slice(5).trim();
}
}
```
```python
import json
import requests
def read_n_events(base, channel, client_id, n, last_event_id=""):
"""Open SSE, read n events, return the last SSE id seen."""
if last_event_id:
# Omit events_store_type so Last-Event-ID takes precedence.
sse_url = f"{base}/ce/subscribe/events-store?client_id={client_id}&channel={channel}"
extra_headers = {"Last-Event-ID": last_event_id}
else:
sse_url = (f"{base}/ce/subscribe/events-store"
f"?client_id={client_id}&channel={channel}&events_store_type=2")
extra_headers = {}
headers = {"Accept": "text/event-stream", **extra_headers}
last_id = ""
count = 0
with requests.get(sse_url, stream=True, timeout=None, headers=headers) as resp:
ev_type = data = sse_id = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
ce = json.loads(data)
count += 1
last_id = sse_id
print(f" [{count}] id={sse_id} data={ce.get('data')}")
if count == n:
return last_id
ev_type = data = sse_id = ""
continue
if line.startswith("id:"):
sse_id = line[3:].strip()
elif line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
return last_id
# 1) First connection captures the last id; 2) reconnect resumes from it.
last_id = read_n_events(base, channel, "python-reconnect-sub", 3)
read_n_events(base, channel, "python-reconnect-sub", 3, last_id)
```
```ruby
require "net/http"; require "uri"; require "json"
# Open one SSE connection, collect up to max_events, return [events, last_id].
def subscribe_es(base, channel, last_event_id, max_events)
# Omit events_store_type when reconnecting with Last-Event-ID.
query = last_event_id.nil? ? "&events_store_type=2" : ""
uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-reconnect&channel=#{URI.encode_www_form_component(channel)}#{query}")
events = []; last_id = nil
Net::HTTP.start(uri.host, uri.port, read_timeout: 12) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
req["Last-Event-ID"] = last_event_id if last_event_id
http.request(req) do |resp|
ev_type = nil; data = nil; id = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
last_id = id if id
ce = JSON.parse(data)
ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String)
events << ce
return [events, last_id] if events.size >= max_events
end
ev_type = nil; data = nil; id = nil
elsif line.start_with?("id:") then id = line.sub("id:", "").strip
elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip
elsif line.start_with?("data:") then data = line.sub("data:", "").strip
end
end
end
end
end
[events, last_id]
end
first_events, last_id = subscribe_es(base, channel, nil, 2)
second_events, _ = subscribe_es(base, channel, last_id, 2)
```
```rust
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::Value;
// Subscribe and collect up to `max` events. Returns (events, last_id_seen).
async fn subscribe_and_collect(
client: &Client,
url: &str,
last_event_id: Option<&str>,
max: usize,
) -> (Vec, String) {
let mut req = client.get(url).header("Accept", "text/event-stream");
if let Some(id) = last_event_id {
req = req.header("Last-Event-ID", id);
}
let stream = req.send().await.expect("SSE connect").bytes_stream();
let mut stream = Box::pin(stream);
let mut ev_type = String::new(); let mut data = String::new();
let mut id_field = String::new(); let mut last_id = String::new();
let mut buffer = String::new();
let mut events = Vec::new();
while let Some(chunk) = stream.next().await {
buffer.push_str(&String::from_utf8_lossy(&chunk.unwrap_or_default()));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data.is_empty() {
if !id_field.is_empty() { last_id = id_field.clone(); }
events.push(serde_json::from_str(&data).unwrap_or(Value::Null));
if events.len() >= max { return (events, last_id); }
}
ev_type.clear(); data.clear(); id_field.clear();
} else if let Some(v) = line.strip_prefix("id:") { id_field = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); }
}
}
(events, last_id)
}
// First connection (events_store_type=2) captures last_id; the reconnect URL
// omits events_store_type and passes Some(&last_id) to resume from sequence + 1.
```
## Response and status codes [#response-and-status-codes]
| Status | When |
| --------------------------- | ----------------------------------------------------------------------------- |
| `202 Accepted` | `POST /ce/send/event-store` succeeded; the send result is in `data` |
| `200 OK` (stream) | `GET /ce/subscribe/events-store` opened; frames follow as `text/event-stream` |
| `400 Bad Request` | Invalid CloudEvent, missing channel, or a reserved channel name |
| `429 Too Many Requests` | SSE connection limit reached (`MaxSSEConnections` exceeded) |
| `500 Internal Server Error` | Backend messaging error or SSE setup failure |
A successful send returns the standard envelope:
```json
{
"is_error": false,
"message": "OK",
"data": { ... }
}
```
Each delivered CloudEvent frame carries the sequence number in its `id:` field:
```text
id: 42
event: cloudevent
data: {"specversion":"1.0","type":"com.example.audit.entry","source":"audit-service","id":"...","subject":"audit-log","time":"...","data":{"action":"user.login"}}
```
## Related [#related]
# Events (/connectors/cloudevents/how-to/events)
Events are **fire-and-forget** pub/sub messages over the CloudEvents connector. A publisher sends a CloudEvent with `POST /ce/send/event` and returns immediately; every subscriber connected to the channel receives every event over an SSE stream.
## Overview [#overview]
The Events pattern maps the KubeMQ **Events** messaging primitive onto plain HTTP. Publishers do not wait for delivery confirmation, and events are **not persisted** — a subscriber must be connected at the moment of delivery to receive a message. Connect every subscriber to a channel and you get **fan-out** (each one receives every event); add a shared `group` and you get a **consumer group** where the server load-balances each event to exactly one member.
Use this pattern for real-time notifications, telemetry, and broadcast scenarios where missed messages are acceptable. When you need durability and replay, use [Events Store](/connectors/cloudevents/how-to/events-store) instead.
| Operation | Endpoint | Method |
| --------- | ---------------------- | --------- |
| Publish | `/ce/send/event` | POST |
| Subscribe | `/ce/subscribe/events` | GET (SSE) |
## How it works [#how-it-works]
A publisher posts one CloudEvent to the connector, which fans it out to every connected subscriber on the channel; subscribers in a shared group split the load round-robin.
*A published CloudEvent fans out to all subscribers; a shared `group` load-balances it to one member.*
## Publishing [#publishing]
Send a CloudEvent in **structured mode** (`application/cloudevents+json`). The `subject` attribute selects the KubeMQ channel; the connector replies with HTTP `202 Accepted` and a `{"is_error": false, ...}` body. Each native example below opens an SSE subscriber, publishes one event, prints it, and exits.
```bash
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"subject": "orders",
"datacontenttype": "application/json",
"data": {"order_id": "12345", "amount": 99.99}
}'
```
```csharp
// Example: events/BasicPubSub
// Run: dotnet run
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static string ServerUrl() =>
Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var channel = "csharp-ce-events.basic-pubsub";
var clientId = "kubemq-ce-csharp-example";
var received = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
// Start SSE subscriber.
var subscriberTask = Task.Run(async () =>
{
using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var sseUrl = $"{base_}/ce/subscribe/events?client_id={clientId}-sub&channel={Uri.EscapeDataString(channel)}";
using var request = new HttpRequestMessage(HttpMethod.Get, sseUrl);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true };
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream, Encoding.UTF8);
string? eventType = null, data = null;
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "")
{
if (eventType == "cloudevent" && data != null)
{
received.TrySetResult(JsonSerializer.Deserialize(data));
return;
}
eventType = null; data = null;
}
else if (line.StartsWith(":")) { /* keepalive */ }
else if (line.StartsWith("event:")) eventType = line["event:".Length..].Trim();
else if (line.StartsWith("data:")) data = line["data:".Length..].Trim();
}
});
// Allow subscription to establish.
await Task.Delay(500);
// Build and publish CloudEvent (structured mode).
var formatter = new JsonEventFormatter();
var cloudEvent = new CloudEvent
{
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.events.sent",
Source = new Uri($"urn:{clientId}"),
Subject = channel,
DataContentType = "application/json",
Data = new { message = "Hello from C# CloudEvents example!" },
};
cloudEvent.SetAttributeFromString("time", DateTimeOffset.UtcNow.ToString("O"));
var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType);
using var httpClient = new HttpClient();
using var content = new ByteArrayContent(eventBytes.ToArray());
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString());
var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content);
var resultJson = await resp.Content.ReadAsStringAsync();
using var resultDoc = JsonDocument.Parse(resultJson);
Console.WriteLine($"Published: status={resp.StatusCode} is_error={resultDoc.RootElement.GetProperty("is_error")}");
// Wait for the event.
var ce = await received.Task;
Console.WriteLine($"Received: {ce.GetProperty("type")} / {ce.GetProperty("data")}");
```
```go
// Example: events/basic-pubsub
// Run: go run ./events/basic-pubsub/main.go
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
func main() {
base := serverURL()
channel := "go-ce-events.basic-pubsub"
clientID := "kubemq-ce-go-example"
received := make(chan string, 1)
// Start SSE subscriber in background goroutine.
go func() {
sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s",
base, clientID+"-sub", channel)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("SSE connect:", err)
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
var eventType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if eventType == "cloudevent" && data != "" {
received <- data
return
}
eventType, data = "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue // keepalive
}
if strings.HasPrefix(line, "event:") {
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
}()
// Allow SSE subscription to establish.
time.Sleep(500 * time.Millisecond)
// Build and send CloudEvent (structured mode).
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.events.sent")
event.SetSource("kubemq-ce-go-example")
event.SetSubject(channel) // subject = KubeMQ channel
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{
"message": "Hello from Go CloudEvents example!",
})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send event:", err)
}
defer resp.Body.Close()
var result map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Published: status=%d is_error=%v\n", resp.StatusCode, result["is_error"])
// Wait for subscriber to receive the event.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
select {
case data := <-received:
var ce map[string]interface{}
_ = json.Unmarshal([]byte(data), &ce)
fmt.Printf("Received: type=%v subject=%v data=%v\n", ce["type"], ce["subject"], ce["data"])
case <-ctx.Done():
log.Fatal("Timed out waiting for event")
}
}
```
```java
// Example: events/basic-pubsub
// Run: mvn compile exec:java
package io.kubemq.examples.events.basicpubsub;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
public class Main {
static String serverUrl() {
String u = System.getenv("KUBEMQ_CE_URL");
return (u != null && !u.isEmpty()) ? u : "http://localhost:9090";
}
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
String base = serverUrl();
String channel = "java-ce-events.basic-pubsub";
String clientId = "kubemq-ce-java-example";
BlockingQueue received = new ArrayBlockingQueue<>(1);
// Start SSE subscriber in background thread.
String sseUrl = base + "/ce/subscribe/events?client_id=" + clientId
+ "-sub&channel=" + channel;
Thread subscriber = Thread.ofVirtual().start(() -> {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("Cache-Control", "no-cache");
conn.setDoInput(true);
conn.setReadTimeout(15_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
String eventType = null, data = null;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
if ("cloudevent".equals(eventType) && data != null) {
received.offer(data);
return;
}
eventType = null;
data = null;
} else if (line.startsWith(":")) {
// keepalive
} else if (line.startsWith("event:")) {
eventType = line.substring("event:".length()).trim();
} else if (line.startsWith("data:")) {
data = line.substring("data:".length()).trim();
}
}
}
} catch (Exception e) {
System.err.println("SSE error: " + e.getMessage());
}
});
// Allow subscription to establish.
Thread.sleep(500);
// Build CloudEvent (structured mode).
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.events.sent")
.withSource(URI.create(clientId))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!")))
.build();
byte[] body = format.serialize(event);
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.header("Content-Type", "application/cloudevents+json")
.build();
HttpResponse response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
Map, ?> result = MAPPER.readValue(response.body(), Map.class);
System.out.printf("Published: status=%d is_error=%s%n",
response.statusCode(), result.get("is_error"));
// Wait for event.
String data = received.poll(10, TimeUnit.SECONDS);
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println("Received: " + ce.get("type") + " / " + ce.get("data"));
subscriber.interrupt();
}
}
```
```typescript
// Example: events/basic-pubsub
// Run: npx tsx events/basic-pubsub/index.ts
import { CloudEvent, HTTP } from 'cloudevents';
import EventSource from 'eventsource';
function serverUrl(): string {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
async function waitForEvent(
base: string,
channel: string,
clientId: string,
): Promise> {
return new Promise((resolve, reject) => {
const sseUrl = `${base}/ce/subscribe/events?client_id=${clientId}&channel=${encodeURIComponent(channel)}`;
const es = new EventSource(sseUrl);
const timer = setTimeout(() => {
es.close();
reject(new Error('Timed out waiting for event'));
}, 10_000);
es.addEventListener('cloudevent', (evt: MessageEvent) => {
clearTimeout(timer);
es.close();
resolve(JSON.parse(evt.data) as Record);
});
});
}
async function main(): Promise {
const base = serverUrl();
const channel = 'js-ce-events.basic-pubsub';
const clientId = 'kubemq-ce-js-example';
// Start waiting for event (opens SSE stream).
const eventPromise = waitForEvent(base, channel, `${clientId}-sub`);
// Allow SSE connection to establish.
await new Promise((r) => setTimeout(r, 500));
// Build and publish CloudEvent (structured mode).
const event = new CloudEvent({
type: 'com.kubemq.examples.events.sent',
source: clientId,
subject: channel,
datacontenttype: 'application/json',
data: { message: 'Hello from JavaScript/TypeScript CloudEvents example!' },
});
const message = HTTP.structured(event);
const resp = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: message.headers as Record,
body: message.body as string,
});
const result = await resp.json() as { is_error: boolean };
console.log(`Published: status=${resp.status} is_error=${result.is_error}`);
// Wait for subscriber.
const received = await eventPromise;
console.log(`Received: ${received.type} / ${JSON.stringify(received.data)}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```python
# Example: events/basic_pubsub
# Run: python events/basic_pubsub/main.py
from __future__ import annotations
import json
import os
import threading
import time
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def subscribe(base: str, channel: str, client_id: str, received: list[str]) -> None:
"""Open SSE stream and collect one cloudevent."""
sse_url = (
f"{base}/ce/subscribe/events"
f"?client_id={client_id}&channel={channel}"
)
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream",
"Cache-Control": "no-cache"}) as resp:
event_type = ""
data = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if event_type == "cloudevent" and data:
received.append(data)
return
event_type = ""
data = ""
continue
if line.startswith(":"):
continue # keepalive
if line.startswith("event:"):
event_type = line[len("event:"):].strip()
elif line.startswith("data:"):
data = line[len("data:"):].strip()
def main() -> None:
base = server_url()
channel = "python-ce-events.basic-pubsub"
client_id = "kubemq-ce-python-example"
received: list[str] = []
# Start subscriber in background thread.
t = threading.Thread(
target=subscribe,
args=(base, channel, client_id + "-sub", received),
daemon=True,
)
t.start()
# Allow SSE connection to establish.
time.sleep(0.5)
# Build and send CloudEvent (structured mode).
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.events.sent",
"source": client_id,
"subject": channel,
"datacontenttype": "application/json",
},
data={"message": "Hello from Python CloudEvents example!"},
)
headers, body = to_structured(event)
resp = requests.post(
f"{base}/ce/send/event",
data=body,
headers=dict(headers),
timeout=10,
)
result = resp.json()
print(f"Published: status={resp.status_code} is_error={result.get('is_error')}")
# Wait for subscriber.
deadline = time.time() + 10
while not received and time.time() < deadline:
time.sleep(0.1)
ce = json.loads(received[0])
print(f"Received: {ce.get('type')} / {ce.get('data')}")
if __name__ == "__main__":
main()
```
```ruby
# Example: events/basic_pubsub
# Run: ruby events/basic_pubsub/main.rb
require "net/http"
require "uri"
require "json"
require "timeout"
require "securerandom"
require "cloud_events"
def server_url
ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
end
base = server_url
channel = "ruby-ce-events.basic-pubsub"
client_id = "kubemq-ce-ruby-example"
received = Queue.new
# SSE subscriber thread.
subscriber = Thread.new do
uri = URI("#{base}/ce/subscribe/events?client_id=#{client_id}-sub&channel=#{URI.encode_www_form_component(channel)}")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri)
req["Accept"] = "text/event-stream"
req["Cache-Control"] = "no-cache"
http.request(req) do |resp|
ev_type = nil
data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
received.push(data)
Thread.exit
end
ev_type = nil
data = nil
elsif line.start_with?(":") # keepalive
elsif line.start_with?("event:")
ev_type = line.sub("event:", "").strip
elsif line.start_with?("data:")
data = line.sub("data:", "").strip
end
end
end
end
end
end
# Allow subscription to establish.
sleep 0.5
# Build and publish CloudEvent (structured mode).
sdk = CloudEvents::HttpBinding.default
event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid,
type: "com.kubemq.examples.events.sent",
source: URI("urn:#{client_id}"),
subject: channel,
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" })
)
# Encode as structured mode.
headers, body = sdk.encode_event(event, structured_format: "json")
uri = URI("#{base}/ce/send/event")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = headers["Content-Type"]
req.body = body
res = http.request(req)
result = JSON.parse(res.body)
puts "Published: status=#{res.code} is_error=#{result['is_error']}"
end
# Wait for subscriber.
data = nil
Timeout.timeout(10) { data = received.pop }
ce = JSON.parse(data)
puts "Received: #{ce['type']} / #{ce['data']}"
```
```rust
//! Example: events/basic-pubsub
//! Run: cargo run -p basic-pubsub
use bytes::Bytes;
use cloudevents::{EventBuilder, EventBuilderV10};
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use std::env;
use tokio::sync::oneshot;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
/// Parse SSE lines and return the data of the first cloudevent.
async fn wait_for_cloudevent(
mut stream: impl futures_util::Stream- > + Unpin,
tx: oneshot::Sender,
) {
let mut event_type = String::new();
let mut data = String::new();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => { eprintln!("SSE read error: {}", e); break; }
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if event_type == "cloudevent" && !data.is_empty() {
let _ = tx.send(data.clone());
return;
}
event_type.clear();
data.clear();
} else if line.starts_with(':') {
// keepalive comment — ignore
} else if let Some(v) = line.strip_prefix("event:") {
event_type = v.trim().to_string();
} else if let Some(v) = line.strip_prefix("data:") {
data = v.trim().to_string();
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let channel = "rust-ce-events.basic-pubsub";
let client_id = "kubemq-ce-rust-example";
let client = Client::new();
// Start SSE subscriber.
let (tx, rx) = oneshot::channel::();
let sub_url = format!(
"{}/ce/subscribe/events?client_id={}-sub&channel={}",
base, client_id, channel
);
let sub_client = client.clone();
tokio::spawn(async move {
let stream = sub_client
.get(&sub_url)
.header("Accept", "text/event-stream")
.header("Cache-Control", "no-cache")
.send()
.await
.expect("SSE connect failed")
.bytes_stream();
wait_for_cloudevent(stream, tx).await;
});
// Allow SSE to establish.
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Build CloudEvent (structured mode using cloudevents-sdk).
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.events.sent")
.source(format!("urn:{}", client_id))
.subject(channel)
.data(
"application/json",
json!({"message": "Hello from Rust CloudEvents example!"}),
)
.build()?;
// Serialize to structured mode JSON.
let body = serde_json::to_string(&event)?;
let resp = client
.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/cloudevents+json")
.body(body)
.send()
.await?;
let result: Value = resp.json().await?;
println!("Published: status=202 is_error={}", result["is_error"]);
// Wait for received event.
let data = tokio::time::timeout(tokio::time::Duration::from_secs(10), rx)
.await
.expect("Timed out waiting for event")
.expect("Channel closed");
let ce: Value = serde_json::from_str(&data)?;
println!("Received: {} / {}", ce["type"], ce["data"]);
Ok(())
}
```
The publish call returns `HTTP 202 Accepted`:
```json
{ "is_error": false, "message": "OK", "data": {} }
```
## Subscribing [#subscribing]
Open a long-lived SSE stream with `GET /ce/subscribe/events`. The connector streams `event: cloudevent` frames as messages arrive; each `data:` line holds the reconstructed CloudEvent JSON.
```bash
curl -N "http://localhost:9090/ce/subscribe/events?client_id=my-client&channel=orders"
```
A delivered frame looks like:
```text
event: cloudevent
data: {"specversion":"1.0","type":"com.example.order.created","source":"order-service","subject":"orders","data":{"order_id":"12345","amount":99.99}}
```
Subscription accepts these query parameters:
| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------------------------------------------ |
| `client_id` | string | Yes | Client identifier (overridden by auth claims when auth is enabled) |
| `channel` | string | Yes | Channel to subscribe to |
| `group` | string | No | Load-balancing group name |
## Consumer groups [#consumer-groups]
Without a `group`, every subscriber on a channel receives every event (fan-out). Add the **same** `group` to multiple subscribers and they form a load-balancing pool — each event is delivered to exactly **one** member, round-robin.
```bash
# Terminal 1 — worker 1 in group "workers"
curl -N "http://localhost:9090/ce/subscribe/events?client_id=w1&channel=orders&group=workers"
# Terminal 2 — worker 2 in the same group
curl -N "http://localhost:9090/ce/subscribe/events?client_id=w2&channel=orders&group=workers"
```
```go
// Example: events/consumer-group — each subscriber gets one of two events.
func subscribe(base, clientID, channel, group string, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s&group=%s",
base, clientID, channel, group)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
client := &http.Client{Timeout: 0} // no timeout for SSE
resp, err := client.Do(req)
if err != nil {
log.Printf("[%s] SSE connect error: %v", clientID, err)
return
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
var eventType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if eventType == "cloudevent" && data != "" {
results <- fmt.Sprintf("[%s] received: %s", clientID, data)
return
}
eventType, data = "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "event:") {
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
}
func main() {
base := serverURL()
channel := "go-ce-events.consumer-group"
group := "workers"
results := make(chan string, 2)
var wg sync.WaitGroup
// Two subscribers in the same group.
for i := 1; i <= 2; i++ {
wg.Add(1)
go subscribe(base, fmt.Sprintf("worker-%d", i), channel, group, results, &wg)
}
time.Sleep(600 * time.Millisecond)
// Publish two events — each subscriber should get exactly one.
sendEvent(base, channel, 1)
sendEvent(base, channel, 2)
deadline := time.After(10 * time.Second)
for received := 0; received < 2; {
select {
case msg := <-results:
fmt.Println(msg)
received++
case <-deadline:
log.Fatal("Timed out waiting for events")
}
}
}
```
```python
# Example: events/consumer_group — load-balanced subscriber groups.
import threading
import time
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def subscribe_group(base, channel, group, client_id, results, stop):
sse_url = (f"{base}/ce/subscribe/events"
f"?client_id={client_id}&channel={channel}&group={group}")
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
ev_type = ""
data = ""
for line in resp.iter_lines(decode_unicode=True):
if stop.is_set():
return
if line == "":
if ev_type == "cloudevent" and data:
results.append(f"[{client_id}] received: {data[:80]}")
return
ev_type = data = ""
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
def main():
base = "http://localhost:9090"
channel = "python-ce-events.consumer-group"
group = "workers"
results, stop = [], threading.Event()
for i in range(1, 3):
threading.Thread(
target=subscribe_group,
args=(base, channel, group, f"worker-{i}", results, stop),
daemon=True,
).start()
time.sleep(0.6)
for seq in range(1, 3):
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.events.grouped",
"source": "kubemq-ce-python-example",
"subject": channel,
"datacontenttype": "application/json",
},
data={"seq": seq},
)
headers, body = to_structured(event)
resp = requests.post(f"{base}/ce/send/event", data=body,
headers=dict(headers), timeout=10)
print(f"Published event seq={seq} (status={resp.status_code})")
deadline = time.time() + 10
while len(results) < 2 and time.time() < deadline:
time.sleep(0.1)
stop.set()
for r in results:
print(r)
if __name__ == "__main__":
main()
```
Consumer groups are server-side and ephemeral — they exist only while subscribers are connected. Because events are not persisted, a group that has no connected members when an event is published will not receive it. For durable, replayable delivery, use [Events Store](/connectors/cloudevents/how-to/events-store).
## Related [#related]
# Queues (/connectors/cloudevents/how-to/queues)
The CloudEvents connector exposes KubeMQ's durable **queue** pattern over plain HTTP: producers `POST` a CloudEvent to a queue, and one consumer at a time pulls messages off it with at-least-once delivery.
## Overview [#overview]
A queue is a **durable, FIFO work channel**. Unlike events (fire-and-forget pub/sub), a queue message is stored until a consumer receives it, and is delivered to exactly one consumer in the group — making queues the right fit for distributing work across a pool of workers.
The connector maps three control operations onto HTTP:
| Operation | Endpoint | Body | Purpose |
| --------- | ------------------------ | ----------------------------------------- | ------------------------------------- |
| Send | `POST /ce/queue/send` | CloudEvent (structured or binary) | Enqueue one message |
| Receive | `POST /ce/queue/receive` | *(ignored)* — all params via query string | Pull (and consume) messages |
| Ack all | `POST /ce/queue/ack_all` | *(ignored)* — all params via query string | Drain all pending messages atomically |
`receive` and `ack_all` are **control operations**: the request body is ignored and every parameter is supplied as a query string. To inspect messages without consuming them, set `is_peek=true` on a `receive` call (peek).
## How it works [#how-it-works]
A producer enqueues CloudEvents; a worker polls the queue and the connector hands each stored message to exactly one consumer.
*Messages are durably stored on the queue channel and delivered to one worker at a time.*
## Send and receive [#send-and-receive]
Send a CloudEvent to a queue with `POST /ce/queue/send` (returns HTTP 202), then pull it back with `POST /ce/queue/receive`. The receive call is a control operation — pass `channel`, `client_id`, `max_messages`, and `wait_timeout` as query parameters.
```bash
# Send a message to the queue (structured CloudEvent)
curl -X POST http://localhost:9090/ce/queue/send \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.job.submit",
"source": "job-scheduler",
"subject": "work-queue",
"data": {"job_type": "report", "params": {"month": "2026-03"}}
}'
# Receive (consume) up to 10 messages, waiting up to 10s
curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=10"
```
```csharp
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text.Json;
static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var channel = "csharp-ce-queues.basic";
var clientId = "kubemq-ce-csharp-worker";
var formatter = new JsonEventFormatter();
using var httpClient = new HttpClient();
Console.WriteLine($"Sending 3 messages to queue '{channel}':");
for (int i = 1; i <= 3; i++)
{
var ev = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.queues.task",
Source = new Uri("urn:kubemq-ce-csharp-example"),
Subject = channel,
DataContentType = "application/json",
Data = new { task_id = i, task = "process-item" },
};
var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct);
using var c = new ByteArrayContent(bytes.ToArray());
c.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString());
var r = await httpClient.PostAsync($"{base_}/ce/queue/send", c);
var j = JsonSerializer.Deserialize(await r.Content.ReadAsStringAsync());
Console.WriteLine($" Sent task {i}: is_error={j.GetProperty("is_error")}");
}
Console.WriteLine("\nReceiving 3 messages:");
for (int i = 0; i < 3; i++)
{
var url = $"{base_}/ce/queue/receive?channel={Uri.EscapeDataString(channel)}&client_id={clientId}&max_messages=1&wait_timeout=5";
var r = await httpClient.PostAsync(url, null);
var j = JsonSerializer.Deserialize(await r.Content.ReadAsStringAsync());
if (j.GetProperty("is_error").GetBoolean()) {
Console.WriteLine($" Error: {j.GetProperty("message")}"); continue;
}
if (j.TryGetProperty("data", out var data) && data.TryGetProperty("messages", out var msgs))
{
foreach (var m in msgs.EnumerateArray())
Console.WriteLine($" Received: type={m.GetProperty("type")} data={m.GetProperty("data")}");
}
}
```
```go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
type CEResponse struct {
IsError bool `json:"is_error"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
type QueueReceiveData struct {
MessagesReceived int `json:"messages_received"`
Messages []map[string]interface{} `json:"messages"`
}
func sendToQueue(base, channel string, i int) {
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.queues.task")
event.SetSource("kubemq-ce-go-example")
event.SetSubject(channel)
_ = event.SetData(cloudevents.ApplicationJSON, map[string]interface{}{
"task_id": i, "task": "process-item", "priority": "normal",
})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/queue/send", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("queue send:", err)
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Sent task %d: status=%d is_error=%v\n", i, resp.StatusCode, result.IsError)
}
func receiveFromQueue(base, channel, clientID string) {
url := fmt.Sprintf("%s/ce/queue/receive?channel=%s&client_id=%s&max_messages=1&wait_timeout=5",
base, channel, clientID)
req, _ := http.NewRequest("POST", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("queue receive:", err)
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
var data QueueReceiveData
_ = json.Unmarshal(result.Data, &data)
for _, msg := range data.Messages {
fmt.Printf("Received: type=%v data=%v\n", msg["type"], msg["data"])
}
}
func main() {
base := serverURL()
channel := "go-ce-queues.basic"
clientID := "kubemq-ce-go-worker"
for i := 1; i <= 3; i++ {
sendToQueue(base, channel, i)
}
for i := 0; i < 3; i++ {
receiveFromQueue(base, channel, clientID)
}
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class Main {
static String serverUrl() {
String u = System.getenv("KUBEMQ_CE_URL");
return (u != null && !u.isEmpty()) ? u : "http://localhost:9090";
}
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
String base = serverUrl();
String channel = "java-ce-queues.basic";
String clientId = "kubemq-ce-java-worker";
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
HttpClient httpClient = HttpClient.newHttpClient();
for (int i = 1; i <= 3; i++) {
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.queues.task")
.withSource(URI.create("kubemq-ce-java-example"))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("task_id", i, "task", "process-item")))
.build();
httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/queue/send"))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(event)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
}
for (int i = 0; i < 3; i++) {
String url = base + "/ce/queue/receive?channel=" + channel
+ "&client_id=" + clientId + "&max_messages=1&wait_timeout=5";
HttpResponse resp = httpClient.send(
HttpRequest.newBuilder().uri(URI.create(url))
.POST(HttpRequest.BodyPublishers.noBody()).build(),
HttpResponse.BodyHandlers.ofString());
Map, ?> r = MAPPER.readValue(resp.body(), Map.class);
Map, ?> data = (Map, ?>) r.get("data");
List> msgs = data != null ? (List>) data.get("messages") : List.of();
for (Object m : msgs) {
Map, ?> msg = (Map, ?>) m;
System.out.println(" Received: type=" + msg.get("type") + " data=" + msg.get("data"));
}
}
}
}
```
```javascript
import { CloudEvent, HTTP } from 'cloudevents';
function serverUrl() {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
async function main() {
const base = serverUrl();
const channel = 'js-ce-queues.basic';
const clientId = 'kubemq-ce-js-worker';
const numMessages = 3;
for (let i = 1; i <= numMessages; i++) {
const event = new CloudEvent({
type: 'com.kubemq.examples.queues.task',
source: 'kubemq-ce-js-example',
subject: channel,
datacontenttype: 'application/json',
data: { task_id: i, task: 'process-item' },
});
const msg = HTTP.structured(event);
const resp = await fetch(`${base}/ce/queue/send`, {
method: 'POST',
headers: msg.headers,
body: msg.body,
});
const result = await resp.json();
console.log(` Sent task ${i}: status=${resp.status} is_error=${result.is_error}`);
}
for (let i = 0; i < numMessages; i++) {
const url = `${base}/ce/queue/receive?channel=${encodeURIComponent(channel)}&client_id=${clientId}&max_messages=1&wait_timeout=5`;
const resp = await fetch(url, { method: 'POST' });
const result = await resp.json();
for (const m of result.data?.messages ?? []) {
console.log(` Received: type=${m.type} data=${JSON.stringify(m.data)}`);
}
}
}
main().catch(console.error);
```
```python
import os
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def main() -> None:
base = server_url()
channel = "python-ce-queues.basic"
client_id = "kubemq-ce-python-worker"
num_messages = 3
for i in range(1, num_messages + 1):
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.queues.task",
"source": "kubemq-ce-python-example",
"subject": channel,
"datacontenttype": "application/json",
},
data={"task_id": i, "task": "process-item"},
)
headers, body = to_structured(event)
resp = requests.post(f"{base}/ce/queue/send", data=body,
headers=dict(headers), timeout=10)
result = resp.json()
print(f" Sent task {i}: status={resp.status_code} is_error={result.get('is_error')}")
for _ in range(num_messages):
resp = requests.post(
f"{base}/ce/queue/receive",
params={
"channel": channel,
"client_id": client_id,
"max_messages": 1,
"wait_timeout": 5,
},
timeout=10,
)
result = resp.json()
for msg in result.get("data", {}).get("messages", []):
print(f" Received: type={msg.get('type')} data={msg.get('data')}")
if __name__ == "__main__":
main()
```
```ruby
require "net/http"
require "uri"
require "json"
require "securerandom"
require "cloud_events"
def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
base = server_url
channel = "ruby-ce-queues.basic"
client_id = "kubemq-ce-ruby-worker"
sdk = CloudEvents::HttpBinding.default
(1..3).each do |i|
event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid, type: "com.kubemq.examples.queues.task",
source: URI("urn:kubemq-ce-ruby-example"), subject: channel,
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ task_id: i, task: "process-item" })
)
enc_headers, enc_body = sdk.encode_event(event, structured_format: "json")
uri = URI("#{base}/ce/queue/send")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri)
enc_headers.each { |k, v| req[k] = v }
req.body = enc_body
res = http.request(req)
r = JSON.parse(res.body)
puts " Sent task #{i}: status=#{res.code} is_error=#{r['is_error']}"
end
end
3.times do
uri = URI("#{base}/ce/queue/receive")
uri.query = URI.encode_www_form(channel: channel, client_id: client_id,
max_messages: 1, wait_timeout: 5)
Net::HTTP.start(uri.host, uri.port) do |http|
res = http.request(Net::HTTP::Post.new(uri))
r = JSON.parse(res.body)
msgs = r.dig('data', 'messages') || []
msgs.each { |m| puts " Received: type=#{m['type']} data=#{m['data']}" }
end
end
```
```rust
use cloudevents::{EventBuilder, EventBuilderV10};
use reqwest::Client;
use serde_json::{json, Value};
use std::env;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let client = Client::new();
let channel = "rust-ce-queues.basic";
let client_id = "kubemq-ce-rust-worker";
for i in 1..=3u32 {
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.queues.task")
.source("urn:kubemq-ce-rust-example")
.subject(channel)
.data("application/json", json!({"task_id": i, "task": "process-item"}))
.build()?;
let body = serde_json::to_string(&event)?;
let resp = client.post(format!("{}/ce/queue/send", base))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await?;
let r: Value = resp.json().await?;
println!(" Sent task {}: is_error={}", i, r["is_error"]);
}
for _ in 0..3 {
let url = format!(
"{}/ce/queue/receive?channel={}&client_id={}&max_messages=1&wait_timeout=5",
base, channel, client_id
);
let resp = client.post(&url).send().await?;
let r: Value = resp.json().await?;
let msgs = r["data"]["messages"].as_array().cloned().unwrap_or_default();
for m in &msgs {
println!(" Received: type={} data={}", m["type"], m["data"]);
}
}
Ok(())
}
```
### Receive parameters [#receive-parameters]
| Parameter | Type | Default | Description |
| -------------- | ------ | ------------ | ------------------------------------------------------------------ |
| `channel` | string | *(required)* | Queue channel name |
| `client_id` | string | *(required)* | Client identifier (overridden by auth claims when auth is enabled) |
| `max_messages` | int | `1` | Maximum number of messages to receive (1–1000) |
| `wait_timeout` | int | `5` | Wait timeout in seconds |
| `is_peek` | bool | `false` | If true, peek at messages without consuming them |
A successful receive returns HTTP 200. Each message that carries CE tags is reconstructed as a CloudEvent JSON object; messages without CE tags are returned in plain KubeMQ format:
```json
{
"is_error": false,
"message": "OK",
"data": {
"messages_received": 1,
"messages": [
{
"specversion": "1.0",
"type": "com.example.job.submit",
"source": "job-scheduler",
"subject": "work-queue",
"id": "550e8400-e29b-41d4-a716-446655440000",
"time": "2026-03-29T10:30:00Z",
"data": {"job_type": "report", "params": {"month": "2026-03"}}
}
]
}
}
```
## Peek [#peek]
Peeking inspects queued messages **without consuming them** — pass `is_peek=true` on a `receive` call. The same messages remain available for a later consuming receive, which makes peek useful for monitoring queue depth or previewing work before committing to it.
```bash
# Peek: inspect up to 10 messages without removing them
curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=3&is_peek=true"
# A normal receive (is_peek omitted) consumes the same messages
curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=3"
```
```go
func receiveOrPeek(base, channel, clientID string, isPeek bool, label string) int {
isPeekStr := "false"
if isPeek {
isPeekStr = "true"
}
url := fmt.Sprintf(
"%s/ce/queue/receive?channel=%s&client_id=%s&max_messages=10&wait_timeout=3&is_peek=%s",
base, channel, clientID, isPeekStr)
req, _ := http.NewRequest("POST", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("receive:", err)
}
defer resp.Body.Close()
var result CEResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
var data QueueReceiveData
_ = json.Unmarshal(result.Data, &data)
fmt.Printf("[%s] messages_received=%d\n", label, data.MessagesReceived)
return data.MessagesReceived
}
// Peek twice (messages stay), then consume.
receiveOrPeek(base, channel, "go-peek-client", true, "peek #1")
receiveOrPeek(base, channel, "go-peek-client", true, "peek #2")
receiveOrPeek(base, channel, "go-peek-client", false, "consume")
```
```javascript
async function receiveOrPeek(base, channel, clientId, isPeek, label) {
const url = `${base}/ce/queue/receive?channel=${encodeURIComponent(channel)}&client_id=${clientId}&max_messages=10&wait_timeout=3&is_peek=${isPeek}`;
const resp = await fetch(url, { method: 'POST' });
const result = await resp.json();
const n = result.data?.messages_received ?? 0;
console.log(`[${label}] messages_received=${n}`);
return n;
}
// Peek twice (messages stay), then consume.
await receiveOrPeek(base, channel, 'js-peek-client', true, 'peek #1');
await receiveOrPeek(base, channel, 'js-peek-client', true, 'peek #2');
await receiveOrPeek(base, channel, 'js-peek-client', false, 'consume');
```
```python
def receive_or_peek(base, channel, client_id, is_peek, label):
resp = requests.post(
f"{base}/ce/queue/receive",
params={
"channel": channel,
"client_id": client_id,
"max_messages": 10,
"wait_timeout": 3,
"is_peek": "true" if is_peek else "false",
},
timeout=10,
)
result = resp.json()
n = result.get("data", {}).get("messages_received", 0)
print(f"[{label}] messages_received={n}")
return n
# Peek twice (messages stay), then consume.
receive_or_peek(base, channel, "python-peek-client", True, "peek #1")
receive_or_peek(base, channel, "python-peek-client", True, "peek #2")
receive_or_peek(base, channel, "python-peek-client", False, "consume")
```
The query parameter is spelled `is_peek` on the wire — setting `is_peek=true` performs a non-destructive peek (messages stay queued), while omitting it (or `is_peek=false`) performs a normal consuming receive. The internal Go struct field is named `IsPeak`, but that name never appears on the wire.
## Ack all [#ack-all]
`POST /ce/queue/ack_all` acknowledges (drains) **all pending messages** in a queue atomically, without receiving them one by one. This is a control operation — the body is ignored and `channel`, `client_id`, and `wait_timeout` come from query parameters. Use it to clear a backlog or reset a work channel.
```bash
curl -X POST "http://localhost:9090/ce/queue/ack_all?channel=work-queue&client_id=worker-1&wait_timeout=10"
```
```go
// Drain the queue atomically.
ackURL := fmt.Sprintf("%s/ce/queue/ack_all?channel=%s&client_id=%s&wait_timeout=5",
base, channel, clientID)
req, _ := http.NewRequest("POST", ackURL, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("ack_all:", err)
}
defer resp.Body.Close()
var ackResult CEResponse
_ = json.NewDecoder(resp.Body).Decode(&ackResult)
fmt.Printf("ack_all: is_error=%v message=%s\n", ackResult.IsError, ackResult.Message)
```
```python
# Drain the queue atomically.
ack_resp = requests.post(
f"{base}/ce/queue/ack_all",
params={"channel": channel, "client_id": client_id, "wait_timeout": 5},
timeout=10,
)
result = ack_resp.json()
print(f"ack_all: is_error={result.get('is_error')} message={result.get('message')}")
```
### Ack-all parameters [#ack-all-parameters]
| Parameter | Type | Default | Description |
| -------------- | ------ | ------------ | ------------------------------------------------------------------ |
| `channel` | string | *(required)* | Queue channel name |
| `client_id` | string | *(required)* | Client identifier (overridden by auth claims when auth is enabled) |
| `wait_timeout` | int | `5` | Wait timeout in seconds |
A successful ack returns HTTP 200 with the result in `data`.
`send`, `receive`, and `ack_all` are synchronous `POST` endpoints and are subject to the connector's `TimeoutSeconds` (default 60s). A request that exceeds the timeout returns HTTP 504. See [Configuration](/connectors/cloudevents/concepts/configuration-model).
## Related [#related]
# SSE Behavior (/connectors/cloudevents/how-to/sse-behavior)
The CloudEvents connector streams every subscription over **Server-Sent Events (SSE)** — a standard, long-lived HTTP mechanism for server-to-client delivery. This guide covers the wire format your client must parse, the keepalive and idle-timeout behavior, the connection limit, and how `Last-Event-ID` resumes an events-store stream after a disconnect.
## Overview [#overview]
When a client opens any `GET /ce/subscribe/*` endpoint, the connector holds the connection open and responds with `Content-Type: text/event-stream`. Messages arriving on the subscribed channel are pushed to the client as SSE frames as they happen — there is no polling.
All four subscription endpoints (`events`, `events-store`, `commands`, `queries`) share the same SSE wire format and lifecycle. The only behavioral difference is **replay**: events-store streams carry an `id:` per frame and support `Last-Event-ID` reconnection, while plain `events` streams do not (non-persistent events cannot be replayed).
SSE subscription endpoints are long-lived `GET` requests and are **not** subject to the `TimeoutSeconds` (default 60s) that applies to synchronous `POST` endpoints. They stay open until idle timeout, the connection limit, a client disconnect, or a server shutdown.
## SSE wire format [#sse-wire-format]
Each frame is a set of optional `id:`, `event:`, and `data:` lines terminated by a blank line. The `event:` field tells the client which kind of payload the `data:` line carries.
```text title="SSE frame — CloudEvent message"
id: 42
event: cloudevent
data: {"specversion":"1.0","type":"com.example.order","source":"svc","id":"abc","subject":"orders","data":{"amount":99}}
```
```text title="SSE frame — non-CloudEvent message"
event: message
data: {"channel":"orders","metadata":"","tags":{"key":"val"},"data":{"amount":99}}
```
```text title="SSE frame — error event"
event: error
data: {"is_error":true,"message":"stream idle timeout"}
```
```text title="Keepalive comment (not a named event)"
: keepalive
```
### Event types [#event-types]
The `event:` field distinguishes message kinds on a single stream:
| `event:` value | Description |
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
| `cloudevent` | The message carries a `ce_specversion` tag; `data:` is a reconstructed CloudEvent JSON object. |
| `message` | A non-CloudEvent message; `data:` is plain JSON with native KubeMQ fields (`channel`, `metadata`, `tags`, `data`). |
| `error` | A terminal error (for example, idle timeout); `data:` is `{"is_error":true,"message":"..."}`. |
| *(absent)* | A keepalive comment line (`: keepalive`) — ignored by standard `EventSource` clients. |
## How reconnection works [#how-reconnection-works]
The diagram below shows an events-store subscription: the client reads several frames, records the last `id:` it saw, disconnects, then reconnects with `Last-Event-ID` to resume from the next sequence — no duplicates, no gaps.
*An events-store stream resumes from `sequence + 1` when the client reconnects with `Last-Event-ID`.*
## Keepalive [#keepalive]
To stop proxies and load balancers from closing an idle connection, the connector sends a keepalive comment every **30 seconds**:
```text
: keepalive
```
Keepalive frames are SSE comments (lines starting with `:`). Standard `EventSource` clients ignore them automatically; a manual parser should skip any line that starts with `:`.
## Idle timeout [#idle-timeout]
If no message arrives for `MaxSSEIdleSeconds` (default **300 seconds**), the connector emits an `error` event and closes the connection:
```text
event: error
data: {"is_error":true,"message":"stream idle timeout"}
```
The idle timer resets on every received message — keepalive comments do **not** reset it. Treat the idle-timeout error as a normal lifecycle event and reconnect if you still need the stream. See [Configuration](/connectors/cloudevents/concepts/configuration-model) to tune `MaxSSEIdleSeconds`.
## Connection limits [#connection-limits]
When `MaxSSEConnections` is greater than `0`, the connector caps the number of concurrent SSE connections across **all** subscription endpoints. A new connection that exceeds the limit is rejected with **HTTP 429 Too Many Requests**. The default of `0` disables the limit (unlimited connections).
## Last-Event-ID reconnection [#last-event-id-reconnection]
Replay-on-reconnect is available for **events-store subscriptions only**. Each frame on an events-store stream carries an `id:` line set to the message sequence number. When a client reconnects with the `Last-Event-ID` header, the connector resumes from `sequence + 1`, automatically replaying anything missed during the disconnect.
This follows the standard SSE reconnection protocol — browsers and `EventSource` clients send `Last-Event-ID` on reconnect for you.
**Omit `events_store_type` when reconnecting with `Last-Event-ID`.** If both are present, the `events_store_type` query parameter takes precedence and the `Last-Event-ID` resume is ignored. Drop `events_store_type` from the reconnect URL.
Plain `events` subscriptions do not emit `id:` fields and cannot be replayed. For command/query subscriptions, CloudEvent messages carry an `id:` set to the CloudEvent `id` attribute, while non-CE command/query messages have no `id:`.
## Mixed CE and non-CE messages [#mixed-ce-and-non-ce-messages]
A single channel can carry both CloudEvents and native KubeMQ messages, and the connector delivers both on the same SSE stream. The `event:` field is how you tell them apart:
* `event: cloudevent` — `data:` is a reconstructed CloudEvent JSON object.
* `event: message` — `data:` is plain JSON with native KubeMQ fields.
Always branch on the `event:` value so your consumer handles mixed-protocol channels correctly.
## Usage [#usage]
The example below demonstrates the full reconnection lifecycle against `events-store`: subscribe with `events_store_type=2` (start from first), read a batch of frames while recording the last `id:`, disconnect, then reconnect with the `Last-Event-ID` header to resume. The curl tab shows the two raw requests; each language tab is the verbatim KubeMQ example that parses the SSE stream and performs the resume.
```bash
# 1. Subscribe from the first stored message; note the id: on each frame.
curl -N \
-H "Accept: text/event-stream" \
"http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=2"
# Frames look like:
# id: 1
# event: cloudevent
# data: {"specversion":"1.0",...}
#
# : keepalive
# 2. Reconnect after a disconnect — resume from sequence + 1.
# Omit events_store_type so Last-Event-ID takes precedence.
curl -N \
-H "Accept: text/event-stream" \
-H "Last-Event-ID: 3" \
"http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log"
```
```csharp
// events-store/ReconnectResume — Last-Event-ID reconnect.
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var base_ = ServerUrl();
var channel = "csharp-ce-events-store.reconnect-resume";
var formatter = new JsonEventFormatter();
using var httpClient = new HttpClient();
// Publish 4 events.
Console.WriteLine("Publishing 4 events...");
for (int i = 1; i <= 4; i++)
{
var ev = new CloudEvent {
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.eventsstore.stored",
Source = new Uri("urn:kubemq-ce-csharp-example"),
Subject = channel,
DataContentType = "application/json",
Data = new { seq = i },
};
var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct);
using var c = new ByteArrayContent(bytes.ToArray());
c.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString());
await httpClient.PostAsync($"{base_}/ce/send/event-store", c);
}
// Helper: subscribe and collect up to maxEvents, return (events, lastId).
async Task<(List events, string lastId)> Subscribe(
string clientId, string? lastEventId, int maxEvents)
{
var events = new List();
var lastId = "";
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
// Omit events_store_type when reconnecting with Last-Event-ID.
var url = lastEventId == null
? $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}&events_store_type=2"
: $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (lastEventId != null) req.Headers.Add("Last-Event-ID", lastEventId);
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8);
string? evType = null, data = null, id = null, line;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while ((line = await reader.ReadLineAsync().WaitAsync(cts.Token)) != null)
{
if (line == "") {
if (evType == "cloudevent" && data != null) {
if (id != null) lastId = id;
events.Add(JsonSerializer.Deserialize(data));
if (events.Count >= maxEvents) break;
}
evType = null; data = null; id = null;
}
else if (line.StartsWith(":")) { }
else if (line.StartsWith("id:")) id = line[3..].Trim();
else if (line.StartsWith("event:")) evType = line[6..].Trim();
else if (line.StartsWith("data:")) data = line[5..].Trim();
}
return (events, lastId);
}
// First connection: receive 2 events.
Console.WriteLine("First connection — receiving 2 events:");
var (first, lastId) = await Subscribe("csharp-es-reconnect-1", null, 2);
foreach (var ce in first)
Console.WriteLine($" Received: seq={ce.GetProperty("data").GetProperty("seq")}");
Console.WriteLine($" Last-Event-ID recorded: {lastId}");
// Reconnect using Last-Event-ID (new client_id; broker requires a unique active client_id).
await Task.Delay(500);
Console.WriteLine($"Reconnecting with Last-Event-ID={lastId}...");
var (second, _) = await Subscribe("csharp-es-reconnect-2", lastId, 2);
foreach (var ce in second)
Console.WriteLine($" Resumed: seq={ce.GetProperty("data").GetProperty("seq")}");
Console.WriteLine("Reconnect-resume demonstration complete.");
```
```go
// events-store/reconnect-resume — SSE reconnection with Last-Event-ID.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func serverURL() string {
if u := os.Getenv("KUBEMQ_CE_URL"); u != "" {
return u
}
return "http://localhost:9090"
}
func publishAll(base, channel string, count int) {
for i := 1; i <= count; i++ {
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.eventsstore.reconnect")
event.SetSource("kubemq-ce-go-example")
event.SetSubject(channel)
_ = event.SetData(cloudevents.ApplicationJSON, map[string]int{"n": i})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/event-store",
strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send:", err)
}
resp.Body.Close()
}
fmt.Printf("Published %d events to events-store.\n", count)
}
// readN reads exactly n cloudevents from an SSE stream, returning the last SSE id.
func readN(body io.ReadCloser, n int) (lastID string) {
scanner := bufio.NewScanner(body)
var evType, data, sseID string
received := 0
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if evType == "cloudevent" && data != "" {
received++
lastID = sseID
var ce map[string]interface{}
_ = json.Unmarshal([]byte(data), &ce)
fmt.Printf(" [%d] id=%s data=%v\n", received, sseID, ce["data"])
if received == n {
return lastID
}
}
evType, data, sseID = "", "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "id:") {
sseID = strings.TrimSpace(strings.TrimPrefix(line, "id:"))
} else if strings.HasPrefix(line, "event:") {
evType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
return lastID
}
func openSSE(base, channel, clientID, lastEventID string) (*http.Response, error) {
// When reconnecting, omit events_store_type so Last-Event-ID takes precedence.
var sseURL string
if lastEventID == "" {
sseURL = fmt.Sprintf(
"%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=2",
base, clientID, channel)
} else {
sseURL = fmt.Sprintf(
"%s/ce/subscribe/events-store?client_id=%s&channel=%s",
base, clientID, channel)
}
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
if lastEventID != "" {
req.Header.Set("Last-Event-ID", lastEventID)
}
client := &http.Client{Timeout: 0}
return client.Do(req)
}
func main() {
base := serverURL()
channel := "go-ce-events-store.reconnect-resume"
const totalEvents = 6
const firstBatch = 3
publishAll(base, channel, totalEvents)
time.Sleep(200 * time.Millisecond)
// First connection: receive first batch.
fmt.Printf("\nFirst connection (StartFromFirst, reading first %d events):\n", firstBatch)
resp1, err := openSSE(base, channel, "go-reconnect-sub", "")
if err != nil {
log.Fatal("first connect:", err)
}
lastID := readN(resp1.Body, firstBatch)
resp1.Body.Close()
fmt.Printf("Disconnected. Last-Event-ID captured: %s\n", lastID)
// Second connection: resume from lastID.
fmt.Printf("\nReconnecting with Last-Event-ID: %s\n", lastID)
resp2, err := openSSE(base, channel, "go-reconnect-sub", lastID)
if err != nil {
log.Fatal("reconnect:", err)
}
defer resp2.Body.Close()
remaining := totalEvents - firstBatch
fmt.Printf("Receiving remaining %d events:\n", remaining)
readN(resp2.Body, remaining)
fmt.Println("\nReconnect-resume demonstration complete.")
}
```
```java
// events-store/reconnect-resume — Last-Event-ID reconnect.
package io.kubemq.examples.eventsstore.reconnectresume;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class Main {
static String serverUrl() {
String u = System.getenv("KUBEMQ_CE_URL");
return (u != null && !u.isEmpty()) ? u : "http://localhost:9090";
}
static final ObjectMapper MAPPER = new ObjectMapper();
/** Opens one SSE connection, reads up to maxEvents, returns lastEventID seen. */
static String subscribe(String base, String channel, String clientId,
String lastEventId, int maxEvents,
BlockingQueue out) throws Exception {
// Build URL — omit events_store_type when reconnecting with Last-Event-ID.
String sseUrl = base + "/ce/subscribe/events-store?client_id=" + clientId + "&channel=" + channel;
if (lastEventId == null) {
sseUrl += "&events_store_type=2";
}
final String finalLastEventId = lastEventId;
final String finalUrl = sseUrl;
AtomicReference lastId = new AtomicReference<>("");
BlockingQueue done = new ArrayBlockingQueue<>(1);
Thread.ofVirtual().start(() -> {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(finalUrl).openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(10_000);
if (finalLastEventId != null) {
conn.setRequestProperty("Last-Event-ID", finalLastEventId);
}
int[] count = {0};
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line; String evType = null, data = null, id = null;
while ((line = reader.readLine()) != null && count[0] < maxEvents) {
if (line.isEmpty()) {
if ("cloudevent".equals(evType) && data != null) {
if (id != null) lastId.set(id);
out.offer(data);
count[0]++;
if (count[0] >= maxEvents) break;
}
evType = null; data = null; id = null;
} else if (line.startsWith("id:")) id = line.substring(3).trim();
else if (line.startsWith("event:")) evType = line.substring(6).trim();
else if (line.startsWith("data:")) data = line.substring(5).trim();
}
}
} catch (Exception e) { /* read ended */ }
done.offer(true);
});
done.poll(12, TimeUnit.SECONDS);
return lastId.get();
}
public static void main(String[] args) throws Exception {
String base = serverUrl();
String channel = "java-ce-events-store.reconnect-resume";
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
HttpClient httpClient = HttpClient.newHttpClient();
// Publish 4 events.
System.out.println("Publishing 4 events...");
for (int i = 1; i <= 4; i++) {
CloudEvent ev = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.eventsstore.stored")
.withSource(URI.create("kubemq-ce-java-example"))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json", MAPPER.writeValueAsBytes(Map.of("seq", i)))
.build();
httpClient.send(
HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event-store"))
.POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(ev)))
.header("Content-Type", "application/cloudevents+json").build(),
HttpResponse.BodyHandlers.ofString());
}
// First connection: receive 2 events, record lastEventID.
BlockingQueue received = new ArrayBlockingQueue<>(10);
System.out.println("First connection — receiving 2 events:");
String lastId = subscribe(base, channel, "java-es-reconnect", null, 2, received);
for (int i = 0; i < 2; i++) {
String data = received.poll(5, TimeUnit.SECONDS);
if (data != null) {
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println(" Received: seq=" + ((Map, ?>) ce.get("data")).get("seq"));
}
}
System.out.println(" Last-Event-ID recorded: " + lastId);
// Reconnect using Last-Event-ID — resume from next sequence.
System.out.println("Reconnecting with Last-Event-ID=" + lastId + "...");
subscribe(base, channel, "java-es-reconnect", lastId, 2, received);
for (int i = 0; i < 2; i++) {
String data = received.poll(5, TimeUnit.SECONDS);
if (data != null) {
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println(" Resumed: seq=" + ((Map, ?>) ce.get("data")).get("seq"));
}
}
System.out.println("Reconnect-resume demonstration complete.");
}
}
```
```typescript
// events-store/reconnect-resume — Last-Event-ID reconnection.
import { CloudEvent, HTTP } from 'cloudevents';
import EventSource from 'eventsource';
function serverUrl(): string {
return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
}
function readNEvents(
base: string, channel: string, clientId: string,
n: number, lastEventId?: string,
): Promise {
return new Promise((resolve) => {
const params = new URLSearchParams({ client_id: clientId, channel });
if (lastEventId) {
// omit events_store_type so Last-Event-ID takes precedence
} else {
params.set('events_store_type', '2');
}
const headers: Record = {};
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
// EventSource works for the initial connection; for the Last-Event-ID
// reconnect we use fetch so we can set the header explicitly.
if (!lastEventId) {
const es = new EventSource(`${base}/ce/subscribe/events-store?${params}`);
let count = 0;
let lastId = '';
es.addEventListener('error', (err) => {
console.error('SSE error:', err);
es.close();
resolve('');
});
es.addEventListener('cloudevent', (evt: MessageEvent & { lastEventId: string }) => {
const ce = JSON.parse(evt.data) as Record;
count++;
lastId = evt.lastEventId;
console.log(` [${count}] id=${lastId} data=${JSON.stringify(ce.data)}`);
if (count === n) {
es.close();
resolve(lastId);
}
});
} else {
const url = `${base}/ce/subscribe/events-store?${params}`;
fetch(url, { headers }).then(async (resp) => {
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let count = 0;
let lastId = lastEventId;
let evType = '';
let data = '';
while (count < n) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line === '') {
if (evType === 'cloudevent' && data) {
const ce = JSON.parse(data) as Record;
count++;
console.log(` [${count}] id=${lastId} data=${JSON.stringify(ce.data)}`);
if (count === n) { reader.cancel(); resolve(lastId); return; }
}
evType = data = '';
} else if (line.startsWith('id:')) {
lastId = line.slice(3).trim();
} else if (line.startsWith('event:')) {
evType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
data = line.slice(5).trim();
}
}
}
resolve(lastId);
});
}
});
}
async function main(): Promise {
const base = serverUrl();
const channel = 'js-ce-events-store.reconnect-resume';
const total = 6;
const firstBatch = 3;
for (let i = 1; i <= total; i++) {
const event = new CloudEvent({
type: 'com.kubemq.examples.eventsstore.reconnect',
source: 'kubemq-ce-js-example',
subject: channel,
datacontenttype: 'application/json',
data: { n: i },
});
const msg = HTTP.structured(event);
await fetch(`${base}/ce/send/event-store`, {
method: 'POST',
headers: msg.headers as Record,
body: msg.body as string,
});
}
console.log(`Published ${total} events.`);
await new Promise((r) => setTimeout(r, 200));
console.log(`\nFirst connection (reading first ${firstBatch} events):`);
const lastId = await readNEvents(base, channel, 'js-reconnect-sub', firstBatch);
console.log(`Disconnected. Last-Event-ID: ${lastId}`);
console.log(`\nReconnecting with Last-Event-ID=${lastId}:`);
await readNEvents(base, channel, 'js-reconnect-sub', total - firstBatch, lastId);
console.log('\nReconnect-resume complete.');
}
main().catch(console.error);
```
```python
"""events_store/reconnect_resume — SSE reconnect with Last-Event-ID."""
from __future__ import annotations
import json
import os
import time
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
def server_url() -> str:
return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090")
def read_n_events(base: str, channel: str, client_id: str,
n: int, last_event_id: str = "") -> str:
"""Open SSE, read n events, return last SSE id."""
if last_event_id:
sse_url = (f"{base}/ce/subscribe/events-store"
f"?client_id={client_id}&channel={channel}")
extra_headers = {"Last-Event-ID": last_event_id}
else:
sse_url = (f"{base}/ce/subscribe/events-store"
f"?client_id={client_id}&channel={channel}&events_store_type=2")
extra_headers = {}
headers = {"Accept": "text/event-stream", **extra_headers}
last_id = ""
count = 0
with requests.get(sse_url, stream=True, timeout=None, headers=headers) as resp:
ev_type = data = sse_id = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if ev_type == "cloudevent" and data:
ce = json.loads(data)
count += 1
last_id = sse_id
print(f" [{count}] id={sse_id} data={ce.get('data')}")
if count == n:
return last_id
ev_type = data = sse_id = ""
continue
if line.startswith(":"):
continue
if line.startswith("id:"):
sse_id = line[3:].strip()
elif line.startswith("event:"):
ev_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
return last_id
def main() -> None:
base = server_url()
channel = "python-ce-events-store.reconnect-resume"
total = 6
first_batch = 3
for i in range(1, total + 1):
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.eventsstore.reconnect",
"source": "kubemq-ce-python-example",
"subject": channel,
"datacontenttype": "application/json",
},
data={"n": i},
)
headers, body = to_structured(event)
requests.post(f"{base}/ce/send/event-store", data=body,
headers=dict(headers), timeout=10)
print(f"Published {total} events.")
time.sleep(0.2)
print(f"\nFirst connection (reading first {first_batch} events):")
last_id = read_n_events(base, channel, "python-reconnect-sub", first_batch)
print(f"Disconnected. Last-Event-ID: {last_id}")
print(f"\nReconnecting with Last-Event-ID={last_id}:")
read_n_events(base, channel, "python-reconnect-sub", total - first_batch, last_id)
print("\nReconnect-resume complete.")
if __name__ == "__main__":
main()
```
```ruby
# events_store/reconnect_resume — Last-Event-ID reconnect.
require "net/http"; require "uri"; require "json"; require "cloud_events"; require "securerandom"
def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090")
base = server_url; channel = "ruby-ce-events-store.reconnect-resume"
sdk = CloudEvents::HttpBinding.default
# Publish 4 events.
puts "Publishing 4 events..."
(1..4).each do |i|
ev = CloudEvents::Event::V1.new(
id: SecureRandom.uuid, type: "com.kubemq.examples.eventsstore.stored",
source: URI("urn:kubemq-ce-ruby-example"), subject: channel, spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ seq: i })
)
enc_h, enc_b = sdk.encode_event(ev, structured_format: "json")
uri = URI("#{base}/ce/send/event-store")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri); enc_h.each{|k,v|req[k]=v}; req.body=enc_b
http.request(req)
end
end
# Helper: open one SSE connection, collect up to max_events, return [events, last_id].
def subscribe_es(base, channel, last_event_id, max_events)
# Omit events_store_type when reconnecting with Last-Event-ID.
query = last_event_id.nil? \
? "events_store_type=2" \
: "" # no events_store_type on reconnect
uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-reconnect&channel=#{URI.encode_www_form_component(channel)}#{query.empty? ? '' : '&' + query}")
events = []; last_id = nil
Net::HTTP.start(uri.host, uri.port, read_timeout: 12) do |http|
req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream"
req["Last-Event-ID"] = last_event_id if last_event_id
http.request(req) do |resp|
ev_type = nil; data = nil; id = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
last_id = id if id
ce = JSON.parse(data)
ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String)
events << ce
return [events, last_id] if events.size >= max_events
end
ev_type = nil; data = nil; id = nil
elsif line.start_with?("id:") then id = line.sub("id:","").strip
elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip
elsif line.start_with?("data:") then data = line.sub("data:","").strip
end
end
end
end
end
[events, last_id]
end
# First connection: receive 2 events.
puts "First connection — receiving 2 events:"
first_events, last_id = subscribe_es(base, channel, nil, 2)
first_events.each { |ce| puts " Received: seq=#{ce.dig('data','seq')}" }
puts " Last-Event-ID recorded: #{last_id}"
# Reconnect using Last-Event-ID.
puts "Reconnecting with Last-Event-ID=#{last_id}..."
second_events, _ = subscribe_es(base, channel, last_id, 2)
second_events.each { |ce| puts " Resumed: seq=#{ce.dig('data','seq')}" }
puts "Reconnect-resume demonstration complete."
```
```rust
// events-store/reconnect-resume — Last-Event-ID reconnect.
use bytes::Bytes;
use cloudevents::{EventBuilder, EventBuilderV10};
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use std::env;
use uuid::Uuid;
fn server_url() -> String {
env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string())
}
/// Subscribe and collect up to `max` events. Returns (events, last_id_seen).
async fn subscribe_and_collect(
client: &Client,
url: &str,
last_event_id: Option<&str>,
max: usize,
) -> (Vec, String) {
let mut req = client.get(url).header("Accept", "text/event-stream");
if let Some(id) = last_event_id {
req = req.header("Last-Event-ID", id);
}
let stream = req.send().await.expect("SSE connect").bytes_stream();
let mut stream = Box::pin(stream);
let mut ev_type = String::new(); let mut data = String::new();
let mut id_field = String::new(); let mut last_id = String::new();
let mut buffer = String::new();
let mut events = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk: Bytes = chunk.unwrap_or_default();
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if ev_type == "cloudevent" && !data.is_empty() {
if !id_field.is_empty() { last_id = id_field.clone(); }
let ce: Value = serde_json::from_str(&data).unwrap_or(Value::Null);
events.push(ce);
if events.len() >= max { return (events, last_id); }
}
ev_type.clear(); data.clear(); id_field.clear();
} else if line.starts_with(':') {
} else if let Some(v) = line.strip_prefix("id:") { id_field = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); }
else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); }
}
}
(events, last_id)
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = server_url();
let channel = "rust-ce-events-store.reconnect-resume";
let client = Client::new();
// Publish 4 events.
println!("Publishing 4 events...");
for i in 1..=4u32 {
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.eventsstore.stored")
.source("urn:kubemq-ce-rust-example")
.subject(channel)
.data("application/json", json!({"seq": i}))
.build()?;
let body = serde_json::to_string(&event)?;
client.post(format!("{}/ce/send/event-store", base))
.header("Content-Type", "application/cloudevents+json")
.body(body).send().await?;
}
// First connection: StartFromFirst (events_store_type=2), receive 2 events.
let first_url = format!(
"{}/ce/subscribe/events-store?client_id=rust-es-reconnect&channel={}&events_store_type=2",
base, channel
);
println!("First connection — receiving 2 events:");
let (first_events, last_id) = subscribe_and_collect(&client, &first_url, None, 2).await;
for ce in &first_events {
println!(" Received: seq={}", ce["data"]["seq"]);
}
println!(" Last-Event-ID recorded: {}", last_id);
// Reconnect using Last-Event-ID (no events_store_type param).
let reconnect_url = format!(
"{}/ce/subscribe/events-store?client_id=rust-es-reconnect&channel={}",
base, channel
);
println!("Reconnecting with Last-Event-ID={}...", last_id);
let (second_events, _) = subscribe_and_collect(&client, &reconnect_url, Some(&last_id), 2).await;
for ce in &second_events {
println!(" Resumed: seq={}", ce["data"]["seq"]);
}
println!("Reconnect-resume demonstration complete.");
Ok(())
}
```
## Client implementation tips [#client-implementation-tips]
* **Standard `EventSource` clients** (browsers, `eventsource` libraries) handle keepalive comments, `event:` dispatch, and `Last-Event-ID` resumption automatically. They cannot, however, set a custom `Last-Event-ID` on the first manual reconnect — use a raw HTTP read for that case, as the JavaScript example does.
* **Manual parsers** (the Go, Java, Python, Ruby, Rust, and C# tabs) read the body line by line: an empty line dispatches the current frame, a line starting with `:` is a keepalive to skip, and `id:` / `event:` / `data:` accumulate frame state. Branch on the `event:` value so `cloudevent`, `message`, and `error` frames are each handled.
* **Always handle the `error` event.** Treat `stream idle timeout` as a normal lifecycle signal and reconnect (with `Last-Event-ID` for events-store) rather than as a fatal failure.
## Related [#related]
# Getting Started (/connectors/cloudevents/tutorials/getting-started)
The CloudEvents connector lets you publish and subscribe to KubeMQ messages over plain
HTTP using the CNCF [CloudEvents](https://cloudevents.io/) envelope — no KubeMQ SDK
required. You `POST` a CloudEvent to `/ce/send/event` and open a long-lived SSE stream on
`/ce/subscribe/events` to receive it. This walkthrough takes you from a running server to
a verified publish-and-receive round-trip.
## Prerequisites [#prerequisites]
* A running **kubemq-server** with the shared HTTP server reachable on **port 9090**.
* `curl` (or one of the language clients below) to publish and subscribe.
* For the language tabs, a CloudEvents SDK for your runtime (the examples use the official
CNCF SDKs, sourced from `.kb/cloud-events/examples`).
Confirm the shared HTTP server is live:
```bash
curl http://localhost:9090/ready
```
## Enable / disable [#enable--disable]
The CloudEvents connector is **enabled by default**. Start kubemq-server and the `/ce/*`
routes are live immediately — there is **no `=true` flag to set**.
To **disable** CloudEvents, set its enable variable to `false`:
The disable variable name is irregular by design — the config key `Connectors.CE.Enable`
snake-cases to `CONNECTORSCE_ENABLE`, with **no underscore** between `CONNECTORS` and `CE`.
Older docs show `CONNECTORS_CE_ENABLE`, which does **not** match the live binding. See
[Shared HTTP server](/connectors/concepts/shared-http-server) for the full enable model.
## How it works [#how-it-works]
A publisher `POST`s a CloudEvent to the connector, which maps it to a KubeMQ event and
delivers it to every SSE subscriber on the channel. The subscriber receives it as an
`event: cloudevent` SSE frame, reconstructed as a CloudEvent JSON object.
*The connector maps the incoming CloudEvent to a KubeMQ event and streams it back to SSE subscribers.*
## Steps [#steps]
### Subscribe to the channel [#subscribe-to-the-channel]
Open a long-lived SSE stream on `/ce/subscribe/events`. Pass a `client_id` and the
`channel` to subscribe to. Run this in a separate terminal — it stays open and prints
each event as it arrives.
```bash
curl -N "http://localhost:9090/ce/subscribe/events?client_id=demo-subscriber&channel=notifications"
```
The connector sends a `: keepalive` comment every 30 seconds to hold the connection open;
standard EventSource clients ignore it.
### Publish a CloudEvent [#publish-a-cloudevent]
Send a CloudEvent in **structured mode** (`Content-Type: application/cloudevents+json`)
to `/ce/send/event`. The CloudEvent `subject` becomes the KubeMQ channel, so it must
match the channel you subscribed to. A successful send returns **HTTP 202**.
The language tabs below each run the complete round-trip from a single program: they open
the SSE subscription, publish one CloudEvent, then print the received event.
```bash
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.greeting",
"source": "demo-publisher",
"subject": "notifications",
"datacontenttype": "application/json",
"data": {"message": "Hello, CloudEvents!"}
}'
```
```csharp
using CloudNative.CloudEvents;
using CloudNative.CloudEvents.SystemTextJson;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var base_ = Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090";
var channel = "csharp-ce-events.basic-pubsub";
var clientId = "kubemq-ce-csharp-example";
var received = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
// Start SSE subscriber.
var subscriberTask = Task.Run(async () =>
{
using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var sseUrl = $"{base_}/ce/subscribe/events?client_id={clientId}-sub&channel={Uri.EscapeDataString(channel)}";
using var request = new HttpRequestMessage(HttpMethod.Get, sseUrl);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true };
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream, Encoding.UTF8);
string? eventType = null, data = null;
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line == "")
{
if (eventType == "cloudevent" && data != null)
{
received.TrySetResult(JsonSerializer.Deserialize(data));
return;
}
eventType = null; data = null;
}
else if (line.StartsWith(":")) { /* keepalive */ }
else if (line.StartsWith("event:")) eventType = line["event:".Length..].Trim();
else if (line.StartsWith("data:")) data = line["data:".Length..].Trim();
}
});
// Allow subscription to establish.
await Task.Delay(500);
// Build and publish CloudEvent (structured mode).
var formatter = new JsonEventFormatter();
var cloudEvent = new CloudEvent
{
Id = Guid.NewGuid().ToString(),
Type = "com.kubemq.examples.events.sent",
Source = new Uri($"urn:{clientId}"),
Subject = channel,
DataContentType = "application/json",
Data = new { message = "Hello from C# CloudEvents example!" },
};
cloudEvent.SetAttributeFromString("time", DateTimeOffset.UtcNow.ToString("O"));
var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType);
using var httpClient = new HttpClient();
using var content = new ByteArrayContent(eventBytes.ToArray());
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString());
var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content);
var resultJson = await resp.Content.ReadAsStringAsync();
using var resultDoc = JsonDocument.Parse(resultJson);
Console.WriteLine($"Published: status={resp.StatusCode} is_error={resultDoc.RootElement.GetProperty("is_error")}");
// Wait for the event.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var ce = await received.Task.WaitAsync(cts.Token);
Console.WriteLine($"Received: type={ce.GetProperty("type")} data={ce.GetProperty("data")}");
```
```go
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
)
func main() {
base := "http://localhost:9090"
channel := "go-ce-events.basic-pubsub"
clientID := "kubemq-ce-go-example"
received := make(chan string, 1)
// Start SSE subscriber in background goroutine.
go func() {
sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s",
base, clientID+"-sub", channel)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("SSE connect:", err)
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
var eventType, data string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if eventType == "cloudevent" && data != "" {
received <- data
return
}
eventType, data = "", ""
continue
}
if strings.HasPrefix(line, ":") {
continue // keepalive
}
if strings.HasPrefix(line, "event:") {
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
}()
// Allow SSE subscription to establish.
time.Sleep(500 * time.Millisecond)
// Build and send CloudEvent (structured mode).
event := cloudevents.NewEvent()
event.SetType("com.kubemq.examples.events.sent")
event.SetSource("kubemq-ce-go-example")
event.SetSubject(channel) // subject = KubeMQ channel
_ = event.SetData(cloudevents.ApplicationJSON, map[string]string{
"message": "Hello from Go CloudEvents example!",
})
body, _ := json.Marshal(event)
req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/cloudevents+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal("send event:", err)
}
defer resp.Body.Close()
var result map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Published: status=%d is_error=%v\n", resp.StatusCode, result["is_error"])
// Wait for subscriber to receive the event.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
select {
case data := <-received:
var ce map[string]interface{}
_ = json.Unmarshal([]byte(data), &ce)
fmt.Printf("Received: type=%v data=%v\n", ce["type"], ce["data"])
case <-ctx.Done():
log.Fatal("Timed out waiting for event")
}
}
```
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
public class Main {
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
String base = "http://localhost:9090";
String channel = "java-ce-events.basic-pubsub";
String clientId = "kubemq-ce-java-example";
BlockingQueue received = new ArrayBlockingQueue<>(1);
// Start SSE subscriber in background thread.
String sseUrl = base + "/ce/subscribe/events?client_id=" + clientId
+ "-sub&channel=" + channel;
Thread subscriber = Thread.ofVirtual().start(() -> {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(15_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line, eventType = null, data = null;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
if ("cloudevent".equals(eventType) && data != null) {
received.offer(data);
return;
}
eventType = null;
data = null;
} else if (line.startsWith(":")) {
// keepalive
} else if (line.startsWith("event:")) {
eventType = line.substring("event:".length()).trim();
} else if (line.startsWith("data:")) {
data = line.substring("data:".length()).trim();
}
}
}
} catch (Exception e) {
System.err.println("SSE error: " + e.getMessage());
}
});
// Allow subscription to establish.
Thread.sleep(500);
// Build CloudEvent (structured mode).
EventFormatProvider.getInstance().registerFormat(new JsonFormat());
EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
CloudEvent event = CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withType("com.kubemq.examples.events.sent")
.withSource(URI.create(clientId))
.withSubject(channel)
.withDataContentType("application/json")
.withTime(OffsetDateTime.now())
.withData("application/json",
MAPPER.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!")))
.build();
byte[] body = format.serialize(event);
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(base + "/ce/send/event"))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.header("Content-Type", "application/cloudevents+json")
.build();
HttpResponse response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
Map, ?> result = MAPPER.readValue(response.body(), Map.class);
System.out.printf("Published: status=%d is_error=%s%n",
response.statusCode(), result.get("is_error"));
// Wait for event.
String data = received.poll(10, TimeUnit.SECONDS);
if (data == null) {
throw new RuntimeException("Timed out waiting for event");
}
Map, ?> ce = MAPPER.readValue(data, Map.class);
System.out.println("Received: type=" + ce.get("type") + " data=" + ce.get("data"));
subscriber.interrupt();
}
}
```
```typescript
import { CloudEvent, HTTP } from 'cloudevents';
import EventSource from 'eventsource';
const base = process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090';
const channel = 'js-ce-events.basic-pubsub';
const clientId = 'kubemq-ce-js-example';
function waitForEvent(): Promise> {
return new Promise((resolve, reject) => {
const sseUrl = `${base}/ce/subscribe/events?client_id=${clientId}-sub&channel=${encodeURIComponent(channel)}`;
const es = new EventSource(sseUrl);
const timer = setTimeout(() => {
es.close();
reject(new Error('Timed out waiting for event'));
}, 10_000);
es.addEventListener('cloudevent', (evt: MessageEvent) => {
clearTimeout(timer);
es.close();
resolve(JSON.parse(evt.data) as Record);
});
});
}
async function main(): Promise {
// Start waiting for event (opens SSE stream).
const eventPromise = waitForEvent();
// Allow SSE connection to establish.
await new Promise((r) => setTimeout(r, 500));
// Build and publish CloudEvent (structured mode).
const event = new CloudEvent({
type: 'com.kubemq.examples.events.sent',
source: clientId,
subject: channel,
datacontenttype: 'application/json',
data: { message: 'Hello from JavaScript CloudEvents example!' },
});
const message = HTTP.structured(event);
const resp = await fetch(`${base}/ce/send/event`, {
method: 'POST',
headers: message.headers as Record,
body: message.body as string,
});
const result = await resp.json() as { is_error: boolean };
console.log(`Published: status=${resp.status} is_error=${result.is_error}`);
// Wait for subscriber.
const received = await eventPromise;
console.log(`Received: type=${received.type} data=${JSON.stringify(received.data)}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```python
import json
import threading
import time
import requests
from cloudevents.v1.conversion import to_structured
from cloudevents.v1.http import CloudEvent
base = "http://localhost:9090"
channel = "python-ce-events.basic-pubsub"
client_id = "kubemq-ce-python-example"
received: list[str] = []
def subscribe() -> None:
"""Open SSE stream and collect one cloudevent."""
sse_url = (
f"{base}/ce/subscribe/events"
f"?client_id={client_id}-sub&channel={channel}"
)
with requests.get(sse_url, stream=True, timeout=None,
headers={"Accept": "text/event-stream"}) as resp:
event_type = ""
data = ""
for line in resp.iter_lines(decode_unicode=True):
if line == "":
if event_type == "cloudevent" and data:
received.append(data)
return
event_type = ""
data = ""
continue
if line.startswith(":"):
continue # keepalive
if line.startswith("event:"):
event_type = line[len("event:"):].strip()
elif line.startswith("data:"):
data = line[len("data:"):].strip()
# Start subscriber in background thread.
threading.Thread(target=subscribe, daemon=True).start()
time.sleep(0.5) # allow SSE connection to establish
# Build and send CloudEvent (structured mode).
event = CloudEvent(
attributes={
"type": "com.kubemq.examples.events.sent",
"source": client_id,
"subject": channel,
"datacontenttype": "application/json",
},
data={"message": "Hello from Python CloudEvents example!"},
)
headers, body = to_structured(event)
resp = requests.post(f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10)
result = resp.json()
print(f"Published: status={resp.status_code} is_error={result.get('is_error')}")
# Wait for subscriber.
deadline = time.time() + 10
while not received and time.time() < deadline:
time.sleep(0.1)
if not received:
raise TimeoutError("Timed out waiting for event")
ce = json.loads(received[0])
print(f"Received: type={ce.get('type')} data={ce.get('data')}")
```
```ruby
require "net/http"
require "uri"
require "json"
require "timeout"
require "securerandom"
require "cloud_events"
base = "http://localhost:9090"
channel = "ruby-ce-events.basic-pubsub"
client_id = "kubemq-ce-ruby-example"
received = Queue.new
# SSE subscriber thread.
Thread.new do
uri = URI("#{base}/ce/subscribe/events?client_id=#{client_id}-sub&channel=#{URI.encode_www_form_component(channel)}")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Get.new(uri)
req["Accept"] = "text/event-stream"
http.request(req) do |resp|
ev_type = nil
data = nil
resp.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.empty?
if ev_type == "cloudevent" && data
received.push(data)
Thread.exit
end
ev_type = nil
data = nil
elsif line.start_with?(":") # keepalive
elsif line.start_with?("event:")
ev_type = line.sub("event:", "").strip
elsif line.start_with?("data:")
data = line.sub("data:", "").strip
end
end
end
end
end
end
sleep 0.5 # allow subscription to establish
# Build and publish CloudEvent (structured mode).
sdk = CloudEvents::HttpBinding.default
event = CloudEvents::Event::V1.new(
id: SecureRandom.uuid,
type: "com.kubemq.examples.events.sent",
source: URI("urn:#{client_id}"),
subject: channel,
spec_version: "1.0",
data_content_type: CloudEvents::ContentType.new("application/json"),
data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" })
)
headers, body = sdk.encode_event(event, structured_format: "json")
uri = URI("#{base}/ce/send/event")
Net::HTTP.start(uri.host, uri.port) do |http|
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = headers["Content-Type"]
req.body = body
res = http.request(req)
result = JSON.parse(res.body)
puts "Published: status=#{res.code} is_error=#{result['is_error']}"
end
# Wait for subscriber.
data = nil
Timeout.timeout(10) { data = received.pop }
ce = JSON.parse(data)
puts "Received: type=#{ce['type']} data=#{ce['data']}"
```
```rust
use bytes::Bytes;
use cloudevents::{EventBuilder, EventBuilderV10};
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use tokio::sync::oneshot;
use uuid::Uuid;
/// Parse SSE lines and return the data of the first cloudevent.
async fn wait_for_cloudevent(
mut stream: impl futures_util::Stream- > + Unpin,
tx: oneshot::Sender,
) {
let mut event_type = String::new();
let mut data = String::new();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => { eprintln!("SSE read error: {}", e); break; }
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end_matches('\r').to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
if event_type == "cloudevent" && !data.is_empty() {
let _ = tx.send(data.clone());
return;
}
event_type.clear();
data.clear();
} else if line.starts_with(':') {
// keepalive comment — ignore
} else if let Some(v) = line.strip_prefix("event:") {
event_type = v.trim().to_string();
} else if let Some(v) = line.strip_prefix("data:") {
data = v.trim().to_string();
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let base = "http://localhost:9090";
let channel = "rust-ce-events.basic-pubsub";
let client_id = "kubemq-ce-rust-example";
let client = Client::new();
// Start SSE subscriber.
let (tx, rx) = oneshot::channel::();
let sub_url = format!(
"{}/ce/subscribe/events?client_id={}-sub&channel={}",
base, client_id, channel
);
let sub_client = client.clone();
tokio::spawn(async move {
let stream = sub_client
.get(&sub_url)
.header("Accept", "text/event-stream")
.send()
.await
.expect("SSE connect failed")
.bytes_stream();
wait_for_cloudevent(stream, tx).await;
});
// Allow SSE to establish.
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Build CloudEvent (structured mode using cloudevents-sdk).
let event = EventBuilderV10::new()
.id(Uuid::new_v4().to_string())
.ty("com.kubemq.examples.events.sent")
.source(format!("urn:{}", client_id))
.subject(channel)
.data(
"application/json",
json!({"message": "Hello from Rust CloudEvents example!"}),
)
.build()?;
// Serialize to structured mode JSON.
let body = serde_json::to_string(&event)?;
let resp = client
.post(format!("{}/ce/send/event", base))
.header("Content-Type", "application/cloudevents+json")
.body(body)
.send()
.await?;
let result: Value = resp.json().await?;
println!("Published: status=202 is_error={}", result["is_error"]);
// Wait for received event.
let data = tokio::time::timeout(tokio::time::Duration::from_secs(10), rx)
.await
.expect("Timed out waiting for event")
.expect("Channel closed");
let ce: Value = serde_json::from_str(&data)?;
println!("Received: type={} data={}", ce["type"], ce["data"]);
Ok(())
}
```
### Verify the round-trip [#verify-the-round-trip]
The subscriber terminal from step 1 prints the event as an `event: cloudevent` SSE frame.
Because the message carried `ce_*` tags, the connector reconstructs it as a CloudEvent
JSON object on the way out:
```text
event: cloudevent
data: {"specversion":"1.0","type":"com.example.greeting","source":"demo-publisher","id":"550e8400-e29b-41d4-a716-446655440000","subject":"notifications","time":"2026-06-08T10:30:00Z","data":{"message":"Hello, CloudEvents!"}}
```
The publish call returns `HTTP 202` with `is_error: false`, and the subscriber receives the
event with `id` and `time` auto-generated by the connector. That confirms a successful
publish-and-receive round-trip through the CloudEvents connector.
## What's next [#whats-next]
# CE ↔ KubeMQ Mapping (/connectors/cloudevents/reference/ce-to-kubemq-mapping)
The CloudEvents connector is a faithful, round-tripping bridge: every CloudEvent
attribute becomes a prefixed KubeMQ tag on the way in, and is reconstructed back
into a CloudEvent on the way out. This page is the authoritative reference for that
mapping — the attribute table, channel and `ClientID` resolution, how outbound
messages are detected as CloudEvents, the `data` vs `data_base64` rule, and the
error surface.
## Attribute mapping [#attribute-mapping]
When a CloudEvent is received, each of its attributes is stored as a KubeMQ message
tag with a `ce_` prefix. The `ce_` prefix is what makes the mapping reversible: any
subscriber — including [CESQL routing](/connectors/cloudevents/how-to/cesql-routing) —
can read the original CloudEvent attributes off the message tags.
| CloudEvent attribute | KubeMQ tag key | Required | Notes |
| -------------------- | -------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- |
| `specversion` | `ce_specversion` | Yes | Always `"1.0"`. Its presence is the marker the connector uses to detect a CloudEvent on outbound delivery. |
| `type` | `ce_type` | Yes | Application-defined event type. |
| `source` | `ce_source` | Yes | Also used as the KubeMQ `ClientID` (see [ClientID resolution](#clientid-resolution)). |
| `id` | `ce_id` | Yes (auto-generated) | Also used as the `EventID` / `RequestID` / `MessageID`. |
| `subject` | `ce_subject` | No | Primary [channel resolution](#channel-resolution) source. |
| `time` | `ce_time` | No (auto-generated) | RFC3339Nano format. |
| `datacontenttype` | `ce_datacontenttype` | No | e.g. `application/json`. |
| `dataschema` | `ce_dataschema` | No | URI of the data schema. |
| *(any extension)* | `ce_{name}` | No | Extension attributes get the same `ce_` prefix. |
Extension attributes follow the same convention: `{"myextension": "value"}` is stored
as the `ce_myextension` tag and reconstructed as `{"myextension": "value"}` on delivery.
## Auto-generation [#auto-generation]
The connector fills in missing optional attributes **before** validation, so a minimal
CloudEvent with only `type` and `source` is accepted:
| Attribute | When missing | Generated value |
| --------- | ------------ | ----------------------------------------- |
| `id` | Empty | A new UUID v4. |
| `time` | Zero | The current UTC time, RFC3339Nano format. |
Because `id` is always populated, every accepted CloudEvent has a stable identifier
for correlation and replay.
## Channel resolution [#channel-resolution]
The KubeMQ destination channel is resolved in priority order:
1. **CE `subject` attribute** — if the CloudEvent carries a `subject`, it is the channel name.
2. **`?channel=` query parameter** — used only when no `subject` is set.
If neither is provided, the request is rejected with **HTTP 400** (`channel is required`).
```bash
# subject sets the channel
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{"specversion":"1.0","type":"com.example.order.created","source":"order-service","subject":"orders","data":{"id":"123"}}'
# or the query parameter sets it (no subject)
curl -X POST "http://localhost:9090/ce/send/event?channel=orders" \
-H "Content-Type: application/cloudevents+json" \
-d '{"specversion":"1.0","type":"com.example.order.created","source":"order-service","data":{"id":"123"}}'
```
See [Channel resolution](/connectors/cloudevents/how-to/channel-resolution) for the
full priority rules and SSE subscription behavior.
## ClientID resolution [#clientid-resolution]
The KubeMQ `ClientID` attached to the message is determined as follows:
1. **Auth claims** — when [authentication](/connectors/cloudevents/how-to/authentication)
is enabled and the request carries valid credentials, the authenticated `ClientID`
from the JWT claims overrides everything else.
2. **CE `source` attribute** — used as the `ClientID` when auth is off or the claim is `anonymous`.
This means the CloudEvent `source` is the effective identity for unauthenticated
traffic, while authenticated traffic always carries its verified identity regardless
of what `source` says.
## Outbound CE detection [#outbound-ce-detection]
When delivering a message to a subscriber — over [SSE](/connectors/cloudevents/how-to/sse-behavior)
or a [queue receive](/connectors/cloudevents/how-to/queues) — the connector
inspects the message tags for `ce_specversion`:
| Condition | Delivered as | SSE `event:` type |
| ---------------------------- | -------------------------------------------------------------------------------------- | ----------------- |
| `ce_specversion` **present** | A reconstructed CloudEvent JSON object with standard CE attributes. | `cloudevent` |
| `ce_specversion` **absent** | A plain JSON object with KubeMQ-native fields (`channel`, `metadata`, `tags`, `data`). | `message` |
Because detection is per-message, a single channel can carry **mixed** CloudEvent and
non-CloudEvent traffic — each frame is shaped according to its own tags. Clients
subscribing to such a channel should handle both `cloudevent` and `message` event types.
## data vs data\_base64 [#data-vs-data_base64]
When reconstructing a CloudEvent for outbound delivery, the connector inspects the
message body:
| Body | Carried in | Encoding |
| ------------------------------------------ | ------------- | ---------------------- |
| Valid JSON | `data` | Inline JSON object. |
| Binary or non-JSON (plain text, raw bytes) | `data_base64` | Base64-encoded string. |
This follows the CloudEvents JSON format specification, where `data_base64` is the
standard carrier for non-JSON payloads.
## CESQL attribute names [#cesql-attribute-names]
[CESQL routing](/connectors/cloudevents/how-to/cesql-routing) expressions reference
CloudEvent attribute names **without** the `ce_` prefix, even though the underlying
KubeMQ tags carry it:
```sql
type = 'com.example.order.created' -- matches the ce_type tag
source = 'order-service' -- matches the ce_source tag
```
The router constructs a lightweight CloudEvent from the `ce_*` tags at evaluation time,
so any message with `ce_*` tags — regardless of which connector produced it — is eligible
for CESQL matching.
## Error codes [#error-codes]
All CloudEvents endpoints return a single response envelope. Errors use the same shape
with `is_error: true`:
```json
{
"is_error": true,
"message": "descriptive error message",
"data": null
}
```
### HTTP status codes [#http-status-codes]
| Status | Meaning | When |
| ------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| 200 | OK | Query response, queue receive, queue ack\_all. |
| 202 | Accepted | Event send, event-store send, command, queue send, response send. |
| 400 | Bad Request | Invalid CloudEvent (including unrecognized Content-Type), missing required parameters, validation failure, reserved channel name, subscription error. |
| 429 | Too Many Requests | SSE connection limit (`MaxSSEConnections`) exceeded. |
| 500 | Internal Server Error | Backend messaging error or SSE setup failure. |
| 504 | Gateway Timeout | A synchronous request exceeded `TimeoutSeconds`. |
### Common error messages [#common-error-messages]
| Message | Cause | Resolution |
| --------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `channel is required` | No `subject` attribute and no `?channel=` parameter. | Set the CloudEvent `subject` attribute or pass `?channel=`. |
| `invalid CloudEvent` | Malformed CE JSON or a missing required attribute. | Ensure `specversion`, `type`, and `source` are present. |
| `stream idle timeout` | No messages delivered for `MaxSSEIdleSeconds`. | Reconnect; consider lowering `MaxSSEIdleSeconds`. |
| `context deadline exceeded` | Request exceeded `TimeoutSeconds`. | Increase the timeout or ensure a responder is active. |
| `connection limit exceeded` | `MaxSSEConnections` reached. | Raise the limit or reduce concurrent SSE connections. |
| `reserved channel name` | Channel name conflicts with an internal KubeMQ channel (e.g. the `_AGENTS_.` prefix). | Use a different channel name. |
## Related [#related]
# Configuration (/connectors/cloudevents/reference/configuration)
This is the full field reference for the CloudEvents connector's `Connectors.CE`
config section — every field, its validation rules, and how to set them via TOML,
environment variables, or Docker. For the enabled-by-default model and how config
keys map to environment variable names, see the
[configuration model](../concepts/configuration-model).
## Config fields [#config-fields]
All fields live under `[Connectors.CE]`. Defaults are taken verbatim from the
server's `CeConfig` struct.
## Validation rules [#validation-rules]
Validation is **skipped entirely when `Enable` is `false`**. When the connector is
enabled, the server rejects an invalid config at startup with these rules:
| Field | Rule |
| ------------------- | -------------------------------------------- |
| `TimeoutSeconds` | must be greater than `0` |
| `SubBuffSize` | must be greater than `0` and at most `10000` |
| `MaxSSEIdleSeconds` | must be greater than `0` |
| `MaxSSEConnections` | must be greater than or equal to `0` |
## Configuring the connector [#configuring-the-connector]
The same five settings can be supplied through a TOML config file, environment
variables, or `docker run` flags. Pick whichever fits your deployment.
```toml title="config.toml"
[Connectors.CE]
Enable = true
TimeoutSeconds = 60
SubBuffSize = 100
MaxSSEIdleSeconds = 300
MaxSSEConnections = 0
```
```bash title="cloudevents.env"
CONNECTORSCE_ENABLE=true
CONNECTORSCE_TIMEOUT_SECONDS=60
CONNECTORSCE_SUB_BUFF_SIZE=100
CONNECTORSCE_MAX_SSE_IDLE_SECONDS=300
CONNECTORSCE_MAX_SSE_CONNECTIONS=0
```
The connector is already enabled, so the Docker example overrides only the
SSE buffer and a connection cap. There is no `-e CONNECTORSCE_ENABLE=true` —
that would be redundant. Set `CONNECTORSCE_ENABLE=false` only when you want to
turn the connector off.
### Environment variable names [#environment-variable-names]
Each variable below is derived from its dotted config key by the server's standard
env-var transform (see
[Environment variable names](../concepts/configuration-model#environment-variable-names)
for the derivation rule — including why the enable variable is `CONNECTORSCE_ENABLE`,
not `CONNECTORS_CE_ENABLE`).
| Config key | Environment variable |
| --------------------------------- | ----------------------------------- |
| `Connectors.CE.Enable` | `CONNECTORSCE_ENABLE` |
| `Connectors.CE.TimeoutSeconds` | `CONNECTORSCE_TIMEOUT_SECONDS` |
| `Connectors.CE.SubBuffSize` | `CONNECTORSCE_SUB_BUFF_SIZE` |
| `Connectors.CE.MaxSSEIdleSeconds` | `CONNECTORSCE_MAX_SSE_IDLE_SECONDS` |
| `Connectors.CE.MaxSSEConnections` | `CONNECTORSCE_MAX_SSE_CONNECTIONS` |
The CloudEvents connector shares the HTTP server's port (`9090`), body limit, CORS,
and TLS settings. Those are configured under `Connectors.Http` and documented once
in [Shared HTTP server](/connectors/concepts/shared-http-server) — not repeated here.
For the conceptual explanation of the enable model, see the
[configuration model](../concepts/configuration-model).
## Verifying the connector is live [#verifying-the-connector-is-live]
Because the connector is enabled by default, you can confirm it is serving as soon
as the server is up — send a CloudEvent and watch for an HTTP `202`.
```bash
curl -i -X POST http://localhost:9090/ce/send/event \
-H 'Content-Type: application/cloudevents+json' \
-d '{
"specversion": "1.0",
"type": "com.example.healthcheck",
"source": "config-check",
"id": "check-1",
"subject": "diagnostics",
"data": {"ok": true}
}'
```
```bash
# Point the example client at your server, then run the basic pub/sub sample.
export KUBEMQ_CE_URL=http://localhost:9090
go run ./examples/go/events/basic-pubsub/main.go
```
```bash
export KUBEMQ_CE_URL=http://localhost:9090
python examples/python/events/basic_pubsub.py
```
```bash
export KUBEMQ_CE_URL=http://localhost:9090
node examples/javascript/events/basic-pubsub.js
```
```bash
export KUBEMQ_CE_URL=http://localhost:9090
mvn -q exec:java -Dexec.mainClass=com.kubemq.ce.events.BasicPubSub
```
```bash
export KUBEMQ_CE_URL=http://localhost:9090
dotnet run --project examples/csharp/Events/BasicPubSub
```
```bash
export KUBEMQ_CE_URL=http://localhost:9090
ruby examples/ruby/events/basic_pubsub.rb
```
```bash
KUBEMQ_CE_URL=http://localhost:9090 cargo run -p basic-pubsub
```
Every CloudEvents example client reads the server base URL from the
`KUBEMQ_CE_URL` environment variable, defaulting to `http://localhost:9090`.
Override it to point at a remote or clustered KubeMQ deployment.
## Related [#related]
# Endpoints (/connectors/cloudevents/reference/endpoints)
The CloudEvents connector exposes its full surface on the shared HTTP server
(default port **9090**) and is **enabled by default**. This page is the
authoritative reference for every endpoint: the send and queue operations (`POST`),
the SSE subscriptions (`GET`), their request bodies, success status codes, and query
parameters. To disable the connector, set `CONNECTORSCE_ENABLE=false` — see
[Configuration](/connectors/cloudevents/concepts/configuration-model).
## Base URL [#base-url]
All endpoints are served from the shared HTTP server:
```text
http://localhost:9090
```
The port is inherited from the REST transport (default `9090`). See the
[shared HTTP server](/connectors/concepts/shared-http-server) page for the middleware
chain, body limits, and SSE handling that apply to every route below.
## Endpoint table [#endpoint-table]
The connector groups its 12 endpoints into three families — **send** (synchronous
operations across the five messaging patterns), **queue** (durable-queue control),
and **subscribe** (long-lived SSE streams).
| Method | Path | Purpose | Request body | Success status |
| ------ | ---------------------------- | --------------------------------------- | --------------------------------- | ----------------------- |
| POST | `/ce/send/event` | Fire-and-forget pub/sub event | CloudEvent (structured or binary) | 202 Accepted |
| POST | `/ce/send/event-store` | Persistent, replayable event | CloudEvent (structured or binary) | 202 Accepted |
| POST | `/ce/send/command` | Command (execution confirmation) | CloudEvent (structured or binary) | 202 Accepted |
| POST | `/ce/send/query` | Query (data response) | CloudEvent (structured or binary) | 200 OK |
| POST | `/ce/send/response` | Response to a command/query | CloudEvent + `?request_id=` | 202 Accepted |
| POST | `/ce/queue/send` | Send a message to a queue | CloudEvent (structured or binary) | 202 Accepted |
| POST | `/ce/queue/receive` | Receive (or peek) queue messages | None (body ignored) | 200 OK |
| POST | `/ce/queue/ack_all` | Acknowledge all pending queue messages | None (body ignored) | 200 OK |
| GET | `/ce/subscribe/events` | SSE pub/sub subscription | — | 200 `text/event-stream` |
| GET | `/ce/subscribe/events-store` | SSE persistent subscription with replay | — | 200 `text/event-stream` |
| GET | `/ce/subscribe/commands` | SSE command subscription | — | 200 `text/event-stream` |
| GET | `/ce/subscribe/queries` | SSE query subscription | — | 200 `text/event-stream` |
The two success statuses are deliberate: `/ce/send/query` and the queue receive/ack
operations return **200 OK** because they carry a synchronous payload, while the
fire-and-forget and command sends return **202 Accepted**. All SSE endpoints return
**200** with `Content-Type: text/event-stream`.
## Send endpoints [#send-endpoints]
All five send endpoints accept a CloudEvent in either content mode — structured
(`application/cloudevents+json` body) or binary (`ce-*` headers + raw data body).
See [Content modes](/connectors/cloudevents/how-to/content-modes) for the wire
format, and [Channel resolution](/connectors/cloudevents/how-to/channel-resolution)
for how the destination channel is derived from `subject` or `?channel=`.
| Endpoint | Pattern | Blocks for a reply | Notes |
| ---------------------- | ------------------- | ------------------ | --------------------------------------------------------------------------- |
| `/ce/send/event` | Events | No | Pub/sub fan-out; returns the send result in `data`. |
| `/ce/send/event-store` | Events Store | No | Persisted and replayable; same shape as `/ce/send/event`. |
| `/ce/send/command` | Command | Yes | Waits for execution confirmation; timeout from `TimeoutSeconds`. |
| `/ce/send/query` | Query | Yes | Waits for a data response (returns **200**); timeout from `TimeoutSeconds`. |
| `/ce/send/response` | Command/Query reply | No | Requires `?request_id=`; `subject` is the reply channel. |
The `/ce/send/response` endpoint is the only send endpoint with a required query
parameter:
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | --------------------------------------------------------------------- |
| `request_id` | string | Yes | The request ID from the received command/query, used for correlation. |
Synchronous send endpoints enforce `TimeoutSeconds` (default 60s). A request that
exceeds the timeout returns **HTTP 504**. SSE subscriptions are long-lived and are
not subject to this timeout.
```bash
# Send a fire-and-forget event (structured mode)
curl -X POST http://localhost:9090/ce/send/event \
-H "Content-Type: application/cloudevents+json" \
-d '{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "order-service",
"subject": "notifications",
"data": {"order_id": "12345", "amount": 99.99}
}'
```
## Queue endpoints [#queue-endpoints]
The queue family adds durable, at-least-once delivery. `send` carries a CloudEvent
body; `receive` and `ack_all` are **control operations** — the request body is
ignored and all inputs come from query parameters.
### POST /ce/queue/send [#post-cequeuesend]
Send a CloudEvent to a queue channel. Returns **202 Accepted** with the send result
in `data`.
### POST /ce/queue/receive [#post-cequeuereceive]
Receive (or peek at) messages from a queue. Returns **200 OK** with the received
messages; each message carrying `ce_*` tags is reconstructed as a CloudEvent, others
are returned in native KubeMQ form.
| Parameter | Type | Required | Default | Description |
| -------------- | ------ | -------- | ------- | ------------------------------------------------------------------- |
| `channel` | string | Yes | — | Queue channel name. |
| `client_id` | string | Yes | — | Client identifier (overridden by auth claims when auth is enabled). |
| `max_messages` | int | No | `1` | Maximum messages to receive (1–1000). |
| `wait_timeout` | int | No | `5` | Wait timeout in seconds. |
| `is_peek` | bool | No | `false` | Peek at messages without consuming them. |
### POST /ce/queue/ack\_all [#post-cequeueack_all]
Acknowledge (drain) all pending messages in a queue. Returns **200 OK** with the ack
result in `data`.
| Parameter | Type | Required | Default | Description |
| -------------- | ------ | -------- | ------- | ------------------------------------------------------------------- |
| `channel` | string | Yes | — | Queue channel name. |
| `client_id` | string | Yes | — | Client identifier (overridden by auth claims when auth is enabled). |
| `wait_timeout` | int | No | `5` | Wait timeout in seconds. |
```bash
# Receive up to 10 messages, waiting up to 10 seconds
curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=10"
```
See [Queues](/connectors/cloudevents/how-to/queues) for the full send/receive
walkthrough.
## Subscribe endpoints (SSE) [#subscribe-endpoints-sse]
All four subscription endpoints are long-lived `GET` requests that respond with
`Content-Type: text/event-stream` and **HTTP 200**. They have no request body —
the subscription is configured entirely through query parameters. See
[SSE behavior](/connectors/cloudevents/how-to/sse-behavior) for the frame
format, keepalive, idle timeout, and reconnection semantics.
| Endpoint | Pattern | Replay support |
| ---------------------------- | ------------ | ------------------------------------------------------------------- |
| `/ce/subscribe/events` | Events | No (non-persistent). |
| `/ce/subscribe/events-store` | Events Store | Yes — `events_store_type`/`events_store_value` and `Last-Event-ID`. |
| `/ce/subscribe/commands` | Command | No; frames include request-ID/reply-channel fields. |
| `/ce/subscribe/queries` | Query | No; frames include request-ID/reply-channel fields. |
### Common SSE query parameters [#common-sse-query-parameters]
All four endpoints share these base parameters:
| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------------------------------------------- |
| `client_id` | string | Yes | Client identifier (overridden by auth claims when auth is enabled). |
| `channel` | string | Yes | Channel to subscribe to. |
| `group` | string | No | Load-balancing group name. |
### Events-store replay parameters [#events-store-replay-parameters]
`/ce/subscribe/events-store` accepts two additional parameters that select the replay
start position:
| Parameter | Type | Default | Description |
| -------------------- | ----- | ------------------ | -------------------------------------------------------- |
| `events_store_type` | int | `1` (StartNewOnly) | Start position (1–6); see the table below. |
| `events_store_value` | int64 | `0` | Value for sequence/time-based positions (types 4, 5, 6). |
| Value | Name | Description |
| ----- | ---------------- | ----------------------------------------------------------------- |
| 1 | StartNewOnly | Only new messages from this point forward. |
| 2 | StartFromFirst | Replay from the first stored message. |
| 3 | StartFromLast | Start from the last stored message. |
| 4 | StartAtSequence | Start at a specific sequence number (`events_store_value`). |
| 5 | StartAtTime | Start at a Unix timestamp in seconds (`events_store_value`). |
| 6 | StartAtTimeDelta | Start at a time delta in seconds from now (`events_store_value`). |
For events-store subscriptions, the server emits an `id:` field (the message sequence)
on each frame. Reconnecting with the `Last-Event-ID` header resumes from `sequence + 1`.
If both `Last-Event-ID` and `events_store_type` are present, the query parameter wins —
omit `events_store_type` to use header-based reconnection.
### SSE connection limits [#sse-connection-limits]
| Behavior | Setting | Status when exceeded |
| -------------- | ------------------------------------------- | --------------------------------------------------------- |
| Idle timeout | `MaxSSEIdleSeconds` (default 300s) | `error` event (`stream idle timeout`), connection closed. |
| Connection cap | `MaxSSEConnections` (default 0 = unlimited) | **HTTP 429** Too Many Requests. |
```bash
# Subscribe to events
curl -N "http://localhost:9090/ce/subscribe/events?client_id=my-client&channel=notifications"
# Subscribe to events-store, replaying from the first stored message
curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=2"
# Reconnect to events-store, resuming after sequence 42
curl -N -H "Last-Event-ID: 42" \
"http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log"
```
## Response envelope [#response-envelope]
Every endpoint returns the same response envelope. Successful responses carry the
operation result in `data`; errors set `is_error` to `true` with a descriptive
`message`:
```json
{
"is_error": false,
"message": "OK",
"data": { }
}
```
## Status codes [#status-codes]
| Status | Meaning | When |
| ------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| 200 | OK | Query response, queue receive, queue ack\_all, and all SSE streams. |
| 202 | Accepted | Event send, event-store send, command send, queue send, response send. |
| 400 | Bad Request | Invalid CloudEvent (including unrecognized Content-Type), missing required parameters, validation failure, reserved channel name, subscription error. |
| 429 | Too Many Requests | SSE connection limit (`MaxSSEConnections`) exceeded. |
| 500 | Internal Server Error | Backend messaging error or SSE setup failure. |
| 504 | Gateway Timeout | A synchronous request exceeded `TimeoutSeconds`. |
The full error message catalog lives in
[CE ↔ KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping#error-codes).
## Related [#related]
# Architecture (/connectors/gcp-pub-sub/concepts/architecture)
The KubeMQ **Google Cloud Pub/Sub connector** is an embedded, wire-protocol bridge inside
kubemq-server that speaks the genuine Pub/Sub v1 **gRPC** services on a dedicated gRPC listener
(default TCP **8085**, the Pub/Sub emulator convention). The connector is **opt-in (disabled by
default)** — enable it with `CONNECTORS_GCP_ENABLE=true` (Docker) or `spec.gcp.enabled: true`
(Kubernetes). It runs in **emulator mode**: no authentication, no TLS, insecure gRPC — exactly
like Google's local emulator. Any standard Pub/Sub client connects to it by setting one
environment variable, `PUBSUB_EMULATOR_HOST` — no code changes, no library swap, no emulator
to install.
Two KubeMQ primitives back the model:
* **A topic** maps onto a native KubeMQ **Events Store** log `gcp.{topic}` — the authoritative,
cross-protocol, replayable source of truth.
* **A subscription** maps onto a native KubeMQ **Queue** channel `gcp.sub.{subscription}` — one
filtered copy per subscription.
A publish is written **once** to the topic log, then fanned out to one Queue copy per
subscription. The connector touches the **Events Store** and **Queue** primitives only — it does
**not** map onto KubeMQ's commands / queries / events RPC patterns, and there is **no
request/reply** anywhere.
## The gRPC emulator listener [#the-grpc-emulator-listener]
A single gRPC server on `Connectors.Gcp.Port` (default **8085**) implements the real Pub/Sub v1
wire protocol — **38 RPCs** across four services. Each request passes a fixed three-stage
interceptor chain — **Recovery → Logger → Traffic-gate** — and there is **no auth interceptor**
(emulator mode):
| Service | RPCs | What it covers |
| -------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------ |
| `google.pubsub.v1.Publisher` | 9 | Topics and publish (`CreateTopic`, batch `Publish`, `UpdateTopic`, `DeleteTopic`, listing). |
| `google.pubsub.v1.Subscriber` | 16 | Subscriptions, `Pull` / `StreamingPull`, `Acknowledge`, `ModifyAckDeadline`, push config, snapshots, `Seek`. |
| `google.pubsub.v1.SchemaService` | 10 | Avro and Protobuf schema definitions, revisions, and validation. |
| `google.iam.v1.IAMPolicy` | 3 | Permissive stubs — no enforcement (emulator parity). |
The **Traffic-gate** interceptor short-circuits requests with transient `UNAVAILABLE` while the
broker is not ready; on a not-ready → ready transition the connector **drops all in-memory
leases** (their downstream transactions are dead) and the poller rebuilds. SDKs see the
transient `UNAVAILABLE` and retry. A nil auth seam is reserved so a future release can add token
validation without touching handlers. See
[Capabilities](/connectors/gcp-pub-sub/reference/capabilities) for the full RPC matrix.
## How topics & subscriptions map to KubeMQ [#how-topics--subscriptions-map-to-kubemq]
A request arrives at the gRPC listener; the handler parses the resource, dispatches to the
matching service, and lands on a KubeMQ primitive. A `Publish` writes once to the topic's
Events Store log and fans out one Queue copy per bound subscription, applying each
subscription's filter.
*A publish writes once to the Events Store log `gcp.{topic}` and fans out one filtered Queue copy per subscription on `gcp.sub.{subscription}`, all backed by the message broker. Snapshots and schemas are registry records with no channel.*
The channel mapping is the single most important mental model:
| Concept | Behavior |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Listener** | A dedicated gRPC server on `Connectors.Gcp.Port` (default **8085**). Insecure gRPC, no auth, no TLS — the emulator contract. |
| **Topic → Events Store log** | Topic `projects/{p}/topics/{t}` ↔ native KubeMQ **Events Store** log `gcp.{t}`. A publish writes **once** here (`Array.SendEventsStore`) — the authoritative, replayable, cross-protocol source. |
| **Subscription → Queue** | Subscription `projects/{p}/subscriptions/{s}` ↔ native KubeMQ **Queue** channel `gcp.sub.{s}`. One Queue copy is fanned out per subscription (`Array.SendQueueMessage`), applying that subscription's filter. |
| **Ordering-key channel** | A keyed message fans onto its own channel `gcp.sub.{sub}.k.{enc(key)}` (sha256 fallback `gcp.sub.{sub}.h.{hash}`) for at-most-one-in-flight per key. |
| **Snapshots / schemas** | A per-node replicated registry record — no native channel. |
| **Project segment** | `{p}` is parsed and validated but **ignored** — the connector is single-tenant, so resource ids are global across projects. |
| **Reserved namespace** | Topic ids may **not** start with `sub.` — it collides with the `gcp.sub.*` subscription-queue namespace. |
See [Channel mapping](/connectors/gcp-pub-sub/reference/channel-mapping) for the full
grammar and [Configuration](/connectors/gcp-pub-sub/concepts/configuration) for the connector knobs.
## Publish once, then fan out [#publish-once-then-fan-out]
A `Publish` is the heart of the model and follows a strict order:
1. **Validate the whole batch before enqueuing anything** (atomicity): batch size 1..1000; per
message — total ≤ 10 MiB, ≤ 100 attributes, attribute key ≤ 256 B (no `goog` prefix),
attribute value ≤ 1024 B, ordering key ≤ 1024 B, and `data` **or** `attributes` non-empty.
Any failure rejects the entire batch with `INVALID_ARGUMENT` and nothing is published.
2. **Schema enforcement** — if the topic references a schema, every message is validated against
it; the whole batch is rejected on the first non-conforming message.
3. **Topic-log write** — each message is written once to the Events Store log `gcp.{topic}`
(`Array.SendEventsStore`), assigning a server message id and publish time. This single record
is the authoritative, cross-protocol, replayable copy and the source for `Seek`.
4. **Fan-out** — one Queue copy per subscription (`Array.SendQueueMessage`), **applying each
subscription's filter**; a filtered-out message is never enqueued. Detached subscriptions are
skipped.
Delivery is then driven from each subscription's queue: every delivered message gets an opaque
`ack_id` under an ack-deadline lease; a 250 ms sweeper expires overdue leases, applies retry
backoff, and redelivers — or dead-letters once the receive count exceeds the policy. See
[Publishing](/connectors/gcp-pub-sub/how-to/publishing) and
[Subscribing](/connectors/gcp-pub-sub/how-to/subscribing).
## Reserved tags & project segment [#reserved-tags--project-segment]
A `PubsubMessage` becomes a KubeMQ message whose body is `data` and whose tags are the message
`attributes` plus **three reserved tags** carried across the wire:
| Reserved tag | Carries |
| ---------------------- | ------------------------------- |
| `_pubsub_message_id` | The server-assigned message id. |
| `_pubsub_publish_time` | The publish timestamp. |
| `_pubsub_ordering_key` | The ordering key (when set). |
Native KubeMQ consumers **see** these tags; they are **stripped from `attributes`** when a
message is delivered back to a Pub/Sub client. The `{p}` project segment is parsed and validated
but ignored (single-tenant), and the `sub.` topic-id prefix is reserved.
## Cross-protocol interop [#cross-protocol-interop]
Because every topic is a normal KubeMQ Events Store log, a Pub/Sub `Publish` to topic `orders`
(written to `gcp.orders`) is consumable by a **native** KubeMQ Events Store subscriber on the
same channel — and the native side sees the three reserved `_pubsub_*` tags that are stripped
for Pub/Sub clients. This lets you migrate one side at a time, or run Pub/Sub-SDK producers
alongside native KubeMQ consumers.
*A Pub/Sub publish lands on the Events Store log `gcp.orders`; a native KubeMQ Events Store subscriber reads the same record, including the reserved `_pubsub_*` tags.*
Topic, subscription, snapshot, and schema **records** are synchronized across cluster nodes with
a **last-writer-wins** rule (a per-node replicated registry). Message **data** itself rides the
existing Events Store / Queues replication. Exactly-once tokens and `StreamingPull` leases are
**node-local** — pin an exactly-once subscription's `StreamingPull` to one node, or accept
at-least-once across nodes. See
[Reliability](/connectors/gcp-pub-sub/how-to/reliability).
## Related [#related]
# Configuration (/connectors/gcp-pub-sub/concepts/configuration)
The Pub/Sub connector is configured server-side under the `Connectors.Gcp` block of the KubeMQ
server config, exposed as **thirteen `CONNECTORS_GCP_*` environment variables**. The connector
is **opt-in (disabled by default)** — a stock kubemq-server does not bind gRPC port 8085 until
you enable it.
The only thing **clients** configure is the emulator host via the standard
`PUBSUB_EMULATOR_HOST` environment variable (default `localhost:8085`) plus any
`PUBSUB_PROJECT_ID`. Everything below is broker-side server configuration.
## Enable the connector [#enable-the-connector]
Enable the connector with its enable variable:
To turn it **off** again, set `CONNECTORS_GCP_ENABLE=false`.
Setting `CONNECTORS_GCP_ENABLE=false` closes port 8085 and skips the connector entirely — a
config-only rollback with no data migration. The enable variable carries the underscore in its
prefix (`CONNECTORS_GCP_*`), and the port **must differ** from the server's gRPC/REST/HTTP and
AWS-connector ports — a collision aborts startup.
## Security posture [#security-posture]
**The connector runs in emulator mode: no authentication, no TLS, insecure gRPC — by design.**
There is no Google OAuth2/JWT validation, no IAM enforcement (the IAM RPCs are permissive
stubs), and no per-connector TLS option. **Do not expose port 8085 to untrusted networks.** TLS
is provided by the server-wide `Security` block, not by a Pub/Sub-specific setting. DoS guards
stay active regardless — the message-size cap, `MaxInflightPerSubscription`,
`MaxConcurrentPolls`, `MaxSeekReplay`, and push backoff. See
[Auth & security](/connectors/reference/auth-and-security) and
[Connectivity & emulator mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode).
## Configuring the connector [#configuring-the-connector]
The same settings can be supplied through a TOML config file, environment variables, or
`docker run` flags. Every environment variable uses the `CONNECTORS_GCP_` prefix (with the
underscore between `CONNECTORS` and `GCP`). For the full field-by-field table and copy-paste
TOML/env/Docker examples, see
[Configuration reference](../reference/configuration).
## Related [#related]
# Cross-Protocol Interop (/connectors/gcp-pub-sub/concepts/cross-protocol-interop)
Because every Pub/Sub **topic** is a normal KubeMQ **Events Store** log (`gcp.{topic}`), a Google Pub/Sub application and a native KubeMQ gRPC/REST client can work the **same** stream. A message published by `google-cloud-pubsub` to topic `orders` is consumable by a native KubeMQ Events Store subscriber on channel `gcp.orders` — carrying the connector's reserved `_pubsub_*` tags across the wire. This lets you **bridge a legacy native consumer during a migration**, or run Pub/Sub producers alongside native KubeMQ consumers without changing either side's protocol.
## Overview [#overview]
A `Publish` writes the message **once** to the topic's Events Store log `gcp.{topic}` — the authoritative, replayable source — before fanning out per-subscription queue copies. The native side reads that topic log directly: there is no subscription on the native path, just an Events Store subscribe on `gcp.{topic}`. The Pub/Sub side speaks the v1 gRPC wire protocol to the connector (port 8085); the native side speaks gRPC/REST to the KubeMQ broker directly (default `localhost:50000`).
| Direction | Producer | Consumer | What carries over |
| ---------------- | ----------------------------------- | --------------------------------------------- | ------------------------------------------------ |
| Pub/Sub → native | Pub/Sub `Publish` on topic `orders` | native `SubscribeToEventsStore("gcp.orders")` | Body + your attributes + the three reserved tags |
The three reserved tags are **visible to native consumers** (and stripped from `attributes` for Pub/Sub clients):
* `_pubsub_message_id` — the server-assigned message id (matches the `Publish` return value)
* `_pubsub_publish_time` — the publish timestamp
* `_pubsub_ordering_key` — the ordering key, if any
## How it works [#how-it-works]
The Pub/Sub publish writes once to the Events Store log `gcp.{topic}` through the message broker. A native KubeMQ Events Store subscriber attached to the same `gcp.{topic}` channel reads exactly that message, including the reserved `_pubsub_*` tags the connector stamps on the wire.
*A Pub/Sub publish writes once to the Events Store log `gcp.{topic}`; a native KubeMQ Events Store subscriber on the same channel reads that message with the reserved `_pubsub_*` tags carried across the wire.*
## The Pub/Sub publish side [#the-pubsub-publish-side]
The publish half is ordinary Pub/Sub code — `CreateTopic` then `Publish` against topic `orders` (which maps to `gcp.orders`). Each client sets only `PUBSUB_EMULATOR_HOST` (default `localhost:8085`) and a project id. To make the `_pubsub_ordering_key` tag observable on the native side, the publisher enables ordering and supplies an `ordering_key`.
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"cloud.google.com/go/pubsub"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
projectID := os.Getenv("PUBSUB_PROJECT_ID")
if projectID == "" {
projectID = "my-project"
}
// PUBSUB_EMULATOR_HOST routes the official client at the connector (insecure gRPC).
client, err := pubsub.NewClient(ctx, projectID)
if err != nil {
log.Fatalf("NewClient: %v", err)
}
defer client.Close()
// Topic "orders" maps to the Events Store log "gcp.orders".
topic, err := client.CreateTopic(ctx, "orders")
if err != nil {
log.Fatalf("CreateTopic: %v", err)
}
defer topic.Stop()
topic.EnableMessageOrdering = true // makes _pubsub_ordering_key observable natively.
// Publish one message; a native consumer on gcp.orders reads it.
id, err := topic.Publish(ctx, &pubsub.Message{
Data: []byte("from-gcp-pubsub"),
OrderingKey: "shipments",
Attributes: map[string]string{"region": "emea"}, // rides along as a plain tag.
}).Get(ctx)
if err != nil {
log.Fatalf("Publish: %v", err)
}
fmt.Printf("published: %s (native channel: gcp.orders)\n", id)
}
```
```python
import os
from google.cloud import pubsub_v1
from google.cloud.pubsub_v1.types import PublisherOptions
def main() -> None:
project_id = os.environ.get("PUBSUB_PROJECT_ID", "my-project")
# Ordering must be enabled to publish with an ordering key.
publisher = pubsub_v1.PublisherClient(
publisher_options=PublisherOptions(enable_message_ordering=True)
)
topic_path = publisher.topic_path(project_id, "orders") # -> gcp.orders
publisher.create_topic(request={"name": topic_path})
# Publish one message; a native consumer on gcp.orders reads it.
future = publisher.publish(
topic_path,
b"from-gcp-pubsub",
ordering_key="shipments",
region="emea", # an ordinary attribute — rides along as a plain tag.
)
print(f"published: {future.result(timeout=15)} (native channel: gcp.orders)")
if __name__ == "__main__":
main()
```
```java
import com.google.api.gax.core.NoCredentialsProvider;
import com.google.api.gax.grpc.GrpcTransportChannel;
import com.google.api.gax.rpc.FixedTransportChannelProvider;
import com.google.api.gax.rpc.TransportChannelProvider;
import com.google.cloud.pubsub.v1.TopicAdminClient;
import com.google.cloud.pubsub.v1.TopicAdminSettings;
import com.google.protobuf.ByteString;
import com.google.pubsub.v1.PublishRequest;
import com.google.pubsub.v1.PubsubMessage;
import com.google.pubsub.v1.TopicName;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
public final class Main {
public static void main(String[] args) throws Exception {
String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085");
String projectId = System.getenv().getOrDefault("PUBSUB_PROJECT_ID", "my-project");
ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build();
TransportChannelProvider channelProvider =
FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel));
NoCredentialsProvider noCreds = NoCredentialsProvider.create();
TopicName topic = TopicName.of(projectId, "orders"); // -> gcp.orders
try (TopicAdminClient topicAdmin = TopicAdminClient.create(TopicAdminSettings.newBuilder()
.setTransportChannelProvider(channelProvider).setCredentialsProvider(noCreds).build())) {
topicAdmin.createTopic(topic);
// Publish one message; a native consumer on gcp.orders reads it.
String id = topicAdmin.publish(PublishRequest.newBuilder()
.setTopic(topic.toString())
.addMessages(PubsubMessage.newBuilder()
.setData(ByteString.copyFromUtf8("from-gcp-pubsub"))
.setOrderingKey("shipments")
.putAttributes("region", "emea") // rides along as a plain tag.
.build())
.build()).getMessageIds(0);
System.out.printf("published: %s (native channel: gcp.orders)%n", id);
} finally {
channel.shutdown();
}
}
}
```
```typescript
import { PubSub } from "@google-cloud/pubsub";
const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project";
async function main(): Promise {
// The high-level client auto-detects PUBSUB_EMULATOR_HOST (insecure gRPC).
const pubsub = new PubSub({ projectId });
// Topic "orders" maps to the Events Store log "gcp.orders".
const [topic] = await pubsub.createTopic("orders");
topic.setPublishOptions({ messageOrdering: true }); // makes _pubsub_ordering_key observable.
// Publish one message; a native consumer on gcp.orders reads it.
const messageId = await topic.publishMessage({
data: Buffer.from("from-gcp-pubsub"),
orderingKey: "shipments",
attributes: { region: "emea" }, // rides along as a plain tag.
});
console.log(`published: ${messageId} (native channel: gcp.orders)`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```csharp
using Google.Api.Gax;
using Google.Cloud.PubSub.V1;
using Google.Protobuf;
var projectId = Environment.GetEnvironmentVariable("PUBSUB_PROJECT_ID") ?? "my-project";
var topicName = TopicName.FromProjectTopic(projectId, "orders"); // -> gcp.orders
// The .NET client does NOT auto-detect the emulator — set EmulatorOnly.
var publisher = await new PublisherServiceApiClientBuilder
{
EmulatorDetection = EmulatorDetection.EmulatorOnly,
}.BuildAsync();
await publisher.CreateTopicAsync(topicName);
// Publish one message; a native consumer on gcp.orders reads it.
var publishResponse = await publisher.PublishAsync(topicName, new[]
{
new PubsubMessage
{
Data = ByteString.CopyFromUtf8("from-gcp-pubsub"),
OrderingKey = "shipments",
Attributes = { ["region"] = "emea" }, // rides along as a plain tag.
},
});
Console.WriteLine($"published: {publishResponse.MessageIds[0]} (native channel: gcp.orders)");
```
```ruby
# frozen_string_literal: true
require "google/cloud/pubsub"
project_id = ENV["PUBSUB_PROJECT_ID"] || "my-project"
emulator_host = ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085"
pubsub = Google::Cloud::PubSub.new(project_id: project_id, emulator_host: emulator_host)
topic_admin = pubsub.topic_admin
topic_path = pubsub.topic_path("orders") # -> gcp.orders
topic = topic_admin.create_topic(name: topic_path)
# ordered: true makes _pubsub_ordering_key observable on the native side.
publisher = pubsub.publisher(topic.name, async: { ordered: true })
msg = publisher.publish("from-gcp-pubsub", ordering_key: "shipments", region: "emea")
publisher.async_publisher.stop!
puts "published: #{msg.message_id} (native channel: gcp.orders)"
```
## The native consumer [#the-native-consumer]
The other side is an ordinary KubeMQ **Events Store** subscriber talking gRPC to the broker (default `localhost:50000`) on the `gcp.orders` channel — no Pub/Sub SDK involved. Subscribe with the **"new only"** start position and confirm the stream is open **before** publishing, so the published message is in-window (Events Store subscribers attach to a stream, not a fixed offset). The message the Pub/Sub side published carries its body, your attributes, and the three reserved `_pubsub_*` tags.
```go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
kubemq "github.com/kubemq-io/kubemq-go/v2"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
grpcAddress := os.Getenv("KUBEMQ_GRPC_ADDRESS")
if grpcAddress == "" {
grpcAddress = "localhost:50000"
}
// Native KubeMQ gRPC client on the shared Events Store channel gcp.orders.
native, err := kubemq.NewClient(ctx,
kubemq.WithAddress("localhost", 50000),
kubemq.WithClientId("gcp-interop-native-go"),
)
if err != nil {
log.Fatalf("connect native gRPC: %v", err)
}
defer native.Close()
received := make(chan *kubemq.EventStoreReceive, 1)
// Subscribe FIRST with start policy "new only"; the stream is open on return.
sub, err := native.SubscribeToEventsStore(ctx, "gcp.orders", "", kubemq.StartFromNewEvents(),
kubemq.WithOnEventStoreReceive(func(ev *kubemq.EventStoreReceive) { received <- ev }),
kubemq.WithOnError(func(e error) { log.Printf("subscribe error: %v", e) }),
)
if err != nil {
log.Fatalf("SubscribeToEventsStore: %v", err)
}
defer sub.Cancel()
fmt.Println("native SubscribeToEventsStore(gcp.orders, startAt=new) -> stream open")
// (Run the Pub/Sub publish side now; it lands on gcp.orders.)
select {
case ev := <-received:
fmt.Printf("native received %q\n", string(ev.Body))
fmt.Printf(" _pubsub_message_id=%s\n", ev.Tags["_pubsub_message_id"])
fmt.Printf(" _pubsub_ordering_key=%s\n", ev.Tags["_pubsub_ordering_key"])
fmt.Printf(" region (attribute)=%s\n", ev.Tags["region"])
case <-time.After(15 * time.Second):
log.Fatal("timed out waiting for the native event")
}
}
```
```python
import os
import queue
from kubemq import EventsStoreSubscription, EventStoreReceived, PubSubClient
from kubemq.pubsub.events_store_subscription import EventStoreStartPosition
GRPC_ADDRESS = os.environ.get("KUBEMQ_GRPC_ADDRESS", "localhost:50000")
CHANNEL = "gcp.orders"
def main() -> None:
received: queue.Queue[EventStoreReceived] = queue.Queue(maxsize=1)
native = PubSubClient(address=GRPC_ADDRESS, client_id="gcp-interop-native-python")
# Subscribe FIRST with start policy "new only".
native.subscribe_to_events_store(
EventsStoreSubscription(
channel=CHANNEL,
events_store_type=EventStoreStartPosition.StartFromNew,
on_receive_event_callback=received.put,
on_error_callback=lambda err: print(f"subscribe error: {err}"),
)
)
print(f"native SubscribeToEventsStore({CHANNEL}, startAt=new) -> stream open")
# (Run the Pub/Sub publish side now; it lands on gcp.orders.)
event = received.get(timeout=15)
print(f"native received {event.body.decode()!r}")
print(f" _pubsub_message_id={event.tags.get('_pubsub_message_id')}")
print(f" _pubsub_ordering_key={event.tags.get('_pubsub_ordering_key')}")
print(f" region (attribute)={event.tags.get('region')}")
native.close()
if __name__ == "__main__":
main()
```
```typescript
import { KubeMQClient, EventStoreStartPosition } from "kubemq-js";
import type { EventStoreReceived, KubeMQError } from "kubemq-js";
const grpcAddress = process.env["KUBEMQ_GRPC_ADDRESS"] ?? "localhost:50000";
const CHANNEL = "gcp.orders";
async function main(): Promise {
// KubeMQClient.create() connects before returning a ready client.
const kube = await KubeMQClient.create({ address: grpcAddress, clientId: "gcp-interop-native-js" });
const received = new Promise((resolve, reject) => {
// Subscribe FIRST with start position "new only".
kube.subscribeToEventsStore({
channel: CHANNEL,
startFrom: EventStoreStartPosition.StartFromNew,
onEvent: (event: EventStoreReceived) => resolve(event),
onError: (err: KubeMQError) => reject(err),
});
});
console.log(`native SubscribeToEventsStore(${CHANNEL}, startAt=new) -> stream open`);
// (Run the Pub/Sub publish side now; it lands on gcp.orders.)
const event = await received;
console.log(`native received ${JSON.stringify(Buffer.from(event.body).toString("utf8"))}`);
console.log(` _pubsub_message_id=${event.tags["_pubsub_message_id"]}`);
console.log(` _pubsub_ordering_key=${event.tags["_pubsub_ordering_key"]}`);
console.log(` region (attribute)=${event.tags["region"]}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
The native consumer is the **only** place a KubeMQ SDK appears in the Pub/Sub connector examples — the Pub/Sub publish half above is idiomatic Google client code in every language. The native half is shown in Go, Python, and JavaScript; where a language's native Events Store client is less mature, it can fall back to a `kubemq-go` sidecar or a REST Events Store call.
## Subscribe before publish [#subscribe-before-publish]
**Establish the native subscribe stream before publishing.** An Events Store subscriber with the "new only" start position receives only events published **after** the stream is open. A naive "publish then subscribe" races — the publish can land before the subscriber attaches and be missed. Open the native subscription first, confirm the stream is up, then run the Pub/Sub publish so the message is in-window.
## Topic log, not subscription queue [#topic-log-not-subscription-queue]
The native path reads the **topic log** `gcp.{topic}` directly — the authoritative, replayable source written once per publish. It does not read a subscription's Queue channel `gcp.sub.{subscription}`; those are the per-subscription fan-out copies consumed by Pub/Sub `Pull`. For point-to-point native consumption of a subscription's backlog instead, subscribe to its `gcp.sub.{subscription}` Queue channel. See the [channel mapping reference](/connectors/gcp-pub-sub/reference/channel-mapping) for the full grammar.
## Related [#related]
# Migrating from ActiveMQ (/connectors/how-to/migration/from-activemq)
ActiveMQ is a multi-protocol broker, so there is no single migration path. Which connector you
use — and which guide you follow — depends on **which protocol your client speaks** and which
ActiveMQ variant (Classic or Artemis) you run. This page is the router: it points you at the
right per-protocol guide and calls out the one thing that does **not** migrate at all.
KubeMQ serves ActiveMQ workloads through three existing connectors — **AMQP 1.0**, **STOMP**, and
**MQTT** — depending on the protocol. All three are **opt-in** (`Enable = false` by default); you
enable only the connector(s) your clients need. The per-protocol guides carry the full code; this
page keeps the endpoint deltas and the cross-cutting deviations in one place.
**OpenWire is NOT supported.** KubeMQ has no OpenWire wire decoder. Any client connecting over
the OpenWire protocol will fail. There is no configuration option to add OpenWire support — an
ActiveMQ client using the default OpenWire transport must switch to AMQP 1.0, STOMP, or MQTT
before it can talk to KubeMQ.
## Choose your path [#choose-your-path]
Pick the row that matches your client, then follow the linked guide for the connection snippet,
destination mapping, and a working code example.
| Client type | ActiveMQ variant | Connector | Guide |
| ---------------------------- | ------------------------- | ------------------------------ | ------------------------------------------------------------------------ |
| Java / JMS applications | Classic or Artemis | AMQP 1.0 (via Apache Qpid JMS) | [Migrating from JMS](/connectors/how-to/migration/from-jms) |
| Non-Java clients using STOMP | Classic or Artemis | STOMP | [Migrating from STOMP](/connectors/stomp/scenarios/migration-from-stomp) |
| Non-Java clients using MQTT | Classic or Artemis | MQTT | [Migrating from MQTT](/connectors/mqtt/scenarios/migration) |
| Native AMQP 1.0 clients | Artemis (native AMQP 1.0) | AMQP 1.0 | [Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0) |
Java/JMS applications keep their JMS code and swap only the `ConnectionFactory` implementation to
Apache Qpid JMS — a **client-swap**. STOMP, MQTT, and native AMQP 1.0 clients are **endpoint-only**:
change the broker host and credentials, nothing else.
Default ports (when the connector is enabled):
| Connector | Port | TLS port | Protocol |
| --------- | ----- | -------------- | ---------------------------- |
| AMQP 1.0 | 5672 | 5671 | AMQP 1.0 (Qpid JMS, Artemis) |
| STOMP | 61613 | 61614 | STOMP 1.0 / 1.1 / 1.2 |
| MQTT | 1883 | 8883 (WS 8083) | MQTT 3.1.1 / 5.0 |
Enable only what you need. Each connector has its own enable variable:
```bash title="Enable variables"
CONNECTORS_AMQP10_ENABLE=true # Java/JMS via Qpid JMS, and native AMQP 1.0 (Artemis) clients
CONNECTORS_STOMP_ENABLE=true # STOMP clients (Classic and Artemis)
CONNECTORSMQTT_ENABLE=true # MQTT clients (note: no underscore)
```
## Compatibility Matrix [#compatibility-matrix]
This matrix is self-contained for the ActiveMQ workload across all three connectors. Where a
capability differs by path, the cell names the path it applies to.
| Dimension | Status | Notes |
| --------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------- |
| Drop-in level | client-swap / endpoint | Java/JMS: client-swap (Qpid JMS); STOMP/MQTT: endpoint-only |
| Point-to-point queues | ✅ | All three connectors support Queues |
| Pub/sub (non-durable) | ✅ | Events pattern on all paths |
| Durable subscriptions | ✅ | Via Events Store; `unsubscribe()` is node-local — see [Behavioral deviations](#behavioral-deviations) |
| Request/reply (RPC) | ✅ ² | AMQP 1.0 (Qpid JMS) path only |
| Ordering | ⚠️ node-local | Per-channel ordering is not preserved cluster-wide |
| Transactions | ❌ | Not supported on any path |
| Dead-letter / redrive | ❌ no client DLQ ⁶ | No client-settable DLQ; poison messages are silently dropped |
| Selectors / filtering | ✅ (AMQP 1.0) / ❌ (STOMP) | SQL92 selectors work on the AMQP 1.0 path (Events / Events Store); no selectors on STOMP |
| Auth model | PLAIN (JWT) | JWT in SASL PLAIN password (AMQP 1.0), CONNECT passcode (STOMP), or CONNECT password (MQTT) |
| TLS / mTLS | ✅ | 5671 (AMQP 1.0), 61614 (STOMP), 8883 / wss 8083 (MQTT) |
| Top unsupported | — | **OpenWire protocol**; transactions; selectors on the STOMP path |
**Footnotes:**
* ² ActiveMQ RPC via the AMQP 1.0 (Qpid JMS) path only; STOMP reply-to works but requires a
pre-existing reply subscription.
* ⁶ See [What Does NOT Migrate → Hard blockers](#hard-blockers) for the authoritative statement.
## Connection / Endpoint Migration [#connection--endpoint-migration]
The change is the same shape on every path: point the client at the KubeMQ host and supply a
KubeMQ JWT as the credential. The full code lives in the linked per-protocol guides — these tabs
show only the endpoint delta.
Java applications using the ActiveMQ JMS client (`ActiveMQConnectionFactory`) migrate by swapping
the `ConnectionFactory` implementation to Apache Qpid JMS — the JMS application code itself does
not change. Native AMQP 1.0 clients (e.g. `go-amqp`, AMQP.NET Lite, Qpid Proton) and Artemis
clients that already speak AMQP 1.0 migrate by changing only the broker endpoint.
```text title="ConnectionFactory / broker URI"
# Before (ActiveMQ Classic, OpenWire — must switch protocol)
tcp://activemq.example.com:61616
# Before (ActiveMQ Artemis, native AMQP 1.0)
amqp://artemis.example.com:5672
# After (KubeMQ)
amqp://kubemq.example.com:5672
amqps://kubemq.example.com:5671 # TLS
```
For the JNDI setup, destination mapping, and the full Qpid JMS snippet, see
[Migrating from JMS](/connectors/how-to/migration/from-jms). For the native AMQP 1.0 path
(addressing, message translation, RPC, and the `go-amqp` snippet), see
[Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0).
```text title="STOMP endpoint"
# Before (ActiveMQ Classic STOMP)
host: activemq.example.com
port: 61613
# After (KubeMQ STOMP)
host: kubemq.example.com
port: 61613 # same port; TLS on 61614
```
**Destination compatibility note:** ActiveMQ uses `/queue/NAME` and `/topic/NAME` with
`.`-delimited names. KubeMQ accepts both forms — `/topic/orders.created` and
`/topic/orders/created` reach the same KubeMQ channel `orders.created`. Pick one convention and
apply it consistently.
For destinations, ack modes, durable subscriptions, and the full `stomp.py` snippet, see
[Migrating from STOMP](/connectors/stomp/scenarios/migration-from-stomp).
```text title="MQTT endpoint"
# Before (ActiveMQ Classic MQTT)
host: activemq.example.com
port: 1883
# After (KubeMQ MQTT)
host: kubemq.example.com
port: 1883 # plain; TLS on 8883; WS on 8083
```
**MQTT version note:** KubeMQ **rejects MQTT 3.1 clients** at CONNECT. Use MQTT 3.1.1 (the
default for most clients, including Eclipse Paho) or MQTT 5.0.
For topic→pattern mapping, QoS, and the `paho-mqtt` snippet, see
[Migrating from MQTT](/connectors/mqtt/scenarios/migration).
## Concept & Destination Mapping [#concept--destination-mapping]
ActiveMQ concepts map onto KubeMQ patterns through the connector chosen for each client type.
### AMQP 1.0 path (Qpid JMS / Artemis) [#amqp-10-path-qpid-jms--artemis]
| ActiveMQ concept | KubeMQ pattern | Channel / address |
| -------------------------- | ---------------------------------------- | -------------------------------------- |
| Queue | Queues | `/queues/` |
| Topic (non-durable) | Events | `/events/` |
| Durable topic subscription | Events Store | `/events-store/` |
| Temporary queue | Dynamic node (temp reply mailbox) | `source.dynamic = true` |
| Virtual Topic / shared sub | Consumer group | link property `x-opt-kubemq-group` |
| Command (request/reply) | Commands or Queries | `/commands/` / `/queries/` |
| Reply-to | `/responses/` or dynamic node | — |
The JMS capability hint (`queue` / `topic`) lets Qpid JMS `Queue("orders")` and `Topic("orders")`
map automatically without an explicit prefix — see
[Migrating from JMS](/connectors/how-to/migration/from-jms) for details.
### STOMP path [#stomp-path]
| ActiveMQ destination | KubeMQ destination | Pattern |
| --------------------------------------- | ----------------------- | ------------ |
| `/queue/NAME` | `/queue/NAME` | Queues |
| `/topic/NAME` | `/topic/NAME` | Events |
| Durable topic | `/topic-store/NAME` | Events Store |
| ActiveMQ Virtual Topic `VirtualTopic.X` | `/topic/VirtualTopic.X` | Events |
### MQTT path [#mqtt-path]
| ActiveMQ MQTT topic prefix | KubeMQ pattern |
| -------------------------- | ------------------------ |
| `events/` | Events |
| `store/` | Events Store |
| `queues/` | Queues |
| `commands/` | Commands (MQTT 5.0 only) |
| `queries/` | Queries (MQTT 5.0 only) |
Wildcards `+` and `#` are supported **for Events subscriptions only**.
## Security [#security]
All three connectors accept the same KubeMQ JWT as the credential, but deliver it differently:
| Connector | Where the JWT goes | Auth disabled |
| --------- | ---------------------------------------------------------------- | -------------------------------------- |
| AMQP 1.0 | SASL PLAIN **password** field (username is informational) | ANONYMOUS or bare AMQP header accepted |
| STOMP | CONNECT **`passcode`** header (username recorded for audit) | Any credentials accepted |
| MQTT | CONNECT **`password`** field (username recorded for audit) | Any credentials accepted |
All three connectors are **opt-in** (`Enable = false`). They do not open listeners until
explicitly enabled. When `Authentication.Enable = false` (the server default), listeners accept
unauthenticated clients — enable authentication or firewall the ports when the server is reachable
from untrusted networks.
**TLS:** each connector uses the server-wide `Security` block. TLS is active on the TLS port only
when `Security` is configured. mTLS (client-certificate auth) is available on the AMQP 1.0 path
via SASL EXTERNAL (cert CN = ClientID).
**Authorization (Casbin):** with `Authorization.Enable = true`, Write is enforced on SEND /
produce, and Read on SUBSCRIBE / consume, against the resolved KubeMQ channel.
See [Authentication & Security](/connectors/reference/auth-and-security) and the
[configuration reference](/configure/reference/connectors).
## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations]
### Hard blockers [#hard-blockers]
| Feature | Status | Detail |
| ---------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **OpenWire protocol** | ❌ Not supported | See the callout at the top of this page. There is no configuration option to add OpenWire support. |
| **Transactions** | ❌ Not supported | No `SESSION_TRANSACTED` / XA (JMS), no STOMP `BEGIN` / `COMMIT` / `ABORT`. Use idempotent producers and at-least-once consumers instead. |
| **Message selectors (STOMP path)** | ❌ Not supported | The STOMP connector rejects `selector` headers with ERROR `selectors not supported` and closes the connection. Remove all `selector` usage from STOMP applications. |
| **No client-settable DLQ** | ❌ | No dead-letter address is exposed to clients over AMQP 1.0, STOMP, or MQTT. A message that exceeds `MaxReceiveCount` is silently dropped — there is no consumable dead-letter address over any of these protocols. |
### Behavioral deviations [#behavioral-deviations]
| Feature | Deviation |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Selectors (AMQP 1.0 path)** | Supported on Events / Events Store only (SQL92 subset via `apache.org:selector-filter`). Rejected on `/queues/` links (`amqp:not-implemented`). |
| **ActiveMQ durable subscriptions** | Map to Events Store via `/events-store/` (AMQP 1.0) or `/topic-store/` (STOMP). Replay-position headers control where the subscription starts. `unsubscribe()` / DETACH of the durable identity is **node-local** — connect back to the same node to cleanly detach. |
| **ActiveMQ Virtual Topics** | Map to KubeMQ consumer groups via link property `x-opt-kubemq-group` (AMQP 1.0) or a shared STOMP subscription. No automatic `VirtualTopic.` prefix translation. |
| **Ordering** | Per-channel ordering is node-local, not cluster-wide. MQTT ordering is QoS-dependent (QoS 0 unordered; QoS 1/2 ordered per connection only). |
| **STOMP reply-to RPC** | The reply subscription must be active before the SEND carrying `reply-to` is issued. Sending without a pre-existing reply subscription produces ERROR `reply-to subscription required` and closes the connection. |
| **MQTT 3.1 rejected** | KubeMQ refuses MQTT 3.1 clients at CONNECT. Use MQTT 3.1.1 or 5.0. |
| **MQTT retained messages** | `RetainAvailable = 0`. Retained publishes are rejected with an audit event (not silently dropped). |
| **MQTT RPC** | Request/reply (Commands / Queries) requires MQTT 5.0. Not available over MQTT 3.1.1. |
## Verification Smoke Test [#verification-smoke-test]
Choose the path that matches your application, enable the relevant connector(s), and point a test
client at the KubeMQ endpoint. The AMQP 1.0 and MQTT paths defer to their guides for the full
client snippet; the STOMP quick check is below.
* **AMQP 1.0 path (Java/JMS)** — requires the AMQP 1.0 connector enabled
(`CONNECTORS_AMQP10_ENABLE=true`). See [Migrating from JMS](/connectors/how-to/migration/from-jms)
for the full Qpid JMS snippet, and [Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0)
for the native `go-amqp` snippet.
* **MQTT path** — requires the MQTT connector enabled (`CONNECTORSMQTT_ENABLE=true`). See
[Migrating from MQTT](/connectors/mqtt/scenarios/migration) for the full `paho-mqtt` snippet.
**STOMP path** — requires the STOMP connector enabled (`CONNECTORS_STOMP_ENABLE=true`):
```python title="smoke_test.py"
# stomp.py 8.x — publish one message, consume it, confirm arrival
# Symbols: stomp.Connection, conn.connect, conn.send, conn.subscribe,
# ConnectionListener.on_message, conn.ack, conn.disconnect
import stomp, time
class Listener(stomp.ConnectionListener):
def __init__(self): self.received = []
def on_message(self, frame):
self.received.append(frame.body)
print(f"received: {frame.body}")
listener = Listener()
conn = stomp.Connection([("kubemq.example.com", 61613)])
conn.set_listener("", listener)
conn.connect(login="user", passcode="", wait=True)
# Subscribe before publishing (required for Events pattern)
conn.subscribe("/topic/smoke-test", id=1, ack="auto")
# Publish
conn.send("/topic/smoke-test", body="hello from activemq migration")
time.sleep(1)
assert len(listener.received) == 1, "smoke test failed: no message received"
print("smoke test passed")
conn.disconnect()
```
## See Also [#see-also]
# Migrating from AMQP 1.0 (/connectors/how-to/migration/from-amqp-1-0)
If you have an application that speaks native **AMQP 1.0 (ISO/IEC 19464)** — using a client
such as Azure/go-amqp, AMQP.NET Lite, or Apache Qpid Proton — you can point it at KubeMQ's
built-in AMQP 1.0 connector by changing **only the endpoint** and, where required, the SASL
credentials. Address prefixes select the KubeMQ messaging pattern, so simple publish/consume
needs no app-code rewrite. This is a drop-in migration at the **endpoint / client** level.
If you are migrating a **JMS** application (Java) instead, see the
[Migrating from JMS](/connectors/how-to/migration/from-jms) guide; for **ActiveMQ**
applications routed by client type, see [Migrating from ActiveMQ](/connectors/how-to/migration/from-activemq).
## Overview [#overview]
The AMQP 1.0 connector exposes KubeMQ's Queues, Events, Events Store, Commands, and Queries
patterns over the native AMQP 1.0 wire protocol. It listens on the same ports as the
AMQP 0-9-1 connector — **5672** (plain / SASL) and **5671** (TLS / mTLS) — because both
protocols share a single listener that routes each connection by its protocol header. No
separate firewall rule is needed beyond what the AMQP port already allows.
* **Canonical client (this guide):** `github.com/Azure/go-amqp` **v1.7.0**. For .NET shops,
**AMQP.NET Lite** is a direct alternative; the connection-string and address conventions
are the same, but the snippets below target `go-amqp`.
* **Opt-in default:** The connector is **disabled by default**. Set
`CONNECTORS_AMQP10_ENABLE=true` (or `Enable = true` under `[Connectors.Amqp10]` in TOML)
before connecting.
Enable the connector before you migrate any traffic:
The enable variable is **`CONNECTORS_AMQP10_ENABLE`** — the literal `10` stays attached to
`AMQP` with no underscore. `CONNECTORS_AMQP_1_0_ENABLE` and `CONNECTORS_AMQP10ENABLE` do
**not** bind. For Kubernetes, set `spec.amqp10.enabled: true` in the `KubemqCluster` CR.
For the full wire-protocol contract, see the
[AMQP connector capabilities reference](/connectors/amqp/reference/capabilities).
## Compatibility Matrix [#compatibility-matrix]
The cells below describe the AMQP 1.0 column of the cross-protocol migration matrix.
| Dimension | Support | Notes |
| -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Drop-in level** | endpoint / client | Change the host in the AMQP URI; prefix addresses with the pattern. No app-code rewrite for simple publish/consume. |
| **Point-to-point queues** | ✅ | `/queues/` → KubeMQ Queues; competing consumers, ack/nack, visibility. |
| **Pub/sub (non-durable)** | ✅ | `/events/` → Events; fire-hose fan-out, sender-settled (at-most-once). |
| **Durable / persistent subscriptions** | ✅ | `/events-store/` → Events Store; backed by the persistence engine, resume from last acked position. |
| **Request / reply (RPC)** | ✅ | `/commands/` / `/queries/` → Commands / Queries; hand-rolled reply receiver (see [snippet](#canonical-client-example)). |
| **Ordering guarantee** | ⚠️ node-local | Within one node; ordering is not cluster-wide. |
| **Transactions** | ❌ | AMQP `coordinator` / `declare` / `discharge` frames are not implemented. |
| **Dead-letter / redrive** | ❌ no client DLQ | No client-settable DLQ over this protocol. Poison messages that exceed `MaxReceiveCount` are **silently dropped** by the broker, not delivered to a dead-letter address.¹ |
| **Selectors / filtering** | ✅ selectors (SQL92 subset) | `apache.org:selector-filter:string` on Events / Events Store links; not supported on `/queues/` links. |
| **Auth model** | PLAIN (JWT) / EXTERNAL | SASL PLAIN: password = KubeMQ JWT. SASL EXTERNAL: mTLS, cert CN = ClientID. |
| **TLS / mTLS** | ✅ 5671 | Active when the top-level `Security` block is configured. |
| **Top unsupported** | transactions; durable-unsub node-local | See [What Does Not Migrate](#what-does-not-migrate--deviations). |
¹ There is no client-settable DLQ over this protocol. The AMQP 1.0 connector never marks
published messages for dead-lettering, so a poison message that exceeds `MaxReceiveCount` is
silently dropped by the broker rather than delivered to any consumable dead-letter address.
For a genuine client-facing DLQ, use the RabbitMQ (DLX) or AWS (redrive) connector.
## Connection / Endpoint Migration [#connection--endpoint-migration]
Change only the host and, if required, the SASL credentials. The AMQP 1.0 port is shared with
AMQP 0-9-1 on the same listener, so no firewall change is needed beyond what the AMQP port
already allows.
| | Before (existing broker) | After (KubeMQ) |
| --------- | --------------------------- | ------------------------------------- |
| **Plain** | `amqp://broker:5672` | `amqp://kubemq-host:5672` |
| **TLS** | `amqps://broker:5671` | `amqps://kubemq-host:5671` |
| **Auth** | Broker-specific credentials | SASL PLAIN, password = **KubeMQ JWT** |
```go
// go-amqp v1.7.0 — amqp.Dial
conn, err := amqp.Dial(ctx, "amqp://kubemq-host:5672",
&amqp.ConnOptions{
SASLType: amqp.SASLTypePlain("svc-orders", ""),
})
```
When `Authentication.Enable = false` on the server, the connector also accepts SASL ANONYMOUS
and bare AMQP headers (no SASL) — convenient for local development.
## Concept & Destination Mapping [#concept--destination-mapping]
The **address prefix** of the AMQP link selects the KubeMQ messaging pattern. The leading `/`
is optional (`queues/orders` ≡ `/queues/orders`).
| Source concept | AMQP 1.0 address | KubeMQ pattern | Channel name |
| ------------------------------ | ------------------------------------ | ----------------- | ----------------- |
| Queue / P2P | `/queues/` | **Queues** | `