Skip to content

Ask before a tool runs

View as Markdown

This guide makes an agent wait for a person before it calls a tool that changes something, then approves or rejects the call.

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.

  • A pack on a runtime such as kindgi dev, with Kindgi 0.1's CLI. This guide uses the pack acme-ops, with a read-only tool, acme-ops.greet.
  • A model the agent can route to, with tool-use, as Models shows.

acme-ops.post-update posts to a public status page. It changes something outside your app, so it doesn't say mutating: false. Ask your coding agent: "I want a tool that posts an update to our public status page." Kindgi's skills in your pack tell it how (How it knows Kindgi). For reference, the tool looks like this:

tools/post-update/index.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;

This agent posts status updates with it, and every post waits for a person. Ask your coding agent: "I want an agent that posts status updates, and asks a person before every post." For reference, the agent looks like this:

agents/status-agent/index.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;

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, with mutating: false, runs without asking. Any other tool asks. So hitl: { tools: {} } asks before every tool that can change something. 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.

Approvals are decided by reviewers. Under kindgi dev you're one already, an admin. On any other runtime, register yourself once, as the user your API token belongs to. It covers any token of yours, a console sign-in too:

Terminal window
pnpm exec kindgi reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'

--spec is the reviewer: its role and, optionally, a displayName. It prints the registered reviewer, as JSON.

Decide an approval covers reviewers and their roles.

Start a turn that asks for the tool. --agent is the agent, and --input its input:

Terminal window
pnpm exec kindgi runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'
{
"id": "0c01376f-1566-4a0d-ba6f-95f4efff4758",
…
"flowId": "agent.turn",
"flowVersion": "1.1.0",
"status": "suspended",
…
}
Excerpt

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.

List the pending approvals. --status=pending lists only the ones still pending:

Terminal window
pnpm exec kindgi approvals list --status=pending
{
"data": [
{
"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"
},
…
}
],
"hasMore": false
}
Excerpt

The approval names the tool and the exact arguments the model chose, and provenanceRef.runId is the run that waits.

Decide it by the approval's id. --decision=approve lets the tool run, and --rationale is kept with the decision:

Terminal window
pnpm exec kindgi approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"
{
"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,
"resume": { "kind": "ok" }
}
Excerpt

The run continues in the same call: the tool runs and the agent finishes its turn. The tool runs at the version the turn started with, even when a newer version in the agent's range appeared while it waited.

If that version is gone by then, the turn can't go on. Under kindgi dev, that happens when the tool's file now says another version. The approval still stands, its answer says so in resume, and the run fails:

"resume": {
"kind": "failed",
"code": "tool-version-unresolvable",
"message": "Tool \"acme-ops.post-update\": this turn started with version 0.1.0, which is no longer registered; it doesn't run another version mid-turn."
}
Excerpt

Its journal says who decided which approval. kindgi runs journal <run-id> shows it:

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

Get the run by its id to see the tool's result:

Terminal window
pnpm exec kindgi runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758
{
"id": "0c01376f-1566-4a0d-ba6f-95f4efff4758",
…
"status": "completed",
…
{
"role": "tool",
"content": {
"posted": true,
"postedAt": "2026-10-03T20:05:54.473Z"
},
…
Excerpt

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. <approval-id> is the approval's id:

Terminal window
pnpm exec kindgi approvals complete <approval-id> --decision=reject --rationale="Not confirmed yet"

It prints the recorded decision, as approving does. The run's messages then hold the rejection as the tool's result:

{
"role": "tool",
"content": {
"status": "rejected",
"rationale": "Not confirmed yet"
},
…
Excerpt

The run completes; it doesn't fail. The turn's provenance keeps the rejected call: its tool-call, and a tool-result that is the rejection the model read.

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. <flow-run-id> is the flow run's id:

Terminal window
pnpm exec kindgi runs journal <flow-run-id>
{
"sequence": 6,
"kind": "wait.suspended",
"nodeId": "respond",
…
}
Excerpt

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.