Sprinter Docs

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 at features/mcp/amble-server.ts serializes it to JSON Schema 2020-12 via zod-to-json-schema@3 and attaches it to each tool's MCP registration so MCP clients validate structuredContent responses. Enforced by the conformance test in features/mcp/tool-schema-conformance.test.ts.
  • Tool input UI — the sole path is a FormSpec registered via registerFormSpec("tool:<slug>", spec) in features/custom/tools/ui.ts, rendered by <FormSpecRenderer>. All 14 custom tools register through this path; the legacy registerToolUI(slug, { InputForm }) API is fully retired.
  • Tool output UI — canonically a ViewSpec registered via registerViewSpec("tool:<slug>", spec) + per-tool custom block at block:tool-output-<slug>. All 11 custom tools migrated in Wave E4. Legacy OutputDisplay surface was removed in Wave E6.
  • Retired: "field-input", "tool-output", and "tool-input" SlotKinds in lib/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-client look 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 either system_override (a per-tenant patch) or tenant_custom (a fully DB-authored tool).
  • schema_versions — content-addressed history. Tools store input/output schemas inline in tools.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 into ResolvedTool[].
  • 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):

KindModeWhat it does
promptdirectRenders a prompt_template with input vars, hits a model
promptskillRuns a registered skill against an agent
promptagentDelegates to another agent
taskCreates and (optionally) waits on a task
compositionChains 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:

PolicyBehavior
autoDefault. Agents can execute the tool as long as they have requiredPermission (if any).
always_askEvery 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)

CategoryToolsModule
Entity (8 tools)searchEntities, getEntity, createEntity, updateEntity, deleteEntity, createRelation, listEntityTypes, getEntityStatsentity-tools.ts
Web (1 tool)webSearch (Exa API)web-tools.ts
Task (2 tools)getTaskStatus, retrySessionadmin-tools.ts
Context (3 tools)addCorrection, addLesson, getUsageStatscontext-tools.ts
Admin (3 tools)updateEntityTypeSchema, updateAgentConfig, manageViewadmin-tools.ts
Document (1 tool)searchDocumentsdocument-tools.ts
ViewmanageView, inspectViews, generateView, saveTransientViewview-tools.ts
MediaImage/video generation and processingmedia-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":

SlugNameDescription
ims-before-after-visualizerBefore & After VisualizerUpload a real planter photo, select live IMS product/SKU options, and edit the source image into an after visualization
ims-coverage-estimatorCoverage & Order EstimatorCalculate bags, pallets, weight, and cost for a mulch project
ims-product-selectorProduct SelectorQuiz-style recommendation with weighted scoring across 6 attributes
ims-risk-screenerMulch Risk ScreenerAssess fire, off-gassing, breakdown, drainage, and slip risks of existing ground cover

OCI product line tools — tenant-restricted to "oci":

SlugNameDescription
oci-comparison-generatorProduct Comparison GeneratorSide-by-side Protoast vs competitor comparison with per-category scores
oci-order-estimatorCommercial Order EstimatorBulk order pricing with tiered freight and volume discount brackets

Publishing tools:

SlugNameDescription
content-desk-publishContent Desk PublishCreate 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:

EventMeaning
tool.startedExecution has begun — input captured
tool.output_appendedStreaming chunk appended to the running output
tool.completedExecution finished successfully — final output captured
tool.failedExecution raised an error — error message captured
tool.cancelledExecution was cancelled by the user (or runtime)
tool.needs_approvalExecution paused — permission_policy: "always_ask" gate hit
tool.approvedUser 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 null in embed mode — config fields are completely absent from the DOM.
  • In internal mode, renders a shadcn Collapsible that is closed by default — keeps the internal page just as tight as the embed, with power-user config one click away.
  • title defaults to "Additional options". className is 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:

PropTypeDefaultDescription
compactbooleanfalseReduces dropzone height and hides the URL paste input. Used in embed mode where vertical space is constrained.
hideManualUrlbooleanfalseHides 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 typeDefault (no row)Row with enabled: trueRow with enabled: false
System (e.g. Image Generator)HiddenVisibleHidden
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 an enabled: true row for the current tenant or the default tenant.
  • getDisabledToolSlugs() — returns slugs of any tools that have an enabled: false row 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, checks getDisabledToolSlugs(). For system tools, checks getEnabledSystemToolSlugs(). 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.ts

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

  1. Lookup -- Retrieves the tool via the resolver (resolveToolForTenant(slug, tenantId)), picking up any tenant_custom or overlay rows.
  2. Permission check -- If the tool has requiredPermission, verifies the caller has it. If permission_policy: "always_ask", emits a tool.needs_approval event and waits for a tool.approved event (chat surfaces the approval prompt).
  3. Input validation -- Parses raw input through the tool's Zod schema (code-defined) or FieldDefinition[]-derived Zod (tenant_custom).
  4. Execution -- Calls tool.execute for code-defined tools, or dispatches through executeSpec() in features/tools/server/execute-spec.ts for tenant_custom tools with an executionSpec.
  5. Event logging -- Appends tool.startedtool.completed (or tool.failed) events to the active session (fire-and-forget for web/agent sources, awaited for API sources).
  6. Analytics -- Fires a tool_run analytics 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 picker

The 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) -- When outputSections are 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:

OperatorJSONB translationWhen to use
eq / neq->> text castString equality, enum values
gt / lt / gte / lte-> JSONBNumeric comparisons (preserves ordering)
in->> text cast + INEnum multi-value ("any of")
contains-> JSONB @> or ILIKEArray 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 typeControl type
string with enum (≤6 values)select
string with enum (>6 values)multi-select
number / integer (no range)number
number / integer with minimum/maximumrange
booleantoggle
array with items.enummulti-select
array (no enum)text
string (no enum)text

API Reference

Registry (features/tools/registry.ts)

FunctionSignatureDescription
registerTool(tool)(tool: ToolDefinition) => voidRegister a tool definition.
getTool(slug)(slug: string) => ToolDefinition | undefinedLook up a tool by slug.
getAllTools()() => ToolDefinition[]List all registered tools.
toToolMeta(tool)(tool: ToolDefinition) => ToolMetaExtract serializable metadata.
getAllToolMeta()() => ToolMeta[]List all tool metadata (for client-side listings).

Visibility (features/tools/system-tools.ts)

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

FunctionSignatureDescription
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).

FunctionSignatureDescription
withToolContext(context, fn)(context: { tenantId: string }, fn: () => T) => TRun a callback with tenant context stored in AsyncLocalStorage. Wraps the agent execution call in background jobs.
getToolContextTenantId()() => string | nullRead 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)

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

FunctionSignatureDescription
getUserFacingToolSet(permissions?, tenantSlug?)(permissions?: AppPermission[], tenantSlug?: string) => ToolSetAll registered tools as AI SDK ToolSet, filtered by permissions and optional tenant slug allowlist.

Entity Tools (features/tools/entity-tools.ts)

FunctionSignatureDescription
getEntityTools(permissions?)(permissions?: AppPermission[]) => ToolSet8 entity CRUD tools as AI SDK ToolSet, filtered by permissions. Returns empty object when no permissions provided.

Filter Tools (features/tools/filter-tools.ts)

FunctionSignatureDescription
getFilterTools(permissions?)(permissions?: AppPermission[]) => ToolSetReturns 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 helperInternal helper that translates FilterCondition[] into Supabase query builder calls.

Research Tools (features/tools/research-tools.ts)

FunctionSignatureDescription
getResearchTools(permissions?)(permissions?: AppPermission[]) => ToolSetReturns researchEntities tool. Returns empty object when no permissions provided.

Filter Schema (features/blocks/lib/filter-schema.ts)

FunctionSignatureDescription
schemaToFilterFields(properties, visibleFields?)(props: Record<string, unknown>, visible?: string[]) => FilterFieldConfig[]Derive filter UI config from JSON Schema properties.
humanizeFieldName(name)(name: string) => stringConvert snake_case or kebab-case field names to "Title Case" labels.

Pages

PathDescription
/toolsTool 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 gets webSearch
  • 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

  1. 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 };
      },
    });
  2. Create features/custom/tools/[name]/definition.test.ts using shared test utilities (mockToolRegistry(), captureTool()).

  3. Import in features/custom/tools/index.ts to ensure registration.

  4. (Optional) Custom input/output UI: register a FormSpec via registerFormSpec("tool:<slug>", spec) and/or a ViewSpec via registerViewSpec("tool:<slug>", spec) in features/custom/tools/ui.ts.

  5. 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 of inputSchema — the SDK can't extract .shape and 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 to z.object({ action: z.enum([...]), ...allOptionalFields }) plus runtime per-action guards in execute() — see manageTools for the canonical pattern.
  • z.any() and z.unknown() in z.record() value position — both emit additionalProperties: {} (verified with the MCP SDK and zod-to-json-schema versions 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 live codex exec against the post-flatten server — see documents/work/2026-04-23-codex-cli-mcp-schema-fix/followups.md. If you need an open-ended config field, prefer a typed z.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

PathDescription
/admin/toolsTwo-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
/toolsEnd-user library with origin / kind / category filters
POST /api/toolsCreate a tenant_custom tool (tools.tenant.create)
GET/PATCH/DELETE /api/tools/[slug]Read / update / delete with kind-appropriate policy
POST /api/tools/[slug]/testNon-streaming preview (admin TestPanel fallback)
POST /api/tools/[slug]/test/streamStreaming preview for prompt.direct text tools
GET /api/tools/[slug]/versionsRead 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:

  1. Define the entity_type in the bundle manifest with config.fields.{name}.extraction_task_slug per generative or computed field.
  2. Define the tasks in tasks[] with proper depends_on (DAG, no cycles). Layer-1 tasks are independent; layer-2 tasks run after layer-1 completes.
  3. Define a single composer agent (e.g. executive-summary-composer) that handles every task via generateObject() with a Zod schema enforcing claims[] = {text, source_entity_ids[]} per claim.
  4. Define the thin tool: validates IDOR + workspace scope + sufficient-graph + bundle-installed; creates the entity; returns {entityId, briefUrl}.
  5. Register the tool via the bundle's tools[] section, NOT the global features/custom/tools/index.ts registry. Bundle-scoped tools never leak to non-bundled tenants.
  6. Per-claim structured output via generateObject(), not streamText() — schema validation catches shape drift cheap.
  7. 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).
  8. Use existing surface types — pdf for paginated print-ready briefs, page for SEO-friendly long-form. DO NOT invent new surface_type values per content type.
  9. Disclaimer / classification metadata fields on the entity_type, populated from tenant_settings at 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.

  • 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 toolGroups and customTools
  • Auth and Permissions -- Permission gating uses the unified RBAC system
  • Sessions -- Tool execution events live on the session event log

On this page

MCP Phase 1 — four-shape interactivity (2026-04-24)The unified platform (2026-04-17)The two-table modelResolvedToolExecutionSpec (five shapes)FieldDefinition primitiveAuthoring custom toolsAgents and tenant_custom toolsExecution captured in session_eventsPermission policy (auto vs always_ask)Spec & roadmapOverviewKey ConceptsToolDefinitionToolMetaTool CategoriesPlatform Tools (built into the engine)Custom Tools (product-specific, in features/custom/tools/)Tool Output SectionsCollaborative SessionsTool execution events (session_events)Custom Tool UImode prop — internal vs embedAdditionalInputs collapsibleImageDropzoneUploader — compact and URL propsHow It WorksTool RegistrationTenant-Scoped Tool Visibility1. Definition-level allowlist (tenantSlugs)2. DB-based enabled/disabled (tools table)Tool ExecutionAI BridgePermission GatingEntity-Aware Schema FieldsGeneric UIFilter and Research ToolsfilterEntitiesextractFiltersresearchEntitiesFilter Schema (features/blocks/lib/filter-schema.ts)API ReferenceRegistry (features/tools/registry.ts)Visibility (features/tools/system-tools.ts)Server Actions (features/tools/server/actions.ts)Background Job Context (features/tools/lib/tool-context.ts)Execution (features/tools/execute.ts)AI Bridge (features/tools/ai-bridge.ts)Entity Tools (features/tools/entity-tools.ts)Filter Tools (features/tools/filter-tools.ts)Research Tools (features/tools/research-tools.ts)Filter Schema (features/blocks/lib/filter-schema.ts)PagesFor AgentsAdding a New Custom ToolDesign DecisionsAdmin authoring surfacemanageTools AI toolThin tool + DAG-driven field extraction (ADR-0021)Related Modules