| title | Actions |
|---|---|
| description | Declarative buttons with server-side behavior — define once, bind to lists, records, and navigation, permission-check on both surfaces, and optionally expose 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 | target names the FormView; routed to /forms/:target?recordId=… |
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),
});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: '!record.done',
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 reportedFor logic that belongs in real source files, point target at a handler name
and register it in your config's onEnable lifecycle hook:
export async function completeTask(ctx: ActionContext): Promise<void> {
const { record, engine } = ctx; // ctx = { record, user, engine, params }
await engine.update('todo_task', record.id as string, {
status: 'completed',
completed_date: new Date().toISOString(),
});
}export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => {
ctx.ql.registerAction('todo_task', 'completeTask', completeTask);
};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 |
Related-list sections |
record_section |
Named action bars on record pages |
global_nav |
App-level navigation |
Surfaces can also reference actions by name:
// List views — row and bulk menus
defineView({
// ...
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' } }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.
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.
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. Referenced capabilities must exist —os lintchecks that.visibleis a CEL predicate evaluated fail-closed: an expression that throws hides the action silently. The rule that saves real debugging time: always prefix record fields (record.status != "closed", never a barestatus, which faults as an undeclared identifier). Compound&&/||predicates are fully supported — see the formulas guide for CEL syntax.requiresFeatureties visibility to a feature flag (compiled into avisiblepredicate).
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). Equivalent to POST /api/v1/automation/:target/trigger, without having to know the flow name. |
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. |
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.
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.
| 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 |
- 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