# Ask before a tool runs

> Make an agent wait for a person's approval before it calls a tool that changes something.

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

- **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](https://docs.kindgi.com/v0.1.9/guides/models.md) shows.

## 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](https://docs.kindgi.com/v0.1.9/start/coding-agents.md)). For reference, the tool looks like this:

- **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())
  ```

- **Java**

  `src/main/java/acmeops/tools/PostUpdate.java`

  ```java
  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() {}
  }
  ```

- **Scala**

  `src/main/scala/acmeops/tools/PostUpdate.scala`

  ```scala
  package acmeops.tools

  import com.kindgi.pack.scaladsl._
  import jakarta.validation.constraints.Size
  import 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:

- **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"},
              },
          },
      },
  )
  ```

- **Java**

  `src/main/java/acmeops/agents/StatusAgent.java`

  ```java
  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() {}
  }
  ```

- **Scala**

  `src/main/scala/acmeops/agents/StatusAgent.scala`

  ```scala
  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. 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](https://docs.kindgi.com/v0.1.9/guides/approvals/approval-rules-for-every-agent.md).

### 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):

- **`true`** applies to every gated tool.
- **Per tool,** set `overrides`, keyed by tool id as in `tools`. An optional `default` covers the tools you don't name.

```ts
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:**

  ```json excerpt
  {
    "error": {
      "code": "separate-approver-required",
      "message": "Approval ecaaa545-… was asked for by you: someone else must approve it",
      "details": { "approvalId": "ecaaa545-…" },
      "requestId": "…"
    }
  }
  ```

- **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 `separateApprover` per tool, and `defineAgent` refuses 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

**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_ask` does. The agent's `hitl` can't lift it, nor can your tenant's [approval rules](https://docs.kindgi.com/v0.1.9/guides/approvals/approval-rules-for-every-agent.md): 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](https://docs.kindgi.com/v0.1.9/guides/approvals/ask-before-a-tool-runs.md#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:

- **TypeScript**

  ```ts excerpt
  // in tools/post-update/index.ts
    effects: [{ kind: 'external-side-effect', resource: 'external:status-page' }],
    needsApprovalWhenUnattended: true,
  ```

- **Python**

  ```python excerpt
  # 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,
  )
  ```

- **Java**

  ```java excerpt
  // in src/main/java/acmeops/tools/PostUpdate.java
        .effect("external-side-effect", "external:status-page")
        .needsApprovalWhenUnattended(true)
  ```

- **Scala**

  ```scala excerpt
  // 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](https://docs.kindgi.com/v0.1.9/deploy/people-and-keys.md#service-accounts): 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 `startedBy` otherwise. Either way it counts as unattended: it never reads as a person's.
- **The `woocommerce` template declares it** on `<pack>.woo.refund`, `<pack>.woo.refund-large`, `<pack>.woo.set-price` and `<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

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:

- **TypeScript**

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

- **Python**

  ```sh
  uv run kindgi reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'
  ```

- **Java**

  ```sh
  ./kindgiw reviewers register --spec='{"role":"standard","displayName":"Ada Lovelace"}'
  ```

- **Scala**

  ```sh
  ./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](https://docs.kindgi.com/v0.1.9/guides/approvals/decide-an-approval.md) covers reviewers and their roles.

## Run it

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

- **TypeScript**

  ```sh
  pnpm exec kindgi runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'
  ```

- **Python**

  ```sh
  uv run kindgi runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'
  ```

- **Java**

  ```sh
  ./kindgiw runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'
  ```

- **Scala**

  ```sh
  ./kindgiw runs start --agent=acme-ops.status-agent --input='{"userMessage":"Checkout is back to normal."}'
  ```

```json excerpt
{
  "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

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

- **TypeScript**

  ```sh
  pnpm exec kindgi approvals list --status=pending
  ```

- **Python**

  ```sh
  uv run kindgi approvals list --status=pending
  ```

- **Java**

  ```sh
  ./kindgiw approvals list --status=pending
  ```

- **Scala**

  ```sh
  ./kindgiw approvals list --status=pending
  ```

```json excerpt
{
  "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

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

- **TypeScript**

  ```sh
  pnpm exec kindgi approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"
  ```

- **Python**

  ```sh
  uv run kindgi approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"
  ```

- **Java**

  ```sh
  ./kindgiw approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"
  ```

- **Scala**

  ```sh
  ./kindgiw approvals complete 416c22ba-73f9-45b7-999d-c1d5938758f7 --decision=approve --rationale="Confirmed with the on-call engineer"
  ```

```json excerpt
{
  "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:

```json excerpt
"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:

```json excerpt
{"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:

- **TypeScript**

  ```sh
  pnpm exec kindgi runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758
  ```

- **Python**

  ```sh
  uv run kindgi runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758
  ```

- **Java**

  ```sh
  ./kindgiw runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758
  ```

- **Scala**

  ```sh
  ./kindgiw runs get 0c01376f-1566-4a0d-ba6f-95f4efff4758
  ```

```json excerpt
{
  "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. `<approval-id>` is the approval's `id`:

- **TypeScript**

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

- **Python**

  ```sh
  uv run kindgi approvals complete <approval-id> --decision=reject --rationale="Not confirmed yet"
  ```

- **Java**

  ```sh
  ./kindgiw approvals complete <approval-id> --decision=reject --rationale="Not confirmed yet"
  ```

- **Scala**

  ```sh
  ./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:

```json excerpt
      {
        "role": "tool",
        "content": {
          "status": "rejected",
          "rationale": "Not confirmed yet"
        },
        …
```

The run completes; it doesn't fail. The turn's [provenance](https://docs.kindgi.com/v0.1.9/guides/observability/trace-an-answer.md) 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. `<flow-run-id>` is the flow run's id:

- **TypeScript**

  ```sh
  pnpm exec kindgi runs journal <flow-run-id>
  ```

- **Python**

  ```sh
  uv run kindgi runs journal <flow-run-id>
  ```

- **Java**

  ```sh
  ./kindgiw runs journal <flow-run-id>
  ```

- **Scala**

  ```sh
  ./kindgiw runs journal <flow-run-id>
  ```

```json excerpt
  {
    "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:

```text
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.

## Next

- [Decide an approval](https://docs.kindgi.com/v0.1.9/guides/approvals/decide-an-approval.md): escalate, withdraw, roles, and deciding from your app.
- [Set approval rules for every agent](https://docs.kindgi.com/v0.1.9/guides/approvals/approval-rules-for-every-agent.md).
- [CLI reference: `kindgi approvals`](https://docs.kindgi.com/v0.1.9/reference/cli/approvals.md).
