Tools

Publish a route as a tool a model can call, and read each call typed in your client.

Alpha

Tools are new. How a route publishes, how a call runs, and how a client reads one will likely change while v2 is in beta, so pin your version if you depend on it.

A tool is usually written three times: a JSON Schema for the model, a typed event for the client, and a function that runs it. A route already carries all three, so say tool: true and it publishes.

Say which routes publish

src/routes/weather.ts
import { z } from 'zod';
import { k } from '../k';

export const weatherRoutes = k.routes('weather', {
    getForecast: k
        .route({
            method: 'GET',
            path: '/forecast/:city',
            auth: false,
            pathParams: z.object({
                city: z.string().min(1),
            }),
            query: z.object({
                unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
            }),
            responses: {
                200: z.object({
                    temperature: z.number(),
                    unit: z.enum(['celsius', 'fahrenheit']),
                    summary: z.string(),
                }),
            },
            summary: 'Look up tomorrow forecast for one city',
            tool: true,
        })
        .handler(({ params, query }) => ({
            status: 200,
            body: forecastFor(params.city, query.unit),
        })),
});

tool: true takes the route's summary as what a model reads before calling, so a route that publishes has to carry one. Its input is the { params, query, body } the route already declares, and its output is the 200 response.

Say how it behaves

tool also takes an object. description, title and the four hints are MCP's own, field for field:

src/routes/workspace.ts
deleteWorkspace: k
    .route({
        method: 'DELETE',
        path: '/workspace',
        auth: { identity: 'member', requires: { workspace: ['delete'] } },
        summary: 'Delete the workspace, owner only',
        tool: {
            confirm: 'This deletes the workspace and everything in it. It cannot be undone.',
        },
        responses: {
            200: z.object({ ok: z.boolean() }),
        },
    })
    .handler(/* ... */),
FieldMeaning
descriptionWhat the model reads when it decides whether to call. Defaults to summary
titleA human-readable name for display. MCP's ToolAnnotations.title
readOnlyHintThe route only reads. Defaults to true for GET and HEAD
idempotentHintCalling twice does what calling once did. Defaults to true for PUT and DELETE
destructiveHintThe route may remove or overwrite. Defaults to true for DELETE
openWorldHintThe route reaches something outside this API
confirmWhat the person is asked before it runs. Kizuna's own, sent over MCP's elicitation

The hints come from the method's RFC 9110 semantics, so a GET is already marked read only and a DELETE already destructive. Set one to say what the method cannot.

confirm is the one field MCP has no way to declare. A client reads the hints and decides for itself whether to ask; confirm asks, and declining leaves the handler uncalled. It sits on the declaration rather than in the handler because the handler also answers HTTP, where there is nobody to ask.

Publish them

mcpPlugin serves the endpoint. Which routes it offers is each route's own business, so it takes nothing but its name:

kizuna.config.ts
export default defineConfig({
    adapter: expressAdapter(),
    routes,
    plugins: [
        mcpPlugin({
            name: 'My API',
        }),
    ],
});

A route that publishes is still an ordinary route. An HTTP caller reaches GET /forecast/Oslo, a model calls weather_get_forecast, and both run the same handler through the same validation, guards and Problem Details.

Put them on a stream

Naming tools on a streamed response adds three events, typed against the routes you name:

src/routes/assistant.ts
responses: {
    200: {
        stream: {
            delta: z.object({
                text: z.string(),
            }),
        },
        tools: weatherRoutes,
    },
},
EventPayload
tool_call{ id, name, input }
tool_result{ id, name, output }
tool_error{ id, name, message }

id ties a result back to its call, and input carries the { params, query, body } the route takes.

Driving the model

buildToolDefinitions turns the routes that publish into MCP's shape, so the same list that reaches a model over MCP reaches one you drive yourself:

src/routes/assistant.ts
import Anthropic from '@anthropic-ai/sdk';
import { buildToolDefinitions } from '@kizunajs/mcp';
import { weatherRoutes } from './weather';

const anthropic = new Anthropic();
const definitions = buildToolDefinitions(weatherRoutes);

reply: k
    .route({ /* ... */ })
    .handler(({ body }) => ({
        status: 200,
        body: async function* () {
            const stream = anthropic.messages.stream({
                model: 'claude-opus-5',
                max_tokens: 1024,
                messages: [{ role: 'user', content: body.prompt }],
                tools: definitions.map((tool) => ({
                    name: tool.name,
                    description: tool.description,
                    input_schema: tool.inputSchema,
                })),
            });

            for await (const event of stream) {
                if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
                    yield { event: 'delta', data: { text: event.delta.text } };
                }
            }
        },
    })),

Each definition carries the name a model calls it by, the description it reads, and inputSchema as JSON Schema. Running one is an ordinary call to the route's own handler, which you import like any other function.

Reading the calls

A call arrives as one event and its answer as another. readToolCalls folds them back into one row per call, carrying the tool's name, a state of running, done or failed, and the payloads narrowed to that tool.

TypeScript imports it from kizunajs; the Swift and Kotlin generators emit it beside the route's Event type.

streamOptions from @kizunajs/tanstack-query already holds the messages received so far, so there is no state to keep at all.

components/Chat.tsx
import { useQuery } from '@tanstack/react-query';
import { readToolCalls } from 'kizunajs';
import { api } from '../api';

export function Chat({ prompt }: { prompt: string }) {
    const { data } = useQuery(
        api.assistant.reply.streamOptions({
            input: {
                body: {
                    prompt,
                },
            },
        })
    );

    const messages = data ?? [];

    return (
        <article>
            {readToolCalls(messages).map((call) => (
                <ToolCallView key={call.id} call={call} />
            ))}
            <p>{messages.flatMap((message) => (message.event === 'delta' ? [message.data.text] : [])).join('')}</p>
        </article>
    );
}
components/ToolCallView.tsx
import type { ToolCallRecord } from 'kizunajs';

type Call = ToolCallRecord<AssistantMessage>;

export function ToolCallView({ call }: { call: Call }) {
    switch (call.name) {
        case 'charts.plotSignups':
            return call.state === 'done' ? <SignupChart points={call.output.points} /> : <ChartSkeleton />;
        default:
            return <ToolPill name={call.name} state={call.state} />;
    }
}
ChatModel.swift
@Observable
@MainActor
final class ChatModel {
    var messages: [AssistantReply.Event] = []

    func send(_ prompt: String) async throws {
        messages = []
        let result = try await client.assistantReply(body: .init(prompt: prompt))

        for try await event in result.body {
            messages.append(event)
        }
    }
}
ChatView.swift
import APIClient

struct ChatView: View {
    @State private var model = ChatModel()

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            ForEach(APIClient.AssistantReply.readToolCalls(model.messages)) { call in
                ToolCallView(call: call)
            }
            Text(model.messages.text)
        }
    }
}
ToolCallView.swift
struct ToolCallView: View {
    let call: ToolCallRecord

    var body: some View {
        switch call.name {
        case "charts.plotSignups":
            SignupChart(points: call.points)
        default:
            ToolPill(name: call.name, state: call.state)
        }
    }
}
ChatViewModel.kt
class ChatViewModel(private val client: APIClient) : ViewModel() {
    private val _messages = MutableStateFlow(emptyList<Event>())
    val messages: StateFlow<List<Event>> = _messages.asStateFlow()

    fun send(prompt: String) = viewModelScope.launch {
        _messages.value = emptyList()
        client.assistantReply(AssistantReplyBody(prompt)).body.collect { event ->
            _messages.update { it + event }
        }
    }
}
ChatScreen.kt
import com.kizuna.demo.APIClient

@Composable
fun ChatScreen(model: ChatViewModel = viewModel()) {
    val messages by model.messages.collectAsStateWithLifecycle()

    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        APIClient.AssistantReply.readToolCalls(messages).forEach { call -> ToolCallView(call) }
        Text(messages.text())
    }
}
ToolCallView.kt
@Composable
fun ToolCallView(call: ToolCallRecord) {
    when (call.name) {
        "charts.plotSignups" -> SignupChart(call.points)
        else -> ToolPill(call.name, call.state)
    }
}

Reaching one tool payload

Narrow on the tool name where you want its own data. The generated types carry the payload, so there is no cast.

if (message.event === 'tool_result') {
    switch (message.data.name) {
        case 'charts.plotSignups':
            // { points: Array<{ date: string; signups: number }> }
            points = message.data.output.points;
            break;
        case 'weather.getForecast':
            // { temperature: number; unit: 'celsius' | 'fahrenheit'; summary: string }
            temperature = message.data.output.temperature;
            break;
        case 'countWords':
            // { words: number }
            words = message.data.output.words;
            break;
    }
}
case .tool_result(let result):
    switch result {
    case .charts_plotSignups(let plotted):
        points = plotted.output.points
    case .weather_getForecast(let forecast):
        temperature = forecast.output.temperature
    case .countWords(let counted):
        words = counted.output.words
    }
is Event.ToolResult -> when (val result = event.data) {
    is ToolResult.Charts_plotSignups -> points = result.value.output.points
    is ToolResult.Weather_getForecast -> temperature = result.value.output.temperature
    is ToolResult.CountWords -> words = result.value.output.words
}

Rename a field on the route and every one of these stops compiling.

Publishing over MCP

A route that declares tool already has a name, a description, an input schema, an output schema and a handler, which is what an MCP tool is. mcpPlugin serves them:

kizuna.config.ts
import { mcpPlugin } from '@kizunajs/mcp';
import { routes } from './src/routes';

export default defineConfig({
    adapter: expressAdapter(),
    routes,
    plugins: [
        mcpPlugin({
            name: 'My API',
        }),
    ],
});

A route names itself users_get_user over MCP, from its dotted key. If it needs an identity, the same guard runs on a tool call as on an HTTP request, and a refusal reaches the model as an execution error.

See MCP for the rest of the endpoint, including OAuth.

How it works underneath

Nothing here is a new transport. Every piece reduces to something Kizuna already had.

The events are folded into the stream at k.routes time. tools on a response is authoring sugar. Before a route is validated, expandStreamTools turns each route you named into three Zod schemas and merges them into the response's stream record, then deletes the tools field. From that point on the response is an ordinary named-event stream, which is why the four adapters, the OpenAPI generator, the fetch client, and the Swift and Kotlin generators needed no changes to carry tools.

Each event is a discriminated union on the route's dotted key. tool_call is z.discriminatedUnion('name', [...]) with one arm per route, each carrying that route's own input. The type-level side walks the same tree to the same dotted keys, so message.data.input narrows in TypeScript, the Swift generator emits an enum with one case per tool, and the Kotlin generator emits a sealed interface.

The dotted key is the discriminator, not the published name. weather.getForecast on the wire, weather_get_forecast over MCP. Deriving the snake-case name at the type level would need a string algorithm that has to agree exactly with the runtime one, and disagreeing silently would be worse than reading the name off a definition.

Execution is the route's own pipeline. A tool call runs the route handler through the same validation, guards and Problem Details an HTTP request does, so there is one place a route runs whoever asked.

What Kizuna leaves out

  • Models. Kizuna never calls one, holds a conversation, or runs an agent loop. It validates input, runs a handler, validates output. The loop lives in your route handler, the way a job's transport lives outside kizuna.
  • Provider wire shapes. There is no toAnthropicTools or toOpenAITools, because those track a vendor changelog. buildToolDefinitions hands you MCP's shape and the .map to a provider is a few lines you can read.
  • Progressive tool input. A model streams a tool's arguments as partial JSON, which has no schema until it is whole. A half-parsed { city: "Os" } would type-check as a finished value while being wrong, so Kizuna emits tool_call once the arguments are complete.

A note on standards

The declaration follows the Model Context Protocol Tool object, and its schemas follow JSON Schema 2020-12. The three event names are Kizuna's own, since no standard covers streamed tool calls. See standards.

On this page