MainSDK
Search⌘K
How-to

Use realtime updates in your app

Push live updates from your server to browsers over server-sent events, checked per channel and tenant, and add a WebSocket when the client must talk back.

Building for 0.3

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

You will push an order status to a browser as it changes, stream a model's answer token by token, and check that a person who may not read an order never hears about it. It takes about 15 minutes.

You need

  • A MainSDK app with a main.yaml and sign-in (see Thirty-minute path).
  • An access action for the thing you push, such as orders.read.

1. Declare the channel

A channel prefix is declared with the access action a subscriber needs. Without it, publishing to the prefix throws MAIN-REALTIME-0003.

main.yaml
realtime:
  channels:
    order: { action: orders.read }
    chat: { action: chats.read }

2. Publish with realtime.publish

Call it after the change is saved. It never waits on a browser and never throws because the other instances are unreachable; it resolves with dropped: true instead.

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

declare function saveShipped(orderId: string): Promise<void>;   // your own write

export async function markShipped(orderId: string) {
  await saveShipped(orderId);
  await realtime.publish(`order:${orderId}`, "updated", { status: "shipped" });
}

Keep personal data out of the event. Send the id and the status, and let the client read the rest through your own calls.

3. Mount the endpoint with realtime.handler

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

export const GET = realtime.handler();

Each subscribe is checked through the session and access for every channel, inside the person's tenant. One refused channel refuses the whole request with 403 MAIN-REALTIME-0002.

4. Listen in the browser

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

declare function render(data: unknown): void;
declare function refetchOrder(): void;

const sub = realtime.subscribe(["order:42", "user:me"], {
  events: ["updated"],
  onEvent: (e) => render(e.data),
  onReset: () => refetchOrder(),
});

onReset means events were missed while the connection was down and the server could not replay them: refetch the order. Delivery is best effort, so your page must be able to rebuild its state from your own calls.

5. Stream a model's answer with realtime.pipe

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 });
}

In the browser, realtime.tokens(sub, "chat:c_7", { onText, onDone }) builds the text as tokens arrive and takes the full text from the final event when one was missed.

6. Let the client talk back with realtime.on

Add websocket to transports, declare a send action, register a handler and mount the upgrade (see Realtime updates).

main.yaml
realtime:
  transports: [sse, websocket]
  channels:
    chat: { action: chats.read, send: chats.write }
app/chat/handlers.ts
import { realtime } from "mainsdk";
import type { ClientMessage } from "mainsdk/realtime";

declare function saveMessage(channel: string, user: string, data: unknown): Promise<void>;   // your own write

realtime.on("chat", "message", {}, async (msg: ClientMessage) => {
  await saveMessage(msg.channel, msg.user, msg.data);
  return { ok: true };
});

The sender gets the returned value as the reply to await sub.send(...).

Check it worked

Run npx mainsdk check --env prod. It passes when every published prefix is declared, the driver is broadcast (or single-instance: true), and max-event fits events.broadcast.max-payload. In a test, open app.realtime.handler() with a Request, publish, and read the stream.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet