Tool System
Standalone calculators and utilities usable by humans (form UI) and AI agents (AI SDK ToolSet). Tool registration, permission gating, execution tracking, collaborative sessions, and the AI bridge.
MCP Phase 1 — four-shape interactivity (2026-04-24)
Tools are now the CallSpec shape in the four-shape interactivity model (see Interactivity and ADR-0006). Key changes:
ToolDefinition.outputSchemaZod: z.ZodType— required on every code-defined tool. The MCP server atfeatures/mcp/amble-server.tsserializes it to JSON Schema 2020-12 viazod-to-json-schema@3and attaches it to each tool's MCP registration so MCP clients validatestructuredContentresponses. Enforced by the conformance test infeatures/mcp/tool-schema-conformance.test.ts.- Tool input UI — the sole path is a
FormSpecregistered viaregisterFormSpec("tool:<slug>", spec)infeatures/custom/tools/ui.ts, rendered by<FormSpecRenderer>. All 14 custom tools register through this path; the legacyregisterToolUI(slug, { InputForm })API is fully retired. - Tool output UI — canonically a
ViewSpecregistered viaregisterViewSpec("tool:<slug>", spec)+ per-tool custom block atblock:tool-output-<slug>. All 11 custom tools migrated in Wave E4. LegacyOutputDisplaysurface was removed in Wave E6. - Retired:
"field-input","tool-output", and"tool-input"SlotKinds inlib/ui-registry.ts. The migration finished with the FormSpec v2 ship (dynamic arrays + file upload + conditional visibility). - Caller pattern:
tool-page-shell,tool-block,tool-call-card,session-page-clientlook up FormSpec/ViewSpec, then fall back to the generic JSON renderer for output.
The unified platform (2026-04-17)
The tool system has been restructured around a single tools table and a generic schema-versioning primitive. The legacy type column is gone; in its place, tools.kind discriminates system_override (per-tenant adjustments to a code-defined tool) from tenant_custom (DB-authored tools). Tenant admins can now author tools end-to-end from /admin/tools/new or via the manageTools AI tool.
The two-table model
tools— every tool surface lives here, one row per(slug, tenant_id, kind). Code-defined tools manifest as virtual rows merged in by the resolver; rows actually persisted are eithersystem_override(a per-tenant patch) ortenant_custom(a fully DB-authored tool).schema_versions— content-addressed history. Tools store input/output schemas inline intools.input_schema/tools.output_schema(the canonical JSON Schema) and write a versioned snapshot here on every change. Other consumers (entities, criteria, blocks) adopt the same primitive later.
ResolvedTool
features/tools/server/resolve.ts exposes two React-cached helpers:
resolveAllToolsForTenant(tenantId)— merges code-defined tools, system overrides, and tenant_custom rows intoResolvedTool[].resolveToolForTenant(slug, tenantId)— single-row variant.
All consumers (getEnabledSystemToolSlugs, getDisabledToolSlugs, the AI SDK bridge, the agent tool resolver) read through this resolver. There is no other supported read path.
ExecutionSpec (five shapes)
A Zod-discriminated union (features/tools/types/execution-spec.ts):
| Kind | Mode | What it does |
|---|---|---|
prompt | direct | Renders a prompt_template with input vars, hits a model |
prompt | skill | Runs a registered skill against an agent |
prompt | agent | Delegates to another agent |
task | — | Creates and (optionally) waits on a task |
composition | — | Chains other tools sequentially |
Beyond-Zod validation lives in validateExecutionSpec (cycle detection, missing-tool checks).
FieldDefinition primitive
features/schemas/field-definition.ts defines a portable, JSON-Schema-compatible shape covering 12 storage types (text, number, boolean, date, enum, url, email, phone, media, relation, object, array) crossed with display variants. bootstrapFieldRegistry() registers 24 default UI components under the field-input and field-display slot kinds in the UI registry.
FieldDefinitionForm and FieldDefinitionDisplay consume any list of FieldDefinitions; FieldDefinitionEditor lets humans (or the manageTools AI tool) author them.
Authoring custom tools
From the UI: /admin/tools/new renders the <ToolEditor mode="create"> composite, which wraps four sections (Identity / Visibility / Schema / Execution) and a TestPanel. Save dispatches createTenantTool (features/tools/server/tool-crud-actions.ts); each save records a new schema_versions row.
From an AI agent: the manageTools admin AI tool (registered with permission_policy: "always_ask") accepts a discriminated action ∈ {create, update, delete, list, get} and routes to the same server actions. Agents always require user approval before mutating tools.
Agents and tenant_custom tools
Agents continue to declare agent.config.customTools[] as a slug list. The agent tool resolver tries the code registry first, then falls back to resolveAllToolsForTenant so tenant_custom tools become discoverable. Use the new async resolveAgentToolsWithTenant(config, tenantId, userId, permissions?, tenantSlug?) overload from a request that has a tenant in scope; the synchronous resolveAgentTools is preserved for callers without a tenant.
Execution captured in session_events
tool_runs was dropped. Every tool execution now appends a tool.* event to the active session (features/sessions/server/event-log.ts). The event types: tool.started, tool.completed, tool.failed, tool.cancelled, tool.output_appended, tool.needs_approval, tool.approved. UI consumers reading the legacy ToolRun shape go through toLegacyToolRun in features/tools/server/translators.ts; everything below the boundary is unified.
Permission policy (auto vs always_ask)
Every tool carries a permission_policy that controls whether its execution requires user approval when invoked by an agent:
| Policy | Behavior |
|---|---|
auto | Default. Agents can execute the tool as long as they have requiredPermission (if any). |
always_ask | Every agent invocation waits for explicit user approval. Used for destructive or high-trust tools. |
The policy ships on the tool row (tools.permission_policy) or — for code-defined tools — in the ToolDefinition.permissionPolicy field. It surfaces in the resolver as ResolvedTool.permissionPolicy and drives a tool.needs_approval event in the session log before the actual execution. The built-in manageTools admin AI tool is registered with permission_policy: "always_ask" so agents cannot silently mutate the tool catalog.
Spec & roadmap
- Spec:
docs/superpowers/specs/2026-04-16-unified-tool-platform.md - Plan:
docs/superpowers/plans/2026-04-16-unified-tool-platform.md
Code-defined tools and tenant_custom tools sit side by side in ResolvedTool[]. The rest of this page documents the ToolDefinition registration path (still current for code-defined tools), the custom UI hooks, shared form helpers, and the filter/research tools. Anywhere the legacy text still mentions tool_runs or a type column on the tools table, read it with the single-table model in mind: tool_runs is dropped and execution lives in session_events; tools.type was replaced by tools.kind.
Overview
The tool system provides standalone calculators and utilities that serve two audiences simultaneously: humans interact with tools through form-based UI, while AI agents use the same tools as AI SDK ToolSet functions. A single ToolDefinition is the source of truth for both interaction modes -- the Zod input schema drives both the form rendering and the AI SDK tool contract.
Tools are divided into two categories: platform tools (entity, web, workflow, context, admin, document, media, view) that ship with the platform and operate on core data, and custom tools (user-facing calculators like ROI Calculator, Priority Matrix) that are product-specific and live in features/custom/tools/.
The tool system lives in features/tools/ and is fully domain-agnostic. Custom tools are registered via a plugin pattern and can optionally provide custom UI components.
Key Concepts
ToolDefinition
The core type that defines a tool:
interface ToolDefinition {
slug: string; // URL-safe identifier
name: string; // Human-readable name
description: string; // Used by both UI and AI agents
category: string; // Grouping for the tool library
icon: string; // Lucide icon name
inputSchema: z.ZodType; // Zod schema for input validation
execute: (input: any) => Promise<any>; // The tool's logic
collaborative?: boolean; // Enables multi-user sessions
aggregate?: (submissions: ToolSessionSubmission[]) => any; // Combine submissions
outputSections?: ToolOutputSection[]; // Structured output rendering
requiredPermission?: AppPermission; // Permission gating
tenantSlugs?: string[]; // Allowlist of tenant slugs that can see this tool
}ToolMeta
A serializable subset of ToolDefinition without the execute function or schema, used for tool library listings and client-side rendering:
type ToolMeta = Pick<
ToolDefinition,
"slug" | "name" | "description" | "category" | "icon" |
"collaborative" | "outputSections" | "requiredPermission" | "tenantSlugs"
>;Tool Categories
Platform Tools (built into the engine)
| Category | Tools | Module |
|---|---|---|
| Entity (8 tools) | searchEntities, getEntity, createEntity, updateEntity, deleteEntity, createRelation, listEntityTypes, getEntityStats | entity-tools.ts |
| Web (1 tool) | webSearch (Exa API) | web-tools.ts |
| Task (2 tools) | getTaskStatus, retrySession | admin-tools.ts |
| Context (3 tools) | addCorrection, addLesson, getUsageStats | context-tools.ts |
| Admin (3 tools) | updateEntityTypeSchema, updateAgentConfig, manageView | admin-tools.ts |
| Document (1 tool) | searchDocuments | document-tools.ts |
| View | manageView, inspectViews, generateView, saveTransientView | view-tools.ts |
| Media | Image/video generation and processing | media-tools.ts |
| Filter (2 tools) | filterEntities, extractFilters | filter-tools.ts |
| Research (1 tool) | researchEntities | research-tools.ts |
Custom Tools (product-specific, in features/custom/tools/)
Custom tools are registered via the same registerTool() API and appear alongside platform tools in the tool library and agent ToolSets. Current Amble custom tools include:
General analysis tools: ROI Calculator, Time Savings Calculator, AI Readiness Assessment, Complexity Estimator, Priority Matrix, Process Automation Scoper, FTE Impact Calculator, Quick Score.
IMS product line tools (Protoast mulch) — tenant-restricted to "ims":
| Slug | Name | Description |
|---|---|---|
ims-before-after-visualizer | Before & After Visualizer | Upload a real planter photo, select live IMS product/SKU options, and edit the source image into an after visualization |
ims-coverage-estimator | Coverage & Order Estimator | Calculate bags, pallets, weight, and cost for a mulch project |
ims-product-selector | Product Selector | Quiz-style recommendation with weighted scoring across 6 attributes |
ims-risk-screener | Mulch Risk Screener | Assess fire, off-gassing, breakdown, drainage, and slip risks of existing ground cover |
OCI product line tools — tenant-restricted to "oci":
| Slug | Name | Description |
|---|---|---|
oci-comparison-generator | Product Comparison Generator | Side-by-side Protoast vs competitor comparison with per-category scores |
oci-order-estimator | Commercial Order Estimator | Bulk order pricing with tiered freight and volume discount brackets |
Publishing tools:
| Slug | Name | Description |
|---|---|---|
content-desk-publish | Content Desk Publish | Create a draft or publish content through a saved WordPress, webhook, or social publishing connection |
IMS tools carry tenantSlugs: ["ims"] and OCI tools carry tenantSlugs: ["oci"], restricting each tool family to its own tenant. They will not appear in tool library listings, agent ToolSets, or individual tool lookups for any other tenant. The IMS catalog tools fall back to the canonical product catalog in features/custom/lib/ims-product-data.ts (ProductSlug, PRODUCT_DATA, PRODUCT_NAMES, PRODUCT_SLUGS, round2()), and the visualizer now prefers live product / sku entities when they exist.
The shared generate-image system tool also ships with a custom web UI now: it pulls the live image model list from /api/tools/image-models, injects the Google Nano Banana fallbacks (gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image), supports drag/drop reference-image uploads, and renders generated output with a signed image preview when storage objects are private.
Tool Output Sections
Tools can declare structured output rendering via outputSections:
interface ToolOutputSection {
key: string; // Key in the output object
label: string; // Display label
type: "summary" | "table" | "chart" | "radar" | "ranking" | "custom";
config?: Record<string, any>;
}These sections are converted to blocks via toolOutputToBlocks() for rendering in both the tool page and chat panel.
Collaborative Sessions
Tools with collaborative: true support multi-user sessions:
interface ToolSession {
id: string;
tenant_id: string;
tool_slug: string;
title: string | null;
status: "active" | "completed" | "cancelled";
entity_ids: string[];
config: Record<string, unknown>;
share_token: string | null;
created_by: string | null;
}
interface ToolSessionSubmission {
id: string;
session_id: string;
user_id: string;
user_display_name?: string;
input: Record<string, unknown>;
output: Record<string, unknown> | null;
created_at: string;
}Sessions are created via the session launcher, shared via a share link, and display aggregated results computed by the tool's aggregate function.
Tool execution events (session_events)
The legacy tool_runs table was dropped. Every tool execution now writes to session_events via appendSessionEvent() in features/sessions/server/event-log.ts. The event types are:
| Event | Meaning |
|---|---|
tool.started | Execution has begun — input captured |
tool.output_appended | Streaming chunk appended to the running output |
tool.completed | Execution finished successfully — final output captured |
tool.failed | Execution raised an error — error message captured |
tool.cancelled | Execution was cancelled by the user (or runtime) |
tool.needs_approval | Execution paused — permission_policy: "always_ask" gate hit |
tool.approved | User granted approval; execution resumes |
UI consumers that still expect the legacy ToolRun shape are served by the boundary translator toLegacyToolRun() in features/tools/server/translators.ts, which replays a tool.started + tool.completed pair into a single ToolRun for backwards compatibility. New code should read events directly.
The shape preserved at the UI boundary is:
// Produced by toLegacyToolRun() — do not persist this shape.
interface ToolRun {
id: string;
tenant_id: string;
tool_slug: string;
user_id: string | null;
input: Record<string, unknown>;
output: Record<string, unknown> | null;
status: "success" | "error";
error: string | null;
duration_ms: number;
source: "web" | "agent" | "api";
chat_id: string | null;
session_id: string | null;
}Custom Tool UI
Tools can optionally register custom UI components that replace the generic form and output renderers:
// features/tools/types.ts
export type ToolFormMode = "internal" | "embed";
interface ToolUIComponents {
InputForm?: ComponentType<{
onSubmit: (input: Record<string, any>) => void;
isLoading: boolean;
defaultValues?: Record<string, any>;
/** "internal" = full tool page; "embed" = published view block. Defaults to "internal". */
mode?: ToolFormMode;
}>;
OutputDisplay?: ComponentType<{
output: any;
input?: any;
}>;
}Custom input UI is registered via registerFormSpec("tool:<slug>", spec) in features/custom/tools/ui.ts; custom output UI via registerViewSpec("tool:<slug>", spec). When present, the tool page and chat panel use those specs instead of the generic form/output.
mode prop — internal vs embed
tool-page-shell.tsx passes mode="internal" to custom input forms; tool-block.tsx passes mode="embed". Forms use this prop to hide operator-facing config (AI model, aspect ratio, scene type) from end-customers in embedded views. The prop is optional — existing custom forms that don't declare it continue to work unchanged.
AdditionalInputs collapsible
features/tools/components/additional-inputs.tsx is a shared wrapper for internal-only config fields:
import { AdditionalInputs } from "@/features/tools/components/additional-inputs";
// Inside a custom InputForm component:
<AdditionalInputs mode={mode} title="Additional options">
<ModelSelect ... />
<AspectRatioSelect ... />
</AdditionalInputs>- Returns
nullin embed mode — config fields are completely absent from the DOM. - In internal mode, renders a shadcn
Collapsiblethat is closed by default — keeps the internal page just as tight as the embed, with power-user config one click away. titledefaults to"Additional options".classNameis forwarded to the collapsible root.
ImageDropzoneUploader — compact and URL props
features/custom/tools/shared/image-dropzone-uploader.tsx accepts two additional props for tighter embed layouts:
| Prop | Type | Default | Description |
|---|---|---|---|
compact | boolean | false | Reduces dropzone height and hides the URL paste input. Used in embed mode where vertical space is constrained. |
hideManualUrl | boolean | false | Hides the URL input without reducing the dropzone height. Useful when a tool accepts uploads only. |
The publishing tool uses the custom UI hook to expose a connection-aware input form. Instead of typing raw JSON, operators pick a saved publishing destination, create a new connection inline when needed, choose draft vs publish, and submit the content body. Because the execution path still runs through a normal ToolDefinition, agents and workflows can call the same tool with connectionId or connectionName and get identical behavior.
How It Works
Tool Registration
Tools are registered via registerTool() in features/tools/registry.ts:
registerTool({
slug: "roi-calculator",
name: "ROI Calculator",
description: "Calculate NPV, payback period, and year-by-year ROI",
category: "analysis",
icon: "calculator",
inputSchema: roiInputSchema,
execute: async (input) => { /* ... */ },
outputSections: [
{ key: "summary", label: "Summary", type: "summary" },
{ key: "yearlyResults", label: "Year-by-Year", type: "table" },
],
});The registry is a simple Map<string, ToolDefinition>. There is no initialization order dependency -- tools can be registered at any time before they are needed.
For agent-facing use, tool descriptions should read like operating instructions, not UI marketing copy. Good descriptions tell the model when to use the tool, when not to use it, and which cheaper or safer tool to try first.
Tenant-Scoped Tool Visibility
There are two independent mechanisms for restricting tool visibility to specific tenants.
1. Definition-level allowlist (tenantSlugs)
Setting tenantSlugs on a ToolDefinition restricts the tool to tenants whose slug appears in the array. The check is applied at the registry layer — before the DB-based enabled/disabled check — and affects all three access paths:
getAvailableToolMeta()(tool library page)getAvailableTool(slug)(individual tool lookup)getUserFacingToolSet(permissions?, tenantSlug?)(agent ToolSet construction)
The tenantSlug is resolved from the active tenant context and passed through resolveAgentTools() → buildResolved() → getUserFacingToolSet() so the filtering is consistent across chat, heartbeat, and extraction contexts.
Tools with no tenantSlugs (or an empty array) are visible to all tenants — the allowlist is purely additive.
2. DB-based enabled/disabled (tools table)
Tool visibility is also controlled by rows in the tools DB table. The two models differ for system tools vs custom tools:
| Tool type | Default (no row) | Row with enabled: true | Row with enabled: false |
|---|---|---|---|
| System (e.g. Image Generator) | Hidden | Visible | Hidden |
| Custom (e.g. IMS tools) | Visible | — (same as no row) | Hidden |
System tools use an opt-in model because they require platform infrastructure (API keys, model credits) that may not be provisioned for every tenant. Custom tools use an opt-out model to preserve backwards compatibility — all existing custom tools remain visible unless explicitly disabled.
The tools table is scoped by tenant_id. Rows can use DEFAULT_TENANT_ID (from features/tenant/constants.ts) to apply a setting globally across all tenants.
Relevant functions:
getEnabledSystemToolSlugs()— returns slugs of system tools that have anenabled: truerow for the current tenant or the default tenant.getDisabledToolSlugs()— returns slugs of any tools that have anenabled: falserow for the current tenant or the default tenant. Used to gate custom tool visibility.getAvailableTool(slug)— resolves a single tool by slug, applying the appropriate visibility check. For custom tools, checksgetDisabledToolSlugs(). For system tools, checksgetEnabledSystemToolSlugs(). In unauthenticated (public embed) contexts, the disabled-slug check is skipped gracefully via try/catch — the embed token is the access control.getAvailableToolMeta()— returns the filtered list of tool metadata for the tool library page. Applies both enabled and disabled filters.
To hide a code-defined tool from a specific tenant:
INSERT INTO tools (tenant_id, slug, enabled, kind)
VALUES ('your-tenant-id', 'ims-coverage-estimator', false, 'system_override');To hide a code-defined tool globally:
INSERT INTO tools (tenant_id, slug, enabled, kind)
VALUES ('00000000-0000-0000-0000-000000000000', 'ims-coverage-estimator', false, 'system_override');
-- Use DEFAULT_TENANT_ID from features/tenant/constants.tsNote: the prior type column on tools was replaced by kind (system_override | tenant_custom) in the 2026-04-17 migration. Tenant-authored tools live entirely in tenant_custom rows; there is no longer a "custom" kind on the override path.
Tool Execution
executeTool() in features/tools/execute.ts handles the full execution lifecycle:
- Lookup -- Retrieves the tool via the resolver (
resolveToolForTenant(slug, tenantId)), picking up anytenant_customor overlay rows. - Permission check -- If the tool has
requiredPermission, verifies the caller has it. Ifpermission_policy: "always_ask", emits atool.needs_approvalevent and waits for atool.approvedevent (chat surfaces the approval prompt). - Input validation -- Parses raw input through the tool's Zod schema (code-defined) or
FieldDefinition[]-derived Zod (tenant_custom). - Execution -- Calls
tool.executefor code-defined tools, or dispatches throughexecuteSpec()infeatures/tools/server/execute-spec.tsfor tenant_custom tools with anexecutionSpec. - Event logging -- Appends
tool.started→tool.completed(ortool.failed) events to the active session (fire-and-forget for web/agent sources, awaited for API sources). - Analytics -- Fires a
tool_runanalytics event.
The function selects the Supabase client based on the source: authenticated client for web (preserves RLS context), admin client for agent and api (cross-user access).
AI Bridge
getUserFacingToolSet() in features/tools/ai-bridge.ts wraps all registered custom tools as an AI SDK ToolSet:
function getUserFacingToolSet(permissions?: AppPermission[], tenantSlug?: string): ToolSet {
const tools = getAllTools();
const toolSet: ToolSet = {};
for (const tool of tools) {
// Skip tools restricted to specific tenants that don't match
if (tool.tenantSlugs && tool.tenantSlugs.length > 0 && tenantSlug) {
if (!tool.tenantSlugs.includes(tenantSlug)) continue;
}
// Skip tools the caller lacks permission for
if (tool.requiredPermission && !permissions?.includes(tool.requiredPermission)) {
continue;
}
toolSet[tool.slug] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (input) => {
const result = await executeTool(tool.slug, input, "agent", { permissions });
if (result.error) return { error: result.error };
return result.output;
},
};
}
return toolSet;
}The tenantSlug is resolved from the active tenant context in the chat route and passed through resolveAgentTools() and buildResolved(). This means the filtering applies consistently in chat, heartbeat, and extraction contexts. This ToolSet is merged into every agent's tools, giving agents access to all registered user-facing tools alongside their platform tools.
At execution time, the agent runtime normalizes the ToolSet before sending it to the provider:
- tool names are sorted deterministically to maximize provider-level prompt caching
- Anthropic-backed runs add cache-control metadata to stable tool definitions
- permission filtering still happens before normalization, so agents never see tools they cannot use
Permission Gating
Platform tools use permission maps to control access:
const ENTITY_TOOL_PERMISSIONS: Record<string, AppPermission> = {
searchEntities: "entities.team.read",
getEntity: "entities.team.read",
createEntity: "entities.team.create",
updateEntity: "entities.team.update",
deleteEntity: "entities.team.delete",
createRelation: "entities.team.create",
listEntityTypes: "entity_types.team.read",
getEntityStats: "entities.team.read",
};Each tool group function (getEntityTools(), getWorkflowTools(), getContextTools(), getAdminTools()) accepts an optional permissions array. Tools requiring a permission the caller lacks are excluded from the returned ToolSet. This means agents never even see tools they cannot use.
When no permissions are passed to getEntityTools(), it returns an empty object (fail-closed). This prevents accidentally giving unpermissioned callers access to entity tools.
Entity-Aware Schema Fields
Tool input schemas can use Zod .meta() to trigger special rendering in the generic form:
z.string().meta({ "x-entity-type": "opportunity" }); // Single entity picker
z.array(z.string()).meta({
"x-entity-type": "opportunity",
"x-entity-select": "multiple"
}); // Multi-select entity pickerThe generic form auto-detects this metadata and renders the EntityPicker component instead of a text input.
Generic UI
When no custom UI is registered, the platform renders:
- Generic form (
generic-tool-form.tsx) -- Renders input fields from JSON Schema. Auto-detects entity picker fields. Supports all standard Zod types. - Generic output (
generic-tool-output.tsx) -- Renders key-value output. Falls back for unknown output shapes. - Sectioned output (
sectioned-output.tsx) -- WhenoutputSectionsare defined, renders each section using the appropriate block renderer (summary, table, chart, radar, ranking).
Filter and Research Tools
features/tools/filter-tools.ts and features/tools/research-tools.ts provide typed field-level querying and cross-entity document research. Both are platform tools (not custom tools) and require entities.team.read.
filterEntities
Applies structured filter conditions to the entities.content JSONB column. Unlike searchEntities (which is full-text / title oriented), filterEntities targets specific content fields with typed operators.
// Tool input schema
{
entityTypeSlug: string, // Type to query
filters: FilterCondition[], // Field comparisons
sortBy?: string, // Content field, "title", or "created_at"
sortOrder?: "asc" | "desc",
limit?: number, // Default 50
offset?: number,
}
// FilterCondition
{
field: string, // Content field name
operator: "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "in" | "contains",
value: unknown,
}Operator semantics:
| Operator | JSONB translation | When to use |
|---|---|---|
eq / neq | ->> text cast | String equality, enum values |
gt / lt / gte / lte | -> JSONB | Numeric comparisons (preserves ordering) |
in | ->> text cast + IN | Enum multi-value ("any of") |
contains | -> JSONB @> or ILIKE | Array containment or substring match |
Output: { results: EntitySummary[], total: number, appliedFilters: number }
Each result includes id, slug, title, type, typeSlug, href (e.g., /programs/uuid), tags, and content.
extractFilters
Uses generateObject to extract structured filter criteria from a chat conversation. Reads the entity type's json_schema to understand available fields, then maps natural language to FilterCondition[].
// Input
{
messages: Array<{ role: string; content: string }>,
entityTypeSlug: string,
}
// Output
{
entityType: string,
entityTypeSlug: string,
filtersToApply: FilterCondition[], // Ready-to-use conditions
suggestedFilters: Array<{ field, reason }>, // Fields the user should clarify
relevantFields: string[], // Mentioned but without concrete values
}Agents call extractFilters to convert a user's natural language request into filters, then pass those filters to filterEntities. This is the two-step pattern for conversational filtering.
researchEntities
Combines entity filtering with hybrid document search. Resolves a set of matching entities, then searches each entity's associated documents for a research query, returning per-entity findings with relevance scores.
// Input
{
query: string, // Research question
entityTypeSlug: string,
filters?: FilterCondition[], // Optional pre-filter
maxEntities?: number, // Default 10
documentsPerEntity?: number, // Max chunks per entity, default 3
}
// Output
{
query: string,
entityTypeSlug: string,
results: Array<{
entity: { id, title, typeSlug, href, content },
findings: Array<{ content, score, documentId, page }>,
documentCount: number,
}>,
summary: { totalEntities, entitiesWithFindings, totalChunks, totalMatchedEntities },
}Documents are fetched via a single batch query on the documents table filtered by entity_id IN (entityIds) and status = "ready". Per-entity document search runs in parallel via Promise.all(). Entities with no attached documents appear in results with an empty findings array.
Filter Schema (features/blocks/lib/filter-schema.ts)
schemaToFilterFields() converts a JSON Schema properties object into FilterFieldConfig[] — the shared type used by both the entity-filter block UI and the extractFilters AI tool context.
function schemaToFilterFields(
properties: Record<string, unknown>,
visibleFields?: string[],
): FilterFieldConfig[]Control type mapping:
| Schema type | Control type |
|---|---|
string with enum (≤6 values) | select |
string with enum (>6 values) | multi-select |
number / integer (no range) | number |
number / integer with minimum/maximum | range |
boolean | toggle |
array with items.enum | multi-select |
array (no enum) | text |
string (no enum) | text |
API Reference
Registry (features/tools/registry.ts)
| Function | Signature | Description |
|---|---|---|
registerTool(tool) | (tool: ToolDefinition) => void | Register a tool definition. |
getTool(slug) | (slug: string) => ToolDefinition | undefined | Look up a tool by slug. |
getAllTools() | () => ToolDefinition[] | List all registered tools. |
toToolMeta(tool) | (tool: ToolDefinition) => ToolMeta | Extract serializable metadata. |
getAllToolMeta() | () => ToolMeta[] | List all tool metadata (for client-side listings). |
Visibility (features/tools/system-tools.ts)
| Function | Signature | Description |
|---|---|---|
getAvailableTool(slug) | (slug: string) => Promise<ToolDefinition | undefined> | Resolve a single tool with visibility checks applied. Returns undefined if the tool is hidden for the current tenant. Safe in unauthenticated contexts. |
getAvailableToolMeta() | () => Promise<ToolMeta[]> | Return filtered tool metadata for the tool library. Applies both system-tool enabled list and custom-tool disabled list. |
ensureSystemToolsRegistered() | () => Promise<void> | Register system tools into the registry (idempotent). Called automatically by visibility functions. |
Server Actions (features/tools/server/actions.ts)
| Function | Signature | Description |
|---|---|---|
getEnabledSystemToolSlugs() | () => Promise<string[]> | Slugs of system tools with enabled: true for the current or default tenant. Cached per request. |
getDisabledToolSlugs() | () => Promise<string[]> | Slugs of any tools with enabled: false for the current or default tenant. Cached per request. Used to hide custom tools. |
Background Job Context (features/tools/lib/tool-context.ts)
Used to inject tenant identity into tool execute functions when there is no HTTP request context (automations, heartbeat, Inngest functions).
| Function | Signature | Description |
|---|---|---|
withToolContext(context, fn) | (context: { tenantId: string }, fn: () => T) => T | Run a callback with tenant context stored in AsyncLocalStorage. Wraps the agent execution call in background jobs. |
getToolContextTenantId() | () => string | null | Read the tenant ID from the current AsyncLocalStorage store. Returns null when called outside a withToolContext scope. |
getTenantIdForTool() | () => Promise<string> | Resolve tenant ID for a tool execute function. Prefers the AsyncLocalStorage context; falls back to getActiveTenantId() for HTTP request paths. All tool files must call this instead of getActiveTenantId() directly. |
Execution (features/tools/execute.ts)
| Function | Signature | Description |
|---|---|---|
executeTool(slug, rawInput, source, options?) | Returns { output, error, durationMs, runId } | Full execution lifecycle with validation, permission check, recording, and analytics. |
AI Bridge (features/tools/ai-bridge.ts)
| Function | Signature | Description |
|---|---|---|
getUserFacingToolSet(permissions?, tenantSlug?) | (permissions?: AppPermission[], tenantSlug?: string) => ToolSet | All registered tools as AI SDK ToolSet, filtered by permissions and optional tenant slug allowlist. |
Entity Tools (features/tools/entity-tools.ts)
| Function | Signature | Description |
|---|---|---|
getEntityTools(permissions?) | (permissions?: AppPermission[]) => ToolSet | 8 entity CRUD tools as AI SDK ToolSet, filtered by permissions. Returns empty object when no permissions provided. |
Filter Tools (features/tools/filter-tools.ts)
| Function | Signature | Description |
|---|---|---|
getFilterTools(permissions?) | (permissions?: AppPermission[]) => ToolSet | Returns filterEntities and extractFilters tools. Returns empty object when no permissions provided. |
executeFilterEntities(input) | (input: FilterEntitiesInput) => Promise<FilterResult> | Exported for reuse by researchEntities. Applies content filters and returns entity summaries. |
applyContentFilters(qb, filters) | Generic Supabase query builder helper | Internal helper that translates FilterCondition[] into Supabase query builder calls. |
Research Tools (features/tools/research-tools.ts)
| Function | Signature | Description |
|---|---|---|
getResearchTools(permissions?) | (permissions?: AppPermission[]) => ToolSet | Returns researchEntities tool. Returns empty object when no permissions provided. |
Filter Schema (features/blocks/lib/filter-schema.ts)
| Function | Signature | Description |
|---|---|---|
schemaToFilterFields(properties, visibleFields?) | (props: Record<string, unknown>, visible?: string[]) => FilterFieldConfig[] | Derive filter UI config from JSON Schema properties. |
humanizeFieldName(name) | (name: string) => string | Convert snake_case or kebab-case field names to "Title Case" labels. |
Pages
| Path | Description |
|---|---|
/tools | Tool library grid showing all available tools |
/tools/[slug] | Individual tool page with form, output, and run history |
/tools/[slug]/session/[id] | Collaborative session with submissions and aggregated results |
For Agents
All agents automatically receive user-facing tools via getUserFacingToolSet(). Platform tools are added based on the agent's toolGroups configuration:
- An agent with
toolGroups: ["entity"]gets entity tools + document search + user-facing tools - An agent with
toolGroups: ["entity", "web"]additionally getswebSearch - An agent with
toolGroups: ["admin"]gets admin tools + view tools
Agents use tools the same way humans do -- the AI SDK calls the tool's execute function, input is validated against the same Zod schema, and the run is logged to session_events with source: "agent".
Tool results in chat are auto-rendered as rich blocks via the toolOutputToBlocks() bridge when the tool defines outputSections.
The getUsageStats context tool now returns prompt-caching and runtime telemetry in addition to spend and token totals, so agents can inspect whether cached prompt blocks and long-chat controls are being used effectively.
Adding a New Custom Tool
-
Create
features/custom/tools/[name]/definition.ts:import { z } from "zod/v4"; import { registerTool } from "@/features/tools/registry"; registerTool({ slug: "my-tool", name: "My Tool", description: "Does something useful", category: "analysis", icon: "calculator", inputSchema: z.object({ value: z.number() }), execute: async (input) => { return { result: input.value * 2 }; }, }); -
Create
features/custom/tools/[name]/definition.test.tsusing shared test utilities (mockToolRegistry(),captureTool()). -
Import in
features/custom/tools/index.tsto ensure registration. -
(Optional) Custom input/output UI: register a
FormSpecviaregisterFormSpec("tool:<slug>", spec)and/or aViewSpecviaregisterViewSpec("tool:<slug>", spec)infeatures/custom/tools/ui.ts. -
The tool immediately appears in
/tools, gets its own page at/tools/[slug], and is available to all agents in chat.
Design Decisions
Opt-out visibility for custom tools, opt-in for system tools. Custom tools default to visible (no tools row required) to preserve backwards compatibility. System tools default to hidden (opt-in via enabled: true row) because they require infrastructure — API keys, model credits — that may not be provisioned for every tenant. The asymmetry is intentional and reflects the different deployment models.
Definition-level tenant allowlist for product-specific tools. The tenantSlugs field on ToolDefinition addresses a gap in the DB-based model: custom tools that belong to one product tenant and must never appear on others. Using a definition-level allowlist (rather than a DB row per tenant) keeps the constraint co-located with the tool's code, requires no migration, and applies consistently to all three access paths (tool library, individual lookup, agent ToolSet). The DB opt-out mechanism (tools table with enabled: false) handles the inverse case — disabling a tool for a specific tenant that would otherwise see it.
Graceful degradation in public embed contexts. getAvailableTool() wraps the getDisabledToolSlugs() call in a try/catch. If there is no tenant context (unauthenticated public embed), the disabled-slug check is skipped and the tool is returned. The embed token itself is the access control — a tool embedded on a published view was explicitly authorized by an authenticated user. Disabling a tool after publishing is a separate concern that should be handled at the publish layer.
Single definition, two interfaces. The same ToolDefinition powers both the human form UI and the AI SDK tool. The Zod schema validates input in both cases. This eliminates divergence between what humans can do and what agents can do with a tool.
Permission gating via exclusion, not runtime checks. When permissions are provided, tools the caller lacks permission for are removed from the ToolSet entirely. Agents never see tool descriptions or schemas for tools they cannot use. This is more secure than checking permissions at execution time (though executeTool() also checks as a defense-in-depth measure).
Fire-and-forget recording for web/agent. tool.started / tool.completed event appends are fire-and-forget for web and agent sources to avoid blocking the response. For API sources, the event appends are awaited so the run ID is available in the response (the ID is derived from the tool.started event row).
Entity tools use admin client. Entity tools use createAdminClient() because they run in agent context where the authenticated user's RLS policies may not match the entity being operated on. Tenant scoping is enforced explicitly via getTenantIdForTool().
Background jobs require injected tenant context. getActiveTenantId() calls Next.js cookies() internally, which is only available during an HTTP request. Automations, heartbeat jobs, and Inngest functions run outside request scope, so calling getActiveTenantId() in a tool execute function silently returns nothing or throws. All tool files call getTenantIdForTool() instead, which checks AsyncLocalStorage first (populated by withToolContext) and falls back to the request-scoped getActiveTenantId() for HTTP contexts. Background callers must wrap their agent execution in withToolContext({ tenantId }, fn) before invoking any tools. This pattern mirrors how Next.js itself propagates request state (cookies, headers) through async call chains.
Fail-closed for entity tools. getEntityTools() returns an empty object when no permissions are passed. This prevents accidentally exposing entity tools to unpermissioned callers. Every caller must explicitly pass permissions.
Deterministic tool ordering supports prompt caching. Before execution, the agent runtime sorts tool names and attaches provider-specific cache metadata for Anthropic-backed runs. Stable tool ordering improves cache reuse because the provider sees identical tool definitions across repeated calls.
MCP-exposed tools must emit extractable JSON Schemas. Every tool registered via registerTool() is reachable through the MCP API-key surface (subject to tenantSlugs and per-tenant disable). The MCP SDK serializes each tool's inputSchema to JSON Schema using getObjectShape(), which can only extract a usable shape from a ZodObject (or a ZodRawShape-compatible value). Two patterns silently break this contract:
z.discriminatedUnion(...)at the top level ofinputSchema— the SDK can't extract.shapeand falls back to{ type: "object", properties: {} }. The tool appears schema-less to every MCP client (Codex CLI silently dropped the entire Amble surface this way until 2026-04-23). Fix: flatten toz.object({ action: z.enum([...]), ...allOptionalFields })plus runtime per-action guards inexecute()— seemanageToolsfor the canonical pattern.z.any()andz.unknown()inz.record()value position — both emitadditionalProperties: {}(verified with the MCP SDK andzod-to-json-schemaversions pinned in this repo as of 2026-04-23; the equivalence may shift on future minor bumps). OpenAI strict-mode tool schemas may reject this shape. Whether Codex specifically rejects it is unconfirmed pending livecodex execagainst the post-flatten server — seedocuments/work/2026-04-23-codex-cli-mcp-schema-fix/followups.md. If you need an open-ended config field, prefer a typedz.object({...})with the known keys when possible.
The contract is enforced by features/mcp/tool-schema-conformance.test.ts. Tools that legitimately take no input (e.g. getNavConfig with inputSchema: z.object({})) are listed in INTENTIONALLY_INPUT_LESS so the schema-less check doesn't false-fire.
Admin authoring surface
| Path | Description |
|---|---|
/admin/tools | Two-tab list page — tenant_custom tools and code-defined tools |
/admin/tools/new | <ToolEditor mode="create"> — author a new tenant_custom tool |
/admin/tools/[slug]/edit | <ToolEditor mode="edit"> — edit a tenant_custom tool or author a system override |
/tools | End-user library with origin / kind / category filters |
POST /api/tools | Create a tenant_custom tool (tools.tenant.create) |
GET/PATCH/DELETE /api/tools/[slug] | Read / update / delete with kind-appropriate policy |
POST /api/tools/[slug]/test | Non-streaming preview (admin TestPanel fallback) |
POST /api/tools/[slug]/test/stream | Streaming preview for prompt.direct text tools |
GET /api/tools/[slug]/versions | Read schema_versions history for a tool's schemas |
manageTools AI tool
features/tools/admin/tool-management-tools.ts registers a single admin AI tool named manageTools with permission_policy: "always_ask". It accepts an action ∈ { create, update, delete, list, get } plus a flat object of all optional fields and dispatches to the corresponding server action in tool-crud-actions.ts. Per-action required-field validation lives in assertActionFields() so the agent-facing contract matches the prior discriminated-union shape (a create without slug returns { error: "manageTools.create requires 'slug'" }, etc.). Every agent-driven tool mutation requires explicit user approval in the chat UI before a write happens.
The flat-object schema (rather than z.discriminatedUnion) is required for MCP wire compatibility — see the "MCP-exposed tools must emit extractable JSON Schemas" design decision above.
Thin tool + DAG-driven field extraction (ADR-0021)
Bundle-shipped content generators (executive summaries, diligence memos, assessment reports) follow a "thin tool, fat DAG" pattern. The tool's execute function is a trigger, not an orchestrator: it validates inputs, creates one entity, and returns the link in under 500ms. The platform's existing task DAG auto-populates the entity's fields async via Inngest workers.
Why this pattern over a monolithic god-tool. Sequential executeTool() per Pilot + per Tech Assessment + LLM streaming is 30–60s on any real engagement, which blows Vercel's serverless timeout. Tasks are async-by-default, observable via session_events, retryable per-step via Inngest, and use the platform's superpower (the DAG) instead of routing around it. See ADR-0021 for the full rationale.
Authoring checklist for a content generator in a bundle:
- Define the entity_type in the bundle manifest with
config.fields.{name}.extraction_task_slugper generative or computed field. - Define the tasks in
tasks[]with properdepends_on(DAG, no cycles). Layer-1 tasks are independent; layer-2 tasks run after layer-1 completes. - Define a single composer agent (e.g.
executive-summary-composer) that handles every task viagenerateObject()with a Zod schema enforcingclaims[] = {text, source_entity_ids[]}per claim. - Define the thin tool: validates IDOR + workspace scope + sufficient-graph + bundle-installed; creates the entity; returns
{entityId, briefUrl}. - Register the tool via the bundle's
tools[]section, NOT the globalfeatures/custom/tools/index.tsregistry. Bundle-scoped tools never leak to non-bundled tenants. - Per-claim structured output via
generateObject(), notstreamText()— schema validation catches shape drift cheap. - Citations validator as a layer-2 task. Reject claims with empty
source_entity_ids[]. Reject entity ids not in the input engagement's connected-entity set (fabricated-id guard). - Use existing surface types —
pdffor paginated print-ready briefs,pagefor SEO-friendly long-form. DO NOT invent newsurface_typevalues per content type. - Disclaimer / classification metadata fields on the entity_type, populated from
tenant_settingsat create time.
Reference implementation: features/custom/bundles/cbt-ai-tech-consulting/ ships the first instance — the executive-summary entity_type + 7 extraction tasks + executive-summary-composer agent + generateExecutiveSummary thin tool. See features/custom/tools/generate-executive-summary/definition.ts for the ~200-line tool (the execute function itself is ~30 lines; the rest is the input/output schemas + the test-friendly core/wrap split).
The ~50-line execute pattern:
async function generateExecutiveSummaryCore(input, ctx): Promise<Output> {
if (!ctx.bundleInstalled) return reject("bundle_not_installed", "...");
if (!ctx.engagement) return reject("engagement_not_found", "...");
if (ctx.engagement.tenantId !== ctx.activeTenantId)
return reject("idor_violation", "...");
if (
ctx.activeWorkspaceId &&
ctx.engagement.workspaceId &&
ctx.engagement.workspaceId !== ctx.activeWorkspaceId
)
return reject("workspace_violation", "...");
if (ctx.engagement.connectedEntityCount < 3)
return reject("insufficient_graph", "...");
const { id } = await ctx.createBrief({ /* metadata */ });
return { entityId: id, briefUrl: buildUrl(ctx, id), sourceEntityCount, error: null };
}The pure core function takes an ExecuteContext (loaded by the wrapper), so unit tests cover all 5 rejection codes + happy path without booting Supabase / Next headers / Inngest.
Related Modules
- Entity System -- Entity tools are the primary way agents interact with entities
- Block System -- Tool output sections are converted to blocks via bridge functions
- Agent System -- Agents receive tools based on their config's
toolGroupsandcustomTools - Auth and Permissions -- Permission gating uses the unified RBAC system
- Sessions -- Tool execution events live on the session event log
Components Runtime — the @/runtime palette for agent-authored components
Agent-facing reference for the curated @/runtime palette, the action allowlist, the pure-render contract, and the constraints.
Agent System
DB-managed AI agents with configurable tool groups, scheduled heartbeat execution, external connections, delegation, config versioning, and a unified execution runtime.