gage is a provider-agnostic Go toolkit for building agentic systems. It is a
library — you import it into your own program; it never starts a server or
owns a main. Everything streams end to end.
It gives you, behind clean interfaces:
- LLM providers: Anthropic (API key), OpenRouter, vLLM, Ollama, Codex
(ChatGPT/Codex plan via OAuth) and Claude Code (Claude plan via OAuth) —
with reasoning round-trip (signed thinking blocks), prompt caching
(
cache_control), and structured output (JSON mode / JSON Schema). - Built-in tools:
read,write,edit,bash,grep,glob,list_dir,webfetch,websearch— plustools.Typed[T]to define your own tools from plain Go structs (schema generated by reflection). - MCP: connect to Model Context Protocol servers (stdio or streamable HTTP,
with header/bearer auth): tools (with live
tools/list_changedsync), resources, prompts, and sampling backed by anygage.Provider. - Skills: load Claude Code–style
SKILL.mdfolders. - Memory: a
MemoryStoreport, in-memory adapter, andmemory_remember/memory_recall/memory_forgettools. - Agents: an agentic loop with tool execution (sequential or parallel),
permissions (allow/deny/rewrite/remember), JSON Schema tool-input
validation, hooks, context compaction, sub-agents, aggregated usage, and a
terminal
Result. - Production guards: secure policy helpers, per-tool timeouts, panic
recovery, structured observations, concurrency and result-size wrappers,
SSRF-pinned
webfetch, sandboxable/sanitizedbash, root-confined filesystem tools, encrypted file stores, and durable workflow checkpoints. - HTTP: an SSE
http.Handlerto expose an agent (you mount it).
gage is built as hexagonal ports & adapters. The root package gage holds
the domain types and the ports (interfaces); everything else is an adapter that
depends on the core, never the reverse.
gage/ core: Message, Event, Usage, Result + ports (Provider, Tool, Approver, Compactor, ...)
├── providers/ Provider adapters (anthropic, openrouter, vllm, ollama, codex, claudecode)
│ ├── shared/ HTTP client, SSE parser, OAuth (PKCE, token source, stores)
│ ├── openai/ reusable Chat Completions + Responses wire formats
│ └── anthropic/ reusable Messages wire format + API-key provider
├── tools/ built-in tools, Typed[T], registry + permission/limit decorators
├── policy/ conservative Approver policies for secure defaults
├── search/ SearchProvider impls (duckduckgo, brave, tavily)
├── mcp/ MCP client → gage.Tool bridge (+ resources, prompts, sampling)
├── skills/ SKILL.md loader + the "skill" tool
├── memory/ in-memory MemoryStore + memory tools
├── workflow/ durable run/checkpoint persistence around an agent
├── jsonschema/ small JSON Schema builder for tool parameters
├── agent/ the agentic loop, hooks, compactors, sub-agents
└── httpx/ SSE handler (no server)
The two central contracts:
// A model backend.
type Provider interface {
Stream(ctx context.Context, req Request) (<-chan Event, error)
Name() string
}
// An executable capability the model can call.
type Tool interface {
Name() string
Description() string
Schema() JSONSchema
Execute(ctx context.Context, input json.RawMessage) (ToolResult, error)
}A Provider streams a channel of unified Events (text_delta,
reasoning_delta, tool_call_*, usage, message_done, ...). The agent relays
those events, runs the requested tools, feeds results back, and repeats until a
final answer — emitting a terminal done event.
go get github.com/deepteams/gageRequires Go 1.26+.
package main
import (
"context"
"fmt"
"github.com/deepteams/gage"
"github.com/deepteams/gage/agent"
"github.com/deepteams/gage/tools"
"github.com/deepteams/gage/providers/openrouter"
)
func main() {
// 1. A provider.
provider := openrouter.New("sk-or-...", openrouter.WithDefaultModel("anthropic/claude-sonnet-4.5"))
// 2. A tool registry with built-in tools confined to a working dir.
reg := tools.NewRegistry()
reg.MustRegister(tools.NewFSTools(tools.FSConfig{Root: "."})...)
reg.MustRegister(tools.NewSearchTools(tools.FSConfig{Root: "."})...)
reg.MustRegister(tools.NewBashTool(tools.BashConfig{Dir: "."}))
// 3. An agent.
ag, _ := agent.New(agent.Config{
Provider: provider,
Registry: reg,
System: "You are a coding assistant. Use tools to inspect the repo.",
})
// 4. Stream the run.
stream, _ := ag.Run(context.Background(), []gage.Message{
gage.UserText("List the Go files and summarize the project."),
})
for ev := range stream {
switch ev.Type {
case gage.EventTextDelta:
fmt.Print(ev.Text)
case gage.EventToolResult:
fmt.Printf("\n[tool %s]\n", ev.ToolResult.Text())
}
}
}| Provider | Constructor | Auth |
|---|---|---|
| Anthropic | anthropic.New(anthropic.Config{APIKey: ...}) |
API key |
| OpenRouter | openrouter.New(apiKey, ...) |
API key |
| vLLM | vllm.New(baseURL, ...) |
optional key |
| Ollama | ollama.New(baseURL, ...) |
none (local) |
| Codex | codex.New(store, ...) |
OAuth (ChatGPT/Codex plan) |
| Claude Code | claudecode.New(store, console, ...) |
OAuth (Claude plan) |
All implement gage.Provider, so they are interchangeable in agent.Config.
Generation options are uniform, and providers fail fast with
gage.ErrUnsupported instead of silently dropping an option they cannot
honor:
Options: gage.ApplyOptions(gage.GenerateOptions{},
gage.WithJSONSchema("report", schema), // structured output
gage.WithPromptCache(), // cache_control breakpoints (Anthropic)
gage.WithReasoningEffort(gage.ReasoningHigh),
),Reasoning/thinking blocks are preserved across turns: providers emit
reasoning_done events carrying an opaque signature, the agent replays them in
history, and encoders reattach them (required for Anthropic extended thinking
with tool use).
⚠️ Heads-up. Codex and Claude Code here authenticate against undocumented backend endpoints using the OAuth "plan" flow, presenting themselves as the official CLIs. These endpoints are not a public API, can change without notice, and their use is subject to the respective providers' terms. Use at your own risk.
You supply a gage.TokenStore — you own how tokens are persisted. gage
provides an optional file/memory store in providers/shared/oauth, but any
implementation works (database, keychain, secret manager):
type TokenStore interface {
Load(ctx context.Context) (Credentials, error)
Save(ctx context.Context, c Credentials) error
}Log in once to populate the store, then construct the provider:
store := oauth.NewFileStore("/secure/path/codex.json") // or your own TokenStore
// One-time login (opens a browser to the auth URL).
_, err := codex.Login(ctx, store, func(url string) { browser.Open(url) })
// Use it — tokens refresh transparently through the store.
provider := codex.New(store)Claude Code uses a copy-paste redirect flow:
authURL, complete, _ := claudecode.Login(false)
fmt.Println("Visit:", authURL)
// user pastes the returned "code#state"
creds, _ := complete(ctx, store, pasted)
provider := claudecode.New(store, false)Register the built-ins you want, or define your own from a plain struct with
tools.Typed — the JSON Schema is generated by reflection and inputs are
unmarshaled for you:
type WeatherArgs struct {
City string `json:"city" desc:"City name"`
Unit string `json:"unit,omitempty" desc:"Unit" enum:"celsius,fahrenheit"`
}
reg.MustRegister(tools.Typed("weather", "Get the current weather.",
func(ctx context.Context, a WeatherArgs) (gage.ToolResult, error) {
return gage.TextResult("", fetchWeather(a.City, a.Unit)), nil
}))Gate every execution behind an approver. An approval can carry a denial reason (shown to the model), a rewritten input, and a "remember" flag:
import "github.com/deepteams/gage/policy"
// Secure allows local read-only filesystem tools and pauses network, shell,
// writes, MCP, memory mutations and unknown tools for out-of-band approval.
approver := policy.Secure()Or provide your own application policy:
approver := gage.ApproverFunc(func(ctx context.Context, r gage.PermissionRequest) (gage.Approval, error) {
if r.Metadata.ReadOnly {
return gage.Approval{Allow: true, Remember: true}, nil
}
// Your app decides how to ask the user or apply policy. r includes:
// Tool, Input, Agent, RunID, Turn, Metadata, and Summary.
return askUserForApproval(ctx, r.Summary)
})
// gage.RememberingPerInput caches remembered decisions by tool+arguments.
ag, _ := agent.New(agent.Config{Provider: p, Registry: reg, Approver: gage.RememberingPerInput(approver)})The agent validates tool arguments against each tool's JSON Schema before
execution by default. Set agent.Config.DisableToolInputValidation only when
you deliberately need raw, schema-incompatible inputs.
gage.Remembering is also available when you deliberately want broad caching
by tool name. For write, shell, network, and other argument-sensitive tools,
prefer RememberingPerInput or RememberingBy with an app-specific key.
Built-in tools expose advisory ToolMetadata (ReadOnly, Filesystem,
Network, Shell, Destructive, RequiresApproval, Tags) and concise call
summaries. Custom tools can implement gage.ToolMetadataProvider /
gage.ToolCallDescriber, or use tools.FuncWithMetadata.
For production use, add a per-tool timeout and an observer for audit logs, metrics, or traces:
observer := agent.ObserverFunc(func(ctx context.Context, o agent.Observation) {
log.Printf("run=%s type=%s tool=%s error=%v duration=%s",
o.RunID, o.Type, o.Tool, o.IsError, o.Duration)
})
ag, _ := agent.New(agent.Config{
Provider: p,
Registry: reg,
Approver: approver,
ToolTimeout: 30 * time.Second,
Observer: observer,
})Tool panics are recovered and returned to the model as failed tool results.
tools.LimitConcurrency caps concurrent executions of expensive tools, and
tools.LimitResultSize keeps oversized results (MCP, custom tools) from
blowing the context window:
reg.MustRegister(tools.LimitConcurrency(tools.NewBashTool(tools.BashConfig{Dir: "."}), 2))
reg.MustRegister(tools.LimitResultSize(myTool, 64<<10))agent.Config controls the loop end to end:
ag, _ := agent.New(agent.Config{
Provider: provider,
Registry: reg,
MaxParallelTools: 4, // run a turn's tool calls concurrently
Compactor: agent.Summarize(provider, "", 20), // or agent.Trim(30)
CompactAfter: 150_000, // input-token threshold
Hooks: agent.Hooks{
PreToolUse: func(ctx context.Context, tc gage.ToolCall) (gage.ToolCall, error) {
return tc, nil // rewrite the input, or return an error to block
},
PostToolUse: func(ctx context.Context, tc gage.ToolCall, res gage.ToolResult) gage.ToolResult {
return redact(res) // rewrite results before the model sees them
},
},
})Every run ends with a terminal done event carrying a gage.Result — the
full conversation, final text, stop reason, aggregated usage, and turn count —
so multi-run conversations need no event bookkeeping. ag.RunSync(ctx, msgs)
is the blocking shortcut when you don't need the stream.
websearch needs a SearchProvider. DuckDuckGo needs no key:
reg.MustRegister(tools.NewWebTools(tools.WebConfig{Search: duckduckgo.New()})...)brave.New(key) and tavily.New(key) are drop-in alternatives.
webfetch blocks localhost, private, link-local, multicast and unspecified
addresses by default, including redirects. For trusted local/internal use only,
set tools.WebConfig{AllowPrivateHosts: true}.
bash sanitizes the environment, applies time/output limits, and kills the
process group on timeout, but direct shell execution is not an operating-system
sandbox. For untrusted commands, require an external sandbox:
bash := tools.NewBashTool(tools.BashConfig{
RequireSandbox: true,
Sandbox: tools.ExternalSandbox{
Label: "firejail",
Binary: "firejail",
Args: []string{"--private", "--", "{{shell}}", "-c", "{{command}}"},
},
})import "github.com/deepteams/gage/memory"
mem := memory.New()
reg.MustRegister(memory.NewTools(mem)...)Memories carry optional namespace, user_id, provenance, sensitivity,
confidence, expires_at, and metadata fields. Recall can be scoped by
namespace/user and hides expired memories by default. The built-in store uses
simple keyword recall (or cosine similarity with memory.NewWithEmbedder) and
is useful for tests and local agents. Production systems can implement
gage.MemoryStore with a database, vector index, user-profile service, or
tenant-scoped memory layer without changing the agent loop.
sessions.NewFileStore writes atomic 0600 JSON files. For local encrypted
storage, use AES-GCM:
store, _ := sessions.NewEncryptedFileStore("./sessions", key32Bytes)OAuth helpers have the same option:
tokenStore, _ := oauth.NewEncryptedFileStore("./tokens/codex.json", key32Bytes)workflow.Runner wraps an agent with a SessionStore: completed runs persist
their full conversation, and paused approval checkpoints are saved so another
process can resume later.
runner := workflow.New(ag, store)
out, err := runner.Run(ctx, "session-1", []gage.Message{gage.UserText("ship it")})
if errors.Is(err, gage.ErrApprovalPending) {
// Persisted already; collect decisions, then:
out, err = runner.Resume(ctx, "session-1", decisions)
}client, _ := mcp.ConnectStdio(ctx, mcp.StdioConfig{
Name: "fs", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/data"},
},
mcp.WithToolSync(reg), // keep the registry in sync on tools/list_changed
mcp.WithSamplingProvider(provider), // let the server sample through your Provider
)
defer client.Close()
client.Register(ctx, reg) // tools appear as "fs__<tool>"
resources, _ := client.Resources(ctx) // list server resources
parts, _ := client.ReadResource(ctx, uri) // text and images as ContentParts
msgs, _ := client.GetPrompt(ctx, "review", nil) // server prompts as []gage.MessageHTTP servers with auth:
client, _ := mcp.ConnectHTTP(ctx, mcp.HTTPConfig{
Name: "api", Endpoint: "https://mcp.example.com",
Headers: mcp.BearerHeaders(token),
})set, _ := skills.LoadDir("./skills") // folders each holding a SKILL.md
reg.MustRegister(skills.NewTool(set)) // the model can load a skill on demand
ag, _ := agent.New(agent.Config{Provider: p, Registry: reg, Skills: set})Any agent can be exposed as a tool for another agent:
researcher, _ := agent.New(agent.Config{Provider: p, Registry: researchReg, Name: "researcher"})
reg.MustRegister(researcher.AsTool("researcher", "Delegate research tasks."))gage gives you the handler; you mount and serve it:
h := httpx.StreamHandler(ag, func(r *http.Request) ([]gage.Message, error) {
var body struct{ Prompt string `json:"prompt"` }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return nil, err
}
return []gage.Message{gage.UserText(body.Prompt)}, nil
})
http.Handle("/agent", h)
http.ListenAndServe(":8080", nil) // your app owns thisEverything is tested against httptest/in-memory transports — no network access:
go test ./... -raceMIT. See LICENSE.