en2026-05-13

Building a Coding Agent in Zig: tools

Hypercode tools illustration

At the end of the fourth post, we wrote: "the agent has memory now; but it still can't do anything other than talk". This post fixes that. We give it its first real capability: reading a file.

By the end, Hypercode will be able to take a prompt like "read src/main.zig and summarize it", trigger a call to the read tool, get the contents back, and produce an answer. This is the moment the assistant becomes an agent.

Code stays on github.com/alexisbchz/hypercode.

The tool-call protocol

OpenRouter (and the OpenAI-compatible API behind it) works in three steps when a model wants to invoke a tool.

1. The request — we declare available tools in the tools field:

{
  "model": "...",
  "messages": [{ "role": "user", "content": "read src/main.zig" }],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "read",
        "description": "Read the contents of a UTF-8 text file.",
        "parameters": {
          "type": "object",
          "properties": { "path": { "type": "string" } },
          "required": ["path"]
        }
      }
    }
  ]
}

2. The response — instead of content, the model returns tool_calls:

{
  "choices": [{
    "message": {
      "tool_calls": [{
        "id": "call_abc",
        "type": "function",
        "function": {
          "name": "read",
          "arguments": "{\"path\":\"src/main.zig\"}"
        }
      }]
    }
  }]
}

arguments is a string containing encoded JSON — not a nested object. A detail that catches everyone the first time.

3. The next turn — we execute the tool and send the result back as a new message shape:

{ "role": "tool", "tool_call_id": "call_abc", "content": "<file contents>" }

The model sees the result, then either calls another tool or produces a final text answer. We loop until it produces text.

Rethinking Message

The old message struct (cf. Post 04) was:

pub const Message = struct {
    role: Role,
    content: ?[]const u8 = null,
    tool_calls: []const ToolCall = &.{},
    tool_call_id: ?[]const u8 = null,
};

Four fields, three optional. The compiler doesn't stop you from writing .{ .role = .assistant, .content = "hi", .tool_calls = some_calls } — an assistant message that's both text and tool-call. The wire format says that's impossible, but the Zig type doesn't know it.

Instead of juggling optionals, we use a tagged union. Each variant has the right shape for its role:

src/session.zig
pub const Message = union(enum) {
    user: []const u8,
    assistant_text: []const u8,
    assistant_tool_calls: []const ToolCall,
    tool_result: ToolResult,
};

pub const ToolResult = struct {
    tool_call_id: []const u8,
    content: []const u8,
};

pub const ToolCall = struct {
    id: []const u8,
    name: []const u8,
    arguments_json: []const u8,
};

Four variants. The union tag is the role. The compiler now forces every switch to handle each case — if we add system someday, you can't forget to write it.

The Role enum is deleted. It duplicated the union tag.

An ownership convention for append_*

Before, append did gpa.dupe of the content internally. Convenient, but it cost us a double allocation when tool_calls arrived already gpa-owned from openrouter.call.

New rule: append_* methods take ownership of the bytes they're handed. The caller pre-allocates; the session frees on deinit.

pub fn append_user(self: *Session, owned_content: []const u8) !void {
    errdefer self.gpa.free(owned_content);
    try self.messages.append(self.gpa, .{ .user = owned_content });
}

Consistent across all variants. On the caller side, when reading a line from stdin:

try session.append_user(try gpa.dupe(u8, trimmed));

One allocation, one free. The friction is small.

The tool table

For a single tool, we don't need a vtable or a fancy registry. A table of declarations plus a switch for dispatch:

src/tools.zig
pub const Definition = struct {
    name: []const u8,
    description: []const u8,
    parameters_json: []const u8,
};

pub const definitions: []const Definition = &.{
    .{ .name = read.name, .description = read.description, .parameters_json = read.parameters_json },
};

pub fn run(gpa: std.mem.Allocator, io: std.Io, name: []const u8, args_json: []const u8) ![]u8 {
    if (std.mem.eql(u8, name, read.name)) return read.run(gpa, io, args_json);
    return error.UnknownTool;
}

Adding a tool = adding an entry to definitions plus a branch in the switch. When we have six, we'll refactor to a table-driven dispatch. For now this is verbose but readable.

The read tool

src/tools/read.zig
const std = @import("std");
const constants = @import("../constants.zig");

pub const name = "read";
pub const description = "Read the contents of a UTF-8 text file from the working directory.";

pub const parameters_json =
    \\{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}
;

const Args = struct { path: []const u8 };

pub fn run(gpa: std.mem.Allocator, io: std.Io, args_json: []const u8) ![]u8 {
    const parsed = try std.json.parseFromSlice(Args, gpa, args_json, .{ .ignore_unknown_fields = true });
    defer parsed.deinit();

    const buf = try gpa.alloc(u8, constants.read_tool_bytes_max);
    defer gpa.free(buf);

    const contents = try std.Io.Dir.cwd().readFile(io, parsed.value.path, buf);
    return gpa.dupe(u8, contents);
}

Three things deserve a word.

ElementRole
parameters_json as a stringStoring the validated JSON schema as text lets openrouter.zig embed it raw into the request without parsing-then-stringifying.
constants.read_tool_bytes_maxHard 256 KiB cap. A larger file gets truncated — std.Io.Dir.readFile fills the buffer you supply, end of story.
gpa.dupe(u8, contents)contents points into buf which we free; we copy to caller-owned memory.

All bounds live in src/constants.zig, one auditable place.

Writing the JSON request by hand

Before tool calls, we had:

const payload = try std.json.Stringify.valueAlloc(gpa, req, .{});

Convenient, but for tools we need to write the schema's parameters as raw JSON (not a Zig struct). So we build the body explicitly with std.json.Stringify's streaming API:

src/openrouter.zig
fn write_request_body(
    writer: *std.Io.Writer,
    model: []const u8,
    messages: []const session.Message,
    tool_defs: []const tools.Definition,
) !void {
    var s: std.json.Stringify = .{ .writer = writer };

    try s.beginObject();
    try s.objectField("model");
    try s.write(model);

    try s.objectField("messages");
    try s.beginArray();
    for (messages) |m| try write_message(&s, m);
    try s.endArray();

    if (tool_defs.len > 0) {
        try s.objectField("tools");
        try s.beginArray();
        for (tool_defs) |d| try write_tool_def(&s, d);
        try s.endArray();
    }

    try s.endObject();
}

For each tool, we call beginWriteRaw around the schema — this tells Stringify: "what follows is already valid JSON, don't escape it". Without that, the schema would be stringified and end up in the payload as an escaped string literal.

fn write_tool_def(s: *std.json.Stringify, d: tools.Definition) !void {
    try s.beginObject();
    try s.objectField("type");        try s.write("function");
    try s.objectField("function");
    try s.beginObject();
    try s.objectField("name");        try s.write(d.name);
    try s.objectField("description"); try s.write(d.description);
    try s.objectField("parameters");
    try s.beginWriteRaw();
    try s.writer.writeAll(d.parameters_json);
    s.endWriteRaw();
    try s.endObject();
    try s.endObject();
}

write_message is an exhaustive switch on the Message union — four cases, four JSON shapes. The compiler enforces completeness.

Decoding the response

The wire-mirror types are now explicitly named (rather than nested anonymously like before):

const WireFunctionCall = struct {
    name: []const u8,
    arguments: []const u8,
};

const WireToolCall = struct {
    id: []const u8,
    type: []const u8 = "function",
    function: WireFunctionCall,
};

const WireMessage = struct {
    content: ?[]const u8 = null,
    tool_calls: ?[]const WireToolCall = null,
};

const Choice = struct { message: WireMessage };
const Response = struct { choices: []const Choice };

One type, one role. If OpenRouter adds a field to message, we add it in WireMessage and nowhere else.

The Result that openrouter.call returns becomes a richer union:

pub const Result = union(enum) {
    text: []const u8,                  // final reply
    tool_calls: []const ToolCall,      // model wants a tool
    network_error,
    http_status: u16,
    bad_response,
};

The agent loop

This is where Hypercode shifts from chat to agent. The answer function loops: call the model, on .text we're done, on .tool_calls we execute them and call again.

src/main.zig
fn answer(...) !void {
    var i: u8 = 0;
    while (i < constants.tool_iterations_max) : (i += 1) {
        const result = try openrouter.call(gpa, io, cfg.api_key, cfg.model, session.messages.items);
        switch (result) {
            .text => |text| {
                try stdout.writeAll(text);
                try stdout.writeAll("\n");
                try stdout.flush();
                try session.append_assistant_text(text);
                return;
            },
            .tool_calls => |calls| {
                try session.append_assistant_tool_calls(calls);
                for (calls) |c| try run_one_tool(gpa, io, session, stderr, c);
            },
            .network_error => fail(stderr, "could not reach {s}", .{openrouter.endpoint}),
            .http_status => |code| fail(stderr, "{s} returned HTTP {d}", .{ openrouter.endpoint, code }),
            .bad_response => fail(stderr, "unexpected response shape from {s}", .{openrouter.endpoint}),
        }
    }
    fail(stderr, "agent loop exceeded {d} iterations", .{constants.tool_iterations_max});
}

The tool_iterations_max = 16 bound (in constants.zig) kills infinite loops — if the model decides for whatever reason to call read over and over, we stop it. That's TigerStyle's "everything has a limit" rule.

Executing one call lives in run_one_tool:

fn run_one_tool(
    gpa: std.mem.Allocator,
    io: std.Io,
    session: *session_mod.Session,
    stderr: *Io.Writer,
    c: session_mod.ToolCall,
) !void {
    try stderr.print("→ {s}({s})\n", .{ c.name, c.arguments_json });
    try stderr.flush();

    const id = try gpa.dupe(u8, c.id);
    if (tools.run(gpa, io, c.name, c.arguments_json)) |out| {
        try session.append_tool_result(id, out);
    } else |err| {
        try stderr.print("× {s} failed: {s}\n", .{ c.name, @errorName(err) });
        try stderr.flush();
        const msg = try std.fmt.allocPrint(gpa, "tool '{s}' failed: {s}", .{ c.name, @errorName(err) });
        try session.append_tool_result(id, msg);
    }
}

Diagnostics on stderr

Deliberately, → read(...) and × ... failed: ... announcements go to stderr, not stdout. Consequence:

hypercode "read src/main.zig and summarize it" > answer.txt

answer.txt contains only the model's reply. Diagnostics stay in the terminal. When you want everything in the file: ... > answer.txt 2>&1.

POSIX convention, and the thing that makes tools composable.

Demonstration

./zig-out/bin/hypercode "Read the file src/main.zig and tell me in one sentence what its main function does."
→ read({"path": "src/main.zig"})
The `main` function initializes I/O buffers, parses CLI arguments, resolves
the configuration, and then either runs a single-shot answer loop or an
interactive REPL session that queries an LLM via OpenRouter, allowing it
to call tools like file reading in a loop until completion.

The trick: the model recognised the request ("read the file"), generated a tool_call with the right JSON arguments, we executed it, fed back the contents, and it synthesised an answer — all in one user invocation, two network round-trips.

Error:

./zig-out/bin/hypercode "Read the file does-not-exist.zig"
→ read({"path": "does-not-exist.zig"})
× read failed: FileNotFound
The file does-not-exist.zig does not exist in the current working directory.

The error is visible on stderr; we also send it to the model, which tells the user cleanly instead of crashing.

The commits

Two commits, two layers:

ba615ac feat(agent): tool-call protocol end-to-end
406af97 feat(tools): central limits + Read tool with simple dispatch

The first adds constants.zig, tools.zig, and tools/read.zig — isolated code that breaks nothing. The second flips session.zig, openrouter.zig and main.zig together to speak the tool-call protocol. It's a bigger commit because the changes are atomic — splitting would only create intermediate states that don't compile.

Conclusion

Hypercode is now an agent. Not a powerful one — it has one tool — but the mechanism is in place: every future tool is one entry in tools.zig plus a <name>.zig next to it.

In the next post, we add the two essential ones: write (create a file) and edit (modify a specific chunk of an existing file). At two tools, we already have 80% of a working coding assistant.

Stuck, or want to share notes? Join the Discord server.