Quality Gates & Rich Output
Once an agent can write and commit code, the question stops being "can it produce output?" and becomes "can I trust it to merge?" Six concepts: the machine gates that let humans review intent while machines enforce the floor.
The 6 concepts of quality gates
Tap any concept to jump to its 5-card cluster.
- 1Why Agent Code Is DifferentMachines enforce the floor
- 2Structural Code LintingAST beats regex: ruff, bandit, ast-grep
- 3Pre-Commit Gates & the LoopA failing gate becomes a teacher
- 4Write-Time HookBlock an unsafe edit before it lands
- 5Pre-Commit + CIThe layer that can't be skipped
- 6Rich & Visual OutputThe agent returns a chart, not prose
Humans review intent; machines enforce the floor
An agent produces plausible-looking code at machine speed and almost never pauses to ask. A human who is unsure leaves a TODO; an agent confidently invents an API, hardcodes a secret to pass a test, or swallows an exception — across many files, faster than you can read the diff.
So you stop relying on a human reading every line and put deterministic machine checks in the path: linters, security scanners, and commit gates the agent must satisfy before its code is allowed to exist. The human reviews whether it's the right change; the machines guarantee it isn't dangerous.
The tireless junior who never says "I'm unsure"
BEFORE: You hire a junior who types 200 words a minute, never gets bored, and attempts anything at 3 a.m. Ask for a function; seconds later it exists, with tests, looking completely plausible.
PAIN: "Looks plausible" is the failure mode. A careful human pauses or asks; this junior never does. It hardcodes a password to make a test green and commits with the message "done" — dozens of times an hour, faster than any reviewer can keep up. Your code review, the thing that catches a careless human, drowns.
MAPPING: The fix isn't a better reviewer — it's a turnstile. Deterministic gates run in seconds on every commit and reject the unsafe pattern outright. The agent doesn't get annoyed; it reads the error, fixes it, and tries again.
What the machines take over
- Catch unsafe patternsA linter rejects
eval(),shell=True, and other footguns — deterministically, not "if the reviewer notices." - Find leaked secretsA security scanner flags the hardcoded password the agent added to make a test pass.
- Block the bad commitA pre-commit gate refuses to record code that fails the checks — nothing enters history.
- Hand back the reasonThe specific error goes to the agent, which fixes exactly that and retries. The loop closes without a human in it.
Prompt it, or gate it?
# Question: how do I enforce this rule on agent code? IF the rule must hold EVERY time (security, secrets, compliance, irreversible actions): USE a deterministic gate / hook # a prompt is a request the model may ignore ELIF it's a style preference or a "usually do this" nudge: USE a prompt instruction (CLAUDE.md) ELSE: PREFER the gate # machines enforce; humans review intent
"Please don't commit secrets" in a prompt is probabilistic. A scanner that exits non-zero is a guarantee.
Misconceptions
Agent code needs stricter automated gates, not looser ones. The agent's speed is exactly why human review can't be the only net.
Humans review intent ("is this the right change?"); machines enforce the floor ("is it safe?") in seconds, on every commit.
Read the code the way the compiler does
A structural linter parses source into an Abstract Syntax Tree (AST) — the grammar of the code — and matches on the shape of a construct, like "a call to eval with any argument." That's immune to spacing, aliasing, comments, and string look-alikes.
Three free, local tools cover it: ruff (style + likely bugs), bandit (Python security anti-patterns), and ast-grep (your own structural rules, any language). Each exits non-zero on a violation — the contract a gate branches on.
Spell-check vs Find & Replace
BEFORE: To ban eval() your first instinct is grep "eval(" — a text Find. Fast, simple, works for a day.
PAIN: Find matches characters. It flags self.evaluate() and a comment "don't eval this" (false alarms) and misses the real thing: ev = eval; ev(x), builtins.eval(x), or eval (x) with a space. Simultaneously too noisy and too leaky to be a security gate.
MAPPING: A structural linter is spell-check — it understands grammar, not letters. It sees the actual call node regardless of how it's spelled, so aliasing and spacing can't evade it and a string can't trigger it.
Three tools, three jobs
- ruff — style + bugsCatches unused imports, undefined names, mutable default args. Fast, auto-fixes much of it.
- bandit — securityFlags known Python anti-patterns: hardcoded passwords (B105),
shell=True,eval(), try/except/pass (B110). - ast-grep — your policyEncodes rules specific to your codebase as structural patterns, e.g. "no swallowed exception (
except: pass)." - Chain on exit codesRun all three; any non-zero exit fails the gate. Same exit-code contract you used for headless agents.
Pseudocode
# A structural rule matches SHAPE, not text RULE no_swallowed_except: match: try { ... } except { ONLY pass } message: "handle the error or re-raise" # The gate = run all three, fail on any non-zero RUN ruff check file # style + bugs RUN bandit -q file # security (B105, B110...) RUN ast-grep scan rules/ file # your policy IF any exit_code != 0: gate = "FAIL" # block + collect the messages ELSE: gate = "PASS"
Misconceptions
grep for the dangerous string is good enough."Match the parse tree, not the characters. An AST rule sees the real call node, immune to spacing, aliasing, and look-alike text.
ruff (bugs) + bandit (security) + ast-grep (your policy), each exiting non-zero, give a gate three deterministic judges.
A failing gate is a teacher
A pre-commit gate is a set of checks Git runs automatically just before a commit is recorded. If any check fails, the commit is blocked — nothing enters history. The open-source pre-commit framework manages them as a list of hooks in one config file.
For a human it's a safety net. For an agent it's stronger: hand the gate's specific rejection back to the model, and it fixes exactly that and re-commits. "Write good code" becomes a concrete, machine-checkable loop the agent never tires of running.
The editor that won't let you hit Save
BEFORE: You write a document and save whenever you like. Mistakes get caught later — if someone proofreads.
PAIN: When the writer is an agent producing dozens of drafts an hour, "caught later" means broken text already shipped. By the time a human notices, the bad commit is in history.
MAPPING: The gate is an editor that refuses Save until the spell-check passes — and tells you exactly which word is wrong. The agent reads "B105 hardcoded password, line 7," fixes that line, and saves clean. The rejection is feedback, not a dead end.
Config + correction loop
- Declare the hooksA
.pre-commit-config.yamllists layers: whitespace/size hygiene, then ruff, then bandit, then your ast-grep rules.pre-commit installwires them into Git. - Commit triggers the gateOn
git commit, the hooks run on the staged files. A non-zero exit blocks the commit. - Feed the failure backCapture the gate's exact output and send it to Claude with the file: "this was rejected, return the corrected file."
- Retry, boundedRe-stage and re-run. On pass, the commit lands (exit 0). After N failed fixups, stop and escalate (exit 2) — never loop forever.
Pseudocode
FOR attempt IN 0..max_fixups: stage(file) passed, output = RUN pre-commit on file IF passed: commit("agent change (gate-clean)") RETURN 0 # success IF attempt == max_fixups: BREAK # give up, escalate # the gate teaches: send the real error back fixed = ASK Claude(file, gate_output=output) write(file, fixed) # the correction RETURN 2 # still failing -> human, do not auto-merge
Misconceptions
The gate blocks the commit and hands back the reason. The agent fixes exactly that and re-commits — a closed loop with no human in it.
Always bound the retries: pass → commit (0); exhausted → escalate (2). Never spin on the gate.
Block the unsafe edit before it lands
A Claude Code PreToolUse hook runs before Claude executes a tool like Write or Edit. It receives the proposed tool input on stdin. If it exits with code 2, Claude Code blocks the tool call and feeds the hook's stderr back to Claude as the reason.
So instead of catching a hardcoded secret at commit time, you refuse to even write it — and tell Claude why, so it self-corrects on the spot. It's the M26 hook machinery aimed at code safety.
Spell-check as you type vs proofread at the end
BEFORE: The pre-commit gate is a proofreader who reads the whole document at the end and rejects it if something's wrong. Effective — but the mistake was already typed and saved.
PAIN: Catching everything only at the end means wasted work: the agent writes a flawed file, stages it, hits the gate, then has to redo it. The earlier you catch it, the cheaper.
MAPPING: The PreToolUse hook is spell-check-as-you-type. The red squiggle appears before the word is committed to the page — the edit is intercepted at write time, with the reason shown immediately so the agent fixes it in-session.
The hook lifecycle
- Register the matcherIn
.claude/settings.json, aPreToolUseentry matchesWrite|Editand runs your hook command. - Claude proposes an editBefore the Write/Edit executes, Claude Code passes the tool input (file path + new content) to the hook on stdin.
- The hook lints the proposed contentWrite it to a temp file, run bandit and ast-grep on it.
- Exit code decidesClean → exit 0, the write proceeds. A violation → print the reason to stderr and exit 2 — the write is blocked and the reason goes to Claude.
Pseudocode
# .claude/settings.json wires this on Write|Edit payload = READ JSON from stdin content = payload.tool_input.content OR payload.tool_input.new_string IF file is not .py OR content is empty: EXIT 0 # nothing to check -> allow write(content -> temp.py) problems = RUN bandit, ast-grep on temp.py IF problems: PRINT problems -> stderr # shown to Claude EXIT 2 # BLOCK the write EXIT 0 # clean -> allow
Misconceptions
A PreToolUse hook stops an unsafe edit before it touches disk and tells Claude why, so it fixes in-session.
Exit 2 = block + reason to Claude. It's the earliest, cheapest place to enforce the floor.
One gate isn't enough
A local pre-commit hook protects your machine, but it can be skipped with git commit --no-verify, misconfigured, or simply absent on a fresh clone that never ran pre-commit install.
So the same checks run again on a clean server after the code is pushed — in CI. Two layers: pre-commit catches mistakes before the commit; CI catches them before the merge, gating a protected branch that refuses a red check.
Boarding-pass scan vs the security checkpoint
BEFORE: At the gate, a quick boarding-pass scan keeps the line moving. Fast, convenient, right where you need it.
PAIN: But the scan is easy to wave past, and a brand-new traveler might skip it entirely. If it were the only check, the one person who slipped by is exactly the risk.
MAPPING: CI is the central security checkpoint everyone must clear before the secure area — on neutral ground, not skippable. Pre-commit is the fast convenience you want; CI on a protected branch is the guarantee you need.
Local → push → CI → merge
- Local gate runsPre-commit (and the write-time hook) fire on your machine — fast feedback before the commit.
- Push to remoteThe commit goes up to the shared repository.
- CI re-runs the identical checksA workflow runs
pre-commit run --all-fileson a fresh, clean runner — the same config, where--no-verifyhas no effect. - Protected branch gates the mergeOnly if CI is green can the change merge to
main. A concurrency rule cancels stale runs when the agent pushes many small commits.
Which layer does which job
# Pre-commit (local) — the fast feedback you WANT runs: on your machine, at `git commit` speed: seconds, instant skippable: YES (--no-verify, fresh clone) job: catch mistakes before the commit # CI (server) — the guarantee you NEED runs: clean server, on every push speed: minutes skippable: NO (gates a protected branch) job: catch mistakes before the merge RULE: use BOTH. Local for speed, CI as the authoritative floor.
Misconceptions
pre-commit run --all-files). Divergence is how a check silently stops protecting you. Use concurrency cancellation for speed instead.Pre-commit is fast feedback; CI is the non-optional guarantee. Local can be skipped; a protected branch gated by CI cannot.
Run the same checks in both. Pre-commit before the commit, CI before the merge.
The agent returns a chart, not a sentence
Visual output generation is an agent producing a visual artifact — a chart, a diagram — as the result of its work. Claude doesn't draw pixels; it calls a tool that renders the visual, and the tool returns an image content block.
So "plot last quarter's filings by month" ends with an actual chart, drawn deterministically by code Claude orchestrated. And because Claude can see the returned image, it can verify the chart matches the request before handing it over.
Use the printer, don't draw freehand
BEFORE: You ask someone to "show the trend." They could sketch a chart freehand from memory.
PAIN: A freehand sketch (a model emitting SVG or ASCII art) is wonky and inconsistent, and it burns effort getting the axes wrong. The drawing depends on the artist's steadiness, which a small model doesn't have.
MAPPING: Hand the numbers to a printer. The model supplies data and intent; battle-tested rendering code (matplotlib, Chart.js) prints a correct chart every time. The agent decides what to chart; the tool draws it right; Claude looks at the result to confirm.
Data in, image out
- Define a render toolA
render_charttool with a schema: labels, values, title. Its job is to draw, not decide. - Claude picks the dataIn the tool loop, the model reasons it needs a visual and calls
render_chartwith structured arguments. - Deterministic code rendersmatplotlib (headless) draws a PNG into memory — correct and consistent every time.
- Return an image blockThe tool result is a base64 image content block, not a file path. Claude can see it to verify, and the host saves the artifact.
Pseudocode
DEFINE TOOL render_chart: schema: { labels[], values[], title? } body(args): png = DRAW BAR CHART(args) # matplotlib, headless RETURN base64(png) # tool loop FOR EACH step (bounded): reply = ASK Claude(prompt, tools=[render_chart]) IF reply has tool_use(render_chart): img = render_chart(reply.input) SEND BACK tool_result = { type: "image", data: img } # Claude can SEE it ELSE: RETURN reply.text # final answer
Misconceptions
Orchestrate, don't hallucinate. Claude supplies data and intent; a deterministic tool renders the image and returns it as a content block.
Because Claude can see the result, it can catch a wrong axis before the user does.
Test the core insight
One question per concept. Tap to reveal the answer.
grep "eval(" as a security gate?ev = eval) or re-spaced (eval (x)) calls. A structural rule is immune to spacing, aliasing, and look-alikes.--no-verify), disabled, or missing on a fresh clone. CI re-runs the identical checks on a clean server and gates a protected branch, so a commit that dodged the local gate still can't merge. Local is the fast feedback; CI is the guarantee.Open the full module on desktop
The desktop version is the full build — ruff/bandit/ast-grep configs, a .pre-commit-config.yaml, a PreToolUse gate hook, the Anthropic-SDK correction loop, a CI workflow, and a chart-returning tool — in Python and Node, with a hands-on lab that wires the whole self-correcting gate together.
Module 22C of 30 · Track 7: Production Deployment