---
summary: "HITL recipe \u2014 publish a message with a hitl_spec, render it in the\
  \ admin UI, complete it with a reviewer's decision."
title: Add a human review step
path: tutorials/human-review
status: published
---

You're going to add a human-in-the-loop step to an otherwise automated flow: a publisher attaches a `hitl_spec` to messages, the admin UI renders them as review forms, a reviewer submits a decision, and your consumer reads the decision off the completed message. About 15 minutes.

## 1. Pick a scope and a review queue

You need a scope and a dedicated queue where review-needing messages land. Either reuse an existing scope or:

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaiqueue/scopes" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug": "moderation", "display_name": "Moderation"}'
```

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaiqueue/scopes/$SCOPE/queues" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug": "review-pending", "display_name": "Review Pending", "ordering": "fifo"}'
```

Reviewers will see this queue under "Queues → Messages" in the admin UI.

## 2. Register a reusable HITL pattern

Patterns live in the scope's pattern registry; publishers reference them by name and pass parameters. This keeps the rendering spec in one curated place rather than scattered through publish calls.

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaiqueue/scopes/$SCOPE/hitl-patterns" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "moderation-decision",
    "version": "1.0",
    "spec": {
      "sections": [
        {"type": "context", "title": "Submission", "content": "{{summary}}"},
        {"type": "context", "title": "Reason flagged", "content": "{{flag_reason}}"},
        {"type": "decision", "field": "decision",
         "options": ["approve", "reject", "needs_more_info"]},
        {"type": "input", "field": "note", "kind": "text", "required": false}
      ],
      "timeout_s": 86400
    }
  }'
```

Preview the expanded spec with sample parameters before any real publishes:

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaiqueue/scopes/$SCOPE/hitl-patterns/moderation-decision/expand" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"version": "1.0",
       "parameters": {"summary": "User posted: ...", "flag_reason": "automated-classifier-low-confidence"}}'
```

## 3. Publish a review-needing message

Attach the expanded spec to the message as `hitl_spec`. In practice your publisher would expand the pattern server-side, then publish.

```python
import httpx, os
H = {"Authorization": f"Bearer {os.environ['SCAIGRID_API_KEY']}", "Content-Type": "application/json"}
HOST = os.environ["SCAIGRID_HOST"]
SCOPE, QUEUE = os.environ["SCOPE_ID"], os.environ["QUEUE_ID"]

spec = httpx.post(
    f"{HOST}/v1/modules/scaiqueue/scopes/{SCOPE}/hitl-patterns/moderation-decision/expand",
    headers=H,
    json={"version": "1.0",
          "parameters": {"summary": "User posted: ...", "flag_reason": "low-confidence"}},
).json()["data"]["expanded"]

msg = httpx.post(
    f"{HOST}/v1/modules/scaiqueue/scopes/{SCOPE}/queues/{QUEUE}/messages",
    headers=H,
    json={
        "type": "moderation.review",
        "body": {"submission_id": "sub_42"},
        "labels": {"queue": "moderation"},
        "correlation_id": "sub_42",
        "hitl_spec": spec,
    },
).json()["data"]
print("waiting for review:", msg["id"])
```

```javascript
const expand = await (await fetch(
  `${HOST}/v1/modules/scaiqueue/scopes/${SCOPE}/hitl-patterns/moderation-decision/expand`,
  { method:"POST", headers:H,
    body:JSON.stringify({version:"1.0", parameters:{summary:"User posted: ...", flag_reason:"low-confidence"}})}
)).json();
const msg = (await (await fetch(
  `${HOST}/v1/modules/scaiqueue/scopes/${SCOPE}/queues/${QUEUE}/messages`,
  { method:"POST", headers:H, body:JSON.stringify({
      type:"moderation.review",
      body:{submission_id:"sub_42"},
      correlation_id:"sub_42",
      hitl_spec: expand.data.expanded,
    })}
)).json()).data;
```

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaiqueue/scopes/$SCOPE/queues/$QUEUE/messages" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "moderation.review",
    "body": {"submission_id": "sub_42"},
    "correlation_id": "sub_42",
    "hitl_spec": { "sections": [ ... ], "timeout_s": 86400 }
  }'
```

The message is now pending in the review queue. The admin UI's `/scaiqueue/messages` view renders it as a structured form using the spec.

## 4. Reviewer completes the message

When a reviewer submits the form, the admin UI calls `POST /scopes/{scope_id}/messages/{msg_id}/complete` with a response body carrying the decision and any inputs:

```json
{
  "response": {
    "decision": "approve",
    "note": "Marginal but on-policy.",
    "reviewer_id": "usr_jane"
  }
}
```

The message moves to `completed`. ScaiGrid publishes a `scaiqueue.message.completed` event on the internal event bus with the original `correlation_id` and the response payload — so any worker waiting on this submission can react without polling.

## 5. Wire the consumer

The consumer that started this flow either listens for `scaiqueue.message.completed` events on the event bus, or polls the message back out:

```bash
curl "$SCAIGRID_HOST/v1/modules/scaiqueue/scopes/$SCOPE/queues/$QUEUE/messages/$MSG_ID" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY"
```

The full message representation includes the `response` field with the reviewer's decision.

## Patterns that work

- **One pattern per decision type.** Don't templatize a generic "form"; make one pattern per kind of decision (`refund-approval`, `content-moderation`, `kyc-review`). Reviewers learn the layout faster.
- **Always set a timeout_s in the spec.** A pending review that no one notices for two weeks is worse than an auto-rejected one. Use `_dead_letter` plus a follow-up rule to handle silent reviews.
- **Carry the correlation_id all the way through.** Trace a single submission across automated → review → automated stages by querying `GET /scopes/{scope_id}/messages?correlation_id=...`.
- **Permission split.** Reviewers need `scaiqueue:consume`; publishers need `scaiqueue:publish`. Maintainers of the pattern registry need `scaiqueue:manage`.
