ObjectStackObjectStack

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: true

Benefits:

  • 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.

TypeBehaviortarget required?
scriptRun an inline body (CEL expression or sandboxed JS), or a registered handler named by target.No (prefer body)
urlNavigate to a URL.Yes
flowInvoke a Screen/automation Flow by name.Yes
modalOpen a modal/page by name.Yes
apiCall an API endpoint (method defaults to POST).Yes
formOpen 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: true

API 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: true

Request 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 a list_item location.

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_edit

Open a modal/page by name:

name: edit_customer
label: Edit Customer
type: modal
target: customer_edit_modal

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
  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:

LocationWhere it renders
list_toolbarHeader/toolbar of a list view (bulk actions, "New", export)
list_itemPer-row action on a list/grid row
record_headerPrimary actions in the record-detail title bar
record_moreOverflow ("More" / ⋯) menu on a record
record_relatedActions on a related-list section inside a record
record_sectionActions inside a body section/tab of a record
global_navGlobal 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:

  • order defaults to 0. An action with a negative order is promoted toward the primary slot; a positive order is demoted toward the overflow menu.
  • The sort is stable. Actions that leave order unset (or tie on the same value) keep their original registration order, so adding order to one action never reshuffles the others. Setting order on 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. order makes it declarative: a plugin such as plugin-approvals can inject an Approve/Reject decision with a low order so 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 with variant: '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 CELrecord.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 record

Prefer 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: oidcProvider

This 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: true
  • confirmText — 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 via Ctrl+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:

  1. Field-backed (preferred) — reference an object field with field; the runtime resolves label, type, validation, and options from object metadata. Cross-object references use objectOverride.
  2. 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: true
  • category — overrides the derived tool category (data, action, flow, integration, vector_search, analytics, utility).
  • paramHints — per-parameter hints (keyed by param name or recordId) 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?

On this page