Skip to content

Wait for an approval

View as Markdown

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.

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'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:

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.

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:

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
pnpm exec kindgi 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:

Terminal window
pnpm exec kindgi 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",
…
}
Excerpt

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

The approval is what the reviewer decides. List the ones waiting:

Terminal window
pnpm exec kindgi 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
}
Excerpt

provenanceRef.runId is the agent's turn, the step's own run. Its parentRunId is the flow's run. Approve it:

Terminal window
pnpm exec kindgi 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:

Terminal window
pnpm exec 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"
}
}
Excerpt

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.

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.