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

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

  1. 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/:name stays deep-linkable and bookmarkable, which is what a route buys you over a transient dialog.
  2. 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-you panel is the default for the public /console/f/:slug path only, where there is no record the anonymous submitter is allowed to see.
  3. An explicit submitBehavior on 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)
Audienceanonymous visitorsauthed operators — where type: 'form' sends them
Chromestandalone page, no console chromenested inside the console shell
Default when submitBehavior is omittedthank-you confirmation panelredirect 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.

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_modal

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

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

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: 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. Param-LESS actions only. On a registered action, pairing it with a non-empty params is 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 on description instead — the param dialog renders it under its title, so the user gets one dialog with the question intact. Not ai.description, which is the LLM-facing tool contract. (A view's bulkActionDefs are 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 (a delete mode 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:

  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
  # 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?

On this page