Academy › Advanced Marketing Automation with Claude › Lesson 7

The human approval queue

Hold every outbound draft in a queue, with a history, until a named person approves it. · 9 min read

Automation without a gate is a liability. The gate does not need to be fancy; it needs to be impossible to skip.

What you will build

A queue that stores drafts as pending, lets a person move them to approved or rejected, and refuses to mark anything sent unless it was approved.

States: pending goes to approved or rejected, approved goes to sent Figure 1. The only path to “sent” goes through “approved”.

Step 1: The queue

# approval_queue.py
"""A tiny human-approval queue stored as JSON lines. Nothing is sent until status == approved."""
from __future__ import annotations
import json
import time
from pathlib import Path

ALLOWED = {"pending", "approved", "rejected", "sent"}


class Queue:
    def __init__(self, path: str):
        self.path = Path(path)
        self.path.touch(exist_ok=True)

    def _read(self) -> list[dict]:
        return [json.loads(l) for l in self.path.read_text(encoding="utf-8").splitlines() if l.strip()]

    def _write(self, items: list[dict]) -> None:
        self.path.write_text("".join(json.dumps(i, ensure_ascii=False) + "\n" for i in items), encoding="utf-8")

    def add(self, item_id: str, kind: str, body: str, meta: dict | None = None) -> None:
        items = self._read()
        if any(i["id"] == item_id for i in items):
            return  # idempotent: re-running a job does not duplicate drafts
        items.append({"id": item_id, "kind": kind, "body": body, "meta": meta or {},
                      "status": "pending", "created": int(time.time()), "history": []})
        self._write(items)

    def set_status(self, item_id: str, status: str, who: str) -> None:
        if status not in ALLOWED:
            raise ValueError(f"bad status {status}")
        items = self._read()
        for i in items:
            if i["id"] == item_id:
                if status == "sent" and i["status"] != "approved":
                    raise PermissionError("only approved items can be sent")
                i["history"].append({"from": i["status"], "to": status, "by": who, "at": int(time.time())})
                i["status"] = status
                self._write(items)
                return
        raise KeyError(item_id)

    def by_status(self, status: str) -> list[dict]:
        return [i for i in self._read() if i["status"] == status]

Three behaviours are deliberate:

  • Idempotent add. Running the job twice does not create two drafts for the same id. Choose ids from the thing (lead id, campaign id and date), not from a random number.
  • Illegal transition raises. sent without approved throws PermissionError. The rule is enforced in code, not left to discipline.
  • History. Every change records who and when. When someone asks “who approved this?”, you can answer.

Step 2: A review command

Reviewers should not edit JSON by hand. A tiny command-line tool is enough for a solo marketer or a two-person team:

# review.py
import sys
from approval_queue import Queue

q = Queue("queue.jsonl")
who = sys.argv[1] if len(sys.argv) > 1 else "me"

for item in q.by_status("pending"):
    print("\n" + "=" * 60)
    print(f"{item['id']}  [{item['kind']}]  {item['meta'].get('title', '')}")
    print(item["body"])
    answer = input("approve / reject / skip? ").strip().lower()
    if answer == "approve":
        q.set_status(item["id"], "approved", who)
    elif answer == "reject":
        q.set_status(item["id"], "rejected", who)

Step 3: The sender

Sending is a separate, boring script that only touches approved items. It is the only code that has credentials for your email tool.

# send_approved.py
from approval_queue import Queue

def send(item):          # replace with your email tool's draft or send call
    print("sending:", item["id"])

q = Queue("queue.jsonl")
for item in q.by_status("approved"):
    send(item)
    q.set_status(item["id"], "sent", "sender-bot")

Because the sender reads only approved, a bug in the drafting pipeline cannot cause a send.

Step 4: Make approving fast

A gate that is painful gets bypassed. Three habits help:

  1. Show what changed. Include the source facts and the check results next to the draft.
  2. Batch the review. One 10-minute slot per day beats scattered interruptions.
  3. Graduate low-risk items. After a month with a 98% approval rate for one kind (for example internal summaries), you may auto-approve that kind, but never customer-facing sends from the red list.

Test it

The queue tests cover: a duplicate add is ignored, sent before approved raises, the history shows approved then sent, and an unknown id raises KeyError. They run in milliseconds and need no files outside a temporary folder.

Worked example

A coaching business let Claude draft replies to enquiry emails. For two weeks every draft went through the queue. Reviewers rejected 11 of 80, mostly for one pattern: the draft promised a call within 24 hours. They added that to the prompt rules (lesson 2) and to the citation check (lesson 5), and the rejection rate fell to 3 of 80 the next fortnight. The queue’s history made the pattern visible.

Common mistakes

  • Approving in the same tool that sends, so the click does both.
  • No “rejected” state, so you never learn from what was refused.
  • Sharing one login so history says “admin” for everything.

Checklist

  • Nothing outbound is sent except from approved items
  • Illegal transitions raise errors
  • History records who and when
  • Review has a fixed slot and a short command