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.
The tool
Section titled “The tool”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:
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;inputandoutputare Zod schemas (or JSON Schema). The handler gets the parsed input, with defaults and transforms applied.defineToolreturns a result instead of throwing. Unwrap it when the file loads, so a bad definition fails at once rather than on the first call.ctxcarriestenantId,runId,requestIdand anabortSignalthat fires when the call is cancelled, or passes its deadline (Work that takes longer than a call). In a run it also carriesprojectId, the run's project, andorgId, 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.
from typing import Literal
from pydantic import BaseModel, Field
from kindgi import ToolContext, tool
# Stands in for your app's database.ORDERS = { "ord_1001": {"status": "shipped", "totalCents": 4200}, "ord_1002": {"status": "pending", "totalCents": 1250},}
class OrderRef(BaseModel): order_id: str = Field(alias="orderId", pattern=r"^ord_\d+$")
class Order(BaseModel): order_id: str = Field(alias="orderId") status: Literal["pending", "shipped", "delivered"] total_cents: int = Field(alias="totalCents")
@tool(id="my-pack.lookup-order", mutating=False)def lookup_order(ref: OrderRef, ctx: ToolContext) -> Order: """Looks up an order by id. Returns its status and total in cents.""" order = ORDERS.get(ref.order_id) if order is None: raise LookupError(f"No order {ref.order_id}") print(f"lookup-order {ref.order_id} for tenant {ctx.tenant_id}") return Order(orderId=ref.order_id, **order)- The first parameter's annotation is the input, the return annotation the
output: pydantic models here (a
TypedDictor a dataclass works too). Field aliases are the names on the wire. - The docstring is the description. The version is the pack's, unless you
pass
version=. ctxcarriestenant_id,run_id,request_idandcancellation, which fires when the call is cancelled, or passes its deadline (Work that takes longer than a call). In a run it also carriesproject_id, the run's project, andorg_id, the project's org when it has one (elseNone). The runtime sets both from the run, never from the input. Adefhandler runs in a worker thread, so blocking calls are fine; anasync defhandler runs on the event loop.
package mypack.tools;
import com.fasterxml.jackson.annotation.JsonProperty;import com.kindgi.pack.Tool;import jakarta.validation.constraints.Pattern;import java.util.Map;
public final class LookupOrder { public record Input(@Pattern(regexp = "^ord_\\d+$") String orderId) {}
public enum Status { @JsonProperty("pending") PENDING, @JsonProperty("shipped") SHIPPED, @JsonProperty("delivered") DELIVERED }
public record Output(String orderId, Status status, long totalCents) {}
// Stands in for your app's database. private static final Map<String, Output> ORDERS = Map.of( "ord_1001", new Output("ord_1001", Status.SHIPPED, 4200), "ord_1002", new Output("ord_1002", Status.PENDING, 1250));
public static final Tool<Input, Output> TOOL = Tool.define("my-pack.lookup-order") .description("Looks up an order by id. Returns its status and total in cents.") .version("0.1.0") .input(Input.class) .output(Output.class) .mutating(false) .handler((input, ctx) -> { Output order = ORDERS.get(input.orderId()); if (order == null) throw new IllegalArgumentException("No order " + input.orderId()); System.out.println("lookup-order " + input.orderId() + " for tenant " + ctx.tenantId()); return order; });
private LookupOrder() {}}- The tool is a
public static finalfield of a class undertools/: the indexer finds it there. The package comes from the pack's id (my-pack→mypack). inputandoutputare records, and their schemas come from the records: a Jakarta Validation constraint such as@Patternbecomes its schema keyword, and an enum the list of its JSON values.ctxcarriestenantId(),runId(),requestId()andcancellation(), which fires when the call is cancelled, or passes its deadline (Work that takes longer than a call). In a run it also carriesprojectId(), the run's project, andorgId(), the project's org when it has one (elsenull). The runtime sets both from the run, never from the input. Each call runs on its own thread, so blocking calls are fine.
package mypack.tools
import com.kindgi.pack.scaladsl._import jakarta.validation.constraints.Pattern
object LookupOrder { final case class Input(@Pattern(regexp = "^ord_\\d+$") orderId: String)
enum Status { case pending, shipped, delivered }
final case class Output(orderId: String, status: Status, totalCents: Long)
// Stands in for your app's database. private val orders = Map( "ord_1001" -> Output("ord_1001", Status.shipped, 4200), "ord_1002" -> Output("ord_1002", Status.pending, 1250))
val tool: Tool[Input, Output] = Tool[Input, Output]("my-pack.lookup-order") .description("Looks up an order by id. Returns its status and total in cents.") .version("0.1.0") .readOnly .handler { (in, ctx) => val order = orders.getOrElse(in.orderId, throw new NoSuchElementException(s"No order ${in.orderId}")) println(s"lookup-order ${in.orderId} for tenant ${ctx.tenantId}") order }}- The tool is a
valof an object named like its file, undertools/: the indexer finds it there. InputandOutputare case classes, and their schemas come from them: a Jakarta Validation constraint such as@Patternbecomes its schema keyword, and a Scala 3 enum's cases are the values its string may take.ctxis the same context as in Java:ctx.tenantId,ctx.runId,ctx.projectIdandctx.orgId(nullwhen the project has no org), set by the runtime from the run.
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,.readOnlyin Scala) declares that the tool only reads. See Mark a tool read-only.- A
pattern(@Patternin Java and Scala) is read as JavaScript'sRegExpreads 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'sre, so oneredoesn't read, such as\p{L}, is refused then too. A pydantic model'sField(pattern=...)is checked by pydantic, which reads\p{L}. One pydantic doesn't read, such asa\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.
- In Python, one JavaScript doesn't read, such as
- What the handler prints shows in the
kindgi devterminal, 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 buildloads 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.
Try it from a flow
Section titled “Try it from a flow”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:
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;pnpm exec kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}'from kindgi import Flow
from ..tools.lookup_order import lookup_order
check_order = Flow( id="my-pack.check-order", version="0.1.0", name="Check order", description="Looks up one order.", nodes=[ { "id": "lookup", "kind": "tool", "ref": lookup_order, "inputMapping": {"orderId": {"path": "runInput.orderId"}}, } ], edges=[ {"id": "e-start", "from": "$start", "to": "lookup"}, {"id": "e-end", "from": "lookup", "to": "$end"}, ],)uv run kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}'package mypack.flows;
import com.kindgi.pack.Flow;import java.util.Map;import mypack.tools.LookupOrder;
public final class CheckOrder { public static final Flow FLOW = Flow.define("my-pack.check-order") .version("0.1.0") .set("name", "Check order") .set("description", "Looks up one order.") .node(Map.of( "id", "lookup", "kind", "tool", "ref", LookupOrder.TOOL, "inputMapping", Map.of("orderId", Map.of("path", "runInput.orderId")))) .edge("e-start", "$start", "lookup") .edge("e-end", "lookup", "$end") .build();
private CheckOrder() {}}./kindgiw runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}'package mypack.flows
import com.kindgi.pack.scaladsl._import mypack.tools.LookupOrder
object CheckOrder { val flow: Flow = Flow("my-pack.check-order") .version("0.1.0") .set("name", "Check order") .set("description", "Looks up one order.") .node(Map( "id" -> "lookup", "kind" -> "tool", "ref" -> LookupOrder.tool, "inputMapping" -> Map("orderId" -> Map("path" -> "runInput.orderId")))) .edge("e-start", "$start", "lookup") .edge("e-end", "lookup", "$end") .build()}./kindgiw 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-09650af85492Log from a tool
Section titled “Log from a tool”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 handlerctx.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?..
# in tools/lookup_order.py, in the handlerctx.log.info("looked up order", {"orderId": ref.order_id, "status": order["status"]})Fields can also be keywords (order_id=…). In a test,
ToolContext.for_test(…) logs nothing unless you pass it a logger as log=.
// in src/main/java/mypack/tools/LookupOrder.java, in the handlerctx.log().info("looked up order", Map.of("orderId", input.orderId(), "status", order.status()));ctx.log().warn("retrying", e) logs an exception, under err. In a
test, ToolContext.forTest() logs nothing; ToolContext.forTest(log) takes
a logger.
// in src/main/scala/mypack/tools/LookupOrder.scala, in the handlerctx.log.info("looked up order", "orderId" -> in.orderId, "status" -> order.status)Fields can also be a map. A case class is logged as an object of its fields,
so one named like a secret (apiKey) is redacted.
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-1001The 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.
When a call fails
Section titled “When a call fails”Kindgi checks the input before your handler runs. An order id that doesn't match the pattern never reaches it:
pnpm exec kindgi runs start --flow=my-pack.check-order --input='{"orderId":"9999"}'uv run kindgi runs start --flow=my-pack.check-order --input='{"orderId":"9999"}'./kindgiw runs start --flow=my-pack.check-order --input='{"orderId":"9999"}'./kindgiw 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:
pnpm exec kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_9999"}'uv run kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_9999"}'./kindgiw runs start --flow=my-pack.check-order --input='{"orderId":"ord_9999"}'./kindgiw 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",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.
Make a side effect happen once
Section titled “Make a side effect happen once”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 handlerconst 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 }),});# in tools/issue_refund.py, in the handlerheaders = {"Idempotency-Key": ctx.idempotency_key} if ctx.idempotency_key else {}res = httpx.post( "https://payments.acme.example/refunds", json={"orderId": ref.order_id, "amount": ref.amount}, headers=headers,)// in src/main/java/mypack/tools/IssueRefund.java, in the handlerHttpRequest.Builder request = HttpRequest.newBuilder(URI.create("https://payments.acme.example/refunds")) .POST(HttpRequest.BodyPublishers.ofString(body));if (ctx.idempotencyKey() != null) { request.header("Idempotency-Key", ctx.idempotencyKey());}// in src/main/scala/mypack/tools/IssueRefund.scala, in the handlerval request = HttpRequest.newBuilder(URI.create("https://payments.acme.example/refunds")) .POST(HttpRequest.BodyPublishers.ofString(body))Option(ctx.idempotencyKey).foreach(key => request.header("Idempotency-Key", key))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.
requestIdwon'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.
Work that takes longer than a call
Section titled “Work that takes longer than a call”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-erroris in the agent'stoolErrors.retryOn(tool_errorsin Python). Then it goes back to the model, which can call the tool again. The list replaces the default one, so nameinvalid-argumentsandunknown-toolin it too to keep their retries. - In a flow, it fails the step. An edge's
retryruns the step again, maybe while the first handler still runs, with the samectx.idempotencyKey(Make a side effect happen once). An edge'stimeoutMscan 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.idempotencyKeyalong, 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 fromrun.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.
Check the run's org
Section titled “Check the run's org”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 }; },# in tools/lookup_order.py# Stands in for your app's database. Each order belongs to one customer's org.ORDERS = { "ord_1001": {"org_id": "1154e250-2a07-491e-b00a-3675b411c411", "status": "shipped", "totalCents": 4200}, "ord_1002": {"org_id": "feb005e6-6798-47eb-a87a-8feed584d37b", "status": "pending", "totalCents": 1250},}
@tool(id="my-pack.lookup-order", mutating=False)def lookup_order(ref: OrderRef, ctx: ToolContext) -> Order: """Looks up an order by id. Returns its status and total in cents.""" order = ORDERS.get(ref.order_id) # Another org's order is as missing as one that doesn't exist. if order is None or order["org_id"] != ctx.org_id: raise LookupError(f"No order {ref.order_id}") return Order(orderId=ref.order_id, status=order["status"], totalCents=order["totalCents"])// in src/main/java/mypack/tools/LookupOrder.java// Stands in for your app's database. Each order belongs to one customer's org.record Stored(String orgId, Output order) {}
private static final Map<String, Stored> ORDERS = Map.of( "ord_1001", new Stored("1154e250-2a07-491e-b00a-3675b411c411", new Output("ord_1001", Status.SHIPPED, 4200)), "ord_1002", new Stored("feb005e6-6798-47eb-a87a-8feed584d37b", new Output("ord_1002", Status.PENDING, 1250)));
// … .handler((input, ctx) -> { Stored stored = ORDERS.get(input.orderId()); // Another org's order is as missing as one that doesn't exist. if (stored == null || !stored.orgId().equals(ctx.orgId())) { throw new IllegalArgumentException("No order " + input.orderId()); } return stored.order(); });// in src/main/scala/mypack/tools/LookupOrder.scala// Stands in for your app's database. Each order belongs to one customer's org.private val orders = Map( "ord_1001" -> ("1154e250-2a07-491e-b00a-3675b411c411", Output("ord_1001", Status.shipped, 4200)), "ord_1002" -> ("feb005e6-6798-47eb-a87a-8feed584d37b", Output("ord_1002", Status.pending, 1250)))
// … .handler { (in, ctx) => orders.get(in.orderId) match { // Another org's order is as missing as one that doesn't exist. case Some((orgId, order)) if orgId == ctx.orgId => order case _ => throw new NoSuchElementException(s"No order ${in.orderId}") } }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, 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.
Test it
Section titled “Test it”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).
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');});pnpm exec kindgi test ✓ tools/lookup-order/index.test.ts (2 tests) 16ms… Tests 4 passed (4)A tool is still a function: call it with its input model and a test context.
import pytest
from kindgi import ToolContextfrom tools.lookup_order import OrderRef, lookup_order
def test_finds_a_shipped_order(): order = lookup_order(OrderRef(orderId="ord_1001"), ToolContext.for_test()) assert order.status == "shipped" assert order.total_cents == 4200
def test_an_unknown_order_raises(): with pytest.raises(LookupError): lookup_order(OrderRef(orderId="ord_9999"), ToolContext.for_test())uv run pytest..... [100%]5 passed in 0.10sTest files aren't indexed: they never become tools.
TOOL.call runs the handler with an input and a context, as a unit test
wants it. ToolContext.forTest() is a context with a test tenant and run,
whose logger writes nothing. The pack service checks the input and the output
against their schemas on every call; call doesn't.
package mypack.tools;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
import com.kindgi.pack.ToolContext;import org.junit.jupiter.api.Test;
class LookupOrderTest { @Test void findsAShippedOrder() throws Exception { LookupOrder.Output order = LookupOrder.TOOL.call(new LookupOrder.Input("ord_1001"), ToolContext.forTest()); assertEquals(LookupOrder.Status.SHIPPED, order.status()); assertEquals(4200, order.totalCents()); }
@Test void anUnknownOrderThrows() { assertThrows(IllegalArgumentException.class, () -> LookupOrder.TOOL.call(new LookupOrder.Input("ord_9999"), ToolContext.forTest())); }}./mvnw test[INFO] Running mypack.tools.LookupOrderTestlookup-order ord_1001 for tenant t-test[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.091 s -- in mypack.tools.LookupOrderTest…[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0[INFO][INFO] ------------------------------------------------------------------------[INFO] BUILD SUCCESSTests (src/test/, *Test.java) aren't indexed: they never become tools.
tool.call runs the handler, as in Java: the schema checks are the pack
service's.
package mypack.tools
import com.kindgi.pack.ToolContext
class LookupOrderSuite extends munit.FunSuite { test("finds a shipped order") { val order = LookupOrder.tool.call(LookupOrder.Input("ord_1001"), ToolContext.forTest()) assertEquals(order.status, LookupOrder.Status.shipped) assertEquals(order.totalCents, 4200L) }
test("an unknown order throws") { intercept[NoSuchElementException] { LookupOrder.tool.call(LookupOrder.Input("ord_9999"), ToolContext.forTest()) } }}sbt testlookup-order ord_1001 for tenant t-test…Test run mypack.tools.LookupOrderSuite finished: 0 failed, 0 ignored, 2 total 0.095s…[info] Passed: Total 5, Failed 0, Errors 0, Passed 5Tests (src/test/) aren't indexed: they never become tools.
Give it to an agent
Section titled “Give it to an agent”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.tstools: [{ 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.
# in agents/order_desk.pytools=[lookup_order],The Tool object pins its version. A ref with a range works too:
{"id": "my-pack.lookup-order", "version": "^0.1.0"}.
// in src/main/java/mypack/agents/OrderDesk.java.tool(LookupOrder.TOOL)The tool itself pins its version. A ref with a range works too:
.tool("my-pack.lookup-order", "^0.1.0").
// in src/main/scala/mypack/agents/OrderDesk.scala.tool(LookupOrder.tool)The tool itself pins its version. A ref with a range works too:
.tool("my-pack.lookup-order", "^0.1.0").
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):
pnpm exec kindgi runs start --agent=my-pack.order-desk --input='{"userMessage":"What is the status of order ord_1002?"}'uv run kindgi runs start --agent=my-pack.order-desk --input='{"userMessage":"What is the status of order ord_1002?"}'./kindgiw runs start --agent=my-pack.order-desk --input='{"userMessage":"What is the status of order ord_1002?"}'./kindgiw 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.
In the console
Section titled “In the console”A project's Tools page lists its tools: "Tools available in this project: its own and the tenant'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. Underkindgi dev, tools come from your pack'stools/folder: add one there, andkindgi devloads 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 thatkindgi 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 addskindgi 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 devthey'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 CommonJS package in TypeScript
Section titled “A CommonJS package in TypeScript”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);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);