A provider-agnostic Go library for building AI agent loops with pluggable tools, memory, and middleware.
Forge handles the LLM call -> tool execution -> response cycle. You supply a provider (Anthropic, OpenAI, etc.), register tools, and forge runs the loop, including error handling, iteration limits, and conversation memory.
go get github.com/katasec/forge
go get github.com/katasec/forge/provider/anthropic # optional
go get github.com/katasec/forge/provider/openai # optional
go get github.com/katasec/forge/provider/xai # optional: xAI Responses API with web searchexport ANTHROPIC_API_KEY=sk-ant-...package main
import (
"context"
"fmt"
"log"
"os"
"github.com/katasec/forge"
"github.com/katasec/forge/provider/anthropic"
)
func main() {
provider := anthropic.New(os.Getenv("ANTHROPIC_API_KEY"), "claude-sonnet-4-20250514")
agent, err := forge.NewAgent(forge.Config{
Provider: provider,
SystemPrompt: "You are a helpful assistant. Keep responses brief.",
})
if err != nil {
log.Fatal(err)
}
resp, err := agent.Ask(context.Background(), "Hello! What are you?")
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.LastText())
}Swap to OpenAI by changing one import:
import "github.com/katasec/forge/provider/openai"
provider := openai.New(os.Getenv("OPENAI_API_KEY"), openai.ModelGPT54Nano)The openai package uses the OpenAI Responses API, including text and image content.
Or use the xAI Responses API with built-in web search:
import "github.com/katasec/forge/provider/xai"
provider := xai.New(os.Getenv("XAI_API_KEY"), xai.ModelGrok4FastNonReasoning, xai.WithWebSearch())
// After running the agent, access citations:
citations := provider.LastCitations()
for _, c := range citations {
fmt.Printf("[%s] %s\n", c.Title, c.URL)
}See _examples/hello-world for the full runnable code.
The Provider interface makes a single LLM call. Forge ships with three built-in providers, or you can implement your own:
type Provider interface {
Generate(ctx context.Context, req ProviderRequest) (*ProviderResponse, error)
}Define tools with Func[T]. The JSON schema for parameters is derived from the Go struct at construction time using invopop/jsonschema:
type SearchInput struct {
Query string `json:"query" jsonschema:"description=Search query"`
Limit int `json:"limit" jsonschema:"description=Max results"`
}
searchTool := forge.Func[SearchInput]("search", "Search the database", func(ctx context.Context, in SearchInput) (string, error) {
// ... your search logic
return results, nil
})Or implement the Tool interface directly for full control:
type Tool interface {
Name() string
Description() string
Schema() ToolSchema
Invoke(ctx context.Context, args json.RawMessage) (string, error)
}Agent.Run executes this loop:
- Load conversation history from memory
- Call the provider with messages + tool definitions
- If the provider says stop, return the response
- If the provider requests tool use, execute tools, feed results back, go to 2
- If iteration limit hit, return with
FinishReasonIterLimit - Save conversation to memory
For the common case, use Ask:
resp, err := agent.Ask(ctx, "Hello")
fmt.Println(resp.LastText())For multimodal input, use AskContent:
resp, err := agent.AskContent(ctx,
forge.Text("Describe this image."),
forge.ImageURL("https://example.com/cat.png"),
)
fmt.Println(resp.LastText())Use AskIn when you want to manage multiple named conversations:
resp, err := agent.AskIn(ctx, "support-ticket-123", "What happened last?")Use Run when you need full control over message roles, multiple messages, or advanced conversation wiring.
Controls what happens when a tool returns an error:
ErrorPolicyStop(default): terminate the loop immediatelyErrorPolicyContinue: feed the error back to the LLM so it can adapt
Intercept provider calls for logging, retries, rate limiting, etc:
logging := forge.Middleware(func(next forge.RunFunc) forge.RunFunc {
return func(ctx context.Context, req forge.ProviderRequest) (*forge.ProviderResponse, error) {
log.Printf("calling provider with %d messages", len(req.Messages))
resp, err := next(ctx, req)
if err == nil {
log.Printf("provider returned: %s", resp.FinishReason)
}
return resp, err
}
})
agent, _ := forge.NewAgent(forge.Config{
Provider: myProvider,
Middleware: []forge.Middleware{logging},
})Middleware composes as decorators: given [A, B, C], request flows A -> B -> C -> provider -> C -> B -> A.
Forge uses in-memory conversation history by default. Repeated Ask calls on the same agent continue the same default conversation:
agent, _ := forge.NewAgent(forge.Config{
Provider: myProvider,
})
resp, _ := agent.Ask(ctx, "My name is Ameer.")
resp, _ = agent.Ask(ctx, "What is my name?")For named conversations:
resp, _ := agent.AskIn(ctx, "conv-1", "Hi")
resp, _ = agent.AskIn(ctx, "conv-1", "What did I just say?")Disable memory explicitly for stateless agents:
agent, _ := forge.NewAgent(forge.Config{
Provider: myProvider,
DisableMemory: true,
})Implement MemoryStore for persistent storage (SQLite, Redis, etc.):
type MemoryStore interface {
Load(ctx context.Context, conversationID string) ([]Message, error)
Save(ctx context.Context, conversationID string, messages []Message) error
Clear(ctx context.Context, conversationID string) error
}Or opt into a supplied memory implementation explicitly:
import "github.com/katasec/forge/memory/inmem"
agent, _ := forge.NewAgent(forge.Config{
Provider: myProvider,
Memory: inmem.New(),
})Attach arbitrary key-value data to the context, accessible by tools and middleware:
ctx := forge.WithMetadata(context.Background(), forge.Metadata{
Values: map[string]string{"user_id": "123", "tenant": "acme"},
})
// Inside a tool:
if meta, ok := forge.MetadataFromContext(ctx); ok {
userID := meta.Values["user_id"]
}See the _examples directory for runnable demos:
- hello-world: simplest possible example: call Claude or xAI with one flag swap
- calculator: agent with math tools, middleware, and a mock provider
- chat-console: interactive console app showing
forge.Config, explicit memory,Ask, andLastText
MIT