Skip to content

Write a tool

View as Markdown

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.

Ask your coding agent: "I want a tool that looks up an order by its id." Kindgi's skills in your pack tell it how (How it knows Kindgi). For reference, the tool looks like this. This guide calls it my-pack.lookup-order:

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, or passes its deadline (Work that takes longer than a call). 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 every language:

  • 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 (.mutating(false) in Java, .readOnly in Scala) declares that the tool only reads. See Mark a tool read-only.
  • A pattern (@Pattern in Java and Scala) is read as JavaScript's RegExp reads it, in the runtime. In Python, Java and Scala, the pack checks data against it with its own regular expressions too, so it must be one both read, as ^ord_\d+$ is:
    • In Python, one JavaScript doesn't read, such as (?P<name>.), is refused when the tool is defined. A schema given as a dict has its data checked with Python's re, so one re doesn't read, such as \p{L}, is refused then too. A pydantic model's Field(pattern=...) is checked by pydantic, which reads \p{L}. One pydantic doesn't read, such as a\Z, fails when the model class is created.
    • In Java and Scala, one JavaScript or Java doesn't read, such as [^], which Java doesn't, is refused when the pack is indexed.
  • What the handler prints 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 (in a static field or an object's val, in Java and Scala). kindgi build loads every tool, guardrail and flow module to index it, so what a module does when it's loaded runs during the build too, inside an image that doesn't have your app's files or services (Build, sign and push your pack).

Once the file is saved, 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. Ask your coding agent: "I want a flow that runs the order lookup, so I can try it." For reference, the flow and the command that starts it look like this:

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
},
…
}
Excerpt

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. Ask your coding agent: "Log each order my lookup tool looks up." For reference, it adds a line like this to the handler:

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

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), System.out.println(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
pnpm exec 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",
Excerpt

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

Terminal window
pnpm exec 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",
Excerpt

In TypeScript, the message goes on to say what caused the error, as Node writes it, up to three causes. A fetch that failed says why, where it said only fetch failed: TypeError: fetch failed; caused by ConnectTimeoutError [UND_ERR_CONNECT_TIMEOUT]: Connect Timeout Error (attempted address: shop.acme.example:443, timeout: 10000ms). The message is masked, its causes included: a Kindgi token, and a URL's secret query value such as ?token= or X-Amz-Signature=, show as [redacted].

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. Ask your coding agent: "Make sure my refund tool never issues the same refund twice." For reference, the handler passes the key on like this:

// 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 }),
});
Excerpt

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, or none. Check for it, as above; without one, the call can't be deduped on it.

A tool call has a deadline: 120 seconds by default. When it passes, the call fails with deadline-exceeded, and the handler's abort signal fires: ctx.abortSignal in TypeScript, ctx.cancellation in Python, ctx.cancellation() in Java and ctx.cancellation in Scala. In Java and Scala the thread is interrupted too, which ends a wait such as Thread.sleep. A handler that doesn't stop then runs on, and what it returns goes nowhere.

  • In an agent, the failure fails the turn, unless tool-error is in the agent's toolErrors.retryOn (tool_errors in Python). Then it goes back to the model, which can call the tool again. The list replaces the default one, so name invalid-arguments and unknown-tool in it too to keep their retries.
  • In a flow, it fails the step. An edge's retry runs the step again, maybe while the first handler still runs, with the same ctx.idempotencyKey (Make a side effect happen once). An edge's timeoutMs can fail the step sooner, never later.

While a call runs, its run is executing, not waiting. If the runtime stops in the middle, for a deploy, a restart or a crash, the run fails (If the runtime stops). On a runtime you run, KINDGI_PACK_CALL_TIMEOUT_MS sets the deadline, in milliseconds. A longer deadline doesn't change that.

So don't make a tool wait for work that takes minutes or hours, such as a report, a batch job or someone's answer in another system. Hand the work on instead:

  • Pause the run until the other system answers. The tool hands that system a link and returns ctx.pause(). The answer becomes the call's result, and the run goes on from there. The run waits parked, for up to 24 days, so a restart doesn't end it. It's in TypeScript and Python (Wait for another system's answer).
  • Start the job and return its id. The tool starts the job and returns at once. It passes ctx.idempotencyKey along, so a call that runs again doesn't start a second job (Make a side effect happen once). When the job ends, it posts its result to a webhook trigger, which starts a run that takes it on. Your app hears that each run ended from run.finished.
  • Check on a schedule when the other system can't call back. A scheduled flow asks how the job stands, and takes it on once it's done.

Ask your coding agent: "My tool starts a report that takes an hour. Make the run wait for it." Kindgi's skills in your pack tell it how.

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. Ask your coding agent: "My order lookup tool should only find orders from the run's own org." For reference, the tool checks it like this:

// 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 };
},
Excerpt

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

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

Ask your coding agent: "Write tests for my order lookup tool." For reference, the tests look like this, with the command that runs them:

invokeTool calls a tool the way the runtime does: input checked, handler called, output checked. It returns a result rather than throwing. toolContextForTest(), also from @kindgi/sdk/define, builds a context like the one below and takes what to change; for a tool with env values, invokeToolForTest decides them as a run does (Give a tool env values).

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)
Excerpt

An agent lists the tools it may call. Ask your coding agent: "Let my agent use the order lookup tool." For reference, it adds the tool to the agent's list:

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

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
pnpm exec 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.",
…
}
],
Excerpt

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.

A project's Tools page lists its tools: "Tools available in this project: its own and the workspace's." Each row has the tool's Namespace, its name under Tool, What it does, Only reads ("Yes" for a tool that only reads), its Version, and In use: "Yes", with what uses it on hover, or "No". The list has a search box, and Columns chooses which columns it shows. A tool whose every version is retired is hidden until Show retired in Columns, and then it's marked retired. With no tools, it says what a tool is and how one gets here, and links the docs:

  • Under kindgi dev: "No tools yet", then "A tool is code in your pack that agents and flows call, to read or change something in another system. Under kindgi dev, tools come from your pack's tools/ folder: add one there, and kindgi dev loads it.", with Write a tool.
  • On any other runtime: "No tools in this project yet". It says to build and try tools with kindgi dev, and that kindgi deploy --project <project> puts the pack in this project, which it names by its slug, or by its id when another project has the same slug. On a runtime that serves many tenants, such as Kindgi Cloud, it adds kindgi deploy --hosted --project <project>, which builds the pack there. It links Write a tool and Where your agents run, and there also Deploy your agent to Kindgi Cloud.

A tool's page has its name and description at the top, with Download its JSON. Under them are its facts: its ID, its Version, tagged Latest or Retired, Only reads ("Yes", "No" or "Not declared"), In dry runs ("Runs" or "Skipped"), and, when the tool says, where it runs and what it declares it touches. Then:

  • Input and Output: each field, with its Type, whether it's Required, and its Description when the tool gives one.
  • Used by agents: each agent that lists it, with what it does and whether a call Needs approval.
  • Used in flows: each flow that runs it, with the steps that do and Locked at: the version the flow is locked to, or "Not locked".
  • Versions: each version with what it does, whether it only reads, and Locked by: the agent and flow versions locked to it. Retired versions are hidden until Show retired in the table's Columns. Tool admins see Retire and Bring back, as a flow's versions have. Under kindgi dev they're off: "the pack's files are this tool's versions. Change the tool there, and kindgi dev reloads it."
  • Full spec: its definition, as the runtime sent it.

When every version is retired, the page says "Every version of this tool is retired": "The Tools list shows it as retired. Agent and flow versions that locked one of its versions still run it, but publishing an agent that lists it is refused. Bring a version back under Versions, or publish a new one."

A pack's code runs as ES modules, the app modules its tools import included. kindgi build bundles it into .mjs files and leaves out the packages it imports from node_modules: the image installs them, and Node loads them by name. kindgi dev and a deployed pack load that code with Node's own import(), with no loader in between.

That shows with a package that ships only CommonJS and puts its class on exports.default, as TypeScript's CommonJS output does for export default. Node gives import OrdersClient from '@acme/orders-client' the package's whole module.exports, with the class on its default. Vitest, Next.js, and tsx in an app without "type": "module" give the class itself. So a tool can pass kindgi test, and app code it imports can work in your app, while under kindgi dev, and once deployed, the tool fails with TypeError: OrdersClient is not a constructor. With "moduleResolution": "NodeNext", as a new pack's tsconfig.json has, TypeScript types the import as Node loads it and doesn't compile new OrdersClient(). With "bundler", as Next.js apps have, it compiles.

Take the class either way:

import OrdersClientExport from '@acme/orders-client';
type DefaultOf<T> = T extends { default: infer C } ? C : T;
// The class itself under Vitest and Next.js; under Node, the package's
// whole module.exports, with the class on `default`.
function defaultOf<T>(loaded: T): DefaultOf<T> {
const value = typeof loaded === 'function' ? loaded : (loaded as { default: unknown }).default;
return value as DefaultOf<T>;
}
const OrdersClient = defaultOf(OrdersClientExport);
Excerpt

A dynamic import is the same: (await import('@acme/orders-client')).default is the class under Vitest and, under Node, the package's whole module.exports. Take it with defaultOf too:

const OrdersClient = defaultOf((await import('@acme/orders-client')).default);
Excerpt