Skip to content

Tool Authoring

The complete guide to building tools for ToolRouter — from scaffolding to publishing.

Scaffold a tool

bash
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 package

Every 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 files

For 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.

FieldMinMaxPurpose
displayName350The tool's name
subtitle1080Short tagline for search results and tool cards
about4002500Long-form Markdown — pitch, skills, workflow, getting started
Skill description20300Action-oriented: what it does, when to use it
Skill returns10200Brief summary of what the skill returns
Input param description150What the param is and valid values
Example description5100Realistic 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

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

typescript
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

FieldTypeRequiredRules
namestringYeskebab-case, e.g. my-tool
displayNamestringYesMin 3 chars
subtitlestringYes10-80 chars
aboutstringYes400-2500 chars Markdown
categoriesstring[]YesMin 1, from allowed list (see below)
changelogChangelogEntry[]YesMin 1 entry. Version auto-computed from length
skillsSkillConfig[]YesMin 1 skill
workflowstring[]NoSuggested skill execution order
requirementsToolRequirement[]NoSecrets and credentials
authorobjectNoPublic author info — leave unset to omit from tool page
homepagestringNoValid URL
repositorystringNoPublic source URL — leave unset to omit
licensestringNoSPDX identifier — leave unset to omit
iconstringNoURL. Defaults to /icons/<tool-name>.webp
knowledgeKnowledgeToolConfigNoRAG config
currencystringNo3-char code, defaults to USD
premadePromptstringNo"Try with Claude" prompt with [placeholders]
usesPersonasbooleanNoLets users pass persona_file_id; gateway injects persona fields
usesScenesbooleanNoLets users pass scene_file_id; gateway injects scene fields
usesProductsbooleanNoLets users pass product_file_ids[]
usesOutfitsbooleanNoLets users pass outfit_file_ids[]
usesStyleReferencesbooleanNoInjects _resolved_style_prompt and _resolved_style_image_url
acceptsImageReferencesbooleanNoMerges file IDs into a single image_urls array
imageReferencesKeystringNoCustom field name for the merged URL array. Default image_urls
imageReferencesMaxnumberNoHard cap on combined reference count. Default 4
modelMediaTypestringNo'image', 'video', 'audio', 'text' — auto-injects a list_models skill
brainToolBrainConfigNoConnects to user's Knowledge Brain

Allowed categories: data, media, search, marketing, development, communication, analytics, productivity, ai, finance, security, infrastructure

Skill-level fields

FieldTypeRequiredRules
namestringYessnake_case
displayNamestringYesMin 3 chars
descriptionstringYes20–300 chars
inputJsonSchemaYesEvery property must have description
outputJsonSchemaNoRecommended for publishability
handlerSkillHandlerYesAsync function
examplesSkillExample[]YesMin 1 (2+ recommended)
pricing'free' | 'paid' | 'premium'Yesfree = no provider cost, paid = calls a paid API
returnsstringNo10–200 chars
contentType'json' | 'image'NoDefault json
annotationsToolAnnotationsNoSee below
executionExecutionProfileNoShortcut — placed inside annotations.execution
maxCostUsdnumberNoWorst-case cost ceiling for cost reservation pre-flight

Annotations

AnnotationDefaultMeaning
readOnlyHintfalseTool does not modify anything
destructiveHintfalseTool may delete/overwrite data
idempotentHinttrueSame input → same effect
openWorldHintfalseTool 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.

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

typescript
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):

Scenarioraw_costUser pays
Our infra (Playwright, Remotion, RapidAPI)0$0.005
Provider API (fal, OpenRouter, ElevenLabs, etc.)actual costrawCost

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:

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

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

typescript
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

PropertyDescription
context.toolName / skillName / callIdIdentifiers (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.knowledgeRAG 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:

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

typescript
// 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:

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

typescript
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

  • *_path files must live under tmpdir() or ~/.toolrouter/assets/ — others are rejected
  • Use context.callId in 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 keyFramework injects
image_pathimage_url, image_asset, image_page
video_pathvideo_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.

typescript
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

FormatRenders asInput shape
tableSortable rows, paginationitems[] of objects with consistent keys
galleryImage grid + lightboximages[] of { url, title? }
reportScored sections with pass/warn/failscore?, sections[] with items[]
compareSide-by-side columnsitems[] of 2–4 objects
timelineVertical event streamevents[] of { time, title, status? }
chartLine/bar/pietype, labels[], datasets[]
mapPins + info cardspins[] of { lat, lng, title }
mediaAudio/video playerurl, type: 'audio'|'video'
documentFile card with previewurl, filename, type
listNumbered itemsitems[] 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:

typescript
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_link in format_data to add a "View" link
  • snake_case keys 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)

typescript
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')):

  1. Per-request header: X-Provider-Key-OpenAI: sk-...
  2. User's saved value (Convex)
  3. Config file (~/.toolrouter/config.json)
  4. 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 typeDefault modelExamples
imagenano-banana-2flux-2-pro, ideogram-v3, recraft-v4
videokling-3.0veo-3.1, sora-2, wan-2.2
textgoogle/gemini-2.5-flashanthropic/claude-sonnet-4, openai/gpt-4o-mini

Using the registry

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

  1. Name the param model — never video_model, image_model, etc.
  2. Description: 'Model to use. Call list_models to see available options. Omit for the recommended default.'
  3. Include a list_models skill for discovery
  4. Track in usage.details: usage: { raw_cost, details: { model: modelKey } } — drives popularity sort
  5. Error format: 'Unknown model "...". Call list_models to see available options.'

Adding a `list_models` skill

typescript
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):

typescript
{
  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:

typescript
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():

typescript
knowledge: {
  dir: 'knowledge',       // default
  maxChunkSize: 1500,     // default
  overlap: 200,           // default
  defaultTopK: 5,         // default
},

Use in handlers:

typescript
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):

typescript
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

typescript
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

WhatConventionExample
Tool namekebab-casemy-tool
Skill namesnake_caseanalyze_page
Skill filenamekebab-caseskills/analyze-page.ts
Handler variablecamelCase + HandleranalyzePageHandler
Register exportregister + PascalCaseregisterMyTool
MCP tool nametool__skillseo__analyze_page
Tool directorykebab-casesrc/tools/my-tool/

Register your tool

Add to src/tools/index.ts:

typescript
import { registerMyTool } from './my-tool/index.js';

export async function createRegistry(): Promise<ToolRegistry> {
  const registry = new ToolRegistry();
  registerMyTool(registry);
  return registry;
}

Validate and test

bash
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.

typescript
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.

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

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

typescript
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/:

typescript
// 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:

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

typescript
// 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):

  1. tests/helpers/registry-factory.ts — add registerMyTool to ALL_REGISTERS. Gives you Tier 1 (manifest validation) and Tier 2 (billing) for free.
  2. tests/helpers/msw-handlers.ts — add MSW mocks for any external APIs (place before catch-all):
typescript
   http.get('https://api.my-service.com/*', () => HttpResponse.json({ results: [{ title: 'Mock' }] })),
  1. tests/e2e/tools.test.ts — if the tool has requirements, add to MOCK_PROVIDER_KEYS:
typescript
   my_api: 'test-my-api-key',
  1. Skip if needed — tools depending on Playwright, CLI binaries, or real files: add to SKIP_EXECUTION set.
  2. Run npm run test:e2e.

Plugins (standalone npm packages)

bash
toolrouter init my-tool --plugin

The 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 --strict passes
  • [ ] toolrouter call <tool> <skill> works for at least the primary skill
  • [ ] subtitle is 10–80 chars
  • [ ] about follows the six-section template (400–2500 chars)
  • [ ] changelog has at least 1 entry
  • [ ] Every skill has at least 2 examples
  • [ ] Every input property has a description
  • [ ] outputSchema defined for all skills
  • [ ] returns field set
  • [ ] requirements declared for any external API keys
  • [ ] premadePrompt set (primary entry point for non-technical users)

Handler quality:

  • [ ] Heavy dependencies lazy-loaded
  • [ ] Missing keys use requireKeyOrUnavailable() / resolveKey() — never throw
  • [ ] File outputs use _path suffix
  • [ ] usage: { raw_cost } returned for paid APIs
  • [ ] Pricing uses context.getRate() — no hardcoded values
  • [ ] execution profile set for skills > 5 seconds
  • [ ] Output includes format + format_data matching 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:e2e passes

Registry:

  • [ ] Registered in src/tools/index.ts
  • [ ] npm run build succeeds

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> and https://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.

bash
# 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:

bash
curl -s https://api.toolrouter.com/v1/tools/my-tool | python3 -m json.tool

Final 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

bash
# 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:

javascript
'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

  1. ONE object only — not "magnifying glass with globe and documents." Just the magnifying glass.
  2. 3D with real materials — ceramic, chrome, leather, glass, wood.
  3. Unique gradient — check existing tools in your category. Don't duplicate.
  4. No accessories — no floating secondary elements, sparkles, or extras.
CategoryGradient range
Search/WebBlues, cyans, teals
SecurityDark reds, crimsons, blacks
FinanceTeals, golds, navys
Media/AudioPurples, violets, magentas
Data/ReferenceGreens, limes, soft blues
SocialPinks, oranges, corals
MarketingElectric blues, purples
Weather/NatureSky blues, golds, oranges
UtilityCharcoals, 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

ContextWhat's shown
Search results (6+ matches)name + subtitle + skill names
Compact results (2–5 matches)name + subtitle + skills with input params
Exact match / tool detailsubtitle + about + full skills with schemas and examples
Website tool cardIcon + displayName + subtitle + star rating
Website tool pageIcon + displayName + subtitle + rendered about + all skills
GET /v1/tools/:toolFull 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 the about template literal — breaks TypeScript syntax. Use skill_name` (bold)
  • Naming the model param image_model / video_model instead of model
  • Returning hundreds of items in one response instead of paginating
  • Skipping format + format_data — host then shows raw JSON
  • Adding placeholder author / repository / license to silence warnings — leave them unset
  • Approving the tool before committing the icon
  • Targeting the dev Convex when approving a production tool