---
title: 'Tutorial: Expense approval with HITL'
path: tutorials/expense-approval-with-hitl
status: published
---

# 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

```mermaid
flowchart LR
  Entry["API Entry"]
  Classify["Classify<br/>(Flexible)"]
  Provision["Provision Sandbox"]
  Policy["Policy Check<br/>(Exec)"]
  Review["Review<br/>(HITL)"]
  Notify["Notify<br/>(Queue Publish)"]
  Destroy["Destroy Sandbox"]

  Entry --> Classify --> Provision --> Policy --> Review --> Notify
  Provision --> Destroy
```

## 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**: `1000` millicores
- **Memory**: `512` MB
- **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:

```jsonc
{ "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:

1. Provisions the `finance-approvals` scope + two queues in ScaiQueue.
2. Creates the Core in ScaiGrid.

### 10. Invoke

```bash
curl -X POST "https://scaigrid.scailabs.ai/v1/modules/scaicore/cores/${CORE_ID}/invoke" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"input": {"text": "Conference ticket, $847 for ScaiCon 2026, billed by Acme Events"}}'
```

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-level `bunker` capability + 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.scaiqueue` is set.
- Parallel branches (review path + destroy path) are just two outgoing edges from the same source port.

## Next

- **[Publish as chat model](./publish-as-chat-model)** — expose this flow as a chat surface so a chat client can submit expenses conversationally.
