| name | generate-route |
| description | Scaffold a new AI generation route using createGenerationHandler. Creates route file, adds pricing entry, adds integration test. Use when adding a new /api/generate/* endpoint. |
| user-invocable | true |
| disable-model-invocation | true |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob |
| argument-hint | <route-name> <provider> <operation> |
Scaffold a New Generation Route
Create a new /api/generate/<route-name> endpoint using createGenerationHandler.
Arguments
route-name: Directory name under web/src/app/api/generate/ (e.g. ambience)
provider: Provider from DB_PROVIDER (e.g. sfx, voice, music, model3d, texture, sprite, chat)
operation: Token operation name for pricing (e.g. ambience_generation)
Files to Create/Modify
| # | File | Action |
|---|
| 1 | web/src/app/api/generate/<name>/route.ts | Create — route handler |
| 2 | web/src/lib/tokens/pricing.ts | Edit — add TOKEN_COSTS entry |
| 3 | web/src/app/api/generate/__tests__/route-integration.test.ts | Edit — add integration test |
| 4 | web/src/app/api/__tests__/sentry-regressions.test.ts | Edit — add to ASYNC_ROUTES if async |
Step 1: Create the Route File
export const maxDuration = 60;
import { createGenerationHandler } from '@/lib/api/createGenerationHandler';
import { DB_PROVIDER } from '@/lib/config/providers';
export const POST = createGenerationHandler<
{ prompt: string; },
{ }
>({
route: '/api/generate/<name>',
provider: DB_PROVIDER.<provider>,
operation: '<operation>',
rateLimitKey: 'gen-<name>',
validate: (body) => {
const { prompt } = body as Record<string, unknown>;
if (!prompt || typeof prompt !== 'string' || prompt.length < 3 || prompt.length > 500) {
return { ok: false, error: 'Prompt must be between 3 and 500 characters' };
}
return {
ok: true,
params: {
prompt: prompt as string,
},
};
},
execute: async (params, apiKey, ctx) => {
},
});
Step 2: Add Token Pricing
Edit web/src/lib/tokens/pricing.ts — add the operation to TOKEN_COSTS:
<operation>: <cost>,
If cost is dynamic (per-item, per-frame), add a _cost_per_item or _cost_per_frame entry (matching existing naming: sprite_sheet_cost_per_frame) and use tokenCost callback in the route.
Step 3: Add Integration Test
Edit web/src/app/api/generate/__tests__/route-integration.test.ts.
Add a mock for the provider client (if not already mocked), then add tests:
it('<name>: valid request -> <status> with <key field>', async () => {
const { POST } = await import('@/app/api/generate/<name>/route');
const res = await POST(makeRequest('http://test/api/generate/<name>', {
prompt: 'test input',
}));
expect(res.status).toBe(<200 or 201>);
const data = await res.json();
expect(data.<key field>).toBeDefined();
});
// Validation rejection
it('<name>: rejects missing prompt', async () => {
const { POST } = await import('@/app/api/generate/<name>/route');
const res = await POST(makeRequest('http://test/api/generate/<name>', {}));
expect(res.status).toBe(422);
});
Step 4: Update Sentry Regression Test (if async)
If the route returns usageId in success responses (async job pattern), add the route name to ASYNC_ROUTES in web/src/app/api/__tests__/sentry-regressions.test.ts.
Validation Checklist
After creating the route, verify:
cd web
npx vitest run src/app/api/generate/<name>/
npx vitest run src/app/api/generate/__tests__/route-integration.test.ts
npx vitest run src/app/api/__tests__/sentry-regressions.test.ts
npx eslint --max-warnings 0 src/app/api/generate/<name>/route.ts
npx tsc --noEmit
Common Mistakes to Avoid
- Raw provider strings — always use
DB_PROVIDER.<x> from @/lib/config/providers, never 'anthropic' or 'openai' literals
- Truthy checks for optional enum fields —
if (style && ...) misses 0, false. Use style !== undefined && typeof style !== 'string'
- Missing Number.isInteger() on counts —
frameCount, itemCount must be integers or billing gets fractional costs
- Large params in billing metadata — if params include base64, arrays, or long text, add
billingMetadata callback to exclude them
- textLength before content safety — if you need text length for billing, handle content safety in
validate() with skipContentSafety: true and compute length from the sanitized text
- Missing usageId in async responses — async job routes MUST include
usageId: ctx.usageId for client-side refund via polling
- Forgetting to add pricing —
getTokenCost() returns 0 for unknown operations, so the route silently executes for free. This is revenue loss, not a crash — harder to detect. Always verify the operation key exists in TOKEN_COSTS before deploying