en2026-05-13

Building a Coding Agent in Zig: setup

Hypercode setup illustration

"What I cannot create, I do not understand."

Richard Feynman

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.

Why Zig?

Zig isn't the most obvious choice for a coding agent. Most existing agents are written in TypeScript, Rust or Go. So why Zig?

ReasonDetail
A single binaryDistribution with no runtime. The user downloads an executable of a few megabytes, runs it, that's it.
Static allocationAll memory is allocated at startup. No more alloc, no more free after that. This eliminates memory leaks and gives predictable latencies.
Zero dependenciesA coding agent reads your source code, manipulates your files, and accesses your API keys. The attack surface must be minimal.
comptimeLets you generate specialised code without macros or opaque generics. Perfect for a tool registry, for example.
LearningZig 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.

Style before code

Before writing a single line, we lay down the rules. The whole project will be built under strict discipline:

  • No recursion. Every loop has an explicit upper bound.
  • Static memory allocation: everything is allocated at startup, nothing after.
  • At least two assertions per function, on average.
  • Maximum 70 lines per function.
  • 100 columns per line, never more.
  • Zero external dependencies.

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.

Pinning the compiler version

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:

zig/download.sh
#!/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.

ElementRole
ZIG_MIRRORBase URL for official releases.
ZIG_RELEASEExact version. One place to change for a bump.
ZIG_CHECKSUMSSHA-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.

Preparing the .gitignore

We now have a multi-hundred-megabyte compiler in ./zig/. We absolutely must not commit it. Create a .gitignore:

.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.

The build.zig

This is the file that describes how to build the project. Zig has no Makefile and no Cargo.tomlbuild.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:

build.zig
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);
ElementRole
standardTargetOptionsLets the user pick the target (-Dtarget=x86_64-linux, etc.).
standardOptimizeOptionSame for the optimisation mode (-Doptimize=ReleaseFast).
addExecutableDefines the hypercode binary.
b.path("src/main.zig")The entry point. Path is relative to the project root.
installArtifactTells 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.

The manifest: build.zig.zon

The declarative counterpart of build.zig. It holds the package metadata:

build.zig.zon
.{
    .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",
    },
}
FieldRole
namePrefixed by a . — it's an enum literal, not a string.
fingerprintUnique identifier generated by Zig. Lets the tool detect forks.
minimum_zig_versionDocuments the minimum version (in addition to the comptime assertion).
dependenciesEmpty — that's our zero-dependency policy.
pathsList 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.

The first main.zig

This is our "Hello, World!". The goal is minimal: print the version to confirm the toolchain works end-to-end.

src/main.zig
//! 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.

Verifying everything works

./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.)

The commits

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.

Conclusion

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.