What if AI did not write code at all? No JavaScript, no Python, just the raw bytes the machine runs. “Skip the code and generate the binary directly” is an idea that keeps going around, so I tested it for real with a local model on my own computer, with an automated harness that validates, runs and tests every answer. Every result below is a real run, including the many failures.
This tutorial walks through the full experiment: how a WebAssembly module is laid out byte by byte, how to build a test harness in Node.js 22 that asks Ollama (qwen2.5:7b, Q4_K_M, CPU only) for answers in three formats, how to validate them with WebAssembly.validate and wabt, and what the scoreboard says about AI and binary.
⚡ TL;DR – A website cannot be pure binary: browsers run text (HTML, CSS, JavaScript). WebAssembly is the closest real thing. – Raw hex
.wasmbytes from a 7B local model: 0/15 valid. The 8-byte header was perfect every time; a length field broke at byte 10. – WebAssembly text (WAT): 6/15 passed, 4 only after renaming old instruction names. – Plain JavaScript: 13/15 passed; the 2 failures imported a made-up package. – Better idea: let AI write code and let compilers produce the binary.
Before you start
- Node.js 22 (built-in
fetch,WebAssembly, ES modules). - Ollama with
ollama pull qwen2.5:7b. Any machine works; the video ran on CPU only. - wabt (WebAssembly Binary Toolkit). The npm package
wabtexposesparseWatfrom JavaScript. - Basic familiarity with hex and with ES modules.
Step 1: Understand the honest catch
Browsers do not run binary websites. They parse text: HTML, CSS and JavaScript. So “a website written in pure binary” is impossible today. The one binary format browsers do execute is WebAssembly (.wasm). The experiment therefore becomes: the AI writes a wasm module as raw hex bytes with no source code at all, and a tiny HTML page loads and runs it.
Step 2: Read a real wasm module byte by byte
Here is a hand-written add function, 41 bytes in total:
00 61 73 6d ; magic "\0asm"
01 00 00 00 ; version 1
01 07 01 60 02 7f 7f 01 7f ; type section: (i32, i32) -> i32
03 02 01 00 ; function section: 1 function of type 0
07 07 01 03 61 64 64 00 00 ; export section: "add" -> func 0
0a 09 01 07 00 20 00 20 01 6a 0b ; code: local.get 0, local.get 1, i32.add, end
Why this is hard for a language model: every section starts with its own length in bytes, encoded as LEB128. Get one count wrong and every byte after it is read as something else. There are no comments, no names, and no partial credit. A language model predicts the next token; it does not count bytes.
Sanity-check the harness with the known-good bytes first:
// sanity.mjs
const hex = "0061736d0100000001070160027f7f017f030201000707010361646400000a09010700200020016a0b";
const bytes = Uint8Array.from(hex.match(/../g), h => parseInt(h, 16));
console.log("valid:", WebAssembly.validate(bytes));
const { instance } = await WebAssembly.instantiate(bytes);
console.log("add(2,3) =", instance.exports.add(2, 3)); // 5
Valid, and add(2, 3) returns 5. The harness works.
Step 3: Define the tasks
Three small jobs, each with an automatic test:
// tasks.mjs
export const tasks = {
add: {
desc: "export a function add(a, b) that returns a + b for 32-bit integers",
test: e => e.add(2, 3) === 5 && e.add(-7, 10) === 3,
},
counter: {
desc: "export a function next() that returns 1, then 2, then 3 on successive calls",
test: e => e.next() === 1 && e.next() === 2 && e.next() === 3,
},
draw: {
desc: "export memory and a function draw() that fills the first 256 RGBA pixels with opaque red",
test: e => { e.draw(); const m = new Uint8Array((e.memory || e.mem).buffer);
for (let i = 0; i < 256 * 4; i += 4)
if (m[i] !== 255 || m[i+1] !== 0 || m[i+2] !== 0 || m[i+3] !== 255) return false;
return true; },
},
};
Step 4: Ask the model in three formats
// ask.mjs
const PROMPTS = {
hex: t => `Write a WebAssembly module that will ${t}. Output only the bytes as hex, starting with the wasm header. No source code, no explanation.`,
wat: t => `Write a WebAssembly text (WAT) module that will ${t}. Output only the WAT.`,
js: t => `Write a plain JavaScript ES module that will ${t}. Output only the code.`,
};
export async function ask(format, task) {
const r = await fetch("http://localhost:11434/api/generate", {
method: "POST",
body: JSON.stringify({ model: "qwen2.5:7b", stream: false,
prompt: PROMPTS[format](task), options: { temperature: 0.7, num_predict: 1024 } }),
});
return (await r.json()).response;
}
Step 5: Check every answer automatically
// check.mjs
import wabtInit from "wabt";
const wabt = await wabtInit();
function hexToBytes(s) {
const pairs = s.match(/\b[0-9a-fA-F]{2}\b/g) || [];
return Uint8Array.from(pairs, h => parseInt(h, 16));
}
function watToBytes(src) {
try { return { bytes: wabt.parseWat("m.wat", src).toBinary({}).buffer, renamed: false }; }
catch {
// second chance: pre-2019 names such as get_local -> local.get
const fixed = src.replace(/get_local/g, "local.get").replace(/set_local/g, "local.set")
.replace(/get_global/g, "global.get").replace(/set_global/g, "global.set");
return { bytes: wabt.parseWat("m.wat", fixed).toBinary({}).buffer, renamed: true };
}
}
export async function check(format, answer, test, n) {
if (format === "js") {
const url = "data:text/javascript;base64," + Buffer.from(answer + `\n//${n}`).toString("base64");
return test(await import(url));
}
const { bytes } = format === "hex" ? { bytes: hexToBytes(answer) } : watToBytes(answer);
if (!WebAssembly.validate(bytes)) return "invalid";
const { instance } = await WebAssembly.instantiate(bytes);
return test(instance.exports);
}
Note the //${n} comment appended to each JavaScript data URL. That line exists because of a real bug in my harness, covered below.
Step 6: Run five attempts per cell
// run.mjs
import { writeFile } from "node:fs/promises";
import { tasks } from "./tasks.mjs"; import { ask } from "./ask.mjs"; import { check } from "./check.mjs";
let n = 0;
for (const format of ["hex", "wat", "js"]) for (const [name, t] of Object.entries(tasks)) {
let pass = 0;
for (let i = 0; i < 5; i++) {
const a = await ask(format, t.desc);
await writeFile(`out/${format}-${name}-${i}.txt`, a);
let ok; try { ok = await check(format, a, t.test, n++); } catch { ok = false; }
if (ok === true) pass++;
}
console.log(format, name, `${pass}/5`);
}
Step 7: Read the raw-binary results
Raw hex, all three tasks: 0/5, 0/5, 0/5. Every answer started with a perfect header, 00 61 73 6d 01 00 00 00. Then the validator reported an error at byte 10: the section length. After that the model repeated zeros and small numbers until it hit the token limit.
One pixel answer looked convincingly like binary. It had the right header and the right “shape”, but it was the shape of a wasm file, not a wasm file. The model learned what these bytes look like, not what they mean. Loaded in the browser test page, draw.wasm was simply invalid: nothing to draw.
Step 8: Compare text formats
WAT (same model, same jobs): add 5/5 (3 of them used old names like get_local and passed only after the rename), counter 1/5, pixels 0/5. Text helps, but WebAssembly is still a niche language for a 7B model.
JavaScript: add 5/5, counter 5/5, pixels 3/5. The counter first showed 1/5, and that was my bug, not the model’s. Identical answers produced identical data URLs, so Node reused the cached module and the counter kept counting from where the previous attempt stopped. Making each URL unique fixed it. The harness can lie too, so test the tester. The two pixel failures were real: the model imported Uint8Array from a package called Uint8Array. That type is built into JavaScript; the package is invented.
Step 9: Draw pixels the right way
How do you actually get pixels from WebAssembly? Write it as text and let the compiler produce the binary:
(module
(memory (export "memory") 1)
(func (export "draw") (local $i i32)
(loop $l
(i32.store (local.get $i) (i32.const 0xff0000ff)) ;; bytes: ff 00 00 ff = R G B A
(local.set $i (i32.add (local.get $i) (i32.const 4)))
(br_if $l (i32.lt_u (local.get $i) (i32.const 1024))))))
wabt turns that into 75 bytes. The loader page fetches it, validates it, calls draw(), and copies memory into a canvas:
<canvas id="c" width="16" height="16" style="width:256px;image-rendering:pixelated"></canvas>
<script type="module">
const bytes = new Uint8Array(await (await fetch("draw.wasm")).arrayBuffer());
if (!WebAssembly.validate(bytes)) { document.body.append("Invalid wasm"); }
else {
const { instance } = await WebAssembly.instantiate(bytes);
instance.exports.draw();
const px = new Uint8ClampedArray(instance.exports.memory.buffer, 0, 16 * 16 * 4);
document.getElementById("c").getContext("2d").putImageData(new ImageData(px, 16, 16), 0, 0);
}
</script>
The result is a 16 by 16 red square drawn by 75 bytes of WebAssembly, made from text by a compiler rather than by an AI typing bytes.
The scoreboard
| Format | Passed | Notes |
|---|---|---|
| Raw hex bytes | 0/15 | Perfect header, broken length at byte 10 |
| WAT | 6/15 | 4 needed old-name rename |
| JavaScript | 13/15 | 2 imported a made-up package |
That is one small local model with five tries each. A bigger cloud model would likely do better on text, but the gap tells the story.
Why binary is so hard for LLMs
- Models learn from text, and there is very little raw wasm on the internet compared with source code.
- Binary formats need exact byte counts (LEB128 lengths); one slip corrupts everything after it.
- Even when binary output works, you cannot read it, review it or fix it.
- Compilers already turn text into correct binary every single time.
Troubleshooting
WebAssembly.validatereturns false on hand-written hex: recount each section’s length byte; it must cover exactly the bytes that follow in that section.- wabt rejects model WAT with “unexpected token get_local”: the model used pre-2019 names. Rename to
local.getand friends, and record it as a partial pass. - JavaScript results drift between attempts: dynamic
import()caches by URL. Make every data URL unique. - “Cannot find package” errors: the model invented a dependency. Treat it as a failure; that is a real-world risk.
- Canvas stays blank: check that the module exports
memoryand that you read pixels at the offset the module writes to.
Best practices
- Validate before you run:
WebAssembly.validateis cheap and catches structural garbage. - Instantiate untrusted modules with no imports so they cannot reach anything outside their own memory.
- Save every raw answer to disk so you can inspect failures, not just count them.
- Sanity-check the harness with known-good input before trusting any score.
- Let models write readable source; let toolchains emit binaries.
FAQ
Can a website be pure binary?
No. Browsers parse HTML, CSS and JavaScript as text. WebAssembly runs inside a page, but something textual still loads it.
Would a larger model write valid wasm bytes?
Possibly better on WAT and JavaScript. Raw bytes remain hard because of exact length fields and the scarcity of training data, which is what this series goes on to tackle.
Is WebAssembly faster to generate than JavaScript?
Not for an LLM. On this test it was far less reliable, and the speed benefit of wasm comes at runtime, not at generation time.
What is LEB128?
A variable-length integer encoding used for sizes and indexes in wasm. Small numbers take one byte; larger ones spill into more bytes, which makes counting even harder for a model.
Key takeaways
- A local 7B model writing raw
.wasmbytes went 0/15; the same model in JavaScript went 13/15. - The model reproduced the header perfectly but could not keep section lengths consistent.
- Your test harness can be wrong too: a cached data URL faked a counter failure.
- Code is not what slows AI down; it is the interface that makes AI output reviewable and fixable.
- Let AI write code, and let compilers produce the binary.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- n8n vs AI Agent: I Gave Both the Same Job (Local, Ollama)
- Local AI Video Generator: Build Your Own App (Free, Python, No GPU)
- AI Wrote Raw Binary Instead of Code. Here’s What Happened (WebAssembly)
- I Built a Browser That Only Runs Encrypted Binary (Ep 1)
- RAG From Scratch: Chunking, Embeddings, Reranking and Evaluation (Real Numbers)
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.