Working with Shoddy

Grounding an Assistant

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

1. The instructions file

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:

ToolFileWhen it loads
GitHub Copilot (VS Code).github/copilot-instructions.mdAttached to every Copilot Chat request in the workspace.
Claude CodeCLAUDE.mdRead at the start of every session in the folder.
Cursor.cursor/rules/shoddy.mdcAttached per request.
OthersAGENTS.mdA 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.

2. What else to hand it

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.

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.

The one-line version: if a word matters to the answer, put the file that defines it in front of the assistant. Everything else on this page is a way of doing that in advance.

3. Give it tools, not a shell

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.

A terminal showing two declared trees with their permissions, and a path from outside the boundary refusedFettlerbounded file tools · no shell in between
Start with 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.

4. What to ask, and what comes back

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.

1. Print every value

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.

2. Add them up

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.

3. Only the ones over ten

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.

4. Label each one

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.

5. Names as well as numbers

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.

6. Correct one of them

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.

7. Load them from a file

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.

8. A report you can read

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.

9. Keep asking until they stop

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.

10. Prove it works

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.

5. When the answer is wrong

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:

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.

6. Let the toolchain be the judge

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:

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.

Next: Building with an AI takes this further — the method for a whole project, from a written brief through a plan you approve to code you can ship, with copyable templates for each step. This page grounds the assistant; that one puts it to work.