Skip to content

Server plugins

Most plugins are pure-CRDT: client UI + shared state is enough. Add a server part when you need something a browser can’t (or shouldn’t) do:

  • call an external API with a secret (the client runs in an opaque sandbox with no secrets),
  • enforce logic centrally (e.g. authoritative tallying, timed transitions),
  • bridge to a database or another service.

The server part runs once, on the machine that started liebstoeckel live. It never runs in the audience’s browsers, and never on the relay. With a relay, your local process connects as a privileged “runner” peer and applies the server plugin’s effects to the shared doc.

Declare the entry in package.json and export a server(ctx) function:

package.json
{
"liebstoeckel": { "client": "./src/client.tsx", "server": "./src/server.ts" }
}
src/server.ts
import type { PluginServerCtx } from "@liebstoeckel/plugin-sdk";
import { pluginState } from "@liebstoeckel/plugin-sdk";
import { mySchema, type MyState } from "./logic";
export default function server(ctx: PluginServerCtx<MyState>) {
const state = pluginState(ctx.doc, "myplugin", mySchema);
// react to shared-state changes, run host-only work, write results back
const stop = state.subscribe((snap) => {
// …e.g. when a round closes, compute + write the official result
});
return () => stop(); // optional teardown
}

The runtime ctx gives you the shared doc, the session id, and the instance string. Build a typed state accessor yourself by calling pluginState(ctx.doc), as the example above does.

At build time the server entry is bundled (target bun, self-contained) and base64-encoded into the deck’s plugin manifest. A live server decodes it to a temp module and runs it with an injected ctx. The browser never touches it.

Comments

liebstoeckel

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

© 2026 Leon Kaucher