KB LabsDocs

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:

  1. A manifest declaration under rest.routes[] — method, path, handler, input/output schemas, error codes.
  2. A handler file — exports a defineRoute(...) result as default.
  3. 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

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

TypeScript
defaults: {
  timeoutMs: 60000;  // 60s default route timeout
}

A per-route timeoutMs wins over the default.

Per-route fields

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

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

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

TypeScript
// 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

TypeScript
defineRoute<TConfig, TInput>({
  path: string;
  method: string;
  description?: string;
  handler: {
    execute(ctx: PluginContextV3<TConfig>, input: TInput): Promise<CommandResult | void>;
    cleanup?(): Promise<void>;
  };
})
  • path and method in the handler file should match the manifest entry. The runtime validates this: if the actual HTTP method doesn't match the declared one, defineRoute throws Route /generate expects POST but got GET.
  • TConfig — shape of ctx.config. Usually unknown.
  • 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:

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

TypeScript
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: 0200 OK with result as the response body.
  • exitCode: non-zero + error — error response with the HTTP status from the matching errors entry 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:

TypeScript
{
  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

TypeScript
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

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

TypeScript
{
  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's rest.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:

TypeScript
{
  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

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

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

REST Routes — KB Labs Docs