Action protocol — buttons as metadata
The ObjectUI action protocol: declare buttons, menu items and triggers as metadata that run business logic, navigate, or call external integrations.
The Action Protocol specifies how user interactions (button clicks, menu selections) trigger business logic, navigation, or external integrations. Actions are the "verbs" of ObjectUI—they make things happen.
The canonical schema is ActionSchema in @objectstack/spec (packages/spec/src/ui/action.zod.ts). Everything on this page maps directly to that Zod schema.
Philosophy: Declarative Interactions
Instead of writing onClick handlers in JavaScript, you declare what should happen in metadata:
# ❌ Imperative (React)
<Button onClick={() => {
if (validate()) {
api.post('/customers', data);
router.push('/customers');
}
}}>Save</Button>
# ✅ Declarative (ObjectUI)
name: save_customer
label: Save
type: api
target: /api/v1/data/customer
method: POST
refreshAfter: trueBenefits:
- No code: Business analysts configure actions
- Type-safe: Actions validated against
ActionSchema - Testable: Actions are data, can be unit tested
- Auditable: See what actions users can perform
Action Types
The type field selects how an action is dispatched. The complete enum is script, url, modal, flow, api, form — there are no other types. type defaults to script when omitted.
| Type | Behavior | target required? |
|---|---|---|
script | Run an inline body (CEL expression or sandboxed JS), or a registered handler named by target. | No (prefer body) |
url | Navigate to a URL. | Yes |
flow | Invoke a Screen/automation Flow by name. | Yes |
modal | Open a modal/page by name. | Yes |
api | Call an API endpoint (method defaults to POST). | Yes |
form | Open a FormView by name, routed to /_console/forms/:name inside the console shell; the submit lands on the created record. | Yes |
The one thing that is not a type: the declarative single-record field write. It is spelled operation: 'update' + patch — the same words a list view's bulkActionDefs uses for the bulk form — and it rides the default script route: the platform action route performs the write on the data plane as the caller (never system-elevated), so the caller's permissions, the object's hooks and its validations fire exactly as for a user edit. Beside operation: 'update' an action carries no executor binding of its own — target, body, method, bodyExtra and any other explicit type are refused — and patch is merged under whatever the params dialog collects, so a fixed value can be declared without exposing it in the dialog. undoable has its anchor here: the capture is the prior value of every field the action writes — the merged bag, patch under params, not patch alone.
{ name: 'task_complete', label: 'Complete', operation: 'update', patch: { status: 'done' },
visible: 'record.status == "open"', undoable: true }target is the canonical binding for every non-script type, and since protocol 17 it is the only one. The deprecated execute alias was removed (#3855): authoring it is rejected with an error naming the replacement, not silently stripped. Run os migrate meta --from 16 to list the mechanical edits for existing sources, then apply them by hand — the removal also ships in the machine-readable change manifest (spec-changes.json), which composes across however many majors you are jumping.
Script Actions
Run logic with no server round trip via body (an L1 CEL expression or L2 sandboxed JS). body is only meaningful when type is script, and declaring one on any other type is a parse-time error — those types dispatch on target, so the body would never be invoked.
name: greet_user
label: Greet
type: script
body:
language: expression
source: '"Hello, " + ctx.user.name'name: stamp_now
label: Stamp Timestamp
type: script
body:
language: js
source: |
return { stamped_at: new Date().toISOString() };URL Actions
Navigate to an internal route or external URL. target supports ${param.X} and ${ctx.X} interpolation (renderers encodeURIComponent interpolated values before substituting them into query positions).
name: view_dashboard
label: View Dashboard
type: url
target: /_console/dashboards/sales_overview
name: open_in_maps
label: Open in Maps
type: url
target: 'https://www.google.com/maps/search/?api=1&query=${param.address}'
params:
- name: address
label: Address
type: text
required: trueFor a static url, openIn: 'new-tab' opens target in a new tab and openIn: 'self' navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an async handler that redirects, set opensInNewTab: true instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); newTabUrl provides a zero-roundtrip new-tab target template supporting the {recordId} placeholder.
The interpolation scope is not authorable, and params is not it. params
is the parameter DEFINITION array — ActionParam[], the dialog shown before
the action runs — on every action type, so params: { id: 'abc' } on a url
action is rejected at parse time. ${param.X} resolves against the values that
dialog collected; a value you already know at authoring time belongs
literally in the target string.
Two url-side readings of an object-form params existed in the renderer and
were retired by the 2026-08-10 ruling on
#6828 rather than
given a key: a statically authored ${param.X} scope (say it in target), and
params.newTab (say it with openIn: 'new-tab'). The refusal message names
both replacements. Note the asymmetry with type: 'api', where the object form
did get a key — bodyExtra, per
#5777 — because a
request payload has no other spelling; a url interpolation scope does.
Flow Actions
Invoke a Flow by name. Collect inputs with params:
name: approve_request
label: Approve Request
type: flow
target: approval_wizard
params:
- { name: approver, field: approver }
refreshAfter: trueAPI Actions
Call an endpoint. method defaults to POST; use PATCH/PUT/DELETE for other verbs.
name: sync_with_erp
label: Sync with ERP
type: api
target: /api/integrations/erp/sync
method: POST
params:
- { name: customer_id, field: id, defaultFromRow: true }
successMessage: Sync completed successfully
refreshAfter: trueRequest body shaping:
bodyShape—'flat'(default) sends collected params at the top level;{ wrap: 'data' }nests user params under that key.bodyExtra— constant fields merged into the request, applied last so they always win (e.g.bodyExtra: { resend: true }).recordIdParam/recordIdField— inject the current row id (or another row field) into a body key when the action runs from alist_itemlocation.
Form Actions
Open a FormView by name. The renderer routes to /_console/forms/:name:
name: quick_edit
label: Quick Edit
type: form
target: customer_quick_editWhat type: 'form' promises
type: 'form' is the platform's first-class way to open an object's form — the shape that replaced the type: 'modal' object fallback — so its contract is stated here rather than left to each renderer to decide (ruled 2026-08-10 on #7245):
- It renders in-shell. The internal form route nests inside the console shell: sidebar, app navigation and breadcrumb stay. A form action never drops the operator onto an unchromed standalone page with the browser Back button as the only way home. The route survives too —
/_console/forms/:namestays deep-linkable and bookmarkable, which is what a route buys you over a transient dialog. - A successful submit lands on the created record. On the internal path the default post-submit behavior is a redirect to the record that was just created, not a confirmation panel. The
thank-youpanel is the default for the public/_console/f/:slugpath only, where there is no record the anonymous submitter is allowed to see. - An explicit
submitBehavioron the FormView always wins, in either mode. The defaults above are what you get for declaring nothing; they are not a ceiling.
Public path (/_console/f/:slug) | Form action / internal path (/_console/forms/:name) | |
|---|---|---|
| Audience | anonymous visitors | authed operators — where type: 'form' sends them |
| Chrome | standalone page, no console chrome | nested inside the console shell |
Default when submitBehavior is omitted | thank-you confirmation panel | redirect to the created record |
The four submitBehavior kinds and their options are documented on the Forms guide.
Modal Actions
Open a modal/page by name. target is resolved as a page first, then as an object (which opens that object's create/edit form); when it names neither, the action falls through to its server-side handler.
name: edit_customer
label: Edit Customer
type: modal
target: customer_edit_modalA modal action may also declare params — the renderer collects them in a dialog before opening the target. It may not declare a body: see Script Actions above, and use type: script when the goal is to collect input and then run logic.
Action Configuration
Action Properties
ActionSchema defines the following fields:
interface Action {
// Identification
name: string; // Machine name, lowercase snake_case (required)
label: string; // Display label (required, i18n)
objectName?: string; // Owning object; auto-merged by defineStack()
// Display
icon?: string; // Lucide icon name
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link';
component?: 'action:button' | 'action:icon' | 'action:menu' | 'action:group';
locations?: ActionLocation[]; // Where the action surfaces
order?: number; // Sort order within a location group (lower = higher)
// Behavior
type?: 'script' | 'url' | 'modal' | 'flow' | 'api' | 'form'; // default 'script'
target?: string; // URL / script / flow / modal / endpoint
operation?: 'update'; // Declarative single-record field write (rides the default script route)
patch?: Record<string, unknown>; // Static field values for operation: 'update', merged under params
body?: HookBody; // Inline logic for type: 'script'
params?: ActionParam[]; // Inputs collected before execution
method?: 'POST' | 'PATCH' | 'PUT' | 'DELETE'; // for type: 'api'
// UX
confirmText?: string; // Confirmation message before execution
successMessage?: string; // Toast shown after success
errorMessage?: string; // Toast shown on failure (overrides the raw error)
undoable?: boolean; // Offer an Undo affordance after a single-record update succeeds
refreshAfter?: boolean; // Reload the view after execution (default false)
resultDialog?: ResultDialog; // One-shot reveal of API response values
shortcut?: string; // Keyboard shortcut, e.g. "Ctrl+S"
// Access
visible?: ExpressionInput; // Visibility predicate (CEL)
requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time
disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL)
// Bulk
bulkEnabled?: boolean; // Apply to multiple selected records
// AI (ADR-0011)
ai?: ActionAi; // Opt-in AI tool exposure
}An action has no aria block. Its accessible name is its required label: the
visible button or menu-item text, and the aria-label of an icon-only action.
To name the toolbar or list the actions sit in, author aria on the node that
places them (page.components[].aria or the list view's aria).
Action name is the configuration ID and must be lowercase snake_case (approve_request, not approveRequest or Approve Request). JavaScript function names referenced by body/target may still use camelCase.
Action Locations
The locations array declares where an action surfaces. The canonical enum (ACTION_LOCATIONS) is:
| Location | Where it renders |
|---|---|
list_toolbar | Header/toolbar of a list view (bulk actions, "New", export) |
list_item | Per-row action on a list/grid row |
record_header | Primary actions in the record-detail title bar |
record_more | Overflow ("More" / ⋯) menu on a record |
record_related | Per-row action on each row of a related-list section inside a parent record, in that parent's context only — unlike list_item, never on the object's own list views. Declared, not yet placed: the console does not render it on those rows yet |
record_section | Actions inside a body section/tab of a record |
name: export_customers
label: Export
icon: download
type: api
target: /api/customers/export
locations: [list_toolbar]Ordering & the Primary Button
Within each location group, actions render in order sequence — lower comes first / more prominent. In record_header the first visible action becomes the primary button and the rest fall into the ⋯ overflow menu, so order is how you decide which action holds the primary slot.
# Approve holds the primary button even though the app action registered first.
name: approve_request
label: Approve
locations: [record_header]
variant: primary
order: -10 # negative → floats ahead of app actions (order defaults to 0)Semantics:
orderdefaults to0. An action with a negativeorderis promoted toward the primary slot; a positiveorderis demoted toward the overflow menu.- The sort is stable. Actions that leave
orderunset (or tie on the same value) keep their original registration order, so addingorderto one action never reshuffles the others. Settingorderon nobody is a no-op — existing screens are unaffected. - No more fragile registration order. Previously "who becomes the primary button" depended on the cross-file order in which
defineStack({ actions })merged its arrays.ordermakes it declarative: a plugin such as plugin-approvals can inject anApprove/Rejectdecision with a loworderso it stably outranks app actions, instead of the app having to hide its other actions to make room. - When two actions tie on
order, the record-header renderer MAY additionally prefer the one withvariant: 'primary'when choosing the primary button.
order sorts actions within the same location; it does not move an action between locations. Use locations to choose where an action appears and order to choose its position there.
Visibility & Disabled Rules
visible and disabled are CEL predicates (not declarative { field, value } objects). disabled may also be a plain boolean.
# Show only while the record is a draft
name: edit_draft
label: Edit
type: form
target: customer_form
visible: "record.status == 'draft'"
# Disable while the order is unpaid
name: ship_order
label: Ship Order
type: api
target: /api/orders/ship
disabled: "record.status != 'paid'"Predicates are bare CEL — record.status == 'paid', not ${record.status == 'paid'} and not {record.status} == 'paid' (in CEL, {…} is a map literal). A ${…}-wrapped or brace-wrapped predicate is not evaluated as a boolean.
Evaluation context
Action predicates evaluate against the record the action is attached to. Both record.<field> and the bare field name resolve to the current record's value:
disabled: "record.status == 'converted'" # preferred — works on every surface
disabled: "status == 'converted'" # bare field — also resolves to the recordPrefer the record.<field> form: it reads unambiguously and resolves identically on every surface an action renders (record_header, record_more, list_item, related lists). The bare-field form is supported for brevity but is easy to confuse with a local variable.
Beyond record, action predicates also expose the authenticated user as ctx.user (e.g. record.id == ctx.user.id) and public feature flags as features.* (see Capability gates) — they are not limited to the record. record-alert conditions surface the analogous identity values under the os.* namespace (os.user / os.org / os.env) instead.
The bound record is SPARSE — guard every read with has(). Unlike a
validation or hook predicate, an action predicate is evaluated against whatever
record the client already fetched. On list_item that is the row the view's
$select projected, so a column the view did not ask for is absent, not
null — and CEL aborts the whole expression at key resolution (No such key: status). The abort is fail-closed, so the button simply is not offered, which
looks exactly like the predicate having said no. Nothing logs it.
what the predicate does with record.x | guard |
|---|---|
compares it to a literal (==, !=) | has(record.x) && record.x == 'draft' — has() alone. CEL compares heterogeneously, so a projected-null column answers false rather than faulting |
| traverses, calls a method, orders or does arithmetic | has(record.x) && record.x != null && record.x.size() > 0 — both halves |
| traverses into a nested block | guard the leaf: has(record.x) && has(record.x.k) && record.x.k == true. The leaf guard subsumes the parent != null |
tests truthiness (!record.x, bare record.x) | rewrite to the equality form — has(record.x) && record.x != true / == true. ! and the logical operators need a bool and fault on null |
The last row is the one that bites: visible: "!record.disabled" faults on
every row whose disabled column is stored NULL, so the button vanishes from
exactly the records it was meant for.
Capability gates: requiresFeature
When an action (or param) only works with an opt-in auth capability behind it — the better-auth admin plugin, phoneNumber, twoFactor, … — gate it with requiresFeature: '<flag>' instead of a hand-written features.* predicate. The flag names the public feature flag served at /api/v1/auth/config (see PUBLIC_AUTH_FEATURES in @objectstack/spec/kernel); at parse time the schema lowers it into the canonical visible predicate — features.X == true for opt-in flags, features.X != false for default-on flags — AND-composing with any explicit visible you also declare, then strips itself from the output. An unknown flag name fails the parse.
# Hidden entirely when the admin plugin is off (instead of a button that 404s)
name: ban_user
label: Ban User
type: api
target: /api/v1/auth/admin/ban-user
requiresFeature: admin
# Composes with a residual row predicate:
# (has(record.disabled) && record.disabled != true) && features.oidcProvider != false
name: disable_oauth_application
visible: "has(record.disabled) && record.disabled != true"
requiresFeature: oidcProviderThis is how the platform keeps "form follows plugin" (#2874): the UI never advertises an input the default backend would reject.
Confirmation & Feedback
Actions express confirmation and feedback through dedicated string fields — there is no nested confirm-dialog object with requireTyping/confirmLabel.
name: delete_customer
label: Delete Customer
type: api
method: DELETE
target: /api/v1/data/customer
mode: delete
variant: danger
confirmText: Are you sure you want to delete this customer? This cannot be undone.
successMessage: Customer deleted.
refreshAfter: trueconfirmText— message shown in a confirm dialog before the action runs. Param-LESS actions only. On a registered action, pairing it with a non-emptyparamsis refused at authoring time (#7428): the runner chains confirmation then param collection, so the pair opens two dialogs for one decision and the first already reads as "the action ran" while nothing has been sent. When the action collects params, put the question ondescriptioninstead — the param dialog renders it under its title, so the user gets one dialog with the question intact. Notai.description, which is the LLM-facing tool contract. (A view'sbulkActionDefsare a different surface, where the pair renders a single dialog and stays correct.)successMessage— toast shown after a successful run. When omitted the UI shows a generic "Action completed" toast, so set this for any action whose outcome isn't self-evident.errorMessage— toast shown when the action fails; overrides the raw server error with author-controlled copy.undoable— marks a single-record update action as offering an Undo affordance in the success toast, which restores the record's prior field values. The action runtime snapshots the record before the update and only builds the undo operation when this flag is set, so an action that omits it gets no Undo. Single-record updates only: there is nothing to snapshot when the action isn't scoped to one record.refreshAfter— reload the current view after success.mode— a semantic hint (create/edit/delete/custom). Pure metadata with no runtime branching: today only the AI confirmation heuristic reads it (adeletemode nudges an AI invocation toward requiring approval), while the UI does not branch on it.
# A reversible owner reassignment: custom success/error copy + Undo
name: reassign_lead
label: Reassign Lead
type: api
target: lead
params:
- { field: assigned_to, required: true }
undoable: true
successMessage: Lead reassigned.
errorMessage: Couldn't reassign this lead — try again.Result Dialog (one-shot reveals)
For API actions that return a value the user must copy now (a freshly minted secret, TOTP URI, or backup codes), resultDialog renders the response in an acknowledge-only dialog instead of a toast:
name: create_oauth_application
label: Register OAuth Application
type: api
method: POST
target: /api/v1/auth/sys-oauth-application/register
resultDialog:
title: Application Registered
acknowledge: I have saved the secret
fields:
- { path: client.client_id, label: Client ID, format: text }
- { path: client.client_secret, label: Client Secret, format: secret }Each fields[].path is a dot path into result.data. The per-field format (qrcode, code-list, secret, text, json) controls rendering.
⚠️ That target is not interchangeable with the SDK's registration door
POST /api/v1/auth/sys-oauth-application/register is an ObjectStack mount written for this form, and it is what keeps the YAML above this short. It accepts the application's display name as a plain name body key — the spelling an author writes as a param — and it splits a newline-separated redirect-URL textarea into the redirect_uris array the underlying provider schema requires. Both are form-shaped conveniences the mount performs on the action's behalf; the route ledger carries it as disposition: server-only (packages/plugins/plugin-auth/src/auth-route-ledger.ts) for exactly that reason, and #17210 ruled that it stays that way — no SDK method builds this URL.
So do not re-point this target at the provider's own registration route, /api/v1/auth/oauth2/create-client (what @objectstack/client's oauth.applications.register posts). Driven on a real socket, the two doors answer the same body differently: the mount above returned 200, mapped name onto client_name, and set the sys_oauth_application row's name; the provider route returned 201 with the value stripped — absent from the response, from oauth.applications.get, from oauth.applications.list, and null in that column. An action retargeted there needs its name param renamed to client_name, and its redirect URLs split into an array before the post — the textarea param would arrive as one unsplit string.
Action Parameters
params declares inputs collected from the user before execution. Each entry is an ActionParam with two modes:
- Field-backed (preferred) — reference an object field with
field; the runtime resolves label, type, validation, and options from object metadata. Cross-object references useobjectOverride. - Inline — declare
name,label,type, etc. directly when no matching field exists.
name is required unless field is provided (in which case name defaults to the field name and is used as the request-body key).
params:
# Field-backed: inherits everything from customer.email
- field: email
# Field-backed, pre-filled from the selected row
- { field: client_id, defaultFromRow: true, required: true }
# Inline select with explicit options
- name: priority
label: Priority
type: select
required: true
options:
- { label: Low, value: low }
- { label: High, value: high }ActionParam fields: name, field, objectOverride, label, type, required (default false), options, placeholder, helpText, defaultValue, defaultFromRow, visible (CEL predicate — the dialog omits the param when false), requiresFeature (capability gate, lowered into visible — see Capability gates).
AI Tool Exposure (ADR-0011)
An action can be exposed as an AI-callable tool. Exposure is opt-in and default off — set ai.exposed: true, which then requires ai.description (≥ 40 chars, the LLM-facing contract).
name: merge_duplicate_customers
label: Merge Duplicates
type: api
target: /api/customers/merge
ai:
exposed: true
description: Merge two duplicate customer records into one, keeping the primary record's identity and reparenting all related records.
category: action
requiresConfirmation: truecategory— overrides the derived tool category (data,action,flow,integration,vector_search,analytics,utility).paramHints— per-parameter hints (keyed by param name orrecordId) that tighten the JSON Schema the model sees without changing UI metadata.outputSchema— JSON Schema for the return value. The cloud AI runtime validates the action's result against it and withholds a result that does not conform; a schema it cannot enforce is refused before the action runs.requiresConfirmation— override the human-in-the-loop gate for AI invocations.
Real-World Examples
Disable / Enable via API
- name: disable_oauth_application
label: Disable OAuth Application
icon: pause-circle
variant: secondary
mode: custom
locations: [list_item, record_header]
type: api
method: POST
target: /api/v1/auth/admin/oauth2/toggle-disabled
# The confirm question rides `description`, not `confirmText`: this action
# collects params, and pairing the two keys is refused (#7428) because it
# would open two dialogs for one decision.
description: Disable this OAuth application? Existing integrations will stop working immediately.
params:
- { name: client_id, field: client_id, defaultFromRow: true, required: true }Create via API with secret reveal
- name: rotate_client_secret
label: Rotate Client Secret
icon: refresh-cw
variant: secondary
mode: custom
locations: [record_header, record_more]
type: api
method: POST
target: /api/v1/auth/sys-oauth-application/rotate-secret
# Three dialogs collapse to two, and the survivors are the two the user needs:
# ONE param dialog (question + `client_id`), then the post-run `resultDialog`
# that reveals the new secret. A post-run reveal is not a second pre-run
# decision, so it is not part of the pair `description` replaces (#7428).
description: Rotate this application's client secret? The current secret stops working immediately, and the new one is shown only once.
params:
- { name: client_id, field: client_id, defaultFromRow: true, required: true }
resultDialog:
title: New Client Secret
acknowledge: I have saved this
fields:
- { path: client_secret, label: Client Secret, format: secret }What's Next?
Layout DSL
Learn how to structure pages and sections
Widget Contract
Understand component props and events
Related Resources
- Action Reference - Auto-generated
ActionSchemareference - View Reference - List and form view metadata
- Hook Bodies - Authoring
bodyexpressions and sandboxed JS - Flow Builder - Build multi-step flows
Widget Contract
Build a custom ObjectUI field widget: the standard props, events and lifecycle it implements, how to register it, and accessibility and theme rules.
record:alert — banners on record pages
Surface contextual notices (unverified email, expired trial, locked account…) at the top of any record page using the slotted page schema.