
In the fifth post, we gave Hypercode its first tool: read. With a single tool, the agent could inspect but not act. What follows lifts the second restriction: write (create or overwrite a file) and edit (modify a specific spot in an existing file).
By the end, we'll have an agent capable of doing a complete task: "create this file, then modify that one". That's 80% of what a coding assistant needs to know.
Code stays on github.com/alexisbchz/hypercode.
@embedFileBefore adding new tools, we backport an improvement from Post 05. The JSON schema for read lived as a multi-line string in read.zig:
pub const parameters_json =
\\{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}
;
Readable at 10 characters. Unreadable at 100. Zig has @embedFile which inlines a file's contents at compile time. We move the schema to read.schema.json next to the .zig:
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file, relative to the working directory."
}
},
"required": ["path"]
}
And the .zig becomes:
pub const parameters_json = @embedFile("read.schema.json");
The file's bytes are embedded into the binary at compile time — zero runtime cost, JSON syntax highlighting in the editor, validation by any external JSON tool. Convention for every tool from here on: <name>.zig + <name>.schema.json side by side.
write toolpub const name = "write";
pub const description = "Write contents to a file, creating or overwriting it.";
pub const parameters_json = @embedFile("write.schema.json");
const Args = struct {
path: []const u8,
content: []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 args = parsed.value;
if (args.content.len > constants.tool_file_bytes_max) return error.FileTooLarge;
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = args.path, .data = args.content });
return std.fmt.allocPrint(
gpa,
"Wrote {d} bytes to {s}.",
.{ args.content.len, args.path },
);
}
Short because the stdlib does the work: Dir.writeFile creates the file (or overwrites it) and writes the contents. The only honest defence: cap the size at tool_file_bytes_max (256 KiB), shared with read — an agent that needs to write a 10 MiB file is probably doing something wrong.
The schema:
{
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file." },
"content": { "type": "string", "description": "Bytes to write." }
},
"required": ["path", "content"]
}
edit toolThis is the most important of the three. It takes three arguments: path, old_string, new_string. It reads the file, checks that old_string appears exactly once, and replaces it with new_string.
The exactly once invariant deserves explanation. Consider an agent that wants to rename a variable count to total in a file where count appears fifty times. A blind replace_all is dangerous — some counts are substrings (countdown, account) that shouldn't be touched. The solution: force the model to provide enough context to identify ONE specific site. Not two. One.
It's the pattern Claude Code, Codex, and Cursor all use. It forces the model to reason about context before editing, and to iterate (read, identify unique context, edit) instead of carpet-bombing the file.
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 args = parsed.value;
if (args.old_string.len == 0) return error.OldStringEmpty;
std.debug.assert(args.old_string.len > 0);
const cwd = std.Io.Dir.cwd();
const buf = try gpa.alloc(u8, constants.tool_file_bytes_max);
defer gpa.free(buf);
const contents = try cwd.readFile(io, args.path, buf);
const first = std.mem.indexOf(u8, contents, args.old_string) orelse return error.OldStringNotFound;
if (std.mem.indexOfPos(u8, contents, first + 1, args.old_string) != null) {
return error.OldStringNotUnique;
}
const new_len = contents.len - args.old_string.len + args.new_string.len;
if (new_len > constants.tool_file_bytes_max) return error.FileTooLarge;
const out = try gpa.alloc(u8, new_len);
defer gpa.free(out);
@memcpy(out[0..first], contents[0..first]);
@memcpy(out[first..][0..args.new_string.len], args.new_string);
@memcpy(out[first + args.new_string.len ..], contents[first + args.old_string.len ..]);
try cwd.writeFile(io, .{ .sub_path = args.path, .data = out });
return std.fmt.allocPrint(
gpa,
"Edited {s} ({d} → {d} bytes).",
.{ args.path, contents.len, new_len },
);
}
Four pairs of checks:
| Check | Why |
|---|---|
args.old_string.len == 0 → OldStringEmpty | An empty string would match at position 0 and insert new_string at the start. Hard refusal. |
indexOf → OldStringNotFound | If the model mistyped the target text (extra space, wrong case), hand it back with a clear error. |
indexOfPos(first+1) → OldStringNotUnique | The uniqueness invariant. If the string appears twice, refuse. |
new_len > tool_file_bytes_max → FileTooLarge | No runaway expansion. |
The write itself is a triple @memcpy: prefix, new string, suffix. No incremental reallocation, no ArrayList. A function that fits on one screen.
The schema:
{
"type": "object",
"properties": {
"path": { "type": "string" },
"old_string": {
"type": "string",
"description": "Exact text to replace. Must occur exactly once in the file."
},
"new_string": { "type": "string", "description": "Replacement text." }
},
"required": ["path", "old_string", "new_string"]
}
The description on old_string is crucial: it's what the model sees when deciding how to format its call.
tools.zig gains two entries and two branches. Five lines:
pub const definitions: []const Definition = &.{
.{ .name = read.name, .description = read.description, .parameters_json = read.parameters_json },
.{ .name = write.name, .description = write.description, .parameters_json = write.parameters_json },
.{ .name = edit.name, .description = edit.description, .parameters_json = edit.parameters_json },
};
pub fn run(...) ![]u8 {
if (std.mem.eql(u8, name, read.name)) return read.run(gpa, io, args_json);
if (std.mem.eql(u8, name, write.name)) return write.run(gpa, io, args_json);
if (std.mem.eql(u8, name, edit.name)) return edit.run(gpa, io, args_json);
return error.UnknownTool;
}
At six tools we'll probably want a table-driven dispatch (for (registry) |t|), but at three it's still clearer as explicit branches.
./zig-out/bin/hypercode "Create a file at /tmp/hc-test.txt with the content 'hello, world.'"
→ write({"path": "/tmp/hc-test.txt", "content": "hello, world.\n"})
→ read({"path": "/tmp/hc-test.txt"})
I've created the file `/tmp/hc-test.txt` with the content `hello, world.`
(including the newline at the end). The file has been written successfully
and verified.
The model wrote, then re-read to verify — interesting emergent behaviour: when writing has a validation tool (read) available, the agent uses it.
./zig-out/bin/hypercode "Edit /tmp/hc-test.txt and change 'world' to 'hypercode'."
→ read({"path": "/tmp/hc-test.txt"})
→ edit({"new_string": "hello, hypercode.", "old_string": "hello, world.", "path": "/tmp/hc-test.txt"})
→ read({"path": "/tmp/hc-test.txt"})
Done! I've edited `/tmp/hc-test.txt` and changed "world" to "hypercode".
Notice that instead of old_string: "world", the model picked "hello, world." — a longer anchor. Without being told, it understood that uniqueness needed context. That's the pattern that works: the model learns to provide enough precision for the tool to accept.
Clear error:
./zig-out/bin/hypercode "Edit /tmp/hc-test.txt: replace 'goodbye' with 'farewell'."
→ read({"path": "/tmp/hc-test.txt"})
The file doesn't contain the word "goodbye", so there's nothing to replace.
The model re-read, saw the target string was absent, and answered intelligently instead of attempting the edit.
--help reflects the worldOne line per tool:
Tools (always available to the model):
read Read the contents of a UTF-8 text file.
write Create or overwrite a file.
edit Replace one exact occurrence of a string in a file.
f37c8fc docs(main): list write and edit in --help
1653150 feat(tools): edit — unique-anchor in-place file edit
257063d feat(tools): write — create or overwrite a file
0fa7d4d refactor(constants): generalize tool_file_bytes_max
dfd8f12 refactor(tools): embed read parameters from read.schema.json
Five commits, five readable steps. The constant was generalised before the second consumer arrived. That's what you want in a clean git log: every step reads in isolation.
The agent can now touch the filesystem: read, write, edit. With those three primitives, it already knows how to do real work — refactor a module, create a test file, update a README. Not technically impressive, but it's all an IDE-in-your-pocket needs.
In the next post, we add two tools that make Hypercode agentic in the full sense: bash (run a shell command — with a strict timeout) and grep (search the codebase). Once those land, the agent can observe its own work, verify that tests pass, and correct itself.
Stuck, or want to share notes? Join the Discord server.