Most AI agent APIs are turning into constructor soup. Add tools, then memory, then hooks, then approvals, then retries, then handoffs, then model settings, then telemetry, and suddenly your “simple” NewAgent(...) call looks like an archaeological dig through six months of product decisions.
Go solved this problem years ago. The functional options pattern is still one of the cleanest ways to build APIs that start simple, grow safely, and stay readable. After building AI Harness as a reference implementation for Harness as Code and writing about Harness as Code, I’m convinced the same pattern maps incredibly well to AI agent composition.
Not because agents are written in Go.
Because agents have the exact same shape of problem.
Why Go Reached for Functional Options in the First Place
Dave Cheney’s classic post on functional options for friendly APIs is still the best starting point. His argument was simple: constructor signatures get brittle fast when you keep adding optional behavior. Teams usually bounce through the same bad progression:
- start with a nice small constructor
- add more positional arguments
- give up and introduce a config struct
- end up passing zero values or
niljust to say “use the default”
That works until it doesn’t.
Cheney called out the exact problems: poor discoverability, awkward defaults, nil or empty config values that exist only to satisfy the compiler, and APIs that become harder to evolve over time. His alternative was elegant: keep the default path tiny, and expose behavior through With* functions that mutate configuration in a controlled way.
That pattern didn’t stay theoretical. You can see the same shape across mature Go libraries:
- gRPC exposes a whole surface of
DialOptionvalues such asWithSharedWriteBuffer,WithAuthority, and interceptor-related options. -
go-containerregistryexposesOptionfunctions likeWithContext,WithPlatform,WithJobs, andWithRetryBackoff, including validation when an option is invalid. - Cheney’s own follow-up on refactoring his profiling package shows why the pattern is powerful: defaults got simpler, invalid combinations got easier to reason about, and the public API stopped growing every time a new capability appeared.
That is the real win.
Functional options are not a cute Go idiom. They’re a growth strategy for APIs.
AI Agents Have the Same API Growth Problem

The inevitable progression every agent SDK follows — from clean constructor to chaos. Functional options provide the exit ramp.
Look at what modern agent systems need to package.
According to OpenAI’s agent definitions guide, an agent can include a model, instructions, tools, handoffs, guardrails, approvals, structured output, and MCP-backed capabilities. OpenAI’s docs on orchestration and handoffs and guardrails and human review make the point even more clearly: the surface area of a real agent grows fast.
Anthropic has been saying something similar from the runtime side. In Building Effective Agents, their team argues that the most successful systems use simple, composable patterns instead of unnecessary framework complexity. In Effective harnesses for long-running agents, they describe the harness as the layer that helps agents keep making progress across many context windows.
That is exactly where functional options shine.
If your agent constructor looks like this, you already lost:
agent := NewAgent(
model,
instructions,
tools,
hooks,
memory,
approvals,
maxTurns,
retryPolicy,
telemetry,
handoffs,
)
Nobody remembers parameter seven. Nobody knows which ones are truly optional. And the next feature request guarantees the signature gets worse.
The functional-options version is much closer to how agent systems actually evolve:
type AgentOption func(*Agent)
func WithTool(t Tool) AgentOption {
return func(a *Agent) {
a.tools = append(a.tools, t)
}
}
func WithHook(h Hook) AgentOption {
return func(a *Agent) {
a.hooks = append(a.hooks, h)
}
}
func WithMemoryStore(m MemoryStore) AgentOption {
return func(a *Agent) {
a.memory = m
}
}
func WithMaxTurns(n int) AgentOption {
return func(a *Agent) {
a.maxTurns = n
}
}
func WithApprovalPolicy(p ApprovalPolicy) AgentOption {
return func(a *Agent) {
a.approvals = p
}
}
func NewAgent(model Model, instructions string, opts ...AgentOption) *Agent {
a := &Agent{
model: model,
instructions: instructions,
maxTurns: 12,
}
for _, opt := range opts {
opt(a)
}
return a
}
Now the default path is obvious, and the advanced path reads like a sentence:
agent := NewAgent(
claudeSonnet,
researcherPrompt,
WithTool(webSearch),
WithTool(readFile),
WithHook(preToolBudgetGuard),
WithMemoryStore(sqliteMemory),
WithApprovalPolicy(humanReviewOnShell),
WithMaxTurns(20),
)
That is better API design, but more importantly, it is better architecture communication.
The Best Use of Options in Agents: Composition, Not Just Configuration

Each option represents a small architectural move — orthogonal concerns composing independently around a stable core.
This is the part I think most teams miss.
Functional options are often explained as a cleaner way to set fields. That’s true, but it undersells the pattern. For agent systems, the bigger payoff is that options become a composition language for behavior.
Each option can add or change a capability surface:
- tools
- middleware
- pre/post tool hooks
- approval gates
- memory backends
- model routing rules
- telemetry sinks
- retry policies
- handoffs to specialist agents
- context filters
In other words, options stop being “parameters” and become small architectural moves.
That maps cleanly to how I think about context engineering and harness design. You do not want one god constructor that knows every future behavior up front. You want a tiny core plus explicit composition points.
Why This Pattern Fits AI Harness Especially Well
AI Harness already leans into this direction in a very literal way.
Its artifact composer exposes a ComposeWith API backed by functional options in artifact/options.go. The options are not random toggles. They shape how composition behaves:
// Only active artifacts (default)
result, _ := composer.ComposeWith()
// Include inactive artifacts (debugging/observability)
result, _ := composer.ComposeWith(artifact.WithIncludeInactive())
// Filter by type
result, _ := composer.ComposeWith(artifact.WithTypeFilter(artifact.TypePlugin))
// Filter by tag
result, _ := composer.ComposeWith(artifact.WithTagFilter("governance"))
// Dynamic evaluation
result, _ := composer.ComposeWith(artifact.WithEvalFn(myEvalFn))
That is not just a nice API. It reflects the product thesis from the repo itself: keep the core small and make composition explicit.
The important part is what those options mean:
-
WithTypeFilter(...)says which artifact classes should participate -
WithTagFilter(...)says which concerns matter for this composition pass -
WithEvalFn(...)says composition is dynamic and state-aware, not just startup config -
WithIncludeInactive()turns observability into a first-class debugging mode
That is exactly how I expect serious agent infrastructure to evolve.
Not through one bigger Config blob.
Through small, named composition decisions that can be combined on demand.
And once you pair that with the per-turn evaluation model, the pattern gets even more powerful: options can control not just static setup, but how the harness resolves behavior against live session state.
What This Looks Like in Production Agent Systems
Here’s the mental model I recommend.
Use functional options when you need to compose orthogonal behaviors around an agent runtime:
| Concern | Good option shape |
|---|---|
| Tool access |
WithTool(...), WithTools(...)
|
| Safety |
WithGuardrail(...), WithApprovalPolicy(...)
|
| Runtime hooks |
WithPreToolHook(...), WithPostToolHook(...)
|
| Model control |
WithModel(...), WithTemperature(...)
|
| Memory |
WithMemoryStore(...), WithSessionStore(...)
|
| Multi-agent routing |
WithHandoff(...), WithDelegate(...)
|
| Observability |
WithTraceSink(...), WithEventLogger(...)
|
| Failure handling |
WithRetryPolicy(...), WithTimeout(...)
|
That gives you three big advantages.
1. The default agent stays readable
This matters more than people admit. If the simple case is ugly, teams build wrappers immediately, and now you have two APIs to maintain.
2. Advanced behavior stays discoverable
A well-named WithApprovalPolicy(...) is much easier to understand than “argument #8 is optional unless argument #6 is nil.”
3. New capabilities stop breaking existing code
That was the original Go motivation, and it matters even more for agent platforms where the capability surface keeps expanding every quarter.
Where Functional Options Can Go Wrong

Four anti-patterns to watch for — even clean APIs can hide complexity if options aren't designed carefully.
I like this pattern a lot, but it is not magic.
There are a few failure modes worth calling out.
Hidden side effects
If WithMemoryStore(...) quietly enables background persistence, telemetry, and retries, the API stops being honest. Options should be compositional, not surprising.
Order-sensitive behavior
If WithTool(A) followed by WithTool(B) means something different than the reverse order, document it aggressively. Better yet, design around deterministic merge rules.
No validation layer
One reason I like the go-containerregistry version is that its Option type returns an error. That gives the library a clean way to reject invalid combinations such as contradictory auth configuration. Agent systems need the same discipline for incompatible memory backends, mutually exclusive approval modes, or impossible retry settings.
Config blob in disguise
If every option just writes into one giant unstructured struct, you may have improved readability without improving architecture. The best options expose meaningful seams in the system.
That is why I prefer options that represent real agent concerns instead of raw field mutation.
The Bigger Point: This Is a Harness Pattern
I don’t think the functional options pattern is just a nicer constructor trick for AI.
I think it is one of the cleanest ways to express a deeper idea: agent behavior should be composed at the harness layer, not buried in application glue or bloated prompts.
That lines up with everything I’ve been arguing about harness engineering:
- keep the core small
- make behavior explicit
- let governance compose cleanly
- make runtime decisions inspectable
- avoid monolithic prompt/config blobs
Go developers learned this lesson because APIs kept growing. Agent builders are about to learn the same lesson because runtimes keep growing.
The teams that win here won’t be the ones with the fanciest prompt. They’ll be the ones with the cleanest composition model.
The Bottom Line
The functional options pattern gives you a cleaner way to build agent APIs, but that undersells it.
What it really gives you is a discipline for composing agent behavior: tools, hooks, memory, guardrails, routing, and observability as named, reusable moves instead of constructor chaos.
That is why I think this pattern belongs in every serious conversation about agent architecture.
Go figured out how to keep fast-moving APIs friendly. AI agent platforms should steal that idea immediately.
Top comments (0)