Module 22C of 30
Agentic Coding
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.

Track 7: Production ⏱ ~18 min read 22C / 30
Concept 1 of 6

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.

Concept 1 of 6

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.

Concept 1 of 6

What the machines take over

  1. Catch unsafe patternsA linter rejects eval(), shell=True, and other footguns — deterministically, not "if the reviewer notices."
  2. Find leaked secretsA security scanner flags the hardcoded password the agent added to make a test pass.
  3. Block the bad commitA pre-commit gate refuses to record code that fails the checks — nothing enters history.
  4. Hand back the reasonThe specific error goes to the agent, which fixes exactly that and retries. The loop closes without a human in it.
Concept 1 of 6

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.

Concept 1 of 6

Misconceptions

"A smarter model needs fewer gates."
Speed is the problem, not intelligence. A capable agent ships more plausible diffs faster, so the review bottleneck gets worse, not better. Gates scale; a human reading every line does not.
"Just tell it in the system prompt to write safe code."
A prompt is a probabilistic request. Under pressure to finish, the model can ignore it. Anything that must hold every time belongs in a deterministic gate outside the model.

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.

Concept 2 of 6

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.

Concept 2 of 6

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.

Concept 2 of 6

Three tools, three jobs

  1. ruff — style + bugsCatches unused imports, undefined names, mutable default args. Fast, auto-fixes much of it.
  2. bandit — securityFlags known Python anti-patterns: hardcoded passwords (B105), shell=True, eval(), try/except/pass (B110).
  3. ast-grep — your policyEncodes rules specific to your codebase as structural patterns, e.g. "no swallowed exception (except: pass)."
  4. Chain on exit codesRun all three; any non-zero exit fails the gate. Same exit-code contract you used for headless agents.
Concept 2 of 6

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"
Concept 2 of 6

Misconceptions

"grep for the dangerous string is good enough."
Text search false-positives on strings and comments and misses aliased or re-spaced calls. As a security gate it's theater you'll regret.
"Linters need a cloud service."
ruff, bandit, and ast-grep are free and fully local — they run offline in milliseconds, perfect for a gate on every commit.

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.

Concept 3 of 6

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.

Concept 3 of 6

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.

Concept 3 of 6

Config + correction loop

  1. Declare the hooksA .pre-commit-config.yaml lists layers: whitespace/size hygiene, then ruff, then bandit, then your ast-grep rules. pre-commit install wires them into Git.
  2. Commit triggers the gateOn git commit, the hooks run on the staged files. A non-zero exit blocks the commit.
  3. Feed the failure backCapture the gate's exact output and send it to Claude with the file: "this was rejected, return the corrected file."
  4. 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.
Concept 3 of 6

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
Concept 3 of 6

Misconceptions

"Let the agent retry the gate until it passes."
Unbounded retries on a gate the model can't satisfy spin forever. Cap the fixups and return a distinct exit code so an outer system escalates instead of merging junk.
"Just tell the agent to 'fix the code.'"
Vague feedback gets a guess. Feeding the exact gate output ("B105, line 7") makes the fix targeted — the deterministic error is the teaching signal.

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.

Concept 4 of 6

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.

Concept 4 of 6

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.

Concept 4 of 6

The hook lifecycle

  1. Register the matcherIn .claude/settings.json, a PreToolUse entry matches Write|Edit and runs your hook command.
  2. Claude proposes an editBefore the Write/Edit executes, Claude Code passes the tool input (file path + new content) to the hook on stdin.
  3. The hook lints the proposed contentWrite it to a temp file, run bandit and ast-grep on it.
  4. 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.
Concept 4 of 6

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
Concept 4 of 6

Misconceptions

"Exit 1 blocks the tool call."
No — in a Claude Code hook, exit 2 is the blocking signal and sends stderr back to Claude. Exit 0 allows; exit 1 is a non-blocking error.
"The hook replaces the pre-commit gate."
They're complementary. The hook catches problems at write time in a Claude Code session; the pre-commit gate still catches anything written outside that session or by another tool.

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.

Concept 5 of 6

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.

Concept 5 of 6

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.

Concept 5 of 6

Local → push → CI → merge

  1. Local gate runsPre-commit (and the write-time hook) fire on your machine — fast feedback before the commit.
  2. Push to remoteThe commit goes up to the shared repository.
  3. CI re-runs the identical checksA workflow runs pre-commit run --all-files on a fresh, clean runner — the same config, where --no-verify has no effect.
  4. 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.
Concept 5 of 6

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.
Concept 5 of 6

Misconceptions

"If pre-commit passed locally, CI is redundant."
Local hooks can be skipped, disabled, or missing on a clone. The one commit that dodged the local gate is the one CI must still catch on a clean server.
"CI should run a lighter set of checks for speed."
CI re-runs the identical config (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.

Concept 6 of 6

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.

Concept 6 of 6

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.

Concept 6 of 6

Data in, image out

  1. Define a render toolA render_chart tool with a schema: labels, values, title. Its job is to draw, not decide.
  2. Claude picks the dataIn the tool loop, the model reasons it needs a visual and calls render_chart with structured arguments.
  3. Deterministic code rendersmatplotlib (headless) draws a PNG into memory — correct and consistent every time.
  4. 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.
Concept 6 of 6

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
Concept 6 of 6

Misconceptions

"Just ask the model to emit SVG or ASCII art."
Unreliable and token-hungry. The pixels should never depend on the model's drawing ability — let deterministic rendering code produce them.
"Return the chart as a file path."
The host may not share your filesystem. Return a base64 image content block — and Claude can then see and verify its own output.

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.

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.

Open M22C on Desktop → Full gate build · hooks + linting + CI + rich output · ~60 min

Module 22C of 30 · Track 7: Production Deployment