carlos / todomvc-bakeoff Public
🤖 TodoMVC bakeoff on Carloku — design
Status: draft for Paul's approval, 2026-10-23. Reviewed by Astra (builder check).
Why
CARLOS apps don't have to be written in Go, and the CARLOS Platform runs any binary that keeps its process contract. This bakeoff shows that with working apps. It's driven by curiosity and acts as a showcase. It isn't meant to pick a winner or feed a decision.
We build the same app, TodoMVC, in nine language setups. Each one renders HTML on the server and layers JavaScript on top, and all of them are deployed to the Carloku bakeoff account (bfj). A report page on the same account compares them.
Goals
- Nine working TodoMVC apps, live on Carloku, one per setup: 1. Go, without Rastrillo 2. Go, with Rastrillo 3. Rust 4. Zig 5. OCaml 6. Haskell 7. Swift 8. Nim (replaced Mojo, Paul's call 2026-10-24: Mojo can't make a single-file binary; see
docs/toolchains.md) 9. Spinel (a Ruby compiler that produces native binaries) - Every app passes the same behaviour tests with JavaScript on and with it off.
- A report page that puts honest numbers side by side: what each build cost in tokens, how readable the code is, how fast it responds, how it holds up under load, and the other measures listed below.
Non-goals
- Choosing a "best" language. The report presents numbers and doesn't crown anyone.
- Tuning one app beyond the others after the build. Every app gets the same build process and the same review-and-fix round, and nothing more.
- Rich features beyond TodoMVC: no sign-in, no sharing, no sync between devices.
Anti-goals (failure even if everything technically works)
- A report that looks rigged. One app gets a different UI, a different database, a bigger instance, more tuning, extra review rounds, or quietly drops part of the spec.
- An app missing from the report without comment. If a setup can't be built or can't run on Carloku, the report says so plainly and says why.
- Working around Carloku. If the platform lacks something an app needs (for example, a binary that needs files beside it), we file a request against the platform and report the app as blocked on it. We don't build workarounds. (Paul's standing rule.)
Decided with Paul (2026-10-23)
- One shared UI. Every app uses TodoMVC's standard markup and CSS (
tastejs/todomvc-app-template,todomvc-app-css). For the bakeoff this overrides the CARLOS skill's default that Rastrillo apps use Rastrillo's own UI components. The Go + Rastrillo app uses the TodoMVC CSS too. - Server-rendered with progressive enhancement. No client-side app framework. The page is HTML from the server and still works with JavaScript turned off.
- Each agent chooses its own stack. Web server, router, templating, SQLite binding and so on are each agent's own best recommendation for its language.
- The builder is Opus 5.5 for every app. Astra reviews every app through the codex CLI, and the same Opus agent fixes what the review raises.
- Spinel is already supported on Carloku. The Spinel builder starts from lpay's working setup (
paulca/lpay, directoryspinel/): tep (a Sinatra-style layer that drives Spinel), thetep-carlos.patchfor the socket contract, and the zig-based cross-build to a static aarch64-musl binary inBUILD-arm64.md. Mojo is expected to work if it compiles to a single binary. - Arm64 builds: try docs-t1 first (cross-compiling or an emulated arm64 container), and fall back to tito-studio over Tailscale.
- After the one fix round: remaining correctness gaps ship as they are, and the report shows them. Security findings get a second fix round, counted separately, because the apps are public.
- Downloads approved: GHC (via ghcup) and Mojo (via pixi) may be installed under
~on docs-t1. - Everything lives on the bakeoff account (
bfj): nine apps and the report page.
What every app must do (the shared brief)
Each builder gets the same written brief. It contains:
Behaviour
- The TodoMVC app spec (
tastejs/todomvc/app-spec.md), with these changes because the server holds the data: - Persistence: in SQLite on the server, not inlocalStorage. - Routes: real paths/,/activeand/completed, not#/fragments. The selected filter survives a reload because it's in the URL. - Editing state isn't persisted (same as the spec). - With JavaScript off, everything works through plain forms and links: add, toggle, toggle all, edit, delete, clear completed, and the three filters. Each action is a POST that redirects back to the current filter (post/redirect/get). Editing without JS uses a link to an edit view of that item, such as
/?edit=<id>, rendered with the edit field open. - With JavaScript on, the page behaves like the reference TodoMVC: double-click to edit, Enter saves, Escape cancels, blur saves, an empty title deletes, toggles happen without a full page reload, and the counter and the "Clear completed" button update in place. The JS sends the same POSTs and swaps in server-rendered HTML or applies a small JSON reply (the builder chooses). There's no build step and no framework. Plain ES modules are allowed, and so is vendored VanJS (the one reactive library CARLOS sanctions).
- Who owns a list: the whole app is one instance. Each visitor gets their own list through an anonymous, random,
HttpOnly,SameSite=Laxcookie set on the first visit. There's no sign-in. Forms are protected against cross-site posts (by SameSite plus an Origin check, or a CSRF token; the builder chooses).
Platform contract
- One binary for
linux-arm64(the platform default;carlos deploy --target), started as<bin> --socket <path> --db <path>. It listens on the unix socket, has no$PORT, and servesGET /healthzandGET /api/version. - Linked statically, or with only libc. Instances run sandboxed and see only their own build, so the builder must not assume any shared library exists on the box.
carlos vet --boot(a local check that the binary starts and answers/healthz) must pass before the first deploy. - Reads
CARLOS_HOSTwhen it needs its own hostname. Never parses the socket path. - Uses SQLite in WAL mode. Migrations only ever add.
- The app starts quickly after hibernation (sleeping when idle) and shuts down cleanly on SIGTERM.
- Configures the build probe (
carlos apps create --build-probe /api/version) socarlos deployconfirms that the new build is what's actually serving.
Repo shape for each app
apps/<slug>/in this bakeoff repo. The slugs arego,go-rastrillo,rust,zig,ocaml,haskell,swift,mojoandspinel.AGENTS.mdfirst. It holds the one rule and the tenancy declaration: one instance, anonymous lists by cookie, trust class C (plaintext todo text, chosen and written down).- A
README.mdwith how to build, run locally, test and deploy. One command for each (aMakefileor a script). - The app's own tests, whatever is normal for its language.
- The Carloku app name is
todo-<slug>, e.g.todo-rust.
What the builder may not do
- Change the markup or the CSS beyond adding hooks the JS needs, such as
data-attributes or wrapping each toggle in a<form>. The rendered page must look identical to the reference. - Add caching layers, a CDN or edge tricks that the other apps don't have. Each app may set ordinary HTTP cache headers on its static assets.
- Spend effort on performance tuning beyond what an experienced developer would write the first time. "Write it the way you'd normally write it well" is the instruction.
How the apps get built
- Shared tooling first, built by the orchestrator (this session) before any builder starts, so every app is judged by the same tools: - The brief, as one file every builder reads. - A behaviour test suite adapted from TodoMVC's Cypress tests (
tastejs/todomvc/cypress). It runs against any URL, once with JS on and once with JS off. The JS-off run uses forms only and skips the parts that are JS-only, such as double-click editing (replaced by the edit link). Builders run it locally against their own app. - A load test script and a metrics collector (see "Metrics"). - Toolchains installed per user, under
~(rustup, Zig tarball,opam,ghcup, Swift's static Linux SDK, Mojo throughpixi, Spinel built from source). GHC and Mojo each take several GB, so we ask Paul before downloading them (machine rule). - Nine Opus 5.5 builders run in parallel, each in its own headless
claudesession with JSON output so token use is recorded. Each starts from a fresh context with the same brief and the CARLOS skills (carlosframework/skills), plus Rastrillo's ownSKILL.mdfor Go + Rastrillo. Each builds, tests locally, then deploys tobfjwithcarlos deployand checks it on the live URL. - Astra reviews each app through
codex execagainst the brief, the spec and the code. The review is limited to correctness, spec compliance and security. It doesn't ask for performance tuning. - One fix round. The same Opus builder session fixes what Astra raised, then redeploys. Only one round, for every app. The one exception is security: if Astra still finds a security problem after that round, there's a second round for security fixes only, with its tokens reported separately.
- Blind readability scoring by Fable. Each app's code is shown with the language named but nothing else identifying, and scored against a fixed rubric. Astra doesn't score readability because it already reviewed the code.
- Measurement and report (below).
All local servers bind to 127.0.0.1. Scratch space goes in $TMPDIR and is removed at the end.
Metrics
All of them are collected by scripts in this repo and can be re-run.
| Measure | How | |---|---| | Tokens to build | From each builder's session log: input, output, cache read and cache write, plus turns and wall-clock time. The first build and the fix round are reported separately, and so is the Astra review. | | Correctness | Behaviour tests passed, JS on and JS off, against the live URL. Spec gaps found by review. | | Readability | Fable's rubric score (naming, structure, how easy it is to follow one request end to end, error handling, comments), with a short written justification. | | Size of the code | Lines of code split three ways: server code, templates, and JS. Also the number of third-party dependencies. | | Build | Three build times for the arm64 binary that ships: cold (the app and every third-party library compiled from already-downloaded sources; compiler and standard library as shipped), warm (libraries already compiled, app rebuilt from clean), and one-line change (rebuild after a trivial edit in the request-handling code). Plus binary size. Paul asked for cold and warm, 2026-10-23. Measured on docs-t1 where the app cross-compiles there; otherwise on the machine that built it, with the machine named beside the number and kept out of the ranking. | | Response time | Time to the first byte for GET / with an empty list and with 50 items, from docs-t1, median of repeated runs. | | Wake time | Time to the first byte on the first request after the instance has gone to sleep. | | Under load | A ramp for reads (GET / on a 50-item list) and for writes (toggling an item): 1, 2, 4 … 1,024 concurrent connections, 10 s per step. Reports peak requests per second, latency at every step, and where it slows down: the most connections at which doubling the load still raised throughput by 10% or more. A ramp stops early after two flat doublings or more than 1% errors. Paul asked for this, 2026-10-23, replacing fixed loads at 1, 16 and 64. | | Memory | Peak and idle memory from the platform, if it exposes them. If it doesn't, we file a request against the platform rather than measure inside the app. | | Page weight | Bytes of HTML and JS sent for GET /. |
Load tests run from docs-t1 over the public internet, so the numbers include network time. That's the same for every app, and the report says so. Every app runs on the same box class and instance size, and we run the apps one after another, never at the same time.
The report page
- A static site on
bfj(carlos deploy --kind static), namedbakeoff. - One page with a summary table of every measure for every app, and a chart per performance measure. Each app gets a short section covering its stack, notable choices, Astra's main findings, Fable's readability notes, a link to the live app and a link to its source.
- A "How we measured" section and a "What this does and doesn't show" section, including the network caveat, Spinel's single-threaded server and any app that's blocked.
- Built from the metrics files by a script, so a re-run regenerates it.
- Report copy goes through the copy-review gate before it ships.
Known risks
- Everything targets
linux-arm64, and docs-t1 is x86-64. Rust, Zig, Go and Swift (static Linux SDK) cross-compile well, and so does Spinel (lpay's zig recipe). OCaml, Haskell and Mojo are harder. Each tries docs-t1 first, by cross-compiling or in an emulated arm64 container, and falls back to tito-studio. If neither works within the effort limit, that app is reported as blocked. - Mojo may need runtime files beside the binary. The binary kind takes a single file. If Mojo can't produce one, we file a platform request and report Mojo as blocked.
- Spinel has no threads. lpay's notes flag its aarch64 fiber context switch as untested at runtime. Whatever concurrency model the Spinel server ends up with, the report states it next to the load numbers.
- Load testing shared boxes can affect other people's apps on the same box. We keep load runs short, one app at a time.
Effort limit
Every builder gets the same limit. If an app still doesn't have a local build passing the behaviour tests after 4 hours of wall-clock time, the builder stops and writes down where it got stuck, and the app is reported as blocked. All tokens a builder spends are reported, including for blocked apps and time spent getting toolchains working.
Left to the builders
- Each app's libraries, framework, server, templating and SQLite binding.
- How the JS talks to the server (HTML fragments or small JSON).
- Internal code layout, within the repo shape above.
Calls made by the orchestrator, not by Paul
- Real paths instead of
#/routes, and per-visitor lists by anonymous cookie. - Fable for blind readability scoring, separate from Astra's review.
- One review-and-fix round per app.
- All apps in one repo under
apps/<slug>/. - App names
todo-<slug>, and the report app namedbakeoff. - The shared test suite and load test are written once by the orchestrator, not by each builder.
- The effort limit: 4 hours of wall-clock time per builder.
- Build time for apps that can't cross-compile on docs-t1 is measured where they're built, with the machine named, and isn't ranked against the others.