TL;DR: When a CLI tool needs to talk to an LLM, the instinct is to reach for a framework like LangChain. For most CLI work that is far too much machinery. go-tool-base’s chat package is the deliberate opposite: a thin, purpose-built abstraction whose entire core interface is four methods, Add, Chat, Ask and SetTools. It was settled on only after LangChain Go and a dozen others were evaluated and found not to fit. It is right-sized: big enough to hide real provider differences, small enough that the whole interface fits on one screen.
The instinct, and why it overshoots
When you add AI to a tool, the instinct is to reach for the big general-purpose framework. LangChain and its relatives are capable and they exist for a real need: orchestrating complex multi-step AI applications, with retrieval pipelines, memory stores, chains of calls, fleets of agents.
Now look at what a CLI tool actually needs from an LLM. It needs to send a prompt and get text back. Sometimes it wants structured data back instead of prose. Sometimes it wants to let the model call a few of the tool’s own functions. That is close to the whole list.
Pulling in a framework built to orchestrate retrieval and agent swarms, in order to do that, is a poor trade. You take on a large new vocabulary of concepts, a wide dependency surface, and a great deal of abstraction you will never touch, all to perform three or four operations. The framework isn’t wrong. It is just answering a much bigger question than the one a CLI tool is asking.
What go-tool-base chose instead
go-tool-base didn’t reach for a framework. The decision is on the record in its own design notes: before a single line was written, LangChain Go, go-openai, vercel’s AI SDK and around ten other options were evaluated, and none of them matched what a CLI framework actually needs. So the chat package was built deliberately small.
How small? The entire core ChatClient interface is four methods:
type ChatClient interface {
Add(prompt string) error
Chat(ctx context.Context, prompt string) (string, error)
Ask(question string, target any) error
SetTools(tools []Tool) error
}
Add appends a message to the conversation. Chat sends a prompt and returns text. Ask sends a prompt and returns a typed Go struct, the model’s answer unmarshalled straight into a value you defined. SetTools hands the model a set of your own functions it is allowed to call. That is the whole surface. Downstream code that uses AI never holds anything larger than this, and never has to know which provider is behind it.
The package’s own documentation has a word for this: right-sized. Large enough to solve genuine provider-abstraction complexity, small enough that the full interface fits on a single screen.
“Thin” is not the same as “does little”
This is the part worth being precise about, because “four methods” can sound like “barely does anything,” and that is the wrong read.
Behind those four methods sits genuinely awkward work. Five providers, OpenAI, Claude, Gemini, a locally installed claude binary, and any OpenAI-compatible endpoint, each with a different wire API, all normalised behind the one interface. A tool-calling loop. Structured output via JSON Schema, made to behave consistently across providers that each express it differently. Error normalisation. Token chunking.
The point of a thin abstraction is not that there is little underneath it. It is that the interface stays small while the implementation absorbs the complexity. Four methods on the surface; five provider integrations and a tool-calling loop below the waterline. The thinness is a property of what the caller sees, not of what the package does. A reach-for-LangChain decision gets that backwards: it exposes the caller to all the machinery, whether or not the caller needs it.
The core stays small even as features grow
There is a neat detail in how chat keeps the interface from creeping. The package also supports streaming responses and conversation persistence, both of which are real features with real surface area. Neither of them is in the four-method core.
Instead they are separate, optional interfaces. A streaming-capable client also satisfies StreamingChatClient; a persistable one also satisfies PersistentChatClient. Code that wants those capabilities does a type assertion to ask for them, and code that doesn’t simply never sees them. So the common path stays four methods forever. New capabilities arrive as opt-in interfaces alongside the core, not as new methods bolted onto it. The thing that fits on one screen keeps fitting on one screen.
Extensible without forking, testable without a network
Two more properties keep the package small without making it limiting.
It is extensible. The provider list is not closed. A RegisterProvider call lets any package contribute a new provider, and chat.New will route to it. You add a backend without forking pkg/chat or sending a patch upstream.
And it is testable. The package ships generated mocks. A downstream tool’s AI features can be tested against a mock ChatClient returning canned responses, with no network, no API key, and no flakiness. Because the interface is four methods, that mock is trivial to set up and complete by construction. A sprawling framework interface is a sprawling thing to fake; a four-method one is not.
Boiling it down
When a CLI tool needs AI, the instinct is a large framework like LangChain. For orchestrating retrieval pipelines and agent swarms, that is the right tool. For sending a prompt, getting a struct back, and letting the model call a few functions, it is enormous overkill.
go-tool-base’s chat package is the deliberate alternative, chosen only after LangChain Go and a dozen others were evaluated and rejected. Its core ChatClient interface is four methods. Underneath sit five normalised providers, a tool-calling loop, structured output and error handling, but the caller sees four methods and never learns which provider is active. Streaming and persistence are opt-in interfaces beside the core, not additions to it. It extends without forking and tests without a network. Right-sized: the complexity is real, but it lives under the interface rather than in it.