The moment that decides what kind of library you’re writing is the one where a human has to approve a tool call.
Everything is lovely right up until then. The model wants to run delete_branch, or move money, or send the email — and something has to stop, surface that intent to a person, and wait. Not “log it.” Not “emit a hook you can subscribe to.” Stop, hand the decision over, and do nothing until it comes back.
Every “just describe your goal, bro” abstraction falls apart at exactly that line, because the loop belongs to the framework and you’re on the outside of it holding a callback. So you end up fighting the thing that was supposed to help you, reverse-engineering its state machine to inject one pause it never planned for.
elelem — LLM, spelled out loud, say it fast and you get it — starts from the other end. Run hands you the tool calls and stops. That’s the default. The engine only drives the loop if you explicitly ask it to:
WithAutoToolCalls() // without this, YOU drive the loopOne line. Drop it and the tool calls come back to you, to approve or tell to fuck off.
Most of the work was deciding what it wouldn’t do
The README’s list of things this package does not contain is longer than most packages’ feature lists, and that’s the actual design document:
- No planner. No memory store. No chain-of-anything.
- No swarm, no crew, no graph of nodes that’s secretly a
forloop with extra steps. - Stores nothing.
- Picks no driver, resolves no credentials.
- Discovers no external tools.
- Decides who may call which tool exactly fucking never.
- Renders nothing to a user.
- No config loader. No
init()quietly rummaging through your environment.
You wire it, it runs requests. Agent frameworks are one go get and several regrets away, and this is the layer they’d be sitting on.
The distinction it’s drawing — library versus framework — usually gets argued about in terms of size or opinionatedness, which is nonsense. The real test is one question: who owns the for loop? A framework owns it and calls you. A library gets called. Once you frame it that way, most of the “lightweight, unopinionated” agent packages turn out to be frameworks that haven’t admitted it yet.
Everything is one chain, and it goes where you point it
Swapping providers is a constructor:
driver := openai.NewDriver(openai.WithAPIKey(apiKey))
// or
driver := anthropic.NewDriver(anthropic.WithAPIKey(apiKey))
client := elelem.New(driver)The rest of your code never finds out it happened. Wrap the driver and you get retries with backoff, still one line:
client := elelem.New(elelem.WithRetry(driver, elelem.RetryConfig{MaxAttempts: 3}))Streaming, a tool loop and a token budget, all on the same builder with no ceremony:
response, err := elelem.NewRequest(client).
WithModel(model).
WithPrompt(elelem.NewPrompt().UserText(question)).
WithTools(tools).
WithAutoToolCalls().
WithMaxRounds(8).
WithMaxContextTokens(100_000).
OnText(func(_ context.Context, delta elelem.TextDelta) error {
fmt.Print(delta.Text)
return nil
}).
Run(ctx)There are 35 of those With* methods on a request. Model and prompt, tools and tool choice, temperature and top-p and seed and stop sequences, reasoning effort, JSON mode and JSON schema, round caps, tool concurrency caps, per-tool timeouts, context and output-reserve budgets, transcript repair, response repair. Want a typed answer instead of prose? RunInto(ctx, &dst) — same builder, and it validates before it assigns, so a half-decoded struct never lands in your variable.
The parts nobody demos
Anyone can show you a streaming token counter. The stuff that actually decides whether a library survives contact with production is duller than that:
History that fits. It counts the transcript and drops whole units oldest-first, and it will not orphan a tool result — because a tool result whose call got evicted is a message referencing a function invocation that, as far as the provider can tell, never happened. That’s a 400 from the API and a confused afternoon for you. Don’t like the sliding window? Replace it with your own compaction in one call.
Retries that don’t repeat themselves. The decorator classifies failures, honors Retry-After, and stops the instant output starts streaming — because retrying a half-delivered answer hands your user the same paragraph twice. It also keeps a ledger of what the failed attempts cost you, which is the number you actually want when the bill arrives.
Tool calls that can’t take the process down. Bounded concurrency, per-tool timeouts, a PreRun → Handler → OnSuccess|OnError → PostRun lifecycle, per-call denial, and panic recovery that turns a panicking tool into a tool error rather than a dead process. A model-triggered nil deref shouldn’t be a production incident.
Fourteen On* hooks. Run and round lifecycle (OnStart, OnRoundStart, OnRoundEnd, OnFinish), text and reasoning deltas, tool-call start and fragment and result, retries, message injection, errors. Delivery stays ordered even when tools run concurrently — which is the hard part, because concurrent tools finishing out of order is the default and useless for anything rendering a transcript. Getting those deltas out to whoever is waiting — a browser, a NATS subscriber, a WebSocket — is a separate problem, and essessey is the piece that does it; its elelemstream subpackage translates these callbacks straight into a block protocol.
Content it refuses locally. Images, audio and documents are content parts on a user message, and content the model can’t read gets rejected on your machine instead of by the provider a round trip and a bill later.
The tokenizer is embedded, and that’s a stance
There’s an o200k_base tokenizer compiled in, so budgeting doesn’t need the network. Sounds like a footnote. It isn’t: the alternative is a package that phones somewhere to answer “will this fit,” which means your context-budget math has a latency cost, a failure mode, and an outage you don’t control. Counting tokens is arithmetic. Arithmetic should not require a DNS lookup.
Same energy in the driver contract. Both shipped drivers run the same conformance suite that a third-party driver would — so the Driver interface is executable, not aspirational bullshit in a markdown file. And unknown model IDs stay usable rather than getting rejected against a hardcoded allowlist, which means this morning’s model release works this morning, not after somebody merges a PR adding a string constant.
v0.4.0 deleted two thirds of the launcher
Worth flagging if you grabbed it early, because it’s a breaking change and the fix is mechanical: Request.Complete, Request.Stream and Request.CompleteInto are gone. Run(ctx) and RunInto(ctx, &dst) are the entire launcher surface now, and streaming became a choice you make on the driver with WithStreaming rather than a different method you call.
Three ways to start a request was two ways too many. The shape of the call shouldn’t encode a transport decision.
It’s Go, it’s on GitHub, and there’s a documentation file per subsystem because cramming requests, prompts, tools, callbacks, history, retries, structured output, drivers and test doubles into one README produces something nobody finishes reading.
And when you eventually do want the engine to drive the loop itself, you add one line and it does. That’s the whole relationship: the
for loop is yours by default, and lending it out is an explicit sentence you write, not a default you inherit.