Glue is a Go agent harness for building local and programmable agents,
inspired by Flue and
pi-mono. It is built around a reusable
provider-agnostic agent loop, a code-first Agent / Session API, and
pluggable providers — Gemini, plus OpenAI-compatible NVIDIA build and
OpenRouter out of the box.
GitHub issues are the source of truth for the roadmap and implementation order:
- Project tracker: #1
- Design doc: docs/design.md
- Project plan: docs/project-plan.md
- Contributor workflow: CONTRIBUTING.md
The harness is feature-complete for the 0.x series and is in active
use behind the agents/glue-review agent (stable at v1.1.0). The
library itself remains pre-1.0 — the public Agent / Session surface
is stable in practice, but minor versions may still break API.
Shipped today:
- Normalized loop types and the provider-agnostic agent loop in
loop/, with deterministic sequential tool execution, opt-inRunRequest.Parallel, andStopReasonMaxTurnsfor budget-exhaustion detection. - Public
Agent/SessionAPI: per-prompt event streaming withWithStreamWriter/WithToolLogger, structured JSON output (PromptJSON), Markdown-driven skills/roles/AGENTS.mddiscovery, opt-inCompactorinterface, typedNewTool[T]helper. - Providers:
gemini(GooglegenaiSDK),nvidiaandopenrouter(OpenAI-compatible, sharing theproviders/openaicompatcore), a driver-style registry underproviders/, andglue.WithFailover. - Storage: file-backed session store at
stores/file. Tools: sharedtools/fsandtools/gitextension packages. CLI:cmd/gluerunner pluscli.RegisterStandardFlagsfor downstream agents. Versioned prompts viaprompts.NewCatalog.
See CHANGELOG.md for library-level notes and
agents/glue-review/CHANGELOG.md for
the agent's release history.
go get github.com/erain/glueThe module path is github.com/erain/glue. Subpackages:
github.com/erain/glue/loop (reusable agent loop),
github.com/erain/glue/providers/{gemini,nvidia,openrouter} (with the
shared OpenAI-compatible core in providers/openaicompat and the
driver-style registry in providers/),
github.com/erain/glue/stores/file (file-backed session store),
github.com/erain/glue/tools/{fs,git} (extension tool packages),
github.com/erain/glue/prompts (versioned-prompt catalog), and
github.com/erain/glue/cli (shared standard flags).
Set a Gemini API key:
export GEMINI_API_KEY=...Send a prompt:
package main
import (
"context"
"fmt"
"log"
"github.com/erain/glue"
"github.com/erain/glue/providers/gemini"
)
func main() {
ctx := context.Background()
agent := glue.NewAgent(glue.AgentOptions{
Provider: gemini.New(gemini.Options{}),
Model: "gemini-2.5-flash",
})
session, err := agent.Session(ctx, "local-dev")
if err != nil {
log.Fatal(err)
}
result, err := session.Prompt(ctx, "Reply with the single word: glue.")
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Text)
}The session keeps an in-memory transcript, so a second session.Prompt(...)
continues the conversation. Pass AgentOptions.Store (e.g.
stores/file) to persist transcripts across processes.
The providers/nvidia package speaks the OpenAI-compatible API exposed at
build.nvidia.com, so any model listed there
(Kimi K2 family, Llama, Qwen, etc.) can be driven through Glue without a
separate SDK.
export NVIDIA_API_KEY=nvapi-...import (
"github.com/erain/glue"
"github.com/erain/glue/providers/nvidia"
)
agent := glue.NewAgent(glue.AgentOptions{
Provider: nvidia.New(nvidia.Options{}),
Model: "moonshotai/kimi-k2.6",
})The model id matches the org/name path on build.nvidia.com (e.g.
moonshotai/kimi-k2.6, meta/llama-3.3-70b-instruct). Cold-start latency
on Kimi K2 can reach tens of seconds for the first chunk; configure your
HTTP client and context timeouts accordingly.
The providers/openrouter package speaks the OpenAI-compatible API at
openrouter.ai, which aggregates many upstream
model providers behind a single endpoint. The meta-route openrouter/free
auto-picks a free underlying model — handy for tests and examples.
export OPENROUTER_API_KEY=sk-or-v1-...import (
"github.com/erain/glue"
"github.com/erain/glue/providers/openrouter"
)
agent := glue.NewAgent(glue.AgentOptions{
Provider: openrouter.New(openrouter.Options{}),
Model: "openrouter/free",
})The provider sends HTTP-Referer and X-Title attribution headers by
default; override them via Options.Headers for your own application.
OpenRouter emits SSE comment-line keep-alives during cold routing — the
provider drops them silently — so first-byte latency may be a few seconds
even when the underlying model is fast.
Session.Subscribe registers a session-scoped handler that fires on every
loop event for every prompt run on that session. glue.WithEvents registers
a per-prompt handler that fires alongside it.
For the two most common cases — mirror text deltas and log tool starts to a writer — use the convenience options:
_, err := session.Prompt(ctx, "Stream a haiku about glue.",
glue.WithStreamWriter(os.Stdout),
glue.WithToolLogger(os.Stderr),
)WithStreamWriter writes EventTextDelta.Delta straight to the writer;
WithToolLogger emits [tool] <name>\n on EventToolStart. Both nil-safe
and silently drop writer errors. They compose additively with WithEvents
and each other — adding one does not displace any other handler.
For richer formatting, use WithEvents directly:
unsubscribe := session.Subscribe(func(e glue.Event) {
if e.Type == glue.EventTextDelta {
fmt.Print(e.Delta)
}
})
defer unsubscribe()
_, err := session.Prompt(ctx, "Stream a haiku about glue.")
if err != nil {
log.Fatal(err)
}result, err := session.Prompt(ctx, "Be concise.",
glue.WithModel("gemini-2.5-pro"),
glue.WithSystemPrompt("Reply in five words or fewer."),
glue.WithMaxTurns(4),
)glue.WithFailover(provs...) returns a Provider that tries each
underlying provider in order until one accepts a Stream — useful when
your CLI agent supports multiple LLM backends and you want it to skip
providers whose API keys aren't set rather than fail. Pre-filter via
the small registry under providers:
import (
"github.com/erain/glue"
"github.com/erain/glue/providers"
_ "github.com/erain/glue/providers/gemini" // registers "gemini"
_ "github.com/erain/glue/providers/nvidia" // registers "nvidia"
_ "github.com/erain/glue/providers/openrouter" // registers "openrouter"
)
var provs []glue.Provider
for _, name := range []string{"nvidia", "openrouter", "gemini"} {
if !providers.KeyAvailable(name) {
continue
}
p, _, _, err := providers.New(name)
if err == nil {
provs = append(provs, p)
}
}
agent := glue.NewAgent(glue.AgentOptions{
Provider: glue.WithFailover(provs...),
Model: "", // let each provider use its DefaultModel
})WithFailover only falls through before the first event commits to
the consumer (Stream error, immediate ProviderEventError, or empty
stream). Once any non-error event is observed, it commits to that
provider for the rest of the turn. All-providers-failed surfaces as a
typed *glue.FailoverError with per-provider attempts.
A role is a named instruction profile with an optional model override.
Pass roles via AgentOptions.Roles or load them from
<WorkDir>/roles/*.md with simple name: / description: / model:
frontmatter.
agent := glue.NewAgent(glue.AgentOptions{
Provider: gemini.New(gemini.Options{}),
Model: "gemini-2.5-flash",
Roles: []glue.Role{
{Name: "reviewer", Model: "gemini-2.5-pro", Instructions: "Review for SQL safety."},
{Name: "writer", Instructions: "Write in plain English."},
},
Role: "writer", // agent default
})
session, _ := agent.Session(ctx, "review", glue.WithSessionRole("reviewer"))
result, _ := session.Prompt(ctx, "Review this PR.", glue.WithRole("reviewer"))Effective role precedence: WithRole (call) > WithSessionRole (session)
AgentOptions.Role(agent). Effective model precedence:WithModel(call) > effective role'sModel>AgentOptions.Model. Unknown role names return a typed error.
Set AgentOptions.WorkDir to enable Markdown context discovery:
<WorkDir>/AGENTS.mdis appended to the system prompt for every prompt on the agent's sessions (missing file is non-fatal).<WorkDir>/.agents/skills/<name>/SKILL.mdis loaded as aglue.Skillwith optionalname:anddescription:frontmatter.
agent := glue.NewAgent(glue.AgentOptions{
Provider: gemini.New(gemini.Options{}),
Model: "gemini-2.5-flash",
WorkDir: ".",
})
session, _ := agent.Session(ctx, "skills")
result, err := session.Skill(ctx, "triage", map[string]int{"issue": 12})Session.Skill renders the skill instructions, appends the args as
formatted JSON, and runs the result through Session.Prompt. Unknown skill
names return a typed error. Skills supplied via AgentOptions.Skills win on
name collision over disk-discovered skills.
prompts.NewCatalog(fsys, dir, defaultVersion) wraps an embed.FS of
<version>.md files so agents can A/B-test prompts and roll back without
rebuilding history. Unknown versions return an error that lists every
available version verbatim — silent fallback would hide A/B test
misconfiguration.
import (
"embed"
"github.com/erain/glue/prompts"
)
//go:embed prompts/*.md
var promptFS embed.FS
cat, err := prompts.NewCatalog(promptFS, "prompts", "v2")
if err != nil { /* default version must exist at construction time */ }
systemPrompt, err := cat.Get("v1") // or cat.Get("") for the defaultThe catalog is read-only and concurrency-safe. Templating and variable substitution are intentionally out of scope; rendering is the caller's job.
var out struct {
Name string `json:"name"`
Count int `json:"count"`
}
_, err := session.PromptJSON(ctx, "Return a project name and count.", &out)PromptJSON augments the prompt with JSON-only instructions and sets
response_mime_type: application/json on the provider request. Pass
glue.WithJSONSchema(schema) to forward an explicit JSON Schema (Gemini:
response_json_schema). V1 validation is JSON decoding into the caller's Go
type.
glue.NewTool[Args] decodes ToolCall.Arguments into a typed Go value
before invoking the executor, so most tools no longer need a manual
json.Unmarshal. Pair it with glue.TextResult / glue.ErrorResult for
the result side:
type weatherArgs struct {
City string `json:"city"`
}
weather := glue.NewTool[weatherArgs](
glue.ToolSpec{
Name: "weather",
Description: "Lookup current weather for a city.",
Parameters: json.RawMessage(`{
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}`),
},
func(ctx context.Context, a weatherArgs) (glue.ToolResult, error) {
report, err := lookup(ctx, a.City)
if err != nil {
return glue.ErrorResult(err), nil
}
return glue.TextResult(report), nil
},
)Malformed arguments surface to the model as an error ToolResult rather
than crashing the loop. Schema generation from Args is intentionally
out of scope; supply Parameters explicitly.
The glue.Provider interface is small, so tests can drive sessions with a
fake provider — no credentials required:
type fakeProvider struct{}
func (fakeProvider) Stream(_ context.Context, _ glue.ProviderRequest) (<-chan glue.ProviderEvent, error) {
events := make(chan glue.ProviderEvent, 3)
events <- glue.ProviderEvent{Type: glue.ProviderEventStart}
events <- glue.ProviderEvent{Type: glue.ProviderEventTextDelta, Delta: "hello"}
events <- glue.ProviderEvent{Type: glue.ProviderEventDone}
close(events)
return events, nil
}
func ExampleSession_Prompt() {
ctx := context.Background()
agent := glue.NewAgent(glue.AgentOptions{Provider: fakeProvider{}})
session, _ := agent.Session(ctx, "test")
result, _ := session.Prompt(ctx, "say hi")
fmt.Println(result.Text)
// Output: hello
}The repository's own tests (glue/agent_test.go, loop/run_test.go,
loop/tool_exec_test.go) use this pattern.
go build ./...
go vet ./...
go test ./...CI runs the same commands on every PR. The Gemini provider has a gated live smoke test:
GEMINI_API_KEY=... go test ./providers/gemini -run LiveReal agents built on the harness live under agents/ (peer of the harness
itself), not examples/ (which holds tutorial-grade demos only).
-
agents/glue-review— a free, local pre-push branch reviewer (stable:v1.1.0, floating tagv1). Reads the diff againstmain, deep-reads files when context demands it, posts inline review comments on the diff via the GitHub PR Reviews API (each comment carries a copy-pasteableAI prompt to fixblock as ofv1.1.0), and falls back to a sticky markdown comment when entries don't parse cleanly. Defaults to NVIDIA's free Kimi K2.6; flags swap to OpenRouter or Gemini, with automatic provider failover and a fixture-replay mode for prompt regression tests.As a CLI:
export NVIDIA_API_KEY=nvapi-... go run ./agents/glue-review # review current branch vs main go run ./agents/glue-review --provider openrouter
As a GitHub Action — drop into any repo:
- uses: erain/glue/agents/glue-review@v1 with: nvidia-api-key: ${{ secrets.NVIDIA_API_KEY }}
See
agents/glue-review/CHANGELOG.mdfor surface guarantees and release history.
-
examples/local-agentis a small Gemini-backed tutorial CLI that registers alocal_timetool, streams text to stdout, and persists sessions throughstores/file. It's the shortest path from zero to "Glue agent that calls a Go function":export GEMINI_API_KEY=... go run ./examples/local-agent --prompt "Use local_time for America/Toronto." --id demo
A thin local CLI is built on the same library API:
go run ./cmd/glue run --prompt "Say hi" --id local-dev --store .glue/sessionsFlags:
--id— session id (default"default").--prompt— prompt text (required).--model— model id orgemini/<model>(defaultgemini-2.5-flash).--store— file session store directory (default.glue/sessions).--env—.envfile to load before readingGEMINI_API_KEY. Repeatable; shell environment wins on conflict.
The CLI streams text deltas to stdout, persists sessions through
stores/file, and uses WorkDir="." so AGENTS.md, .agents/skills, and
roles/ discovery work from the invocation directory. Errors return a
non-zero exit code; missing GEMINI_API_KEY produces a clear message.
Agents that ship their own CLI binary share the same six flags
(--provider, --model, --id, --store, --work, --max-turns).
cli.RegisterStandardFlags wires them onto a flag.FlagSet and returns
a getter:
import "github.com/erain/glue/cli"
fs := flag.NewFlagSet("my-agent", flag.ContinueOnError)
get := cli.RegisterStandardFlags(fs, nil) // pass &cli.StandardConfig{...} to override defaults
fs.Parse(os.Args[1:])
cfg := get() // cfg.Provider, cfg.Model, cfg.ID, cfg.Store, cfg.Work, cfg.MaxTurns--provider accepts a comma-separated list (e.g. nvidia,openrouter,gemini)
which agents are expected to handle by chaining the providers registry
with glue.WithFailover.
Glue's Provider interface is small. See
docs/provider-guide.md for the contract and
common pitfalls, and examples/echo-provider
for the shortest possible runnable implementation.
P0–P2 are shipped: the reusable loop, public Agent / Session API,
file-backed sessions, structured JSON, skills, roles, the CLI runner,
parallel tool execution, opt-in context compaction, the shell/filesystem
tool extension packages (tools/fs, tools/git per
docs/adr/0003-shell-filesystem-tools.md),
the provider plugin guide (docs/provider-guide.md),
and the GitHub issue automation workflow
(docs/issue-automation.md). The current
focus is hardening through dogfooding agents/glue-review and closing
the agent-ergonomics wishlist (typed tools, provider failover, prompt
catalog, stream writer, standard flags) plus the broader gaps in
docs/flue-gap-analysis.md: multi-target
deployment, sandbox connectors, subagent orchestration, MCP. See
docs/project-plan.md and the project tracker
(#1) for the next recommended issue.