
"What I cannot create, I do not understand."
Coding agents have become a daily companion for many of us. Claude Code, Codex, Cursor, Aider — each drives a model, dispatches tools, edits files. They're surprisingly powerful programs, and surprisingly simple once you look under the hood.
What better way to understand them than to reimplement one?
In this series, we'll build Hypercode, an open-source coding agent, written in Zig. No framework, no dependency — just the Zig 0.16 compiler and the standard library. Like the Git.ts series, we'll go feature by feature, tool by tool, one commit at a time.
The full code lives on GitHub: github.com/alexisbchz/hypercode.
Zig isn't the most obvious choice for a coding agent. Most existing agents are written in TypeScript, Rust or Go. So why Zig?
| Reason | Detail |
|---|---|
| A single binary | Distribution with no runtime. The user downloads an executable of a few megabytes, runs it, that's it. |
| Static allocation | All memory is allocated at startup. No more alloc, no more free after that. This eliminates memory leaks and gives predictable latencies. |
| Zero dependencies | A coding agent reads your source code, manipulates your files, and accesses your API keys. The attack surface must be minimal. |
comptime | Lets you generate specialised code without macros or opaque generics. Perfect for a tool registry, for example. |
| Learning | Zig is a DSL for machine code. You see exactly what the machine will do. |
The main inspiration comes from TigerBeetle, the financial database. Their TigerStyle is our reference: safety, performance, developer experience — in that order.
Before writing a single line, we lay down the rules. The whole project will be built under strict discipline:
These rules live in a CLAUDE.md file at the project root. It's a direct adaptation of TigerStyle, translated for a coding agent. You don't need to read it in detail to follow the series, but it's the compass we'll consult whenever an implementation choice is ambiguous.
First classic trap: "works on my machine". Zig nightlies move fast. To avoid that trap, we vendor the exact compiler version into the repo.
We create a zig/download.sh script that downloads Zig 0.16.0 and verifies its SHA-256:
#!/usr/bin/env sh
set -eu
ZIG_MIRROR="https://ziglang.org/download"
ZIG_RELEASE="0.16.0"
ZIG_CHECKSUMS=$(cat<<EOF
${ZIG_MIRROR}/0.16.0/zig-aarch64-linux-0.16.0.tar.xz ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17
${ZIG_MIRROR}/0.16.0/zig-aarch64-macos-0.16.0.tar.xz b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489
${ZIG_MIRROR}/0.16.0/zig-x86_64-linux-0.16.0.tar.xz 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00
${ZIG_MIRROR}/0.16.0/zig-x86_64-macos-0.16.0.tar.xz 0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7
EOF
)
The script detects the architecture and OS, downloads the matching archive, verifies the checksum, then extracts it into ./zig/. The binary becomes ./zig/zig.
| Element | Role |
|---|---|
ZIG_MIRROR | Base URL for official releases. |
ZIG_RELEASE | Exact version. One place to change for a bump. |
ZIG_CHECKSUMS | SHA-256 per (arch, OS) pair. Stops a tampered download in its tracks. |
The full script is adapted from TigerBeetle — they already solved the problem, we inherit directly. There's also a PowerShell version for Windows in zig/download.ps1.
Make it executable and run it:
chmod +x zig/download.sh
./zig/download.sh
Downloading Zig 0.16.0 ...
Extracting ./zig/cache/zig-aarch64-macos-0.16.0.tar.xz ...
Downloaded Zig 0.16.0 to /Users/alex/hypercode/zig/zig
And check:
./zig/zig version
0.16.0
The rest of the project will use ./zig/zig rather than the system zig.
.gitignoreWe now have a multi-hundred-megabyte compiler in ./zig/. We absolutely must not commit it. Create a .gitignore:
# Zig build artefacts.
zig-out/
.zig-cache/
# Vendored Zig toolchain — only the download scripts are tracked.
/zig/*
!/zig/download.sh
!/zig/download.ps1
# Local environment.
.env
.env.local
# Editor.
.idea/
.DS_Store
The trick here is !/zig/download.sh: we ignore everything inside ./zig/, except the download scripts. So a new contributor clones the repo, runs the script, and ends up with the same compiler as everyone else.
build.zigThis is the file that describes how to build the project. Zig has no Makefile and no Cargo.toml — build.zig is a Zig program that defines the build graph.
We start by pinning the compiler version at comptime. If anyone tries to compile with another version, we fail the build with a clear message:
const std = @import("std");
const builtin = @import("builtin");
const zig_version_required = std.SemanticVersion{ .major = 0, .minor = 16, .patch = 0 };
comptime {
const v = builtin.zig_version;
if (v.major != zig_version_required.major or
v.minor != zig_version_required.minor or
v.patch != zig_version_required.patch)
{
@compileError(std.fmt.comptimePrint(
"unsupported zig version: expected {d}.{d}.{d}, found {d}.{d}.{d}",
.{
zig_version_required.major, zig_version_required.minor, zig_version_required.patch,
v.major, v.minor, v.patch,
},
));
}
}
The comptime { ... } block runs at compile time. This is our first assertion: you can't use the wrong compiler without finding out immediately.
Then we define the executable:
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "hypercode",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(exe);
| Element | Role |
|---|---|
standardTargetOptions | Lets the user pick the target (-Dtarget=x86_64-linux, etc.). |
standardOptimizeOption | Same for the optimisation mode (-Doptimize=ReleaseFast). |
addExecutable | Defines the hypercode binary. |
b.path("src/main.zig") | The entry point. Path is relative to the project root. |
installArtifact | Tells zig build to copy the binary into zig-out/bin/. |
We expose four steps: build, run, check, test.
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
const run_step = b.step("run", "Build and run hypercode");
run_step.dependOn(&run_cmd.step);
const check_step = b.step("check", "Typecheck without installing");
check_step.dependOn(&exe.step);
const exe_tests = b.addTest(.{ .root_module = exe.root_module });
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_exe_tests.step);
}
The check step is worth a word. It forces the type-checker through without producing a binary — the fastest iteration loop you can have. Every time you change a file, run ./zig/zig build check, and you get compiler feedback in a few hundred milliseconds.
build.zig.zonThe declarative counterpart of build.zig. It holds the package metadata:
.{
.name = .hypercode,
.version = "0.1.0",
.fingerprint = 0xd3bfaf40e914a053,
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
"LICENSE",
"README.md",
},
}
| Field | Role |
|---|---|
name | Prefixed by a . — it's an enum literal, not a string. |
fingerprint | Unique identifier generated by Zig. Lets the tool detect forks. |
minimum_zig_version | Documents the minimum version (in addition to the comptime assertion). |
dependencies | Empty — that's our zero-dependency policy. |
paths | List of files included in the package. |
The fingerprint is generated the first time you run zig init. We keep it as-is: changing it is essentially lying about the package's identity.
main.zigThis is our "Hello, World!". The goal is minimal: print the version to confirm the toolchain works end-to-end.
//! Hypercode — entry point.
//!
//! For now this just prints a banner so we can verify the toolchain end-to-end.
//! CLI parsing arrives in Post 02.
const std = @import("std");
const Io = std.Io;
const version = "0.1.0";
pub fn main(init: std.process.Init) !void {
const io = init.io;
var stdout_buffer: [256]u8 = undefined;
var stdout_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.print("hypercode {s}\n", .{version});
try stdout.flush();
}
test "version is non-empty" {
try std.testing.expect(version.len > 0);
}
Three things deserve explanation.
The main signature. In Zig 0.16, main receives a std.process.Init — a struct that contains an arena allocator for the process lifetime, the command-line arguments, and the std.Io instance for I/O. This is different from previous versions, which had no parameter.
The explicit buffer for stdout. Rather than writing directly to the file descriptor, Zig 0.16 requires you to supply a buffer. This is intentional: the program controls exactly how much memory is used for I/O. No surprises, no hidden allocations. We buffer 256 bytes — more than enough for a banner.
The flush(). Without it, the buffer is never written. The zig init template comment reminds you: Don't forget to flush! It's one of those small disciplines you get used to.
The test block at the end is our first testable assertion. It's trivial — we check that the version isn't empty — but it sets the convention: every file carries its own tests.
./zig/zig build
./zig-out/bin/hypercode
hypercode 0.1.0
Quick check:
./zig/zig build check
(No output = success.)
Tests:
./zig/zig build test
(No output = success.)
Formatting:
./zig/zig fmt --check .
(No output = success.)
I like to chop commits into legible units. For this article, we have five:
600bf8d feat: print hypercode 0.1.0 banner
88b84a5 build: add build.zig pinned to Zig 0.16
18f300a build: vendor Zig 0.16.0 via download scripts
c76e831 chore: replace Go .gitignore with Zig conventions
8bc7837 chore: add CLAUDE.md style guide for Hypercode
Each one tells one story. It's a discipline that pays off: six months later, when you're trying to figure out why a particular decision was made, git log is more useful than any wiki.
We have the foundation: a repo, a pinned compiler, a build that works, a binary that runs. It's not a coding agent yet, but it's the base everything else will rest on.
In the next post, we'll tackle the CLI: parsing arguments, reading environment variables, picking the model. Hypercode will start to look like something.
Stuck, or want to share notes? Join the Discord server.