Skip to main content
A plugin lets an external integration change what the POS shows without threading a new field through 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 pluginData that an integration stamped onto an order;
  • decorate tables on the floor plan (border, tint, badge, accessible label);
  • mount components into three named slots.
A plugin cannot block an action. There is no veto extension point. A plugin can say “this table is booked at 19:30”; it cannot stop a waiter seating it. Gating needs an override path, a permission and a manager flow, none of which is designed. A POS that refuses to take an order is a worse failure than one showing a stale badge.

The inert rule

A plugin with no events must render nothing. Every slot renderer returns null when it has no data, and decorateTables returns an empty map.
This is what makes the hub’s HUB_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
Require a UTC offset on any timestamp. A naive 2026-08-16T19:30:00 is ambiguous, and a booking an hour out is a booking at the wrong time.

2. Define the plugin

src/plugins/deliveries/index.tsx
Declare hooks with method syntax, as above. TypeScript checks method parameters bivariantly, which is what keeps a concretely-typed 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
Being listed here does not turn it on. The hub only accepts events for ids in its 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

An id outside the list gets 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.
FloorTable.id is a string on the frontend. The canvases look decorations up with parseInt(table.id, 10). Keying the map any other way type-checks and silently never matches.
A decoration declares a toneneutral, 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

Each contribution is wrapped in a 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:
Read it in an 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

Assert on something visible, not only the accessible label. An E2E that checked only aria-label once passed while the entire visual treatment was missing, because a custom table colour set borderColor inline and beat the tone class.

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, the stripPluginData sites and the read-only-channel rule.