Building a plugin
We’ll build a small “applause meter” plugin: viewers tap to clap, and a bar shows the total. It’s pure-CRDT (no server part), modeled on @liebstoeckel/plugin-poll.
1. The package
Section titled “1. The package”Directorypackages/plugin-applause/
- package.json
Directorysrc/
- logic.ts schema + pure derivations (unit-tested)
- client.tsx the React surfaces
- index.ts exports
{ "name": "@liebstoeckel/plugin-applause", "version": "0.0.0", "private": true, "type": "module", "keywords": ["liebstoeckel-plugin"], "liebstoeckel": { "client": "./src/client.tsx" }, "exports": { ".": "./src/index.ts", "./logic": "./src/logic.ts" }, "dependencies": { "@liebstoeckel/plugin-sdk": "workspace:*", "@liebstoeckel/plugin-ui": "workspace:*" }}2. State schema + pure logic
Section titled “2. State schema + pure logic”Keep all derivations as pure functions so they’re trivially testable. Model shared state to fit the SDK’s one-level record API (see State & sync).
import { schema, t, type Infer } from "@liebstoeckel/plugin-sdk";
// one entry per participant → their clap countexport const applauseSchema = schema({ claps: t.record(t.number),});export type ApplauseState = Infer<typeof applauseSchema>;
export const total = (s: ApplauseState): number => Object.values(s.claps).reduce((n, c) => n + c, 0);
export const mine = (s: ApplauseState, pid: string): number => s.claps[pid] ?? 0;3. The client surfaces
Section titled “3. The client surfaces”A plugin’s client gets ClientProps<T>: { doc, state, snapshot, role, live, participantId, theme, ui, props }. Style against var(--brand-*) (or reuse the @liebstoeckel/plugin-ui primitives) so it’s automatically on-brand.
import { definePlugin, type ClientProps } from "@liebstoeckel/plugin-sdk";import { Card, Bar, Button, Eyebrow } from "@liebstoeckel/plugin-ui";import { applauseSchema, total, mine, type ApplauseState } from "./logic";
function ApplauseSlide(p: ClientProps<ApplauseState>) { const { snapshot, state, participantId } = p; const clap = () => state.recordSet("claps", participantId, mine(snapshot, participantId) + 1); const sum = total(snapshot); return ( <Card style={{ width: "100%", maxWidth: 420 }}> <Eyebrow>Applause · {sum}</Eyebrow> <Bar pct={Math.min(100, sum * 2)} label="👏" value={sum} color={p.theme.viz[0]} /> <Button onClick={clap}>Clap 👏</Button> </Card> );}
function ApplauseFallback({ snapshot }: { snapshot: ApplauseState }) { return ( <Card style={{ width: "100%", maxWidth: 420 }}> <Eyebrow>Applause · offline preview</Eyebrow> <Button disabled>Clap 👏</Button> </Card> );}
export default definePlugin<ApplauseState>({ id: "applause", state: applauseSchema, client: { Slide: ApplauseSlide, fallback: ApplauseFallback, interactive: true, // shows the tap-to-interact breakout on touch (default) },});export { default } from "./client";export * from "./logic";4. Use it
Section titled “4. Use it”import applause from "@liebstoeckel/plugin-applause";<Present plugins={[applause]} slides={[…]} /><Plugin id="applause" />Multiple independent instances
Section titled “Multiple independent instances”id selects the plugin type; state lives at plugin:<id>. Place the same id
twice and both share that one slice. That is handy to mirror a poll across slides, but it
means a second <Plugin id="poll"> with a different question is ignored. For
independent instances, give each an instance (a stable, author-chosen id, never
random, since it’s part of the shared address). State then lives at
plugin:<id>:<instance>:
<Plugin id="poll" instance="lunch" props={{ question: "Lunch?", options: […] }} /><Plugin id="poll" instance="dinner" props={{ question: "Dinner?", options: […] }} />Each gets its own state and its own presenter-console tab. Give a presenter.title
(below) or a <Plugin title="…"> so the tabs read “Poll · Lunch?” rather than the
bare instance id. Omitting instance keeps the original single-slice behaviour.
Layout: the slide canvas is fixed, bound your own height
Section titled “Layout: the slide canvas is fixed, bound your own height”A slide is rendered on a fixed 1280×720 canvas that’s scaled to fit and clipped (overflow: hidden), so the audience view, presenter view, and thumbnails stay pixel-identical. The canvas never scrolls. That’s fine for authored slides (you control how much is on them), but a plugin whose content grows with the audience (a Q&A queue, a chat, a long results list) will eventually overflow and get cut off, with no way to scroll to the rest.
So: pin your header/input and scroll the growing part yourself. @liebstoeckel/plugin-ui ships a ScrollArea for exactly this: a bounded region that scrolls internally and won’t chain its scroll to the deck:
import { Card, Eyebrow, ScrollArea, Stack } from "@liebstoeckel/plugin-ui";
function QueueSlide(p: ClientProps<State>) { return ( <Card style={{ width: "100%", maxWidth: 520 }}> <Eyebrow>Questions</Eyebrow> {/* …input box stays pinned here… */} <ScrollArea> {/* default cap: min(360px, 42vh) */} <Stack> {items.map((q) => <Row key={q.id} {...q} />)} </Stack> </ScrollArea> </Card> );}The client contract
Section titled “The client contract”| Surface | When it renders |
|---|---|
Slide | a live server is connected: the interactive in-deck UI |
presenter? | optional presenter console, a tab in the presenter view (see below) |
fallback? | standalone .html and in build-time thumbnails; show real content from snapshot/props |
surfaces? | named override points an author can replace per placement |
interactive? | false to suppress the mobile breakout for display-only plugins (default true) |
global? | deck-wide surfaces: an Overlay, and a chrome control declared as icon + label that toggles a Panel (see the overview). pinned: true keeps the control in the mobile rail; otherwise it folds into the ⋮ menu on touch so the rail can’t overflow. panelMode: "sheet" opens the panel full-viewport on touch (for a text input the keyboard would otherwise bury) |
The presenter console
Section titled “The presenter console”A plugin can claim a tab in the presenter view: a presenter-private surface for a live readout (vote tallies, queue depth) and privileged moderation the audience shouldn’t see being done (close a poll, mark a question answered, dismiss spam). Notes are the default tab; your console sits beside them on both desktop and mobile.
client: { Slide: PollSlide, presenter: { label: "Poll", icon: "📊", // optional attention pill on the tab; return undefined/0 to hide it badge: (s) => totalVotes(s) || undefined, // optional per-instance label, to tell sibling instances apart in the tabs title: (s) => s.question || undefined, // same ClientProps as Slide (doc, state, snapshot, role, live, …) Console: PollConsole, },},Driving the audience needs no special API. State is deck-global and writes are
role-gated, so an audience-affecting action is just a write to your own state. The
Slide reads it and re-renders on the audience screen:
function PollConsole({ snapshot, state, role }: ClientProps<PollState>) { return ( <Card> <Results snapshot={snapshot} /* … */ /> {role === "presenter" && ( <Button onClick={() => state.set("closed", !snapshot.closed)}> {snapshot.closed ? "Reopen voting" : "Close voting"} </Button> )} </Card> );}Code-first presentations your agent can author. One file out, no server.
Comments