---
title: "Building custom widgets"
description: "Author package-based custom widgets with named data inputs, manifest-driven settings, SDK v3, and explicit permission grants."
section: "Dashboards"
canonical: "https://banksync.io/docs/dashboards/custom-widgets"
---

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.

```ts
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

```tsx
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;
  requires `broker-fetch`.
- `openUrl(url)` requires `open-url`.
- `persistLocalState(key, value)` requires `persist-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](/docs/dashboards/custom-widget-data-and-permissions) for the
administrator/user flow and [Custom-widget packages](/docs/dashboards/custom-widget-packages) for
versioning and installation semantics.
