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. This is the manage-by-exception pattern: the agent proposes a risky action, a human decides, and the run continues automatically.

You will:

  1. Create a project.
  2. Create the SOAT tool whose call the approval gates.
  3. Define an orchestration whose approval node branches to approved / rejected / expired edges.
  4. Start a run and watch it pause with a pending approval item.
  5. 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.
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 SOAT 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 "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"

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, run_id, node_id, proposed_action}'

Look for origin: "node", 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-id "$ORCHESTRATION_ID" \
--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-id "$ORCHESTRATION_ID" \
--run-id "$RUN2_ID" | jq '{status, outcome: .state.outcome}'

Expected output:

{
"status": "succeeded",
"outcome": "Refund declined."
}

How It Works

  • Snapshot at emit time. The node's arguments, reasoning, evidence, and predicted_impact are 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 a condition node. 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_in sets how long the item stays actionable. A background sweeper flips overdue items to expired and resumes the run down its expired edge; 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 (node here) so consumers never branch on where it came from.

Next Steps