Plugin lifecycle
How activate(api) and deactivate() run, the disposer cleanup pattern, and persisting plus re-applying state across restarts and reloads.
A plugin is just an ESM module (src/index.ts bundled to dist/index.js) that exports two
functions. The host calls them at well-defined moments; everything else your plugin does happens
in between.
import type { BodegaAPI } from '@bodegasuite/plugin-sdk'
export async function activate(api: BodegaAPI): Promise<void> {
// wire up hooks, panels, themes, config — track every disposer
}
export async function deactivate(): Promise<void> {
// tear everything down (optional but strongly recommended)
}
activate(api) is required; deactivate() is optional but you should always provide it so
the host can fully unwind your plugin.
When each function runs
| Function | Runs when | Receives |
|---|---|---|
activate(api) | On load (boot / enable) and on every reload | The mediated BodegaAPI façade |
deactivate() | On disable and on uninstall | Nothing |
Because activate runs again on every reload (for example, when the operator toggles some other
plugin and the host re-activates the set), it must be idempotent and must re-apply any state your
plugin owns. Do not assume activate runs only once.
The api object is scoped to your plugin: api.pluginId matches your manifest.id, and the data
façade is auto-scoped to the active business (you never pass a businessId). See the
API reference for the full surface.
The disposer pattern
Every register/subscribe call returns a disposer — a zero-argument function that undoes the
registration. Track them as you create them and call them in reverse order inside deactivate():
let disposers: Array<() => void> = []
export async function activate(api: BodegaAPI): Promise<void> {
disposers.push(api.ui.registerPanel({ id: 'my-panel', title: 'My panel', location: 'navbar' }))
disposers.push(
api.hooks.addAction('sale:afterComplete', async (sale) => {
// react to a completed sale
})
)
}
export async function deactivate(): Promise<void> {
for (const dispose of disposers.reverse()) {
dispose()
}
disposers = []
}
Reverse order means later registrations (which may depend on earlier ones) are removed first. Calls
that return disposers include hooks.addAction, filters.addFilter, ui.registerPanel,
ui.registerTheme, and ui.onNavItemClick. Fire-and-forget calls like ui.notify, ui.setTheme,
and config.set do not return disposers — there is nothing to undo.
Cleanup safety net: the host also unregisters your hooks, panels, themes, and click handlers when it tears your plugin down. Your
deactivate()should still dispose everything (it is the explicit contract and keeps reloads clean), but a missing disposer will not strand a stale registration in the host.
Initializing on app:ready
If your plugin needs to run setup logic once the host is fully booted, subscribe to the app:ready
action hook rather than doing host-dependent work at the top of activate:
disposers.push(
api.hooks.addAction('app:ready', async () => {
api.ui.notify('Plugin ready', { type: 'success' })
})
)
app:ready is one of the action hooks fired by core today. See
Hooks and the API for the complete catalog. The first core filter,
sale:receiptTitle, is now invoked by core (when exporting a sale ticket PDF) and the host
re-validates whatever your callback returns; filters registered under other names do not fire yet.
Persisting and re-applying state
Use config to persist per-plugin state. It is stored as JSON under
userData/plugin-config/<pluginId>/settings.json and survives restarts:
await api.config.set('theme', 'dark') // persist
const saved = await api.config.get('theme') // read back (Promise<unknown | undefined>)
Because activate runs on every load and every reload, re-apply persisted state inside
activate so a reload never drops the operator's choices:
const saved = await api.config.get('theme')
if (saved === 'light' || saved === 'dark') {
await api.ui.setTheme(saved)
}
Worked example: theme-toggle
The official com.puntosuite.theme-toggle plugin
(packages/plugin-theme-toggle/src/index.ts) is the canonical lifecycle reference. It:
- Reads the host locale once at
activate(api.i18n.getLocale()) to localize its own label. - Registers a
'dark'theme slug and a'contrast'navbar button, pushing both disposers. - Subscribes to
ui.onNavItemClick; on its own item, flips light/dark viaui.setThemeand persists the new value withconfig.set. - Re-applies the saved theme on every
activate(not viaapp:ready) so a reload triggered by any other plugin never loses the chosen theme. - Disposes everything in reverse order in
deactivate(). It does not callsetTheme('light')on teardown — the host reverts to light automatically when this plugin unregisters its theme.
let disposers: Array<() => void> = []
export async function activate(api: BodegaAPI): Promise<void> {
const lang = api.i18n.getLocale().toLowerCase().startsWith('es') ? 'es' : 'en'
disposers.push(api.ui.registerTheme('dark', { label: 'Dark' }))
disposers.push(
api.ui.registerPanel({
id: 'theme-toggle',
title: lang === 'es' ? 'Cambiar tema' : 'Toggle theme',
location: 'navbar',
icon: 'contrast'
})
)
disposers.push(
api.ui.onNavItemClick(async (itemId) => {
if (itemId !== 'theme-toggle') return
const current = await api.ui.getActiveTheme()
const next = current === 'dark' ? 'light' : 'dark'
await api.ui.setTheme(next)
await api.config.set('theme', next)
})
)
// Re-apply persisted preference on EVERY activation (first launch and any reload).
const saved = await api.config.get('theme')
if (saved === 'light' || saved === 'dark') {
await api.ui.setTheme(saved)
}
}
export async function deactivate(): Promise<void> {
for (const dispose of disposers.reverse()) dispose()
disposers = []
}
Auto-disable on repeated failures
The host watches your hook and filter callbacks. If a callback throws ~5 consecutive times, the host tears the plugin down (auto-disable) to protect the app. Catch and handle expected errors inside your callbacks so a transient failure does not accumulate toward that threshold. When the plugin is later re-enabled (or dev-reloaded), the failure count starts fresh.
Checklist
-
activate(api)is idempotent and re-applies any persisted state it owns. - Every disposer is tracked and disposed in reverse order in
deactivate(). - Host-dependent setup runs on
app:ready, not at the top ofactivate. - Persistent choices use
config(they survive restarts). - Callbacks handle their own errors to avoid auto-disable.
Related
- Hooks and the API — the action hook catalog and the BodegaAPI surface.
- API reference — every method, cap, and return type.
- Manifest —
id,entry, permissions, and capabilities. - Security — worker isolation, trust tiers, and the auto-disable model.