This guide walks through integrating a new upstream API provider into ToolRouter. A provider is any external API that executes tool skills — image generation, search, transcription, etc.
Overview
Adding a provider touches 6 files:
| File | Purpose |
|---|---|
src/tools/shared/<provider>-client.ts | API client (HTTP calls, response parsing, file downloads) |
src/core/provider-catalog.ts | Display metadata for /v1/providers endpoints |
src/tools/<tool>/models.ts | Model registry entries (if the provider serves models) |
src/tools/<tool>/index.ts | Tool requirement + changelog |
src/tools/<tool>/skills/<skill>.ts | Dispatch path in the handler |
.env.local | Local API key for development |
Step 1: Create the API client
Create src/tools/shared/<provider>-client.ts. Every client follows the same pattern:
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import type { SkillContext } from '../../core/types.js';
import { CircuitBreaker } from '../../core/circuit-breaker.js';
const BASE_URL = 'https://api.example.com/v1';
const REQUEST_TIMEOUT_MS = 120_000;
export const exampleBreaker = new CircuitBreaker({
name: 'example',
threshold: 3,
resetMs: 60_000,
});API patterns
Providers fall into three patterns:
| Pattern | Description | Examples |
|---|---|---|
| Synchronous | POST → immediate result | Photalabs, Prodia |
| Async queue | Submit → poll status → fetch result | fal.ai, Higgsfield |
| Streaming | POST → stream chunks | (not yet used) |
Required conventions
- Circuit breaker: Wrap all calls in
providerBreaker.call(async () => { ... })— trips after 3 consecutive failures, resets after 60s - Timeout: Use
AbortSignal.timeout(REQUEST_TIMEOUT_MS)on all fetch calls - Download to /tmp: Provider URLs are ephemeral — always download media to local
/tmpand return the path. The asset system picks up*_pathkeys and uploads to permanent storage - File naming: Use
<provider>_<type>_<id>.<ext>pattern (e.g.phota_gen_a1b2c3d4.png) - Error handling: Throw on failure — never swallow errors. Include the HTTP status and response body in error messages
- Logging: Use
context.log()for operational logging (goes to stderr in stdio mode)
Auth patterns
| Provider | Header format | Env var |
|---|---|---|
| fal.ai | Authorization: Key ${key} | FAL_KEY |
| Prodia | Authorization: Bearer ${token} | PRODIA_TOKEN |
| Higgsfield | Authorization: Key ${keyId}:${keySecret} | HIGGSFIELD_API_KEY |
| Photalabs | X-API-Key: ${key} | PHOTA_API_KEY |
Return types
Export a result interface that includes:
export interface ExampleResult {
image_path: string; // Local /tmp path (required for asset system)
job_id: string; // Provider request ID or synthetic UUID
content_type: string; // MIME type (e.g. 'image/png')
}For providers returning multiple images, use image_paths: string[] instead.
Step 2: Register in provider catalog
Add display metadata to src/core/provider-catalog.ts:
export const PROVIDER_DISPLAY: Record<string, ProviderDisplay> = {
// ... existing providers
example: {
displayName: 'Example',
description: 'What this provider does — keep it concise.',
website: 'https://example.com',
},
};This makes the provider appear on /v1/providers endpoints and the website's provider pages.
Step 3: Add model entries (if applicable)
If the provider serves models (image gen, video gen, etc.), add entries to the relevant models.ts:
- Add the provider to the
ModelProviderunion type:
export type ModelProvider = 'fal' | 'prodia' | 'higgsfield' | 'example';- Add model entries to
MODEL_REGISTRY:
'example-model': {
key: 'example-model',
displayName: 'Example Model',
falId: 'endpoint-id', // Provider-specific endpoint identifier
provider: 'example', // Routes to your dispatch path
capabilities: ['text-to-image'],
pricing: { amount: 0.05, unit: 'per-image' },
maxImages: 4,
description: 'What this model is good at.',
},The provider field is what routes the model to your dispatch path in the handler. The falId field stores the provider-specific model/endpoint identifier.
Step 4: Add tool requirement
In the tool's index.ts, add a requirement entry so the credential system knows about the provider:
requirements: [
// ... existing requirements
{
name: 'example',
type: 'secret',
displayName: 'Example API Key',
description: 'Optional: use your own Example key instead of the platform default',
acquireUrl: 'https://example.com/api-keys',
envFallback: 'EXAMPLE_API_KEY',
},
],Also add a changelog entry:
changelog: [
// ... existing entries
{ date: '2026-03-27', changes: ['Added Example as a provider — X new models'] },
],Step 5: Add dispatch path in handler
In the skill handler (e.g. text-to-image.ts), add a provider dispatch block. Place it before the default fal.ai path:
import { exampleGenerate } from '../../shared/example-client.js';
// Inside the handler, after model resolution:
// ---------------------------------------------------------------------------
// Example provider path
// ---------------------------------------------------------------------------
if (model?.provider === 'example') {
const unavailable = requireKeyOrUnavailable(context, 'example', 'EXAMPLE_API_KEY');
if (unavailable) return unavailable;
const apiKey = resolveKey(context, 'example', 'EXAMPLE_API_KEY')!;
// Build provider-specific params from input
// Call provider client
// Calculate cost
// Return { output, usage: { raw_cost } }
}Dispatch order
The handler checks providers in order:
- Prodia (explicit
provider === 'prodia'orinference.*prefix) - Higgsfield (explicit
provider === 'higgsfield') - Photalabs (explicit
provider === 'phota') - fal.ai (default fallback)
Add your provider before the fal.ai default path.
Output structure
All providers must return the same output shape:
const output: Record<string, unknown> = {
format: 'gallery',
format_data: { images: [{ url, title }] },
images: [{ url, width, height, content_type }],
model: modelName,
model_key: model.key,
seed: null,
request_id: result.job_id,
num_images: count,
image_path: result.image_paths[0], // First image for asset system
};
// Multi-image: additional paths
for (let i = 1; i < result.image_paths.length; i++) {
output[`image_${i + 1}_path`] = result.image_paths[i];
}
return { output, usage: { raw_cost: calculatedCost } };Step 6: Set up credentials
Local development
Add the API key to .env.local:
EXAMPLE_API_KEY=your-key-hereProduction (Railway)
railway variables --set "EXAMPLE_API_KEY=your-key-here" --service ToolRouter
railway variables --set "EXAMPLE_API_KEY=your-key-here" --service WorkerPricing strategies
How you calculate raw_cost depends on the provider:
| Strategy | When to use | Example |
|---|---|---|
| Live API pricing | Provider returns cost in response | Prodia (result.price.dollars) |
| Pre-execution pricing | Provider has a pricing API | fal.ai (fetch /v1/models/pricing before billing) |
| Fixed pricing | Provider has static documented rates | Higgsfield ($0.09/720p), Photalabs ($0.09/1K) |
Rule: Never silently fall back to $0 if pricing is unavailable. Either throw an error or use a static rate from the provider's documentation. The handler must always return usage: { raw_cost }.
Checklist
- [ ] Client file in
src/tools/shared/with circuit breaker, timeouts, and/tmpdownloads - [ ] Provider entry in
src/core/provider-catalog.ts - [ ]
ModelProvidertype updated inmodels.ts - [ ] Model entries added to
MODEL_REGISTRY - [ ] Tool requirement added in
index.ts - [ ] Changelog entry added in
index.ts - [ ] Dispatch path added in handler(s) before the fal.ai default
- [ ] API key added to
.env.localand production env vars - [ ]
npm run buildpasses - [ ]
providers.mddoc updated with new provider details