Write what your server can do. The framework handles how it's exposed.
Capix replaces routes and middleware with one primitive: a capability — a typed, guarded, pure function. Define it once, and every transport serves it: the same function is a REST endpoint, a GraphQL field, a WebSocket call, a background job, and an MCP tool your AI agents can invoke — with one validation pipeline, one error model, and guards that cannot be forgotten on any of them.
import { z } from 'zod';
import { capability, defineGuard, createServer } from '@capixjs/core';
import { restTransport } from '@capixjs/transport-rest';
import { graphqlTransport } from '@capixjs/transport-graphql';
import { mcpTransport } from '@capixjs/transport-mcp';
const mustBeAdmin = defineGuard((ctx) => {
if (!ctx.user?.admin) throw new Error('Forbidden');
});
const createPost = capability(
z.object({ title: z.string(), body: z.string() }),
async ({ title, body }, ctx) => db.posts.create({ title, body, authorId: ctx.user.id }),
).guard(mustBeAdmin);
createServer({
capabilities: { posts: { createPost } },
transports: [
restTransport({ port: 3000 }), // POST /posts ← route inferred from the name
graphqlTransport({ port: 4000 }), // mutation posts_createPost
mcpTransport({ port: 5000 }), // MCP tool posts_createPost, guarded the same way
],
}).start();No req/res. No next(). No middleware stack. Transports are wiring, not architecture — add or remove one without touching a single capability.
5-minute quick start → · Documentation · Examples · Benchmarks · API stability
Status: stable (1.0). The public API and wire contracts follow semantic versioning — breaking changes only ship in a new major version.
npm install @capixjs/coreresolves to the current stable release.
| Traditional | Capix |
|---|---|
| Routes scatter business logic | Capabilities are pure functions — testable without a server |
| Middleware is implicit and ordered | Guards are explicit, typed, and chainable |
| Cross-cutting concerns require middleware | Enhancers wrap the resolver directly |
| Type safety stops at the controller | Input/output schemas validate at runtime and infer TypeScript types |
Every route file imports Request/Response |
Zero framework imports in your domain code |
| Package | Description |
|---|---|
@capixjs/core |
Core framework — capability, createServer, guards, enhancers, event bus |
@capixjs/transport-rest |
HTTP/1.1 REST transport with automatic route inference |
@capixjs/transport-ws |
WebSocket transport for real-time capabilities and server push |
@capixjs/transport-graphql |
GraphQL transport with auto-generated schema and GraphiQL playground |
@capixjs/transport-queue |
Queue transport for background jobs — BullMQ and SQS adapters included, or bring any queue via QueueAdapter |
@capixjs/transport-mcp |
MCP transport — expose capabilities as Model Context Protocol tools |
@capixjs/store-redis |
Redis-backed cache, rate-limit stores, and cross-instance event bus |
@capixjs/plugin-auth |
JWT authentication — jwtContextBuilder, createJWTHelpers, mustBeAuthenticated |
@capixjs/plugin-cors |
CORS plugin |
@capixjs/plugin-helmet |
Security headers plugin |
@capixjs/plugin-logging |
Structured request logging via pino |
@capixjs/testing |
Test utilities — mockContext, testServer |
npm install @capixjs/core @capixjs/transport-rest zod@^4import { z } from 'zod';
import {
capability,
defineContext,
defineGuard,
defineError,
createServer,
} from '@capixjs/core';
import { restTransport } from '@capixjs/transport-rest';
// --- Errors ---
const Errors = {
NotFound: defineError(404, 'Not found'),
Forbidden: defineError(403, 'Forbidden'),
};
// --- Context ---
const buildContext = defineContext(async (req) => ({
requestId: crypto.randomUUID(),
user: await verifyToken(req.headers.authorization),
}));
// --- Guards ---
const mustBeLoggedIn = defineGuard((ctx) => {
if (!ctx.user) throw Errors.Forbidden();
});
// --- Capabilities ---
const getUser = capability(
z.object({ id: z.string() }),
async ({ id }) => {
const user = await db.users.find(id);
if (!user) throw Errors.NotFound();
return user;
},
).guard(mustBeLoggedIn);
const createUser = capability(
z.object({ name: z.string(), email: z.string().email() }),
async (input) => db.users.create(input),
);
// --- Server ---
createServer({
context: buildContext,
capabilities: { users: { getUser, createUser } },
transports: [restTransport({ port: 3000 })],
}).start();
// GET /users/:id ← getUser
// POST /users ← createUserEvery real Capix app has app-specific context (database connection, current user, logger). Use capability.withContext<YourContext>() to create a factory pre-typed for your context — define it once, import it everywhere:
// src/capabilities.ts — define once
import { capability } from '@capixjs/core';
import type { AppContext } from './context.js';
export const cap = capability.withContext<AppContext>();// src/capabilities/users/get.ts — import and use
import { cap } from '../../capabilities.js';
export const getUser = cap(
z.object({ id: z.string() }),
async ({ id }, ctx) => {
// ctx.user, ctx.db — all typed correctly, no annotation needed
const user = await ctx.db.users.findById(id);
if (!user) throw errors.NotFound();
return user;
},
'query',
).guard(mustBeUser);Without withContext, ctx is typed as BaseContext (only requestId).
Authenticated capabilities — create a second factory for capabilities that require a logged-in user:
// After mustBeUser runs, ctx.user is non-null.
// Use authCap to express this in the resolver's type.
type AuthContext = AppContext & { user: NonNullable<AppContext['user']> };
export const cap = capability.withContext<AppContext>(); // public endpoints
export const authCap = capability.withContext<AuthContext>(); // authenticated endpoints
// ctx.user is User (not User | null) — no null check needed
export const getProfile = authCap(z.object({}), async (_, ctx) => ctx.user, 'query')
.guard(mustBeUser);Note: TypeScript cannot retroactively narrow the resolver's
ctxfrom guards added via.guard(). TheauthCappattern pre-types the resolver instead. See docs/ts-workarounds.md for details.
A capability wraps a resolver function with optional input/output schemas:
// No schema — accepts any input
const ping = capability(() => 'pong');
// With input schema — validates and infers types automatically
const greet = capability(
z.object({ name: z.string() }),
({ name }) => `Hello, ${name}`,
);
// With output schema — validates resolver return value
const getMetrics = capability(
z.object({ window: z.enum(['1h', '24h']) }),
fetchMetrics,
).output(MetricsSchema);Capabilities are immutable. .guard() and .enhance() return new instances:
const adminOnly = greet.guard(mustBeAdmin); // new capability
const cached = getMetrics.enhance(withCache(60)); // new capabilityGuards run before the resolver and throw to reject the request:
const mustBeUser = defineGuard((ctx) => {
if (!ctx.user) throw Errors.Unauthorized();
// TypeScript narrows ctx.user as non-null after this point
});
// Multiple guards run in order; first failure stops execution
const cap = capability(schema, handler)
.guard(mustBeLoggedIn)
.guard(mustBeAdmin);Enhancers wrap the resolver for cross-cutting concerns:
import { withCache, withRateLimit, withCircuitBreaker, withTimeout, withMetrics } from '@capixjs/core';
const robustCap = capability(schema, handler)
.enhance(withCache(30)) // cache for 30 seconds
.enhance(withRateLimit({ max: 100, windowMs: 60_000 }))
.enhance(withCircuitBreaker({ threshold: 5, resetMs: 30_000 }))
.enhance(withTimeout(5000));The REST transport infers routes from capability names. No annotations required:
| Capability name | Route |
|---|---|
getUser |
GET /users/:id |
listUsers |
GET /users |
createUser |
POST /users |
updateUser |
PATCH /users/:id |
replaceUser |
PUT /users/:id |
deleteUser |
DELETE /users/:id |
uploadAvatar |
POST /users/uploadAvatar |
To override a route, pass overrides to restTransport — routing is a transport concern and does not belong in capability definitions:
restTransport({
port: 3000,
overrides: {
'tasks.listTasks': { method: 'GET', path: '/projects/:projectId/tasks' },
},
})Group related capabilities and context extensions into reusable plugins:
import { definePlugin } from '@capixjs/core';
const authPlugin = definePlugin({
capabilities: { users: { getUser, createUser } },
context: (base) => ({ ...base, isPlugin: true }),
});
createServer({
plugins: [authPlugin],
transports: [restTransport({ port: 3000 })],
}).start();For URLs like /projects/:projectId/tasks, pass overrides to restTransport — the inference engine handles flat groups but not hierarchies:
// src/capabilities/tasks/list.ts — no routing info here
const listTasks = capability(
z.object({
projectId: z.string(),
page: z.coerce.number().default(1),
status: z.enum(['todo', 'done']).optional(),
}),
async ({ projectId, page, status }, ctx) => {
return ctx.db.tasks.list({ projectId, page, status });
},
'query',
).guard(mustBeUser);// src/server.ts — routing lives here
restTransport({
port: 3000,
overrides: {
'tasks.listTasks': { method: 'GET', path: '/projects/:projectId/tasks' },
},
})The REST transport merges URL params, query string, and body into a single typed input object. See examples/nested-routes for a full working example.
By default, all capabilities are available on all transports. Pass capabilities directly to a transport to expose only a subset:
const publicAPI = { items: { list: listItems, get: getItem } };
const memberAPI = { items: { create: createItem, update: updateItem } };
const jobsOnly = { jobs: { processItem, generateReport } };
createServer({
context: buildContext,
transports: [
// REST and GraphQL expose public + member capabilities
restTransport({ port: 3000, capabilities: { ...publicAPI, ...memberAPI } }),
graphqlTransport({ port: 4000, capabilities: { ...publicAPI, ...memberAPI } }),
// Queue only processes background jobs — never gets an HTTP endpoint
queueTransport({ queues: ['jobs'], adapter, capabilities: jobsOnly }),
],
});Capabilities are plain objects — pass the same reference to multiple transports to share them without duplication.
Providing capabilities at the top level sets the default for all transports that don't specify their own:
createServer({
context: buildContext,
capabilities: publicAPI, // default for REST + GraphQL
transports: [
restTransport({ port: 3000 }), // uses publicAPI
graphqlTransport({ port: 4000 }), // uses publicAPI
queueTransport({
queues: ['jobs'],
adapter,
capabilities: jobsOnly, // overrides publicAPI for queue only
}),
],
});If every transport specifies its own capabilities, the top-level field can be omitted entirely. Capix throws at startup if a transport has no capabilities and no server-level default is provided.
The WebSocket transport is request/response. For server-push (broadcasting mutations to connected WS clients), use a module-level EventEmitter:
// src/events.ts — shared event bus
import { EventEmitter } from 'node:events';
export const taskEvents = new EventEmitter();
// src/capabilities/tasks/update.ts — emit after mutation
export const updateTask = authCap(Input, async ({ id, ...data }, ctx) => {
const task = await ctx.db.tasks.update(id, data);
taskEvents.emit('task.updated', { taskId: id, data: task });
return task;
}, 'update').guard(mustBeUser);See examples/realtime for the complete broadcast pattern.
Guard type narrowing applies to subsequent guards in the chain, but TypeScript cannot retroactively narrow the resolver's ctx parameter based on guards added via .guard(). Use capability.withContext<AuthContext>() as the workaround.
See docs/ts-workarounds.md for a full explanation, the two-factory pattern, and what a future fix would look like.
Capix's REST transport trails Fastify by ~3% in a hello-world microbenchmark and beats Hono by ~19%. Zod validation and the capability dispatch pipeline add ~270ns/request relative to a bare handler. See docs/benchmarks.md for the full results and methodology.
| Framework | req/s (hello world) | req/s (auth + guard) |
|---|---|---|
| Fastify | 27,659 | 25,813 |
| Capix | 26,240 | 24,194 |
| Hono | 22,910 | 20,332 |
| Express | 16,176 | 15,366 |
Measured on 0.1.0-beta.1 — see docs/benchmarks.md for methodology, caveats, and how to reproduce.
pnpm -r test → 627 tests, 0 failures
npm install -g @capixjs/cli| Command | Description |
|---|---|
capix new <name> |
Scaffold a new project |
capix dev |
Start dev server with file watching |
capix list |
List all registered capabilities |
capix docs |
Print capability docs as Markdown |
capix generate capability <group> <name> |
Generate a capability file |
capix client |
Generate a typed fetch client |
capix openapi |
Generate an OpenAPI 3.1 spec |
capix mcp |
Serve capabilities as MCP tools (stdio or HTTP) |
See docs/cli.md for all commands.
- Quick start
- Capabilities
- Guards
- Context
- Errors
- Enhancers
- Plugins
- Testing
- Transports overview
- REST transport
- WebSocket transport
- GraphQL transport
- Queue transport
- Patterns: auth
- Patterns: composition
- Patterns: real-time
- Patterns: multi-step mutations
- Migration from Express
- CLI reference
- API reference
| Example | Description |
|---|---|
examples/basic-rest |
CRUD API with REST transport |
examples/with-auth |
JWT auth, role-based guards |
examples/nested-routes |
Nested resource routes with http override |
examples/realtime |
EventEmitter broadcast pattern for WS push |
examples/file-upload |
Multipart file upload |
examples/pagination |
Query string coercion, filters, sorting |
examples/jwt-auth |
Full JWT auth flow |
examples/with-mcp |
Same capabilities served over REST and MCP at once |
@capixjs/testing provides helpers to test capabilities without a running server:
import { mockContext, testServer } from '@capixjs/testing';
// Unit test: invoke capability directly (no server needed)
const ctx = mockContext({ user: { id: '1', admin: true } });
const result = await getUser.resolve({ id: '1' }, ctx);
expect(result.name).toBe('Alice');
// Integration test: real execution engine, no HTTP server
const server = testServer({
context: buildContext,
capabilities: { users: { getUser } },
});
const response = await server.call({
capability: 'users.getUser',
input: { id: '1' },
headers: { authorization: 'Bearer test-token' },
});
expect(response.ok).toBe(true);
expect(response.status).toBe(200);pnpm install
pnpm -r build # build all packages
pnpm -r test # run all tests
pnpm -r typecheck # typecheck all packages# Run a specific package
cd packages/core
pnpm test
pnpm typecheckCapix 1.0.0 is out. The stability policy is now in its stable phase: the documented public API and wire contracts only change across a major version, and deprecations live for at least one minor release before removal. See docs/changelog.md for the full history from alpha through 1.0.
Found a bug or a gap? Open an issue.
MIT