Working with Shoddy
What to tell Copilot or Claude before you ask it for Shoddy — and the questions worth asking
Shoddy is new and small, and an assistant has never read a line of it. What it has read is a great deal of code that looks like it, and none of that code behaves the way Shoddy does. Left to itself it will answer from the resemblance: confident, tidy, well-indented, and not this language. Nothing about the answer looks wrong, which is exactly the problem — the failure is fluent.
Grounding is the fix, and it is about five minutes of work done once. Give the assistant the handful of rules that make Shoddy what it is, tell it where the real answers live, and it stops guessing. This page is that setup, followed by a run of worked examples: things you might actually ask it to do, and the answers you should expect back.
On this page: 1. The instructions file | 2. What else to hand it | 3. Give it tools, not a shell | 4. What to ask, and what comes back | 5. When the answer is wrong | 6. Let the toolchain be the judge
Every assistant worth using will read a file of standing instructions
from your project and attach it to each request. You write the grounding
once, instead of pasting it into every conversation. The file goes at
the root of the folder you work in — the same folder as your
.shoddy files — and the name depends on the tool:
| Tool | File | When it loads |
|---|---|---|
| GitHub Copilot (VS Code) | .github/copilot-instructions.md | Attached to every Copilot Chat request in the workspace. |
| Claude Code | CLAUDE.md | Read at the start of every session in the folder. |
| Cursor | .cursor/rules/shoddy.mdc | Attached per request. |
| Others | AGENTS.md | A convention several tools now read; harmless if yours doesn't. |
Tip If you are unsure, write the same content to more than one of these. They are small, and an unread file costs nothing. If your tool reads none of them, paste the block below as the first message of each session — it is short enough.
Here is the file. It is deliberately built in two halves, and knowing
which half is which is the whole trick to keeping it useful as the
language grows. The first half is the shape of Shoddy: the
handful of facts that make a confident guess wrong. It is written out in
full because you cannot look up what you don't know you're
missing. Left alone, an assistant writes
For i = 1 To 10 fluently and never suspects there is
anything to check. Those facts exist to manufacture doubt in the right
places. The second half is where the answers are. Every word
name, argument order and machine list lives in the sources, not in the
file, because a copied detail is a detail that goes stale. Copy it
whole:
# Shoddy — how to help in this project
Shoddy is a small, purely functional BASIC that compiles to .NET. It looks
like BASIC and does not behave like it. You have not seen this language
before. Do not pattern-match to VB.NET, VBA, QBasic, Python or F#.
## This file is two halves, and they are different kinds of thing
The FIRST half is the SHAPE of the language. It is written out because you
cannot look up what you do not know you are missing: left alone you will
write `For i = 1 To 10` fluently and never suspect there is anything to
check. These facts are here to make you uncertain in the right places.
The SECOND half is WHERE THE ANSWERS ARE. Every word name, argument order,
machine list and behavioural detail lives in the sources named there and
deliberately NOT in this file, because a copied detail is a detail that
goes stale. When you need a specific, go and read it. Do not reconstruct
it from memory, and do not ask me what a word does — the file is right
there and it is faster than the question.
## The shape — this is what does not change
- Nothing mutates. `Let` binds a name once. No assignment, no `Dim x = 0`
variable, no `x = x + 1`, no `goto`. "Updating" a record returns a new
one: `With(r, Field = v)`.
- 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 the structure. There is no `End If`, `End Function`,
`Next`, `Wend`, brace or semicolon anywhere in the language.
- A Def's last expression is its result. There is no `Return`.
- Effects live at the edge: `Print` and `Input` belong in `Main`, not in
the words that compute.
- Names are case-insensitive: `total`, `Total` and `TOTAL` are one word.
- Booleans are not numbers. `If n Then` is an error; write `If n <> 0 Then`.
- `Number` is an IEEE double and the ONLY numeric type. There are no
integers and NO BITWISE OPERATORS — bit work is a machine's words, not
`&`, `|` or `<<`, and a design that wants the operators themselves is the
wrong design here.
- Sequences are 1-based and ranges include both ends.
- There are no exceptions. `Error(msg)` aborts with a line number. Anything
a caller should be able to handle comes back as a VALUE instead — the
predeclared `Option` (`Some`/`None`) and `Result` (`Ok`/`Err`) are what
that means, and neither needs an Include.
- Records compare structurally with `=`. Lists compare by identity, so two
separately built lists of the same items are not equal.
- Self-tail-recursion compiles to a loop, so a word that calls itself last
runs in constant stack. MUTUAL recursion does not — it consumes stack
like any other call.
- 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 (50,000 elements takes ~3.6s).
Recurse over lists for SHAPE, not for speed; to visit every element of a
long one, `Fold`, or `ToArray` once and index with `Nth`.
- Building with `Prepend` on the way OUT of a recursion costs a stack frame
per element and dies past ~20,000 — a hard process kill, not an `Error`.
Thread an accumulator in and `Reverse` at the end instead.
- Operators are infix: `a Mod b`, never `Mod(a, b)`. An operator in call
position silently builds a function value rather than a result, and the
program fails somewhere else entirely. Same for a unary minus before
brackets: write `0 - x * x`.
- 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))`.
- A line continues onto the next only inside open brackets.
- `Type T` declares a record and creates one accessor word per field.
Fields are POSITIONAL — inserting one mid-record shifts every later
constructor argument silently.
- An accessor loses every naming contest, so never name a local, a
parameter or a Def after a field the program reads by accessor.
- The network is gated behind `--allow-net`. A plain socket never blocks; a
SECURED socket is the deliberate exception and blocks on its read.
## The house style — narrower than the language allows
Read three or four machines before you write anything. The language
permits far more than this project uses, and matching the tree matters
more than being clever.
- Defs are SMALL. The mean body across the standard library is four lines.
When a body grows past that the answer is another named Def, not deeper
nesting — the library is full of chains like `Split` / `SplitFrom` /
`SplitAt`, each doing one step.
- `Select Case` for taking a sum type apart or matching a range of values;
plain `If` for a two-way test. `Case Type(a, b)` destructures a record and
binds its fields, which is how you read one in a match.
- `Fn(x) => ...` for a lambda used once; a bare name or an operator section
for anything already named.
- Point-free pipelines are legal and the spec shows them, but the library
hardly uses them. Nested calls and named helpers are the house style.
- A word that aborts often 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. Follow that pairing rather than writing both twice.
- Every file opens with a header comment saying what it is for and which
decisions are load-bearing, in prose. Match that.
## How to answer
- If you are unsure of a word or its arguments, READ THE SOURCE. Do not
invent a plausible name, and do not ask me — `machines/` is the answer
and it is quicker than waiting for me.
- Prefer the shortest idiomatic form over a clever one.
- Every answer must be code that compiles. Say how to run it:
`mill run FILE.shoddy`.
- `Include` every machine you name a word from. A machine gives you the
words it DECLARES and not the ones it includes in turn, so
`Include "stats.shoddy"` does not hand you seq's `ZipWith` even though
stats uses it. The compiler refuses the word and names the Include to
add; write the Include.
- Split every program: a pure core (`NAME-core.shoddy`) that computes and
can be tested with no screen, and a thin shell (`NAME.shoddy`) that does
the input and output.
- Tests are a program: `test.shoddy` full of `Assert(cond, "MESSAGE")`,
run like anything else. A run that reaches the end passed.
- You write the tests, and you write them BEFORE the implementation, from
what I said must be true. Never edit a test to make code pass: if
something fails, either the code is wrong or I asked for the wrong thing,
and I want to know which.
## Where the answers are — read these instead of guessing
Three trees. All of it is fair game, and none of it needs summarising back
to me. Go and look rather than reconstructing an answer from memory.
- `machines/*` — the standard library, in readable Shoddy. THE definitive
answer on any library word's name, arguments and types, and the best
example of house style there is. Never guess which machines exist or what
they are called: list the directory. Each one opens with a header naming
the builtins it is built on, documented word by word.
- `mills/*` — complete programs, each built the way this one should be: a
pure core, a thin shell, a headless test, build scripts. Read the one
closest in shape to what you are writing.
- `docs/*` — the documentation. `quickref.html` is the whole language on
one page and is where to start; `spec.html` is the grammar and the
semantics; `errors.html` is every message the toolchain can raise with
its cause and its fix; `guide.html` teaches it chapter by chapter;
`machines/index.html` is the catalogue and `machines/NAME.html` the word
reference for one machine; `stack.html` is the concatenative core beneath
the typed surface.
If you are NOT working inside a copy of the repository, the same material
is published and you can fetch it:
- https://shoddymills.github.io/shoddy/quickref.html
- https://shoddymills.github.io/shoddy/spec.html
- https://shoddymills.github.io/shoddy/machines/index.html
- https://github.com/shoddymills/shoddy — `machines/` and `mills/` as source
## Let the toolchain settle it, not the argument
The weave catches every invented word and every stray `End If` in under a
second. The linter warns about shadowed accessors, operator sections and
stack-effect mistakes without ever blocking a build, and a clean build
prints nothing at all. Never hand me code you have not run.
Why it is built that way The
two halves fail differently, and only one of them can be fixed by a
link. Pointing at the documentation completely solves "I need a
detail I haven't got". It solves it better than a copy does,
because a copied word list is wrong the moment a machine gains a word.
But it does nothing at all for "I don't know I'm wrong", which
is the failure that actually bites. An assistant confidently writing a
For loop never reaches for a link, because nothing has told
it there is a question. So the shape stays in the file, and the
specifics stay out of it. That is also what keeps this page from going
stale. There is no machine list to fall behind the tree, and the last
section points at a directory rather than naming its contents.
The instructions file covers the language. For a particular task, attach the particular source — an assistant reasons far better from twenty lines of real code than from a description of them.
machines/turtle.shoddy is
short enough to read in a sitting and tells it every word, argument and type,
comments included. This is the single
highest-value attachment there is, and it is why the library ships as
readable source rather than a binary.mills/invaders/. A data tool? mills/demographics/.
The mills are worked examples of the whole shape.test.shoddy tells it
what "working" means far more precisely than a paragraph can.In Copilot Chat, type #file: and the name to attach one, or
drag the file into the chat. In Claude Code, name the path and it reads it.
Either way, prefer naming a file over describing what is in it.
Everything above is about what an assistant knows. This is
about what it can do. Left to itself it will reach a source tree
through a shell — and the shell is where the work breaks. Quoting,
&& versus ;, native stderr becoming a
terminating error under PowerShell 5.1: none of these are file problems,
and all of them are paid on every single operation.
Fettler is this repository's answer.
fettle performs the operations an assistant actually carries
out — find, search, read, write, edit, replace, move, copy, delete, and
run a declared task — as one program with an MCP front end, bounded to
trees you declare. Installing it has the
registration for every client, and the exclusions that make it the
only route to the tree rather than merely an available one.
fettle doctor. It
answers three things in one screen: whether Fettler is registered with
each client at each level, whether the binary it names can actually be
launched, and what on this machine still lets an assistant go round the
boundary. The routes it names are concrete — a pre-approved
sed -i, an allow on the built-in editor,
an instructions file that has never heard of Fettler. It writes nothing.
On the machine this was written for, its first honest verdict was
broken: every documented registration launched a bare
fettle that resolved to nothing, and the server had never
once run.There is no working directory, and this is the habit worth breaking
hardest. Paths resolve against declared trees, so cd and
Set-Location do nothing for Fettler, and reaching for one is
a reliable sign the wrong tool is being used. The server says this at
initialize, before the model has said anything, which is the
one place an instruction ships with the tool and needs nobody's
cooperation.
Being honest about how well that works: in the session that designed
all of this, the assistant had extensive grounding, used
Set-Location on nearly every command anyway, and needed three
corrections. Instruction is the weak lever. What works is removing the
need — Fettler wants no working directory now, and everything else
that did has a cwd-free form: git -C, an absolute script
path, fettle run for a build.
Ask for the trees before guessing why something was refused.
fettle roots says which trees are open, what may be done
in each, and which one an unqualified path lands in. A tree may be
read-only; a scope inside it may grant more or less than the tree does;
and a scope with no list is not there at all as far as
searching is concerned. Learning a boundary by being refused by it is
learning by trial against a security check.
Two files — .fettler.json and
.fettler.local.json — say what the tool may do, and the tool
does not write them, at any permission level. That is not fussiness:
before it was true, Fettler could write its own task declaration and then
run it, which defeats every other permission at once. See
the boundary.
Two habits are worth putting in the instructions file, because they
are where the time goes. Prefer one call to several — read takes
many paths, search takes many patterns, and replace does one
substitution across a whole glob, so a rename across a tree is two calls
rather than two dozen. And pass the hash back: read returns one,
and giving it to an edit as expect means an edit against a
file that moved underneath is refused rather than quietly clobbering
somebody's work.
The rest of this page is worked examples. One small program, a series of things you might want it to do next, the prompt you would type for each, and the answer a grounded assistant hands back. Read them to learn what a good answer looks like — and since every answer is correct Shoddy, they work just as well as a cheat sheet on the days you are doing the typing yourself.
It all builds on one file. Assume you already have this much:
Rem readings.shoddy
Let x = { 12, 7, 30, 4, 18 }
Watch Every answer below was compiled and run before it went on this page. Might is doing real work in “what you might get”, though: an assistant picks its own names and spacing, and two runs of the same prompt rarely match to the character. What should not vary is the shape. If what you get differs from these in kind rather than in wording, look twice before you keep it.
You ask Print out all the values in x.
What you might get
Def Main()
Each(x, Fn(v) => Print(Str(v)))
Read it as “for each item in x, call this little
function”. Each is for doing something to every item;
Fn(v) => … is a function written where it is used, with
v standing for whatever item it is up to. There is no counter and
no index — nothing to get wrong by one. Str is there because
Print takes text and the values are numbers.
You ask Add up everything in x and print the total.
What you might get
Def Total(xs As List Of Number) As Number
Fold(xs, 0, +)
Def Main()
Print(Str(Total(x)))
Fold is the one to learn. It starts at 0 and
applies + to what it has so far and the next item, all the way
along. It answers every question of the form “boil this list down to
one value” — a total, a maximum, the longest word, a picture built up
shape by shape.
Notice there is no total variable being added to. Nothing in
Shoddy changes after it is set, so the running value is passed along rather
than kept anywhere. If an answer ever shows you a name being reassigned, it
will not compile.
You ask Now just the readings above ten, and their total.
What you might get
Def Above(xs As List Of Number, limit As Number) As List Of Number
Filter(xs, Fn(v) => v > limit)
Def Main()
Print(Str(Total(Above(x, 10))))
Filter keeps the items the little function says
True to, and hands back a new list — x itself is
untouched, and still has all five readings in it. That is why these stack so
readily: Total(Above(x, 10)) is just one answer fed into the
next.
Worth knowing: the first version of this used the name
Over, and the mill refused it —
OVER is a builtin - a Def of that name would shadow it everywhere;
rename it. A generic name like Sum, Count or
Over may already be taken, and the toolchain says so on the spot
rather than letting the collision through.
You ask Print each reading with LOW, OK or HIGH beside it — over twenty is high, over ten is ok.
What you might get
Def Band(v As Number) As String
Select Case v
Case Is > 20
"HIGH"
Case Is > 10
"OK"
Case Else
"LOW"
Def Main()
Each(x, Fn(v) => Print(Str(v) & " " & Band(v)))
Select Case takes the first branch that matches, so the order
is the logic: Is > 10 would swallow the high ones too if it
came first. Case Else is what catches everything left over —
without it, a value that matches nothing stops the program when it eventually
turns up.
The whole Select Case is the answer Band gives
back. Nothing is stored in a variable and returned; the last thing a Def works
out is what it hands over.
You ask Each reading should have a name too — MILL, SHED, YARD. Print the name, the value and the band.
What you might get
Type Reading
Name As String
Value As Number
Let readings = { Reading("MILL", 12), Reading("SHED", 7), Reading("YARD", 30) }
Def Describe(r As Reading) As String
Name(r) & " " & Str(Value(r)) & " " & Band(Value(r))
Def Main()
Each(Map(readings, Describe), Print)
Those four lines of Type do two jobs: they make
Reading(…) for building one, and they make Name and
Value for reading the parts back out. There is no dot — you call
the field's name like any other word: Name(r).
Map is Each's twin: same idea, but it collects
what comes back instead of throwing it away. And Describe is
passed by name with no brackets after it, which is how you hand a function to
something rather than calling it.
Two things to check in any answer that declares a
Type: the constructor takes its fields in the order they are
declared, with nothing to catch a swap between two fields of the same
kind; and no local or parameter should be named after a field, because the
local wins and the accessor quietly disappears.
You ask The MILL reading was wrong — it should be 99.
What you might get
Def Corrected(r As Reading, v As Number) As Reading
With(r, Value = v)
With makes a copy that differs in the one field named,
carrying everything else across. The original record is still there,
unchanged, for anyone else holding it — which is the answer to “where
did my edit go?”: nowhere. It came back as the result, and it exists
only if the caller keeps it.
You ask Read the numbers from readings.txt instead of having them in the code. Skip anything that isn't a number.
What you might get
Include "file.shoddy"
Def NumbersIn(path As String) As List Of Number
Map(Filter(ReadLines(path), IsNumeric), Val)
One line, read right to left: every line of the file, keep the ones that
are numbers, turn each into a number. IsNumeric and
Val are handed over by name, the same way Describe
was.
The guard is not decoration. Val stops the program on anything
that isn't a number, which is right when a bad file should halt everything and
wrong when a person is typing. Ask for the forgiving version and you get
ValOr(s, 0), which hands back the fallback instead.
The Include is the other thing to watch. The library is
a shed of machines, and the one you want has to be named at the top of
the file. No path, no setup — but if an answer uses ReadLines
without Include "file.shoddy", it will not build.
You ask Print a table — names in a column on the left, values lined up on the right with two decimal places.
What you might get
Include "str.shoddy"
Def Line(r As Reading) As String
PadRight(Name(r), 10) & PadLeft(ToFixed(Value(r), 2), 8)
Def Main()
Each(Map(readings, Line), Print)
MILL 12.00
SHED 7.00
YARD 30.00
There is no format string and no picture clause to get wrong — just words
that each do one thing, joined with &.
PadRight and PadLeft square a column off at a width,
ToFixed fixes the decimal places, and Commas(x, 2)
is there when the numbers get big enough to want separators.
You ask Let me type readings in one at a time, and stop when I type DONE.
What you might get
Def Collect(so As List Of Number) As List Of Number
Let line = Input("reading (or DONE): ")
If Upper(Trim(line)) = "DONE" Then
so
Else
Collect(Append(so, ValOr(line, 0)))
Def Main()
Let got = Collect({ })
Print("total " & Str(Total(got)))
This is the shape to recognise, because every “keep going until
something happens” job in Shoddy looks like it: do one turn, then
call yourself with what you now know. The list gathered so far travels in
as so and comes back out grown by one. The stopping case is the
branch that returns it instead of calling again.
It costs nothing to do it this way. A call in the last position becomes a jump, so this can run all night without piling up. If you want the same shape in a window — waiting on keys instead of typed lines — the first tutorial builds one in §12.
You ask Before you change anything else: write me the tests. Total of a plain list, the empty list, and Above keeping only what clears the limit.
What you might get
Rem test.shoddy
Include "readings-core.shoddy"
Def Main()
Assert(Total({ 1, 2, 3, 4 }) = 10, "IT ADDS")
Assert(Total({ }) = 0, "AN EMPTY LIST IS ZERO")
Assert(Length(Above({ 4, 18 }, 10)) = 1, "IT KEEPS THE BIG ONE")
Print("ALL CHECKS PASSED")
A test is a program: mill run test.shoddy, nothing to install.
Reaching the final Print is the pass; a failed
Assert stops the run and names the message, which is why the
messages read like statements of what should be true.
Notice the order. Ask for the tests first, from what you said must be true, and only then for the code — an assistant that has already written the implementation writes tests that agree with it, which proves nothing. Get them in that order and the assertions are a statement of the requirement rather than a description of the answer.
Then ask the follow-up, which is the whole of your job here: what should this handle that the tests don't? Out come the empty list, the negative reading, the file that isn't there — and each one goes back as a sentence, not as code you write.
Sooner or later it will be — most often deep in a long conversation, or right after you have pasted in a lot of something else. The instructions file is a nudge, not a lock. Three things work:
unknown word: FOR
is unambiguous and ends the discussion in one exchange. Explaining why the
language is the way it is does not.Watch The tell is fluency about
a word you have never seen. If the answer uses
PadCenter or Sum or ForEach, don't
reason about whether it exists — search
the quick reference, or just compile it. The
weave names an unknown word on the line that used it.
The reason this works at all is that Shoddy tells you the truth cheaply and immediately. You never have to decide whether generated code is right by reading it:
End If. It runs in under a second.So the rule is simple, and it is the only one on this page that really matters: never accept code you have not run. Not because an assistant is untrustworthy in particular, but because running it takes four seconds and settles the question completely.