REST Routes
Last updated April 7, 2026
Expose HTTP endpoints from your plugin: manifest, handlers, Zod schemas, errors.
Plugins can expose HTTP endpoints that mount on the REST API service. A plugin declares routes in its manifest, implements handlers with defineRoute or defineHandler, and the REST API service takes care of everything else — Fastify registration, schema validation, auth, OpenAPI generation.
This page covers the authoring side. For the service that mounts these routes, see Services → REST API.
The shape
Every REST route has:
- A manifest declaration under
rest.routes[]— method, path, handler, input/output schemas, error codes. - A handler file — exports a
defineRoute(...)result as default. - Zod schemas referenced by the manifest via
{ zod: '@pkg#Schema' }notation.
The canonical example is kb-labs-commit-plugin's manifest.ts which declares 14 routes — browse that for a production-grade reference.
1 — The manifest entry
rest: {
basePath: '/v1/plugins/commit',
defaults: {
timeoutMs: 60000,
},
routes: [
{
method: 'POST',
path: '/generate',
handler: './rest/handlers/generate-handler.js#default',
input: {
zod: '@kb-labs/commit-contracts#GenerateRequestSchema',
},
output: {
zod: '@kb-labs/commit-contracts#GenerateResponseSchema',
},
timeoutMs: 300000,
description: 'Generate a commit plan using LLM',
errors: [
{ code: 'NO_CHANGES', http: 400, description: 'No git changes to commit' },
{ code: 'LLM_FAILED', http: 502, description: 'LLM adapter failed' },
],
},
// ... more routes
],
}basePath
Required. Template literal type: must start with /v1/plugins/. The plugin's routes all mount under this prefix. Typically you use your plugin's name (or group prefix): /v1/plugins/commit, /v1/plugins/mind, /v1/plugins/qa.
When the REST API loads your plugin, it prepends basePath to every route's path, so { method: 'POST', path: '/generate' } becomes POST /v1/plugins/commit/generate.
defaults
Optional. Settings applied to all routes in the plugin unless they override:
defaults: {
timeoutMs: 60000; // 60s default route timeout
}A per-route timeoutMs wins over the default.
Per-route fields
interface RestRouteDecl {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string; // relative to basePath
description?: string;
timeoutMs?: number;
input?: { zod: string } | { $ref: string };
output?: { zod: string } | { $ref: string };
errors?: Array<{
code: string;
http: number;
description?: string;
}>;
handler: string; // './rest/handlers/...#default'
security?: ('none' | 'user' | 'token' | 'oauth')[];
permissions?: PermissionSpec;
}See Manifest Reference → rest for the full schema.
2 — Zod schema references
Input and output schemas are referenced by string, not imported directly:
input: { zod: '@kb-labs/commit-contracts#GenerateRequestSchema' },
output: { zod: '@kb-labs/commit-contracts#GenerateResponseSchema' },The format is <package-name>#<export-name>. At load time, the runtime dynamically imports the package, reads the named export, and wires it into Fastify's request/response validation.
Why not direct imports? Because the manifest is sometimes serialized to JSON (the .kb/marketplace.manifests.json cache, for example), and JSON can't carry live references. The string form is stable across serialization boundaries.
Where to put schemas
Convention: put Zod schemas in your plugin's contracts package, separate from the CLI and core packages:
packages/
├── commit-cli/ # CLI + REST handlers + manifest
├── commit-core/ # Business logic (framework-free)
└── commit-contracts/ # Zod schemas, types, constants// packages/commit-contracts/src/generate.ts
import { z } from 'zod';
export const GenerateRequestSchema = z.object({
scope: z.string().optional(),
dryRun: z.boolean().default(false),
});
export type GenerateRequest = z.infer<typeof GenerateRequestSchema>;
export const GenerateResponseSchema = z.object({
commits: z.array(z.object({
message: z.string(),
files: z.array(z.string()),
})),
tokens: z.number(),
});
export type GenerateResponse = z.infer<typeof GenerateResponseSchema>;This keeps the contracts package small and depends-only-on-Zod, which matters when the CLI and REST handlers want to import it without pulling in heavy dependencies.
Alternative: $ref
The manifest supports { $ref: '...' } for OpenAPI JSON Schema references:
input: { $ref: '#/components/schemas/GenerateRequest' }Use this when you have an external OpenAPI document you want to reference. Most plugins use the Zod form — it's tighter and the runtime can validate against it directly.
3 — The handler
Handlers are built with defineRoute from @kb-labs/sdk:
// packages/commit-cli/src/rest/handlers/generate-handler.ts
import { defineRoute, useLLM, useLogger } from '@kb-labs/sdk';
import type { GenerateRequest, GenerateResponse } from '@kb-labs/commit-contracts';
export default defineRoute<unknown, GenerateRequest>({
path: '/generate',
method: 'POST',
description: 'Generate a commit plan using LLM',
handler: {
async execute(ctx, input) {
const logger = useLogger();
const llm = useLLM();
if (!llm) {
return {
exitCode: 1,
error: {
code: 'LLM_FAILED',
message: 'LLM adapter not configured',
},
};
}
logger.info('Generating commit plan', { scope: input.scope });
const commits = await generatePlan(ctx, input);
const response: GenerateResponse = {
commits,
tokens: ctx.meta?.llm?.totalTokens ?? 0,
};
return {
exitCode: 0,
result: response,
};
},
},
});Signature
defineRoute<TConfig, TInput>({
path: string;
method: string;
description?: string;
handler: {
execute(ctx: PluginContextV3<TConfig>, input: TInput): Promise<CommandResult | void>;
cleanup?(): Promise<void>;
};
})pathandmethodin the handler file should match the manifest entry. The runtime validates this: if the actual HTTP method doesn't match the declared one,defineRoutethrowsRoute /generate expects POST but got GET.TConfig— shape ofctx.config. Usuallyunknown.TInput— the parsed request body (POST/PUT/PATCH) or query (GET). Type this against your Zod-inferred request type.
Host guard
defineRoute wraps the handler with a guard that throws if it's invoked from a non-REST host:
if (context.host !== 'rest') {
throw new Error(
`Route ${definition.path} can only run in REST host (current: ${context.host})`
);
}You can't call a REST handler as a CLI command, and vice versa. Use the right define* helper for the right host.
Input
The input parameter is the validated request body for POST/PUT/PATCH routes, or the validated query string for GET/DELETE. Fastify runs the input through the Zod schema declared in the manifest before calling your handler, so input is always the typed, validated payload — no manual parsing.
If the request fails validation, the caller gets a 400 with the validation errors, and your handler never runs.
Output
The return value of execute is a CommandResult<T>:
interface CommandResult<T = unknown> {
exitCode: number;
result?: T;
meta?: Record<string, unknown>;
error?: {
message: string;
code?: string;
details?: Record<string, unknown>;
};
}For REST, the REST API service unwraps this:
exitCode: 0—200 OKwithresultas the response body.exitCode: non-zero+error— error response with the HTTP status from the matchingerrorsentry in the manifest (or 500 if the code isn't declared), and a body like{ error: { code, message, details } }.
See SDK → Commands → CommandResult for the contract details.
Error handling
Declare every error code your route can return in the manifest's errors[] array:
{
method: 'POST',
path: '/generate',
handler: './rest/handlers/generate-handler.js#default',
errors: [
{ code: 'NO_CHANGES', http: 400, description: 'No git changes to commit' },
{ code: 'INVALID_SCOPE', http: 400, description: 'Scope glob did not match any files' },
{ code: 'LLM_FAILED', http: 502, description: 'LLM adapter failed' },
{ code: 'RATE_LIMITED', http: 429, description: 'LLM rate limit exceeded' },
],
}The manifest validation enforces that http is 400–599. At runtime, when your handler returns { error: { code: 'NO_CHANGES' } }, the REST API looks up the code in your declared errors, maps it to the HTTP status, and returns the error response.
Undeclared codes fall back to 500 and emit a warning log. Always declare the codes you return — it's part of the API contract for callers.
Returning errors
async execute(ctx, input) {
const status = await getGitStatus(ctx);
if (!hasChanges(status)) {
return {
exitCode: 1,
error: {
code: 'NO_CHANGES',
message: 'No git changes to commit',
},
};
}
// ... happy path
}Throwing is different:
- Return with
error— handled error, maps to the declared HTTP status. - Throw — unhandled exception, caught by the REST API middleware, becomes 500 with an opaque error body.
Use return-with-error for every expected failure; reserve throwing for truly unexpected states.
Security
security?: ('none' | 'user' | 'token' | 'oauth')[];Declares what authentication the route accepts. If multiple values are listed, any of them works. 'none' means the route is publicly accessible. Omit to inherit from plugin defaults.
The REST API's auth middleware validates the caller against the declared security before dispatching to your handler. Your handler sees ctx.user populated when auth succeeded.
Per-route permissions
Like CLI commands, REST routes can override plugin-wide permissions:
{
method: 'POST',
path: '/push',
handler: './rest/handlers/push-handler.js#default',
permissions: combinePermissions()
.with(gitWorkflowPreset)
.withShell({ allow: ['git push'] })
.build(),
}Use this for routes that need stronger permissions than the rest of your plugin. The route's permissions replace the plugin's defaults entirely — see Plugins → Permissions for the merge semantics.
OpenAPI integration
The REST API generates OpenAPI specs from your manifest automatically:
/openapi.json— the native Fastify spec covering platform-level routes./openapi-plugins.json— plugin-contributed routes, generated from every plugin'srest.routes[]block and Zod schemas.
Your routes appear in /openapi-plugins.json with their declared input/output schemas. Callers can browse the spec in Swagger UI at /docs (or /docs-all on the gateway for the merged view).
Making routes visible in OpenAPI
A route appears in the spec only if it has a Fastify tags field in its schema. The REST API uses hideUntagged: true, so undeclared routes are hidden from the spec but still reachable. This is how admin/debug routes stay out of the public API surface.
Tagging happens at the REST API mount layer, not in the manifest — the service inspects route metadata as it mounts each plugin route. If your route doesn't show up in Swagger UI, it's either untagged or explicitly hidden.
Path parameters and query strings
The manifest path is a literal string — no {id} placeholders. If you need path parameters, use them in the path directly as Fastify route syntax:
{
method: 'GET',
path: '/plans/:id',
handler: './rest/handlers/get-plan.js#default',
}Inside the handler, path params arrive via ctx.request.params (not through input, which is the body/query). Query strings on GET/DELETE routes are validated through the input schema.
Timeouts
{
method: 'POST',
path: '/generate',
handler: './rest/handlers/generate-handler.js#default',
timeoutMs: 300000, // 5 minutes for LLM generation
}When a route handler exceeds its timeout, Fastify aborts the request and returns 504 to the caller. The default is set by rest.defaults.timeoutMs (plugin-wide) or the server default (typically 60s).
Use longer timeouts sparingly — long-running work should go through the workflow engine instead, which has better support for progress reporting, retries, and cancellation.
Handler signature: defineHandler vs defineRoute
There are two helpers for REST handlers:
defineRoute— canonical, shown in all examples on this page. Gives you full control over the request shape and result.defineHandler— lighter-weight wrapper, exported from@kb-labs/sdk. Same basic shape, different ergonomics.
Most plugins use defineRoute. Use defineHandler when you want a simpler signature for small handlers.
Testing routes
Use createTestContext from @kb-labs/sdk/testing to unit-test handlers:
import { createTestContext } from '@kb-labs/sdk/testing';
import { describe, it, expect } from 'vitest';
import generateHandler from './generate-handler';
describe('POST /generate', () => {
it('returns NO_CHANGES when working tree is clean', async () => {
const ctx = createTestContext({ host: 'rest' });
const result = await generateHandler.execute(ctx, { scope: undefined, dryRun: false });
expect(result.exitCode).toBe(1);
expect(result.error?.code).toBe('NO_CHANGES');
});
});For integration tests that exercise the full Fastify pipeline, boot the REST API service with an in-memory adapter stack — see SDK → Testing for patterns.
What to read next
- Manifest Reference → rest — the full schema.
- Services → REST API — how the service mounts your routes.
- Plugins → Permissions — per-route permission overrides.
- SDK → Routes — more on
defineRoute,defineAction,defineWebhook. - SDK → Testing — unit and integration testing strategies.