Building custom widgets
Author package-based custom widgets with named data inputs, manifest-driven settings, SDK v3, and explicit permission grants.
7 min read
On this page
- Compatibility
- The package contract
- Complete SDK v3 example
- Penny and MCP proposals
- Data API
- useDataset(inputId: string): DataState — SDK 3
- useData(): DataState — legacy compatibility
- useFilters() and emitFilter(id, value)
- Settings API
- useSettings(): Settings and useSetting(key: string): Value — SDK 3
- Binding and consent
- Other APIs and capabilities
- Limits
- Assumptions and current boundaries
Custom widgets are sandboxed React components for visualizations and small interactive tools that the built-in widget kinds cannot express. A package declares what data and settings its code needs; each workspace installation chooses its own banks or synced feeds and grants access separately.
Private beta boundary
Package creation and installation are workspace-scoped in this release. Public marketplace, portal, and public-dashboard installation remain blocked while review, signing, takedown, and publisher-policy systems are completed.
Compatibility#
| Installed widget | SDK / protocol | Data API | Settings API | Consent identity |
|---|---|---|---|---|
| Legacy custom v1 | SDK API 3 / protocol v3 (compat) | useData() default rows | legacy props; no generated settings form | exact bundle hash |
| Package manifest v2 | SDK API 3 / protocol v3 | useDataset(inputId) with independent state and schema | useSettings() and useSetting(key) | package digest + binding fingerprint |
Legacy widgets keep working. New packages should use named inputs. Rebuilding a legacy widget does
not silently convert it; create a manifest, update its source to useDataset, and install the new
package version deliberately.
Preview data is generated
The package creator previews each declared input with an independent, contract-aware synthetic fixture. It never reads your workspace's banks or feeds during authoring. Real rows are available only after installation, local binding, and administrator approval.
The package contract#
The source code is only one part of a package version. An immutable version pins:
- its compiled bundle hash;
- SDK API version and provided-runtime digest;
- toolchain and vendor-manifest versions;
- a content-addressed private source reference;
- the package manifest.
The manifest contains logical input ports, settings definitions, requested capabilities, allowed network hosts, and supported surfaces. It never contains a user's feed ID, bank ID, account ID, credential, or token.
const manifest = { schemaVersion: 1, name: 'Spending by category', inputs: [ { id: 'transactions', label: 'Transactions', required: true, accepts: { resourceTypes: ['transactions'], resultShapes: [], fields: [ { id: 'amount', label: 'Amount', required: true, types: ['currency'], roles: ['measure'], measureKinds: ['additive'], resourceFieldIds: ['amount'], }, { id: 'category', label: 'Category', required: true, types: ['enum', 'string'], roles: ['dimension'], measureKinds: [], resourceFieldIds: ['category'], }, ], }, }, ], settings: [ { key: 'heading', label: 'Heading', section: 'content', scope: 'installation', type: 'text', default: 'Spending', maxLength: 80, }, { key: 'bar_color', label: 'Bar color', section: 'appearance', scope: 'placement', type: 'color', default: '#6366f1', }, ], capabilities: ['read-data-source'], allowedNetworkOrigins: [], supportedSurfaces: ['dashboard'],}Input and setting IDs are package-local stable API names. Settings cannot masquerade as data or
credential selectors: keys such as bank_id, feed, dataset, or token are rejected. Data is
always chosen through an input binding in the inspector or install flow.
Complete SDK v3 example#
import { defineWidget, useDataset, useSetting, useTheme } from '@banksync/widget-sdk'
export default defineWidget({ name: 'Spending by category', capabilities: ['read-data-source'], Component() { const transactions = useDataset('transactions') const heading = useSetting<string>('heading') const color = useSetting<string>('bar_color') const theme = useTheme()
if (transactions.loading) return <div className="p-4 text-sm">Loading…</div> if (transactions.error) { return <div className="p-4 text-sm text-destructive">{transactions.error.message}</div> }
const totals = new Map<string, number>() for (const row of transactions.rows) { const category = String(row.category ?? 'Uncategorized') totals.set(category, (totals.get(category) ?? 0) + Math.abs(Number(row.amount) || 0)) } const rows = [...totals].sort((a, b) => b[1] - a[1]).slice(0, 8) const max = Math.max(1, ...rows.map(([, value]) => value))
return ( <div className="space-y-3 p-4" style={{ fontFamily: theme.fontFamily }}> <h3 className="font-semibold">{heading}</h3> {rows.map(([label, value]) => ( <div key={label} className="grid grid-cols-[7rem_1fr] items-center gap-2 text-xs"> <span className="truncate">{label}</span> <span className="h-2 rounded bg-muted"> <span className="block h-2 rounded" style={{ width: `${(value / max) * 100}%`, background: color }} /> </span> </div> ))} </div> ) },})The authoring surface can generate package-local TypeScript declarations from the manifest. Those types describe input row keys and settings, but intentionally contain no workspace binding identity. In the current developer beta, the creator exposes this contract as validated JSON beside the source editor. Name, description, and selected capabilities remain the creator's authoritative metadata; workspace sources are deliberately absent and are selected only after Create package.
Penny and MCP proposals#
Penny and MCP use the same generate_custom_widget input: source plus a complete portable manifest.
The build result is an inert proposal containing the normalized manifest and immutable bundle
identity. It is not a widget installation and cannot name a bank, feed, account, query, grant, or
credential.
In the app, a live Penny proposal is queued until the user opens an editable dashboard. BankSync then seeds the ordinary package creator with its source and manifest; the user reviews/rebuilds it, creates the package, chooses each local data source and setting in the installer, and completes the normal administrator approval flow. Replayed chat history does not reopen old proposals, and a locked or view-only dashboard leaves the proposal queued rather than consuming it.
MCP returns the same portable proposal through its widget-build service binding. It does not create a workspace installation or grant. A direct MCP-to-app import/handoff surface is not included in this beta, so the caller must retain the source and manifest for a later explicit package import; do not describe MCP generation alone as an installed or data-authorized widget.
Data API#
useDataset(inputId: string): DataState — SDK 3#
DataState contains rows, loading, error, resultSchema, and refetch(). Each declared input
has independent state: an update or error for budget does not overwrite actuals. Unknown input
IDs return a typed error state rather than another input's rows.
Only server-compiled projected fields enter the sandbox. resultSchema.columns describes the same
renamed field keys present on the rows. read-data-source must be declared and granted before any
workspace rows are sent.
useData(): DataState — legacy compatibility#
useData() reads the @default dataset for v1 bundles. It is not the authoring default for package
v2. It remains available so an installed legacy widget does not break during the migration window.
useFilters() and emitFilter(id, value)#
useFilters() returns the dashboard filter state and updates without reloading the iframe.
emitFilter drives a dashboard filter as a UI signal; it does not grant data authority.
Settings API#
useSettings(): Settings and useSetting(key: string): Value — SDK 3#
Values are validated against the immutable manifest before persistence. Installation settings are shared by the installed widget. Placement settings are stored separately and appear in the Style tab, so one dashboard can change appearance without changing package code or another placement. Settings updates are delivered live without an iframe reload.
Changing a setting does not require re-consent because settings cannot select data, credentials, or network hosts. Changing an input binding, code/package digest, or requested permission does.
Binding and consent#
The inspector lists each logical input separately. A compatible bank or synced feed is matched by canonical resource type and field semantics; exact canonical field IDs win, then compatible role/type matches. The compiled binding records the precise source leaves, mapped fields, projection, resource contract version, and source fingerprint.
An administrator consents to the exact package digest and binding fingerprint. At runtime BankSync intersects that grant with the current viewer's scopes, mints a short-lived delegated token, and renders only that widget's persisted inputs. Account/date narrowing is applied before aggregation and participates in cache identity. The iframe never receives the user's session credential or the delegated token.
Revocation takes effect immediately. Rebinding data or installing a new package version invalidates the old grant and pauses data delivery until review. The widget version is used as a stale-write check, not as permission identity, so an ordinary settings or placement edit does not revoke access.
Other APIs and capabilities#
useTheme()returns{ dark, accent, palette, fontFamily }.brokerFetch(url, init?)calls a declared and consented hostname through the SSRF-guarded broker; requiresbroker-fetch.openUrl(url)requiresopen-url.persistLocalState(key, value)requirespersist-local-state.requestSync(feedId)remains reserved; read-only grants refuse it.
Direct fetch, XHR, WebSocket, local storage, cookies, parent-window access, and relative imports
are blocked. Available imports fall in two groups. Provided by the runtime (shared, not counted
against your bundle size): React, react-dom, recharts, and lucide-react. Bundled into your widget:
selected d3 modules, Radix primitives, date-fns, zod, clsx, Tailwind Merge, and
class-variance-authority.
Limits#
| Item | Limit |
|---|---|
| Source | 256 KB |
| Compiled bundle | 512 KB |
| Manifest | 64 KB; 8 inputs; 100 settings |
| Resolved settings per scope | 32 KB |
| Rows per input | 5,000 |
| Network origins | 100 declared; consent can narrow further |
Source is stored behind a private content reference. Bundle and guest assets are served by the sandbox; source is not part of that public asset surface.
Assumptions and current boundaries#
- A package version is immutable. Updating code creates a new version; it never mutates old bytes.
- Canonical resource contracts are aligned with existing feed data types. A live bank transaction source and a synced transaction feed therefore satisfy the same logical input.
- The first install endpoint accepts direct feed or direct bank inputs. More complex joins and reusable queries can be composed after installation as the binding compiler expands.
- New dashboard custom widgets use the in-app package creator and installer. The Packages library tab can install another package from the same workspace. Source-aware preview, manual field repair, account/date selectors, settings, grant review, and compatible explicit upgrades are wired.
- Penny now queues a validated v2 package proposal into the same dashboard creator and installer used by manual authoring. MCP advertises and builds the same proposal artifact, but the beta has no direct MCP-to-app import handoff. Neither path can choose workspace data or create a grant during generation.
- Legacy v1 widgets remain supported and are not silently migrated. Their deep-linked source editor still uses the compatibility contract.
- The compatible upgrader blocks a version that adds an unbound required input; the user must configure that input rather than inheriting broader access automatically.
- Public package publication, public-dashboard execution, portal execution, automated upgrades, review/signing, and marketplace discovery are not enabled by this beta contract.
See Custom-widget data and permissions for the administrator/user flow and Custom-widget packages for versioning and installation semantics.
Use this page with your AI assistant
Every BankSync doc is available as plain Markdown for agents and LLMs.