PubSub
Ephemeral channel-based publish/subscribe: JSON messages published over HTTP fan out in real time to WebSocket subscribers. Nothing is persisted - a message reaches whoever is listening at that instant and is gone. Access is capability-based: every principal owns a private channel namespace and can share it through grants and rooms.
When to reach for it: real-time event fanout, live dashboards and tickers, cross-client signals ("refresh now"), tailing events during development - anywhere losing a message is acceptable.
When not to: durable delivery with acknowledgements, visibility timeouts, and dead-letter queues belongs to Message Queue; persistent per-user notifications belong to Inbox; shared mutable state with change subscriptions belongs to Live JSON.
Concepts
- Ephemeral broadcast - no history, no replay. Publishing to a channel
with zero subscribers succeeds and reports
subscribers_notified: 0; the message is simply lost. - Channels are dot-segmented names - alphanumerics plus
.,-,_, starting and ending alphanumeric, at most 128 characters, no consecutive special characters (a..b,a--bare rejected with400 invalid_channel). - Namespaces - every principal owns the channels under
u.{ns}., where{ns}is a stable, opaque 16-character id derived server-side. You learn yours when you mint a token. - Capability tokens - a session token alone cannot publish or
subscribe (it fails with
403 channel_forbidden). You first mint a short-lived pub/sub token carrying your capabilities; platform admins and service accounts skip this and use their normal token. - Grants and rooms - a grant shares one of your channels with another
user; a room (
room.{name}) is a first-come claimed channel for shared topics that don't belong to any one user's namespace. Both are covered in Access control.
Mint a token, publish, subscribe
snug --output json pubsub token{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs...",
"expires_in": 600,
"namespace": "5JY5XUIVDLNWX5WV"
}Export the minted token and use your namespace as the channel prefix. The
CLI's listen command subscribes over WebSocket and blocks until Ctrl+C:
export SNUG_API_TOKEN=<minted token>
NS=5JY5XUIVDLNWX5WV
snug pubsub listen -c "u.$NS.alerts" --raw & # subscriber
snug pubsub publish -c "u.$NS.alerts" \
-p '{"level":"critical","msg":"disk full"}' -t trace-1The subscriber receives the frame (captured live; --raw prints raw
WebSocket text frames, ideal for piping to jq):
{
"type": "pubsub_message",
"channel": "u.5JY5XUIVDLNWX5WV.alerts",
"message_id": "msg_rewwTuxqjWBs",
"payload": { "level": "critical", "msg": "disk full" },
"timestamp": 1787893754,
"headers": { "trace_id": "trace-1" },
"publisher_id": "docs-wave"
}timestamp is Unix seconds; headers appears only when the publish set
one (-t sets trace_id). The stream also carries ping/pong heartbeats.
Over HTTP, publishing is POST /api/v1/pubsub/publish/{channel}:
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"payload": {"level": "critical", "msg": "disk full"}}' \
http://localhost:4000/api/v1/pubsub/publish/u.$NS.alerts{
"status": 200,
"msg": "OK",
"data": {
"message_id": "msg_WqCrAAMEKrZt",
"channel": "u.5JY5XUIVDLNWX5WV.alerts",
"timestamp": 1787893754,
"subscribers_notified": 1
}
}The WebSocket endpoint is /ws/pubsub/{channel} at the server root (not
under /api/v1), authenticated with a Bearer header or a ?token=
query parameter for browsers. An unauthorized subscribe is rejected with
403 at the handshake, before the socket ever opens.
publish-batch sends one payload to up to 100 channels in a single call
(repeat -c per channel) and reports a per-channel message_id and
subscriber count:
snug pubsub publish-batch -c "u.$NS.alerts" -c "u.$NS.audit" -p '{"deploy":"v42"}'Inspect channels
snug pubsub channels # active channels your capabilities cover
snug pubsub info "u.$NS.alerts" # subscriber count (positional name, not -c)
snug pubsub stats "u.$NS.alerts" # Redis vs WebSocket subscriber split{
"name": "u.5JY5XUIVDLNWX5WV.alerts",
"redis_subscribers": 1,
"websocket_subscribers": 1,
"total_subscribers": 1,
"is_active": true
}channels lists only channels that currently have a subscriber, filtered
to the ones your capabilities allow you to subscribe to - with a bare
session token the list is []. info and stats never return 404: a
channel "exists" only as long as someone is subscribed, so an idle name
reports zero counts and is_active: false. Asking about a channel outside
your capabilities is 403 channel_forbidden.
Behaviors and gotchas
- Mint first. Publishing with a plain session token fails with
403 channel_forbidden("missing 'publish' capability") even on your own namespace - capabilities live only in the minted token. subscribers_notifiedcounts Redis fanout, not end clients: one server node multiplexing many WebSocket subscribers counts once. Usestatsfor the end-client picture.- Revocation is not instant. A minted token keeps its capabilities until it expires (600 seconds by default), so a revoked grant dies at the grantee's next mint, not immediately.
- Batch publishes are all-or-nothing on authorization - one channel you
cannot publish to rejects the whole batch with
403, publishing nothing.
Limits and configuration
Channel names are capped at 128 characters and batch publishes at 100
channels. The namespace-deriving secret, the 600-second capability token
TTL, and publish connection timeouts are set by the PUBSUB_* variables in
the
PubSub CONFIG reference.
Reference
- PubSub API - every endpoint, callable
- Access control - capability tokens, grants, rooms
- PubSub Debug API - system-state
snapshot (
snug pubsub debug, requires a platform admin or service account token) - Related: Message Queue for durable delivery, Inbox for persistent notifications, Live JSON for shared documents, Webhooks for delivery to external URLs