Wait for an approval
This guide stops a flow until a person approves what an agent step is about to do, then continues 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.
Before you start
Section titled “Before you start”- The tools
acme.get-order, from Write a flow,acme.hold-order, from Pass data between steps, andacme.email-customer, from Join branches. - A runtime such as
kindgi dev, with Kindgi 0.1's CLI. - A registered model that calls tools.
acme.escalate-order has an agent put an order on hold, then emails the
customer if the hold went through. Every hold waits for a person.
The agent
Section titled “The agent”The agent's tool call is what waits. Ask your coding agent: "I want an agent
that puts an order on hold, but only once a person approves." Kindgi's skills
in your pack tell it how (How it knows Kindgi).
For reference, the agent looks like this. It gates acme.hold-order with
always_ask, and returns a typed answer the flow can branch on:
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;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 itdef hold_order(input: HoldInput) -> Held: """Puts an order on hold for a person to review.""" return Held(orderId=input.order_id)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},)package acme.agents;
import com.kindgi.pack.Agent;import java.util.List;import java.util.Map;
public final class OrderHolder { public static final Agent AGENT = Agent.define("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.") .capability(Map.of("needs", List.of(Map.of("feature", "tool-use")))) .tool("acme.hold-order", "0.1.0") .set("conversationPolicy", Map.of("hitl", Map.of("tools", Map.of("overrides", Map.of("acme.hold-order", "always_ask"))))) .set("output", Map.of("schema", Map.of( "type", "object", "properties", Map.of("held", Map.of("type", "boolean"), "summary", Map.of("type", "string")), "required", List.of("held", "summary")))) .set("budget", Map.of("maxSteps", 4, "maxCostUsd", 0.05, "maxWallMs", 60_000)) .build();
private OrderHolder() {}}package acme.agents
import com.kindgi.pack.scaladsl._
object OrderHolder { val agent: Agent = Agent("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.") .capability(Map("needs" -> Seq(Map("feature" -> "tool-use")))) .tool("acme.hold-order", "0.1.0") .set("conversationPolicy", Map("hitl" -> Map("tools" -> Map("overrides" -> Map("acme.hold-order" -> "always_ask"))))) .set("output", Map("schema" -> Map( "type" -> "object", "properties" -> Map("held" -> Map("type" -> "boolean"), "summary" -> Map("type" -> "string")), "required" -> List("held", "summary")))) .set("budget", Map("maxSteps" -> 4, "maxCostUsd" -> 0.05, "maxWallMs" -> 60000)) .build()}Ask before a tool runs covers the
gates: always_ask, never_ask, and the default for tools that aren't
read-only.
The flow
Section titled “The flow”Ask your coding agent: "I want a flow that runs that agent, then emails the customer if the order went on hold." For reference, the flow runs the agent as a step, then branches on its answer:
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;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"}, } },)package acme.flows;
import com.kindgi.pack.Flow;import java.util.Map;
public final class EscalateOrder { private static final Map<String, Object> IS_HELD = Map.of( "op", "eq", "left", Map.of("path", "nodeOutputs.hold.output.held"), "right", Map.of("literal", true));
public static final Flow FLOW = Flow.define("acme.escalate-order") .version("0.1.0") .set("name", "Escalate an order") .set("description", "Has an agent put an order on hold, once a person approves, then tells the customer.") .node(Map.of("id", "order", "kind", "tool", "ref", "acme.get-order", "inputMapping", Map.of("orderId", Map.of("path", "runInput.orderId")))) .node(Map.of("id", "hold", "kind", "agent", "ref", "acme.order-holder", "inputMapping", Map.of( "orderId", Map.of("path", "runInput.orderId"), "reason", Map.of("path", "runInput.reason")))) .node(Map.of("id", "email", "kind", "tool", "ref", "acme.email-customer", "inputMapping", Map.of( "customer", Map.of("path", "nodeOutputs.order.customer"), "status", Map.of("literal", "held")))) .edge("e1", "$start", "order") .edge("e2", "order", "hold") .edge(Map.of("id", "e3", "from", "hold", "to", "email", "when", IS_HELD)) .edge(Map.of("id", "e4", "from", "hold", "to", "$end", "when", Map.of("op", "not", "child", IS_HELD))) .edge("e5", "email", "$end") .set("output", Map.of("mapping", Map.of( "held", Map.of("path", "nodeOutputs.hold.output.held"), "summary", Map.of("path", "nodeOutputs.hold.output.summary"), "notified", Map.of("path", "nodeOutputs.email.to")))) .build();
private EscalateOrder() {}}package acme.flows
import com.kindgi.pack.scaladsl._
object EscalateOrder { private val isHeld = Map( "op" -> "eq", "left" -> Map("path" -> "nodeOutputs.hold.output.held"), "right" -> Map("literal" -> true))
val flow: Flow = Flow("acme.escalate-order") .version("0.1.0") .set("name", "Escalate an order") .set("description", "Has an agent put an order on hold, once a person approves, then tells the customer.") .node(Map("id" -> "order", "kind" -> "tool", "ref" -> "acme.get-order", "inputMapping" -> Map("orderId" -> Map("path" -> "runInput.orderId")))) .node(Map("id" -> "hold", "kind" -> "agent", "ref" -> "acme.order-holder", "inputMapping" -> Map( "orderId" -> Map("path" -> "runInput.orderId"), "reason" -> Map("path" -> "runInput.reason")))) .node(Map("id" -> "email", "kind" -> "tool", "ref" -> "acme.email-customer", "inputMapping" -> Map( "customer" -> Map("path" -> "nodeOutputs.order.customer"), "status" -> Map("literal" -> "held")))) .edge("e1", "$start", "order") .edge("e2", "order", "hold") .edge(Map("id" -> "e3", "from" -> "hold", "to" -> "email", "when" -> isHeld)) .edge(Map("id" -> "e4", "from" -> "hold", "to" -> "$end", "when" -> Map("op" -> "not", "child" -> isHeld))) .edge("e5", "email", "$end") .set("output", Map("mapping" -> Map( "held" -> Map("path" -> "nodeOutputs.hold.output.held"), "summary" -> Map("path" -> "nodeOutputs.hold.output.summary"), "notified" -> Map("path" -> "nodeOutputs.email.to")))) .build()}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
Section titled “Run it”Only reviewers can decide approvals. Under kindgi dev you're one already.
On any other runtime, register yourself once:
pnpm exec kindgi reviewers register --spec='{"role":"standard"}'uv run kindgi reviewers register --spec='{"role":"standard"}'./kindgiw reviewers register --spec='{"role":"standard"}'./kindgiw reviewers register --spec='{"role":"standard"}'--spec is the reviewer, as JSON: role is standard, senior or admin.
It prints the registered reviewer, as JSON.
Then start the flow:
pnpm exec kindgi runs start --flow=acme.escalate-order --input='{"orderId":"A-200","reason":"Address looks wrong"}'uv run kindgi runs start --flow=acme.escalate-order --input='{"orderId":"A-200","reason":"Address looks wrong"}'./kindgiw runs start --flow=acme.escalate-order --input='{"orderId":"A-200","reason":"Address looks wrong"}'./kindgiw runs start --flow=acme.escalate-order --input='{"orderId":"A-200","reason":"Address looks wrong"}'--flow is the flow, and --input its input: the order and why to hold it.
It prints the run, waiting:
{ "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. kindgi runs journal <run-id> prints
it:
{"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
Section titled “Decide”The approval is what the reviewer decides. List the ones waiting:
pnpm exec kindgi approvals list --status=pendinguv run kindgi approvals list --status=pending./kindgiw approvals list --status=pending./kindgiw approvals list --status=pending--status=pending lists the ones nobody is assigned to yet. It prints the
agent turn's approval:
{ "data": [ { "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" }, … } ], "hasMore": false}provenanceRef.runId is the agent's turn, the step's own run. Its
parentRunId is the flow's run. Approve it:
pnpm exec kindgi approvals complete 0eaa2820-0afe-4147-a017-fc8a4d1657cc --decision=approve --rationale="Checked with the customer"uv run kindgi approvals complete 0eaa2820-0afe-4147-a017-fc8a4d1657cc --decision=approve --rationale="Checked with the customer"./kindgiw approvals complete 0eaa2820-0afe-4147-a017-fc8a4d1657cc --decision=approve --rationale="Checked with the customer"./kindgiw approvals complete 0eaa2820-0afe-4147-a017-fc8a4d1657cc --decision=approve --rationale="Checked with the customer"The id is the approval's, --decision=approve approves it (reject turns
it down), and --rationale is kept with it. It prints the recorded decision
as JSON: the approval, the decision, and what it did to the run.
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. Read the flow's run, with its id:
pnpm exec kindgi runs get 7111dde4-bdd3-40a1-a6e8-2a550d0ad318uv run kindgi runs get 7111dde4-bdd3-40a1-a6e8-2a550d0ad318./kindgiw runs get 7111dde4-bdd3-40a1-a6e8-2a550d0ad318./kindgiw runs get 7111dde4-bdd3-40a1-a6e8-2a550d0ad318{ … "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
Section titled “When the reviewer rejects”A rejection doesn't fail the step. The tool doesn't run, and the agent gets
the rejection, with the rationale, as the tool's result. Then it answers.
Here it answers held: false, so e4 ends the run without the email:
{ "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
Section titled “Stop waiting”kindgi runs cancel <run-id> on the flow's run cancels it and the agent's
turn. It also withdraws the approval the turn waited on, as
Cancel a run
describes.