Zig Programming skill for Codex

Zig for the compiler you have, leak-free. Codex checks zig version first and writes for that release only, reading the installed standard library when an API is in doubt instead of guessing. Every allocation gets a visible allocator and a matching defer or errdefer, build.zig uses the createModule and root_module shape, and work is proven with zig build test and the leak-checking testing allocator.

  • Languages
  • $zig-programming
  • 210 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- zig-programming

Installs to ~/.agents/skills/zig-programming/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $zig-programming, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes, builds and debugs Zig code that matches the installed compiler - explicit allocators (DebugAllocator, ArenaAllocator, FixedBufferAllocator, testing.allocator), error unions with try, errdefer and defer, comptime generics, build.zig with createModule and root_module, build.zig.zon dependencies, zig test, and C interop through @cImport, linkLibC and zig cc. Use when starting or fixing a Zig project, upgrading build.zig across Zig releases, chasing leaks reported by the testing allocator, or calling C libraries from Zig.

Use it when

  • Starting or extending a Zig project or library
  • Upgrading build.zig or std usage across Zig releases
  • Tests report leaks or errdefer and defer cleanup looks wrong
  • Calling C code from Zig or cross-compiling with zig cc

Use something else when

  • The code is C and you are hunting memory corruption: use c-memory-safety
  • You need embedded Rust instead of Zig for firmware: use embedded-rust

How to check the result

zig fmt --check, zig build and zig build test pass on the pinned compiler version with the testing allocator reporting no leaks.

The mistake it prevents

Copying build.zig or ArrayList code from a tutorial written for a different Zig release; the APIs changed in 0.14 and again in 0.15.

~/.agents/skills/zig-programming/SKILL.md

--- name: zig-programming description: Writes, builds and debugs Zig code that matches the installed compiler - explicit allocators (DebugAllocator, ArenaAllocator, FixedBufferAllocator,… license: MIT ---

Zig is pre-1.0 and the standard library and build API change between minor releases. Good Zig work starts by pinning down the exact compiler version, then writes code where every allocation has a visible allocator and a matching defer/errdefer, every error is handled or propagated with try, and zig build test passes with the testing allocator reporting no leaks.

Before writing code

  1. zig version. Check build.zig.zon for .minimum_zig_version and CI for the version it installs. Write for that version only.
  2. Read AGENTS.md, build.zig, build.zig.zon.
  3. When unsure whether an API exists in this version, read the installed stdlib source instead of guessing: zig env prints lib_dir; grep under <lib_dir>/std/. This works offline and is the source of truth.
  4. zig build fetches .dependencies on first run; that needs network access. Ask before running it in a sandbox without network.

Version drift to expect

AreaOlder code (0.11-0.13)Current style (0.14 / 0.15)
Artifactsb.addExecutable(.{ .root_source_file = ..., .target, .optimize })b.addExecutable(.{ .name, .root_module = b.createModule(.{ .root_source_file = b.path(...), .target, .optimize }) })
Paths.{ .path = "src/main.zig" }b.path("src/main.zig")
General allocatorstd.heap.GeneralPurposeAllocator(.{}){}std.heap.DebugAllocator(.{}) (debug) or std.heap.smp_allocator (release)
ArrayListmanaged, stores the allocator0.15: std.ArrayList(T) is unmanaged; pass the allocator to each call
Stdoutstd.io.getStdOut().writer()0.15: std.fs.File.stdout().writer(&buf) then .interface, and flush()
.zon namestring "myapp"enum literal .myapp plus a .fingerprint
usingnamespace, asyncpresentremoved

When upgrading a project across versions, change the compiler, fix compile errors one by one with the release notes open, and do not mix idioms from two versions.

Allocators

SituationAllocator
Testsstd.testing.allocator (fails the test on leaks)
Debug builds of an appstd.heap.DebugAllocator(.{}), check deinit() result
Release, multithreaded appstd.heap.smp_allocator, or std.heap.c_allocator when linking libc
Request/frame/batch lifetimestd.heap.ArenaAllocator wrapping a parent; free everything at once
Known small upper bound, no heapstd.heap.FixedBufferAllocator over a stack buffer

Functions that allocate take allocator: std.mem.Allocator as a parameter and document who frees the result. Libraries never pick a global allocator.

const std = @import("std");

pub fn main() !void {
    var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
    defer std.debug.assert(debug_allocator.deinit() == .ok);
    const gpa = debug_allocator.allocator();

    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();

    const names = try loadNames(arena.allocator(), "names.txt");
    for (names) |name| std.debug.print("{s}\n", .{name});
}

Errors, defer and errdefer

const ParseError = error{ EmptyInput, TooLong };

/// Caller owns the returned slice and must free it with `allocator`.
pub fn upperCopy(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
    if (input.len == 0) return ParseError.EmptyInput;
    if (input.len > 4096) return ParseError.TooLong;

    const out = try allocator.alloc(u8, input.len);
    errdefer allocator.free(out);              // runs only if a later step fails

    for (input, out) |c, *o| o.* = std.ascii.toUpper(c);
    try validate(out);
    return out;
}
  • defer runs at scope exit always; errdefer only when the scope returns an error. Put errdefer immediately after the allocation it protects.
  • Use explicit error sets for public APIs so callers can switch on them; inferred !T is fine internally.
  • catch |err| switch (err) { ... } to handle specific errors; catch unreachable only when it is impossible and you would accept a crash in safe builds.
  • Optionals: ?T, unwrap with orelse, if (opt) |v|, or .? when null is a bug.

Collections in 0.15 style

var list: std.ArrayList(u32) = .empty;
defer list.deinit(gpa);
try list.append(gpa, 42);
try list.appendSlice(gpa, &.{ 1, 2, 3 });
const owned = try list.toOwnedSlice(gpa);   // list is now empty; caller frees `owned`
defer gpa.free(owned);

On 0.14 the same code compiles with std.ArrayListUnmanaged(u32). Hash maps: use std.StringHashMapUnmanaged(V) / std.AutoHashMapUnmanaged(K, V) with the allocator passed per call; remember map keys are not copied, so the key memory must outlive the map.

comptime generics

fn RingBuffer(comptime T: type, comptime capacity: usize) type {
    return struct {
        items: [capacity]T = undefined,
        head: usize = 0,
        len: usize = 0,

        const Self = @This();

        pub fn push(self: *Self, value: T) error{Full}!void {
            if (self.len == capacity) return error.Full;
            self.items[(self.head + self.len) % capacity] = value;
            self.len += 1;
        }
    };
}

Use comptime parameters for types and sizes; use anytype sparingly (it hides the contract) and check it with @hasDecl / @TypeOf where it matters.

build.zig (0.14/0.15 shape)

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,                  // only if you call C
    });
    mod.linkSystemLibrary("sqlite3", .{});

    const exe = b.addExecutable(.{ .name = "app", .root_module = mod });
    b.installArtifact(exe);

    const run = b.addRunArtifact(exe);
    if (b.args) |args| run.addArgs(args);
    b.step("run", "Run the app").dependOn(&run.step);

    const tests = b.addTest(.{ .root_module = mod });
    b.step("test", "Run unit tests").dependOn(&b.addRunArtifact(tests).step);
}

Dependencies: zig fetch --save <tarball-url> writes the URL and hash into build.zig.zon; then const dep = b.dependency("name", .{ .target = target, .optimize = optimize }); and mod.addImport("name", dep.module("name"));. Pin release tarballs or commit tarballs, not branch archives.

C interop

const c = @cImport({
    @cInclude("sqlite3.h");
});

pub fn openDb(path: [:0]const u8) !*c.sqlite3 {
    var db: ?*c.sqlite3 = null;
    if (c.sqlite3_open(path.ptr, &db) != c.SQLITE_OK) return error.OpenFailed;
    return db.?;
}
  • C strings need a sentinel: use [:0]const u8 and allocator.dupeZ to create one.
  • C pointers arrive as [*c]T; convert to ?*T or slices at the boundary and keep the rest of the code in Zig types.
  • zig cc / zig c++ are drop-in cross-compiling C compilers (zig cc -target aarch64-linux-musl); addCSourceFiles compiles C inside build.zig.
  • @cImport is slated to move into the build system (translate-c steps); if the installed version deprecates it, follow the release notes rather than this snippet.

Verification

  • zig fmt --check .
  • zig build and zig build test pass; the testing allocator reports no leaks.
  • Run the binary in -Doptimize=Debug (safety checks on) for any change touching indexing or pointer math; ReleaseSafe keeps checks in release.
  • Cross-compile once for each shipped target (zig build -Dtarget=x86_64-windows).
  • Report the Zig version the code was verified with.

Gotchas

  • undefined memory is not zeroed; reading it is a bug that Debug mode only sometimes catches (it fills with 0xaa).
  • Slices of a std.ArrayList (list.items) are invalidated by any append.
  • String literals are *const [N:0]u8; comparing strings needs std.mem.eql(u8, a, b), not ==.
  • Integer overflow panics in Debug/ReleaseSafe and is UB in ReleaseFast; use +% or std.math.add when wrapping or checked arithmetic is intended.
  • Unused locals and parameters are compile errors; discard with _ = x; only when the value is truly not needed.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Zig Programming"
  short_description: "Zig for the compiler you have, leak-free"
  default_prompt: "$zig-programming Our build.zig stopped compiling after upgrading Zig, fix it"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc