Back to blog
Engineering

Why We Built Our Agent Harness in Go

Every agent framework is Python. We think that's a category error. The honest engineering case for a Go agent runtime, with the real code and the tradeoffs we won't pretend away.

Python won the agent framework. The runtime belongs to Go.

Almost every agent framework you have heard of is written in Python. LangChain, LangGraph, CrewAI, AutoGen: all Python. The Vercel AI SDK is TypeScript. The Claude and OpenAI SDKs lead with Python and TypeScript.

We wrote our harness in Go.

This was not a contrarian bet or a language crush. When you actually spend time inside the AI ecosystem, Go is the obvious choice, and it is a puzzle why the rest of the field defaulted the other way. A harness is not a notebook and it is not a web app. It is a runtime that gets spawned constantly, runs on the user's own machine, drives many concurrent agents, and cannot be allowed to corrupt state or leak a user's data. That is a systems problem, and it is the problem Go was built to solve.

Here is the reasoning, with the actual code.

What a harness even is

Our harness runs the agentic loop: prompt, then LLM, then tool calls, repeat until the task is done or the budget is exhausted. Around that loop sits the unglamorous, load-bearing work: a registry of 138+ tools, three tiers of memory, permission prompts, dollar-and-turn budget tracking, sandboxing, streaming, and multi-agent delegation. It is roughly 120,000 lines of Go across 800+ files.

Crucially, it does not run on a server you control. In our product, a desktop app spawns the harness as a child process every time it runs an agent:

Desktop app Electron spawns CLI one Go binary executes Agent the loop Tools LLMs Memory
Spawned as a local subprocess, over and over: the shape that drives everything below.

That one fact, spawned locally and repeatedly on someone else's laptop, is the load-bearing constraint. It is the same shape the Claude Code and Codex CLIs use, a runtime the host spawns as a subprocess, both of them TypeScript. The pattern is language-agnostic; what differs is how well the spawned runtime fits the job. That is the whole argument.

It ships as one binary

A Go program compiles to a single binary. We build the core with CGO_ENABLED=0, which means no libc linkage and a genuinely portable artifact, and we did the real work to keep it that way. The database layer, the usual reason an agent tool reaches for cgo, has a pure-Go path selected at compile time by build tags:

// memory/agent_sqlite_cgo.go
//go:build cgo      // mattn/go-sqlite3 when cgo is on

// memory/agent_sqlite_nocgo.go
//go:build !cgo     // modernc.org/sqlite, pure Go, no C toolchain

Flip one flag and the same code compiles to a static binary with no C dependency at all. Cross-compile it for macOS, Linux, and Windows from one machine; each artifact just runs. Setup for the user is "download one file." Python can be frozen too, with PyInstaller or uv or Nuitka, but a static, cross-compiled binary is Go's default output, not a packaging project you take on.

The one asterisk, stated plainly: tools that drive a real browser pull in chromedp and playwright-go, and the first browser run downloads a browser driver. So the core runtime is one static binary; the browser tools need Chrome present, like any automation stack. We are not going to pretend that away.

For a tool a non-developer installs on their laptop, "one file, no interpreter" is not a nice-to-have. For everything that is not browser automation, it is simply true, and it is the product.

Startup is in the critical path

Because the app spawns the harness per agent run, cold-start latency is paid on every invocation. It sits between the user pressing enter and anything happening. On a server that boots once and serves for days, a few hundred milliseconds of interpreter warm-up is invisible. On a desktop agent spawned dozens of times an hour, it is the difference between native-feeling and sluggish.

In our own runs the Go binary starts meaningfully faster than a Python interpreter plus imports, at a fraction of the resident memory. We are not going to dress that up as a universal benchmark; a fair Python "hello world" is fast too. The point is structural: Go's runtime is already resident and compiled, so there is no interpreter to warm up on each spawn. For a per-spawn workload, that property compounds.

Concurrency is the whole game

An agent harness is a concurrency problem wearing a trench coat. A single turn can fire several tool calls at once. A parent agent delegates to children that run in parallel. Tokens stream back while tools are still executing.

Go's goroutines make the parallel version the simple version. Here is our real parallel tool-execution path: every tool call in a turn runs on its own goroutine, and we join on all of them.

// agent/execution.go, one goroutine per tool call in the turn
var wg sync.WaitGroup
for i, toolCallMsg := range toolCallMsgs {
    wg.Add(1)
    go func(idx int, msg *types.Message) {
        defer wg.Done()
        defer func() {
            if p := recover(); p != nil {   // see the note, this line matters
                errors[idx] = fmt.Errorf("tool %s panicked: %v", msg.Name, p)
            }
        }()
        results[idx], errors[idx] = agent.executeHookedTool(r.turnCtx, *msg, ...)
    }(i, toolCallMsg)
}
wg.Wait()

Look at the second defer. This is worth being honest about, because Go people will call it out otherwise: goroutines are not crash isolation. An unrecovered panic in any goroutine takes down the whole process. Go is not Erlang. The recover() is doing the work, and we pay for it with a deliberate one per spawn. So it is not "isolation for free." It is "the language forces you to be explicit, and the explicit version is five honest lines." A single misbehaving tool becomes an error value for that one tool instead of a crash that kills the run.

Delegation is just as direct. A child agent that runs concurrently is go child.Execute(ctx, req). And streaming reads as the state machine it is:

select {
case msg := <-s.messages:
    return msg, nil
case <-s.done:
    return Message{}, io.EOF
}

Where Go pulls decisively ahead is CPU-bound fan-out and true parallelism: several delegated agents actually running on several cores. For the I/O-bound majority of agent work an event loop is fine; that is what it is for, and Node does it well. Node has worker_threads, Python has multiprocessing. The difference is not capability, it is default: in Go, cheap parallelism is the thing you reach for first, not a separate module you opt into.

Cancellation that propagates

When a user hits stop, or a turn blows its deadline, that cancellation has to reach every goroutine, every in-flight provider request, every child agent. Go has one idiom for it, threaded through the whole codebase: context.Context. It is the first argument on the hot path, including the core LLM interface:

// llms/interface.go
type Client interface {
    Respond(ctx context.Context, messages []Message, opts ...Option) (*Response, error)
}

One cancel() unwinds the whole tree. Passing a ctx does not guarantee anyone honors it; you still have to select on ctx.Done(). But because it is in the type signature, you cannot forget to pass it, and every layer that wants to be cancellable already has the handle. Compared with threading an AbortController signal through every async call by hand, the default is just better.

Types and interfaces

Two quieter wins. First, the compiler checks the whole surface of 138 tools: rename a field and every caller fails to build, at build time, not at 2 a.m. in someone's session. We use reflection for the small stuff, the struct and package provenance that feeds our tool catalog and logs, not to conjure schemas; each tool still declares its own Schema(). Python's type-hint introspection is genuinely comparable here, and we will not claim otherwise.

Second, interfaces make providers swappable. OpenAI, OpenRouter, Perplexity, a local model server, our own proxy, and a fake for tests all satisfy that same one-method Client above. Adding a provider means implementing Respond; nothing upstream changes. The test double is just another implementation, so we exercise the full agent loop deterministically with no network and no mocking framework, just a struct that satisfies the interface. Structural typing keeps the seam between our logic and a vendor's wire format clean without a dependency-injection cathedral.

"Go vs Python" is a false binary

The real objection: the AI ecosystem is Python. Embeddings, RAG, local inference, the newest model SDK, it lands in Python first. True, and it is the honest cost of this choice. But the framing is wrong, because the process model Go makes natural is also the escape hatch. The harness runs a non-Go agent as a subprocess and translates its output back into our event stream. LangChain, CrewAI, and the Claude Code CLI are all supported this way:

// agent/external.go
import "os/exec"
// ExternalAgent runs an external process as an agent and translates its
// stdout (JSON lines, astream_events, and so on) into harness events.

So when Python genuinely is the right tool, we do not reimplement it. We shell out to it, sandbox it, budget it, and stream its events through the same UI. And "sandbox it" means real OS-level control: each subprocess goes in its own process group so a runaway is killed cleanly, the whole tree. Go's build tags let us write the Unix and Windows versions as separate files with zero runtime branching:

//go:build !windows        // tools/process_manager_unix.go
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// ...
pgid, _ := syscall.Getpgid(cmd.Process.Pid)
syscall.Kill(-pgid, sig)   // negative pgid = the whole group

A process_manager_windows.go sits beside it and shells out to taskkill; the compiler picks the right one per target. Process groups, signals, per-OS syscalls: Go treats this as first-class. We also speak MCP and A2A, so a Go orchestrator driving a fleet of Python agents is a designed arrangement, not a workaround. Go is the orchestrator. It does not have to be the whole world.

Code for invariants, natural language for strategy

The deepest reason is a principle, and a compiled, statically-typed core is what we lean on to enforce it.

An LLM is wonderful at strategy: which specialist to delegate to, how to decompose a problem, which tool fits. It is unreliable at invariants: under pressure it will skip the budget check or talk itself past a permission boundary, especially the cheap models you run to control cost. So we drew a hard line down the middle of the system.

Concern Owner Why
Budget enforcement Go code (budget.go) An LLM will skip a budget check under pressure. Code never does.
Permission checks Go pipeline (hooks/permission.go) A missed permission check is a security incident, not a quality issue.
Error classification Typed Go codes Raw error strings reaching users is unacceptable. Code guarantees structure.
Delegation, decomposition, tool choice Natural language Exactly the judgment a model is good at. Let it stay flexible.

Natural language handles what benefits from flexibility. Go handles what must never fail. A statically-typed, compiled language is the right substrate for "must never fail": the guarantees are checked before the code runs, not asserted in a prompt and hoped for at runtime.

The honest tradeoff

The cost is real, and we will not pretend otherwise: Go's AI tooling ecosystem is smaller than Python's. Fewer vendor SDKs land in Go first. There is no Go equivalent of the transformers and numpy gravity well. If your agent's core value is heavy in-process ML, training, embeddings at scale, notebook-driven iteration, then Python is still home, and you should use it. Our answer to the gap is the subprocess bridge and the protocols above: we reach Python when we need it rather than rewriting it. And our UI layer is TypeScript. The harness is deliberately a mixed-language stack, each layer in the language that fits it. We did not pick Go to be monolingual. We picked it for the core runtime, where its properties are load-bearing.

The verdict

For a consumer agent runtime, one spawned constantly on a user's own machine, orchestrating concurrent agents, starting instantly, shipping as nearly one file, and never leaking data or blowing a budget, Go is not a compromise. For this job, it is the fit.

  • Static CGO_ENABLED=0 core: one file for everything but browser automation.
  • Resident runtime: no interpreter to warm up on each spawn.
  • Goroutines and channels: parallel tools and true CPU parallelism as the default path.
  • context.Context: a cancel handle you cannot forget to pass.
  • Compile-time types and interfaces: swappable providers, real test doubles, no DI cathedral.
  • Subprocess plus MCP and A2A: Python whenever it is actually the right tool.
  • Compiled invariants: budgets and permissions the model cannot talk its way past.

Python built the agent framework category. We think the runtime category, the thing that ships to users and runs on their machines, belongs to Go. So we spent our time there, on the layer that has to be correct, and let natural language do what it is good at on top.

Read Next