The previous two articles in this series covered OpenAI’s side of this problem: Chat Completions, where the client owns the entire conversation history, and Responses, where OpenAI’s servers do. Anthropic’s Claude models are built on a third set of API decisions — close enough to Chat Completions in shape that the same agent design carries over almost directly, but different enough in the details, like how the system prompt is passed and how a response is structured, that it is worth building the same agent again to see exactly where.
This article rebuilds that agent on Anthropic’s Messages API — the stateless, request-based interface behind Claude. By the end, you will have a working conversational agent that can call external tools to answer questions it otherwise could not, and a clear picture of what changes when you swap providers.
A short introduction to Claude and Anthropic #
The path to large language models runs through a decade of incremental progress in deep learning. Early models like word2vec and GloVe learned to embed words into dense vector spaces, capturing semantic relationships between terms. The transformer architecture, introduced by Google in 2017, changed the trajectory of the field — it processes sequences in parallel using attention mechanisms that capture long-range dependencies far more effectively than recurrent networks. This architectural shift made it practical to train models on orders of magnitude more data. GPT-1 in 2018 showed that large-scale unsupervised pre-training followed by fine-tuning could match or beat purpose-built models across a range of language tasks.
Understanding what these models actually do removes a lot of the mysticism around them. An LLM is, at its core, a next-token predictor. It takes a sequence of tokens as input and outputs a probability distribution over the vocabulary for the next token. The transformer’s attention mechanism allows every token in the input to attend to every other token, building a rich contextual representation before making that prediction. Training adjusts billions of parameters to minimise prediction error across enormous text corpora. What emerges is a model with broad world knowledge encoded in its weights — not because it was taught facts directly, but because predicting text well requires internalising the structure of the world that produced that text.
Claude is Anthropic’s model family, and the company itself was founded in 2021 by a group of former OpenAI researchers, including Dario Amodei and Daniela Amodei, with AI safety as its founding focus. What differentiates Claude’s training pipeline from a pure RLHF approach is Constitutional AI — a technique where the model critiques and revises its own outputs against a written set of principles, rather than relying solely on human raters to judge every response. The goal is a model that behaves predictably even in situations no human rater explicitly labelled. For developers, the way to reach any Claude model programmatically is the Messages API — the stateless, request-based interface used throughout this article.
First AI agent #
The official Go SDK for the Anthropic API is anthropic-sdk-go,
maintained by Anthropic directly. It provides typed bindings for the Messages API and handles authentication, serialisation,
and retries. For this series, we use it exclusively — no third-party wrappers.
To call the API, you need a secret key from Anthropic. Create an account at console.anthropic.com, navigate to the API Keys section, and generate a new key. Store it in an environment variable and never commit it to version control.
package main
import (
// ..
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
)
var history []anthropic.MessageParam
var client anthropic.Client
func main() {
apiKey := os.Getenv("ANTHROPIC_API_KEY")
if apiKey == "" {
fmt.Fprintln(os.Stderr, "error: ANTHROPIC_API_KEY environment variable is not set")
os.Exit(1)
}
client = anthropic.NewClient(option.WithAPIKey(apiKey))
}Two things are initialised here: the API client and the conversation history. The client is created with the API key read
from the environment — if the variable is missing, the application exits immediately rather than failing later with a
cryptic authentication error. The history slice is the conversation state. The Messages API is stateless: every request
must include the full conversation history so the model has the context it needs to respond correctly. This slice is
where we maintain it. Unlike some other providers, Anthropic does not treat the system prompt as part of that message
history — it is passed as a dedicated System parameter on every request instead, which we wire up next.
import (
"bufio"
// ...
)
func talkToAgent() (string, error) {
return "", nil
}
func main() {
// ...
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("AI assistant ready. Type your question or 'exit' to quit.")
fmt.Println()
for {
fmt.Print("Human: ")
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
continue
}
if strings.EqualFold(input, "exit") {
fmt.Println("Bye.")
break
}
history = append(history, anthropic.NewUserMessage(anthropic.NewTextBlock(input)))
answer, err := talkToAgent()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
continue
}
fmt.Printf("Agent: %s\n\n", answer)
}
}The main loop reads from standard input using bufio.Scanner, which
handles line-by-line reading cleanly. Empty inputs are skipped. Typing exit terminates the application. For any other
input, the user’s message is appended to the history as a UserMessage and passed to talkToAgent. The result is printed
back to the terminal. At this stage, talkToAgent is a stub that returns nothing — we fill it in next.
func talkToAgent() (string, error) {
resp, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5,
MaxTokens: 1024,
System: []anthropic.TextBlockParam{
{Text: "You are a helpful assistant."},
},
Messages: history,
})
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}
history = append(history, resp.ToParam())
var text strings.Builder
for _, block := range resp.Content {
if variant, ok := block.AsAny().(anthropic.TextBlock); ok {
text.WriteString(variant.Text)
}
}
return text.String(), nil
}Here Messages.New sends the full history to the model, along with the system prompt and a MaxTokens limit — required
on every Anthropic request, since there is no default cap. The Model field specifies which model to use — here we use
claude-haiku-4-5 to keep costs low during development. Unlike a Chat Completions-style response, the Messages API returns
a Content slice of content blocks rather than a list of choices; a single response can contain multiple blocks (text,
tool calls, and more), so we iterate over resp.Content and concatenate every TextBlock we find. As before, the important
step is calling ToParam() on the response before appending it to the history, so the model will remember what it said in
subsequent turns.
The three Claude models cover different points on the cost-quality spectrum:
| Model | Input cost (per 1M tokens) | Output cost (per 1M tokens) | Best for |
|---|---|---|---|
| claude-opus-5 | $5.00 | $25.00 | Complex reasoning, production-grade agentic work |
| claude-sonnet-5 | $3.00 | $15.00 | Balanced cost and quality |
| claude-haiku-4-5 | $1.00 | $5.00 | High-volume, low-complexity tasks |
For development and experimentation, claude-haiku-4-5 is the right starting point. The quality is sufficient for most
tasks, and the cost difference against claude-opus-5 is significant at scale.
AI assistant ready. Type your question or 'exit' to quit.
Human: hi
Agent: # Hey there! 👋
How's it going? What can I help you with today?
Human: what is my name?
Agent: I don't have any information about your name. We just started chatting, and I don't have access to personal information about you unless you share it with me.
Feel free to tell me your name if you'd like! 😊
Human: my name is Marko
Agent: Nice to meet you, Marko! 👋
Thanks for letting me know. How can I help you today?
Human: what is my name?
Agent: Your name is Marko! You told me that just a moment ago. 😊
Is there something I can help you with?The key thing to notice in this exchange is that the agent remembers the user’s name. That is not magic — it is the history slice doing its job. Every time a user sends a message and the agent replies, both messages are appended to the history. On the next request, the full history is sent to the model, giving it complete context. Without that history, the model would have no idea who Marko (me!) is.
First tool integration #
Even with full conversation history, the model has a fundamental limitation: it cannot access real-time information. Ask it for the current time, and it will tell you so.
Human: what is the current time in Frankfurt?
Agent: I don't have access to real-time information, so I can't tell you the exact current time in Frankfurt right now.
However, I can help you find it:
- **Frankfurt is in the Central European Time (CET) zone**
- In winter, it's UTC+1
- In summer (daylight saving time), it's UTC+2
You can check the current time in Frankfurt by:
- Searching "current time in Frankfurt" online
- Checking a world clock website
- Using your phone's world clock feature
Is there anything else I can help you with?This is where tools come in. A tool is a function that the model can request to be called on its behalf. You define the tool — its name, description, and parameter schema — and send those definitions alongside the conversation history. When the model determines it needs information it cannot generate from its weights alone, it responds not with text but with a list of tool calls it wants executed. Your application runs those calls, sends the results back, and the model incorporates them into its final response. The model never executes code directly; it only requests calls and receives results.
The integration process has a clear shape: send the tool definitions with every request, check whether the response contains tool calls, execute each call, append the results to the history as tool messages, and send another request. Repeat until the model returns a plain assistant message with no tool calls. This loop is what makes the agent able to use multiple tools in sequence when needed.
The first step is building the function the tool will call.
func getCurrentTime(timezone string) (string, error) {
location, err := time.LoadLocation(timezone)
if err != nil {
return "", fmt.Errorf("failed to load location: %w", err)
}
now := time.Now().In(location)
return now.Format("2006-01-02 15:04:05"), nil
}This is a plain Go function with no awareness of the LLM. It takes a timezone variable, loads the location, and returns the current time formatted as a string. Keeping tool implementations as ordinary functions is intentional — it keeps them testable and reusable outside the agent context.
With the function in place, we need to describe it to the model and update the request loop.
var getTimeToolDefinition = anthropic.ToolParam{
Name: "get_time",
Description: anthropic.String("Fetch the current time in a given timezone."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"timezone": map[string]any{
"type": "string",
"description": "Tne international timezone name, e.g. America/Los_Angeles.",
},
},
Required: []string{"timezone"},
ExtraFields: map[string]any{
"additionalProperties": false,
},
},
}
func talkToAgent() (string, error) {
for {
resp, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5,
MaxTokens: 1024,
System: []anthropic.TextBlockParam{
{Text: "You are a helpful time assistant. " +
"When asked about time at particular locations, " +
"always use the get_time tool to get current time in a given timezone. " +
"Never guess the time."},
},
Messages: history,
Tools: []anthropic.ToolUnionParam{
{OfTool: &getTimeToolDefinition},
},
})
// ...
}
}The tool definition is a struct that describes the function to the model. The Name field is what the model uses when
it requests a call. The Description is critical — it is what the model reads to decide whether to use this tool at
all, so it should be precise. The InputSchema block follows the JSON Schema format and tells the model exactly what
arguments to provide.
The request loop in talkToAgent is now wrapped in a for loop because a single user message may require multiple
round-trips: the model calls a tool, you return the result, and the model may call another tool before finally producing its answer.
func callTool(block anthropic.ToolUseBlock) (string, error) {
// ...
}
func talkToAgent() (string, error) {
for {
resp, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5,
MaxTokens: 1024,
System: []anthropic.TextBlockParam{
{Text: "You are a helpful time assistant. " +
"When asked about time at particular locations, " +
"always use the get_time tool to get current time in a given timezone. " +
"Never guess the time."},
},
Messages: history,
Tools: []anthropic.ToolUnionParam{
{OfTool: &getTimeToolDefinition},
},
})
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}
history = append(history, resp.ToParam())
var text strings.Builder
var toolResults []anthropic.ContentBlockParamUnion
for _, block := range resp.Content {
switch variant := block.AsAny().(type) {
case anthropic.TextBlock:
text.WriteString(variant.Text)
case anthropic.ToolUseBlock:
fmt.Printf(" [tool] %s(%s)\n", variant.Name, string(variant.Input))
result, isError := callTool(variant)
toolResults = append(toolResults, anthropic.NewToolResultBlock(variant.ID, result, isError))
}
}
if resp.StopReason != anthropic.StopReasonToolUse {
return text.String(), nil
}
history = append(history, anthropic.NewUserMessage(toolResults...))
}
}When the model returns a response whose StopReason is not StopReasonToolUse, we return the accumulated text directly
to the caller. When it does contain tool calls, we iterate over the content blocks, print each ToolUseBlock for
visibility, execute it via callTool, and collect the result into a ToolResultBlock tied to the call’s ID via
NewToolResultBlock. The model uses that ID to match results back to the requests it made. The loop then fires another
request with the updated history, and the cycle continues until the model is satisfied.
The system prompt now explicitly instructs the model to use the get_time tool whenever time at a specific location
is requested. Without this instruction, the model might attempt to reason from its training data and produce a plausible
but wrong answer. The instruction removes the ambiguity, especially by saying that the model should never guess the time
itself.
func callTool(block anthropic.ToolUseBlock) (string, error) {
switch block.Name {
case "get_time":
var args timeArgs
if err := json.Unmarshal(block.Input, &args); err != nil {
errMsg, _ := json.Marshal(map[string]string{"error": "failed to parse tool arguments: " + err.Error()})
return string(errMsg), true
}
currentTime, err := getCurrentTime(args.Timezone)
if err != nil {
errMsg, _ := json.Marshal(map[string]string{"error": err.Error()})
return string(errMsg), true
}
out, err := json.Marshal(timeResult{Time: currentTime})
if err != nil {
errMsg, _ := json.Marshal(map[string]string{"error": "failed to marshal tool result: " + err.Error()})
return string(errMsg), true
}
return string(out), false
default:
errMsg, _ := json.Marshal(map[string]string{"error": fmt.Sprintf("unknown tool: %q", block.Name)})
return string(errMsg), true
}
}The function callTool is a router. It receives the tool call struct from the model, switches on the function name,
and dispatches to the appropriate implementation. For get_time, it unmarshals the JSON arguments the model provided,
calls getCurrentTime, and serialises the result back to JSON. If the tool execution fails — for example, an invalid
timezone — the error is returned as a JSON payload rather than bubbling up to the caller. This keeps the loop running:
the model receives the error, understands what went wrong, and can either try a corrected call or inform the user
gracefully. Any tool name the application does not recognise returns an error, which surfaces cleanly through the loop.
Time assistant ready. Type your question or 'exit' to quit.
Human: hi
Agent: Hello! 👋 I'm a helpful time assistant. I can help you find out what time it is in different locations around the world.
Just ask me about the current time in any timezone or city, and I'll get you the accurate information. For example, you could ask:
- "What time is it in New York?"
- "What's the current time in Tokyo?"
- "Tell me the time in London and Paris"
What would you like to know?
Human: what is the current time in Frankfurt?
[tool] get_time({"timezone":"Europe/Berlin"})
Agent: The current time in Frankfurt is **21:23:57** (9:23:57 PM) on August 7, 2026.
Frankfurt is in the Europe/Berlin timezone, which is Central European Time (CET) in winter or Central European Summer Time (CEST) in summer.The [tool] line shows the model calling get_time with the correct IANA timezone for Frankfurt. The model resolved
“Frankfurt” to Europe/Berlin on its own, based on the tool description and its world knowledge. The full source for
this example is available at llm-and-golang-examples.
Conclusion #
The Messages API is the right starting point for anyone building Claude-powered features in Go. It is explicit, stateless, and gives you full control over what the model sees on every request. The history management is your responsibility, but that also means you understand exactly what is happening in every request. Adding tools extends the model’s reach into live data without complicating the core loop much — define the tool, check the stop reason, execute, return results, repeat. This pattern scales to multiple tools cleanly. The next article in this series will continue exploring practical LLM integration patterns in Go.