BuildInterface

Actions

Named operations the platform exposes as REST endpoints, buttons and AI tools — from one declaration.

An Action is a named operation on an object. Declare it once and it appears as:

  • a REST endpoint at /api/v1/actions/<object>/<action>
  • a button on the record detail page
  • an AI tool (action_<name>) for Agents and the AI Builder — once you expose it to AI (ai.exposed, below)

You don't repeat yourself across three surfaces. One declaration; three ways to call it. An action can also start a flow (type: 'flow') when the work takes more than one step.

Declare an action

// src/actions/approve_invoice.action.ts
import { Action } from '@objectstack/spec/ui';

export const approveInvoice = Action.create({
  name: 'approve_invoice',          // lowercase snake_case (machine id)
  label: 'Approve Invoice',
  objectName: 'invoice',            // attaches to the invoice object
  icon: 'check',
  variant: 'primary',
  locations: ['record_header'],     // where the button shows
  description: 'Approve this invoice?',  // shown in the param dialog
  successMessage: 'Invoice approved',
  refreshAfter: true,

  // collect input before running
  params: [
    { name: 'note', label: 'Approval note', type: 'textarea' },
  ],

  // only show the button when the record is still pending
  visible: 'record.status == "pending"',

  // expose it to agents and the AI Builder as `action_approve_invoice`
  ai: {
    exposed: true,
    description: 'Approve a pending invoice, recording the approver and an optional note.',
  },

  // what it does — a sandboxed script body. `input` is the params bag;
  // `ctx.record` is a read-only snapshot, so the write goes through `ctx.api`,
  // which needs the `api.write` capability
  type: 'script',
  body: {
    language: 'js',
    capabilities: ['api.write'],
    source: `
      await ctx.api.object('invoice').update({
        id: ctx.recordId,
        status: 'approved',
        approved_by: ctx.user.id,
        approved_at: new Date().toISOString(),
        approval_note: input.note,
      });
    `,
  },
});

After os dev recompiles:

  • POST /api/v1/actions/invoice/approve_invoice works
  • The Invoice record page shows an Approve Invoice button
  • The AI assistant can call action_approve_invoice — it is exposed to AI — if its skills allow

Action types

The type field decides what the action does:

typeWhat runsUse for
scriptA body — an L1 formula expression or sandboxed L2 JavaScriptMost cases — server-side logic, auditable, and AI-callable once exposed
apiAn HTTP call to a target endpoint (method, bodyExtra)Reusing data-API or platform endpoints
flowRuns the flow named in targetMulti-step business processes
urlNavigates to the target URLDeep links, redirect-style actions
modalOpens the page/modal named in targetCustom dialogs
formOpens the FormView named in targetGuided data entry
// api type — reuse a data-API endpoint
Action.create({
  name: 'archive_order',
  objectName: 'order',
  label: 'Archive',
  locations: ['list_item'],
  type: 'api',
  method: 'PATCH',
  target: '/api/v1/data/order/{id}',
  bodyExtra: { archived: true },
});

Types other than script require a target. Whichever type, it is one declaration — but url, modal and form actions run only in the UI, so they never become AI tools.

Calling an action

REST

# the record id can go in the body, or in the path
curl -X POST https://app.example.com/api/v1/actions/invoice/approve_invoice/inv_123 \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{"note": "LGTM"}'

Params are sent flat in the request body. The record id can be supplied either as a trailing path segment (.../approve_invoice/:recordId) or in the body. The response is your script body's return value (or the call result for api-type actions).

From the UI

Where a button shows is declared on the action itself: locations (record_header, record_more, list_item, list_toolbar, …) and order. The visible predicate then decides, record by record, whether it renders. A list view can also name actions for each row and for a selection of rows:

// One list view; the container and `data` are omitted (see Views).
{
  type: 'grid',
  columns: ['number', 'customer', 'amount', 'status'],
  rowActions: ['approve_invoice', 'reject_invoice'],
  bulkActions: ['send_to_customer'],
}

With a flow

There is no flow node that calls an action. Go the other way: put the multi-step logic in a flow, and start it from an action with type: 'flow' and the flow's name in target:

// flow type — start a flow by name
Action.create({
  name: 'run_invoice_approval',
  objectName: 'invoice',
  label: 'Run approval',
  locations: ['record_more'],
  type: 'flow',
  target: 'approve_invoice',
});

From an AI Agent

An action reaches agents only when its author exposes it to AI: ai: { exposed: true } with an ai.description of at least 40 characters, as in the example above, on a type that runs without a UI (script, flow or api). Exposed, it is the action_<name> tool in ObjectOS's own agents and the AI Builder, and run_action for an MCP client. Unexposed, it is invisible to agents and refused if called.

If approve_invoice is exposed and in a skill the agent has, the LLM can call it. Inputs come from the conversation; the same invoke-time checks apply as if the user had pressed the button.

"Approve invoice INV-2042 with note 'verified by phone.'"

Permissions

Who may run an action is checked as the caller, when it is invoked — the same way over REST, from a button, and from an agent:

  1. requiredPermissions — capabilities the caller's permission sets must hold. A caller missing one gets 403.
  2. The subject record — it is loaded as the caller. A record the caller cannot read is answered as not found.
  3. AI exposure — an agent reaches only actions exposed to AI.
  4. UI gating — the visible and disabled predicates (CEL, evaluated against record, os.user, and params) control whether the button renders or is greyed out.

What the action then does is trusted app code. A script body's reads and writes run with the app's authority: record access and field-level security do not bound them. A flow action follows the flow's runAs. So declare requiredPermissions for anything sensitive, and treat writing an action as a code-review-worthy act.

Built-in actions

Every object gets these for free:

ActionWhat it does
createInsert a record
updateUpdate a record
deleteDelete (or soft-delete) a record
restoreUndo a soft-delete
cloneDeep-copy a record
shareDirect share with a user, team, position, business unit, or a unit and its subordinates

Don't redeclare these — they follow the object's lifecycle & capability flags.

Auditing

Platform events land in sys_audit_log, an immutable trail with fields including:

  • user_id — the originating user
  • action — the action name
  • object_name and record_id — what was touched
  • old_value / new_value — the change
  • ip_address / user_agent — request origin
  • created_at — when it happened

This is your first stop for "who pushed the button?" questions.

Generating actions with the AI Builder

"Create an action escalate_ticket on support_ticket that sets priority to urgent and assigns it to the on-call engineer."

The AI Builder generates the action metadata and queues the change for approval. After approval, the action is callable from REST, the UI, and — recursively — the AI itself.

Where to go next

  • Flows — the multi-step logic a type: 'flow' action starts
  • Agents — expose actions as AI tools
  • API Access — call actions from external systems
  • Permissions — gate who can call what