Developer quickstart
Scaffold, edit, check, and pack a PuntoSuite desktop plugin, then sideload it locally or publish it through the developer portal.
PuntoSuite plugins extend the desktop POS through a typed, mediated BodegaAPI (Shopify-style):
no DOM, no Node built-ins unless you declare a capability. This page is the fastest path from an
empty folder to a working plugin. For concepts see Overview; for the full
surface see API reference.
What you need
- Node.js 20+ (PuntoSuite core runs on Node 24 LTS; the template supports 20+).
- pnpm via Corepack:
corepack enable
corepack prepare pnpm@10.26.0 --activate
You do not need to clone the monorepo. External authors copy a standalone starter folder. (Plugins
live under the internal @bodegasuite/* namespace; you import host types from @bodegasuite/plugin-sdk
or the offline snapshot the template ships.)
1. Scaffold
Two equivalent ways to get the starter:
| You have… | Run |
|---|---|
| The monorepo checked out | pnpm plugin:create com.acme.my-plugin |
| Just the template folder | Copy tools/harness/plugin-templates/theme-toggle/ out of the repo |
pnpm plugin:create <reverse.dns.id> copies the template (minus build artifacts), rewrites
manifest.json (id, name, version: 0.1.0, description, author) and the package name, then prints
next steps. Useful flags: --name "Display Name" and --dest <dir> (default: a folder named after the
last id segment in the current directory).
pnpm plugin:create com.acme.my-plugin
cd my-plugin
pnpm install
The id must be reverse-DNS (e.g. com.acme.my-plugin). The org.bodegasuite.* namespace is reserved
for official plugins — the scaffolder rejects it. See Manifest for all rules.
Folder layout
my-plugin/
├── manifest.json
├── package.json
├── src/
│ ├── index.ts # your plugin entry
│ └── types/bodega-api.d.ts # offline snapshot of the host API types
└── dist/index.js # build output (created by `pnpm run build`)
src/types/bodega-api.d.tsis an offline snapshot of the host API so you get types without a network install. Once@bodegasuite/plugin-sdkis published to npm you can import the types from there instead.
2. Edit manifest.json
The scaffolder fills these in; adjust as needed. Minimal manifest:
{
"id": "com.acme.my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"entry": "dist/index.js",
"bodegasuite": { "version": ">=0.1.0", "apiVersion": "v1" },
"permissions": []
}
Add only what you use:
permissions— gate BodegaAPI surfaces. Today:ipc:products:read,ipc:sales:read(fordb.products.*/db.sales.*). Unknown tokens fail validation.capabilities— gate Node built-ins (net,fs,child_process,worker); deny-all by default. These are different from permissions and do not gate the BodegaAPI.net.allow— host allowlist used with thenetcapability (egress is SSRF-guarded).
Full field reference: Manifest.
3. Edit src/index.ts
Export activate (and optionally deactivate). The template ships this skeleton — activate runs on
load and on every reload; track every disposer and undo it in deactivate:
import type { BodegaAPI } from './types/bodega-api.js'
let disposers: Array<() => void> = []
export async function activate(api: BodegaAPI): Promise<void> {
// React to a core event. Action hooks fired today: app:ready, sale:afterComplete,
// sale:afterRefund, inventory:afterAdjust, inventory:lowStock, cashClosure:afterSubmit.
disposers.push(
api.hooks.addAction('sale:afterComplete', async () => {
// your integration logic
})
)
// Optional: add a topbar button and react to clicks (no DOM access).
// disposers.push(
// api.ui.registerPanel({ id: 'my-action', title: 'My action', location: 'navbar', icon: 'star' })
// )
// disposers.push(
// api.ui.onNavItemClick((itemId) => {
// if (itemId !== 'my-action') return
// api.ui.notify('Hello from my plugin!', { type: 'success' })
// })
// )
// Optional: persist JSON scoped to your plugin (survives restarts).
// await api.config.set('lastRunAt', Date.now())
}
export async function deactivate(): Promise<void> {
for (const dispose of disposers.reverse()) dispose()
disposers = []
}
What api gives you, at a glance:
| Area | Use it for |
|---|---|
hooks | React to the core events listed above. addAction(name, cb, priority?) → disposer. |
ui | registerPanel (only location: 'navbar' renders today), onNavItemClick, notify, setTheme/getActiveTheme, registerTheme (selects a shipped palette). |
config | get/set per-plugin JSON; survives restarts. |
db | Read-only products/sales (need ipc:products:read / ipc:sales:read); auto-scoped to the active business. |
i18n | getLocale() to localize your own strings. |
Filters (filters.addFilter) are now live: the first core filter, sale:receiptTitle, is applied
when an operator exports a sale ticket PDF. Your callback receives the localized default heading and may
return a replacement; the host re-validates the returned string (must be a non-empty string, stripped
to a single line and clamped to 120 chars) before drawing it — otherwise the default heading is used.
Other filter names are not wired yet. Full details and caps: API reference
and Hooks and filters. For a complete real plugin, study
packages/plugin-theme-toggle/src/index.ts (a navbar button + theme toggle persisted via config).
4. Check and pack
pnpm run check # quality gate: type-check + tests (coverage) + build + validate
pnpm run pack # check + create <id>-<version>.zip (won't build a ZIP unless the gate passes)
| Script | Does |
|---|---|
pnpm run build | Bundle src/index.ts → dist/index.js (ESM, via esbuild) |
pnpm run type-check | TypeScript check |
pnpm run test | Run tests with coverage (Vitest) |
pnpm run validate | Manifest schema, entry file, and ZIP checks |
pnpm run check | Quality gate: type-check → test (coverage) → build → validate |
pnpm run zip | Create <id>-<version>.zip (run build first) |
pnpm run pack | check → zip |
Tests + coverage are a gate, not optional.
check/packfail if coverage drops below the thresholds invitest.config.ts(80% statements/lines/functions, 70% branches) — the same bar the platform expects, so an untested plugin can't be packaged or published. Write tests for youractivate/deactivatelogic (the sample'ssrc/index.test.tsshows the pattern).
The ZIP root must contain manifest.json next to dist/:
com.acme.my-plugin-0.1.0.zip
├── manifest.json
└── dist/index.js
5a. Test locally before publishing
Easiest: in the desktop app open Marketplace → Installed, turn on Developer mode, click Load
plugin from file…, pick your <id>-<version>.zip, and accept the unverified-plugin warning. It
installs and then asks you to confirm it before it loads (trust tier C — a non-marketplace plugin
never auto-runs). Developer mode is off by default and only unlocks local installs.
You can also copy manifest.json + dist/ into %APPDATA%\BodegaSuite\plugins\<manifest.id>\ and
restart. Full steps + common errors: Local testing & troubleshooting.
5b. Publish to the marketplace
The developer portal lives on the web dashboard, not on this docs site
(puntosuite.com in production; http://localhost:3110 with pnpm dev:dashboard:only).
- Sign in on the dashboard (
/auth/signin; create an account at/auth/signup). - Open
/developer/portal. - Drop your ZIP in Publish a plugin — id and version are read from
manifest.json. A pass / warn / fail checklist validates structure, manifest, and security policy. - Optionally adjust display name and description (defaults come from
manifest.json). - Click Publish to marketplace. On pass the listing goes
publishedand shops install it from the desktop Marketplace — no reviewer step.
To update, upload a higher semver. Full detail: Publishing.
Next steps
- Lifecycle — load, reload, deactivate, auto-disable.
- API reference — the full
BodegaAPIsurface and caps. - Manifest — every field and validation rule.
- Security — worker isolation, trust tiers, blocklist.
- Troubleshooting — sideload and build issues.