Artificial Linguist

From Luddite to Lyric

A journey to explore what's really possible with LLMs (Large Language Models).

· Building Lyric with Claude Code


The title is a little tongue-in-cheek. I’m not really a Luddite; I’ve been quietly using and watching LLMs for about a year, and my view has shifted gradually rather than dramatically. I’ve watched them go from impressive demos to genuinely useful tools, and I’ve watched the “vibe coding” discourse with a fairly hard eye. My adoption curve has been gradual. I started with spec-driven design tools including Kiro and Antigravity. I won a Claude subscription as part of an internal company challenge and used it for a few personal projects. The results were good enough that I upgraded to Max 5x. At work I started using Copilot, often pointing it at the Claude models since that’s the most capable option my client allows. Over the months, more of my work has slid into LLM-assisted territory: documentation, requirements clarification, coding, debugging.

So when I sat down to write this, the honest framing wasn’t a dramatic conversion. It was: a 20-year engineer who’s been quietly watching wanted to find out what’s actually at the top end of what these tools can do.

I picked the hardest thing I could think of to test it. I asked it to design and build a programming language.

This is what happened.

Why a language

I’d been reading about Ada, specifically an essay called The Quiet Colossus, and was struck by how many of the safety properties modern languages have been independently rediscovering for forty years were already present, in mature, standardized form, in Ada by 1983. Range subtypes. Distinct nominal types. Design-by-contract. Opaque types. Protected types for concurrency. The whole landscape of “modern” type system features is, to a surprising degree, a slow rediscovery of decisions Jean Ichbiah’s team made before I started primary school.

It seemed like a worthy thought experiment to ask: if you sat down to design a language today that took those ideas seriously, married to a modern type system and runtime, what would it look like? And could a current-generation LLM be a real collaborator on that, or would it pattern-match to “make me a clone of Rust” and fall over the moment the design got hard?

A programming language is a particularly cruel test for an LLM. It’s an integrative problem: you can’t pattern-match individual snippets, because every decision constrains every other decision. The type system has to be consistent with the concurrency model, which has to be consistent with how you handle errors, which has to be consistent with how you do dependency injection, which has to interoperate with the runtime you’re targeting. Get any of it wrong and the whole thing collapses. There’s also a brutal feedback loop: code that doesn’t compile is obviously broken, and code that compiles but produces wrong output is observable as soon as you run a test.

I started in a regular Claude chat. We spent several long sessions on the design, covering what to take from Ada, what to take from Rust, Kotlin, Swift, SPARK; what to deliberately reject; how the type system would compose with the concurrency story; how proofs would interact with code that hadn’t opted into proofs. I directed where I had strong opinions, but in most cases I asked Claude to give me the trade-offs and went with its recommendation. I pushed back on a handful of things such as the string interpolation syntax (Swift’s \(...) is cleaner but ${} is what every TS, Kotlin, and shell developer has in muscle memory), the proposed split between spec files and implementation files (unnecessary ceremony in 2026), the use of a generic keyword instead of just [] for type parameters, the proposed FFI (Foreign Function Interface) mechanism (the first version was Java-flavoured and overcomplicated). For most other decisions I’d push it to justify the choice and accept the answer if the reasoning held up. Several decisions surprised me.

By the end of the design phase the language had a name, Lyric, and a 3,500-line specification across nine markdown files: an overview, a language reference, worked examples, a decision log, an out-of-scope list, a phased implementation plan, open questions, references, and a README. The plan was sober. Phase 1, a bootstrap compiler with the smallest credible language subset, was scoped at twelve months of focused engineering, drawing on rough comparables from Rust, Go, and TypeScript’s early histories.

I committed the spec to a fresh GitHub repo on the 29th of April. I pointed Claude Code at the docs/ folder. I gave it a single starter prompt: read the design documents, then begin Phase 1, starting with the lexer.

What got built in three days

I’m writing this on the 2nd of May. The repository sits at 180 commits. Phase 1 is complete. Phase 2 is substantially complete. Lyric targets .NET for three practical reasons: reified generics, so type parameters survive to runtime; value types, which keep thin wrappers like distinct types cheap; and Native AOT (ahead-of-time compilation), which leaves a path to small, fast native binaries. The compiler, written in F# (chosen for its discriminated unions and pattern matching, which make AST (Abstract Syntax Tree) manipulation natural on that same runtime), does end-to-end: a .l source file in, a runnable .NET assembly out. There’s a CLI (Command Line Interface), a stdlib import resolver with content-addressed caching, eight standard library modules covering strings, parsing, file I/O, errors, time, collections, JSON derivation, and iteration helpers. There’s a basic Language Server Protocol (LSP) implementation. There’s a documentation generator. There are 690 tests passing across five test suites.

The hello world is what you’d expect:

package Hello
import Std.Core

func main(): Unit {
  println("Hello, world!")
}

But this is the function I find genuinely arresting:

@externTarget("System.Collections.Generic.Dictionary`2.TryGetValue")
pub func tryGetValue[K, V](m: in Map[K, V], key: in K, value: out V): Bool = ()

pub func mapGet[K, V](m: in Map[K, V], key: in K): Option[V] {
  var value: V = default()
  if tryGetValue(m, key, value) {
    Some(value = value)
  } else {
    None
  }
}

That five-line function exercises about a third of the language’s interesting features. Generics with two type parameters. Foreign function interface to a generic .NET BCL (Base Class Library) method. Parameter modes (in, out) lowering to CLR (Common Language Runtime) byref. Definite-assignment analysis to prove value is written before it’s read. The default[T]() builtin picking its CLR type from the surrounding ascription. Sum types with named-field construction. The fact that this works, that I can write m.add("alice", 30) and match mapGet(m, "alice") { case Some(v) -> ... } and get the right MSIL out the other end with the JIT not throwing InvalidProgramException, represents weeks of careful infrastructure work.

It also shipped, with tests, in a single session.

Evaluating Claude Code’s decisions

The most useful artifact in the repository, to me, isn’t the compiler. It’s a file called docs/10-bootstrap-progress.md. Claude Code created it unprompted as a running engineering log: every significant decision it made during implementation, numbered, dated, with rationale, scope, and tracked debt. There are 32 numbered entries. They’re written for a future engineer (human or otherwise) coming to the code cold.

This is unusual. Most codebases I’ve worked on don’t have anything like this; you reconstruct the reasoning from commit messages and Slack history if you can find them. The progress log isn’t perfect prose, but it’s a far better record of why the code is the way it is than I’d get from any team I’ve ever managed.

Six decisions stood out as worth highlighting.

The parser-level AST synthesis pattern. Lyric has several features that would naively want bespoke codegen support, such as @stubbable interfaces (which need an auto-generated stub implementation), wire blocks (compile-time DI containers), and @derive(Json) annotations. Claude Code’s choice in every case was to lower these to AST synthesis at parse time: walk the parsed source, expand the annotated declarations into ordinary records and functions, hand the cooked AST to the rest of the pipeline. The downstream type checker and emitter never know these features existed. This is the kind of move you make once you’ve internalised that compiler complexity is multiplicative: every layer you involve in a feature multiplies the work, and the cheapest layer you can get away with is usually the right one. It’s also the kind of move that often gets argued out of a junior implementation team because it feels too clever. Claude Code reached for it on the third feature it implemented.

The willingness to retire its own code. A few sessions in, Claude Code had built a Std.Collections module containing monomorphised wrappers including IntList, StringList, LongList, StringIntMap, and StringStringMap because generic FFI wasn’t yet supported. Three sessions later it had built generic FFI properly (extern type List[T] = "System.Collections.Generic.List1”), and it deleted the entire monomorphised surface in favour of List[T]andMap[K, V]`. The progress log entry for that work is candid that the earlier surface was a workaround and the new one supersedes it. Most engineers struggle with this kind of self-supersession; the sunk cost feels too real. Claude Code shipped, recognised better was now possible, and deleted.

Real CLR-internals debugging. One progress entry walks through diagnosing an InvalidProgramException that surfaced when the type checker emitted an imported sum-type case constructor. The bug was that the per-field type peek was running before the expected-type-shape check, which produced a Result_Ok<obj, ParseError> on one branch of an if and a Result_Err<Int, obj> on the other, and the JIT (Just-In-Time compiler) correctly rejected the join. The fix reorders the inference so the expected-type shape gets first preference, then the return-type shape, then the per-field peek. That is real reasoning about how the .NET JIT instantiates generics, not pattern-matching to a known answer. There is no Stack Overflow post for this.

Definite-assignment analysis, done properly. When out parameters landed, the implementation included a textbook DA analysis: monotonic sets through sequential statements, intersection at if-joins, weak loop bodies, return-consumes-the-path, function-exit checks. A few sessions later the same DA analysis was extended through match arms with the same join shape. This is a real piece of compiler theory implemented correctly the first time, with the right separation of concerns between type-checker (l-value rule, T0085) and analysis (T0086). I’ve seen senior engineers hand-roll DA analysis incorrectly and ship the bug for years.

The “bootstrap-grade” discipline. Almost every progress entry ends with a section called Bootstrap-grade scope, listing what was deliberately deferred and why. This is the single most important thing on the list. Language projects don’t fail because the design is wrong; they fail because the scope grows faster than the team can absorb. Claude Code’s instinct, on its own, was to ship the simplest viable thing and write down what the next iteration would need. That’s the discipline most language teams don’t have.

Discovering its own gaps. When Claude Code wrote the first real program, a CSV (Comma-Separated Values) parser, it immediately surfaced four type-checker gaps that the test harness had missed. s[i] on a String wasn’t supported. println(<non-string>) didn’t type-check even though codegen handled it. String + <other> similarly didn’t type-check. Several builtins were codegen-only. It wrote up the gaps, fixed them in the same session, added tests. The instinct to write a real program to find out what’s broken, rather than declaring victory based on the synthetic test suite, is engineering judgement.

I’d be lying if I said I overrode it on much. I pushed back on a handful of design-time decisions such as the syntax pieces I mentioned earlier and the FFI shape, and on those it accepted the redirect, gave me a revised proposal, and got on with it. During implementation I asked it to rethink choices a few times and once or twice it convinced me my original instinct had been wrong. There were no moments where I had to revert a commit or unwind a bad direction.

That’s not because the work is flawless. There are real things to push back on, which I’ll come to.

What four years of effort used to mean

To get a sense of scale, here’s what reaching a working bootstrap compiler with a standard library and basic tooling has historically cost:

  • Go started in September 2007 with three senior engineers at Google: Pike, Thompson, and Griesemer. Go 1.0 shipped in March 2012. Roughly four and a half years to a stable language, and self-hosting in the gc compiler arrived a few years after that.
  • Kotlin started internally at JetBrains in 2010. The first milestone build was 2011, public open source in 2012, Kotlin 1.0 in February 2016. Six years, with a team that grew from a handful into dozens.
  • Rust began as Graydon Hoare’s side project in 2006, with Mozilla picking it up properly around 2009. Rust 0.1 in January 2012. Rust 1.0 in May 2015. A nine-year arc with substantial corporate backing.
  • Swift was started in secret at Apple by Chris Lattner in 2010 and shipped publicly in 2014. Four years, with one of the strongest compiler teams in the industry working on it full-time.
  • TypeScript began under Anders Hejlsberg in 2010, hit 0.8 in October 2012, and shipped 1.0 in April 2014. Roughly four years.

I want to be clear about what I am and am not claiming. Lyric is not a 1.0 language. It’s not even close. It’s a Phase 1 bootstrap with a Phase 2 substantially in. The async story is bootstrap-grade: Task<T> mapping rather than real state machines. The standard library is thin. The LSP is a placeholder. The proof system, which is the thing that would distinguish Lyric from “another good modern language”, is unstarted. There are 690 tests; the Roslyn test suite is in the tens of thousands.

What I am claiming is this: the time-to-credible-bootstrap has collapsed by something like two orders of magnitude. The body of work currently sitting in nichobbs/lyric-lang would, on any team I’ve ever worked with, have represented twelve to eighteen months of focused engineering for a strong individual contributor or a small team. It took three days. I directed it loosely, in a few hour-long planning sessions per day. The vast majority of the actual decisions, including the ones I think are the most interesting and the most technically right, were made by Claude Code with no human in the loop.

What I’d push back on

A balanced view requires me to flag the things that aren’t as good as the headline number suggests.

The async implementation is not real. It maps async/await to Task<T> in a way that works for simple straight-line code but doesn’t generate true IAsyncStateMachine IL. That’s the largest unfinished piece of the language and it’s a known item in the progress log as the next major focused effort. If you’re evaluating Lyric, the async story today is more aspiration than reality.

The LSP is rudimentary. Full document sync, no completion, no go-to-definition, no signature help, hover is a placeholder. Calling it Phase 3 work overstates it. It’s a foothold, not a feature.

The test count is thin. 690 tests across a compiler, a stdlib, a CLI, and an LSP is several lines per feature, not the thousands of edge cases a production compiler accumulates. Real users would find real bugs quickly.

There was visible scope creep. I’m not sure lyric doc and a basic LSP were the right things to spend time on in week one; the Phase 1 plan deferred LSP to Phase 3 explicitly, and Claude Code overshot the plan in pursuit of “visible features.” Some of the Phase 2 features shipped before the Phase 1 features they depend on were fully hardened. This is a tendency, and it would compound on a longer project unless it was actively managed.

There were small bugs that got fixed in subsequent iterations rather than caught the first time, such as JSON serialisation shipping without string escaping, then escaping was added in a follow-up PR. That’s the kind of “ship now, fix in v2” cycle that, in a real production language with real users, creates real problems. I don’t know yet how to systematically prevent it.

What changed

I started this experiment to find out what’s at the top end of what’s actually possible. I don’t think I have a clean answer. I have something messier: a working compiler in a repository, a long progress log of decisions I think are mostly good, a list of things that are still bootstrap-grade, and a set of comparisons to language histories that used to take half a decade.

What’s changed for me, concretely, is the scope of what I now consider a tractable side project. Six months ago I would have classified “design and bootstrap a new programming language” as a multi-year commitment requiring a team. Today I’d classify it as something I could make meaningful progress on in a long weekend, and the limiting factor is my own ability to direct the work well: asking the right questions, pushing back at the right points, recognising when a proposal is a local optimum that boxes me out of a better one. The part of the work that I contribute hasn’t gotten faster. The part of the work I delegate has gotten dramatically faster, and the quality of the delegated work is high enough that I’d accept a lot of it on a code review.

That’s not a conversion to evangelism. I still think most of the discourse around LLM-driven development is overheated. I still think there are real failure modes such as scope creep, plausible-but-wrong code, and the occasional insistence on a bad direction, that need active management. I still won’t ship anything to production without reading it carefully.

But I’m also no longer a sceptic about depth. The Lyric repository is the answer, for me, to “is this actually capable of integrative, multi-day, multi-decision technical work on something genuinely hard?” The answer turned out to be yes. The progress log is the receipt.

If you want to look at the work yourself, the repository is at github.com/nichobbs/lyric-lang. The compiler builds and runs. Hello world is in the README. The progress log is docs/10-bootstrap-progress.md. Make of it what you will.