| name | string | ✅ | Machine name (lowercase snake_case) |
| label | string | Record<string, string> | ✅ | Display label |
| description | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string> | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing ai.description. It is a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over record, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for {{ record.name }}.'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. |
| objectName | string | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). |
| icon | string | optional | Icon name |
| locations | Enum<'list_toolbar' | 'list_item' | 'record_header' | 'record_more' | …>[] | optional | Locations where this action is visible |
| component | Enum<'action:button' | 'action:icon' | 'action:menu' | 'action:group'> | optional | Visual component override |
| type | Enum<'script' | 'url' | 'modal' | 'flow' | 'api' | 'form'> | optional (default: "script") | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is operation: 'update' + patch on the default script route. |
| target | string | optional | URL, Script Name, Flow ID, or API Endpoint. Supports ${param.X} and ${ctx.X} interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused. |
| openIn | Enum<'self' | 'new-tab'> | optional | For type:'url' — where to open target. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of params (which is user-input-collection only). |
| body | { language: 'expression'; source: string } | { language: 'js'; source: string; capabilities?: Enum<'api.read' | 'api.write' | 'api.transaction' | 'crypto.uuid' | 'log'>[]; timeoutMs?: integer; … } | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is script. |
| operation | Enum<'update'> | optional | The declarative single-record field write, mirroring a list view's bulkActionDefs: 'update' applies patch (merged under the collected params) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. type stays at its default 'script' (the platform action route the write is performed on); target/body/method/bodyExtra are refused beside it. 'delete' and 'custom' have no row-level form. |
| patch | Record<string, any> | optional | For operation: 'update' — static field values written to the current record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without operation: 'update' (it would be silently dropped). |
| execution | Enum<'perRecord' | 'aggregate'> | optional | The bulk dispatch contract this action's BODY is written for, in bulkActionDefs' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's recordId (the view's bulkActions: ['<name>'] bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in params._selectedIds (a bulkActionDefs entry with execution: 'aggregate'). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by @objectstack/lint (action-dispatch-contract-mismatch). |
| params | { name?: string; field?: string; objectOverride?: string; label?: string | Record<string, string>; … }[] | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in bodyExtra). A param name (its name, or the field a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in visible / requiresFeature, e.g. one optional and one required); the params check treats the name as required only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (type, multiple, options), so keep that shape identical across them. |
| submitText | string | Record<string, string> | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.submitText (globalActions.ACTION.submitText for an object-less action). Only on an action with a non-empty params: without one no param dialog opens, so it is refused. Not confirmText, which is the message of a param-less action's confirm dialog. A blank label is refused. |
| cancelText | string | Record<string, string> | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares confirmText, or the param dialog of an action with a non-empty params — in place of the default "Cancel". Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.cancelText (globalActions.ACTION.cancelText for an object-less action). Only on an action that opens one of those dialogs: with neither confirmText nor a non-empty params no dialog opens, so it is refused. The dismiss twin of submitText, as on object-form. A blank label is refused. |
| variant | Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'link'> | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| order | number | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without order keep their registration order. |
| confirmText | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string> | optional | Confirmation message before execution — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over record, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's params is an object of static values — params ({{ params.tier }}); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete {{ record.name }}?'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. Pairing this with a non-empty params is refused — that opens a second dialog for one decision; put the question on description instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
| successMessage | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string> | optional | Success message shown after the action succeeds — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over result, the payload the action's handler answered ({{ result.email }}; for an api or script action the server response), and record, the record the action ran against. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to {{ result.email }}'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. On a registered action that declares outcomeMessages, the entry named by the response's outcome is shown instead, and this message is the fallback. |
| outcomeMessages | Record<string, string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string>> | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case outcome values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message is a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over result, the payload the action's handler answered ({{ result.email }}), and record, the record the action ran against — the roots successMessage reads. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation resent to {{ result.email }}'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. An outcome with no entry here falls back to successMessage, then to the default text. Not allowed beside resultDialog (which suppresses the success toast) or operation: 'update' (no handler, so no outcome). |
| errorMessage | string | Record<string, string> | optional | Error message to show when the action fails (overrides the raw error). |
| refreshAfter | boolean | optional (default: false) | Refresh view after execution |
| undoable | boolean | optional | Offer an Undo affordance after this single-record update action succeeds. operation: 'update' is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, patch UNDER the collected params, not patch alone. An action with no operation declares no write set here, but type: 'api' is fulfilled by the console, which builds the undo envelope from undoable alone. Those two shapes — operation: 'update' and type: 'api' — are the whole fulfillable set; on a registered action undoable beside any other shape is refused, because nothing would anchor the capture. |
| resultDialog | { title?: string | Record<string, string>; description?: string | Record<string, string>; acknowledge?: string | Record<string, string>; format?: Enum<'qrcode' | 'code-list' | 'secret' | 'text' | 'json'>; … } | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| visible | boolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| requiresFeature | Enum<'twoFactor' | 'organization' | 'multiOrgEnabled' | 'degradedTenancy' | …> | optional | Public auth feature flag gating this action; lowered into visible at parse time. |
| requiresMembershipReach | Enum<'invite_member' | 'cancel_invitation' | 'update_member_role' | …> | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into visible over current_user.positions at parse time. |
| disabled | boolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Disabled predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
| disabledReason | string | Record<string, string> | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while disabled evaluates TRUE — the default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.disabledReason (globalActions.ACTION.disabledReason for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, requiredPermissions), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. |
| pressed | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Pressed-state predicate (CEL string or {dialect, source} envelope) over the same scope as visible: the action surface draws the button as already pressed for this reader (a toggle face, aria-pressed) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under attachedOnRead, as has(record.viewer) && record.viewer.vote == 'helpful'. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. |
| requiredPermissions | string[] | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (POST /actions/:object/:action and MCP run_action) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a type: api action whose declared target is a same-origin platform path (exactly one leading /, followed by neither / nor a backslash, with a path after it) under that door; its target endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A type: api action with an off-origin target is browser-only and not gated server-side. |
| ai | { exposed?: boolean; description?: string; category?: Enum<'data' | 'action' | 'flow' | 'integration' | 'vector_search' | 'analytics' | 'utility'>; paramHints?: Record<string, object>; … } | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. |
| recordIdParam | string | optional | Body key to inject the row id into when running from a list_item context. |
| recordIdField | string | optional | Row field whose value seeds recordIdParam. Defaults to "id". |
| bodyShape | 'flat' | { wrap: string } | optional | Body wrapping: flat (default) or { wrap: key } to nest user-collected params under a key. |
| method | Enum<'POST' | 'PATCH' | 'PUT' | 'DELETE'> | optional | HTTP method for type:"api" actions. Defaults to POST. |
| bodyExtra | Record<string, any> | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). {{page.<var>}} tokens are resolved by the runtime. This — not params — is where a payload goes. |
| mode | Enum<'create' | 'edit' | 'delete' | 'custom'> | optional | Semantic mode of the action. |
| opensInNewTab | boolean | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. |
| newTabUrl | string | optional | New-tab path template on this backend, starting with / ({recordId} placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'. |
| onSuccess | { navigate: string; openIn?: Enum<'self' | 'newTab'> } | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. navigate is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response; for a flow, the completed run's answer); openIn defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior. |
| aria | never | optional | [REMOVED] action.aria was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's label and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required label — the visible button or menu-item text, and the aria-label of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author ariaLabel / ariaDescribedBy / role in the aria block of the placing node: page.components[].aria (the component that renders the actions) or the list view aria. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string | optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> | optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> | optional | Origin of the item (package | org | env-forced). |
| _packageId | string | optional | Owning package machine id. |
| _packageVersion | string | optional | Owning package version. |
| _lockDocsUrl | string | optional | Optional documentation link surfaced next to _lockReason. |
| overrides | { package: string; basedOn: string } | optional | Override declaration (ADR-0146 D2): { package, basedOn } — this item of "<base>.custom" replaces the base package's same-named item whole. basedOn is recorded and never judged. |