This is the full developer documentation for Kindgi
# Start
> What Kindgi is, installing it, and your first pack.
Begin here.
1. [What is Kindgi?](introduction/): packs, the runtime, and how your app and your coding agent work with them.
2. [Install](install/): Node, Docker, and how a project gets the CLI and the SDK.
3. A first pack, running on your machine in ten minutes: [TypeScript](quickstart-typescript/) or [Python](quickstart-python/).
4. [Add Kindgi to an existing app](existing-app/): your app's own code as tools.
5. [Your coding agent](coding-agents/): the skills that teach it Kindgi.
Working with a coding agent?
Once `kindgi init` has run, your coding agent has Kindgi's skills. You can follow these pages yourself, or ask the agent for what you want: it writes the tools, agents and flows, and runs them with `kindgi dev` and `kindgi runs start`. You still name your pack and its agents, and you provide the credentials, such as your model provider's API key. [How it knows Kindgi](coding-agents/).
# Your coding agent
> How your coding agent knows Kindgi, the commands it runs, and what stays with you.
You don't have to learn Kindgi before you build with it. Tell your coding agent what you want, in your own words:
* "Add a tool that looks up a customer by email."
* "Write an agent that answers support tickets with a category, a priority and a reply."
* "Connect Claude."
* "Branch the flow on the priority."
* "Why did the last run fail?"
The agent writes the code in your project's TypeScript or Python, runs it with the Kindgi CLI, reads what happened, and fixes it. This page explains how it knows to.
## How it knows Kindgi
### Skills, loaded for the task
`kindgi init` copies Kindgi's **skills** into the project, under `.claude/skills/`. A skill is a page of instructions written for an agent: the steps, the commands to run, what to check, and the mistakes to avoid.
Each skill opens with a short description of when it applies. Claude Code reads those descriptions and loads a skill's full text only when your request matches: asking for a tool loads the tools skill, asking for a model loads the providers skill. A request that spans several (a tool, an agent that uses it, a flow around both) loads each one in turn.
| Skill | Loaded when |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `kindgi-getting-started`, `kindgi-python-getting-started` | Setting up or orienting in a pack |
| `kindgi-authoring-tools`, `kindgi-python-authoring-tools` | Writing a tool: typed input and output, side effects, secrets, HTTP tools |
| `kindgi-authoring-agents`, `kindgi-python-authoring-agents` | Writing an agent: instructions, tools, typed output, budgets |
| `kindgi-authoring-flows`, `kindgi-python-authoring-flows` | Writing a flow: steps, edges, conditions, loops, fanout |
| `kindgi-authoring-guardrails`, `kindgi-python-authoring-guardrails` | Writing a guardrail check |
| `kindgi-authoring-providers` | Connecting a model: Anthropic, Gemini, OpenAI-compatible endpoints |
| `kindgi-authoring-mcp-servers` | Giving the coding agent an MCP server |
| `kindgi-framework-feedback` | Reporting a problem in Kindgi itself |
A pack gets the skills for its language, plus the shared ones.
### Matched to your version
The skills ship with the CLI, so `kindgi init` copies the ones written for the Kindgi version it installs. The agent writes against the API you have, not one it half-remembers. After an upgrade, `kindgi dev` tells you the skills are behind, and `kindgi skills sync` refreshes them (see [Keeping them current](#keeping-them-current)).
### What else it reads
* **The SDK documents itself.** Every TypeScript export has JSDoc and every Python one a docstring, so the agent finds field-level docs right where it writes the code.
* **The CLI explains itself.** `kindgi --help`, and `--help` on each command, list the commands and their flags.
* **Errors name what's wrong.** A flow step the runtime has no tool for, an answer that doesn't match the agent's output schema, a package that's only in `devDependencies`: the message says which, and the agent fixes it and runs again.
* **These docs, for agents.** [`docs.kindgi.com/llms.txt`](https://docs.kindgi.com/llms.txt) indexes this site in plain text, one file per section; [`llms-full.txt`](https://docs.kindgi.com/llms-full.txt) is all of it. Point an agent there when it needs more than the skills.
## What it runs
The skills tell the agent which `kindgi` commands to run and when, so it works the way you would: write, run, look, fix.
| Command | What the agent does with it |
| --------------------------------- | ------------------------------------------------------------ |
| `kindgi init` | Creates a pack, or adds Kindgi to your app |
| `kindgi dev` | Starts Kindgi on your machine, which picks up every save |
| `kindgi runs start` | Tries an agent or a flow with an input, and reads the answer |
| `kindgi runs get`, `runs journal` | Finds out what a run did, step by step, and why it failed |
| `kindgi providers register` | Connects a model, once its key is in your env file |
| `kindgi mcp add` | Gives itself access to a database or another service |
| `kindgi skills sync` | Refreshes its skills after an upgrade |
| `kindgi feedback write` | Records a bug it found in Kindgi itself |
| `kindgi build` | Builds the pack's image for deployment |
In a TypeScript project it runs the CLI the project pins (`pnpm exec kindgi`). In a Python project it runs `npx --yes @kindgi/cli@0.1`; `--yes` skips npx's install prompt, so the agent never waits on it.
## What stays with you
* **The names.** You name your pack and its agents. The pack's id prefixes every tool, agent and flow (`.`), so pick it once; the skills tell the agent to ask you for it rather than guess.
* **The credentials.** You provide them, such as your model provider's API key: put it in your env file (`.env` / `.env.local`), or type it into `kindgi secrets set`, which asks for it without showing it. The agent never invents one or writes one into code. Then it registers the provider.
Logging in to the runtime image's registry is yours too: `kindgi auth registry` asks for your token the same way ([Install](../install/)).
## Other coding agents
Claude Code loads the skills by itself. They're plain Markdown, so any coding agent can follow them once it knows where they are:
* A new pack has an `AGENTS.md` that points to them, for agents that read `AGENTS.md`.
* In an existing app, `kindgi init` leaves your `AGENTS.md` (or rules file) alone. Add a line to it, for example: "Kindgi's instructions are in `.claude/skills/`. Before writing Kindgi code, read the skill for the task."
## Keeping them current
The skills are copies, made when you ran `kindgi init`. After upgrading Kindgi, refresh them:
```sh
kindgi skills sync # keeps skills you edited; --force replaces them
```
In a Python project, run it as `npx --yes @kindgi/cli@0.1 skills sync`.
`kindgi dev` tells you when the installed skills are behind the CLI's. `.claude/skills/.kindgi-manifest.json` records what was installed.
## MCP servers for your coding agent
Give the coding agent access to a database or another service through an MCP server, without writing its secret into the project:
```sh
kindgi mcp presets
kindgi mcp add postgres --secret=MY_DB_URL
```
`add` writes an entry to `.mcp.json` that starts the server with the secret from your env files. These servers are for your coding agent (Claude Code, Cursor, VS Code), not for Kindgi's runtime.
## When Kindgi is the problem
If your agent diagnoses a bug in Kindgi rather than in your code, the `kindgi-framework-feedback` skill has it write a structured note to `FEEDBACK.md` with `kindgi feedback write`, ready to send to us.
# Add Kindgi to an existing app
> Kindgi inside the app you already have (a Next.js or Node app, or a Python app), so your tools are your app's own code.
Kindgi doesn't need a project of its own. Run `kindgi init` in your app and the pack lives beside your code: its tools import your app's modules, and your app starts runs through the SDK.
Your coding agent can do most of this
`kindgi init` gives your app Kindgi's skills, in `.claude/skills/`. After that, you can ask your coding agent instead of following each step: "make a Kindgi tool from our order lookup", "add an agent that uses it", "run it". It writes the code next to yours, runs it with `kindgi dev`, and asks you for your model's key. [How it knows Kindgi](../coding-agents/).
## A TypeScript or Node app
In the app's root (where its `package.json` is), with no pack name:
```sh
npx @kindgi/cli init
pnpm install # or the app's own package manager
```
On Kindgi 0.1.0 (fixed in 0.1.1)
* In a pnpm 11+ app, the first `pnpm install` stops with `ERR_PNPM_IGNORED_BUILDS` for `esbuild`. In `pnpm-workspace.yaml`, set `esbuild: false` under `allowBuilds:` (replacing pnpm's placeholder), then install again. esbuild works without its install script: its native binary comes from its `@esbuild/` package.
* `init` doesn't add Zod, which tools and agents use for their schemas: `pnpm add zod`.
`init` adds:
* the pack's config: its id (from the app's name), version, and where its primitives live. It's `kindgi.config.ts` in an app whose `package.json` says `"type": "module"`, and `kindgi.config.mts` in any other;
* a `kindgi/` folder for the primitives: `kindgi/tools/`, `kindgi/agents/`, `kindgi/guardrails/`, `kindgi/flows/`. In an app that isn't `"type": "module"`, it also gets a one-line `package.json` that makes them ES modules;
* `@kindgi/sdk` (a dependency) and `@kindgi/cli` (a devDependency) in your `package.json`, at the CLI's own version, and, from 0.1.1, `zod`;
* from 0.1.1, in a pnpm app, `allowBuilds: { esbuild: false }` in `pnpm-workspace.yaml`: pnpm 11+ won't install until every dependency's install script has a decision, and esbuild (the CLI's bundler) doesn't need its script. A decision the app already has, `true` or `false`, is kept;
* the skills for your coding agent under `.claude/skills/`, and `.gitignore` entries (`.kindgi/`, `.kindgirc.json`, `.env.local`).
It creates no env files: `kindgi dev` reads your app's own `.env` and `.env.local`. If your app lints with ESLint, leave out what Kindgi builds: add `".kindgi/**"` to the `globalIgnores` in `eslint.config.mjs`. To keep the pack separate from the app instead, pass `--new-repo`.
### Your code as a tool
A tool imports your app's modules like any other file in it, path aliases (`@/…`) included:
kindgi/tools/get-request/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
import { findRequest } from '@/lib/requests'; // your app's own code
const defined = defineTool({
id: 'acme-support.get-request' as ToolId,
description: 'Looks up a support request by its id (REQ-1234).',
version: '0.1.0',
input: z.object({ id: z.string() }),
output: z.object({
found: z.boolean(),
subject: z.string().optional(),
body: z.string().optional(),
}),
effects: [],
mutating: false,
handler: async ({ id }) => {
const request = findRequest(id);
return request
? { found: true, subject: request.subject, body: request.body }
: { found: false };
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
Ids start with the pack's id (`acme-support`, from the app's name). `mutating: false` says the tool only reads, so a dry run may call it.
A package a tool imports (an ORM client such as `@prisma/client`, an API SDK) must be in your app's `dependencies`, not `devDependencies`: the deployed pack installs production dependencies only, so a dev-only import works under `kindgi dev` and fails once deployed. `kindgi dev` warns when a tool imports one, and `kindgi build` refuses the pack until it moves. Build-time tools (the `prisma` CLI, `typescript`) stay in `devDependencies`.
### An agent that uses it
kindgi/agents/triage/index.ts
```ts
import { defineAgent } from '@kindgi/sdk/define';
import type { AgentId, Semver } from '@kindgi/sdk/types';
import { z } from 'zod';
const defined = defineAgent({
id: 'acme-support.triage' as AgentId,
version: '0.1.0' as Semver,
name: 'Triage',
description: 'Reads a support request and sets its priority.',
instructions:
'The user names a support request id. Look it up with `acme-support.get-request`, ' +
'then answer with its priority and a one-sentence summary.',
capabilities: [{ needs: [{ feature: 'tool-use' as const }] }],
tools: [{ id: 'acme-support.get-request', version: '^0.1.0' }],
retrieval: [],
guardrails: [],
output: {
schema: z.object({
priority: z.enum(['low', 'normal', 'urgent']),
summary: z.string(),
}),
},
budget: { maxSteps: 4, maxCostUsd: 0.05, maxWallMs: 60_000 },
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
`output` makes the answer typed: the model has to answer with JSON that fits the schema, and your app gets it as an object.
### Run it
```sh
pnpm exec kindgi dev
```
`kindgi dev` builds the pack from `kindgi/`, runs its tools in a process started from your app's root, and reloads on every save.
Until you register a model, agents answer with `dev-echo`, which only checks the wiring: it calls the agent's first tool with `{"message": …}` and can't produce a typed answer, so this agent fails with `output-schema-violation`. Register a model first:
```sh
echo 'ANTHROPIC_API_KEY=sk-ant-…' >> .env.local
pnpm exec kindgi providers register --preset=anthropic
pnpm exec kindgi runs start --agent=acme-support.triage --input='{"userMessage":"Triage REQ-1002"}'
```
```text
"status": "completed",
…
"output": {
"summary": "User unable to log in due to expired password with non-functional password reset email delivery.",
"priority": "urgent"
},
```
To have `kindgi dev` register the model on every boot, in each worktree and after `--reset`, declare it in the pack's config: [Declare them in your pack's config](../../guides/models/#declare-them-in-your-packs-config).
### What the pack's image needs
`kindgi build` turns the pack into an image. It installs your app's production dependencies with install scripts off, so your app's own `postinstall` and `prepare` scripts don't run there, and the build says so:
```text
✓ The app's own install scripts don't run in the image: postinstall (`prisma generate`)
```
When the image needs what such a script does, say so under `image` in `kindgi.config.ts`.
**Prisma.** A tool that uses Prisma's client needs `prisma generate` in the image. Without it the build fails when it loads the pack:
```text
kindgi-index: file-import-failed: Failed to import kindgi/tools/find-order/index.ts: Error: @prisma/client did not initialize yet. Please run "prisma generate" and try to import it again.
```
`prisma()` copies the schema into the image and runs `prisma generate` after the install:
```ts
// in kindgi.config.ts
import { prisma } from '@kindgi/sdk/build';
const config = {
// pack, discovery and environments, as kindgi init wrote them
image: {
extensions: [prisma({ schema: 'prisma/schema.prisma' })],
},
};
```
With a `prisma.config.ts`, name it too: `prisma({ schema: 'prisma/schema.prisma', config: 'prisma.config.ts' })`.
**Also under `image`:**
* `systemPackages`: Debian packages the image needs, such as `['tesseract-ocr']`.
* `buildEnv`: placeholder variables for the build steps and for loading your modules while building, such as `{ DATABASE_URL: 'postgresql://build-placeholder' }`. Never secrets: they're set while building only, and the final image doesn't have them.
* **patch-package:** when a skipped script runs it, `kindgi build` prints the build step to add (`defineBuildExtension`, also from `@kindgi/sdk/build`). With pnpm, `pnpm patch` applies your patches in the install itself.
The build ends with the image and a check of what's in it:
```text
✓ Built kindgi-pack/acme-orders:20261004.1 (sha256:042e285f8cd9…)
✓ /app/index.json in the image matches the local index byte for byte
```
[Self-host with Docker](../../deploy/self-host/) pushes, signs and deploys it.
## A Python app
In the app's directory (where its `pyproject.toml` is):
```sh
npx --yes @kindgi/cli@0.1 init # --pack-id= if the app's name doesn't make one
uv sync # or what it prints for Poetry or pip
npx --yes @kindgi/cli@0.1 dev
```
`init` edits your `pyproject.toml` in place, keeping its layout and comments:
* it adds the `[tool.kindgi]` tables: the pack id from `[project].name`, discovery under `kindgi/`, and, in a Poetry app, `dev.python` set to run `poetry run python`;
* it adds `kindgi` to `[project].dependencies`. Where it can't edit them (Poetry 1, or `dynamic` dependencies), it prints the command to run instead.
Then the `kindgi/tools`, `guardrails`, `agents` and `flows` folders, the Python skills and `.gitignore` entries. An app with both a `package.json` and a `pyproject.toml` gets a TypeScript pack unless you pass `--template=python`.
The app's root is on `sys.path`, so a tool imports your packages by name:
kindgi/tools/orders.py
```python
from typing import Any
from pydantic import BaseModel
from acme.orders import find_orders # your app's own code
from kindgi import tool
class Customer(BaseModel):
customer_id: str
class Orders(BaseModel):
orders: list[dict[str, Any]]
@tool(id="acme.customer-orders", mutating=False)
def customer_orders(input: Customer) -> Orders:
"""The customer's orders, newest first."""
return Orders(orders=find_orders(input.customer_id))
```
`mutating=False` says the tool only reads, so a dry run may call it.
A package a tool imports must be in your app's main dependencies (`[project].dependencies`), not a dev group: the deployed pack installs without dev dependencies, so a dev-only import works under `kindgi dev` and fails once deployed.
Inside `kindgi/`, import the pack's own modules relatively. Don't add an `__init__.py` to `kindgi/`: the folder would shadow the `kindgi` package.
## Starting runs from your app
Your app calls Kindgi over HTTP, through the SDK's client.
### Connect your app to the runtime
In development, `kindgi dev` is enough: it writes the API's URL and a token to `.kindgirc.json`, and the client finds them there, with a one-time warning to set them. For production, and to silence the warning, give them to your app as `KINDGI_API_URL` and `KINDGI_API_TOKEN` in your env file (`.env` / `.env.local`). `kindgi dev` prints them (`API` and `Token` in its banner):
.env.local
```sh
KINDGI_API_URL=http://127.0.0.1:4000
KINDGI_API_TOKEN=kgi_bt_…
```
The token stays the same when you restart `kindgi dev`. `kindgi dev --reset` starts the project over, dropping its database ([after asking](../install/#where-kindgi-dev-keeps-its-data)), with a new token: copy the new token after it (the client warns when the token in your env file no longer matches). In production they're your deployment's URL and API token. To pass them yourself instead: `createClient({ apiUrl, auth: { kind: 'apiToken', token } })`, or `Kindgi(url, token=…)` in Python.
### In a CommonJS app
An app that isn't `"type": "module"`, or that compiles to CommonJS, loads the client with `require()`:
```js
const { createClient } = require('@kindgi/sdk/client');
```
* **Node 22.12 or later.** The SDK is ES modules, and Node's `require()` loads ES modules from 22.12 on.
* **TypeScript that compiles to CommonJS:** TypeScript 5.8 or later, with `"module": "nodenext"`. In an app a bundler builds, `"moduleResolution": "bundler"` works too. With `"moduleResolution": "node10"` (or `"node"`), TypeScript doesn't find the SDK's types.
Pack code (`kindgi/`) is ES modules in every app; `init` sets that up.
### Run an agent and read its answer
src/lib/kindgi.ts
```ts
import { createClient } from '@kindgi/sdk/client';
export const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
```
src/app/api/requests/\[id]/triage/route.ts
```ts
import { KindgiApiError } from '@kindgi/sdk/client';
import { kindgi } from '@/lib/kindgi';
type Triage = { priority: 'low' | 'normal' | 'urgent'; summary: string };
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const run = await kindgi.runs.start({
agent: 'acme-support.triage',
input: { userMessage: `Triage ${id}` },
});
const { output } = run.output as { output: Triage }; // the agent's typed answer
return Response.json(output);
} catch (error) {
if (error instanceof KindgiApiError) {
const code = error.error.code === 'server' ? error.error.serverCode : error.error.code;
return Response.json({ error: code, message: error.error.message }, { status: 502 });
}
throw error;
}
}
```
* An agent's input is `{ userMessage }`. `runs.start` waits for the answer.
* `run.output.output` is the typed answer of an agent with an `output`; `run.output.response.content` is the answer as text. A flow's `run.output` is the flow's own output.
* When the turn fails, or the API refuses the call, `runs.start` throws a `KindgiApiError`. `error.error.code` says what kind of failure it is (`not-found`, `auth`, `network`, …); for a failure on the server (`server`), `error.error.serverCode` is the server's own code, such as `output-schema-violation`.
In Python:
```python
from kindgi.client import Kindgi, KindgiApiError
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
try:
run = kindgi.runs.start(agent="acme-support.triage", input={"userMessage": "Triage REQ-1002"})
triage = run.output["output"]
except KindgiApiError as error:
print(error.code, error.server_code, error.message)
```
### Follow a run as it runs
Start the run with `wait: false` and the call returns as soon as the run exists. Its events then arrive as they happen, through to its last one:
src/app/api/requests/\[id]/triage/stream/route.ts
```ts
import { kindgi } from '@/lib/kindgi';
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const run = await kindgi.runs.start({
agent: 'acme-support.triage',
input: { userMessage: `Triage ${id}` },
options: { wait: false }, // returns as soon as the run exists
});
const encoder = new TextEncoder();
const body = new ReadableStream({
async start(controller) {
for await (const event of kindgi.runs.stream(run.id)) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
controller.close();
},
});
return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
}
```
Each event has a `kind` (`run.started`, `run.step-started`, `run.step-completed`, …, `run.completed`) and a `payload`; the `run.completed` event's `payload.output` is the run's output. In Python, `kindgi.runs.stream(run.id)` is an iterator of the same events.
A browser can also follow a run directly, with a short-lived read-only token, without your API token: see [Follow a run from the browser](../../guides/runs/follow-from-the-browser/). To be told when a run ends instead, have Kindgi send your app a signed webhook: see [Get a webhook when a run finishes](../../guides/webhooks/receive-run-finished/).
### Keep what a run did
Store the run's id on your own row (a `kindgi_run_id` column), and read its status, output, steps and sources through the API when your app shows them. Never from Kindgi's database, and never by sending users to Kindgi's console: see [Show runs in your app](../../guides/runs/show-runs-in-your-app/).
# Install
> What you need on your machine, and how a project gets the Kindgi CLI and SDK.
## What you need
* **Node 22.12 or later.** The `kindgi` CLI is a Node program, for TypeScript and Python projects alike.
* **Docker** (Docker Desktop, or the Docker engine on Linux). `kindgi dev` runs the Kindgi runtime as a container, and a Postgres container for it: with `docker compose` when it's there, with plain `docker` otherwise. With your own database (Postgres 16 with pgvector, passed as `--database-url`), it starts no Postgres.
* **For a Python pack:** Python 3.11 or later, and uv (or Poetry, or pip). The CLI still needs Node 22.12.
Nothing else: the runtime is a container image that `kindgi dev` pulls and runs for you.
Private preview
The runtime image is in private preview: request access at . With the pull credentials you receive (a robot name and a token), log in to its registry once. The CLI asks for the token without showing it, hands both to `docker login` (Kindgi keeps no copy), and checks that you can pull the image it runs:
```sh
npx --yes @kindgi/cli@0.1 auth registry --username
```
```text
✓ You can pull quay.io/kindgi/runtime:…@sha256:…, the image this CLI runs.
```
The first `kindgi dev` then pulls the image (about 700 MB; `amd64` and `arm64`).
## A TypeScript project
The CLI is **project-local**: a project depends on `@kindgi/cli` as a devDependency and runs the version it pins, so everyone on the project runs the same one. `kindgi init` adds it, with `@kindgi/sdk`:
```sh
npx @kindgi/cli init my-pack # a new pack; in an existing app: npx @kindgi/cli init
```
From then on, run `kindgi` through the project's package manager:
| Package manager | Command |
| --------------- | ---------------------- |
| pnpm | `pnpm exec kindgi dev` |
| npm | `npx --no kindgi dev` |
| yarn | `yarn kindgi dev` |
| bun | `bun run kindgi dev` |
Use the scoped name, `@kindgi/cli`: there is no unscoped `kindgi` package on npm.
## A Python project
A Python app has no npm project for the CLI, so run it with `npx` (it comes with Node 22), pinned to Kindgi's minor version:
```sh
npx --yes @kindgi/cli@0.1 init my-pack --template=python
```
In a Python project, every `kindgi ` in these docs is `npx --yes @kindgi/cli@0.1 `. (`--yes` skips npx's install prompt, so a coding agent never waits on it.)
The Python SDK is the `kindgi` package. `kindgi init` adds it to your `pyproject.toml`; to add it yourself:
```sh
uv add kindgi # or: poetry add kindgi, or: pip install kindgi
```
## Where `kindgi dev` keeps its data
`kindgi dev` gives each project its own database in the Postgres it starts: `kindgi_`, and in a git worktree `kindgi___`. The project's name is `project` in the Kindgi config (`kindgi.config.ts`, or `[tool.kindgi]` in `pyproject.toml`); without it, the git repository's, the workspace root's or the pack folder's. It says which when it starts:
```text
✓ Project: acme-desk (the pack's folder name; set `project` in the Kindgi config to name it)
✓ Database: kindgi_acme_desk (created) in the bundled Postgres; to use your own: --database-url
```
* **Starting over:** `kindgi dev --reset` drops the project's database after asking, and makes a new token. `--yes` skips the question, for scripts; without a terminal to ask on and without `--yes`, it refuses. A database you pass with `--database-url` is never dropped.
* **Several packs in one project** share its database and tenant, but not each other's tools: under `kindgi dev`, a flow in one pack can't call another pack's tools.
* **Coming from Kindgi 0.1.2 or earlier,** where every project shared one database: that database stays as it was, and the project's first start says so once. Register your model providers and reviewers again in each project:
```text
This project now has its own database, kindgi_acme_desk. The old shared `kindgi` database is left as it is: projects on an older Kindgi still use it. Providers and reviewers are set up once per project: set them up here again.
```
## Calling Kindgi from an application
An app that only starts runs and reads their results (no pack of its own) needs just the SDK's client:
* **TypeScript:** `@kindgi/sdk`, and `createClient` from `@kindgi/sdk/client`.
* **Python:** `kindgi`, and `Kindgi` (or `AsyncKindgi`) from `kindgi.client`.
## Next
* [Quickstart: TypeScript](../quickstart-typescript/)
* [Quickstart: Python](../quickstart-python/)
* [Add Kindgi to an existing app](../existing-app/)
# What is Kindgi?
> Packs, the runtime, and how your app and your coding agent work with them.
Kindgi runs the AI parts of your application (tools, agents, flows and guardrails) durably, on infrastructure you choose, and records every step they take.
## Your code, as a pack
A **pack** is the Kindgi part of your app, written in the app's own language and living in its repository:
* **Tools:** functions the app already has, exposed to agents and flows: a database lookup, a ticket write, an HTTP API.
* **Agents:** a model with instructions and tools, answering with a typed result your code can rely on.
* **Flows:** your process as steps and branches: an agent triages, a tool writes, an approval waits for a person.
* **Guardrails:** checks on what an agent says or does before it counts.
You write packs in **TypeScript** (`@kindgi/sdk`) or **Python** (the `kindgi` package). Tools call your app's own modules; nothing is rewritten for Kindgi.
## The runtime
The Kindgi runtime runs agent turns and flows **durably**: each step is journaled, a run can be resumed, and long work continues in the background while your app carries on. Models come from providers you register: Anthropic, Gemini, or any OpenAI-compatible endpoint, including an open model served inside your own network.
Your app talks to the runtime over HTTP: it starts runs, follows their events as they happen, and receives signed webhooks when they finish.
## Four ways in
* **The CLI** (`@kindgi/cli`): create a pack, run it locally with `kindgi dev`, build and deploy it.
* **The HTTP API:** every operation, for any language.
* **The SDKs:** TypeScript and Python clients for the API.
* **The console:** in your browser, at `/console` on any runtime (with `kindgi dev`, `http://127.0.0.1:4000/console/`). Each run has one page: what the agent was asked, the tools it called and their results, its answer, the model and the cost; its journal, one sentence per step; and where the answer came from. It also has approvals to approve or reject, conversations, and the agents and tools your pack defines. A project's pages show that project's records, with a switch to see all of them.
## Your coding agent
`kindgi init` installs skills for Claude Code into the project, so your coding agent writes tools, agents, flows and guardrails the way Kindgi expects, without you learning a new API first. The skills also tell it which commands to run: `kindgi dev` to start Kindgi, `kindgi runs start` to try what it wrote, and the run's journal when something fails. [How it knows Kindgi](../coding-agents/).
## Where it runs
* **On your machine:** `kindgi dev` runs the runtime as a container next to your pack's code and picks up every save. (The runtime image is in private preview: request access at .)
* **In your own cloud:** you deploy the same runtime image into your project; your data and prompts stay there.
* **Kindgi Cloud:** we operate the runtime for you. It's in private preview.
## Status
Kindgi is in **preview**: APIs may change between `0.x` releases. These docs are versioned with each release; the version menu shows which one you're reading.
The SDKs are Apache-2.0. The runtime is source-available under the Business Source License 1.1.
# Quickstart: Python
> Create a Python pack with tools, an agent, a guardrail and a flow, run it on your machine, and connect a real model.
The same pack as the [TypeScript quickstart](../quickstart-typescript/), in Python: two tools, an agent that calls them, a guardrail and a flow.
**Before you start**, set up what the [Install page](../install/) describes: Node 22.12 (for the CLI), Docker, Python 3.11 and uv, and access to the runtime image. The image is in private preview: request access at , then log in once with `kindgi auth registry`.
## 1. Create the pack
```sh
npx --yes @kindgi/cli@0.1 init my-pack --template=python
cd my-pack
uv sync # a .venv with the kindgi package
uv run pytest # the template's tests: the tools and the check, called directly
```
On Kindgi 0.1.0 (fixed in 0.1.1)
`init` from npm writes no `.gitignore`, so git would track `.env` files (where model keys go) and `.kindgirc.json` (the dev token). Before your first commit:
```sh
printf '%s\n' .venv/ __pycache__/ .kindgi/ .kindgirc.json .env '.env.*' >> .gitignore
```
The pack's config is the `[tool.kindgi]` table of its `pyproject.toml`; its tools, guardrails, agents and flows live in four folders:
```plaintext
my-pack/
├── pyproject.toml # [tool.kindgi]: the pack's id, version and folders
├── tools/echo.py, tools/greet.py # @tool
├── guardrails/response_not_empty.py # @guardrail
├── agents/echo_agent.py # Agent(...)
├── flows/echo_flow.py # Flow(...)
├── tests/test_tools.py
└── .claude/skills/ # skills for your coding agent
```
Or ask your coding agent
The pack already has Kindgi's skills in `.claude/skills/`. Follow the steps below yourself, or ask your coding agent ("run the pack and try the agent", "add a tool that looks up an order"): the skills tell it which commands to run. [How it knows Kindgi](../coding-agents/).
## 2. Run it
```sh
npx --yes @kindgi/cli@0.1 dev
```
`kindgi dev` starts the Kindgi runtime in Docker, indexes the pack with the pack's own Python (`.venv/bin/python`), runs its tools and checks in a pack service, and reloads on every save. It writes the API's URL and a token to `.kindgirc.json`, so the commands below find the runtime by themselves. Leave it running.
## 3. Run the agent and the flow
In a second terminal, in `my-pack`:
```sh
npx --yes @kindgi/cli@0.1 runs start --agent=my-pack.echo-agent --input='{"userMessage":"Ada"}'
npx --yes @kindgi/cli@0.1 runs start --flow=my-pack.echo-flow --input='{"message":"Ada"}'
```
```text
"status": "completed",
…
⚠ Answered by "dev-echo", a fallback provider: no other registered provider satisfies agent "my-pack.echo-agent".
…
"echo": "Ada",
```
Without a model, the agent's answer comes from `dev-echo`, a stand-in that calls the agent's first tool with `{"message": }` and replies with what it returned (the run carries a `fallback-provider` warning). The flow runs the `echo` tool on its input and returns what the tool returned. Either way, your Python tool ran: the runtime called it over HTTP in the pack service.
dev-echo checks the wiring, nothing more
It can't fill in any other tool input, and it can't produce a typed answer (an agent with an `output` fails with `output-schema-violation`). Connect a model ([step 5](#5-connect-a-real-model)) before you write an agent of your own.
## 4. Look at the code
A tool is a typed Python function. Pydantic models are its input and output, checked on every call:
tools/echo.py
```python
from datetime import UTC, datetime
from pydantic import BaseModel, Field
from kindgi import tool
class EchoInput(BaseModel):
message: str = Field(min_length=1, max_length=500)
class Echo(BaseModel):
echo: str
echoed_at: str = Field(alias="echoedAt")
character_count: int = Field(alias="characterCount", ge=0)
@tool(id="my-pack.echo")
def echo(input: EchoInput) -> Echo:
"""Echoes the caller-provided message with a UTC timestamp and character count."""
return Echo(
echo=input.message,
echoedAt=datetime.now(UTC).isoformat(),
characterCount=len(input.message),
)
```
An agent is data, and it refers to the tools themselves, not to strings:
agents/echo_agent.py
```python
from kindgi import Agent
from ..guardrails.response_not_empty import response_not_empty
from ..tools.echo import echo
from ..tools.greet import greet
echo_agent = Agent(
id="my-pack.echo-agent",
version="0.1.0",
name="Echo Agent",
description="Uses the pack's echo and greet tools; the response-not-empty guardrail guards the output.",
instructions=(
"For each user message: if the user sends a name, invoke `my-pack.greet` with it. "
"Otherwise invoke `my-pack.echo` with the message text. Quote the tool result verbatim."
),
capabilities=[{"needs": [{"feature": "tool-use"}]}],
tools=[echo, greet],
guardrails=[response_not_empty],
conversation_policy={"historyLimit": 10},
budget={"maxSteps": 4, "maxCostUsd": 0.1, "maxWallMs": 60_000},
)
```
`uv run python -m kindgi.pack index --pack-dir .` prints what Kindgi sees. A file with an error is reported with its path, and the rest of the pack keeps serving.
## 5. Connect a real model
Store an Anthropic key as a secret (you're prompted for it; it isn't echoed), then register the provider:
```sh
npx --yes @kindgi/cli@0.1 secrets set ANTHROPIC_API_KEY --env=local --scope=tenant
npx --yes @kindgi/cli@0.1 providers register --preset=anthropic
```
It takes over from `dev-echo` at the next turn. Gemini on Vertex AI has a preset too; any OpenAI-compatible endpoint registers from a short spec file.
The registration is in this project's dev database. To have `kindgi dev` register the model on every boot, in each worktree and after `--reset`, declare it in `pyproject.toml`: [Declare them in your pack's config](../../guides/models/#declare-them-in-your-packs-config).
## Next
* [Add Kindgi to an existing Python app](../existing-app/#a-python-app): your app's own modules as tools.
* [Build a support desk](../../tutorials/support-desk-python/) in Python.
* [Guides](../../guides/): one task at a time.
* [Concepts](../../concepts/): packs, runs and the journal, security.
* [The Python SDK reference](../../reference/python/).
* [Set up your coding agent](../coding-agents/): it already has Kindgi's skills.
# Quickstart: TypeScript
> Create a pack with tools, an agent, a guardrail and a flow, run it on your machine, and connect a real model.
In ten minutes: a pack with two tools, an agent that calls them, a guardrail on its answers and a flow, running on your machine.
**Before you start**, set up what the [Install page](../install/) describes: Node 22.12, Docker, and access to the runtime image. The image is in private preview: request access at , then log in once with `kindgi auth registry`.
## 1. Create the pack
```sh
npx @kindgi/cli init my-pack --template=sample
cd my-pack
pnpm install
```
On Kindgi 0.1.0 (fixed in 0.1.1)
`init` from npm writes no `.gitignore`, so git would track `.env` (where your model key goes) and `.kindgirc.json` (the dev token). Before your first commit:
```sh
printf '%s\n' node_modules/ dist/ .kindgi/ .kindgirc.json '*.tsbuildinfo' .env .env.local >> .gitignore
```
`my-pack` is the pack's id: every tool, agent and flow in it is named `my-pack.`. The `sample` template gives you:
```plaintext
my-pack/
├── kindgi.config.ts # the pack's id, version and folders
├── tools/echo/index.ts # a tool: echoes a message
├── tools/greet/index.ts # a tool: greets a name
├── tools/fetch-httpbin/index.ts # a tool that calls an HTTP API
├── agents/echo-agent/index.ts # an agent that calls the tools
├── guardrails/response-not-empty/ # a check on the agent's answers
├── flows/echo-flow/index.ts # a flow: a tool step, then the agent
└── .claude/skills/ # skills for your coding agent
```
Or ask your coding agent
The pack already has Kindgi's skills in `.claude/skills/`. Follow the steps below yourself, or ask your coding agent ("run the pack and try the agent", "add a tool that looks up an order"): the skills tell it which commands to run. [How it knows Kindgi](../coding-agents/).
(The default template, `minimal`, gives you the folders and none of the examples.)
## 2. Run it
```sh
pnpm exec kindgi dev
```
`kindgi dev` starts the Kindgi runtime in Docker, indexes the pack, registers every tool, agent, guardrail and flow, and does it again on every save. It prints the API's URL and a token, and writes them to `.kindgirc.json` in the pack, so the commands below find the runtime by themselves. Leave it running.
## 3. Run the agent
In a second terminal, in `my-pack`:
```sh
pnpm exec kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}'
```
```text
"status": "completed",
…
⚠ Answered by "dev-echo", a fallback provider: no other registered provider satisfies agent "my-pack.echo-agent".
```
There's no model yet, so the answer comes from `dev-echo`, a stand-in a new pack gets: it calls the agent's first tool with `{"message": }` and replies with what the tool returned, and the run carries a `fallback-provider` warning. That's enough to see the whole path: the agent's turn, the tool call into your code, the guardrail's check.
dev-echo checks the wiring, nothing more
It can't fill in any other tool input, and it can't produce a typed answer (an agent with an `output` schema fails with `output-schema-violation`). Connect a model ([step 6](#6-connect-a-real-model)) before you write an agent of your own.
## 4. Run the flow
```sh
pnpm exec kindgi runs start --flow=my-pack.echo-flow --input='{"name":"Ada"}'
```
```text
"status": "completed",
…
"greeting": "Hello, Ada!"
```
The flow greets the name with the `greet` tool, then hands the greeting to the agent and returns both. Add `--dry-run` to see which steps would run without running the tools that change anything.
## 5. Look at the code
A tool is a typed function. Its input and output are schemas, checked on every call:
tools/greet/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const GreetInput = z.object({
name: z.string().min(1).max(100),
greeting: z.string().min(1).max(50).default('Hello'),
});
const GreetOutput = z.object({
message: z.string(),
});
const defined = defineTool({
id: 'my-pack.greet' as ToolId,
description: 'Formats a greeting for the named recipient.',
version: '0.1.0',
input: GreetInput,
output: GreetOutput,
effects: [],
mutating: false,
handler: async (input) => ({
message: `${input.greeting}, ${input.name}!`,
}),
});
if (defined.kind === 'err') {
throw new Error(`my-pack.greet failed to compile: ${defined.error.message}`);
}
export default defined.value;
```
`mutating: false` says the tool changes nothing, so a dry run calls it and approval gates don't stop it by default. Leave it out for a tool that writes, sends or charges anything: a tool is treated as changing something unless it says otherwise. `effects` names what a tool does outside your code (writes, network calls), for policies and the audit trail.
The agent is data: its instructions, the tools it may call, the guardrails on its answers, its budget. Change a file and save; `kindgi dev` picks it up.
## 6. Connect a real model
Put an Anthropic key in the pack's `.env`, then register the provider:
```sh
echo 'ANTHROPIC_API_KEY=sk-ant-…' >> .env
pnpm exec kindgi providers register --preset=anthropic
```
It takes over from `dev-echo` at the next turn. Run the agent again and it answers with a real model, still calling your tools. Gemini on Vertex AI has a preset too (`--preset=gemini --project=`); any OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, OpenRouter) registers from a short spec file.
The registration is in this project's dev database. To have `kindgi dev` register the model on every boot, in each worktree and after `--reset`, declare it in `kindgi.config.ts`: [Declare them in your pack's config](../../guides/models/#declare-them-in-your-packs-config).
## Next
* [Build a support desk](../../tutorials/support-desk-typescript/): tools over your own code, a typed answer, a flow that acts on it.
* [Guides](../../guides/): one task at a time: tools, agents, models, flows, runs, webhooks, approvals, secrets.
* [Add Kindgi to an existing app](../existing-app/): your app's own code as tools, and your app starting runs.
* [Concepts](../../concepts/): packs, runs and the journal, security.
* [Set up your coding agent](../coding-agents/): it already has Kindgi's skills.
# Concepts
> How Kindgi works, and why it works that way.
The ideas behind Kindgi: how it fits your app, packs and their primitives, runs and the journal, the security model, and licensing. Read these to understand a behavior; read [Guides](../guides/) to get something done.
# How Kindgi fits your app
> Your app, your pack's code, the Kindgi runtime and the models — what runs where, and how a run moves between them.
Kindgi sits beside your application, not in front of it. Your app keeps its own code, data and users; Kindgi runs the agents and flows you define, calls your code when they need it, and records every step.
```plaintext
Your app ──── HTTP: start runs, follow them ────▶ Kindgi runtime
▲ │ API, run engine,
│ signed webhooks (run.finished) │ journal (Postgres)
└────────────────────────────────────────────────┤
├──▶ Model providers
│ (Anthropic, Gemini,
│ OpenAI-compatible,
│ a model you serve)
│
└──▶ Pack service
your tools and
guardrail checks,
your code
```
## The parts
* **Your app** starts runs and reads their results over HTTP, through the TypeScript or Python SDK. It can wait for a run, or start it in the background and get a signed webhook when it ends.
* **Your pack** is the Kindgi part of your repository: tools, guardrails, agents and flows. Agents and flows are data the runtime runs; tools and guardrail checks are code.
* **The pack service** runs that code: a process built from your app (Node or Python), with your app's dependencies, in your environment. The runtime calls it over HTTP for each tool call, with a deadline, and it can reach whatever your code reaches: your database, your services.
* **The runtime** is Kindgi itself: the API, the engine that runs agents and flows step by step, the journal of every step in Postgres, the secrets your tools use, and the calls to model providers. It ships as a container image.
* **Model providers** are registered per tenant: a vendor's API, or a model you serve inside your own network. An agent states what it needs; Kindgi picks a model that fits.
* **The console**, at `/console` on any runtime, shows each run on one page (what it did and cost, its journal, and where an agent's answer came from), the agents, tools and conversations, project by project; reviewers approve or reject there.
## Where it runs
The same runtime image runs everywhere:
* **On your machine:** `kindgi dev` starts it as a container, with a Postgres, and runs your pack's code on your machine, reloading on every save.
* **In your infrastructure:** you deploy the runtime and your pack service next to your data. Prompts, data and keys stay there. See [Deploy](../../deploy/).
* **Kindgi Cloud:** we operate the runtime for you (private preview).
Private preview
The runtime image is in private preview: request access at .
## What stays yours
Your code stays in your repository and runs in your process. Your data stays where your code reads it. Secrets your tools need are referenced by name and resolved for each call; Kindgi doesn't copy them into your code. With a model you serve inside your network, your prompts stay there too.
# Licensing
> Free to build, paid for production, and how the runtime's license key works.
Kindgi is free to build with. Running it in production needs a commercial license, which comes as a license key the runtime checks when it starts.
## Two licenses
| Part | License | In short |
| ---------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The SDKs and the CLI: the `@kindgi/*` npm packages and the `kindgi` Python package | Apache-2.0 | Use, change and ship them, commercially too. |
| The runtime: the server that runs your packs, and its container image | Business Source License 1.1 | Free for non-production use, with no time limit. Production use needs a commercial license. Each release becomes Apache-2.0 four years after it's first published. |
**Non-production** means building, testing and evaluating software with the runtime, including CI and staging environments that serve no end users and process no live business data.
**Production** means using the runtime to serve end users or customers, or to process live business data as part of an organization's operations. That includes internal systems your own employees use.
## Building needs no key
```sh
kindgi dev
```
`kindgi dev` runs the runtime in development mode (`KINDGI_DEV=true`), which needs no license key. Everything you build and test on your machine is free.
Private preview
The runtime image is in private preview: request access at .
## Running outside development mode
Anywhere else (a server, staging, CI that runs the runtime itself), give the runtime its key in `KINDGI_LICENSE_KEY`, from your secrets store:
```sh
KINDGI_LICENSE_KEY=kgi_lk_…
```
When it starts, the runtime checks the key and prints the license under its banner:
```text
License: Docs example · non-production · until 2027-10-03
```
The check is **offline**: the runtime verifies the key's signature against public keys built into the release. It never calls Kindgi, at startup or later.
There are two kinds of key, both from :
* **A production key** comes with a commercial license.
* **A non-production key** is free, for staging and CI that run the runtime outside development mode.
The runtime starts with either and prints which one it has.
## Without a valid key
Outside development mode, a runtime with no key doesn't start. It exits with code 2 and says why:
```text
KINDGI_LICENSE_KEY is not set. Outside development mode the Kindgi runtime needs a license key: a production key comes with a commercial license, and a free non-production key covers staging and CI. To get one: contact@kindgi.com. Local development needs none: `kindgi dev` runs the runtime with KINDGI_DEV=true.
```
A key that was changed, or that Kindgi didn't issue, is refused the same way, with its own message.
## When a key expires
* **From 30 days before** the expiry date, the banner warns that the key expires soon.
* **After it expires,** the runtime still starts for 14 days, with a warning that says until when.
* **After those 14 days,** it refuses to start, naming the date the key expired.
```text
KINDGI_LICENSE_KEY expired on 2026-07-31, more than 14 days ago. Renew it: contact@kindgi.com.
```
The key is checked only when the runtime starts, so a running server never stops because of its key. A platform that restarts instances on its own (scaling, a deploy) keeps working through the grace period while you renew.
# Packs and primitives
> Tools, agents, flows and guardrails — what each one is, and how a pack holds them.
A **pack** is everything Kindgi runs for your app, defined in your app's own language. It has an id (`acme-desk`) and a version, and holds four kinds of **primitives**, each with its own id under the pack's (`acme-desk.triage`).
## Tools: your code
A tool is a function with a typed input and output. Kindgi checks both on every call, so an agent can't hand your code something malformed.
* **Code tools** are your functions (TypeScript `defineTool`, Python `@tool`). They run in the pack service, so they import your app's modules and reach what your app reaches.
* **HTTP tools** are one HTTP request, declared with no handler (`http_tool` in Python, an `http` spec in TypeScript). The runtime makes the request, resolving its secret for each call.
* A tool declares whether it **changes anything**. One declared read-only (`mutating: false`) runs in a dry run, and approval rules can skip it.
## Agents: a model with instructions and tools
An agent is data: instructions, the tools it may call, the guardrails on its answers, a budget (steps, cost, time), and what it needs from a model (tool use, typed output). It can answer with a **typed result**, a schema Kindgi checks; if the model's answer doesn't fit, Kindgi asks it to repair the answer once, then fails the turn instead of passing on bad data.
## Flows: your process
A flow is your process as data: steps and the edges between them.
* **Steps** run a tool or an agent, with their input mapped from the run's input or earlier steps' outputs.
* **Edges** can carry conditions (`when`), so a flow branches: an agent triages, and only an urgent message reaches the step that writes a ticket.
* Flows can join branches, loop over items, and fan out to run steps in parallel.
## Guardrails: checks on what an agent does
A guardrail is a check over an agent's turn: its answer, its tool calls, their results. It passes or fails, with a reason. A failed check can halt the turn or be recorded as a violation.
## How a pack is found
Kindgi finds primitives by folder: `tools/`, `agents/`, `flows/` and `guardrails/` (under `kindgi/` when the pack lives inside an app). The **indexer** reads them into the pack's `index.json`, the same file for a TypeScript or a Python pack, and the runtime registers what it lists. `kindgi dev` re-indexes on every save; `kindgi build` puts the index in the image.
See the [Packages](../../reference/packages/) and [JSON Schemas](../../reference/schemas/) reference for every field.
# Runs, the journal and durability
> What a run is, how every step is recorded, and how long work survives and continues.
A **run** is one execution of a flow, or one turn of an agent. Every run has an id, a status, and a **journal**: the ordered record of everything that happened in it.
## The journal
Each step that starts, completes or fails, and each edge the run follows or skips, is written to the journal before the run moves on, with its inputs and outputs. An agent's turn is a run of its own, with the model calls (the provider, the model, the tokens and the cost) and the tool calls in order.
```sh
kindgi runs journal
```
The journal is how you answer "why did it do that?" after the fact: which step ran, with what input, what the model was asked and what it answered, which branch the flow took.
## Waiting, or not
Your app chooses how to start a run:
* **Wait** for it: the call returns when the run completes or fails, with its output.
* **Start it in the background** (`wait: false`): the call returns as soon as the run exists. Follow it with its events (a stream your browser can read directly, with a short-lived read-only token), or let Kindgi send your app a signed `run.finished` webhook when it ends.
## Durable
A run's state is the journal, not a process's memory. A run that waits for a person's approval is parked at a **waitpoint**, and continues when a reviewer decides the approval:
```sh
kindgi approvals complete --decision=approve
```
Retries are safe: a start with the same **idempotency key** returns the run the first call started.
### If the runtime stops
A runtime that's shut down (a deploy, `docker stop`) gives the runs it's executing a few seconds to finish ([Operate](../../deploy/operate/#restart-the-runtime)). One that stops without a shutdown (killed, out of memory, a crash) leaves its runs mid-way. Kindgi finds them by their **lease**: the server executing a run renews it while the run goes on. Every server sweeps for leases that ran out (`KINDGI_RUN_LEASE_MS`, default 5 minutes; `KINDGI_RUN_SWEEP_INTERVAL_MS`, default a minute), and within about a lease plus a sweep:
* **A run that was executing fails**, and `run.finished` tells your app.
* **A run whose wait was resolved** (its approval decided) but that nothing resumed **is resumed**. After three failed attempts it fails: `Interrupted: the run was ready to continue, but every attempt to resume it failed.`
* **A flow waiting on a child run that already ended is woken**, and goes on.
* **A run whose parent run ended is ended too:** cancelled when the parent was cancelled, otherwise failed: `Interrupted: its parent run had ended (failed), so nothing waits for it.` So an approval inside a cancelled flow can't run its tools.
A run that was just started gets at least a minute before a sweep can pick it up, whatever the lease.
## Dry runs
A dry run (`--dry-run`) runs a flow without changing anything: only the tools declared read-only (`mutating: false`) run, and an agent's turn skips its model call. Use it to check a flow's wiring before it touches real data.
## Provenance and cost
Because every step is recorded, every answer can be traced to where it came from: the agent's turn, the model call, the tool results it used. `GET /v1/provenance/{runId}` returns that graph; [Trace an answer](../../guides/observability/trace-an-answer/) reads it. Each model call is recorded with its tokens and cost too, so you can read what a run, a flow's whole run or one customer's month cost: [Cost per run and per customer](../../guides/observability/cost-per-run/).
In the console, a run's page shows all of it: what the run did and what it cost (Overview), its journal, one sentence per step (Journal), and where the answer came from, as a list of steps or a graph (Provenance).
# Security
> How Kindgi keeps tenants apart, authenticates callers and handles secrets, and what to know before you deploy it.
This page covers four things: what keeps one tenant's data from another's, who may call the API, what a tenant can't make the server do, and where secrets live. It ends with what to check before you deploy.
## Tenants and isolation
Every record the runtime stores (runs and their journals, agents, flows, tools, conversations, secrets) carries its **tenant** id. Postgres **row-level security** restricts each query to the tenant it runs for. Requests are handled as a database role that can't bypass those policies, so a query that forgot its tenant finds nothing rather than another tenant's rows.
A runtime serves the tenant it's started with, `KINDGI_TENANT_ID`. Without one, it creates a new tenant at every start and prints its id, so set it for anything that should keep its data across restarts. For hard separation between customers or environments, give each its own runtime and database.
## Calling the API
Every `/v1` request carries the deployment's API token:
```sh
curl -H "Authorization: Bearer $KINDGI_API_TOKEN" "$KINDGI_API_URL/v1/runs?limit=1"
```
Set it with `KINDGI_API_TOKEN` (a `kgi_bt_…` value from your secrets store). Without it, the runtime generates one at each start and prints it. Tokens are compared in constant time. A wrong or missing token gets `401`:
```json
{"error":{"code":"auth-missing","message":"Bearer token is not recognized","requestId":"req-88fd4597-bed6-4936-9906-83fafe44712d"}}
```
To rotate the token, restart the runtime with a new value.
## Following a run from a browser
Your backend keeps the API token. A browser that shows a run's progress gets a **public run token** (`kgi_pt_…`) instead: `POST /v1/runs` returns one as `publicAccessToken`, and your backend can mint more with `POST /v1/tokens/public`.
A public run token is:
* **read-only:** it opens only `GET /v1/runs/{runId}/progress` and that run's event stream (`/progress/stream`);
* **limited to the runs it names:** up to 50;
* **short-lived:** 15 minutes by default, 24 hours at most.
With it, a browser can follow the run:
```sh
curl -H "Authorization: Bearer $PUBLIC_TOKEN" "$KINDGI_API_URL/v1/runs/$RUN_ID/progress"
```
```json
{"id":"086f8e9b-9368-4f3a-9c28-9a84fb3f1a09","flowId":"agent.turn","flowVersion":"1.1.0","status":"completed","createdAt":"2026-10-03T15:21:50.698Z","updatedAt":"2026-10-03T15:21:52.115Z","completedAt":"2026-10-03T15:21:52.115Z"}
```
Anything else it tries is refused with `403`, even reading the same run:
```json
{"error":{"code":"permission-denied","message":"A public run token can only follow the runs it names: GET /v1/runs/{runId}/progress and GET /v1/runs/{runId}/progress/stream","requestId":"req-0557ce73-7ae8-4b3f-b3b4-a70006d57d37"}}
```
Outside development mode, public run tokens are on only when the runtime has a signing key (`KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH`, an Ed25519 key used for nothing else). Browser origins that may call those routes are listed in `KINDGI_CORS_ORIGINS`; with none listed, no CORS headers are sent.
## What a tenant can't make the server do
What a tenant registers (an MCP server, a model provider, a webhook) shouldn't reach the machine the runtime runs on. `KINDGI_TENANT_HOST_ACCESS` controls that:
* **`deployed`** (the default outside development mode) refuses an MCP endpoint that would run a command on the server (`stdio`), when it's registered and when the runtime connects to it. Run MCP servers over HTTP instead.
* **`local`** (the default in development mode) allows it. Use it only on a machine where everyone holding an API token may run commands anyway.
Registering a `stdio` endpoint with the default:
```json
{"error":{"code":"host-access-denied","message":"MCP endpoint \"acme.docs\" uses the stdio transport, which runs a command on the server's host; KINDGI_TENANT_HOST_ACCESS=deployed refuses that. Run the MCP server over HTTP (streamable-http) instead.","requestId":"req-61bc35bf-9ca4-4894-b041-a5babbc7ec3f"}}
```
Outside development, a webhook goes only to a public address, and only over https. Kindgi checks this when the webhook is registered, and again at every delivery, for every address the receiver's name resolves to. A self-hosted runtime whose receiver is on its own private network sets `KINDGI_WEBHOOK_PRIVATE_NETWORKS=allow`, which opens RFC 1918, CGNAT and IPv6 unique-local addresses. Loopback and the cloud metadata addresses stay refused.
## Secrets by reference
The runtime stores the **names** of secrets, not their values, wherever it can:
* a model provider's API key, an MCP endpoint's credentials and a webhook endpoint's signing secret are each a `secretRef`, a name resolved in the tenant's own secrets when it's needed;
* a tool's code receives the secrets it declares, through its context (`ctx.secrets`). In development only, the pack's process also sees the values in your env files, since `kindgi dev` reads them for it.
The values live where your other secrets live: `.env` files in development, and in production Postgres, envelope-encrypted with a key held in your cloud's KMS (`KINDGI_SECRETS_BACKEND=postgres`, `KINDGI_SECRETS_BACKEND_KMS=gcp`).
Webhooks the runtime sends (such as `run.finished`) are signed with the endpoint's secret, so your app can check that they came from your runtime.
## Before you deploy
Postgres
The runtime's database user needs `CREATEROLE` and must own the runtime's database: the runtime uses it to set up the restricted role that requests run as. It needn't be a superuser, so managed Postgres services such as Cloud SQL, Amazon RDS and AlloyDB work. Create the `vector` extension once ([Self-host](../../deploy/self-host/)). With Kindgi 0.1.0, it had to be a superuser.
* **Development mode is for development.** `KINDGI_DEV=true` turns on settings meant for one machine (secrets from `.env` files, a console login that hands out the API token) and needs no license key. Never set it on a server others can reach.
* **Keep keys in your secrets store:** the API token, the license key (see [Licensing](../licensing/)), the public-token signing key, and the key that protects stored secrets.
Private preview
The runtime image is in private preview: request access at .
# Guides
> How to do one thing with Kindgi, in TypeScript and in Python.
Short, task-focused pages. Each assumes the basics from [Start](../start/), shows TypeScript and Python side by side, and was run as written against the version of Kindgi these docs describe.
* [Tools](tools/): write a tool in TypeScript or Python, call an HTTP API without code, give a tool a secret, mark it read-only, and use an MCP server's tools.
* [Agents](agents/): write an agent, give it input, get a typed answer, choose its model, hold a conversation and cap what a turn may spend.
* [Models](models/): connect Anthropic, Gemini on Vertex AI, an OpenAI-compatible endpoint, or a model you serve yourself.
* [Flows](flows/): pass data between steps, branch, loop, run steps in parallel, retry, wait for a person, and dry-run a flow.
* [Runs](runs/): start runs from the CLI or your app, retry safely, follow them live (from the browser too), read their journal, cancel and list them.
* [Webhooks](webhooks/): get a signed `run.finished` request when a run ends, verify it in your app, and test and replay deliveries.
* [Guardrails](guardrails/): check an agent's answers, give a check its settings, and choose whether a failure stops the turn.
* [Approvals](approvals/): have a person approve what an agent does before it happens.
* [Secrets and env](secrets/): where a pack's settings and secrets live on your machine and in a deployment, and how your code gets them.
* [Cost and provenance](observability/trace-an-answer/): trace an answer to the model and tool calls behind it, and what each call cost.
# Agents
> Write an agent, give it input, get a typed answer, choose its model, hold a conversation and cap what a turn may spend.
An agent is data: instructions, the tools it may call, the guardrails on its answer, what its model must support, and a budget. Each run of an agent is one **turn**: the model reads the instructions and the user's message, calls tools until it has an answer, and the answer is checked and stored. A turn is a run like any other, with a journal of every model call and tool call.
* [Write an agent](write-an-agent/): the agent file, its instructions, tools and guardrails.
* [Give an agent its input](give-an-agent-input/): the user message, parameters for its instructions, and the input of a flow step.
* [Give an agent a typed answer](typed-answer/): an answer your code can read as fields, checked against a schema.
* [Choose the model an agent uses](choose-a-model/): capabilities, preferences, and the `dev-echo` fallback.
* [Hold a conversation](conversations/): continue a conversation turn by turn, read its history, close it.
* [Set an agent's budget](budgets/): steps, cost and time per turn, and what happens when a turn runs out.
The examples use a pack named `acme` (from the `sample` template) with one more tool, `acme.lookup-order`, shown on [Write an agent](write-an-agent/). Where a real model matters, the output is from Claude Haiku 4.5, registered with `kindgi providers register --preset=anthropic` (see [Connect Anthropic](../models/anthropic/)). Without a model, `dev-echo` answers.
# Set an agent's budget
> Cap the model calls, the spend and the time of each agent turn, and handle a turn that runs out.
A budget caps each turn of an agent: how many model calls it makes, what they cost, and how long it takes. A turn that runs out fails, and the run says which limit it hit.
* TypeScript
```ts
// in agents/order-desk/index.ts
budget: { maxSteps: 6, maxCostUsd: 0.05, maxWallMs: 60_000 },
```
* Python
```python
# in agents/order_desk.py
budget={"maxSteps": 6, "maxCostUsd": 0.05, "maxWallMs": 60_000},
```
| Field | Caps | Default |
| ------------ | -------------------------------------- | ------- |
| `maxSteps` | Model calls in the turn. | 8 |
| `maxCostUsd` | The turn's model spend, in US dollars. | none |
| `maxWallMs` | The turn's time, in milliseconds. | 120000 |
A **step** is one model call. The tool calls the model asks for run between steps, so a turn that looks an order up and then answers takes two. Repairing a [typed answer](../typed-answer/#when-the-answer-doesnt-fit) takes a step too.
Cost is what the models' registered prices make of the tokens each call used (see [Models](../../models/)); a model registered with a price of zero never uses the cost budget.
## When a turn runs out
Each limit fails the turn with its own error. These are from `kindgi runs start … --verbose` on `acme.order-desk`, with one limit set low at a time.
**Steps.** After a model call that asks for more tool calls, with no steps left:
```text
Error [server]: Agent turn steps budget exceeded (limit 1, observed 1)
{
"code": "server",
"serverCode": "budget-exceeded",
"message": "Agent turn steps budget exceeded (limit 1, observed 1)",
"fields": {
"kind": "steps",
"limit": 1,
"observed": 1,
"runId": "32e87e56-e221-489c-be0e-74885038520d"
}
}
```
**Cost.** Checked after each model call, against the turn's total so far:
```text
Error [server]: Agent turn cost budget exceeded (limit 0.0005, observed 0.000982)
{
"code": "server",
"serverCode": "budget-exceeded",
"message": "Agent turn cost budget exceeded (limit 0.0005, observed 0.000982)",
"fields": {
"kind": "cost",
"limit": 0.0005,
"observed": 0.000982,
"runId": "c24f58c5-f883-4b82-8091-a48b053e53b7"
}
}
```
**Time.** A timer that starts with the turn; when it fires, the model call in flight is cancelled:
```text
Error [server]: Agent turn aborted: Request was aborted.
{
"code": "server",
"serverCode": "agent-turn-aborted",
"message": "Agent turn aborted: Request was aborted.",
"fields": {
"reason": "timeout",
"runId": "2950dd5b-c706-48bd-837f-5329b19c29d4"
}
}
```
The run is `failed`, and `kindgi runs journal ` shows how far it got.
The last step has happened
The limits are checked after a model call and its tool calls. So when steps or cost run out, the call that crossed the limit was made and paid for (`observed 0.000982` against a limit of `0.0005` above), and the tools it asked for have run. Set `maxCostUsd` with one call's worth of room.
Leave room for real models: one call to a hosted model takes a second or more, and a model on your own machine can take ten. A turn with a tool call makes two calls.
## Handle it in your app
The failed turn comes back as an error, with the limit in its fields:
* TypeScript
scripts/budget.ts
```ts
import { KindgiApiError, createClient } from '@kindgi/sdk/client';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
try {
await kindgi.runs.start({
agent: 'acme.order-desk',
input: { userMessage: 'Where is my order A-1001?' },
});
} catch (err) {
if (!(err instanceof KindgiApiError)) throw err;
const e = err.error;
if (e.code === 'server' && e.serverCode === 'budget-exceeded') {
console.log(e.fields?.kind, e.fields?.limit, e.fields?.observed, e.fields?.runId);
} else {
throw err;
}
}
```
```text
steps 1 1 2cc56724-92e7-42e8-aefd-14ad5c294d1f
```
* Python
scripts/budget.py
```python
from kindgi.client import Kindgi, KindgiApiError
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
try:
kindgi.runs.start(agent="acme.order-desk", input={"userMessage": "Where is my order A-1001?"})
except KindgiApiError as err:
if err.server_code == "budget-exceeded":
print(err.details["kind"], err.details["limit"], err.details["observed"], err.details["runId"])
else:
raise
```
```text
steps 1 1 3deffc12-053e-43e3-b735-9a415d95645f
```
A turn that runs out of time has `serverCode` `agent-turn-aborted` instead, with `reason` `timeout`.
## In a flow
An agent step whose turn runs out fails, and the flow run fails with it. The run carries the reason:
```sh
kindgi runs start --flow=acme.notify-customer --input='{"orderId":"A-1001"}'
```
```json
{
…
"flowId": "acme.notify-customer",
…
"status": "failed",
…
"failureMessage": "budget-exceeded: Agent turn cost budget exceeded (limit 0.00001, observed 0.00031)",
"output": {},
…
}
```
Here the step's agent, `acme.order-update` from [Give an agent its input](../give-an-agent-input/#in-a-flow-step), had `budget: { maxCostUsd: 0.00001 }`.
# Choose the model an agent uses
> Say what an agent's model must support, prefer or require a provider or model, and see which one answered.
An agent doesn't name a model. Its `capabilities` say what the model must support, and when a turn starts, Kindgi picks one from the providers registered for your tenant ([Models](../../models/) shows how to register them). You can prefer a provider or a model, or require one.
## What the model must support
* TypeScript
```ts
// in agents/order-desk/index.ts
capabilities: [{ needs: [{ feature: 'tool-use' }] }],
```
* Python
```python
# in agents/order_desk.py
capabilities=[{"needs": [{"feature": "tool-use"}]}],
```
Each entry in `needs` is a hard requirement. A model qualifies only if it meets all of them:
* **`{ feature: … }`**: a feature the model lists in its registration: `tool-use`, `parallel-tool-use`, `structured-output`, `long-context`, `thinking`, `vision` and others. An agent that calls tools needs `tool-use`.
* **`{ contextWindow: { op: '>=', value: 500000 } }`**: a context window.
* **`{ region: 'on-prem' }`**: a provider registered with this `region`.
* **`{ models: { allow: […] } }`**, **`{ providers: { allow: […] } }`**: only these models or providers (`deny` excludes instead).
`needs: []` takes any model. Only the first entry of `capabilities` picks the turn's model.
## How Kindgi picks
1. It keeps every registered model that meets all the needs.
2. A **fallback** provider, such as `dev-echo`, is considered only when no other model qualifies.
3. It ranks what's left: the agent's preferred provider and model first, then the weights in its capability's `prefer`, then alphabetically by provider id and model name.
The last step decides more often than you might expect. With the whole Anthropic preset registered, a `tool-use` agent with no preference gets `claude-haiku-4-5` (first alphabetically), and an agent that needs `structured-output` gets `claude-opus-5-5`, the most expensive. Prefer or require the model you mean.
## Prefer a provider or a model
* TypeScript
```ts
// in agents/order-desk/index.ts
capabilities: [{ needs: [{ feature: 'tool-use' }] }],
preferredProvider: 'anthropic',
preferredModel: 'claude-sonnet-5-5',
```
* Python
```python
# in agents/order_desk.py
capabilities=[{"needs": [{"feature": "tool-use"}]}],
preferred_provider="anthropic",
preferred_model="claude-sonnet-5-5",
```
A dry run shows the pick without calling the model:
```sh
kindgi runs start --agent=acme.order-desk --input='{"userMessage":"Where is my order A-1001?"}' --dry-run
```
```json
{
…
"status": "completed",
"dryRun": true,
…
"output": {
…
"provider": { "id": "anthropic", "model": "claude-sonnet-5-5" },
…
}
}
```
* Both set: that model of that provider is ranked first.
* Only `preferredModel`: that model is ranked first, from whichever provider serves it.
* Only `preferredProvider`: its models are ranked first.
`preferredProvider` is a provider id (`anthropic`) and `preferredModel` a model name (`claude-sonnet-5-5`); a combined `anthropic/claude-sonnet-5-5` matches nothing. A preference never excludes: if the provider isn't registered, or its models don't meet the needs, the turn takes the next model in line.
## Require a model
To make sure a turn runs on one model, put it in the needs:
* TypeScript
```ts
// in agents/order-desk/index.ts
capabilities: [{ needs: [{ feature: 'tool-use' }, { models: { allow: ['claude-sonnet-5-5'] } }] }],
```
* Python
```python
# in agents/order_desk.py
capabilities=[{"needs": [{"feature": "tool-use"}, {"models": {"allow": ["claude-sonnet-5-5"]}}]}],
```
When no registered model qualifies, the turn fails instead of falling back to another one:
```text
Error [server]: No registered provider satisfies the capability declaration
```
The run's journal says why, model by model, in the failed `setup` step (`kindgi runs journal `, with the `runId` that `--verbose` prints). For `models: { allow: ['gpt-5'] }`:
```json
{
"code": "capability-unsatisfiable",
"message": "No registered provider satisfies the capability declaration",
"reasons": [
{
"requirement": "models{allow:[gpt-5]deny:[]}",
"satisfyingProviders": [],
"rejectingProviders": [
{ "id": "anthropic/claude-opus-5-5", "reason": { "code": "model-not-in-allowlist", "message": "model not on allow list", … } },
…
{ "id": "ollama/llama3.1", "reason": { "code": "model-not-in-allowlist", "message": "model not on allow list", … } }
]
}
]
}
```
## Rank with weights
`prefer` adds a weight for each match, against a model's features or a provider's `attributes`, and ranks the higher total first. A weight can be negative. With a local model registered with `"attributes": ["local"]` (see [Connect an OpenAI-compatible endpoint](../../models/openai-compatible/)):
* TypeScript
```ts
// in agents/order-desk/index.ts
capabilities: [{ needs: [{ feature: 'tool-use' }], prefer: [{ feature: 'local', weight: 1 }] }],
```
* Python
```python
# in agents/order_desk.py
capabilities=[{"needs": [{"feature": "tool-use"}], "prefer": [{"feature": "local", "weight": 1}]}],
```
the dry run picks `{ "id": "ollama", "model": "llama3.1" }` over the Anthropic models.
## `dev-echo`, the fallback
`kindgi dev` gives your tenant `dev-echo`, a stand-in that needs no key. It calls the agent's first tool with `{"message": }` and answers `Tool responded: `; an agent with no tools gets its user message back. It's a fallback: once a registered model qualifies, it never answers.
When it does answer, the turn says so, in its result and on the CLI's stderr:
```json
"warnings": [
{
"code": "fallback-provider",
"message": "Answered by \"dev-echo\", a fallback provider: no other registered provider satisfies agent \"acme.echo-agent\"."
}
]
```
```text
⚠ Answered by "dev-echo", a fallback provider: no other registered provider satisfies agent "acme.echo-agent".
```
If you registered a model and still see this, the agent's needs don't match it: compare its `capabilities` with the models' `features` (`kindgi providers list`). `dev-echo` has only `tool-use`, so an agent that needs anything else fails with no model registered.
## See which model answered
The turn's result names it in `output.provider` (`{ "id": "anthropic", "model": "claude-haiku-4-5" }`), and each model call in the turn's provenance record names its model and cost: see [Trace an answer and its cost](../../observability/trace-an-answer/).
# Hold a conversation
> Continue a conversation with an agent turn by turn, limit the history its model sees, read the history, and close the conversation.
Every turn belongs to a conversation. A run without a `conversationId` opens a new one, and its result carries the id. Pass the id with the next message, and the agent's model sees what was said before.
## Continue a conversation
The scripts on this page read the API's URL and token from `KINDGI_API_URL` and `KINDGI_API_TOKEN`. With `kindgi dev`, they're the URL and token it prints (also in the pack's `.kindgirc.json`).
* TypeScript
scripts/chat.ts
```ts
import { createClient } from '@kindgi/sdk/client';
import type { ThreadId } from '@kindgi/sdk/types';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
type Turn = { conversationId: ThreadId; turnNumber: number; response: { content: string } };
const first = await kindgi.runs.start({
agent: 'acme.order-desk',
input: { userMessage: 'Hi, I am Ada. My order is A-1002.' },
});
const { conversationId } = first.output as Turn;
const second = await kindgi.runs.start({
agent: 'acme.order-desk',
input: { userMessage: 'Has it shipped yet?', conversationId },
});
const turn = second.output as Turn;
console.log(turn.turnNumber, turn.response.content);
const history = await kindgi.conversations.messages(conversationId);
for (const m of history.items) console.log(m.sequence, m.role, JSON.stringify(m.content));
```
```sh
node scripts/chat.ts
```
```text
2 No, your order A-1002 hasn't shipped yet—it's still in processing status. You'll receive an update once it's on its way.
0 user "Hi, I am Ada. My order is A-1002."
1 agent {"text":"","toolCalls":[{"id":"toolu_01LLRAWoi55mYXt2ztfW1Ap8","name":"acme.lookup-order","arguments":{"orderId":"A-1002"}}]}
2 tool {"eta":null,"status":"processing","orderId":"A-1002"}
3 agent "Hi Ada! Your order A-1002 is currently being processed. We don't have an estimated delivery date yet, but we'll update you once it ships."
4 user "Has it shipped yet?"
5 agent "No, your order A-1002 hasn't shipped yet—it's still in processing status. You'll receive an update once it's on its way."
```
* Python
scripts/chat.py
```python
from kindgi.client import Kindgi
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
first = kindgi.runs.start(
agent="acme.order-desk",
input={"userMessage": "Hi, I am Ada. My order is A-1002."},
)
conversation_id = first.output["conversationId"]
second = kindgi.runs.start(
agent="acme.order-desk",
input={"userMessage": "Has it shipped yet?", "conversationId": conversation_id},
)
print(second.output["turnNumber"], second.output["response"]["content"])
history = kindgi.conversations.messages(conversation_id)
for m in history.data:
print(m.sequence, m.role, m.content)
```
```sh
uv run python scripts/chat.py
```
```text
2 No, your order A-1002 hasn't shipped yet—it's still being processed. You'll be notified once it ships with a delivery date.
0 user Hi, I am Ada. My order is A-1002.
1 agent {'text': '', 'toolCalls': [{'id': 'toolu_01XKoyp8kWq9o8vVfoJdwmx3', 'name': 'acme.lookup-order', 'arguments': {'orderId': 'A-1002'}}]}
2 tool {'eta': None, 'status': 'processing', 'orderId': 'A-1002'}
3 agent Hi Ada! Your order A-1002 is currently being processed. Unfortunately, we don't have an expected delivery date available yet—we'll update you once it ships.
4 user Has it shipped yet?
5 agent No, your order A-1002 hasn't shipped yet—it's still being processed. You'll be notified once it ships with a delivery date.
```
The second message names no order, and the agent answers about A-1002: its model saw the first turn, including the tool call and its result. The history is every message in order: the user's, the agent's (a tool call or an answer) and each tool result. From the command line, pass the same field: `--input='{"userMessage":"Has it shipped yet?","conversationId":""}'`.
## Limit what the model sees
A long conversation makes every turn's prompt longer. `historyLimit` caps how many earlier messages a turn loads:
* TypeScript
```ts
// in agents/order-desk/index.ts
conversationPolicy: { historyLimit: 20 },
```
* Python
```python
# in agents/order_desk.py
conversation_policy={"historyLimit": 20},
```
The turn loads the last 20 messages before the new one; older ones stay in the conversation but aren't sent. Without `historyLimit`, every turn loads the whole history. To see what a turn sent, read its journal (`kindgi runs journal `): the `build-initial-messages` step's output has the messages.
## Open, list and close conversations
A conversation can also be opened before its first turn, with a title and the person it's with. Closing one ends it: a turn on a closed conversation is refused.
* TypeScript
scripts/close.ts
```ts
import { KindgiApiError, createClient } from '@kindgi/sdk/client';
import type { AgentId } from '@kindgi/sdk/types';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
const conversation = await kindgi.conversations.open({
agentId: 'acme.order-desk' as AgentId,
agentVersion: '0.1.0',
title: 'Order A-1002',
participantId: 'customer-4411',
});
console.log(conversation.id, conversation.status, conversation.turnCount);
await kindgi.runs.start({
agent: 'acme.order-desk',
input: { userMessage: 'Where is order A-1002?', conversationId: conversation.id },
});
const closed = await kindgi.conversations.close(conversation.id);
console.log(closed.status, closed.turnCount);
try {
await kindgi.runs.start({
agent: 'acme.order-desk',
input: { userMessage: 'One more thing…', conversationId: conversation.id },
});
} catch (err) {
if (!(err instanceof KindgiApiError) || err.error.code !== 'conflict') throw err;
console.log(err.error.reason, err.message);
}
const list = await kindgi.conversations.list({ agent: 'acme.order-desk' as AgentId, status: 'closed' });
console.log(list.items.map((c) => c.id));
```
```text
88356874-400d-4bcd-9708-63ab51f88dd3 open 0
closed 1
conversation-closed Conversation "88356874-400d-4bcd-9708-63ab51f88dd3" is closed
[
'88356874-400d-4bcd-9708-63ab51f88dd3',
…
]
```
* Python
scripts/close.py
```python
from kindgi.client import ConflictError, Kindgi
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
conversation = kindgi.conversations.open(
agent_id="acme.order-desk",
agent_version="0.1.0",
title="Order A-1002",
participant_id="customer-4411",
)
print(conversation.id, conversation.status, conversation.turn_count)
kindgi.runs.start(
agent="acme.order-desk",
input={"userMessage": "Where is order A-1002?", "conversationId": str(conversation.id)},
)
closed = kindgi.conversations.close(str(conversation.id))
print(closed.status, closed.turn_count)
try:
kindgi.runs.start(
agent="acme.order-desk",
input={"userMessage": "One more thing…", "conversationId": str(conversation.id)},
)
except ConflictError as err:
print(err.server_code, err.message)
page = kindgi.conversations.list(agent_id="acme.order-desk", status="closed")
print([str(c.id) for c in page.data])
```
```text
b3135493-47bd-4f78-90ac-fe0ff3e21e8d open 0
closed 1
conversation-closed Conversation "b3135493-47bd-4f78-90ac-fe0ff3e21e8d" is closed
['b3135493-47bd-4f78-90ac-fe0ff3e21e8d', …]
```
- `open` pins the agent and its version (below); `title` defaults to `Untitled conversation`. A conversation that a turn opens takes the agent's name as its title.
- `participantId` is kept on the conversation. A first turn can set it too, with `participantId` in its input.
- `close` is safe to repeat: closing a closed conversation returns it as it is.
- `list` filters by agent and by `status` (`open` or `closed`), newest first; `get` fetches one conversation.
- A conversation is in a project: a run opens it in the run's project, and `open` takes a `projectId` (one of your tenant's projects; without one, the Default project, as for a run). `list` takes a `scope` for one project's conversations, or every project's in an org: `{ kind: 'project', projectId }` (`scope_kind="project", scope_id=…`) or `{ kind: 'org', orgId }`. A conversation from before Kindgi 0.1.3 has no project and is listed only without a scope.
Over HTTP, these are `POST /v1/conversations`, `GET /v1/conversations`, `GET /v1/conversations/{id}`, `POST /v1/conversations/{id}/close` and `GET /v1/conversations/{id}/messages`.
## A conversation keeps its agent version
A conversation is pinned to the agent version it was opened with. After you change the agent's `version`, the next turn of an open conversation is refused:
```text
Error [server]: Conversation opened with acme.order-desk@0.1.0; invoked with acme.order-desk@0.2.0
```
(Its `serverCode` is `agent-version-mismatch`.) With `kindgi dev`, only the current version of an agent is registered, so start a new conversation. Bump the version when a change would break conversations under way, not on every edit.
## Approvals in a conversation
A conversation can make a person approve each turn once it runs long, or before a tool runs: see [Ask before a long conversation continues](../../approvals/ask-after-n-turns/) and [Ask before a tool runs](../../approvals/ask-before-a-tool-runs/).
# Give an agent its input
> What a turn's input is, how parameters fill an agent's instructions, what an agent gets as a flow step, and what a turn returns.
An agent runs one turn at a time. Run directly, a turn's input is the user's message and a few optional fields. Run as a step of a flow, the agent gets the step's input instead.
## A direct run
```sh
kindgi runs start --agent=acme.order-desk --input='{"userMessage":"Where is my order A-1001?"}'
```
The input has these fields:
| Field | |
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
| `userMessage` | The turn's message. Required. |
| `conversationId` | Continue a conversation: see [Hold a conversation](../conversations/). Without it, the turn opens a new one. |
| `participantId` | Who the conversation is with, kept on the conversation the turn opens. |
| `parameters` | Values for the agent's parameters (below): strings, numbers or booleans. |
Without a `userMessage`, the run is refused:
```text
Error [invalid-request]: An agent run expects input { userMessage: string, conversationId?: string, participantId?: string, parameters?: { [name]: string | number | boolean } }.
```
From your app, the same input goes in `runs.start`. This runs the `acme.reply-drafter` agent from the next section:
* TypeScript
scripts/drafter.ts
```ts
import { createClient } from '@kindgi/sdk/client';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
const run = await kindgi.runs.start({
agent: 'acme.reply-drafter',
input: {
userMessage: 'My parcel is a week late.',
parameters: { tone: 'formal', signature: 'Sam at Acme' },
},
});
const turn = run.output as { response: { content: string } };
console.log(turn.response.content);
```
* Python
scripts/drafter.py
```python
from kindgi.client import Kindgi
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
run = kindgi.runs.start(
agent="acme.reply-drafter",
input={
"userMessage": "My parcel is a week late.",
"parameters": {"tone": "formal", "signature": "Sam at Acme"},
},
)
print(run.output["response"]["content"])
```
A run waits for the turn to end. To get the run back as soon as it exists, add `--no-wait` (`options: { wait: false }` in the clients): it comes back `pending`, and `kindgi runs get ` shows it `completed`, with its `output`, once the turn ends.
## Parameters
Parameters are your own variables in the agent's instructions. Declare each one; the caller supplies the values.
* TypeScript
agents/reply-drafter/index.ts
```ts
import { defineAgent } from '@kindgi/sdk/define';
const defined = defineAgent({
id: 'acme.reply-drafter',
version: '0.1.0',
name: 'Reply drafter',
instructions: [
'You draft short replies to Acme customers, in a {{ tone }} tone.',
'Sign every reply as {{ signature }}.',
].join('\n'),
parameters: [
{ name: 'tone', type: 'string', description: 'friendly, formal, …' },
{ name: 'signature', type: 'string', default: 'The Acme team' },
],
capabilities: [{ needs: [] }],
tools: [],
retrieval: [],
guardrails: [],
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
agents/reply_drafter.py
```python
from kindgi import Agent
reply_drafter = Agent(
id="acme.reply-drafter",
version="0.1.0",
name="Reply drafter",
instructions="\n".join([
"You draft short replies to Acme customers, in a {{ tone }} tone.",
"Sign every reply as {{ signature }}.",
]),
parameters=[
{"name": "tone", "type": "string", "description": "friendly, formal, …"},
{"name": "signature", "type": "string", "default": "The Acme team"},
],
capabilities=[{"needs": []}],
)
```
A parameter is `string`, `number`, `boolean` or `date`. Give it a `default` to make it optional: here `tone` is required, and `signature` falls back to its default. (`required: false` without a default isn't enough: the instructions still name the variable, and rendering fails without a value.)
```sh
kindgi runs start --agent=acme.reply-drafter --input='{"userMessage":"My parcel is a week late.","parameters":{"tone":"friendly"}}'
```
```json
{
…
"status": "completed",
…
"output": {
…
"response": {
"role": "agent",
"content": "Hi there!\n\nI'm sorry to hear your parcel is running late – we know how frustrating that can be! … Thanks for your patience!\n\nThe Acme team",
…
},
…
}
}
```
The journal shows what the model was told (`kindgi runs journal `, the `render-prompt` step):
```text
You draft short replies to Acme customers, in a friendly tone.
Sign every reply as The Acme team.
```
A required parameter with no value fails the turn before the model is called:
```text
Error [server]: Prompt render failed: Required parameter missing: tone
```
## In a flow step
A flow runs an agent as a step: one turn, as a child run of the flow's run. The step's `inputMapping` builds the agent's input, and `config.parameters` fills its parameters. This flow looks an order up with `acme.lookup-order` (from [Write an agent](../write-an-agent/#the-tool-it-calls)), then has an agent write the customer an update:
* TypeScript
flows/notify-customer/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.notify-customer',
version: '0.1.0',
name: 'Notify a customer',
nodes: [
{
id: 'lookup',
kind: 'tool',
ref: 'acme.lookup-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
{
id: 'update',
kind: 'agent',
ref: 'acme.order-update',
inputMapping: { order: { path: 'nodeOutputs.lookup' } },
config: { parameters: { tone: 'friendly' } },
},
],
edges: [
{ id: 'e-start', from: '$start', to: 'lookup' },
{ id: 'e-update', from: 'lookup', to: 'update' },
{ id: 'e-end', from: 'update', to: '$end' },
],
output: { mapping: { update: { path: 'nodeOutputs.update.text' } } },
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
flows/notify_customer.py
```python
from kindgi import Flow
from ..agents.order_update import order_update
notify_customer = Flow(
id="acme.notify-customer",
version="0.1.0",
name="Notify a customer",
nodes=[
{
"id": "lookup",
"kind": "tool",
"ref": "acme.lookup-order",
"inputMapping": {"orderId": {"path": "runInput.orderId"}},
},
{
"id": "update",
"kind": "agent",
"ref": order_update,
"inputMapping": {"order": {"path": "nodeOutputs.lookup"}},
"config": {"parameters": {"tone": "friendly"}},
},
],
edges=[
{"id": "e-start", "from": "$start", "to": "lookup"},
{"id": "e-update", "from": "lookup", "to": "update"},
{"id": "e-end", "from": "update", "to": "$end"},
],
output={"mapping": {"update": {"path": "nodeOutputs.update.text"}}},
)
```
The agent gets the step's input two ways:
* as **`{{ input }}`** in its instructions, so it can use the fields;
* as its **user message**: the input as JSON.
- TypeScript
agents/order-update/index.ts
```ts
import { defineAgent } from '@kindgi/sdk/define';
const defined = defineAgent({
id: 'acme.order-update',
version: '0.1.0',
name: 'Order update',
instructions: [
'Write a two-sentence update for the customer about order {{ input.order.orderId }}.',
'Its status is {{ input.order.status }}{% if input.order.eta %}, expected on {{ input.order.eta }}{% endif %}.',
'Use a {{ tone }} tone.',
].join('\n'),
parameters: [{ name: 'tone', type: 'string' }],
capabilities: [{ needs: [] }],
tools: [],
retrieval: [],
guardrails: [],
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
- Python
agents/order_update.py
```python
from kindgi import Agent
order_update = Agent(
id="acme.order-update",
version="0.1.0",
name="Order update",
instructions="\n".join([
"Write a two-sentence update for the customer about order {{ input.order.orderId }}.",
"Its status is {{ input.order.status }}"
"{% if input.order.eta %}, expected on {{ input.order.eta }}{% endif %}.",
"Use a {{ tone }} tone.",
]),
parameters=[{"name": "tone", "type": "string"}],
capabilities=[{"needs": []}],
)
```
```sh
kindgi runs start --flow=acme.notify-customer --input='{"orderId":"A-1001"}'
```
```json
{
…
"flowId": "acme.notify-customer",
…
"status": "completed",
…
"output": {
"update": "Great news! Your order A-1001 is on its way and should arrive by October 6th, 2026. Thanks for your patience, and we can't wait for you to receive it!"
}
}
```
In the agent's turn, the instructions rendered as:
```text
Write a two-sentence update for the customer about order A-1001.
Its status is shipped, expected on 2026-10-06.
Use a friendly tone.
```
and its user message was the step's input:
```json
{
"order": {
"orderId": "A-1001",
"status": "shipped",
"eta": "2026-10-06"
}
}
```
The step's output, which later steps and the flow's `output` read as `nodeOutputs.update.`:
```json
{
"text": "Great news! Your order A-1001 is on its way …",
"runId": "c2cd458f-3364-4c0d-aefc-e6a7765f96d2",
"usage": { "steps": 1, "durationMs": 1212, "promptTokens": 90, "totalCostUsd": 0.00031999999999999997, "completionTokens": 46 },
"violations": [],
"conversationId": "1cf8b177-7f18-4513-b3fe-178f8aea6738"
}
```
`text` is the answer. An agent with a typed answer also has `output`: see [Give an agent a typed answer](../typed-answer/#in-a-flow).
`input` exists only in a flow step
A direct run has no `{{ input }}`, so an agent whose instructions read it fails when run directly:
```text
Error [server]: Prompt render failed: Template references an unresolved variable: input
```
## What a turn returns
A turn's result is the run's `output`:
| Field | |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `response.content` | The answer, as text. |
| `output` | The typed answer, for an agent with an `output` schema. |
| `conversationId`, `turnNumber` | The conversation and the turn's number in it. |
| `usage` | `steps` (model calls), `promptTokens`, `completionTokens`, `totalCostUsd`, `durationMs`. |
| `provider` | The provider `id` and `model` that answered. |
| `appended` | The messages the turn added to the conversation: the user message, tool calls and their results, the answer. |
| `warnings` | For example `fallback-provider`, when `dev-echo` answered. |
| `violations` | Guardrail results. |
| `provenance` | The turn's provenance record: see [Trace an answer and its cost](../../observability/trace-an-answer/). |
In the TypeScript client, `run.output` is `unknown`: cast it to the fields you read. In the Python client, it's a dict.
# Give an agent a typed answer
> Have an agent answer with JSON that matches a schema, read its fields in your app and in a flow, and see what happens when an answer doesn't fit.
An agent's answer is text unless you give it an `output` schema. With one, the final answer must be JSON that matches the schema; Kindgi checks it before the turn completes, and your code reads the fields.
## Declare the answer
This agent triages support tickets:
* TypeScript
agents/ticket-triage/index.ts
```ts
import { defineAgent } from '@kindgi/sdk/define';
import { z } from 'zod';
export const Triage = z.object({
category: z.enum(['billing', 'shipping', 'returns', 'other']),
priority: z.enum(['low', 'normal', 'urgent']),
summary: z.string(),
});
const defined = defineAgent({
id: 'acme.ticket-triage',
version: '0.1.0',
name: 'Ticket triage',
instructions: [
'You triage support tickets for Acme. Reply with only a JSON object:',
'{"category": "billing" | "shipping" | "returns" | "other",',
' "priority": "low" | "normal" | "urgent",',
' "summary": ""}',
].join('\n'),
capabilities: [{ needs: [] }],
tools: [],
retrieval: [],
guardrails: [],
output: { schema: Triage, name: 'triage' },
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
`schema` takes a Zod schema or a JSON Schema object.
* Python
agents/ticket_triage.py
```python
from typing import Literal
from pydantic import BaseModel
from kindgi import Agent
class Triage(BaseModel):
category: Literal["billing", "shipping", "returns", "other"]
priority: Literal["low", "normal", "urgent"]
summary: str
ticket_triage = Agent(
id="acme.ticket-triage",
version="0.1.0",
name="Ticket triage",
instructions="\n".join([
"You triage support tickets for Acme. Reply with only a JSON object:",
'{"category": "billing" | "shipping" | "returns" | "other",',
' "priority": "low" | "normal" | "urgent",',
' "summary": ""}',
]),
capabilities=[{"needs": []}],
output={"schema": Triage, "name": "triage"},
)
```
`output=Triage` alone works too; the full form also takes `name` and `maxRepairs`.
- **`schema`** is what the answer must match.
- **`name`** names the answer in what the model is told when it gets it wrong, and in errors (default `output`).
- **`maxRepairs`** is how many times a wrong answer goes back to the model (default 1): see [below](#when-the-answer-doesnt-fit).
Describe the JSON in the instructions too, as above. The model isn't sent the schema with its first call, so an agent that doesn't describe the shape spends an extra model call on a repair. `capabilities: [{ needs: [] }]` lets any registered model answer: this agent calls no tools.
## Run it
```sh
kindgi runs start --agent=acme.ticket-triage --input='{"userMessage":"I was charged twice for order A-1001 and I need the money back today."}'
```
````json
{
…
"status": "completed",
…
"output": {
…
"usage": { "steps": 1, "durationMs": 2448, "promptTokens": 88, "totalCostUsd": 0.000343, "completionTokens": 51 },
"output": {
"summary": "Customer was double-charged for order A-1001 and requests immediate refund.",
"category": "billing",
"priority": "urgent"
},
…
"response": {
"role": "agent",
"actor": "acme.ticket-triage",
"content": "```json\n{\n \"category\": \"billing\",\n \"priority\": \"urgent\",\n \"summary\": \"Customer was double-charged for order A-1001 and requests immediate refund.\"\n}\n```",
…
},
…
}
}
````
The run's `output.output` is the parsed answer; `output.response.content` is the text the model wrote. A JSON answer in a fenced `json` code block, as here, is accepted.
## Read it in your app
Parse the fields with the same schema:
* TypeScript
```ts
import { Triage } from './agents/ticket-triage/index.js';
const run = await kindgi.runs.start({
agent: 'acme.ticket-triage',
input: { userMessage: 'I was charged twice for order A-1001 and I need the money back today.' },
});
const triage = Triage.parse((run.output as { output: unknown }).output);
console.log(triage.category, triage.priority); // billing urgent
```
* Python
```python
from agents.ticket_triage import Triage
run = kindgi.runs.start(
agent="acme.ticket-triage",
input={"userMessage": "I was charged twice for order A-1001 and I need the money back today."},
)
triage = Triage.model_validate(run.output["output"])
print(triage.category, triage.priority) # billing urgent
```
## When the answer doesn't fit
An answer that isn't JSON, or doesn't match the schema, goes back to the model with a message that lists what's wrong and gives the schema:
```text
[kindgi:output-repair]
Your answer must be the triage as JSON matching this JSON Schema, and nothing else:
{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"category":{"enum":["billing","shipping","returns","other"],"type":"string"},…},"required":["category","priority","summary"],"type":"object"}
It did not fit:
- the answer is not valid JSON (Unexpected token 'I', "I understa"... is not valid JSON)
Reply again with only the corrected triage JSON.
```
That's from a version of the agent whose instructions didn't describe the JSON: its first answer was prose, the repair worked, and the turn took two steps instead of one. Each repair is a model call: it counts as a step against the agent's [budget](../budgets/), and it costs.
When the repairs run out (`maxRepairs`, default 1), the turn fails with `output-schema-violation`. With `maxRepairs: 0`, an answer whose `priority` is `"high"` fails at once:
```text
Error [server]: The agent's answer does not match its triage schema after 0 repairs: /priority must be equal to one of the allowed values
{
"code": "server",
"serverCode": "output-schema-violation",
"message": "The agent's answer does not match its triage schema after 0 repairs: /priority must be equal to one of the allowed values",
"fields": {
"errors": [
"/priority must be equal to one of the allowed values"
],
"attempts": 1,
"runId": "08152bc1-e4b8-423f-a16b-0ffe1aea8485"
}
}
```
(That's `kindgi runs start … --verbose`.) A Zod object refuses fields it doesn't name (`"additionalProperties": false` above); the schema of the pydantic model doesn't.
## In a flow
A flow reads a typed answer at `nodeOutputs..output.`:
* TypeScript
flows/triage-ticket/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.triage-ticket',
version: '0.1.0',
name: 'Triage a ticket',
nodes: [
{
id: 'triage',
kind: 'agent',
ref: 'acme.ticket-triage',
inputMapping: { ticket: { path: 'runInput.ticket' } },
},
],
edges: [
{ id: 'e-start', from: '$start', to: 'triage' },
{ id: 'e-end', from: 'triage', to: '$end' },
],
output: {
mapping: {
category: { path: 'nodeOutputs.triage.output.category' },
priority: { path: 'nodeOutputs.triage.output.priority' },
},
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
flows/triage_ticket.py
```python
from kindgi import Flow
from ..agents.ticket_triage import ticket_triage
triage_ticket = Flow(
id="acme.triage-ticket",
version="0.1.0",
name="Triage a ticket",
nodes=[
{
"id": "triage",
"kind": "agent",
"ref": ticket_triage,
"inputMapping": {"ticket": {"path": "runInput.ticket"}},
},
],
edges=[
{"id": "e-start", "from": "$start", "to": "triage"},
{"id": "e-end", "from": "triage", "to": "$end"},
],
output={
"mapping": {
"category": {"path": "nodeOutputs.triage.output.category"},
"priority": {"path": "nodeOutputs.triage.output.priority"},
},
},
)
```
```sh
kindgi runs start --flow=acme.triage-ticket --input='{"ticket":"I was charged twice for order A-1001 and I need the money back today."}'
```
```json
{
…
"flowId": "acme.triage-ticket",
…
"status": "completed",
…
"output": {
"category": "billing",
"priority": "urgent"
}
}
```
## Dry runs
A dry run (`--dry-run`) skips the model call, and with it the check: the turn completes with no `output`, and its answer is `[dry-run: model call skipped]`.
# Write an agent
> Define an agent in a pack, with its instructions, the tools it may call, the guardrails on its answer and what its model must support.
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.
## The tool it calls
* TypeScript
tools/lookup-order/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const ORDERS: Record = {
'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;
```
* Python
tools/lookup_order.py
```python
from pydantic import BaseModel, Field
from kindgi import tool
ORDERS = {
"A-1001": {"status": "shipped", "eta": "2026-10-06"},
"A-1002": {"status": "processing", "eta": None},
}
class OrderQuery(BaseModel):
order_id: str = Field(alias="orderId")
class Order(BaseModel):
order_id: str = Field(alias="orderId")
status: str
eta: str | None
@tool(id="acme.lookup-order", mutating=False)
def lookup_order(input: OrderQuery) -> Order:
"""Looks up an order by its id (like A-1001): its status and expected delivery date."""
order = ORDERS.get(input.order_id, {"status": "not-found", "eta": None})
return Order(orderId=input.order_id, **order)
```
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.
## The agent
* TypeScript
agents/order-desk/index.ts
```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 acme.lookup-order 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.
* Python
agents/order_desk.py
```python
from kindgi import Agent
from ..guardrails.response_not_empty import response_not_empty
from ..tools.lookup_order import lookup_order
order_desk = Agent(
id="acme.order-desk",
version="0.1.0",
name="Order desk",
description="Answers customers' questions about their orders.",
instructions="\n".join([
"You answer customer questions about Acme orders. Today is {{ today }}.",
"Look an order up with acme.lookup-order 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.",
]),
capabilities=[{"needs": [{"feature": "tool-use"}]}],
tools=[lookup_order],
guardrails=[response_not_empty],
budget={"maxSteps": 6, "maxCostUsd": 0.05, "maxWallMs": 60_000},
)
```
Assign the agent at module level: an agent built inside a function isn't found. The keys inside the dicts (`maxSteps`, `feature`) keep the API's camelCase; only the keyword arguments are snake_case.
- **`id`** is `.`; **`version`** is an exact semver.
- **`instructions`** is what the model is told at the start of every turn: a template, below.
- **`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](../choose-a-model/).
- **`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](../budgets/).
In TypeScript, `tools`, `retrieval` and `guardrails` are required; pass `[]` for none.
## Run it
```sh
kindgi runs start --agent=acme.order-desk --input='{"userMessage":"Where is my order A-1001?"}'
```
```json
{
"id": "0ae4c417-c373-4523-962e-75c296c188fe",
…
"status": "completed",
…
"output": {
…
"usage": { "steps": 2, "durationMs": 2291, "promptTokens": 1449, "totalCostUsd": 0.001889, "completionTokens": 88 },
"appended": [
{ "role": "user", "content": "Where is my order A-1001?", "sequence": 0, … },
{ "role": "agent", "actor": "acme.order-desk",
"content": { "text": "", "toolCalls": [{ "id": "toolu_01XP…", "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": "Your order A-1001 has been shipped and is expected to arrive on October 6, 2026.", "sequence": 3, … }
],
"provider": { "id": "anthropic", "model": "claude-haiku-4-5" },
"response": { "role": "agent", "content": "Your order A-1001 has been shipped and is expected to arrive on October 6, 2026.", … },
…
"turnNumber": 1,
"violations": [],
"conversationId": "49cd02f6-6cc7-40e5-b5ff-6d3bf1f4fb0f"
}
}
```
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](../give-an-agent-input/#what-a-turn-returns).
Edit the file and save: `kindgi dev` reloads it, and the next run uses it.
## Instructions are a template
`{{ 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](../give-an-agent-input/).
Rendering is strict. A variable that is neither built in nor declared fails the turn before the model is called:
```text
Error [server]: Prompt render failed: Template references an unresolved variable: customer
```
The rendered instructions are in the run's journal (`kindgi runs journal `), as the output of its `render-prompt` step.
## Tools and their versions
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.
The range is resolved when the turn starts. When no registered version is in it, the turn fails:
```text
Error [server]: 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:
```sh
uv run python -m kindgi.pack index --pack-dir .
```
```json
{
…
"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"
}
]
}
```
## Guardrails
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:
```text
Error [invalid-request]: 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.
## When a file doesn't load
`kindgi dev` keeps serving the rest of the pack and names the file:
```text
⚠ 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'`).
# Approvals
> Have a person approve what an agent does before it happens.
An agent can stop and wait for a person: before it calls a tool that changes something, or once a conversation runs long. The run waits (its status is `suspended`) until a reviewer decides, then carries on from where it stopped. A flow with that agent as a step waits with it.
* [Ask before a tool runs](ask-before-a-tool-runs/): turn approval gates on in an agent.
* [Decide an approval](decide-an-approval/): reviewers and their roles, the four decisions, and deciding from your app.
* [Ask before a long conversation continues](ask-after-n-turns/): a gate on the number of turns.
* [Set approval rules for every agent](approval-rules-for-every-agent/): a tenant policy that agents can't loosen.
The examples use a pack named `acme-ops` with the sample template's agent and tools, plus `acme-ops.post-update`, a tool that posts to a status page. They run on `kindgi dev` with no model key: `dev-echo` answers, and it calls the agent's first tool.
# Set approval rules for every agent
> Publish a tenant policy that makes chosen tools ask for approval and raises the reviewer role, whatever each agent says.
An agent's own `hitl` settings are up to whoever writes the agent. A tenant's `hitl` **policy** sets rules every agent is held to: a tool that must always ask, and a lowest reviewer role. A policy can only make an agent stricter, never looser.
Policies are published through the HTTP API. With `kindgi dev`, the URL and token are the ones it prints (they're also in the pack's `.kindgirc.json`).
## Publish a policy
```sh
curl -X POST "$KINDGI_API_URL/v1/policies" \
-H "Authorization: Bearer $KINDGI_API_TOKEN" -H 'content-type: application/json' \
-d '{
"id": "acme.approval-rules",
"version": "1.0.0",
"kind": "hitl",
"description": "Status page posts need a senior reviewer",
"spec": {
"minReviewerRole": "senior",
"tools": { "acme-ops.post-update": "always_ask" }
}
}'
```
```json
{"policyId":"acme.approval-rules","version":"1.0.0"}
```
It applies from the next run. The `spec`:
* **`tools`**: tool id to gate. `always_ask` makes every call of that tool, by any agent, wait for a decision, even an agent with no `hitl` settings of its own.
* **`minReviewerRole`**: `standard`, `senior` or `admin`. Approvals need at least this role, whatever the agent asks for.
## What it changes
An agent that calls `acme-ops.post-update` and has no approval settings now waits:
```sh
kindgi runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'
```
```json
{
"id": "dd30a1ce-cfb6-42d8-b951-987be9cb31c1",
…
"status": "suspended",
…
}
```
The approval needs a `senior` reviewer, so a `standard` reviewer doesn't see it:
```sh
kindgi approvals list --status=pending
```
```json
{
"items": []
}
```
Once you're registered as `senior` (`kindgi reviewers register --spec='{"role":"senior"}'`), it's there:
```json
{
"items": [
{
"id": "a7a223bd-ac2f-4f69-9961-b00bd2501625",
…
"requiredRole": "senior",
"status": "pending",
"title": "HITL review: acme-ops.post-update",
…
}
]
}
```
## Change or remove it
A policy is versioned. Publish the same `id` with a higher `version` to change it; the latest version is the one that applies. Several `hitl` policies (with different ids) all apply: every tool any of them gates asks, and the highest `minReviewerRole` wins.
To remove a policy, unregister **every** version: unregistering only the latest makes the version before it apply again.
A change applies to approvals not asked for yet. A turn already waiting keeps the approval it asked for: the reviewer's answer still decides, even if the policy no longer gates that tool.
```sh
curl -X POST "$KINDGI_API_URL/v1/policies/acme.approval-rules/versions/1.0.0/unregister" \
-H "Authorization: Bearer $KINDGI_API_TOKEN"
```
```json
{"policyId":"acme.approval-rules","version":"1.0.0","unregistered":true}
```
`GET /v1/policies` lists the tenant's policies. See [Policies in the HTTP API](../../../reference/api/operations/tags/policies/).
# Ask before a long conversation continues
> Make every turn past a set number in a conversation wait for a person's approval.
Some conversations should get a person's attention once they run long: a support chat that hasn't been resolved in a few turns, say. `hitl.afterTurns` lets the first turns of a conversation run, then makes each turn after them wait for a reviewer before it starts.
## Set the limit
In the sample pack's agent:
* TypeScript
```ts
// in agents/echo-agent/index.ts
conversationPolicy: { historyLimit: 10, hitl: { afterTurns: 2 } },
```
* Python
```python
# in agents/echo_agent.py
conversation_policy={"historyLimit": 10, "hitl": {"afterTurns": 2}},
```
Two turns run as usual. From the third on, each turn of the same conversation waits for an approval before it runs.
## Try it
Start a conversation, then continue it with the `conversationId` the first turn returns (in its `output`):
```sh
kindgi runs start --agent=acme-ops.echo-agent --input='{"userMessage":"My order is late."}'
kindgi runs start --agent=acme-ops.echo-agent --input='{"userMessage":"It was due Monday.","conversationId":""}'
kindgi runs start --agent=acme-ops.echo-agent --input='{"userMessage":"Can you refund it?","conversationId":""}'
```
The first two runs complete. The third is `suspended`:
```json
{
"id": "0c424165-fc78-4563-965f-5ca9ba0ec4a9",
…
"status": "suspended",
…
}
```
Its approval says why:
```sh
kindgi approvals list --status=pending
```
```json
{
"items": [
{
"id": "ff98fdd3-5d1d-4245-bdab-2ab9971f3962",
…
"subjectKind": "agent-turn:session-hitl-gate",
"subjectRef": {
"agentId": "acme-ops.echo-agent",
"threshold": 2,
"turnCount": 2,
"agentVersion": "0.1.0",
"conversationId": "5d72d0da-188f-4ea0-b923-766be818891e"
},
"requiredRole": "standard",
"status": "pending",
"title": "HITL review required for Echo Agent",
"description": "Conversation reached HITL threshold: 2 turns >= 2",
"provenanceRef": {
"runId": "0c424165-fc78-4563-965f-5ca9ba0ec4a9"
},
…
}
]
}
```
The turn hasn't run yet: the model hasn't been called.
## Approve or reject
```sh
kindgi approvals complete --decision=approve
```
Approve, and the turn runs; it's the conversation's third (`"turnNumber": 3` in the run's output). The next turn asks again: every turn past the limit needs its own approval.
Reject, and the run fails without calling the model:
```sh
kindgi approvals complete ff98fdd3-5d1d-4245-bdab-2ab9971f3962 --decision=reject --rationale="Hand this one to a person"
kindgi runs get 0c424165-fc78-4563-965f-5ca9ba0ec4a9
```
```json
{
"id": "0c424165-fc78-4563-965f-5ca9ba0ec4a9",
…
"status": "failed",
…
"failureMessage": "{\"__agent_turn_failure__\":true,\"error\":{\"code\":\"hitl-rejected\",\"message\":\"Reviewer rejected the session-HITL gate: Hand this one to a person\",\"rationale\":\"Hand this one to a person\"}}"
}
```
A rejected turn doesn't count: the conversation still has two turns, and the next message asks again.
You need to be a reviewer to see and decide these: see [Decide an approval](../decide-an-approval/).
# Ask before a tool runs
> Make an agent wait for a person's approval before it calls a tool that changes something.
An agent turns on approval gates in its `conversationPolicy`. When the model asks for a gated tool, the run stops before the tool is called and waits for a reviewer. Approve, and the tool runs; reject, and it doesn't.
## Gate a tool
`acme-ops.post-update` posts to a public status page. It changes something outside your app, so it doesn't say `mutating: false`:
* TypeScript
tools/post-update/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const defined = defineTool({
id: 'acme-ops.post-update' as ToolId,
description: 'Posts a message to the public status page.',
version: '0.1.0',
input: z.object({ message: z.string().min(1).max(500) }),
output: z.object({ posted: z.boolean(), postedAt: z.string() }),
effects: [{ kind: 'external-side-effect', resource: 'external:status-page' }],
handler: async () => ({ posted: true, postedAt: new Date().toISOString() }),
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
tools/post_update.py
```python
from datetime import UTC, datetime
from pydantic import BaseModel, Field
from kindgi import tool
class Update(BaseModel):
message: str = Field(min_length=1, max_length=500)
class Posted(BaseModel):
posted: bool
posted_at: str = Field(alias="postedAt")
@tool(id="acme-ops.post-update", effects=[{"kind": "external-side-effect", "resource": "external:status-page"}])
def post_update(input: Update) -> Posted:
"""Posts a message to the public status page."""
return Posted(posted=True, postedAt=datetime.now(UTC).isoformat())
```
This agent posts status updates with it, and every post waits for a person:
* TypeScript
agents/status-agent/index.ts
```ts
import { defineAgent } from '@kindgi/sdk/define';
import type { AgentId, Semver } from '@kindgi/sdk/types';
const defined = defineAgent({
id: 'acme-ops.status-agent' as AgentId,
version: '0.1.0' as Semver,
name: 'Status Agent',
description: 'Posts status updates.',
instructions: 'Post the update the user gives you with acme-ops.post-update.',
capabilities: [{ needs: [{ feature: 'tool-use' as const }] }],
tools: [
{ id: 'acme-ops.post-update', version: '0.1.0' },
{ id: 'acme-ops.greet', version: '0.1.0' },
],
retrieval: [],
guardrails: [],
parameters: [],
conversationPolicy: {
hitl: {
tools: {
overrides: { 'acme-ops.post-update': 'always_ask' },
},
},
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
agents/status_agent.py
```python
from kindgi import Agent
from ..tools.greet import greet
from ..tools.post_update import post_update
status_agent = Agent(
id="acme-ops.status-agent",
version="0.1.0",
name="Status Agent",
description="Posts status updates.",
instructions="Post the update the user gives you with acme-ops.post-update.",
capabilities=[{"needs": [{"feature": "tool-use"}]}],
tools=[post_update, greet],
conversation_policy={
"hitl": {
"tools": {
"overrides": {"acme-ops.post-update": "always_ask"},
},
},
},
)
```
`hitl.tools` turns tool gates on for this agent. Each tool then gets a gate:
* **`always_ask`**: every call waits for a decision.
* **`never_ask`**: calls run without asking.
* **No override:** a tool declared read-only (`mutating: false`) runs without asking; any other tool asks. So `hitl: { tools: {} }` asks before every tool that can change something, and here `acme-ops.greet`, which is read-only, never asks.
An agent without `hitl.tools` has no tool gates, unless your tenant has [approval rules](../approval-rules-for-every-agent/).
## Become a reviewer
Approvals are decided by **reviewers**. Register yourself once (the user your API token belongs to); it covers any token of yours, a console sign-in too:
```sh
kindgi reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'
```
[Decide an approval](../decide-an-approval/) covers reviewers and their roles.
## Run it
```sh
kindgi runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'
```
```json
{
"id": "0c01376f-1566-4a0d-ba6f-95f4efff4758",
…
"flowId": "agent.turn",
"flowVersion": "1.1.0",
"status": "suspended",
…
}
```
The run is `suspended`: the model asked for `acme-ops.post-update`, and the call waits. `kindgi runs start` returns as soon as the run waits; it doesn't block until someone decides.
## See what it waits for
```sh
kindgi approvals list --status=pending
```
```json
{
"items": [
{
"id": "416c22ba-73f9-45b7-999d-c1d5938758f7",
…
"subjectKind": "tool-call:pending",
"subjectRef": {
…
"toolId": "acme-ops.post-update",
"agentId": "acme-ops.status-agent",
…
"arguments": {
"message": "Checkout is back to normal."
},
…
},
"requiredRole": "standard",
"status": "pending",
"title": "HITL review: acme-ops.post-update",
…
"provenanceRef": {
"runId": "0c01376f-1566-4a0d-ba6f-95f4efff4758"
},
…
}
]
}
```
The approval names the tool and the exact arguments the model chose, and `provenanceRef.runId` is the run that waits.
## Approve it
```sh
kindgi approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"
```
```json
{
"kind": "terminal",
"approval": {
"id": "416c22ba-73f9-45b7-999d-c1d5938758f7",
…
"status": "approved",
…
},
"decision": {
…
"decision": "approve",
"rationale": "Confirmed with the on-call engineer",
"reviewerRoleAtDecision": "standard",
…
},
"waitpointResolved": true
}
```
The run continues in the same call: the tool runs and the agent finishes its turn. Its journal (`kindgi runs journal `) says who decided which approval:
```json
{"sequence": 27, "kind": "wait.resumed", "nodeId": "agent-loop", "payload": {"value": {"decided": "approve", "decidedBy": "user:26fc414a-b49f-4e1f-a022-162865770178", "rationale": "Confirmed with the on-call engineer", "approvalId": "a281a631-b48b-4868-b138-213a433e8c40"}, …}, …}
```
```sh
kindgi runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758
```
```json
{
"id": "0c01376f-1566-4a0d-ba6f-95f4efff4758",
…
"status": "completed",
…
{
"role": "tool",
"content": {
"posted": true,
"postedAt": "2026-10-03T20:05:54.473Z"
},
…
```
## Reject it
With `--decision=reject`, the tool doesn't run. The agent gets the rejection as the tool's result, with your rationale, and finishes its turn (a real model can tell the user why nothing was posted):
```sh
kindgi approvals complete --decision=reject --rationale="Not confirmed yet"
```
```json
{
"role": "tool",
"content": {
"status": "rejected",
"rationale": "Not confirmed yet"
},
…
```
The run completes; it doesn't fail. The turn's [provenance](../../observability/trace-an-answer/) keeps the rejected call: its `tool-call`, and a `tool-result` that is the rejection the model read.
## In a flow
When the agent is a step of a flow, the flow waits with it: the flow run is `suspended` too, and its journal shows the step waiting:
```sh
kindgi runs journal
```
```json
{
"sequence": 6,
"kind": "wait.suspended",
"nodeId": "respond",
…
}
```
The approval's `provenanceRef.runId` is the agent step's own run, not the flow run. Once the approval is decided, the agent's turn finishes and the flow continues from that step.
## Next
* [Decide an approval](../decide-an-approval/): escalate, withdraw, roles, and deciding from your app.
* [CLI reference: `kindgi approvals`](../../../reference/cli/approvals/).
# Decide an approval
> Register reviewers, find what's waiting, and approve, reject, escalate or withdraw it from the CLI or your app.
A run that waits for a person (a [gated tool call](../ask-before-a-tool-runs/) or a [long conversation](../ask-after-n-turns/)) creates an **approval**. A **reviewer** decides it, and the run moves on.
## Reviewers and roles
Only reviewers see approvals. Until you register, every approvals command is refused:
```sh
kindgi approvals list
```
```text
Error [auth]: The caller isn't a reviewer: its token carries no reviewer role, and its user isn't registered as one (`kindgi reviewers register`). The approvals surface is reviewer-only.
```
Register yourself, the user your API token belongs to, with a role. That covers any token of yours: this one, another API key, or a console sign-in.
```sh
kindgi reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'
```
```json
{
"id": "b4bc07d5-1640-4c4c-9cbe-710aa6ca7d36",
"tenantId": "3a9fb26d-9782-4bdb-a4f9-3c97d2644b72",
"userId": "b9b6a8af-84ee-4514-a609-a76230750869",
"role": "standard",
"displayName": "Ada Lovelace",
"createdAt": "2026-10-03T19:55:32.200Z"
}
```
The roles rank `standard`, `senior`, `admin`. Each approval has a `requiredRole`, and a reviewer sees and decides the approvals that need their role or a lower one. An approval that needs a higher role doesn't show up in the list, and fetching or deciding it answers `not-found`.
Registering again changes the role. `kindgi reviewers list` shows the reviewers; `kindgi reviewers unregister ` removes one (their past decisions stay).
## Find what's waiting
```sh
kindgi approvals list --status=pending
kindgi approvals get
```
Without `--status`, the list includes decided approvals too. In each approval:
* **`subjectKind`** is what waits: `tool-call:pending` is a tool call (the `subjectRef` has the `toolId` and the `arguments`); `agent-turn:session-hitl-gate` is a conversation that reached its turn limit (the `subjectRef` has the `conversationId` and the `turnCount`).
* **`requiredRole`** is the lowest role that may decide it.
* **`provenanceRef.runId`** is the run that waits.
* **`decision`**, once someone decided it: the decision, the rationale, who (`decidedBy`, as `user:`, and their `reviewerId`), their role then, and when. There's none while it's open, nor when it ended without a decision (it expired, or a timeout escalated it).
```json
"decision": {
"decision": "approve",
"decidedBy": "user:26fc414a-b49f-4e1f-a022-162865770178",
"reviewerId": "974b5bb3-c690-4886-a609-c7fe0cbd5659",
"reviewerRoleAtDecision": "standard",
"decidedAt": "2026-10-05T16:25:52.878Z",
"rationale": "Confirmed with the on-call engineer"
}
```
## Decide
```sh
kindgi approvals complete --decision=approve --rationale="Confirmed with the on-call engineer"
```
`--rationale` is optional and is kept with the decision. The four decisions:
| Decision | The approval | The run |
| ---------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `approve` | `approved` | Continues: the tool runs, or the turn runs. |
| `reject` | `rejected` | A tool call: the tool doesn't run, and the agent gets the rejection as the tool's result. A turn: the run fails with `hitl-rejected`. |
| `escalate` | `escalated`, and a new approval for the next role up | Keeps waiting for the new approval. |
| `withdraw` | `withdrawn` | Keeps waiting; cancel it with `kindgi runs cancel `. |
An approve or reject resumes the run within the same call, so the run has moved on by the time the command returns. A decided approval can't be decided again.
### Escalate
A `standard` reviewer who wants a second opinion escalates:
```sh
kindgi approvals complete 2134897a-d176-4ca8-b000-8bc00d79ad04 --decision=escalate --rationale="Needs sign-off from the incident lead"
```
```json
{
"kind": "escalated",
"approval": {
"id": "2134897a-d176-4ca8-b000-8bc00d79ad04",
…
"requiredRole": "standard",
"status": "escalated",
…
},
…
"nextApproval": {
"id": "a65cd23c-1bdb-47d8-9b3a-aaec36475a4f",
…
"requiredRole": "senior",
"status": "pending",
…
},
"waitpointResolved": false
}
```
The new approval needs a `senior` reviewer, so the `standard` reviewer no longer sees it. A `senior` (or `admin`) reviewer approves or rejects `a65cd23c-…`, and the run continues.
### Withdraw
Withdraw closes an approval without deciding what it asked. The run keeps waiting, so cancel it:
```sh
kindgi approvals complete --decision=withdraw
kindgi runs cancel
```
Cancelling a run doesn't close its approval either: withdraw the approval of a run you cancelled, so it leaves the pending list.
### When nobody decides
Each approval has a deadline, its `expiresAt`: 24 hours after it's asked for, or the agent's own `hitl.timeoutMs` (one hour: `conversationPolicy: { hitl: { tools: {}, timeoutMs: 3_600_000 } }`). When the deadline passes with no decision:
* **Below `admin`,** the approval becomes `escalated`, and a new one asks the next role up (`standard`, then `senior`, then `admin`), with a deadline as long again. The run keeps waiting.
* **At `admin`,** the approval becomes `expired`, and the run's turn fails:
```text
"error":{"code":"hitl-cancelled","message":"Tool-call HITL cancelled for acme-desk.echo: timeout","reason":"timeout"}
```
A decision made before the deadline always wins. A deadline always escalates this way: `hitl.onTimeout` takes only `'escalate'`, the default. An agent that asks for anything else is refused when it's defined:
```text
hitl.onTimeout 'auto-approve' isn't supported: an approval that times out escalates one reviewer tier, and at admin it expires (the turn fails with hitl-cancelled). Use 'escalate', or leave it out.
```
## From your app
A reviewer usually decides in your app, not in a terminal. The client lists the pending approvals and records the decision; the reviewer is the user the API token belongs to.
* TypeScript
review\.ts
```ts
import { createClient } from '@kindgi/sdk/client';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
const pending = await kindgi.approvals.list({ status: 'pending' });
for (const approval of pending.items) {
console.log(approval.id, approval.title, approval.subjectRef);
}
const first = pending.items[0];
if (first) {
const result = await kindgi.approvals.decide(first.id, {
decision: 'approve',
rationale: 'Checked the dashboard',
});
console.log(result.approval.status, result.waitpointResolved);
}
```
```text
46f0f9ad-f22d-4a93-8337-25b7c32a7473 HITL review: acme-ops.post-update {
callId: 'dev-echo-call-1',
toolId: 'acme-ops.post-update',
agentId: 'acme-ops.status-agent',
…
arguments: { message: 'Checkout is slow for some customers.' },
…
}
approved true
```
* Python
review\.py
```python
from kindgi.client import Kindgi
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
pending = kindgi.approvals.list(status="pending")
for approval in pending.data:
print(approval.id, approval.title, approval.subject_ref)
if pending.data:
result = kindgi.approvals.complete(
pending.data[0].id, decision="approve", rationale="Checked the dashboard"
)
print(result.approval.status, result.waitpoint_resolved)
```
```text
0afd1543-fe4e-45ca-af34-2effc1095bab HITL review: acme-ops.post-update {'callId': 'dev-echo-call-1', 'toolId': 'acme-ops.post-update', 'agentId': 'acme-ops.status-agent', …, 'arguments': {'message': 'Search is back.'}, …}
approved True
```
`list` takes a `scope` to see one project's approvals, or every project's in an org: `{ kind: 'project', projectId }` (`scope_kind="project", scope_id=…`) or `{ kind: 'org', orgId }`. An agent's approval is in its turn's project. Approvals from before Kindgi 0.1.3 have no project, so only a list without a scope shows them.
## Reference
* [`kindgi approvals`](../../../reference/cli/approvals/) and [`kindgi reviewers`](../../../reference/cli/reviewers/)
* [Approvals in the HTTP API](../../../reference/api/operations/tags/approvals/)
# Flows
> Write a flow, pass data between its steps, branch, loop, run steps in parallel, retry, wait for a person and dry-run it.
A **flow** is a graph of steps that runs in a known order: tool steps run your code, agent steps ask a model for a judgment, and edges between them decide what runs next. A flow is data, versioned and checked when it loads; every run of it is recorded step by step in its journal.
Use a flow when you know the order of the work (look up, decide, then act). Use a single agent when the model should decide the order.
* [Write a flow](write-a-flow/): tool and agent steps, edges, and running it.
* [Pass data between steps](pass-data-between-steps/): a step's input from the run input and earlier steps, and the run's output.
* [Branch a flow](branch-a-flow/): edges that fire only when a condition holds.
* [Join branches](join-branches/): a step that waits for whichever branches ran.
* [Repeat steps in a loop](loop/): once per element of a list, or until a condition holds.
* [Run steps in parallel](run-steps-in-parallel/): several tools on the same input at once.
* [Retry a failing step](retry-a-step/): retries with backoff, and a time limit per step.
* [Wait for an approval](wait-for-an-approval/): a flow that stops until a person decides.
* [Dry-run a flow](dry-run-a-flow/): run the steps that only read, and stop before the first one that writes.
The examples build up one pack, `acme`, from the `sample` template (`kindgi init acme --template=sample`, or `--template=python`). Each page shows the tools it adds; [Write a flow](write-a-flow/) has the first ones. Where a model matters, the output is from Claude Haiku 4.5, registered with `kindgi providers register --preset=anthropic` (see [Models](../models/)).
# Branch a flow
> Put a condition on an edge so a step runs only when it holds, and write the "otherwise" branch.
An edge with a `when` condition fires only when the condition holds. Two edges out of one step, with opposite conditions, make an if/else. `acme.review-order` holds an order over 1000 USD and confirms the rest, with the tools from [Write a flow](../write-a-flow/) and [Pass data between steps](../pass-data-between-steps/):
* TypeScript
flows/review-order/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const isLarge = {
op: 'gt',
left: { path: 'nodeOutputs.order.total' },
right: { literal: 1000 },
} as const;
const defined = defineFlow({
id: 'acme.review-order',
version: '0.1.0',
name: 'Review an order',
description: 'Holds a large order for review and confirms the rest.',
nodes: [
{
id: 'order',
kind: 'tool',
ref: 'acme.get-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
{
id: 'hold',
kind: 'tool',
ref: 'acme.hold-order',
inputMapping: {
orderId: { path: 'runInput.orderId' },
reason: { literal: 'Over 1000 USD' },
},
},
{
id: 'confirm',
kind: 'tool',
ref: 'acme.confirm-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
],
edges: [
{ id: 'e1', from: '$start', to: 'order' },
{ id: 'e2', from: 'order', to: 'hold', when: isLarge },
{ id: 'e3', from: 'order', to: 'confirm', when: { op: 'not', child: isLarge } },
{ id: 'e4', from: 'hold', to: '$end' },
{ id: 'e5', from: 'confirm', to: '$end' },
],
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
flows/review_order.py
```python
from kindgi import Flow
IS_LARGE = {
"op": "gt",
"left": {"path": "nodeOutputs.order.total"},
"right": {"literal": 1000},
}
review_order = Flow(
id="acme.review-order",
version="0.1.0",
name="Review an order",
description="Holds a large order for review and confirms the rest.",
nodes=[
{
"id": "order",
"kind": "tool",
"ref": "acme.get-order",
"inputMapping": {"orderId": {"path": "runInput.orderId"}},
},
{
"id": "hold",
"kind": "tool",
"ref": "acme.hold-order",
"inputMapping": {
"orderId": {"path": "runInput.orderId"},
"reason": {"literal": "Over 1000 USD"},
},
},
{
"id": "confirm",
"kind": "tool",
"ref": "acme.confirm-order",
"inputMapping": {"orderId": {"path": "runInput.orderId"}},
},
],
edges=[
{"id": "e1", "from": "$start", "to": "order"},
{"id": "e2", "from": "order", "to": "hold", "when": IS_LARGE},
{"id": "e3", "from": "order", "to": "confirm", "when": {"op": "not", "child": IS_LARGE}},
{"id": "e4", "from": "hold", "to": "$end"},
{"id": "e5", "from": "confirm", "to": "$end"},
],
)
```
`e2` fires when the order's total is over 1000; `e3` when it isn't. The condition is defined once and wrapped in `not` for the other branch.
```sh
kindgi runs start --flow=acme.review-order --input='{"orderId":"A-200"}'
```
```json
{
…
"flowId": "acme.review-order",
"status": "completed",
…
"output": {
"status": "held",
"orderId": "A-200"
},
…
}
```
`A-200`'s total is 1250, so the order is held. With `A-100` (42.50), `e3` fires and the order is confirmed.
## What the journal records
Every edge the run evaluates is in the journal with its decision. A step whose edge didn't fire is skipped and has no entries:
```sh
kindgi runs journal
```
```json
{"sequence": 4, "kind": "edge.evaluated", "payload": {"edgeId": "e2", "decision": true}, …}
{"sequence": 5, "kind": "edge.evaluated", "payload": {"edgeId": "e3", "decision": false}, …}
{"sequence": 6, "kind": "step.started", "nodeId": "hold", "payload": {"input": {"reason": "Over 1000 USD", "orderId": "A-200"}}, …}
```
(One entry per line here; the command prints them as one JSON document.)
## Conditions
A condition is JSON. Each operand is a `{ path }` (the same roots as `inputMapping`) or a `{ literal }`:
| Operator | Shape | True when |
| ------------------------------- | ------------------------- | --------------------------------------------------------------- |
| `eq` `ne` `lt` `lte` `gt` `gte` | `{ op, left, right }` | the comparison holds (`lt`… compare two numbers or two strings) |
| `in` `notIn` | `{ op, value, set }` | `value` is (isn't) an element of the array `set` |
| `exists` `notExists` | `{ op, value }` | the path resolves (doesn't) |
| `truthy` `falsy` | `{ op, value }` | the value is truthy (falsy; a missing path is falsy) |
| `and` `or` | `{ op, children: [...] }` | all (any) of the children hold |
| `not` | `{ op, child }` | the child doesn't hold |
A missing value
When a path doesn't resolve, `eq`, `lt`, `lte`, `gt` and `gte` are false and `ne` is true. So for the "otherwise" branch, write `not` around the condition, as above, rather than a second comparison: it covers exactly what the first edge doesn't.
## Branch on an agent's answer
To branch on what an agent decided, give the agent a [typed answer](../../agents/typed-answer/) and compare one of its fields: `{ path: 'nodeOutputs..output.' }`. [Wait for an approval](../wait-for-an-approval/) branches on `nodeOutputs.hold.output.held`, a boolean the agent returns.
## When no branch fires
A step none of whose incoming edges fired is skipped, and so is every step only it leads to. If no edge reaches `$end`, the run still completes, with no output. Cover every case with your conditions, or bring the branches back together: [Join branches](../join-branches/).
# Dry-run a flow
> Run a flow's read-only steps to check its wiring, and stop before the first step that would change something.
A dry run runs a flow for real up to the first step that could change something, and stops there. Use it to check a flow's wiring (mappings, conditions, the data each step gets) against real data, without the writes.
```sh
kindgi runs start --flow=acme.handle-order --input='{"orderId":"A-100"}' --dry-run
```
```json
{
"id": "404da259-4a63-4e6d-a0af-15ad7d60dd8c",
…
"flowId": "acme.handle-order",
"flowVersion": "0.1.0",
"status": "failed",
"dryRun": true,
…
"failureMessage": "dry-run-effectful-tool: tool \"acme.email-customer\" is not declared read-only (mutating: false), so it does not run in a dry run",
…
}
```
`acme.get-order` ran, the branch was decided, and the run stopped at `acme.email-customer`, before calling it. The journal shows how far it got and the input the next step would have had:
```json
{"sequence": 3, "kind": "step.completed", "nodeId": "order", "payload": {"output": {"items": [{"sku": "mug", "quantity": 2}], "total": 42.5, "orderId": "A-100", "customer": "ada@example.com"}}, …}
{"sequence": 4, "kind": "edge.evaluated", "payload": {"edgeId": "e2", "decision": false}, …}
{"sequence": 5, "kind": "edge.evaluated", "payload": {"edgeId": "e3", "decision": true}, …}
{"sequence": 6, "kind": "step.started", "nodeId": "email", "payload": {"input": {"customer": "ada@example.com"}}, …}
{"sequence": 7, "kind": "step.failed", "nodeId": "email", "payload": {"message": "dry-run-effectful-tool: tool \"acme.email-customer\" is not declared read-only (mutating: false), so it does not run in a dry run"}, …}
```
A flow whose steps all only read completes, with its output:
```sh
kindgi runs start --flow=acme.check-order-stock --input='{"orderId":"A-200"}' --dry-run
```
```json
{
…
"status": "completed",
"dryRun": true,
…
"output": {
"items": [
…
]
},
…
}
```
## What runs
* **A tool step** runs if the tool is declared read-only (`mutating: false`) and its `effects` declare no `writes`, `deletes`, `spawns-run`, `emits-event` or `external-side-effect`. A tool that leaves `mutating` out counts as one that changes something. The first other tool stops the run with `dry-run-effectful-tool`; everything before it really ran.
* **An agent step** runs its turn without calling the model: its answer is `[dry-run: model call skipped]`, and it costs nothing. The steps after it get that text, so the dry run shows the wiring, not the agent's judgment.
So a dry run is only as safe as the tools' declarations. Declare `mutating: false` on every tool that only reads, and never on one that writes.
## From your app
* TypeScript
```ts
const run = await kindgi.runs.start({
flow: 'acme.check-order-stock',
input: { orderId: 'A-200' },
options: { dryRun: true },
});
```
* Python
```python
run = kindgi.runs.start(
flow="acme.check-order-stock", input={"orderId": "A-200"}, options={"dryRun": True}
)
```
A dry run is a run like the others, marked `dryRun: true`, with a journal. [Webhook endpoints](../../webhooks/receive-run-finished/) leave dry runs out unless their filter says `includeDryRuns: true`.
# Join branches
> Bring the branches of a flow back together in a step that runs after whichever of them ran.
A step with several incoming edges waits until each of them is decided, then runs once if at least one fired. `acme.handle-order` holds a large order, then emails the customer either way. It adds one tool, `acme.email-customer`:
* TypeScript
tools/email-customer/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const defined = defineTool({
id: 'acme.email-customer' as ToolId,
description: 'Emails the customer about their order.',
version: '0.1.0',
input: z.object({
customer: z.string(),
status: z.string().optional(), // absent when the order wasn't held
}),
output: z.object({ to: z.string(), message: z.string() }),
effects: [],
handler: async ({ customer, status }) => ({
to: customer,
message: status === 'held' ? 'Your order is being reviewed.' : 'Your order is on its way.',
}),
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
flows/handle-order/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const isLarge = {
op: 'gt',
left: { path: 'nodeOutputs.order.total' },
right: { literal: 1000 },
} as const;
const defined = defineFlow({
id: 'acme.handle-order',
version: '0.1.0',
name: 'Handle an order',
description: 'Holds a large order, then tells the customer either way.',
nodes: [
{
id: 'order',
kind: 'tool',
ref: 'acme.get-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
{
id: 'hold',
kind: 'tool',
ref: 'acme.hold-order',
inputMapping: {
orderId: { path: 'runInput.orderId' },
reason: { literal: 'Over 1000 USD' },
},
},
{
id: 'email',
kind: 'tool',
ref: 'acme.email-customer',
inputMapping: {
customer: { path: 'nodeOutputs.order.customer' },
status: { path: 'nodeOutputs.hold.status' }, // absent when hold didn't run
},
},
],
edges: [
{ id: 'e1', from: '$start', to: 'order' },
{ id: 'e2', from: 'order', to: 'hold', when: isLarge },
{ id: 'e3', from: 'order', to: 'email', when: { op: 'not', child: isLarge } },
{ id: 'e4', from: 'hold', to: 'email' },
{ id: 'e5', from: 'email', to: '$end' },
],
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
tools/email_customer.py
```python
from pydantic import BaseModel
from kindgi import tool
class EmailInput(BaseModel):
customer: str
status: str | None = None # absent when the order wasn't held
class Emailed(BaseModel):
to: str
message: str
@tool(id="acme.email-customer")
def email_customer(input: EmailInput) -> Emailed:
"""Emails the customer about their order."""
message = (
"Your order is being reviewed." if input.status == "held" else "Your order is on its way."
)
return Emailed(to=input.customer, message=message)
```
flows/handle_order.py
```python
from kindgi import Flow
IS_LARGE = {
"op": "gt",
"left": {"path": "nodeOutputs.order.total"},
"right": {"literal": 1000},
}
handle_order = Flow(
id="acme.handle-order",
version="0.1.0",
name="Handle an order",
description="Holds a large order, then tells the customer either way.",
nodes=[
{
"id": "order",
"kind": "tool",
"ref": "acme.get-order",
"inputMapping": {"orderId": {"path": "runInput.orderId"}},
},
{
"id": "hold",
"kind": "tool",
"ref": "acme.hold-order",
"inputMapping": {
"orderId": {"path": "runInput.orderId"},
"reason": {"literal": "Over 1000 USD"},
},
},
{
"id": "email",
"kind": "tool",
"ref": "acme.email-customer",
"inputMapping": {
"customer": {"path": "nodeOutputs.order.customer"},
"status": {"path": "nodeOutputs.hold.status"}, # absent when hold didn't run
},
},
],
edges=[
{"id": "e1", "from": "$start", "to": "order"},
{"id": "e2", "from": "order", "to": "hold", "when": IS_LARGE},
{"id": "e3", "from": "order", "to": "email", "when": {"op": "not", "child": IS_LARGE}},
{"id": "e4", "from": "hold", "to": "email"},
{"id": "e5", "from": "email", "to": "$end"},
],
)
```
`email` has two incoming edges: `e4` from `hold`, and `e3` straight from `order` when the order isn't large. Whichever path the run takes, `email` runs once, at the end of it.
```sh
kindgi runs start --flow=acme.handle-order --input='{"orderId":"A-100"}'
kindgi runs start --flow=acme.handle-order --input='{"orderId":"A-200"}'
```
The two runs return:
```json
{ "to": "ada@example.com", "message": "Your order is on its way." }
{ "to": "grace@example.com", "message": "Your order is being reviewed." }
```
## The input of a joining step
`email` maps `status` from `nodeOutputs.hold.status`. For `A-100`, `hold` didn't run, so the path doesn't resolve and the key is left out:
```json
{"sequence": 4, "kind": "edge.evaluated", "payload": {"edgeId": "e2", "decision": false}, …}
{"sequence": 5, "kind": "edge.evaluated", "payload": {"edgeId": "e3", "decision": true}, …}
{"sequence": 6, "kind": "step.started", "nodeId": "email", "payload": {"input": {"customer": "ada@example.com"}}, …}
```
That's why `status` is optional in `acme.email-customer`'s input. A required key fed by a branch that may not run fails the step whenever that branch doesn't run.
Give a joining step an `inputMapping`. Without one, a step gets the output of the step before it, and a joining step has several: its input is empty.
## Branches that both run
Edges without conditions fire together, so two steps after the same step run at the same time, and a step that joins them runs once, after both. To run several tools on the same input and collect their outputs as one, a [fanout step](../run-steps-in-parallel/) is shorter.
# Repeat steps in a loop
> Run steps once for each element of a list, several at a time, or again and again until a condition holds.
A `loop` step runs a small flow of its own, its **body**, several times:
* **`foreach`**: once for each element of a list, one or several at a time.
* **`while`**: again and again, until a condition on the last pass holds.
## For each element of a list
`acme.check-order-stock` checks every item of an order with a new tool, `acme.check-stock`, two items at a time:
* TypeScript
tools/check-stock/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const STOCK: Record = { mug: 10, desk: 0, lamp: 5 };
const defined = defineTool({
id: 'acme.check-stock' as ToolId,
description: 'Checks whether a quantity of one item is in stock.',
version: '0.1.0',
input: z.object({ sku: z.string(), quantity: z.number().int().positive() }),
output: z.object({ sku: z.string(), inStock: z.boolean() }),
effects: [],
mutating: false,
handler: async ({ sku, quantity }) => ({ sku, inStock: (STOCK[sku] ?? 0) >= quantity }),
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
flows/check-order-stock/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.check-order-stock',
version: '0.1.0',
name: 'Check stock for an order',
description: 'Checks every item of an order, two at a time.',
nodes: [
{
id: 'order',
kind: 'tool',
ref: 'acme.get-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
{
id: 'each-item',
kind: 'loop',
loopKind: 'foreach',
iterateOver: { path: 'nodeOutputs.order.items' },
concurrency: 2,
maxIterations: 100,
collectAllIterations: true,
outputSchema: {
type: 'object',
properties: { sku: { type: 'string' }, inStock: { type: 'boolean' } },
required: ['sku', 'inStock'],
},
body: {
nodes: [{ id: 'check', kind: 'tool', ref: 'acme.check-stock' }],
edges: [
{ id: 'b1', from: '$loop-start', to: 'check' },
{ id: 'b2', from: 'check', to: '$loop-end' },
],
},
},
],
edges: [
{ id: 'e1', from: '$start', to: 'order' },
{ id: 'e2', from: 'order', to: 'each-item' },
{ id: 'e3', from: 'each-item', to: '$end' },
],
output: {
mapping: { items: { path: 'nodeOutputs.each-item.outputs' } },
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
tools/check_stock.py
```python
from pydantic import BaseModel, Field
from kindgi import tool
STOCK = {"mug": 10, "desk": 0, "lamp": 5}
class StockInput(BaseModel):
sku: str
quantity: int = Field(gt=0)
class Stock(BaseModel):
sku: str
in_stock: bool = Field(alias="inStock")
@tool(id="acme.check-stock", mutating=False)
def check_stock(input: StockInput) -> Stock:
"""Checks whether a quantity of one item is in stock."""
return Stock(sku=input.sku, inStock=STOCK.get(input.sku, 0) >= input.quantity)
```
flows/check_order_stock.py
```python
from kindgi import Flow
check_order_stock = Flow(
id="acme.check-order-stock",
version="0.1.0",
name="Check stock for an order",
description="Checks every item of an order, two at a time.",
nodes=[
{
"id": "order",
"kind": "tool",
"ref": "acme.get-order",
"inputMapping": {"orderId": {"path": "runInput.orderId"}},
},
{
"id": "each-item",
"kind": "loop",
"loopKind": "foreach",
"iterateOver": {"path": "nodeOutputs.order.items"},
"concurrency": 2,
"maxIterations": 100,
"collectAllIterations": True,
"outputSchema": {
"type": "object",
"properties": {"sku": {"type": "string"}, "inStock": {"type": "boolean"}},
"required": ["sku", "inStock"],
},
"body": {
"nodes": [{"id": "check", "kind": "tool", "ref": "acme.check-stock"}],
"edges": [
{"id": "b1", "from": "$loop-start", "to": "check"},
{"id": "b2", "from": "check", "to": "$loop-end"},
],
},
},
],
edges=[
{"id": "e1", "from": "$start", "to": "order"},
{"id": "e2", "from": "order", "to": "each-item"},
{"id": "e3", "from": "each-item", "to": "$end"},
],
output={"mapping": {"items": {"path": "nodeOutputs.each-item.outputs"}}},
)
```
- **`iterateOver`** is the list: a `{ path }` that must resolve to an array when the loop starts.
- **`body`** has its own steps and edges, from `$loop-start` to `$loop-end`. Each pass gets one element as its input: here `{ "sku": "desk", "quantity": 1 }`, which is `acme.check-stock`'s input as it is.
- **`concurrency`** runs that many passes at a time (1 by default, at most 32).
- **`maxIterations`** caps the passes, and **`outputSchema`** is what each pass must return. Both are required.
```sh
kindgi runs start --flow=acme.check-order-stock --input='{"orderId":"A-200"}'
```
```json
{
…
"status": "completed",
…
"output": {
"items": [
{
"sku": "desk",
"inStock": false
},
{
"sku": "lamp",
"inStock": true
}
]
},
…
}
```
The loop step's own output, in the journal:
```json
{"sequence": 18, "kind": "step.completed", "nodeId": "each-item", "payload": {"output": {"outputs": [{"sku": "desk", "inStock": false}, {"sku": "lamp", "inStock": true}], "iterations": 2, "stopReason": "array-exhausted", "finalOutput": {"sku": "lamp", "inStock": true}}}, …}
```
* `finalOutput` is the last pass's output.
* `outputs` has every pass's output, in the list's order, even when passes finish out of order. It's there only with `collectAllIterations: true`.
* `iterations` is how many passes ran; `stopReason` is why the loop stopped: `array-exhausted`, `exit-condition` (a `while` loop) or `max-iterations`.
Each pass is in the journal as `iteration.started` and `iteration.completed`, and the body's steps carry the pass they belong to.
Inside the body
In a body step's `inputMapping`, `runInput` is the pass's input (the element), and `nodeOutputs` has only the body's own steps. A path to a step outside the loop, or to the run's input, doesn't resolve. Put what every pass needs into the list's elements.
## Until a condition holds
`acme.list-all-orders` reads every page of a paginated list with a new tool, `acme.list-orders`:
* TypeScript
tools/list-orders/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const PAGES: Record = {
first: { orders: ['A-100', 'A-101'], nextCursor: 'p2' },
p2: { orders: ['A-102', 'A-103'], nextCursor: 'p3' },
p3: { orders: ['A-200'] },
};
const defined = defineTool({
id: 'acme.list-orders' as ToolId,
description: 'Lists open orders, one page at a time.',
version: '0.1.0',
input: z.object({ cursor: z.string().optional() }),
output: z.object({ orders: z.array(z.string()), nextCursor: z.string().optional() }),
effects: [],
mutating: false,
handler: async ({ cursor }) => {
const page = PAGES[cursor ?? 'first'];
if (page === undefined) throw new Error(`Unknown cursor ${cursor}`);
return page;
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
flows/list-all-orders/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.list-all-orders',
version: '0.1.0',
name: 'List all open orders',
description: 'Reads every page of open orders.',
nodes: [
{
id: 'pages',
kind: 'loop',
loopKind: 'while',
// stop when the last page read has no next cursor
exitCondition: { op: 'falsy', value: { path: 'iterationOutput.nextCursor' } },
maxIterations: 20,
collectAllIterations: true,
outputSchema: {
type: 'object',
properties: {
orders: { type: 'array', items: { type: 'string' } },
nextCursor: { type: ['string', 'null'] },
},
required: ['orders'],
},
body: {
nodes: [
{
id: 'page',
kind: 'tool',
ref: 'acme.list-orders',
// runInput here is the previous page (the run input on the first pass)
inputMapping: { cursor: { path: 'runInput.nextCursor' } },
},
],
edges: [
{ id: 'b1', from: '$loop-start', to: 'page' },
{ id: 'b2', from: 'page', to: '$loop-end' },
],
},
},
],
edges: [
{ id: 'e1', from: '$start', to: 'pages' },
{ id: 'e2', from: 'pages', to: '$end' },
],
output: {
mapping: { pages: { path: 'nodeOutputs.pages.outputs' } },
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
tools/list_orders.py
```python
from pydantic import BaseModel, Field
from kindgi import tool
PAGES = {
"first": {"orders": ["A-100", "A-101"], "nextCursor": "p2"},
"p2": {"orders": ["A-102", "A-103"], "nextCursor": "p3"},
"p3": {"orders": ["A-200"]},
}
class ListInput(BaseModel):
cursor: str | None = None
class Page(BaseModel):
orders: list[str]
next_cursor: str | None = Field(None, alias="nextCursor")
@tool(id="acme.list-orders", mutating=False)
def list_orders(input: ListInput) -> Page:
"""Lists open orders, one page at a time."""
page = PAGES.get(input.cursor or "first")
if page is None:
raise ValueError(f"Unknown cursor {input.cursor}")
return Page(**page)
```
flows/list_all_orders.py
```python
from kindgi import Flow
list_all_orders = Flow(
id="acme.list-all-orders",
version="0.1.0",
name="List all open orders",
description="Reads every page of open orders.",
nodes=[
{
"id": "pages",
"kind": "loop",
"loopKind": "while",
# stop when the last page read has no next cursor
"exitCondition": {"op": "falsy", "value": {"path": "iterationOutput.nextCursor"}},
"maxIterations": 20,
"collectAllIterations": True,
"outputSchema": {
"type": "object",
"properties": {
"orders": {"type": "array", "items": {"type": "string"}},
"nextCursor": {"type": ["string", "null"]},
},
"required": ["orders"],
},
"body": {
"nodes": [
{
"id": "page",
"kind": "tool",
"ref": "acme.list-orders",
# runInput here is the previous page (the run input on the first pass)
"inputMapping": {"cursor": {"path": "runInput.nextCursor"}},
}
],
"edges": [
{"id": "b1", "from": "$loop-start", "to": "page"},
{"id": "b2", "from": "page", "to": "$loop-end"},
],
},
},
],
edges=[
{"id": "e1", "from": "$start", "to": "pages"},
{"id": "e2", "from": "pages", "to": "$end"},
],
output={"mapping": {"pages": {"path": "nodeOutputs.pages.outputs"}}},
)
```
- The first pass gets the loop step's input: the output of the step before it, here the run's input. Each later pass gets the **previous pass's output**, so `runInput.nextCursor` in the body is the cursor the last page returned.
- **`exitCondition`** is checked after each pass, against `iterationOutput` (that pass's output) and `iterationIndex` (from 0). The loop stops when it holds, or after `maxIterations` passes.
```sh
kindgi runs start --flow=acme.list-all-orders --input='{}'
```
* TypeScript
```json
{
"pages": [
{ "orders": ["A-100", "A-101"], "nextCursor": "p2" },
{ "orders": ["A-102", "A-103"], "nextCursor": "p3" },
{ "orders": ["A-200"] }
]
}
```
* Python
```json
{
"pages": [
{ "orders": ["A-100", "A-101"], "nextCursor": "p2" },
{ "orders": ["A-102", "A-103"], "nextCursor": "p3" },
{ "orders": ["A-200"], "nextCursor": null }
]
}
```
A pydantic field that is `None` is `null` in the output, so the schema allows `null` and the condition is `falsy` (true for a missing value and for `null`).
That's the run's `output`, from `nodeOutputs.pages.outputs`.
A loop that reaches `maxIterations` stops with `stopReason: "max-iterations"` and completes; check `stopReason` if running out of passes is an error for you.
# Pass data between steps
> Build each step's input from the run's input, earlier steps' outputs and fixed values, and declare what the run returns.
Each step's `inputMapping` says where every key of its input comes from. The flow's `output` says what the run returns. This page uses one more tool, `acme.hold-order`, which puts an order on hold:
* TypeScript
tools/hold-order/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const defined = defineTool({
id: 'acme.hold-order' as ToolId,
description: 'Puts an order on hold for a person to review.',
version: '0.1.0',
input: z.object({ orderId: z.string(), reason: z.string() }),
output: z.object({ orderId: z.string(), status: z.literal('held') }),
effects: [],
// no `mutating: false`: it writes, so a dry run stops before it
handler: async ({ orderId }) => ({ orderId, status: 'held' as const }),
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
tools/hold_order.py
```python
from typing import Literal
from pydantic import BaseModel, Field
from kindgi import tool
class HoldInput(BaseModel):
order_id: str = Field(alias="orderId")
reason: str
class Held(BaseModel):
order_id: str = Field(alias="orderId")
status: Literal["held"] = "held"
@tool(id="acme.hold-order") # no mutating=False: it writes, so a dry run stops before it
def hold_order(input: HoldInput) -> Held:
"""Puts an order on hold for a person to review."""
return Held(orderId=input.order_id)
```
## Map a step's input
`acme.hold-for-stock` looks an order up, then holds it with a fixed reason:
* TypeScript
flows/hold-for-stock/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.hold-for-stock',
version: '0.1.0',
name: 'Hold an order for stock',
description: 'Looks up an order and puts it on hold.',
nodes: [
{
id: 'order',
kind: 'tool',
ref: 'acme.get-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
{
id: 'hold',
kind: 'tool',
ref: 'acme.hold-order',
inputMapping: {
orderId: { path: 'nodeOutputs.order.orderId' },
reason: { literal: 'Waiting for stock' },
},
},
],
edges: [
{ id: 'e1', from: '$start', to: 'order' },
{ id: 'e2', from: 'order', to: 'hold' },
{ id: 'e3', from: 'hold', to: '$end' },
],
output: {
mapping: {
orderId: { path: 'runInput.orderId' },
customer: { path: 'nodeOutputs.order.customer' },
status: { path: 'nodeOutputs.hold.status' },
},
schema: {
type: 'object',
properties: {
orderId: { type: 'string' },
customer: { type: 'string' },
status: { type: 'string' },
},
required: ['orderId', 'customer', 'status'],
},
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
flows/hold_for_stock.py
```python
from kindgi import Flow
hold_for_stock = Flow(
id="acme.hold-for-stock",
version="0.1.0",
name="Hold an order for stock",
description="Looks up an order and puts it on hold.",
nodes=[
{
"id": "order",
"kind": "tool",
"ref": "acme.get-order",
"inputMapping": {"orderId": {"path": "runInput.orderId"}},
},
{
"id": "hold",
"kind": "tool",
"ref": "acme.hold-order",
"inputMapping": {
"orderId": {"path": "nodeOutputs.order.orderId"},
"reason": {"literal": "Waiting for stock"},
},
},
],
edges=[
{"id": "e1", "from": "$start", "to": "order"},
{"id": "e2", "from": "order", "to": "hold"},
{"id": "e3", "from": "hold", "to": "$end"},
],
output={
"mapping": {
"orderId": {"path": "runInput.orderId"},
"customer": {"path": "nodeOutputs.order.customer"},
"status": {"path": "nodeOutputs.hold.status"},
},
"schema": {
"type": "object",
"properties": {
"orderId": {"type": "string"},
"customer": {"type": "string"},
"status": {"type": "string"},
},
"required": ["orderId", "customer", "status"],
},
},
)
```
Each key of `inputMapping` is a key of the tool's input, and its value is one of:
* **`{ path: 'runInput.…' }`**: from the input the run was started with.
* **`{ path: 'nodeOutputs..…' }`**: from the output of an earlier step, by the step's `id`.
* **`{ literal: … }`**: a fixed value, any JSON.
Paths are dot-separated field names: `nodeOutputs.order.customer` is the `customer` field of the `order` step's output.
In Python, the keys are the tool input's names as they travel: a field's alias when it has one (`orderId` for `order_id: str = Field(alias="orderId")`), else its name.
```sh
kindgi runs start --flow=acme.hold-for-stock --input='{"orderId":"A-200"}'
```
```json
{
…
"flowId": "acme.hold-for-stock",
"status": "completed",
…
"output": {
"status": "held",
"orderId": "A-200",
"customer": "grace@example.com"
},
…
}
```
The journal shows the input each step got, after mapping:
```sh
kindgi runs journal
```
```json
{
"sequence": 5,
"kind": "step.started",
"nodeId": "hold",
"payload": {
"input": {
"reason": "Waiting for stock",
"orderId": "A-200"
}
},
…
},
```
## Without a mapping
A step without `inputMapping` gets the output of the step before it (the run's input, after `$start`). The tool's input schema takes the fields it declares and drops the rest, so a step that needs exactly what the previous step returns doesn't need a mapping. A step with several incoming edges needs one.
## When a path doesn't resolve
A path that doesn't resolve leaves its key out of the input, rather than setting it to `null`. If the tool requires that key, the step fails. Mapping `reason` from `nodeOutputs.order.reason`, which `acme.get-order` doesn't return, fails the run with:
```text
input-validation-failed: Input for tool "acme.hold-order" failed validation
```
A key that may be missing (because it comes from a branch that may not run) belongs in the tool's input as optional: `.optional()` in zod, a default in pydantic. [Join branches](../join-branches/) has an example.
## An agent step's answer
An agent step's output has the answer as text, and as fields when the agent declares a [typed answer](../../agents/typed-answer/):
* `nodeOutputs..text`: the answer as text.
* `nodeOutputs..output.`: a field of the typed answer. Note the `.output`: `nodeOutputs..` doesn't resolve.
## Declare the run's output
`output.mapping` is resolved when the run finishes, from the same roots as `inputMapping`. With a `schema`, the result is checked against it, and a run whose output doesn't match fails. If `customer` were mapped from a path that doesn't resolve, the run above would fail with:
```text
output-schema-violation: the run's output does not match the flow's output schema: [{"instancePath":"","schemaPath":"#/required","keyword":"required","params":{"missingProperty":"customer"},"message":"must have required property 'customer'"}]
```
Without `output`, the run returns the output of the step that reached `$end`. Declare `output` when callers rely on the shape: it stays the same when you add or reorder steps.
# Retry a failing step
> Retry a step that fails now and then, with backoff, and give it a time limit.
A step that calls something flaky can be retried before its failure fails the run. The retry policy goes on the **edge into the step**. `acme.reserve-stock` calls a warehouse that is busy for the first two calls per order:
* TypeScript
tools/reserve-stock/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
// The warehouse API is busy for the first two calls per order.
const calls = new Map();
const defined = defineTool({
id: 'acme.reserve-stock' as ToolId,
description: 'Reserves the stock for an order at the warehouse.',
version: '0.1.0',
input: z.object({ orderId: z.string() }),
output: z.object({ orderId: z.string(), reserved: z.boolean(), attempt: z.number().int() }),
effects: [],
handler: async ({ orderId }) => {
const attempt = (calls.get(orderId) ?? 0) + 1;
calls.set(orderId, attempt);
if (attempt < 3) throw new Error('Warehouse busy (503)');
return { orderId, reserved: true, attempt };
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
flows/reserve-order/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.reserve-order',
version: '0.1.0',
name: 'Reserve an order',
description: 'Reserves the stock for an order, retrying while the warehouse is busy.',
nodes: [{ id: 'reserve', kind: 'tool', ref: 'acme.reserve-stock' }],
edges: [
{
id: 'e1',
from: '$start',
to: 'reserve',
policy: {
retry: { maxAttempts: 4, delayMs: 500, backoff: 'exponential', maxDelayMs: 5_000 },
timeoutMs: 10_000,
},
},
{ id: 'e2', from: 'reserve', to: '$end' },
],
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
tools/reserve_stock.py
```python
from pydantic import BaseModel, Field
from kindgi import tool
# The warehouse API is busy for the first two calls per order.
calls: dict[str, int] = {}
class ReserveInput(BaseModel):
order_id: str = Field(alias="orderId")
class Reserved(BaseModel):
order_id: str = Field(alias="orderId")
reserved: bool
attempt: int
@tool(id="acme.reserve-stock")
def reserve_stock(input: ReserveInput) -> Reserved:
"""Reserves the stock for an order at the warehouse."""
attempt = calls.get(input.order_id, 0) + 1
calls[input.order_id] = attempt
if attempt < 3:
raise RuntimeError("Warehouse busy (503)")
return Reserved(orderId=input.order_id, reserved=True, attempt=attempt)
```
flows/reserve_order.py
```python
from kindgi import Flow
reserve_order = Flow(
id="acme.reserve-order",
version="0.1.0",
name="Reserve an order",
description="Reserves the stock for an order, retrying while the warehouse is busy.",
nodes=[{"id": "reserve", "kind": "tool", "ref": "acme.reserve-stock"}],
edges=[
{
"id": "e1",
"from": "$start",
"to": "reserve",
"policy": {
"retry": {"maxAttempts": 4, "delayMs": 500, "backoff": "exponential", "maxDelayMs": 5_000},
"timeoutMs": 10_000,
},
},
{"id": "e2", "from": "reserve", "to": "$end"},
],
)
```
```sh
kindgi runs start --flow=acme.reserve-order --input='{"orderId":"A-200"}'
```
```json
{
…
"status": "completed",
…
"output": {
"attempt": 3,
"orderId": "A-200",
"reserved": true
},
…
}
```
The journal has each retry, with the error that caused it and the wait before the next attempt:
```json
{"sequence": 2, "kind": "step.started", "nodeId": "reserve", "payload": {"input": {"orderId": "A-200"}}, …}
{"sequence": 3, "kind": "step.retry-scheduled", "nodeId": "reserve", "payload": {"nodeId": "reserve", "attempt": 1, "nextDelayMs": 500, "previousError": "handler-error: Tool \"acme.reserve-stock\" handler threw: … Warehouse busy (503)"}, …}
{"sequence": 4, "kind": "step.retry-scheduled", "nodeId": "reserve", "payload": {"nodeId": "reserve", "attempt": 2, "nextDelayMs": 1000, "previousError": "handler-error: Tool \"acme.reserve-stock\" handler threw: … Warehouse busy (503)"}, …}
{"sequence": 5, "kind": "step.completed", "nodeId": "reserve", "payload": {"output": {"attempt": 3, "orderId": "A-200", "reserved": true}}, …}
```
## The policy
`policy.retry`:
* **`maxAttempts`**: attempts in all, the first one included (at most 10).
* **`delayMs`**: the wait before the first retry (0 by default).
* **`backoff`**: `fixed` (the default: the same wait each time), `linear` (`delayMs` × the retry's number) or `exponential` (doubling each time: 500, 1000, 2000 ms here). `exponential` needs **`maxDelayMs`**, the longest wait.
When the last attempt fails, the step fails and so does the run, with the last error.
`policy.timeoutMs` limits each attempt. An attempt that takes longer is stopped and counts as a failed attempt; without retries, the step fails with `reason: "timeout"`:
```json
{"sequence": 3, "kind": "step.failed", "nodeId": "reserve", "payload": {"reason": "timeout", "limitMs": 5, "message": "Handler for node \"reserve\" exceeded timeout of 5ms", "elapsedMs": 68}, …}
```
(That was `timeoutMs: 5`, to show it.)
Caution
Retry only a step that is safe to run twice. A tool that failed may have done part of its work: a payment that timed out may still have gone through. Make the tool idempotent (for example, pass the order id to the API as its idempotency key), or don't retry it.
## Where a policy applies
A policy applies to the step its edge leads to, when that step has exactly one incoming edge. A step that joins branches (several incoming edges) ignores the policies on them. The other policy fields, `concurrencyKey` and `priority`, are in the [flow schema](../../../reference/schemas/flow/).
# Run steps in parallel
> Run several tools on the same input at the same time with a fanout step, and choose how their results come back.
A `fanout` step runs several tools at once, on the same input, and collects their outputs into one. `acme.assess-order` scores an order's risk and quotes its shipping at the same time, with two new read-only tools:
* TypeScript
tools/score-risk/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const defined = defineTool({
id: 'acme.score-risk' as ToolId,
description: 'Scores the fraud risk of an order.',
version: '0.1.0',
input: z.object({ total: z.number() }),
output: z.object({ level: z.enum(['low', 'high']) }),
effects: [],
mutating: false,
handler: async ({ total }) => ({ level: total > 1000 ? ('high' as const) : ('low' as const) }),
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
tools/quote-shipping/index.ts
```ts
import { defineTool } from '@kindgi/sdk/define';
import type { ToolId } from '@kindgi/sdk/types';
import { z } from 'zod';
const defined = defineTool({
id: 'acme.quote-shipping' as ToolId,
description: 'Quotes how many days an order takes to ship.',
version: '0.1.0',
input: z.object({ items: z.array(z.object({ sku: z.string(), quantity: z.number() })) }),
output: z.object({ days: z.number().int() }),
effects: [],
mutating: false,
handler: async ({ items }) => ({ days: 2 + items.length }),
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
flows/assess-order/index.ts
```ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.assess-order',
version: '0.1.0',
name: 'Assess an order',
description: 'Scores the risk and quotes shipping for an order, at the same time.',
nodes: [
{
id: 'order',
kind: 'tool',
ref: 'acme.get-order',
inputMapping: { orderId: { path: 'runInput.orderId' } },
},
{
id: 'assess',
kind: 'fanout',
convergence: 'all-succeed',
branches: [
{
branchId: 'risk',
handler: 'acme.score-risk',
outputSchema: { type: 'object', properties: { level: { type: 'string' } }, required: ['level'] },
},
{
branchId: 'shipping',
handler: 'acme.quote-shipping',
outputSchema: { type: 'object', properties: { days: { type: 'integer' } }, required: ['days'] },
},
],
},
],
edges: [
{ id: 'e1', from: '$start', to: 'order' },
{ id: 'e2', from: 'order', to: 'assess' },
{ id: 'e3', from: 'assess', to: '$end' },
],
output: {
mapping: {
risk: { path: 'nodeOutputs.assess.risk.level' },
shippingDays: { path: 'nodeOutputs.assess.shipping.days' },
},
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;
```
* Python
tools/score_risk.py
```python
from typing import Literal
from pydantic import BaseModel
from kindgi import tool
class RiskInput(BaseModel):
total: float
class Risk(BaseModel):
level: Literal["low", "high"]
@tool(id="acme.score-risk", mutating=False)
def score_risk(input: RiskInput) -> Risk:
"""Scores the fraud risk of an order."""
return Risk(level="high" if input.total > 1000 else "low")
```
tools/quote_shipping.py
```python
from pydantic import BaseModel
from kindgi import tool
class Item(BaseModel):
sku: str
quantity: int
class ShippingInput(BaseModel):
items: list[Item]
class Shipping(BaseModel):
days: int
@tool(id="acme.quote-shipping", mutating=False)
def quote_shipping(input: ShippingInput) -> Shipping:
"""Quotes how many days an order takes to ship."""
return Shipping(days=2 + len(input.items))
```
flows/assess_order.py
```python
from kindgi import Flow
assess_order = Flow(
id="acme.assess-order",
version="0.1.0",
name="Assess an order",
description="Scores the risk and quotes shipping for an order, at the same time.",
nodes=[
{
"id": "order",
"kind": "tool",
"ref": "acme.get-order",
"inputMapping": {"orderId": {"path": "runInput.orderId"}},
},
{
"id": "assess",
"kind": "fanout",
"convergence": "all-succeed",
"branches": [
{
"branchId": "risk",
"handler": "acme.score-risk",
"outputSchema": {
"type": "object",
"properties": {"level": {"type": "string"}},
"required": ["level"],
},
},
{
"branchId": "shipping",
"handler": "acme.quote-shipping",
"outputSchema": {
"type": "object",
"properties": {"days": {"type": "integer"}},
"required": ["days"],
},
},
],
},
],
edges=[
{"id": "e1", "from": "$start", "to": "order"},
{"id": "e2", "from": "order", "to": "assess"},
{"id": "e3", "from": "assess", "to": "$end"},
],
output={
"mapping": {
"risk": {"path": "nodeOutputs.assess.risk.level"},
"shippingDays": {"path": "nodeOutputs.assess.shipping.days"},
}
},
)
```
- Each **branch** has a `branchId`, the tool it runs (`handler`), and the `outputSchema` its output must match.
- Every branch gets the fanout step's input: the output of the step before it, here the whole order. Each tool takes the fields its input declares (`total`, `items`) and drops the rest.
- **`convergence`** decides when the step is done and what it returns (below).
```sh
kindgi runs start --flow=acme.assess-order --input='{"orderId":"A-200"}'
```
```json
{
…
"status": "completed",
…
"output": {
"risk": "high",
"shippingDays": 4
},
…
}
```
In the journal, both branches are dispatched before either completes:
```json
{"sequence": 6, "kind": "fanout.dispatched", "nodeId": "assess", "payload": {…, "handler": "acme.score-risk", "branchId": "risk", "fanoutNodeId": "assess"}, …}
{"sequence": 7, "kind": "fanout.dispatched", "nodeId": "assess", "payload": {…, "handler": "acme.quote-shipping", "branchId": "shipping", "fanoutNodeId": "assess"}, …}
{"sequence": 8, "kind": "fanout.branch-completed", "nodeId": "assess", "payload": {"output": {"days": 4}, "branchId": "shipping", "fanoutNodeId": "assess"}, …}
{"sequence": 9, "kind": "fanout.branch-completed", "nodeId": "assess", "payload": {"output": {"level": "high"}, "branchId": "risk", "fanoutNodeId": "assess"}, …}
{"sequence": 10, "kind": "fanout.converged", "nodeId": "assess", "payload": {"output": {"risk": {"level": "high"}, "shipping": {"days": 4}}, "convergence": "all-succeed", "fanoutNodeId": "assess"}, …}
```
## Convergence
| `convergence` | Done when | The step's output |
| ------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `all-succeed` | every branch succeeded; the first failure fails the step and cancels the others | `{ "":