MainSDK
Search⌘K
Interfaces

Messaging and notifications

Realtime updates

realtime

Push live updates to browsers over server-sent events, with every subscribe checked per channel and tenant.

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec realtimeFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

realtime.publish() pushes live updates from your server to connected clients, and realtime.subscribe() lets a client listen on a channel, checked per user and tenant.

events.broadcast() is a different thing. It sends a message between instances of your own app, for cache invalidation, and lives in Events and background jobs.

At a glance

realtime:
  driver: broadcast
  path: /mainsdk/realtime
  transports: [sse, websocket]
  channels:
    order: { action: orders.read }
    chat: { action: chats.read, send: chats.write }
  replay: { size: 500, ttl: 5m }
  max-event: 32KB
  streams:
    call: { types: ["audio/opus"], max-per-socket: 4, budget: 64MB }
  socket: { rate: 50/s, buffer: 1MB }

Calls

  • realtime.publish(channel, event, data)
  • realtime.pipe(channel, tokens, { event? })
  • realtime.handler()
  • realtime.on(prefix, event, opts, fn)
  • realtime.onStream(prefix, fn)
  • realtime.ticket()
  • realtime.subscribe(channels, { onEvent, onReset? })
  • realtime.tokens(sub, channel, { onText, onDone })
  • http.websocket(url, options)
  • new SocketServer(hub)
  • new Realtime(ports)
  • parseRealtime(raw)
  • publishProgress(hub, source)
  • publishRecords(hub, source)
  • new Broadcaster(opts)
  • new MemoryBus()
  • redisBroadcast(o)
  • events(object: broadcast, onBroadcast)

Guarantees

Whatever the driver.

  • Every subscribe is checked through the session and access, per declared channel prefix
  • Channels are keyed by the current tenant
  • A reader that falls behind is disconnected, never buffered without bound
  • A stream resumes from Last-Event-ID, or is told to reset

Drivers

5 drivers. Select one to see its line in main.yaml.

main.yamlInstance broadcast (events.broadcast) (default)
realtime:
  driver: broadcast

Change this one line to swap providers; your calls stay the same.Instance broadcast integration

Codes

CodeWhere it appearsMeaning
MAIN-REALTIME-0003Checkmainsdk check, before deployA channel prefix not declared under realtime.channels (404 on subscribe, a throw on publish; a check on a literal in code)
MAIN-REALTIME-0006Checkmainsdk check, before deployThe local driver in production without single-instance: true
MAIN-REALTIME-0007Checkmainsdk check, before deploymax-event plus the 4KB envelope is larger than events.broadcast.max-payload
MAIN-REALTIME-0012Checkmainsdk check, before deployA client message or stream refused: no send action on the prefix, access denied, or no handler
MAIN-REALTIME-0015Checkmainsdk check, before deployrealtime.transports lists websocket on an adapter or platform without HTTP upgrade
MAIN-REALTIME-0016Checkmainsdk check, before deployA binary stream refused: prefix not under realtime.streams, type not listed, or max-per-socket reached
MAIN-REALTIME-0001RuntimeError responses and logsSubscribe with no session (401)
MAIN-REALTIME-0002RuntimeError responses and logsSubscribe refused: access denied, or a user: channel that isn't the caller's (403)
MAIN-REALTIME-0004RuntimeError responses and logsEvent data is not JSON, or is larger than max-event
MAIN-REALTIME-0005RuntimeError responses and logsA channel or event name doesn't match its pattern
MAIN-REALTIME-0008RuntimeError responses and logsA stream was closed because its reader fell more than buffer behind (logged)
MAIN-REALTIME-0009RuntimeError responses and logsThe person already has per-user open streams (429)
MAIN-REALTIME-0010RuntimeError responses and logsWebSocket upgrade refused: the Origin is neither the app's own nor in inbound.cors.origins (403)
MAIN-REALTIME-0011RuntimeError responses and logsWebSocket upgrade refused: the ticket is unknown, expired, used or from another tenant (401)
MAIN-REALTIME-0013RuntimeError responses and logsA client message isn't JSON, has an unknown type, is over max-message or fails the handler's schema
MAIN-REALTIME-0014RuntimeError responses and logsClient messages over realtime.socket.rate
MAIN-REALTIME-0017RuntimeError responses and logsA stream passed its byte budget, or a frame passed max-frame; the stream or socket is closed
MAIN-REALTIME-0018RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
realtime@1 contract and channel namesPhase 10.3realtime#contract
Publish and the SSE endpoint on the local driverPhase 10.3realtime#sse-local
Subscribe checked per channel prefixPhase 10.3realtime#subscribe-auth
Channels keyed by tenantPhase 10.3realtime#tenant
Replay buffer and resumePhase 10.3realtime#resume
Back-pressure and per-user connection capPhase 10.3realtime#backpressure
Fan-out across instancesPhase 10.3realtime#fanout
Model token streams with pipePhase 10.3realtime#ai-pipe
Workflow progress and record-change eventsPhase 10.3realtime#progress-records
Reconnect hint on shutdownPhase 10.3realtime#shutdown
Browser clientPhase 10.3realtime#client
Checks for undeclared channels and oversized eventsPhase 10.3realtime#checks
RFC 6455 WebSocket server on node:httpPhase 10.4realtime-sockets#rfc6455
Upgrade checked by originPhase 10.4realtime-sockets#upgrade-auth
SubscribePhase 10.4realtime-sockets#socket-channels
Client messages handled with realtime.onPhase 10.4realtime-sockets#client-messages
Numbered binary streams with realtime.onStreamPhase 10.4realtime-sockets#binary-streams
Bounded socket and stream queuesPhase 10.4realtime-sockets#backpressure
Outbound http.websocket with the rules of http.fetchPhase 10.4realtime-sockets#outbound-parity
Close every socket with a reconnect code on drainPhase 10.4realtime-sockets#drain
Socket transport in the browser clientPhase 10.4realtime-sockets#client
Checks for sockets without upgrade and undeclared send and stream prefixesPhase 10.4realtime-sockets#checks
Real-time collaboration and presencePhase 51.6platforms#collab-presence

Quick start

main.yaml
realtime:
  driver: broadcast
  channels:
    order: { action: orders.read }
app/orders/actions.ts
"use server";
import { realtime } from "mainsdk";

export async function markShipped(orderId: string) {
  // update the order in db first
  await realtime.publish(`order:${orderId}`, "status", { status: "shipped" });
}

Configuration

main.yaml
realtime:
  driver: broadcast            # local | broadcast; pusher, ably and supabase-realtime later
  single-instance: false       # true says local is enough in production
  path: /mainsdk/realtime
  channels:                    # the channel prefixes clients may use, and the access action each needs
    order: { action: orders.read }
    chat: { action: chats.read, send: chats.write }   # send: what a client message needs (websocket)
    workflow: { action: workflows.read }              # a run's state changes are published here
  records: [orders]            # models that publish `changed` to order:<id> after commit
  per-user: 10                 # open streams and sockets per person
  transports: [sse, websocket] # websocket is opt-in
  streams:
    call: { types: ["audio/opus"], max-per-socket: 4, budget: 64MB }
  socket: { max-message: 64KB, max-frame: 256KB, rate: 50/s, high-water: 256KB, buffer: 1MB, inbound-buffer: 1MB, ping: 20s }
  ticket-ttl: 30s
  heartbeat: 15s
  max-age: 15m                 # a stream closes with a reconnect hint, so access is checked again
  replay: { size: 500, ttl: 5m }
  buffer: 256KB                # a reader that falls further behind is disconnected
  max-event: 32KB
KeyWhat it does
driverlocal serves one instance. broadcast fans events out to every instance through events.broadcast
channelsChannel prefixes you allow, such as order for order:42, with the access action a subscribe needs and, for sockets, the send action. A plain list of prefixes reads as <prefix>.read. tenant and user:<id> are always there
recordsModels whose committed writes publish changed with { op, id, version } and no field values
transportssse (default) and websocket (opt-in). mainsdk check reports MAIN-REALTIME-0015 for websocket on a platform with no HTTP upgrade
streamsPrefixes that may open binary streams, with their content types, max-per-socket and byte budget
socketLimits of one socket: message and frame sizes, rate of client messages, high-water per stream, buffer per socket, inbound-buffer and ping
replayEvents kept per channel so a client can resume from Last-Event-ID
max-eventThe largest event. A bigger one fails with MAIN-REALTIME-0004

Channel names always live inside the current tenant. user:me is one person, and tenant is every member.

What you get

  • Live updates for order status that changes without a refresh, live dashboards or a notification badge.
  • Server-sent events (0.3, spec realtime): one stream per browser carries many channels, resumes after a drop and closes when access is gone.
  • realtime.publish(channel, event, data) sends from your server, and realtime.subscribe(channels, options) listens in the browser.
  • Add websocket to transports (0.4, spec realtime-sockets) and the same channels also run over one WebSocket, with messages from the client and binary streams.
  • Every subscribe is checked through the session and access, so a client only hears the channels it may see.

The endpoint and the browser client

realtime.handler() returns the SSE endpoint as a (req) => Response handler. Adapters mount it at realtime.path.

app/mainsdk/realtime/route.ts
import { realtime } from "mainsdk";

export const GET = realtime.handler();

In the browser, realtime.subscribe(channels, options) opens one stream for many channels. onReset fires when events were missed, so you refetch.

app/orders/live.ts
import { realtime } from "mainsdk/realtime/client";

const sub = realtime.subscribe(["order:42", "user:me"], {
  onEvent: (e) => console.log(e.channel, e.event, e.data),
  onReset: () => void fetch("/api/orders/42"),
});
sub.add("workflow:wr_01J");
sub.close();

Stream model tokens

realtime.pipe(channel, tokens) sends a model's tokens as indexed token events and a final done event with the full text. It takes the iterable ai.stream returns, unchanged. The browser reads them with realtime.tokens(), which waits for done and uses its text when an index was missed.

app/api/chat/route.ts
import { ai, realtime } from "mainsdk";

export async function POST(req: Request) {
  const { chatId, prompt } = (await req.json()) as { chatId: string; prompt: string };
  const { text } = await realtime.pipe(`chat:${chatId}`, ai.stream({ model: "anthropic/claude-sonnet-4-5", feature: "chat", prompt }));
  return Response.json({ text });
}
app/chat/stream.ts
import { realtime } from "mainsdk/realtime/client";

const sub = realtime.subscribe(["chat:c_7"], { onEvent: () => {} });

realtime.tokens(sub, "chat:c_7", { onText: (soFar) => console.log(soFar), onDone: (text) => console.log(text) });

EventSource hears events by name, so subscribe listens for a default set of names (updated, changed, status, token, done and the workflow states). Pass events: ["my-event"] for others.

Workflow progress and record changes

With realtime.channels.workflow declared, a run publishes { state, currentStep, wakeAt? } to workflow:<runId>: sleeping, running, completed, never a step result. publishProgress(hub, source) and publishRecords(hub, source) connect those two feeds to a hub through small ports, so the workflow runner and the database tell realtime when a run changes state or a transaction commits.

WebSocket

Set transports: [sse, websocket] and mount the upgrade next to the SSE endpoint. The server side is RFC 6455 on node:http and node:crypto, with no dependency and no compression.

server.ts
import { createServer } from "node:http";
import { app } from "mainsdk";
import { SocketServer } from "mainsdk/realtime";

const server = createServer();
const sockets = new SocketServer(app().realtime);
server.on("upgrade", (req, socket, head) => void sockets.upgrade(req, socket, head));

Every upgrade is checked before 101: draining (503), the Origin against the app's own and inbound.cors.origins (MAIN-REALTIME-0010), then a session or a single-use ticket from realtime.ticket() for clients with no cookies (MAIN-REALTIME-0001, MAIN-REALTIME-0011). Channels pass the same checks as SSE, and per-user counts sockets and streams together.

chat.ts
import { realtime } from "mainsdk";
import { s } from "mainsdk/schema";
import type { BinaryStream, ClientMessage } from "mainsdk/realtime";

declare function saveMessage(channel: string, user: string, data: unknown): Promise<void>;
declare function transcode(chunk: Uint8Array): Uint8Array;

// A client message needs `send` on the channel's prefix and passes access; the handler runs in the sender's tenant.
realtime.on("chat", "message", { schema: s.object({ text: s.string() }) }, async (msg: ClientMessage) => {
  await saveMessage(msg.channel, msg.user, msg.data);
  return { ok: true };       // sent back as the acknowledgement
});

// A binary stream, such as microphone audio in and speech out.
realtime.onStream("call", async (stream: BinaryStream) => {
  const out = stream.writable.getWriter();
  for await (const chunk of stream.readable) {
    await out.ready;         // waits at high-water instead of buffering
    await out.write(transcode(chunk));
  }
});

Client messages are rate-limited per socket through limits (MAIN-REALTIME-0014); when the limits store is down the socket counts locally and never refuses on that account. A socket whose queue passes buffer is closed with 4008; one whose handler stops reading is paused at inbound-buffer and closed after 30 seconds.

In the browser, transport: "auto" tries a WebSocket and falls back to SSE:

ts
import { realtime } from "mainsdk/realtime/client";

const sub = realtime.subscribe(["chat:c_7"], { transport: "auto", onEvent: (e) => console.log(e.data) });
await sub.send("chat:c_7", "message", { text: "hi" });          // resolves with the handler's reply
const mic = sub.stream("call:k_1", { contentType: "audio/opus" });

send and stream reject on SSE with a clear error.

Outbound sockets

http.websocket(url, { protocols?, headers?, connectTimeout?, highWater?, maxMessage? }) opens a socket to a provider with the rules of http.fetch: the host must be in network.allow (MAIN-NET-0002), a redirect is refused (MAIN-NET-0003), the connect timeout applies (MAIN-NET-0009) and each attempt is a decision on the network diagnostics channel. send waits while the socket's buffered bytes are over highWater, and iterating the socket yields its messages.

ts
import { http, secrets } from "mainsdk";

const ws = await http.websocket("wss://api.elevenlabs.io/v1/text-to-speech/x/stream-input", {
  headers: { "xi-api-key": await secrets.get("ELEVENLABS_KEY") },
});
await ws.send(JSON.stringify({ text: "Hello" }));
for await (const msg of ws) console.log(msg);

http.websocket(url, protocols?) with no options still returns the browser WebSocket after the same host check. mainsdk discover lists each site with via: websocket.

Examples

Listen for order updates in the browser

app/orders/live-status.ts
import { realtime } from "mainsdk/realtime/client";

// Call it from your component's effect (in React, `useEffect`) and return the stop function as its cleanup.
export function watchStatus(
  orderId: string,
  setStatus: (status: string) => void,
  refetch: (orderId: string) => Promise<string>,
): () => void {
  const sub = realtime.subscribe([`order:${orderId}`], {
    onEvent: (e) => setStatus((e.data as { status: string }).status),
    onReset: () => void refetch(orderId).then(setStatus),
  });
  return () => sub.close();
}

Publish from a webhook

app/api/webhooks/carrier/route.ts
import { realtime, webhooks } from "mainsdk";

export const POST = webhooks.receive("standard", async (event) => {
  const { orderId, status } = (event as { data: { orderId: string; status: string } }).data;
  await realtime.publish(`order:${orderId}`, "status", { status });
});

The handler runs only after the webhook signature and timestamp are checked; see Webhooks.

Update a team dashboard from a job

jobs/metrics.ts
import { realtime } from "mainsdk";

export async function publishDailyTotals(totals: { orders: number }) {
  await realtime.publish(`tenant`, "dashboard", totals);
}

Reference

The broadcast driver sends each event between instances of your app through the broadcast layer of events. These are the parts it is built from.

NameWhat it is
events(...)The events object, whose broadcast(topic, payload) and onBroadcast(topic, fn) calls carry messages between instances. The catalog lists it as events(object: broadcast, onBroadcast).
new Realtime(ports)The hub behind realtime: channels, publishing, replay and the SSE endpoint, over the ports the app supplies. The app builds it for you; tests and adapters build their own.
parseRealtime(raw)Read the realtime section of a parsed main.yaml. Absent keys take their defaults, and a bad value throws with the key named.
new Broadcaster(opts)The class behind those two calls, over one driver. broadcast resolves with { id, reached, dropped } once the driver accepts the message or 250 ms pass, and never throws for an unreachable driver. onBroadcast returns a function that stops its handler, and close() ends the subscription. Topics are lowercase letters, digits, dots, dashes and underscores, up to 128 characters.
new MemoryBus()A bus that several instances in one process share. It backs the local driver in tests and mainsdk dev, and bus.sent lists the messages sent.
redisBroadcast(o)The redis driver: one PUBLISH per message and one SUBSCRIBE per instance on the channel <prefix>:broadcast, over the SDK's own client. Its host is a driver host of the network layer.

Broadcast is best effort and at most once. A message is not stored or retried.

Errors and checks

CodeWhenFix
MAIN-REALTIME-0001Subscribe with no session (401)Sign the person in
MAIN-REALTIME-0002Access denied, or a user: channel that isn't the caller's (403)Subscribe to a channel the person may read
MAIN-REALTIME-0003A channel prefix not under realtime.channelsDeclare it
MAIN-REALTIME-0004Event data is not JSON, or over max-eventSend JSON under the limit
MAIN-REALTIME-0005A channel or event name does not match its patternUse <prefix>:<id> and a lowercase event name
MAIN-REALTIME-0006The local driver in production (check, runtime)Use broadcast, or set single-instance: true
MAIN-REALTIME-0007max-event plus 4KB is over events.broadcast.max-payload (check)Lower max-event or raise the payload limit
MAIN-REALTIME-0008A stream or socket was closed because its reader fell behind (logged)The client resumes with its last id
MAIN-REALTIME-0009The person already has the most open streams (429)Close one
MAIN-REALTIME-0010Upgrade refused: the Origin is not allowed (403)Add it to inbound.cors.origins
MAIN-REALTIME-0011Upgrade refused: the ticket is unknown, expired, used or from another tenant (401)Ask realtime.ticket() again
MAIN-REALTIME-0012A client message or stream refused: no send action, access denies it, or no handler (also a check)Declare send and register realtime.on
MAIN-REALTIME-0013A client message is not valid JSON, is too big or fails the handler's schemaFix the message
MAIN-REALTIME-0014Client messages over socket.rateWait retryAfter seconds
MAIN-REALTIME-0015websocket listed on a platform with no HTTP upgrade (check)Remove it or change platform
MAIN-REALTIME-0016A binary stream refused: prefix, type or count (also a check)Declare it under realtime.streams
MAIN-REALTIME-0017A stream passed its byte budget, or a frame passed max-frameRaise the limit or send less
MAIN-REALTIME-0018Subscribe or upgrade while the app is draining (503)Reconnect in a few seconds

See check codes for the rest.

Swapping drivers

main.yaml
realtime:
  driver: local   # was broadcast; one instance only

local in production needs single-instance: true, or mainsdk check fails with MAIN-REALTIME-0006. Hosted drivers (Pusher, Ably, Supabase Realtime) come later. Your realtime.publish() calls stay the same. See Swap a driver.

Testing

realtime runs on the local driver in tests with no provider account, and app.realtime.handler() takes plain Request objects, so a test opens a stream, publishes and reads the events with no network. Time comes from the fake clock: advance it 45 seconds to see three heartbeats, or past max-age to see the reconnect.

app/orders/actions.test.ts
import { createTestApp } from "@mainsdk/testing";

const cookie = "";   // the session cookie of the person who subscribes
const app = createTestApp({ main: "./main.yaml" });
const res = await app.realtime.handler()(new Request("http://app.test/mainsdk/realtime?c=user:me", { headers: { cookie } }));
await app.tenancy.anonymous(() => app.realtime.publish("user:u_1", "ping", { n: 1 }));

For WebSocket, drive SocketServer.upgrade(req, socket) with a pair of in-memory duplex sockets.

Security and privacy

  • Every subscribe is checked through the session and access, per declared channel prefix and the current tenant.
  • Keep personal data out of messages. Send IDs and statuses, and let the client fetch details through scoped db reads.
  • A new realtime update can go to a small share of users first and pause itself on spikes in errors, through the same staged rollout as notifications.

FAQ

Is this for chat and presence? Publish and subscribe ship first. Real-time collaboration and presence come in phase 5.

Can a client publish? Over SSE, no: clients subscribe and your server publishes. Over a WebSocket a client sends messages to a channel whose prefix declares send, and your realtime.on handler decides what to publish.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet