A minimal, embeddable Go agent orchestration library. Single binary. Full context control.
You own the []Message — the library provides the loop and the plumbing.
- One OpenAI-compatible provider — talks to OpenAI, Azure OpenAI, Ollama, GitHub Copilot, Gemini, Groq, and OpenRouter through a single implementation
- Unified provider manager — one entry point for login, logout, status, model discovery, and default persistence across every supported provider
- Full context control — the caller owns the message slice and decides what goes in it
- Block-shaped messages — each message holds an ordered slice of typed blocks (text, thinking, tool_use, tool_result, image, redacted_thinking), so interleaved assistant output replays faithfully
- Durable session store — pure-Go SQLite (
modernc.org/sqlite) backing for branching message trees, artifact offload for large tool outputs and images, and FTS5 search across block types; shares the same file with parent-app tables - Built-in auth — static API keys, GitHub device flow for Copilot, "Sign in with ChatGPT" for OpenAI (Codex OAuth — device or browser, no API key, no app registration), plus device/PKCE OAuth against the standard OpenAI API for embedders with their own registered app. File-backed storage with expiry handling.
- Tool system — register Go functions as tools with auto-generated JSON Schema
- ReAct agent loop — streaming
Step/Runwith per-block hooks, plus runtime provider/model swap - Model discovery — list and switch between every chat-capable model on every authenticated provider
- Optional adapters — Bubbletea TUI (with slash commands) and HTTP/SSE server
go get github.com/stack-bound/stackllmThe fastest path uses the profile manager to handle auth and model selection for you. On the first call it walks the user through login; after that, LoadDefault returns a ready-to-use provider.
package main
import (
"context"
"fmt"
"github.com/stack-bound/stackllm/agent"
"github.com/stack-bound/stackllm/conversation"
"github.com/stack-bound/stackllm/profile"
"github.com/stack-bound/stackllm/tools"
)
type GreetArgs struct {
Name string `json:"name" jsonschema:"description=Name to greet,required"`
}
func main() {
ctx := context.Background()
// 1. Load the persisted default provider. Run `go run ./examples/login`
// once beforehand to pick a provider and model interactively.
mgr := profile.New()
p, err := mgr.LoadDefault(ctx)
if err != nil {
panic(err)
}
// 2. Register tools.
registry := tools.NewRegistry()
registry.Register("greet", "Greet someone", func(ctx context.Context, args GreetArgs) (string, error) {
return fmt.Sprintf("Hello, %s!", args.Name), nil
})
// 3. Create the agent.
a := agent.New(p,
agent.WithTools(registry),
agent.WithMaxSteps(10),
agent.WithHooks(agent.Hooks{
OnToken: func(ctx context.Context, delta string) { fmt.Print(delta) },
}),
)
// 4. Run.
msgs := conversation.NewBuilder().
System("You are a helpful assistant. Use the greet tool when asked.").
User("Say hello to Alice").
Build()
events, _ := a.Run(ctx, msgs)
for range events {
}
fmt.Println()
}If you would rather wire a provider up directly without the manager, skip to Providers.
profile.Manager composes auth, config, and provider into a single object that knows how to log into any supported backend, discover its models, and persist the user's choice.
mgr := profile.New(profile.WithCallbacks(profile.Callbacks{
OnDeviceCode: func(userCode, verifyURL string) { /* show code */ },
OnOpenURL: func(authURL string) { /* open browser for PKCE sign-in */ },
OnPromptKey: func(providerName string) (string, error) { /* read API key */ },
OnPromptURL: func(providerName, defaultURL string) (string, error) { /* read URL */ },
}))
// Authenticate — GitHub device flow for Copilot, API key for Gemini,
// base URL prompt for Ollama.
mgr.Login(ctx, profile.ProviderCopilot)
// OpenAI has three paths; pick whichever fits the UX:
mgr.LoginOpenAICodexDevice(ctx) // "Sign in with ChatGPT" — headless device code
mgr.LoginOpenAICodexWeb(ctx) // "Sign in with ChatGPT" — browser + local callback
mgr.Login(ctx, profile.ProviderOpenAI) // paste an API key (uses OnPromptKey)
// See which providers are authenticated and which is the default.
statuses, _ := mgr.Status(ctx)
// List chat-capable models across every authenticated provider, sorted.
models, _ := mgr.ListAllModels(ctx)
// Persist the user's choice, preserving any routing metadata.
mgr.SetDefaultModel(models[0])
// Later, in your app:
p, _ := mgr.LoadDefault(ctx)Recently selected models are tracked via RecentModels / TrackRecentModel so interactive pickers can surface them at the top of the list. Credentials are stored in ~/.config/stackllm/auth.json and preferences in ~/.config/stackllm/config.json (or the equivalent XDG_CONFIG_HOME path).
Wire any supported provider directly if you don't want the manager:
provider.OpenAIConfig("gpt-4o", auth.NewStatic(key))
provider.AzureConfig(endpoint, deployment, apiVersion, tokenSource)
provider.OllamaConfig("http://localhost:11434", "llama3")
provider.CopilotConfig("gpt-4o", auth.NewCopilotSource(cfg))
provider.GeminiConfig("gemini-2.5-pro", auth.NewStatic(key))
provider.GroqConfig("llama-3.3-70b-versatile", auth.NewStatic(key))
provider.OpenRouterConfig("openai/gpt-4o", auth.NewStatic(key))Every provider shares the same Complete(ctx, Request) surface and returns a streaming channel of block events (BlockStart, BlockDelta, BlockEnd, ToolCall, Done, Error). Each BlockEnd carries the fully accumulated conversation.Block; the agent concatenates them in order to build the assistant message, preserving any interleaving of thinking, text, and tool_use the model produced.
ExtraBody adds top-level fields the typed Request doesn't model. Set a default for every call on provider.Config.ExtraBody, per agent with agent.WithExtraBody / Agent.SetExtraBody, or per call on provider.Request.ExtraBody. Agent/request fields override config fields per key, a nil value removes a configured key, and model, messages, input and stream are reserved (Complete returns an error if you set them).
OpenRouter serves most models from several upstream providers (Llama 3.3 70B, for example, is available from Groq, Together, DeepInfra, SambaNova and others). By default it picks one for you, balancing price and uptime. To choose for yourself, send OpenRouter's provider routing object through ExtraBody.
Providers are identified by lowercase slugs such as groq, together, deepinfra, sambanova, azure, openai and google-vertex. To see which providers serve a model, and their slugs, query its endpoints:
curl -s https://openrouter.ai/api/v1/models/meta-llama/llama-3.3-70b-instruct/endpointsThe fields you'll use most:
| Field | Type | Effect |
|---|---|---|
order |
[]string |
Try these providers first, in this order |
allow_fallbacks |
bool |
false stops OpenRouter from falling back to providers outside your list (default true) |
only |
[]string |
Allowlist: never use any other provider |
ignore |
[]string |
Blocklist: never use these providers |
sort |
string |
"price", "throughput" or "latency", instead of the default balancing |
require_parameters |
bool |
Only use providers that support every parameter you send (e.g. tools, reasoning_effort) |
data_collection |
string |
"deny" skips providers that may store or train on your prompts |
quantizations |
[]string |
Restrict to precisions such as "fp8", "bf16", "fp16" |
Preferred order, with fallback. Try Groq, then Together; if both are unavailable OpenRouter still routes the request elsewhere:
a := agent.New(p,
agent.WithModel("meta-llama/llama-3.3-70b-instruct"),
agent.WithExtraBody(map[string]any{
"provider": map[string]any{
"order": []string{"groq", "together"},
},
}),
)Strict order, no fallback. Only Groq or Together will ever serve the request. If neither can, the call fails instead of silently going to another provider:
agent.WithExtraBody(map[string]any{
"provider": map[string]any{
"order": []string{"groq", "together"},
"allow_fallbacks": false,
},
})Allowlist. Let OpenRouter choose, but only among these providers:
agent.WithExtraBody(map[string]any{
"provider": map[string]any{
"only": []string{"azure", "openai"},
},
})Blocklist. Use any provider except these:
agent.WithExtraBody(map[string]any{
"provider": map[string]any{
"ignore": []string{"deepinfra", "novita"},
},
})Combining fields. Pick the cheapest of a shortlist, skip providers that retain data, and skip any that can't handle tool calls:
agent.WithExtraBody(map[string]any{
"provider": map[string]any{
"only": []string{"groq", "together", "deepinfra", "sambanova"},
"sort": "price",
"data_collection": "deny",
"require_parameters": true,
},
})Routing is per request, so keep a routing table keyed by model and switch it whenever you switch models. SetModel and SetExtraBody must not be called while a Run or Step is in progress:
routing := map[string]map[string]any{
"meta-llama/llama-3.3-70b-instruct": {"order": []string{"groq", "sambanova"}, "allow_fallbacks": false},
"openai/gpt-4o": {"only": []string{"azure"}},
"deepseek/deepseek-chat-v3.1": {"ignore": []string{"novita"}, "sort": "throughput"},
}
useModel := func(a *agent.Agent, model string) {
a.SetModel(model)
if r, ok := routing[model]; ok {
a.SetExtraBody(map[string]any{"provider": r})
} else {
a.SetExtraBody(nil) // no routing: OpenRouter's default choice
}
}
useModel(a, "openai/gpt-4o")
events, err := a.Run(ctx, msgs)This works the same whether the provider came from profile.Manager (mgr.LoadDefault(ctx) / mgr.LoadProvider(ctx, "openrouter", model)) or was built directly.
When you build the provider yourself, you can set routing once on the config. Agent routing still overrides it, and a nil value drops it for particular requests:
cfg := provider.OpenRouterConfig("meta-llama/llama-3.3-70b-instruct", auth.NewStatic(os.Getenv("OPENROUTER_API_KEY")))
cfg.ExtraBody = map[string]any{
"provider": map[string]any{"ignore": []string{"deepinfra"}},
}
p := provider.New(cfg)
// One call routed differently, without touching the config:
events, err := p.Complete(ctx, provider.Request{
Messages: msgs,
Stream: true,
ExtraBody: map[string]any{"provider": map[string]any{"only": []string{"groq"}}},
})
// One call with the configured routing removed:
events, err = p.Complete(ctx, provider.Request{
Messages: msgs,
Stream: true,
ExtraBody: map[string]any{"provider": nil},
})The whole provider object is replaced, not merged field by field: a request that sets {"only": ["groq"]} does not keep the configured ignore list.
- Model suffixes. For speed or price alone, no
ExtraBodyis needed: append:nitro(highest throughput) or:floor(lowest price) to the model ID, e.g.openrouter/meta-llama/llama-3.3-70b-instruct:nitroas the default model, ora.SetModel("meta-llama/llama-3.3-70b-instruct:floor"). - Account-wide lists. OpenRouter's account settings also have Allowed providers and Ignored providers. They apply to every request made with your key and cannot target individual models. A request's
onlycan narrow the allowed list but not widen it, and itsignoreis added to the account's ignored list.
session.SessionStore is the persistence interface. InMemoryStore ships in the session package; the durable SQLite-backed sqlitestore.Store lives in the session/sqlitestore subpackage so embedders that bring their own store (Redis, Postgres, SQL Server, etc.) never link modernc.org/sqlite into their binary — the session package itself is guaranteed driver-free.
import "github.com/stack-bound/stackllm/session/sqlitestore"
store, _ := sqlitestore.Open(sqlitestore.Config{AppName: "myapp"})
defer store.Close()
sess := session.New()
sess.Name = "Refactor auth middleware" // optional, surfaced as a column
store.Save(ctx, sess)
loaded, _ := store.Load(ctx, sess.ID)
all, _ := store.List(ctx) // every session, ordered by Updated descList(ctx) returns every row in one slice — fine for small UIs, but unworkable once a long-running embedder accumulates thousands of conversations. Stores that can paginate opt into the optional SessionPaginator capability:
type SessionPaginator interface {
ListPage(ctx context.Context, opts ListOptions) (ListResult, error)
}Both InMemoryStore and sqlitestore.Store implement it. Feature-detect via type assertion so custom stores stay free to omit it:
if p, ok := store.(session.SessionPaginator); ok {
page, _ := p.ListPage(ctx, session.ListOptions{Limit: 25, Offset: 50})
fmt.Printf("page 3 of %d (%d rows)\n",
(page.Total+24)/25, page.Total)
for _, s := range page.Sessions {
fmt.Println(s.ID, s.Name)
}
}ListOptions{Limit, Offset}—Limit == 0falls back tosession.DefaultListLimit(50); a negativeLimitreturns every matching row (handy for "export everything"). NegativeOffsetis treated as 0.ListResult{Sessions, Total}—Totalis the row count ignoringLimit/Offset, so you can render "page X of Y" without a second query.- Sort order matches
List: most-recently-updated first. - Like
List,ListPagereturns sessions with metadata populated andMessagesempty — callLoadfor the rows you actually want to read in full.
Filters beyond pagination (name search, date range, project filter, etc.) are deliberately left to the embedder for now: the stackllm_sessions schema is documented and stable, so apps that need them can drop down to the shared *sql.DB returned by store.DB() and write the join their domain needs.
| Package | Purpose |
|---|---|
conversation/ |
Block-shaped Message types, builder, context compaction |
auth/ |
Token sources, storage, OAuth flows |
config/ |
User preferences (default provider/model, provider settings) |
profile/ |
Provider manager: login, status, model discovery, defaults |
tools/ |
Tool interface, JSON Schema generation, registry |
provider/ |
OpenAI-compatible LLM provider |
agent/ |
ReAct agent loop with hooks |
session/ |
Session state and persistence |
tui/ |
Bubbletea terminal UI adapter |
web/ |
HTTP/SSE server adapter |
Runnable examples live in examples/.
| Example | Description |
|---|---|
examples/login |
Interactive CLI for provider management — login, logout, status, browse models, and set the default. For OpenAI the menu prompts between device-code ChatGPT sign-in (default), browser ChatGPT sign-in, or an API key; subcommand shortcuts (login openai device, login openai web, login openai key, login copilot, status, models, default copilot/gpt-4o) are available for scripting. |
examples/simple |
Minimal agent with greet and add tools. Walks the user through provider login and model selection on first run, then uses the persisted default on subsequent runs. |
examples/copilot |
Direct Copilot wiring without the manager — shows the two-phase GitHub device flow and caching token source. |
examples/tui |
Full Bubbletea TUI agent. Streams tokens as they arrive, renders tool calls and results inline, supports Ctrl+V image paste (inserts a [Image #N] placeholder and attaches a BlockImage on send), and supports slash commands: /models to switch provider/model at runtime (with recently-used models surfaced first), /new to start a fresh session. Uses the persisted default or falls back to OPENAI_API_KEY. |
examples/sqlite |
Shared-DB demo for sqlitestore.Store: opens a single SQLite file, runs a parent-app migration (memories table), hands the same *sql.DB to sqlitestore.New, saves a conversation, and queries both namespaces to prove coexistence. No network calls — usable as a CI smoke test. |
examples/web |
Browser-only embedding. Serves web.ManagedHandler under /api/* and a minimal single-page UI at / that drives provider login (API keys, Ollama URL, Copilot device flow, "Sign in with ChatGPT" for OpenAI), model selection, default setting, and streaming chat — all over HTTP with no TUI. The ChatGPT browser/PKCE flow is deliberately CLI-only because its OAuth callback lands on the user's localhost, not the server's — remote-hosted web UIs should use the device-code flow (which works regardless of where the server lives). |
Run any of them with go run ./examples/<name>.
MIT