AI

Can a coding agent build a programming language from scratch?

On this page

In my last post, I locked a coding agent in a sandbox with a programming language it had never seen, and watched it figure the language out from a few apps. To my surprise, it did really well.

But that language had to come from somewhere, right? Every language I know is in some model’s training data, so I had to make a new one. And, of course, I made it with a coding agent.

So this post is about the other question: how far can you get building a programming language with a coding agent? Let me set expectations right away: Slopersons is an experiment, not a language you’d build anything serious with. But it does have a REPL, modules, error handling, files, JSON, sockets, a tiny HTTP server, and a testing library, and docs good enough for another agent to learn from.

It took almost two days, but honestly, the speed is the least interesting part. What stuck with me was how much of the work ended up being decisions rather than code, and how confidently the agent could be wrong about some of them.

What’s in a name

I want to cover this topic very briefly because I know the name is super silly. I wanted it to contain "slop" in its name, and sometimes in Brazil people put "-ersons" at the end of some word to make it look extra silly, so that was it.

The brief

I started from an empty folder and this prompt:

I want to create a new interpreted language, this folder will be used to implement this interpreter and the stdlib of this language. It will be a structured programming language, but the syntax should be nothing like any other language (like C, or Ruby, or Haskell, or Python, or whatever). It will not be a typed language. I want the interpreter to be easy to run, like a file with a Node shebang that runs it in Node. It should have common primitives (int, float, char, boolean) and some objects (string, array, hashmap, set, date, probably some others). The stdlib will cover these primitives and objects for now, and we will add some more complex things later. The interpreter should not be just a proxy to the underlying Node process, it should actually implement things.

I also asked for an instructions file for agents and docs for the language, and the agent answered with "we should first start discussing the syntax".

There are two very important points in that prompt: first, "nothing like any other language", and second, "not just a proxy" to Node: no wrapping JavaScript’s Map and calling it a hashmap.

Take 1: "nothing like any other language"

The first thing the agent did was push back, saying that "nothing like any other language" can’t be done at the level of tokens: every symbol and keyword has been used by some language. That sounds like a small detail, but this is a very important aspect of working with LLMs! So we agreed that what would be made unfamiliar was the shape: how statements are formed, how calls look, how blocks nest, etc. From this point, the agent proposed spending the "strangeness budget" there and keeping arithmetic and literals boring on purpose, so the language would be strange without being hostile. For that experiment, I might still make an actual hostile language and try it in the future.

Then it offered three shapes:

  • A flow language: every statement reads from left to right, data on the left and steps on the right, joined by ->.
  • Sentence templates, in the style of Inform 7, where procedures are sentences with holes. The most distinctive, but the hardest to parse.
  • Prefix calls with fixed arity, in the style of Logo. The simplest parser, but it reads like a known family of languages.

I went with the flow, and that’s how Slopersons got its look:

[3 8 15 22 7] => numbers
0 => total

numbers -> array/each n
| n mod 2 = 0 ? total + n =>> total

"even total: {total}" -> io/print-line

=> binds a name, | in the margin marks a block, ? is a condition, ~ starts a comment, yes and no are the booleans, nothing is the absent value. Also, a bare name is never a call: you call things by flowing a value into them.

The agent asked most of its questions as multiple choice, with a recommended option and the trade-offs of each one. That made it easy for me to decide quickly, and, more importantly, to disagree.

Where I overruled the agent

And I disagreed a lot. Looking back at the session, these are the choices where I went against its recommendation:

  • Functions as values. The agent recommended that behavior could only be passed as a trailing block, never stored or returned. I chose full first-class procedures, so it redesigned around them: a bare name became a value, and calling a procedure with no inputs became datetime/now -> do.
  • Scoping. It recommended that lambdas share their procedure’s variables while procedures stay isolated. I chose "always local, with an explicit marker to update an outer variable", which became =>>.
  • Exports. It recommended exporting every top-level name, Python style. I chose explicit export lines.
  • Error handling. It recommended try/rescue. I picked attempt/recover, a pair no mainstream language uses, and later always instead of finally.

The best moment was what happened right after I chose =>>. The agent came back with a refinement: with my rule, writing total + n => total inside a loop would silently create a new local total and leave the outer one untouched, a bug that would be really annoying to find. So it proposed turning that into an error that points to the fix:

error: `total` already exists in an enclosing scope; use `=>> total` to update it or choose another name
  at even.slop:5:27

Remember this message? In the last post, it was one of the error messages that taught the sandboxed agents the scope rules. It exists because the agent questioned my decision instead of just implementing it.

Not a proxy over JavaScript

The second constraint needed a line drawn somewhere, since an interpreter written in TypeScript can’t avoid JavaScript numbers or I/O. The agent proposed one, and I accepted it: data structures and algorithms are ours, numbers, Unicode tables and I/O come from JavaScript.

So the interpreter has its own hash table (an ordered one with open addressing), its own stable merge sort, its own string search, split and trim over code points, its own calendar math and, later, its own UTF-8 encoder and decoder. Ints are arbitrary precision. And there’s a test that greps the source for Map, Array.prototype.sort, Date, regexes and friends, and fails if any of them shows up where language behavior lives. A rule that a test enforces survives a lot longer than a rule written in a README.

This constraint paid off in an unexpected way later. When I asked for a JSON parser that worked like the original one by Douglas Crockford, the agent read the source first and pointed out that json2.js doesn’t parse anything: it validates the text with four regular expressions and hands it to eval. Porting that "faithfully" was impossible, so the parser had to be a real recursive-descent parser, and I asked for it to be written in Slopersons itself, available without an import, like any builtin.

That meant building a mechanism for standard library modules written in the language. Today, json (294 lines), test (67 lines) and http (414 lines) are written in Slopersons. The test module even needed a small hook so it could print its summary and set the exit code when the program ends. Writing them in Slopersons was the best test the language got: if it can’t express its own JSON parser, it’s not much of a language. It’s also where you see how slow it is: the JSON parser does about 400 KB per second. Fine for a config file, not for big data.

I had to move that line once when the issue tracker from the last post needed to store passwords: I told the agent to use Node’s crypto underneath instead of writing the hashing itself. Hand-written cryptography is where mistakes cost the most, and Slopersons is way too slow for the 600000 rounds of PBKDF2 that OWASP recommends.

The first 45 minutes

This part still impresses me: after the syntax discussion, the agent wrote a plan with the whole language specification: lexical rules, parsing rules, semantics, standard library, docs. I approved it, and about 45 minutes later there was a lexer, a parser, a tree-walking interpreter, a standard library with ten modules, documentation for each of them, and 317 passing tests. The only thing I said in between was "Keep going until all agents are done and tests pass".

The trick was parallelism: the main agent worked on the core while five subagents each wrote part of the standard library, with its tests and docs. And the integration problems you’d expect from five people working in parallel showed up too: agents for different modules registered functions with the same names (put, keep, copy, count), and one would silently replace the other. The main agent’s fix was to make registering the same name twice crash at startup, so a collision fails on the very first test run instead of hiding.

The same thing happened later, when I asked to move the whole standard library into modules (print became io/print-line, length became array/length and string/length, and so on). Eight subagents converted tests and docs while the main agent changed the interpreter, and the subagents found bugs in its part: error messages that still used the old names, conversions that accepted the wrong type, and error functions that had silently lost their input checks. Agents reviewing agents worked surprisingly well, maybe because each of them only had to compare a small slice against the spec.

Writing the decisions down

From the first plan, every decision went into a docs/decisions.md file with its alternatives and the reasons they lost. A few hours in, I ran my adr-setup skill, and the log became Architecture Decision Records: 53 of them right away, and now there are 95.

ADRs are important because they tell the story of how the language changed. For example, the output procedures initially were print and print-ln. Then, while designing streams, I noticed that we had read-line next to print-ln, and that inconsistency had to go, so print-ln became print-line.

Why does that matter so much with an agent? Because every session starts from zero. Over these days there was a /clear, a model switch, and several context compactions, and each time, what kept the agent consistent wasn’t its memory, it was the AGENTS.md file and the ADRs. AGENTS.md tells it to read the ADR index before proposing a language change, and to stop and ask when a request conflicts with an accepted decision.

Where it got it wrong

On the second day, while reviewing the HTTP ADR, I noticed this sentence: "The project owner wants…". I asked whether that kind of wording belonged in an ADR. The agent agreed it didn’t, counted, and found it in 34 records.

Think about it: a decision record whose reason is "the owner wanted it" tells a future reader who decided, never why. When circumstances change, nobody can tell if the decision still holds. And it wasn’t one slip: once the phrase was in a record, the agent kept copying its own pattern into every new one.

Its first suggestion was to leave the old records alone, since ADRs are immutable. I said no, and told it that it could perfectly well look up how ADRs should be written. So it went to the sources: Michael Nygard’s original post (the context should describe the forces at play, in value-neutral language), MADR, which keeps the people in optional metadata and the reasons in the body, and Microsoft’s guidance (a record without justification loses its value over time). In the end, 39 records were reworded (some cited "the brief", which isn’t in the repository either), with no decision changed. And then a test was added that fails if a record says who asked instead of why.

This one isn’t about languages at all: an agent repeats its own patterns, the bad ones included, and a mistake in the first record becomes a convention by the tenth.

The hard parts

Most of the language went smoothly, but…

The calculator that answered everything at once

One of the first examples was an interactive calculator. The agent had tests for it, they passed, and when I ran it in my terminal, this happened (trimmed):

Welcome to the slopersons calculator.
=
-
-
...
First number: '=' is not a number, try again.
First number: '-' is not a number, try again.
...
Operator (+, -, *, /): Second number: -1 + 3 = 2
Another calculation? (y/n): Goodbye.

The prompts didn’t show up while I typed my answers, and all the output came out at the end, after I was done. The cause: programs run on a worker thread (more on why below), and a worker’s standard output sends one chunk to the main thread, then waits for an acknowledgement before sending the next. That acknowledgement is processed by the worker’s event loop, which never runs while the program is blocked reading a line. The first line got out, everything after it waited until the end.

But what about the test? It passed by luck: it wrote only once before reading. The fix was writing synchronously to the file descriptors, and making the test write twice before reading, reproducing my session.

Why a worker thread at all?

A tree-walking interpreter uses many JavaScript frames per Slopersons call (about sixteen, in this one), so Node’s default stack only fit about 630 nested calls. The obvious fix, node --stack-size, segfaults when the value is larger than the operating system’s thread stack. So programs run in a worker with a 256 MB stack, and the interpreter stops at 10000 nested calls with a regular Slopersons error. That one decision affected almost everything after it: output, input, the REPL, sockets, and Ctrl-C all had to deal with "the program is on another thread".

Bugs it found by itself

To be fair, the agent found many problems before I did, usually while doing something else:

  • Piping a program into head printed a Node EPIPE stack trace, breaking the "nobody sees JavaScript" rule.
  • io/read-line decoded standard input one byte at a time, so café ü came back with 8 characters instead of 6.
  • Program arguments were silently dropped: slopersons echo.slop a b c never saw a b c.
  • "1e400" -> string/to-float returned Infinity, which the docs said was an error.
  • Its own randomized JSON tests were weaker than it claimed: the random generator’s low bits repeated, so 150 "random" documents had only 48 distinct texts. It noticed because a bug it introduced on purpose went undetected.

That last one is my favorite. The agent checked its tests by breaking the code on purpose and confirming that a test failed (mutation testing, with a few small scripts). It’s a habit I’d like more humans to have, and it caught weak tests more than once.

Sockets in a language that can only wait for one thing

When I asked what we’d need for small web apps, the agent’s first proposal was to run Node’s HTTP server on the main thread and hand requests to the program. I found that too high level: I wanted sockets first, and HTTP built on top of them, in native Slopersons.

The problem is that Node has no blocking sockets, and the interpreter is synchronous. So the program’s thread posts a request ("accept a client", "read up to 64 KiB from connection 3"), blocks with Atomics.wait, and the main thread does the networking and wakes it up. The REPL already used that trick for reading lines, so it extended that pattern. The result is a classic accept loop:

"127.0.0.1" -> socket/listen 8080 => listener
while yes
| listener -> socket/accept => connection
| attempt
| | connection -> stream/read-line => line
| | connection -> stream/write-line "you said: {line}"
| recover problem
| | io/stderr -> stream/write-line "connection failed: {problem}"
| always
| | connection -> stream/close

It serves one client at a time, and I set the limit of 10 waiting clients, so the agent made some calls on how to deal with these limitations and recorded them in ADRs in Proposed state until I accepted them.

Ctrl-C

Making Ctrl-C behave like exit (stop, flush open files, close connections, exit with 130) sounds like a signal handler, right? WRONG. The program’s thread could be in a busy loop, waiting for a socket or blocked reading standard input, and a blocking read can’t be interrupted from another thread. So the interpreter checks a shared flag on every call, and every loop pass, socket waits get cancelled by the main thread, and standard input moved to the main thread too. A second Ctrl-C kills the process for good, which needed one more trick, since worker.terminate() can’t stop a thread stuck in a system call either.

Tests that hang, and tests that test nothing

The sockets made the test suite fragile in new ways. During one round of mutation checks, the whole suite hung: a test used spawnSync with no timeout, and a mutation stopped a program from exiting. In another, a stray REPL kept running for 16 minutes after its test ended. Every spawnSync got a timeout, the network tests kill whatever they leave behind, and the terminal tests kill their whole process group.

And the funniest one: in one mutation round, not a single mutation made a test fail. The agent found that suspicious (which is good!) and dug in. My shell is zsh, which doesn’t split an unquoted variable into words, so the list of test files went through as one path that didn’t exist, and no test ran at all. That’s exactly why you check the checks.

A bigger app, and what it found missing

The apps in the last post started small, so for the third one I asked for something bigger: an issue tracker with logins, projects, a workflow, comments, and a JSON API, split into layers. I also asked the agent to fix whatever the app needed in the language itself, with an ADR for each decision, and to write the new parts in native Slopersons whenever it could.

It needed five things: password hashing, cookies, a way to create folders, a fix in the parser, and a way for errors to carry data. That last one is my favorite. In a layered app, the web layer has to know whether something failed because it doesn’t exist or because you’re not allowed, to answer with a 404 or a 403. With errors that only carried a message, the only way to tell was matching the message’s text, which breaks the day someone fixes a typo. So fail got an optional value that error/details reads back:

proc not-found message
| message -> fail {"problem": "not-found"}

The parser fix superseded an older ADR. A hashmap that spans several lines inside a procedure couldn’t repeat the procedure’s | margin, and now it can. It wasn’t free, though: one old layout of a lambda stopped parsing, and the new ADR says so, along with the workaround. Three of the four new ADRs are still Proposed, waiting for me to accept them.

The designer fell into its own trap

Remember the precedence trap from the last post?

{"id": 1} -> hashmap/at "id" = 1

A procedure’s arguments are whole expressions, so this compares "id" = 1 and passes the result to hashmap/at. The agent that designed the language fell into it three times while writing the tracker, and once it even made a golden program print the wrong answer before it caught the mistake. It also kept naming variables file and error, which hides the modules with the same names.

Where I had to step in

Besides the ADR wording, there were multiple moments where I had to point out the problem myself:

  • The REPL had no arrow keys. The agent had even listed it as a known trade-off, but a REPL where you can’t move the cursor isn’t acceptable. The fix moved line reading to the main thread with Node’s readline, and later a test drove the REPL inside a real pseudo-terminal.
  • The read-line/print-ln inconsistency went unnoticed until I saw them side by side.
  • "Golden programs" (complete programs checked in next to their exact expected output) were used everywhere and defined nowhere. I only found out because I asked what they were.
  • The users example saved its JSON in the repository root, because file paths are relative to the working directory. That one was correct behavior, and the agent explained why (a tool should find the files where you are), but it led to a path module and a way for a program to find its own folder.
  • The commit with the tracker’s changes to the language was titled "Add what the issue tracker app needed from the language". The tracker lives in another repository, so anyone reading the language’s history would find a name that points nowhere, and no clue about what changed. It’s the ADR wording mistake again, in a different place: writing down what asked for the change instead of the change itself.

None of these were hard to fix. They just needed someone actually using the thing.

So, can a coding agent build a programming language?

I’d say yes, as long as "programming language" means a small, experimental one. In about two days of work, Slopersons got a REPL, modules, error handling, files and streams, JSON, sockets, a minimal HTTP server, a testing library and a standalone executable. That’s 37 commits, around 8000 lines of interpreter and standard library, 6800 lines of tests (891 of them), 24 standard library pages and 95 decision records.

That sounds like a lot, and for an experiment it is. But it’s far from what makes a language usable for real work, as you’ll see below.

But "the agent built a language" isn’t quite right either. It wrote the code, and wrote it well, but the language’s character came from decisions: what to reject, when to go against the reasonable default, what "not a proxy" means, what is too high-level.

What can we learn from that?

  1. Ask for options with a recommendation, then feel free to overrule it. The recommendation shows you the agent’s reasoning, and the options show you the trade-offs. Defaults pull things toward the average, so if you want something specific, you have to choose it
  2. Write the decisions down, with reasons. Every session starts from zero for the agent. A decision that isn’t written down will be "improved" away, and a decision written without its reason can’t be re-evaluated
  3. Test the real thing, and use it for something real. Unit tests in-process didn’t catch the dropped arguments or the stuck prompts. Golden programs running through the real binary did. And no test asked for password hashing or cookies, but the first real app did
  4. Check the checks. Breaking the code on purpose found weak tests, tests that passed by luck, and one round where no test ran at all
  5. Agents repeat their own patterns, good and bad. Review the first instance of anything that will be copied

None of this is new, right? It’s what we’ve always wanted from humans building software: clear decisions, written reasons, tests that test something. Agents just make the cost of skipping them show up faster.

Again, I don’t want to oversell it. Slopersons is a small language that one person cared about for a few days. Real languages take years and a community, and they come with things Slopersons doesn’t have: a package manager, a debugger, editor support, concurrency, decent performance. It’s a tree-walking interpreter, so it’s slow. It handles one connection at a time, has no bytes type, and dates are UTC only. It was only built and tested on my computer. It still has traps that even its designer falls into. And the only programs written in it so far were written by agents. A language with real users would find problems that no test suite has ever imagined.

Wrapping up

I started with a prompt that asked for something "nothing like any other language" and ended up with a small, slow, experimental language that agents can learn without ever seeing it before. Along the way, the agent was a great builder, a decent reviewer of its own work, and sometimes a very consistent repeater of its own mistakes.

The source code is available on GitHub. If you write something in it, or manage to break it, I’d love to hear about it!

We want to work with you. Check out our Services page!

We want to work with you

The engineers who write here are the same ones who join client teams. Let’s build something that grows with your business.

Book a call