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.
| Import | Tier |
|---|---|
@wickermoney/plugin-sdk (manifest, dashboard range) | stable |
@wickermoney/plugin-sdk/runtime | stable |
@wickermoney/plugin-sdk/money | stable |
@wickermoney/plugin-sdk/server | stable |
@wickermoney/plugin-sdk/date | stable |
@wickermoney/plugin-sdk/isolated | experimental |
@wickermoney/plugin-sdk/recurrence | experimental |
- Stable. A breaking change (a removed or renamed export, a changed
signature, type, return value or error) comes with a
BREAKING CHANGEcommit 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:
occurrencesis experimental even when imported from the root.addDaysis stable from the root only because the root takes it from/date; it used to come from/recurrenceand was experimental. - Until
SDK_MAJOR_VERSIONis 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_VERSIONstays0,/runtimeis 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/pluginsreports theruntimeof 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,/moneyand/date. - Don't eval, and don't let zod try. The frame's Content Security Policy has
no
'unsafe-eval', soevalandnew Functionare refused, and the browser logs each refusal as a violation (DevTools Issues shows "blocks the use ofeval"). zod 4 makes anew 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 onglobalThis, 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 callz.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 aResizeObserverof your own never fires for a widget below the fold.observeSizealso 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'
/datehas 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.
/recurrenceand 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)andaddMonths(date, months)work onYYYY-MM-DDstrings with no time zone involved, and accept negative values.addMonthsclamps 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 as2026-02-30no 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 aspath: message, separated by;. - It also rejects a manifest built against a newer SDK major version than the host's.
- The
PluginManifest,TableGrant,WidgetContributionandPageContributiontypes andcheckRemoteEntryare exported from the root as before. parseManifestuses Zod, sozodstays a dependency of the SDK and importing the root still brings it in. Keep value imports from the root out of plugin UI bundles;/runtimeis the entry point for browser code.
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
divideUnitsrounds, 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 witheditableMoney('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,QueryandRunAsPlugindescribe whatregisteris handed.RuleForMatching,ConditionForMatchingandRuleSubjectare the category-rule shapes the host injects.RegisterRoutetakes an optional fourth argument.bodyLimitraises 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 ofDB_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_VERSIONis 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.isUuidis 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:idparams, 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-pluginheader gets403 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. Aftershow(errors), focus moves to the first control marked invalid, or to theFormErrorwhen 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 orfallback.FormErrorshows 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_ERRORSandhasFormErrorscover the empty case.FieldandSelectFieldtake anerror(wired toaria-invalidandaria-describedby) and, since 0.4.0, an optionalhint.
// `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.