Why Go is the perfect language for building agents
The model is the easy part. An agent is a long-lived concurrent server that fans out to unreliable I/O, streams while it works, and must never forget whose data it is touching — which is Go's home turf, not Python's.
By Harrison ItotiaSep 2, 202610 min read
Pick a language for an AI agent and someone will settle the argument in one line: Python has the ecosystem. It does — and for the work most teams are actually doing, that's beside the point. You aren't training anything. The model lives in someone else's datacentre behind an HTTPS endpoint, equally reachable from every language on earth.
What you are actually writing is the thing that calls it: a long-lived server that holds a conversation open for twenty minutes, fans out to half a dozen flaky APIs, streams progress to a browser while it works, and must never once forget whose data it is touching. Judged as a machine-learning problem, Go looks like an odd pick. Judged as what it is — a concurrent, network-bound, identity-carrying service — it stops being odd and starts looking obvious. Five properties made it our default for every agent we ship.
1. An agent turn is a concurrency problem wearing an AI hat
Watch a single turn. The model is asked for a plan. It comes back wanting four tool calls, three of which have nothing to do with each other. Each is a network round trip to something that might take 40 milliseconds or 40 seconds. While that happens the UI wants a live activity strip, the session store wants every event persisted, and the user may close the tab at any moment. That is the whole job, and none of it is machine learning.
In Go the fan-out is the standard library. A buffered channel is the concurrency limiter — model providers rate-limit, so unbounded parallelism just converts one slow call into a wall of 429s. A WaitGroup closes the results channel. The drain loop is single-threaded, which means the callback that persists each result never needs a mutex: results arrive concurrently and land one at a time.
// Fan the independent calls out, bounded. Results drain on one goroutine, so
// the persist callback stays serialised and needs no lock of its own.
sem := make(chan struct{}, maxParallel)
results := make(chan result, len(calls))
var wg sync.WaitGroup
for i, call := range calls {
wg.Add(1)
go func(i int, call ToolCall) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
out, err := call.Run(ctx)
results <- result{idx: i, out: out, err: err}
}(i, call)
}
go func() { wg.Wait(); close(results) }()
var firstErr error
for r := range results {
if r.err != nil {
if firstErr == nil {
firstErr = r.err
}
continue
}
out[r.idx] = r.out
persist(r.idx, r.out) // safe: only one goroutine is ever here
}Twenty-odd lines, no dependencies, and go test -race tells you if you got the sharing wrong before your users do. That shape — bounded fan-out, single-threaded drain — is in every agent we run: parallel tool calls, parallel section authoring in our document engine, parallel retrieval across several indexes. It is the same twenty-odd lines every time.
2. context.Context is the spine: deadline, cancellation and identity in one argument
A common way for an agent to misbehave in production is that it doesn't stop. A user closes the tab; three model calls and a database read carry on, burning tokens for an answer nobody will read. In Go that failure takes deliberate effort, because the cancellation signal isn't a side channel — it is the first parameter of every function that does I/O, all the way down.
The same argument carries deadlines, which matters because model endpoints occasionally accept a request and then simply stop talking. Left alone that hangs until a gateway gives up a minute later. A per-attempt timeout with a retry that still respects the caller's cancellation turns it into a blip:
func generateWithRetry(ctx context.Context, req Request, attempts int) (*Response, error) {
var err error
for attempt := 1; attempt <= attempts; attempt++ {
// Each attempt gets its own deadline; the caller's ctx still governs.
cctx, cancel := context.WithTimeout(ctx, perAttempt)
resp, cerr := models.Generate(cctx, req)
cancel()
if cerr == nil {
return resp, nil
}
err = cerr
alog.Warnf(ctx, "generate attempt %d/%d: %v", attempt, attempts, err)
select {
case <-ctx.Done(): // caller gave up; don't sleep out the backoff
return nil, ctx.Err()
case <-time.After(backoff):
}
}
return nil, err
}And the argument that carries the deadline carries the identity too. The caller's verified token rides in the context's request metadata, so when a tool calls a downstream service it inherits who is asking without a single global variable. That isn't a convenience; it's the mechanism that lets an agent act as the person using it rather than as an all-access service account — the difference we unpack in letting an agent act as you, safely.
3. The type system is the tool contract
Here's a bug that only ever shows up in production. A backend team renames a field. Nobody tells the agent team, whose tool description still promises the old one. Nothing fails to start; the model just gets a slightly wrong answer, forever, in a way no test catches because the test mocked the call.
Go closes that gap in two steps. The first is ordinary: tool arguments and results are structs, and the framework derives the JSON schema the model sees from the struct itself. One definition, so the schema you publish and the type you unmarshal into cannot disagree.
type listOrdersArgs struct {
CustomerID string `json:"customer_id"`
Status string `json:"status,omitempty"`
PageSize int32 `json:"page_size,omitempty"`
}
type listOrdersResult struct {
Orders []orderSummary `json:"orders"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
}
func newListOrdersTool() (tool.Tool, error) {
return functiontool.New(functiontool.Config{
Name: "list_orders",
Description: "List a customer's orders. Use when the user asks what someone bought.",
}, handleListOrders) // func(tool.Context, listOrdersArgs) (listOrdersResult, error)
}The second step removes the class of bug entirely. Our services are defined in protobuf, so a tool can be generated straight from the gRPC method it calls — request type in, response type out, schema derived from the generated Go types:
// UnaryRPC is the shape of any generated gRPC client method.
type UnaryRPC[TReq any, TResp any] func(ctx context.Context, req TReq, opts ...grpc.CallOption) (TResp, error)
// NewUnary turns one into an agent tool. The proto is the schema.
func NewUnary[TReq any, TResp any](cfg functiontool.Config, rpc UnaryRPC[TReq, TResp]) (tool.Tool, error) {
var handler functiontool.Func[TReq, TResp] = func(ctx tool.Context, req TReq) (TResp, error) {
return rpc(ctx, req) // ctx carries the caller's identity downstream
}
return functiontool.New(cfg, handler)
}Now the contract has exactly one definition and it lives in the proto the service is built from. Rename a field and the agent stops compiling — precisely when you want to find out. It's the same argument we make for MCP and A2A: agents get reliable when the boundaries between them are typed.
4. Errors are values, so a broken tool is just another result
An agent spends its life calling things that fail: rate limits, expired credentials, a search index mid-reindex, a user who isn't allowed to see the record they asked about. In an exception-based language the default behaviour of a failing tool is to unwind the stack — which, inside an agent loop, means one bad call throws away a turn that had already done good work.
Go's error-as-value discipline pushes the other way. The failure is a value you decide about, and for a tool the right decision is almost always to hand the model something it can read and route around:
resp, err := clients.Orders.ListOrders(ctx, req)
if err != nil {
// Don't kill the turn. Tell the model what happened, in words it can act on.
if status.Code(err) == codes.PermissionDenied {
return listOrdersResult{Error: "you do not have access to this customer's orders"}, nil
}
alog.Warnf(ctx, "list_orders failed: %v", err) // ids only, never the payload
return listOrdersResult{Error: "orders service unavailable, try again shortly"}, nil
}The model reads "you do not have access", tells the user, and carries on with what it can do. Nothing is swallowed — the operator still gets the log line — but a permission denial on tool three doesn't discard the work of tools one and two. Errors that genuinely are exceptional get wrapped with %w on the way up and mapped to a gRPC status code at the service boundary, so a caller sees PermissionDenied, not a stack trace.
5. One binary, and everything that follows from it
The last argument is the least glamorous and the one operations teams care about most. A Go agent builds to a single static binary. The production image is a slim base plus that one file: no interpreter, no virtualenv, no dependency resolution at container start, and a vulnerability surface small enough that reviewing it is realistic.
FROM golang:1.26 AS builder WORKDIR /app COPY . ./ RUN go build -mod=readonly -o server FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates COPY --from=builder /app/server /app/server CMD ["/app/server"]
That matters more for agents than for a typical CRUD service, because agents are bursty. They sit idle, then somebody arrives and wants an answer now. On a scale-to-zero platform the cold start is part of the user's first impression, and a static binary with a small heap wakes up in a fraction of the time an image with a hundred transitive imports does. Goroutines are cheap enough that one small instance holds a lot of simultaneous sessions — which lands directly on the bill we wrote about in what an AI agent actually costs.
The same binary also serves everything the agent needs to be a citizen of the platform: the gRPC surface, the streaming endpoint, the callback the task queue posts to when long work finishes, the health check. One process, one port, one thing to deploy.
Where we still reach for Python
It would be dishonest to end without this. Python remains the better tool for the work that happens around the agent rather than inside it: evaluation harnesses, notebook-driven prompt iteration, data wrangling, anything touching a real ML library, and new provider features that ship in a Python SDK first. We use it for exactly that — and we keep it off the request path.
Python for the work that happens once. Go for the process that has to be up at three in the morning.
A language argument that only weighs the first half is really a prototyping argument, and prototyping is not what agent projects die of. They die on the crossing to production, for reasons that are almost never the model.
The short version
Agents are marketed as machine-learning products and operated as distributed systems. The hard parts of the ones we run — concurrent I/O, cancellation, streaming, typed contracts between services, an identity that survives four hops, a process that stays up — are the exact problems Go was designed for, and they are the stack under every system we ship, and its boringness is a feature when the model in the middle is already the exciting part. Swap that model out next quarter and none of the above changes, which is the whole point of staying model-agnostic.
Thinking about an agent like this for your team?
Describe the job you want automated and our Automation Architect blueprints it in seconds — or talk it through with the engineers who ship them.