spectopus
v0.2.0
Published
The OpenAPI 3.1 spec builder that LLMs actually love — fluent, TypeScript-first, with first-class LLM output adapters
Maintainers
Readme
🐙 spectopus
The OpenAPI 3.1 spec builder where docs live with the code that proves them true.
The Problem with API Docs
Every API has two definitions of itself. The real one — the code — and the documentary one: Swagger YAML, JSDoc annotations, a Notion page, a Postman collection. The two diverge the moment they're created.
You rename a field. The code is updated. The docs? Maybe. Eventually. If someone remembers.
You add a query parameter. You write the logic. Then, separately, you open the Swagger YAML and write it again. Two sources of truth means one of them is lying.
spectopus is built on a different premise: documentation that is derived from the code can never drift from the code.
Write Once: Code and Docs
import { z } from 'zod';
import { SpecBuilder, OperationBuilder } from 'spectopus';
// This schema validates incoming requests at runtime.
// spectopus reads it to generate your docs.
// One definition. Both jobs.
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']).optional(),
});
const spec = new SpecBuilder()
.title('User API').version('1.0.0')
.bearerAuth()
.add('/users', 'post',
new OperationBuilder()
.summary('Create a user')
.body(CreateUserSchema) // ← same schema you parse with at runtime
.response(201, UserSchema)
.response(400, ErrorSchema)
)
.build();Change CreateUserSchema and your documentation changes with it — automatically, correctly, immediately. No separate annotation to update. No drift possible.
Features
| Feature | Details | |---|---| | OpenAPI 3.1 | Full spec support including JSON Schema 2020-12 | | Fluent builder API | Chainable, immutable, TypeScript-native | | Zod integration | Your validation schemas become your API docs | | Joi integration | First-class support for Joi 17+ schemas | | LLM adapters | Export as OpenAI tools, Anthropic tools, or compact context | | Decorator API | Class-based controllers with colocated docs | | Zero runtime deps | Pure TypeScript. Nothing to audit. Nothing to break. | | Framework-agnostic | Works with Express, Fastify, Hono, Koa, bare Node, anything | | Type-safe | Full TypeScript types for the entire OpenAPI 3.1 spec |
Installation
npm install spectopus
# If using Zod integration:
npm install zod
# If using Joi integration:
npm install joispectopus has zero required runtime dependencies. Both Zod and Joi are optional peer dependencies — install only what you use.
Quick Start
import { z } from 'zod';
import { SpecBuilder, OperationBuilder, toLLM } from 'spectopus';
// Your schemas. Used for validation already.
// Now they're your documentation too.
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
});
const ErrorSchema = z.object({
error: z.string(),
code: z.string().optional(),
});
const spec = new SpecBuilder()
.title('My API')
.version('1.0.0')
.server('https://api.example.com', 'Production')
.server('http://localhost:3000', 'Development')
.bearerAuth()
.tag('Users', 'User management')
.component('User', UserSchema)
.component('Error', ErrorSchema)
.add('/users', 'get',
new OperationBuilder()
.summary('List users')
.tag('Users')
.query('page', z.number().int().min(1).default(1), 'Page number')
.query('limit', z.number().int().min(1).max(100).default(20), 'Per page')
.response(200, z.object({ users: z.array(UserSchema), total: z.number() }))
.response(401, ErrorSchema, 'Unauthorized')
)
.add('/users/:id', 'get',
new OperationBuilder()
.summary('Get user by ID')
.tag('Users')
.pathParam('id', z.string().uuid(), 'User UUID')
.response(200, '#/components/schemas/User', 'User found')
.response(404, ErrorSchema, 'Not found')
)
.add('/users', 'post',
new OperationBuilder()
.summary('Create user')
.tag('Users')
.body(z.object({ name: z.string(), email: z.string().email() }))
.response(201, '#/components/schemas/User', 'Created')
.response(400, ErrorSchema, 'Validation error')
)
.build();
// Serve as OpenAPI JSON
console.log(JSON.stringify(spec, null, 2));
// One line to LLM tools — always accurate because they come from your real spec
const openaiTools = toLLM.openai(spec);
const anthropicTools = toLLM.anthropic(spec);
const compactContext = toLLM.compact(spec); // paste into system promptsLLM Integration
This is where spectopus earns its name.
OpenAI Function Calling
import { toLLM } from 'spectopus';
import OpenAI from 'openai';
const tools = toLLM.openai(spec);
// [{ type: 'function', function: { name: 'listUsers', description: '...', parameters: {...} } }]
const openai = new OpenAI();
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Show me all admin users' }],
tools,
});Anthropic Tool Use
import Anthropic from '@anthropic-ai/sdk';
const tools = toLLM.anthropic(spec);
// [{ name: 'listUsers', description: '...', input_schema: {...} }]
const anthropic = new Anthropic();
const response = await anthropic.messages.create({
model: 'claude-opus-4-5',
messages: [{ role: 'user', content: 'Create a guest account for [email protected]' }],
tools,
max_tokens: 1024,
});Compact Context (Token-Efficient)
For pasting into system prompts — maximum information density, minimum tokens:
const context = toLLM.compact(spec);Output:
API: My API v1.0.0
Base: https://api.example.com
Auth: Bearer JWT (Authorization header)
### Users
GET /users — List users
Query: ?page(int,default:1), ?limit(int,1-100,default:20)
→ 200: { users: User[], total: int }
→ 401: { error: string }
GET /users/{id} — Get user by ID
Path: id(uuid)
→ 200: User
→ 404: { error: string }
POST /users — Create user
Body: { name: string, email: email }
→ 201: User
→ 400: { error: string }
Schemas:
User: { id: uuid, name: string, email: email, role: "admin" | "user" | "guest", createdAt: datetime }
Error: { error: string, code?: string }Filter and Customize LLM Output
// Only expose certain tags to the LLM
const tools = toLLM.openai(spec, { includeTags: ['Users', 'Orders'] });
// Exclude sensitive endpoints
const tools = toLLM.openai(spec, { excludeOperations: ['deleteUser', 'adminPanel'] });
// OpenAI strict mode (structured outputs)
const tools = toLLM.openai(spec, { strict: true });
// Limit to N tools (LLMs have limits)
const tools = toLLM.openai(spec, { limit: 20 });Zod Integration
spectopus treats Zod schemas as first-class citizens throughout the entire API. Pass Zod schemas anywhere a schema is expected:
import { z } from 'zod';
import { zodToOpenAPI } from 'spectopus';
// Manual conversion if you need the raw schema object
const openAPISchema = zodToOpenAPI(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user']).default('user'),
tags: z.array(z.string()),
metadata: z.record(z.unknown()).optional(),
}));Supported Zod Types
| Zod Type | OpenAPI Output |
|---|---|
| z.string() | { type: "string" } |
| z.string().email() | { type: "string", format: "email" } |
| z.string().uuid() | { type: "string", format: "uuid" } |
| z.string().url() | { type: "string", format: "uri" } |
| z.string().datetime() | { type: "string", format: "date-time" } |
| z.string().min(n).max(m) | { minLength: n, maxLength: m } |
| z.string().regex(/.../) | { pattern: "..." } |
| z.number() | { type: "number" } |
| z.number().int() | { type: "integer" } |
| z.number().min(n).max(m) | { minimum: n, maximum: m } |
| z.number().gt(n).lt(m) | { exclusiveMinimum: n, exclusiveMaximum: m } |
| z.boolean() | { type: "boolean" } |
| z.null() | { type: "null" } |
| z.literal(x) | { const: x } |
| z.enum([...]) | { type: "string", enum: [...] } |
| z.object({...}) | Full object schema with required[] |
| z.object({...}).strict() | additionalProperties: false |
| z.array(T) | { type: "array", items: T } |
| z.array(T).min(n).max(m) | { minItems: n, maxItems: m } |
| z.tuple([A, B]) | { prefixItems: [A, B], items: false } |
| z.union([A, B]) | { anyOf: [A, B] } |
| z.intersection(A, B) | { allOf: [A, B] } |
| z.discriminatedUnion(...) | { anyOf: [...] } |
| z.optional(T) | Inner type (marked not-required in parent) |
| z.nullable(T) | { type: ["T", "null"] } (JSON Schema 2020-12) |
| z.default(v) | Inner type + { default: v } |
| z.readonly(T) | Inner type + { readOnly: true } |
| z.record(T) | { type: "object", additionalProperties: T } |
| z.set(T) | { type: "array", uniqueItems: true, items: T } |
| z.branded(T) | Inner type (brand erased) |
| z.describe("...") | { description: "..." } |
Joi Integration
spectopus supports Joi 17+ as a first-class schema source alongside Zod. Pass Joi schemas anywhere a schema is expected — in OperationBuilder, response definitions, or directly via joiToOpenAPI.
Joi uses its stable public .describe() API under the hood, so spectopus works across all Joi 17+ releases without coupling to internal properties.
import Joi from 'joi';
import { joiToOpenAPI, SpecBuilder, OperationBuilder } from 'spectopus';
// Manual conversion
const openAPISchema = joiToOpenAPI(Joi.object({
id: Joi.string().uuid().required(),
name: Joi.string().min(1).max(100).required(),
age: Joi.number().integer().min(0).optional(),
email: Joi.string().email().required(),
role: Joi.string().valid('admin', 'user', 'guest').default('user'),
}));
// Or pass Joi schemas directly into the builder API
const spec = new SpecBuilder()
.title('My API').version('1.0.0')
.add('/users', 'post',
new OperationBuilder()
.summary('Create a user')
.body(Joi.object({ name: Joi.string().required() })) // ← Joi schema
.response(201, Joi.object({ id: Joi.string().uuid() }))
)
.build();Supported Joi Types
| Joi Schema | OpenAPI Output |
|---|---|
| Joi.string() | { type: "string" } |
| Joi.string().email() | { type: "string", format: "email" } |
| Joi.string().uri() | { type: "string", format: "uri" } |
| Joi.string().guid() / .uuid() | { type: "string", format: "uuid" } |
| Joi.string().isoDate() | { type: "string", format: "date-time" } |
| Joi.string().hostname() | { type: "string", format: "hostname" } |
| Joi.string().min(n).max(m) | { minLength: n, maxLength: m } |
| Joi.string().length(n) | { minLength: n, maxLength: n } |
| Joi.string().pattern(/.../) | { pattern: "..." } |
| Joi.string().alphanum() | { pattern: "^[a-zA-Z0-9]*$" } |
| Joi.string().hex() | { pattern: "^[a-fA-F0-9]*$" } |
| Joi.string().base64() | { contentEncoding: "base64" } |
| Joi.number() | { type: "number" } |
| Joi.number().integer() | { type: "integer" } |
| Joi.number().min(n).max(m) | { minimum: n, maximum: m } |
| Joi.number().greater(n).less(m) | { exclusiveMinimum: n, exclusiveMaximum: m } |
| Joi.number().multiple(n) | { multipleOf: n } |
| Joi.boolean() | { type: "boolean" } |
| Joi.date() | { type: "string", format: "date-time" } |
| Joi.binary() | { type: "string", contentEncoding: "base64" } |
| Joi.object({...}) | Full object schema with required[] |
| Joi.array().items(T) | { type: "array", items: T } |
| Joi.array().items(A, B) | { type: "array", items: { anyOf: [A, B] } } |
| Joi.array().ordered(A, B) | { prefixItems: [A, B], items: false } (tuple) |
| Joi.array().min(n).max(m) | { minItems: n, maxItems: m } |
| Joi.array().unique() | { uniqueItems: true } |
| Joi.alternatives().try(A, B) | { anyOf: [A, B] } |
| .valid('a', 'b') | { enum: ['a', 'b'] } |
| .allow(null) | { type: ["T", "null"] } (JSON Schema 2020-12) |
| .default(v) | { default: v } |
| .description("...") | { description: "..." } |
| .required() / .optional() | Tracked in parent object's required[] |
| Joi.any() | {} (accepts anything) |
Direct Description Conversion
If you already have Joi's describe output (e.g. from serialization), use joiDescriptionToOpenAPI:
import { joiDescriptionToOpenAPI } from 'spectopus';
const desc = Joi.string().email().describe();
// { type: 'string', rules: [{ name: 'email' }] }
const schema = joiDescriptionToOpenAPI(desc);
// { type: 'string', format: 'email' }Decorator API
For class-based controller patterns. Put your docs where your handlers live.
import { z } from 'zod';
import {
Controller, Get, Post, Patch, Delete,
Query, Path, Body,
Response,
Tag, Summary, Description,
} from 'spectopus/decorators';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
});
const ErrorSchema = z.object({ error: z.string() });
@Controller('/users')
@Tag('Users')
class UserController {
@Get()
@Summary('List all users')
@Query('page', z.number().int().default(1), 'Page number')
@Query('limit', z.number().int().max(100).default(20), 'Per page')
@Response(200, z.object({ users: z.array(UserSchema) }), 'User list')
async listUsers(req, res) {
const { page, limit } = UserListQuerySchema.parse(req.query);
// ... implementation
}
@Get('/:id')
@Summary('Get user by ID')
@Path('id', z.string().uuid(), 'User ID')
@Response(200, UserSchema, 'User found')
@Response(404, ErrorSchema, 'Not found')
async getUser(req, res) {
const { id } = req.params;
// ... implementation
}
@Post()
@Summary('Create a user')
@Body(z.object({ name: z.string(), email: z.string().email() }))
@Response(201, UserSchema, 'Created')
@Response(400, ErrorSchema, 'Validation error')
async createUser(req, res) {
// ... implementation
}
@Delete('/:id')
@Summary('Delete user')
@Path('id', z.string().uuid())
@Response(204, undefined, 'Deleted')
async deleteUser(req, res) {
// ... implementation
}
}
// Extract and build
import { extractControllerOperations } from 'spectopus/decorators';
import { SpecBuilder } from 'spectopus';
const ops = extractControllerOperations(UserController);
let builder = new SpecBuilder().title('My API').version('1.0.0').bearerAuth();
for (const { path, method, operation } of ops) {
builder = builder.add(path, method, operation);
}
const spec = builder.build();Schema Components
Register reusable schemas to $ref them throughout your spec:
const spec = new SpecBuilder()
.title('My API').version('1.0.0')
// Register once
.component('User', UserSchema)
.component('Error', ErrorSchema)
// Reference anywhere with '#/components/schemas/Name'
.add('/users/:id', 'get',
new OperationBuilder()
.response(200, '#/components/schemas/User')
.response(404, '#/components/schemas/Error')
)
.build();Security Schemes
const spec = new SpecBuilder()
.title('Secure API').version('1.0.0')
// Bearer JWT — applied globally
.bearerAuth()
// API key — applied globally
.apiKeyAuth('apiKey', 'X-API-Key')
// Custom scheme
.securityScheme('oauth2', {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: 'https://auth.example.com/oauth/authorize',
tokenUrl: 'https://auth.example.com/oauth/token',
scopes: { 'read:users': 'Read users', 'write:users': 'Write users' },
},
},
})
.add('/users', 'get',
new OperationBuilder()
.noAuth() // Override: this endpoint is public
.response(200, UserListSchema)
)
.add('/users', 'post',
new OperationBuilder()
.oauth2('oauth2', 'write:users') // Require specific scope
.body(CreateUserSchema)
.response(201, UserSchema)
)
.build();Full API Reference
SpecBuilder
new SpecBuilder(options?)
// Info
.title(string)
.version(string)
.description(string)
.summary(string)
.contact(name, email?, url?)
.license(name, url?)
.termsOfService(url)
// Servers
.server(url, description?)
// Operations
.add(path, method, operation)
.addRoutes([[path, method, operation], ...])
.paths(PathsObject)
// Tags
.tag(name, description?)
// Security
.bearerAuth(schemeName?, applyGlobally?)
.apiKeyAuth(schemeName?, header?, applyGlobally?)
.security(SecurityRequirementObject)
.securityScheme(name, SecuritySchemeObject)
// Components
.component(name, schema)
.components(ComponentsBuilder)
// Output
.build() // → OpenAPIDocument
.toJSON(indent?) // → string (JSON)
.toYAML() // → Promise<string> (requires 'yaml' package)OperationBuilder
new OperationBuilder()
// Identity
.operationId(string)
.summary(string)
.description(string)
.tag(string)
.tags(...strings)
.deprecated(boolean?)
// Parameters
.query(name, schema, description?, required?)
.requiredQuery(name, schema, description?)
.pathParam(name, schema, description?)
.header(name, schema, description?, required?)
.cookie(name, schema, description?, required?)
.param(ParameterObject)
// Body
.body(schema, description?, required?)
.bodyContent(mediaType, schema, description?, required?)
.formBody(schema, description?, required?)
.requestBody(RequestBodyObject)
// Responses
.response(statusCode, schema, description?)
.noContent(statusCode?, description?)
.rawResponse(statusCode, ResponseObject)
// Security
.bearerAuth(schemeName?)
.apiKeyAuth(schemeName?)
.oauth2(schemeName?, ...scopes)
.noAuth()
.security(SecurityRequirementObject)
// Output
.build() // → OperationObjecttoLLM Adapters
import { toLLM } from 'spectopus';
// OpenAI function calling
toLLM.openai(spec, {
strict?: boolean, // Enable structured outputs mode
includeTags?: string[], // Filter by tag
excludeOperations?: string[], // Exclude by operationId
limit?: number, // Max tools to return
})
// Anthropic tool use
toLLM.anthropic(spec, {
includeTags?: string[],
excludeOperations?: string[],
limit?: number,
includePathInDescription?: boolean,
})
// Compact text for system prompts
toLLM.compact(spec, {
includeSchemas?: boolean,
includeServers?: boolean,
includeSecurity?: boolean,
includeParamDescriptions?: boolean,
includeTags?: string[],
maxSchemaDepth?: number,
})zodToOpenAPI
import { zodToOpenAPI } from 'spectopus';
zodToOpenAPI(zodSchema, {
includeDescriptions?: boolean, // Include .describe() text
includeDefaults?: boolean, // Include .default() values
maxDepth?: number, // Max recursion depth (default: 20)
})joiToOpenAPI
import { joiToOpenAPI, joiDescriptionToOpenAPI } from 'spectopus';
// Convert a Joi schema instance
joiToOpenAPI(joiSchema, {
includeDescriptions?: boolean, // Include .description() text
includeDefaults?: boolean, // Include .default() values
maxDepth?: number, // Max recursion depth (default: 20)
})
// Convert raw describe() output (plain object — no Joi instance required)
joiDescriptionToOpenAPI(joiSchema.describe(), options?)Also available as a named sub-path import:
import { joiToOpenAPI } from 'spectopus/adapters/joi';Zero Dependencies
spectopus's runtime has no required dependencies. Zero. The package is pure TypeScript compiled to JavaScript.
- Zod is an optional peer dependency. Install it only if you want the Zod adapter. Without it, you can still use plain
SchemaObjectdefinitions. - Joi is an optional peer dependency. Install it only if you want the Joi adapter. spectopus uses Joi's public
.describe()API — no internal coupling. - YAML libraries (
yamlorjs-yaml) are only needed if you call.toYAML(). Neither is required. - LLM SDKs are not required — spectopus just produces plain JavaScript objects that match the tool definition formats.
This means spectopus is fast to install, easy to audit, and won't bloat your dependencies. The bundle is small and tree-shakeable.
Why not swagger-jsdoc? Why not tsoa?
| | spectopus | swagger-jsdoc | tsoa | typed-openapi | |---|---|---|---|---| | Colocation | ✅ Schema IS the doc | ❌ JSDoc is separate annotation | ⚠️ Decorator-only | ❌ Manual types | | Zod-native | ✅ First-class | ❌ | ❌ | ❌ | | Joi support | ✅ First-class | ❌ | ❌ | ❌ | | LLM adapters | ✅ Built-in | ❌ | ❌ | ❌ | | Runtime deps | ✅ Zero | ⚠️ Some | ⚠️ Some | ✅ Zero | | OpenAPI 3.1 | ✅ | ⚠️ Partial | ⚠️ 3.0 | ✅ | | Fluent API | ✅ | ❌ | ❌ | ❌ | | Drift possible | ❌ Never | ✅ Always | ⚠️ Sometimes | ✅ Always |
swagger-jsdoc requires you to write your API schema twice: once in code, once in JSDoc. Those two things will diverge.
tsoa is closer — decorators are colocated — but it doesn't support Zod, doesn't have LLM adapters, and its OpenAPI 3.1 support is incomplete.
spectopus is designed from the ground up for the principle that the schema that validates your runtime data is the schema that documents your API. No copies. No synchronization. One source of truth.
Framework Examples
Express
import express from 'express';
import { spec } from './spec.js';
const app = express();
app.use(express.json());
// Serve docs
app.get('/openapi.json', (req, res) => res.json(spec));
// Optional: serve Swagger UI
// app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
// Your routes use the same Zod schemas for validation:
app.post('/users', async (req, res) => {
const body = CreateUserSchema.parse(req.body); // same schema as the doc
const user = await db.createUser(body);
res.status(201).json(user);
});Fastify
import Fastify from 'fastify';
import { spec } from './spec.js';
const app = Fastify();
app.get('/openapi.json', async () => spec);
app.post('/users', {
// Fastify can use JSON Schema for built-in validation
schema: { body: zodToOpenAPI(CreateUserSchema) },
handler: async (req, reply) => {
const user = await db.createUser(req.body);
reply.status(201).send(user);
},
});Hono
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { spec } from './spec.js';
const app = new Hono();
app.get('/openapi.json', (c) => c.json(spec));
app.post('/users',
zValidator('json', CreateUserSchema), // same schema as the doc
async (c) => {
const body = c.req.valid('json');
const user = await db.createUser(body);
return c.json(user, 201);
}
);The Philosophy
The central insight behind spectopus:
Documentation that is generated from the source of truth can never drift from the source of truth.
When you use swagger-jsdoc, you write:
/**
* @openapi
* /users:
* post:
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string ← Is this still accurate?
* email:
* type: string ← Who checks?
*/
app.post('/users', (req, res) => {
const { name, email } = CreateUserSchema.parse(req.body);
// CreateUserSchema may have changed last week.
// The JSDoc above hasn't.
});When you use spectopus, you write:
.body(CreateUserSchema) // ← If the schema changes, the doc changes. Always.There is no annotation to forget. There is no second place to update. The documentation is a view over the code, not a parallel copy of it.
This is especially powerful for LLM tools, where stale function definitions cause silent failures: the LLM calls a function with parameters that no longer exist. With spectopus, that class of error is impossible.
Contributing
Contributions are very welcome! See CONTRIBUTING.md.
Areas we'd love help with:
- More Zod type coverage edge cases
- More Joi type coverage edge cases
- Valibot adapter (
spectopus/adapters/valibot) - Express/Fastify/Hono route extraction helpers
- CLI tool (
spectopus generate --output openapi.json) - OpenAPI spec validation (full schema validation, not just structural)
License
MIT © spectopus contributors
