ObjectStackObjectStack

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

ToolWhat it does
list_actionsEnumerates 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_actionInvokes 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.typeDispatch pathAvailable where
scriptIDataEngine.executeAction(object, target, ctx) — the same call Studio makesopen (MCP) + ObjectOS
flowautomation flow runner — execute(target, automationContext) (caller identity forwarded so runAs engages)open (MCP) + ObjectOS; needs the automation service registered
apiHTTP call to action.target via a configured apiClientObjectOS 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's ExecutionContext, so row-level security scopes what the agent can see and do — if a user cannot read account acc_42 through 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/body handler's own reads/writes are not RLS-bounded (#2849).

On the open MCP path the action gate works like this:

  1. The server binds each session to the caller's principal — the API key acts as the user, resolving to the same ExecutionContext a plain ObjectQL request from that user would get.
  2. list_actions / run_action fail-closed on the author's ai.exposed opt-in, so an action never meant for AI is invisible and uninvokable — regardless of the caller's capabilities.
  3. Declared requiredPermissions are enforced against the caller — the same declaration the REST /actions/... route checks — so list_actions hides and run_action refuses anything the user cannot invoke.
  4. 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.
  5. 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,
  },
});

See also

On this page