Contributions

Tabs, panels, actions, and line columns added to existing entity or page surfaces.

pages adds full pages. ui contributes smaller surfaces: tabs and panels on existing entity pages, action buttons on entity headers and on your own pages, and read-only columns on host line tables.

Contribution kinds

interface UiConfig {
  tabs?: UiTabConfig[];          // adds a tab to an entity page
  panels?: UiPanelConfig[];      // adds a side panel to an entity page
  actions?: UiActionConfig[];    // adds a button (entity header or page toolbar)
  lineColumns?: UiLineColumnConfig[]; // adds a read-only host line-table column
}

Tabs and panels

Tabs and panels share the same shape — they both render content inline on an entity page.

interface UiRenderableConfig {
  id: string;
  label: string;
  entity: string;        // standard entity, or your custom entity name
  render: string;        // path to a .tsx render script
  icon?: string;
  order?: number;
  visibleWhen?: { field: string; equals?: any; in?: any[] };
}

Example:

ui: {
  tabs: [
    {
      id: "stripe-charges",
      label: "Stripe charges",
      entity: "Invoice",
      render: "src/ui/invoice-stripe-charges.tsx",
      order: 50,
      visibleWhen: { field: "status", in: ["open", "paid"] },
    },
  ],
}

The render script receives a UiRenderContext with an extra record field — the entity instance the tab is rendered for:

import { Stack, Stat } from "@backfill-io/sdk/ui";

export default function StripeCharges({ record }) {
  const charges = StripeCharge.query({ where: { invoiceId: record.id } });
  return (
    <Stack>
      <Stat label="Charges" value={charges.length} />
      {/* table of charges */}
    </Stack>
  );
}

visibleWhen controls when the tab appears — equality (equals) or membership (in) on a field of the host entity.

Line columns

Line columns render extension-owned line field values inside Backfill’s host line table. The surface is deliberately narrow: every line-bearing canonical document (Invoice, CreditMemo, SalesReceipt, RefundReceipt, VendorBill, Expense, PurchaseOrder, Quote, VendorCredit, SalesOrder, and Deposit) on line_items, host-rendered read-only cells, and no arbitrary per-cell JSX.

Declare persisted fields in src/fields/<EntityName>.ts, then place them in the UI with ui.lineColumns[]:

// src/fields/Invoice.ts
import { defineLineField } from "@backfill-io/sdk";

export const stripeTaxBehavior = defineLineField("line_items", {
  key: "stripe_tax_behavior",
  label: "Stripe tax behavior",
  type: "select",
  options: ["inclusive", "exclusive", "unspecified"],
});
// backfill.config.ts
import { defineExtension } from "@backfill-io/sdk";

export default defineExtension({
  key: "stripe-reconciliation",
  name: "Stripe Reconciliation",
  version: "1.0.0",

  permissions: {
    entities: {
      Invoice: ["read", "write"],
    },
  },

  ui: {
    lineColumns: [
      {
        id: "stripe-tax-behavior",
        label: "Stripe tax",
        entity: "Invoice",
        lineCollection: "line_items",
        field: "stripe_tax_behavior",
        width: 140,
        align: "left",
        order: 60,
        visibleWhen: { field: "source_system", equals: "stripe" },
      },
    ],
  },
});
interface UiLineColumnConfig {
  id: string;
  label: string;
  entity: "Invoice";
  lineCollection?: "line_items";
  field: string;        // references a defineLineField key
  width?: number;       // 64-320 px
  align?: "left" | "right" | "center";
  order?: number;
  visibleWhen?: {
    field: "source_system" | "source_id" | "document_type" | "document_number" | "customer_id" | "approval_status" | "payment_status" | "currency";
    equals?: string | number | boolean | null;
    notEquals?: string | number | boolean | null;
    in?: Array<string | number | boolean | null>;
    exists?: boolean;
  };
}

When you are already writing the invoice, include declared line field keys directly in the line payload:

import { Invoice } from "@backfill-io/sdk";

Invoice.update(invoiceId, {
  line_items: [
    {
      line_key: "stripe:il_123",
      quantity: "2",
      stripe_tax_behavior: "exclusive",
    },
  ],
});

For field-only writes after the canonical document and stable line keys exist, use the generated line-collection namespace:

import { Invoice } from "@backfill-io/sdk";

export default async function afterSave(ctx) {
  for (const line of ctx.entity.line_items ?? []) {
    if (!line.line_key) continue;

    Invoice.lineItems.fields.setAll(ctx.entity.id, line.line_key, {
      stripe_tax_behavior: line.metadata?.stripe_tax_behavior ?? "unspecified",
    });
  }
}

Do not write line fields from before-save hooks. Before-save runs before the canonical document version and persisted line rows exist, so Backfill cannot validate the target line_key.

Line fields are extension-owned. Backfill excludes them from core reports, company export, canonical API projections, MCP responses, and outbound connector sync payloads by default. Future API or export support must opt in explicitly and should expose values by immutable extension_key, not tenant-specific extension UUID.

Actions

Actions are buttons that, when clicked, run a server-side handler and return a result (a redirect, a toast, validation errors).

interface UiActionConfig {
  id: string;
  label: string;
  entity?: string;       // for entity-scoped actions (header buttons)
  page?: string;         // for page-scoped actions (form submits)
  run: string;           // path to a .ts handler
  icon?: string;
  order?: number;
  visibleWhen?: VisibilityCondition;
}

An action declares either entity (entity header action) or page (page-scoped action). Not both — deploy rejects mixing them.

Page-scoped action (form submit)

The pattern:

pages: [
  { id: "nexus-address-form", title: "Nexus Address", path: "nexus-address-form",
    render: "src/pages/nexus-address-form.tsx", kind: "page" },
],
ui: {
  actions: [
    {
      id: "save-nexus-address",
      label: "Save Nexus Address",
      page: "nexus-address-form",
      run: "src/pages/actions/save-nexus-address.ts",
    },
  ],
}

The form references the action by id; the handler runs server-side when submitted:

// src/pages/actions/save-nexus-address.ts
import type { ExtensionPageActionContext } from "@backfill-io/sdk/ui";
import { redirect, validationError } from "@backfill-io/sdk/ui";

declare const NexusAddress: any;

export default async function (ctx: ExtensionPageActionContext) {
  const { values } = ctx;
  if (!values.nexusName) {
    return validationError("Name is required", [{ field: "nexusName", message: "Required" }]);
  }
  NexusAddress.create({ ...values });
  return redirect("/nexus-addresses", "Saved", "success");
}

For routed page actions, ctx.page.params contains the page route/query params, ctx.values contains serialized form fields, and ctx.action.params contains the params from the clicked <Button> or <SubmitButton>.

Helpers from @backfill-io/sdk/ui:

HelperResult shape
ok(message?, opts){ ok: true, message?, level? }
message(text, level?)Toast at level info/success/warning/error.
redirect(to, message?, level?)Navigate to to with optional toast.
validationError(error, fieldErrors[])Per-field validation result.

Entity header action

ui: {
  actions: [
    {
      id: "post-invoice",
      label: "Post invoice",
      entity: "Invoice",
      run: "src/ui/actions/post-invoice.ts",
      visibleWhen: { field: "status", equals: "open" },
    },
  ],
}

Entity actions appear in the entity’s detail-page header. The handler receives a UiActionContext with the record.

Slots

Discovery normalizes contributions into slots:

KindSlot
Tabtabs
Panelright_panels
Action with entityheader_actions
Action with pagepage_actions
Line columnline_columns

These map onto where in the dashboard your contribution appears.

Validation rules

  • IDs must be non-empty and unique across all contributions in the extension.
  • entity (when present) must be a known entity (standard or one you declared in src/entities/).
  • render and run paths must resolve to a script the compiler picked up.
  • ui.lineColumns[].field must reference a defineLineField key for the same entity and line collection.
  • ui.lineColumns[] supports line_items on every line-bearing canonical document — Invoice, CreditMemo, SalesReceipt, RefundReceipt, VendorBill, Expense, PurchaseOrder, Quote, VendorCredit, SalesOrder, and Deposit.
  • order must be an integer.
  • visibleWhen must be an object (or omitted).

All enforced at deploy.