AI Chat¶
The chat package provides a unified, high-level interface for interacting with various AI providers. It abstracts away the complexities of different APIs, allowing you to focus on building intelligent features for your CLI.
Overview¶
Whether you're generating code, analyzing errors, or creating interactive assistants, the chat package serves as your gateway to Large Language Models (LLMs). It supports:
- Multiple Providers: OpenAI, Claude, Gemini, a locally installed
claudebinary, and any OpenAI-compatible endpoint. - Structured Output: Easily unmarshal AI responses into Go structs.
- Tool Calling: Expose your own Go functions to the AI.
- Extensible Registry: Register custom providers from external packages without modifying the core.
Getting Started¶
Configuration¶
The chat package integrates with the application's configuration system, picking up authentication tokens from environment variables automatically.
The Config struct accepts the following fields:
| Field | Type | Description |
|---|---|---|
Provider |
Provider |
The provider constant. Defaults to ProviderOpenAI if unset. |
Model |
string |
Model name. Falls back to a sensible default per provider if empty. Required for ProviderOpenAICompatible. |
Token |
string |
API key. Optional if set via environment variable. |
BaseURL |
string |
API endpoint override. Required for ProviderOpenAICompatible. |
SystemPrompt |
string |
Initial system prompt for the conversation. |
ResponseSchema |
any |
JSON schema for enforcing structured output (used by Ask). |
SchemaName |
string |
Name for the response schema tool. |
SchemaDescription |
string |
Description for the response schema tool. |
MaxSteps |
int |
Maximum ReAct loop iterations in Chat(). Zero uses the default (20). |
MaxTokens |
int |
Maximum tokens per response. Zero uses the provider default (OpenAI: 4096, Claude: 8192, Gemini: 8192). |
ParallelTools |
bool |
Enables concurrent execution of multiple tool calls within a single ReAct step. Disabled by default. |
MaxParallelTools |
int |
Maximum number of tool calls executing concurrently. Zero uses the default (5). Only effective when ParallelTools is true. |
import "gitlab.com/phpboyscout/go-tool-base/pkg/chat"
cfg := chat.Config{
Provider: chat.ProviderOpenAI, // or ProviderClaude, ProviderGemini, ProviderClaudeLocal, ProviderOpenAICompatible
Model: "gpt-4o",
// Token is optional if set via OPENAI_API_KEY environment variable
SystemPrompt: "You are a helpful CLI assistant.",
}
Credential Resolution¶
Every provider resolves its API key through a shared four-step precedence so tool authors never need to re-implement the cascade:
- Direct token โ
Config.Tokensupplied by the caller (tests, explicit overrides). - Env-var reference in config โ
{provider}.api.envnames an env var (e.g.ANTHROPIC_API_KEY). The resolver reads the name from config and thenos.Getenv(name)for the value. This keeps the literal secret out of the config file while letting the user control which env var holds it. - Literal in config โ
{provider}.api.key. Routed through Viper'sAutomaticEnv, so a prefixed env var (e.g.MYTOOL_ANTHROPIC_API_KEY) is picked up here too. - Unprefixed ecosystem env โ
ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY. Final fallback for compatibility with provider SDKs and common CI conventions.
Three pairs of config-key constants describe the per-provider surface:
| Provider | Literal key | Env-var-reference key | Ecosystem fallback env var |
|---|---|---|---|
| Claude | ConfigKeyClaudeKey (anthropic.api.key) |
ConfigKeyClaudeEnv (anthropic.api.env) |
EnvClaudeKey (ANTHROPIC_API_KEY) |
| OpenAI | ConfigKeyOpenAIKey (openai.api.key) |
ConfigKeyOpenAIEnv (openai.api.env) |
EnvOpenAIKey (OPENAI_API_KEY) |
| Gemini | ConfigKeyGeminiKey (gemini.api.key) |
ConfigKeyGeminiEnv (gemini.api.env) |
EnvGeminiKey (GEMINI_API_KEY) |
The interactive gtb init ai wizard defaults to env-var mode โ it prompts for an env var name (pre-populated with the provider standard) and writes only {provider}.api.env. The literal is never persisted to disk in the recommended path. See pkg/credentials for the storage-mode taxonomy shared with the setup wizard, doctor, and config masker.
Initialization¶
client, err := chat.New(ctx, props, cfg)
if err != nil {
return errors.Newf("failed to initialize chat client: %w", err)
}
Features¶
Basic Chat¶
Send a natural language prompt and receive a text response.
response, err := client.Chat(ctx, "Explain how to use the 'ls' command.")
if err != nil {
// Handle error
}
fmt.Println(response)
Structured Output (Ask)¶
The Ask method forces the AI to return data in a specific JSON structure, automatically unmarshaled into your Go struct.
type AnalysisResult struct {
Severity string `json:"severity"`
Suggestions []string `json:"suggestions"`
}
var result AnalysisResult
err := client.Ask(ctx, "Analyze this error log and suggest fixes...", &result)
if err != nil {
// Handle error
}
fmt.Printf("Severity: %s\n", result.Severity)
When ResponseSchema is set in the config at construction time, all subsequent Ask calls enforce that schema.
Tool Calling¶
The chat package provides a robust mechanism for exposing Go functions as tools to the AI, implemented using JSON Schema for parameter definition and a handler-based execution loop.
Registration¶
tools := []chat.Tool{
{
Name: "read_file",
Description: "Read the contents of a file",
Parameters: chat.GenerateSchema[struct { Path string `json:"path"` }]().(*jsonschema.Schema),
Handler: myHandler,
},
}
client.SetTools(tools)
Complete Tool Handler Example¶
package main
import (
"context"
"encoding/json"
"os"
"gitlab.com/phpboyscout/go-tool-base/pkg/chat"
"github.com/cockroachdb/errors"
)
type ReadFileParams struct {
Path string `json:"path" jsonschema:"description=The file path to read"`
}
type FileContents struct {
Content string `json:"content"`
Size int `json:"size"`
}
func readFileHandler(ctx context.Context, args json.RawMessage) (any, error) {
var params ReadFileParams
if err := json.Unmarshal(args, ¶ms); err != nil {
return nil, errors.Newf("failed to parse arguments: %w", err)
}
content, err := os.ReadFile(params.Path)
if err != nil {
return nil, errors.Newf("failed to read file: %w", err)
}
return FileContents{
Content: string(content),
Size: len(content),
}, nil
}
func setupTools(client chat.ChatClient) error {
tools := []chat.Tool{
{
Name: "read_file",
Description: "Read the contents of a file from the filesystem",
Parameters: chat.GenerateSchema[ReadFileParams]().(*jsonschema.Schema),
Handler: readFileHandler,
},
}
return client.SetTools(tools)
}
Execution Loop¶
When a model issues a tool call, the Chat method:
- Intercepts the response.
- Identifies the requested tool by name.
- Unmarshals arguments into the handler's expected format.
- Executes the handler.
- Injects the result back into the conversation history.
- Automatically resumes the conversation to get the model's next response.
This loop continues for up to Config.MaxSteps iterations (default 20) before returning an error.
Parallel Tool Execution¶
When a provider returns multiple tool calls in a single response step, they can be executed concurrently rather than sequentially. This reduces latency for I/O-bound tools (HTTP requests, file reads, subprocess invocations).
Enable via Config.ParallelTools:
cfg := chat.Config{
Provider: chat.ProviderClaude,
Model: "claude-sonnet-4-6",
ParallelTools: true,
MaxParallelTools: 3, // optional; defaults to 5
}
Behaviour:
- Disabled by default โ sequential execution is preserved unless opted in.
- Only activates when the provider returns more than one tool call in a single step. Single tool calls always use the sequential path regardless of this setting.
- Results are returned in the same order as the input tool calls, regardless of completion order.
- Context cancellation propagates to all in-flight tool goroutines.
- Tool errors (handler errors, tool not found) are returned as error strings in the conversation, consistent with the sequential path โ they do not abort the ReAct loop.
- Bounded by
MaxParallelTools(default 5) to prevent goroutine storms when the AI returns many calls at once.
Thread safety: tool handlers receive independent json.RawMessage inputs and return independent results. Parallel execution is safe as long as individual handlers do not share mutable state without synchronization.
Multi-Turn Conversations¶
The chat client maintains conversation history. You can build multi-turn conversations:
func interactiveSession(ctx context.Context, client chat.ChatClient) error {
response1, err := client.Chat(ctx, "I have a Go project at /tmp/myproject")
if err != nil {
return err
}
fmt.Println("AI:", response1)
// Second turn โ client remembers the context
response2, err := client.Chat(ctx, "What files are in the cmd directory?")
if err != nil {
return err
}
fmt.Println("AI:", response2)
return nil
}
Streaming Chat¶
Providers that support streaming implement the StreamingChatClient interface in addition to ChatClient. Streaming delivers partial response text as it is generated rather than waiting for the full response, which reduces perceived latency for long replies.
Discover streaming support via a type assertion:
client, err := chat.New(ctx, p, chat.Config{
Provider: chat.ProviderClaude,
Model: "claude-sonnet-4-6",
})
if err != nil {
return err
}
if streamer, ok := client.(chat.StreamingChatClient); ok {
result, err := streamer.StreamChat(ctx, "Write a haiku about Go.", func(e chat.StreamEvent) error {
switch e.Type {
case chat.EventTextDelta:
fmt.Print(e.Delta) // progressive output
case chat.EventComplete:
fmt.Println() // newline after stream ends
case chat.EventToolCallStart:
fmt.Printf("[calling tool: %s]\n", e.ToolCall.Name)
case chat.EventToolCallEnd:
fmt.Printf("[tool result: %s]\n", e.ToolCall.Result)
case chat.EventError:
return e.Error
}
return nil
})
if err != nil {
return err
}
_ = result // full assembled text, equal to concatenation of all EventTextDelta fragments
}
Callback contract:
- The callback is invoked synchronously for each event; it blocks the stream while executing.
- Return a non-nil error from the callback to cancel the stream โ that error is returned by
StreamChat. StreamChatreturns the complete assembled response (concatenation of allEventTextDeltafragments) regardless of whether it exited early due to a callback error.
Tool calls during streaming:
Tool calls are handled transparently inside the StreamChat ReAct loop. The callback receives EventToolCallStart when execution begins and EventToolCallEnd (with the result populated) when it completes. Config.ParallelTools and Config.MaxParallelTools are respected.
ProviderClaudeLocal does not implement StreamingChatClient. Use the type assertion pattern to handle this gracefully.
Streaming as the preferred path¶
When building components that benefit from progressive output (TUI widgets, CLI answer commands, doc generators), prefer StreamChat over Chat and fall back gracefully:
func queryAI(ctx context.Context, client chat.ChatClient, prompt string, deltaFn func(string)) (string, error) {
if streamer, ok := client.(chat.StreamingChatClient); ok {
return streamer.StreamChat(ctx, prompt, func(e chat.StreamEvent) error {
if e.Type == chat.EventTextDelta && deltaFn != nil {
deltaFn(e.Delta)
}
return nil
})
}
return client.Chat(ctx, prompt)
}
This pattern is used by pkg/docs (AskAI) and internal/generator (writeAIDocs) so that all three streaming providers benefit automatically without callers needing to know which provider is active.
Provider Reference¶
Provider Constants¶
| Constant | String Value | API Key Required |
|---|---|---|
chat.ProviderOpenAI |
"openai" |
Yes โ OPENAI_API_KEY |
chat.ProviderClaude |
"claude" |
Yes โ ANTHROPIC_API_KEY |
chat.ProviderGemini |
"gemini" |
Yes โ GEMINI_API_KEY |
chat.ProviderClaudeLocal |
"claude-local" |
No โ uses local claude binary |
chat.ProviderOpenAICompatible |
"openai-compatible" |
Backend-dependent (set via Token) |
The default provider when Config.Provider is empty (and AI_PROVIDER env var is not set) is ProviderOpenAI.
Capability Comparison¶
| Provider | Tool Calling | Parallel Tools | Structured Output | Streaming | Notes |
|---|---|---|---|---|---|
| OpenAI | โ | โ | โ JSON Schema | โ | |
| Claude | โ | โ | โ Tool-based | โ | |
| Gemini | โ | โ | โ JSON Schema | โ | |
| Claude Local | โ | โ | โ --json-schema |
โ | MCP tool support planned |
| OpenAI-Compatible | โ | โ | โ JSON Schema | โ | Backend-dependent |
ProviderClaudeLocal¶
ProviderClaudeLocal routes requests through the locally installed claude CLI binary instead of the API. This is valuable in environments where direct outbound HTTPS to api.anthropic.com is blocked but the pre-authenticated claude binary is permitted.
Requirements:
- claude binary installed and authenticated (claude login)
- Binary must be in PATH
- No Token or API key needed
client, err := chat.New(ctx, p, chat.Config{
Provider: chat.ProviderClaudeLocal,
Model: "claude-sonnet-4-6", // optional; uses claude's default if empty
SystemPrompt: "You are a helpful assistant.",
})
Multi-turn continuity is maintained via session IDs captured from the CLI's JSON output and passed via --resume on subsequent calls.
ProviderOpenAICompatible¶
Use ProviderOpenAICompatible to target any backend that exposes an OpenAI-compatible API, including Ollama, Groq, Fireworks AI, Together AI, LM Studio, and vLLM.
Requirements:
- BaseURL must be set in Config
- Model must be set (no default โ model names are backend-specific)
// Ollama (local)
client, err := chat.New(ctx, p, chat.Config{
Provider: chat.ProviderOpenAICompatible,
BaseURL: "http://localhost:11434/v1",
Model: "llama3.2",
Token: "ollama", // Ollama ignores the token; any non-empty value works
})
// Groq (cloud)
client, err := chat.New(ctx, p, chat.Config{
Provider: chat.ProviderOpenAICompatible,
BaseURL: "https://api.groq.com/openai/v1",
Model: "llama-3.3-70b-versatile",
Token: os.Getenv("GROQ_API_KEY"),
})
Token chunking falls back to cl100k_base encoding for model names not recognised by the tokenizer, so Ollama and other non-OpenAI model names are handled gracefully.
Provider Registry¶
The provider registry is open for extension. Register a custom provider from any package:
// mypackage/provider.go
func init() {
chat.RegisterProvider("my-backend", newMyBackend)
}
func newMyBackend(ctx context.Context, p *props.Props, cfg chat.Config) (chat.ChatClient, error) {
return &MyBackendClient{token: cfg.Token, baseURL: cfg.BaseURL}, nil
}
After importing your package, chat.New(ctx, p, chat.Config{Provider: "my-backend"}) routes to your factory.
Error Handling¶
The chat package normalizes errors from each provider:
- Gemini:
genai.APIErroris extracted and formatted asGemini API Error (<code>): <message>. - OpenAI / Compatible:
ResponseFormatis cleared whenChatis called so JSON schema mode does not bleed into regular chat calls. - Claude Local: subprocess
stderris captured and surfaced when theclaudebinary exits non-zero.
Error Recovery Example¶
func robustChat(ctx context.Context, p *props.Props, prompt string) (string, error) {
client, err := chat.New(ctx, p, chat.Config{
Provider: chat.ProviderClaude,
Model: "claude-sonnet-4-6",
})
if err != nil {
return "", err
}
response, err := client.Chat(ctx, prompt)
if err != nil {
p.Logger.Warn("Primary provider failed, trying fallback", "error", err)
fallback, fbErr := chat.New(ctx, p, chat.Config{
Provider: chat.ProviderOpenAI,
Model: "gpt-4o",
})
if fbErr != nil {
return "", errors.Newf("both providers failed: primary=%v, fallback=%w", err, fbErr)
}
return fallback.Chat(ctx, prompt)
}
return response, nil
}
Conversation Persistence¶
Conversation state can be saved and restored across CLI invocations using the PersistentChatClient interface. Discover it via type assertion (same pattern as StreamingChatClient):
if pc, ok := client.(chat.PersistentChatClient); ok {
snapshot, err := pc.Save()
// ... store snapshot for later
}
Supported Providers¶
| Provider | Persistence | Notes |
|---|---|---|
| Claude | Yes | Messages include system prompt as first user message |
| OpenAI | Yes | System prompt preserved as first SystemMessage |
| OpenAI-Compatible | Yes | Same as OpenAI |
| Gemini | Yes | System prompt restored to both config and history |
| ClaudeLocal | No | External subprocess โ no internal state to persist |
Saving and Restoring¶
// Save current conversation
snapshot, err := pc.Save()
// Store to filesystem (with optional encryption)
store, _ := chat.NewFileStore(afero.NewOsFs(), "~/.mytool/conversations",
chat.WithEncryption(encryptionKey), // optional, 32-byte AES-256 key
)
store.Save(ctx, snapshot)
// Later โ restore from storage
snapshot, _ := store.Load(ctx, snapshotID)
err := pc.Restore(snapshot)
// Re-register tools (handlers are not serialised)
pc.SetTools(myTools)
// Continue the conversation
response, _ := pc.Chat(ctx, "Where were we?")
Snapshot Contents¶
| Field | Included | Notes |
|---|---|---|
| Messages | Yes | Provider-specific format (opaque JSON) |
| System prompt | Yes | Restored to provider-specific location |
| Tool metadata | Yes | Name, description, parameters only |
| Tool handlers | No | Must re-register via SetTools after restore |
| API tokens | No | Security โ never persisted |
| Model name | Yes | For reference; client uses its configured model |
FileStore¶
The built-in FileStore persists snapshots as JSON files with optional AES-256-GCM encryption:
// Unencrypted
store, err := chat.NewFileStore(fs, "/path/to/conversations")
// Encrypted (key must be exactly 32 bytes)
store, err := chat.NewFileStore(fs, "/path/to/conversations",
chat.WithEncryption(key),
)
Files are written with 0600 permissions. The directory is created with 0700 if it doesn't exist.
Operations: Save, Load, List (returns summaries without loading full messages), Delete.
Provider endpoint security¶
Every call to chat.New validates Config.BaseURL before any credentials leave the process. A misconfigured endpoint fails fast with a typed error instead of sending an Authorization header to an attacker-controlled host.
Rejection rules, cheapest first:
- Length โ rejected if
len(BaseURL) > chat.MaxBaseURLLength(2 KiB). - Control characters โ any byte in
0x00โ0x1For0x7Frejected. - Parse failure โ
url.Parsemust succeed. - Userinfo โ URLs of the form
https://user:pass@host/rejected unconditionally. Put credentials inToken, not the URL. - Scheme โ must be
https. The test-onlyConfig.AllowInsecureBaseURLbool permitshttpforhttptest.Servertargets; the field is taggedjson:"-"so production config cannot enable it. - Host โ the URL must include a host.
- Placeholders โ
example.com,example.net,example.org,localhost.localdomain, and any subdomain of these, are rejected to catch scaffolding values.
ProviderOpenAICompatible additionally requires a non-empty BaseURL.
On every successful provider construction, the package logs the endpoint hostname at INFO:
Hostname only โ never the URL path or query, which may carry provider-specific identifiers.
Downstream tool authors accepting BaseURL in their own config surface should call chat.ValidateBaseURL at the boundary so misconfiguration surfaces early:
if err := chat.ValidateBaseURL(userInput, false); err != nil {
return fmt.Errorf("bad base URL: %w", err)
}
Rejections wrap chat.ErrInvalidBaseURL โ discriminate via errors.Is.
Snapshot storage security¶
FileStore refuses to touch any path built from a snapshot identifier that is not a canonical google/uuid string. Two layers of defence are applied to every Save, Load, and Delete call:
- Shape validation. The ID must match
^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$(lowercase hex, canonical 8-4-4-4-12 hyphenation). This forecloses path-traversal at the shape level โ no.., no/, no\, no NUL bytes, no Unicode lookalikes. - Path containment. After
filepath.Clean+filepath.Abs, the resolved file path is verified to lie inside the store directory viafilepath.Rel. This is defence-in-depth against future relaxation of the regex and platform-specific path quirks.
A rejected identifier returns an error wrapping the exported sentinel chat.ErrInvalidSnapshotID:
if err := store.Load(ctx, userSuppliedID); err != nil {
if errors.Is(err, chat.ErrInvalidSnapshotID) {
// user-supplied ID was not a canonical UUID
return fmt.Errorf("bad snapshot id: %w", err)
}
// otherwise it's an I/O error โ unknown snapshot, permission denied, etc.
return err
}
If your application accepts snapshot identifiers from an external source (CLI flag, HTTP handler, queue payload), validate them at the boundary via chat.ValidateSnapshotID rather than deferring the check to Save/Load/Delete:
if err := chat.ValidateSnapshotID(id); err != nil {
// reject the request before any filesystem work happens
return err
}
List is intentionally robust rather than strict: files in the store directory whose names do not match the canonical UUID shape are logged at DEBUG level (via the optional chat.WithLogger option) and skipped, so one corrupt or manually-placed file cannot break snapshot enumeration for the user.
Snapshots constructed via chat.NewSnapshot always receive a fresh uuid.New() ID, so the validator is transparent for GTB-produced snapshots.
Thread Safety¶
ChatClient implementations are not safe for concurrent use by multiple goroutines. This is consistent with Go conventions (http.Request, json.Decoder, etc.). Each goroutine should create its own client instance via chat.New().
Message history from Add() calls persists across Chat() and Ask() calls within the same client instance. To start a fresh conversation, create a new client.
Best Practices¶
- Context Management: All methods that perform I/O (
Add,Ask,Chat) acceptcontext.Contextas the first parameter. Always pass an appropriate context to ensure operations can be cancelled or timed out. - One Client Per Goroutine: Do not share a
ChatClientinstance across goroutines. Create a new client for each concurrent conversation. - System Prompts: Use
SystemPromptin the config to define the AI's persona and constraints. - Validation: Validate AI outputs before using them in critical code paths, even when using structured
Askresponses. - Token Limits: Be mindful of token limits when building conversation history; consider summarizing or truncating long sessions.
- Rate Limiting: Implement appropriate backoff when encountering rate limit errors.
- Local vs. API: Prefer
ProviderClaudeLocalonly when API access is restricted; API providers offer lower latency and full feature support.