Addon Cookbook
Short recipes for the patterns that come up while building addons. Each entry is a question, the smallest working snippet, and a one-line note about why. For deep context on any feature, follow the links to manifest-spec.md and dynamic-ui.md.
Table of contents
- How do I add a foreign-key relation?
- How do I make a column searchable?
- How do I add a custom validation?
- How do I create a custom action with a modal?
- How do I require a permission for a button?
- How do I emit an event when a record changes?
- How do I subscribe to events from another addon?
- How do I show a different UI for create vs edit?
- How do I add a soft-delete column?
- How do I scope records per organization?
- How do I bundle a frontend extension with my addon?
- How do I test my addon locally?
- How do I prefetch metadata at app boot?
- How do I add a row dropdown action that links to another page?
- How do I gate an action on the row's current state?
How do I add a foreign-key relation?
Declare ref on the column. The host generates the FOREIGN KEY constraint and exposes a /options/<model> endpoint that the modal's relation picker uses.
{
"table_name": "ticket_comments",
"columns": [
{ "name": "ticket_id", "type": "uuid", "required": true,
"ref": "addon_tickets.tickets" }
]
}The edit dialog will render a searchable combobox for ticket_id. Targets can be cross-schema (addon_<other>.<table>) or core (users, organizations).
How do I make a column searchable?
Two layers — column-level filterability (driven by metadata, surfaced as a per-column filter chip in the toolbar) and global search (driven by the toolbar's free-text input).
{
"name": "title",
"type": "string",
"size": 255,
"searchable": true,
"filterable": true
}searchable: true includes the column in the global ILIKE-style search the toolbar emits as ?search=. filterable: true lets the kernel produce a FilterDefinition for it so users see a per-column filter button.
How do I add a custom validation?
For action fields, add validation (regex applied after normalize):
"input_schema": [
{ "name": "rfc", "type": "string", "required": true,
"normalize": "uppercase",
"validation": "^[A-ZÑ&]{3,4}\\d{6}[A-Z0-9]{3}$" }
]For column constraints beyond what the manifest expresses (NOT NULL, UNIQUE, length), validate in your action handler / WASM export. Keep schema-level constraints declarative; keep business rules in code.
How do I create a custom action with a modal?
Declare the action under the model with fields[]:
"actions": {
"tickets": [
{
"key": "reassign",
"label": "Reassign",
"icon": "UserPlus",
"fields": [
{ "name": "assignee_id", "label": "New assignee", "type": "user", "required": true },
{ "name": "note", "label": "Note", "type": "text" }
]
}
]
}<DynamicTable> adds "Reassign" to the row dropdown. Clicking it fires <ActionModalDispatcher>, which renders a modal with the declared inputs and POSTs to /data/tickets/<id>/action/reassign. Wire the server side via hooks (webhook), a WASM export, or a compiled ActionInterceptor — see manifest-spec.md.
For full custom UI register a component:
import { actionRegistry } from '@asteby/metacore-sdk'
actionRegistry.register('tickets', 'reassign', ReassignDialog)The dispatcher routes to your component when (model, action.key) matches.
How do I require a permission for a button?
Wrap the affordance in <CapabilityGate>:
import { CapabilityGate } from '@asteby/metacore-runtime-react'
<CapabilityGate require="db:write addon_tickets.tickets">
<Button onClick={createTicket}>New ticket</Button>
</CapabilityGate>The kernel still enforces the same capability server-side — gating UI is purely a UX courtesy. See dynamic-ui.md for all / any / invert modes.
How do I emit an event when a record changes?
Declare the capability and the event topic:
"capabilities": [
{ "kind": "event:emit", "target": "ticket.*", "reason": "Notify state changes" }
],
"events": ["ticket.created", "ticket.resolved"]In a webhook / WASM export, call the host's event API with { topic: 'ticket.resolved', payload: {…} }. The kernel checks the capability, persists the event, and fans out to subscribers.
For automatic events on every CRUD operation, use lifecycle_hooks:
"lifecycle_hooks": {
"tickets": [
{ "event": "after_create",
"target": { "type": "webhook", "url": "/webhooks/ticket_created" },
"async": true }
]
}How do I subscribe to events from another addon?
Declare the capability and pull events from the bus in your handler:
"capabilities": [
{ "kind": "event:subscribe", "target": "invoice.stamped" }
]The publishing addon must declare events: ["invoice.stamped"] so the host knows the schema. Subscribe via the kernel's bus API in your WASM module or via a webhook the kernel calls when the event fires.
How do I show a different UI for create vs edit?
<DynamicRecordDialog> already swaps title and submit label per mode. If you need different fields, branch at the call site and render two distinct components (or two manifest models — one for the create funnel, one for editing the persisted record).
{mode === 'create'
? <FullCreationWizard onDone={refetch} />
: <DynamicRecordDialog open mode="edit" model="tickets" recordId={id} />}How do I add a soft-delete column?
{
"table_name": "tickets",
"soft_delete": true,
"columns": [ /* … */ ]
}The host adds a deleted_at timestamptz column, filters it out from default queries, and routes DELETE /data/tickets/<id> to a UPDATE … SET deleted_at = now(). No app-level wiring required.
How do I scope records per organization?
{
"table_name": "tickets",
"org_scoped": true,
"columns": [ /* … */ ]
}The host adds an organization_id column, sets a NOT NULL + index, applies a Postgres RLS policy, and stamps the column on insert. Cross-tenant reads are denied by the kernel even if a capability would otherwise allow them.
For regulated data prefer tenant_isolation: "schema-per-tenant" instead — see manifest-spec.md.
How do I bundle a frontend extension with my addon?
Declare a federation entry in the manifest:
"frontend": {
"entry": "/api/metacore/addons/tickets/frontend/remoteEntry.js",
"format": "federation",
"expose": "./plugin",
"container": "metacore_tickets"
}Build the frontend with @originjs/vite-plugin-federation and a name matching container. The exposed module must export register(api: AddonAPI), which receives the host SDK and registers slot contributions, action handlers, navigation items, etc.
// frontend/src/plugin.tsx
import type { AddonAPI } from '@asteby/metacore-sdk'
export function register(api: AddonAPI) {
api.slot.register('dashboard.widgets', RevenueWidget, { priority: 10 })
api.action.register('tickets', 'reassign', ReassignDialog)
api.nav.add({ key: 'tickets', label: 'Tickets', to: '/m/tickets' })
}The host loads it via <AddonLoader> from @asteby/metacore-runtime-react.
How do I test my addon locally?
metacore validate # static checks: regex, semver, capabilities, defaults
metacore build --strict # produces my-addon-0.1.0.tar.gz
metacore inspect *.tar.gz # prints manifest + migrations + bundle sizesRun a host with a file: reference to your addon directory and reload — the kernel re-runs AutoMigrate on every restart in dev. Webhooks pointed at http://localhost:7101/webhooks/... work straight through; for WASM, use metacore compile-wasm to produce a fresh backend/backend.wasm before reloading.
How do I prefetch metadata at app boot?
import { useMetadataCache } from '@asteby/metacore-runtime-react'
function PrefetchMetadata() {
const { prefetchAll } = useMetadataCache()
const api = useApi()
useEffect(() => { prefetchAll(api) }, [api])
return null
}prefetchAll issues a single GET /metadata/all and seeds both the table and modal caches. Subsequent <DynamicTable> mounts render with no network round-trip. The cache is namespaced by metadataVersion — when the kernel bumps it, the cache invalidates automatically.
How do I add a row dropdown action that links to another page?
Declare an action with type: "link" and a linkUrl template:
{
"key": "open_invoice",
"label": "Open invoice",
"icon": "ExternalLink",
"type": "link",
"linkUrl": "/invoices/{invoice_id}"
}Tokens like {invoice_id} are replaced with the row's value before navigation. <DynamicTable> recognises type: "link" and uses the host's TanStack Router navigate() instead of opening a modal.
How do I gate an action on the row's current state?
Use condition to hide the action from the dropdown when the row doesn't match, and requiresState to assert the same on the server:
{
"key": "resolve",
"label": "Resolve",
"icon": "CheckCircle2",
"confirm": true,
"condition": { "field": "status", "operator": "in", "value": ["open", "in_progress"] },
"requiresState": ["open", "in_progress"]
}condition filters the dropdown client-side (eq, neq, in, not_in); requiresState makes the kernel reject stale executions where the row state changed between fetch and click.
Have a recipe to add? Send a PR — recipes live in this file as a flat list, no nesting.