
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.
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.
MessageThe 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:
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.
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.
For a single tool, we don't need a vtable or a fancy registry. A table of declarations plus a switch for dispatch:
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.
read toolconst 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.
| Element | Role |
|---|---|
parameters_json as a string | Storing the validated JSON schema as text lets openrouter.zig embed it raw into the request without parsing-then-stringifying. |
constants.read_tool_bytes_max | Hard 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.
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:
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.
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,
};
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.
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);
}
}
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.
./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.
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.
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.