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 | `{ "": , … }` | | `any-succeed` | the first branch succeeds (the others are cancelled); fails only if every branch fails | `{ "winnerBranchId": "", "output": }` | | `settle-all` | every branch finished, succeeded or failed; the step itself succeeds | `{ "": { "status": "succeeded", "output": … } \| { "status": "failed", "error": "…" }, … }` | With `settle-all`, a failed branch is data for the next step, not a failed run. Adding a branch that runs `acme.check-stock` (whose input the order doesn't fit) to the fanout above gives: ```json { "risk": { "output": { "level": "high" }, "status": "succeeded" }, "stock": { "error": "input-validation-failed: Input for tool \"acme.check-stock\" failed validation", "status": "failed" } } ``` With `all-succeed`, the same branch fails the run: ```text [branch-failure] Fanout "assess" all-succeed mode: branch "stock" failed — input-validation-failed: Input for tool "acme.check-stock" failed validation ``` ## Fanout or loop A fanout runs **different** tools on the **same** input. To run the **same** steps on each element of a list, several at a time, use a `foreach` loop with `concurrency`: [Repeat steps in a loop](../loop/). # Wait for an approval > Stop a flow until a person approves what an agent step is about to do, then continue on their decision. A flow waits for a person through an agent step: when the agent asks for a tool that needs approval, its turn stops, and the flow stops with it. A reviewer decides, and the flow continues from where it stopped. `acme.escalate-order` has an agent put an order on hold (every hold waits for a person), then emails the customer if the hold went through. It uses `acme.hold-order` from [Pass data between steps](../pass-data-between-steps/) and `acme.email-customer` from [Join branches](../join-branches/). ## The agent The agent gates `acme.hold-order` with `always_ask`, and returns a typed answer the flow can branch on: * TypeScript agents/order-holder/index.ts ```ts import { defineAgent } from '@kindgi/sdk/define'; import type { AgentId, Semver } from '@kindgi/sdk/types'; const defined = defineAgent({ id: 'acme.order-holder' as AgentId, version: '0.1.0' as Semver, name: 'Order holder', description: 'Puts an order on hold, after a person approves.', instructions: 'Put order {{ input.orderId }} on hold with the acme.hold-order tool, reason "{{ input.reason }}". ' + 'Set held to true if the tool ran, false if the call was rejected, and say why in summary.', capabilities: [{ needs: [{ feature: 'tool-use' as const }] }], tools: [{ id: 'acme.hold-order', version: '0.1.0' }], retrieval: [], guardrails: [], parameters: [], conversationPolicy: { hitl: { tools: { overrides: { 'acme.hold-order': 'always_ask' } } }, }, output: { schema: { type: 'object', properties: { held: { type: 'boolean' }, summary: { type: 'string' } }, required: ['held', 'summary'], }, }, budget: { maxSteps: 4, maxCostUsd: 0.05, maxWallMs: 60_000 }, }); 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) ``` agents/order_holder.py ```python from pydantic import BaseModel from kindgi import Agent from ..tools.hold_order import hold_order class HoldResult(BaseModel): held: bool summary: str order_holder = Agent( id="acme.order-holder", version="0.1.0", name="Order holder", description="Puts an order on hold, after a person approves.", instructions=( 'Put order {{ input.orderId }} on hold with the acme.hold-order tool, reason "{{ input.reason }}". ' "Set held to true if the tool ran, false if the call was rejected, and say why in summary." ), capabilities=[{"needs": [{"feature": "tool-use"}]}], tools=[hold_order], conversation_policy={"hitl": {"tools": {"overrides": {"acme.hold-order": "always_ask"}}}}, output=HoldResult, budget={"maxSteps": 4, "maxCostUsd": 0.05, "maxWallMs": 60_000}, ) ``` [Ask before a tool runs](../../approvals/ask-before-a-tool-runs/) covers the gates (`always_ask`, `never_ask`, and the default for tools that aren't read-only). ## The flow * TypeScript flows/escalate-order/index.ts ```ts import { defineFlow } from '@kindgi/sdk/define'; const isHeld = { op: 'eq', left: { path: 'nodeOutputs.hold.output.held' }, right: { literal: true }, } as const; const defined = defineFlow({ id: 'acme.escalate-order', version: '0.1.0', name: 'Escalate an order', description: 'Has an agent put an order on hold, once a person approves, then tells the customer.', nodes: [ { id: 'order', kind: 'tool', ref: 'acme.get-order', inputMapping: { orderId: { path: 'runInput.orderId' } }, }, { id: 'hold', kind: 'agent', ref: 'acme.order-holder', inputMapping: { orderId: { path: 'runInput.orderId' }, reason: { path: 'runInput.reason' }, }, }, { id: 'email', kind: 'tool', ref: 'acme.email-customer', inputMapping: { customer: { path: 'nodeOutputs.order.customer' }, status: { literal: 'held' }, }, }, ], edges: [ { id: 'e1', from: '$start', to: 'order' }, { id: 'e2', from: 'order', to: 'hold' }, { id: 'e3', from: 'hold', to: 'email', when: isHeld }, { id: 'e4', from: 'hold', to: '$end', when: { op: 'not', child: isHeld } }, { id: 'e5', from: 'email', to: '$end' }, ], output: { mapping: { held: { path: 'nodeOutputs.hold.output.held' }, summary: { path: 'nodeOutputs.hold.output.summary' }, notified: { path: 'nodeOutputs.email.to' }, }, }, }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` * Python flows/escalate_order.py ```python from kindgi import Flow IS_HELD = { "op": "eq", "left": {"path": "nodeOutputs.hold.output.held"}, "right": {"literal": True}, } escalate_order = Flow( id="acme.escalate-order", version="0.1.0", name="Escalate an order", description="Has an agent put an order on hold, once a person approves, then tells the customer.", nodes=[ { "id": "order", "kind": "tool", "ref": "acme.get-order", "inputMapping": {"orderId": {"path": "runInput.orderId"}}, }, { "id": "hold", "kind": "agent", "ref": "acme.order-holder", "inputMapping": { "orderId": {"path": "runInput.orderId"}, "reason": {"path": "runInput.reason"}, }, }, { "id": "email", "kind": "tool", "ref": "acme.email-customer", "inputMapping": { "customer": {"path": "nodeOutputs.order.customer"}, "status": {"literal": "held"}, }, }, ], edges=[ {"id": "e1", "from": "$start", "to": "order"}, {"id": "e2", "from": "order", "to": "hold"}, {"id": "e3", "from": "hold", "to": "email", "when": IS_HELD}, {"id": "e4", "from": "hold", "to": "$end", "when": {"op": "not", "child": IS_HELD}}, {"id": "e5", "from": "email", "to": "$end"}, ], output={ "mapping": { "held": {"path": "nodeOutputs.hold.output.held"}, "summary": {"path": "nodeOutputs.hold.output.summary"}, "notified": {"path": "nodeOutputs.email.to"}, } }, ) ``` Nothing in the flow says "wait": the step waits because the agent's tool call does. After the decision, `e3` or `e4` reads the agent's answer, `nodeOutputs.hold.output.held`. ## Run it Only reviewers can decide approvals. Register yourself once: ```sh kindgi reviewers register --spec='{"role":"standard"}' ``` Then start the flow: ```sh kindgi runs start --flow=acme.escalate-order --input='{"orderId":"A-200","reason":"Address looks wrong"}' ``` ```json { "id": "7111dde4-bdd3-40a1-a6e8-2a550d0ad318", … "flowId": "acme.escalate-order", "flowVersion": "0.1.0", "status": "suspended", … } ``` The run is `suspended`, and the command returns as soon as it waits. Nothing keeps running while it waits: the journal records where the run stopped, and the decision picks it up from there. ```json {"sequence": 5, "kind": "step.started", "nodeId": "hold", "payload": {"input": {"reason": "Address looks wrong", "orderId": "A-200"}}, …} {"sequence": 6, "kind": "wait.suspended", "nodeId": "hold", "payload": {"nodeId": "hold", "tokenId": "child:5722bdd7-9c8c-4e57-a74c-ff5d1d3c46e2:25"}, …} ``` ## Decide ```sh kindgi approvals list --status=pending ``` ```json { "items": [ { "id": "0eaa2820-0afe-4147-a017-fc8a4d1657cc", … "subjectKind": "tool-call:pending", "subjectRef": { … "toolId": "acme.hold-order", "agentId": "acme.order-holder", … "arguments": { "reason": "Address looks wrong", "orderId": "A-200" }, … }, "requiredRole": "standard", "status": "pending", … "provenanceRef": { "runId": "5722bdd7-9c8c-4e57-a74c-ff5d1d3c46e2" }, … } ] } ``` `provenanceRef.runId` is the agent's turn, the step's own run; its `parentRunId` is the flow's run. Approve it: ```sh kindgi approvals complete 0eaa2820-0afe-4147-a017-fc8a4d1657cc --decision=approve --rationale="Checked with the customer" ``` The decision resumes the agent's turn and then the flow, within the same call. By the time the command returns, the flow has finished: ```sh kindgi runs get 7111dde4-bdd3-40a1-a6e8-2a550d0ad318 ``` ```json { … "status": "completed", … "output": { "held": true, "summary": "Order A-200 has been successfully placed on hold with the reason 'Address looks wrong'. The hold was accepted and processed.", "notified": "grace@example.com" } } ``` ## When the reviewer rejects A rejection doesn't fail the step. The tool doesn't run, the agent gets the rejection (with the rationale) as the tool's result, and answers. Here it answers `held: false`, so `e4` ends the run without the email: ```json { "held": false, "summary": "The hold request was rejected. The system determined that order A-100 is not a duplicate order, so the hold could not be placed with the reason 'Duplicate order.'" } ``` That's why the agent returns a typed answer: the flow decides what comes next from a field, not from the wording of a sentence. ## Stop waiting `kindgi runs cancel ` on the flow's run cancels it and the agent's turn. It doesn't close the approval: withdraw it as well, as [Decide an approval](../../approvals/decide-an-approval/) shows. # Write a flow > Define a flow of tool and agent steps, run it, and change it while kindgi dev runs. This page builds `acme.process-order`: look an order up, have an agent write a thank-you note, then confirm the order with that note. Two tools, one agent, one flow. ## The tools `acme.get-order` only reads, so it says `mutating: false`. `acme.confirm-order` changes something, so it doesn't: * TypeScript tools/get-order/index.ts ```ts import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; const Order = z.object({ orderId: z.string(), customer: z.string(), total: z.number(), items: z.array(z.object({ sku: z.string(), quantity: z.number().int() })), }); const ORDERS: Record> = { 'A-100': { orderId: 'A-100', customer: 'ada@example.com', total: 42.5, items: [{ sku: 'mug', quantity: 2 }] }, 'A-200': { orderId: 'A-200', customer: 'grace@example.com', total: 1250, items: [ { sku: 'desk', quantity: 1 }, { sku: 'lamp', quantity: 2 }, ], }, }; const defined = defineTool({ id: 'acme.get-order' as ToolId, description: 'Looks up an order by id.', version: '0.1.0', input: z.object({ orderId: z.string().min(1) }), output: Order, effects: [], mutating: false, // only reads handler: async ({ orderId }) => { const order = ORDERS[orderId]; if (order === undefined) throw new Error(`No order ${orderId}`); return order; }, }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` tools/confirm-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.confirm-order' as ToolId, description: 'Confirms an order and emails the customer.', version: '0.1.0', input: z.object({ orderId: z.string(), note: z.string().optional() }), output: z.object({ orderId: z.string(), status: z.literal('confirmed'), note: z.string().optional(), }), effects: [], // no `mutating: false`: it changes something handler: async ({ orderId, note }) => ({ orderId, status: 'confirmed' as const, note }), }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` * Python tools/get_order.py ```python from pydantic import BaseModel, Field from kindgi import tool class OrderQuery(BaseModel): order_id: str = Field(alias="orderId", min_length=1) class Item(BaseModel): sku: str quantity: int class Order(BaseModel): order_id: str = Field(alias="orderId") customer: str total: float items: list[Item] ORDERS = { "A-100": {"customer": "ada@example.com", "total": 42.5, "items": [{"sku": "mug", "quantity": 2}]}, "A-200": { "customer": "grace@example.com", "total": 1250, "items": [{"sku": "desk", "quantity": 1}, {"sku": "lamp", "quantity": 2}], }, } @tool(id="acme.get-order", mutating=False) # only reads def get_order(input: OrderQuery) -> Order: """Looks up an order by id.""" order = ORDERS.get(input.order_id) if order is None: raise ValueError(f"No order {input.order_id}") return Order(orderId=input.order_id, **order) ``` tools/confirm_order.py ```python from typing import Literal from pydantic import BaseModel, Field from kindgi import tool class ConfirmInput(BaseModel): order_id: str = Field(alias="orderId") note: str | None = None class Confirmed(BaseModel): order_id: str = Field(alias="orderId") status: Literal["confirmed"] = "confirmed" note: str | None = None @tool(id="acme.confirm-order") # no mutating=False: it changes something def confirm_order(input: ConfirmInput) -> Confirmed: """Confirms an order and emails the customer.""" return Confirmed(orderId=input.order_id, note=input.note) ``` ## The agent The agent has no tools: it reads the order's customer and total from its input and answers with one sentence. * TypeScript agents/note-writer/index.ts ```ts import { defineAgent } from '@kindgi/sdk/define'; import type { AgentId, Semver } from '@kindgi/sdk/types'; const defined = defineAgent({ id: 'acme.note-writer' as AgentId, version: '0.1.0' as Semver, name: 'Note writer', description: 'Writes a one-sentence thank-you note for an order.', instructions: 'Write a one-sentence thank-you note to {{ input.customer }} for their order of {{ input.total }} USD. Answer with the note only.', capabilities: [{ needs: [{ feature: 'tool-use' as const }] }], tools: [], retrieval: [], guardrails: [], parameters: [], budget: { maxSteps: 2, maxCostUsd: 0.05, maxWallMs: 30_000 }, }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` * Python agents/note_writer.py ```python from kindgi import Agent note_writer = Agent( id="acme.note-writer", version="0.1.0", name="Note writer", description="Writes a one-sentence thank-you note for an order.", instructions=( "Write a one-sentence thank-you note to {{ input.customer }} for their order of " "{{ input.total }} USD. Answer with the note only." ), capabilities=[{"needs": [{"feature": "tool-use"}]}], budget={"maxSteps": 2, "maxCostUsd": 0.05, "maxWallMs": 30_000}, ) ``` ## The flow * TypeScript flows/process-order/index.ts ```ts import { defineFlow } from '@kindgi/sdk/define'; const defined = defineFlow({ id: 'acme.process-order', version: '0.1.0', name: 'Process an order', description: 'Looks up an order, writes a thank-you note, and confirms the order with it.', nodes: [ { id: 'order', kind: 'tool', ref: 'acme.get-order', inputMapping: { orderId: { path: 'runInput.orderId' } }, }, { id: 'note', kind: 'agent', ref: 'acme.note-writer', inputMapping: { customer: { path: 'nodeOutputs.order.customer' }, total: { path: 'nodeOutputs.order.total' }, }, }, { id: 'confirm', kind: 'tool', ref: 'acme.confirm-order', inputMapping: { orderId: { path: 'runInput.orderId' }, note: { path: 'nodeOutputs.note.text' }, }, }, ], edges: [ { id: 'e1', from: '$start', to: 'order' }, { id: 'e2', from: 'order', to: 'note' }, { id: 'e3', from: 'note', to: 'confirm' }, { id: 'e4', from: 'confirm', to: '$end' }, ], }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` * Python flows/process_order.py ```python from kindgi import Flow process_order = Flow( id="acme.process-order", version="0.1.0", name="Process an order", description="Looks up an order, writes a thank-you note, and confirms the order with it.", nodes=[ { "id": "order", "kind": "tool", "ref": "acme.get-order", "inputMapping": {"orderId": {"path": "runInput.orderId"}}, }, { "id": "note", "kind": "agent", "ref": "acme.note-writer", "inputMapping": { "customer": {"path": "nodeOutputs.order.customer"}, "total": {"path": "nodeOutputs.order.total"}, }, }, { "id": "confirm", "kind": "tool", "ref": "acme.confirm-order", "inputMapping": { "orderId": {"path": "runInput.orderId"}, "note": {"path": "nodeOutputs.note.text"}, }, }, ], edges=[ {"id": "e1", "from": "$start", "to": "order"}, {"id": "e2", "from": "order", "to": "note"}, {"id": "e3", "from": "note", "to": "confirm"}, {"id": "e4", "from": "confirm", "to": "$end"}, ], ) ``` - **`nodes`** are the steps. Each has an `id` (unique in the flow), a `kind` and a `ref`: the id of the tool or agent it runs. In Python, `ref` can also be the `Tool` or `Agent` object you import. - **`edges`** connect them. `$start` and `$end` are where the run enters and leaves; an edge without a condition fires when its source step completes. - **`inputMapping`** builds each step's input from the run's input (`runInput.…`) and earlier steps' outputs (`nodeOutputs..…`). See [Pass data between steps](../pass-data-between-steps/). The shape is checked when the file loads: ids, edges between steps that exist, `$start` and `$end`, no cycles. That the tools and the agent exist is checked when a run starts: a run of a flow that names a tool your tenant doesn't have is refused before anything runs. ## Run it With `kindgi dev` running in the pack, start a run from a second terminal: ```sh kindgi runs start --flow=acme.process-order --input='{"orderId":"A-100"}' ``` ```json { "id": "f35d0699-00d8-4e85-944c-e0c9da3ce8e5", … "flowId": "acme.process-order", "flowVersion": "0.1.0", "status": "completed", "dryRun": false, … "output": { "note": "Thank you for your order of $42.50, ada@example.com!", "status": "confirmed", "orderId": "A-100" }, … } ``` The command waits for the run to finish. The flow declares no `output`, so the run returns the output of the step that reached `$end`: here, `acme.confirm-order`'s. ## The agent step An agent step runs one turn of the agent, as a run of its own (a child of the flow's run). The agent gets the step's input twice: as structured input, which `{{ input.customer }}` in its instructions reads, and as its user message (the input as JSON). [Give an agent its input](../../agents/give-an-agent-input/) has the details. The step's output has the answer as `text`, and as `output` when the agent declares a typed answer, plus the turn's `runId`. Without a model provider, `dev-echo` answers, and the note is the agent's input echoed back. ## Change it Save a file and `kindgi dev` loads the new version; the next run uses it, with no restart. A run already in flight keeps the version it started on. Change `version` when what callers send or get back changes, not on every save. ## Next * [Pass data between steps](../pass-data-between-steps/) * [Branch a flow](../branch-a-flow/) * [Start a run from your app](../../runs/start-a-run/) # Guardrails > Write a check on an agent's answers, give it settings, and choose whether a failure stops the turn. A guardrail is a rule an agent's turn must satisfy. It's a **check**, a function you write over the turn (its answer, its tool calls and their results), and an **action**: what happens when the check fails. Kindgi runs an agent's guardrails once per turn, on its final answer, before the answer is stored. * [Write a guardrail](write-a-guardrail/): the check, the declaration, and wiring it onto an agent. * [Configure a guardrail](configure-a-guardrail/): settings the check runs with, and their schema. * [Stop a turn or record a violation](halt-or-record/): `halt` versus `log-only`, severity, and which agents a guardrail covers. The examples use the quickstarts' pack, `my-pack`, whose agent answers with `dev-echo` until you connect a model. # Configure a guardrail > Give a guardrail's check its settings with config, describe them with a schema, and change them without touching the check. A check's settings (a limit, a list of words, a tool's id) don't belong in its code. The guardrail declares them as `config`, and the check runs with them. The [answer-length guardrail](../write-a-guardrail/) has one setting, `maxChars`: * TypeScript ```ts // in guardrails/answer-length/index.ts export const check = defineCheck({ id: 'my-pack.checks.answer-length', kind: 'zero-llm', configSchema: z.object({ maxChars: z.number().int().positive().default(500) }), evaluate: async (config, trace) => { // config.maxChars: 60 here, or 500 when the declaration gives none // … }, }); export default { id: 'my-pack.answer-length', // … check, config: { maxChars: 60 }, // … }; ``` `configSchema` describes the settings; `config` in the declaration gives their values. The check's `evaluate` gets those values, typed from the schema. * Python ```python # in guardrails/answer_length.py class Config(BaseModel): max_chars: int = Field(500, alias="maxChars", gt=0) @guardrail( id="my-pack.answer-length", # … config={"maxChars": 60}, ) def answer_length(config: Config, trace: RunTrace) -> CheckResult: ... ``` The type of the check's first parameter describes the settings; `config=` gives their values, keyed by their wire names, the aliases: `maxChars`, not `max_chars`. ## Change a setting Edit the value and save; the next turn uses it. With `maxChars: 100`, the `dev-echo` answer from [Write a guardrail](../write-a-guardrail/#run-it) passes: ```sh kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}' ``` ```json "status": "completed", … "violations": [], ``` The registered guardrail shows the values it runs with: ```sh kindgi guardrails get my-pack.answer-length ``` ```json "config": { "maxChars": 100 }, ``` ## What the check receives The check gets `config` resolved by its schema, in both SDKs: * **Defaults are filled in.** A guardrail without `config` runs with the schema's defaults; here, a limit of 500. * **A value that doesn't fit is refused** before any turn runs, and the message names the setting. - TypeScript Zod's `.default()` and a JSON Schema's `default` both apply. When the pack is indexed, each guardrail's `config` is checked against its `configSchema`, so `kindgi dev` reports the file and `kindgi build` refuses the pack. With `config: { maxChars: -5 }`: ```text guardrails/answer-length/index.ts: guardrail my-pack.answer-length's config doesn't fit its configSchema at /maxChars: must be > 0 ``` A setting the schema requires, with no default, must be in `config`: ```text guardrails/answer-length/index.ts: guardrail my-pack.answer-length's config doesn't fit its configSchema: must have required property 'maxChars' ``` - Python The check gets `config` parsed into its type: * **Defaults are filled in.** Give every field a default: a required field with no value isn't caught when the pack is indexed, and fails every turn the guardrail checks, whatever its action (`input-validation-failed: Check "my-pack.req-cfg" config failed validation`). * **Values are checked where they're declared.** A value that doesn't fit is a `DefinitionError`, and `python -m kindgi.pack index` (and `kindgi dev`) reports the file: ```text Guardrail "my-pack.answer-length": config does not fit its type: 1 validation error for Config maxChars Input should be greater than 0 [type=greater_than, input_value=-5, input_type=int] ``` * **A key that isn't a wire name is ignored.** `config={"max_chars": 60}` isn't an error: the field keeps its default. # Stop a turn or record a violation > Choose what a failed guardrail does to an agent's turn, how severe it is, and which agents it covers. A guardrail's action decides what a failed check does to the turn: * **`halt`** stops it. The turn fails with `guardrail-violation`, and the run has no answer. * **`log-only`** lets it finish. The answer goes out, and the failure is recorded on the turn as a violation. - TypeScript ```ts // in guardrails/answer-length/index.ts action: { 'on-violation': 'log-only' }, ``` - Python ```python # in guardrails/answer_length.py @guardrail( id="my-pack.answer-length", name="Answer is short", on_violation="log-only", severity="error", config={"maxChars": 60}, ) ``` ## A halted turn With `halt`, the command fails with the check's reason: ```sh kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}' ``` ```text Error [guardrail-violation]: Turn blocked by guardrail 'my-pack.answer-length': The answer is 86 characters; the limit is 60. ``` The run is `failed`, and its `failureMessage` carries the violation: ```sh kindgi runs get ``` ```json { "id": "5202d750-e510-483f-991a-db34c6cba950", … "flowId": "agent.turn", "status": "failed", … "failureMessage": "{\"__agent_turn_failure__\":true,\"error\":{\"code\":\"guardrail-violation\",\"message\":\"Turn blocked by guardrail 'my-pack.answer-length': The answer is 86 characters; the limit is 60.\",\"violations\":[{\"guardrailId\":\"my-pack.answer-length\",\"result\":{\"passed\":false,\"reason\":\"The answer is 86 characters; the limit is 60.\"},\"action\":\"halt\",\"severity\":\"error\",\"at\":\"2026-10-03T20:10:37.910Z\"}],\"evaluationErrors\":[]}}" } ``` Use `halt` for a rule whose failure makes the answer unusable or unsafe to show. ## A recorded violation With `log-only`, the turn completes, and its output lists the violation: ```json "status": "completed", … "output": { … "response": { "role": "agent", "actor": "my-pack.echo-agent", "content": "Tool responded: {\"echo\":\"hi\",\"echoedAt\":\"2026-10-03T20:10:57.767Z\",\"characterCount\":2}", … }, … "violations": [ { "at": "2026-10-03T20:10:57.958Z", "action": "log-only", "result": { "passed": false, "reason": "The answer is 86 characters; the limit is 60." }, "severity": "error", "guardrailId": "my-pack.answer-length" } ], ``` Use `log-only` while you find out how often a new rule fails, or for a rule your app acts on itself: read `violations` from the turn's result. Other actions The guardrail schema also accepts `retry`, `escalate` and `compensate`. In 0.1 a turn records them as violations, like `log-only`, and doesn't act on them: there's no second attempt, escalation or compensating tool call. ## Severity `severity` is `info`, `warn`, `error` or `critical`. It's recorded with each violation, and doesn't change what the action does: a `log-only` guardrail can be `critical`. ## Which agents it covers A guardrail checks the turns of every agent that lists it. `scope` narrows that: * TypeScript ```ts // in guardrails/answer-length/index.ts scope: { when: 'always', agents: ['my-pack.echo-agent'] }, ``` * Python ```python # in guardrails/answer_length.py scope={"when": "always", "agents": ["my-pack.echo-agent"]}, ``` - `agents` lists the agents the guardrail applies to. An agent that lists the guardrail but isn't in its scope isn't checked. - `when` is `always`, `runtime-only` or `ci-only`. A `ci-only` guardrail doesn't run in an agent's turn. # Write a guardrail > Write a check over an agent's turn, declare it as a guardrail, and put it on an agent. This guardrail keeps an agent's answers short: it fails a turn whose answer is longer than a limit. ## The guardrail * TypeScript guardrails/answer-length/index.ts ```ts import { defineCheck } from '@kindgi/sdk/define'; import { z } from 'zod'; export const check = defineCheck({ id: 'my-pack.checks.answer-length', kind: 'zero-llm', configSchema: z.object({ maxChars: z.number().int().positive().default(500) }), evaluate: async (config, trace) => { const length = trace.output?.length ?? 0; if (length > config.maxChars) { return { passed: false, reason: `The answer is ${length} characters; the limit is ${config.maxChars}.`, }; } return { passed: true }; }, }); export default { id: 'my-pack.answer-length', name: 'Answer is short', kind: 'zero-llm', check, config: { maxChars: 60 }, action: { 'on-violation': 'halt' }, severity: 'error', }; ``` The file has two parts: * **The check**, from `defineCheck`, exported as `check`. Its `evaluate` gets the guardrail's config and the turn's trace, and returns `{ passed, reason? }`. * **The declaration**, the default export: the guardrail's id, the check it runs, the check's `config`, and the `action` on a failure. * Python guardrails/answer_length.py ```python from pydantic import BaseModel, Field from kindgi import CheckResult, RunTrace, guardrail class Config(BaseModel): max_chars: int = Field(500, alias="maxChars", gt=0) @guardrail( id="my-pack.answer-length", name="Answer is short", on_violation="halt", severity="error", config={"maxChars": 60}, ) def answer_length(config: Config, trace: RunTrace) -> CheckResult: length = len(trace.output or "") if length > config.max_chars: return CheckResult( passed=False, reason=f"The answer is {length} characters; the limit is {config.max_chars}.", ) return CheckResult(passed=True) ``` `@guardrail` declares both parts: the function is the check, `(config, trace)`, `def` or `async def`; the decorator's arguments are the declaration. The first parameter's type is the config's schema. - **The id** is `.`. Name the rule as what should hold: `answer-length`, `response-not-empty`. - **`kind: 'zero-llm'`**: the check is your code, a function of the trace. It can't call a model. - **The reason** is what the violation reports. Say what was wrong. - **The trace** has the turn's final answer (`output`), its tool calls (`toolCalls` / `tool_calls`, each with the tool's name and arguments), their results, its model calls, the user's input and its cost. It also names the run's project (`projectId` / `project_id`) and, when the project has one, its org (`orgId` / `org_id`), set by the runtime, never from the input. See `RunTrace` in the [Python reference](../../../reference/python/authoring/#runtrace). ## Put it on an agent An agent lists its guardrails: * TypeScript ```ts // in agents/echo-agent/index.ts guardrails: ['my-pack.response-not-empty' as GuardrailId, 'my-pack.answer-length' as GuardrailId], ``` The agent names the guardrail's id, not the check's. * Python ```python # in agents/echo_agent.py from ..guardrails.answer_length import answer_length from ..guardrails.response_not_empty import response_not_empty # … guardrails=[response_not_empty, answer_length], ``` Save. `kindgi dev` registers the guardrail with the rest of the pack. An agent that names a guardrail that isn't registered doesn't run: ```text Error [invalid-request]: Agent "my-pack.gated" references guardrails not in the registry: my-pack.no-such-guardrail ``` ## Run it `dev-echo` answers with the tool's result, which is longer than 60 characters, so the turn stops: * TypeScript ```sh pnpm exec kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}' ``` ```text Error [guardrail-violation]: Turn blocked by guardrail 'my-pack.answer-length': The answer is 86 characters; the limit is 60. ``` * Python ```sh npx --yes @kindgi/cli@0.1 runs start --agent=my-pack.echo-agent --input='{"userMessage":"Ada"}' ``` ```text Error [guardrail-violation]: Turn blocked by guardrail 'my-pack.answer-length': The answer is 95 characters; the limit is 60. ``` The command exits with status 1. The run's status is `failed`, and it has no answer. [Stop a turn or record a violation](../halt-or-record/) shows what the run records, and how to let the turn finish instead. A check that throws fails the turn whatever its action, with the exception's message (`handler-throw: Check "…" evaluate() threw: …`). Return `passed: false` for a rule that isn't met; throw only when the check itself can't run. ## Test the check * TypeScript guardrails/answer-length/index.test.ts ```ts import type { RunId, TenantId } from '@kindgi/sdk/types'; import { expect, test } from 'vitest'; import { check } from './index.js'; const trace = (output: string) => ({ runId: 'run-1' as RunId, tenantId: 'test-tenant' as TenantId, output, toolCalls: [], toolResults: [], modelCalls: [], mode: 'runtime' as const, }); test('passes a short answer', async () => { expect(await check.evaluate({ maxChars: 60 }, trace('Shipped.'), {})).toEqual({ passed: true }); }); test('fails a long answer, with a reason', async () => { const result = await check.evaluate({ maxChars: 5 }, trace('Shipped yesterday.'), {}); expect(result.passed).toBe(false); expect(result.reason).toBe('The answer is 18 characters; the limit is 5.'); }); ``` ```sh pnpm exec kindgi test ``` ```text ✓ guardrails/answer-length/index.test.ts (2 tests) 2ms … Tests 7 passed (7) ``` * Python tests/test_answer_length.py ```python from kindgi import RunTrace from guardrails.answer_length import Config, answer_length def test_a_short_answer_passes(): trace = RunTrace(run_id="r", tenant_id="t", output="Shipped.") assert answer_length(Config(maxChars=60), trace).passed def test_a_long_answer_fails_with_a_reason(): trace = RunTrace(run_id="r", tenant_id="t", output="Shipped yesterday.") result = answer_length(Config(maxChars=5), trace) assert not result.passed assert result.reason == "The answer is 18 characters; the limit is 5." ``` ```sh uv run pytest ``` ```text ........ [100%] 8 passed in 0.19s ``` # Models > Connect the models your agents run on, from a preset or a spec file, and manage the connections. An agent doesn't name an API. It says what its model must support, and Kindgi picks a model from the **providers** registered for your tenant. A provider is one connection, to a vendor's API or to an endpoint you serve, with the models it offers: each model's features, context window and price. * [Connect Anthropic](anthropic/): Claude, from a preset. * [Connect Gemini on Vertex AI](gemini-on-vertex-ai/): Gemini, from a preset, with your Google Cloud credentials. * [Connect an OpenAI-compatible endpoint](openai-compatible/): Ollama or a hosted endpoint, from a spec file. * [Connect a model you serve](serve-your-own-model/): an open model on your own hardware or in your own network. How a turn picks among them is on [Choose the model an agent uses](../agents/choose-a-model/). ## Before you register one A new pack's tenant has `dev-echo`, a stand-in that answers without a model (it calls the agent's first tool and replies with what the tool returned). It's a **fallback**: it answers only while no other provider fits the agent, so a provider you register takes over at the next turn, with nothing to switch off and no restart. ## Presets ```sh kindgi providers presets ``` ```json [ { "name": "anthropic", "description": "Claude on the Anthropic API (key: ANTHROPIC_API_KEY).", "adapterId": "@kindgi/adapter-model-anthropic", "models": [ "claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-4-5" ], "secret": "ANTHROPIC_API_KEY", "pricesCheckedAt": "2026-10-01" }, { "name": "gemini", "description": "Gemini on Vertex AI (Google Application Default Credentials; --project=).", "adapterId": "@kindgi/adapter-model-gemini", "models": [ "gemini-2.5-pro", "gemini-2.5-flash" ], "needs": [ "--project" ], "pricesCheckedAt": "2026-10-01" } ] ``` A preset carries its models' features, context windows, output limits and prices. `pricesCheckedAt` is when the prices were last compared with the vendor's. `--max-output-tokens=` registers the models with another output limit. ## A provider spec Anything that isn't a preset registers from a spec: a JSON file you pass with `kindgi providers register --spec=@`. ```json { "adapter_id": "@kindgi/adapter-model-openai-compat", "adapter_config": { "baseURL": "http://localhost:11434/v1" }, "metadata": { "id": "ollama", "region": "local", "models": [ { "name": "llama3.1", "contextWindow": 8192, "features": ["tool-use"], "cost": { "promptUsdPer1kTokens": 0, "completionUsdPer1kTokens": 0 } } ] } } ``` * **`adapter_id`** is the adapter's full package name. * **`adapter_config`** holds the connection's settings, such as the endpoint's URL: flat keys with string, number or boolean values. Never a key. * **`secret_ref`** names the secret that holds the key: `{ "envName": "local", "name": "GROQ_API_KEY" }`. Leave it out for an endpoint that takes none. * **`metadata.id`** names the connection; agents prefer it by this id. **`metadata.models`** lists the models, by the name the endpoint knows them by, each with its `contextWindow`, its `features` (what agents can require) and its `cost`. * **`metadata.attributes`** (optional) are labels agents can rank by, such as `"local"`; **`metadata.fallback: true`** makes the provider a fallback, like `dev-echo`. Prices are per thousand tokens `promptUsdPer1kTokens` and `completionUsdPer1kTokens` are per **thousand** tokens; vendors quote per million. Divide their price by 1000: "$2 per million input tokens" is `0.002`. Agents' cost budgets are computed from these prices, so copy them from the vendor's page when you register. ## Declare them in your pack's config Under `kindgi dev`, each project and each git worktree has its own database, and a provider you register by hand is in that one only. Declare it in the pack's config instead, and `kindgi dev` registers it on every boot: in a new worktree, after `--reset`, and on a teammate's machine. * TypeScript ```ts // in kindgi.config.ts providers: [ { preset: 'anthropic', models: ['claude-haiku-4-5'] }, // its key, ANTHROPIC_API_KEY, from the env files { preset: 'gemini', project: 'acme-gcp', models: ['gemini-2.5-flash'] }, { spec: { /* what --spec takes */ } }, ], ``` * Python ```toml # in pyproject.toml [[tool.kindgi.providers]] preset = "anthropic" models = ["claude-haiku-4-5"] # its key, ANTHROPIC_API_KEY, from the env files [[tool.kindgi.providers]] preset = "gemini" project = "acme-gcp" models = ["gemini-2.5-flash"] ``` A preset entry takes `models`, `project`, `maxOutputTokens`, and `secret`: the key's name, in place of the preset's own. The keys are spelled the same in `pyproject.toml`. A `spec` entry is what `--spec` takes. A key is always a secret's name, read from the env files; a `spec` with a credential in `adapter_config` is refused. At each boot, `kindgi dev` says what it did: ```text Providers from kindgi.config.ts: ✓ anthropic: registered ✓ qwen-local: registered ⚠ qwen-keyed: not registered: QWEN_API_KEY is not in .env, .env.local. Set it (pnpm exec kindgi secrets set QWEN_API_KEY --env=local --scope=tenant), then restart kindgi dev ``` * A provider whose key isn't in the env files is skipped. The config isn't watched: restart `kindgi dev` after setting the key. * One `kindgi dev` registered and whose declaration changed is registered again (`qwen-local: registered again (changed in kindgi.config.ts)`); one the config no longer declares is unregistered. * If the runtime already has a provider with that id that `kindgi dev` didn't register, `kindgi dev` leaves it as it is. When its region or models (names, limits, prices) differ from the config's, a ⚠ line names the `kindgi providers unregister` that lets the config's version apply. The runtime doesn't list a provider's adapter, its settings or its key's name, so one that differs only there is left with a plain line. `kindgi dev` keeps track of the ones it registered in `.kindgi/dev/providers.json`. A runtime that `kindgi dev` doesn't run, such as your staging or production one, gets its providers with `kindgi providers register`. ## Keys With `kindgi dev`, a provider's key is read from the pack's env files, `.env` and then `.env.local`, when a turn calls the model. To store one without echoing it, with `kindgi dev` running: ```sh kindgi secrets set ANTHROPIC_API_KEY --env=local --scope=tenant ``` It prompts for the value and writes it to `.env.local`. In a script, pipe it in with `--from-stdin`. To replace a key that's already set, add `--write-mode=add-version`. ## List, change and remove ```sh kindgi providers list # every provider, with its models kindgi providers list --feature=structured-output kindgi providers get ollama kindgi providers unregister ollama ``` `list` and `get` show the metadata only, never `adapter_config` or the key. A registration can't be edited: registering an id that exists is refused, so unregister it first, then register the new spec. ```text Error [conflict]: Provider "ollama" is already registered ``` # Connect Anthropic > Register Claude from the Anthropic preset, with your API key in the pack's env files instead of your code. The `anthropic` preset registers Claude Opus 5.5, Sonnet 5.5 and Haiku 4.5 on the Anthropic API, with their features, context windows and prices. You provide the key. ## 1. Store the key With `kindgi dev` running, in the pack: ```sh kindgi secrets set ANTHROPIC_API_KEY --env=local --scope=tenant ``` It prompts for the key without echoing it and writes it to the pack's `.env.local`. A line `ANTHROPIC_API_KEY=…` in the pack's `.env` works too, so a pack inside an app whose `.env` already has the key needs nothing more. Keep both files out of git. ## 2. Register the preset ```sh kindgi providers register --preset=anthropic ``` ```text { "providerId": "anthropic" } ✓ Registered anthropic: claude-opus-5-5, claude-sonnet-5-5, claude-haiku-4-5 — key ANTHROPIC_API_KEY (env local) ``` * `--models=claude-haiku-4-5` registers only the models you list (comma-separated). * `--secret=` reads the key from another variable than `ANTHROPIC_API_KEY`. * To have `kindgi dev` register it on every boot, in every worktree and after `--reset`, declare it in the pack's config instead: `{ preset: 'anthropic' }` in `kindgi.config.ts`'s `providers`, or a `[[tool.kindgi.providers]]` table with `preset = "anthropic"` in `pyproject.toml`. See [Declare them in your pack's config](../#declare-them-in-your-packs-config). The preset checks that the key is there first: ```text Error: ANTHROPIC_API_KEY (the anthropic key) is not in .env, .env.local. Set it first, then register again: pnpm exec kindgi secrets set ANTHROPIC_API_KEY --env=local --scope=tenant # a no-echo prompt or add ANTHROPIC_API_KEY=… to .env yourself. ``` ## 3. Run an agent The provider answers from the next turn on; nothing restarts. ```sh kindgi runs start --agent=acme.order-desk --input='{"userMessage":"Where is my order A-1001?"}' ``` ```json { … "status": "completed", … "output": { … "usage": { "steps": 2, "durationMs": 2291, "promptTokens": 1449, "totalCostUsd": 0.001889, "completionTokens": 88 }, … "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.", … }, … } } ``` `totalCostUsd` is the turn's cost at the preset's prices. ## Which Claude model answers | Model | Features | Context window | Per 1K input / output tokens | | ------------------- | -------------------------------------------------------------------- | -------------- | ---------------------------- | | `claude-opus-5-5` | `tool-use`, `parallel-tool-use`, `structured-output`, `long-context` | 1,000,000 | $0.004 / $0.02 | | `claude-sonnet-5-5` | `tool-use`, `parallel-tool-use`, `structured-output`, `long-context` | 1,000,000 | $0.002 / $0.01 | | `claude-haiku-4-5` | `tool-use`, `parallel-tool-use` | 200,000 | $0.001 / $0.005 | Among the models that meet an agent's needs, Kindgi takes the agent's preferred one, else the first alphabetically. So an agent that needs only `tool-use` gets Haiku, and one that needs `structured-output` gets Opus. To choose, set `preferredModel` or require the model: see [Choose the model an agent uses](../../agents/choose-a-model/). To offer only some models, register only those (`--models`). ## If it doesn't answer A wrong key fails the turn with Anthropic's own message: ```text Error [server]: Model call to anthropic (claude-haiku-4-5) failed: 401 {"type":"error","error":{"type":"authentication_error","message":"API key is invalid."},"request_id":null} ``` To change the key, set it again with `--write-mode=add-version`; the next turn uses the new one. If `dev-echo` still answers (the turn has a `fallback-provider` warning), the agent needs something none of the registered models has: see [`dev-echo`, the fallback](../../agents/choose-a-model/#dev-echo-the-fallback). # Connect Gemini on Vertex AI > Register Gemini from the gemini preset, using your Google Cloud credentials instead of an API key. The `gemini` preset registers Gemini 2.5 Pro and Gemini 2.5 Flash on Google Cloud's Vertex AI. It takes no API key: calls use your Google Cloud credentials, and Vertex AI runs and bills them in a project you name. ## 1. Log in to Google Cloud ```sh gcloud auth application-default login ``` That writes your Application Default Credentials. `kindgi dev` hands them to the runtime it starts, read-only: the file `GOOGLE_APPLICATION_CREDENTIALS` names, if it's set, else the one this command writes (`~/.config/gcloud/application_default_credentials.json`). The account needs access to Vertex AI in the project. ## 2. Register the preset ```sh kindgi providers register --preset=gemini --project= ``` `--project` is the Google Cloud project Vertex AI runs and bills in; the preset refuses to register without it: ```text Error: preset "gemini" needs --project=<…> (The Google Cloud project Vertex AI bills and authorises against.) ``` `--models` registers only some of the models: ```sh kindgi providers register --preset=gemini --project= --models=gemini-2.5-flash ``` The preset's models answer with up to 65,536 tokens, thinking included. `--max-output-tokens=` registers them with another limit. To have `kindgi dev` register it on every boot, declare it in the pack's config instead: `{ preset: 'gemini', project: '' }` in `kindgi.config.ts`'s `providers`, or a `[[tool.kindgi.providers]]` table with `preset = "gemini"` and `project = ""` in `pyproject.toml`. See [Declare them in your pack's config](../#declare-them-in-your-packs-config). ```text { "providerId": "gemini" } ✓ Registered gemini: gemini-2.5-flash ``` ## What it registers ```sh kindgi providers get gemini ``` The provider's id is `gemini` and its region `global`. Both models list `tool-use` as their only feature: | Model | Context window | Per 1K input / output tokens | | ------------------ | -------------- | ---------------------------- | | `gemini-2.5-pro` | 1,048,576 | $0.00125 / $0.01 | | `gemini-2.5-flash` | 1,048,576 | $0.0003 / $0.0025 | An agent that needs `structured-output` or `long-context` doesn't route to them. To send an agent to Gemini when other providers are registered too, set `preferredProvider: 'gemini'` (`preferred_provider="gemini"` in Python), or require it: see [Choose the model an agent uses](../../agents/choose-a-model/). ## From a deployed runtime A deployed runtime has no login of yours: it calls Vertex AI as its own service account (on Cloud Run, the service's). That account needs `roles/aiplatform.user` in the project, and the project needs the Vertex AI API turned on. Without the role, every model call fails with `Permission 'aiplatform.endpoints.predict' denied`. [Deploy on Google Cloud Run](../../../deploy/cloud-run/#use-gemini) sets this up. # Connect an OpenAI-compatible endpoint > Register any endpoint that speaks the OpenAI Chat Completions API, such as Ollama on your machine or a hosted one with a key. Many servers and services answer the OpenAI Chat Completions API: Ollama, vLLM, LiteLLM and OpenRouter among them. Kindgi reaches them with one adapter, `@kindgi/adapter-model-openai-compat`, pointed at the endpoint's base URL. This page uses Ollama on your machine, then a gateway that takes a key. ## Ollama on your machine With Ollama running and the model pulled (`ollama pull llama3.1`), write the provider as a spec in the pack: ```json { "adapter_id": "@kindgi/adapter-model-openai-compat", "adapter_config": { "baseURL": "http://localhost:11434/v1" }, "metadata": { "id": "ollama", "region": "local", "models": [ { "name": "llama3.1", "contextWindow": 8192, "features": ["tool-use"], "cost": { "promptUsdPer1kTokens": 0, "completionUsdPer1kTokens": 0 } } ] } } ``` ```sh kindgi providers register --spec=@ollama.json ``` ```text { "providerId": "ollama" } ``` To have `kindgi dev` register it on every boot, put the spec in the pack's config instead: `{ spec: { … } }` in `kindgi.config.ts`'s `providers`, or `spec = { … }` in a `[[tool.kindgi.providers]]` table in `pyproject.toml`. See [Declare them in your pack's config](../#declare-them-in-your-packs-config). Then run an agent. With no other provider registered, Ollama answers: ```sh kindgi runs start --agent=acme.order-desk --input='{"userMessage":"Where is my order A-1001?"}' ``` ```json { … "status": "completed", … "output": { … "usage": { "steps": 2, "durationMs": 16282, "promptTokens": 398, "totalCostUsd": 0, "completionTokens": 65 }, … "provider": { "id": "ollama", "model": "llama3.1" }, "response": { "role": "agent", "content": "Your order, A-1001, has been shipped and is expected to arrive on 2026-10-06. …", … }, … } } ``` * **`baseURL`** is the endpoint, up to and including `/v1`. `kindgi dev` runs the runtime in Docker and points `localhost`, `127.0.0.1` and `::1` at your machine (it says so when it starts), so `http://localhost:11434/v1` reaches your Ollama. * **`name`** is the model as the endpoint knows it. * **`features`**: list only what the model does. An agent that calls tools needs `tool-use`, and the model must support tool calling. * **`cost`**: zero for a model you run yourself. There's no `secret_ref`: Ollama takes no key. ## An endpoint with a key A hosted endpoint takes a key. Name the secret that holds it in `secret_ref`, and store the key in the pack's env files (see [Keys](../#keys)). The endpoint's documentation gives its base URL and its model names: ```json { "adapter_id": "@kindgi/adapter-model-openai-compat", "adapter_config": { "baseURL": "https://llm-gateway.acme.example/v1" }, "secret_ref": { "envName": "local", "name": "ACME_GATEWAY_KEY" }, "metadata": { "id": "acme-gateway", "region": "us-east-1", "models": [ { "name": "llama3.1", "contextWindow": 8192, "features": ["tool-use"], "cost": { "promptUsdPer1kTokens": 0.0002, "completionUsdPer1kTokens": 0.0006 } } ] } } ``` The key is sent as `Authorization: Bearer ` on every call, and read when the call is made: a key you change is used from the next turn on. A key that isn't set fails the turn when the model is called, not when you register: ```text Error [server]: Model call to acme-gateway (llama3.1) failed: provider-runtime-bridge: failed to resolve secret local/ACME_GATEWAY_KEY for tenant d4414be3-755c-4293-9972-dbadf18b2a50: No secret "ACME_GATEWAY_KEY" for env "local" in .env, .env.local at /pack ``` Copy each model's prices from the vendor's page, divided by 1000: Kindgi's prices are per thousand tokens (see [Prices are per thousand tokens](../#a-provider-spec)). A turn's `totalCostUsd`, and the agent's cost budget, come from them. ## More than one model One provider can list several models of the same endpoint; each one is matched and ranked on its own. Each endpoint (each base URL and key) is its own provider, with its own `metadata.id`. # Connect a model you serve > Run agents on an open model on your own hardware or in your own network, pass the extra request fields it needs, and keep it as a fallback. An open model you serve yourself (with Ollama, vLLM or llama.cpp's server, on your machine or on a server in your network) registers like any [OpenAI-compatible endpoint](../openai-compatible/): no key, and a price of zero. Two things are specific to it: the request fields your server needs, and whether it should be a fallback. ## The provider ```json { "adapter_id": "@kindgi/adapter-model-openai-compat", "adapter_config": { "baseURL": "http://localhost:11434/v1", "extraBody.chat_template_kwargs.enable_thinking": false }, "metadata": { "id": "acme-llm", "region": "on-prem", "models": [ { "name": "llama3.1", "contextWindow": 8192, "features": ["tool-use"], "cost": { "promptUsdPer1kTokens": 0, "completionUsdPer1kTokens": 0 } } ] } } ``` ```sh kindgi providers register --spec=@acme-llm.json kindgi runs start --agent=acme.order-desk --input='{"userMessage":"Where is my order A-1002?"}' ``` ```json { … "status": "completed", … "output": { … "provider": { "id": "acme-llm", "model": "llama3.1" }, "response": { "role": "agent", "content": "I have located your order, A-1002. It is currently in the processing stage and no delivery date has been specified yet.", … }, … } } ``` * **`baseURL`** is your server as the runtime reaches it. Here it's Ollama on the same machine as `kindgi dev`; for a server in your network, it's that server's address (`http://:/v1`). * **`region`** is a label of your choosing; agents can require it. * **`contextWindow`** is the context your server serves the model with, which can be less than the model's own. To have `kindgi dev` register it on every boot, put the spec in the pack's config instead (`{ spec: { … } }` in `providers`); see [Declare them in your pack's config](../#declare-them-in-your-packs-config). ## Extra request fields Some servers need fields the OpenAI API doesn't have. A thinking model (Qwen 3, for one) writes its reasoning before its answer unless it's asked not to, and an agent with a [typed answer](../../agents/typed-answer/) then gets an answer that isn't JSON. If you can't change the server's defaults, send the field with every request. `adapter_config` is flat: each extra field is one key that starts with `extraBody.`, and the dots nest. The provider above sends, with every call: ```json { "chat_template_kwargs": { "enable_thinking": false } } ``` Fields the adapter sets itself can't be set this way: `model`, `messages`, `tools`, `response_format`, `temperature`, `max_tokens` and `stream`. An `extraBody` object, instead of flat keys, is refused when you register: ```text Error [invalid-request]: `adapter_config` values must be strings, numbers or booleans (extraBody). ``` Your server must also call tools the OpenAI way for an agent that has tools: turn tool calling on in its settings, and serve a model that supports it. ## Keep it as a fallback A model of your own can be the one that answers only when nothing else fits, the way `dev-echo` does. Add `"fallback": true` to `metadata`: ```json { "adapter_id": "@kindgi/adapter-model-openai-compat", "adapter_config": { "baseURL": "http://localhost:11434/v1", "extraBody.chat_template_kwargs.enable_thinking": false }, "metadata": { "id": "acme-llm", "region": "on-prem", "fallback": true, "models": [ { "name": "llama3.1", "contextWindow": 8192, "features": ["tool-use"], "cost": { "promptUsdPer1kTokens": 0, "completionUsdPer1kTokens": 0 } } ] } } ``` A turn it answers carries a `fallback-provider` warning: ```text ⚠ Answered by "acme-llm", a fallback provider: no other registered provider satisfies agent "acme.order-desk". ``` When several fallbacks fit, the usual ranking applies: `acme-llm` comes before `dev-echo` alphabetically. # Cost per run and per customer > Read what each model call cost, for one run, a flow's whole run, or one customer's month. Kindgi records every model call a run makes: the model, the tokens, and what the call cost at the prices its provider was registered with (see [Connect a model](../../models/)). Read the records of one run or of a flow's whole run, sum them for one customer's month, or get each run's total when it finishes. ## A run's calls * TypeScript run-cost.ts ```ts import { createClient } from '@kindgi/sdk/client'; import type { RunId } from '@kindgi/sdk/types'; const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev const calls = await kindgi.cost.usage.query({ runId: process.argv[2] as RunId }); for (const call of calls.items) { console.log(call.step, call.model, call.status, call.usage?.promptTokens, call.usage?.completionTokens, call.costUsd); } ``` * Python run_cost.py ```python import sys from kindgi.client import Kindgi kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev calls = kindgi.cost.records.list(run_id=sys.argv[1]) for call in calls.data: usage = call.usage print(call.step, call.model, call.status, usage and usage.prompt_tokens, usage and usage.completion_tokens, call.cost_usd) ``` For an agent turn that called a tool once, both print two model calls, the newest first: ```text 2 claude-haiku-4-5 ok 885 39 0.00108 1 claude-haiku-4-5 ok 786 57 0.0010710000000000001 ``` Each record is one model call: ```json { "id": "080ccfc0-cdf1-4c1e-87cf-4dfbee234fe1", "tenantId": "9126df1e-094b-496d-9ea8-7daab93c3f4f", "category": "llm.inference", "providerId": "anthropic", "runId": "c53a4eaa-2aee-44de-9546-64c7e710bc21", "agentId": "acme-ops.echo-agent", "conversationId": "dd9c2a5d-fca1-4ce8-9810-b97dd73a70d5", "quantity": 843, "unit": "tokens", "costUsd": 0.0010710000000000001, "occurredAt": "2026-10-05T07:32:07.079Z", "callId": "1d1a29bb-fc04-4e77-a4c9-3dfcb78b8ee9", "projectId": "13b7cfca-8b77-4983-858f-149c16afe56d", "rootRunId": "c53a4eaa-2aee-44de-9546-64c7e710bc21", "agentVersion": "0.1.0", "nodeId": "model-call", "step": 1, "model": "claude-haiku-4-5", "servedModel": "claude-haiku-4-5-20251001", "status": "ok", "usage": { "promptTokens": 786, "completionTokens": 57, "cacheReadTokens": 0, "cacheWriteTokens": 0 }, "durationMs": 1043, "finishReason": "tool-use", "providerRequestId": "req_011CfihbPhdHEhT7hauiXwZt", "attempts": 1 } ``` | Field | What it is | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model`, `servedModel` | The model the agent asked for, and the exact version the provider answered with. | | `usage` | The tokens: `promptTokens`, `completionTokens`, and the cache and reasoning counts. A count the provider reports is there, 0 included; one it doesn't report is absent. | | `costUsd` | The call's cost in US dollars. | | `status` | `ok`, or `failed` (see [A failed call](#a-failed-call)). | | `step` | The model call's number in the turn. | | `runId`, `rootRunId` | The run that made the call, and the run at the top of its tree. | | `flowId` | For an agent step in a flow, the flow. An agent run on its own has none. | | `projectId`, `agentId`, `agentVersion`, `conversationId` | Where the call belongs. | | `providerRequestId` | The provider's id for the request, to quote to its support. Not every provider sends one. | | `callId` | The call's id. The model call's node in the turn's [provenance](../trace-an-answer/) has the same `callId`. | To see the usage exactly as the provider sent it, add `includeRawUsage: true` (Python: `include="rawUsage"`). Each record then has `rawUsage`: ```json "rawUsage": { "provider": "anthropic", "model": "claude-haiku-4-5", "usage": { "input_tokens": 786, "output_tokens": 57, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, … } } ``` ## A flow's whole run Each agent step of a flow runs as a run of its own, under the flow's run. The flow's run makes no model calls itself, so ask for its whole tree with `rootRunId`: * TypeScript ```ts const calls = await kindgi.cost.usage.query({ rootRunId: flowRunId }); ``` * Python ```python calls = kindgi.cost.records.list(root_run_id=flow_run_id) ``` Each record's `runId` is the agent step's run, and its `rootRunId` is the flow's run. ## One customer's month Give each of your customers an org, and run their work in a project of that org. One call then sums a customer's spend, here by month and model: * TypeScript customer-cost.ts ```ts import { createClient } from '@kindgi/sdk/client'; import type { Timestamp } from '@kindgi/sdk/types'; const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev // Once per customer: an org, and a project in it. Slugs are unique across the tenant. const org = await kindgi.orgs.create({ slug: 'acme-customer-one', name: 'Customer one' }); const project = await kindgi.projects.create({ orgId: org.id, slug: 'acme-customer-one-support', name: 'Support' }); // Each run for that customer names the project. await kindgi.runs.start({ agent: 'acme-ops.echo-agent', input: { userMessage: 'Echo hello.' }, projectId: project.id, }); // The customer's month so far, by model. const now = new Date(); const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const month = await kindgi.cost.usage.summary({ scope: { kind: 'org', orgId: org.id }, from: monthStart.toISOString() as Timestamp, to: now.toISOString() as Timestamp, groupBy: ['month', 'model'], }); console.log(JSON.stringify(month, null, 2)); ``` * Python customer_cost.py ```python import json from datetime import datetime, timezone from kindgi.client import Kindgi kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev # Once per customer: an org, and a project in it. Slugs are unique across the tenant. org = kindgi.orgs.create(slug="acme-customer-one", name="Customer one") project = kindgi.projects.create(org_id=str(org.id), slug="acme-customer-one-support", name="Support") # Each run for that customer names the project. kindgi.runs.start( agent="acme-ops.echo-agent", input={"userMessage": "Echo hello."}, project_id=project.id, ) # The customer's month so far, by model. now = datetime.now(timezone.utc) month = kindgi.cost.aggregate( group_by="month,model", scope_kind="org", scope_id=str(org.id), from_=now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat(), to=now.isoformat(), ) print(json.dumps(month.model_dump(mode="json", by_alias=True, exclude_none=True), indent=2)) ``` The sum covers only that customer's runs: ```json { "groups": [ { "key": { "month": "2026-10", "model": "claude-haiku-4-5" }, "count": 2, "totalUsd": 0.0021260000000000003, "tokens": { "prompt": 1671, "completion": 91, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0 } } ], "totalUsd": 0.0021260000000000003, "totalRecords": 2, "tokens": { "prompt": 1671, "completion": 91, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0 }, "timeRange": { "from": "2026-10-01T00:00:00.000Z", "to": "2026-10-05T07:35:16.122Z" }, "groupBy": ["month", "model"] } ``` * **`scope`** narrows the sum to an org (`{ kind: 'org', orgId }`) or a project (`{ kind: 'project', projectId }`). Python: `scope_kind` and `scope_id`. * **`from` and `to`**: the time window; `to` is exclusive. * **`groupBy`** splits the sum. Besides `month` and `model`: `day`, `orgId`, `projectId`, `agentId`, `flowId`, `runId`, `rootRunId`, `conversationId`, `providerId`, `servedModel`, `category` and `tenant`. With no `groupBy`, you get the totals only. A tool can check that a run belongs to the right customer: see [Check the run's org](../../tools/write-a-tool/#check-the-runs-org). ## When a run finishes The [`run.finished` webhook](../../webhooks/receive-run-finished/) carries the run's total under `data.run.usage`: ```json { "id": "8004eba4-0d7a-4b81-9cd2-9c1884295f9c", "type": "run.finished", "createdAt": "2026-10-05T07:08:42.227Z", "data": { "run": { "id": "695492e1-0569-48cf-bf5b-e92170ebcd38", "projectId": "cb742f19-68f2-4594-9cd6-b555617dc851", "flowId": "acme-ops.echo-flow", "flowVersion": "0.1.0", "status": "completed", "dryRun": false, "failureMessage": null, "createdAt": "2026-10-05T07:08:39.834Z", "completedAt": "2026-10-05T07:08:42.227Z", "usage": { "calls": 2, "costUsd": 0.001994, "tokens": { "prompt": 1669, "completion": 65, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0 } } } } } ``` Only the run at the top of a tree sends `run.finished`, and its `usage` covers the whole tree: this flow's two calls were made by its agent step. ## A failed call A model call that fails is recorded too, at no cost. A turn whose provider key was wrong: ```json { "runId": "97a6a04b-b6e2-4b14-a682-4dc5f653e075", "model": "claude-haiku-4-5", "status": "failed", "costUsd": 0, "durationMs": 381, "attempts": 1, "error": { "message": "401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"API key is invalid.\"},\"request_id\":null}" }, … } ``` It has no `usage`, and it counts in `run.finished`'s `calls`. # Trace an answer and its cost > See which model calls and tool calls produced an agent's answer, and what each model call cost. Every agent turn leaves a **provenance record**: a graph of the turn, from the user's message through each model call and tool call to the answer. It names the model of each call and the version of each tool, and, beside the graph, each call's tokens and cost. Use it to answer "where did this answer come from?" and "what did it cost?". ## Read a turn's record Fetch it by the run's id. With `kindgi dev`, `$KINDGI_API_URL` and `$KINDGI_API_TOKEN` are the URL and token it prints (they're also in the pack's `.kindgirc.json`). ```sh curl "$KINDGI_API_URL/v1/provenance/11a79b0a-ebdd-4d21-9e66-e0cacaea50e8" \ -H "Authorization: Bearer $KINDGI_API_TOKEN" ``` ```json { "id": "11308345-89bc-4620-a705-69c2beff5317", "runId": "11a79b0a-ebdd-4d21-9e66-e0cacaea50e8", … "dag": { "nodes": [ {"id": "input:0", "kind": "input", "timestamp": "2026-10-05T07:07:25.067Z"}, {"id": "model-call:1", "kind": "model-call", "timestamp": "2026-10-05T07:07:27.039Z", "modelVersion": "anthropic/claude-haiku-4-5", "attributes": {"step": 1, "model": "claude-haiku-4-5", "callId": "b4fd91c7-e38d-44c2-9b1e-888296324280", "providerId": "anthropic", "finishReason": "tool-use"}}, {"id": "tool-call:toolu_019Evao73J4aVhpAaf2f4jVN", "kind": "tool-call", "timestamp": "2026-10-05T07:07:27.736Z", "attributes": {"toolId": "acme-ops.echo", "toolVersion": "0.1.0", "invocationId": "toolu_019Evao73J4aVhpAaf2f4jVN", "toolVersionRange": "0.1.0"}}, {"id": "tool-result:toolu_019Evao73J4aVhpAaf2f4jVN", "kind": "tool-result", "timestamp": "2026-10-05T07:07:27.736Z"}, {"id": "model-call:2", "kind": "model-call", "timestamp": "2026-10-05T07:07:28.828Z", "modelVersion": "anthropic/claude-haiku-4-5", "attributes": {"step": 2, "model": "claude-haiku-4-5", "callId": "ce07551c-16de-4290-a8cb-e19833481545", "providerId": "anthropic", "finishReason": "stop"}}, {"id": "model-output:2", "kind": "model-output", "timestamp": "2026-10-05T07:07:28.905Z", "modelVersion": "anthropic/claude-haiku-4-5"} ], "edges": [ {"from": "model-call:1", "to": "input:0", "kind": "caused-by"}, {"from": "tool-call:toolu_019Evao73J4aVhpAaf2f4jVN", "to": "model-call:1", "kind": "invoked"}, {"from": "tool-result:toolu_019Evao73J4aVhpAaf2f4jVN", "to": "tool-call:toolu_019Evao73J4aVhpAaf2f4jVN", "kind": "produced"}, {"from": "model-call:2", "to": "input:0", "kind": "caused-by"}, {"from": "model-call:2", "to": "tool-result:toolu_019Evao73J4aVhpAaf2f4jVN", "kind": "influenced-by"}, {"from": "model-output:2", "to": "model-call:2", "kind": "produced"} ] }, "callUsage": { "b4fd91c7-e38d-44c2-9b1e-888296324280": {"usage": {"promptTokens": 786, "completionTokens": 57, "cacheReadTokens": 0, "cacheWriteTokens": 0}, "costUsd": 0.0010710000000000001, "durationMs": 1558, "servedModel": "claude-haiku-4-5-20251001"}, "ce07551c-16de-4290-a8cb-e19833481545": {"usage": {"promptTokens": 885, "completionTokens": 40, "cacheReadTokens": 0, "cacheWriteTokens": 0}, "costUsd": 0.001085, "durationMs": 699, "servedModel": "claude-haiku-4-5-20251001"} } } ``` Read it from the answer backwards: the answer (`model-output:2`) was produced by the second model call. That call answered the user's message (`caused-by input:0`) and read the echo tool's result (`influenced-by`), which the tool call the first model call made (`invoked`) produced. | Node | What it is | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | The message the turn answered. | | `model-call` | One call to a model: `modelVersion` is `provider/model`; `attributes` has its `step`, `model`, `providerId`, why it stopped (`finishReason`) and its `callId`. | | `tool-call`, `tool-result` | A tool the model called, with the exact tool version that ran, and what it returned. | | `model-output` | The answer. | | `wait`, `resume` | A tool call that waited for an approval: the wait, and the decision that ended it. | Edges point from an effect to its cause: `caused-by`, `invoked`, `produced`, and `influenced-by` from a model call to each tool result it read. `callUsage` holds each model call's tokens, cost in USD, duration and the model version that answered (`servedModel`), by the call's `callId`. A token count the provider reports is there, 0 included (`cacheReadTokens: 0`); one it doesn't report is absent. A turn that waited for an approval is recorded whole, before and after the wait. The wait sits between the tool call and its result: the `tool-call` `waited-on` a `wait` node, which was `resumed-from` a `resume` node, and the `tool-result` was `caused-by` the `resume`. The `resume` node's `actor` is who decided, and its `attributes` have the decision, the rationale and the approval's id. A call a reviewer rejected: ```json {"id": "tool-hitl-gate-resume:toolu_01MAfC8Fb7e24iY12Cenoh11", "kind": "resume", "timestamp": "2026-10-05T16:26:25.504Z", "actor": "user:26fc414a-b49f-4e1f-a022-162865770178", "attributes": {"gate": "tool-call", "decision": "reject", "rationale": "Not confirmed yet", "approvalId": "12c38101-4cac-4241-8fc7-57323e4f63b9"}} ``` Its `tool-call` is there too, and its `tool-result` is the rejection the model read. `kindgi runs get ` shows the same graph, under `output.provenance`, without `callUsage`. ## From your app * TypeScript trace.ts ```ts import { createClient } from '@kindgi/sdk/client'; import type { RunId } from '@kindgi/sdk/types'; const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev const record = await kindgi.provenance.get(process.argv[2] as RunId); for (const node of record.dag.nodes) { const { callId } = (node.attributes ?? {}) as { callId?: string }; const call = typeof callId === 'string' ? record.callUsage?.[callId] : undefined; console.log(node.kind, node.modelVersion ?? '', call ? `${call.usage.promptTokens}+${call.usage.completionTokens} tokens, ${call.costUsd} USD` : ''); } ``` * Python trace.py ```python import sys from kindgi.client import Kindgi kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev record = kindgi.provenance.get(sys.argv[1]) for node in record.dag.nodes: call_id = (node.attributes or {}).get("callId") call = (record.call_usage or {}).get(call_id) if call_id else None usage = f"{call.usage.prompt_tokens}+{call.usage.completion_tokens} tokens, {call.cost_usd} USD" if call else "" print(node.kind, node.model_version or "", usage) ``` Both print each node, with the tokens and cost of each model call: ```text input model-call anthropic/claude-haiku-4-5 786+57 tokens, 0.0010710000000000001 USD tool-call tool-result model-call anthropic/claude-haiku-4-5 885+40 tokens, 0.001085 USD model-output anthropic/claude-haiku-4-5 ``` ## What a turn cost Each model call's cost, in `callUsage`, is its tokens times the prices its provider was registered with (see [Connect a model](../../models/)). The whole turn's total is in the run's output, under `usage`: ```sh kindgi runs get ``` ```json "usage": { "steps": 2, "durationMs": 4971, "promptTokens": 1671, "totalCostUsd": 0.002156, "completionTokens": 97 }, ``` For the cost of several runs, a flow's whole run, or one customer's month, read the cost API: [Cost per run and per customer](../cost-per-run/). ## A flow's answers A flow run has no record of its own: ```json {"error":{"code":"provenance-not-found","message":"No provenance record for run \"41027166-71ec-4bd6-b516-cc47c0787da4\"","details":{"runId":"41027166-71ec-4bd6-b516-cc47c0787da4"},"requestId":"req-997f000a-22f6-470a-b52b-acdd21d6e228"}} ``` Each agent step is a run of its own, with its own record. The step's output in the flow's journal has that run's id: ```sh kindgi runs journal ``` ```json { "sequence": 9, "kind": "step.completed", "nodeId": "respond", "payload": { "output": { "text": "Tool responded: …", "runId": "4bdae2f9-ff67-48a3-9549-49028d290cc1", … ``` Fetch the record for `4bdae2f9-…`. ## Find records `GET /v1/provenance` lists records, newest first, without their graphs. Filter by `runId` or by `createdAfter`: ```sh curl "$KINDGI_API_URL/v1/provenance?createdAfter=2026-10-05T16:25:00Z&limit=3" \ -H "Authorization: Bearer $KINDGI_API_TOKEN" ``` ```json {"data":[{"id":"e87e13ae-472f-404b-8dfe-94ec738980cd","runId":"f91ff793-b029-48e8-9ce4-f9fa70bd0c40","tenantId":"9126df1e-094b-496d-9ea8-7daab93c3f4f","version":"1.0.0","createdAt":"2026-10-05T16:26:30.533Z","signed":false,"projectId":"13b7cfca-8b77-4983-858f-149c16afe56d"},…]} ``` Each record carries its run's `projectId`. `scopeKind=project&scopeId=` lists one project's records, and `scopeKind=org&scopeId=` every project's in an org (`scope` in the clients). Records from before Kindgi 0.1.3 have no project, so only a list without a scope shows them. See [Provenance in the HTTP API](../../../reference/api/operations/tags/provenance/). # Runs > Start runs from the CLI or your app, retry safely, follow them live, read their journal, cancel and list them. A **run** is one execution of a flow, or one turn of an agent. Your app starts it, follows it, and reads what it returned; the journal records every step. [Runs, the journal and durability](../../concepts/runs/) explains the model. * [Start a run](start-a-run/): wait for its result, or start it in the background. * [Retry a start safely](retry-a-start-safely/): an idempotency key, so a retried request doesn't start a second run. * [Follow a run's events](follow-a-run/): each step as it starts and ends. * [Follow a run from the browser](follow-from-the-browser/): a short-lived, read-only token, so a page can show progress without your API token. * [Read a run's journal](read-the-journal/): everything that happened in a run, with each step's input and output. * [Cancel a run](cancel-a-run/): stop a run that's running or waiting. * [List runs](list-runs/): the newest runs, page by page, and the runs inside a run. * [Show runs in your app](show-runs-in-your-app/): keep a run's id on your own row, and read the rest through the API, not Kindgi's database. The examples use the flows from the [flow guides](../flows/), and a client for the Kindgi API: `@kindgi/sdk/client` in TypeScript, `kindgi.client` in Python. # Cancel a run > Stop a run that is running or waiting, along with the agent turns inside it. ```sh kindgi runs cancel ``` ```json { "ok": true, "runId": "265627c7-1ac7-4d18-a926-6da9fcc18577" } ``` The run's status becomes `cancelled`, and the journal ends with `run.cancelled`: ```json {"sequence": 6, "kind": "wait.suspended", "nodeId": "hold", "payload": {"nodeId": "hold", "tokenId": "child:d8c2c7dc-7ceb-4e4b-8986-ee75e7c9bcc2:25"}, …} {"sequence": 7, "kind": "run.cancelled", "payload": {}, …} ``` That run was waiting for an approval. A run can be cancelled while it runs too: no further step starts, and the run ends `cancelled`. Cancelling a flow's run cancels the agent turns inside it. A run that already ended can't be cancelled: ```text Error [conflict]: Cannot cancel run: already cancelled ``` ## From your app * TypeScript ```ts const run = await kindgi.runs.start({ flow: 'acme.reserve-order', input: { orderId: 'A-500' }, options: { wait: false }, }); const cancelled = await kindgi.runs.cancel(run.id); console.log(cancelled.status); ``` ```text cancelled ``` * Python ```python run = kindgi.runs.start( flow="acme.reserve-order", input={"orderId": "A-500"}, options={"wait": False} ) cancelled = kindgi.runs.cancel(run.id) print(cancelled.status) ``` ```text cancelled ``` `cancel` returns the run. It answers with a conflict error when the run had already ended. ## Approvals of a cancelled run Cancelling a run doesn't close the approval it was waiting for: it stays in the pending list. Withdraw it: ```sh kindgi approvals complete --decision=withdraw ``` [Decide an approval](../../approvals/decide-an-approval/) has more on approvals. # Follow a run's events > Get each step of a run as it starts and ends, from your app or the CLI, through to the run's end. A run's events are its journal as it's written: the run starting, each step starting and ending, waits, and the run's end. Follow them to show progress, or to react when the run ends without polling. * TypeScript ```ts const run = await kindgi.runs.start({ flow: 'acme.reserve-order', input: { orderId: 'A-400' }, options: { wait: false }, }); for await (const event of kindgi.runs.stream(run.id)) { console.log(event.sequence, event.kind, event.nodeId ?? ''); } // The loop ends after run.completed, run.failed or run.cancelled. ``` ```text 0 run.started 2 run.step-started reserve 3 run.step-retry-scheduled reserve 4 run.step-retry-scheduled reserve 5 run.step-completed reserve 7 run.completed ``` * Python ```python run = kindgi.runs.start( flow="acme.reserve-order", input={"orderId": "A-400"}, options={"wait": False} ) for event in kindgi.runs.stream(run.id): print(event.sequence, event.kind, event.node_id or "") ``` ```text 0 run.started 2 run.step-started reserve 3 run.step-retry-scheduled reserve 4 run.step-retry-scheduled reserve 5 run.step-completed reserve 7 run.completed ``` The stream starts from the run's first event, whenever you open it, so you don't miss what happened before. It ends after `run.completed`, `run.failed` or `run.cancelled`; the last event's `payload.output` is the run's output. ## The events Each event has the run's id, a `kind`, a `sequence` (its place in the journal), a `timestamp`, the step's `nodeId` for step events, and a `payload`: | `kind` | When | `payload` | | -------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------- | | `run.started` | The run started. | `input` | | `run.step-started` | A step started. | `input` | | `run.step-completed` | A step ended. | `output` | | `run.step-failed` | A step failed. | `message` | | `run.step-retry-scheduled` | A step failed and will be [retried](../../flows/retry-a-step/). | `attempt`, `nextDelayMs`, `previousError` | | `run.iteration-started`, `run.iteration-completed` | A pass of a [loop](../../flows/loop/). | `iteration`, … | | `run.wait-suspended`, `run.wait-resumed` | The run stopped to wait, and went on ([approvals](../../flows/wait-for-an-approval/)). | | | `run.completed` | The run completed. | `output` | | `run.failed` | The run failed. | `message` | | `run.cancelled` | The run was cancelled. | | Edge decisions are in the [journal](../read-the-journal/), not in the stream. ## Reconnecting The server ends a stream after 5 minutes; a run that waits longer needs a new one. Each event's id is `:`, and a new stream opened with the last id you saw (the `Last-Event-Id` header) starts after it. * In TypeScript, `runs.stream` does this for you: it reconnects after a dropped connection or the server's limit, and ends only with the run. * In Python, `runs.stream` ends when the server closes the stream. Open it again with `last_event_id=f"{run.id}:{event.sequence}"` to go on. ## From the CLI ```sh kindgi runs stream ``` It prints one event per line, as JSON: ```text {"eventId":"28795fe7-a357-4d67-8740-1bda88f3b85c:0","runId":"28795fe7-a357-4d67-8740-1bda88f3b85c",…,"kind":"run.started","sequence":0,"payload":{"input":{"orderId":"A-300"}}} {"eventId":"28795fe7-a357-4d67-8740-1bda88f3b85c:2",…,"kind":"run.step-started","sequence":2,"nodeId":"reserve","payload":{"input":{"orderId":"A-300"}}} … {"eventId":"28795fe7-a357-4d67-8740-1bda88f3b85c:7",…,"kind":"run.completed","sequence":7,"payload":{"output":{"attempt":3,"orderId":"A-300","reserved":true}}} ``` Note In this release, `kindgi runs stream` prints the events when the run ends, all at once. To watch them arrive, use the client. To show a run's progress in a browser, without giving the page your API token, see [Follow a run from the browser](../follow-from-the-browser/). # Follow a run from the browser > Let a web page show a run's progress with a short-lived, read-only token, without your API token. Your backend starts the run and hands the page two things: the run's id and a **public run token**. The page follows the run's progress with them, straight from Kindgi. The token can do nothing else, and it expires. ## 1. Allow your site's origin The browser calls Kindgi from your site's origin, so Kindgi must allow it. List the origins in `KINDGI_CORS_ORIGINS`, comma-separated: in development, in the pack's `.env`, then restart `kindgi dev`: ```sh echo 'KINDGI_CORS_ORIGINS=http://localhost:5173' >> .env ``` `kindgi dev` shows it: ```text Origins http://localhost:5173 (browsers may follow runs with public run tokens) ``` In a deployment, set it in the runtime's environment ([`KINDGI_CORS_ORIGINS`](../../../reference/env-vars/#kindgi_cors_origins)). ## 2. Hand the page a token When the deployment issues public run tokens, every start returns `publicAccessToken`, a token for the run it started (15 minutes by default). `kindgi dev` issues them; a deployment does when it has a signing key ([`KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH`](../../../reference/env-vars/#kindgi_public_token_signing_key_path)). Your backend passes the token on with the run's id, and mints a fresh one when the page asks: * TypeScript ```ts // When the user starts a reservation const run = await kindgi.runs.start({ flow: 'acme.reserve-order', input: { orderId }, options: { wait: false }, }); return { runId: run.id, token: run.publicAccessToken }; ``` ```ts // When the page asks for a fresh token (check first that the user may see this run) const { token } = await kindgi.tokens.createPublic({ runIds: [runId] }); return token; ``` * Python ```python # When the user starts a reservation run = kindgi.runs.start( flow="acme.reserve-order", input={"orderId": order_id}, options={"wait": False} ) return {"runId": str(run.id), "token": run.public_access_token} ``` ```python # When the page asks for a fresh token (check first that the user may see this run) return kindgi.tokens.mint_public(run_ids=[run_id]).token ``` A minted token can name up to 50 runs, and live from a second to a day (`expiresInSeconds`, `expires_in_seconds`). ## 3. Follow the run in the page The page uses `subscribeToRun` from `@kindgi/sdk/client`, which runs in the browser: ```ts import { subscribeToRun } from '@kindgi/sdk/client'; const { runId, token } = await fetch('/api/reservations', { method: 'POST' }).then((r) => r.json()); for await (const event of subscribeToRun({ apiUrl: 'http://127.0.0.1:4313', // your Kindgi API runId, accessToken: token, refreshAccessToken: () => fetch(`/api/run-token?runId=${runId}`).then((r) => r.text()), })) { showProgress(event.kind, event.nodeId); // run.step-started reserve, … } // The loop ends after run.completed, run.failed or run.cancelled. ``` In a page served from `http://localhost:5173`, `showProgress` (your page's code) gets, for a run of `acme.reserve-order`: ```text run.started run.step-started reserve run.step-retry-scheduled reserve run.step-retry-scheduled reserve run.step-completed reserve run.completed ``` `subscribeToRun` reconnects by itself when the server ends the stream, and calls `refreshAccessToken` when the token expires. ## What the token can do * **Only follow progress.** The token works on two routes, `GET /v1/runs/{runId}/progress` and `GET /v1/runs/{runId}/progress/stream`, for the runs it names and the runs inside them (an agent step's turn, for example). Any other call is refused: ```text {"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-19ffb614-4c27-438b-95dd-5ace0de7125b"}} ``` * **No data.** Progress events have the kind, the step and the time, but no inputs, outputs or errors: ```text id: f7bb2e8c-6895-4ae4-a377-065be3a6a952:2 event: run.step-started data: {"eventId":"f7bb2e8c-6895-4ae4-a377-065be3a6a952:2","runId":"f7bb2e8c-6895-4ae4-a377-065be3a6a952","timestamp":"2026-10-03T20:13:13.452Z","kind":"run.step-started","sequence":2,"nodeId":"reserve"} ``` Show the result from your backend, which reads the run with your API token. * **No revoking one by one.** Keep lifetimes short. In development, tokens are signed with a key `kindgi dev` makes when it starts, so they stop working when it restarts. # List runs > List the newest runs page by page, only the ones your app started, the agent turns inside one run, or one agent's turns. ```sh kindgi runs list --limit=2 ``` ```json { "data": [ { "id": "657512f8-97b5-41bb-a0cc-396bffe158f6", … "flowId": "acme.reserve-order", "flowVersion": "0.1.0", "status": "cancelled", … }, { "id": "365d7fd0-7801-43e5-a3c5-66f9479cf02d", … } ], "hasMore": true, "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTEwLTAzVDIwOjE0OjE2Ljc0NFoiLCJpZCI6IjM2NWQ3ZmQwLTc4MDEtNDNlNS1hM2M1LTY2Zjk0NzljZjAyZCJ9" } ``` Runs come newest first, 25 to a page by default (`--limit`, at most 100). `--cursor=` gets the next page. The list leaves out each run's `output`; `kindgi runs get ` has it. The CLI lists every run, including the agent turns inside flows (each one a run of `agent.turn`, with a `parentRunId`). The client can filter. ## From your app * TypeScript ```ts // The newest runs your app started, without the agent turns inside them const page = await kindgi.runs.list({ topLevel: true, limit: 3 }); for (const run of page.data) console.log(run.id, run.flowId, run.status); // The next page if (page.nextCursor !== undefined) { const next = await kindgi.runs.list({ topLevel: true, limit: 3, cursor: page.nextCursor }); console.log(next.data.length, next.hasMore); } ``` ```text f703bffb-367c-4abb-a699-006575b7c21a acme.reserve-order cancelled 54e3258d-3cd3-444a-8175-c965ef6cf682 acme.reserve-order completed 42b7c0c8-3c47-4962-9b08-3319ff887ada acme.review-order completed 3 true ``` * Python ```python from kindgi.client import paginate # The newest runs your app started, without the agent turns inside them page = kindgi.runs.list(top_level=True, limit=3) for run in page.data: print(run.id, run.flow_id, run.status) # Every one of them, page after page print(sum(1 for _ in paginate(kindgi.runs.list, top_level=True))) ``` ```text 969888a4-a1a8-43fc-8e83-63529ad580c2 acme.reserve-order cancelled 197046c4-e220-4b6d-abcb-1703d6c6efd5 acme.reserve-order completed edaf7625-0585-40c3-805c-0c098f6846b8 acme.review-order failed 81 ``` - **`topLevel: true`** (`top_level=True`) leaves out runs started inside other runs, such as agent turns: the runs your app and the CLI started. - **`includeOutput: true`** (`include="output"`) adds each run's `output`. - **`scope`** (`scope_kind` / `scope_id`) lists one project's runs, or the runs of every project in an org: `{ kind: 'project', projectId }` (`scope_kind="project", scope_id=…`), or `{ kind: 'org', orgId }`. - **`agentId`** (`agent_id`) lists one agent's turns; see below. - In Python, `paginate` follows `next_cursor` for you. ## The runs inside a run `parentRunId` (`parent_run_id`) lists the runs a run started: for a flow, its agent steps' turns, each with the step it belongs to. * TypeScript ```ts const run = await kindgi.runs.start({ flow: 'acme.process-order', input: { orderId: 'A-100' } }); const children = await kindgi.runs.list({ parentRunId: run.id }); for (const child of children.data) console.log(child.id, child.flowId, child.parentNodeId); ``` ```text eb59636f-aaae-4cd2-84ec-66cbcbf55f83 agent.turn note ``` * Python ```python run = kindgi.runs.start(flow="acme.process-order", input={"orderId": "A-100"}) children = kindgi.runs.list(parent_run_id=run.id) for child in children.data: print(child.id, child.flow_id, child.parent_node_id) ``` ```text 13237b2f-6cdd-4592-a94d-f5e751258790 agent.turn note ``` ## One agent's turns An agent's turn names its agent: `agent` carries the agent's id, the version that ran and the conversation. That goes for an agent run and for the turn a flow's agent step starts. `agentId` (`agent_id`) lists one agent's turns, at every version. * TypeScript ```ts const turns = await kindgi.runs.list({ agentId: 'acme.desk-agent' }); for (const run of turns.data) { console.log(run.id, run.agent?.version, run.agent?.conversationId, run.parentRunId ?? '-'); } ``` ```text 51f736ba-bdde-4558-bf5f-32270622b822 1.1.0 d8d51075-ee70-44bd-b93d-f15e450798b7 259f5e11-6ebe-4347-a590-e3db5c71cf7f f9745c06-9935-4641-bf41-77d11a7f1ffb 1.1.0 b3e40910-9a84-4fc5-a4f8-d2cced5dbb53 - d0f3817d-1166-462d-a666-7a193bc48f08 1.0.0 60c9d486-ff3b-4ef9-8fbe-be19ab1d7313 - ``` * Python ```python turns = kindgi.runs.list(agent_id="acme.desk-agent") for run in turns.data: print(run.id, run.agent.version, run.agent.conversation_id, run.parent_run_id or "-") ``` ```text 51f736ba-bdde-4558-bf5f-32270622b822 1.1.0 d8d51075-ee70-44bd-b93d-f15e450798b7 259f5e11-6ebe-4347-a590-e3db5c71cf7f f9745c06-9935-4641-bf41-77d11a7f1ffb 1.1.0 b3e40910-9a84-4fc5-a4f8-d2cced5dbb53 - d0f3817d-1166-462d-a666-7a193bc48f08 1.0.0 60c9d486-ff3b-4ef9-8fbe-be19ab1d7313 - ``` The first turn is a flow's agent step (it has a `parentRunId`); add `topLevel: true` to leave those out. It combines with `scope` too. Turns that ran before Kindgi 0.1.3 don't name their agent: they have no `agent`, and `agentId` doesn't list them. [Read a run's journal](../read-the-journal/) to see what a turn did. # Read a run's journal > Read every step a run took, with its input and output, the branches it followed, and the agent turns inside it. The journal is the ordered record of a run: every step that started, completed or failed, with its input and output, and every edge the run evaluated. It's written before the run moves on, so it's also how the run continues after a wait. ```sh kindgi runs journal ``` For a run of `acme.review-order` with order `A-200`: ```json { "data": [ { "sequence": 0, "kind": "run.started", "payload": { "input": { "orderId": "A-200" } }, "timestamp": "2026-10-03T20:45:08.120Z" }, { "sequence": 1, "kind": "edge.evaluated", "payload": { "edgeId": "e1", "decision": true }, … }, { "sequence": 2, "kind": "step.started", "nodeId": "order", "payload": { "input": { "orderId": "A-200" } }, … }, { "sequence": 3, "kind": "step.completed", "nodeId": "order", "payload": { "output": { "items": [{ "sku": "desk", "quantity": 1 }, { "sku": "lamp", "quantity": 2 }], "total": 1250, "orderId": "A-200", "customer": "grace@example.com" } }, … }, { "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" } }, … }, { "sequence": 7, "kind": "step.completed", "nodeId": "hold", "payload": { "output": { "status": "held", "orderId": "A-200" } }, … }, { "sequence": 8, "kind": "edge.evaluated", "payload": { "edgeId": "e4", "decision": true }, … }, { "sequence": 9, "kind": "run.completed", "payload": { "output": { "status": "held", "orderId": "A-200" } }, … } ], "hasMore": false } ``` Read it top to bottom: the input, the order looked up, the branch taken (`e2` fired, `e3` didn't, so `confirm` never ran), the hold, the result. `--since=` returns only the entries after that one, to read what's new since you last looked. ## The entries | `kind` | | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `run.started`, `run.completed`, `run.failed`, `run.cancelled` | The run's start (with its input) and end (with its output, or why it failed). | | `step.started`, `step.completed`, `step.failed` | A step, with the input it got after mapping, its output, or its error. | | `edge.evaluated` | An edge and its `decision`. | | `step.retry-scheduled` | A failed attempt that will be retried. | | `iteration.started`, `iteration.completed` | A pass of a loop; the body's steps carry a `loopContext`. | | `fanout.dispatched`, `fanout.branch-completed`, `fanout.converged`, … | The branches of a fanout step. | | `wait.suspended`, `wait.resumed` | The run stopped to wait, and went on. | | `value.recorded` | A decision a step made once and keeps when it runs again after a wait: which approval an approval gate waits on, or a clock read. | ## Agent turns An agent step runs the agent's turn as a run of its own. Its id is in the step's output (`runId`), and the turn's run has the flow's run as its `parentRunId`: ```sh kindgi runs journal ``` The turn's journal has the model calls (the messages sent, the answer, the tokens and the cost of each) and the tool calls. That's where to look when an agent step answered something you didn't expect. [List runs](../list-runs/) shows how to find the turns of a run. ## From your app * TypeScript ```ts type JournalEntry = { sequence: number; kind: string; nodeId?: string; payload?: unknown }; const { data } = await kindgi.runs.journal(runId); for (const entry of data as JournalEntry[]) { console.log(entry.sequence, entry.kind, entry.nodeId ?? ''); } ``` * Python ```python for entry in kindgi.runs.journal(run_id).data: print(entry.sequence, entry.kind, entry.node_id or "") ``` For a run of `acme.escalate-order` that waited for an approval: ```text 0 run.started 1 edge.evaluated 2 step.started order 3 step.completed order 4 edge.evaluated 5 step.started hold 6 wait.suspended hold 7 wait.resumed hold 8 step.started hold 9 step.completed hold 10 edge.evaluated 11 edge.evaluated 12 step.started email 13 step.completed email 14 edge.evaluated 15 run.completed ``` # Retry a start safely > Send an idempotency key with a start, so a retried request returns the run the first one started instead of starting another. A request to start a run can time out after the run started. Retry it with the same **idempotency key**, and Kindgi returns the run the first request started instead of starting a second one. ```sh kindgi runs start --flow=acme.review-order --input='{"orderId":"A-100"}' --idempotency-key=order-A-100-review ``` Run it twice: both print the same run, with the same `id` and `createdAt`. From your app, pass `idempotencyKey` (`idempotency_key` in Python): * TypeScript ```ts import { KindgiApiError } from '@kindgi/sdk/client'; const start = () => kindgi.runs.start({ flow: 'acme.review-order', input: { orderId: 'A-200' }, idempotencyKey: 'review-A-200', }); const first = await start(); const retry = await start(); // a retry after a timeout, say console.log(first.id === retry.id, retry.status); try { await kindgi.runs.start({ flow: 'acme.review-order', input: { orderId: 'A-100' }, // a different request, same key idempotencyKey: 'review-A-200', }); } catch (err) { if (err instanceof KindgiApiError) console.log(err.error.code, err.message); } ``` ```text true completed conflict Idempotency-Key was reused with a different request body. Use a fresh key or the original body. ``` * Python ```python from kindgi.client import ConflictError def start(): return kindgi.runs.start( flow="acme.review-order", input={"orderId": "A-200"}, idempotency_key="review-A-200" ) first = start() retry = start() # a retry after a timeout, say print(first.id == retry.id, retry.status) try: kindgi.runs.start( flow="acme.review-order", input={"orderId": "A-100"}, # a different request, same key idempotency_key="review-A-200", ) except ConflictError as err: print(err) ``` ```text True completed Idempotency-Key was reused with a different request body. Use a fresh key or the original body. ``` ## How it works * A request with a key Kindgi has seen gets the first request's response again: the same run, as it was returned then. Call `get` for its current state. * The key must come with the same request. The same key with a different body (another input, other options) is refused with a conflict, and nothing starts. * Keys are kept for 24 hours, per tenant. Pick a key that names the work, not the attempt: `review-A-200` for "review order A-200", the same on every retry. A new key per attempt defeats it. Other requests that change something (`cancel`, registering a webhook endpoint, …) take an idempotency key too. # Show runs in your app > Keep a run's id on your own row, and read its status, output, steps, sources and cost through the API, never from Kindgi's database or console. When your app keeps something a run did (a ticket a flow triaged, an answer an agent gave), it keeps the run's id on its own row and reads the rest from Kindgi's API when it needs it. Your users see it in your app, in your UI. ## Keep the run's id `runs.start` answers with the run's id. Store it with your own record, in a column such as `kindgi_run_id`. It's all your app needs to read the run again. ## Hear when it finished Subscribe to the `run.finished` webhook instead of polling: [Get a webhook when a run finishes](../../webhooks/receive-run-finished/). It carries the run's id, its outcome (`completed`, `failed` or `cancelled`) and what its model calls cost, never its output, so your handler finds the row by `kindgi_run_id` and reads the run. ## Read what you show | Your app shows | Read | Client call | | -------------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | The status, the output, when it started and finished | `GET /v1/runs/{runId}` | `runs.get` | | Only the status and timing (no data) | `GET /v1/runs/{runId}/progress` | `runs.progress` | | What happened, step by step: tool calls, branches, approvals | `GET /v1/runs/{runId}/journal` | `runs.journal` ([Read a run's journal](../read-the-journal/)) | | Where an agent's answer came from: its model calls and tool results | `GET /v1/provenance/{runId}` | `provenance.get` ([Trace an answer](../../observability/trace-an-answer/)) | | What it cost: each model call's tokens and cost, its agent steps' included | `GET /v1/cost/records?rootRunId={runId}` | `cost.usage.query` (Python: `cost.records.list`) ([Cost per run and per customer](../../observability/cost-per-run/)) | * TypeScript app/run-details.ts ```ts import { createClient } from '@kindgi/sdk/client'; import type { RunId } from '@kindgi/sdk/types'; const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN /** What a ticket's page shows about its run, from the ticket's kindgi_run_id. */ export async function runDetails(kindgiRunId: string) { const runId = kindgiRunId as RunId; const run = await kindgi.runs.get(runId); const { data: steps } = await kindgi.runs.journal(runId); return { status: run.status, startedAt: run.createdAt, finishedAt: run.completedAt, output: run.output, steps: steps.length, }; } ``` * Python ```python # in app/run_details.py from kindgi.client import Kindgi kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN def run_details(kindgi_run_id: str) -> dict[str, object]: """What a ticket's page shows about its run, from the ticket's kindgi_run_id.""" run = kindgi.runs.get(kindgi_run_id) steps = kindgi.runs.journal(kindgi_run_id).data return { "status": run.status, "started_at": run.created_at, "finished_at": run.completed_at, "output": run.output, "steps": len(steps), } ``` A flow run has no provenance of its own: each agent step in it is a turn with its own run. The step's `step.completed` entry in the flow's journal names that run (`payload.output.runId`); read the turn's provenance by that id. ## Kindgi's database and console aren't your app's Read Kindgi's data only through its API, from your app's server: * **Don't query Kindgi's database**, even when it's on the same Postgres server as your app's. Its schema is private and changes with every release (migrations only go forward), row-level security guards every query by tenant, and a runtime Kindgi hosts for you gives no database access at all. The API is the contract that stays. * **Don't send your users to Kindgi's console** to see a run. Show what they need in your own UI, with your own access rules. * **To keep a copy** for reporting or search, pull it through the API into your own tables. # Start a run > Start a flow or an agent from the CLI or your app, and wait for its result or let it finish in the background. ## From the CLI ```sh kindgi runs start --flow=acme.review-order --input='{"orderId":"A-200"}' kindgi runs start --flow=acme.review-order --input=@order.json ``` `--input` is the run's input as JSON, or `@` and a file to read it from. The command waits for the run to finish, however long it takes, and prints it. Ctrl+C stops the waiting, not the run: ```text Stopped waiting. Run f91ff793-b029-48e8-9ce4-f9fa70bd0c40 goes on: kindgi runs get f91ff793-b029-48e8-9ce4-f9fa70bd0c40 ``` With `--no-wait`, it prints the run as soon as it exists: ```sh kindgi runs start --flow=acme.reserve-order --input='{"orderId":"A-300"}' --no-wait ``` ```json { "id": "1b1f6486-3faf-4ab7-8506-c09f50f989bc", … "flowId": "acme.reserve-order", "flowVersion": "0.1.0", "status": "pending", "dryRun": false, … } ``` `kindgi runs get ` shows it again, with its output once it's done. ## From your app Create a client. With no arguments it reads the API's URL and a token from `KINDGI_API_URL` and `KINDGI_API_TOKEN`, which you set in your env file (`.env` / `.env.local`). In development, when they aren't set, it uses the running `kindgi dev`, which writes both to `.kindgirc.json`, and warns once that you should set them. In production (`NODE_ENV` or `KINDGI_ENV` set to `production`) it never does, so set them there. * TypeScript ```ts import { createClient } from '@kindgi/sdk/client'; const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev ``` * Python ```python from kindgi.client import Kindgi kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev ``` ### Wait for the result * TypeScript ```ts const run = await kindgi.runs.start({ flow: 'acme.review-order', input: { orderId: 'A-200' }, }); console.log(run.status, run.output); ``` ```text completed { status: 'held', orderId: 'A-200' } ``` * Python ```python run = kindgi.runs.start(flow="acme.review-order", input={"orderId": "A-200"}) print(run.status, run.output) ``` ```text completed {'status': 'held', 'orderId': 'A-200'} ``` `start` returns when the run completes, fails or waits for something (a person's approval, for example: `status` is then `suspended`). Use it when the run is short and the caller needs the answer. ### Start it in the background With `wait: false`, `start` returns as soon as the run exists, with `status` `pending`, and the run finishes on the server: * TypeScript ```ts let run = await kindgi.runs.start({ flow: 'acme.reserve-order', input: { orderId: 'A-300' }, options: { wait: false }, }); console.log(run.id, run.status); while (run.status === 'pending' || run.status === 'running') { await new Promise((resolve) => setTimeout(resolve, 1000)); run = await kindgi.runs.get(run.id); } console.log(run.status, run.output); ``` ```text dbcf7457-0d25-49b9-9112-b03c9b44e9e8 pending completed { attempt: 3, orderId: 'A-300', reserved: true } ``` * Python ```python import time run = kindgi.runs.start( flow="acme.reserve-order", input={"orderId": "A-300"}, options={"wait": False} ) print(run.id, run.status) while run.status in ("pending", "running"): time.sleep(1) run = kindgi.runs.get(run.id) print(run.status, run.output) ``` ```text 0ed299be-e04d-47bb-84b8-2ff9a4286822 pending completed {'attempt': 3, 'orderId': 'A-300', 'reserved': True} ``` Polling works, but there are better ways to learn that a run ended: [follow its events](../follow-a-run/), or have Kindgi [send your app a webhook](../../webhooks/) when it finishes. Agent runs take `options: { wait: false }` too: the turn then runs in the background. Start an agent with `agent` instead of `flow`, and its input (`{ "userMessage": "…" }`, see [Give an agent its input](../../agents/give-an-agent-input/)). ## What you get back | Field | | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | The run's id: for `get`, `cancel`, the journal and the event stream. | | `status` | `pending`, `running`, `suspended`, `completed`, `failed` or `cancelled`. | | `output` | What the run returned, once it completed. | | `failureMessage` | Why it failed. | | `flowId`, `flowVersion` | The flow and the version the run is pinned to. An agent's turn reports `agent.turn`, and its agent is in `agent`. | | `agent` | For an agent's turn: the agent's `id`, the `version` that ran, and the `conversationId`. A flow's run has none, nor does a turn from before Kindgi 0.1.3. | | `dryRun` | Whether it was a [dry run](../../flows/dry-run-a-flow/). | | `publicAccessToken` | A read-only token for this run, for a browser, when the deployment issues them ([details](../follow-from-the-browser/)). Only `start` returns it. | The client references have every field and option: [TypeScript](../../../reference/typescript/sdk/kindgi/sdk/client/), [Python](../../../reference/python/resources/runs/), and the [CLI](../../../reference/cli/runs/). ## When it fails A run that fails is still a run: `start` returns it, with `status: "failed"` and `failureMessage`. A request the API refuses (a flow that doesn't exist, a malformed input) raises an error instead, and no run exists: * TypeScript ```ts import { KindgiApiError } from '@kindgi/sdk/client'; const run = await kindgi.runs.start({ flow: 'acme.review-order', input: { orderId: 'Z-9' } }); if (run.status === 'failed') console.log(run.failureMessage); try { await kindgi.runs.start({ flow: 'acme.no-such-flow', input: {} }); } catch (err) { if (err instanceof KindgiApiError) console.log(err.error.code, err.message); } ``` ```text handler-error: Tool "acme.get-order" handler threw: handler-throw: Handler for tool "acme.get-order" threw: Error: No order Z-9 not-found No flow "acme.no-such-flow" is registered in this tenant. ``` * Python ```python from kindgi.client import NotFoundError run = kindgi.runs.start(flow="acme.review-order", input={"orderId": "Z-9"}) if run.status == "failed": print(run.failure_message) try: kindgi.runs.start(flow="acme.no-such-flow", input={}) except NotFoundError as err: print(err) ``` ```text handler-error: Tool "acme.get-order" handler threw: handler-throw: Handler for tool "acme.get-order" threw: ValueError: No order Z-9 No flow "acme.no-such-flow" is registered in this tenant. ``` A request that times out on your side may have started the run anyway. [Retry a start safely](../retry-a-start-safely/) shows how to retry without starting it twice. # Secrets and environment > Where a pack's settings and secrets live on your machine and in a deployment, and how your code gets them. A pack's code needs two kinds of values, and Kindgi keeps them apart: * **Secrets a tool uses for a tenant**: an API key, a signing key. A tool declares them by name, and Kindgi resolves them for each call from the tenant's secrets (`ctx.secrets`). Model providers, HTTP tools, MCP endpoints and webhooks name theirs the same way. * **Your code's own environment**: a service URL, a database your app owns, a feature flag. Your code reads them from `process.env` or `os.environ`, and the pack declares which ones it needs. On your machine, `kindgi dev` keeps both in the pack's env files. In a deployment, secrets live in the runtime's secrets store, and the pack service gets the environment the pack declares. * [Keep local values in env files](env-files/): what `kindgi dev` reads, and what reaches your code. * [Store a secret](store-a-secret/): `kindgi secrets`, its environments and scopes. * [Declare the environment your code reads](pack-env/): `env.required` and `env.optional`. * [Set values per environment](per-environment-values/): the pack service's environment in staging or production, and `kindgi env plan`. [Give a tool a secret](../tools/give-a-tool-a-secret/) shows the tool's side. # Keep local values in env files > The env files kindgi dev reads, what reaches your pack's code from them, and how to keep them out of git. On your machine, a pack's settings and secrets live in env files at the pack's root. `kindgi dev` reads `.env`, then `.env.local`; a name in both takes its value from `.env.local`. .env ```sh ORDERS_APP_URL=http://localhost:3000 ``` `kindgi dev` says which files it read when it starts: ```text ✓ env files: .env, .env.local — 4 name(s) for the pack ``` ## What reads them Under `kindgi dev`, the env files are two things at once. **The pack service's environment.** Your tools' code runs in the pack service, and every name in the env files is in its environment: * TypeScript tools/order-link/index.ts ```ts import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; const defined = defineTool({ id: 'my-pack.order-link' as ToolId, description: 'Returns the link to an order in the back office.', version: '0.1.0', input: z.object({ orderId: z.string() }), output: z.object({ url: z.string() }), effects: [], mutating: false, handler: async ({ orderId }) => { const base = process.env.ORDERS_APP_URL; if (base === undefined) throw new Error('ORDERS_APP_URL is not set'); return { url: `${base}/orders/${orderId}` }; }, }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` * Python tools/order_link.py ```python import os from pydantic import BaseModel, Field from kindgi import tool class OrderRef(BaseModel): order_id: str = Field(alias="orderId") class Link(BaseModel): url: str @tool(id="my-pack.order-link", mutating=False) def order_link(ref: OrderRef) -> Link: """Returns the link to an order in the back office.""" return Link(url=f"{os.environ['ORDERS_APP_URL']}/orders/{ref.order_id}") ``` Call it from `my-pack.link`, a one-step flow: ```sh kindgi runs start --flow=my-pack.link --input='{"orderId":"ord_1001"}' ``` ```json "flowId": "my-pack.link", "flowVersion": "0.1.0", "status": "completed", … "output": { "url": "http://localhost:3000/orders/ord_1001" }, ``` Nothing else reaches it: a variable exported in the shell you start `kindgi dev` from isn't in the pack service's environment, and neither is a `KINDGI_*` name, from anywhere. Save an env file and the pack service restarts with the new values. **The `local` secrets store.** Tools' declared secrets, HTTP tools' credentials, model providers' keys and MCP endpoints' credentials resolve in an environment; under `kindgi dev` it's `local`, and `local` is these files. `kindgi secrets set … --env=local` writes to `.env.local` ([Store a secret](../store-a-secret/)). Caution Because the files are both, a secret in them is also in the pack service's environment under `kindgi dev`. A deployment doesn't work that way: read a secret from `ctx.secrets` ([Give a tool a secret](../../tools/give-a-tool-a-secret/)), never from the environment. ## See what's set ```sh kindgi env list --env=local ``` ```text Environment: local Env file: .env Env file: .env.local Reveal: NO (redacted; --reveal to show) 4 key(s): HTTPBIN_TOKEN de**********23 [.env.local] INVENTORY_TOKEN in**************56 [.env.local] ORDERS_APP_URL ht*****************00 [.env] RECEIPT_SIGNING_KEY a6************************************************************83 [.env.local] ``` Values are redacted; `--reveal` prints them to a terminal, never to a pipe or a file. ## Read other files To read more files, list them in the pack's config, lowest precedence first: * TypeScript ```ts // in kindgi.config.ts dev: { envFiles: ['.env', '.env.development', '.env.local'] }, ``` * Python ```toml # in pyproject.toml [tool.kindgi.dev] envFiles = [".env", ".env.development", ".env.local"] ``` `kindgi dev` reads the list when it starts: restart it after a change. ```text ✓ env files: .env, .env.development, .env.local — 6 name(s) for the pack ``` ## Keep them out of git The files hold secrets. Make sure your `.gitignore` covers them: ```text .env .env.* !.env.example ``` The Python template's `.gitignore` has these lines. A TypeScript pack made with `kindgi init` 0.1.0 has no `.gitignore` (its lines landed in `.npmignore`): add one. # Declare the environment your code reads > List the environment variables your pack's code needs, so kindgi dev warns about a missing one and a deployment injects exactly those. Your tools' code, and the libraries it imports, read settings from the process environment: a service URL, a database your app owns. The pack declares which names it reads: * TypeScript ```ts // in kindgi.config.ts env: { required: ['ORDERS_APP_URL', 'ORDERS_DB_URL'], optional: ['ORDERS_API_TIMEOUT_MS'], }, ``` * Python ```toml # in pyproject.toml [tool.kindgi.env] required = ["ORDERS_APP_URL", "ORDERS_DB_URL"] optional = ["ORDERS_API_TIMEOUT_MS"] ``` - **`required`**: names the pack service can't work without. - **`optional`**: names it reads when they're set. - Names are environment variable names. `KINDGI_*` names are Kindgi's own settings and are refused: ```text ✗ refresh failed [config-invalid] kindgi.config: "KINDGI_X" in `env.required`: `KINDGI_*` names configure Kindgi, not the pack ``` Your code reads them as usual: `process.env.ORDERS_APP_URL`, `os.environ["ORDERS_APP_URL"]` (see the `order-link` tool in [Keep local values in env files](../env-files/#what-reads-them)). ## On your machine `kindgi dev` gives the pack service the env files' values, and warns about a required name that has none: ```text ⚠ the pack's env.required name ORDERS_DB_URL has no value: add it to the pack's env files (a deployment won't be ready without it) ``` It warns and carries on, so the rest of the pack keeps working while you fill it in. In a TypeScript pack, `kindgi dev` doesn't reload on a change to `kindgi.config.ts`: save a tool or restart it. A Python pack reloads when `pyproject.toml` changes. ## In a deployment The declaration is part of the pack's index, so the pack service in an image knows what it needs: * **Only declared names are injected.** The pack service gets each declared name that has a value for that environment, and nothing else. [Set values per environment](../per-environment-values/) shows where those values come from, and `kindgi env plan` says when one isn't declared: `environments.staging.env.FEATURE_FLAGS isn't declared in env.required or env.optional, so it isn't injected`. * **A required name with no value keeps the pack service from serving.** It starts, says which names are missing, and answers `503` until they're set: ```text {"kind":"missing-env","check":"strict","names":["ORDERS_DB_URL"]} ``` ```json {"error":"missing env","missingEnv":["ORDERS_DB_URL"]} ``` `KINDGI_PACK_ENV_CHECK=warn` on the pack service makes it serve anyway and only report the names, as `kindgi dev` does. See the [environment variable reference](../../../reference/env-vars/#kindgi_pack_env_check). ## What doesn't go here A secret that belongs to a tenant (an API key a customer gives you) isn't part of the pack's environment: one pack service serves every tenant. A tool declares it and reads it from its context: [Give a tool a secret](../../tools/give-a-tool-a-secret/). # Set values per environment > Give the pack service its environment for staging or production, with secrets by reference, and check it with kindgi env plan. Each deploy target of a pack is an **environment** in its config (`staging`, `production`). Its `env` gives the values of the names the pack [declares](../pack-env/), for the pack service running there: * TypeScript ```ts // in kindgi.config.ts environments: { staging: { // endpoint, build, registry, signingKey, tenantId… env: { ORDERS_APP_URL: 'https://backoffice.staging.example.com', ORDERS_DB_URL: { secret: 'orders-db-url', version: '3' }, }, }, }, ``` * Python ```toml # in pyproject.toml [tool.kindgi.environments.staging.env] ORDERS_APP_URL = "https://backoffice.staging.example.com" ORDERS_DB_URL = { secret = "orders-db-url", version = "3" } ``` - **A plain value** is for what isn't secret. It's committed with the pack, and visible in the deployed service's settings. - **`{ secret, version }`** names a secret in your cloud's Secret Manager (add `project`, the project's number, when the secret is in another project). The platform that runs the pack service resolves it; Kindgi never reads the value. Use a version number: `latest` changes under you. ## Check it ```sh kindgi env plan --env=staging ``` ```text The pack service's env in staging: required ORDERS_APP_URL value "https://backoffice.staging.example.com" required ORDERS_DB_URL secret orders-db-url:3 optional ORDERS_API_TIMEOUT_MS (unset: not injected) ``` That summary goes to stderr. On stdout, `env plan` prints the same environment for your infrastructure code: Terraform input by default, ```json { "env": { "ORDERS_APP_URL": "https://backoffice.staging.example.com" }, "secret_env": { "ORDERS_DB_URL": { "secret": "orders-db-url", "version": "3" } } } ``` or, with `--format=gcloud`, flags for `gcloud run deploy`, which set these names and leave the service's other variables alone: ```text --update-env-vars=ORDERS_APP_URL=https://backoffice.staging.example.com \ --update-secrets=ORDERS_DB_URL=orders-db-url:3 ``` ## What it refuses `env plan` exits with status 1, and names the problem, when the environment won't work: * **A required name has no value:** `✗ ORDERS_DB_URL is required and has no value in environments.staging.env` * **A secret is given in the clear**, by its value (a URL with a password) or its name (`*_KEY`, `*_TOKEN`, `*_PASSWORD`, …): ```text ✗ ORDERS_DB_URL's value has a credential in it (a URL with a password): give it as { secret, version } in environments.staging.env ``` ```text ✗ ORDERS_API_KEY is named like a secret: give it as { secret, version } in environments.staging.env ``` `kindgi deploy` runs the same check before it builds or sends anything: ```text kindgi deploy: ORDERS_DB_URL is required by the pack and has no value in environments.staging.env, so the pack service wouldn't be ready. Add it, or deploy anyway with --allow-missing-env. ``` ## Env files for other environments `kindgi env` manages env files per environment: `local` is the pack's own files (`.env`, `.env.local`), any other name its `.env.`: ```sh kindgi env set ORDERS_API_URL https://orders.staging.example.com --env=staging ``` ```text ✓ Wrote ORDERS_API_URL in …/my-pack/.env.staging ``` `kindgi env list --env=staging` shows them, redacted; `kindgi env unset` removes one. `set` won't change a name a file already sets without `--force`, and refuses `KINDGI_*` names. Under `kindgi dev`, a secret reference that names an environment other than `local` (an HTTP tool's `secretRef: { envName: 'staging', … }`, for example) resolves from that environment's file, `.env.staging`. The pack service's environment in a deployment comes from `environments..env`, not from these files: `env plan` doesn't read them. # Store a secret > Store, replace and list a tenant's secrets with kindgi secrets, and what each environment keeps. Tools' declared secrets, HTTP tools' credentials, model providers' keys and MCP endpoints' credentials are all secrets of your tenant, looked up by name in an **environment**. `kindgi secrets` stores them without the value ever going through your shell's history or a command line. ## Set one * TypeScript ```sh pnpm exec kindgi secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant ``` * Python ```sh npx --yes @kindgi/cli@0.1 secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant ``` ```text Value: Re-enter to confirm: ``` It prompts twice, without echoing. In a script, pipe the value in with `--from-stdin`, or read it from a file only you can read with `--from-file=` (a file others can read is refused): ```sh openssl rand -hex 32 | tr -d '\n' | kindgi secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdin ``` ```text Set RECEIPT_SIGNING_KEY at tenant in local. ``` * **`--env`** is the environment the secret belongs to. A runtime resolves secrets in the environment it serves (`KINDGI_ENV`); `kindgi dev` serves `local`. * **`--scope`** is where it lives: `tenant`, `org:` or `project:`. It's required. The secret is available to the next call. Nothing restarts. ## Replace one `set` refuses a name that exists: ```text Version conflict: RECEIPT_SIGNING_KEY already exists (version 1). To store a new version, retry with --write-mode=add-version. ``` Write a new value with `--write-mode=add-version`: ```sh openssl rand -hex 32 | tr -d '\n' | kindgi secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdin --write-mode=add-version ``` `--if-version=` makes the write conditional: it fails if someone changed the secret since version `n`. ## List them `list` and `get` show names and versions, never values: ```sh kindgi secrets get RECEIPT_SIGNING_KEY --env=local --scope=tenant ``` ```json { "name": "RECEIPT_SIGNING_KEY", "scope": { "kind": "tenant", "tenantId": "bb22c936-5111-429a-bfce-09650af85492" }, "envName": "local", "currentVersion": 1, "createdAt": "1970-01-01T00:00:00.000Z", "updatedAt": "1970-01-01T00:00:00.000Z" } ``` `kindgi secrets list --env=local --scope=tenant` lists every secret in the environment. ## Under `kindgi dev` `kindgi dev`'s secrets store is the pack's env files ([Keep local values in env files](../env-files/)), so it behaves like them: * `set` writes the value to `.env.local`, readable only by you. * Every name in the env files is a secret of `local`, and `list` shows them all. Their timestamps are the epoch, as above. * A name has one value: replacing it with `add-version` overwrites it, and its version stays 1. * The files are the same for every scope, so `--scope` doesn't separate anything here. * There's nothing to rotate or revoke. Those commands say so: ```text Error [server]: Dev secrets live in your env files (.env, .env.local) and have no versions to rotate. Edit the value there, or run `kindgi secrets set RECEIPT_SIGNING_KEY --write-mode=add-version`. ``` ```text Error [server]: Dev secrets live in your env files (.env, .env.local) and have no revocation. Remove HTTPBIN_TOKEN from those files. ``` Versions, scopes, rotation and revocation are what a deployment's secrets store (`KINDGI_SECRETS_BACKEND=postgres`, see the [environment variable reference](../../../reference/env-vars/#kindgi_secrets_backend)) adds. Every flag is in the [`kindgi secrets` reference](../../../reference/cli/secrets/). # 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. A tool is a function with a typed input and a typed output. Agents call tools, and so do flow steps. Kindgi checks the input before your code runs and the output after it returns, so neither side gets malformed data. * [Write a tool](write-a-tool/): a function in TypeScript or Python, tried from a flow, tested, and wired onto an agent. * [Call an HTTP API without code](http-tools/): a tool that is one HTTP request, made by Kindgi. * [Give a tool a secret](give-a-tool-a-secret/): declare the secret, read it from the call's context, keep it out of your code. * [Mark a tool read-only](read-only-tools/): what `mutating: false` changes for dry runs and approval gates. * [Use an MCP server's tools](mcp-servers/): register an MCP server, and its tools become tools your agents and flows call. The examples use the pack from the quickstarts, `my-pack` ([TypeScript](../../start/quickstart-typescript/), [Python](../../start/quickstart-python/)), with `kindgi dev` running. # Give a tool a secret > Declare the secrets a tool needs and read them from the call's context, resolved by Kindgi for each call. A tool that needs an API key or a signing key declares it by name. Kindgi resolves it on every call, checks it, and hands it to the handler in the call's context. The value never appears in your code or your config. This tool signs a receipt with a key: * TypeScript tools/sign-receipt/index.ts ```ts import { createHmac } from 'node:crypto'; import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; const defined = defineTool({ id: 'my-pack.sign-receipt' as ToolId, description: 'Signs an order receipt so the customer portal can verify it.', version: '0.1.0', input: z.object({ orderId: z.string(), totalCents: z.number().int() }), output: z.object({ signature: z.string() }), effects: [], mutating: false, needsSpec: { secrets: { RECEIPT_SIGNING_KEY: { type: 'string', minLength: 32 } }, }, handler: async ({ orderId, totalCents }, ctx) => { const key = ctx.secrets?.RECEIPT_SIGNING_KEY; if (key === undefined) throw new Error('RECEIPT_SIGNING_KEY is not set'); const signature = createHmac('sha256', key).update(`${orderId}:${totalCents}`).digest('hex'); return { signature }; }, }); if (defined.kind === 'err') { throw new Error(`my-pack.sign-receipt failed to compile: ${defined.error.message}`); } export default defined.value; ``` * Python tools/receipts.py ```python import hashlib import hmac from pydantic import BaseModel, Field from kindgi import ToolContext, tool class Receipt(BaseModel): order_id: str = Field(alias="orderId") total_cents: int = Field(alias="totalCents") class Signed(BaseModel): signature: str @tool( id="my-pack.sign-receipt", mutating=False, needs_spec={"secrets": {"RECEIPT_SIGNING_KEY": {"type": "string", "minLength": 32}}}, ) def sign_receipt(receipt: Receipt, ctx: ToolContext) -> Signed: """Signs an order receipt so the customer portal can verify it.""" key = ctx.secrets["RECEIPT_SIGNING_KEY"].encode() message = f"{receipt.order_id}:{receipt.total_cents}".encode() return Signed(signature=hmac.new(key, message, hashlib.sha256).hexdigest()) ``` - **The declaration** maps each secret's name to a JSON Schema for its value. Every declared secret is required. - **On every call**, Kindgi resolves each declared secret for the call's tenant, in the environment the runtime serves (`KINDGI_ENV`; under `kindgi dev`, `local`: the pack's env files), and checks it against its schema. If one is missing or doesn't fit, the call fails before the handler runs. - **The context** holds only what the tool declares. `ctx.secrets` is optional in the TypeScript type because a unit test builds its own context. ## Store the secret Until the secret exists, calls fail and name it: ```json "status": "failed", "failureMessage": "handler-error: Tool \"my-pack.sign-receipt\" handler threw: secret-unavailable: tool \"my-pack.sign-receipt\" needs secret \"RECEIPT_SIGNING_KEY\" in env \"local\": No secret \"RECEIPT_SIGNING_KEY\" for env \"local\" in .env, .env.local at /pack", ``` Store a value. `kindgi secrets set` prompts for it without echoing; here it reads it from stdin: * TypeScript ```sh openssl rand -hex 32 | tr -d '\n' | pnpm exec kindgi secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdin ``` * Python ```sh openssl rand -hex 32 | tr -d '\n' | npx --yes @kindgi/cli@0.1 secrets set RECEIPT_SIGNING_KEY --env=local --scope=tenant --from-stdin ``` ```text Set RECEIPT_SIGNING_KEY at tenant in local. ``` Under `kindgi dev` that writes it to the pack's `.env.local`. The next call gets it; nothing restarts. Run the tool (here from `my-pack.sign`, a one-step flow): ```sh kindgi runs start --flow=my-pack.sign --input='{"orderId":"ord_1001","totalCents":4200}' ``` ```json "status": "completed", … "output": { "signature": "b02d6ae82a21be7754b7c605ae55210fdb2d6c14ca25f03b56c0d62c6f27e595" }, ``` A value that doesn't fit the schema fails the call too, without the value in the message: ```json "failureMessage": "handler-error: Tool \"my-pack.sign-receipt\" handler threw: secret-invalid: secret \"RECEIPT_SIGNING_KEY\" in env \"local\" doesn't match the schema tool \"my-pack.sign-receipt\" declares for it: must NOT have fewer than 32 characters", ``` To replace a value, `set` it again with `--write-mode=add-version`; without it, `set` refuses a name that exists. [Store a secret](../../secrets/store-a-secret/) covers `kindgi secrets`. ## Test it A test passes the secret in the context it builds: * TypeScript tools/sign-receipt/index.test.ts ```ts import { invokeTool } from '@kindgi/sdk/define'; import type { TenantId } from '@kindgi/sdk/types'; import { expect, test } from 'vitest'; import signReceipt from './index.js'; test('signs with the key from the context', async () => { const result = await invokeTool( signReceipt, { orderId: 'ord_1001', totalCents: 4200 }, { tenantId: 'test-tenant' as TenantId, abortSignal: new AbortController().signal, secrets: { RECEIPT_SIGNING_KEY: 'k'.repeat(32) }, }, ); expect(result.kind).toBe('ok'); }); ``` * Python tests/test_receipts.py ```python from kindgi import ToolContext from tools.receipts import Receipt, sign_receipt def test_signs_with_the_key_from_the_context(): ctx = ToolContext.for_test(secrets={"RECEIPT_SIGNING_KEY": "k" * 32}) signed = sign_receipt(Receipt(orderId="ord_1001", totalCents=4200), ctx) assert len(signed.signature) == 64 ``` ## Why not the process environment The pack service is one process for every tenant your runtime serves, so its environment can't hold a key that belongs to one of them. Keep `process.env` / `os.environ` for your app's own settings (a service URL, a database your app owns), and declare them as the pack's environment: [Declare the environment your code reads](../../secrets/pack-env/). Caution Under `kindgi dev`, every value in the pack's env files, secrets included, is also in the pack service's environment. Code that reads a secret from `process.env` works on your machine for that reason. Read it from `ctx.secrets`. An [HTTP tool](../http-tools/#send-a-credential) names its credential in `authorization.secretRef` instead, and Kindgi adds it to the request. # Call an HTTP API without code > Declare a tool that is one HTTP request, with its URL filled from the input and its credential resolved by Kindgi. A tool that is a single HTTP request needs no handler. You declare the request; Kindgi makes it on every call, fills the URL from the tool's input and adds the credential. The examples call httpbin.org, which echoes requests back, so they run without an account. ## Declare the request * TypeScript tools/add-order-note/index.ts ```ts import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; const defined = defineTool({ id: 'my-pack.add-order-note' as ToolId, description: 'Adds a note to an order.', version: '0.1.0', input: z.object({ orderId: z.string(), text: z.string().min(1), }), // The API answers with more fields than these: a loose object keeps them. output: z.looseObject({ method: z.string(), url: z.string(), }), effects: [{ kind: 'writes', resource: 'external:orders' }], spec: { kind: 'http', method: 'POST', urlTemplate: 'https://httpbin.org/anything/orders/{orderId}/notes', headers: [{ name: 'Accept', value: 'application/json' }], timeoutMs: 10_000, }, }); if (defined.kind === 'err') { throw new Error(`my-pack.add-order-note failed to compile: ${defined.error.message}`); } export default defined.value; ``` `spec` takes the place of `handler`; a tool has one or the other. * Python tools/orders_api.py ```python from pydantic import BaseModel, Field from kindgi import http_tool class Note(BaseModel): order_id: str = Field(alias="orderId") text: str = Field(min_length=1) class Echoed(BaseModel): method: str url: str add_order_note = http_tool( id="my-pack.add-order-note", description="Adds a note to an order.", input=Note, output=Echoed, method="POST", url_template="https://httpbin.org/anything/orders/{orderId}/notes", headers={"Accept": "application/json"}, effects=[{"kind": "writes", "resource": "external:orders"}], timeout_ms=10_000, ) ``` `http_tool` declares the tool at module level, like `@tool`, with no function. - **The URL.** Each `{name}` in the URL template is filled from the input field with that name (its wire name), URL-encoded. Every placeholder must be an input field. - **The body.** A `POST`, `PUT`, `PATCH` or `DELETE` sends the input fields the URL didn't use, as JSON: here `{"text": …}`. A `GET` sends no body. For another shape, set the request body: `input-passthrough` (the whole input) or `text` with a template (sent as `text/plain`). - **The answer.** The response's JSON is the tool's output, checked against the output schema. A Zod `z.object` rejects fields it doesn't list, and most APIs return more than you need, so use `z.looseObject` (TypeScript). A pydantic model allows extra fields as it is. - **Failures.** A status outside 200 to 299 fails the call (change the range with `successStatus` / `success_status`), as does a response slower than the timeout (30 seconds by default). Kindgi doesn't retry. The tool writes, so it doesn't say `mutating: false`. See [Mark a tool read-only](../read-only-tools/). ## Run it From `my-pack.note-order`, a one-step flow that calls the tool, built like the one in [Write a tool](../write-a-tool/#try-it-from-a-flow): * TypeScript ```sh pnpm exec kindgi runs start --flow=my-pack.note-order --input='{"orderId":"ord_1001","text":"Customer called"}' ``` * Python ```sh npx --yes @kindgi/cli@0.1 runs start --flow=my-pack.note-order --input='{"orderId":"ord_1001","text":"Customer called"}' ``` ```json "flowId": "my-pack.note-order", "flowVersion": "0.1.0", "status": "completed", … "output": { "url": "https://httpbin.org/anything/orders/ord_1001/notes", "args": {}, "data": "{\"text\":\"Customer called\"}", "form": {}, "json": { "text": "Customer called" }, "files": {}, "method": "POST", … "headers": { "Host": "httpbin.org", "Accept": "application/json", "User-Agent": "node", "Content-Type": "application/json", … } }, ``` A request that fails fails the call, and the run's `failureMessage` says why: `got status 404 NOT FOUND`, or `timed out after 1000ms`. ## Send a credential `authorization` names a secret; Kindgi resolves it on every call and sends it as a header. Your code never holds the value. * TypeScript tools/check-token/index.ts ```ts import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; const defined = defineTool({ id: 'my-pack.check-token' as ToolId, description: 'Checks that the API accepts our token.', version: '0.1.0', input: z.object({}), output: z.looseObject({ authenticated: z.boolean() }), effects: [], mutating: false, spec: { kind: 'http', method: 'GET', urlTemplate: 'https://httpbin.org/bearer', authorization: { kind: 'bearer', secretRef: { envName: 'local', name: 'HTTPBIN_TOKEN' }, }, }, }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` * Python ```python # in tools/orders_api.py class NoInput(BaseModel): pass class TokenCheck(BaseModel): authenticated: bool check_token = http_tool( id="my-pack.check-token", description="Checks that the API accepts our token.", input=NoInput, output=TokenCheck, method="GET", url_template="https://httpbin.org/bearer", authorization={"kind": "bearer", "secretRef": {"envName": "local", "name": "HTTPBIN_TOKEN"}}, mutating=False, ) ``` - `bearer` sends `Authorization: Bearer `. For an API that wants the key in its own header, use `{ kind: 'header', headerName: 'X-Api-Key', secretRef: … }`. - `secretRef` names the secret and the environment it lives in. Under `kindgi dev`, `local` is the pack's `.env` and `.env.local`; any other name is that environment's file, `.env.`. Until the secret exists, the call fails and names it: ```json "status": "failed", "failureMessage": "handler-error: Tool \"my-pack.check-token\" handler threw: secret-unavailable: tool \"my-pack.check-token\" needs secret \"HTTPBIN_TOKEN\" in env \"local\": No secret \"HTTPBIN_TOKEN\" for env \"local\" in .env, .env.local at /pack", ``` Store it (here from stdin; without `--from-stdin` the command prompts for it without echoing), then run `my-pack.try-token`, a one-step flow that calls the tool: * TypeScript ```sh printf 'demo-token-123' | pnpm exec kindgi secrets set HTTPBIN_TOKEN --env=local --scope=tenant --from-stdin pnpm exec kindgi runs start --flow=my-pack.try-token --input='{}' ``` * Python ```sh printf 'demo-token-123' | npx --yes @kindgi/cli@0.1 secrets set HTTPBIN_TOKEN --env=local --scope=tenant --from-stdin npx --yes @kindgi/cli@0.1 runs start --flow=my-pack.try-token --input='{}' ``` ```json "status": "completed", … "output": { "token": "demo-token-123", "authenticated": true }, ``` The next call picks the secret up; nothing restarts. (httpbin echoes the token back; a real API doesn't.) [Store a secret](../../secrets/store-a-secret/) covers `kindgi secrets`. ## When to write a handler instead An HTTP tool is one request and its JSON answer. Paging, retries, several calls, or reshaping the answer belong in a [code tool](../write-a-tool/). In Python, calling an HTTP tool's object raises: Kindgi makes the request, not Python. Try it through `kindgi dev` instead of a unit test: ```text RuntimeError: Tool "my-pack.add-order-note" is an HTTP tool: the Kindgi runtime makes its request, not Python. Call it through Kindgi (an agent, a flow, `kindgi dev`). ``` # Use an MCP server's tools > Register an MCP server with the runtime, and call its tools from agents and flows like your own. An MCP server you run, or one a vendor runs, can give your agents its tools. Register its endpoint with the runtime: Kindgi lists the server's tools and registers each one as a tool of your tenant, named `.`. Agents and flow steps then call it like a tool of your pack. Note `kindgi mcp` is a different thing: it adds MCP servers to your coding agent's `.mcp.json`. The runtime's MCP endpoints are registered through the API, as below. ## A server to try An MCP server that speaks Streamable HTTP works. This one, written with the Python MCP SDK (1.x), has two tools: ```python # server.py: an MCP server with two tools, over Streamable HTTP. from mcp.server.fastmcp import FastMCP mcp = FastMCP("inventory", host="127.0.0.1", port=8791) STOCK = {"SKU-1": 12, "SKU-2": 0} @mcp.tool() def stock_level(sku: str) -> dict: """Returns how many units of a SKU are in stock.""" return {"sku": sku, "units": STOCK.get(sku, 0)} @mcp.tool() def reserve(sku: str, units: int) -> dict: """Reserves units of a SKU for an order.""" return {"sku": sku, "reserved": units} if __name__ == "__main__": mcp.run(transport="streamable-http") ``` ```sh uv init --bare --no-workspace uv add 'mcp<2' uv run python server.py ``` It serves at `http://localhost:8791/mcp`. Under `kindgi dev` the runtime reaches your machine's `localhost`. ## Register the endpoint * TypeScript ```ts import { createClient } from '@kindgi/sdk/client'; import type { TenantId } from '@kindgi/sdk/types'; const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev await kindgi.mcp.endpoints.register({ endpointId: 'inventory', name: 'Inventory', transport: 'streamable-http', config: { transport: 'streamable-http', url: 'http://localhost:8791/mcp' }, scope: { kind: 'tenant', tenantId: process.env.KINDGI_TENANT_ID as TenantId }, }); const page = await kindgi.mcp.endpoints.list(); console.log(page.items.map((e) => `${e.endpointId} (${e.transport})`)); ``` ```text [ 'inventory (streamable-http)' ] ``` * Python ```python from kindgi.client import Kindgi client = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev client.mcp.endpoints.register( { "endpointId": "inventory", "name": "Inventory", "transport": "streamable-http", "config": {"transport": "streamable-http", "url": "http://localhost:8791/mcp"}, "scopeKind": "tenant", } ) for endpoint in client.mcp.endpoints.list().data: print(endpoint.endpoint_id, endpoint.transport) ``` ```text inventory streamable-http ``` Or with `curl` (`kindgi dev` prints the API's URL and token): ```sh curl -X POST "$KINDGI_API_URL/v1/mcp/endpoints" \ -H "authorization: Bearer $KINDGI_API_TOKEN" -H 'content-type: application/json' \ -d '{"endpointId":"inventory","name":"Inventory","transport":"streamable-http", "config":{"transport":"streamable-http","url":"http://localhost:8791/mcp"}, "scopeKind":"tenant"}' ``` ```json {"endpointId":"inventory"} ``` The runtime connects, lists the server's tools and registers them. The `kindgi dev` terminal says how it went: ```text [runtime] [mcp-bridge] MCP endpoint "inventory" synced — 2 published, 0 reinstated, 0 unchanged, 0 retired ``` The tools are now your tenant's: ```sh kindgi tools get inventory.stock_level ``` ```json { "id": "inventory.stock_level", "description": "Returns how many units of a SKU are in stock.", "version": "1.0.0", "input": { "type": "object", "title": "stock_levelArguments", "required": [ "sku" ], "properties": { "sku": { "type": "string", "title": "Sku" } } }, "output": { "type": "object", "additionalProperties": true }, "transport": "mcp", … } ``` Kindgi registers MCP tools as version `1.0.0`. A tool's input schema is the one the server gives. Its answer is the server's structured content, checked against the tool's `outputSchema`, when the server declares one; otherwise the answer's text, parsed as JSON when the text is JSON (servers built with the TypeScript MCP SDK often declare none: their `echo` tool answers `"Echo: hi"`). ## Call its tools An agent lists an MCP tool by id, with a version range: * TypeScript ```ts // in agents/order-desk/index.ts tools: [ { id: 'my-pack.lookup-order' as ToolId, version: '^0.1.0' }, { id: 'inventory.stock_level' as ToolId, version: '^1.0.0' }, ], ``` * Python ```python # in agents/order_desk.py tools=[lookup_order, {"id": "inventory.stock_level", "version": "^1.0.0"}], ``` With a model that calls tools (here Llama 3.1 on Ollama, see [Models](../../models/)): ```sh kindgi runs start --agent=my-pack.order-desk --input='{"userMessage":"How many units of SKU-2 are in stock?"}' ``` ```json "appended": [ { "role": "user", "content": "How many units of SKU-2 are in stock?", … }, { "role": "agent", "content": { "text": "", "toolCalls": [{ "id": "call_v7vwbcgs", "name": "inventory.stock_level", "arguments": { "sku": "SKU-2" } }] }, … }, { "role": "tool", "content": { "sku": "SKU-2", "units": 0 }, "toolCall": { "toolId": "inventory.stock_level", "invocationId": "call_v7vwbcgs" }, … }, { "role": "agent", "content": "There are 0 units of SKU-2 in stock.", … } ], ``` A flow step names it in `ref`, like any tool: `{ id: 'stock', kind: 'tool', ref: 'inventory.stock_level', inputMapping: { sku: { path: 'runInput.sku' } } }`. ## Send a credential Add `secretRef` to the registration. Kindgi resolves the secret in the tenant's secrets when it connects, and sends it as `Authorization: Bearer `: ```json "secretRef": { "envName": "local", "name": "INVENTORY_TOKEN" } ``` Under `kindgi dev`, `local` is the pack's env files. If the secret isn't there when the endpoint is registered, no tools are registered, and the `kindgi dev` terminal says why: ```text [runtime] [mcp-bridge] WARN MCP endpoint "warehouse" sync failed: mcp-auth-unresolved: MCP endpoint "warehouse": its secretRef local/INVENTORY_TOKEN did not resolve: No secret "INVENTORY_TOKEN" for env "local" in .env, .env.local at /pack ``` Store the secret (`kindgi secrets set INVENTORY_TOKEN --env=local --scope=tenant`), then register the endpoint again, as below. ## When the server changes The runtime lists an endpoint's tools when the endpoint is registered and each time the runtime starts. It doesn't watch the server. To pick up new or changed tools, unregister the endpoint and register it again, or restart the runtime: ```sh curl -X POST "$KINDGI_API_URL/v1/mcp/endpoints/inventory/unregister" \ -H "authorization: Bearer $KINDGI_API_TOKEN" ``` ```json {"endpointId":"inventory","unregistered":true} ``` Unregistering retires the endpoint's tools. Registering it again brings them back. If the server is down when a tool is called, the call fails and the next one reconnects: ```json "failureMessage": "handler-error: Tool \"inventory.stock_level\" handler threw: mcp-call-failed: MCP endpoint \"inventory\" tool \"stock_level\": tools/call \"stock_level\" failed: fetch failed (the next call reconnects)", ``` ## Transports * **`streamable-http`**: `{ transport: 'streamable-http', url, headers? }`. Use it wherever you can. `headers` are sent as given, and the API returns them as given: put a credential in `secretRef`, not here. * **`http-sse`**: the older HTTP and Server-Sent Events transport, `{ transport: 'http-sse', url, sseUrl?, headers? }`, with `url` the server's SSE endpoint (`http://localhost:8793/sse` for the Python SDK). * **`stdio`**: `{ transport: 'stdio', command, args?, env? }`. The runtime runs the command in its own container, as its own user, under `kindgi dev` too. A deployment refuses it (`KINDGI_TENANT_HOST_ACCESS` is `deployed` outside development): ```json {"error":{"code":"host-access-denied","message":"MCP endpoint \"files\" 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.", …}} ``` See [Security](../../../concepts/security/#what-a-tenant-cant-make-the-server-do). ## Good to know * An MCP tool always counts as one that changes things: it doesn't run in a [dry run](../read-only-tools/#dry-runs), and an approval gate asks before it. * A tool's schemas are read in the JSON Schema dialect they declare (`"$schema"`): draft-06, draft-07 (what servers built with the TypeScript MCP SDK declare), 2019-09 or 2020-12, the default when they declare none. Kindgi skips a tool whose schema declares another dialect or doesn't compile, and the runtime's log names the tool. * An endpoint is registered at a scope: `tenant`, as here, or a project (`"scopeKind": "project", "scopeId": ""`). See the [API reference](../../../reference/api/operations/tags/mcp/). # Mark a tool read-only > Declare which tools only read, so they run in a dry run and pass approval gates without asking. A tool declares whether it changes anything outside Kindgi. One that only reads (a lookup, a search, a calculation) says so: * TypeScript ```ts // in tools/lookup-order/index.ts const defined = defineTool({ id: 'my-pack.lookup-order' as ToolId, // … effects: [], mutating: false, // … }); ``` * Python ```python # in tools/lookup_order.py @tool(id="my-pack.lookup-order", mutating=False) def lookup_order(ref: OrderRef, ctx: ToolContext) -> Order: ... ``` Leaving `mutating` out is the same as `mutating: true`. That's the safe default: a tool counts as one that changes things until it says otherwise. It decides two things. ## Dry runs `--dry-run` runs a flow without changing anything. A tool step runs only if the tool is read-only: ```sh kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}' --dry-run ``` ```json "status": "completed", "dryRun": true, … "output": { "status": "shipped", "orderId": "ord_1001", "totalCents": 4200 }, ``` The first tool that isn't stops the run, before the tool is called: ```sh kindgi runs start --flow=my-pack.note-order --input='{"orderId":"ord_1001","text":"Customer called"}' --dry-run ``` ```json "status": "failed", "dryRun": true, "failureMessage": "dry-run-effectful-tool: tool \"my-pack.add-order-note\" is not declared read-only (mutating: false), so it does not run in a dry run", ``` An agent step in a dry run skips its model call, so the agent calls no tools. The sample's `echo-flow` greets with a read-only tool, then hands the greeting to its agent: ```sh kindgi runs start --flow=my-pack.echo-flow --input='{"name":"Ada"}' --dry-run ``` ```json "status": "completed", "dryRun": true, … "output": { "reply": "[dry-run: model call skipped]", "greeting": "Hello, Ada!" }, ``` Use a dry run to check a flow's wiring before it touches real data. ## Effects `effects` lists what a tool touches, for review and policy: `{ kind: 'writes', resource: 'external:orders' }`. The kinds are `reads`, `writes`, `deletes`, `network`, `spawns-run`, `emits-event`, `external-side-effect` and `sensitive-data-egress`. A tool with a `writes`, `deletes`, `spawns-run`, `emits-event` or `external-side-effect` effect doesn't run in a dry run even when it says `mutating: false`. Keep the two consistent: a tool that only reads declares `mutating: false` and, at most, `reads` effects. ## Approval gates An agent can make a person approve tool calls ([Ask before a tool runs](../../approvals/ask-before-a-tool-runs/)). With its gates on and no rule for a tool, a read-only tool runs without asking, and any other tool waits for approval on its first use. ## Getting it right * Declare `mutating: false` on every tool that only reads. Otherwise a dry run stops at it, and an approval gate asks before it. * Never declare it on a tool that writes, sends or deletes. A dry run would then run it for real. * A tool from an [MCP server](../mcp-servers/) always counts as one that changes things: it doesn't run in a dry run. # Write a tool > Write a tool in TypeScript or Python, try it from a flow, test it, and give it to an agent. A tool is a function with a typed input and a typed output. This one looks an order up by its id. The orders live in a map here; in your app, the handler calls your own code. ## The tool * TypeScript tools/lookup-order/index.ts ```ts import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; // Stands in for your app's database. const ORDERS = new Map([ ['ord_1001', { status: 'shipped', totalCents: 4200 }], ['ord_1002', { status: 'pending', totalCents: 1250 }], ]); const defined = defineTool({ id: 'my-pack.lookup-order' as ToolId, description: 'Looks up an order by id. Returns its status and total in cents.', version: '0.1.0', input: z.object({ orderId: z.string().regex(/^ord_\d+$/), }), output: z.object({ orderId: z.string(), status: z.enum(['pending', 'shipped', 'delivered']), totalCents: z.number().int(), }), effects: [], mutating: false, handler: async ({ orderId }, ctx) => { const order = ORDERS.get(orderId); if (order === undefined) throw new Error(`No order ${orderId}`); console.log(`lookup-order ${orderId} for tenant ${ctx.tenantId}`); return { orderId, status: order.status as 'pending' | 'shipped', totalCents: order.totalCents }; }, }); if (defined.kind === 'err') { throw new Error(`my-pack.lookup-order failed to compile: ${defined.error.message}`); } export default defined.value; ``` * `input` and `output` are Zod schemas (or JSON Schema). The handler gets the parsed input, with defaults and transforms applied. * `defineTool` returns a result instead of throwing. Unwrap it when the file loads, so a bad definition fails at once rather than on the first call. * `ctx` carries `tenantId`, `runId`, `requestId` and an `abortSignal` that fires when the call is cancelled. In a run it also carries `projectId`, the run's project, and `orgId`, the project's org when it has one. The runtime sets both from the run, never from the input, so compare an id in the input with them rather than trusting it. * Python tools/lookup_order.py ```python from typing import Literal from pydantic import BaseModel, Field from kindgi import ToolContext, tool # Stands in for your app's database. ORDERS = { "ord_1001": {"status": "shipped", "totalCents": 4200}, "ord_1002": {"status": "pending", "totalCents": 1250}, } class OrderRef(BaseModel): order_id: str = Field(alias="orderId", pattern=r"^ord_\d+$") class Order(BaseModel): order_id: str = Field(alias="orderId") status: Literal["pending", "shipped", "delivered"] total_cents: int = Field(alias="totalCents") @tool(id="my-pack.lookup-order", mutating=False) def lookup_order(ref: OrderRef, ctx: ToolContext) -> Order: """Looks up an order by id. Returns its status and total in cents.""" order = ORDERS.get(ref.order_id) if order is None: raise LookupError(f"No order {ref.order_id}") print(f"lookup-order {ref.order_id} for tenant {ctx.tenant_id}") return Order(orderId=ref.order_id, **order) ``` * The first parameter's annotation is the input, the return annotation the output: pydantic models here (a `TypedDict` or a dataclass works too). Field aliases are the names on the wire. * The docstring is the description. The version is the pack's, unless you pass `version=`. * `ctx` carries `tenant_id`, `run_id`, `request_id` and `cancellation`. In a run it also carries `project_id`, the run's project, and `org_id`, the project's org when it has one (else `None`). The runtime sets both from the run, never from the input. A `def` handler runs in a worker thread, so blocking calls are fine; an `async def` handler runs on the event loop. In both languages: * The id is `.`, in kebab case. * The description is what a model reads to decide when to call the tool. Say what it does and what it returns. * `mutating: false` declares that the tool only reads. See [Mark a tool read-only](../read-only-tools/). * What the handler logs shows in the `kindgi dev` terminal, prefixed `[pack]`. Save the file. `kindgi dev` indexes it and registers the tool; the next call runs the new code. ## Try it from a flow A flow step can call any tool, which makes a one-step flow the quickest way to run one: * TypeScript flows/check-order/index.ts ```ts import { defineFlow } from '@kindgi/sdk/define'; const defined = defineFlow({ id: 'my-pack.check-order', version: '0.1.0', name: 'Check order', description: 'Looks up one order.', nodes: [ { id: 'lookup', kind: 'tool', ref: 'my-pack.lookup-order', inputMapping: { orderId: { path: 'runInput.orderId' } }, }, ], edges: [ { id: 'e-start', from: '$start', to: 'lookup' }, { id: 'e-end', from: 'lookup', to: '$end' }, ], }); if (defined.kind === 'err') { throw new Error(`my-pack.check-order failed to compile: ${defined.error.message}`); } export default defined.value; ``` ```sh pnpm exec kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}' ``` * Python flows/check_order.py ```python from kindgi import Flow from ..tools.lookup_order import lookup_order check_order = Flow( id="my-pack.check-order", version="0.1.0", name="Check order", description="Looks up one order.", nodes=[ { "id": "lookup", "kind": "tool", "ref": lookup_order, "inputMapping": {"orderId": {"path": "runInput.orderId"}}, } ], edges=[ {"id": "e-start", "from": "$start", "to": "lookup"}, {"id": "e-end", "from": "lookup", "to": "$end"}, ], ) ``` ```sh npx --yes @kindgi/cli@0.1 runs start --flow=my-pack.check-order --input='{"orderId":"ord_1001"}' ``` ```json { "id": "1a9707b6-ae88-47b5-a457-eef7dea2d1fc", … "flowId": "my-pack.check-order", "flowVersion": "0.1.0", "status": "completed", "dryRun": false, … "output": { "status": "shipped", "orderId": "ord_1001", "totalCents": 4200 }, … } ``` A flow without a declared `output` returns its last step's output. The `kindgi dev` terminal shows the handler's log line: ```text [pack] lookup-order ord_1001 for tenant bb22c936-5111-429a-bfce-09650af85492 ``` ## When a call fails Kindgi checks the input before your handler runs. An order id that doesn't match the pattern never reaches it: ```sh kindgi runs start --flow=my-pack.check-order --input='{"orderId":"9999"}' ``` ```json "status": "failed", "failureMessage": "input-validation-failed: Input for tool \"my-pack.lookup-order\" failed validation", ``` An exception in the handler fails the call with its message: ```sh kindgi runs start --flow=my-pack.check-order --input='{"orderId":"ord_9999"}' ``` ```json "status": "failed", "failureMessage": "handler-error: Tool \"my-pack.lookup-order\" handler threw: handler-throw: Handler for tool \"my-pack.lookup-order\" threw: Error: No order ord_9999", ``` What the handler returns is checked against the output schema too; a return value that doesn't fit fails with `output-validation-failed`. ## Check the run's org When your app keeps several customers' data, give each customer its own Kindgi org and project, and start that customer's runs there. A tool then checks what it reads against the run's org, `ctx.orgId`, which the runtime sets from the run. An org id in the input is something a model or a caller wrote, so it proves nothing. * TypeScript ```ts // in tools/lookup-order/index.ts // Stands in for your app's database. Each order belongs to one customer's org. const ORDERS = new Map([ ['ord_1001', { orgId: '1154e250-2a07-491e-b00a-3675b411c411', status: 'shipped', totalCents: 4200 }], ['ord_1002', { orgId: 'feb005e6-6798-47eb-a87a-8feed584d37b', status: 'pending', totalCents: 1250 }], ]); // … handler: async ({ orderId }, ctx) => { const order = ORDERS.get(orderId); // Another org's order is as missing as one that doesn't exist. if (order === undefined || order.orgId !== ctx.orgId) throw new Error(`No order ${orderId}`); return { orderId, status: order.status as 'pending' | 'shipped', totalCents: order.totalCents }; }, ``` * Python ```python # in tools/lookup_order.py # Stands in for your app's database. Each order belongs to one customer's org. ORDERS = { "ord_1001": {"org_id": "1154e250-2a07-491e-b00a-3675b411c411", "status": "shipped", "totalCents": 4200}, "ord_1002": {"org_id": "feb005e6-6798-47eb-a87a-8feed584d37b", "status": "pending", "totalCents": 1250}, } @tool(id="my-pack.lookup-order", mutating=False) def lookup_order(ref: OrderRef, ctx: ToolContext) -> Order: """Looks up an order by id. Returns its status and total in cents.""" order = ORDERS.get(ref.order_id) # Another org's order is as missing as one that doesn't exist. if order is None or order["org_id"] != ctx.org_id: raise LookupError(f"No order {ref.order_id}") return Order(orderId=ref.order_id, status=order["status"], totalCents=order["totalCents"]) ``` A run in the first org's project finds `ord_1001`. Asking it for the second org's order fails as if the order didn't exist: ```json "status": "failed", "failureMessage": "handler-error: Tool \"my-pack.lookup-order\" handler threw: handler-throw: Handler for tool \"my-pack.lookup-order\" threw: Error: No order ord_1002", ``` A project with no org has no `orgId` (`None` in Python), so in it this tool finds no order at all. Start a run in a project with `projectId` on `runs.start`. ## Test it * TypeScript `invokeTool` calls a tool the way the runtime does: input checked, handler called, output checked. It returns a result rather than throwing: tools/lookup-order/index.test.ts ```ts import { invokeTool } from '@kindgi/sdk/define'; import type { TenantId } from '@kindgi/sdk/types'; import { expect, test } from 'vitest'; import lookupOrder from './index.js'; const ctx = { tenantId: 'test-tenant' as TenantId, abortSignal: new AbortController().signal, }; test('finds a shipped order', async () => { const result = await invokeTool(lookupOrder, { orderId: 'ord_1001' }, ctx); expect(result).toEqual({ kind: 'ok', value: { orderId: 'ord_1001', status: 'shipped', totalCents: 4200 }, }); }); test('rejects a malformed id before the handler runs', async () => { const result = await invokeTool(lookupOrder, { orderId: '1001' }, ctx); expect(result.kind === 'err' && result.error.code).toBe('input-validation-failed'); }); ``` ```sh pnpm exec kindgi test ``` ```text ✓ tools/lookup-order/index.test.ts (2 tests) 16ms … Tests 4 passed (4) ``` * Python A tool is still a function: call it with its input model and a test context. tests/test_lookup_order.py ```python import pytest from kindgi import ToolContext from tools.lookup_order import OrderRef, lookup_order def test_finds_a_shipped_order(): order = lookup_order(OrderRef(orderId="ord_1001"), ToolContext.for_test()) assert order.status == "shipped" assert order.total_cents == 4200 def test_an_unknown_order_raises(): with pytest.raises(LookupError): lookup_order(OrderRef(orderId="ord_9999"), ToolContext.for_test()) ``` ```sh uv run pytest ``` ```text ..... [100%] 5 passed in 0.10s ``` Test files aren't indexed: they never become tools. ## Give it to an agent An agent lists the tools it may call: * TypeScript ```ts // in agents/order-desk/index.ts tools: [{ id: 'my-pack.lookup-order' as ToolId, version: '^0.1.0' }], ``` The version is a range: a turn uses the highest registered version that matches it. * Python ```python # in agents/order_desk.py tools=[lookup_order], ``` The `Tool` object pins its version. A ref with a range works too: `{"id": "my-pack.lookup-order", "version": "^0.1.0"}`. With a model that can call tools ([Models](../../models/)), the agent's turn shows the call and its result. Here `my-pack.order-desk`, an agent with this tool, answers with Llama 3.1 on Ollama ([registered as an OpenAI-compatible endpoint](../../models/openai-compatible/)): ```sh kindgi runs start --agent=my-pack.order-desk --input='{"userMessage":"What is the status of order ord_1002?"}' ``` ```json "appended": [ { "role": "user", "content": "What is the status of order ord_1002?", … }, { "role": "agent", "content": { "text": "", "toolCalls": [{ "id": "call_wzydfzdg", "name": "my-pack.lookup-order", "arguments": { "orderId": "ord_1002" } }] }, … }, { "role": "tool", "content": { "status": "pending", "orderId": "ord_1002", "totalCents": 1250 }, … }, { "role": "agent", "content": "The status of order ord_1002 is pending, with a total amount of 12.50 dollars in cents.", … } ], ``` `dev-echo`, the stand-in a new pack answers with, always calls an agent's first tool with `{"message": …}`, so try a tool with any other input from a flow, as above. [Write an agent](../../agents/write-an-agent/) covers the rest of the agent file. # Webhooks > Get a signed run.finished request when a run ends, verify it in your app, and test and replay deliveries. Instead of polling a run, let Kindgi tell your app when it ends. Register a **webhook endpoint**, a URL in your app, and Kindgi sends it a signed `run.finished` request each time a run you started completes, fails or is cancelled. * [Get a webhook when a run finishes](receive-run-finished/): the signing secret, registering the endpoint, and what each request carries. * [Verify a webhook](verify-a-webhook/): check the signature in TypeScript or Python, and deduplicate retries. * [Test and replay deliveries](test-and-replay/): send a test event, see what was delivered, and send a failed delivery again. Requests are signed in the [Standard Webhooks](https://www.standardwebhooks.com) format, so a receiver in another language can use any Standard Webhooks library. # Get a webhook when a run finishes > Store a signing secret, register a webhook endpoint that refers to it by name, and receive a signed run.finished request when a run ends. Kindgi signs each request with a secret that your receiver knows too. The secret lives with your other secrets, and the endpoint refers to it by name: Kindgi never stores it with the endpoint. ## 1. Store a signing secret A signing secret is `whsec_` and the base64 of at least 24 random bytes. Generate one: * TypeScript ```ts import { generateWebhookSecret } from '@kindgi/sdk/webhooks'; console.log(generateWebhookSecret()); ``` * Python ```python from kindgi import webhooks print(webhooks.generate_secret()) ``` Store it as a secret, under a name (the CLI asks for the value and doesn't echo it): ```sh kindgi secrets set ACME_WEBHOOK_SECRET --env=local --scope=tenant ``` ```text Set ACME_WEBHOOK_SECRET at tenant in local. ``` In development, the `local` environment is the pack's env files: this writes `.env.local`, and a line in `.env` works too. In a deployment, store it in that deployment's secrets store, under its environment's name (see [`kindgi secrets`](../../../reference/cli/secrets/)). Your receiver needs the same value, from its own configuration. ## 2. Register the endpoint There's no CLI command for webhook endpoints; register one from your app (or any HTTP client) with the client: * TypeScript ```ts const endpoint = await kindgi.webhookEndpoints.create({ url: 'http://localhost:8787/hooks/kindgi', events: ['run.finished'], secretRef: { envName: 'local', name: 'ACME_WEBHOOK_SECRET' }, filter: { flowIds: ['acme.review-order'] }, // optional }); console.log(endpoint.endpointId, endpoint.url); ``` ```text 5c1d2267-4dc8-4bd6-81e8-940e620405ea http://localhost:8787/hooks/kindgi ``` * Python ```python endpoint = kindgi.webhook_endpoints.create( url="http://localhost:8788/hooks/kindgi", events=["run.finished"], secret_ref={"envName": "local", "name": "ACME_WEBHOOK_SECRET"}, filter={"flowIds": ["acme.review-order"]}, # optional ) print(endpoint.endpoint_id, endpoint.url) ``` ```text 07fec45a-f938-4694-8ac3-f72f38a938a4 http://localhost:8788/hooks/kindgi ``` - **`url`** is your receiver. Use https in production; plain http is for development. In `kindgi dev`, `localhost` reaches your machine, not the runtime's container. Outside development the receiver must be on a public address. A self-hosted runtime whose receiver is on its own private network (the same VPC or compose network) sets `KINDGI_WEBHOOK_PRIVATE_NETWORKS=allow`. That opens RFC 1918, CGNAT and IPv6 unique-local addresses, still over https only. Loopback and the cloud metadata addresses stay refused. A URL the deployment refuses gets `400 webhook-url-refused`, with the reason: `The deployment refuses this URL: private network addresses are not allowed`. - **`events`**: `run.finished`. - **`secretRef`** names the secret: the environment it's in (`local` in development) and its name. Kindgi checks that it exists and is strong enough, and keeps only the name: ```json {"error":{"code":"webhook-secret-not-found","message":"No secret \"NOPE_SECRET\" in \"local\": store it first, then register the endpoint",…}} ``` - **`filter`** narrows which runs reach the endpoint: `flowIds` (any version of these flows), `projectId`, and `includeDryRuns` (dry runs are left out unless it's `true`). Without a filter, every run you start does. Only runs your app or the CLI started send `run.finished`; the runs inside them (an agent step's turn) don't. ## 3. What your endpoint receives When a run of `acme.review-order` ends, your URL gets a `POST`: ```text POST /hooks/kindgi content-type: application/json user-agent: Kindgi-Webhooks/1 webhook-id: d7bc2805-587c-4830-a36f-fddeb0b7a423 webhook-timestamp: 1791058584 webhook-signature: v1,bj8Mj6s7ln2Gd1sDRQi8+Wax2SOFHovsdMIwvNhDrgw= ``` ```json { "id": "d7bc2805-587c-4830-a36f-fddeb0b7a423", "type": "run.finished", "createdAt": "2026-10-03T20:16:24.554Z", "data": { "run": { "id": "c24869c1-9243-4531-920b-d657e05eac0b", "projectId": "bc654965-c4fb-4667-85f1-33e46f4a4f0c", "flowId": "acme.check-order-stock", "flowVersion": "0.1.0", "status": "completed", "dryRun": false, "failureMessage": null, "createdAt": "2026-10-03T20:16:24.465Z", "completedAt": "2026-10-03T20:16:24.554Z", "usage": { "calls": 0, "costUsd": 0, "tokens": { "prompt": 0, "completion": 0, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0 } } } } } ``` * `status` is `completed`, `failed` or `cancelled`; `failureMessage` says why when it failed. * `usage` is what the run's model calls cost, its agent steps included: `calls` (failed ones too), `costUsd` and `tokens`. This flow calls no model, so it's all zeros. See [Cost per run and per customer](../../observability/cost-per-run/). A runtime before 0.1.3 doesn't send it. * The event carries the run's identity and outcome, never its input or output. Fetch the run (`runs.get`) for its output. * `webhook-id` is the event's id, the same on every retry. ## Delivery Answer with a 2xx status. Any other answer, or none, is retried with backoff (a minute later, then five minutes, then longer). A delivery is **at least once**: the same event can arrive twice, so deduplicate on `webhook-id`. Outside development, every attempt checks the receiver's address again, with the runtime's settings at that moment. An attempt to an address it refuses fails with `lastError: "url-refused"` and is retried like any other: set `KINDGI_WEBHOOK_PRIVATE_NETWORKS=allow`, and the queued retries to a private receiver go out. [Verify a webhook](../verify-a-webhook/) has receivers that check the signature and do that. # Test and replay deliveries > Send a test event to your endpoint, see each delivery and how your endpoint answered, and send a failed one again. ## Send a test event `sendTest` (`send_test`) queues a signed `webhook.test` event for an endpoint, to check the receiver end to end: * TypeScript ```ts const queued = await kindgi.webhookEndpoints.sendTest(endpointId); console.log(queued.deliveryId, queued.event.type, queued.status); // A few seconds later await new Promise((resolve) => setTimeout(resolve, 5000)); const { data } = await kindgi.webhookEndpoints.listDeliveries(endpointId, { limit: 1 }); console.log(data[0]?.status, data[0]?.attempts, data[0]?.lastResponseStatus); ``` ```text a0e05ab7-6cff-440b-b291-d03c0f4443c0 webhook.test pending delivered 1 204 ``` * Python ```python import time queued = kindgi.webhook_endpoints.send_test(endpoint_id) print(queued.delivery_id, queued.event.type, queued.status) time.sleep(5) # a few seconds later delivery = kindgi.webhook_endpoints.list_deliveries(endpoint_id, limit=1).data[0] print(delivery.status, delivery.attempts, delivery.last_response_status) ``` ```text 3b75488b-7dd0-4bfe-8abe-83a76b9e1bf2 webhook.test pending delivered 1 204 ``` The test event is signed like any other: ```json { "id": "c1214e78-694f-401d-8b92-e374f0846a88", "type": "webhook.test", "createdAt": "2026-10-03T20:16:35.159Z", "data": { "endpointId": "9c0b9504-c5a6-439f-a5f9-e755f1c5e6e9" } } ``` ## See the deliveries Each event sent to an endpoint is a **delivery**. `listDeliveries` (`list_deliveries`) shows them, newest first, with how the endpoint answered: ```json { "deliveryId": "6ca19f2b-0c57-481e-a768-ef06283898c0", "endpointId": "9c0b9504-c5a6-439f-a5f9-e755f1c5e6e9", "event": { "id": "f2b45827-da3c-4f4e-95ff-9422f0de0e13", "type": "run.finished", … }, "status": "delivered", "attempts": 1, "nextAttemptAt": null, "lastAttemptAt": "2026-10-03T20:15:32.194Z", "lastResponseStatus": 204, "lastError": null, "createdAt": "2026-10-03T20:15:32.095Z", "deliveredAt": "2026-10-03T20:15:32.194Z" } ``` * **`status`**: `pending` (waiting for its next attempt), `delivered` (the endpoint answered 2xx) or `failed` (every attempt failed). * **`lastResponseStatus`** and **`lastError`** say what went wrong: `connection-refused` when nothing listened at the URL, for example, or `url-refused` when the deployment refuses the receiver's address (see [the `url`](../receive-run-finished/#2-register-the-endpoint)). * **`nextAttemptAt`** is when a pending delivery is tried again. Pass `status: 'failed'` (`status="failed"`) to list only the ones that gave up. ## Fix and send again When an endpoint's URL is wrong, its deliveries stay `pending`: the first attempt's `lastError` is `connection-refused` when nothing listens there, and `nextAttemptAt` is a minute later. Fix the URL and send the delivery again, now: * TypeScript ```ts // Point the endpoint at the right URL await kindgi.webhookEndpoints.update(endpointId, { url: 'http://localhost:8787/hooks/kindgi' }); // Send a delivery again now, instead of at its next attempt const again = await kindgi.webhookEndpoints.redeliver(endpointId, deliveryId); console.log(again.status); ``` ```text pending ``` * Python ```python # Point the endpoint at the right URL kindgi.webhook_endpoints.update(endpoint_id, url="http://localhost:8788/hooks/kindgi") # Send a delivery again now, instead of at its next attempt again = kindgi.webhook_endpoints.redeliver(endpoint_id, delivery_id) print(again.status) ``` ```text pending ``` A few seconds later, the delivery is `delivered`. `update` also changes an endpoint's `events`, `filter`, `secretRef` and `description`. ## Stop receiving events `unregister` stops the endpoint. Its deliveries that were still pending fail, and it no longer appears in `list`. The [webhook endpoints reference](../../../reference/python/resources/webhook-endpoints/) lists every operation. # Verify a webhook > Check a webhook's signature against the raw body in TypeScript or Python, reject anything else, and handle each event once. Anyone can send your URL a request. Before you act on one, check that Kindgi signed it with your secret, against the **raw** body, before you parse it. ## A receiver * TypeScript `verifyWebhook` from `@kindgi/sdk/webhooks` (server only: it uses `node:crypto`). This receiver uses Node's `http` module; with a framework, pass the request's headers and its unparsed body the same way. ```ts import { createServer } from 'node:http'; import { verifyWebhook } from '@kindgi/sdk/webhooks'; const secret = process.env.ACME_WEBHOOK_SECRET!; const handled = new Set(); // use your database in production createServer(async (req, res) => { if (req.method !== 'POST' || req.url !== '/hooks/kindgi') { res.writeHead(404).end(); return; } const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks).toString('utf8'); // the raw body, unparsed const result = verifyWebhook({ secret, headers: req.headers, body }); if (result.kind !== 'ok') { console.log('rejected:', result.reason); res.writeHead(401).end(); return; } if (handled.has(result.id)) { res.writeHead(204).end(); // a retry of an event you already handled return; } handled.add(result.id); const event = JSON.parse(body); if (event.type === 'run.finished') { const { id, flowId, status } = event.data.run; console.log('run finished:', id, flowId, status); } else { console.log('event:', event.type); } res.writeHead(204).end(); }).listen(8787, () => console.log('listening on http://localhost:8787/hooks/kindgi')); ``` * Python `kindgi.webhooks.verify`. This receiver uses FastAPI; any framework works the same way: pass the headers and the body's bytes. ```python import os from fastapi import FastAPI, Request, Response from kindgi import webhooks app = FastAPI() secret = os.environ["ACME_WEBHOOK_SECRET"] handled: set[str] = set() # use your database in production @app.post("/hooks/kindgi") async def kindgi_webhook(request: Request) -> Response: body = await request.body() # the raw bytes, unparsed try: delivery = webhooks.verify(secret, request.headers, body) except webhooks.WebhookVerificationError as err: print("rejected:", err.reason) return Response(status_code=401) if delivery.id in handled: return Response(status_code=204) # a retry of an event you already handled handled.add(delivery.id) event = webhooks.parse_event(body) if event.type == "run.finished": run = event.data.run print("run finished:", run.id, run.flow_id, run.status) else: print("event:", event.type) return Response(status_code=204) ``` Run it with `ACME_WEBHOOK_SECRET` set: `uvicorn receiver:app --port 8788`. With the endpoint registered ([Get a webhook when a run finishes](../receive-run-finished/)), a run of `acme.review-order` prints: ```text listening on http://localhost:8787/hooks/kindgi run finished: 9fefc7cf-3249-4ba2-a50e-2795f16c7c67 acme.review-order completed ``` ## What verification checks * **The signature.** `webhook-signature` must hold an HMAC-SHA256 of `..` made with your secret. The comparison is constant-time. * **The time.** `webhook-timestamp` must be within 5 minutes of your clock, so an old request can't be replayed later. * **The headers.** A request without the three `webhook-*` headers fails. A failure says which check failed (`result.reason` in TypeScript, `err.reason` in Python): `missing-headers`, `invalid-timestamp`, `timestamp-out-of-tolerance`, `invalid-secret` or `no-matching-signature`. Answer 401 and do nothing else. Verify the body exactly as it arrived. Parsing it and serializing it again changes the bytes (spacing, key order), and the signature no longer matches. ## Handle each event once Deliveries are at least once: a retry after a timeout can bring an event you already handled. The verified result has the event's id (`result.id`, `delivery.id`), the same on every retry. Record the ids you've handled (in your database, not in memory as in the examples) and answer 2xx to a repeat without acting again. ## Test the receiver without Kindgi Sign a request yourself with the same secret, and with a wrong one: * TypeScript ```ts import { generateWebhookSecret, webhookHeaders } from '@kindgi/sdk/webhooks'; const body = JSON.stringify({ id: 'evt-test-1', type: 'webhook.test', createdAt: new Date().toISOString(), data: { endpointId: 'local' }, }); for (const secret of [process.env.ACME_WEBHOOK_SECRET!, generateWebhookSecret()]) { const headers = webhookHeaders({ secret, id: 'evt-test-1', timestamp: Math.floor(Date.now() / 1000), body, }); if (headers.kind === 'err') throw new Error(headers.error.message); const res = await fetch('http://localhost:8787/hooks/kindgi', { method: 'POST', headers: { 'content-type': 'application/json', ...headers.value }, body, }); console.log(res.status); } ``` ```text 204 401 ``` * Python ```python import json import os import time import httpx from kindgi import webhooks body = json.dumps( {"id": "evt-test-1", "type": "webhook.test", "createdAt": "2026-10-03T12:00:00.000Z", "data": {"endpointId": "local"}} ) for secret in [os.environ["ACME_WEBHOOK_SECRET"], webhooks.generate_secret()]: headers = webhooks.signed_headers(secret, id="evt-test-1", timestamp=int(time.time()), body=body) res = httpx.post( "http://localhost:8788/hooks/kindgi", headers={"content-type": "application/json", **headers}, content=body, ) print(res.status_code) ``` ```text 204 401 ``` The receiver accepts the first and logs `rejected: no-matching-signature` for the second. To test with Kindgi itself, send a `webhook.test` event: [Test and replay deliveries](../test-and-replay/). ## Other languages The format is [Standard Webhooks](https://www.standardwebhooks.com): its libraries verify Kindgi's requests with the same secret. # Deploy > Run Kindgi in your own infrastructure. Kindgi is self-host first: the runtime you run on your machine with `kindgi dev` is the same image you deploy into your own infrastructure, next to your data. Your pack's code runs beside it, in a pack service built from your app. Private preview The runtime image is in private preview: request access at . * **[Self-host with Docker](self-host/):** the runtime and your pack service as containers, with your own Postgres. * **[Operate it](operate/):** health and logs, backups and restores, upgrades, and rotating its tokens and keys. * **[Google Cloud Run](cloud-run/):** the runtime and your pack's service as two Cloud Run services, with Cloud SQL, from Kindgi's Terraform module. * **Kindgi Cloud:** we run it for you. In private preview. # Deploy on Google Cloud Run > Run the Kindgi runtime and your pack's service on Cloud Run, with Cloud SQL, Artifact Registry, Secret Manager and Cloud KMS, from Kindgi's Terraform module. Kindgi's Terraform module runs the runtime and your pack's service as two Cloud Run services in one Google Cloud project, with everything around them. Built end to end on runtime 0.1.1, the first apply takes about ten minutes (most of it Cloud SQL), and a tool call from the runtime to your pack takes 42 ms at the median (100 ms at p95). Private preview The runtime image is in private preview: request access at ## What you'll have * **The runtime** (`kindgi-server`): Kindgi's server, on Cloud Run with its own service account. Public ingress, port 4000 (Cloud Run passes it as `PORT`, which the runtime listens on), always-allocated CPU (so a run started in the background keeps running after its answer), one instance. * **Your pack's service** (`kindgi-pack`): your tools' code. Internal ingress, and IAM-protected: only the runtime's service account may call it, with a Google ID token on every call. * **Cloud SQL** (Postgres 16) for the runtime's data, reached through the Cloud SQL socket. * **Artifact Registry** for both images. The runtime also reads your pack's image from it when you deploy. * **Secret Manager** for the runtime's secrets, and **a Cloud KMS key** that wraps the secrets the runtime stores (model API keys, webhook secrets). * **A VPC** with Direct VPC egress and Cloud NAT, which carries the runtime's calls to your pack's service and out to model APIs and webhooks. If your organization forbids public access to Cloud Run (the `iam.allowedPolicyMemberDomains` policy refuses `allUsers`), set `server_invoker_iam_disabled = true` and `server_public = false`: the runtime's API token still guards every call. Who calls whom: your app calls the runtime (its API token); the runtime calls your pack's service (an ID token), Cloud SQL, and model APIs; `kindgi deploy` calls the runtime with a signed envelope, and the runtime reads the pack's image from Artifact Registry. ## Before you start * **A Google Cloud project**, with an Owner (or Editor plus Security Admin and Secret Manager Admin) to apply the module, and a Cloud Storage bucket for Terraform's state. * **Terraform** 1.6 or later, **gcloud**, **Docker** with `buildx`, and your pack's `kindgi` CLI (0.1.2 or later, for `kindgi key trust`). * **Runtime 0.1.1 or later.** Cloud SQL has no superuser; 0.1.0 can't start on it. * **Access to the runtime image** (`kindgi auth registry`; see [Install](../../start/install/)). * **A license key:** a non-production key covers staging; . * **A pack** that `kindgi build --local --push` builds, with an environment block for this deployment in its config. ## 1. The foundation The services need images and secret values that don't exist yet, so the first apply creates everything else: the APIs, the network, the service accounts, the repository, the KMS key, Cloud SQL and the secrets' containers. The module is `deploy/gcp-cloud-run/` in the [kindgi-sdk repository](https://github.com/kindgi/kindgi-sdk/tree/main/deploy/gcp-cloud-run): copy the folder from the release you run. Its `README.md` lists every variable. `example.tfvars` is the shape on this page (a new VPC); `example-connector.tfvars` runs in a VPC you already have, through a Serverless VPC Access connector. ```sh cp example.tfvars prod.tfvars # project_id, region, kindgi_env, the seed ids, … terraform init -backend-config=bucket= -backend-config=prefix= terraform apply -var-file=prod.tfvars \ -target=google_project_service.apis \ -target=google_compute_router_nat.nat \ -target=google_artifact_registry_repository_iam_member.server_reads_images \ -target=google_kms_crypto_key_iam_member.server_wraps \ -target=google_sql_database.kindgi \ -target=google_secret_manager_secret_iam_member.server_reads \ -target=google_secret_manager_secret_iam_member.pack_reads_token \ -target=google_project_iam_member.server_sql_client ``` Give each deployment its own state `prefix`, never shared with your app's own infrastructure. The plan creates 34 resources. Cloud SQL is the long one, about six minutes. The module sets Cloud SQL's edition to `ENTERPRISE`. A Postgres 16 instance otherwise defaults to `ENTERPRISE_PLUS`, which takes only the `db-perf-optimized-N-*` tiers, and the apply fails with `Invalid Tier (db-f1-micro) for (ENTERPRISE_PLUS) Edition`. ## 2. The images into Artifact Registry Cloud Run pulls from Artifact Registry, not from the runtime's private registry. Copy the runtime image there by digest, with every platform it was built for: ```sh REPO=$(terraform output -raw image_repository) gcloud auth configure-docker "${REPO%%/*}" docker buildx imagetools create --tag "$REPO/runtime:0.1.3" \ quay.io/kindgi/runtime:0.1.3@sha256: ``` The copy keeps the release's digest. (A plain `docker pull`, `tag` and `push` from an Apple silicon machine pushes only the arm64 image, which Cloud Run can't run.) Then build and push your pack's image, signed: ```sh pnpm exec kindgi build --local --push --env prod ``` ```text ✓ Pushed …/acme@sha256:bd7bfe4f… ✓ /app/index.json in the image matches the local index byte for byte ✓ Ed25519 signature over (imageDigest, artifactVersion, indexHash, tenantId, publishedAt) ``` Set `server_image` and `pack_image` in `prod.tfvars` to the two digests (`…@sha256:…`). ## 3. The secrets Make each value here and pipe it straight into Secret Manager: it's never on disk or in Terraform's state, and Kindgi generates none of them. ```sh N=kindgi # name_prefix CONN=$(terraform output -raw sql_connection_name) # Kindgi's database user: a built-in user, not an IAM one. The instance is # named after name_prefix ($N); the database is database_name (kindgi). DBPW=$(openssl rand -hex 24) gcloud sql users create kindgi --instance=$N --password="$DBPW" printf 'postgres://kindgi:%s@/kindgi?host=/cloudsql/%s' "$DBPW" "$CONN" \ | gcloud secrets versions add $N-database-url --data-file=- unset DBPW # The token the runtime and the pack's service share. openssl rand -hex 32 | tr -d '\n' | gcloud secrets versions add $N-pack-service-token --data-file=- # The first API token, and the two keys, base64. printf 'kgi_bt_%s' "$(openssl rand -hex 32)" | gcloud secrets versions add $N-api-token --data-file=- openssl rand 32 | base64 | gcloud secrets versions add $N-secrets-aad-key --data-file=- openssl genpkey -algorithm ed25519 | base64 | gcloud secrets versions add $N-public-token-key --data-file=- # The license key, pasted, never echoed. read -rs LICENSE_KEY && printf '%s' "$LICENSE_KEY" | gcloud secrets versions add $N-license-key --data-file=- && unset LICENSE_KEY ``` **The database user** is a built-in Cloud SQL user. It isn't a superuser, but it has `CREATEROLE` and owns the database through `cloudsqlsuperuser`, which is what the runtime needs. An IAM database user has neither. **Your pack's own secrets:** `kindgi env plan --env prod` lists what your pack needs. Create each secret, add its value, and the module passes them to the pack's service: ```sh pnpm exec kindgi env plan --env prod > /tmp/pack-env.json jq '{pack_env: .env, pack_secret_env: .secret_env}' /tmp/pack-env.json > prod.pack-env.auto.tfvars.json ``` ## 4. The services ```sh terraform apply -var-file=prod.tfvars ``` The pack's service comes up first (22 seconds), and is ready only when every module loaded and every required variable is set. Then the runtime. Its startup log names the pack's service it reached, and how it calls it: ```text Pack service: https://kindgi-pack-…a.run.app — acme (artifact 20261004.1), protocol 2, 3 tools, 1 check Pack service auth: a Google ID token per call (KINDGI_PACK_SERVICE_AUTH) ``` ### How the runtime calls your pack's service The pack's service has internal ingress and requires IAM, so nothing but the runtime reaches it. `KINDGI_PACK_SERVICE_AUTH=google-id-token` makes the runtime mint a Google ID token for the pack's URL, from its own service account, on every call. Cloud Run checks it (the runtime's service account has `roles/run.invoker` on the pack's service, and no one else does), then the pack's service checks the shared pack token. If the pack's service refuses the token, the runtime's startup log says so: `⚠ Pack service at https://… isn't answering (pack-service-unauthorized: The pack service rejected the pack token).` ## 5. Trust your key, and deploy ```sh pnpm exec kindgi key trust acme-prod --url "$(terraform output -raw server_url)" --token "$KINDGI_API_TOKEN" ``` ```text ✓ Trusted acme-prod (sha256:…) ``` ```sh pnpm exec kindgi deploy --env prod --endpoint "$(terraform output -raw server_url)" --token "$KINDGI_API_TOKEN" ``` ```text ✓ POST /v1/deployments → 201 Created artifactVersion: 20261004.1 primitives: 3 tools, 1 guardrail, 1 agent, 2 flows Deploy complete. ``` The runtime read your pack's image from Artifact Registry with its own token (`KINDGI_IMAGE_REGISTRY_AUTH=google`) to check it before registering it. A deploy that's refused (an untrusted key, a missing variable) says what to fix. Fix it and run the same command again. ## 6. Check it ```sh curl "$(terraform output -raw server_url)/health" # {"ok":true} pnpm exec kindgi tools list --url … --token … # your pack's tools pnpm exec kindgi runs start --flow=acme.greet-echo --input='{"name":"Ada"}' --url … --token … ``` In the verification run: every run completed; a new instance of the runtime was ready in about 7 seconds (8 with its first migrations), the pack's service in about 5. ## The IAM it sets up | Who | Role | On | | ----------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | The runtime's service account | `roles/run.invoker` | the pack's service (and no one else) | | | `roles/cloudkms.cryptoKeyEncrypterDecrypter` | the KMS key (the startup check encrypts and decrypts with it) | | | `roles/cloudkms.viewer` | the KMS key; needed only by runtimes before 0.1.3, whose startup check read the key's metadata | | | `roles/artifactregistry.reader` | the repository | | | `roles/cloudsql.client` | the project, conditioned on Kindgi's instance | | | `roles/secretmanager.secretAccessor` | each of its secrets | | | `roles/aiplatform.user`, only with `vertex_ai = true` | the project: [Gemini](#use-gemini) | | The pack's service account | `roles/secretmanager.secretAccessor` | the pack token and your pack's secrets | | | what your tools need | your own resources | ## Use Gemini The runtime can call Gemini on Vertex AI with its own service account, so there's no key to store. Set `vertex_ai = true` and apply: the module turns on the Vertex AI API and grants the runtime's service account `roles/aiplatform.user`. Then register the preset: ```sh pnpm exec kindgi providers register --preset=gemini --project= --models=gemini-2.5-flash --url … --token … ``` ```text ✓ Registered gemini: gemini-2.5-flash ``` Without the role, every model call fails: ```text Model call to gemini (gemini-2.5-flash) failed: {"error":{"code":403,"message":"Permission 'aiplatform.endpoints.predict' denied on resource '//aiplatform.googleapis.com/projects//locations/global/publishers/google/models/gemini-2.5-flash' (or it may not exist). … ``` A new grant can take a minute or two to apply. ## Operate it * **Upgrade:** back up Cloud SQL, copy the new runtime image by digest, set `server_image`, and apply. The new revision takes all the traffic. Migrations only go forward: never run two runtime versions on one database, and go back by restoring the backup. * **Rotate a secret:** add a version, then roll a new revision of each service that reads it (`gcloud run services update … --update-labels=rotated=$(date +%s)`). Never rotate the AAD key this way: every stored secret is bound to it. * **Logs:** Cloud Logging, per service. The runtime's startup lines are in [Operate](../operate/#the-startup-log). ## Tear it down Cloud SQL is protected from deletion: set `database_deletion_protection = false` and apply first. The KMS key ring and key outlive `terraform destroy`: Google Cloud never deletes them. Take them out of Terraform's state, destroy the rest, then schedule the key's versions for destruction: ```sh terraform apply -var-file=prod.tfvars -var=database_deletion_protection=false terraform state rm google_kms_crypto_key.secrets google_kms_key_ring.kindgi terraform destroy -var-file=prod.tfvars gcloud kms keys versions destroy 1 --key=… --keyring=… --location=… ``` If the destroy stops at the subnet (`is already being used by …/addresses/serverless-ipv4-cloudrun-…`), Cloud Run hasn't released its addresses yet: run it again later. ## Limits today * **One runtime instance.** Several aren't supported yet. * **Signing in through OAuth** doesn't work on Cloud SQL yet; API tokens do. # Operate a self-hosted runtime > Check, back up, restore and upgrade a self-hosted Kindgi runtime, and rotate its tokens and keys. This page continues [Self-host Kindgi](../self-host/): the same containers (`kindgi-server`, `kindgi-db`, `kindgi-pack`), the same `kindgi.env` and `pack.env`, and the same pack. Commands that take your API token read it from `$KINDGI_API_TOKEN`. ## Restart the runtime The runtime reads its settings when it starts, so most changes on this page take a restart: ```sh docker stop --time 30 kindgi-server docker rm kindgi-server docker run -d --name kindgi-server --network kindgi \ --add-host registry.localhost:host-gateway \ -p 127.0.0.1:4000:4000 --env-file kindgi.env \ quay.io/kindgi/runtime:0.1.3 ``` On a stop, the runtime stops taking requests and gives the runs it's executing up to 7 seconds to finish, then exits with code 0. `--time 30` gives it that time before Docker kills it. Runs in flight A run that was executing when the runtime stopped, and didn't finish in time, can be left `running`: nothing resumes it. Cancel it, and start it again: ```sh pnpm exec kindgi runs cancel --url http://localhost:4000 --token "$KINDGI_API_TOKEN" ``` To avoid it, restart when no runs are executing. ## Check health and logs ```sh curl -s http://localhost:4000/health ``` ```text {"ok":true} ``` `/health` says the process is up. `/ready` says its database answers too, within two seconds. Neither needs a token: ```sh curl -s http://localhost:4000/ready ``` ```text {"ok":true,"database":"ok"} ``` While Postgres is unreachable, `/ready` answers `503` with `{"ok":false,"database":"unreachable"}`, and `/health` still answers `{"ok":true}`. Use `/ready` for a load balancer's or platform's readiness check. The image's own health check uses it, so `docker ps` shows the runtime's state: ```sh docker ps --filter name=kindgi-server --format 'table {{.Names}}\t{{.Status}}' ``` ```text NAMES STATUS kindgi-server Up 34 seconds (healthy) ``` `docker ps` shows `(unhealthy)` while the database is down, and the runtime's log says why: ```text [ready] the database doesn't answer: host not found (getaddrinfo ENOTFOUND kindgi-db) ``` A route that reads the database answers `500` and names the cause. With Postgres unreachable, `GET /v1/deployments` answers: ```sh curl -s http://localhost:4000/v1/deployments -H "authorization: Bearer $KINDGI_API_TOKEN" ``` ```text {"error":{"code":"internal-server-error","message":"Deployment list failed: deployments: Tenant-scoped query failed: host not found (getaddrinfo ENOTFOUND kindgi-db)","requestId":"req-…"}} ``` ### The startup log The runtime prints what it's running with when it starts (`docker logs kindgi-server`). The lines to check after a change: ```text Token: kgi_bt_…65bb (provided) … Public run tokens: off (no signing key) License: Docs example · non-production · until 2026-11-02 ⚠ The license key expires in 29 days (2026-11-02). Renew it: contact@kindgi.com. Env: production (tool secrets resolve in it) Tenant host access: deployed (stdio MCP endpoints refused; KINDGI_TENANT_HOST_ACCESS) Pack service: http://kindgi-pack:8080 — acme-pack (artifact 20261003.1), protocol 2, 3 tools, 1 check ``` * **`Token`:** the last four characters of the API token it accepts. * **`License`:** who the key is for, its kind and its end date. A warning follows within 30 days of the end. * **`Pack service`:** the pack it reached, with its artifact version. When it can't reach it, a warning takes this line's place, with the reason. ### After a crash When the runtime comes back after stopping without a shutdown, its sweep repairs the runs the stop left mid-way ([If the runtime stops](../../concepts/runs/#if-the-runtime-stops)). Each repair is a line in its log: ```text [run-leases] resuming run (tenant ): its wait resolved and nothing resumed it [run-leases] woke the flow run waiting on child run …: the child had moved on [run-leases] ended run …: its parent run had ended ``` ### Where errors show * **A setting the runtime refuses** (a missing license key, for example): it exits with code 2, and its log says what to fix. * **A database it can't reach while starting:** it exits with code 1, and its log says which database and why, never the password. Here Postgres wasn't running: ```sh docker inspect --format '{{.State.ExitCode}}' kindgi-server docker logs kindgi-server ``` ```text 1 Can't connect to the database at kindgi-db:5432/kindgi: host not found (getaddrinfo ENOTFOUND kindgi-db). Check KINDGI_DATABASE_URL, and that Postgres is up and reachable from here. ``` * **Another failure while starting:** it exits with code 1, and the log's first line starts with `kindgi-runtime: fatal:` and ends with the cause. * **A run that fails:** its `failureMessage`, in `pnpm exec kindgi runs get `. ## Back up Postgres The runtime keeps its state in Postgres: deployments, trusted keys, providers, runs and their journals. Back it up with `pg_dump`, while the runtime runs: ```sh docker exec kindgi-db pg_dump -U kindgi -Fc kindgi > kindgi-backup.dump ``` `-Fc` is pg_dump's custom format, which `pg_restore` reads. The dump holds your runs' inputs and outputs: store it like the database. If the runtime stores model keys ([Models that need a key](../self-host/#8-add-a-model-and-run-a-flow)), the dump holds them encrypted, but not the keys that open them. Back up the secrets store's keys too, apart from the dump: the AAD key, and the local key (with Google Cloud KMS, that key stays in KMS). A restore needs the same ones. A runtime started with another local key stops with exit code 2: ```text The secrets' local key changed: tenant 207f5388-1737-45c0-a02c-b5388ca12da0's secrets were stored under local:42080424744213e5, and this key is local:f6bc4a15bef0d446. Start with the key they were stored under (KINDGI_SECRETS_LOCAL_KEY_PATH or KINDGI_SECRETS_LOCAL_KEY). ``` A different AAD key isn't checked when the runtime starts, so keep the two keys together. ## Restore into a fresh database Start a new Postgres, and restore the backup into it once it accepts connections: ```sh docker run -d --name kindgi-db-restored --network kindgi \ -e POSTGRES_USER=kindgi -e POSTGRES_PASSWORD="$DB_PASSWORD" -e POSTGRES_DB=kindgi \ -v kindgi-db-restored:/var/lib/postgresql/data \ pgvector/pgvector:pg16 docker exec kindgi-db-restored pg_isready -U kindgi -d kindgi docker exec -i kindgi-db-restored pg_restore -U kindgi -d kindgi --no-privileges < kindgi-backup.dump ``` `--no-privileges` leaves out the backup's grants. The runtime grants its database role what it needs every time it starts, and a fresh server doesn't have that role yet. Point the runtime at the new database in `kindgi.env`: ```sh KINDGI_DATABASE_URL=postgres://kindgi:@kindgi-db-restored:5432/kindgi ``` Then [restart the runtime](#restart-the-runtime). A run from before the backup is there: ```sh pnpm exec kindgi runs get --url http://localhost:4000 --token "$KINDGI_API_TOKEN" ``` ```text "status": "completed", … "output": { "reply": "Hello, Ada! It's great to meet you. How can I assist you today?", "greeting": "Hello, Ada!" } ``` ## Upgrade the runtime 1. [Back up Postgres.](#back-up-postgres) 2. Pull the new version, and [restart](#restart-the-runtime) with it, with the same `kindgi.env`: ```sh docker pull quay.io/kindgi/runtime: docker stop --time 30 kindgi-server docker rm kindgi-server docker run -d --name kindgi-server --network kindgi \ --add-host registry.localhost:host-gateway \ -p 127.0.0.1:4000:4000 --env-file kindgi.env \ quay.io/kindgi/runtime: ``` When it starts, the runtime brings the database up to date: it applies the migrations the database doesn't have yet, then serves. On a database that has them all, it applies nothing, so a restart on the same version changes nothing. The log doesn't list them: once the `Kindgi API server listening` lines appear, they're done. If one fails, the runtime exits with code 1, and its log says `kindgi-runtime: fatal: Error: migration failed for …` and why. Migrations only go forward, and an older runtime isn't guaranteed to work on a database a newer one migrated. To go back, [restore the backup](#restore-into-a-fresh-database) you took before the upgrade, and run the older version on it. ## Rotate the API token Make a new token, replace `KINDGI_API_TOKEN` in `kindgi.env` with it, and [restart](#restart-the-runtime): ```sh printf 'kgi_bt_%s\n' "$(openssl rand -hex 32)" ``` The `Token` line in the log now shows the new token's last four characters, and the old token is refused: ```text {"error":{"code":"auth-missing","message":"Bearer token is not recognized","requestId":"req-…"}} ``` `KINDGI_API_TOKEN` is one token: switch your CLI and apps to the new one when you restart. ## Rotate the pack service token The runtime and your pack's service share this token. Make a new one: ```sh openssl rand -base64 32 ``` Put it in both files, `KINDGI_PACK_SERVICE_TOKEN` in `pack.env` and in `kindgi.env`. Then restart the pack's service with the same image: ```sh docker stop --time 30 kindgi-pack docker rm kindgi-pack docker run -d --name kindgi-pack --network kindgi --env-file pack.env \ registry.localhost:5050/acme-pack@sha256: ``` And [restart the runtime](#restart-the-runtime). While the two tokens differ, the pack's service refuses the runtime's calls, and your tools fail. The runtime's log says so: ```text ⚠ Pack service at http://kindgi-pack:8080 isn't answering (pack-service-unauthorized: The pack service rejected the pack token). The server is up; pack tools and checks fail until it answers. ``` Once both sides have the same token, the runtime's calls go through again, with no further restart. ## Rotate the license key Replace `KINDGI_LICENSE_KEY` in `kindgi.env` with the new key, [restart](#restart-the-runtime), and check the `License` line in the log: ```text License: Docs example · non-production · until 2026-11-02 ``` ## Rotate a signing key Rotate under a new key id. A key id stays bound to its public key, and a revoked id can't be trusted again. 1. Create a key, and trust it: ```sh pnpm exec kindgi key create acme-selfhost-2 --env selfhost pnpm exec kindgi key trust acme-selfhost-2 --url http://localhost:4000 --token "$KINDGI_API_TOKEN" ``` 2. Sign your next release with it. In the `selfhost` block of `kindgi.config.ts`: ```ts signingKey: '~/.kindgi/keys/acme-selfhost-2.pem', signerKeyId: 'acme-selfhost-2', ``` Then build and deploy as usual, and run the new image as your pack's service ([step 4](../self-host/#4-run-your-packs-service)). The first line keeps the old key's envelope, to check the revocation below: ```sh cp .kindgi/build/deploy-envelope.json old-envelope.json pnpm exec kindgi build --local --push --env selfhost --artifact-version 20261003.2 pnpm exec kindgi deploy --env selfhost --token "$KINDGI_API_TOKEN" ``` ```text Registering deployment ✓ POST /v1/deployments → 201 Created deploymentId: 2a4677f2-5845-4e05-8630-5f0d01972331 artifactVersion: 20261003.2 ``` The artifact version defaults to today's date with `.1`; this example's second release of the day is `.2`. 3. Revoke the old key: ```sh pnpm exec kindgi key revoke acme-selfhost --reason "rotated to acme-selfhost-2" \ --url http://localhost:4000 --token "$KINDGI_API_TOKEN" ``` ```text ✓ Revoked acme-selfhost ``` A revoked id can't be trusted again: `kindgi key trust` says so, and how to trust another key. From then on, the runtime refuses a deploy signed by the old key. Here, an envelope it signed earlier: ```sh pnpm exec kindgi deploy --env selfhost --from-envelope old-envelope.json \ --idempotency-key redeploy-old --token "$KINDGI_API_TOKEN" ``` ```text Registering deployment ✗ POST /v1/deployments → HTTP 403 code: signer-not-trusted message: Signer key "acme-selfhost" is not on this tenant's trust list ``` Without `--idempotency-key`, the CLI's key is a hash of the envelope. Sending an envelope you already deployed then returns the first answer again (for 24 hours), and deploys nothing. The runtime checks signatures when you deploy. A deployment the revoked key signed keeps running, through restarts too. The revoked key stays listed for audit: ```sh curl -s "http://localhost:4000/v1/signing-keys?includeRevoked=true" -H "authorization: Bearer $KINDGI_API_TOKEN" ``` Each key in the list has its `revokedAt` and `revokedReason`, if it was revoked. ## Rotate the public run token key If browsers [follow runs](../../guides/runs/follow-from-the-browser/), the runtime signs their public run tokens with a key of its own. Make one: ```sh openssl genpkey -algorithm ed25519 -out public-token-signing.pem chmod 600 public-token-signing.pem ``` Give it to the runtime as a file: add this line to `kindgi.env`, and [restart](#restart-the-runtime) with the file mounted: ```sh KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH=/etc/kindgi/public-token-signing.pem ``` ```sh docker run -d --name kindgi-server --network kindgi \ --add-host registry.localhost:host-gateway \ -v "$PWD/public-token-signing.pem:/etc/kindgi/public-token-signing.pem:ro" \ -p 127.0.0.1:4000:4000 --env-file kindgi.env \ quay.io/kindgi/runtime:0.1.3 ``` The file must have mode 0600, and the runtime's user in the container (uid 10001) must be able to read it. The log says: ```text Public run tokens: on (browsers follow runs; no CORS origins) ``` To rotate it, replace the file with a new key (the same two commands), and restart with the same mount. Tokens signed with the old key are refused from then on: ```text {"error":{"code":"auth-missing","message":"Bearer token is not recognized","requestId":"req-…"}} ``` Tokens minted after the restart work as before. # Self-host Kindgi > Run the Kindgi runtime with Docker and your own Postgres, then deploy a pack to it and run a flow. You run four containers on one Docker network: * **the runtime:** Kindgi's server, with its API, agents and flows; * **Postgres;** * **your pack's service:** your tools' code; * **a registry** the runtime reads your pack's image from. You then deploy a pack to it, and run a flow end to end. Everything here runs on one machine with Docker Desktop. On a server the pieces are the same; step 2 says what changes. Private preview The runtime image is in private preview: request access at ## Before you start * **Docker**, and **Node 22.12** or later. * **A pack.** This page uses the sample: ```sh npx @kindgi/cli init acme-pack --template=sample cd acme-pack pnpm install ``` * **A license key.** Outside development mode, the runtime needs one: a free non-production key covers staging and CI, and a production key comes with a commercial license. To get one: . See [Licensing](../../concepts/licensing/). `kindgi dev` needs none. ## 1. Pull the runtime image ```sh docker login quay.io docker pull quay.io/kindgi/runtime:0.1.3 ``` ## 2. Start Postgres and a registry ```sh docker network create kindgi export DB_PASSWORD="$(openssl rand -hex 24)" docker run -d --name kindgi-db --network kindgi \ -e POSTGRES_USER=kindgi -e POSTGRES_PASSWORD="$DB_PASSWORD" -e POSTGRES_DB=kindgi \ -v kindgi-db:/var/lib/postgresql/data \ pgvector/pgvector:pg16 docker run -d --name kindgi-registry -p 127.0.0.1:5050:5000 registry:2 ``` Postgres The runtime needs **Postgres 16 with pgvector**. Its database user needn't be a superuser: it needs `CREATEROLE` and to own the runtime's database (the migrations create the restricted role that tenant queries run as), so managed Postgres such as Cloud SQL, Amazon RDS or AlloyDB works. Create the `vector` extension once, as a user allowed to: `CREATE EXTENSION IF NOT EXISTS vector`. The official image's `POSTGRES_USER` has all of this. With Kindgi 0.1.0, the user had to be a superuser. The runtime verifies your pack's image by reading it from a registry. On one machine with Docker Desktop, the name `registry.localhost` reaches the local registry from two places: * **Docker:** it treats `*.localhost` as loopback, so it pushes there over plain HTTP; * **the runtime's container:** `--add-host` (step 5) maps the name to your machine. On Linux or a server, use your own registry instead, and give the runtime its credentials (`KINDGI_IMAGE_REGISTRY_HOST`, `_USERNAME`, `_PASSWORD`). ## 3. Build, sign and push your pack If your app's code needs a generate step in the image (Prisma's client, for example), set that up first: [What the pack's image needs](../../start/existing-app/#what-the-packs-image-needs). In a Python pack, run each `pnpm exec kindgi` on this page as `npx --yes @kindgi/cli@0.1`, and lock its dependencies first (`uv lock`, or `poetry lock`): the image installs them from the lockfile. Pick a tenant id. The runtime serves this tenant, and the pack's signature names it: ```sh uuidgen | tr 'A-Z' 'a-z' ``` Create a signing key: ```sh pnpm exec kindgi key create acme-selfhost --env selfhost ``` Add an environment for this deployment to `kindgi.config.ts`: ```ts environments: { selfhost: { endpoint: 'http://localhost:4000', registry: 'registry.localhost:5050', tenantId: '', signingKey: '~/.kindgi/keys/acme-selfhost.pem', signerKeyId: 'acme-selfhost', }, }, ``` In a Python pack, the same keys go in `pyproject.toml`: ```toml [tool.kindgi.environments.selfhost] endpoint = "http://localhost:4000" registry = "registry.localhost:5050" tenantId = "" signingKey = "~/.kindgi/keys/acme-selfhost.pem" signerKeyId = "acme-selfhost" ``` Build the image with your own Docker, push it, and sign it: ```sh pnpm exec kindgi build --local --push --env selfhost ``` ```text ✓ Pushed registry.localhost:5050/acme-pack@sha256:faca44e8… ✓ /app/index.json in the image matches the local index byte for byte ✓ Ed25519 signature over (imageDigest, artifactVersion, indexHash, tenantId, publishedAt) Deploy envelope written to …/acme-pack/.kindgi/build/deploy-envelope.json ``` A Python pack's build also says where its dependencies come from: ```text ✓ 22 pack file(s) in the image (the pack root, minus caches, virtualenvs and secrets); dependencies from uv.lock ``` The image is for `linux/amd64` by default. On Apple silicon it runs under emulation; `--platform` picks another. ## 4. Run your pack's service Your tools' code runs in the pack's own container. The runtime calls it with a token both sides share: ```sh echo "KINDGI_PACK_SERVICE_TOKEN=$(openssl rand -base64 32)" > pack.env chmod 600 pack.env docker run -d --name kindgi-pack --network kindgi --env-file pack.env \ registry.localhost:5050/acme-pack@sha256: ``` Its log says it's listening: ```text {"kind":"listening","port":8080,"packId":"acme-pack","artifactVersion":"20261003.1"} ``` ## 5. Configure and start the runtime Make an API token. Your CLI and apps send it as their bearer: ```sh printf 'kgi_bt_%s\n' "$(openssl rand -hex 32)" ``` Put the runtime's settings in `kindgi.env`: ```sh KINDGI_DATABASE_URL=postgres://kindgi:@kindgi-db:5432/kindgi KINDGI_TENANT_ID= KINDGI_API_TOKEN= KINDGI_ENV=production KINDGI_PACK_SERVICE_URL=http://kindgi-pack:8080 KINDGI_PACK_SERVICE_TOKEN= KINDGI_IMAGE_REGISTRY_INSECURE_HOSTS=registry.localhost:5050 KINDGI_LICENSE_KEY= ``` It holds the API token and the license key, so keep it to yourself: `chmod 600 kindgi.env`. Every setting is in the [environment variable reference](../../reference/env-vars/). Three are worth knowing now: * **`KINDGI_ENV`** names the environment your tools' secrets resolve in. * **The port** is 4000 unless something sets another. The runtime takes the first that's set: `KINDGI_API_PORT`, then the platform's `PORT` (Cloud Run, Render, Heroku and Fly set it), then 4000 ([`KINDGI_API_PORT`](../../reference/env-vars/#kindgi_api_port) has the whole order). * **`KINDGI_TENANT_HOST_ACCESS`** isn't set here, so it's `deployed`, the default outside development. It refuses an MCP endpoint that would run a command on the runtime's host (`stdio`). Run MCP servers over HTTP instead. `local` allows it; set that only on a machine where everyone with an API token may run commands. Start the runtime: ```sh docker run -d --name kindgi-server --network kindgi \ --add-host registry.localhost:host-gateway \ -p 127.0.0.1:4000:4000 --env-file kindgi.env \ quay.io/kindgi/runtime:0.1.3 ``` ## 6. Check it ```sh curl -s http://localhost:4000/ready ``` ```text {"ok":true,"database":"ok"} ``` `/ready` answers once the runtime is up and its database answers (`/health` checks only the process; see [Operate](../operate/#check-health-and-logs)). Its log names what it's running with: ```sh docker logs kindgi-server ``` ```text Kindgi API server listening on http://localhost:4000 Tenant: 8f34192d-53bb-4fc2-bfb8-9094157b2404 Token: kgi_bt_…abb1 (provided) … Deployments: on (signed images, /v1/deployments) License: Docs example · non-production · until 2026-11-02 ⚠ The license key expires in 29 days (2026-11-02). Renew it: contact@kindgi.com. Env: production (tool secrets resolve in it) Tenant host access: deployed (stdio MCP endpoints refused; KINDGI_TENANT_HOST_ACCESS) Pack service: http://kindgi-pack:8080 — acme-pack (artifact 20261003.1), protocol 2, 3 tools, 1 check ``` Without `KINDGI_LICENSE_KEY`, the runtime doesn't start. It exits with code 2 and says: ```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 within 30 days of expiry adds the warning under the license line, as this example key does. ## 7. Trust your key and deploy The runtime deploys only images signed by a key its tenant trusts. Trust yours: ```sh export KINDGI_API_TOKEN= pnpm exec kindgi key trust acme-selfhost --url http://localhost:4000 --token "$KINDGI_API_TOKEN" ``` ```text ✓ Trusted acme-selfhost (sha256:7bfbd97be075d796eb24f80d) ``` The fingerprint is the one `kindgi key create` printed. Then deploy: ```sh pnpm exec kindgi deploy --env selfhost --token "$KINDGI_API_TOKEN" ``` ```text "status": 201, "outcome": "created", … "primitives": { "tools": 3, "guardrails": 1, "agents": 1, "flows": 1 }, ``` The runtime checked the signature, read the pack's index from the image, and registered its tools, agents and flows. ## 8. Add a model, and run a flow The sample's agent needs a model that can call tools. Any OpenAI-compatible endpoint whose model supports tool calling works. This example uses Ollama on the same machine (after `ollama pull llama3.1`), which needs no key. The runtime reaches it at `host.docker.internal`: Docker Desktop provides that name, and on Linux, add `--add-host host.docker.internal:host-gateway` to the runtime's `docker run`. Save it as `ollama.json`: ```json { "adapter_id": "@kindgi/adapter-model-openai-compat", "adapter_config": { "baseURL": "http://host.docker.internal:11434/v1" }, "metadata": { "id": "ollama-local", "region": "unspecified", "models": [{ "name": "llama3.1", "contextWindow": 131072, "features": ["tool-use"], "cost": { "promptUsdPer1kTokens": 0, "completionUsdPer1kTokens": 0 } }] } } ``` ```sh pnpm exec kindgi providers register --spec=@ollama.json --url http://localhost:4000 --token "$KINDGI_API_TOKEN" pnpm exec kindgi runs start --flow=acme-pack.echo-flow --input='{"name":"Ada"}' --url http://localhost:4000 --token "$KINDGI_API_TOKEN" ``` ```text "status": "completed", … "output": { "reply": "…", "greeting": "Hello, Ada!" } ``` The flow's tool step ran in your pack's container, and its agent step answered with the model; what the reply says depends on the model. A first call can outlast the agent's time budget while the model loads; run it again. Models that need a key A provider that needs an API key (Anthropic, OpenAI, Gemini) keeps it in the runtime's secrets store, in Postgres (`KINDGI_SECRETS_BACKEND=postgres`). The store needs two keys, each 32 random bytes (`openssl rand 32`): * **The AAD key,** which every stored secret is bound to: a file `KINDGI_SECRETS_AAD_KEY_PATH` names, or base64 in `KINDGI_SECRETS_AAD_KEY`. The same on every replica. * **The key that wraps each secret's own key,** held in one of two places: * **Google Cloud KMS:** `KINDGI_SECRETS_BACKEND_KMS=gcp`, with its settings. * **A local key, on a single host:** `KINDGI_SECRETS_BACKEND_KMS=libsodium` and `KINDGI_SECRETS_LOCAL_KEY_ACK=single-node`, with the key in a file `KINDGI_SECRETS_LOCAL_KEY_PATH` names, or base64 in `KINDGI_SECRETS_LOCAL_KEY`. It must differ from the AAD key. The startup log then says `Secrets: Postgres, with a local key from /etc/kindgi/secrets-local.key (single-node; not a cloud KMS; KINDGI_SECRETS_LOCAL_KEY_ACK)`. Mount key files into the container like the signing key in [Operate](../operate/#rotate-the-public-run-token-key): mode 0600, readable by the runtime's user (uid 10001). Back both keys up apart from the database: if either is lost, every stored secret is lost. Without them the runtime doesn't start, and says which setting is missing. The settings are in the [reference](../../reference/env-vars/). A keyless endpoint (Ollama, vLLM) needs no secrets store. ## Clean up ```sh docker rm -f kindgi-server kindgi-pack kindgi-registry kindgi-db docker volume rm kindgi-db docker network rm kindgi ``` ## Next [Operate a self-hosted runtime](../operate/): health and logs, backups, upgrades, and rotating its tokens and keys. ## On Google Cloud Run [Deploy on Google Cloud Run](../cloud-run/) runs the same pieces as Cloud Run services, with Cloud SQL, Artifact Registry and Cloud KMS. # Kindgi documentation > Write tools, agents and flows in your app's own TypeScript or Python. Kindgi runs them durably, on your infrastructure, and records every step. ## Your coding agent already knows Kindgi `kindgi init` gives your project Kindgi's skills: instructions your coding agent loads when the task calls for them. Describe what you want in your own words. The agent writes the code, runs it, reads what happened and fixes it. It knows your version The skills ship with the CLI, written for the Kindgi version you installed. Claude Code loads the one the task needs. It writes the code Tools, agents, guardrails and flows, in your project's TypeScript or Python. It runs Kindgi `kindgi dev` to start Kindgi on your machine, `kindgi runs start` to try what it wrote, the run's journal to find what went wrong. You still decide You name your pack and its agents, and you provide the credentials, such as your model provider's API key. The agent never writes them into code. [How your coding agent knows Kindgi](start/coding-agents/) ## The documentation [Start](start/)What Kindgi is, installing it, and your first pack in TypeScript or Python. [Tutorials](tutorials/)Build something real, step by step, in TypeScript or Python. [Guides](guides/)How to do one thing: tools, agents, models, flows, runs, webhooks, approvals, secrets. [Concepts](concepts/)How Kindgi works: packs, runs and the journal, the security model, licensing. [Reference](reference/)Every API route, SDK export, CLI command, setting and event. [Contributing](contributing/)How the SDK repository is organized and the rules for changes. # Tutorials > Build something with Kindgi, step by step. Each tutorial builds one working thing from an empty directory. The steps that need no model are run by our CI against the version of Kindgi these docs describe, so they work as written; the steps with a real model were run by hand for this version. * **Build a support desk** in [TypeScript](support-desk-typescript/) or [Python](support-desk-python/): tools over your own code, an agent with a typed answer, and a flow that acts on it. # Build a support desk (Python) > Tools over your own code, an agent with a typed answer, and a flow that acts on it, in one Python pack. You'll build the first line of a support desk: an agent reads a ticket, classifies it, sets its priority and drafts a reply, and a flow escalates the urgent tickets and records a reply on the rest. Along the way you'll write two tools over your own code (one that reads, one that writes), an agent with a typed answer, and a flow that branches on that answer. You need Python 3.11 or later with uv, Node 22.12 (for the CLI), Docker with access to the runtime image ([Install](../../start/install/)), and an Anthropic API key for step 6. About 20 minutes. ## 1. Create the pack ```sh npx --yes @kindgi/cli@0.1 init acme-desk --template=python cd acme-desk rm tools/echo.py tools/greet.py agents/echo_agent.py flows/echo_flow.py guardrails/response_not_empty.py tests/test_tools.py mkdir support && touch support/__init__.py uv sync ``` The `python` template comes with a small example; you removed it, and kept the folders (`tools/`, `agents/`, `guardrails/`, `flows/`) and the `[tool.kindgi]` tables in `pyproject.toml`. `support/` is where your code goes. 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): ```sh printf '%s\n' .venv/ __pycache__/ .kindgi/ .kindgirc.json .env '.env.*' >> .gitignore ``` ## 2. Your code The tools will work on tickets. In your app they'd come from a database; here it's a small module of the pack's own, so the tutorial runs anywhere: support/tickets.py ```python """Your app's code: here, an in-memory ticket store.""" from dataclasses import dataclass, field @dataclass class Ticket: id: str customer: str subject: str body: str notes: list[str] = field(default_factory=list) _TICKETS = { "T-100": Ticket("T-100", "Ada", "Charged twice", "My card was charged twice for the March invoice."), "T-101": Ticket("T-101", "Grace", "Locked out", "Production is down for my team: nobody can log in since this morning."), } def get_ticket(ticket_id: str) -> Ticket | None: return _TICKETS.get(ticket_id) def add_note(ticket_id: str, note: str) -> int: ticket = _TICKETS[ticket_id] ticket.notes.append(note) return len(ticket.notes) ``` ## 3. Two tools One file, two tools. `get_ticket` changes nothing, and says so with `mutating=False`; `add_note` writes, so it doesn't, and `effects` names what it writes: tools/tickets.py ```python from pydantic import BaseModel, Field from kindgi import tool from support.tickets import add_note as store_note from support.tickets import get_ticket as find_ticket class TicketRef(BaseModel): ticket_id: str = Field(alias="ticketId") class TicketView(BaseModel): found: bool customer: str | None = None subject: str | None = None body: str | None = None class Note(BaseModel): ticket_id: str = Field(alias="ticketId") note: str = Field(min_length=1) class NoteCount(BaseModel): notes: int @tool(id="acme-desk.get-ticket", mutating=False) def get_ticket(input: TicketRef) -> TicketView: """Reads a support ticket by its id (T-123).""" ticket = find_ticket(input.ticket_id) if ticket is None: return TicketView(found=False) return TicketView(found=True, customer=ticket.customer, subject=ticket.subject, body=ticket.body) @tool(id="acme-desk.add-note", effects=[{"kind": "writes", "resource": "tickets"}]) def add_note(input: Note) -> NoteCount: """Adds an internal note to a ticket.""" return NoteCount(notes=store_note(input.ticket_id, input.note)) ``` The pack's root is on `sys.path`, so a tool imports your code by its package name (`support.tickets`). ## 4. An agent with a typed answer agents/triage.py ```python from typing import Literal from pydantic import BaseModel from kindgi import Agent from ..tools.tickets import get_ticket class Triage(BaseModel): category: Literal["billing", "access", "other"] priority: Literal["low", "normal", "urgent"] reply: str triage = Agent( id="acme-desk.triage", version="0.1.0", name="Triage", description="Classifies a support ticket and drafts a first reply.", instructions=( "You triage support tickets. Read the ticket with `acme-desk.get-ticket`, then answer " "with its category (billing, access or other), its priority (low, normal or urgent) " "and a short, friendly first reply to the customer. " "A ticket is urgent when the customer cannot work at all." ), capabilities=[{"needs": [{"feature": "tool-use"}]}], tools=[get_ticket], output=Triage, budget={"maxSteps": 4, "maxCostUsd": 0.05, "maxWallMs": 60_000}, ) ``` `output` is the answer's shape. The model has to answer with JSON that fits it; an answer that doesn't is sent back once with what's wrong, and the turn fails if it still doesn't fit. ## 5. A flow that acts on the answer flows/handle_ticket.py ```python from kindgi import Flow from ..agents.triage import triage from ..tools.tickets import add_note IS_URGENT = { "op": "eq", "left": {"path": "nodeOutputs.triage.output.priority"}, "right": {"literal": "urgent"}, } handle_ticket = Flow( id="acme-desk.handle-ticket", version="0.1.0", name="Handle a ticket", description="Triages a ticket, then escalates it or records the drafted reply.", nodes=[ { "id": "triage", "kind": "agent", "ref": triage, "inputMapping": {"ticketId": {"path": "runInput.ticketId"}}, }, { "id": "escalate", "kind": "tool", "ref": add_note, "inputMapping": { "ticketId": {"path": "runInput.ticketId"}, "note": {"literal": "Escalated to the on-call engineer."}, }, }, { "id": "record-reply", "kind": "tool", "ref": add_note, "inputMapping": { "ticketId": {"path": "runInput.ticketId"}, "note": {"path": "nodeOutputs.triage.output.reply"}, }, }, ], edges=[ {"id": "start", "from": "$start", "to": "triage"}, {"id": "urgent", "from": "triage", "to": "escalate", "when": IS_URGENT}, {"id": "not-urgent", "from": "triage", "to": "record-reply", "when": {"op": "not", "child": IS_URGENT}}, {"id": "escalated", "from": "escalate", "to": "$end"}, {"id": "recorded", "from": "record-reply", "to": "$end"}, ], output={ "mapping": { "category": {"path": "nodeOutputs.triage.output.category"}, "priority": {"path": "nodeOutputs.triage.output.priority"}, "reply": {"path": "nodeOutputs.triage.output.reply"}, }, }, ) ``` * The `triage` step runs the agent on the run's `ticketId`. * Two edges leave it, each with a `when`: `escalate` runs when the agent said `urgent`, `record-reply` when it didn't. Both call `add_note`, with different inputs. * `output` is what the run returns: three fields of the agent's answer. Check what Kindgi finds in the pack: ```sh uv run python -m kindgi.pack index --pack-dir . ``` ```text "counts": { "tools": 2, "guardrails": 0, "agents": 1, "flows": 1 }, … "fileErrors": [] ``` ## 6. Run it ```sh npx --yes @kindgi/cli@0.1 dev ``` ```text ✓ loaded: 2 tools, 0 guardrails, 1 agents, 1 flows ``` Leave it running, and continue in a second terminal, in `acme-desk`. The agent needs a real model for its typed answer (the stand-in `kindgi dev` starts with can't produce one). Store your Anthropic key (you're prompted for it; it isn't echoed) and 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 ``` ## 7. Triage a ticket Run the agent on its own first: ```sh npx --yes @kindgi/cli@0.1 runs start --agent=acme-desk.triage --input='{"userMessage":"Triage ticket T-101"}' ``` ```text "status": "completed", … "output": { "reply": "Hi Grace, thank you for reaching out. I'm sorry to hear your team is locked out of production. …", "category": "access", "priority": "urgent" }, ``` The agent called `get_ticket`, read the ticket, and answered in the shape you gave it. Your reply will read differently: it's the model's. ## 8. Handle tickets Now the flow, once for each ticket: ```sh npx --yes @kindgi/cli@0.1 runs start --flow=acme-desk.handle-ticket --input='{"ticketId":"T-100"}' npx --yes @kindgi/cli@0.1 runs start --flow=acme-desk.handle-ticket --input='{"ticketId":"T-101"}' ``` The first returns `"category": "billing"`, `"priority": "normal"` and a drafted reply; the second `"category": "access"`, `"priority": "urgent"`. The journal shows which way each run went. For `T-101`: ```sh npx --yes @kindgi/cli@0.1 runs journal ``` ```text "kind": "step.completed", "nodeId": "triage", … "kind": "step.completed", "nodeId": "escalate", ``` `T-100` went through `record-reply` instead, and its ticket now has the drafted reply as a note. ## Next * [Call it from your app](../../start/existing-app/#starting-runs-from-your-app): start the flow from your own code and read its output. * [Ask before a tool runs](../../guides/approvals/ask-before-a-tool-runs/): have a person approve the escalation first. # Build a support desk (TypeScript) > Tools over your own code, an agent with a typed answer, and a flow that acts on it, in one TypeScript pack. You'll build the first line of a support desk: an agent reads a ticket, classifies it, sets its priority and drafts a reply, and a flow escalates the urgent tickets and records a reply on the rest. Along the way you'll write two tools over your own code (one that reads, one that writes), an agent with a typed answer, and a flow that branches on that answer. You need Node 22.12, Docker with access to the runtime image ([Install](../../start/install/)), and an Anthropic API key for step 6. About 20 minutes. ## 1. Create the pack ```sh npx @kindgi/cli init acme-desk --template=minimal cd acme-desk pnpm install ``` The `minimal` template gives you the pack's folders (`tools/`, `agents/`, `guardrails/`, `flows/`) and nothing in them. 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): ```sh printf '%s\n' node_modules/ dist/ .kindgi/ .kindgirc.json '*.tsbuildinfo' .env .env.local >> .gitignore ``` ## 2. Your code The tools will work on tickets. In your app they'd come from a database; here it's a small module of the pack's own, so the tutorial runs anywhere: src/tickets.ts ```ts // Your app's code: here, an in-memory ticket store. export interface Ticket { id: string; customer: string; subject: string; body: string; notes: string[]; } const tickets = new Map([ ['T-100', { id: 'T-100', customer: 'Ada', subject: 'Charged twice', body: 'My card was charged twice for the March invoice.', notes: [] }], ['T-101', { id: 'T-101', customer: 'Grace', subject: 'Locked out', body: 'Production is down for my team: nobody can log in since this morning.', notes: [] }], ]); export function getTicket(id: string): Ticket | undefined { return tickets.get(id); } export function addNote(id: string, note: string): number { const ticket = tickets.get(id); if (!ticket) throw new Error(`No ticket ${id}`); ticket.notes.push(note); return ticket.notes.length; } ``` ## 3. Two tools One tool reads a ticket. It changes nothing, and says so with `mutating: false`: tools/get-ticket/index.ts ```ts import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; import { getTicket } from '../../src/tickets.js'; const defined = defineTool({ id: 'acme-desk.get-ticket' as ToolId, description: 'Reads a support ticket by its id (T-123).', version: '0.1.0', input: z.object({ ticketId: z.string() }), output: z.object({ found: z.boolean(), customer: z.string().optional(), subject: z.string().optional(), body: z.string().optional(), }), effects: [], mutating: false, handler: async ({ ticketId }) => { const ticket = getTicket(ticketId); return ticket ? { found: true, customer: ticket.customer, subject: ticket.subject, body: ticket.body } : { found: false }; }, }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` The other adds a note to a ticket. It writes, so it doesn't say `mutating: false`, and `effects` names what it writes: tools/add-note/index.ts ```ts import { defineTool } from '@kindgi/sdk/define'; import type { ToolId } from '@kindgi/sdk/types'; import { z } from 'zod'; import { addNote } from '../../src/tickets.js'; const defined = defineTool({ id: 'acme-desk.add-note' as ToolId, description: 'Adds an internal note to a ticket.', version: '0.1.0', input: z.object({ ticketId: z.string(), note: z.string().min(1) }), output: z.object({ notes: z.number() }), effects: [{ kind: 'writes', resource: 'tickets' }], handler: async ({ ticketId, note }) => ({ notes: addNote(ticketId, note) }), }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` A tool imports your code like any other module (with the `.js` extension the pack's TypeScript settings ask for). ## 4. An agent with a typed answer 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-desk.triage' as AgentId, version: '0.1.0' as Semver, name: 'Triage', description: 'Classifies a support ticket and drafts a first reply.', instructions: [ 'You triage support tickets. Read the ticket with `acme-desk.get-ticket`, then answer', 'with its category (billing, access or other), its priority (low, normal or urgent)', 'and a short, friendly first reply to the customer.', 'A ticket is urgent when the customer cannot work at all.', ].join(' '), capabilities: [{ needs: [{ feature: 'tool-use' as const }] }], tools: [{ id: 'acme-desk.get-ticket', version: '^0.1.0' }], retrieval: [], guardrails: [], output: { schema: z.object({ category: z.enum(['billing', 'access', 'other']), priority: z.enum(['low', 'normal', 'urgent']), reply: 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` is the answer's shape. The model has to answer with JSON that fits it; an answer that doesn't is sent back once with what's wrong, and the turn fails if it still doesn't fit. ## 5. A flow that acts on the answer flows/handle-ticket/index.ts ```ts import { defineFlow } from '@kindgi/sdk/define'; const isUrgent = { op: 'eq', left: { path: 'nodeOutputs.triage.output.priority' }, right: { literal: 'urgent' }, } as const; const defined = defineFlow({ id: 'acme-desk.handle-ticket', version: '0.1.0', name: 'Handle a ticket', description: 'Triages a ticket, then escalates it or records the drafted reply.', nodes: [ { id: 'triage', kind: 'agent', ref: 'acme-desk.triage', inputMapping: { ticketId: { path: 'runInput.ticketId' } }, }, { id: 'escalate', kind: 'tool', ref: 'acme-desk.add-note', inputMapping: { ticketId: { path: 'runInput.ticketId' }, note: { literal: 'Escalated to the on-call engineer.' }, }, }, { id: 'record-reply', kind: 'tool', ref: 'acme-desk.add-note', inputMapping: { ticketId: { path: 'runInput.ticketId' }, note: { path: 'nodeOutputs.triage.output.reply' }, }, }, ], edges: [ { id: 'start', from: '$start', to: 'triage' }, { id: 'urgent', from: 'triage', to: 'escalate', when: isUrgent }, { id: 'not-urgent', from: 'triage', to: 'record-reply', when: { op: 'not', child: isUrgent } }, { id: 'escalated', from: 'escalate', to: '$end' }, { id: 'recorded', from: 'record-reply', to: '$end' }, ], output: { mapping: { category: { path: 'nodeOutputs.triage.output.category' }, priority: { path: 'nodeOutputs.triage.output.priority' }, reply: { path: 'nodeOutputs.triage.output.reply' }, }, }, }); if (defined.kind === 'err') throw new Error(defined.error.message); export default defined.value; ``` * The `triage` step runs the agent on the run's `ticketId`. * Two edges leave it, each with a `when`: `escalate` runs when the agent said `urgent`, `record-reply` when it didn't. Both call `add-note`, with different inputs. * `output` is what the run returns: three fields of the agent's answer. Check that everything compiles: ```sh pnpm exec tsc --noEmit ``` ## 6. Run it ```sh pnpm exec kindgi dev ``` ```text ✓ loaded: 2 tools, 0 guardrails, 1 agents, 1 flows ``` Leave it running, and continue in a second terminal, in `acme-desk`. The agent needs a real model for its typed answer (the stand-in `kindgi dev` starts with can't produce one). Add your Anthropic key and register the provider: ```sh echo 'ANTHROPIC_API_KEY=sk-ant-…' >> .env pnpm exec kindgi providers register --preset=anthropic ``` ## 7. Triage a ticket Run the agent on its own first: ```sh pnpm exec kindgi runs start --agent=acme-desk.triage --input='{"userMessage":"Triage ticket T-101"}' ``` ```text "status": "completed", … "output": { "reply": "Hi Grace,\n\nThank you for reaching out. I'm sorry to hear your team is locked out and production is down. …", "category": "access", "priority": "urgent" }, ``` The agent called `get-ticket`, read the ticket, and answered in the shape you gave it. Your reply will read differently: it's the model's. ## 8. Handle tickets Now the flow, once for each ticket: ```sh pnpm exec kindgi runs start --flow=acme-desk.handle-ticket --input='{"ticketId":"T-100"}' pnpm exec kindgi runs start --flow=acme-desk.handle-ticket --input='{"ticketId":"T-101"}' ``` ```text "output": { "reply": "Hi Ada,\n\nThank you for reaching out! I'm sorry to hear that your card was charged twice …", "category": "billing", "priority": "normal" }, … "output": { "reply": "Hi Grace,\n\nThank you for reaching out, and I'm sorry to hear your team is locked out of production! …", "category": "access", "priority": "urgent" }, ``` The journal shows which way each run went. For `T-101`: ```sh pnpm exec kindgi runs journal ``` ```text "kind": "step.completed", "nodeId": "triage", … "kind": "step.completed", "nodeId": "escalate", ``` `T-100` went through `record-reply` instead, and its ticket now has the drafted reply as a note. ## Next * [Call it from your app](../../start/existing-app/#starting-runs-from-your-app): start the flow from your own code and read its output. * [Ask before a tool runs](../../guides/approvals/ask-before-a-tool-runs/): have a person approve the escalation first. # Reference > Every API route, SDK export, CLI command, setting and event. The reference is generated from Kindgi's own sources (the API spec, the SDKs, the CLI and the schemas), so it matches the release it documents. * **[HTTP API](api/):** every route, its parameters, request and response bodies, and the errors it answers, with a request example for each. From the API's OpenAPI specification. * **[TypeScript](typescript/sdk/):** `@kindgi/sdk`, by the path you import from: * `@kindgi/sdk/define`: defining tools, agents, flows and checks; * `@kindgi/sdk/client`: the API client, its resources and its errors; * `@kindgi/sdk/types`: IDs and shared types; * `@kindgi/sdk/webhooks`: verifying the webhooks Kindgi sends (server only). Everything is also exported from `@kindgi/sdk` itself. * **[Python](python/):** the `kindgi` package: authoring (`kindgi`), the API client (`kindgi.client`) with every `client.` and the API's models, `kindgi.webhooks`, and `python -m kindgi.pack`. * **[CLI](cli/):** every `kindgi` command, its flags and its subcommands. * **[Environment variables](env-vars/):** every `KINDGI_*` variable the runtime and the pack service read. * **[Packages](packages/):** every public `@kindgi/*` package: what it's for and how to use it. * **[JSON Schemas](schemas/):** the documents Kindgi reads and writes (the pack index, the pack protocol, flows, tools, events, …), field by field. # CLI > Every kindgi command, its flags and its subcommands. The `kindgi` command-line interface (`@kindgi/cli`). Generated from the CLI's own command definitions, so it matches this version. ## Commands * [`kindgi auth`](auth/): Manage local auth: the API's URL and token, and the runtime image's registry. * [`kindgi init`](init/): Scaffold a new Kindgi pack repo, or add Kindgi to an existing Node.js project (auto-detected when a `package.json` is present and no pack-name is given). * [`kindgi dev`](dev/): Run the Kindgi runtime as a container + hot-reload the pack under cwd. * [`kindgi build`](build/): Bundle + build + sign a pack image. Emits deploy-envelope.json for `kindgi deploy`. `--local` builds the image with this machine's Docker instead: no build service, no signing; with `--push`, it pushes the image, signs it, and writes the envelope. * [`kindgi deploy`](deploy/): Deploy a signed pack image to Kindgi. Reads deploy-envelope.json (or runs `kindgi build` inline) + POSTs to /v1/deployments. * [`kindgi test`](test/): Run the pack's tests via vitest. Ergonomic wrapper over `vitest run`. * [`kindgi env`](env/): Manage per-environment values that resolve into `needs.env` at deploy time. * [`kindgi secrets`](secrets/): Manage per-environment secrets via the /v1/secrets/\* wire. * [`kindgi key`](key/): Manage Ed25519 signing keys: the local pairs under \~/.kindgi/keys/, and the runtime's trust list. * [`kindgi mcp`](mcp/): Manage MCP server entries in .mcp.json (add / list / remove / presets). * [`kindgi mcp-launch`](mcp-launch/): Spawn an MCP server subprocess with secrets injected from Kindgi. Invoked by MCP clients via .mcp.json — not for direct use. * [`kindgi runs`](runs/): Manage kernel runs — the single execution primitive. * [`kindgi agents`](agents/): Manage agent registrations. * [`kindgi tools`](tools/): Manage tool registrations. * [`kindgi guardrails`](guardrails/): Manage guardrail registrations. * [`kindgi approvals`](approvals/): Review HITL approvals. * [`kindgi reviewers`](reviewers/): Manage HITL reviewers. * [`kindgi providers`](providers/): Manage model providers. * [`kindgi adapters`](adapters/): Manage adapter lifecycle (prepare / warmup). Read-side lives on `kindgi providers`. * [`kindgi skills`](skills/): Manage the pack's `.claude/skills/` directory. * [`kindgi feedback`](feedback/): Report framework friction Claude Code diagnosed — bugs, wire schema gaps, misleading errors, UX cliffs. * [`kindgi health`](health/): Ping the API server (GET /health). * [`kindgi version`](version/): Print CLI + SDK versions (and API version if reachable). ## Global flags Every command takes these: * `--help` (`-h`): Show help. * `--json`: Output pretty-printed JSON (the default). * `--quiet`: Print nothing: the exit code alone reports the result. * `--raw`: Output compact, one-line JSON. * `--table`: Output a table, for commands that list. * `--token `: The API token. Overrides `KINDGI_API_TOKEN` and the config files. * `--url `: The Kindgi API's URL. Overrides `KINDGI_API_URL` and the config files. * `--verbose` (`-v`): Show more detail when a command fails. * `--version`: Print the CLI's version. # kindgi adapters > Manage adapter lifecycle (prepare / warmup) Manage adapter lifecycle (prepare / warmup). Read-side lives on `kindgi providers`. ## `kindgi adapters prepare` Pre-warm an adapter (download weights, initialize sessions). Streams SSE progress. Call after `providers register`, before `runs start`. ```sh kindgi adapters prepare [--model=] [--params=] ``` Flags: * `--model `: The model to prepare, by key: shorthand for a `model` field in `--params`, and wins over it. * `--params `: The adapter's prepare settings, as an inline JSON object. Every command also takes the [global flags](../#global-flags). # kindgi agents > Manage agent registrations Manage agent registrations. ## `kindgi agents publish` Register an agent definition. ```sh kindgi agents publish --spec= [--project=] ``` Flags: * `--project `: The project to register the agent in, by id (default: the tenant's Default project). * `--spec `: The agent definition as JSON, or `@` to read it from a file. Required. Every command also takes the [global flags](../#global-flags). # kindgi approvals > Review HITL approvals Review HITL approvals. ## `kindgi approvals list` List the approvals your reviewer role can see. ```sh kindgi approvals list [--status=pending|assigned|in_review|approved|rejected|escalated|expired|withdrawn] [--limit=] [--cursor=] ``` Flags: * `--cursor `: Resume after this cursor, from the previous page's `nextCursor`. * `--limit `: The most approvals to return (default 25, at most 100). * `--status `: Only approvals in this status: `pending`, `assigned`, `in_review`, `approved`, `rejected`, `escalated`, `expired` or `withdrawn`. ## `kindgi approvals get` Fetch an approval by id. ```sh kindgi approvals get ``` ## `kindgi approvals complete` Decide an approval. Approving or rejecting resumes the run that waits on it (and the flow it is a step of). ```sh kindgi approvals complete --decision=approve|reject|escalate|withdraw [--rationale=] ``` Flags: * `--decision `: The decision: `approve`, `reject`, `escalate` or `withdraw`. Required. * `--rationale `: Why, recorded with the decision. Every command also takes the [global flags](../#global-flags). # kindgi auth > Manage local auth: the API's URL and token, and the runtime image's registry Manage local auth: the API's URL and token, and the runtime image's registry. ## `kindgi auth login` Persist an API URL + token to \~/.kindgi/config.json. ```sh kindgi auth login [--url=] [--token=] ``` ## `kindgi auth whoami` Verify the configured token by hitting the API. ```sh kindgi auth whoami ``` ## `kindgi auth registry` Log Docker in to the runtime image's registry, then check it can pull the image kindgi dev runs. ```sh kindgi auth registry [--username ] [--password-stdin] [--check] [--registry ] ``` Flags: * `--check`: Only check that Docker can pull the image, without logging in. * `--password-stdin`: Read the token from stdin instead of prompting for it. * `--registry `: The registry to log in to, such as a mirror; the check then looks for the image there. Default: the runtime image's registry (`quay.io`). * `--username `: The robot name you were given with your pull token. Needed to log in. Every command also takes the [global flags](../#global-flags). # kindgi build > Bundle + build + sign a pack image Bundle + build + sign a pack image. Emits deploy-envelope.json for `kindgi deploy`. `--local` builds the image with this machine's Docker instead: no build service, no signing; with `--push`, it pushes the image, signs it, and writes the envelope. ```sh kindgi build [--local [--push []] [--platform ]] [--target ] [--endpoint ] [--env ] [--out ] [--artifact-version ] [--published-at ] [--tenant ] [--signing-key ] [--registry-push-creds ] [--skip-integrity-gate] [--skip-image-pull] [--skip-sign] [--path ] ``` Flags: * `--artifact-version `: The artifact version, in the image and its signature. Default: today's date as `YYYYMMDD.1` (UTC). * `--endpoint `: The build server. Default: the env block's `build`. Not used with `--local`. * `--env `: The environment block in `kindgi.config.ts` to build for. Default: `staging`. * `--local`: Build with this machine's Docker instead of a build server: no signing and no envelope unless `--push`. * `--out `: Where the build output and `deploy-envelope.json` go. Default: `.kindgi/build` under the pack root. * `--path `: The pack root. Default: the current directory. * `--platform `: With `--local`: the image's platform. Default: `linux/amd64` when pushing, else this machine's. * `--published-at `: The publish time (ISO 8601), in the image and its signature. Default: the Unix epoch, so builds are reproducible. * `--push `: With `--local`: push the image, sign it and write the envelope. Default repository: the env block's `registry` + `/`. * `--registry-push-creds `: A reference to the registry push credentials for the build server to use, passed through as is. * `--signer-key-id `: The key id the signature names. Default: the env block's `signerKeyId`, else the key file's name. * `--signing-key `: The Ed25519 private key (PEM) to sign with. Default: the env block's `signingKey`. * `--skip-image-pull`: Check the image's index by hash only, without pulling the image. Build-server builds only. * `--skip-integrity-gate`: Sign without checking the image's index against the local one. Prints a warning. Build-server builds only. * `--skip-sign`: Write an unsigned envelope (for CI that signs elsewhere); `kindgi deploy` refuses it. * `--target `: The build target, set in the image build and sent to the build server. Default: the env block's `buildTarget`, else the env name. * `--tenant `: The tenant the signature names. Default: the env block's `tenantId`, else `KINDGI_TENANT_ID`. Every command also takes the [global flags](../#global-flags). # kindgi deploy > Deploy a signed pack image to Kindgi Deploy a signed pack image to Kindgi. Reads deploy-envelope.json (or runs `kindgi build` inline) + POSTs to /v1/deployments. ```sh kindgi deploy [--env ] [--from-envelope ] [--endpoint ] [--token ] [--tenant ] [--idempotency-key ] [--dry-run] [--sync-secrets] [--allow-missing-env] [--target ] [--build-endpoint ] [--out ] [--artifact-version ] [--published-at ] [--signing-key ] [--signer-key-id ] [--registry-push-creds ] [--skip-integrity-gate] [--skip-image-pull] [--skip-sign] [--path ] ``` Flags: * `--allow-missing-env`: Deploy, with a warning, even when a name in the pack's `env.required` has no value for `--env`. * `--artifact-version `: For an inline build: the artifact version. Default: today's date as `YYYYMMDD.1` (UTC). * `--build-endpoint `: For an inline build: the build server (`kindgi build --endpoint`). Default: the env block's `build`. * `--dry-run`: Print the equivalent `curl` request instead of sending it. A missing envelope is still built first. * `--endpoint `: The API to deploy to. Default: the env block's `endpoint`, else the CLI's API URL (`--url`, `KINDGI_API_URL`, config files). * `--env `: The environment: its block in `kindgi.config.ts` and its `.env.` file. Default: `staging`. * `--from-envelope `: The envelope to deploy. Default: `deploy-envelope.json` in `--out`; when there is none, `kindgi build` runs first. * `--idempotency-key `: The `Idempotency-Key` header. Default: a hash of the request body, so the same image deployed again returns the same deployment. * `--out `: Where the envelope is looked for, and an inline build writes. Default: `.kindgi/build` under the pack root. * `--path `: The pack root. Default: the current directory. * `--published-at `: For an inline build: the publish time (ISO 8601). Default: the Unix epoch. * `--registry-push-creds `: For an inline build: a reference to the registry push credentials for the build server to use. * `--signer-key-id `: For an inline build: the key id the signature names. Default: the env block's `signerKeyId`, else the key file's name. * `--signing-key `: For an inline build: the Ed25519 private key (PEM) to sign with. Default: the env block's `signingKey`. * `--skip-image-pull`: For an inline build: check the image's index by hash only, without pulling the image. * `--skip-integrity-gate`: For an inline build: sign without checking the image's index against the local one. * `--skip-sign`: For an inline build: write an unsigned envelope, which the deploy then refuses unless `--dry-run`. * `--sync-secrets`: After the deploy lands, also send the values in `.env.` to the deployment's secrets. Off by default. * `--target `: For an inline build: the build target. Default: the env block's `buildTarget`, else the env name. * `--tenant `: Refuse unless the envelope was signed for this tenant (checked before sending). Also passed to an inline build. Every command also takes the [global flags](../#global-flags). # kindgi dev > Run the Kindgi runtime as a container + hot-reload the pack under cwd Run the Kindgi runtime as a container + hot-reload the pack under cwd. ```sh kindgi dev [--port ] [--database-url ] [--tenant ] [--dev-token ] [--no-watch] [--path ] [--reset [--yes]] [--recreate-services] [--runtime-image | --runtime-url ] ``` Flags: * `--database-url `: The Postgres to use. Default: `KINDGI_DATABASE_URL` (the shell's, then the env files'), else the bundled Postgres. * `--dev-token `: Pin the API token. Default: the previous run's (from `.kindgirc.json`), else a new one. * `--no-watch`: Start, index and register once, then exit. For smoke tests and CI. * `--path `: The pack root, with a `kindgi.config.ts` (or `.mts`) or a `pyproject.toml` with `[tool.kindgi]`. Default: the current directory. * `--port `: The port the runtime's API is reached on, on `127.0.0.1`. Default: `4000`. Not used with `--runtime-url`. * `--recreate-services`: Let `docker compose` recreate the bundled Postgres if its definition changed (needs compose). By default an existing container is reused. * `--reset`: Start the project fresh: drops its database in the bundled Postgres (every pack's dev data in it), after asking, and makes a new token. A database you pass with --database-url is never dropped: only the token is new. * `--runtime-image `: The runtime image to run. Default: the one this CLI release pins. * `--runtime-url `: Use a runtime you run yourself, at this origin, instead of starting the container. Start it with the pack's `.kindgi/dev/runtime.env`. * `--tenant `: Pin the tenant. Default: the previous run's (from `.kindgirc.json`), else a new one. * `--watch`: Re-index and reload the pack on every save. On by default; `--no-watch` turns it off. * `--yes`: With --reset: drop the project's database without asking (for scripts). Every command also takes the [global flags](../#global-flags). # kindgi env > Manage per-environment values that resolve into `needs.env` at deploy time Manage per-environment values that resolve into `needs.env` at deploy time. ## `kindgi env list` List resolved env values for the target env (merges config + .env.\). ```sh kindgi env list [--env ] [--reveal] [--force-reveal] [--path ] ``` Flags: * `--env `: The environment: `local` is the project's own env files, any other name its `.env.`. Default: `staging`. * `--force-reveal`: With `--reveal`, print the values even when stdout isn't a terminal (a file, a pipe, CI). * `--path `: The pack root, where the env files are. Default: the current directory. * `--reveal`: Print the values instead of redacting them. Refused when stdout isn't a terminal (a file, a pipe), unless `--force-reveal` is also given. ## `kindgi env set` Set a KEY=VALUE in .env.\. Refuses to overwrite unless --force. ```sh kindgi env set [--env ] [--force] [--path ] ``` Flags: * `--env `: The environment: `local` is the project's own env files, any other name its `.env.`. Default: `staging`. * `--force`: Change a key an env file already sets: overwrite it, or override a lower-precedence file. By default `set` refuses. * `--path `: The pack root, where the env files are. Default: the current directory. ## `kindgi env unset` Remove KEY from .env.\. Idempotent — no error if the key is absent. ```sh kindgi env unset [--env ] [--path ] ``` Flags: * `--env `: The environment: `local` is the project's own env files, any other name its `.env.`. Default: `staging`. * `--path `: The pack root, where the env files are. Default: the current directory. ## `kindgi env pull` Fetch remote env values via /v1/env/\* and merge into .env.\. Requires --scope. ```sh kindgi env pull [--env ] --scope=[:id] [--path ] [--overwrite-existing] ``` Flags: * `--env `: The environment to pull, written to its `.env.` (`local`: the project's own env files). Default: `staging`. * `--overwrite-existing`: Replace keys an env file already sets. By default they are skipped. * `--path `: The pack root, where the env files are. Default: the current directory. * `--scope `: Whose values to pull: `tenant`, `org:` or `project:`. Required. ## `kindgi env init` Scaffold `.env.example` for a deployment target. Interactive prompt when flags omit an axis and stdin is a TTY. ```sh kindgi env init [--secrets-backend=] [--kms=] [--out=] [--force] [--non-interactive] ``` Flags: * `--force`: Overwrite an existing file at the output path. By default `init` refuses. * `--kms `: The KMS for the `postgres` or `secret-manager` backend: `gcp`, `aws`, `libsodium` or `vault`. Asked for when needed and omitted. * `--non-interactive`: Never prompt: fail when `--secrets-backend`, or a needed `--kms`, is missing. * `--out `: Where to write the file. Default: `.env.example` in the current directory. * `--secrets-backend `: The deployment's secrets backend: `none`, `postgres` or `secret-manager`. Asked for when omitted on a terminal. ## `kindgi env plan` Show the process env a deployed pack service gets in --env: each name the pack declares (env.required / env.optional) and its value or Secret Manager reference from environments.\.env. Prints Terraform input (default) or gcloud flags; exits 1 when a required name has no value or a secret is given in the clear. ```sh kindgi env plan [--env ] [--format=terraform|gcloud] [--path ] ``` Flags: * `--env `: The environment to plan, from `environments..env` in `kindgi.config.ts`. Default: `staging`. * `--format `: The output: `terraform` (default) for Terraform input, or `gcloud` for `--update-env-vars` / `--update-secrets` flags (they add or replace the listed names, and leave the service's other variables alone). * `--path `: The pack root, where `kindgi.config.ts` is. Default: the current directory. Every command also takes the [global flags](../#global-flags). # kindgi feedback > Report framework friction Claude Code diagnosed — bugs, wire schema gaps, misleading errors, UX cliffs Report framework friction Claude Code diagnosed — bugs, wire schema gaps, misleading errors, UX cliffs. ## `kindgi feedback write` Append a framework-friction entry to FEEDBACK.md at the pack root (Claude Code diagnostic → durable input for maintainers). ```sh kindgi feedback write --kind= --title= [--severity=] [--body=@ | --body-stdin | --interactive] [--authored-by=] [--path=] ``` Flags: * `--authored-by `: Who wrote the entry: `human` (default), `claude-code` or `mixed`. * `--body `: The entry's body: the text itself, or `@` to read it from a file. * `--body-stdin`: Read the entry's body from stdin. * `--interactive`: Write the body in `$EDITOR`: the default when neither `--body` nor `--body-stdin` is given. * `--kind `: The entry's kind: `bug`, `friction`, `question` or `design`. Required. * `--path `: The pack root, where `FEEDBACK.md` is. Default: the current directory. * `--severity `: The entry's severity: `blocker`, `high`, `medium` (default) or `low`. * `--title `: A short title: the entry's heading. Required. ## `kindgi feedback submit` Submit feedback reports to Kindgi (not available yet). ```sh kindgi feedback submit ``` Every command also takes the [global flags](../#global-flags). # kindgi guardrails > Manage guardrail registrations Manage guardrail registrations. ## `kindgi guardrails list` List registered guardrails. ```sh kindgi guardrails list [--name=] [--limit=] [--cursor=] ``` Flags: * `--cursor `: Resume after this cursor, from the previous page's `nextCursor`. * `--limit `: The most guardrails to return (default 25, at most 100). * `--name `: Only the guardrails whose id starts with this prefix. ## `kindgi guardrails get` Fetch a guardrail by id. ```sh kindgi guardrails get ``` ## `kindgi guardrails register` Register a guardrail. ```sh kindgi guardrails register --spec= [--project=] ``` Flags: * `--project `: The project to register the guardrail in, by id (default: the tenant's Default project). * `--spec `: The guardrail definition as JSON, or `@` to read it from a file. Required. ## `kindgi guardrails unregister` Unregister a guardrail. ```sh kindgi guardrails unregister ``` Every command also takes the [global flags](../#global-flags). # kindgi health > Ping the API server (GET /health) Ping the API server (GET /health). ```sh kindgi health ``` Every command also takes the [global flags](../#global-flags). # kindgi init > Scaffold a new Kindgi pack repo, or add Kindgi to an existing Node.js project (auto-detected when a `package.json` is present and no pack-name is given) Scaffold a new Kindgi pack repo, or add Kindgi to an existing Node.js project (auto-detected when a `package.json` is present and no pack-name is given). ```sh kindgi init [] [--template=minimal|sample|python] [--path=] [--force] [--link-local] [--new-repo] ``` Flags: * `--force`: Write into a non-empty directory; in an existing app, overwrite the Kindgi config and files already there instead of refusing or skipping them. * `--link-local`: Node packs: link `@kindgi/*` from the Kindgi checkout the CLI runs from, even inside its workspace. No effect for an installed CLI. * `--new-repo`: In an existing app, scaffold a separate pack instead of adding Kindgi to the app. Requires ``. * `--pack-id `: Adding Kindgi to an existing app: the pack id, instead of the one derived from the app's name. Needed when that name makes no valid id. * `--path `: Where to scaffold. Default: `` (its last dot segment) under the current directory; in an existing app, the current directory. * `--template `: The starter: `minimal` (default; empty primitive folders), `sample` (tools, a guardrail, an agent and a flow) or `python` (a Python pack). Every command also takes the [global flags](../#global-flags). # kindgi key > Manage Ed25519 signing keys: the local pairs under ~/.kindgi/keys/, and the runtime's trust list Manage Ed25519 signing keys: the local pairs under \~/.kindgi/keys/, and the runtime's trust list. ## `kindgi key create` Generate a new Ed25519 keypair under \~/.kindgi/keys/. ```sh kindgi key create [--env ] [--home ] ``` Flags: * `--env `: Also print the `signingKey` and `signerKeyId` lines to add to this env block in `kindgi.config.ts`. * `--home `: The home directory whose `.kindgi/keys/` holds the keys. Default: `$HOME`. ## `kindgi key export` Print the PUBLIC key material for a local key. Never exports the private key. ```sh kindgi key export [--format=pem|base64|raw-hex] [--home ] ``` Flags: * `--format `: The public key's encoding: `pem` (default), `base64` or `raw-hex`. * `--home `: The home directory whose `.kindgi/keys/` holds the keys. Default: `$HOME`. ## `kindgi key list` List local Ed25519 keys under \~/.kindgi/keys/. ```sh kindgi key list [--home ] ``` Flags: * `--home `: The home directory whose `.kindgi/keys/` holds the keys. Default: `$HOME`. ## `kindgi key trust` Add a local key's public key to the runtime's trust list, so the runtime accepts deploys the key signs. ```sh kindgi key trust [--label ] [--home ] ``` Flags: * `--home `: The home directory whose `.kindgi/keys/` holds the keys. Default: `$HOME`. * `--label `: A label the runtime keeps with the key (up to 200 characters). ## `kindgi key revoke` Remove a key from the runtime's trust list: the runtime refuses new deploys it signs. ```sh kindgi key revoke [--reason ] ``` Flags: * `--reason `: Why, kept with the key as `revokedReason`. Every command also takes the [global flags](../#global-flags). # kindgi mcp > Manage MCP server entries in .mcp.json (add / list / remove / presets) Manage MCP server entries in .mcp.json (add / list / remove / presets). ## `kindgi mcp add` Add an MCP server entry to .mcp.json from a preset. ```sh kindgi mcp add --secret= [--env=] [--scope=[:id]] [--server-name=