Gateways
Your backend's gateways are its realtime channels, and an app joins them through the module palbase apps:sync writes for it. pb.<backend>.join(Gateway, params) resolves once the backend let the user in, with a handle: the gateway's events arrive on it, typed, and its messages go out through it, each answered with the backend's ack. The app writes no channel name, event name or payload type of its own, and a gateway that carries a chat holds the chat's state for the app to render.
Quick example
// src/room.ts
import { GatewayError } from "palbase/web";
import { pb, RoomGateway } from "../palbase/shop-web";
// Rejects with a GatewayError (unauthorized) when the backend does not let this user in.
const room = await pb.backend.join(RoomGateway, { roomId: "42" });
room.on("welcome", (w) => console.log(`${w.members} in the room`));
room.on("message", (m) => console.log(`${m.from}: ${m.text}`));
try {
// Resolves with the ack the backend's @Subscribe returned.
const { id } = await room.send("say", { text: "hello" });
console.log("taken as", id);
} catch (e) {
// The backend's own word (no_commands), or the platform's: forbidden, unavailable, …
if (e instanceof GatewayError) console.error("not taken:", e.reason);
}
// When the view goes:
room.leave();
RoomGateway is the backend's class of that name, as the generated module describes it: its pattern room:{roomId}, the events it sends and the messages it takes. See the backend's side in Gateways.
The generated module
palbase apps:sync <app> writes the checkout's apps their settings and their backends' clients under palbase/, from what the project serves now (The generated module). For a web app it writes palbase/<app>.ts, which exports the app's pb, and a module for each backend the app reaches. With one backend, palbase/<app>.ts exports that backend's types and gateways too; with several, each backend's are in palbase/palbe/<machine>.ts. Each gateway is a descriptor named by its class. Run the sync again when the backend changes a gateway, and a payload the backend no longer takes is a compile error in the app.
pb.<backend> is the machine's name camelCased: pb.backend for the machine named backend, pb.shopApi for shop-api. palbase apps:sync --as shop-api=shop names it otherwise.
What the app imports from it, cut to the gateways this page uses:
// palbase/shop-web.ts
// Code generated by palbase apps:sync — DO NOT EDIT. (Cut to this page's gateways.)
import { createClient, type BackendBase, type PalbaseClient } from "palbase/web";
import { __registerBackend } from "palbase/web/palbe";
export interface OrderUpdatesStatusEvent {
orderId: string;
status: "paid" | "packed" | "shipped" | "delivered";
}
export type OrderUpdatesEvent =
| { event: "status"; payload: OrderUpdatesStatusEvent };
export interface OrderUpdatesParams { userId: string }
export const OrderUpdates = {
name: "OrderUpdates",
pattern: "orders:{userId}",
params: ["userId"] as const,
durable: true,
events: ["status"] as const,
messages: [] as const,
send: {} as {},
reply: {} as {},
event: {} as OrderUpdatesEvent,
} as const;
export interface RoomGatewayMessageEvent {
from: string;
id: string;
text: string;
}
export interface RoomGatewayWelcomeEvent {
members: number;
}
export interface RoomGatewaySayMessage {
text: string;
}
export interface RoomGatewaySayReply {
id: string;
}
export type RoomGatewayEvent =
| { event: "message"; payload: RoomGatewayMessageEvent }
| { event: "welcome"; payload: RoomGatewayWelcomeEvent };
export interface RoomGatewayParams { roomId: string }
export const RoomGateway = {
name: "RoomGateway",
pattern: "room:{roomId}",
params: ["roomId"] as const,
durable: false,
events: ["message", "welcome"] as const,
messages: ["say"] as const,
send: {} as { say: RoomGatewaySayMessage },
reply: {} as { say: RoomGatewaySayReply },
event: {} as RoomGatewayEvent,
} as const;
export interface SupportGatewayChatEvent {
data: Record<string, unknown>;
id?: number;
type: string;
}
export interface SupportGatewayAnswerMessage {
callId: string;
output: string;
}
export interface SupportGatewayDecideMessage {
approvalId: string;
approve: boolean;
reason?: string;
}
export interface SupportGatewaySayMessage {
clientId?: string;
delivery?: "next" | "now";
text: string;
}
export interface SupportGatewaySayReply {
clientId: string;
}
export type SupportGatewayStopMessage = Record<string, unknown>;
export interface SupportGatewayStopReply {
stopped: string | null;
}
export type SupportGatewayEvent =
| { event: "chat"; payload: SupportGatewayChatEvent };
export interface SupportGatewayParams { chatId: string }
export const SupportGateway = {
name: "SupportGateway",
pattern: "support:{chatId}",
params: ["chatId"] as const,
durable: true,
chat: true,
events: ["chat"] as const,
messages: ["answer", "decide", "say", "stop"] as const,
send: {} as { answer: SupportGatewayAnswerMessage; decide: SupportGatewayDecideMessage; say: SupportGatewaySayMessage; stop: SupportGatewayStopMessage },
reply: {} as { answer: unknown; decide: unknown; say: SupportGatewaySayReply; stop: SupportGatewayStopReply },
event: {} as SupportGatewayEvent,
} as const;
// The backend, for the clients of this environment's project alone. Its routes are in the tree too: see Calling Your Backend.
__registerBackend("https://k3xq81w4m.palbase.studio", "backend", "backend", "https://k3xq81w4m.app.palbase.studio", {});
export interface Backend extends BackendBase {}
export const settings = { url: "https://k3xq81w4m.palbase.studio", publishableKey: "pb_k3xq81w4m_cZ8yX7wV6uT5" } as const;
/** The app's client: the project's modules (pb.auth, …) and its backend, pb.backend. The module makes it on first use (connect). */
export const pb = createClient({ url: settings.url, publishableKey: settings.publishableKey }) as PalbaseClient & { backend: Backend };
send, reply and event carry types alone: a send is checked against the message's shape and resolves with its ack's, and an event handler is typed by the event. The lists of names are there for a JavaScript caller, which has no compiler: a message or an event the gateway does not declare throws a TypeError before anything is sent.
palbase apps:sync writes an iOS or Android app in the checkout the same gateways: pb.backend.join(…) in Swift and in Kotlin, and in both a chat gateway's handle holds handle.chat (iOS, Android).
Joining
join(Gateway, params, options?) takes the gateway's descriptor and a value for each of its pattern's {param}s, each one segment of the channel's name: empty, or holding a :, it throws a TypeError. It opens the project's socket if it is not open, and resolves once the backend let the signed-in user in.
| Member of the handle | What it does |
|---|---|
on(event, handler) | Listens to one event, its payload typed. Returns the function that stops it. |
on(handler) | Listens to every event the gateway declares, as a union to switch over. |
off(subscription) | Stops one listener, as calling it does. |
send(message, payload) | Sends one message and waits for the backend to take it: its ack, or a GatewayError. |
leave() | Drops the handle's listeners and lets go of the channel. |
topic | The channel: room:42. |
seq | A durable gateway's number of the last event the handle was sent. |
chat | A chat gateway's chat. See Chat gateways. |
Hold a handle for as long as its view is on screen, and leave it when the view goes: a gateway with members is one its backend still runs for (what its @OnFirst started stops when the last member leaves). Two handles of the same channel share one membership, and the channel is left with the last of them.
Only a signed-in user joins: the access token the socket carries is who they are. The socket is one person's, and it closes when someone signs in or out (Realtime).
Events
// src/room-feed.ts
import { pb, RoomGateway, type RoomGatewayEvent } from "../palbase/shop-web";
export async function follow(roomId: string, render: (line: string) => void): Promise<() => void> {
const room = await pb.backend.join(RoomGateway, { roomId });
room.on((e: RoomGatewayEvent) => {
switch (e.event) {
case "message":
render(`${e.payload.from}: ${e.payload.text}`);
break;
case "welcome":
render(`${e.payload.members} in the room`);
break;
}
});
return () => room.leave();
}
An event the generated module does not know, from a backend that moved ahead of it, is not delivered. A listener that throws does not take the socket down.
Messages and acks
send resolves with what the backend's @Subscribe returned, as its reply schema types it, or with nothing when it returned nothing. It rejects with a GatewayError whose reason says why the message was not taken:
reason | What it means |
|---|---|
| The backend's own word | Its method threw GatewayRefusal("…"): no_commands, not_your_turn, too_many_messages. Act on it. |
handler_failed | The method threw something else. The details are in the backend's log. |
reply_too_large | The ack was past 64 KiB. |
| The schema's issues | The payload did not fit the message's schema, said as text (text: Too small: …). |
forbidden | The user may only listen on this channel, or the platform refused with no reason given. |
rate_limited | Past the project's events per second. |
unavailable | The backend could not be asked just then. Worth trying again. |
no reply, socket closed | No answer came. The message may have been taken. |
not joined | Nothing was sent: the handle was left, its socket closed for good, or the socket is being opened again. |
A join that is refused rejects with a GatewayError too: unauthorized when the backend did not let the user in (final), unavailable when it could not be asked (worth trying again), and rate_limited or too_many_channels past the project's limits.
A message goes to the backend alone, never to the other members. They hear what the backend emits.
Durable gateways
A durable gateway keeps what it sends. A handle that joins one is sent what it keeps first, in order, then what comes after. { history: false } asks for only what comes after the join:
// src/orders.ts
import { OrderUpdates, pb } from "../palbase/shop-web";
export async function watchOrders(userId: string, onStatus: (orderId: string, status: string) => void): Promise<() => void> {
// Only what happens from now: the page reads the orders' current state from the backend's routes.
const orders = await pb.backend.join(OrderUpdates, { userId }, { history: false });
orders.on("status", (s) => onStatus(s.orderId, s.status));
return () => orders.leave();
}
The channel's first handle decides: a second handle of the same channel gets the events from where it joins. handle.seq is the number of the last event the handle was sent. When the socket drops, the channel is joined again after that number: the handle is sent what it missed, in order, and nothing twice.
When the connection drops
The socket reconnects by itself (Realtime), and every gateway that was joined is joined again with it. Meanwhile a send answers not joined. An event a gateway that is not durable sent while the socket was down is not delivered.
When the socket closes for good (a sign-out, or another user's sign-in) every handle on it ends: its listeners are dropped, and send answers not joined. Join again, as the user who is there now.
Chat gateways
A gateway declared with chat: true carries a chat of the project's agents, which the platform writes into its channel (AI). Its handle has chat, the chat's state folded from what the platform writes there: from the chat's start, since the channel keeps it, and then each change as it comes.
Member of handle.chat | What it is |
|---|---|
state | The chat now. The same object until something changes. |
subscribe(listener) | Told each time the state changes, at most once a microtask. Returns the unsubscribe. |
for await (const state of handle.chat) | The state now, then each change, until the gateway is left. |
hasOlder, loadOlder({ limit? }) | The channel keeps a day of the chat. loadOlder reads the page before what the state holds, with the member's own session, and folds it in; it resolves with hasOlder, false once a page reaches the chat's start. |
What state holds:
| Field | What it is |
|---|---|
items | The conversation in order, each one of four types: { type: "message", message }, { type: "response", response }, { type: "goal", goal } (a word of the chat's goal: set, changed, a turn of it begun, judged or checked, its status, cleared) and { type: "voice", voice } (the chat's voice began or ended). A subagent's answer is a part of its parent's. |
messages | Each member's (or the server's) message: clientId, text, author (null: the server), authorName, status (pending until the agent has it, then delivered, or failed with its error), kind (text, or voice for a line said in the chat's voice), attachments (its pictures and files), metadata, createdAt, and mine. |
responses | Each answer, the subagents' too: id, status (running, waiting, completed, failed, canceled, timed_out, budget_exceeded), end and error once it ended, text so far, parts, thinking (the model's reasoning step by step, when the agent shows it), goal (the goal's turn it is, or null), startedBy, and mine. |
turn | The last turn's state, and what it waits for: approval, tool_output, subagents or message. |
waiting | What waits for the signed-in user: approvals, the tool calls of their own turns waiting for their yes, and outputs, their own turns' client tool calls waiting for their app's answer. Another member's turn is theirs. |
goal | The chat's goal once one was set: objective, status (active, paused, blocked, budget_limited, achieved, or cleared), its stop code and reason, and its events. null without one. |
voice | The chat's voice, the last that began in it, live or ended: status (started, closed), mine, seconds and closeReason once it closed. null without one. |
forkedFrom | A fork's: { chatId, beforeMessage }, the chat it was forked from. null for a chat of its own. |
An answer's parts are its pieces in order, each one of four types: { type: "text", text }; { type: "tool", tool }, a tool call; { type: "agent", agent }, a subagent handed work (or refused it, status: "denied" with its reason); and { type: "thinking", thinking }, a step's reasoning (text, or unavailable: true with the provider's reason when the agent shows its reasoning and the provider gave none). A render that leaves a type out leaves that piece of the conversation out.
mine is the signed-in user's: a message they wrote, an answer to a turn they began. A tool call (a tool part, or one of waiting) has call_id, name, status, input, output and, while it waits for a yes, approval_id. Its output is the answer's last 16 KiB: omittedBytes says how much came before it, and pb.chatToolCall(chatId, callId) reads the whole answer.
What the member does in the chat goes through the gateway's own messages, and the backend does it as them: say, decide (an approval's yes or no), answer (a client tool's output) and stop, in the backend of AI. A refusal is its reason: not_your_turn, too_many_messages, history_full.
A chat in React:
// src/support/SupportChat.tsx
import { useEffect, useState, useSyncExternalStore } from "react";
import { GatewayError, type ChatItem, type ChatPart, type ChatResponse, type ChatState, type GatewayChat } from "palbase/web";
import { pb, SupportGateway } from "../../palbase/shop-web";
const joinSupport = (chatId: string) => pb.backend.join(SupportGateway, { chatId });
type Support = Awaited<ReturnType<typeof joinSupport>>;
function useChat(chat: GatewayChat): ChatState {
return useSyncExternalStore((changed) => chat.subscribe(changed), () => chat.state);
}
export function SupportChat({ chatId }: { chatId: string }) {
const [support, setSupport] = useState<Support | null>(null);
const [refused, setRefused] = useState<string | null>(null);
useEffect(() => {
let gone = false;
let held: Support | undefined;
joinSupport(chatId).then(
(h) => (gone ? h.leave() : setSupport((held = h))),
(e: unknown) => setRefused(e instanceof GatewayError ? e.reason : "unavailable"),
);
return () => {
gone = true;
held?.leave();
};
}, [chatId]);
if (refused) return <p>The chat did not open: {refused}</p>;
return support ? <Conversation support={support} /> : <p>Opening…</p>;
}
function Conversation({ support }: { support: Support }) {
const state = useChat(support.chat);
const [draft, setDraft] = useState("");
const [problem, setProblem] = useState<string | null>(null);
const act = (work: Promise<unknown>) => work.catch((e: unknown) => setProblem(e instanceof GatewayError ? e.reason : "unavailable"));
return (
<section>
{support.chat.hasOlder && <button onClick={() => void support.chat.loadOlder()}>Earlier messages</button>}
{state.goal && <p className="goal">Goal: {state.goal.objective} ({state.goal.status})</p>}
{state.items.map((item, i) => <Item key={keyOf(item, i)} item={item} />)}
{state.waiting.approvals.map((call) => {
const approvalId = call.approval_id;
return approvalId ? (
<p key={call.call_id}>
The assistant asks to run {call.name}.
<button onClick={() => act(support.send("decide", { approvalId, approve: true }))}>Allow</button>
<button onClick={() => act(support.send("decide", { approvalId, approve: false, reason: "Not now." }))}>Deny</button>
</p>
) : null;
})}
{problem && <p role="alert">{problem}</p>}
<form
onSubmit={(e) => {
e.preventDefault();
setProblem(null);
void act(support.send("say", { text: draft }).then(() => setDraft("")));
}}
>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button type="submit">Send</button>
<button type="button" onClick={() => act(support.send("stop", {}))}>Stop</button>
</form>
</section>
);
}
function keyOf(item: ChatItem, i: number): string {
switch (item.type) {
case "message":
return `m:${item.message.clientId}`;
case "response":
return `r:${item.response.id}`;
case "goal":
return `g:${item.goal.seq}:${i}`;
case "voice":
return `v:${item.voice.voice_id}:${item.voice.status}`;
}
}
function Item({ item }: { item: ChatItem }) {
switch (item.type) {
case "message": {
const m = item.message;
return (
<p className={m.mine ? "mine" : "theirs"}>
<b>{m.authorName || "Support"}</b> {m.kind === "voice" ? "(said) " : ""}
{m.text}
{m.attachments.map((a) => <small key={a.position}> [{a.name}]</small>)}
{m.status === "pending" ? " …" : m.status === "failed" ? ` (not delivered: ${m.error ?? "failed"})` : ""}
</p>
);
}
case "response":
return <Answer response={item.response} />;
case "goal":
return <small className="goal">Goal: {item.goal.kind}{item.goal.goal ? `, ${item.goal.goal.status}` : ""}</small>;
case "voice":
return <small>{item.voice.status === "started" ? "A voice call began" : `The voice call ended after ${item.voice.seconds ?? 0} s`}</small>;
}
}
function Part({ part }: { part: ChatPart }) {
switch (part.type) {
case "text":
return <p>{part.text}</p>;
case "thinking":
return part.thinking.unavailable ? (
<small>No reasoning shown: {part.thinking.reason ?? "the provider gave none"}</small>
) : (
<details>
<summary>Thinking</summary>
{part.thinking.text}
</details>
);
case "tool":
return <small>{part.tool.name}: {part.tool.status}</small>;
case "agent":
return <small>{part.agent.name}: {part.agent.status === "denied" ? `refused (${part.agent.reason ?? ""})` : "working"}</small>;
}
}
function Answer({ response }: { response: ChatResponse }) {
return (
<div className="answer">
{response.parts.map((part, i) => <Part key={i} part={part} />)}
{response.end === "budget_exceeded" && <small>This answer reached its budget.</small>}
{response.end === "failed" && <small>This answer failed.</small>}
</div>
);
}
A client tool's call waits in state.waiting.outputs until the app answers it, once:
// src/support/pick-city.ts
import type { ChatState } from "palbase/web";
import { pb, SupportGateway } from "../../palbase/shop-web";
export async function answerCities(chatId: string, pick: (reason: string) => Promise<string>): Promise<() => void> {
const support = await pb.backend.join(SupportGateway, { chatId });
const answering = new Set<string>();
const stop = support.chat.subscribe((state: ChatState) => {
for (const call of state.waiting.outputs) {
if (call.name !== "pick_city" || answering.has(call.call_id)) continue;
answering.add(call.call_id);
const { reason } = call.input as { reason: string };
void pick(reason).then((city) => support.send("answer", { callId: call.call_id, output: city }));
}
});
return () => {
stop();
support.leave();
};
}