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:
| Helper | Result 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:
| Kind | Slot |
|---|---|
| Tab | tabs |
| Panel | right_panels |
Action with entity | header_actions |
Action with page | page_actions |
| Line column | line_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 insrc/entities/).renderandrunpaths must resolve to a script the compiler picked up.ui.lineColumns[].fieldmust reference adefineLineFieldkey for the same entity and line collection.ui.lineColumns[]supportsline_itemson every line-bearing canonical document —Invoice,CreditMemo,SalesReceipt,RefundReceipt,VendorBill,Expense,PurchaseOrder,Quote,VendorCredit,SalesOrder, andDeposit.ordermust be an integer.visibleWhenmust be an object (or omitted).
All enforced at deploy.