Apply the api-route-engineer specialist workflow. Build endpoints grounded in the factory's API conventions, not bespoke per-route handlers. Load the canonical factory-api and factory-auth skills through the host's skill capability when needed.
How to think (in order)
API style? Apply the decision matrix from factory-api.md:
- Server actions — default. One frontend consumer. Feature-folder colocation matters.
- tRPC — ≥3 entities with cross-feature queries. Multiple consumers. Typed RPC.
- REST / OpenAPI — only for external system callers.
If the project already commits to one, use it. Don't mix.
What's the surface?
- Mutation — create, update, delete, custom action
- Query — list (paginated), detail (by ID), search, aggregation
- Webhook — external system → your service
Auth tier?
- publicProcedure — only for genuinely public endpoints (signup, public docs)
- protectedProcedure — authed user, no org context
- orgProcedure — authed user + org context (default for app endpoints)
Input shape? Per-endpoint Zod schema. For paginated lists:
limit: number().int().min(1).max(100).default(50)
offset: number().int().min(0).default(0)
orderBy: enum([...]).default('createdAt')
orderDir: enum(['asc','desc']).default('desc')
- Plus per-feature filter object
Output shape? Three options:
- List:
{ items: T[], total?: number } — include total if pagination needs it
- Detail:
T | null — return null on not-found, throw NotFoundError if the caller expected it
- Mutation: the updated/created entity, or
ActionResult<T> for server actions
Multi-tenant filter? Every query / mutation in orgProcedure filters by ctx.orgId. This is automatic enforcement, not "remember to add WHERE."
Pagination shape?
- Offset — default for everything (admin tables, settings, normal CRUD)
- Cursor — only for real-time feeds, append-only logs, or pagination-stable-under-inserts requirements (chat messages, audit log)
Aggregations in list? If the list view needs counts (e.g. "customer with vehicle count"), use a subquery / leftJoin + groupBy rather than a per-row round-trip:
ctx.db.select({
...getTableColumns(customers),
vehicleCount: count(vehicles.id),
}).from(customers).leftJoin(vehicles, ...).groupBy(customers.id);
Error shape?
- Throw
AuthError, NotFoundError, ValidationError from src/lib/errors.ts
- For server actions, catch at the boundary and convert to
{ error: 'message' }
- For tRPC, throw
TRPCError({ code, message })
Audit log? Fire-and-forget at the mutation boundary. Never await it on the critical path. See factory-security.md.
Reference: canonical server action
// features/customers/actions.ts
'use server';
import { customerInputSchema } from './schema';
import { withOrgContext } from '@/lib/auth';
import { db } from '@/db';
import { customers } from '@/db/schema';
import { logAdminAction } from '@/lib/admin/activity';
import type { ActionResult } from '@/lib/api/types';
export async function createCustomer(input: unknown): Promise<ActionResult<Customer>> {
const parsed = customerInputSchema.safeParse(input);
if (!parsed.success) return { error: parsed.error.issues[0].message };
return withOrgContext(async ({ orgId, user }) => {
const [customer] = await db.insert(customers).values({
...parsed.data,
orgId,
}).returning();
logAdminAction({ action: 'customer.create', subject_id: customer.id, actor_id: user.id })
.catch((err) => console.error('audit log failed', err));
return { data: customer };
});
}
Reference: canonical tRPC router
// server/api/routers/customers.ts
import { z } from 'zod';
import { eq, and, ilike, or, count, desc, asc } from 'drizzle-orm';
import { createTRPCRouter, orgProcedure } from '../trpc';
import { customers, vehicles } from '@/db/schema';
const listInput = z.object({
limit: z.number().int().min(1).max(100).default(50),
offset: z.number().int().min(0).default(0),
orderBy: z.enum(['createdAt', 'name']).default('createdAt'),
orderDir: z.enum(['asc', 'desc']).default('desc'),
q: z.string().optional(),
});
export const customersRouter = createTRPCRouter({
list: orgProcedure.input(listInput).query(async ({ ctx, input }) => {
const conditions = [eq(customers.orgId, ctx.orgId)];
if (input.q) {
const term = `%${input.q}%`;
conditions.push(or(ilike(customers.name, term), ilike(customers.email, term))!);
}
return ctx.db.select().from(customers)
.where(and(...conditions))
.limit(input.limit)
.offset(input.offset)
.orderBy(input.orderDir === 'desc' ? desc(customers[input.orderBy]) : asc(customers[input.orderBy]));
}),
create: orgProcedure.input(createInput).mutation(async ({ ctx, input }) => {
const [customer] = await ctx.db.insert(customers).values({ ...input, orgId: ctx.orgId }).returning();
logAdminAction({ action: 'customer.create', subject_id: customer.id, actor_id: ctx.user.id })
.catch((err) => console.error('audit log failed', err));
return customer;
}),
});
// server/api/root.ts
export const appRouter = createTRPCRouter({
customers: customersRouter,
// ... add a line per domain. Manual registration, easy to grep.
});
Output format
## Restated request
<one sentence>
## API surface
- Style: <server actions / tRPC / REST>
- Auth tier: <public / protected / org>
- Operations: <list / detail / create / update / delete / custom>
## Files to create or modify
<bulleted with paths>
## Code
<organized by file>
## Conventions check
- Per-endpoint Zod input: yes
- Multi-tenant filter (orgId): yes
- Pagination shape: <offset / cursor — why>
- Aggregation strategy: <subquery / leftJoin / N+1 — flag if N+1>
- Audit log fire-and-forget: <yes — at mutation boundary>
- Error class taxonomy used: <yes>
## Open questions
<things the user should confirm>
What you do NOT do
- Don't mix tRPC and server actions in the same project. Pick a side.
- Don't write per-route HTTP handlers when tRPC exists. Use the fetch adapter.
- Don't
UPDATE or DELETE without WHERE. ESLint Drizzle rule from factory-data-layer.md.
- Don't await audit logs on the mutation critical path. Fire-and-forget.
- Don't reach for cursor pagination by default. Offset is fine for almost everything.
- Don't make every endpoint
publicProcedure. Auth tier explicitly per endpoint.
- Don't return raw
Error objects to the client. Convert at the boundary.
- Don't filter by an
orgId from the request body. Always from session.
- Don't put per-mutation schemas in a separate file unless they're shared with the client form schemas.
When the request is too small for this framework
If the user asks to add a single field to an existing input schema or change one validation rule, do it directly. The framework is for new endpoints, new routers, or substantial API surface changes.
1---2name: factory-api-route-engineer3description: Use when designing or implementing API endpoints — server actions, tRPC procedures, REST routes for external consumers. Carries the factory's API conventions — the server actions vs tRPC decision, procedure tier stacking, per-mutation Zod schemas, central router composition with manual registration, pagination (limit/offset/orderBy default; cursor only when needed), multi-field search via `ilike` + `or()`, aggregated stats in list queries, mutation lifecycle hooks, stale-time defaults, custom error class taxonomy, fetch adapter for tRPC in App Router. Outputs endpoints that fit the house style — not bespoke per-route handlers.4---56Apply the **api-route-engineer** specialist workflow. Build endpoints grounded in the factory's API conventions, not bespoke per-route handlers. Load the canonical `factory-api` and `factory-auth` skills through the host's skill capability when needed.78## How to think (in order)9101. **API style?** Apply the decision matrix from `factory-api.md`:11 - **Server actions** — default. One frontend consumer. Feature-folder colocation matters.12 - **tRPC** — ≥3 entities with cross-feature queries. Multiple consumers. Typed RPC.13 - **REST / OpenAPI** — only for external system callers.1415 If the project already commits to one, use it. Don't mix.16172. **What's the surface?**18 - **Mutation** — create, update, delete, custom action19 - **Query** — list (paginated), detail (by ID), search, aggregation20 - **Webhook** — external system → your service21223. **Auth tier?**23 - **publicProcedure** — only for genuinely public endpoints (signup, public docs)24 - **protectedProcedure** — authed user, no org context25 - **orgProcedure** — authed user + org context (default for app endpoints)26274. **Input shape?** Per-endpoint Zod schema. For paginated lists:28 - `limit: number().int().min(1).max(100).default(50)`29 - `offset: number().int().min(0).default(0)`30 - `orderBy: enum([...]).default('createdAt')`31 - `orderDir: enum(['asc','desc']).default('desc')`32 - Plus per-feature filter object33345. **Output shape?** Three options:35 - **List**: `{ items: T[], total?: number }` — include `total` if pagination needs it36 - **Detail**: `T | null` — return null on not-found, throw `NotFoundError` if the caller expected it37 - **Mutation**: the updated/created entity, or `ActionResult<T>` for server actions38396. **Multi-tenant filter?** Every query / mutation in `orgProcedure` filters by `ctx.orgId`. This is automatic enforcement, not "remember to add WHERE."40417. **Pagination shape?**42 - **Offset** — default for everything (admin tables, settings, normal CRUD)43 - **Cursor** — only for real-time feeds, append-only logs, or pagination-stable-under-inserts requirements (chat messages, audit log)44458. **Aggregations in list?** If the list view needs counts (e.g. "customer with vehicle count"), use a subquery / leftJoin + groupBy rather than a per-row round-trip:4647 ```ts48 ctx.db.select({49 ...getTableColumns(customers),50 vehicleCount: count(vehicles.id),51 }).from(customers).leftJoin(vehicles, ...).groupBy(customers.id);52 ```53549. **Error shape?**55 - Throw `AuthError`, `NotFoundError`, `ValidationError` from `src/lib/errors.ts`56 - For server actions, catch at the boundary and convert to `{ error: 'message' }`57 - For tRPC, throw `TRPCError({ code, message })`585910. **Audit log?** Fire-and-forget at the mutation boundary. Never `await` it on the critical path. See `factory-security.md`.6061## Reference: canonical server action6263```ts64// features/customers/actions.ts65'use server';6667import { customerInputSchema } from './schema';68import { withOrgContext } from '@/lib/auth';69import { db } from '@/db';70import { customers } from '@/db/schema';71import { logAdminAction } from '@/lib/admin/activity';72import type { ActionResult } from '@/lib/api/types';7374export async function createCustomer(input: unknown): Promise<ActionResult<Customer>> {75 const parsed = customerInputSchema.safeParse(input);76 if (!parsed.success) return { error: parsed.error.issues[0].message };7778 return withOrgContext(async ({ orgId, user }) => {79 const [customer] = await db.insert(customers).values({80 ...parsed.data,81 orgId,82 }).returning();8384 logAdminAction({ action: 'customer.create', subject_id: customer.id, actor_id: user.id })85 .catch((err) => console.error('audit log failed', err));8687 return { data: customer };88 });89}90```9192## Reference: canonical tRPC router9394```ts95// server/api/routers/customers.ts96import { z } from 'zod';97import { eq, and, ilike, or, count, desc, asc } from 'drizzle-orm';98import { createTRPCRouter, orgProcedure } from '../trpc';99import { customers, vehicles } from '@/db/schema';100101const listInput = z.object({102 limit: z.number().int().min(1).max(100).default(50),103 offset: z.number().int().min(0).default(0),104 orderBy: z.enum(['createdAt', 'name']).default('createdAt'),105 orderDir: z.enum(['asc', 'desc']).default('desc'),106 q: z.string().optional(),107});108109export const customersRouter = createTRPCRouter({110 list: orgProcedure.input(listInput).query(async ({ ctx, input }) => {111 const conditions = [eq(customers.orgId, ctx.orgId)];112 if (input.q) {113 const term = `%${input.q}%`;114 conditions.push(or(ilike(customers.name, term), ilike(customers.email, term))!);115 }116 return ctx.db.select().from(customers)117 .where(and(...conditions))118 .limit(input.limit)119 .offset(input.offset)120 .orderBy(input.orderDir === 'desc' ? desc(customers[input.orderBy]) : asc(customers[input.orderBy]));121 }),122123 create: orgProcedure.input(createInput).mutation(async ({ ctx, input }) => {124 const [customer] = await ctx.db.insert(customers).values({ ...input, orgId: ctx.orgId }).returning();125 logAdminAction({ action: 'customer.create', subject_id: customer.id, actor_id: ctx.user.id })126 .catch((err) => console.error('audit log failed', err));127 return customer;128 }),129});130131// server/api/root.ts132export const appRouter = createTRPCRouter({133 customers: customersRouter,134 // ... add a line per domain. Manual registration, easy to grep.135});136```137138## Output format139140```141## Restated request142<one sentence>143144## API surface145- Style: <server actions / tRPC / REST>146- Auth tier: <public / protected / org>147- Operations: <list / detail / create / update / delete / custom>148149## Files to create or modify150<bulleted with paths>151152## Code153<organized by file>154155## Conventions check156- Per-endpoint Zod input: yes157- Multi-tenant filter (orgId): yes158- Pagination shape: <offset / cursor — why>159- Aggregation strategy: <subquery / leftJoin / N+1 — flag if N+1>160- Audit log fire-and-forget: <yes — at mutation boundary>161- Error class taxonomy used: <yes>162163## Open questions164<things the user should confirm>165```166167## What you do NOT do168169- **Don't mix tRPC and server actions in the same project.** Pick a side.170- **Don't write per-route HTTP handlers when tRPC exists.** Use the fetch adapter.171- **Don't `UPDATE` or `DELETE` without `WHERE`.** ESLint Drizzle rule from `factory-data-layer.md`.172- **Don't await audit logs on the mutation critical path.** Fire-and-forget.173- **Don't reach for cursor pagination by default.** Offset is fine for almost everything.174- **Don't make every endpoint `publicProcedure`.** Auth tier explicitly per endpoint.175- **Don't return raw `Error` objects to the client.** Convert at the boundary.176- **Don't filter by an `orgId` from the request body.** Always from session.177- **Don't put per-mutation schemas in a separate file** unless they're shared with the client form schemas.178179## When the request is too small for this framework180181If the user asks to add a single field to an existing input schema or change one validation rule, do it directly. The framework is for new endpoints, new routers, or substantial API surface changes.