AI as a Tool, Not a Crutch

Let's be honest: AI coding tools are genuinely impressive. You describe a problem, and something that would have taken you an hour appears in seconds. It feels like a superpower — until the moment you need to modify it, debug it, or explain it to someone else, and you realize you have no idea how it actually works.

That's the trap. And it's subtle enough that a lot of people don't notice until they're deep in it.

The core problem

When you generate code you don't understand, you're not programming — you're curating. There's nothing wrong with curation as a skill, but it's a different skill. If your goal is to actually understand what your system does, you need a different relationship with the tool.

The issue isn't that AI is bad. It's that autopilot mode — accepting output without questioning it — erodes the mental model you need to build good software.

Method 1: think in black boxes

This one comes from a professor of mine, and it stuck. The idea is simple: when you build a system — with AI or without — decompose it into small, well-defined black boxes.

A black box is a module or function where you know exactly three things: what goes in, what comes out, and what it's supposed to do. You don't need to know how it does it. That's the point.

When AI generates code, your job isn't to read every line — it's to make sure each piece behaves like the black box you asked for. Does it accept the right inputs? Does it return what it should? Does it break at the edges you care about? If yes, the box is sealed and you can move on.

This keeps you in control of the architecture — the thing that matters — while letting AI handle the internals of each box. And when something breaks, you can isolate exactly which box is misbehaving instead of debugging a wall of generated code you don't own.

The visualization below shows a simple pipeline of black boxes. Run it — it's just Python drawing what I just described.

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import io, base64, math
from PIL import Image as PILImage

BG       = '#ffffff'
FG       = '#555555'
FG_MUTED = '#888888'
BOX_BASE = '#2c2c2c'
BOX_FILL = '#111111'
ARROW_ON = '#00a846'
ARROW_OFF= '#cccccc'
WHITE    = '#ffffff'

BOX_X, BOX_Y, BOX_W, BOX_H = 3.3, 1.1, 3.4, 1.8
CX = BOX_X + BOX_W / 2
CY = BOX_Y + BOX_H / 2
PAD = 0.12
ARR_IN_X0,  ARR_IN_X1  = 1.0,  BOX_X - PAD
ARR_OUT_X0, ARR_OUT_X1 = BOX_X + BOX_W + PAD, 9.0
ARR_Y = CY

FRAMES = 140

fig, ax = plt.subplots(figsize=(9, 3.2), facecolor=BG)
ax.set_facecolor(BG)
ax.set_xlim(0, 10)
ax.set_ylim(0, 3.5)
ax.axis('off')
fig.subplots_adjust(left=0, right=1, top=1, bottom=0)

ax.text((ARR_IN_X0 + ARR_IN_X1) / 2, ARR_Y + 0.38, 'inputs',
        ha='center', va='bottom', fontsize=12, color=FG, style='italic')
ax.text((ARR_IN_X0 + ARR_IN_X1) / 2, ARR_Y + 0.06, 'we know what goes in',
        ha='center', va='bottom', fontsize=10.5, color=FG_MUTED, style='italic')
ax.text((ARR_OUT_X0 + ARR_OUT_X1) / 2, ARR_Y + 0.38, 'outputs',
        ha='center', va='bottom', fontsize=12, color=FG, style='italic')
ax.text((ARR_OUT_X0 + ARR_OUT_X1) / 2, ARR_Y + 0.06, 'and what comes out',
        ha='center', va='bottom', fontsize=10.5, color=FG_MUTED, style='italic')

box = mpatches.FancyBboxPatch(
    (BOX_X, BOX_Y), BOX_W, BOX_H,
    boxstyle='round,pad=0.1',
    linewidth=0, facecolor=BOX_BASE, zorder=3)
ax.add_patch(box)

fill = mpatches.Rectangle(
    (BOX_X, BOX_Y), 0, BOX_H,
    facecolor=BOX_FILL, zorder=4)
ax.add_patch(fill)

ax.text(CX, CY + 0.22, 'BLACK-BOX  (AI)',
        ha='center', va='center', fontsize=13,
        fontweight='bold', color=WHITE, zorder=6)
ax.text(CX, CY - 0.28, "no idea what's happening in here",
        ha='center', va='center', fontsize=10.5,
        color='#888888', zorder=6, style='italic')

arr_in = ax.annotate('',
    xy=(ARR_IN_X1, ARR_Y), xytext=(ARR_IN_X0, ARR_Y),
    arrowprops=dict(arrowstyle='->', color=ARROW_ON,
                    lw=2, mutation_scale=18), zorder=5)
arr_out = ax.annotate('',
    xy=(ARR_OUT_X1, ARR_Y), xytext=(ARR_OUT_X0, ARR_Y),
    arrowprops=dict(arrowstyle='->', color=ARROW_OFF,
                    lw=2, mutation_scale=18), zorder=5)

def ease(t): return t * t * (3 - 2 * t)

def update(frame):
    t = frame / FRAMES

    if t < 0.25:
        a = 0.35 + 0.65 * math.sin(math.pi * t / 0.25)
        arr_in.arrow_patch.set_color(ARROW_ON)
        arr_in.arrow_patch.set_alpha(a)
        arr_out.arrow_patch.set_alpha(0.2)
        arr_out.arrow_patch.set_color(ARROW_OFF)
    else:
        arr_in.arrow_patch.set_alpha(0.2)

    if 0.65 <= t < 0.90:
        a = 0.35 + 0.65 * math.sin(math.pi * (t - 0.65) / 0.25)
        arr_out.arrow_patch.set_color(ARROW_ON)
        arr_out.arrow_patch.set_alpha(a)
    elif t >= 0.90:
        arr_out.arrow_patch.set_alpha(0.2)

    if t < 0.25:
        fill.set_width(0)
    elif t < 0.65:
        fill.set_width(BOX_W * ease((t - 0.25) / 0.40))
        fill.set_x(BOX_X)
    else:
        fill.set_width(0)

fig_frames = []
for i in range(FRAMES):
    update(i)
    fbuf = io.BytesIO()
    fig.savefig(fbuf, format='png', dpi=110, facecolor=BG)
    fbuf.seek(0)
    fig_frames.append(PILImage.open(fbuf).convert('RGB'))

gif_buf = io.BytesIO()
fig_frames[0].save(
    gif_buf, format='GIF', save_all=True,
    append_images=fig_frames[1:], loop=0, duration=80)
gif_buf.seek(0)
print('PLOT_GIF:' + base64.b64encode(gif_buf.read()).decode())

context precision★★★☆☆

autonomy★★★★★

iteration cost★★☆☆☆

Method 2: write the test before you write the prompt

There's a practice called test-driven development — TDD — where you write the test first, watch it fail, then write the minimum code to make it pass, then clean up. Red, green, refactor.

By itself it's a good discipline. Paired with AI, it becomes something more useful: a way to stay honest. Instead of describing what you want and hoping the model understood you, you define what correct looks like — in code — before it generates anything. If it passes, great. If it doesn't, you have a precise failure to point at, not just a vague suspicion that something feels wrong.

Most people prompt AI like they're ordering at a restaurant and trusting the chef. TDD makes you write the recipe first.

Here's what a cycle looks like in practice. Three steps, the same code growing at each one.

Red. Write the test first. The function doesn't exist — that's intentional. Watching it fail is the starting point.

def add(a, b):
    pass

def check(label, got, expected):
    if got == expected:
        print("PASS  " + label)
    else:
        print("FAIL  " + label + "  ->  expected " + str(expected) + ", got " + str(got))

check("2 + 3", add(2, 3), 5)
check("0 + 0", add(0, 0), 0)
check("negative", add(-1, 4), 3)

Green. Write the minimum code to make it pass. Nothing more.

# --- AI generated ---
def add(a, b):
    return a + b
# --- end ---

def check(label, got, expected):
    if got == expected:
        print("PASS  " + label)
    else:
        print("FAIL  " + label + "  ->  expected " + str(expected) + ", got " + str(got))

check("2 + 3", add(2, 3), 5)
check("0 + 0", add(0, 0), 0)
check("negative", add(-1, 4), 3)

Refactor. The tests don't change — they're the contract. Here we extend the function to accept a list of numbers. The original tests still pass; the new one adds capability.

def add(numbers):
    total = 0
    for n in numbers:
        total = total + n
    return total

def check(label, got, expected):
    if got == expected:
        print("PASS  " + label)
    else:
        print("FAIL  " + label + "  ->  expected " + str(expected) + ", got " + str(got))

check("2 + 3",    add([2, 3]),       5)
check("0 + 0",    add([0, 0]),       0)
check("negative", add([-1, 4]),      3)
check("many",     add([1, 2, 3, 4]), 10)

This is what TDD gives you with AI: a workflow where you stay in charge of correctness from the start, and the model is responsible for satisfying your definition of done — not its own.

context precision★★★★

autonomy★★★★

iteration cost★★★☆☆

Method 3: spec-driven development

Imagine you hire someone to renovate your bathroom. You could say "make it nice" and hope for the best — or you could hand them a document: tiles go here, sink goes there, the door opens inward, budget is this. One of those conversations ends in a fight. The other ends in a bathroom.

A spec is that document. It describes the what before anyone touches the how. Not the implementation — just the contract: what goes in, what comes out, what the edges look like. Once it exists, there's no room for creative interpretation. Either the result matches the spec or it doesn't.

Spec-Driven Development applies this to code. You write the specification first — the shape of the data, the signature of the function, the expected behavior at every boundary — and only then does anyone (or anything) start implementing.

With AI, this matters more than usual. A vague prompt produces confident-sounding code that might be doing something completely different from what you intended. A spec produces code you can actually verify — because you already wrote down what correct means.

Here's what the workflow looks like. You start with a plain description — no code, no jargon:

> "I need a function that validates a password. It should tell me if the password is valid or not, and if not, why. A valid password has at least 8 characters, at least one uppercase letter, and at least one number."

You hand that to AI and ask for the spec, not the implementation. What comes back is the contract:

# --- AI generated spec ---
def validate_password(password):
    """
    Input:  password -> a string

    Output: (True,  "")       if the password is valid
            (False, reason)   if not, where reason is one of:
              "too short"    -> fewer than 8 characters
              "no uppercase" -> no uppercase letter
              "no number"    -> no digit

    Examples:
      validate_password("Hello123") -> (True,  "")
      validate_password("hello123") -> (False, "no uppercase")
      validate_password("HELLO")    -> (False, "too short")
      validate_password("Hello!")   -> (False, "no number")
    """
    pass
# --- end ---

You read it. You check that it matches what you actually wanted. Only once you agree does the AI write the implementation — against its own spec.

# --- AI generated implementation ---
def validate_password(password):
    if len(password) < 8:
        return (False, "too short")
    has_upper = False
    has_digit = False
    for char in password:
        if char.isupper():
            has_upper = True
        if char.isdigit():
            has_digit = True
    if not has_upper:
        return (False, "no uppercase")
    if not has_digit:
        return (False, "no number")
    return (True, "")
# --- end ---

valid, reason = validate_password("Hello123")
print(valid, reason)

valid, reason = validate_password("hello123")
print(valid, reason)

valid, reason = validate_password("HELLO")
print(valid, reason)

valid, reason = validate_password("Hello!")
print(valid, reason)

The spec is the checkpoint. If it looked wrong before you ran anything, you would have caught the problem in plain English — not buried in a stack trace.

context precision★★★★★

autonomy★★★★★

iteration cost★★★★


None of these methods require you to stop using AI. In fact, the whole point is the opposite: use it more, but smarter. Think of it like driving — you don't have to understand how the engine works to drive well, but you do have to know where you're going, when to brake, and whose fault it is when you crash.

The engineer who thrives with AI isn't the one who prompts fastest. It's the one who knows exactly what they're building, can spot when the output drifts from the plan, and never outsources the thinking that actually matters.

So experiment. Break things. Let AI write code you wouldn't have written yourself — then understand why it works, where it breaks, and what it assumes. That's not a weakness in the workflow. That's the workflow.

The tool is extraordinary. Just don't let it be the one in charge.