Getting started
Getting started
Section titled “Getting started”Walk through a minimal server and client. For split-file layout see Server layout. Single-file copy-paste: Complete example.
For coding agents, use SKILL.md.
1. Install backend dependencies
Section titled “1. Install backend dependencies”npm i callspec runtyp expressnpm i -D tsx typescript @types/expressRequirements: Node.js 18+ (runtime), TypeScript 5+, Express 4.x (peer). Contributing to this repo (npm run validate, docs site) needs Node ≥22.12 — see Development.
2. Define a route
Section titled “2. Define a route”// server/routes/getProductById.tsimport {route, err} from 'callspec';import {predicates as p} from 'runtyp';
const product = p.object({ id: p.string(), name: p.string(), priceCents: p.number(),});
const products = [ {id: 'sku-1', name: 'Widget', priceCents: 999}, {id: 'sku-2', name: 'Gadget', priceCents: 1299},];
export const getProductById = route({ input: p.object({id: p.string()}), output: product, meta: {summary: 'Get product by ID', tags: ['catalog']}, auth: 'none', mcp: true, resolver: async (input, _ctx) => { // input validated and fully typed — return and errors too! 🎉 const found = products.find((item) => item.id === input.id); if (!found) return err.NOT_FOUND(); return found; },});Quick notes:
- Return failures from resolvers (ie,
return err.NOT_FOUND()) — don’t throw exceptions. See Error handling. - Built-in error responses such as
NOT_FOUNDandSERVICE_UNAVAILABLEcan be returned from any route without defining them. - Define custom domain errors with
errors:on the route. - Test resolver logic with
.resolver(input, ctx)— no HTTP. See Unit testing.
3. Define and mount backend API
Section titled “3. Define and mount backend API”// server/routes.ts + server/index.tsimport {spec} from 'callspec';import {mountSpec} from 'callspec';import express from 'express';import {getProductById} from './routes/getProductById';
export const api = spec({ meta: {title: 'My API', version: '1.0.0'}, routes: {getProductById},});
const app = express();const router = express.Router();router.use(express.json());mountSpec(router, api);app.use('/v1', router);
const port = 3000;app.listen(port, () => { console.log(`RPC: http://127.0.0.1:${port}/v1/getProductById`); console.log(`Docs: http://127.0.0.1:${port}/v1/docs`); console.log(`Callspec: http://127.0.0.1:${port}/v1/callspec.json`); console.log(`OpenAPI: http://127.0.0.1:${port}/v1/openapi.json`); console.log(`MCP: http://127.0.0.1:${port}/v1/mcp`);});npx tsx server/index.tsOpen http://127.0.0.1:3000/v1/docs.
4. Generate the SDK
Section titled “4. Generate the SDK”# Live mount (server running) — pass the mount point; callspec.json is appendednpx callspec http://127.0.0.1:3000/v1 --output src/generated/api.ts
# From file — pin the contract from the server, then codegen offlinecurl -fsS http://127.0.0.1:3000/v1/callspec.json -o callspec.jsonnpx callspec ./callspec.json --output src/generated/api.tsSee SDK generation for CI and --validators. Pinning the contract: SDK generation § Pinning callspec.json for CI.
5. Call from your app
Section titled “5. Call from your app”Each method returns a Result — check result.ok, then branch on result.code. That union is fully exhaustive (every domain, builtin, and client error for the route); TypeScript catches a missing switch case. Types are inferred; import GetProductByIdOutput etc. only when you need them (props, shared helpers).
import {ApiClient} from './generated/api';import {toast} from './toast'; // sonner, react-hot-toast, whatever you use
const api = new ApiClient({baseUrl: 'http://127.0.0.1:3000/v1'});
const id = 'sku-1';const result = await api.getProductById({id});
if (!result.ok) { if (result.code === 'NOT_FOUND') { toast.error(`Unknown sku ${id}`); return; } if (result.code === 'NETWORK_ERROR') { toast.error('Check your connection and try again'); return; } toast.error('Something went wrong'); return;}
result.value.name; // stringresult.value.priceCents; // numberSee Client usage for auth headers, app helpers, and React patterns.