Weft: an attempt to move composition to build time

I tried to replace runtime configuration with compile-time composition, one binary per scenario. The paradigm held and the code ran, but I stopped — not because it was wrong, because it was early.

weft is a Go module I wrote on 14 September 2026 to test one idea about how enterprise agents should be built. What follows is the idea in full, and why I stopped there.

The problem: platforms leave complexity in the runtime

The usual move is to build an agent platform: one general runtime, a visual configuration layer on top, and switches, plugins, and tenant config to bend it toward each scenario. It is comfortable until it scales, and then it hits the same class of problems.

  • The configuration surface grows without bound. A platform's config keys are the union of every feature, and that union only grows. Nobody can say which keys actually take effect in a given scenario.
  • The runtime fills with branches. Every feature has to handle its own "not enabled" case, so the composed behaviour can neither be enumerated nor tested.
  • The platform team becomes the bottleneck. Every scenario queues up behind the platform, and generality keeps adding layers of abstraction.
  • Every scenario pays for someone else's complexity. Bigger images, more dependencies, slower starts, a wider attack surface.

I wanted the root cause stated plainly: when composition happens at runtime, complexity can only be deferred to runtime. If that is true, moving composition to build time should make the complexity disappear at build time instead.

The idea: compose at compile time, fix the scenario at build time

weft goes the other way and splits the work into four steps:

  1. Components. Break the capabilities an agent needs into independent components — model access, tool calling, memory, retrieval, channels, guardrails, audit. They can be written ahead of time, or only when a real scenario asks for them.
  2. A recipe. Write one recipe per scenario, declaring which components it needs and at which versions. Components it does not need never appear in the recipe.
  3. A build. weft weaves the recipe's components into a single binary. Code nobody asked for is not in the binary.
  4. A run. That binary, plus the configuration it needs, runs in a container.

So a traditional platform is one binary and N configurations; weft is N binaries and one configuration each.

weft means the crosswise thread. The warp is strung in advance, the weft follows the pattern, and cloth comes out. Components are threads, a recipe is a pattern, a binary is cloth. The name says what I wanted: cloth is woven, not cut.

A component tells the loom four things

The whole paradigm rests on the component contract, and the contract has four items: identity, configuration surface, the services it provides, and the entries it wants running. Take one away and nothing weaves; add one and every component carries weight that is not its own. A component is a Go package exporting one value that satisfies it:

var Component = &component{}

func (c *component) Name() string { return "llm.dummy" }   // identity, and the config namespace
func (c *component) Config() any  { return &c.cfg }        // configuration surface
func (c *component) Package() func(do.Injector) { /* the services it provides */ }
func (c *component) Entries() []weft.Entry { return []weft.Entry{weft.Use[*Client]()} }

Configuration is declared in place, as fields on a struct, and the field name derives the key. Identity plus key derives the environment variable, so the identity llm.dummy and the key api_key give WEFT_LLM_DUMMY_API_KEY. Nobody hands out prefixes and nothing collides.

type Config struct {
    APIKey  string        `weft:"api_key" required:"true" secret:"true"`
    BaseURL string        `weft:"base_url" default:"https://example.invalid/v1"`
    Timeout time.Duration `weft:"timeout" default:"5s"`
}

A recipe is the shortest file in the repository:

func main() {
    boot.Main(dummyllm.Component)
}

A recipe is Go code rather than a config file, so referring to a component that does not exist does not compile. The compiler catches the composition, not a deployment.

What I expected to get

  • A closed configuration surface. Which keys a binary needs is decided entirely by the components linked into it. The binary can state exactly what it wants, a deployer can check that before rollout, and no one has to wonder whether a switch took effect.
  • A refusal to start when configuration is missing. The startup self-check lists every missing or malformed key at once and then exits. Operations gets a complete checklist instead of a trial-and-error loop.
  • The binary as a contract. Immutable, reproducible, auditable. One recipe plus one configuration behaves the same every time; changing behaviour means producing a new binary.
  • No runtime plugin mechanism. Plugin sandboxes, dynamic loading, ABI compatibility shims, and the whole class of failures and attack surface they bring simply do not exist in this paradigm.
  • Dependency-free deployment. A static binary plus environment variables. Kubernetes, systemd, and an edge node are all the same target.

One more reason I wrote down then and still hold: agent capabilities are naturally assembled. A support agent and a code-review agent differ mainly in which models, tools, knowledge bases, and channels they use. Covering that range with a platform forces the platform into a configure-anything monster; covering it with recipes lets each agent carry only the threads it actually uses.

What actually got built

Three things landed: the component contract, the composition root boot, and llm, the single account of how a model gets called.

boot.Main is the only call a recipe makes, and its order is fixed: check the configuration surface, register services, start entries, wait for a signal, shut down gracefully. Any failing step exits non-zero so the orchestrator knows whether to restart. When the self-check fails, the process prints the binary's complete configuration checklist rather than the first error it found.

llm is not a component; it is a set of types and one method, the single account of how messages and tools reach a model and how the response stream comes back:

type Provider interface {
    Do(ctx context.Context, req llm.Request) iter.Seq2[llm.Event, error]
}

One call produces one stream of events: text deltas, tool calls, done. The parts of a message are peers — text, tool call, tool result, image. An image carries its own media type and its source, either bytes or a URL, so adding audio or video later does not touch the interface.

The one fake provider is llm/dummy: its configuration is a single prefix, and whatever you say, it takes the last user message, prepends the prefix, and streams it back one rune at a time. It cannot read media in a message, but it says how many parts it received instead of pretending not to see them. Its reason to exist is that the whole pipeline runs end to end with no model and no API key.

That was the decision I was happiest with: the abstraction package depends on no vendor, and a real provider only translates its own protocol into these messages and events. Only TopP and MaxOutputTokens genuinely hold across vendors, so only those two are parameters; everything vendor-specific stays in the vendor's provider.

Why I gave it up

I had listed the costs in the README: more builds, a rebuild for every change, a contract that must stay small and stable, and version governance. I wrote them down as costs I could live with. What actually stopped me is that the list was missing a line, and it was the one that mattered.

The line it was missing: this paradigm never costed exploration. It optimizes the operational cost of a steady state, while the bottleneck during exploration is how long it takes to see the result of a change. Swapping a model, adding a tool, rearranging how a prompt is assembled — in weft each one goes through the recipe or a component, a compile, an image build, a redeploy, and then a look. No step is expensive. Together they are enough to make "let me just try it" not worth doing.

What held me back more was the loom never landing. Weaving the same binary from the same scenario, reproducibly, is the paradigm's central benefit, and the loom is what delivers it. With no loom, a recipe was a hand-written main.go and versions were pinned by go.mod for the module as a whole. The benefit was still on paper. The costs had already happened.

And I had underestimated something else: my read on the scenarios themselves was wrong. Compile-time composition assumes the set of scenarios is relatively stable, and at that point I did not know which scenarios I was building. The requirements were still growing while the recipes had already been frozen. The paradigm's correctness rested on an answer I did not have yet.

Not wrong, just early

I still think the direction is right. It just did not fit where I was standing. It fits a platform team that looks like this:

  • the set of scenarios has settled, at dozens or hundreds of them;
  • each scenario genuinely needs to be reproducible, auditable, and rollback-able on its own;
  • image size, startup time, and attack surface are real constraints rather than bullet points;
  • the build pipeline is fast enough that rebuilding a binary and editing a config feel about the same.

When all four hold, "one binary, N configurations" starts to hurt again and weft starts to pay for itself again. Until then, runtime configurability is the cheaper choice: complexity stays in the runtime, and trying something costs almost nothing.

So I stopped, and weft sits in the repository as an idea kept for later. When the scenarios have settled and multiplied, I will read it again and probably find more to change than I expect. That is a problem for the day I know what cloth I am weaving.