usePOS, the snapshot builder, the protocol
schemas and four backend mappers. The reservations plugin is the reference
implementation: a booking platform pushes bookings to the hub, and tables on the
floor plan render as reserved.
Plugins are in-repo TypeScript modules compiled into the bundle. There is no
dynamic loading — the app is a static export inside a Tauri WebView with a strict
CSP, and evaluating remote code there would need a sandbox, a signing story and a
capability API for very little gain when we control every plugin anyway.
What a plugin can and cannot do
A plugin can:- receive events pushed by an integration over the hub’s plugin channel;
- read
pluginDatathat an integration stamped onto an order; - decorate tables on the floor plan (border, tint, badge, accessible label);
- mount components into three named slots.
The inert rule
This is what makes the hub’sHUB_ENABLED_PLUGINS allowlist the only on/off
switch — no per-terminal config flag is needed, because a plugin that receives
nothing is invisible. It is pinned by
src/plugins/__tests__/PluginRuntime.integration.test.tsx. If a slot ever
renders chrome unconditionally, that claim is false and the switch story breaks.
Anatomy
Writing one
1. Declare what you accept
Validation lives in the plugin, not the hub. The hub checks the envelope and the payload’s shape, size and depth — never its meaning. That is deliberate: if the hub knew each plugin’s schema, shipping a plugin would mean upgrading every hub in the field first.src/plugins/deliveries/schema.ts
2. Define the plugin
src/plugins/deliveries/index.tsx
PosPlugin<Courier> assignable to the registry’s PosPlugin<unknown>[]. Written
as arrow-typed properties, every registry entry would need a cast.
3. Register it
src/plugins/registry.ts
HUB_ENABLED_PLUGINS allowlist, so a plugin on a hub that does not know it
simply never receives anything.
4. Enable it on the hub
404 from the whole /external/v1/plugins surface —
before authentication, so an integration cannot probe which plugins a
restaurant runs.
Extension points
Table decorations
decorateTables returns a map keyed by the numeric Odoo restaurant.table
record id.
A decoration declares a tone — neutral, info or warning — not a colour.
table-shape.tsx owns what each looks like, so decorations stay consistent with
the rest of the canvas and with dark mode. Decorations render only on available
tables: a table with a live check keeps showing that check.
Slots
PluginErrorBoundary, so a throwing plugin
loses its own slot rather than taking the floor plan down mid-service.
Time-derived state
Do not start a timer.PluginContext.now is refreshed on a shared 30-second
tick, so a booking crossing into its window re-renders on its own. Keep the logic
a pure function of (events, now) — reservations/holdWindow.ts is the model,
and it is testable by passing a number rather than mocking clocks.
Order data
An integration can stamp namespaced data onto an order:orderHeader slot via order.pluginData?.<yourId>. It is
frontend and hub only — the hub strips it before anything is forwarded to
Odoo, so it never reaches the accounting record. Anything that must survive the
shift belongs in a real order field.
Testing
Reference
- Integrators: the wire contract lives in
docs/external-api.mdx§7 (the plugin channel) and §8 (the reservations payload). - Conventions: the root
CLAUDE.md“Frontend Plugins” section covers the replay caches, thestripPluginDatasites and the read-only-channel rule.