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.
You will define an orchestration whose approval node branches to approved / rejected / expired edges, start runs that pause with pending approval items, then approve one and reject another. 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 builtin 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 "builtin" \
--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, orchestration_run_id, node_id, proposed_action}'
Look for origin: "node", orchestration_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, orchestration_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-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: { orchestration_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-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: { orchestration_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
Approval items are snapshotted at emit time, expiry is a hard gate enforced by a background sweeper, and the queue is producer-agnostic (every item carries an origin). One routing rule worth restating: an unlabeled edge from an approval node follows only on approval, so rejection and expiry paths must be modeled with explicit labeled edges.
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.