Actions as Tools
Expose declarative Action metadata as AI-callable tools with explicit opt-in, HITL approval, and permission-aware execution
Actions as Tools
Part of the AI module — how existing Action metadata becomes LLM-callable, and the guardrails around it.
This is what makes the app you built AI-operable: connect an MCP client (Claude Code, Cursor, …) and your business Actions become callable tools — so an agent can "resolve this ticket" or "convert this lead" through the same logic and permissions as the Console button.
Any business Action you already have — a script action or a Flow — can be
reached by an LLM as a callable tool. On the open edition this happens
through @objectstack/mcp: your own AI (Claude, Cursor, any MCP
client, or a local model) connects over the Model Context Protocol, and the
server exposes two business-action tools — list_actions and run_action —
bound to the caller's principal. The agent invokes actions the same way the
Console toolbar does — but only actions the author explicitly exposed to AI, and
only ones the caller is permitted to run. No cloud service and no ObjectOS
runtime are required.
Action bodies run as trusted code (#2849). Unlike the object CRUD tools —
where every read/write is bounded by the caller's row-level security — a
script/body action's handler executes with the app's full data
authority: its internal engine.insert/update/delete/find calls carry no
caller context, so they bypass RLS/FLS (the SECURITY-DEFINER model many actions
rely on for cross-object writes like convert-lead). The boundary is therefore at
invoke time, not inside the body: ai.exposed (author opt-in) +
requiredPermissions (ADR-0066) decide what an agent may trigger. Expose an
action to AI only when its body is safe to run on behalf of anyone the gate lets
through. Flow actions differ — they honour the flow's runAs (ADR-0049) with
the caller's identity forwarded.
ObjectOS layers an in-product chat runtime
on top of these same actions: it generates one action_<name> tool per action
and adds a server-side approval queue. Both the open MCP path and the ObjectOS
runtime gate on the same ai.exposed opt-in. Those pieces are called out below.
The open path — the same Action reachable as an MCP tool by your own AI — is the
default.
The open path: Actions over MCP
When you run the MCP server over its network (Streamable HTTP) transport, it self-registers a business-action tool set on top of your objects, bound to the caller's principal (the API key acts as the user):
| Tool | What it does |
|---|---|
list_actions | Enumerates the business actions that are AI-exposed (ai.exposed: true) and the caller is permitted to run — name, target object, description, whether it needs a recordId, whether it is destructive, and its declared params. |
run_action | Invokes an action by name with { recordId, params }. Invocation is gated (author opt-in + capabilities); the action body then runs the app's registered logic as trusted code. |
run_action resolves the action and dispatches it through the framework's own
action mechanism — IDataEngine.executeAction for script / inline-body
actions, or the automation flow runner for type:'flow' — exactly the path the
REST /actions/... route uses. Invocation is bound to the caller's
ExecutionContext for the gate checks and subject-record load, so a BYO-AI
client (Claude Code, Cursor, …) can trigger real business logic — "complete this
task", "convert this lead". Note the body itself runs trusted (see the warning
above); the caller context bounds whether the action fires and what record it
loads, not what the handler does internally.
Describing an action for the LLM
Add an optional ai: block to give the model a precise, LLM-facing description
(and to flag confirmation intent). The block is open metadata on the Action
spec; list_actions surfaces ai.description to the model, falling back to the
UI label when it is absent.
export const triageCaseAction = {
name: 'triage_case',
label: 'Triage Case',
objectName: 'case',
type: 'flow',
target: 'case_triage',
ai: {
exposed: true,
description: 'Triage a support case, suggest priority, and assign the next support queue.',
category: 'action',
requiresConfirmation: false,
},
};The ai.exposed flag is a governance gate for the whole AI surface. Both
the open MCP path (list_actions / run_action) and the ObjectOS in-product
chat runtime register an action as an AI tool only when ai.exposed === true
(and then ai.description is required, ≥ 40 chars). An action left un-exposed is
invisible to agents and fail-closed at invocation, even for a caller who holds
every required capability — because the body runs trusted, author opt-in, not a
data-layer backstop, is the boundary (#2849).
What gets exposed
The bridge walks every registered object's actions[] and offers only actions
that are AI-exposed (ai.exposed: true), have a headless dispatch path,
and that the caller is permitted to run. System objects (sys_*) are held
back fail-closed.
action.type | Dispatch path | Available where |
|---|---|---|
script | IDataEngine.executeAction(object, target, ctx) — the same call Studio makes | open (MCP) + ObjectOS |
flow | automation flow runner — execute(target, automationContext) (caller identity forwarded so runAs engages) | open (MCP) + ObjectOS; needs the automation service registered |
api | HTTP call to action.target via a configured apiClient | ObjectOS runtime only |
Console-only types (url, modal, form) are always skipped.
Two gates are single-sourced with the REST route and applied at invoke time: the
ai.exposed opt-in (#2849) and an action's declared requiredPermissions
(ADR-0066), enforced as the caller — so list_actions hides, and run_action
refuses, anything the author did not expose to AI or the user could not invoke
through the API. Destructive actions (confirmText, mode: 'delete',
variant: 'danger', or ai.requiresConfirmation: true) are reported with
requiresConfirmation: true so the client can ask the human before calling. To
assert that a destructive-looking action is safe for autonomous execution, set
ai.requiresConfirmation: false.
Wiring it up
Expose the action tools by running the MCP server over its HTTP transport — no AI-specific configuration is needed:
import { LiteKernel } from '@objectstack/core';
import { MCPServerPlugin } from '@objectstack/mcp';
const kernel = new LiteKernel();
kernel.use(new MCPServerPlugin({ transport: 'http', autoStart: true }));
await kernel.bootstrap();type:'flow' actions are picked up automatically when the automation service
is registered. Point your MCP client at the server; the caller's API key acts as
the user for the invoke-time gates and the subject-record load. (Flow bodies then
honour runAs; script/body handlers run trusted — see the warning above.)
ObjectOS — the in-product runtime wires actions through its own bridge
(including type:'api' dispatch), and reports per-action registration results
with reasons like "not AI-exposed" or "requires confirmation … wire HITL approval" so authors can see whether an action is LLM-callable. See the
ObjectOS docs for its configuration.
Example
A BYO-AI client invokes run_action the same way it calls any MCP tool:
// tools/call → run_action
{
"actionName": "triage_case",
"recordId": "case_42",
"params": { "priority": "high" }
}
// → invoke-gated as the caller; case_triage is a flow, so it honours runAs. Returns the flow result.Human-in-the-loop approval
Destructive actions are too risky to let an LLM execute unattended, but locking
them away entirely defeats agentic UX. On the open MCP path the approval step
lives at the protocol boundary: run_action is annotated destructiveHint: true,
and each action's per-call risk is surfaced through requiresConfirmation in
list_actions, so the MCP client (Claude Desktop, Cursor, …) prompts the
operator to approve the call before it runs. The human stays in the loop at the
point of invocation — the meaningful control point, since the body then runs
with the app's own authority.
ObjectOS — the in-product chat runtime adds a server-side
approval queue for its own action_<name> tools: a gated tool call is
persisted as pending and returned to the model as
{ status: 'pending_approval', … } instead of executing, and an operator
resolves it from the AI Pending Actions Studio inbox (approve re-runs the
same dispatcher; reject records a reason).
A dedicated queue (rather than the multi-step IApprovalService) fits AI
tool-call HITL: the subject is the proposed call, there is no predefined
process, and operators expect single-click yes/no. See the
ObjectOS docs for configuration and the
queue API.
Permission model (invoke-time gate + trusted body)
Two different boundaries apply, and it matters which:
- Object CRUD tools (
query_records/get_record/create_record/ …) execute under the end-user'sExecutionContext, so row-level security scopes what the agent can see and do — if a user cannot read accountacc_42through ObjectQL, neither can an LLM acting on their behalf. This is automatic and needs no separate "agent permission" surface. - Business actions are gated at invoke time, then run trusted. The
caller context decides whether an action fires and which record it loads, but
a
script/bodyhandler's own reads/writes are not RLS-bounded (#2849).
On the open MCP path the action gate works like this:
- The server binds each session to the caller's principal — the API key acts as
the user, resolving to the same
ExecutionContexta plain ObjectQL request from that user would get. list_actions/run_actionfail-closed on the author'sai.exposedopt-in, so an action never meant for AI is invisible and uninvokable — regardless of the caller's capabilities.- Declared
requiredPermissionsare enforced against the caller — the same declaration the REST/actions/...route checks — solist_actionshides andrun_actionrefuses anything the user cannot invoke. - The subject record (for record-context actions) is loaded under the caller's RLS, so an action over a record the user cannot see reads as not-found.
- The action body then executes with the app's full data authority (flows honour
runAs), and the dispatch is audit-logged against the real user.
Because the body is trusted, ai.exposed is the security decision: opt an
action into AI only when its logic is safe to run on behalf of anyone the
capability gate admits.
ObjectOS — the in-product chat routes
(/api/v1/ai/assistant/chat, /api/v1/ai/agents/:agentName/chat) resolve the
authenticated principal from req.user — both cookie session
(better-auth.session_token) and Bearer token are handled — and forward it to
aiService.chatWithTools(...) as
toolExecutionContext: { actor, conversationId, environmentId }. That threads the
same RLS context through the runtime's built-in data tools and its
action_<name> tools. From a custom server route you pass the actor explicitly.
Omitting toolExecutionContext (or its actor) is not a system fallback:
the contract fails closed and data tools run as an unauthenticated, RLS-on
principal that sees nothing (#2991). Trusted internal callers (cron jobs,
migrations) that genuinely need full authority opt in explicitly with
toolExecutionContext: { isSystem: true } — a deliberate, greppable elevation,
never the consequence of a forgotten field.
await aiService.chatWithTools(messages, tools, {
toolExecutionContext: {
actor: {
id: currentUser.id,
name: currentUser.displayName,
positions: currentUser.positions,
permissions: currentUser.permissions,
},
conversationId,
environmentId,
},
});