Approval Gates: Human-in-the-Loop with the approval Node
An approval node pauses a run and files an item in the Approvals queue; a person approves, rejects, or lets it expire, and the run resumes down the matching edge.
You define an orchestration with approved / rejected / expired edges, start runs that pause, then approve one and reject another. No AI provider is required.
Prerequisites
- SOAT running locally (Quick Start); Key Concepts; Configuration.
- CLI or SDK; server 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
See Users for authentication.
- 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 under review and freezes the proposed arguments onto the item; it does not run the tool (a downstream node does, once approved). A read-only builtin tool stands in for a 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 arguments against run state, freezes them onto the item, and pauses. The decision becomes the branch label: edges with condition: "approved" / "rejected" / "expired" route accordingly; an unlabeled edge follows only on approval. Each branch ends in a transform recording 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
The run pauses as awaiting_input; required_action carries the 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 Approvals queue holds the frozen proposed action and its provenance (run and node). Anyone with approvals:ResolveApproval on the project can act.
- CLI
- SDK
- curl
soat list-approvals --project-id "$PROJECT_ID" --status pending \
| jq '.data[] | {id, status, origin, orchestration_run_id, node_id, proposed_action}'
Expect origin: "node", orchestration_run_id = your run, proposed_action.arguments = { "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 run down the approved edge to issue. 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
A reason is required; the run resumes down the rejected edge to declined. Reasons and edit diffs feed the loop described in Approvals.
- 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
Items are snapshotted at emit time; expiry is a hard gate enforced by a sweeper; every item carries an origin. An unlabeled edge from an approval node follows only on approval, so model rejection and expiry with labeled edges.
Next Steps
- Approvals — filter the queue by status or origin.
- Orchestration Control Flow.
- Conditional Branching.