I ported a Zig coding agent to Ruby, and the benchmarks surprised me
An educational exercise. Nobody asked for it, which is roughly the point.
Vercel Labs shipped fx, a coding agent written in Zig. Tiny binary, native speed, “closer to a Unix shell than a heavy IDE-in-the-terminal TUI.” It’s a lovely piece of work.
So I ported it to Ruby. The result is fx-ruby.
Partly because porting something is the only reliable way to actually read it. You can skim a codebase for an afternoon and retain nothing. Port it, and every decision the original author made shows up as a decision you now have to make too.
Mostly, though, because I hadn’t written or read a line of Ruby in ten years. A decade of JavaScript and TypeScript sat in between. I wanted to find out what had happened to the language while I was gone and whether I’d still like it, and a port is a good excuse for exactly that: the design decisions are already made, so you’re not deciding what to build, only how it should look in this language. That second part is the one I wanted to practice.
Not that Ruby is a better choice for shipping a 7 MB native binary. It obviously isn’t.
First, an honest accounting
fx is 693,000 lines of Zig. My port is 5,300 lines of Ruby.
If you’re about to tweet that as a 130× productivity win, please don’t. It isn’t true, and I’d rather say so up front than let a number do dishonest work.
That 693k includes the WebAssembly build, the N-API bindings, the ACP server, MCP client support, subagents, skills, hooks, a terminal render engine, and an enormous test suite. I ported the core: the agent loop, fifteen tools, the permission engine, the command classifier, session storage, layered config, the gateway client, and the CLI.
The fair comparison sits somewhere in the middle and I can’t compute it precisely. What I can say is that the parts I did port came out roughly 5 to 10 times shorter in Ruby, and I’ll show you exactly why below. That number is about expressiveness and I’ll defend it. The 130× is a lie about scope.
The anatomy of a coding agent
Start with what one of these things actually is, because porting it taught me that better than using it ever did. A port forces you to build every part of it yourself.
The short version: a coding agent is a while loop around an HTTP request, wrapped in a permission check, writing to a log.
There isn’t much more to it. Everything else in this section is a consequence of that one loop.
The loop is twenty lines
Here is the actual heart of my port. Not a simplification. This is the real code with the bookkeeping stripped out:
while steps < @settings.max_steps
completion = request(cancel)
@messages << completion.to_message
unless completion.tool_calls?
return Turn.new(text: completion.text, stopped: :complete)
end
completion.tool_calls.each do |call|
result = execute(call)
@messages << tool_message(call, result)
end
end
Send the conversation. Did the model ask for tools? If no, it answered and you’re done. If yes, run them, append what they produced, and send the conversation again. Repeat until it stops asking or you hit a step limit.
flowchart TD
A["User prompt"] --> B["Append to conversation"]
B --> C["POST conversation + tool schemas"]
C --> D{"Did the model<br/>ask for tools?"}
D -->|"No"| E["It answered.<br/>Turn complete."]
D -->|"Yes"| F["Run each tool call"]
F --> G["Append one result per call"]
G --> H{"Step limit<br/>reached?"}
H -->|"No"| C
H -->|"Yes"| I["Stop: max steps"]
People expect something more mystical and there isn’t any. The intelligence lives in the model; the harness is plumbing. What separates a good harness from a bad one is how carefully that plumbing is built, and it turns out there’s a great deal to get wrong.
Six parts, and what each one is for
Building it from scratch, six pieces showed up. Not because I designed them, but because the loop above doesn’t work without them.
-
The system prompt. Static policy about how to behave, plus the project’s own instructions (
AGENTS.md), plus runtime context: cwd, OS, date, git branch, dirty file count. fx’s prompt has six sections covering identity, workspace behavior, source routing, interaction, safety, and verification. I ported it verbatim, because the prompt carries more of the product than the code does. It’s where “don’t overwrite the user’s dirty worktree” actually lives.One detail matters later: the runtime context gets rebuilt on every request rather than once per session, so the facts stay current as the agent changes things. Correct decision. It cost me dearly in performance, and I’ll come back to it.
-
Tool schemas. JSON Schema descriptions of everything the model may ask for. The descriptions aren’t documentation, they’re the interface. fx’s are unusually good, written in a “when to use / when NOT to use” format:
Search text files for a literal substring… When to use: find exact symbols, strings, TODOs, or usage sites. When NOT to use: regex is not supported; avoid unknown-concept exploration, filename lookup, known-path reads, and shell grep.
That second half does more work than the first. A tool description is where you fight the model’s bad habits, and telling it what not to reach for does most of the fighting.
-
The loop. Twenty lines. Already covered.
-
The permission gate. The only thing standing between a language model and your filesystem. It runs at execution time rather than advertisement time: the model always sees all fifteen tools, and the gate decides what actually happens.
-
The workspace boundary. Path resolution. Every filesystem tool resolves its argument through one function that answers two questions. Where does this really point, and did it escape? Escaping always requires approval, even for reads.
-
The session log. Append-only JSONL. Every user message, assistant message, and tool result, in order, as it happens.
Laid out as one tool call’s journey, the pieces are easier to see. Notice how many gates sit between the model’s request and anything actually happening, and that every rejection comes back as text for the model rather than a crash:
flowchart TD
M["Model asks for read_file<br/>path = ../../.ssh/id_rsa"] --> V{"validate<br/>types, required fields"}
V -->|"bad args"| E1["Result: error text"]
V -->|"ok"| P["Resolve path<br/>expand ~, .., symlinks"]
P --> B{"Inside the<br/>workspace?"}
B -->|"No, escaped"| S["Always sensitive"]
B -->|"Yes"| N["This tool's<br/>normal sensitivity"]
S --> GATE{"Permission gate"}
N --> GATE
GATE -->|"denied"| E2["Result: permission denied"]
GATE -->|"granted"| X["Execute"]
X --> R["Bound the output,<br/>add truncation sentinel"]
R --> OUT["Tool result"]
E1 --> OUT
E2 --> OUT
OUT --> C["Conversation"]
OUT --> L["Session log"]
Append-only matters more than it sounds. A crash or a Ctrl-C leaves everything up to that moment on disk and readable. Resuming means replaying a file rather than trusting a snapshot that may never have been written. My loader even skips a torn final line from an interrupted write instead of failing the whole resume, because the one time you need your session back is exactly the time the process died mid-write.
The invariant nobody warns you about
I didn’t know this before I built one, and it’s the sharpest edge in the design.
The conversation you send to the model isn’t a list of messages. It’s a list of messages with a structural constraint: every assistant message containing tool calls must be followed by exactly one tool-result message per call, matched by ID.
Break that and the API rejects your entire conversation, not just the last message, and it keeps rejecting it forever.
So the failure mode goes like this. The user hits Ctrl-C while three tools are running, you stop cleanly, and the session is now permanently unresumable. Every future request 400s. The transcript on disk reads fine while the conversation itself is garbage.
sequenceDiagram
participant U as User
participant FX as fx
participant M as Model
U->>FX: "run the tests"
FX->>M: conversation + tool schemas
M-->>FX: assistant with tool_calls [call_a, call_b]
Note over FX: fx now owes exactly<br/>two tool results
FX->>FX: run call_a, append its result
U--xFX: Ctrl-C
Note over FX: call_b has no result.<br/>Conversation is now permanently invalid.
FX->>FX: synthesize result for call_b:<br/>"interrupted before this tool ran"
Note over FX: Session resumable again
An interrupt handler can’t just stop, then. It has to repair:
rescue Interrupted, Gateway::Interrupted
# An interrupted turn leaves history consistent: any tool call without a
# result would make the next request invalid, so those are filled in.
settle_pending_tool_calls
It walks the history, finds every tool call with no matching result, and synthesizes one saying "interrupted by the user before this tool ran". Which is also, conveniently, true and useful information for the model when the session resumes.
This is the kind of thing you only learn by building the thing. No tutorial mentions it. It’s twenty lines of unglamorous cleanup that decide whether Ctrl-C is safe.
Tool calls arrive in pieces
Streaming looks simple: text chunks arrive, you print them. Tool calls are worse.
A single tool call arrives spread across many chunks, with the name in the first fragment and the JSON arguments dribbling in a few characters at a time, all keyed by an index because several calls might be in flight at once.
{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"read_file"}}]}
{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}
{"tool_calls":[{"index":0,"function":{"arguments":"\"a.rb\"}"}}]}
You cannot act on a partial call, so there’s an accumulator that holds fragments by index, stitches them together, and only emits a whole call once the stream says the turn is finished. Keeping half-formed tool calls away from the agent loop is the accumulator’s entire reason to exist.
Two calls in one turn is normal, and they have to stay separate and ordered. Getting this wrong produces the most confusing bug in the genre: a tool invoked with another tool’s arguments.
Errors are data, not exceptions
This one reframed how I think about the design.
When a tool fails (file missing, bad JSON, ambiguous edit, permission denied) the agent must not raise. It has to hand the model a message describing the failure, as ordinary tool-result content, and keep going.
def invoke(arguments)
validated = validate(args)
decision = authorize(validated)
return Result.failure("permission denied: #{decision.reason}") unless decision.granted?
call(validated)
rescue InvalidArguments => error
Result.failure(error.message)
rescue Errno::ENOENT => error
Result.failure("not found: #{error.message}")
end
Every path returns a Result and nothing escapes. The model reads "edit_file failed: old_string occurs 2 times; include more surrounding context" and fixes its own call on the next step.
This is what makes an agent feel competent rather than brittle. A crash ends the turn, while an error message continues the conversation, and the harness exists to convert the first into the second every single time. That includes bugs in the harness itself, which is why the dispatcher catches StandardError and reports it as a tool result instead of dying.
Everything must be bounded
The context window is a budget, and tools are how you overspend it.
read_file on a 10 MB file would end your turn, so every tool result has caps. fx’s are specific and deliberate: 400 lines by default, 2,000 characters per line, 256 KB per result, 10 MB read from disk at most. Past any of those, the output gets a sentinel:
... [showing 400 of 5000 lines; use start_line/line_count to read more.]
That sentinel is doing real work. It tells the model the result was truncated and how to get the rest. A truncated result without one is a lie: the model reads 400 lines, believes it has seen the file, and reasons confidently from a fragment.
Same principle everywhere. grep paginates and reports exact totals, glob caps at 300 matches and says so, list_files at 500 entries, and the terminal keeps the head and tail of long output while stating how many bytes it dropped in the middle. Never truncate silently. If you cut something, say you cut it.
Drawing the line on what runs without asking
You can write the loop in an afternoon. The permission model is what takes actual thought, because it’s the only thing preventing a plausible-sounding sentence from deleting a directory.
fx has three modes: ask, auto, and yolo. The interesting one is auto, where routine development actions run without interrupting you and anything else asks. The design lives entirely in how you draw that line.
fx draws it with an allow-list, in a module called command_effect.zig. Not a deny-list. Deny-lists on shell commands do not work, because you’ll never enumerate all the ways to spell rm. So the classifier positively recognizes a small set of known-reversible commands and rejects everything else:
git status → run it
npm test → run it
zig build → run it
rm -rf . → ask
git push → ask
npm test | tee log → ask (pipes)
npm run $TASK → ask (expansion)
sh -c 'npm test' → ask (wrapper)
Pipes, redirection, shell expansion, globs, wrappers, background execution, and global installs all get rejected, not because they’re dangerous but because the classifier can’t be sure what they do. A gap in the allow-list costs you an extra permission prompt. A gap in a deny-list costs you your files. That asymmetry is worth stealing whether or not you ever write an agent.
The gate’s evaluation order is fixed:
flowchart TD
A["Tool wants to act"] --> B{"Explicit deny<br/>rule matches?"}
B -->|"Yes"| D1["DENY"]
B -->|"No"| C{"yolo mode?"}
C -->|"Yes"| G1["ALLOW"]
C -->|"No"| D{"Allow rule<br/>matches?"}
D -->|"Yes"| G2["ALLOW"]
D -->|"No"| E{"Approved earlier<br/>this session?"}
E -->|"Yes"| G3["ALLOW"]
E -->|"No"| F{"Sensitive<br/>action?"}
F -->|"No"| G4["ALLOW"]
F -->|"Yes"| H["Ask the human"]
H --> I["once · always · no · never"]
Read the top of that chart carefully: an explicit deny rule is checked before yolo mode. A user’s explicit “never” has to survive every convenience mode above it or it isn’t a real “never”. Everything else is ordered cheapest-first, but that one is ordered on principle.
It composes with the workspace boundary too. Reaching outside the workspace requires approval even to read, because “just read this file” is exactly how you’d exfiltrate an SSH key.
What’s actually hard, and what only looks it
After building the thing, my ranking. Looks hard, is easy: the agent loop, streaming text, tool dispatch, talking to the model at all.
Looks easy, is hard:
- Path resolution.
../,~, symlinks, a path that doesn’t exist yet, a workspace that’s itself a symlink. Get it wrong and your permission rules are decoration. Mine canonicalizes throughrealpathand falls back to canonicalizing the parent for files that don’t exist yet, so a rule written against a real path can’t be dodged with an alias. - The interrupt invariant, discussed above. Silent, permanent, and you won’t find it in testing.
- Bounding output. Easy to add caps. Hard to make truncation legible to the model instead of misleading.
- Deciding what’s safe, which is a taste problem rather than a coding problem.
The plumbing is easy. What’s encoded in the plumbing is the thing you’re actually shipping.
The thing Zig makes you say out loud
On to the languages. Here’s fx decoding the arguments for its read_file tool, real code, trimmed:
pub fn decode(ctx: DispatchContext, args_json: []const u8) DispatchError!DecodeResult {
var parsed = std.json.parseFromSlice(std.json.Value, ctx.allocator, args_json, .{}) catch {
return .{ .failure = try ctx.allocator.dupe(u8, "read_file arguments must be valid JSON") };
};
defer parsed.deinit();
if (parsed.value != .object) {
return .{ .failure = try ctx.allocator.dupe(u8, "read_file arguments must be an object") };
}
const path_value = parsed.value.object.get("path") orelse {
return .{ .failure = try ctx.allocator.dupe(u8, "read_file requires string field \"path\"") };
};
if (path_value != .string) {
return .{ .failure = try ctx.allocator.dupe(u8, "read_file field \"path\" must be a string") };
}
const input = try ctx.allocator.create(Input);
errdefer ctx.allocator.destroy(input);
input.* = .{ .path = try ctx.allocator.dupe(u8, path_value.string) };
errdefer input.deinit(ctx.allocator);
// ... and we haven't gotten to start_line or line_count yet
}
Here’s mine:
def require_string(arguments, key)
value = arguments[key]
raise InvalidArguments, "#{name} requires string field \"#{key}\"" unless value.is_a?(String)
trimmed = value.strip
raise InvalidArguments, "#{name} field \"#{key}\" must not be empty" if trimmed.empty?
trimmed
end
Written once, used by all fifteen tools.
The Zig version isn’t worse, though, and this is the part people skip past on their way to a punchline. It’s answering questions Ruby never makes me answer. Who owns this string? When is it freed? What happens to the allocation if the next line fails? Zig’s errdefer is a genuinely great idea. It’s defer, but only on the error path, meaning “if I bail out from here, undo this,” and it puts the unwind right where the resource is acquired.
Ruby’s answer to all of those questions is that the GC handles it and you should stop asking. Enormously freeing, right up until the moment it isn’t.
The dupe calls are the tell. Every one of those error strings gets copied onto the caller’s allocator, because Zig has no notion of a string that just… exists. In Ruby I wrote the message and moved on with my life. I wrote maybe 40% as much code, and 100% of the difference was memory ceremony.
Where Ruby actually won: the DSL
fx declares its tools as big comptime struct literals: schemas, descriptions, permission targets, and label functions, all wired into a dispatch table. It works, and Zig’s comptime is doing real work there.
In Ruby I got this:
class ReadFile < FileTool
tool_name "read_file"
permission "read"
sensitive false
description "Read one UTF-8 text file with bounded line-numbered output..."
parameters(
"type" => "object",
"properties" => {
"path" => { "type" => "string", "description" => "File to read." },
"start_line" => { "type" => "integer", "minimum" => 1 }
},
"required" => ["path"],
"additionalProperties" => false
)
def call(arguments)
# ...
end
end
Six lines of declaration and a method. No registration, no dispatch table, no schema-generation machinery. The base class reads those declarations and produces the JSON schema the model sees, and invoke wraps every call in validate → authorize → execute.
Coming from TypeScript, where this shape usually lands as decorators or a config object handed to a factory, using the class body itself as the declaration still feels slightly illegal.
This is the thing Ruby is for, and I don’t mean metaprogramming as a party trick. I mean a small vocabulary that makes the fifteenth tool as cheap to write as the second. That’s the difference between a codebase you extend and one you dread.
Where Ruby lost, badly, and permanently
A bare Ruby interpreter takes 50.9 ms to start on my machine.
fx’s entire help command runs in 3.5 ms.
I can optimize my code all day and I will never beat that. Not because my code is bad, but because ruby -e "" is fourteen times slower than fx doing actual work. This is the wall, and no amount of cleverness gets you over it.
Zig hands you a single 11 MB binary that starts instantly and depends on nothing. For a CLI tool you invoke a hundred times a day, that isn’t a nice-to-have, it’s most of why the tool feels good. Ruby hands you an interpreter, and the interpreter charges you on every invocation.
I want to be clear about this, because it’s tempting to write a triumphant post where the dynamic language wins on every axis. It doesn’t. On startup latency this isn’t close, and it’s structural.
But then I profiled, and things got interesting
Here’s my starting position against fx, measured with hyperfine using the same methodology fx uses in its own benchmarks/startup.sh, 50 runs, 5 warmup, both pointed at identical throwaway state:
| Command | fx (Zig) | fx-ruby | Gap |
|---|---|---|---|
help |
3.5 ms | 125.0 ms | 35.9× |
version |
3.8 ms | 124.5 ms | 32.7× |
sessions --json |
3.6 ms | 126.0 ms | 34.9× |
status --json |
91.6 ms | 152.2 ms | 1.66× |
doctor --json |
92.0 ms | 177.3 ms | 1.93× |
Look at that status row. 1.66×, not 35×.
Why? Because status and doctor spend their time on the filesystem, on git, and on spawning processes. Both implementations are waiting on the same operating system, and when the work is I/O the language mostly stops mattering.
The 35× rows are pure startup tax. The 1.66× row is what happens once you actually do something. That’s most of the lesson of this post, but it gets better, because I then went looking for my waste instead of blaming the interpreter.
Four bugs I found by profiling instead of guessing
1. Three subprocesses per model request
Remember the design decision from the anatomy section, where runtime context gets rebuilt on every request so git state stays current? I ported that faithfully.
I also ported it stupidly. My Workspace called git three separate times:
def git_branch = Open3.capture2e("git", "rev-parse", "--abbrev-ref", "HEAD")
def git_repository? = Open3.capture2e("git", "rev-parse", "--git-dir")
def git_dirty_count = Open3.capture2e("git", "status", "--porcelain")
Each subprocess spawn costs about 25 ms. Three of them, every single request.
Building one system message took 24 milliseconds. In a real repo, a turn spent most of its local time waiting for git to boot.
The fix wasn’t Ruby-specific. git status --porcelain --branch returns branch, dirty count, and repository-ness in one call. Add a two-second cache, comfortably inside the window the prompt itself calls “current for the turn”, and:
24,110,446 ns → 35,584 ns. 677× faster.
Which is not a Ruby optimization. It’s me having written a bad program and then fixing it. Zig would have been just as slow spawning three processes, because the subprocess doesn’t care what language called it.
2. Loading a web stack to print a help message
require "fx" cost 87 ms.
net/http and uri accounted for roughly 45 ms of that, loaded eagerly by commands that never open a socket. And tempfile cost another 10 ms while being completely unused. I’d required it, then written the atomic-write path by hand with File.rename, and never removed the require.
Defer HTTP to first use, make the CLI load the agent stack per command instead of at boot, delete the dead require:
fx help: 125 ms → 57.9 ms.
Against a 50.9 ms interpreter floor, that means fx-ruby’s own work in help went from 74 ms to about 7 ms, which is the honest way to read it. I improved the part I control by 10×, and the part I don’t control still dominates.
Ruby’s require being a runtime cost you can choose when to pay is a real advantage here, incidentally. Zig links it all in at build time. Better for startup, worse for this kind of surgical fix.
3. Rebuilding a 19-element sorted array per character
This one’s just embarrassing:
def operator_at(command, index)
(REDIRECTION_OPERATORS + CONTROL_OPERATORS)
.sort_by { |operator| -operator.length }
.find { |operator| command[index, operator.length] == operator }
end
Array concatenation and a sort, called for every character of every command being classified, which per the anatomy section is every shell command the agent wants to run.
Precompute it into a frozen hash indexed by first character:
115,619 ns → 12,889 ns. 9× faster.
The Ruby-specific lesson is that it’s very easy to write an expression that reads like a declaration but executes like a loop body. sort_by inside a per-character function looks fine on the page. Zig would have made me build that table explicitly and I’d never have made this mistake, because no syntax in Zig makes “allocate and sort an array” look cheap. Expressiveness has a real cost here: beautiful code can hide its own bill.
4. I guessed wrong about grep, then measured
My grep_files was slow. I was sure I knew why. It opened each file twice, once to sniff for binary content and once to actually scan it.
I fixed it, combining the sniff and the scan into one file handle, and ran the benchmark.
1.2× faster. Basically nothing.
So I stopped guessing and instrumented it properly:
traverse (each_file) 4.96 ms
read all files 24.93 ms
each_text_line all files 16.34 ms
+ include? per line 20.36 ms
+ Hit alloc per match 49.56 ms ← there it is
Allocating a Hit object per match was about 29 ms of the 64 ms, for objects that count mode never needs and matches mode throws away past the first page.
The fix was algorithmic: make the scan mode-aware so each mode keeps only what it will actually report, while still counting every match so those pagination totals stay honest. (See “never truncate silently,” above. A fast wrong number is worse than a slow right one.)
62.2 ms → 33.0 ms.
I’m including my wrong guess on purpose. The double-open was the obvious culprit and it was worth almost nothing. Profilers exist because our intuitions about performance are bad, and mine certainly were.
And one optimization that made things worse
I tried scoring semantic_search line by line instead of lowercasing whole files, reasoning that I’d avoid allocating a big lowercase copy of every file.
39% slower. I’d traded 300 large allocations for 37,000 small ones.
I reverted it and left a comment in the code saying so, so nobody, including future me, tries it again. A comment explaining why the obvious thing is wrong is worth more than one explaining what the code does.
The final numbers
| Command | fx (Zig) | Ruby before | Ruby after | Gain |
|---|---|---|---|---|
help |
3.8 ms | 125.0 ms | 57.9 ms | 2.16× |
version |
3.5 ms | 124.5 ms | 58.0 ms | 2.15× |
sessions --json |
3.8 ms | 126.0 ms | 68.0 ms | 1.85× |
status --json |
91.5 ms | 152.2 ms | 110.9 ms | 1.37× |
doctor --json |
90.6 ms | 177.3 ms | 112.6 ms | 1.57× |
And the in-process work, which is where I could actually move the needle:
| Operation | Before | After | Gain |
|---|---|---|---|
| build system message | 24,110,446 ns | 35,584 ns | 677× |
| tool schemas | 6,431 ns | 107 ns | 60× |
| tokenize command | 115,619 ns | 12,889 ns | 9.0× |
| classify command | 93,140 ns | 16,638 ns | 5.6× |
| grep (count, 300 files) | 62,248,300 ns | 32,966,766 ns | 1.9× |
| semantic_search | 51,871,600 ns | 34,781,500 ns | 1.5× |
| read_file (400-line window) | 717,290 ns | 516,140 ns | 1.4× |
The gap on status closed from 1.66× to 1.21×. On real work, meaning filesystem and git and subprocesses, Ruby lands within spitting distance of a native binary, because neither of them is the bottleneck.
The gap on help is still 15× and always will be. That’s the interpreter, not the program.
The part where Ruby 4 shows off
I started on macOS system Ruby, which is 2.6, then moved the whole thing to 4.0.6.
This is where the ten-year gap turned into an advantage, because I met a decade of additions all at once instead of absorbing them one release at a time. Data.define didn’t exist when I left. Neither did pattern matching, nor endless method definitions, which is why the three-line git snippet a few sections up looks the way it does.
The suite passed unmodified. 138 tests, zero warnings under -w, zero deprecations. So the migration wasn’t a rescue. It was about spending headroom I’d been denied.
The best of it was Data.define. Twelve of my fourteen Structs became immutable value types: completions, tool calls, stream events, resolved paths, permission decisions, tool results.
Completion = Data.define(:text, :reasoning, :tool_calls, :finish_reason, :usage, :model) do
def initialize(text: "", reasoning: nil, tool_calls: [], finish_reason: nil, usage: nil, model: nil)
super
end
end
These are exactly the values that flow through the agent loop, and they’re now genuinely read-only. An observer rendering progress to the terminal cannot reach back and mutate the completion it was handed.
The bit I didn’t expect: Data requires every member at construction. That forced me to write the optional ones out explicitly as defaults, and Struct’s implicit nils had been quietly hiding which fields a :text stream event actually carries. The constraint made the code more honest.
Two types stayed as Struct deliberately. The tool context, whose session gets attached after construction, and a terminal session, which is a live process handle with IO and a mutex. Those aren’t values. Immutability is a claim about what a thing is, not a style you apply uniformly.
Zig’s equivalent is const, checked at compile time, which is stronger. But Zig has no equivalent of “take this struct and make it a proper value object with equality, hashing, and keyword construction, in one line.”
The one place the two languages met exactly
Back to command_effect.zig, the allow-list from the anatomy section that decides whether a shell command runs without asking.
I ported it line by line, including its lexer. Then I lifted fx’s own test vectors straight out of the Zig source into a Ruby test file:
REVERSIBLE = ["node -v", "git status --short --branch", "npm install 2>&1", "zig build test", ...]
NOT_REVERSIBLE = ["rm -rf .", "git push origin HEAD", "sh -c 'npm install'", "npm install --location global", ...]
All twenty passed on the first run.
That’s my favorite moment in the project, and not because it was hard. It means the logic transplanted cleanly across two languages with nothing in common. Manual memory against GC, compile-time against runtime, static against dynamic: none of it mattered, because the decision was the artifact and the language was only transport. Abstractions that survive a trip like that are the ones worth keeping.
So which should you use?
Wrong question, and I say that as someone with strong opinions about most things.
Use Zig if you’re shipping fx. A single binary, no runtime, 3.5 ms startup, WASM as a build target, memory you can account for byte by byte. There’s no version of this argument where Ruby wins, and fx made the right call by a wide margin.
Use Ruby if you’re doing what I just did. I built a working coding agent, fifteen tools and a permission engine and streaming and sessions and a REPL, in an afternoon’s worth of files, with zero dependencies outside the standard library and no build step. I changed the architecture three times because changing it cost nothing.
The interesting finding isn’t “Zig fast, Ruby slow.” Everyone knew that. The interesting finding is that the 35× gap and the 1.2× gap live in the same program. Startup is where Ruby loses and can’t recover; actual work is where the two converge, because the operating system doesn’t care what language asked it to stat a file.
If you’re writing a CLI that runs and exits a hundred times a day, startup is the workload, and you should write it in something compiled. If you’re writing something that starts once and then works, you’re optimizing the wrong number by picking your language on startup benchmarks.
As for whether I still like Ruby after ten years away: yes, though not uncritically. The tool DSL and the block-heavy traversal code were a genuine pleasure to write, and I’d forgotten how little ceremony sits between an idea and a working version of it. The 50.9 ms floor I had also forgotten about, because a long-running server process never makes you look at it. A CLI puts it in front of you every time you press enter.
And underneath all of it, the agent itself was never the hard part. The loop is twenty lines. What took thought was the permission gate, the path boundary, the interrupt invariant, and making truncation honest, and not one of those is a language question. Port an agent to anything you like and you’ll end up making the same six decisions, which are the same six decisions that determine whether the thing is any good.
The measuring taught me more than the port did. I’d do the port again anyway.
The port lives at github.com/jagenaujagenau/fx-ruby. It’s Apache-2.0, same as upstream fx, and it exists purely so I could understand fx by rebuilding it. If you want a coding agent, use the real one. It’s better, and it starts in 3.5 milliseconds.
What this cost
- claude-opus-5707 turns
- Tokens226,946,223
- Cache hits97.7%
- cache read$110.48
- cache write$52.71
- output$17.92
- input$0.01
- Total$181.12
all work: the port, the migration, benchmarking, optimization, the demo, this post.