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 a run it also carries projectId, the run's project, and orgId, the project's org when it has one. The runtime sets both from the run, never from the input, so compare an id in the input with them rather than trusting it.

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].
  • Open files, databases and connections inside the handler, on first use, not at the module's top level. kindgi build imports every tool, guardrail and flow module to index it, so what a module does when it's imported runs during the build too, inside an image that doesn't have your app's files or services (Build, sign and push your 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

ctx.log writes a record bound to the call: it carries the run's ids, the tool's id and the runtime's trace id, so it can be found beside the run's own records (Logs). Fields go in as an object, never in the message:

// in tools/lookup-order/index.ts, in the handler
ctx.log?.info('looked up order', { orderId, status: order.status });

The pack service sets ctx.log. A context your own code builds may not have one, so write ctx.log?..

Under kindgi dev, the record shows as a [pack] line. Here from a Java pack:

[pack] 12:39:41.533 INFO [pack.tool] looked up order traceId=877833b3f86d26ce95c94bd7b7bea72f spanId=cfe974bcb4284c35 requestId=669b77fc-7ae6-4c48-8002-ebfca944e287 tenantId=cac037c9-c5ff-4f0a-ae85-7a0e5e324bc3 projectId=12a6cc0f-83d8-4739-8223-5f99210c73f0 runId=669b77fc-7ae6-4c48-8002-ebfca944e287 toolId=acme.lookup-order packId=acme artifactVersion=20261009.1 target=tool orderId=o-1001

The record's subsystem is pack.tool, and its level is the method's: info, warn, error, debug or trace. Values under secret-looking keys are redacted, but don't log a secret. Printing the context itself (console.log(ctx), print(ctx)) never shows its secrets.

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.

A run's step can run more than once: resumed after an approval, retried after a failure, or run again when the runtime restarted while it ran. A tool that changes something (a refund, an email, a payment) would then do it twice.

ctx.idempotencyKey is the same every time the same call runs, and different for every other call. Pass it to the system you write to: one that takes an idempotency key keeps the first request and answers a repeat with its result.

// in tools/issue-refund/index.ts, in the handler
const res = await fetch('https://payments.acme.example/refunds', {
method: 'POST',
headers: {
'content-type': 'application/json',
...(ctx.idempotencyKey !== undefined && { 'Idempotency-Key': ctx.idempotencyKey }),
},
body: JSON.stringify({ orderId: input.orderId, amount: input.amount }),
});

When the system has no such key, keep it with what you wrote (in a column that must be unique) and look for it before you write. The key is a UUID.

  • requestId won't do. A model's call id is only unique within one of its answers: two turns of a loop can share one.
  • It can be absent: outside a run (a unit test passes its own), and with a runtime from before 0.1.6. The call can't be deduped on it then.

When your app keeps several customers' data, give each customer its own Kindgi org and project, and start that customer's runs there. A tool then checks what it reads against the run's org, ctx.orgId, which the runtime sets from the run. An org id in the input is something a model or a caller wrote, so it proves nothing.

// in tools/lookup-order/index.ts
// Stands in for your app's database. Each order belongs to one customer's org.
const ORDERS = new Map([
['ord_1001', { orgId: '1154e250-2a07-491e-b00a-3675b411c411', status: 'shipped', totalCents: 4200 }],
['ord_1002', { orgId: 'feb005e6-6798-47eb-a87a-8feed584d37b', status: 'pending', totalCents: 1250 }],
]);
// …
handler: async ({ orderId }, ctx) => {
const order = ORDERS.get(orderId);
// Another org's order is as missing as one that doesn't exist.
if (order === undefined || order.orgId !== ctx.orgId) throw new Error(`No order ${orderId}`);
return { orderId, status: order.status as 'pending' | 'shipped', totalCents: order.totalCents };
},

A run in the first org's project finds ord_1001. Asking it for the second org's order fails as if the order didn't exist:

"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_1002",

A project with no org has no orgId (None in Python), so in it this tool finds no order at all. Start a run in a project with projectId on runs.start.

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.