A small, provider-agnostic agent runtime for Go.
Define a tool as an ordinary Go function. ezai derives its JSON schema from the argument
struct, runs the model loop, dispatches tool calls in parallel, and emits a typed event at every
step so runs can be traced and costed.
go get github.com/0xACE3/ezai
package main
import (
"context"
"fmt"
"log"
"github.com/0xACE3/ezai"
"github.com/0xACE3/ezai/ctxb"
"github.com/0xACE3/ezai/provider"
)
// Tool arguments are a plain struct. The schema sent to the model is derived
// from it: `desc` documents a field, `enum` constrains it, and `omitempty`
// marks it optional.
type WeatherArgs struct {
City string `json:"city" desc:"City to look up"`
Units string `json:"units,omitempty" desc:"Temperature units" enum:"c,f"`
}
func main() {
weather := ezai.NewTool("get_weather", "Current weather for a city",
func(ctx context.Context, in WeatherArgs) (string, error) {
return fmt.Sprintf("%s: 22C, clear", in.City), nil
})
agent, err := ezai.NewAgent(ezai.Agent{
Name: "assistant",
Model: ezai.OpenRouter.ClaudeSonnet4_6,
Provider: provider.NewOpenRouter(""), // falls back to OPENROUTER_API_KEY
Context: ctxb.NewBuilder(ctxb.New("You are a concise assistant.")),
Tools: ezai.Tools(weather),
})
if err != nil {
log.Fatal(err)
}
res, err := agent.Run(context.Background(), ezai.Job{
Input: "What's the weather in Lagos?",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(res.Text)
fmt.Printf("%d tokens, %d tool calls\n", res.Usage.Total, len(res.Calls))
}Agent.Run builds context, then iterates until the model stops asking for tools or MaxLoops
is reached. Each pass:
- Assembles messages through the
ContextBuilderand resolves any lazy content references. - Calls the provider.
- Compacts history if input tokens crossed the configured threshold.
- If the completion was truncated, appends a continuation turn and loops again.
- Otherwise runs every returned tool call in parallel and feeds the results back.
Token usage accumulates across the whole run, not just the final call.
NewTool is generic over the argument type. The JSON schema is generated from the struct by
reflection, and arguments are parsed and validated before your function is called, so the
function body only deals with typed input.
type SearchArgs struct {
Query string `json:"query" desc:"Search query"`
Limit int `json:"limit,omitempty" desc:"Max results"`
}
search := ezai.NewTool("search", "Search the index",
func(ctx context.Context, in SearchArgs) (string, error) {
return index.Query(ctx, in.Query, in.Limit)
})Tools are parallel by default. A tool that fails returns its error to the model as text rather than aborting the run, letting the model recover.
Any agent can be exposed as a tool, which is how multi-agent setups are composed:
researcher, _ := ezai.NewAgent(ezai.Agent{Name: "researcher", /* ... */})
supervisor, _ := ezai.NewAgent(ezai.Agent{
Name: "supervisor",
Tools: ezai.Tools(researcher.AsTool(), search),
// ...
})ctxb.Builder assembles each run's message list in a fixed order: system prompt, skills, user
memory, conversation history, then the current input.
cb := ctxb.NewBuilder(ctxb.Markdown().
Section("role", "You are a research assistant.").
Section("rules", "Cite every claim.")).
WithMemory(store).
WithSkills("skills/research.md").
WithCompaction(120_000, 0.8, 200_000)WithCompaction sets the token ceiling at which history is summarized mid-run. On(trigger, skips...) selectively omits memory, skills or user memory for a given trigger.
A Hook receives a typed Event for every stage. run_done is emitted from a deferred call,
so it fires even on error, and hooks are recovered from — a panicking hook cannot take down a
run.
| Kind | Emitted when | Carries |
|---|---|---|
run_start |
run begins | input |
llm_start |
before each provider call | outbound messages |
llm_done |
after each provider call | usage, content |
tool_start |
before a tool executes | tool name, call ID, args |
tool_done |
after a tool executes | tool name, call ID, output |
compact |
history summarized | token count and limit |
run_done |
run ends, success or failure | result, error |
Every event carries the run, session and user IDs, the elapsed time, and full model metadata including per-million-token input, output and cache costs, so spend can be attributed per run:
Hook: ezai.Hooks(
func(ctx context.Context, e ezai.Event) {
if e.Kind == ezai.KindLLMDone {
cost := float64(e.Usage.Input)/1e6*e.ModelInfo.InputCost +
float64(e.Usage.Output)/1e6*e.ModelInfo.OutputCost
metrics.Record(e.Agent, cost, e.Took)
}
},
tracer.Hook,
),OpenAI, OpenRouter and xAI ship in provider. Each supports Complete, streaming Stream over
SSE, and Embed. Malformed-JSON responses are retried once before failing, and errors include a
truncated body prefix for debugging.
provider.NewOpenAI("") // OPENAI_API_KEY
provider.NewOpenRouter("").WithApp("myapp") // OPENROUTER_API_KEY
provider.NewXAI("") // XAI_API_KEYImplementing schema.Provider adds a new backend without touching the loop.
ezai.OpenRouter and ezai.XAI are typed registries carrying context windows, output limits,
capability flags and pricing, so cost and context budgeting come from the model value itself:
m := ezai.OpenRouter.ClaudeSonnet4_6
m.Context // 1_000_000
m.InputCost // 3.0 per 1M tokens
m.HasCap("tools")Early and under active development. The API may change.
MIT — see LICENSE.