Edition boundary. ☁️ marks a cloud / Enterprise surface, and is not restated below. The in-UI AI runtime — the
ask/buildagents, in-product chat,/api/v1/ai/*(@objectstack/service-ai) — ships cloud / EE, not in the open framework. The schemas in@objectstack/spec/aistay open, so you author*.skill.ts/*.tool.tsas source either way, but they only execute in a cloud / EE host. On the open edition there is no in-product agent: expose the app to your own AI over MCP (below), and author metadata in source mode with an AI coding agent (Claude Code, Cursor).
Agent → Skill → Tool
│ │ │
│ │ └─ Atomic operation (query, action, flow, API call)
│ └─ Capability bundle with instructions & trigger conditions
└─ Autonomous actor with role, instructions, and guardrails
Agents are platform-internal (☁️ ask / build; reference section at the
bottom). Skills are your entry point — you extend the platform by authoring
skills, and a skill reaches tools by naming them.
Skill Configuration
A Skill is a named bundle of tools with dedicated instructions and trigger conditions.
| Required | Type | Description |
|---|---|---|
name |
snake_case |
Unique skill id (/^[a-z_][a-z0-9_]*$/) |
label |
string | Display name |
tools |
string[] |
Tool names this skill grants (trailing wildcard allowed, e.g. action_*) — every name must resolve; see the ladder below |
| Optional | Purpose |
|---|---|
surface |
'ask' | 'build' | 'both' — which platform agent may bind this skill (default 'ask'). Enforced in resolveActiveSkills at load: an incompatible binding is a fast load error (ADR-0063 §3) |
description |
What the skill does — the agent reads it to decide when to use the skill |
instructions |
Prompt guidance for this skill's context. The one field the open edition reads: @objectstack/mcp projects it onto the MCP prompts primitive, so a skill with blank instructions — or active: false — is not listed as a prompt at all |
triggerConditions |
Programmatic activation rules (below) |
active |
Enabled (default true) |
A skill has no
permissionskey — removed in 17.0.0 because it gated nothing. Gate at the agent (access/permissionsondefineAgent, enforced at the chat route) or on the actions the tools call (ADR-0066).
Naming the tools: the resolution ladder
A skill.tools[] entry resolves against the union of three sources
(collectToolUniverse in packages/lint/src/validate-ai-tool-references.ts):
stack.tools[].name— records your own stack declares. ADR-0109: the default third-party path declares none (see Tool metadata below).PLATFORM_PROVIDED_TOOL_NAMES— the statically-registered platform tools, flattened fromPLATFORM_TOOLS_BY_PACKAGEinpackages/spec/src/system/constants/platform-tool-names.ts: data / knowledge tools fromservice-ai, schema / metadata / package tools fromservice-ai-studio. Read that file for the exact set: it is the registry the lint rule checks against.action_<name>— one tool per AI-exposed Action of your own stack (PLATFORM_TOOL_FAMILY_PREFIXES). The default path for anything your app does.
A name in none of the three raises ai-skill-tool-unresolved (warning,
ADR-0078 advisory-first). The rule exists because one audited app shipped 10
fictional tools across 6 skills through validate / lint clean, and became a
copilot claiming abilities it did not have.
Skill Example
import { defineSkill } from '@objectstack/spec';
export default defineSkill({
name: 'case_management',
label: 'Case Management',
description: 'Triage, query, and escalate support cases.',
instructions: `
When managing cases:
- Always check for duplicate cases before creating a new one.
- Set priority by customer tier: Enterprise → High, Pro → Medium, Free → Low.
- Escalated cases must summarise the actions already taken.
`,
// Every name resolves: two platform tools (rung 2) plus one action tool
// materialised from this stack's own `escalate_case` Action (rung 3).
tools: ['query_records', 'get_record', 'action_escalate_case'],
triggerConditions: [
{ field: 'objectName', operator: 'eq', value: 'support_case' },
],
active: true,
});
Trigger Conditions
Each condition is { field, operator, value }, ANDed with its siblings. The
shape of value is coupled to operator — a mismatch is a parse error, not
a coercion (packages/spec/src/ai/skill.zod.ts:37-51, 139-202):
| Operator | Meaning | value must be |
|---|---|---|
eq |
Equals | a string — an array is reference identity, so it never matches |
neq |
Not equals | a string — an array would always fire |
in |
In list | an array (empty [] is allowed and is a real predicate) |
not_in |
Not in list | an array |
contains |
Substring, or array subset | either — both spellings execute |
Nothing matches phrases: write routing as triggerConditions and
natural-language intent in description / instructions, where the model reads it.
Actions as AI Tools — the default path ☁️
You usually don't author tool definitions by hand for action invocation. An
Action attached to an object via defineObject({ actions: [...] }) becomes a
tool named action_<actionName> only when it opts in — ai.exposed: true
(default false) plus an ai.description of ≥ 40 chars, refused by the parse
without it (ADR-0011). registerActionsAsTools() walks the opted-in ones;
exposure is never automatic, and there is no opt-out key.
Three action types dispatch headlessly:
action.type |
Dispatch | Wiring |
|---|---|---|
script |
IDataEngine.executeAction(object, target, ctx) — same as Studio's row toolbar |
none |
api |
HTTP call to action.target (fetch-based by default) |
AIServicePlugin({ apiActionBaseUrl, apiActionHeaders }) or a custom apiClient |
flow |
IAutomationService.execute(target, { triggerData }) |
automation service registered with the kernel |
Skipped even when opted in: UI-only types (url, modal, form); and
dangerous variants — the declared mode: 'delete' / variant: 'danger' only
(confirmText is dialog copy, not a destructive signal; ai.requiresConfirmation
overrides either way) — unless the plugin runs with enableActionApproval: true,
which routes them through the approval queue instead.
type:'api' body assembly (last wins): user params → recordIdParam (using
recordIdField, default 'id') → bodyExtra. bodyShape: { wrap: 'data' }
nests user params under data while keeping recordIdParam flat.
Human-in-the-loop. AIServicePlugin({ enableActionApproval: true }) (default
false) persists an ai_pending_actions row instead of running a dangerous
action and returns { status: 'pending_approval', pendingActionId }; an operator
approves or rejects in Studio's AI Pending Actions inbox (or via
GET/POST /api/v1/ai/pending-actions/...). Whether your action is held is
decided entirely by the skip rule above — actionSkipReason(action, ctx) and
actionRequiresApproval(action) (from @objectstack/service-ai) answer both
questions, and Studio's "AI exposure" diagnostics use the same predicates.
MCP — the open-edition AI path
@objectstack/mcp publishes your app over the Model Context Protocol, so an
external agent (Claude, Cursor, Codex, …) can read it and act on it with no
cloud licence. (Consuming external MCP servers is the inbound sibling,
@objectstack/connector-mcp.)
import { MCPServerPlugin } from '@objectstack/mcp';
kernel.use(new MCPServerPlugin());
The HTTP surface is default-on, served per-request by the runtime dispatcher
at POST /api/v1/mcp — nothing to start (OS_MCP_SERVER_ENABLED=false opts
out). The long-lived stdio transport has its own switch and defaults off:
autoStart: true, or OS_MCP_STDIO_ENABLED=true. (OS_MCP_SERVER_ENABLED=true
still starts it — deprecated, and it warns at boot.)
The tools a client then sees — from registerObjectTools / registerActionTools,
each family gated by the caller's OAuth scopes, every call under the caller's
permissions and row-level security:
| Tools | What they do |
|---|---|
list_objects, describe_object |
schema introspection (system objects filtered out by default) |
query_records, get_record |
reads — filter, field selection, sorting, pagination |
aggregate_records |
GROUP BY totals; registered only when the data bridge implements aggregate |
validate_expression |
build-accurate CEL check before a formula is authored into metadata |
create_record, update_record, delete_record |
writes; delete_record is annotated destructive |
list_actions, run_action |
your AI-exposed business Actions — the same ai.exposed opt-in as above |
Knowledge Sources (RAG)
The RAG primitive is the KnowledgeSource (KnowledgeSourceSchema in
@objectstack/spec/ai): declarative metadata pairing what to index with the id
of an IKnowledgeAdapter that does the work. There is no defineStack
collection for them — they are registered at runtime, and the ☁️
search_knowledge tool exposes registered sources to agents.
Wiring (open packages). KnowledgeServicePlugin({ sources }) from
@objectstack/service-knowledge registers IKnowledgeService with the kernel and
each source at boot (service.registerSource still works later). The adapter
id resolves against an adapter plugin — @objectstack/knowledge-memory
('memory', dev/test) or @objectstack/knowledge-ragflow ('ragflow');
without one the id is unresolvable.
| Property | Purpose |
|---|---|
id |
Snake_case source id |
label / description |
Display metadata |
adapter |
Adapter id (e.g. 'ragflow', 'memory'), resolved via IKnowledgeService.registerAdapter |
adapterConfig |
Adapter-specific configuration (opaque to the service) |
source |
What gets indexed — discriminated on kind: 'object' | 'file' | 'http' |
embedding |
Optional EmbeddingModelSchema ref (provider, model, dimensions) — adapters managing embeddings internally (RAGFlow, Dify, Vectara) may ignore it |
vectorStore |
Optional VectorStoreSchema ref (provider, collection) — same caveat |
refresh |
onRecordChange (default true for object sources) + optional cron (for an external scheduler; not self-scheduled) |
aiExposed |
May search_knowledge expose this source to agents (default true) — false indexes it without making it agent-searchable |
source.kind |
Fields | Hygiene |
|---|---|---|
object |
object, contentFields[] (min 1; * = every readable text field), metadataFields?, where? (ObjectQL where syntax) |
where keeps drafts and archived rows out of the index; contentFields takes meaningful text only (never system fields or ids, * sparingly); project what you will filter on at query time (status, owner_id, tags) into metadataFields |
file |
prefix (storage prefix, e.g. kb/handbooks/), mimeTypes? |
narrow the prefix instead of indexing a whole bucket |
http |
urls[], userAgent? |
enumerate URLs explicitly — there is no crawler |
import { KnowledgeSourceSchema, type KnowledgeSource } from '@objectstack/spec/ai';
export const supportKb: KnowledgeSource = KnowledgeSourceSchema.parse({
id: 'support_kb',
label: 'Support Knowledge Base',
adapter: 'ragflow', // or 'memory' for dev/test
source: {
kind: 'object',
object: 'kb_article',
contentFields: ['title', 'body'], // concatenated into document content
metadataFields: ['category', 'owner_id'], // projected for search-time filtering
where: { published: true }, // index published articles only
},
refresh: { onRecordChange: true }, // re-index on record.* events
});
Chunking, top-K, score thresholds and rerankers are NOT platform metadata. The spec scopes them out deliberately (
embedding.zod.ts): they belong to the adapter (adapterConfig) or application code. The platform carries only the embed + vector primitives, so any RAG strategy can be built on top.
Tool metadata (defineTool) — not the default path
Default: declare no tool records. A skill names a platform tool or an
action_<name> (ladder above). ADR-0109: a stack.tools record "exists only for
AI-presentation refinement (Phase 2: LLM description, parameter narrowing, flow
exposure) and has no runtime reader until that lands"
(packages/spec/src/stack.zod.ts:595-602). Author one only to refine how an
existing capability is presented — never to add one, and never to make
something executable: ToolSchema has no handler / implementation field, and
no framework executor loads a metadata-authored tool.
ToolSchema is strict — an unknown key (a typo, or a retired one) is a parse
error, not a silent strip. Required name / label / description and a JSON
Schema parameters object; optional objectName and outputSchema (⚠️
experimental — nothing reads it on a tool record; declare the schema as
ai.outputSchema on the action instead, which the AI runtime ☁️ enforces
by withholding a result that does not conform). Retired in protocol 17:
category, permissions, active, builtIn — all authorable and inert —
joining requiresConfirmation; each rejection carries its replacement.
Gate what a tool can do on the underlying action (action.requiredPermissions,
ADR-0066) or the objects it touches; to withdraw one, remove it from the skills
referencing it. Categorization belongs on the action (action.ai.category —
live, but listing-only: carried onto the tool, never sent to the model).
Model Configuration
Sampling parameters live under an agent's model (AIModelConfigSchema) —
there is no top-level temperature / maxTokens on an agent
(packages/spec/src/ai/agent.zod.ts:36-40):
| Key | Contract |
|---|---|
provider |
Inline enum openai | azure_openai | anthropic | local (default openai). Model-registry entries (ModelProviderSchema) accept a wider set: also google, cohere, huggingface, custom |
model |
Free string — the provider's own model name |
temperature |
min(0).max(2), default 0.7. Outside 0–2 is a parse error |
maxTokens / topP |
Optional numbers, unbounded by the schema |
Common Pitfalls
- Mistaking
guardrailsfor a gate.guardrailsis enforced per user turn by the AI runtime ☁️ — token and time limits, blocked tool names and categories refused — but it is a limit, not an approval;memoryis enforced by the AI runtime ☁️ too — long-term notes are recalled and reflected on,maxEntriesandreflectionIntervalare required oncelongTerm.enabledis true, and there is no storage backend to choose — and neither is a gate. For a gate that is enforced, useenableActionApproval: true(approval queue ☁️),ai.requiresConfirmationon the action, orapproval: 'always'on an MCP tool binding. AI metadata edits are already gated: they land as drafts a human must publish (ADR-0033). ⚠️requiresConfirmationon the tool was REMOVED (ADR-0033 §2) — no execution path read it, so it produced no pause;ToolSchemais strict, so authoring it now fails the parse. There is norequireApprovalForfield. - Indexing everything. A knowledge source without a
wherefilter and curatedcontentFieldsfills the index with drafts and boilerplate that pollute retrieval. Source hygiene is the metadata's job; relevance tuning belongs to the adapter.
App AI Blueprint
| Layer | File | Pattern |
|---|---|---|
| Reusable skill | src/skills/lead-qualification.skill.ts |
defineSkill — instructions + trigger conditions + a toolset where every name resolves; pick a surface |
| Knowledge source | src/knowledge/sales-kb.ts |
KnowledgeSourceSchema metadata, registered at boot via KnowledgeServicePlugin({ sources }) |
| Central registration | defineStack({ skills: [...] }) |
agents / tools / skills are the only AI stack collections — knowledge sources have none, agents are platform-supplied, and tools is not the default path |
Default for metadata apps: push business capability into skills, expose business logic as AI-exposed Actions, wire domain knowledge through knowledge sources.
Platform agents — reference only ☁️
The agent type is closed to third parties (allowRuntimeCreate:false,
allowEnvironmentOverlay:false; ADR-0063 §2) and os g agent is retired: a
stack-authored agent is filtered out of listAgents(), refused outright by
loadAgent(), and 404s on chat. You extend the platform with skills, never
by adding an agent. AgentSchema's full field list is in
references/_index.md.
The runtime ships exactly two agents, bound by surface — the user never
picks from a roster, the surface they are in selects the agent. ask is the
data product (≈ Claude Chat): conversational read / query / explore over records
plus running the business actions the app exposes, end-user audience,
RLS-bounded, canonical id ask (ASK_AGENT_NAME), and the implicit copilot for
any app that does not pin app.defaultAgent. build is the authoring
product (≈ Claude Code): agentic authoring of metadata through plan → draft →
verify → publish, builder audience, governance-gated, pinned by Studio via
app.defaultAgent. There is no per-turn intent classifier — a build-shaped
request arriving at ask is declined and redirected, never silently re-routed
into authoring (ADR-0063 §1/§5). data_chat→ask and
metadata_assistant→build resolve through the alias table for old bookmarks
and persisted agent_ids; they are not vocabulary — always write ask /
build.
An agent's tool set is the union of its surface-compatible skills' tools —
there is no global fall-through (ADR-0064), so ask cannot author by
construction; deactivating a skill (active: false) revokes that capability for
every agent referencing it. The built-in skills:
| Skill | surface |
Owns |
|---|---|---|
schema_reader |
both |
list_objects, describe_object, query_data |
data_explorer |
ask |
query_records, get_record, aggregate_data, visualize_data |
actions_executor |
ask |
action_* (the business actions an object exposes) |
metadata_authoring + solution_design |
build |
metadata draft / verify / publish + blueprint propose / apply |
All four are cloud-runtime skills — none resolves on the open edition, so
surface / tools / triggerConditions on your own skills are authored for
cloud and inert there; their instructions are what MCP serves.
visualize_data is the only built-in that draws a chart (an inline data-chart
part), auto-registered only when an analytics service (IAnalyticsService)
is wired — query_data / aggregate_data return numbers, not charts.
Two AgentSchema keys are tombstones, not legacy options — authoring either
is a parse error carrying its migration
(packages/spec/src/ai/agent.zod.ts:234, :251):
| Retired key | REMOVED in protocol 17 — why, and what replaces it |
|---|---|
agent.tools |
The inline slot resolved names against the full registry with no surface check. Not a rename: the migration deletes the key and names each tool that was listed; re-declare each in a skill (ADR-0064) |
agent.knowledge |
Declaring sources/indexes on an agent never scoped retrieval — search_knowledge takes sourceIds from the model's tool-call arguments. Restrict at the knowledge-service / source level; describe intended grounding in instructions |
Verify your work
After authoring a *.skill.ts / *.tool.ts, run the
author-time gate before reporting done:
os validate # Zod schema + CEL predicate validation + bindings (no artifact)
# or: os build # the same gates, plus emits dist/
It confirms the skill / tool metadata conforms to the protocol. This
domain has no CEL site — a model-registry promptTemplate.system / .user is
the template dialect ({{var}}); ToolSchema carries no expression field of
any kind. In a scaffolded project the gate is npm run validate. See
objectstack-platform → Verify your work.
References
See references/_index.md for the full list of Zod
schemas (with one-line descriptions) — pointers into
node_modules/@objectstack/spec/src/. Always Read the source for exact field
shapes; do not rely on memory of property names.