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.

Actions

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 to collect input, then submit to a handlertarget names the modal page

The schema also accepts api and form types, but they have no runtime executor / renderer today — stick to the four above. Likewise prefer confirmText/params over schema properties that are not yet wired (shortcut, bulkEnabled, timeout).

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: '!record.done',
  locations: ['list_item', 'record_header', 'record_section'],
  refreshAfter: true,
});

Body-carrying actions are registered automatically at boot.

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 the handler's engine facade is trusted — it bypasses row- and field-level security, so enforce any caller-specific rules yourself.

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
global_navApp-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' } }

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

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, and validation; inline params ({ name, label, type, required, options }) cover the rest. defaultFromRow prefills from the current record.
  • confirmText — confirmation dialog before running.
  • successMessage / errorMessage / refreshAfter — post-run feedback and an automatic data refresh.
  • resultDialog — a one-time reveal dialog for output the user must copy (generated tokens, export links).
  • variant / icon / order — presentation and sort position.

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: always prefix record fields (record.status != "closed", never a bare status), and keep it to a single operand — 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": {} }'
# → { "success": true, "data": ... }   (errors: { "success": false, "error": ... })

Global (object-less) actions post to /api/v1/actions/global/:action. For credentials, see API Authentication.

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 (bare field name, multiple operands) 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)
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