byrobxAI.

Human in the loop: nothing goes live without you

Build approval gates, permission rules, a blocking hook and a simple log into your setup, so your AI employees prepare the work and you decide what goes out.

Deep dive. 14 min read. Updated 25 October 2026.

My AI employees write, plan, check and render. They never publish, send or upload. This guide shows how I make that a fact of the setup, not a promise in a prompt.

Why this matters

An AI employee works fast and sounds sure of itself, even when it’s wrong. Most mistakes are cheap: a weak hook, a typo, a caption that’s too long. You fix them in a minute.

Some actions can’t be undone. A post that goes live. An email that reaches your whole list. A file that overwrites your business knowledge. For those, “the AI usually gets it right” is not good enough.

So I split the work in two:

  • The AI prepares. It writes drafts into an output/ folder, renders files, suggests changes.
  • I decide. I approve, and then I publish or send it myself.

Everything below follows from that split.

Two approval gates

One check at the very end is too late. By then you’ve already paid for a voice-over or spent an hour on visuals for a script that was wrong. So I use two gates.

Gate 1: text Gate 2: final
When After writing, before production After production, before it goes out
What you check Hook, caption, script, slide text The rendered video, carousel or email as people will see it
Why here Text is cheap to change. Paid or slow production only starts after a yes. You see what text alone can’t show: timing, layout, readability
Outcome Approved, or back with a reason Approved, or back with a reason

The rule: production only starts on approved text. Shipping only happens after approved output.

Where the reviewer fits

Before gate 1, I let the post reviewer (BX-004, keyword CHECK) read every post. It checks the post against a fixed list of rules and gives PASS or FIX per rule, with one concrete fix. It never rewrites anything without telling you.

The reviewer is not the gate. It’s the colleague who reads your draft before you look at it. It catches the obvious things, so your own review goes to judgement: is this good, is this me, is this true?

Common mistake: treating an AI PASS as approval. The reviewer can only check rules it knows. You’re the one who knows whether a claim is true.

Proposals, not direct writes

Your business knowledge (who you are, what you sell, how you write) is the base every employee reads from. If an employee can change it freely, one wrong edit spreads into everything.

So employees never write there directly. They write a proposal: a small file in a proposals/ folder that says:

  • which file it wants to change
  • what the text was
  • what it becomes
  • why, with a source
---
target: brain/voice.md
action: change
submitted-by: social department
date: 2026-10-25
---

## Was
Sentences: short.

## Becomes
Sentences: short. Max 15 words in a spoken hook.

## Why
The reviewer flagged long hooks three weeks in a row.

Example: this proposal is fictional and only shows the format.

You read it, and you apply it or throw it away. Your knowledge only changes when you say so.

The four layers

Instructions alone are not enough. The Claude Code docs say it directly: CLAUDE.md files are context, not enforced configuration. To block something whatever Claude decides, you need settings or a hook.

So I stack four layers. Each one catches what the one before can miss.

  1. Instructions in CLAUDE.md and rules files: “You never publish.”
  2. Permission rules in .claude/settings.json: deny or ask for specific tools and commands.
  3. A hook that reads every shell command before it runs and blocks the dangerous ones.
  4. A lock in the code: the publish script itself refuses to run unless a variable is set by a human.

Layer 2: permission settings

Claude Code reads permission rules from .claude/settings.json in your project. There are three lists:

List What it does
allow Claude can use this without asking you
ask Claude must ask you every time
deny Claude can’t use this at all

The order matters. Claude Code checks deny first, then ask, then allow. The first match wins, and a more specific rule doesn’t change that order. An allow rule can’t make an exception to a deny rule.

A small example:

{
  "permissions": {
    "allow": [
      "Read",
      "Edit",
      "Bash(npm run test *)"
    ],
    "ask": [
      "Bash(git push *)"
    ],
    "deny": [
      "Read(./.env)",
      "Bash(python3 scripts/publish.py *)"
    ]
  }
}

What this does:

  • Claude can read and edit files and run tests without asking.
  • Every git push asks you first.
  • Claude can’t read your .env file, where your keys live.
  • Claude can’t run the publish script.

A * stands for any text. Bash(git push *) matches every command that starts with git push.

Two things the docs are clear about:

  • Bash rules match the command text, not the program. Bash(git push *) stops git push origin main, but not the same thing written another way, like bash -c 'git push origin main'. A deny rule covers the form Claude usually writes. It’s not a wall. That’s why layers 3 and 4 exist.
  • Allow rules in a shared project file wait for trust. The first time someone opens the folder, Claude Code asks whether they trust it. Allow rules apply after that. Deny and ask rules apply right away, because they only restrict.

Layer 3: a hook that blocks publishing

A hook is a small script that Claude Code runs automatically at a fixed moment. A PreToolUse hook runs before a tool is used. It can look at what Claude is about to do and stop it.

This is the layer that reads the full command text with your own logic. It doesn’t depend on Claude remembering a rule.

The script

Save this as .claude/hooks/block-publish.py:

#!/usr/bin/env python3
"""PreToolUse hook: block shell commands that publish or send."""
import json
import re
import sys

BLOCKED = [
    (r"PUBLISH_ENABLED\s*=\s*1", "tries to switch the publish lock on"),
    (r"scripts/publish\.py", "runs the publish script"),
    (r"scripts/send_newsletter\.py", "runs the newsletter send script"),
]

try:
    data = json.load(sys.stdin)
except Exception:
    sys.exit(0)  # can't read the input: no decision, the permission rules still apply

command = data.get("tool_input", {}).get("command", "")

for pattern, reason in BLOCKED:
    if re.search(pattern, command, re.IGNORECASE):
        print(
            f"Blocked by block-publish hook: this command {reason}. "
            "Publishing and sending are done by a human. Ask the user.",
            file=sys.stderr,
        )
        sys.exit(2)

sys.exit(0)

Register it

Add the hook to .claude/settings.json, next to your permissions:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-publish.py\""
          }
        ]
      }
    ]
  }
}
  • PreToolUse is the moment: before a tool runs.
  • "matcher": "Bash" means it only runs for the Bash tool, the one that runs shell commands. A matcher like "Edit|Write" would match either of those two tools.
  • $CLAUDE_PROJECT_DIR is the root of your project. Claude Code fills it in, so the path works wherever you start.

How the hook talks back: exit codes

An exit code is the number a script returns when it ends. Claude Code reads that number to decide what happens next. For a PreToolUse hook:

Exit code What happens
0 No objection. The command goes through the normal permission rules. Exit 0 does not approve it.
2 Blocked. The command doesn’t run. What you wrote to stderr (the error output) is sent to Claude as the reason, so it can adjust.
Anything else, including 1 A non-blocking error. The command still runs. You see a short error notice.

That last row catches people out. In most programs, exit 1 means failure. In a Claude Code hook, exit 1 doesn’t block anything. If your hook is meant to enforce a rule, use exit 2.

The JSON option

Instead of exit 2, a hook can exit 0 and print a JSON object. JSON is a structured text format with keys and values. This gives you more control:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Publishing is done by a human. Ask the user."
  }
}

permissionDecision can be:

  • "deny": cancel the tool call and send the reason to Claude.
  • "ask": show you the normal permission prompt.
  • "allow": skip the prompt. Deny and ask rules from your settings still apply, so a hook can’t open a door your settings closed.

The docs say to choose one approach per hook: exit codes, or exit 0 with JSON. Don’t mix them.

Test it before you trust it

Run the script yourself with a fake input:

echo '{"tool_name":"Bash","tool_input":{"command":"python3 scripts/publish.py --now"}}' \
  | python3 .claude/hooks/block-publish.py; echo "exit: $?"

You should see the “Blocked” message and exit: 2. Then try a harmless command like ls and check for exit: 0. Type /hooks in Claude Code to see that the hook is registered.

Common mistake: a hook that crashes. If your script fails with an error, it exits with a code other than 2, and the command runs. Test every hook with a blocked and an allowed example.

Note: my hook exits 0 when it can’t read the input. Then the permission rules decide. If you’d rather block when in doubt, exit 2 there. Then a broken input blocks every shell command until you fix it.

Layer 4: a lock in the code

The last layer lives in the script that would actually publish. It checks an environment variable (a setting the operating system passes to every program) before it does anything.

Set the lock in .claude/settings.json. The env key sets variables for every Claude Code session and the commands it runs:

{
  "env": {
    "PUBLISH_ENABLED": "0"
  }
}

And in the publish script:

import os
import sys

if os.environ.get("PUBLISH_ENABLED") != "1":
    sys.exit("Publishing is locked. PUBLISH_ENABLED must be 1, set by a human.")

Now the script refuses to run inside Claude Code, even if every other layer failed. When you want to publish, you run the script yourself, in your own terminal, with PUBLISH_ENABLED=1 set on purpose.

Notice how the layers fit together. The settings set the lock to 0. The hook blocks any command that tries to set it to 1. The script refuses unless it’s 1.

Tip: for email, use a double lock. The send function needs SEND_ENABLED=1 and a second flag that says you’re not in test mode. If one is missing, it stops with a clear message instead of failing silently. Better still: let the AI only create drafts in your email tool. Sending is a button you press.

What to log

A log is how you know later what happened and why. Keep it simple: one line per event in a text file, in JSON. Log:

  • Every gate decision. Approved, sent back or killed, with the reason.
  • Who decided. You, or which employee.
  • What it was about. The post, email or proposal ID.
  • When. A timestamp.
  • Every block. When the hook stopped a command, write down which one.

One line per decision:

{"ts": "2026-10-25T14:02:00Z", "item": "week-43/post-03", "gate": "text", "from": "pending", "to": "back", "reason": "Hook claims a number without a source", "actor": "robin"}

Example: this log line is fictional.

Never overwrite an earlier line. Only add. Then the log shows the whole history, including the decisions you changed your mind about.

Why bother? Because the reasons you send work back are your best teaching material. After a few weeks you see the same reason three times. That reason becomes a rule in your reviewer, or a proposal for your brain folder.

Checklist

Check:

  1. Do your employees write only into output/ and proposals/?
  2. Is there a text gate before anything slow or paid starts?
  3. Is there a final gate where you see the real output before it goes out?
  4. Does every change to your business knowledge go through a proposal?
  5. Is your .env file in the deny list?
  6. Are publish and send commands in deny or ask?
  7. Is there a PreToolUse hook that blocks publish and send commands with exit 2?
  8. Did you test that hook with one blocked and one allowed command?
  9. Does the publish or send code itself check a lock variable?
  10. Is every decision logged with a reason, and never overwritten?

If you can answer yes to all ten, nothing goes live without you.

Next

Read From three skills to a department to see how several employees, their rules and these gates fit into one folder.

Free downloads from this guide

  • BX-004Marketing, skill

    The post reviewer

    Reads your post against seven fixed rules and gives PASS or FIX per rule, with one concrete fix. It never rewrites behind your back.

    Free skill

    In the starter kit