Tutorial: Expense approval with HITL
Combine LLM classification, a sandboxed (ScaiBunker) policy check, and a human review queue to approve expenses end-to-end.
The shape#
Steps#
1. Start from the example#
Catalog… → Examples → Expense Approver → Open. The flow has six nodes pre-wired.
2. The classifier#
node_classify (Flexible Prompt) extracts {amount, category, vendor, justification} from the submitted text. Returns structured output via output_schema.
3. Provision a sandbox#
node_provision (Provision Sandbox) compiles to scaibunker.create(...). Configured:
- Image:
python-3.12 - Lifecycle:
ephemeral(per-invocation) - CPU:
1000millicores - Memory:
512MB - Network:
registry(default — image registry + minimal allowlist)
Output is a bunker handle that flows into the next node.
4. Policy check#
node_policy_check (Execute Command) runs the policy script in the sandbox:
- Command:
["python", "/policy/check.py", "--amount", "{{classify.amount}}", "--category", "{{classify.category}}"] - Timeout (s):
30
The policy script lives in your reference data and is mounted into the sandbox at runtime. Output is a JSON object like {verdict: "auto-approve" | "needs-review" | "auto-reject", reason: "..."}.
5. HITL Review (when needed)#
node_review (HITL Review) is gated by an upstream Decision node that branches on policy_check.verdict. When the verdict is needs-review, the flow pauses and publishes a review request to the configured ScaiQueue queue.
Sections of the review form:
- Context: the original expense, classification, policy verdict + reason.
- Input: editable approval amount (in case the reviewer wants to partial-approve).
- Decision:
approve,reject,edit_and_approve.
6. Notify#
node_notify (Queue Publish) publishes a decision-made message to the queue downstream systems consume:
{ "queue": "expense-review",
"message_type": "decision-made",
"payload": {
"decision": "{{node_review.decision}}",
"amount": "{{node_classify.amount}}"
} }
7. Destroy the sandbox#
node_destroy (Destroy Sandbox) runs in parallel to the review path, releasing resources as soon as the policy check is done. Wired via a sequential edge from node_provision.out directly to node_destroy.in_bunker — fire-and-forget.
8. Flow-level config#
- Models: one primary (the classifier model).
- Bunker:jsonc
{ "lifecycle": "ephemeral", "image": "python-3.12", "cpu_millicores": 1000, "memory_mb": 512, "network_profile": "registry" } - ScaiQueue:jsonc
{ "scope": "finance-approvals", "queues": [ { "slug": "expense-review", "purpose": "review", "consumer_mode": "fifo", "max_retries": 3 }, { "slug": "expense-escalation", "purpose": "escalation", "consumer_mode": "priority", "max_retries": 5 } ] } - Plugins:
scaibunker,scaiqueue(auto-added by the compiler).
9. Deploy#
The deploy pipeline:
- Provisions the
finance-approvalsscope + two queues in ScaiQueue. - Creates the Core in ScaiGrid.
10. Invoke#
1 2 3 4 | |
For high-amount or off-category expenses, the invocation pauses at the HITL Review. Reviewers see a card in ScaiQueue's review UI; clicking approve resolves the checkpoint and the flow continues to publish the decision.
What you learned#
- ScaiBunker integrates as five
compute_*node kinds; flow-levelbunkercapability + per-call args. - HITL Review under Option B: one checkpoint with
hitl_target, runtime owns the publish + resolve. - Pre-deploy ScaiQueue provisioning is automatic when
flow.config.scaiqueueis set. - Parallel branches (review path + destroy path) are just two outgoing edges from the same source port.
Next#
- Publish as chat model — expose this flow as a chat surface so a chat client can submit expenses conversationally.