Skip to content

State & sync

A plugin’s shared state is a typed schema mapped onto a Yjs structure, so concurrent edits from many devices merge automatically.

Build state shapes with schema/t:

import { schema, t, type Infer } from "@liebstoeckel/plugin-sdk";
const pollSchema = schema({
question: t.string,
options: t.array(t.string),
votes: t.record(t.string), // participantId → chosen option
closed: t.boolean,
});
type PollState = Infer<typeof pollSchema>;
BuilderTypeYjs mapping
t.string t.number t.booleanprimitivesplain values
t.array(item)T[]Y.Array
t.record(value)Record<string, V>Y.Map (concurrent-friendly)
t.object({…}) / schema({…})nested objectY.Map

The schema gives runtime validation, a default value, and static types via Infer.

ClientProps.state is a typed accessor over the shared doc:

state.snapshot(); // current state as plain JS (defaults filled in)
state.set("closed", true); // replace a whole top-level field
state.recordSet("votes", pid, "Yes"); // set one entry of a record field
state.recordDelete("votes", pid); // remove one entry
state.ensureDefaults({ question, options }); // seed once, only if empty
const off = state.subscribe((snap) => …); // observe deep changes

For example, the Q&A plugin needs per-question, per-participant votes. Rather than nesting, it keys a flat record by `${questionId}|${participantId}`:

const qaSchema = schema({
questions: t.record(t.object({ text: t.string, author: t.string, ts: t.number })),
votes: t.record(t.boolean), // key: `${qid}|${pid}`
answered: t.record(t.boolean), // key: qid
dismissed: t.record(t.boolean), // key: qid
});

Then derive views with pure functions (rankedQuestions, voteCount, …) that you can unit-test without a browser.

The presenter typically seeds initial content from the placement’s props, once:

useEffect(() => {
if (p.role === "presenter" && p.snapshot.options.length === 0) {
p.state.ensureDefaults({ question, options });
}
}, [/* … */]);

ensureDefaults is a no-op once the state is non-empty, so it’s safe to run on every presenter render.

For transient effects (e.g. reactions floating up), store entries in a record keyed by crypto.randomUUID() and prune old ones on a timer with recordDelete, plus a per-participant rate limit. This converges across clients and keeps the doc small without needing a separate awareness channel.

Comments

liebstoeckel

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

© 2026 Leon Kaucher