1. me

    I want to port fx to Ruby. I haven't written Ruby in ten years. I'll write the agent loop and the permission engine myself — you take the tools, config, CLI wiring and tests.

  2. claude-opus-5

    Deal. Start with the loop; everything else hangs off it. I'll keep the tool bodies boring so the interesting parts stay yours.

We ported a Zig coding agent to Ruby

My agent and I rebuilt fx in Ruby so I could understand how coding agents work, revisit Ruby after ten years, and find out where a dynamic language actually loses to Zig.

fx-ruby running a session end to end.

Vercel Labs shipped fx, a coding agent written in Zig.

It is a small binary, it is fast, and it feels “closer to a Unix shell than a heavy IDE-in-the-terminal TUI.” I liked it immediately.

And when I really like a piece of software, I usually want to understand how it works.

So my agent and I ported it to Ruby: fx-ruby.

There was another reason too. I had not written Ruby in ten years.

I spent those years mostly in JavaScript and TypeScript. I wanted to know what I had missed and, more importantly, whether I would still enjoy writing Ruby.

A port is a good way to find out because the question of what to build is already settled. You get to focus on how the same ideas should be expressed in a different language.

That is where things got interesting.

Every implementation choice in the Zig version became a question. Is this structure essential to fx, or is it just idiomatic Zig? What is the Ruby equivalent? Should there even be an equivalent?

Ruby is not the language for shipping a 7 MB native binary. Obviously. That was never really the point.

The point was to understand fx by rebuilding it, while using the same program as a lens for comparing two very different languages.

There was one more experiment inside the experiment: I did not write all of it myself.

The agent loop, interrupt repair, streaming accumulator, permission gate, path boundary and command classifier were the parts I wanted to understand deeply, so I wrote those. The agent handled the fifteen tool bodies, config layering, CLI wiring and the 138 tests.

That division of labor became part of the story too.

This is not a 130× productivity win

fx is 693,000 lines of Zig. Our port is 5,300 lines of Ruby.

The 693k includes the WebAssembly build, the N-API bindings, the ACP server, MCP client support, subagents, skills, hooks, a terminal render engine and a large test suite. We ported the core: the agent loop, fifteen tools, the permission engine, the command classifier, session storage, layered config, the gateway client and the CLI.

We can’t compute the fair comparison. What we can say is that the parts we ported came out 5 to 10 times shorter in Ruby.

What a coding agent actually is

Building one taught me what a coding agent is. Using one never did.

It’s a while loop around an HTTP request, wrapped in a permission check, writing to a log. Everything else in this section falls out of that loop.

The loop is twenty lines

This is the real code from our port, with the bookkeeping removed:

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"]

The model does the thinking. The code around it is plumbing. There’s still plenty in that plumbing to get wrong.

Six pieces you can’t skip

Six pieces have to exist or the loop doesn’t work. They’re also the six I wouldn’t delegate, because I can’t review a permission engine I didn’t build.

  1. 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. We ported it verbatim. The prompt carries more of the product than the code does. It’s where “don’t overwrite the user’s dirty worktree” lives.

    The runtime context is rebuilt on every request instead of once per session, so it stays current as the agent changes things. Right call. It cost us a lot of performance, and we’ll come back to it.

  2. Tool schemas. JSON Schema descriptions of everything the model may ask for. The descriptions are 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.

    The second half matters more. A tool description is where you correct the model’s bad habits, and telling it what to avoid does most of that.

  3. The loop. Twenty lines. Already covered.

  4. The permission gate. The only thing between a language model and your filesystem. It runs when a tool executes, not when tools are advertised. The model always sees all fifteen. The gate decides what runs.

  5. 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.

  6. The session log. Append-only JSONL. Every user message, assistant message, and tool result, in order, as it happens.

Here’s one tool call from request to result. Count the checks before anything happens, and note that every rejection goes back to the model as text:

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"]

Appending matters. After a crash or a Ctrl-C, everything up to that point is on disk and readable, and resuming replays a file instead of trusting a snapshot that may never have been written. Our loader skips a torn last line from an interrupted write rather than failing the resume. You need the session back precisely when the process died mid-write.

Every tool call needs a result

The user hits Ctrl-C while three tools are running. You stop cleanly. The session can never be resumed. Every later request returns 400. The transcript on disk still reads fine. The conversation is garbage.

The API has a rule: every assistant message containing tool calls must be followed by exactly one tool-result message per call, matched by ID. Break it and the API rejects your entire conversation, not just the last message, and it keeps rejecting it forever.

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 has to repair the history, not just stop:

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 adds one saying "interrupted by the user before this tool ran". That happens to be true, and useful to the model when the session resumes.

You only find this by building one. No tutorial mentions it. Twenty lines of dull cleanup decide whether Ctrl-C is safe.

Tool calls arrive in pieces

Streaming text is simple. Chunks arrive, you print them. Tool calls are harder.

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 can’t act on a partial call. So an accumulator holds the fragments by index, joins them, and emits a call only once the stream says the turn is finished. That’s all it does: keep half-formed calls out of the agent loop.

Two calls in one turn is normal, and they have to stay separate and in order. Get it wrong and you call a tool with another tool’s arguments, which is a miserable bug to track down.

Errors are data, not exceptions

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.

A crash ends the turn. An error message keeps the conversation going. The harness turns the first into the second every time, including for bugs in the harness itself. That’s why the dispatcher catches StandardError and reports it as a tool result.

Everything must be bounded

The context window is limited, and tool output is what fills 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.]

The sentinel tells the model the result was cut and how to get the rest. Without it the model reads 400 lines, assumes it has the whole file, and reasons from a fragment.

Same 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 and states how many bytes it dropped. Never truncate silently.

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. In auto, routine development commands run without interrupting you and everything else prompts. All the design is in where that line falls.

fx draws it with an allow-list, in a module called command_effect.zig. Deny-lists don’t work on shell commands, because you’ll never list every way to spell rm. The classifier recognizes a small set of known-reversible commands and rejects the rest:

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 are all rejected, because the classifier can’t be sure what they do. A gap in an allow-list costs you a permission prompt. A gap in a deny-list costs you your files. Steal that trade even if you never 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"]

Look at the top of that chart. An explicit deny rule is checked before yolo mode, so a user’s “never” survives every convenience mode. The rest of the order is cheapest check first.

The workspace boundary stacks on top. Reaching outside the workspace needs approval even to read, because reading a file is how you would steal an SSH key.

What’s hard and what isn’t

Easier than they look: the agent loop, streaming text, tool dispatch, talking to the model at all.

Harder than they look:

  • Path resolution. ../, ~, symlinks, a path that doesn’t exist yet, a workspace that’s itself a symlink. Get it wrong and your permission rules do nothing. Ours canonicalizes through realpath and 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.

  • Ctrl-C mid-turn, discussed above. Silent, permanent, and you won’t find it in testing.

  • Bounding output. The caps are easy. Making truncation clear to the model is not.

  • Deciding what’s safe. That’s a judgment call, not a coding problem.

The code is easy. The decisions inside it are what you’re shipping.

What porting Zig to Ruby taught us

What Zig makes you write out

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 ours:

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. It answers questions Ruby never asks. Who owns this string? When is it freed? What happens to the allocation if the next line fails? errdefer is a great idea: defer that runs only on errors, meaning “if I bail out from here, undo this,” and it sits right where the resource is acquired.

Ruby’s answer to all of it is that the GC handles it. Freeing, until it isn’t.

Look at the dupe calls. Every error string is copied onto the caller’s allocator, because Zig has no string that just exists. In Ruby we wrote the message and moved on. We wrote about 40% as much code, and all of the difference was memory handling.

Where Ruby actually wins

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 earns its keep there.

In Ruby we 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.

In TypeScript this ends up as decorators or a config object passed to a factory. Using the class body itself as the declaration still feels slightly illegal to me.

This is what Ruby is for. A small vocabulary that makes the fifteenth tool as cheap to write as the second.

Then we benchmarked it

Where Ruby loses: startup

A bare Ruby interpreter takes 50.9 ms to start on my machine.

fx’s entire help command runs in 3.5 ms.

We can’t beat that. ruby -e "" is fourteen times slower than fx doing real work, so our code has nothing to do with it.

Zig gives you one 11 MB binary that starts instantly and depends on nothing. For a CLI you run a hundred times a day, that’s most of why it feels good. Ruby gives you an interpreter and charges you for it on every run.

On startup this isn’t close, and it never will be.

Where the gap actually is

Here’s our 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×.

status and doctor spend their time on the filesystem, on git, and on spawning processes. Both are waiting on the same operating system, and for I/O the language mostly stops mattering.

The 35× rows are startup cost. The 1.66× row is what happens once the program does something. Then we went looking for waste in our own code.

Four bugs we found by profiling

1. Three subprocesses per model request

We ported the rebuild-on-every-request decision faithfully. We also ported it stupidly. Our 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 spawn costs about 25 ms. Three of them, every request. Building one system message took 24 milliseconds, so in a real repo a turn spent most of its local time waiting for git to boot.

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.

That’s not a Ruby optimization. We wrote a bad program and then fixed it. Zig would have been just as slow spawning three processes.

2. Loading a web stack to print a help message

require "fx" cost 87 ms.

net/http and uri were about 45 ms of that, loaded eagerly by commands that never open a socket. tempfile cost another 10 ms and was unused: we required it, wrote the atomic write by hand with File.rename, and never took the require back out.

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.

The interpreter takes 50.9 ms of that, so fx-ruby’s own work in help went from 74 ms to about 7 ms. We improved the part we control by 10×. The part we don’t control still dominates.

Ruby’s require is a runtime cost, and you choose when to pay it. That helped here. Zig links everything at build time: better for startup, worse for this kind of 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

A concatenation and a sort, run for every character of every command classified, which 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.

In Ruby it’s easy to write an expression that reads like a declaration and runs like a loop body. sort_by inside a per-character function looks fine on the page. Zig would have made us build the table by hand, so we wouldn’t have made this mistake there.

4. We guessed wrong about grep

Our grep_files was slow and we were sure we knew why. It opened each file twice, once to sniff for binary content and once to scan it.

We combined the sniff and the scan into one file handle and ran the benchmark.

1.2× faster. Nothing.

So we stopped guessing and instrumented it:

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. Each scan mode now keeps only what it will report, and still counts every match so the pagination totals stay correct. A fast wrong number is worse than a slow right one.

62.2 ms → 33.0 ms.

I’m including the wrong guess on purpose. The double open was the obvious culprit and fixing it bought nothing. Our intuitions about performance were bad.

One optimization that made things worse

We tried scoring semantic_search line by line instead of lowercasing whole files, to avoid allocating a lowercase copy of each file.

39% slower. We’d traded 300 large allocations for 37,000 small ones.

We reverted it and left a comment saying why, so nobody tries it again, us included. A comment explaining why the obvious thing is wrong beats one describing 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, where we could actually make a difference:

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 filesystem, git and subprocess work, Ruby lands close to a native binary, because neither language is the bottleneck.

The gap on help is still 15× and always will be. That’s the interpreter, not the program.

Coming back to Ruby after ten years

What Ruby 4 added

We started on macOS system Ruby, which is 2.6, then moved the whole thing to 4.0.6.

The ten-year gap helped here. I met a decade of additions at once instead of one release at a time. Data.define, pattern matching and endless method definitions all arrived while I was gone. The last of those is why the three-line git snippet above looks the way it does.

The suite passed unmodified: 138 tests, no warnings under -w, no deprecations. Nothing needed rescuing. I just had features I hadn’t had before.

The best of it was Data.define. Twelve of our 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 the values that flow through the agent loop, and they’re now read-only. An observer printing progress to the terminal can’t reach back and mutate the completion it was handed.

We didn’t expect Data to require every member at construction. It forced us to write the optional ones out as explicit defaults. Struct’s implicit nils had been hiding which fields a :text stream event carries.

Two types stayed as Struct. The tool context, whose session is attached after construction, and a terminal session, which is a live process handle with IO and a mutex. Neither one is a value.

Zig’s const is checked at compile time, so it’s stronger. But Zig can’t take a struct and give it equality, hashing and keyword construction in one line.

Porting the command classifier

Back to command_effect.zig, the allow-list 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.

Porting that lexer by hand was the slowest thing I did on the whole project. A model would have handed it back in a minute, and I’d have had to take it on trust. I wanted a classifier I could argue with.

It’s my favorite moment in the project. The logic moved cleanly between two languages with nothing in common: manual memory against GC, compile-time against runtime, static against dynamic. None of that mattered. What I ported was a set of decisions.

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. One binary, no runtime, 3.5 ms startup, WASM as a build target, memory you can account for byte by byte. Ruby doesn’t win that comparison. fx made the right call.

Use Ruby if you’re doing what we just did. We 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. We changed the architecture three times because changing it cost nothing.

Zig is fast and Ruby is slow. Everyone knew that. What’s interesting is that the 35× gap and the 1.2× gap are in the same program. Ruby loses on startup and can’t recover. On actual work 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.

Do I still like Ruby after ten years away? Yes, with reservations. The tool DSL and the block-heavy traversal code were a pleasure to write, and I’d forgotten how little sits between an idea and a working version of it. I’d forgotten the 50.9 ms startup too, because a long-running server never makes you look at it. A CLI shows it to you every time you press enter.

The agent was never the hard part. The loop is twenty lines. What took thought was the permission gate, the path boundary, unanswered tool calls after Ctrl-C and making truncation clear, and none of those are language questions. Port an agent to whatever you like and you’ll make the same six decisions. Those decide whether it’s any good.

They’re also the six I’d write myself. A model can write a permission gate, and its version would probably have fewer bugs than mine. But all six are judgment calls, and you can’t review a judgment you never made. Delegate the fifteen tool bodies. Keep the gate.

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. Apache-2.0, same as upstream fx. It exists 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