Building AI Agents with Claude
Track 7: Production Deployment Engineering Practices Module
⏱ 60 min 📊 Advanced

M22C: Agentic Coding Quality Gates & Rich Output

In M25 you configured Claude Code with reusable slash commands and CLAUDE.md; in M26 you wired hooks. This module puts that machinery to work on the highest-stakes case: code an agent writes and commits. You'll add structural linting that catches what regex cannot, pre-commit gates that turn a rejection into a self-correction loop, Claude Code hooks that block unsafe edits before they happen, and rich visual output — an agent that returns a rendered chart, not just prose.

Learning Objectives

  • Explain why agent-written code needs stricter deterministic gates than human-written code
  • Use structural linting (ruff, bandit, ast-grep) to catch semantic problems a text search misses
  • Wire a pre-commit gate and feed its failure back to Claude as a correction loop
  • Block unsafe edits at write time with a Claude Code PreToolUse hook
  • Layer local pre-commit + server-side CI so a skipped hook still can't reach main
  • Produce rich visual output: a tool that renders a chart and returns it as an image content block
  • Build a self-correcting commit gate with the Anthropic SDK end to end

Why Agent-Written Code Is Different

Before the Pain

BEFORE: Picture a tireless senior-speed developer who never gets bored and is happy to attempt any task at 3 a.m. You ask for a function; seconds later it exists, with tests, looking completely plausible. For routine work this is a superpower — a week of grunt work done before lunch.

PAIN: The trap is that "looks completely plausible" is exactly the failure mode. A human who is unsure pauses, asks, or leaves a TODO. An agent rarely pauses. Under pressure to make a task "done," it can invent an API that does not exist, hardcode a secret to make a test pass, swallow an exception to silence an error, or quietly remove a check it did not understand — at machine speed, across many files, with a commit message that says "done." Human code review, the thing that catches a careless person, cannot keep pace with diffs produced faster than you can read them.

MAPPING: The fix is to stop relying on a human reading every line and put machines in the path: deterministic checks the agent must satisfy before code is allowed to exist. A linterA program that analyzes source code for problems without running it: style violations, likely bugs, unsafe patterns. Deterministic — same input, same verdict every time. that rejects unsafe patterns; a security scanner that finds the hardcoded secret; a pre-commit gateA set of checks that run automatically just before a commit is recorded. If any check fails, the commit is blocked and nothing enters Git history. that blocks the commit and hands the error back. The agent does not get annoyed by strict rules — it reads the error, fixes the code, and tries again. Humans review intent; machines enforce the floor.

Why It Matters: Throughput Cuts Both Ways

A human opens 3–5 reviewable pull requests a day. A Claude coding agent on a loop can open 40+ commits an hour. If your only safety net is "someone reads the diff," you've moved the bottleneck from writing to reviewing — and a tired reviewer rubber-stamps. One hardcoded secret, one swallowed exception, one eval() on user input that slips through is an incident. The gates here run in seconds, identically, on every commit, so human review can focus on "is this the right change?" instead of "did it do something dangerous?"

We'll build the gates from the inside out: first the linters that judge the code, then the commit gate that ties verdicts to action, then the Claude Code hook that blocks unsafe edits before they're even written, and finally rich visual output.

Structural Code Linting

Before the Pain

BEFORE: Your first instinct for "ban eval() in agent output" is a text search: grep -r "eval(" .. Fast, simple, and for about a day it works.

PAIN: Then the false positives and negatives arrive. grep flags self.evaluate() and a comment that says "don't eval this" — noise. Worse, it misses the real thing: e = eval; e(user_input), or getattr(builtins, "ev"+"al"), or the dangerous call on a line your regex never anticipated. Text search sees characters, not meaning; it cannot tell a function call from the same letters in a string, so it is simultaneously too noisy and too leaky to be a security gate.

MAPPING: A structural linterA linter that analyzes the code's Abstract Syntax Tree (its parsed grammatical structure) rather than its raw text — matching the shape of code, immune to spacing, aliasing, and string look-alikes. reads code the way the compiler does: it parses the source into an ASTAbstract Syntax Tree: a tree representation of source code's structure. eval(x) becomes a Call node whose function is the name eval. Matching the tree is immune to whitespace, comments, and string look-alikes. and matches on the shape of the code — "a call to eval with any argument" — deterministically, in milliseconds. ruff (style + bugs), bandit (security), and ast-grep (your own structural rules) all do exactly this.

ToolCatchesExample it flags in agent code
ruffStyle violations & likely bugs (a fast linter + formatter)Unused import, undefined name, mutable default arg
banditKnown security anti-patterns in PythonHardcoded password, subprocess with shell=True, eval()
ast-grepYour own structural rules, any language"No except: pass", "no print in library code"

The first two are batteries-included. ast-grep is where you encode your policy. Here is a rule banning the swallowed exception an agent loves to use to make an error "go away," plus how all three run.

WHAT: A structural rule that matches a swallowed exception by shape, not text
WHY: It catches except Exception: pass however spaced or named, and never false-matches a string
GOTCHA: Structural rules match the parse tree — test them on real code so a broad pattern doesn't flag legitimate uses
# rules/no-swallowed-except.yml  —  run:  ast-grep scan -r rules/no-swallowed-except.yml .
id: no-swallowed-except
language: python
severity: error
message: "Swallowed exception: handle the error or re-raise, never 'except: pass'."
rule:
  kind: try_statement
  has:
    kind: except_clause
    has:
      kind: block
      has:
        kind: pass_statement
      not:
        has:
          kind: expression_statement   # the except body is ONLY a bare 'pass'
# Install once (all free, all local):
pip install ruff bandit
#   ast-grep:  brew install ast-grep   |   cargo install ast-grep   |   npm i -g @ast-grep/cli

ruff check db.py
#   db.py:3:1: F401 [*] `os` imported but unused

bandit -q db.py
#   >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2'
#      Severity: Low   Confidence: Medium   Location: db.py:7

ast-grep scan -r rules/no-swallowed-except.yml db.py
#   error[no-swallowed-except]: Swallowed exception: handle the error or re-raise.
#     --> db.py:12:5

# Any non-zero exit = the gate fails. Chain them:
ruff check db.py && bandit -q db.py && ast-grep scan -r rules/ db.py
Why Structural Beats Textual for a Security Gate

A regex gate is security theater you'll regret. eval (user_input) with a space, builtins.eval(x), or aliasing ev = eval all sail past grep "eval" but are identical to the AST: a call whose target resolves to eval. A structural rule matches the call node itself, so spacing, aliasing, and comments cannot evade it, and a string literal "see the eval docs" cannot trigger a false alarm. For a gate deciding whether agent code reaches production, that determinism is the whole point.

What Just Happened?

You now have three deterministic judges of agent code — ruff, bandit, and your own ast-grep policy — each exiting non-zero on a violation. That shared exit-code contract is what lets a gate branch on them. Next: put these judges in front of the commit so failing code physically cannot enter history.

Linters you have to remember to run are linters you'll forget to run — especially when the committer is an agent. The fix is to make the checks automatic and blocking, fired by Git at the moment of commit.

Pre-Commit Gates & the Correction Loop

Definition: Pre-Commit Gate

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 these as hooks in a .pre-commit-config.yaml: whitespace fixers, ruff, bandit, ast-grep, secret scanners. For a human it's a safety net; for an agent it's a teacher — the agent hits the failing gate, reads the error, fixes the code, and re-commits. The gate turns a vague "write good code" into a concrete, machine-checkable loop.

The animation traces that loop — the most important idea in this module. The agent writes code and tries to commit. The gate runs and rejects it. The agent reads the specific error, fixes exactly that, and commits again. This time the gate passes and the commit lands. The rejection is feedback, not a dead end.

The Correction Loop — a Failing Gate Is a Lesson
🤖
Claude writes code
git commit
🚧
Gate runs
ruff+bandit+ast-grep
Rejected
B105 hardcoded pw
🔧
Claude reads & fixes
re-commit
Gate passes
commit lands
Claude writes code and runs git commit. The pre-commit gate (ruff + bandit + ast-grep) rejects it with a specific error (e.g. B105 hardcoded password). Claude reads that error, fixes exactly that problem, and re-commits. The gate now passes and the commit lands.

Configuring the Gate

WHAT: A layered pre-commit config: hygiene, then ruff, then bandit, then your ast-grep rules
WHY: One file makes every commit — human or agent — clear the same floor before it can be recorded
GOTCHA: Order matters; cheap auto-fixers first so expensive checks run on already-tidied files
# .pre-commit-config.yaml — the gate every commit must pass
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-added-large-files     # block a 200MB blob from an agent gone wrong
      - id: check-yaml
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/PyCQA/bandit
    rev: 1.7.10
    hooks:
      - id: bandit
        args: ["-q"]
  - repo: local
    hooks:
      - id: ast-grep-policy
        name: ast-grep structural rules
        entry: ast-grep scan -r rules/
        language: system
        types: [python]
pip install pre-commit
pre-commit install            # hooks now fire on every `git commit`

# A blocked commit (agent committed a hardcoded password):
$ git commit -m "add db client"
ruff................................................................Passed
bandit..............................................................Failed
- hook id: bandit
- exit code: 1
  >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2'
     Location: db.py:7
#   --> the commit did NOT happen. Nothing entered history.

pre-commit run --all-files ; echo "gate exit=$?"   # run the gate by hand anytime

Closing the Loop with the Anthropic SDK

When the gate rejects a commit, feed its exact output back to Claude and ask for a fix — the correction loop, in code.

WHAT: A driver that commits, and on a blocked commit feeds the gate output back to Claude to fix
WHY: The "gate as teacher" pattern — the agent improves from the real error, not a guess
GOTCHA: Cap retries; stop on a clean pass or exhaustion, never loop on the gate forever
# commit_with_fixups.py — let the gate teach Claude until the commit is clean
# WHAT: Try to commit; if the gate blocks it, send the error to Claude, fix, retry
# WHY:  Turns "write good code" into a concrete loop graded by deterministic hooks
# GOTCHA: Bound the retries; a gate Claude can't satisfy must fail loudly, not spin
import subprocess, sys
from anthropic import Anthropic

client = Anthropic()                              # reads ANTHROPIC_API_KEY from env
MODEL = "claude-sonnet-4-6"
TARGET = "db.py"

def run_gate() -> tuple[bool, str]:
    proc = subprocess.run(["pre-commit", "run", "--files", TARGET],
                          capture_output=True, text=True, timeout=120)
    return proc.returncode == 0, proc.stdout + proc.stderr

def ask_fix(code: str, gate_output: str) -> str:
    resp = client.messages.create(
        model=MODEL, max_tokens=2000,
        system="You are a Python engineer. Return ONLY the corrected file, no prose, no fences.",
        messages=[{"role": "user", "content":
            f"This file was rejected by our commit gate.\n\nFILE ({TARGET}):\n{code}\n\n"
            f"GATE OUTPUT:\n{gate_output}\n\nReturn the full corrected file."}])
    return resp.content[0].text.strip()           # first text block = the fixed file

def commit_clean(max_fixups: int = 3) -> int:
    for attempt in range(max_fixups + 1):
        subprocess.run(["git", "add", TARGET], check=True)
        passed, output = run_gate()
        if passed:
            subprocess.run(["git", "commit", "-m", "agent change (gate-clean)"], check=True)
            print(f"[gate] passed on attempt {attempt}; commit landed", file=sys.stderr)
            return 0
        print(f"[gate] blocked on attempt {attempt}:\n{output}", file=sys.stderr)
        if attempt == max_fixups:
            break
        fixed = ask_fix(open(TARGET, encoding="utf-8").read(), output)
        open(TARGET, "w", encoding="utf-8").write(fixed)   # Claude's correction
    print("[gate] still failing after retries — escalate to a human", file=sys.stderr)
    return 2                                       # bad output: do not auto-merge

if __name__ == "__main__":
    sys.exit(commit_clean())
// commit_with_fixups.js — let the gate teach Claude until the commit is clean
// WHAT: Try to commit; if the gate blocks it, send the error to Claude, fix, retry
// WHY:  Turns "write good code" into a concrete loop graded by deterministic hooks
// GOTCHA: Bound the retries; a gate Claude can't satisfy must fail loudly, not spin
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();                    // reads ANTHROPIC_API_KEY from env
const MODEL = "claude-sonnet-4-6";
const TARGET = "db.py";

function runGate() {
  try {
    execFileSync("pre-commit", ["run", "--files", TARGET], { stdio: "pipe", timeout: 120000 });
    return { passed: true, output: "" };
  } catch (e) {
    return { passed: false, output: (e.stdout || "") + (e.stderr || "") };
  }
}

async function askFix(code, gateOutput) {
  const resp = await client.messages.create({
    model: MODEL, max_tokens: 2000,
    system: "You are a Python engineer. Return ONLY the corrected file, no prose, no fences.",
    messages: [{ role: "user", content:
      `This file was rejected by our commit gate.\n\nFILE (${TARGET}):\n${code}\n\n` +
      `GATE OUTPUT:\n${gateOutput}\n\nReturn the full corrected file.` }],
  });
  return resp.content[0].text.trim();              // first text block = the fixed file
}

async function commitClean(maxFixups = 3) {
  for (let attempt = 0; attempt <= maxFixups; attempt++) {
    execFileSync("git", ["add", TARGET]);
    const { passed, output } = runGate();
    if (passed) {
      execFileSync("git", ["commit", "-m", "agent change (gate-clean)"]);
      console.error(`[gate] passed on attempt ${attempt}; commit landed`);
      return 0;
    }
    console.error(`[gate] blocked on attempt ${attempt}:\n${output}`);
    if (attempt === maxFixups) break;
    fs.writeFileSync(TARGET, await askFix(fs.readFileSync(TARGET, "utf8"), output));
  }
  console.error("[gate] still failing after retries — escalate to a human");
  return 2;                                         // bad output: do not auto-merge
}

process.exit(await commitClean());
What Just Happened?

The gate and Claude are now in a closed loop. Claude commits; the deterministic hooks judge; on rejection the exact gate output goes back to the model, which fixes that specific thing and re-commits. Success lands the commit and returns 0; exhausting retries returns 2 so an outer system escalates instead of merging junk. No human read a line — yet nothing unsafe could enter history.

The commit gate catches problems after Claude writes the file. With Claude Code, you can do something stronger: stop an unsafe edit before it's written, using a hook.

Gating Edits at Write Time with a Claude Code Hook

Definition: PreToolUse Hook

A Claude Code PreToolUse hookA command Claude Code runs *before* it executes a matching tool (e.g. Write or Edit). The hook receives the tool's input on stdin. Exiting with code 2 blocks the tool call and returns the hook's stderr to Claude as a reason to reconsider. runs before Claude executes a tool like Write or Edit. The hook 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 a reason. So instead of catching a hardcoded secret at commit time, you can refuse to even write it — and tell Claude why, so it self-corrects on the spot. This is the M26 hook machinery applied to code safety.

WHAT: A PreToolUse hook that lints the content Claude is about to Write/Edit and blocks unsafe edits
WHY: Stops a bad pattern from ever hitting disk, and tells Claude why so it fixes itself in-session
GOTCHA: Exit code 2 (not 1) is the blocking signal; stderr is what Claude reads as the reason
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "python .claude/gate_hook.py" }
        ]
      }
    ]
  }
}
# .claude/gate_hook.py — block an unsafe Write/Edit before it touches disk
# WHAT: Read the proposed tool input, lint the new content, exit 2 to block
# WHY:  Catches the problem at WRITE time and tells Claude the reason to self-correct
# GOTCHA: Claude Code passes the tool input as JSON on stdin; exit 2 = block + show stderr
import sys, json, subprocess, tempfile, os

data = json.load(sys.stdin)                        # the PreToolUse payload
tool_input = data.get("tool_input", {})
# Write provides 'content'; Edit provides 'new_string'
content = tool_input.get("content") or tool_input.get("new_string") or ""
path = tool_input.get("file_path", "")
if not path.endswith(".py") or not content.strip():
    sys.exit(0)                                    # not Python / nothing to check: allow

# Lint the PROPOSED content via a temp file
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
    f.write(content); tmp = f.name
try:
    bandit = subprocess.run(["bandit", "-q", tmp], capture_output=True, text=True)
    astg   = subprocess.run(["ast-grep", "scan", "-r", "rules/", tmp],
                            capture_output=True, text=True)
finally:
    os.unlink(tmp)

problems = ""
if bandit.returncode != 0: problems += bandit.stdout + bandit.stderr
if astg.returncode   != 0: problems += astg.stdout + astg.stderr

if problems:
    # stderr is shown to Claude; exit 2 BLOCKS the Write/Edit so Claude reconsiders
    print(f"Blocked: the edit to {path} fails the quality gate.\n{problems}", file=sys.stderr)
    sys.exit(2)
sys.exit(0)                                        # clean: allow the write
🎓 Cert Tip — Hooks & Determinism

A favorite exam theme: when to use a hook versus a prompt instruction. "Please don't commit secrets" in CLAUDE.md is a probabilistic request the model may ignore under pressure. A PreToolUse hook that exits 2 is a deterministic guarantee enforced outside the model. The rule of thumb the cert rewards: if a requirement must hold every time (security, compliance, irreversible actions), encode it as a hook or an external gate — never rely on prompting alone.

What Just Happened?

You now have defense at two moments: the PreToolUse hook refuses to write an unsafe edit (and tells Claude why, so it fixes in-session), and the pre-commit gate refuses to commit one that slipped through. Both are deterministic, both run in seconds, and both hand the model a specific reason rather than a vague "be careful."

Hooks and pre-commit protect your local machine. But a hook can be disabled and a local commit can skip --no-verify. The authoritative gate has to live somewhere the agent can't bypass: CI.

Two Layers: Pre-Commit + CI

A local pre-commit hook protects your machine, but it can be skipped (git commit --no-verify), misconfigured, or absent on a fresh clone. So the same checks must run again on a clean server after the code is pushed — in CIContinuous Integration: a server that automatically runs your checks on every push, on a fresh clean machine. It can't be skipped with --no-verify, so it is the authoritative gate before merge.. Two layers: pre-commit catches mistakes before the commit; CI catches them before the merge.

Two Layers: Local Pre-Commit → Server CI
💻
Local hook + pre-commit
your machine
git push
to remote
CI re-runs gate
clean server
Merge allowed
protected main
A local hook + pre-commit gate runs on your machine. After git push, CI re-runs the identical checks on a clean server. Only if CI passes is the merge to a protected main branch allowed. Local can be skipped; CI cannot.
WHAT: A CI job re-running the exact pre-commit gate on a clean runner, plus a concurrency rule
WHY: CI can't be skipped with --no-verify, so it's the authoritative gate before merge
GOTCHA: Agents push many small commits fast — concurrency cancels stale runs so you don't waste CI on outdated code
# .github/workflows/gate.yml — the same checks, re-run where they can't be skipped
name: Quality gate
on: [push, pull_request]

# Agents push many small commits; cancel superseded runs to save CI minutes.
concurrency:
  group: gate-${{ github.ref }}
  cancel-in-progress: true

jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - name: Install the gate
        run: pip install pre-commit
      # Re-run the IDENTICAL hooks on a clean machine. Non-zero exit blocks the merge.
      - name: Run pre-commit on all files
        run: pre-commit run --all-files --show-diff-on-failure
Why It Matters: --no-verify Exists

Local hooks are advisory: anyone — or any agent — can bypass them with git commit --no-verify, and a fresh clone that never ran pre-commit install has no hooks at all. If local were your only gate, the one commit that skips it is the one that breaks production. CI re-running the same config on a clean server, gating a protected branch that refuses merges on a red check, is what makes the floor non-optional. Pre-commit is the fast feedback you want; CI is the guarantee you need.

Gates govern the code an agent writes. The last piece is what an agent returns: not every answer is best delivered as prose. Sometimes the right output is a picture the agent generated itself.

Rich & Visual Output

Definition: Visual Output Generation

Visual output generation is an agent producing a visual artifact — a chart, a diagram, a rendered image — as the result of its work, not just describing one. Claude does not draw pixels; it calls a tool that renders the visual, and the tool returns an image content blockA piece of message content of type "image" carrying base64 bytes and a media type. Claude's tool_result can include image blocks, so a tool can hand back a rendered picture, and Claude can even "see" the image it produced to verify it.. So "plot last quarter's filings by month" ends with an actual chart, generated deterministically by code Claude orchestrated — and because Claude can see the returned image, it can verify the chart matches the request.

A number in a sentence and a trend line tell a human very different stories. The key engineering choice: keep the rendering in deterministic code and let Claude decide when and with what data to call it. Here is a chart tool wired into the Messages API tool loop, returning an image tool_result.

WHAT: A render_chart tool whose result is an image block; Claude orchestrates when to call it
WHY: Lets the agent answer "show the trend" with a real, deterministic visual it can also verify
GOTCHA: Return the image as a base64 tool_result block — not a file path the model can't open
# chart_agent.py — Claude calls a render_chart tool that returns an IMAGE.  pip install anthropic matplotlib
# WHAT: A tool loop where the tool result is a rendered PNG, not text
# WHY:  The model decides what to chart; deterministic code draws it correctly
# GOTCHA: Use the headless 'Agg' backend; return an image content block in tool_result
import io, base64, json
import matplotlib
matplotlib.use("Agg")                              # no display on a server
import matplotlib.pyplot as plt
from anthropic import Anthropic

client = Anthropic()
MODEL = "claude-sonnet-4-6"

TOOLS = [{
    "name": "render_chart",
    "description": "Render a bar chart from labels and values; returns a PNG image.",
    "input_schema": {
        "type": "object",
        "properties": {
            "labels": {"type": "array", "items": {"type": "string"}},
            "values": {"type": "array", "items": {"type": "number"}},
            "title":  {"type": "string"},
        },
        "required": ["labels", "values"],
    },
}]

def render_chart(labels, values, title="") -> str:
    fig, ax = plt.subplots(figsize=(6, 3.5))
    ax.bar(labels, values, color="#3B82F6")
    ax.set_title(title); fig.tight_layout()
    buf = io.BytesIO(); fig.savefig(buf, format="png", dpi=120); plt.close(fig)
    return base64.standard_b64encode(buf.getvalue()).decode()   # base64 PNG

def run(prompt: str):
    messages = [{"role": "user", "content": prompt}]
    for _ in range(6):                              # bounded tool loop
        resp = client.messages.create(model=MODEL, max_tokens=1024,
                                      tools=TOOLS, messages=messages)
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            return next((b.text for b in resp.content if b.type == "text"), "")
        tool_results = []
        for block in resp.content:
            if block.type == "tool_use" and block.name == "render_chart":
                png_b64 = render_chart(**block.input)
                open("chart.png", "wb").write(base64.b64decode(png_b64))   # save artifact
                tool_results.append({
                    "type": "tool_result", "tool_use_id": block.id,
                    "content": [{"type": "image", "source": {
                        "type": "base64", "media_type": "image/png", "data": png_b64}}],
                })
        messages.append({"role": "user", "content": tool_results})

if __name__ == "__main__":
    print(run("Chart these monthly filing counts: Jan 12, Feb 19, Mar 27. Title it Q1 Filings."))
// chart_agent.js — Claude calls a render_chart tool that returns an IMAGE.
//   npm i @anthropic-ai/sdk chartjs-node-canvas chart.js
import fs from "node:fs";
import Anthropic from "@anthropic-ai/sdk";
import { ChartJSNodeCanvas } from "chartjs-node-canvas";

const client = new Anthropic();
const MODEL = "claude-sonnet-4-6";
const canvas = new ChartJSNodeCanvas({ width: 600, height: 350 });

const TOOLS = [{
  name: "render_chart",
  description: "Render a bar chart from labels and values; returns a PNG image.",
  input_schema: {
    type: "object",
    properties: {
      labels: { type: "array", items: { type: "string" } },
      values: { type: "array", items: { type: "number" } },
      title:  { type: "string" },
    },
    required: ["labels", "values"],
  },
}];

async function renderChart({ labels, values, title = "" }) {
  const png = await canvas.renderToBuffer({
    type: "bar",
    data: { labels, datasets: [{ data: values, backgroundColor: "#3B82F6" }] },
    options: { plugins: { title: { display: !!title, text: title } } },
  });
  return png.toString("base64");
}

async function run(prompt) {
  const messages = [{ role: "user", content: prompt }];
  for (let i = 0; i < 6; i++) {                     // bounded tool loop
    const resp = await client.messages.create({ model: MODEL, max_tokens: 1024,
                                                 tools: TOOLS, messages });
    messages.push({ role: "assistant", content: resp.content });
    if (resp.stop_reason !== "tool_use")
      return resp.content.find((b) => b.type === "text")?.text ?? "";
    const toolResults = [];
    for (const block of resp.content) {
      if (block.type === "tool_use" && block.name === "render_chart") {
        const pngB64 = await renderChart(block.input);
        fs.writeFileSync("chart.png", Buffer.from(pngB64, "base64"));   // save artifact
        toolResults.push({ type: "tool_result", tool_use_id: block.id,
          content: [{ type: "image", source: {
            type: "base64", media_type: "image/png", data: pngB64 } }] });
      }
    }
    messages.push({ role: "user", content: toolResults });
  }
}

console.log(await run("Chart these monthly filing counts: Jan 12, Feb 19, Mar 27. Title it Q1 Filings."));

The animation shows the full path: a question comes in, Claude reasons it needs a picture, calls render_chart with structured data, and the rendered image returns as the artifact.

Claude → render_chart Tool → a Rendered Image
User asks
"chart Q1 filings"
🤖
Claude
picks data + tool
📊
render_chart
matplotlib
📈
Image result
PNG, verifiable
The user asks Claude to chart Q1 filings. Claude reasons it needs a visual, calls the render_chart tool with structured labels and values, matplotlib renders a PNG, and the image returns as a tool_result block Claude can also see to verify.
Why It Matters: Orchestrate, Don't Hallucinate

It's tempting to ask a model to "draw" by emitting SVG or ASCII art directly. Don't — that wastes tokens and produces inconsistent visuals. The robust pattern is the one here: the model supplies structured data and intent, and battle-tested rendering code (matplotlib, Chart.js) produces a correct image every time. Returning it as an image block has a bonus only multimodal models like Claude get: the model can look at its own output and catch "the axis labels are wrong" before handing it to the user. The agent decides what to chart; the tool draws it right; Claude verifies.

You now have linters, a commit gate with a correction loop, a write-time hook, a CI backstop, and visual output. The lab assembles the gate pieces into one self-correcting workflow.

Lab: A Self-Correcting Commit Gate

What you'll build: a workflow where Claude writes a small module, a pre-commit gate rejects unsafe code, and the correction loop drives Claude to a clean, committed version — with a PreToolUse hook catching the worst cases even earlier. Time: ~40 min. Prereqs: ANTHROPIC_API_KEY set; git; Python 3.12.

mkdir gate-lab && cd gate-lab && git init
pip install anthropic ruff bandit pre-commit
# ast-grep:  brew install ast-grep  (or)  cargo install ast-grep  (or)  npm i -g @ast-grep/cli
mkdir -p rules .claude
export ANTHROPIC_API_KEY=sk-ant-...   # your key
Lay down the gate + rules

Create .pre-commit-config.yaml (all four layers) and rules/no-swallowed-except.yml. Run pre-commit install, then pre-commit run --all-files to confirm the gate is live on an empty repo.

Add the write-time hook

Drop .claude/settings.json and .claude/gate_hook.py from the hooks section into the repo. In a Claude Code session, ask Claude to write db.py with a hardcoded password and watch the Write get blocked with the bandit reason — Claude should immediately offer a fixed version.

Have the agent write flawed code via the API

Outside Claude Code, prompt the Anthropic SDK to "write db.py that connects to Postgres." Models sometimes hardcode a password or wrap connect in try/except: pass. Write the reply to db.py (inject password = "hunter2" if needed to see the gate fire).

Run the correction loop

Run commit_with_fixups.py. It runs the gate, feeds the exact failure back to Claude, writes the corrected file, and retries up to 3 times. Watch stderr go "blocked on attempt 0" then "passed on attempt 1" as the secret and exception handling are fixed.

Add the CI backstop

Add .github/workflows/gate.yml. Confirm the contract: it re-runs pre-commit run --all-files on a clean machine, so a teammate who used --no-verify is still caught before merge on a protected branch.

Definition of Done

A hardcoded-secret Write is blocked at write time by the hook; a flawed commit is blocked by the gate (nothing in git log); the correction loop lands exactly one gate-clean commit; exhausting retries returns exit 2 and escalates; and you can explain why CI is required even though the local gate passed. That's a production-shaped agentic coding workflow: humans review intent, machines enforce the floor.

Knowledge Check

Test your understanding of structural linting, pre-commit gates, Claude Code hooks, and rich output.

1. Why does agent-written code need stricter automated gates than human-written code?

A
Agents write in languages humans can't read
B
Linters only work on machine-generated code
C
Agents produce plausible-looking code at machine speed and never pause to ask, so human review can't keep up — deterministic gates enforce the floor instead
D
Agent code is always slower, so checks are cheap

2. Why is a structural linter (AST-based) better than grep "eval(" as a security gate?

A
grep is too slow to run on every commit
B
AST tools require a cloud API
C
grep produces no output
D
Text search matches characters, so it false-positives on strings/comments and misses aliased or re-spaced calls; an AST rule matches the actual call node, immune to both

3. The module calls a failing pre-commit gate a "teacher" for an agent. Why?

A
Because it deletes the agent's code automatically
B
The specific error from the rejection is fed back to Claude, which fixes exactly that and re-commits — a concrete correction loop the agent never tires of
C
Because it fine-tunes the model weights
D
Because it only runs once per project

4. In a Claude Code PreToolUse hook, what exit code blocks the tool call and returns a reason to Claude?

A
Exit code 0
B
Exit code 2 — the hook's stderr is shown to Claude as the reason to reconsider
C
Exit code 1, which silently allows the call
D
Any exit code; hooks can't block tools

5. You have a local pre-commit gate and a PreToolUse hook. Why is a CI job re-running the checks still necessary?

A
CI runs a different, weaker set of checks
B
Local hooks are faster but less accurate
C
Local hooks can be skipped (--no-verify), disabled, or absent on a fresh clone; CI re-runs on a clean server and gates a protected branch, so the floor is non-optional
D
CI is required by the Git specification

6. What's the right way to give a Claude agent visual output like a chart?

A
Ask the model to emit SVG or ASCII art directly
B
Fine-tune Claude to draw pixels
C
Have Claude supply structured data and call a deterministic rendering tool that returns an image content block — which Claude can even see to verify
D
Charts can't be returned through the Messages API
0/6