Action Protocol
Buttons, triggers, navigation, and user interaction definitions
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. | Yes |
target is the canonical binding for every non-script type. The deprecated execute field is auto-migrated to target during parsing.
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.
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: link_social
label: Connect Account
type: url
target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account'To open the result in a new tab, set opensInNewTab: true (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.
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_editModal Actions
Open a modal/page by name:
name: edit_customer
label: Edit Customer
type: modal
target: customer_edit_modalAction 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
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
// Misc
timeout?: number; // Max execution time (ms)
aria?: AriaProps; // Accessibility attributes
}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 | Actions on a related-list section inside a record |
record_section | Actions inside a body section/tab of a record |
global_nav | Global navigation / command-palette actions |
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.
This is a narrower scope than record-alert conditions (which also expose os.user / os.org / os.env) — action predicates see the record only.
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:
# (!record.disabled) && features.oidcProvider != false
name: disable_oauth_application
visible: "!record.disabled"
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.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— for a single-record update action, offer an Undo affordance in the success toast (and viaCtrl+Z). The runtime captures the record's prior values before the write and restores them on undo. Only meaningful for reversible single-record mutations.refreshAfter— reload the current view after success.mode— a semantic hint (create/edit/delete/custom) the UI uses to pick confirm copy and default variants.
# 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.
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, enabling structured tool chaining.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
confirmText: 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
confirmText: Rotate this application's client secret? The current secret stops working immediately.
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