Skip to content

Wait for an approval

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 and acme.email-customer from Join branches.

The agent gates acme.hold-order with always_ask, and returns a typed answer the flow can branch on:

agents/order-holder/index.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;

Ask before a tool runs covers the gates (always_ask, never_ask, and the default for tools that aren't read-only).

flows/escalate-order/index.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;

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.

Only reviewers can decide approvals. Under kindgi dev you're one already; on any other runtime, register yourself once:

Terminal window
kindgi reviewers register --spec='{"role":"standard"}'

Then start the flow:

Terminal window
kindgi runs start --flow=acme.escalate-order --input='{"orderId":"A-200","reason":"Address looks wrong"}'
{
"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.

{"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"}, …}
Terminal window
kindgi approvals list --status=pending
{
"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:

Terminal window
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:

Terminal window
kindgi 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"
}
}

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:

{
"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.

kindgi runs cancel <run-id> 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 shows.