Documentation source
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](/docs/features/interactivity) and [ADR-0006](/docs/adr/0006-four-shape-interactivity-model)). 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:** the `"tool-input"` / `"tool-output"` registration SlotKinds and the `registerToolUI()` API in `lib/ui-registry` — the migration finished with the FormSpec v2 ship (dynamic arrays + file upload + conditional visibility). The `tool-input` / `tool-output` _string tokens_ survive only as members of the `BlockSurfaceSlot` taxonomy in `lib/ui-registry/block-contract.ts` (they describe _where_ a Block is mounted, not how it registers). (`"field-input"` was reintroduced under ADR-0015 with new `displayType`-keyed semantics.) See [Interactivity → Slot registry](/docs/features/interactivity) for the same reconciliation.
- **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.
## Agent View-publishing tools on MCP (gated, 2026-06-04)
Two `visibility: "internal"` tools (ADR-0014) — `publish_view` and `amble_submit_block_response` — are the agent/remote View-authoring + input round-trip seam (work folder `documents/work/2026-06-03-block-engine-finalization-planning/03-publish-view-agent-path.md`). Because they are internal, they never appear in the normal `tools:execute` MCP loop (`listApiKeyAccessibleTools` drops internal tools). The MCP server at `features/mcp/amble-server.ts` surfaces them through a **dedicated gated block** instead:
- The block runs only when the caller has the `tools:execute` scope **and** `isPublishingEnabled(tenantId)` is true — the same per-tenant `mcp_apps_publish_enabled` opt-in (`features/mcp/apps-ui/publisher.ts`) that gates `_meta.ui` MCP Apps publishing. There is no second flag.
- Gate **on** → exactly these two tools are added to `tools/list` (`publish_view`, `amble_submit_block_response`). Gate **off** → both are absent. The contract is locked by `features/mcp/amble-server.test.ts` ("publish_view + amble_submit_block_response gating (W6)").
- The gated block instantiates the tool factories directly (`publishViewTool()`, `submitBlockResponseTool()`) and runs each tool's own `execute` inside `withToolContext` — it does **not** register them in the unified `getAllTools()` registry, so the `tool-schema-conformance` surface (which walks `getAllTools()`) never sees `publish_view`'s top-level discriminated-union input schema.
- `publish_view` is display-only here (input-mode + durable-output Views are rejected by `publicViewToRuntimeView`'s durable-output guard until P5). `amble_submit_block_response` records a submitted response on its `block_render_requests` row via a 5-step fail-closed validator (token → tenant → permission → idempotency → schema); durable materialization stays in `BlockHost.handleOutput`.
## 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`):
| 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 `FieldDefinition`s; `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:
```typescript
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:
```typescript
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` |
| **Goal loop** (2 tools) | `getGoalSystem` (read canonical state + latest auto-graded scores), `runGoalCheckIn` (drive one outcome-graded iteration on demand) | `goal-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`, `publish_view`, `submit_block_response` | `view-tools.ts` |
| **HITL input** (1 tool) | `request_input` — compile an InputRequestSpec into a published input view, deliver a link, park the agent session, resume on submit. (Renamed from `request_feedback` per ADR-0065; the old slug survives one release as an internal-only deprecated alias.) | `features/tools/input-request/request-input.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 |
|---|---|---|
| `generateBeforeAfterVisual` | Before & After Visualizer | Upload a real planter photo, select live IMS product/SKU options, and edit the source image into an after visualization |
| `estimateCoverage` | Coverage & Order Estimator | Calculate bags, pallets, weight, and cost for a mulch project |
| `selectProduct` | Product Selector | Quiz-style recommendation with weighted scoring across 6 attributes |
| `screenRisk` | 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 |
|---|---|---|
| `generateComparison` | Product Comparison Generator | Side-by-side Protoast vs competitor comparison with per-category scores |
| `estimateOrder` | Commercial Order Estimator | Bulk order pricing with tiered freight and volume discount brackets |
**Publishing tools:**
| Slug | Name | Description |
|---|---|---|
| `publishContent` | 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`:
```typescript
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:
```typescript
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:
```typescript
// 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:
```typescript
// 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:
```tsx
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:
| 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`:
```typescript
registerTool({
slug: "calculateRoi",
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.
### Tool design conventions (ADR-0069)
New tools follow the conventions in `.claude/rules/tool-design.md` (decision record: **ADR-0069**). The load-bearing ones:
- **§1 — Canonical slugs are camelCase verb-noun** (`calculateRoi`, `listAgents`). `scripts/check-tool-design.mjs` (in `check:ci-guards`) fails the floor on a new non-camelCase tool slug.
- **§4 — Split mega-tools** into read / write / execute thin front-ends (`manageTasks` → `listTasks` / `manageTask` / `triggerTask`), each with its own permission and annotation.
- **§6 — `annotations`** carries MCP behavior hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). They steer client confirmation UX and are HINTS, not security — RBAC (permission gating, below) is the boundary.
- **§9 — Discovery before mutation:** a read-only enumerator (`listAgents`) precedes a side-effecting call (`delegateToAgent`).
- **§10 — Renames keep a deprecated alias** via `aliasOf(canonical, oldSlug, removeBy)` from `features/tools/lib/deprecated-alias.ts`, so live MCP / API-key callers don't break before the remove-by date.
### 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 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:**
```sql
INSERT INTO tools (tenant_id, slug, enabled, kind)
VALUES ('your-tenant-id', 'estimateCoverage', false, 'system_override');
```
**To hide a code-defined tool globally:**
```sql
INSERT INTO tools (tenant_id, slug, enabled, kind)
VALUES ('00000000-0000-0000-0000-000000000000', 'estimateCoverage', 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.started` → `tool.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`:
```typescript
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:
```typescript
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:
```typescript
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.
```typescript
// 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[]`.
```typescript
// 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.
```typescript
// 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.
```typescript
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 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`:
```typescript
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
| 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:**
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:**
```ts
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.
## `request_input` — Agent-initiated human input
`request_input` (`features/tools/input-request/request-input.ts`) is a platform tool that lets an agent pause mid-task and collect structured human input. Per ADR-0065 it is `visibility: "external"` — the agent control plane's external coding agents (Claude Code, Codex, Cowork) can ask rich, structured, image-bearing questions instead of only the free-text `waiting_human` flag.
Its companion tools stay internal (ADR-0065 visibility table): `submit_input_response` (internal ALWAYS — preserves the ADR-0053 pull-resume boundary), `render_input_request` (the public token-gated `/embed/v/[token]` surface, not a general external tool), `list_input_requests` and `input_request_answer_packet` (cross-agent content isolation).
**No-leak boundary (ADR-0065 P2):** the external success payload exposes `kind: "input_request"` and NEVER the internal `metadata.kind` discriminator (`feedback_request`, which the ADR deliberately did not rename in this round).
**Deprecated alias:** the old slug `request_feedback` survives one release as an internal-only alias (same compiler, not a net-new tool) so internal callers don't break while external MCP only ever sees the clean name.
### InputRequestSpec
The tool's input is an `InputRequestSpec` (Zod-validated, `features/tools/input-request/feedback-spec.ts`; `FeedbackSpec` is retained as a deprecated alias):
```ts
type InputRequestSpec = {
title: string
instructions?: string
questions?: QuestionSpec[] // open-ended text or number inputs
choose?: ChooseSpec // direction-choice block (2–6 labeled options)
markup?: MarkupSpec // image-markup block (annotate an image)
approve?: ApproveSpec // binary approve/reject with optional notes
}
```
Only one of `questions` / `choose` / `markup` / `approve` should be populated per call. The compiler maps each spec shape to one or more input blocks in the generated view.
### What the tool does
1. Compiles `FeedbackSpec` → a `views.definition` tree rooted in the matching input block(s) — each leaf binds an `OutputTarget` so the submit path knows where to deposit each answer.
2. Calls `publish_view` to publish the compiled view under a fresh token.
3. Attaches `metadata.kind = "feedback_request"` and `metadata.feedback_view_id` to the session before parking it at `waiting_human`.
4. Returns the share URL to the calling agent, which can deliver it in a chat message, email, or "Needs You" inbox notification.
### Resume
When a respondent submits the published view, the `session-elicitation-resume` Inngest function wakes the parked session. See the [Agent Feedback Resume](/docs/features/sessions#agent-feedback-resume) section in the Sessions doc for the full flow.
### For Agents: calling request_input
```
request_input({
title: "Design direction for the homepage hero",
choose: {
options: [
{ id: "bold", label: "Bold typographic statement", imageUrl: "..." },
{ id: "minimal", label: "Clean photo + single CTA", imageUrl: "..." },
{ id: "motion", label: "Full-bleed video loop", imageUrl: "..." },
],
chooseMode: "single"
}
})
```
The tool returns `{ shareUrl: "https://app.sprinter.ai/embed/v/...", viewId: "..." }`. Deliver `shareUrl` to the person whose input you need. The agent session is now parked and will resume when they submit.
## Related Modules
- [Entity System](/docs/features/entity-system) -- Entity tools are the primary way agents interact with entities
- [Block System](/docs/features/block-system) -- Tool output sections are converted to blocks via bridge functions
- [Agent System](/docs/features/agent-system) -- Agents receive tools based on their config's `toolGroups` and `customTools`
- [Auth and Permissions](/docs/features/auth-permissions) -- Permission gating uses the unified RBAC system
- [Sessions](/docs/features/sessions) -- Tool execution events live on the session event log; feedback resume documented at [Agent Feedback Resume](/docs/features/sessions#agent-feedback-resume)