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 inside the console shell; the submit lands on the created record. | Yes |
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 rewrite existing sources automatically — 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: 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'For 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.
Current renderer status (2026-08-10). The console shipping at the pinned
.objectui-sha does not implement this contract yet: it renders
/console/forms/:name as a top-level page outside the shell, and it falls back
to the thank-you panel in both modes. The statement above is the contract —
the renderer half is tracked in ObjectUI and linked from the
#7245 thread. Until
it lands, an internal FormView that must land on its new record has to declare
submitBehavior explicitly.
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
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
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 |
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.
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. 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.
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
# 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