# n8n with AI Agents: the Approve pattern and a ten-check workflow scan (free sample)

Version 1.0, 2026-10-31. By PaperChaseWebb. For anyone running n8n next to AI agents, or switching on templates they bought or downloaded. One page and one Python 3 script, standard library only, read-only: it reads one workflow export and never talks to n8n. The script was tested with 5 unit tests on Linux with Python 3.13, 3.12, 3.11, 3.9 and 3.8, on the six workflows from the full pack and a made-up bought template.

## The Approve pattern

Any n8n node that emails, messages or writes to someone outside your business should wait for a person. In n8n 2 that takes three nodes:

1. An Email (or Gmail, Slack, Telegram) node with operation Send and Wait for Response, response type Approval, sent to the owner, with a wait limit.
2. An If node that checks `{{ $json.data.approved }}` is true.
3. The real send on the If node's true output only. The false output (Decline, or the wait limit passing) goes nowhere.

Most people stop after step 1. Without step 2, a Decline click lets the run carry on and the email still goes out. We proved that on a real n8n 2.45.7 with a copy of our lead reply workflow that had the If removed.

One thing to know before you test: n8n ignores an Approve link opened by a script or a link scanner and only acts on a real browser click. So test by clicking, not with curl, and do not worry that a mail filter pre-opening links will approve anything.

## Rules to paste into AGENTS.md, CLAUDE.md or a Grok Bot's standing instructions

1. Draft n8n workflow files only. Never call the n8n API, import, publish or start anything on the owner's n8n.
2. Never put keys, tokens or passwords in a workflow file. Refer to credentials by name; the owner adds them.
3. Every outside send goes behind the three-node Approve pattern above, with a wait limit.
4. Every webhook or form trigger gets authentication. Every HTTP Request gets a timeout. Keep files inactive with no pinned data.
5. Run `python3 n8n_mini.py <file>` after every edit and paste the output. Ask the owner before adding a new recipient or anything to do with payments.

## What the script checks

| Code | Finding |
|---|---|
| M1 | no trigger node |
| M2 | a connection points to a node that does not exist |
| M3 | a send after an Approve step is not gated on approved being true |
| M4 | a send with no Approve step before it |
| M5 | a node type removed or disabled by default in n8n 2 |
| M6 | a webhook or form with no authentication |
| M7 | an HTTP Request with no timeout |
| M8 | a secret-shaped value in the file |
| M9 | pinned test data left in the file |
| M10 | the file is marked active |

```
python3 n8n_mini.py workflow.json
```

Exit 0 when clean, 1 with findings, 2 on bad input. The sample has no rules file, so an email to yourself (a daily report, an error alert) shows as M4. Read those and decide; the full pack lets you list your own addresses so they pass.

On a made-up "AI Lead Responder" template of the kind sold in bulk packs it finds 8 problems, including a webhook anyone can call, an API key in the file and a Slack post with nothing in front of it.

## Install in one line

Save this page as FREE-SAMPLE.md, then:

```
python3 -c "import re;t=open('FREE-SAMPLE.md').read();open('n8n_mini.py','w').write(re.search(r'~~~python\n(.*?)~~~',t,re.S).group(1))"
```

## The script

~~~python
#!/usr/bin/env python3
"""n8n_mini.py: ten quick read-only checks for one n8n workflow export. Free sample from n8n with AI Agents (PaperChaseWebb).
Python 3.8+, standard library only. Usage: python3 n8n_mini.py workflow.json   Exit 0 clean, 1 findings, 2 bad input."""
import json, re, sys

B = "n8n-nodes-base."
SENDS = {"emailSend", "gmail", "slack", "telegram", "discord", "microsoftOutlook", "microsoftTeams", "whatsApp", "twilio", "sendGrid", "mailgun", "stripe"}
REMOVED = {"start", "spontit", "crowdDev", "kitemaker", "automizy", "executeCommand", "localFileTrigger"}
SECRET = re.compile(r"sk-(?:proj-)?[A-Za-z0-9_\-]{20,}|sk_live_[A-Za-z0-9]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|gh[pousr]_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|Bearer\s+[A-Za-z0-9._\-]{24,}")


def check(w):
    F = []
    ns = [n for n in w["nodes"] if n.get("type") != B + "stickyNote"]
    names = {n["name"] for n in w["nodes"]}
    by = {n["name"]: n for n in ns}
    nxt = {}
    for src, outs in (w.get("connections") or {}).items():
        for bi, br in enumerate((outs or {}).get("main") or []):
            for c in br or []:
                if c.get("node") not in names or src not in names:
                    F.append("M2 connection to a missing node: %s -> %s" % (src, c.get("node")))
                else:
                    nxt.setdefault(src, []).append((bi, c["node"]))
    trig = [n["name"] for n in ns if n["type"].endswith("Trigger") or n["type"] in (B + "webhook", B + "start")]
    if not trig:
        F.append("M1 no trigger node")
    ask = {n["name"] for n in ns if (n.get("parameters") or {}).get("operation") == "sendAndWait"}

    def is_send(n):
        p = n.get("parameters") or {}
        t = n["type"]
        if n["name"] in ask or n.get("disabled"):
            return False
        if t == B + "httpRequest":
            return str(p.get("method", "GET")).upper() in ("POST", "PUT", "PATCH", "DELETE")
        return t.startswith(B) and t[len(B):] in SENDS and p.get("operation") not in ("get", "getAll", "search")

    seen, stack = set(), list(trig)
    while stack:
        x = stack.pop()
        if x in seen:
            continue
        seen.add(x)
        if x in ask:
            continue
        stack += [d for _, d in nxt.get(x, [])]
    for a in sorted(ask):
        after, st = set(), [a]
        while st:
            x = st.pop()
            if x in after:
                continue
            after.add(x)
            n = by.get(x, {})
            gate = n.get("type") in (B + "if", B + "filter") and "approved" in json.dumps(n.get("parameters"))
            st += [d for bi, d in nxt.get(x, []) if not (gate and bi == 0)]
        for x in sorted(after):
            if x != a and x in by and is_send(by[x]) and x not in seen:
                F.append("M3 send after Approve without an If on approved: " + x)
    for n in ns:
        t, p = n["type"], n.get("parameters") or {}
        if n["name"] in seen and is_send(n):
            F.append("M4 send with no Approve step before it: " + n["name"])
        if t.startswith(B) and t[len(B):] in REMOVED:
            F.append("M5 node removed or off by default in n8n 2: " + n["name"])
        if t in (B + "webhook", B + "formTrigger") and p.get("authentication", "none") == "none":
            F.append("M6 webhook or form with no authentication: " + n["name"])
        if t == B + "httpRequest" and not (p.get("options") or {}).get("timeout"):
            F.append("M7 HTTP Request with no timeout: " + n["name"])
        if SECRET.search(json.dumps(p)):
            F.append("M8 secret-shaped value in the file: " + n["name"])
    if any((w.get("pinData") or {}).values()):
        F.append("M9 pinned test data left in the file")
    if w.get("active") is True:
        F.append("M10 file is marked active")
    return F


def main(argv):
    if len(argv) != 2:
        print(__doc__.strip().splitlines()[-1], file=sys.stderr)
        return 2
    try:
        with open(argv[1], encoding="utf-8") as f:
            w = json.load(f)
        assert isinstance(w, dict) and isinstance(w.get("nodes"), list)
    except (OSError, ValueError, AssertionError):
        print("bad input: not an n8n workflow export", file=sys.stderr)
        return 2
    F = check(w)
    for f in F:
        print(f)
    print("CLEAN" if not F else "%d finding(s). The full pack has 19 checks, six tested workflows and the go-live card." % len(F))
    return 1 if F else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
~~~

## The full pack

n8n with AI Agents ($29) has six workflows for a made-up service business (lead reply, invoice reminders, review requests, a daily report, a gate that lets your agents ask before anything is sent, and an error alert), each imported and run on n8n 2.45.7 with 44 of 44 live checks passing. It adds a 19-check scanner with a rules file and node list support, a go-live card tied to the exact file, and setup for Grok Bot, Claude Code, Codex and Hermes. If you would rather have it set up on your own n8n, book a call at paperchasewebb.ai or write to mgmt@paperchasewebb.com.

License: free to use inside your own team or company. Do not resell or republish it.
