Ask before a tool runs
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.
Before you start
Section titled “Before you start”- A pack on a runtime such as
kindgi dev, with Kindgi 0.1's CLI. This guide uses the packacme-ops, with a read-only tool,acme-ops.greet. - A model the agent can route to, with
tool-use, as Models shows.
Gate a tool
Section titled “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. 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:
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;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())package acmeops.tools;
import com.kindgi.pack.Tool;import jakarta.validation.constraints.Size;import java.time.Instant;
public final class PostUpdate { public record Input(@Size(min = 1, max = 500) String message) {}
public record Output(boolean posted, String postedAt) {}
public static final Tool<Input, Output> TOOL = Tool.define("acme-ops.post-update") .description("Posts a message to the public status page.") .version("0.1.0") .input(Input.class) .output(Output.class) .effect("external-side-effect", "external:status-page") .handler((input, ctx) -> new Output(true, Instant.now().toString()));
private PostUpdate() {}}package acmeops.tools
import com.kindgi.pack.scaladsl._import jakarta.validation.constraints.Sizeimport java.time.Instant
object PostUpdate { final case class Input(@Size(min = 1, max = 500) message: String) final case class Output(posted: Boolean, postedAt: String)
val tool: Tool[Input, Output] = Tool[Input, Output]("acme-ops.post-update") .description("Posts a message to the public status page.") .version("0.1.0") .effect("external-side-effect", "external:status-page") .handler((_, _) => Output(posted = true, postedAt = Instant.now().toString))}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:
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;from kindgi import Agent
from ..tools.greet import greetfrom ..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"}, }, }, },)package acmeops.agents;
import acmeops.tools.Greet;import acmeops.tools.PostUpdate;import com.kindgi.pack.Agent;import java.util.List;import java.util.Map;
public final class StatusAgent { public static final Agent AGENT = Agent.define("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.") .capability(Map.of("needs", List.of(Map.of("feature", "tool-use")))) .tool(PostUpdate.TOOL) .tool(Greet.TOOL) .set("conversationPolicy", Map.of("hitl", Map.of("tools", Map.of( "overrides", Map.of("acme-ops.post-update", "always_ask"))))) .build();
private StatusAgent() {}}package acmeops.agents
import acmeops.tools.{Greet, PostUpdate}import com.kindgi.pack.scaladsl._
object StatusAgent { val agent: Agent = Agent("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.") .capability(Map("needs" -> Seq(Map("feature" -> "tool-use")))) .tool(PostUpdate.tool) .tool(Greet.tool) .set("conversationPolicy", Map("hitl" -> Map("tools" -> Map( "overrides" -> Map("acme-ops.post-update" -> "always_ask"))))) .build()}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. Sohitl: { tools: {} }asks before every tool that can change something. Hereacme-ops.greet, which is read-only, never asks.
An agent without hitl.tools has no tool gates, unless your tenant has
approval rules.
Someone other than whoever asked
Section titled “Someone other than whoever asked”A tool call's approval names who asked: the person the turn runs for, in
requestedBy (user:<id>, or service_account:<id> for an app's key). It
stays the same when someone else's decision resumes the turn.
By default, that person may approve it too. hitl.separateApprover
makes the approval need someone else (four eyes):
trueapplies to every gated tool.- Per tool, set
overrides, keyed by tool id as intools. An optionaldefaultcovers the tools you don't name.
conversationPolicy: { hitl: { tools: { overrides: { 'acme-ops.post-update': 'always_ask' } }, separateApprover: { overrides: { 'acme-ops.post-update': true } }, },},Every pack language sets it the same way: Python's conversation_policy,
and the map Java and Scala pass to set("conversationPolicy", …), take the
same keys, separateApprover included.
When four eyes is on:
-
The person who asked isn't offered Approve. That holds on the console's approval page, in its inbox's keyboard decisions ("You asked for this, so someone else approves it."), and on the Kindgi plugin for WordPress's Approvals tab.
-
The API refuses their approve:
{"error": {"code": "separate-approver-required","message": "Approval ecaaa545-… was asked for by you: someone else must approve it","details": { "approvalId": "ecaaa545-…" },"requestId": "…"}}Excerpt -
They may still reject their own request.
-
A turn that's already waiting keeps the rule it parked with.
-
It compares people. A turn started with an app's key names the key's service account (
service_account:<id>), so a person holding that key can still approve the call as themselves. -
With
ask_on_first_use, only a tool's first call is asked, and someone other than whoever asked approves that one. -
The template's tools: the store agent sets
separateApproverper tool, anddefineAgentrefuses an override naming a tool the agent doesn't have, so a mistyped id never leaves the real tool without four eyes.
The woocommerce template's store agent turns it on for
<pack>.woo.refund-large, <pack>.woo.set-price and
<pack>.woo.publish-product (acme-shop.woo.refund-large in a pack named
acme-shop).
When no person started the run
Section titled “When no person started the run”A tool can ask for a person's yes on every call no person is there to
see: it declares needsApprovalWhenUnattended: true. Declare it on a tool
that moves money or changes something people rely on. Then, in a run no
person started:
- An agent's call of the tool waits for a reviewer, as
always_askdoes. The agent'shitlcan't lift it, nor can your tenant's approval rules: agents are reused, and the tool's author knows what it does. - A flow's tool step that calls it fails, as In a flow shows.
In a run a person started, the agent's own gates decide, as for any tool.
Absent means false. Here acme-ops.post-update, which posts in public,
declares it:
// in tools/post-update/index.ts effects: [{ kind: 'external-side-effect', resource: 'external:status-page' }], needsApprovalWhenUnattended: true,# in tools/post_update.py@tool( id="acme-ops.post-update", effects=[{"kind": "external-side-effect", "resource": "external:status-page"}], needs_approval_when_unattended=True,)// in src/main/java/acmeops/tools/PostUpdate.java .effect("external-side-effect", "external:status-page") .needsApprovalWhenUnattended(true)// in src/main/scala/acmeops/tools/PostUpdate.scala .effect("external-side-effect", "external:status-page") .needsApprovalWhenUnattended(true)How a run started is on the run, as startedBy:
startedBy |
Who started it | Counts as a person |
|---|---|---|
person |
A person: signed in (the console, the chat of the Kindgi plugin for WordPress), or with their own API key | Yes |
key |
A service account's key | No |
schedule, webhook, event |
A trigger | No |
- A key acts as its person. A script on your own key starts runs as you, and they count as yours. For automation, use a service account's key: its runs count as unattended.
- A flow's agent step, and any run a run starts, started the way its run did.
- A run from before your runtime recorded it reads as its trigger's
kind when a trigger started it, and has no
startedByotherwise. Either way it counts as unattended: it never reads as a person's. - The
woocommercetemplate declares it on<pack>.woo.refund,<pack>.woo.refund-large,<pack>.woo.set-priceand<pack>.woo.publish-product. A staff member's refund in the store chat runs as the agent says; one an order webhook's run asks for waits for a person.
Become a reviewer
Section titled “Become a reviewer”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:
pnpm exec kindgi reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'uv run kindgi reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'./kindgiw reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'./kindgiw 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.
Run it
Section titled “Run it”Start a turn that asks for the tool. --agent is the agent, and --input
its input:
pnpm exec kindgi runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'uv run kindgi runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'./kindgiw runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'./kindgiw 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", …}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
Section titled “See what it waits for”List the pending approvals. --status=pending lists only the ones still pending:
pnpm exec kindgi approvals list --status=pendinguv run kindgi approvals list --status=pending./kindgiw approvals list --status=pending./kindgiw 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}The approval names the tool and the exact arguments the model chose, and
provenanceRef.runId is the run that waits.
Approve it
Section titled “Approve it”Decide it by the approval's id. --decision=approve lets the tool run,
and --rationale is kept with the decision:
pnpm exec kindgi approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"uv run kindgi approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"./kindgiw approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"./kindgiw 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" }}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."}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"}, …}, …}Get the run by its id to see the tool's result:
pnpm exec kindgi runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758uv run kindgi runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758./kindgiw runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758./kindgiw 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" }, …Reject it
Section titled “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. <approval-id> is the
approval's id:
pnpm exec kindgi approvals complete <approval-id> --decision=reject --rationale="Not confirmed yet"uv run kindgi approvals complete <approval-id> --decision=reject --rationale="Not confirmed yet"./kindgiw approvals complete <approval-id> --decision=reject --rationale="Not confirmed yet"./kindgiw 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" }, …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.
In a flow
Section titled “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. <flow-run-id> is
the flow run's id:
pnpm exec kindgi runs journal <flow-run-id>uv run kindgi runs journal <flow-run-id>./kindgiw runs journal <flow-run-id>./kindgiw runs journal <flow-run-id> { "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.
A tool step can't wait for a person. In a run no person started, a step
whose tool declares needsApprovalWhenUnattended fails, and the tool never
runs. The run's failureMessage says why:
needs-a-person: tool "acme-ops.post-update" needs a person's yes when no person started the run (needsApprovalWhenUnattended), and a flow's tool step can't wait for one. Call it from an agent step instead: there the call waits for a person to approve it.Call such a tool from an agent step, whose call waits for a reviewer.
- Decide an approval: escalate, withdraw, roles, and deciding from your app.
- Set approval rules for every agent.
- CLI reference:
kindgi approvals.