Get started

Build fully typed REST APIs with TypeScript

Write one contract. Get a fully typed server, an OpenAPI spec, Swift and Kotlin clients, and more.

Beta

Battle-tested in production. The syntax may still change before v2, so pin your version.Battle-tested in prod.

Read the FAQ
OpenAPI generation

Generate a complete OpenAPI spec from the contract you already wrote.

Native client generation

Typed API clients for Swift (iOS/macOS) and Kotlin (Android/JVM).

MCP server generation

Expose your API as MCP tools so AI assistants can call your endpoints.

TanStack Query

Typed query and mutation options with caching and invalidation.

Adapters

Mount your API on Express, Fastify, Hono, or Next.js.

Typed authentication

Identities, roles, and per-route access control declared on the contract.

Streaming

Stream an AI reply as typed events. Yield them from the handler, read them with for await in the client.

Tools

Declare the tools an AI assistant can call. Run them on the server, and read each call typed in your client.

Plugins

Extend your API with features built on the contract you already wrote, fully typed in your handlers.

Request context

Declare request-scoped values once, and every handler receives them typed, resolved once per request.

Scheduled jobs

Declare cron work next to its handler and tick it from any platform scheduler, or run it in process.

Caching

Declare a cache policy on a response and every adapter sends Cache-Control and Vary.

RPC-like client

Call your API like a function. Every route is a method with typed inputs and a response typed by status code.

Deprecation and sunset

Deprecate routes and fields, and it shows up in your editor, OpenAPI, Swift, Kotlin, and the response headers.

Spec-driven everything

HTTP, OpenAPI, OAuth, and MCP: every status code, error body, and header sits where the spec says it should.

The idea

One contract is the source of truth. Your server, your clients, and every generated artifact read from it.

contract.ts
export const k = new Kizuna();const UserSchema = Kizuna.model({ // shows up as a named User in OpenAPI, Swift, and Kotlin  title: 'User',  schema: z.object({    id: z.string(),    name: z.string(),  }),});const users = k.routes({  getUser: {    method: 'GET',    path: '/users/:id',    responses: {      200: UserSchema,      404: ProblemDetailsSchema, // or ProblemDetailsSchema.extend({ ... }) to add extra fields    },  },});export const contract = k.contract({  routes: {    users,  },});
Server
Validated inputs, type-checked responses
router.ts
server.router({  users: {    getUser: async ({ params, throwError }) => {      const user = await db.users.findById(params.id);      if (!user) throwError({        status: 404,        body: {          detail: 'Not found',        },      });      return {        status: 200,        body: user,      };    },  },});
OpenAPI
Generated from the contract
openapi.yaml
/users/{id}:  get:    operationId: getUser    parameters:      - name: id        in: path        required: true        schema:          type: string    responses:      '200':        content:          application/json:            schema:              $ref: '#/components/schemas/User'      '404':        content:          application/problem+json:            schema:              $ref: '#/components/schemas/ProblemDetails'
REST
Every route is a real REST endpoint
GET
localhost:3000/users/1
HTTP/1.1 200 OKContent-Type: application/json{  "id": "1",  "name": "Ada"}HTTP/1.1 404 Not FoundContent-Type: application/problem+json{  "type": "about:blank",  "status": 404,  "detail": "Not found"}
TypeScript client
Call your API like a function
api-client.ts
const apiClient = new KizunaClient(contract, {  baseUrl: 'http://localhost:3000',});const res = await apiClient.users.getUser({  params: {    id: '1',  },});if (res.status === 200) {  res.body; // User, fully typed} else {  throw new Error(res.body.detail);}
TanStack Query client
Query and mutation options, keys included
user-list.tsx
const api = new KizunaTanstackQuery(contract, apiClient);const { data } = useQuery(  api.users.getUser.queryOptions({    input: {      params: {        id: '1',      },    },  }));// invalidate every users queryqueryClient.invalidateQueries({  queryKey: api.users.key(),});
Swift client
A native client for iOS and macOS
UserService.swift
let client = APIClient(  baseURL: URL(string: "http://localhost:3000")!)do {  let res = try await client.users.getUser(    .params(      id: "1"    )  )  res.body // User, Codable} catch {  error // typed failure (e.g. .notFound)}
Kotlin client
A native client for Android and the JVM
APIClient.kt
val client = APIClient(  baseUrl = "http://localhost:3000")try {  val res = client.users.getUser {    params(      id = "1"    )  }  res.body // User, @Serializable} catch (error: APIClient.UsersGetUser.Failure.NotFound) {  error.body.detail // typed failure}
MCP server
Your routes as tools for AI agents
Claude
>Clean up the test users from yesterday's demo
users_list_users(name: "Test")read-only
200 OK [{ "id": "51", "name": "Test User" }]
users_delete_user(id: "51")destructive
Do you want to proceed?
❯ 1. Yes
2. Yes, and don't ask again this session
3. No
users_delete_user(id: "51")
204 No Content
Found one test user and deleted it.
Built-in validation
Every request checked against the contract
POST
localhost:3000/users
{  "name": "Ada",  "email": "nope"}HTTP/1.1 400 Bad RequestContent-Type: application/problem+json{  "status": 400,  "errors": [    { "code": "invalid_string_format",      "path": ["email"],      "message": "Invalid email" }  ]}
Deprecation and sunset
Phase out routes from the contract
routes.ts
searchUsers: {  deprecated: {    message: 'use listUsers instead',    date: '2026-03-01',  },  sunset: '2027-01-01',  ...}// Editor strikethrough// OpenAPI deprecated: true// Swift @available(*, deprecated, message: "use listUsers instead")// Kotlin @Deprecated("use listUsers instead")// HTTP Deprecation: @1772323200// HTTP Sunset: Fri, 01 Jan 2027 00:00:00 GMT
Streaming
Typed events sent as they happen
router.ts
reply: async ({ body }) => ({  status: 200,  body: async function* ({ signal }) {    const stream = anthropic.messages.stream(      {        model: 'claude-opus-5',        max_tokens: 64000,        messages: [          {            role: 'user',            content: body.prompt,          },        ],      },      { signal }    );    for await (const text of textDeltas(stream)) {      yield { event: 'delta', data: { text } };    }    const { usage } = await stream.finalMessage();    yield { event: 'done', data: { outputTokens: usage.output_tokens } };  },}),
Caching
Cache headers declared on the response
routes.ts
listEvents: {  method: 'GET',  path: '/events',  responses: {    200: {      body: z.array(EventSchema),      cache: {        scope: 'public',        sharedMaxAge: 600, // ten minutes on the CDN        staleWhileRevalidate: 60,      },    },    404: {      body: ProblemDetailsSchema,      cache: {        scope: 'public',        maxAge: 60, // misses are cached too      },    },  },}// Cache-Control: public, s-maxage=600, stale-while-revalidate=60

Inside a handler

Whatever the contract declares, the handler gets it validated and typed.

Typed from the path string itself. Rename a param and every handler that reads it fails to compile.

reports.router.ts
getReport: async ({ params }) => {    const report = await db.reports.findFirst({        where: {            month: params.month,            year: params.

Runs anywhere

The same contract and router move between adapters, and the framework underneath stays available to you.

Express

req, res

Fastify

request, reply

Hono

c, c.env

Next.js

request

Every handler gets params, query, body, and headers validated the same way, and each adapter hands you its own primitives on top.

For Cloudflare Workers, Deno, or Bun, use the Hono adapter.

Start here

Ready to build?

8 minutes from an empty file to a typed client calling a real endpoint.