Skip to main content

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​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

See Users for authentication.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project​

Every resource lives inside a project.

PROJECT_ID=$(soat create-project --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.

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"

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.

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"

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.

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": "..."
}
}

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.

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 }.


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}'.

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

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.

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

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​