Skip to content

Write an agent

An agent is a file under agents/. This one answers customers' questions about their orders. It looks orders up with a tool, and the sample pack's acme.response-not-empty guardrail checks its answer.

tools/lookup-order/index.ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const ORDERS: Record<string, { status: string; eta: string | null }> = {
'A-1001': { status: 'shipped', eta: '2026-10-06' },
'A-1002': { status: 'processing', eta: null },
};
const defined = defineTool({
id: 'acme.lookup-order' as ToolId,
description: 'Looks up an order by its id (like A-1001): its status and expected delivery date.',
version: '0.1.0',
input: z.object({ orderId: z.string() }),
output: z.object({ orderId: z.string(), status: z.string(), eta: z.string().nullable() }),
effects: [],
mutating: false,
handler: async ({ orderId }) => {
const order = ORDERS[orderId];
return order === undefined
? { orderId, status: 'not-found', eta: null }
: { orderId, ...order };
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;

The model sees the tool's id, its description and its input schema. The description is what it reads to decide when to call the tool, so say what the tool does and what its input looks like.

agents/order-desk/index.ts
import { defineAgent } from '@kindgi/sdk/define';
const defined = defineAgent({
id: 'acme.order-desk',
version: '0.1.0',
name: 'Order desk',
description: "Answers customers' questions about their orders.",
instructions: [
'You answer customer questions about Acme orders. Today is {{ today }}.',
'Look an order up with the lookup-order tool before you say anything about it.',
'If the customer gives no order id, ask for it. Never guess a status or a date.',
'Answer in two sentences at most.',
].join('\n'),
capabilities: [{ needs: [{ feature: 'tool-use' }] }],
tools: [{ id: 'acme.lookup-order', version: '^0.1.0' }],
retrieval: [],
guardrails: ['acme.response-not-empty'],
budget: { maxSteps: 6, maxCostUsd: 0.05, maxWallMs: 60_000 },
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;

defineAgent checks the definition where it's written and returns an error for an invalid one (an empty capabilities, a version that isn't semver); defined.error.issues lists the problems. Throwing makes kindgi dev report the file.

  • id is <pack>.<name>; version is an exact semver.
  • instructions is what the model is told at the start of every turn: a template, below. Name a tool by what it does ("the lookup-order tool"), not by its id: a model sees a tool's id in its provider's form (acme__lookup-order for Anthropic and OpenAI-compatible models), and an id with a dot in the instructions can make it call a name it wasn't given.
  • capabilities says what the model must support. tool-use is the one an agent that calls tools needs. Kindgi picks the model when the turn starts: see Choose the model an agent uses.
  • tools are the tools the model may call (below). An agent with tools: [] only talks.
  • guardrails check the agent's final answer, once per turn, before it's stored. A guardrail whose action is halt fails the turn, and the answer is never written to the conversation.
  • budget caps each turn: see Set an agent's budget.

In TypeScript, tools, retrieval and guardrails are required; pass [] for none.

Terminal window
kindgi runs start --agent=acme.order-desk --input='{"userMessage":"Where is my order A-1001?"}'
{
"id": "f3835200-e4c6-4579-a3fc-4b70f7444333",
…
"status": "completed",
…
"output": {
…
"usage": { "steps": 2, "durationMs": 3701, "promptTokens": 1119, "totalCostUsd": 0.004328, "completionTokens": 209 },
"appended": [
{ "role": "user", "content": "Where is my order A-1001?", "sequence": 0, … },
{ "role": "agent", "actor": "acme.order-desk",
"content": { "text": "", "toolCalls": [{ "id": "toolu_01…", "name": "acme.lookup-order", "arguments": { "orderId": "A-1001" } }] },
"sequence": 1, … },
{ "role": "tool", "content": { "eta": "2026-10-06", "status": "shipped", "orderId": "A-1001" }, "sequence": 2, … },
{ "role": "agent", "actor": "acme.order-desk",
"content": "Order A-1001 shows as shipped, and its expected delivery date was October 6, 2026. That date has passed and I can't see any newer tracking details, so the order is running late.", "sequence": 3, … }
],
"provider": { "id": "anthropic", "model": "claude-sonnet-5-5" },
"response": { "role": "agent", "content": "Order A-1001 shows as shipped, and its expected delivery date was October 6, 2026. That date has passed and I can't see any newer tracking details, so the order is running late.", … },
…
"turnNumber": 1,
"violations": [],
"conversationId": "936af311-f1c8-4260-a1ed-973876b4ac71"
}
}

The turn took two steps: the model asked for acme.lookup-order, the tool ran, and the model answered with its result. response.content is the answer; appended is everything the turn added to its conversation. The rest of the result is on Give an agent its input. The instructions give the model today's date, so it reads the tool's eta against it: this run was on 2026-10-08, after the order's expected date.

Edit the file and save: kindgi dev reloads it, and the next run uses it.

{{ today }} above is filled in when the turn starts. The template language is Liquid, so tags such as {% if %} work too. These variables are always there:

Variable Example
today 2026-10-03
now 2026-10-03T20:24:43.576Z
agent.id, agent.name, agent.version acme.order-desk, Order desk, 0.1.0
conversation.id, conversation.turn da5ea34a-…, 1

Your own variables are the agent's parameters, and an agent that runs as a flow step can read the step's input as {{ input.* }}: both are on Give an agent its input.

Rendering is strict. A variable that is neither built in nor declared fails the turn before the model is called:

Error [model-invocation-failed]: Prompt render failed: Template references an unresolved variable: customer

The rendered instructions are in the run's journal (kindgi runs journal <run-id>), as the output of its render-prompt step.

In TypeScript, each tool is an { id, version } reference, and version is a semver range: '^0.1.0' takes the highest registered version from 0.1.0 up to, not including, 0.2.0. In Python, pass the tool itself to pin it to its version, or the same {"id": …, "version": …} reference for a range.

Under kindgi dev, the range is resolved when each turn starts. A published agent version resolves it once, when it's published, and keeps that version: see Agent and tool versions. When no registered version is in the range, the turn fails:

Error [tool-version-unresolvable]: Tool "acme.lookup-order" has no version satisfying "^0.2.0" (available: 0.1.0)

A tool given as a plain string is refused before anything runs: TypeScript reports Type 'string' is not assignable to type 'ToolRef', and the Python index reports the file:

Terminal window
uv run python -m kindgi.pack index --pack-dir .
{
…
"fileErrors": [
{
"code": "manifest-validation-failed",
"message": "agents/order_desk.py: agent 'acme.order-desk': each tool is a Tool or an {id, version} ref, got 'acme.lookup-order'",
"filePath": "agents/order_desk.py"
}
]
}

A guardrail the agent names must be registered: kindgi dev registers the pack's own. An unknown id fails the turn before the model is called:

Error [unresolved-guardrail]: Agent "acme.order-desk" references guardrails not in the registry: acme.no-refunds

In Python, passing the guardrail object you import (as above) keeps the id right.

kindgi dev keeps serving the rest of the pack and names the file:

⚠ loaded 12 of 12 in 1406ms — 4 tools, 1 guardrails, 5 agents, 2 flows
✗ indexer: agents/order-desk/index.ts [file-import-failed] Failed to import agents/order-desk/index.ts: Error: Agent "acme.order-desk" is invalid (1 issue)

In a Python pack, uv run python -m kindgi.pack index --pack-dir . reports a file that fails to load without the runtime: a bare-string tool (above), or a version that isn't an exact semver (version must be an exact semver, got '0.1').