Actions — permission-checked UI buttons
Declare buttons as metadata with server-side behavior — bind them to lists, records and navigation, permission-check both sides, and expose them to AI.
An action is a button declared as metadata: where it appears
(locations), when it's visible (visible), who may run it
(requiredPermissions), and what it executes — an inline sandboxed script, a
registered server handler, a flow, or a URL. The same declaration renders in
the Console, executes over REST, and (with an explicit opt-in) becomes an AI
tool over MCP.
The types you'll actually use:
type | What it does | Server behavior |
|---|---|---|
script (default) | Run server-side logic | Inline body or a handler registered via target |
flow | Launch a flow (e.g. a screen-flow wizard) | target names the flow |
url | Navigate / open a link | target is the URL (${ctx.record.id} interpolation supported) |
modal | Open a modal page — client-side only, no server dispatch | target names the modal page (to collect input and run logic, use script + params) |
api | Call an HTTP endpoint directly | target is the endpoint; method / bodyShape / bodyExtra shape the request |
form | Open a form view, prefilled with the current record — in-shell, and the submit lands on the created record (contract) | target names the FormView; routed to /forms/:target?recordId=… |
Prefer confirmText/params over the two action properties that are not
wired: shortcut (nothing dispatches keydown events to actions) and
bulkEnabled (the multi-select toolbar reads the list view's bulkActions,
not this flag). The
liveness ledger
carries the per-property status of everything the action schema accepts.
Define your first action
From the bundled Todo example — a "Mark Complete" button on the task list and record header:
import { defineAction } from '@objectstack/spec/ui';
export const CompleteTaskAction = defineAction({
name: 'complete_task',
label: 'Mark Complete',
objectName: 'todo_task', // which object this action belongs to
icon: 'check-circle',
type: 'script',
target: 'completeTask', // resolved to the handler registered below
locations: ['record_header', 'list_item'],
successMessage: 'Task marked as complete!',
refreshAfter: true,
ai: {
exposed: true,
description: 'Mark a todo task as complete. Use when the user says a task is done or finished.',
},
});Register it in your stack — top-level actions carrying an objectName are
merged into that object automatically (and ordered by order):
export default defineStack({
// ...
actions: Object.values(actions),
});Give it server behavior — two paths
Path A: inline body (sandboxed)
Self-contained logic ships inside the metadata and runs in the server sandbox
— signature (input, ctx), with ctx.api.object(name) for data access, a
5-second default timeout, and declared capabilities:
export const MarkDoneAction = defineAction({
name: 'showcase_mark_done',
label: 'Mark Done',
objectName: 'showcase_task',
type: 'script',
body: {
language: 'js',
source:
"var id = ctx.recordId || (ctx.record && ctx.record.id);" +
"if (!id) throw new Error('No record to mark done');" +
"await ctx.api.object('showcase_task').update({ id: id, done: true, progress: 100 });" +
"return { ok: true, id: id };",
capabilities: ['api.write'],
},
successMessage: 'Task marked done.',
visible: 'has(record.done) && record.done != true',
locations: ['list_item', 'record_header', 'record_section'],
refreshAfter: true,
});Body-carrying actions are registered automatically at boot.
ctx.record is read-only — persist through ctx.api.
ctx.record is the record the dispatcher pre-fetched before the action ran: a
snapshot the runtime never writes back. Assigning to it changes a copy that
dies with the sandbox, and the action still returns success:
ctx.record.done = true; // ❌ discarded — even though `done` is a declared field
return { ok: true }; // the action reports success, the record is unchanged
await ctx.api.object('showcase_task').update({ id: ctx.recordId, done: true }); // ✅ persistsThis holds for declared fields too — it is not a spelling problem, so no
did-you-mean will appear. Do not reason from ctx.input, which is written
back in a hook body; an action's output is its
return value and its write channel is ctx.api (declare
capabilities: ['api.write']).
You will be told rather than left guessing. os validate / os lint /
os compile raise action-record-write-discarded, and the sandbox logs the
discarded fields at invocation time — the latter also catching computed keys,
aliases and bodies authored in Studio, which no lint ever inspects.
Both report only writes that reach nothing. Building a payload on the snapshot and then persisting it is a normal, live pattern and stays quiet:
ctx.record.done = true;
await ctx.api.object('showcase_task').update(ctx.record); // ✅ lands — not reportedPath B: a registered handler (full TypeScript)
For logic that belongs in real source files, point target at a handler name
and register it in your config's onEnable lifecycle hook:
import type { ActionHandlerContext } from '@objectstack/spec/ui';
export async function completeTask(ctx: ActionHandlerContext): Promise<void> {
const { record, engine } = ctx; // ctx = { record, params, user, session, engine }
await engine.update('todo_task', record.id as string, {
status: 'completed',
});
}Annotate ctx with the published ActionHandlerContext, never a local copy
of it: a hand-rolled context interface drifts from the contract the moment either
side moves, and the drift is silent because the copy is what your handler is
checked against. (The contract's own docblock asks for ActionHandler; that is a
function type, so it fits an arrow-function handler assigned to a const, while
a function declaration like the one above annotates its parameter instead.)
Write only the fields the action owns — and read that literally, because a
handler's ctx.engine runs elevated (isSystem). The read-only strip that
protects a server-owned column from an ordinary caller is gated on not being
a system write, so it does not run on a handler's write at all. What a
handler names, lands. A server-owned field is therefore more dangerous here
than in a form PUT, not less.
completed_date is the example. It is readonly on todo_task — the server
owns it, and the object's beforeUpdate hook stamps it on the completion
transition. Adding completed_date: new Date().toISOString() to the write
above looks harmless, and on that transition it is: the hook overwrites it. But
a completion write that is not a transition — re-completing a task that is
already completed — is not stamped, so nothing overwrites the handler's value
and nothing strips it either, and the action silently replaces the real
completion timestamp with "now". That is why the snippet sends status alone.
ctx.engine.find(object, query) takes the engine's query envelope. The
second argument is the same options bag engine.find takes everywhere else —
the filter goes under where, and fields, orderBy, limit, offset and
expand mean what they mean on the engine. One platform, one query shape.
await ctx.engine.find('todo_task', { where: { status: 'completed' } });
await ctx.engine.find('todo_task', { where: { amount: { $gt: 100 } }, fields: ['id', 'subject'], limit: 50 });
await ctx.engine.find('todo_task', {}); // the unfiltered readThe parameter is typed EngineQueryOptions (ActionEngineFacade in
@objectstack/spec/ui), so if you annotate ctx with the published
ActionHandlerContext a bare filter is a compile error at the call site —
{ status: 'completed' } has nowhere to land, and neither does a filter held in
a FilterCondition variable. A hand-written test double must honour the envelope
too.
One envelope key is not on this parameter: context. A handler's
ctx.engine is trusted and mints its own elevated ExecutionContext, so a
context you write here would be overridden, never honoured — and writing one
reads as narrowing a query to an identity or a tenant when it does nothing of
the kind. It is subtracted from the type, so ctx.engine.find('todo_task', { where: { … }, context: { tenantId: 'org_acme' } }) is a compile error rather
than a silent no-op. To scope a read, put the scope in where. (An untyped
handler still passes the key and still has it overridden, with no error — one
more reason to annotate ctx.)
If you do not annotate it — a handler in an objectstack.config.js /
.mjs, your own copy of the context type, or (ctx: any) — the facade refuses
the bare filter at runtime instead, naming the stray key and prescribing the
same fix. That runtime refusal is what stops { deleted_at: null } from being
dropped unexecuted and quietly widening the read to every row.
Upgrading? This parameter used to be the where half on its own, and the
runtime wrapped it. find(o, f) becomes find(o, { where: f }); an unfiltered
find(o, {}) is unchanged. The old shape had the trap the other way round:
writing the engine's own envelope produced { where: { where: … } }, which
matched no row and returned [] with no error at all.
ctx.engine.delete(object, id) takes one id — or an array of them. Both
spellings are contract: ActionEngineFacade in @objectstack/spec/ui declares
the parameter string | string[], so a handler that already holds a list does
not have to unroll it into a loop to stay on the contract.
await ctx.engine.delete('todo_task', record.id as string); // one row
await ctx.engine.delete('todo_task', ['tsk_0001', 'tsk_0002']); // a setThe array form is a convenience over the same per-row path — not a bulk and not an atomic delete. The runtime issues one delete per id, in order, with no transaction around the set: a failure part-way through leaves the ids before it deleted and the ids after it untouched, and the rejection you see is the one that stopped it. An empty array deletes nothing and resolves.
export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => {
ctx.ql.registerAction('todo_task', 'completeTask', completeTask);
};The "dead button" trap: only actions with an inline body register
themselves. A script action whose target names a handler that was never
registerAction-ed compiles fine but throws Action 'x' on object 'y' not found at click time. (An action with neither body nor target is
rejected at authoring time.) Also note that both data surfaces a body reaches
— ctx.api.object(name) and the handler's ctx.engine facade — are
trusted: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself — and
read Authorization inside an action before
you write that guard, because ctx.record.id is present even when the caller
cannot read the row.
body belongs to script only. Every other type dispatches on target
— the page to open, the URL, the flow, the endpoint — so a body on a
modal/url/flow/api/form action would never be invoked. Writing one
(typically type: 'modal' with params and a body, expecting the body to
run when the modal is submitted) is rejected at authoring time rather than
shipping a button that opens a modal and silently writes nothing. To collect
input and run logic, use type: 'script' with params — the same dialog
is collected, then the body runs with those values as its input.
Bind it to the UI
locations is the primary binding — the action appears wherever it declares:
| Location | Where the button renders |
|---|---|
list_toolbar | List view toolbar (no record context) |
list_item | Per-row menu in list views |
record_header | Record page header |
record_more | Record page overflow ("…") menu |
record_related | Each row of a related list inside a parent record — there only, never on the object's own list views (unlike list_item). Declared, not yet placed: the console does not draw it on those rows yet |
record_section | Named action bars on record pages |
Surfaces can also reference actions by name:
// List views — row and bulk menus. These are LIST-VIEW keys, so they nest
// under `list` (or a `listViews` entry) — never at the container top level:
// `ViewSchema` is a strict object and rejects them there.
defineView({
// ...
list: {
// ...
rowActions: ['complete_task'],
bulkActions: ['showcase_bulk_reassign'],
},
});
// Record pages — a quick-actions bar
{ type: 'record:quick_actions',
properties: { location: 'record_section', actionNames: ['showcase_mark_done'] } }
// App navigation — an action as a nav item
{ type: 'action', actionDef: { actionName: 'crm_convert_lead' } }
// App navigation — deep-link auto-run on an object entry: land on the
// object's list surface, then run the declared action once
{ type: 'object', objectName: 'sys_environment', runAction: 'create_environment' }Naming an action in a widget does not bypass location filtering — the
engine still requires the action to declare the matching location (that's why
MarkDoneAction above includes record_section).
The selection bar is the exception, and the only one: an action named in a
list view's bulkActions or bulkActionDefs is placed by that declaration,
not by locations. That is what the retired action.bulkEnabled tombstone
prescribes ("the multi-select toolbar is driven by the LIST VIEW's
bulkActions / bulkActionDefs"), and it is what lets an aggregate bulk
action — one that acts on a whole selection and has no single-record home by
construction — exist at all.
Declare the bulk dispatch contract
A list view can wire the same action two ways, and the two hand your body opposite input:
| wiring | dispatches | the body receives |
|---|---|---|
bulkActions: ['<name>'] | once per selected row | that row's recordId, no _selectedIds |
bulkActionDefs: [{ name: '<name>', operation: 'custom', execution: 'aggregate' }] | once for the whole selection | params._selectedIds: string[], no recordId |
Say which one you wrote the body for, with the same key and the same two values the def uses:
defineAction({
name: 'export_zip',
type: 'api',
target: '/api/v1/export/zip',
execution: 'aggregate', // this body reads params._selectedIds
});os validate / os lint / os build then refuse a list view that wires it the
other way (action-dispatch-contract-mismatch), naming the action, the view and
both contracts. Without the declaration both mismatches fail silently, in
opposite directions: an aggregate body wired bare-string reads _selectedIds
as undefined, takes its single-record branch and reports success for one row
out of ten; a per-record body wired aggregate finds no recordId and throws its
own "nothing selected", which reads like a selection bug. Nothing else can catch
it — recordId and _selectedIds are both built-in action params, so the
strict params gate admits either bag without a word.
There is no default. An action that omits execution is checked against
neither wiring — which is also the honest declaration for a body written to
serve both contracts (it reads recordId and _selectedIds). There is no
third value for "both": one call and N calls have different side effects, so
if the two wirings want different behaviour they are two actions.
Upgrading an existing app? os migrate meta emits the
action-bulk-dispatch-contract-undeclared TODO, which derives the
declaration from the wirings you already have.
Collect input and shape the UX
params— prompt the user for input before execution. Prefer field-backed params ({ field: 'due_date' }) which inherit the object field's label, type, validation, and widget config; inline params ({ name, label, type, required, options }) cover the rest.defaultFromRowprefills from the current record. The console renders each param through the same field widgets as the record form (objectui ADR-0059), so anyFieldTypeworks — afileparam shows a real upload control (multiple/accept/maxSizehonored),lookupa record picker,datea date picker,richtextthe rich-text editor, and so on.confirmText— confirmation dialog before running.successMessage/errorMessage/refreshAfter— post-run feedback and an automatic data refresh.undoable— on a single-record update, the success toast offers an Undo that restores the record's prior field values (Ctrl+Zworks too). The runtime only captures the prior values when this flag is set, so an action that omits it gets no Undo.resultDialog— a one-time reveal dialog for output the user must copy (generated tokens, export links).variant/icon/order— presentation and sort position.
params is an array of parameter definitions, never a map of values.
Writing params: { … } is rejected at authoring time, and what to write
instead depends on the action's type:
| You wanted | Write this instead |
|---|---|
A static request body for type: 'api' | bodyExtra: { name: '{{page.inquiryName}}' } — merged last, {{page.<var>}} tokens resolved by the runtime |
A value to interpolate into a type: 'url' target | Put it in the target string itself. ${param.X} interpolates a value the params dialog collected; ${ctx.X} one from the action context |
A new tab for type: 'url' | openIn: 'new-tab' (for an async handler that redirects, use opensInNewTab instead) |
There is no object form of params on a url action to migrate to: the two
things it used to mean in the renderer — a statically authored ${param.X}
scope, and a params.newTab flag — were retired, not renamed
(#6828). Both are
already expressible with the keys above, so a third meaning of params earns
nothing; if you have a case the target string genuinely cannot express, that
is a spec proposal for a properly named key, not a values map under this one.
Permissions and visibility
-
requiredPermissions: ['can_close_tickets']is a dual-surface gate (one declaration, two enforcement points): the server rejects unauthorized calls with 403, and the UI hides or disables the button for the same users. Unset means no gate beyond object CRUD permissions — except that aflowaction whose target is an elevated self-triggered flow is refused to every caller but the system principal either way (theflowrow below). Referenced capabilities must exist —os lintchecks that. -
visibleis a CEL predicate evaluated fail-closed: an expression that throws hides the action silently. Two rules save real debugging time:- Always prefix record fields —
record.status != "closed", never a barestatus, which faults as an undeclared identifier. - Guard with
has()— a record-scoped predicate onlist_itembinds a LIST ROW, which carries only the columns that view projects. Reading a field the list does not show aborts the expression withNo such key, and fail-closed means the button simply is not offered — indistinguishable from the gate having said no.has(record.x) && …answersfalseinstead.
Add
&& record.x != nullonly when the value is then traversed (record.x.k), called (record.x.size()), ordered (<<=>>=), negated (!record.x) or used within— those fault on a projected NULL. A plain==/!=against a literal never does, sohas()alone is the whole guard there. Prefer the minimal form: an over-guarded predicate is the pattern the next author copies.Compound
&&/||predicates are fully supported — see the formulas guide for CEL syntax. - Always prefix record fields —
-
requiresFeatureties visibility to a feature flag (compiled into avisiblepredicate).
Authorization inside an action
An action body and a registered handler both run elevated: ctx.api and
ctx.engine carry isSystem, so they bypass row- and field-level security by
design (that is what lets an action do work the caller cannot do directly). The
consequence is that any caller-specific rule has to be re-established inside
your handler — and the predicate most people reach for first does not work:
if (!ctx.record?.id) return refuse(); // ❌ always false — never refuses anythingBefore dispatch, the platform loads the subject row in your caller's own
scope. If that read comes back empty — the row is invisible to them under
row-level security, or the id names nothing — the dispatcher still puts the
recordId from the request onto ctx.record.id, because a new-record /
record-less action legitimately needs it there. So ctx.record.id is present
either way, and the guard above passes for a caller who cannot see the row.
The signal that does distinguish them is ctx.recordLoadDenied:
// ✅ the caller-scope load did not deliver the row — do not act on it
if (ctx.recordLoadDenied) {
throw Object.assign(new Error('Record not available'), { code: 'RECORD_NOT_FOUND' });
}ctx.record.id | ctx.recordLoadDenied | |
|---|---|---|
| Caller can read the row | the id | absent |
| Caller cannot read the row | the id (stamped) | true |
| New-record / record-less action | the id, if one was passed | absent |
Read it as ctx.recordLoadDenied === true. The key is absent, never
false, when nothing was refused — so an action that never loads a row (no
recordId, or an object-less action) never trips the guard.
It reports "the row did not resolve for this caller", not "the platform saw
an authorization error". A row hidden by row-level security and an id that names
nothing both arrive as RECORD_NOT_FOUND, deliberately — the platform does not
disclose whether a record you cannot see exists — and the flag does not pretend
to separate what that read fuses. For an authorization decision they are the
same answer: this caller has not demonstrated read access to that row.
Both invocation doors set it — POST /api/v1/actions/... and the MCP
run_action tool — and it reaches inline body sandboxes and registered
handlers alike.
Set ctx.recordLoadDenied aside only when your action is meant to run without
a readable subject row (an "import this id from elsewhere" action, say). The
default for a row-scoped action on a private object is to refuse.
Flow actions. A type: 'flow' action hands its target flow the same
record — record.id stamped the same way — and the flow run's context
(AutomationContext, the @objectstack/spec contract) declares the same key
in the same spelling, recordLoadDenied?: true, so a runAs: 'system' flow
has something to guard on before it acts on a row its invoker could not read.
On the flow face the key is populated since #15168: dispatchFlowAction
takes the producer's load outcome and spreads the signal into the context it
hands automation.execute, on both doors — the REST /actions endpoint and
the MCP run_action bridge — so a flow run receives this key exactly when a
handler would. A runAs: 'user' flow needs no guard — it re-derives the
caller's scope on its own reads, and the stub resolves to nothing.
Call it over REST
Every action is also an endpoint — the Console button and the API call run the same gate and handler:
curl -b cookies.txt -X POST \
https://your-app.example.com/api/v1/actions/todo_task/complete_task \
-H "Content-Type: application/json" \
-d '{ "recordId": "rec_123", "params": {} }'
# → 200 { "success": true, "data": <your handler's return value> }The URL names the action by its name, never by target. target binds
the action to whatever runs it — a handler key here, a flow id for
type: 'flow', a URL for type: 'url' — so it is an implementation detail:
the server resolves your declaration by name and derives the handler key from
it. Rename the underlying function freely; as long as the declaration's
target follows, the public URL is unchanged.
Failures speak HTTP — the status code is the signal (#3962):
- 400 — the action ran and rejected (a business rule said no, or
validation failed — then with
error.details.fields[]to anchor the input). - 404 / 403 / 503 — it never dispatched: no such action, denied, service
unavailable. A
url/modal/form/apitype with no server dispatch is also a 400. - 500 — it crashed: a
TypeErrorin your handler, a driver error, a sandbox timeout. A deliberatethrow new Error('…')is a 400 rejection, not a crash.
Full table in the error catalog. The
client SDK folds all of it into one
{ success, data?, error? } result.
Global (object-less) actions post to /api/v1/actions/global/:action, or to
/api/v1/actions//:action with the object segment left empty. For credentials,
see API Authentication.
The endpoint dispatches on the declared type, exactly like the MCP
run_action tool — so the same URL invokes a script handler or a flow:
type | Over REST |
|---|---|
script | Runs the registered handler / inline body. |
flow | Runs target on the automation engine, with your identity forwarded (a runAs: 'user' flow enforces RLS as you). Dispatches the same flow as POST /api/v1/automation/:target/trigger, without having to know the flow name — and answers the same way: a run that ran and was rejected is 400 FLOW_FAILED, while a dispatch that never happened is separated out (404 unknown flow / 409 FLOW_DISABLED / 422 FLOW_NO_START_NODE / 422 FLOW_INPUT_SCHEMA_INVALID). A target declared runAs: 'system' whose type is self-triggered (autolaunched, record_change, schedule) is refused 403 PERMISSION_DENIED to every caller but the system principal, exactly as at the trigger door, and nothing runs; holding the action's requiredPermissions does not open it. See Run a flow via API for the full table — it is one table, read by both doors. |
api | 400 — it dispatches on target; call that endpoint directly. |
url / modal / form | 400 — client-side navigation; there is nothing for the server to run. |
Headless actions: declare it, then hide it
An action that should be callable but not appear in the UI is still a declared action. Hiding is a property you set; it is not the absence of a declaration:
defineAction({
name: 'recalculate_commissions',
type: 'script',
target: 'recalcCommissions',
locations: [], // no UI surface
requiredPermissions: ['finance.admin'], // still gated
// `ai.exposed` is false by default — no MCP tool either
})You keep the capability gate, the param contract, the audit trail, and Setup visibility for admins. A declaration that no surface renders costs you nothing.
Registering a handler without a declaration is not a way to hide an action —
it is refused. An undeclared handler has no requiredPermissions to enforce and
no param contract to check, yet it would execute with system privileges, so the
server declines it:
Action 'recalc' on 'crm_account' has no declaration — add
`defineAction({ name: 'recalc', … })`, or register the handler under a declared
action's `target`.If server-side logic should never be reachable over HTTP at all, do not register
it as an action — export a plain function and call it from your own code.
engine.registerAction means "publish this on the HTTP and MCP surfaces".
Migrating an app that has undeclared handlers? Startup lists every one of them
under [action-governance], with the object and key to declare. There is no
flag that runs them meanwhile — one would be the same ungoverned execution this
rule exists to prevent — so declare each one, or drop the registration if
nothing should invoke it over HTTP.
Expose it to AI (MCP)
Actions are not AI-visible by default. Opting in takes two fields — and makes the action a governed MCP tool alongside the data tools:
ai: {
exposed: true,
description: 'At least 40 characters explaining when an agent should use this action.',
}Only headless-callable types appear (script with body or handler, flow);
url/modal never do. The caller's identity and requiredPermissions are
enforced per invocation. Details: Actions as Tools
and Connect an MCP Client.
Troubleshooting
| Symptom | Cause → fix |
|---|---|
| Button doesn't appear | locations doesn't include the surface; or the visible CEL throws (e.g. a bare, unprefixed field name) and fail-closed hides it; or the user fails requiredPermissions |
Click → Action 'x' … not found | target-style script with no registered handler — add the registerAction call in onEnable (Path B above) |
REST → 400 naming the action's type | The type has no server dispatch (url/modal/form/api) — open its target in the client, or call that endpoint directly |
Appears in UI, missing from list_actions (MCP) | ai.exposed not true, ai.description under 40 characters, or the type isn't headless-callable |
| Runs but the list looks stale | Add refreshAfter: true |
Related
- Actions as Tools — the AI exposure model in depth
- Views —
rowActions/bulkActionsbinding - Action protocol — the full spec narrative
- Action schema reference — every property, generated from the spec