Config

Assemble your API in kizuna.config.ts, mount it, and say where its generated clients go.

kizuna.config.ts sits at the root of your app, beside package.json. It assembles everything the API is made of, hands back the api you mount, and says where each generated client is written. The tooling looks for it by name.

The file

kizuna.config.ts
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { swiftClient } from '@kizunajs/swift';
import { routes } from './src/routes';
import { user } from './src/identities';

export default defineConfig({
    adapter: expressAdapter(),
    routes,
    auth: {
        identities: {
            user,
        },
    },
    clients: [
        swiftClient({
            output: './ios/MyApp/Generated/APIClient.swift',
            namespace: 'API',
        }),
    ],
});

It default-exports two things: api, which you mount and which every generator reads, and clients, which the CLI writes.

src/index.ts
import kizuna from '../kizuna.config';

kizuna.api.mount(app);

adapter decides what every handler receives beside its validated inputs. Express here puts req and res in scope for all of them, typed.

See defineConfig for every field it takes.

What each key brings

KeyWhat it brings
authThe identities a route's auth can require. Each carries its own guard, so this is all the config needs. An identity used without a guard refuses to assemble. See Authentication.
requestContextValues every handler receives, resolved once per request. Resolvers run on every route, before the guards, and never deny. See Request Context.
jobsScheduled work. api.mount serves the two job endpoints unless jobRunner.mode is 'in-process', and every handler receives a typed jobs runner so a route can run one in process.
pluginsRoutes, jobs and helpers installed beside your own, as a list. See below, and Create a Plugin to write one.
validationThe code values k.issue may emit beyond Zod's own.
groupsThe groups routes are filed under, each an OpenAPI tag. See k.groups.

Using what a plugin offers

A plugin can hand your handlers functions to call, under plugins:

src/routes/users.ts
updateUser: k
    .route({
        method: 'PATCH',
        path: '/users/:id',
        body: UpdateUserSchema,
        responses: {
            200: UserSchema,
        },
    })
    .handler(async ({ params, body, plugins }) => {
        const user = await db.users.update(params.id, body);
        await plugins.email.send({
            to: user.email,
            subject: 'Your profile was updated',
            html: '<p>Your profile was updated.</p>',
        });

        return {
            status: 200,
            body: user,
        };
    }),

Destructure it for the short form:

.handler(async ({ params, body, plugins: { email } }) => {

Job handlers receive plugins the same way.

Renaming one

Each plugin picks the slug handlers reach it under. When two want the same one, defineConfig throws and names both, and you rename one:

kizuna.config.ts
plugins: [
    pushNotificationsPlugin(),
    webPushPlugin({
        slug: 'webPush',
    }),
],

Middleware

Rate limiting, multipart parsing, and the rest go in your framework's own middleware (app.use, Fastify hooks). Authentication belongs in guards, request-scoped values in requestContext.

Clients

Each client comes from the package that generates it, so its options are typed there and a generator you have not installed cannot be configured by accident. The list is not closed: any package that exports a client built with defineClient drops into clients the same way.

ClientImport fromWrites
fetchClient@kizunajs/fetchA typed TypeScript client
swiftClient@kizunajs/swiftA native Swift client
kotlinClient@kizunajs/kotlinA native Kotlin client
kizuna.config.ts
clients: [
    fetchClient({
        output: './web/src/api.generated.ts',
    }),
    swiftClient({
        output: '../ios/MyApp/Generated/APIClient.swift',
    }),
],

Every route reaches every client. A route no app calls, like a health check or a webhook, is hidden and stays out of them.

Files that aren't clients, like the OpenAPI document, come from plugins. openApiPlugin writes openapi.yaml when you give it an output.

The generated client holds the route shapes and the schemas, and is what a browser imports. An output is resolved against the config's own directory, so in a repository holding both, ../ios/MyApp/Generated/APIClient.swift writes into the app beside it.

Client options

OptionApplies toDescription
outputallPath the generated file is written to
namespaceFetch, Swift, KotlinName of the generated namespace, API by default
packageKotlinPackage the generated file declares
camelCasePropertiesSwift, KotlinConvert wire field names to camelCase, mapping the wire name back
unknownEnumCaseSwift, KotlinGive every enum an unknown case, so an unrecognised value still decodes

Two clients of one language

Add two entries.

kizuna.config.ts
clients: [
    swiftClient({
        output: './ios/MyApp/Generated/APIClient.swift',
        namespace: 'API',
    }),
    swiftClient({
        output: './ios/MyApp/Generated/OpenEnumAPIClient.swift',
        namespace: 'OpenEnumAPI',
        unknownEnumCase: true,
    }),
],

More than one API

One config is one API. A second config is a second API, with its own routes, its own identities and its own generated clients.

When to split

Split when the two have different callers. A public API versioned for outside integrators and an internal admin API answer to different people, change on different schedules, and want different clients, so one OpenAPI document describing both serves neither.

Split when they are deployed apart. A config has one adapter, so an API on Express and an API on Next.js are already two configs.

Split when the surfaces should not mix. Everything in one config shares a namespace: every route reaches every plugin's exports, one guardSchema shapes every refusal, and every route lands in the same generated client. Two configs keep those apart.

Stay with one config when the routes differ only by prefix. A /admin group under the same identities is a route group, not a second API, and splitting it costs you the shared types.

Two configs

Give each one a file, and each its own clients:

my-repo/
├── kizuna.config.ts              # the public API
├── kizuna.admin.config.ts        # the admin API
└── src/
    ├── k.ts                      # typed by the public Config
    ├── admin/
    │   └── k.ts                  # typed by the admin Config
    └── routes/
kizuna.admin.config.ts
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { fetchClient } from '@kizunajs/fetch';
import { operator } from './src/admin/identities';
import { admin } from './src/admin/routes';

export default defineConfig({
    adapter: expressAdapter(),
    routes: [admin],
    auth: {
        identities: {
            operator,
        },
    },
    typescript: {
        outputFile: './src/admin/kizuna.types.ts',
    },
    clients: [
        fetchClient({
            output: './src/admin/api-client.generated.ts',
        }),
    ],
});

Each config writes its own Config, so each gets its own k:

src/admin/k.ts
import { Kizuna } from 'kizunajs';
import type { Config } from './kizuna.types';

export const k = new Kizuna<Config>();

A route declared against the admin k sees the admin identities and nothing else, so a public identity on an admin route is a type error rather than a runtime surprise.

Running them

kizuna generate reads one config, so point it at each in turn:

package.json
{
    "scripts": {
        "kizuna:generate": "kizuna generate && kizuna generate --config kizuna.admin.config.ts"
    }
}

Each config declares its own clients, so the Swift and Kotlin clients for the admin API come out of kizuna.admin.config.ts along with its types.

Sharing between them

What both APIs need lives in a module they both import: schemas, models, an identity, or a whole route group. Kizuna.model keeps a shared schema's name, so User is one named type in both generated clients rather than two shapes that happen to match.

src/schemas.ts
export const UserSchema = Kizuna.model({
    title: 'User',
    schema: z.object({
        id: z.string(),
        name: z.string(),
    }),
});

Serving both from one process is ordinary mounting. On Express, mount takes anything with a use, so a router mounted under a path keeps the admin API off the public one:

src/index.ts
import express from 'express';
import publicApi from '../kizuna.config';
import adminApi from '../kizuna.admin.config';

const app = express();

publicApi.api.mount(app);

const adminRouter = express.Router();
adminApi.api.mount(adminRouter);
app.use('/admin', adminRouter);

On Next.js each API is its own catch-all route file, and mount({ basePath }) is what tells it which prefix it answers on.

kizuna generate

pnpm add -D @kizunajs/cli@beta
Terminal
kizuna generate

It writes four things from kizuna.config.ts: the Config in kizuna.types.ts, every client under clients, the files plugins generate, like openApiPlugin's openapi.yaml, and the .kizuna/snapshot.yaml that kizuna diff compares. A file that already matches is left alone, so watchers and build tools see no change.

OptionDefaultDescription
--checkoffReport what would be rewritten and write nothing. See below
--configkizuna.config.tsThe config to read
--typesthe config's ownWhere the Config is written, overriding typescript.outputFile

Where the types land is usually the config's business rather than the command's:

kizuna.config.ts
export default defineConfig({
    adapter: expressAdapter(),
    routes,
    typescript: {
        outputFile: './src/kizuna.types.ts',
    },
});

Wire it into your scripts

Two scripts cover everywhere it needs to run:

package.json
{
    "scripts": {
        "kizuna:generate": "kizuna generate",
        "kizuna:check": "kizuna generate --check"
    }
}

Run kizuna:generate after changing a route, an identity, or the config. Run kizuna:check in CI, where it fails on anything nobody regenerated.

Whoever changes your routes needs to know about that first script, and a coding agent working in the repository reads the same files your team does. Say it where they look:

AGENTS.md
After changing a route, run `pnpm kizuna:generate`.

kizuna.types.ts

The Config interface is read from your config's syntax, not by running it. That is what lets k be typed by the config that serves your routes without the routes waiting on a config that loads.

kizuna.types.ts
/**
 * This file was automatically generated by kizuna.
 * DO NOT MODIFY IT BY HAND. Instead, modify your source kizuna config,
 * and re-run `kizuna generate` to regenerate this file.
 */
import type { expressAdapter } from '@kizunajs/express';
import type { groups, user } from '@my-repo/api';

export interface Config {
    adapter: ReturnType<typeof expressAdapter>;
    groups: typeof groups;
    auth: {
        identities: {
            user: typeof user;
        };
    };
}

It carries adapter, groups, auth, requestContext, validation, jobs and plugins. Your routes stay out: they are read at runtime, and typing against them would make the config a prerequisite for declaring the routes it serves.

Point new Kizuna<Config>() at it once and every name you write is checked against it:

src/k.ts
import { Kizuna } from 'kizunajs';
import type { Config } from '../kizuna.types';

export const k = new Kizuna<Config>();

kizuna generate --check

Terminal
kizuna generate --check

The same work without writing. It names whatever has fallen behind and exits 1, so a pipeline fails on a file nobody regenerated:

2 generated files are out of date:
  src/lib/api-client.generated.ts is behind the config
  .kizuna/snapshot.yaml is behind the config

Run `kizuna generate` and commit the result.

Commit what kizuna generate writes. The check compares the files on disk against the config, so a generated client that is gitignored reads as missing on every clean checkout and the pipeline fails on it.

Run it in CI, where the CLI page has the workflow. kizuna diff reads the snapshot this writes, so the two belong in the same pipeline: the check proves the snapshot matches today's config, and the diff compares it against the base.

writeClients and checkClients from @kizunajs/cli are the same two steps as functions, for a script that does more than the command does.

Reference