I’ve been thinking about something Andrew Kelley said.
He was talking about Go and Zig. And he put it like this.
Go is doing less with less.
Zig is doing more with less.
I love that.
Because it names something I already do every day.
I’ve written about each language on its own, in On Go and On Zig.
Today I want to talk about the two of them together.
Doing Less With Less
Let’s start with Go.
Go asks very little of me.
The garbage collector handles memory. The standard library covers most of what a tool needs. And the code reads the same, no matter who wrote it.
That includes my agents.
So when memory doesn’t matter, I don’t want to think about memory at all.
Go lets me skip it.
That’s doing less with less.
Doing More With Less
Now Zig.
Zig asks more of me. But it gives more back.
Explicit allocators. Bounded buffers. No garbage collection pauses.
I decide where every byte goes.
And when memory does matter, that control is the whole point.
That’s doing more with less.
The Rule I Give Myself and My Agents
So here’s the rule.
Go is the default. Zig has to earn its place.
And memory is the line between them.
I don’t just keep this in my head. I write it down, because my agents need it too.
At first, I wrote it into the AGENTS.md file of each project.
Here’s how it read in a rollback networking library:
- Use Go when memory is not an issue: host side command line tools, process orchestration, multi process test drivers, log capture, and report generation.
- Use Zig when memory is an issue: the library, examples, consumers, game loops, benchmarks, WASM targets, and anything that needs explicit allocators, bounded buffers, or no garbage collection pauses.
In a documentation tool, I went one step further:
- Write each new tool in Go when memory is not a concern.
- Choose Zig only when memory or performance is a concern, and only after Go proves too slow for the job. Measure Go first and record the result in the commit message or the spec.
Measure Go first. I like that one.
And in a private chat server:
- Use Go for CLI orchestration, ordinary tooling, and tests that are not performance or memory sensitive.
- Use Zig only for performance sensitive or memory sensitive server code and tests.
Three projects. Same idea every time.
The Problem With AGENTS.md
But here’s what I kept running into.
Agents love to edit AGENTS.md.
A rule I wrote carefully would get reworded. Or folded into a paragraph. Or quietly dropped during some unrelated change.
And the line between Go and Zig would drift with it.
So I moved my rules out.
Enter GUARDRAILS.md
Now my rules live in their own file. GUARDRAILS.md.
I manage it with a small Go CLI I built for this. Of course it’s in Go.
Every rule is one line. Every rule gets a stable ID.
And there are only three kinds of rules.
ALWAYS means required.
NEVER means prohibited.
SOMETIMES means you have permission, if you want it.
Here’s the part that matters.
Every change to a rule is a proposal. I see the before. I see the after. And it only applies when I approve that exact proposal.
Retired IDs stay retired. So NEVER-38 always means the same rule.
And AGENTS.md? It keeps one job.
It points at the guardrails. Right at the top:
# AGENTS
<!-- read-guardrails begin. DO NOT MODIFY: managed by my guardrails CLI -->
Before starting work, read GUARDRAILS.md if present and follow its applicable project directives in addition to these instructions.
<!-- read-guardrails end -->
Where the Idea Came From
This one came from OpenCode. I use it from time to time.
OpenCode doesn’t load GUARDRAILS.md on its own. It loads AGENTS.md.
But its docs show two ways to pull in rules from another file.
One. List the file in the instructions array of opencode.json. Then OpenCode loads it for you, right after AGENTS.md.
Two. Put a line in AGENTS.md that tells the agent to go read the other file.
My CLI does both now.
The line in AGENTS.md works with any agent.
And when a project has an OpenCode config, the CLI can add GUARDRAILS.md to instructions. So in OpenCode, the rules load without asking the agent to go find them.
What This Is, and What It Isn’t
Let me be honest about what this buys me.
It’s more deterministic. The rules load the same way in every session. And they only change when I say so.
It costs me something, too. Every rule change waits for my review.
And it’s not enforcement.
The agent still has to follow the rules. What it can’t do anymore is quietly rewrite them.
OpenCode actually draws this same line.
It has something called policies. Those are hard allow or deny rules that the tool itself enforces. Like which model providers an agent is allowed to use.
A policy can block an action, full stop.
But a policy can’t say ‘use Go unless memory matters.’
That takes judgment.
So that rule lives in GUARDRAILS.md. Where the agent reads it. And where I control how it changes.
What the Rules Look Like Today
Here’s the Go and Zig split in GUARDRAILS.md for AirSpace, my virtual world:
- ALWAYS-34: Use Go for CLI orchestration, ordinary tooling, server code, and tests that are not performance or memory sensitive.
- NEVER-37: Never use Zig except for performance or memory sensitive server code and tests, or GPU shader sources compiled offline for the planned GPU rendering evaluation.
- NEVER-38: Never join Go and Zig services inside one process; they meet at process boundaries such as loopback HTTP, standard input and output, or the shared SQLite file.
- ALWAYS-44: Prefer a Go or Zig standard library solution over pulling in a package, even a well known one.
Notice what changed.
Zig isn’t an ALWAYS anymore. It’s a NEVER, with a narrow exception.
And NEVER-38 says something my earlier rules never did.
Where the two languages meet.
The Union
So here’s the whole picture.
Go is the default.
Zig is the exception.
Memory is the line.
They run side by side, as separate processes, and they talk across clean boundaries.
Go does less with less, around the edges.
Zig does more with less, at the core.
And the rules that keep it that way? They live in one file that my agents read, and that changes only when I approve it.