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_invoiceworks- 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:
type | What runs | Use for |
|---|---|---|
script | A body — an L1 formula expression or sandboxed L2 JavaScript | Most cases — server-side logic, auditable, and AI-callable once exposed |
api | An HTTP call to a target endpoint (method, bodyExtra) | Reusing data-API or platform endpoints |
flow | Runs the flow named in target | Multi-step business processes |
url | Navigates to the target URL | Deep links, redirect-style actions |
modal | Opens the page/modal named in target | Custom dialogs |
form | Opens the FormView named in target | Guided 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:
requiredPermissions— capabilities the caller's permission sets must hold. A caller missing one gets403.- The subject record — it is loaded as the caller. A record the caller cannot read is answered as not found.
- AI exposure — an agent reaches only actions exposed to AI.
- UI gating — the
visibleanddisabledpredicates (CEL, evaluated againstrecord,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:
| Action | What it does |
|---|---|
create | Insert a record |
update | Update a record |
delete | Delete (or soft-delete) a record |
restore | Undo a soft-delete |
clone | Deep-copy a record |
share | Direct 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 useraction— the action nameobject_nameandrecord_id— what was touchedold_value/new_value— the changeip_address/user_agent— request origincreated_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_ticketonsupport_ticketthat 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