Skip to content

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.

  • Directorypackages/plugin-applause/
    • package.json
    • Directorysrc/
      • logic.ts schema + pure derivations (unit-tested)
      • client.tsx the React surfaces
      • index.ts exports
package.json
{
"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:*"
}
}

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).

src/logic.ts
import { schema, t, type Infer } from "@liebstoeckel/plugin-sdk";
// one entry per participant → their clap count
export 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;

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.

src/client.tsx
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)
},
});
src/index.ts
export { default } from "./client";
export * from "./logic";
main.tsx
import applause from "@liebstoeckel/plugin-applause";
<Present plugins={[applause]} slides={[…]} />
a slide
<Plugin id="applause" />

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:

src/client.tsx
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>
);
}
SurfaceWhen it renders
Slidea 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)

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

Comments

liebstoeckel

Code-first presentations your agent can author. One file out, no server.

© 2026 Leon Kaucher