| title | Actions as Tools |
|---|---|
| description | Expose declarative Action metadata as AI-callable tools with explicit opt-in, HITL approval, and permission-aware execution |
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.
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.
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.
{/* os:check */}
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 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.
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.)
A BYO-AI client invokes run_action the same way it calls any MCP tool:
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.
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.
await aiService.chatWithTools(messages, {
tools,
toolExecutionContext: {
actor: {
id: currentUser.id,
name: currentUser.displayName,
positions: currentUser.positions,
permissions: currentUser.permissions,
},
conversationId,
environmentId,
},
});