Tutorials

Building with an AI

From requirements to shippable code — a method, and the templates to run it

An AI assistant can write Shoddy faster than you can type it. That is the least interesting thing about working with one. The interesting question is how to get code you would be willing to ship. That means three things. You can state its behaviour. Something other than your own optimism has checked its correctness. And you can hand it to someone else six months from now.

This page is a working method for that, and a set of blank templates to run it with. It doesn't teach a particular program: bring your own. Every template below has slots for the thing you're building.

The short version. Write the requirement down as a document. Make the assistant produce a plan, and approve it before it writes code. Have it write the tests before the implementation, from the acceptance you stated. Then let the toolchain — not the assistant, and not your reading — decide whether it works.

Note You do not type any Shoddy in this method. Not the program, not the tests. What you write is English: the requirement, the acceptance, and the word no at two gates. That is the whole point. Code you did not write is code you can throw away and regenerate, and the only thing that survives a regeneration is the prose.

1. The loop

Nine steps, two of which are yours alone:

  1. Requirement — what you want, in your own words, before any tool is involved.
  2. Brief — that requirement turned into a document the assistant reads: context, facts, numbered requirements, and acceptance (what must be true for the work to count as done).
  3. Plan — the assistant answers with what it intends to do, and what it needs to know. You approve it. Nothing is written yet.
  4. Tests — the assistant writes them, from your acceptance criteria, before the implementation exists. You read the list and say what is missing.
  5. Implementation — written from the plan and the brief, in its own turn, once the tests are settled.
  6. Verify — machines check: tests, the linter (a tool that scans code for likely mistakes), a real run. Not opinions.
  7. Reviewyou read what a machine can't judge: naming, intent, whether it answers the question you actually asked.
  8. Ship — build files, a headless test (one that runs with no window), a commit message that explains why.
  9. Capture — fold what you learned back into the brief, so the next feature starts further along.

Steps 3 and 7 are the gates. Everything between them can be delegated completely. Those two are where the work becomes yours.

Steps 4 and 5 are deliberately separate turns, and the order is the point. An assistant that has already written the implementation will write tests that agree with it — fluently, and proving nothing. Ask for the tests first, and the assertions (the individual checks a test makes) state your requirement. Ask for them afterwards, and they merely describe the answer.

2. Write a brief, not a chat message

A chat message is a keystroke that vanishes. A brief is a document that lasts: you can revise it, re-run it against a different assistant, diff it (compare it line by line) against what was actually built, and read it a year later to find out what you meant. Keep it in a file next to the code — briefs are cheap and they age well.

The single highest-value section is the one most people skip: facts the assistant should not have to re-derive. An assistant asked to write Shoddy will otherwise reinvent the conventions from first principles, get most of them right, and quietly get the rest wrong.

# <Name> — Implementation Brief

## Purpose
<One paragraph: what this is and who it is for. If it has a name, say
where the name comes from — it will end up in the comments.>

## Context
- Language: Shoddy (purely functional BASIC, compiled to .NET).
- Where the code goes: <folder>/<name>.shoddy, plus
  <name>-core.shoddy and test.shoddy.
- May depend on: <machines it may Include, e.g. seq, str, math>.
- Must not depend on: <anything out of bounds>.

## Facts — do not re-derive these
<Paste the fact sheet from §3, plus anything specific to your project.>

## Requirements
1. <A statement of behaviour, testable as written.>
2. <…>
3. <…>

## Out of scope
- <Things that would be nice, and are not this change.>

## Acceptance
<These become test.shoddy, one assertion each. Write them so that
 turning them into Assert calls is transcription, not interpretation.>
- Every requirement above has at least one assertion in test.shoddy.
- <Named cases whose answers you already know: the empty input, the
  extreme input, the case everyone gets wrong.>
- <At least one property: something that must hold for EVERY input,
  not one worked example.>
- The linter is silent, and the repository's tests still pass.

## Open questions
<Leave this empty. The assistant fills it in the plan — see §4.>

Write the requirements so that each one could become an Assert without translation. "Handles errors gracefully" cannot. "An empty input returns zero rather than stopping the program" can.

3. The fact sheet

Copy this into the brief's Facts section. Notice what it does not contain: no list of machines, no table of word names, no argument orders. Those live in the sources, and a copy of them in a brief is a copy that is wrong the first time a machine gains a word. What is written out here is only the shape of the language — the facts a confident guess gets wrong. That is because an assistant cannot look up what it doesn't know it is missing. It will write a For loop fluently and never reach for a link. These facts are here to make it reach.

Tip The same facts are on Grounding an Assistant, packaged as a standing instructions file your tools load by themselves. That page adds worked examples of asking for a change and reading what comes back. Set that up once per project, and this section becomes a reminder rather than a paste.

## Facts — do not re-derive these

This section is the SHAPE of the language, and nothing else. No word list,
no machine list, no argument orders: those are in the sources at the bottom
and you are to read them there rather than work from anything I paste. What
is written out here is only what you would otherwise get confidently wrong
without ever knowing to check.

Purity and shape
- Nothing mutates. `Let` binds a name once; "updating" a record returns a
  new record (`With(r, Field = v)`). There is no assignment and no `goto`.
- There is no `For`, `While`, `Do`, `Next` or `Wend`. Iteration is
  recursion, or Map / Filter / Fold / Each / Times. `Fold` is the workhorse:
  it threads a value through a sequence.
- Indentation is structure — no `End`, no braces, no semicolons.
- A Def's last line is its result; there is no `Return`.
- Effects live at the edge: Print and Input belong in Main, not in the
  words that compute.

Facts that decide a design
- `Number` is an IEEE double and the ONLY numeric type. No integers, and no
  bitwise operators — bit work is a machine's words, never `&` or
  `<<`; a design that wants the operators themselves is the wrong design
  here.
- No exceptions. `Error(msg)` aborts with a line number. Anything a caller
  should be able to handle comes back as a VALUE: the predeclared `Option`
  (`Some`/`None`) and `Result` (`Ok`/`Err`), neither needing an Include.
- Arrays are fixed-length with O(1) `Nth`. `SetNth` returns a copy.
- LISTS ARE NOT CONS CELLS AND `Rest` IS NOT FREE — it copies the tail, so
  a `First`/`Rest` walk is QUADRATIC. Recurse over lists for SHAPE, not for
  speed; to visit every element of a long one, `Fold`, or `ToArray` and index.
- Building with `Prepend` on the way OUT of a recursion costs a stack frame
  per element and dies past ~20,000. Accumulate and `Reverse` instead.
- Self-tail-recursion compiles to a loop; MUTUAL recursion consumes stack.
- The network is gated behind `--allow-net`. A plain socket never blocks; a
  secured socket is the deliberate exception and blocks on its read.

Names and types
- Names are case-insensitive: `total`, `Total` and `TOTAL` are one word.
- Declaring `Type T` creates a constructor plus one accessor word per
  field. Fields are POSITIONAL — inserting one mid-record shifts every
  later argument silently. Records compare structurally with `=`; lists
  compare by identity.
- An accessor loses every naming contest, so never name a local, parameter
  or Def after a field the program reads by accessor.
- Booleans are not numbers. `If n Then` is an error; write `If n <> 0 Then`.
- Sequences are 1-based, and ranges include both ends.

Syntax that surprises
- Operators are infix. `a Mod b`, not `Mod(a, b)` — the call form builds a
  function value (a "section"), not a result. Same for a unary minus before
  parentheses: write `0 - x * x`.
- A line continues onto the next only inside open brackets; parenthesize a
  wrapped condition.
- A bare name in argument position is passed as a function, not called:
  `Map(xs, Double)`. An operator with its trailing argument pre-loaded is
  the same thing: `Filter(xs, >=(100))`.

House style — narrower than the language allows
- Defs are SMALL: the mean body across the standard library is four lines.
  Past that, write another named Def rather than nesting deeper.
- `Select Case` to take a sum type apart or match a range; plain `If` for a
  two-way test. `Case Type(a, b)` destructures a record and binds its fields.
- A word that aborts usually has a total twin answering `Result` or
  `Option`, with the aborting one defined OVER the total one so there is one
  set of rules and not two.
- Point-free pipelines are legal but the library hardly uses them. Nested
  calls and named helpers are the house style.

Structure and tooling
- Split every program: a pure core (`NAME-core.shoddy`) that computes and
  can be tested headless, and a thin shell (`NAME.shoddy`) that does I/O.
- Tests are a program: `test.shoddy` with `Assert(cond, "MESSAGE")`, run
  like anything else. A run that reaches the end passed.
- The standard library is a set of "machines"; `Include "seq.shoddy"` resolves
  from any folder with no configuration. Include every machine you name a word
  from: a machine gives you what it declares, not what it includes in turn, so
  including stats does not hand you seq's `ZipWith`.
- The linter runs on every compile and warns about stack-effect mistakes,
  shadowed accessors, and operator sections. A clean build prints nothing.

Sources — read these instead of guessing
- `machines/*` — the standard library in readable Shoddy: the definitive
  answer on any library word's name, arguments and types, and the house
  style to write in. Never guess which machines exist — list the directory.
- `mills/*` — complete programs built core-shell-test, like this one.
- `docs/*` — the documentation: `quickref.html` (the language on one page),
  `spec.html` (grammar and semantics), `errors.html` (every message with its
  cause and fix), `machines/index.html` (the catalogue) and
  `machines/NAME.html` (one machine's words).
- Not in a checkout? The same material is published:
  https://shoddymills.github.io/shoddy/quickref.html , /spec.html ,
  /machines/index.html , and https://github.com/shoddymills/shoddy for
  `machines/` and `mills/` as source.

4. Give it tools, not a shell

The fact sheet tells the assistant what Shoddy is. This is about what it can do to your tree — your project's folders and files. Left alone, it reaches your files through a shell (the command window where typed commands run). The shell is where the work breaks: quoting that differs between Git Bash and PowerShell, stderr (the error-message channel) that becomes a terminating error on one of them, a path separator that means two things.

Fettler replaces that layer. fettle does the operations an assistant actually performs — find, search, read, write, edit, replace, move, copy, delete, run a declared task. It is one program, bounded to roots (the folders) you declare. Register it once for VS Code, GitHub Copilot, Claude Code or Claude Desktop, and it becomes tools the model calls rather than commands it composes.

Registering it makes it available. Denying the built-in file tools makes it used. A model with its own file tools will reach for them out of habit. That is exactly what happened the first time this tool was finished: the very next task was carried out without it. The exclusion list is the fix. Guidance alone is not. It denies the shell as well, because a shell is a complete way round the boundary. So expect to name the few commands your project needs before the build runs again.

Two habits pay for themselves immediately. One call, not several: read takes many paths, search takes many patterns, and replace does one substitution across a whole glob (a filename pattern like *.shoddy). Renaming something across a tree is therefore two calls rather than two dozen. And pass the hash back. Read returns one — a short fingerprint of the file's exact contents. Give it to an edit as expect, and an edit against a file that moved underneath you is refused rather than quietly overwriting somebody's work.

5. Demand a plan , and read the questions

Before a line is written, ask for a plan. The request is one sentence — "review the brief and give me your plan; don't write anything yet". What comes back tells you more about the eventual code than the code will.

A plan worth approving says:

A plan with no questions is a warning sign on any brief longer than a paragraph. Real requirements have gaps. An assistant that found none either got lucky or is about to choose for you silently. When you get questions, answer them in the brief, not in the reply — that way the answer survives.

This is also where you kill scope creep — work quietly growing past what you asked for. If the plan proposes improving something you didn't ask about, say no now. It costs one sentence here and a review argument later.

6. Acceptance criteria are the contract

Two assistants given the same brief will write different code, and so will the same assistant twice. The acceptance section of your brief is the only part of the exchange that stays fixed. That makes it — not the implementation, and not the assertions — the thing you are really authoring.

Write the acceptance as statements of what must be true, in English. Make each one precise enough that turning it into an Assert is transcription rather than interpretation. Then have the assistant do the transcribing. That is the division: you decide what would prove it; it writes the proof.

Ask for it like this: "Before any implementation: write test.shoddy from the acceptance criteria in the brief. One assertion per criterion, named so a failure tells me which one broke. Do not write the implementation yet."

What comes back is a list you can read without knowing any Shoddy — the messages are sentences. Your job on it is one question, asked before you let the implementation start: what must be true that nothing here checks? The answers go back as more acceptance criteria, in the brief, where they last.

This is the shape to require, and the two kinds of assertion that matter are at the bottom:

Include "seq.shoddy"
Include "<name>-core.shoddy"

Def Main()
    Rem The obvious case: the answer you'd work out on paper.
    Assert(<call> = <expected>, "<WHAT THIS PROVES>")

    Rem The empty / zero / nothing-to-do case.
    Assert(<call with empty input> = <expected>, "<…>")

    Rem The extreme: the largest, the perfect score, the boundary.
    Assert(<call> = <expected>, "<…>")

    Rem The one everybody gets wrong. If your problem has a famous
    Rem edge case, it belongs here, named.
    Assert(<call> = <expected>, "<…>")

    Rem A property, not a number: something that must hold for every
    Rem input, stated as a rule. These survive refactoring.
    Assert(All(<inputs>, Fn(x) => <invariant>), "<…>")

    Print("ALL <NAME> CHECKS PASSED")

Note the last two. A test that pins down why the code is right — an invariant, or property: a rule that must hold for every input — outlives the test that checks one number. It does something a list of examples cannot: it constrains every input at once, including the ones nobody thought of. Ask for these explicitly. "Add a property that must hold for every input" is one sentence, and it is the single strongest thing you can ask for when the same assistant is writing both halves.

Watch Read the assertions for one thing above all: does each one state something from your brief, or does it restate what the code will obviously do? Assert(Total({}) = 0) is a requirement. Assert(Length(Map(xs, f)) = Length(xs)) is the definition of Map and proves nothing about your program. The second kind is how a green suite — every test passing — comes to mean nothing.

7. Let the machine check the machine

Reading generated code carefully feels like verification. It isn't. Plausible-looking Shoddy that computes the wrong answer reads exactly like plausible-looking Shoddy that computes the right one. Run things instead.

## Verification checklist

[ ] The tests pass:            mill run test.shoddy
[ ] The linter is silent:      mill gen <name>.shoddy      (warnings go to stderr)
[ ] Coverage is honest:        mill run test.shoddy --lint-verbose
[ ] The program actually runs: mill run <name>.shoddy
[ ] It still compiles cold:    delete any build output and run again
[ ] Nothing else broke:        the rest of the project's tests

Three of those deserve a word:

8. Review what a machine can't

Tests prove behaviour. They say nothing about whether the code is worth keeping. This is the second gate, and it's short:

Tip One prompt buys a genuinely independent opinion. Paste the acceptance criteria into a fresh session — no brief, no plan, no code — and ask what it would test. Anything on that list and not on yours is a hole in the requirement. You found it for the cost of a message, without writing a line.

9. Ship it

## Definition of done

[ ] <name>-core.shoddy   — pure; no Print, no Input, no window
[ ] <name>.shoddy        — the shell; effects live here
[ ] test.shoddy          — headless; every requirement asserted
[ ] build.sh — run and test targets (Git Bash / WSL on Windows)
[ ] A header comment on each file: what it is, and why it is that way
[ ] The linter silent, the tests green, the program run at least once
[ ] A commit message that explains the change, not the diff

That last line is worth insisting on. An assistant will happily write "Update core file" or list what changed — which the diff already says. Ask instead for the reason: what was wrong before, what is true now, and what a reader six months from now would need to know. It is the one piece of prose in the whole exchange that cannot be regenerated from the code.

10. Capture what you learned

The brief was your input. After the work lands, it becomes a record — worth ten minutes to close the loop:

Do this for a few features and you accumulate something more valuable than any single program. You get a description of your project that a competent stranger — or a fresh assistant with no memory of last week — can pick up and be useful with immediately.

11. Where this goes wrong

Honest failure modes — the ways this method breaks — so you recognise them early:

None of these are reasons not to work this way. They are the reasons the loop has gates in it.

12. Where next