Skip to main content

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

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

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

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

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.

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

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


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

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

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.

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

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