The complete guide to building tools for ToolRouter — from scaffolding to publishing.
Scaffold a tool
toolrouter init my-tool my_skill # Basic tool with one skill
toolrouter init my-tool --knowledge # Tool with RAG knowledge base
toolrouter init my-tool --plugin # Standalone npm plugin packageEvery tool lives in src/tools/<tool-name>/:
src/tools/my-tool/
├── index.ts # defineTool() manifest + skill wiring
├── skills/
│ ├── my-skill.ts # Handler for my_skill
│ └── other-skill.ts # Handler for other_skill
└── knowledge/ # Optional — RAG markdown filesFor ideas on what to build, see docs/plans/ for current proposals (marketplace-search, record-collector, contract-opportunities, company-inspired tool wedges). Capture any serious new tool as a dated plan in docs/plans/ before you start.
Metadata: the three text layers
A tool has three text fields visible to users and agents. The about field is Markdown and serves both — it renders on the website AND is returned to agents via MCP discover. There is no separate "agent instructions" field.
| Field | Min | Max | Purpose |
|---|---|---|---|
displayName | 3 | 50 | The tool's name |
subtitle | 10 | 80 | Short tagline for search results and tool cards |
about | 400 | 2500 | Long-form Markdown — pitch, skills, workflow, getting started |
Skill description | 20 | 300 | Action-oriented: what it does, when to use it |
Skill returns | 10 | 200 | Brief summary of what the skill returns |
Input param description | — | 150 | What the param is and valid values |
Example description | 5 | 100 | Realistic one-line request |
Aim for 1200–2000 chars of about. Every token costs agent context.
Tone rules (apply everywhere)
- No provider names. Don't mention Serper, OpenAI, ElevenLabs, fal.ai, NHTSA, etc.
- No pricing or auth language. Don't say "free", "paid", "no API key needed", "credits".
- Plain English, no marketing fluff.
- Subtitle: one line, no periods, focus on primary capability. Like an App Store subtitle.
About template (six sections)
**[Tool Display Name]** [2–3 sentence hook explaining what the tool does. Plain English, punchy.]
[One paragraph of detail — what problems it solves, what makes it valuable.]
### What you can do
- [Skill 1 — what it does]
- [Skill 2 — what it does]
- [4–6 bullets total]
### Who it's for
[2–3 sentences describing the target users.]
### How to use it
1. [First step — name the key skill in **bold** and describe what to pass it]
2. [Second step]
3. [Continue for the natural workflow — 3–5 steps total]
### Getting started
[1–2 sentences on what to do first. If a service connection is needed, say "connect your [Service] account" — never name the underlying provider.]Use **skill_name** (bold), not backticks — backticks break the TypeScript template literal. The "How to use it" section is what makes agents call the right skills in the right order — don't skip it.
Minimal example
import { defineTool } from '../../core/define-tool.js';
import { mySkillHandler } from './skills/my-skill.js';
const { register, manifest } = defineTool({
name: 'my-tool',
displayName: 'My Tool',
subtitle: 'Track brand mentions across the web in real time',
about: `**My Tool** monitors what people are saying about any brand, product, or topic across news, social media, and forums in real time.
It's the fastest way to catch coverage as it happens — whether you're tracking a launch, watching a competitor, or monitoring sentiment around a campaign. Pulls fresh mentions across the open web with timestamps and source links.
### What you can do
- Track mentions of any brand, product, or keyword across news and social
- Filter by date range to focus on recent coverage
- Get source URLs so you can read the full context
### Who it's for
PR teams, marketers, founders, and brand managers who need to stay on top of what's being said about them.
### How to use it
1. Start with **my_skill** and pass a brand name or keyword
2. Use broad terms ("toolrouter") for discovery, exact phrases for precise monitoring
3. Chain with sentiment analysis tools for deeper insight on tone
### Getting started
Just call **my_skill** with the term you want to track. No setup needed.`,
categories: ['analytics'],
changelog: [{ date: '2026-03-22', changes: ['Initial release'] }],
skills: [{
name: 'my_skill',
displayName: 'My Skill',
description: 'List recent brand mentions for a search term so you can review what people are saying.',
input: {
type: 'object',
properties: {
param: { type: 'string', description: 'Brand name or search term to look up' },
},
required: ['param'],
},
examples: [
{ description: 'Find recent mentions for ToolRouter', input: { param: 'ToolRouter' } },
],
returns: 'Recent mentions with the key details needed for review',
handler: mySkillHandler,
}],
});
export { register as registerMyTool, manifest };Optional features (workflow, requirements, brain, premadePrompt)
Add these to defineTool() alongside the minimal fields when needed:
defineTool({
// ...minimal fields...
// Suggested execution order for agents (hint only, not enforced)
workflow: ['capture', 'annotate'],
// Secrets/credentials this tool needs
requirements: [
{
name: 'fal', // globally unique
type: 'secret', // 'secret' = masked, 'credential' = plain
displayName: 'fal.ai API Key',
description: 'API key for fal.ai model inference',
required: true,
acquireUrl: 'https://fal.ai/dashboard/keys',
envFallback: 'FAL_KEY',
},
],
// "Try with Claude" button on the tool page
premadePrompt:
'Use ToolRouter to audit [company name]\'s website at [website URL] for SEO issues. Give me the top 5 fixes.',
// Connect to user's Knowledge Brain (RAG over user-provided docs)
brain: { /* see brain config docs */ },
});premadePrompt rules
- 1–3 sentences, written for a human reading it on the page
- Use
[placeholders]for user values (e.g.[website URL],[topic]) - Don't reference internal skill names — write the goal, not the implementation
- Every tool should have one — it's the primary entry point for non-technical users
author, repository, and license are public-facing metadata. Leave unset unless you want them shown on the tool page. Don't copy Humanleap / UNLICENSED placeholders into new tools.
defineTool() reference
Tool-level fields
| Field | Type | Required | Rules |
|---|---|---|---|
name | string | Yes | kebab-case, e.g. my-tool |
displayName | string | Yes | Min 3 chars |
subtitle | string | Yes | 10-80 chars |
about | string | Yes | 400-2500 chars Markdown |
categories | string[] | Yes | Min 1, from allowed list (see below) |
changelog | ChangelogEntry[] | Yes | Min 1 entry. Version auto-computed from length |
skills | SkillConfig[] | Yes | Min 1 skill |
workflow | string[] | No | Suggested skill execution order |
requirements | ToolRequirement[] | No | Secrets and credentials |
author | object | No | Public author info — leave unset to omit from tool page |
homepage | string | No | Valid URL |
repository | string | No | Public source URL — leave unset to omit |
license | string | No | SPDX identifier — leave unset to omit |
icon | string | No | URL. Defaults to /icons/<tool-name>.webp |
knowledge | KnowledgeToolConfig | No | RAG config |
currency | string | No | 3-char code, defaults to USD |
premadePrompt | string | No | "Try with Claude" prompt with [placeholders] |
usesPersonas | boolean | No | Lets users pass persona_file_id; gateway injects persona fields |
usesScenes | boolean | No | Lets users pass scene_file_id; gateway injects scene fields |
usesProducts | boolean | No | Lets users pass product_file_ids[] |
usesOutfits | boolean | No | Lets users pass outfit_file_ids[] |
usesStyleReferences | boolean | No | Injects _resolved_style_prompt and _resolved_style_image_url |
acceptsImageReferences | boolean | No | Merges file IDs into a single image_urls array |
imageReferencesKey | string | No | Custom field name for the merged URL array. Default image_urls |
imageReferencesMax | number | No | Hard cap on combined reference count. Default 4 |
modelMediaType | string | No | 'image', 'video', 'audio', 'text' — auto-injects a list_models skill |
brain | ToolBrainConfig | No | Connects to user's Knowledge Brain |
Allowed categories: data, media, search, marketing, development, communication, analytics, productivity, ai, finance, security, infrastructure
Skill-level fields
| Field | Type | Required | Rules |
|---|---|---|---|
name | string | Yes | snake_case |
displayName | string | Yes | Min 3 chars |
description | string | Yes | 20–300 chars |
input | JsonSchema | Yes | Every property must have description |
output | JsonSchema | No | Recommended for publishability |
handler | SkillHandler | Yes | Async function |
examples | SkillExample[] | Yes | Min 1 (2+ recommended) |
pricing | 'free' | 'paid' | 'premium' | Yes | free = no provider cost, paid = calls a paid API |
returns | string | No | 10–200 chars |
contentType | 'json' | 'image' | No | Default json |
annotations | ToolAnnotations | No | See below |
execution | ExecutionProfile | No | Shortcut — placed inside annotations.execution |
maxCostUsd | number | No | Worst-case cost ceiling for cost reservation pre-flight |
Annotations
| Annotation | Default | Meaning |
|---|---|---|
readOnlyHint | false | Tool does not modify anything |
destructiveHint | false | Tool may delete/overwrite data |
idempotentHint | true | Same input → same effect |
openWorldHint | false | Tool interacts with external services |
Set readOnlyHint: true for search/lookup tools. Set openWorldHint: true for tools that call external APIs.
Examples
Each example must have a description (min 5 chars), only use properties declared in the input schema, and provide all required properties. They're validated at registration and used by agents to understand input format.
examples: [
{ description: 'Analyze SEO for a landing page', input: { url: 'https://example.com', depth: 2 } },
],Global market support
If your tool returns prices, listings, or merchants from a region-aware API, expose market and currency on every relevant skill. Define shared params once and spread:
const MARKET_PARAMS = {
market: {
type: 'string' as const,
description: 'Country code for localized prices (default "us"). E.g. "gb", "fr", "de", "jp"',
default: 'us',
},
currency: {
type: 'string' as const,
description: 'Currency code for prices (default "USD"). E.g. "GBP", "EUR", "JPY"',
default: 'USD',
},
};
input: {
type: 'object',
properties: {
query: { type: 'string', description: '...' },
...MARKET_PARAMS,
},
},Rules: default to us/USD. market = where the user shops; country = where the item is from (content filter). Use the real currency in table format (${w.currency} ${w.price}). Don't say "in USD" — say "in local currency". Mention market awareness in about. See src/tools/wine-collector/ for the full pattern.
Pricing model
Every skill call costs max($0.005, raw_cost):
| Scenario | raw_cost | User pays |
|---|---|---|
| Our infra (Playwright, Remotion, RapidAPI) | 0 | $0.005 |
| Provider API (fal, OpenRouter, ElevenLabs, etc.) | actual cost | rawCost |
No markup on provider costs. Revenue comes from the 5.5% purchase fee on credit top-ups.
raw_cost comes from the provider client — don't compute it
Every shared provider client (fal-client.ts, openrouter-image-client.ts, openrouter-video-client.ts, google-client.ts, prodia-client.ts, phota-client.ts, higgsfield-client.ts) returns raw_cost: number. Pass it through:
const result = await falGenerateImage(model.falId, falInput, apiKey, context, {
staticPrice: model.pricing ? { amount: model.pricing.amount, unit: model.pricing.unit } : null,
});
return { output: { ... }, usage: { raw_cost: result.raw_cost } };The client handles credit reservation and live-pricing settlement. Don't re-implement cost math.
Reserve before spend (only for non-shared providers)
Shared clients call reserveCost() for you. Only call it yourself if you're calling a provider not wrapped by a shared client AND the estimated cost is > $0.01:
await context.reserveCost(estimatedCost); // throws INSUFFICIENT_CREDITS if balance too low
const result = await rawProviderCall(...);
return { output: { ... }, usage: { raw_cost: actualCost } };Skill handlers
Handlers are async (input, context) functions:
import type { SkillHandler } from '../../../core/types.js';
import { resolveKey, requireKeyOrUnavailable } from '../../../core/resolve-key.js';
export const mySkillHandler: SkillHandler = async (input, context) => {
const url = input.url as string;
const unavailable = requireKeyOrUnavailable(context, 'openai', 'OPENAI_API_KEY');
if (unavailable) return unavailable;
const apiKey = resolveKey(context, 'openai', 'OPENAI_API_KEY')!;
context.log(`Processing ${url}`);
const result = await fetchSomething(url, apiKey);
return {
output: { data: result },
usage: { raw_cost: 0.002 },
};
};Return either a plain object (no provider cost) or { output, usage: { raw_cost, details? } }.
Context API
| Property | Description |
|---|---|
context.toolName / skillName / callId | Identifiers (use callId in temp filenames) |
context.log(msg) | Operational logging (stderr in stdio mode) |
context.get?.(name) | Get a saved secret/credential by name (use ?. to guard) |
context.getRate(provider, metric) | Live pricing rate (Convex override > static default > 0) |
context.reserveCost(amount) | Reserve credits before a paid provider call |
context.callTool(ref, skill, input) | Call another tool (max 6 levels deep) |
context.getModelStats?.() | Model popularity stats for this tool |
context.knowledge | RAG search if knowledge/ exists |
Error handling
Missing API keys — skip, don't throw. Use requireKeyOrUnavailable() for single-source skills. For multi-source aggregators, use resolveKey() and skip unavailable sources:
const rapidapiKey = resolveKey(context, 'rapidapi', 'RAPIDAPI_KEY');
if (rapidapiKey) {
results.push(await fetchFromRapidApi(rapidapiKey, ...));
}For external service failures (not missing keys), throw with context: throw new Error(\Failed to fetch "${url}": ${message}\).
Lazy load heavy deps
Don't import heavy dependencies at module top — load on first use:
// Good
const { chromium } = await import('playwright');
// Bad — loaded at import time, slows startup
import { chromium } from 'playwright';Asset delivery
The framework auto-stores all media in Convex with permanent URLs. Two patterns:
**1. *_path keys (primary):** Write file to tmpdir() and return the path. Works at any depth — top-level, nested objects, inside arrays:
import { writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
const filePath = join(tmpdir(), `report-${context.callId}.pdf`);
await writeFile(filePath, buffer);
return {
output: {
document_path: filePath, // _path triggers auto-upload at any depth
images: [
{ image_path: paths[0], caption: 'Front view' }, // also works in arrays
],
},
};After processing, the agent receives document_url, document_asset, document_page injected alongside.
2. External provider URLs (auto-captured): Return a provider URL at a media-intent key and the framework downloads, stores, and replaces it in-place:
return {
output: {
completed_clips: [
{ video_url: 'https://v3.fal.media/files/abc123.mp4' }, // auto-captured
],
},
};Media-intent keys: image_url, video_url, audio_url, thumbnail_url, photo_url, media_url, output_url, bare url. Reference keys (source_url, website_url, profile_url) are left alone.
Rules
*_pathfiles must live undertmpdir()or~/.toolrouter/assets/— others are rejected- Use
context.callIdin filenames to avoid collisions - Temp files cleaned up after upload
- Recognised extensions: images (png/jpg/jpeg/gif/webp/svg/avif), video (mp4/webm/mov), audio (mp3/wav/ogg/flac), documents (pdf/zip/pptx/docx/xlsx/csv). Unknown extensions still upload — content-type defaults to
application/octet-stream.
What the framework adds
| Output key | Framework injects |
|---|---|
image_path | image_url, image_asset, image_page |
video_path | video_url, video_asset, video_page, video_thumbnail_url |
*_path (any prefix) | *_url, *_asset, *_page |
video_url / image_url (external) | Replaced in-place + *_page injected |
Interactive Outputs (MCP Apps)
Add format + format_data to your output and supported MCP hosts (Claude, ChatGPT, etc.) render an interactive card instead of raw JSON. Without format, hosts fall back to plain text.
return {
output: {
format: 'table',
format_data: {
items: results.map(r => ({ title: r.name, url: r.link, snippet: r.description })),
total: totalCount,
page: 1,
},
// Keep original fields too — agents read them
items: results,
},
};format_data is the canonical envelope — it must match the format's interface exactly (defined in src/core/format-types.ts). The renderer reads only format_data, never tool-specific keys.
Available formats
| Format | Renders as | Input shape |
|---|---|---|
table | Sortable rows, pagination | items[] of objects with consistent keys |
gallery | Image grid + lightbox | images[] of { url, title? } |
report | Scored sections with pass/warn/fail | score?, sections[] with items[] |
compare | Side-by-side columns | items[] of 2–4 objects |
timeline | Vertical event stream | events[] of { time, title, status? } |
chart | Line/bar/pie | type, labels[], datasets[] |
map | Pins + info cards | pins[] of { lat, lng, title } |
media | Audio/video player | url, type: 'audio'|'video' |
document | File card with preview | url, filename, type |
list | Numbered items | items[] of { title, description?, url? } |
detail | (Intentionally renders nothing inline) | Used for transcript shaping only |
detail is an intermediate state — hosts handle it in their normal accordion. Use one of the inline formats when you want a visible ToolRouter card.
Pagination
Never dump hundreds of items into one output. Return one page at a time and accept a page param:
return {
output: {
format: 'table',
format_data: {
items: pageResults,
total: allResults.length,
page,
per_page: perPage,
},
},
};Pagination is currently client-side only — server-driven app.callServerTool() is planned.
Built-in interactions
- Expand to fullscreen — every card has the button
- Footer link — include
footer_linkinformat_datato add a "View" link snake_casekeys auto-converted to "Title Case" labels- Null/empty values silently hidden
- Internal keys (
review_hint,request_id,usage,_meta) stripped from UI
Design principles
- No JSON ever — if data can't render cleanly, it doesn't show
- No dead buttons — buttons only appear when wired to real actions
- Clean error states — plain text, not
{"error":{"code":"..."}}
The HTML bundle source lives in packages/mcp-apps/src/. To rebuild: cd packages/mcp-apps && npm run build, then node scripts/embed-format-bundle.ts.
Requirements (API keys)
requirements: [
{
name: 'openai', // globally unique — must match across tools
type: 'secret', // 'secret' = masked, 'credential' = plain
displayName: 'OpenAI API Key',
description: 'API key for GPT model access (min 10 chars)',
required: true,
acquireUrl: 'https://platform.openai.com/api-keys',
envFallback: 'OPENAI_API_KEY',
},
],Resolution order (when handler calls resolveKey(context, 'openai', 'OPENAI_API_KEY')):
- Per-request header:
X-Provider-Key-OpenAI: sk-... - User's saved value (Convex)
- Config file (
~/.toolrouter/config.json) - Environment variable
Requirement names are globally unique. If two tools declare name: 'openai', they must have identical metadata — the validator enforces this.
Media provider auto-injection
If your tool outputs images or video, declare just one media provider (e.g. fal) — defineTool() auto-injects the rest (prodia, higgsfield, phota, google) so users can BYO key for any provider.
Model Registry
All AI models live in src/core/model-registry.ts. Each model has a canonical key, one or more provider endpoints, pricing, and capabilities.
| Media type | Default model | Examples |
|---|---|---|
image | nano-banana-2 | flux-2-pro, ideogram-v3, recraft-v4 |
video | kling-3.0 | veo-3.1, sora-2, wan-2.2 |
text | google/gemini-2.5-flash | anthropic/claude-sonnet-4, openai/gpt-4o-mini |
Using the registry
import { resolveModel, getEndpoint, getDefaultModel, resolveModelLive, listModelsLive } from '../../../core/model-registry.js';
const model = resolveModel('flux-2-pro'); // by key, endpoint ID, or fuzzy match
const endpoint = getEndpoint(model)!; // best provider by priority (fal > google > prodia)
const prodiaEp = getEndpoint(model, 'prodia'); // specific provider
const def = getDefaultModel('image'); // platform default
// With live catalog enrichment (fal, OpenRouter)
const keys = collectProviderKeys(context);
const model = await resolveModelLive('some-new-model', 'image', keys);
const all = await listModelsLive('image', undefined, keys);Live catalog results cached 5 minutes. The createListModelsHandler factory uses listModelsLive() automatically.
Standard model UX pattern
Every tool that accepts a model choice must:
- Name the param
model— nevervideo_model,image_model, etc. - Description:
'Model to use. Call list_models to see available options. Omit for the recommended default.' - Include a
list_modelsskill for discovery - Track in
usage.details:usage: { raw_cost, details: { model: modelKey } }— drives popularity sort - Error format:
'Unknown model "...". Call list_models to see available options.'
Adding a `list_models` skill
import { createListModelsHandler } from '../../core/shared-list-models.js';
{
name: 'list_models',
pricing: 'free',
displayName: 'List Models',
description: 'List available models for this tool, sorted by popularity.',
input: {
type: 'object',
properties: {
capability: { type: 'string', description: 'Filter by capability (e.g. text-to-image, editing).' },
},
},
output: { type: 'object', properties: { models: { type: 'array' }, total: { type: 'number' } } },
returns: 'List of available models with pricing and provider info',
execution: { estimatedSeconds: 1, timeoutSeconds: 10, mode: 'cpu' as const },
annotations: { readOnlyHint: true, openWorldHint: false },
handler: createListModelsHandler('image'), // or 'video' or 'text'
}Adding new models
Definitions live in src/core/models/ (image.ts, video.ts, text.ts, image-upscale.ts, video-upscale.ts, tryon.ts, hair.ts):
{
key: 'my-new-model',
displayName: 'My New Model',
mediaType: 'image',
capabilities: ['text-to-image'],
description: 'What this model does best.',
endpoints: [
{ provider: 'fal', endpointId: 'fal-ai/my-new-model', pricing: { amount: 0.05, unit: 'per-image' as const } },
],
}Multiple providers? Add multiple entries to endpoints.
Shared media generation layer
Use generateImage() / generateVideo() from src/tools/shared/generate-media.ts instead of calling provider clients directly:
import { generateImage } from '../shared/generate-media.js';
const modelKey = (input.model as string) ?? 'nano-banana-2';
const result = await generateImage(input.prompt as string, context, {
model: modelKey,
aspectRatio: input.aspect_ratio as string,
});
return {
output: { image_path: result.image_path },
usage: { raw_cost: result.raw_cost, details: { model: modelKey } },
};The model registry handles provider routing automatically.
Knowledge (RAG)
Add a knowledge/ directory with .md files and enable in defineTool():
knowledge: {
dir: 'knowledge', // default
maxChunkSize: 1500, // default
overlap: 200, // default
defaultTopK: 5, // default
},Use in handlers:
const docs = await context.knowledge?.search('meta tag best practices', 3);
// docs = [{ content, heading, score, source }]Indexed lazily on first search using local HuggingFace embeddings (no API key).
For built-in tools, set __knowledgePath to a pre-resolved absolute path (tsup bundles can't resolve relative paths at runtime):
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __knowledgePath = resolve(
dirname(fileURLToPath(import.meta.url)),
'../../src/tools/my-tool/knowledge'
);Calling other tools
const seoResult = await context.callTool('seo', 'analyze_page', { url });Billing and provider keys inherit from the parent call. Max composition depth: 6 levels.
Naming conventions
| What | Convention | Example |
|---|---|---|
| Tool name | kebab-case | my-tool |
| Skill name | snake_case | analyze_page |
| Skill filename | kebab-case | skills/analyze-page.ts |
| Handler variable | camelCase + Handler | analyzePageHandler |
| Register export | register + PascalCase | registerMyTool |
| MCP tool name | tool__skill | seo__analyze_page |
| Tool directory | kebab-case | src/tools/my-tool/ |
Register your tool
Add to src/tools/index.ts:
import { registerMyTool } from './my-tool/index.js';
export async function createRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry();
registerMyTool(registry);
return registry;
}Validate and test
toolrouter validate # warnings only
toolrouter validate --strict # warnings → errors
toolrouter call seo analyze_page --input '{"url":"https://example.com"}'What `validate` checks
Errors (always fail): tool name not kebab-case, skill name not snake_case, missing/invalid subtitle (10–80), about (400–2500), or description (20–300), invalid category, no skills, no examples, input property missing description, example uses undeclared property, example missing required property, duplicate skill names, workflow references non-existent skill, requirement description < 10 chars, requirement name conflict across tools.
Warnings (--strict makes them errors): missing author (do not add placeholder metadata to silence), skills with < 2 examples, skills without outputSchema, knowledge directory missing or empty.
Missing changelog is enforced inside defineTool() itself (throws at registration), not by validate.
For live testing, hit the staging MCP from a connected client — see mcp__toolrouter-staging__use_tool and the staging deploy workflow in CLAUDE.md.
Input schema best practices
Every property needs a description. Enforced by the validator. Agents use descriptions to form correct inputs.
properties: {
url: { type: 'string', description: 'Full URL to analyze including protocol' },
depth: { type: 'integer', description: 'Crawl depth (1 = single page, 2+ = follow links)', default: 1 },
}Use enums for constrained values, declare defaults, and keep required minimal — more optional properties means more flexibility for agents.
device: {
type: 'string',
description: 'Device viewport preset',
enum: ['iphone-15-pro', 'ipad-pro', 'desktop'],
default: 'iphone-15-pro',
}Execution profiles (async skills)
Skills taking >= 30s route async. Declare an execution profile:
execution: {
estimatedSeconds: 60, // gateway routes async if >= 30
timeoutSeconds: 300, // defaults to 2× estimatedSeconds
mode: 'io', // 'io' = network polling, 'cpu' = Playwright/ffmpeg
},The execution field is a shortcut — auto-placed inside annotations.execution.
Dynamic pricing with context.getRate()
Never hardcode provider costs. Resolves Convex override > static default > 0:
const ratePerCredit = context.getRate('firecrawl', 'credit');
const rawCost = (result.creditsUsed ?? 1) * ratePerCredit;Static rates live in src/core/provider-pricing.ts. Add new providers there.
Shared clients and handler factories
When multiple tools call the same API, extract a shared client into src/tools/_shared/:
// src/tools/_shared/my-api-client.ts
export const MY_API_CREDENTIAL = {
name: 'my_api',
type: 'secret' as const,
displayName: 'My API Key',
description: 'API key for My Service',
required: true,
acquireUrl: 'https://my-api.com/keys',
envFallback: 'MY_API_KEY',
};
export async function myApiFetch(path: string, params: Record<string, unknown>, apiKey: string) {
const res = await fetch(`https://api.my-service.com${path}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error(`My API error: HTTP ${res.status}`);
return { data: await res.json(), raw_cost: 0.001 };
}Adding an upstream image/video provider? See Adding Providers for circuit breakers, asset downloads, model registry entries, and dispatch routing.
When a tool has many skills that differ only by an API endpoint, use a factory:
function createSearchHandler(endpoint: string): SkillHandler {
return async (input, context) => {
const result = await serperSearch(endpoint, input, context);
return { output: result.data, usage: { raw_cost: result.raw_cost } };
};
}
skills: [
{ name: 'search', handler: createSearchHandler('/search'), ... },
{ name: 'news_search', handler: createSearchHandler('/news'), ... },
],Stateful session pattern
For multi-step workflows where skills share state, return a session_id from the first skill and accept it in subsequent ones:
// recon.ts — creates the session
return { session_id: session.id, findings: session.recon };
// scan.ts — continues
const session = getSession(input.session_id as string);
if (!session) throw new Error('Invalid session_id — run recon first');Use the workflow array to hint order: workflow: ['recon', 'scan_vulnerabilities', 'generate_report'].
E2E test wiring
After registering, add the tool to test infrastructure for all three tiers (manifest, billing, handler execution):
tests/helpers/registry-factory.ts— addregisterMyTooltoALL_REGISTERS. Gives you Tier 1 (manifest validation) and Tier 2 (billing) for free.tests/helpers/msw-handlers.ts— add MSW mocks for any external APIs (place before catch-all):
http.get('https://api.my-service.com/*', () => HttpResponse.json({ results: [{ title: 'Mock' }] })),tests/e2e/tools.test.ts— if the tool hasrequirements, add toMOCK_PROVIDER_KEYS:
my_api: 'test-my-api-key',- Skip if needed — tools depending on Playwright, CLI binaries, or real files: add to
SKIP_EXECUTIONset. - Run
npm run test:e2e.
Plugins (standalone npm packages)
toolrouter init my-tool --pluginThe package must export a register function. Users install with toolrouter plugins add my-tool-plugin. Plugin tools get the same registry, billing, assets, and composition as built-in tools.
Shipping checklist
Manifest & schema:
- [ ]
toolrouter validate --strictpasses - [ ]
toolrouter call <tool> <skill>works for at least the primary skill - [ ]
subtitleis 10–80 chars - [ ]
aboutfollows the six-section template (400–2500 chars) - [ ]
changeloghas at least 1 entry - [ ] Every skill has at least 2 examples
- [ ] Every input property has a
description - [ ]
outputSchemadefined for all skills - [ ]
returnsfield set - [ ]
requirementsdeclared for any external API keys - [ ]
premadePromptset (primary entry point for non-technical users)
Handler quality:
- [ ] Heavy dependencies lazy-loaded
- [ ] Missing keys use
requireKeyOrUnavailable()/resolveKey()— never throw - [ ] File outputs use
_pathsuffix - [ ]
usage: { raw_cost }returned for paid APIs - [ ] Pricing uses
context.getRate()— no hardcoded values - [ ]
executionprofile set for skills > 5 seconds - [ ] Output includes
format+format_datamatching the canonical interface - [ ] Large result sets paginated via
page(max ~20 items per response)
Test suite:
- [ ] Registered in
tests/helpers/registry-factory.ts - [ ] MSW handlers added if external APIs called
- [ ] Mock provider keys added in
tests/e2e/tools.test.ts(if requirements declared) - [ ]
npm run test:e2epasses
Registry:
- [ ] Registered in
src/tools/index.ts - [ ]
npm run buildsucceeds
App icon:
- [ ] Definition added to
scripts/generate-icons.mjs - [ ] PNG (1024×1024) and WebP (512×512) committed in
web/public/icons/
Going live:
- [ ] Icon committed (must happen before approve)
- [ ] Pushed through staging → main per CLAUDE.md deploy workflow
- [ ] Tool approved in production Convex (see below)
- [ ] Verified at
https://api.toolrouter.com/v1/tools/<tool-name>andhttps://toolrouter.com/tools/<tool-name>
Tool approval (push live)
Registering a tool isn't enough — new tools are hidden from the public API until explicitly approved in production Convex.
# Approve a single tool (uses production Convex creds from Railway)
CONVEX_URL="https://jovial-pika-231.eu-west-1.convex.cloud" \
TOOLROUTER_CONVEX_SERVER_SECRET="$(railway variables --json | node -e "console.log(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).TOOLROUTER_CONVEX_SERVER_SECRET)")" \
node dist/bin/toolrouter.js approve my-tool
# Approve all
node dist/bin/toolrouter.js approve --all.env.local points to the dev Convex (acoustic-antelope-876), NOT production. The toolrouter approve CLI needs explicit production targeting.
The gateway refreshes its approved set from Convex every 60 seconds — wait up to a minute after approving. Verify:
curl -s https://api.toolrouter.com/v1/tools/my-tool | python3 -m json.toolFinal verification: call through the ToolRouter MCP from Claude or another agent — the same path real users hit (mcp__toolrouter__discover then use_tool).
Checking and revoking
# List approved
CONVEX_DEPLOYMENT=prod:jovial-pika-231 npx convex run --no-push toolApprovals:listApproved
# Revoke (hides from public API)
CONVEX_DEPLOYMENT=prod:jovial-pika-231 npx convex run --no-push \
toolApprovals:setApproval '{"toolName": "my-tool", "approved": false, "updatedBy": "yourname"}'App icons
Every tool needs an icon — 3D photorealistic objects on gradient backgrounds, generated using ToolRouter's own generate-image tool (we dogfood our product).
Add to TOOL_ICONS in scripts/generate-icons.mjs:
'my-tool': {
subject: { object: '3D [single object]', material: '[ceramic, metal, glass, etc.]' },
background: { gradient: ['[top color]', '[bottom color]'] },
lighting: '[soft top-down | dramatic rim | warm golden | cool ambient]',
},Design rules
- ONE object only — not "magnifying glass with globe and documents." Just the magnifying glass.
- 3D with real materials — ceramic, chrome, leather, glass, wood.
- Unique gradient — check existing tools in your category. Don't duplicate.
- No accessories — no floating secondary elements, sparkles, or extras.
| Category | Gradient range |
|---|---|
| Search/Web | Blues, cyans, teals |
| Security | Dark reds, crimsons, blacks |
| Finance | Teals, golds, navys |
| Media/Audio | Purples, violets, magentas |
| Data/Reference | Greens, limes, soft blues |
| Social | Pinks, oranges, corals |
| Marketing | Electric blues, purples |
| Weather/Nature | Sky blues, golds, oranges |
| Utility | Charcoals, slates, dark grays |
Build the prompt:
Edge-to-edge [gradient colors] gradient background. Single centered 3D [object] made of [material]. [Lighting style] lighting. No text, labels, badges, or decorative extras. Square composition.
Save as PNG (1024×1024) and WebP (512×512, quality 85) in web/public/icons/. Use sharp to resize/convert. Must be committed before approving. See the generate-tool-icons skill for full icon design rules.
How metadata is surfaced
| Context | What's shown |
|---|---|
| Search results (6+ matches) | name + subtitle + skill names |
| Compact results (2–5 matches) | name + subtitle + skills with input params |
| Exact match / tool detail | subtitle + about + full skills with schemas and examples |
| Website tool card | Icon + displayName + subtitle + star rating |
| Website tool page | Icon + displayName + subtitle + rendered about + all skills |
GET /v1/tools/:tool | Full manifest |
subtitle is the most-seen field. about is the single source of truth for both audiences — workflow guidance lives in its "How to use it" section.
Common mistakes
- Throwing on missing API keys instead of using
requireKeyOrUnavailable()/resolveKey() - Mentioning provider names ("Serper", "fal.ai", "ElevenLabs") in
subtitle/about/description - Mentioning pricing or "API key" in user-facing copy
- Hardcoding provider costs instead of using
context.getRate() - Re-implementing cost math when the shared client already returns
raw_cost - Importing heavy dependencies at module top instead of lazy-loading
- Using backticks (`
skill_name) inside theabouttemplate literal — breaks TypeScript syntax. Useskill_name` (bold) - Naming the model param
image_model/video_modelinstead ofmodel - Returning hundreds of items in one response instead of paginating
- Skipping
format+format_data— host then shows raw JSON - Adding placeholder
author/repository/licenseto silence warnings — leave them unset - Approving the tool before committing the icon
- Targeting the dev Convex when approving a production tool
Read next
- Adding Providers for integrating a new upstream API provider
- Architecture for the runtime execution path
- CLI for validation, testing, and plugin workflows
- Integration for how tools are exposed via MCP and REST