Realtime
A plugin reaches the browser by emitting an event. It defines the event once in its contracts module, declares it on the server, publishes a payload, and the browsers that subscribed to that (name, key) receive a frame. In the app tier, defineQuery turns the frame into a refetch and defineRowStore turns it into an ordered stream of ops. bb's own entity changes arrive the same way. This page builds a plugin with a long job whose progress streams to the browser, a dashboard of its rows, and a badge that follows a thread's status.
Use this when
- Show a live dashboard of my rows. It updates when the CLI, an agent, or another browser changes them.
- Update a badge when a thread changes status. bb's own entity events reach your component the same way.
- Stream a long job's progress. Each job's frames reach the browser under the job's own key; nothing refetches a list.
- Listen on the server without polling. Your own events and bb's arrive on one bus.
What you build
A plugin jobs with one service, jobs/jobs, and two wire events: jobs/progress, keyed by job id, and jobs/changed, unkeyed. The server publishes both; the browser runs one query per job invalidated by its own key, one list query, one mutation, a thread status badge fed by kernel/thread.changed, and a timeline row store fed by kernel/store.rows.
Steps
1. Define the events and the service in src/contracts.ts
wire.to: ["client"] makes an event cross to browsers; wire.key names the payload field the subscription key is built from (job:<jobId>); key: null publishes every frame under the key null. Only emit mode crosses the wire.
import { defineEvent, defineService, method } from "@get-bb/plugin-sdk/contracts";
import type { EventDecl } from "@get-bb/plugin-sdk/contracts";
import { z } from "zod";
export const jobStatusSchema = z.enum(["running", "done", "failed", "cancelled"]);
export const jobSchema = z.strictObject({
id: z.string(), threadId: z.string().nullable(), label: z.string(), status: jobStatusSchema,
percent: z.number().int().min(0).max(100), updatedAt: z.number().int(),
});
export type Job = z.infer<typeof jobSchema>;
export const progressSchema = z.strictObject({ jobId: z.string(), percent: z.number().int().min(0).max(100), status: jobStatusSchema });
export type Progress = z.infer<typeof progressSchema>;
declare module "@get-bb/plugin-sdk/contracts" {
interface Events {
"jobs/progress": EventDecl<Progress, "emit">;
"jobs/changed": EventDecl<{ jobId: string }, "emit">;
}
}
export const progress = defineEvent({ name: "jobs/progress", mode: "emit", payload: progressSchema, scope: "server", wire: { to: ["client"], key: { path: "jobId", prefix: "job" } } });
export const changed = defineEvent({ name: "jobs/changed", mode: "emit", payload: z.strictObject({ jobId: z.string() }), scope: "server", wire: { to: ["client"], key: null } });
const jobId = z.string().min(1).describe("Job id");
export const jobs = defineService({
id: "jobs/jobs",
version: "1.0.0",
summary: "Long-running jobs with live progress",
cli: { group: ["jobs"] },
methods: {
list: method({ summary: "Every job", input: z.strictObject({}), output: z.array(jobSchema), renderText: (rows) => rows.map((j) => `${j.id} ${j.status} ${j.percent}% ${j.label}`).join("\n") || "(none)" }),
show: method({ summary: "One job", input: z.strictObject({ jobId }), output: jobSchema, cli: { fields: { jobId: { positional: 0 } } }, renderText: (j) => `${j.status} ${j.percent}%` }),
start: method({
kind: "mutation", summary: "Start a job",
input: z.strictObject({ label: z.string().min(1), threadId: z.string().nullable().default(null), steps: z.number().int().min(1).max(1000).default(20) }),
output: jobSchema, cli: { fields: { label: { positional: 0 }, threadId: { flag: "thread", ambient: "threadId" } } }, renderText: (j) => `${j.id} started`,
}),
cancel: method({ kind: "mutation", summary: "Cancel a job", input: z.strictObject({ jobId }), output: jobSchema, cli: { fields: { jobId: { positional: 0 } } }, renderText: (j) => `${j.id} ${j.status}` }),
},
});2. Declare and publish on the server in src/server.ts
realtime.declare is events.define checked for the wire shape; realtime.publish is events.emit. Declare before provide: a publish of an undeclared name throws unknown_event.
import { definePlugin, withDefaults, sleep, KernelError } from "@get-bb/plugin-sdk/server";
import { changed, jobs, progress, type Job } from "./contracts.js";
export default definePlugin({
async activate(ctx) {
const table = new Map<string, Job>(); // in memory for brevity; Guide 5 shows the SQLite version
await ctx.realtime.declare(progress);
await ctx.realtime.declare(changed);
const update = async (job: Job): Promise<void> => {
table.set(job.id, job);
await ctx.realtime.publish("jobs/progress", { jobId: job.id, percent: job.percent, status: job.status });
await ctx.realtime.publish("jobs/changed", { jobId: job.id });
};
const run = async (id: string, steps: number): Promise<void> => {
for (let i = 1; i <= steps; i += 1) {
await sleep(250, ctx.signal);
const job = table.get(id);
if (ctx.signal.aborted || job === undefined || job.status !== "running") return;
await update({ ...job, percent: Math.round((i / steps) * 100), status: i === steps ? "done" : "running", updatedAt: Date.now() });
}
};
const find = (id: string): Job => {
const job = table.get(id);
if (job === undefined) throw new KernelError({ code: "not_found", message: `no job ${id}`, data: { jobId: id } });
return job;
};
// the server listens to its own wire event on the same bus, with the same payload (Guide 4 listens to kernel/thread.changed)
await ctx.events.on("jobs/progress", (payload, meta) => {
if (payload.status === "done") ctx.log.info("job done", { jobId: payload.jobId, actor: meta.actor.kind });
});
await ctx.provide(jobs, withDefaults(jobs, {
list: async () => [...table.values()],
show: async ({ jobId }) => find(jobId),
start: async ({ label, threadId, steps }) => {
const job: Job = { id: `job_${Date.now().toString(36)}`, threadId, label, status: "running", percent: 0, updatedAt: Date.now() };
await update(job);
void run(job.id, steps).catch((error: unknown) => ctx.log.warn("job crashed", { jobId: job.id, error: String(error) }));
return job;
},
cancel: async ({ jobId }) => {
const job = find(jobId);
if (job.status !== "running") return job;
const next: Job = { ...job, status: "cancelled", updatedAt: Date.now() };
await update(next);
return next;
},
}));
},
});3. Subscribe in the browser in src/app.tsx
defineQuery, defineMutation, and defineRowStore are the three app-tier primitives. A query names its wire targets in invalidateOn; the kernel subscribes while a component uses the entry and refetches when a frame matches. The entity sugar maps to kernel/<entity>.changed with the key <entity>:<id>.
import { defineMutation, definePluginApp, defineQuery, defineRowStore, KernelError } from "@get-bb/plugin-sdk/app";
import type { SeqOp, SlotComponentProps } from "@get-bb/plugin-sdk/app";
import { Button } from "@bb/ui";
import { z } from "zod";
import { jobSchema, jobs, type Job } from "./contracts.js";
export const jobsQuery = defineQuery<Record<string, never>, Job[]>({
key: "list", invalidateOn: [{ name: "jobs/changed", key: null }],
fetch: async (input, client) => z.array(jobSchema).parse(await client.query(jobs.id, "list", input)),
});
export const jobQuery = defineQuery<{ jobId: string }, Job>({
key: "show", invalidateOn: [{ name: "jobs/progress", key: (input) => `job:${input.jobId}` }], debounceMs: { min: 50, max: 200 },
fetch: async (input, client) => jobSchema.parse(await client.query(jobs.id, "show", input)),
});
const threadStatus = z.object({ status: z.string() });
export const threadStatusQuery = defineQuery<{ threadId: string }, string>({
key: "thread-status", invalidateOn: [{ entity: "thread", id: (input) => input.threadId }],
fetch: async (input, client) => threadStatus.parse(await client.query("threads/threads", "show", input)).status,
});
export const cancelJob = defineMutation<{ jobId: string }, Job>({ ref: jobs.id, method: "cancel" });
function JobRow({ jobId }: { jobId: string }) {
const job = jobQuery.use({ jobId });
const cancel = cancelJob.use();
if (job.data === undefined) return <span>{job.error instanceof KernelError ? job.error.code : "…"}</span>;
return (
<span>
{job.data.label} {job.data.percent}% {job.data.status}
{job.data.status === "running" ? <Button size="sm" variant="outline" disabled={cancel.pending} onClick={() => void cancel.mutate({ jobId })}>Cancel</Button> : null}
</span>
);
}
export function JobDashboard(_props: SlotComponentProps<"sidebar.footer">) {
const list = jobsQuery.use({});
return <ul>{(list.data ?? []).map((job) => <li key={job.id}><JobRow jobId={job.id} /></li>)}</ul>;
}
export function StatusBadge({ threadId }: { threadId: string }) {
const status = threadStatusQuery.use({ threadId });
return <span data-status={status.data ?? "unknown"}>{status.data ?? "…"}</span>;
}
// a row store: one page, one wire follow on kernel/store.rows keyed thread:<id>, ops gated by seq
const rowSchema = z.object({ id: z.string(), seqEnd: z.number().int(), kind: z.string(), status: z.string() });
type Row = z.infer<typeof rowSchema>;
type Op = { kind: "upsert"; row: Row } | { kind: "delete"; id: string } | { kind: "replace"; rows: Row[] };
const storeOp = z.union([z.object({ op: z.literal("upsert"), row: rowSchema }), z.object({ op: z.literal("delete"), id: z.string() })]);
const rowsPayload = z.object({ threadId: z.string(), ops: z.array(z.object({ seq: z.number().int(), op: z.unknown() })) });
const seqEndOf = (rows: Row[], floor: number): number => rows.reduce((max, r) => Math.max(max, r.seqEnd), floor);
export const timelineRows = defineRowStore<Row, Op>({
key: "timeline",
page: async ({ threadId, before, limit }, client) => {
const rows = z.array(rowSchema).parse(await client.query("threads/rows", "page", { threadId, before, limit }));
return { rows, seqEnd: seqEndOf(rows, 0) };
},
resume: async ({ threadId, after }, client) => {
const rows = z.array(rowSchema).parse(await client.query("threads/rows", "page", { threadId, before: null, limit: 60 })); // coarse catch-up: re-page and replace
const seqEnd = seqEndOf(rows, after);
return { ops: [{ seq: seqEnd, op: { kind: "replace", rows } }], seqEnd };
},
apply: (state, ops) => {
let rows = state.rows;
for (const op of ops) {
if (op.kind === "replace") rows = op.rows;
else if (op.kind === "delete") rows = rows.filter((r) => r.id !== op.id);
else rows = rows.some((r) => r.id === op.row.id) ? rows.map((r) => (r.id === op.row.id ? op.row : r)) : [...rows, op.row];
}
return { ...state, rows };
},
follow: {
name: "kernel/store.rows",
key: (threadId) => `thread:${threadId}`,
ops: (payload) => {
const parsed = rowsPayload.safeParse(payload);
if (!parsed.success) return null;
const out: SeqOp<Op>[] = [];
for (const { seq, op } of parsed.data.ops) {
const r = storeOp.safeParse(op);
if (!r.success) continue; // an "append" text delta: skipped here; the row's next upsert carries the text
if (r.data.op === "upsert") out.push({ seq, op: { kind: "upsert", row: r.data.row } });
else out.push({ seq, op: { kind: "delete", id: r.data.id } });
}
return out;
},
},
});
export const Timeline = ({ threadId }: { threadId: string }) => <ol>{timelineRows.use(threadId).rows.map((r) => <li key={r.id}>{r.kind} {r.status}</li>)}</ol>;
export default definePluginApp({
setup(app) {
app.slots.inject("sidebar.footer", (slots) => {
slots.register({ name: "sidebar.footer", kind: "list", scope: "root", order: 60 }, JobDashboard);
});
},
});sidebar.footer is declared by ui-sidebar, which this plugin does not requires, so inject is the sanctioned form: the thunk runs when the declaration exists, and a plain register into a name no live occupant declares fails the load (SlotAuthorityError; Reference §4.2, §5.2). The manifest names every tier and the slot: "server": "./src/server.ts", "app": "./src/app.tsx", "contracts": "./src/contracts.ts", "provides": { "jobs/jobs": { "version": "1.0.0" } }, and "contributes": { "slots": ["sidebar.footer"], "cli": { "commands": ["jobs"] } }; the build cross-checks contributes.slots against the names the app registers into or injects (D9).
4. Run it from two places and watch one browser
The dashboard subscribes once; every publish on the server reaches every open tab.
bb plugin dev .
bb jobs start "index the repo" --steps 40 --json # the dashboard row appears; its percent climbs every 250 ms
bb jobs cancel job_xyz # the row flips to cancelled in every open tabWhat happens at runtime
- Publish.
ctx.realtime.publish(name, payload)validates the payload (invalid_input), delivers to every server listener failure-isolated, waits for them to settle, then hands one frame{ name, key, payload, meta }to the hub.keyis"<prefix>:<payload[path]>"for a keyed event (job:job_xyz) andnullotherwise.metacarriesactor,time,eventId,process,hostId. - The hub. Each browser holds one realtime socket and sends
{ t: "subscribe", targets: [{ name, key }] }for what its queries and stores need. The hub queues matching frames per socket and flushes them as one{ t: "batch", frames }after 16 ms (coalesceMs), or at once when the queued bytes reach the frame limit. A socket that stops reading is closed as a slow consumer (1008). - Keys. A subscription
{ name, key: null }matches every frame of that name, keyed or not;{ name, key: "job:job_xyz" }matches only that job. An event declared withwire.key: nullis matched only bykey: null. Kernel entity events and their keys:kernel/thread.changedthread:<id>{ id, patch };kernel/thread-head.changedthread:<threadId>{ threadId, patch };kernel/project.changedproject:<id>;kernel/environment.changedenvironment:<id>;kernel/host.changedhost:<id>;kernel/interaction.changedinteraction:<id>;kernel/thread-annotation.changedthread:<threadId>;kernel/store.rowsthread:<id>{ threadId, ops: [{ seq, op }] };kernel/store.changedthread:<id>{ threadId, watermark };kernel/thread.purgednull{ ids }.kernel/preferences.changed,kernel/activation.changed,kernel/catalog.changed,kernel/service.*, andkernel/boot.settledare wired undernull.kernel/store.appendedandkernel/command.*stay on the server. - Queries. A
defineQueryentry is keyed<pluginId>/<key>plusJSON(input), so two components share one entry. On mount it refetches when dirty or older thanstaleMs(default 0). A matching frame marks it dirty and the refetch is debounced 50 ms with a 200 ms ceiling (debounceMsoverrides), deferred whiledocument.hidden. An entry nobody renders is evicted after 5 min. A failed background refetch lands inerrorand keeps the staledata. AdefineMutationisclient.mutate(ref, method, input), aPOSTto/api/v1/<service>/<method>;optimistic(input)runs first and returns the rollback that runs on error; an error toasts"<ref>.<method> failed"unlesssilent, then rethrows asKernelError. The mutation invalidates nothing; the server publishes and the query refetches. - Row stores.
defineRowStoreloads one first page per thread, follows one wire target (follow.key(threadId)), and applies ops in seq order. An op at or below the store'sseqEndis skipped; ops arriving while a page or catch-up is in flight are buffered, then gated (D18). Row frames apply at once, never debounced.loadOlder(before)pages back;peek(threadId)reads without subscribing. - Reconnect. The realtime client reconnects with backoff (500 ms doubling to 10 s), re-sends its subscription set, then
{ t: "resume", watermarks }per followed thread. The hub answers{ t: "resumed", stale }; each stale thread's store callsresume({ threadId, after }). Every mounted query refetches on reconnect. The separate scope socket behinduseServicehandles invalidates every handle, toasts once, reconnects, and re-injects. As built, D17 and H1 record that the product treats a lost browser socket as a page reload plus a toast: keep nothing you cannot refetch in component state alone.
Pitfalls
- Declare before you provide.
realtime.declareon a non-emitevent, or one whosewire.tolacks"client", throwsinvalid_contract;publishof an undeclared name throwsunknown_event. Only<pluginId>/…names are yours;kernel/isreserved_name; a duplicate is aconflictproblem (D1).wire.key.pathmust name a field of the payload: the key is data, never code, and there are no globs. To address one thread, key bythreadIdwith prefixthread, asthreads/queue.changeddoes. invalidateOn[].namemust be<owner>/<name>, checked at definition (invalid_contract). A keyed event is matched bykey: (input) => "job:" + input.jobIdor bykey: null; a string key is fixed for every input.client.queryreturns what the HTTP body held, typed by your statement, not a parse. Parse it with the contract's output schema as the examples do.useHandle()is a hook (D18): calljobsQuery.useHandle()during render and usehandle.invalidate()orhandle.setData(...)in event handlers.- Server-only semantics.
waterfallandparallelevents never cross the wire (wireon either throwsinvalid_contract); use them for in-process pipelines (events.waterfall(name, payload, base)re-validates eachnext(payload)).persistondefineEventis recorded in the definition and the catalog; nothing appends the event to a thread's log when youpublish, a stored event goes throughkernel/store.eventsAppend(Reference §3.11). A listener on scopeSsees a dispatch fromS, its descendants, or root; inbound wire frames re-emit at root; pass{ global: true }to see another plugin's in-process emits; same-priority listeners fire in composition order (Reference §3.5). kernel/store.rowsops are the store's{ op: "upsert", row } | { op: "append", id, path, text, seqEnd } | { op: "delete", id }; theappenddelta carries text for one row'spath. Handle it or wait for the nextupsert, but never drop an op silently without knowing why.
See also
- Reference §2.4 (
defineEvent,EventDefinitionInput, modes), §2.8 (KernelError,JsonValue). - Reference §3.5 (
events.*,realtime.*, kernel events and scope tagging), §3.11 (kernel/store), §4.8 (defineQuery,InvalidateOn,defineMutation,defineRowStore), §4.9 (useService, reconnect), §4.12 (reconcile onkernel/activation.changed). - Reference Appendix A: D12, D17, D18; Appendix B: H1. Guide 4 for the commands that cause these events; Guide 5 for
kernel/preferences.changed.