Approval Gates: Human-in-the-Loop with the approval Node
An approval node pauses an orchestration run and files a human-decision item in the Approvals queue. A person then approves, rejects, or lets it expire, and the run resumes down the matching decision edge. This is the manage-by-exception pattern: the agent proposes a risky action, a human decides, and the run continues automatically.
You will:
- Create a project.
- Create the SOAT tool whose call the approval gates.
- Define an orchestration whose
approvalnode branches toapproved/rejected/expirededges. - Start a run and watch it pause with a pending approval item.
- Approve one run and reject another, and see each resume down the right branch.
Everything here is deterministic — no AI provider is required.
Prerequisites
- SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
- New to SOAT? Read Key Concepts to understand projects, tools, and runs first.
- CLI installed and configured, or SDK set up. See CLI or SDK.
- For production hardening (secrets, env vars), see Configuration.
- Server is at
http://localhost:5047.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in as admin
Admin is the built-in superuser role. See Users for authentication details.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Every resource lives inside a project.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Approvals Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Approvals Demo' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Approvals Demo"}' | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
Step 3 — Create the tool the approval gates
The approval node names the Tool whose call is under review and freezes the proposed arguments onto the item — it records the proposal, it does not run the tool (a downstream node performs the action once approved). Create a read-only SOAT tool so the tutorial needs no external services; in a real system this would be your refund, payment, or deployment tool.
- CLI
- SDK
- curl
REFUND_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "issue-refund" \
--type "soat" \
--description "Stand-in for the sensitive action the approval gates" \
--actions '["get-project"]' \
--preset-parameters '{"project_id": "'"$PROJECT_ID"'"}' | jq -r '.id')
echo "REFUND_TOOL_ID: $REFUND_TOOL_ID"
const { data: refundTool } = await adminSoat.tools.createTool({
body: {
project_id: PROJECT_ID,
name: 'issue-refund',
type: 'soat',
description: 'Stand-in for the sensitive action the approval gates',
actions: ['get-project'],
preset_parameters: { projectId: PROJECT_ID },
},
});
const REFUND_TOOL_ID = refundTool.id;
REFUND_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"issue-refund\",\"type\":\"soat\",\"description\":\"Stand-in for the sensitive action the approval gates\",\"actions\":[\"get-project\"],\"preset_parameters\":{\"projectId\":\"$PROJECT_ID\"}}" \
| jq -r '.id')
echo "REFUND_TOOL_ID: $REFUND_TOOL_ID"
Step 4 — Create the orchestration with an approval gate
The approval node resolves its arguments against run state, freezes them onto an approval item, and pauses the run. On resolution the decision becomes the node's branch label: edges labeled condition: "approved" / "rejected" / "expired" route accordingly (an unlabeled edge would follow only on approval). Here each branch ends in a transform that records the outcome.
- CLI
- SDK
- curl
ORCH_NODES='[
{"id":"gate","type":"approval","tool_id":"'"$REFUND_TOOL_ID"'","arguments":{"amount":{"var":"input.amount"}},"reasoning":"Refund exceeds the auto-approve threshold.","expires_in":3600,"instructions":"Approve or reject this refund."},
{"id":"issue","type":"transform","expression":"Refund issued.","state_mapping":{"state.outcome":{"var":"output.result"}}},
{"id":"declined","type":"transform","expression":"Refund declined.","state_mapping":{"state.outcome":{"var":"output.result"}}},
{"id":"stale","type":"transform","expression":"Approval expired.","state_mapping":{"state.outcome":{"var":"output.result"}}}
]'
ORCH_EDGES='[
{"from":"gate","to":"issue","condition":"approved"},
{"from":"gate","to":"declined","condition":"rejected"},
{"from":"gate","to":"stale","condition":"expired"}
]'
ORCHESTRATION_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Refund Approval Gate" \
--description "Human approves or rejects a refund" \
--nodes "$ORCH_NODES" \
--edges "$ORCH_EDGES" | jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"
const { data: orchestration } =
await adminSoat.orchestrations.createOrchestration({
body: {
project_id: PROJECT_ID,
name: 'Refund Approval Gate',
description: 'Human approves or rejects a refund',
nodes: [
{
id: 'gate',
type: 'approval',
tool_id: REFUND_TOOL_ID,
arguments: { amount: { var: 'input.amount' } },
reasoning: 'Refund exceeds the auto-approve threshold.',
expires_in: 3600,
instructions: 'Approve or reject this refund.',
},
{
id: 'issue',
type: 'transform',
expression: 'Refund issued.',
state_mapping: { 'state.outcome': { var: 'output.result' } },
},
{
id: 'declined',
type: 'transform',
expression: 'Refund declined.',
state_mapping: { 'state.outcome': { var: 'output.result' } },
},
{
id: 'stale',
type: 'transform',
expression: 'Approval expired.',
state_mapping: { 'state.outcome': { var: 'output.result' } },
},
],
edges: [
{ from: 'gate', to: 'issue', condition: 'approved' },
{ from: 'gate', to: 'declined', condition: 'rejected' },
{ from: 'gate', to: 'stale', condition: 'expired' },
],
},
});
const ORCHESTRATION_ID = orchestration.id;
ORCHESTRATION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Refund Approval Gate\",\"description\":\"Human approves or rejects a refund\",\"nodes\":[{\"id\":\"gate\",\"type\":\"approval\",\"tool_id\":\"$REFUND_TOOL_ID\",\"arguments\":{\"amount\":{\"var\":\"input.amount\"}},\"reasoning\":\"Refund exceeds the auto-approve threshold.\",\"expires_in\":3600,\"instructions\":\"Approve or reject this refund.\"},{\"id\":\"issue\",\"type\":\"transform\",\"expression\":\"Refund issued.\",\"state_mapping\":{\"state.outcome\":{\"var\":\"output.result\"}}},{\"id\":\"declined\",\"type\":\"transform\",\"expression\":\"Refund declined.\",\"state_mapping\":{\"state.outcome\":{\"var\":\"output.result\"}}},{\"id\":\"stale\",\"type\":\"transform\",\"expression\":\"Approval expired.\",\"state_mapping\":{\"state.outcome\":{\"var\":\"output.result\"}}}],\"edges\":[{\"from\":\"gate\",\"to\":\"issue\",\"condition\":\"approved\"},{\"from\":\"gate\",\"to\":\"declined\",\"condition\":\"rejected\"},{\"from\":\"gate\",\"to\":\"stale\",\"condition\":\"expired\"}]}" \
| jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"
Step 5 — Start a run — it pauses for approval
Start a run. The approval node pauses it as awaiting_input and the response's required_action carries the created approval item's approval_id and expires_at.
- CLI
- SDK
- curl
RUN=$(soat start-orchestration-run \
--orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":500}')
printf '%s\n' "$RUN" | jq '{status, required_action}'
RUN_ID=$(printf '%s\n' "$RUN" | jq -r '.id')
APPROVAL_ID=$(printf '%s\n' "$RUN" | jq -r '.required_action.approval_id')
echo "RUN_ID: $RUN_ID"
echo "APPROVAL_ID: $APPROVAL_ID"
Expected output:
{
"status": "awaiting_input",
"required_action": {
"type": "approval",
"node_id": "gate",
"approval_id": "apr_...",
"expires_at": "..."
}
}
const { data: run } = await adminSoat.orchestrations.startOrchestrationRun({
body: { orchestration_id: ORCHESTRATION_ID, input: { amount: 500 } },
});
console.log('Status:', run.status);
console.log('Required action:', run.required_action);
const RUN_ID = run.id;
const APPROVAL_ID = run.required_action.approval_id;
RUN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":500}}")
printf '%s\n' "$RUN" | jq '{status, required_action}'
RUN_ID=$(printf '%s\n' "$RUN" | jq -r '.id')
APPROVAL_ID=$(printf '%s\n' "$RUN" | jq -r '.required_action.approval_id')
echo "RUN_ID: $RUN_ID"
echo "APPROVAL_ID: $APPROVAL_ID"
Step 6 — See the pending item in the queue
The item is now in the Approvals queue with the frozen proposed action and its provenance (the originating run and node). Anyone with approvals:ResolveApproval on the project can act on it.
- CLI
- SDK
- curl
soat list-approvals --project-id "$PROJECT_ID" --status pending \
| jq '.data[] | {id, status, origin, run_id, node_id, proposed_action}'
Look for origin: "node", run_id matching your run, and proposed_action.arguments equal to { "amount": 500 }.
const { data: pending } = await adminSoat.approvals.listApprovals({
query: { project_id: PROJECT_ID, status: 'pending' },
});
console.log(pending);
curl -s "$SOAT_BASE_URL/api/v1/approvals?project_id=$PROJECT_ID&status=pending" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '.data[] | {id, status, origin, run_id, node_id, proposed_action}'
Step 7 — Approve it — the run resumes
Approving resolves the item and resumes the parked run down the approved edge, which runs the issue node. To approve with different arguments (edit-then-approve), pass --arguments '{"amount": 450}'.
- CLI
- SDK
- curl
soat approve-approval --approval-id "$APPROVAL_ID" | jq '{status, resolved_by}'
soat get-orchestration-run \
--orchestration-id "$ORCHESTRATION_ID" \
--run-id "$RUN_ID" | jq '{status, outcome: .state.outcome}'
Expected output:
{
"status": "succeeded",
"outcome": "Refund issued."
}
await adminSoat.approvals.approveApproval({
path: { approval_id: APPROVAL_ID },
body: {},
});
const { data: resumed } = await adminSoat.orchestrations.getOrchestrationRun({
path: { run_id: RUN_ID },
});
console.log('Status:', resumed.status);
console.log('Outcome:', resumed.state.outcome);
curl -s -X POST "$SOAT_BASE_URL/api/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' | jq '{status, resolved_by}'
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{status, outcome: .state.outcome}'
Step 8 — Reject a second run
Start another run and reject it. A reason is required, and the run resumes down the rejected edge to the declined node. Rejection reasons and edit diffs are the raw material of the feedback loop described in the Approvals module.
- CLI
- SDK
- curl
RUN2=$(soat start-orchestration-run \
--orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":999}')
RUN2_ID=$(printf '%s\n' "$RUN2" | jq -r '.id')
APPROVAL2_ID=$(printf '%s\n' "$RUN2" | jq -r '.required_action.approval_id')
soat reject-approval --approval-id "$APPROVAL2_ID" --reason "Exceeds monthly budget." \
| jq '{status, resolution_reason}'
soat get-orchestration-run \
--orchestration-id "$ORCHESTRATION_ID" \
--run-id "$RUN2_ID" | jq '{status, outcome: .state.outcome}'
Expected output:
{
"status": "succeeded",
"outcome": "Refund declined."
}
const { data: run2 } = await adminSoat.orchestrations.startOrchestrationRun({
body: { orchestration_id: ORCHESTRATION_ID, input: { amount: 999 } },
});
await adminSoat.approvals.rejectApproval({
path: { approval_id: run2.required_action.approval_id },
body: { reason: 'Exceeds monthly budget.' },
});
const { data: rejected } = await adminSoat.orchestrations.getOrchestrationRun({
path: { run_id: run2.id },
});
console.log('Status:', rejected.status);
console.log('Outcome:', rejected.state.outcome);
RUN2=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":999}}")
RUN2_ID=$(printf '%s\n' "$RUN2" | jq -r '.id')
APPROVAL2_ID=$(printf '%s\n' "$RUN2" | jq -r '.required_action.approval_id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/approvals/$APPROVAL2_ID/reject" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason":"Exceeds monthly budget."}' | jq '{status, resolution_reason}'
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN2_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{status, outcome: .state.outcome}'
How It Works
- Snapshot at emit time. The node's
arguments,reasoning,evidence, andpredicted_impactare resolved against run state and frozen onto the approval item when the run pauses. Later state changes never alter what the approver sees. - Decision routing. The decision (
approved/rejected/expired) becomes the paused node's branch label. Labeled edges select the branch, exactly like aconditionnode. An unlabeled edge from an approval node follows only on approval, so the rejection and expiry paths must be modeled with explicit labeled edges. - Expiry is a hard gate.
expires_insets how long the item stays actionable. A background sweeper flips overdue items toexpiredand resumes the run down itsexpirededge; the resolution path re-checks expiry too, so a stale proposal can never execute. See Expiry is a hard gate. - Producer-agnostic queue. The same queue, endpoints, and events serve any producer. Every item carries an
origin(nodehere) so consumers never branch on where it came from.
Next Steps
- Browse the whole queue and filter by status or origin — see Approvals.
- Combine an approval gate with other control-flow nodes in Orchestration Control Flow.
- Route a decision through more branches with Conditional Branching.