The Event Bus
Vincent Depassier · September 29, 2026
The Event Bus is the fast lane. It carries what is changing — a cursor moving, someone typing, a row that just landed — from one connected client to the others, in a few milliseconds, without writing anything down.
That last part is the whole design, and it is what you have to decide about first:
If losing a message matters, it does not belong on the bus. An automation is durable: it runs whether or not anyone is watching, it retries, and it leaves a run history. The bus is the opposite — it relays to whoever is connected right now. The test is one question: if this is lost, does it matter? If yes, use an automation. If no, because there is a newer one coming in 100 ms, use the bus.
They compose well. A chat message uses both: an automation stores it and notifies the people who are away, while the bus makes it appear instantly for everyone who is looking.
There is one exception, and it is narrow — a short reconnect window for buses bound to a table. See Table events on the Event Bus.
Connecting
One endpoint, one connection, however many buses you join on it:
wss://gateway.praxsuite.com/hubs/event-bus?access_token=<end-user JWT>An API key cannot connect. The bus admits end users, not credentials: a key is how your backend logs a user in, and the token you get back is what opens the socket. A sk_live_ key on this endpoint is rejected.
You manage all of it in API Gateway → Event Bus.

Bus keys
Every bus is addressed as {topic}:{instance}:
office:hq
cursor:doc-42
chat:8f3c1a90-5d2e-4c11-9b77-0e5a2c6d4471The topic is declared once by an administrator and governs everything: who may join, what the limits are, whether presence is announced. The instance is free-form and is how one topic serves thousands of buses — one per document, one per channel, one per game lobby.
A key whose topic was never declared is refused. That is deliberate: it means nobody can invent a namespace and start listening in it.
Two things about the key that will bite you:
The topic is folded to lowercase; the instance is not.
Office:hqandoffice:hqare the same bus.chat:Room1andchat:room1are two different buses, and the peers in them will never see each other — which looks exactly like a client bug.`user:self` is reserved. It resolves server-side to your own private bus. You cannot address anybody else's: naming another user's is rejected outright rather than quietly redirected.
Who may join
The topic's access mode decides. They escalate in cost, and the first two need no lookup at all.
Mode | Admits | Use it for |
Workspace | any authenticated end user of the workspace | cursors, typing indicators, an open lobby |
Roles | a caller holding any one of the topic's allowed roles | staff-only channels, an admin feed |
Grants | a caller holding a grant on that specific instance | one chat channel private, the next one open |
Ticket | a caller presenting a signed join ticket | per-resource rules your own logic decides |
Grants is the one to reach for when permissions differ per bus rather than per topic. Roles cannot express that — the allowed roles live on the topic, so chat:a and chat:b would necessarily share them. Grants reuses the same resource grants the rest of the platform authorises against, so a resource you declared once is reachable without declaring it twice. Like every grant, it fails closed: an instance nobody was granted is denied, never public.
Two traps in Grants mode:
The instance must be a GUID, because that is what a grant's resource id is.
chat:generalcannot be granted;chat:{guid}can.An instance with no grants at all is refused, not opened.
Ticket mode is declared but has no issuer today. Nothing in the product mints a join ticket — no endpoint, no automation node, no tool — so a topic set to Ticket cannot currently be joined by anyone. Use Grants until that changes.
The access mode, the source tables, presence, retained state and the limits are all set where the topic is declared:

Talking to it
Four calls.
JoinBus(busKey, ticket) -> { ok, error, peers[] }
JoinBusSince(busKey, ticket, sinceCursor) -> { ok, error, peers[], missed[] }
LeaveBus(busKey)
Publish(busKey, eventName, payload) -> { ok, error, recipients, cursor }JoinBusSince is JoinBus plus the reconnect window described on the table events page. Use JoinBus until you have a cursor worth resuming from; everything else about the two is identical.
Send every argument the call declares, even the ones you have no value for. The transport matches a call by how many arguments you sent, not by their names, so JoinBus(busKey) with the ticket left out is not filled in with a default — it fails to match and comes back as a generic server error that says nothing about the real cause. Pass null: JoinBus(busKey, null).
The same rule is why the reconnect cursor got its own call instead of a third argument on JoinBus. Adding one would have broken every client already built against the two-argument shape, which is exactly what it did for a day in September 2026.
And four things the server sends you:
Event | Payload | When |
|
| somebody published |
|
| a peer joined, if presence is on |
|
| a peer left, if presence is on |
|
| you were removed mid-connection |
Every message carries its `bus`, and you need it. One connection carries every bus you joined, and the transport tells you which call arrived, never which bus it came from. Without that field a client in two buses cannot tell an office:hq event from a cursor:doc-42 one.
A publisher does not receive its own event. Publish goes to the others in the bus. Update your own UI locally; do not wait for the echo.
`bus-evicted` exists because a socket outlives a permission check. If a topic is disabled or re-scoped while you are connected, you are actively thrown out rather than left quietly listening.
Retained state
If the topic retains state, each peer's last message is kept and handed to the next person who joins, as peers[] on the join result. That is what makes a cursor topic work: a late joiner sees where everybody is immediately, instead of waiting for each of them to move.
It is last-write-wins per peer, not a history, and it is capped per peer — a peer publishing a payload over the cap simply keeps no retained state rather than being able to pin memory.
Publishing from a server
You do not need a socket to announce something. A gateway credential can publish over HTTP:
POST https://gateway.praxsuite.com/api/v1/gateway/{workspaceId}/bus/{topic}/{instance}/publish
Authorization: Bearer sk_live_...
Content-Type: application/json
{ "event": "order.paid", "payload": { "orderId": "A-1187" } }event is required. The topic and the instance are separate path segments on purpose, so no HTTP client's own escaping can turn a valid key into a different one.
Limits
These are the platform ceilings. A topic may set a lower value for the first three.
Limit | Default |
Publishes per second, per connection | 60 |
Outbound messages per second, per connection | 600 (burst 1200) |
Payload size | 8 KB |
Peers in one bus | 200 |
Buses one connection may hold | 20 |
Bus key length | 128 characters |
Event name length | 64 characters |
Retained state per peer | 2 KB |
A publish is priced per recipient, not per call. Sixty publishes a second into a 200-peer bus is twelve thousand outbound messages, so that is what the budget counts. A quiet bus costs almost nothing; a loud one in a crowded bus runs out of allowance long before it runs out of calls.
When a call is refused
ok comes back false with an error you can switch on.
Error | Meaning |
| malformed, empty, or over the length limit |
| the topic was never declared |
| declared, but switched off |
| Roles mode, and you hold none of them |
| Grants mode, and you hold no grant on this instance |
| Ticket mode, and the signature did not check out |
| the bus is at capacity, or you already hold your maximum |
| you published to a bus you never joined |
| empty, or over the length limit |
| over the topic's payload cap |
| you spent your allowance |
A refusal always arrives this way, as ok: false with a name you can read. A failure that instead comes back as a bare "error on the server" is not one of these — it means the call itself never matched, and the argument count is the first thing to check.
What the bus is not
Not storage. Nothing is persisted, apart from the short reconnect window described on the table events page. A message nobody was there to hear is gone, and that is correct behaviour.
Not a queue. No acknowledgements, no retries, no dead letters.
Not trusted. A payload published by a peer was written by another client and relayed untouched — never parsed, never validated. Treat it as hostile input, exactly as you would a request body.
Not a replacement for automations. If the receiver is your system rather than a person looking at a screen, you probably want an automation.
Next
Table events on the Event Bus
API Gateway overview
Roles and permissions
Automations: trigger nodes