Status: Accepted — implemented (2026-06-04)
Authors: HotCRM (objectstack-ai/hotcrm) — surfaced the requirement; design finalised in framework
Consumers: @objectstack/service-ai (the bridge), @objectstack/spec (ui.action, contracts.ai-service), every app that ships actions
AI agents call Tools. Apps register business operations as Actions
(*.action.ts) for UI buttons and HTTP endpoints. Without a bridge, giving the
Copilot a capability means writing the logic twice — once as an Action (for
humans), once as a Tool (for the LLM) — and keeping them in sync forever.
Every Action opts in to being an AI Tool by adding a single ai: block to
its metadata. The runtime exposes opted-in Actions to agents through the
existing tool registry — no Tool duplicates. Result: operational parity —
anything an admin can do, the Copilot can do, by calling the same Action with
the same permissions, validation, audit, and transaction guarantees.
Exposure is opt-in, default off. ai.exposed: true is required, and it
forces an explicit, LLM-facing ai.description. There is no heuristic
auto-exposure and no description derived from the UI label.
HotCRM v1 shipped 6 hand-authored "skills" under src/skills/. Each skill is a
defineSkill({ tools: [...] }) bundle of inline tools. While planning v1.1 the
team asked: "We're a metadata-driven platform — do we really need bespoke skill
code for each business operation?"
The breakthrough: Actions are already the metadata vocabulary for "what the system can do". They have a name, a label, a typed parameter schema, permissions, audit logging, transactions, and an implementation. They're the same shape a Tool needs, minus a description aimed at an LLM.
The first implementation (commit 93c05899d, the same day as the original
draft) took a shortcut that inverted this ADR's intent: a flat aiExposed
boolean with heuristic auto-exposure (expose anything that "looks safe") and
a tool description derived from the UI label. That contradicted two explicit
points of this ADR — opt-in-default-off is a Goal, and label-derived
descriptions are a Non-Goal.
This revision realigns the code with the original opt-in ai: block design and
sharpens the rationale for the AI-authoring era (below). Because the platform
had not yet shipped, there were no live apps depending on auto-exposure — so
the realignment is a clean break: the aiExposed field is removed outright,
with no deprecation alias and no compatibility flag.
The platform's direction is that actions are increasingly authored by AI (NL → Action; draft-then-review per ADR-0033). That reframes the trade-offs:
- "Writing a description is a burden" — gone. An AI author writes a good
LLM-facing description for free. The friction that once argued for
auto-derivation evaporates; auto-derivation becomes pointless, not merely
low-quality. So we require an explicit
ai.descriptionand delete the label-derivation default. - Opt-in is the governance gate, not a friction tax. When AI both authors
and invokes actions, the platform's value to an enterprise is that a human
can govern which capabilities the agent fleet may invoke.
ai.exposeddefault-off is the physical boundary between "an AI drafted an action" and "the agent fleet may now run it". A half-finished or unreviewed draft must never be silently armed. - Schema minimalism is itself governance. Every extra knob is one more way an
AI author misconfigures and one more thing a human reviewer must audit. So the
ai:block carries only fields with a real, end-to-end effect.
| Source | Lives where | Used for | Example |
|---|---|---|---|
| ToolRegistry (pure tools) | platform / app code | Generic, schema-discoverable operations that aren't business actions | describe_object, query_data |
Action ai: block (this ADR) |
app metadata (*.action.ts) |
App-specific business operations humans also invoke from the UI | crm_case_triage, complete_task |
| SkillRegistry | platform / app code | Bundles of instructions + a curated tool subset | lead_qualification |
The label vs ai.description split survives the AI-authoring shift because the
consumption audience is still mixed: humans click UI buttons (label), agents
call tools (ai.description). Only the authoring side became AI.
- Zero duplication — one definition (the Action) drives both the UI button and the AI tool.
- Opt-in exposure, default off —
ai.exposed: trueis required; this is the governance gate. - Explicit LLM description —
ai.descriptionis required when exposed, authored for a model, never derived fromlabel. - Auto-derived JSON Schema — tool
parameterscome from the Action'sparams[]+ referenced field types, refined byai.paramHints. - Permission parity — AI invocation goes through the same permission/RLS/audit machinery as human invocation.
- Confirmation parity — destructive actions route through the HITL approval
queue;
ai.requiresConfirmationlets the author override the default.
- Replacing
ToolRegistry. Pure tools stay first-class. - Auto-generating an LLM
descriptionfrom the label. (Authors — human or AI — write it.) - Inferring "safe / unsafe" automatically. The author opts in and sets the confirmation policy.
- Touching
SkillRegistry. - Hot-reload of newly-added actions into running sessions (tool definitions are resolved per turn — see Open Questions).
packages/spec/src/ui/action.zod.ts gains an optional ai field
(ActionAiSchema):
export const ActionAiSchema = z.object({
/** Expose to AI agents. Default false. Requires `description` when true. */
exposed: z.boolean().default(false),
/** LLM-facing description (≥40 chars). Required when exposed. Distinct from `label`. */
description: z.string().min(40).optional(),
/** Tool category override. Defaults to 'action' (side-effect). */
category: ActionAiCategorySchema.optional(), // mirrors ToolCategorySchema
/** Per-parameter AI hints (tighter enum / description / examples), keyed by param name. */
paramHints: z.record(z.string(), z.object({
description: z.string().optional(),
enum: z.array(z.union([z.string(), z.number()])).optional(),
examples: z.array(z.unknown()).optional(),
})).optional(),
/** Output JSON Schema — enables downstream chaining; summarised into the description. */
outputSchema: z.record(z.string(), z.unknown()).optional(),
/** Override HITL confirmation. Defaults to true for destructive-looking actions. */
requiresConfirmation: z.boolean().optional(),
});The category enum is inlined (not imported from ai/tool.zod) to avoid a
ui → ai import cycle (ai/*.form.ts already imports from ui/view.zod).
Validation rules (ActionSchema.refine):
ai.exposed === true⇒ai.descriptionrequired.ai.paramHintskeys must match a declaredparams[].name(or the injectedrecordId) — a typo can't silently no-op.
The old flat aiExposed boolean is removed.
The bridge (not a runtime.ActionRegistry method, as the first draft sketched)
walks every object's actions[] via the metadata service and registers the
opted-in ones into the existing ToolRegistry:
actionSkipReason(action, ctx)— opt-in gate. Skips unlessai.exposed === true(andai.descriptionpresent). Then the structural and wiring checks (UI-only types, missing target/body, no apiClient/automation), then the destructive-action gate.actionToToolDefinition(...)— builds theAIToolDefinition:descriptionfromai.description(+ a compactReturns: …line summarisingai.outputSchema),parametersfromparams[]refined byai.paramHints, and carriescategory/outputSchema/objectName/requiresConfirmation.actionRequiresApproval(action)—ai.requiresConfirmationwins; else the heuristic (confirmText,mode:'delete',variant:'danger').- Lint guardrail — when an exposed action looks destructive yet the author
set
ai.requiresConfirmation:false, it registers (the author's call) but the bridge emits awarningthe plugin logs.
AIToolDefinition (packages/spec/src/contracts/ai-service.ts) was extended with
optional category, outputSchema, objectName, requiresConfirmation so this
richer info is carried rather than dropped.
No change needed beyond registration: action-tools register into the same
ToolRegistry the plugin already feeds into availableTools. The agent's tool
resolution sees them as candidates automatically. Pure tools win name
collisions (an app action can never silently override a platform tool — the
registry refuses a duplicate name).
LLM emits tool_call { name: 'action_complete_task', args: { recordId: '00123' } }
│
▼ if requiresConfirmation && HITL wired:
handler enqueues a pending action → returns { status: 'pending_approval' }
operator approves in Studio → the pre-registered bypass dispatcher runs it
else:
handler dispatches by type — script → dataEngine.executeAction;
api → apiClient.request; flow → automation.execute
│
▼ executes inside the same RLS / permission / audit machinery as a human click.
Result returned to the LLM (shape documented by ai.outputSchema if set).
Invariant: AI-invoked actions never bypass permissions, validation, hooks, or
audit. The only differences vs a human click are the args came from an LLM and
the invocation is attributed to the chat actor (falling back to a synthetic
ai_agent principal).
The bridge runs each action under the chat session's user (or a synthetic
ai_agent principal). RLS / FLS / sharing rules apply to every read and write
inside the handler exactly as for a human click. The LLM cannot escalate by
asking; an action the user can't invoke simply isn't offered.
| Action config | Behavior |
|---|---|
destructive (confirmText / mode:'delete' / variant:'danger') and HITL wired (enableActionApproval + aiService) |
registered; invocation enqueues a pending action and returns pending_approval |
| destructive and HITL not wired | skipped (can't run unattended) |
ai.requiresConfirmation: true on a safe action |
treated as destructive (gated) |
ai.requiresConfirmation: false on a destructive action |
exposed & direct-run — author asserts safe (bridge logs a warning) |
| not destructive | direct-run |
aiblock onActionSchema;aiExposedremoved (clean break).AIToolDefinitionextended (category/outputSchema/objectName/requiresConfirmation).- Bridge rewritten to opt-in:
actionSkipReason,actionToToolDefinition,actionRequiresApproval, paramHints merge, outputSchema summary, lint warnings. - Studio
action.formupdated (aiExposedfield →aiblock). - Internal
ai_pending_actionsapprove/reject actions: dropped the now-redundantaiExposed:false(opt-in means human-only by default). - Tests: spec
aiblock validation; bridge opt-in gating, paramHints, outputSchema, category, requiresConfirmation override, lint warning.
The examples/app-todo script actions (complete_task, start_task,
clone_task, mass_complete, export_csv) opt in with ai.exposed +
ai.description; delete_completed opts in but stays destructive (registers
only when HITL approval is wired); the modal actions stay UI-only. The
test:action / test:hitl demos exercise the path end-to-end.
HotCRM v1.1 follows: convert the 6 hand-authored skills to AI-exposed actions, keeping a thin sales/service persona instruction bundle.
- Guide: "write an AI-callable action" (5-minute tutorial).
- Stack Lint:
type:'delete'actions must make a deliberateai.exposedchoice. - Optional
defineAiAction(...)sugar (defaultsexposed:true) — low priority since AI authors write the block in full.
- Bulk vs single. Tentative: one tool with an array-accepting
parameters. - Streaming results. Out of scope; needs a streaming-tool-return ADR.
- Tool name namespacing. Implemented as a prefix:
action_<name>. - MCP exposure. Action-tools live in the same
ToolRegistry, so the MCP server plugin surfaces them transparently. Confirm in MCP work. - Discoverability with 50+ actions. Mitigations: scope
toolsForAiby the current object, skills as curators, lazy tool resolution. Add as needed. - Versioning. Tool definitions resolve per turn, so the next turn sees the current shape; mid-turn drift is a non-issue.
- Audit attribution (tri-state). Today invocations are attributed to the
actor (or synthetic
ai_agent). A richersource: 'ai-authored' | 'ai-invoked' | 'human'distinction — separating "an AI wrote this action" from "an AI called it" — pairs with the ADR-0033 / NL→Action authoring work.
Adopt the opt-in ai: block. Implementation:
@objectstack/spec—ActionAiSchemaonActionSchema;AIToolDefinitionextension;action.formupdate.@objectstack/service-ai—action-tools.tsbridge + HITL approval queue (already shipped) reading theai:block.
examples/app-todo is the first consumer and validation testbed. HotCRM v1.1
ships next.
- ADR-0003 — Package as first-class citizen (where actions live)
- ADR-0008 / ADR-0009 — Metadata change log & execution-pinned metadata
- ADR-0010 — Natural-language → Flow authoring (the AI-authoring counterpart)
- ADR-0033 — Draft-gating for AI-written metadata (review-before-publish)
packages/spec/src/ui/action.zod.ts—ActionSchema+ActionAiSchemapackages/spec/src/contracts/ai-service.ts—AIToolDefinitionpackages/services/service-ai/src/tools/action-tools.ts— the bridge