ObjectStackObjectStack

Actions

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:

typeWhat it doesServer behavior
script (default)Run server-side logicInline body or a handler registered via target
flowLaunch a flow (e.g. a screen-flow wizard)target names the flow
urlNavigate / open a linktarget is the URL (${ctx.record.id} interpolation supported)
modalOpen a modal page — client-side only, no server dispatchtarget names the modal page (to collect input and run logic, use script + params)
apiCall an HTTP endpoint directlytarget is the endpoint; method / bodyShape / bodyExtra shape the request
formOpen 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:

src/actions/task.actions.ts
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):

objectstack.config.ts
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 });  // ✅ persists

This 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 reported

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

src/actions/task.handlers.ts
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(),
  });
}
objectstack.config.ts
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.

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:

LocationWhere the button renders
list_toolbarList view toolbar (no record context)
list_itemPer-row menu in list views
record_headerRecord page header
record_moreRecord page overflow ("…") menu
record_relatedRelated-list sections
record_sectionNamed 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.

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. defaultFromRow prefills from the current record. The console renders each param through the same field widgets as the record form (objectui ADR-0059), so any FieldType works — a file param shows a real upload control (multiple / accept / maxSize honored), lookup a record picker, date a date picker, richtext the 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+Z works 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 wantedWrite 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' targetPut 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. Referenced capabilities must exist — os lint checks that.

  • visible is a CEL predicate evaluated fail-closed: an expression that throws hides the action silently. Two rules save real debugging time:

    1. Always prefix record fieldsrecord.status != "closed", never a bare status, which faults as an undeclared identifier.
    2. Guard with has() — a record-scoped predicate on list_item binds a LIST ROW, which carries only the columns that view projects. Reading a field the list does not show aborts the expression with No such key, and fail-closed means the button simply is not offered — indistinguishable from the gate having said no. has(record.x) && … answers false instead.

    Add && record.x != null only when the value is then traversed (record.x.k), called (record.x.size()), ordered (< <= > >=), negated (!record.x) or used with in — those fault on a projected NULL. A plain == / != against a literal never does, so has() 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.

  • requiresFeature ties visibility to a feature flag (compiled into a visible predicate).

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/api type with no server dispatch is also a 400.
  • 500 — it crashed: a TypeError in your handler, a driver error, a sandbox timeout. A deliberate throw 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:

typeOver REST
scriptRuns the registered handler / inline body.
flowRuns 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). See Run a flow via API for the full table — it is one table, read by both doors.
api400 — it dispatches on target; call that endpoint directly.
url / modal / form400 — 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

SymptomCause → fix
Button doesn't appearlocations 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 foundtarget-style script with no registered handler — add the registerAction call in onEnable (Path B above)
REST → 400 naming the action's typeThe 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 staleAdd refreshAfter: true

On this page