Skip to main content

Writing a plugin

A plugin is a Module Federation remote plus a manifest, built against @wickermoney/plugin-sdk and @wickermoney/ui-kit (both Apache-2.0). The build setup, and the rules that fail at runtime rather than at build time (the remote must be an ES module, CSS must be injected with adoptPluginStyles, paths are relative to the API root), are in the Writing a plugin section of the app repo's DEVELOPMENT.md. This page covers what changed for plugin code from 0.3.0 to 0.6.0.

Stability tiers​

Added in 0.5.0.

Every @wickermoney/plugin-sdk entry point has a tier, listed in the package README and tagged @stable or @experimental in its module documentation.

ImportTier
@wickermoney/plugin-sdk (manifest, dashboard range)stable
@wickermoney/plugin-sdk/runtimestable
@wickermoney/plugin-sdk/moneystable
@wickermoney/plugin-sdk/serverstable
@wickermoney/plugin-sdk/datestable
@wickermoney/plugin-sdk/isolatedexperimental
@wickermoney/plugin-sdk/recurrenceexperimental
  • Stable. A breaking change (a removed or renamed export, a changed signature, type, return value or error) comes with a BREAKING CHANGE commit footer, an entry under Breaking in the changelog and a migration note. Additions are not breaking.
  • Experimental. May change in any pre-1.0 minor release, including removals and reshaped types. Changes are still recorded in the changelog, but no migration note is promised, so re-check it on each upgrade.
  • An export keeps the tier of the entry point it comes from, however it is imported: occurrences is experimental even when imported from the root. addDays is stable from the root only because the root takes it from /date; it used to come from /recurrence and was experimental.
  • Until SDK_MAJOR_VERSION is frozen at 1, the whole contract can still move between minor releases. The tiers say how much notice to expect and which parts are settled.

No ./recurrence export was removed in 0.5.0 or in 0.6.0.

The isolated entry point​

Added in 0.6.0. Experimental. The web app hosts it, and Insights and Spending Trends run on it.

@wickermoney/plugin-sdk/isolated is the contract for a UI plugin that runs in a sandboxed frame instead of in the app's own page. A PluginContext is an object of functions and cannot cross a frame boundary, so the entry point defines a message protocol (version 1) over a MessagePort, validators the host runs on every message a frame sends, and an in-frame client, connectToHost, that rebuilds the same PluginContext. A component written against PluginWidgetProps or PluginPageProps runs in either place. The manifest gains an optional isolated flag that declares a plugin was built for this runtime. It is a declaration, not a grant: it can only move a plugin towards isolation, and the host decides which runtime a plugin gets.

What this does and doesn't change:

  • It is additive. SDK_MAJOR_VERSION stays 0, /runtime is unchanged, and no manifest needs editing, so there is nothing to migrate.
  • The host decides where a plugin runs. A bundled plugin on a short host-internal list runs in the app's own page; every other plugin, and any plugin whose manifest says isolated: true, runs in a sandboxed frame. GET /api/v1/plugins reports the runtime of each. A plugin in a frame reaches data only through the host's broker, with a token that carries just its manifest's grants. See How much a plugin is trusted for what the sandbox does not stop.
  • A frame costs time and memory. Roughly 6 to 9 MiB and 150 ms for each frame after the first, and the first starts its own renderer process, so keep a plugin's widgets few and its bundle small. Don't import the SDK root in frame code: it carries the Zod manifest schemas (about 85 kB). Import from /runtime, /money and /date.
  • Don't eval, and don't let zod try. The frame's Content Security Policy has no 'unsafe-eval', so eval and new Function are refused, and the browser logs each refusal as a violation (DevTools Issues shows "blocks the use of eval"). zod 4 makes a new Function("") check when it builds its first object schema. The frame turns that off (z.config({ jitless: true })) before it loads your plugin, and zod 4.4 and later keeps the setting on globalThis, so a plugin that bundles its own copy of 4.4 or later follows it. An older copy keeps its own setting and logs one violation per frame; nothing breaks, because zod falls back to its ordinary parser. Update zod, or call z.config({ jitless: true }) before you build your first schema. Never add 'unsafe-eval' to get past it: the policy is fixed by the host.
  • Keep overlays inside the frame. The protocol has no host-drawn overlay or dialog, and a frame can't draw outside its own rectangle, so tooltips and menus must fit inside it. The host owns the frame's width and clamps the height you report (widgets at most 800 px).
  • Report your height with observeSize. Chromium does not run an off-screen cross-origin frame's rendering updates, so a ResizeObserver of your own never fires for a widget below the fold. observeSize also re-reads the height every 250 ms, which is how an off-screen widget gets its size.
  • It stays experimental until the isolation work has settled, so its protocol can change in any minor release.
  • The root does not re-export it: a frame has no use for the Zod manifest code the root carries.

The protocol is described in the SDK README.

Integrity pin and refused permissions​

Added in 0.6.0.

A manifest can carry an optional integrity map of Subresource Integrity values (for example sha384-...), keyed by file path relative to the plugin's directory. A plugin that isn't bundled, or whose entry the host doesn't serve itself, must have one that lists its entry file. The frame has the browser verify every listed file before the plugin runs, and a changed file is refused with a reason. It covers only the files it lists, not stylesheets. The SDK exports parseIntegrity and checkRemoteEntryIntegrity. integrity is optional and additive, so existing manifests are unaffected.

A manifest that declares network:outbound, schedule:daily or notify is now refused at load, with the reason on the plugin list. The host implements none of them; before this they loaded and were ignored. A non-bundled manifest also gets no contributes.endpoints or contributes.exporters: it is refused, because a third-party plugin has no server-side code, storage or scheduled work.

Calendar helpers: addDays and addMonths​

Added in 0.5.0 on /recurrence. Moved to the stable /date entry point in 0.6.0.

Import them from @wickermoney/plugin-sdk/date:

import { addDays, addMonths } from '@wickermoney/plugin-sdk/date'

addDays('2026-12-31', 1) // '2027-01-01'
addMonths('2026-08-31', 6) // '2027-02-28' ('2028-02-29' in a leap year)
addMonths('2026-03-31', -1) // '2026-02-28'
  • /date has no dependencies and is safe in the browser and in Node. Importing it pulls in nothing else from the SDK.
  • Behaviour did not change in the move. /recurrence and the package root still export both helpers, so existing imports keep working. New code should import from /date, because that is the stable entry point (see the tier rule above).
  • addDays(date, days) and addMonths(date, months) work on YYYY-MM-DD strings with no time zone involved, and accept negative values.
  • addMonths clamps to the last day of a shorter month, so Aug 31 plus 6 months is Feb 28 (Feb 29 in a leap year). Each call clamps on its own, so add the total to the original date rather than stepping a month at a time.
  • Anything that isn't a real calendar date or a whole number throws RangeError. An impossible date such as 2026-02-30 no longer rolls over to March 2. If you wrote your own helper with that rollover, check what you pass to the SDK's.

Validating a manifest​

parseManifest checks an untrusted manifest against the SDK's schema. It is exported from the package root and does not throw:

import { parseManifest } from '@wickermoney/plugin-sdk'

const result = parseManifest(input)
if ('error' in result) throw new Error(result.error)
const { manifest } = result
  • On success you get { manifest }. On failure you get { error }, a readable message with each problem as path: message, separated by ; .
  • It also rejects a manifest built against a newer SDK major version than the host's.
  • The PluginManifest, TableGrant, WidgetContribution and PageContribution types and checkRemoteEntry are exported from the root as before.
  • parseManifest uses Zod, so zod stays a dependency of the SDK and importing the root still brings it in. Keep value imports from the root out of plugin UI bundles; /runtime is the entry point for browser code.
Breaking in 0.6.0: the manifest Zod schemas are gone from the root

pluginManifestSchema, tableGrantSchema, widgetContributionSchema, pageContributionSchema and remoteEntrySchema are no longer exported from @wickermoney/plugin-sdk. Exporting them tied the stable root to Zod's major version. If you imported one to validate a manifest, call parseManifest(input) instead. It returns a result rather than throwing, so replace a try/catch around schema.parse with the 'error' in result check above.

The manifest shape and SDK_MAJOR_VERSION are unchanged, so existing manifests still load.

Money: @wickermoney/plugin-sdk/money​

Money is numeric(19,4) in the database and a decimal string in TypeScript, never a number. Since 0.3.0 the SDK has one module for exact arithmetic on those strings, at @wickermoney/plugin-sdk/money (also exported from the package root). Use it instead of keeping your own helpers. The bundled budgets, insights and spending-trends plugins and the recurrence module all do.

import { addMoney, compareMoney, equalMoney, sumMoney } from '@wickermoney/plugin-sdk/money'

sumMoney(['0.1', '0.2']) // '0.3000', not 0.30000000000000004
equalMoney('-81.2000', '-81.20') // true; `===` would say false
rows.sort((a, b) => compareMoney(a.total, b.total))

For loops over many values, work in bigint units of 0.0001 and format once:

import { moneyToUnits, unitsToMoney, divideUnits } from '@wickermoney/plugin-sdk/money'

let total = 0n
for (const row of rows) total += moneyToUnits(row.amount)
unitsToMoney(divideUnits(total * 26n, 12n)) // scale, then round once

The rules:

  • Strict input. A plain decimal string with at most four decimal places. Anything else, a fifth decimal place included, throws RangeError. Nothing is silently truncated, and the API refuses the same input.
  • Canonical output. Four decimal places, never '-0.0000'.
  • One rounding rule. Only divideUnits rounds, half away from zero. That's the API's rule too, so a plugin and the server agree to the unit.
  • Not for display. Show amounts with the host's ctx.formatMoney, and prefill inputs with editableMoney ('450.0000' → '450.00').

The string helpers are addMoney, subtractMoney, sumMoney, negateMoney, absMoney, compareMoney, equalMoney, isNegativeMoney, isZeroMoney, normalizeMoney, editableMoney and ZERO_MONEY; the bigint layer is moneyToUnits, unitsToMoney, divideUnits, MONEY_UNIT and MONEY_SCALE; the type is Money.

Server contract: @wickermoney/plugin-sdk/server​

Added in 0.5.0.

A bundled plugin's server code exports a register function that the API calls with a route context. The types for that, which each plugin used to copy, now live in one subpath:

import { PluginRouteError, isUuid } from '@wickermoney/plugin-sdk/server'
import type { RouteContext, RegisterRoute, Query, RunAsPlugin } from '@wickermoney/plugin-sdk/server'
  • RouteContext, RegisterRoute, RouteMethod, Query and RunAsPlugin describe what register is handed. RuleForMatching, ConditionForMatching and RuleSubject are the category-rule shapes the host injects.
  • RegisterRoute takes an optional fourth argument. bodyLimit raises the request-body limit, in bytes, for that route only. longRunning: true (added in 0.6.0) asks the host to run the route's database work under the longer statement limit, DB_LONG_STATEMENT_TIMEOUT, instead of DB_STATEMENT_TIMEOUT (see Pool, statement timeouts and the import limit). Set it only on a route that does bulk work, such as a file import. The host chooses the limit. It is additive: SDK_MAJOR_VERSION is unchanged.
  • PluginRouteError(message, statusCode, code, issues?) refuses a request the person can fix. Throw it, or a subclass, and the API answers with that status and code.
  • isUuid is the one id rule: the canonical hyphenated form in either case with an RFC 9562 version (1 to 8) and variant, plus the nil and max UUIDs. It is the rule the API applies to :id params, and every id PostgreSQL generates passes.

The subpath has no dependencies (no Zod) and is also re-exported from the package root. It is additive: nothing already published moved. Budgets and CSV Import, whose packages are private, dropped their own copies, so there is nothing for an outside author to migrate. Server-side plugin code is for bundled plugins only: it runs in the API process, and a third-party plugin is refused it.

Reads are shared between widgets​

Added in 0.5.0.

ctx.api.get doesn't always reach the network. Plugins are separate bundles that can't share module state, so the host's scoped client keeps a small in-memory record of reads, per user. Identical GETs in flight join one request, and a resolved one is reused for 5 seconds (50 entries per user, least recently used out first). The three dashboard widgets that read the same monthly summary make one request, with no plugin changes.

  • The key is the signed-in user plus the full URL, so nothing is shared between users, and two plugins allowed to read the same table share the answer.
  • Any write through the client drops that user's whole cache, so a read after a write is fresh. Sign-out and a change of user clear it too.
  • Failures aren't kept: everyone waiting gets the error, and the next call asks again.
  • Each caller gets its own copy of the response, so changing it affects nobody else.
  • To force a fresh read, pass { cache: 'no-store' } (or 'reload').

A plugin that was just turned off can still be answered from memory for up to five seconds. This is a performance feature only: no API change, and no server-side cache.

When a plugin is turned off​

Since 0.4.0 an owner can turn any plugin off from Settings → Plugins, and back on, while the app is open (see Plugins). When yours is turned off:

  • The host unmounts its pages and widgets. A remote that's already loaded stays in memory but is no longer rendered.
  • Its own server routes answer 404 plugin_disabled.
  • A core data request carrying its x-wickermoney-plugin header gets 403 grant_denied.
  • Its schema, rows and database role are kept. Turning it back on restores everything.

So don't assume your code runs at every page load, don't depend on a widget having mounted since sign-in, and never delete data on unmount.

Form errors​

Since 0.4.0, every 400 validation_failed from the API carries issues: { path, message }[] (see API reference). A bundled plugin's server can attach issues in the same shape to its own errors, and the host passes them through.

@wickermoney/ui-kit turns them into errors on the right fields:

  • useFormErrors() holds a form's errors. After show(errors), focus moves to the first control marked invalid, or to the FormError when no field is.
  • formErrorsFrom(error, fields, fallback) maps each issue to one of your field names (a list of dotted paths, or a function from a dotted path to a field name) and returns everything else as one form-level message. An error with no issues becomes a form-level message, its own or fallback.
  • FormError shows the form-level message. Put it beside the submit button, where the person is looking when the form refuses.
  • validationIssuesOf(error) reads the issues on their own. NO_FORM_ERRORS and hasFormErrors cover the empty case.
  • Field and SelectField take an error (wired to aria-invalid and aria-describedby) and, since 0.4.0, an optional hint.
// `api` is the plugin's scoped client from the SDK.
import { Field, FormError, Button, formErrorsFrom, useFormErrors } from '@wickermoney/ui-kit'

const { errors, ref, show, clearField } = useFormErrors()

async function save() {
try {
await api.put('/p/your.plugin.id/thing', { name, amount })
} catch (e) {
show(formErrorsFrom(e, ['name', 'amount'], 'Could not save that.'))
}
}

return (
<form ref={ref} onSubmit={(e) => { e.preventDefault(); void save() }}>
<Field label="Name" value={name} error={errors.fields.name}
onChange={(e) => { setName(e.target.value); clearField('name') }} />
<Field label="Amount" value={amount} error={errors.fields.amount}
hint="Up to four decimal places" onChange={(e) => setAmount(e.target.value)} />
<Button type="submit">Save</Button>
<FormError message={errors.form} />
</form>
)

Check what you can in the browser first, with the same rules the API uses, so most mistakes never make a round trip. The server still checks everything.