Skip to content

Write a tool

A tool is a function with a typed input and a typed output. This one looks an order up by its id. The orders live in a map here; in your app, the handler calls your own code.

tools/lookup-order/index.ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
// Stands in for your app's database.
const ORDERS = new Map([
['ord_1001', { status: 'shipped', totalCents: 4200 }],
['ord_1002', { status: 'pending', totalCents: 1250 }],
]);
const defined = defineTool({
id: 'my-pack.lookup-order' as ToolId,
description: 'Looks up an order by id. Returns its status and total in cents.',
version: '0.1.0',
input: z.object({
orderId: z.string().regex(/^ord_\d+$/),
}),
output: z.object({
orderId: z.string(),
status: z.enum(['pending', 'shipped', 'delivered']),
totalCents: z.number().int(),
}),
effects: [],
mutating: false,
handler: async ({ orderId }, ctx) => {
const order = ORDERS.get(orderId);
if (order === undefined) throw new Error(`No order ${orderId}`);
console.log(`lookup-order ${orderId} for tenant ${ctx.tenantId}`);
return { orderId, status: order.status as 'pending' | 'shipped', totalCents: order.totalCents };
},
});
if (defined.kind === 'err') {
throw new Error(`my-pack.lookup-order failed to compile: ${defined.error.message}`);
}
export default defined.value;
  • input and output are Zod schemas (or JSON Schema). The handler gets the parsed input, with defaults and transforms applied.
  • defineTool returns a result instead of throwing. Unwrap it when the file loads, so a bad definition fails at once rather than on the first call.
  • ctx carries tenantId, runId, requestId and an abortSignal that fires when the call is cancelled.

In both languages:

  • The id is <pack-id>.<tool-name>, in kebab case.
  • The description is what a model reads to decide when to call the tool. Say what it does and what it returns.
  • mutating: false declares that the tool only reads. See Mark a tool read-only.
  • What the handler logs shows in the kindgi dev terminal, prefixed [pack].

Save the file. kindgi dev indexes it and registers the tool; the next call runs the new code.

A flow step can call any tool, which makes a one-step flow the quickest way to run one:

flows/check-order/index.ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'my-pack.check-order',
version: '0.1.0',
name: 'Check order',
description: 'Looks up one order.',
nodes: [
{
id: 'lookup',
kind: 'tool',
ref: 'my-pack.lookup-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
],
edges: [
{ id: 'e-start', from: '$start', to: 'lookup' },
{ id: 'e-end', from: 'lookup', to: '$end' },
],
});
if (defined.kind === 'err') {
throw new Error(`my-pack.check-order failed to compile: ${defined.error.message}`);
}
export default defined.value;
Terminal window
pnpm exec kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}'
{
"id": "1a9707b6-ae88-47b5-a457-eef7dea2d1fc",
…
"flowId": "my-pack.check-order",
"flowVersion": "0.1.0",
"status": "completed",
"dryRun": false,
…
"output": {
"status": "shipped",
"orderId": "ord_1001",
"totalCents": 4200
},
…
}

A flow without a declared output returns its last step's output. The kindgi dev terminal shows the handler's log line:

[pack] lookup-order ord_1001 for tenant bb22c936-5111-429a-bfce-09650af85492

Kindgi checks the input before your handler runs. An order id that doesn't match the pattern never reaches it:

Terminal window
kindgi runs start --flow=my-pack.check-order --input='{"orderId":"9999"}'
"status": "failed",
"failureMessage": "input-validation-failed: Input for tool \"my-pack.lookup-order\" failed validation",

An exception in the handler fails the call with its message:

Terminal window
kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_9999"}'
"status": "failed",
"failureMessage": "handler-error: Tool \"my-pack.lookup-order\" handler threw: handler-throw: Handler for tool \"my-pack.lookup-order\" threw: Error: No order ord_9999",

What the handler returns is checked against the output schema too; a return value that doesn't fit fails with output-validation-failed.

invokeTool calls a tool the way the runtime does: input checked, handler called, output checked. It returns a result rather than throwing:

tools/lookup-order/index.test.ts
import { invokeTool } from '@kindgi/sdk/define';
import type { TenantId } from '@kindgi/sdk/types';
import { expect, test } from 'vitest';
import lookupOrder from './index.js';
const ctx = {
tenantId: 'test-tenant' as TenantId,
abortSignal: new AbortController().signal,
};
test('finds a shipped order', async () => {
const result = await invokeTool(lookupOrder, { orderId: 'ord_1001' }, ctx);
expect(result).toEqual({
kind: 'ok',
value: { orderId: 'ord_1001', status: 'shipped', totalCents: 4200 },
});
});
test('rejects a malformed id before the handler runs', async () => {
const result = await invokeTool(lookupOrder, { orderId: '1001' }, ctx);
expect(result.kind === 'err' && result.error.code).toBe('input-validation-failed');
});
Terminal window
pnpm exec kindgi test
✓ tools/lookup-order/index.test.ts (2 tests) 16ms
…
Tests 4 passed (4)

An agent lists the tools it may call:

// in agents/order-desk/index.ts
tools: [{ id: 'my-pack.lookup-order' as ToolId, version: '^0.1.0' }],

The version is a range: a turn uses the highest registered version that matches it.

With a model that can call tools (Models), the agent's turn shows the call and its result. Here my-pack.order-desk, an agent with this tool, answers with Llama 3.1 on Ollama (registered as an OpenAI-compatible endpoint):

Terminal window
kindgi runs start --agent=my-pack.order-desk --input='{"userMessage":"What is the status of order ord_1002?"}'
"appended": [
{ "role": "user", "content": "What is the status of order ord_1002?", … },
{
"role": "agent",
"content": {
"text": "",
"toolCalls": [{ "id": "call_wzydfzdg", "name": "my-pack.lookup-order", "arguments": { "orderId": "ord_1002" } }]
},
…
},
{
"role": "tool",
"content": { "status": "pending", "orderId": "ord_1002", "totalCents": 1250 },
…
},
{
"role": "agent",
"content": "The status of order ord_1002 is pending, with a total amount of 12.50 dollars in cents.",
…
}
],

dev-echo, the stand-in a new pack answers with, always calls an agent's first tool with {"message": …}, so try a tool with any other input from a flow, as above.

Write an agent covers the rest of the agent file.