Skip to main content
  1. Articles/

LLM and Go: Anthropic Integration via Messages API

·2757 words·13 mins· loading · loading ·
Marko Milojevic
Author
Marko Milojevic
Software engineer and architect. Golang and LLM enthusiast. Awful chess player, gym rat, harmonica newbie and cat lover.
LLM and Go - This article is part of a series.
Part 4: This Article

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.

Useful Resources
#

LLM and Go - This article is part of a series.
Part 4: This Article

Related

LLM and Go: OpenAI Integration via Chat Completions API

·2721 words·13 mins· loading · loading
For most of my career, integrating external intelligence into an application meant calling a rules engine, training a custom classifier, or encoding business logic that someone had painfully documented in a spreadsheet. The idea that I could describe a task in plain language and have a model respond with genuine reasoning was not something I expected to become production-ready in my working life. Then GPT happened, and it changed what backend developers need to know. This article is the first in a series on using LLMs in Go. We start with the OpenAI Chat Completions API — the stateless, request-based interface that gives you direct control over every aspect of the conversation. By the end, you will have a working conversational agent that can call external tools to answer questions it otherwise could not. A short introduction to ChatGPT and OpenAI # 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. ChatGPT is OpenAI’s conversational product built on the GPT model series. What set it apart from raw GPT-3 was the addition of reinforcement learning from human feedback (RLHF) — a technique that fine-tunes the base model to follow instructions and produce responses that human raters judge as helpful and safe. When ChatGPT launched in late 2022, it became one of the fastest-adopted consumer products in history. For developers, the more relevant artefact is the API behind it — specifically the Chat Completions API, which gives programmatic access to the same models powering the product.

LLM and Go: Investigating OpenAI Chat Completions API

·2132 words·11 mins· loading · loading
In the previous article I covered the fundamentals of the Chat Completions API: setting up a client, maintaining conversation history, and integrating tools. That was enough to build a working conversational agent. This article goes a level deeper — into the API parameters that shape what the model returns and how it thinks. Two parameters stand out as particularly useful in production: response_format and reasoning_effort. The first gives you control over the structure of the model’s output. The second controls how much the model reasons before responding — which turns out to matter more than you might expect once you start caring about latency and cost. Chat Completions API details # The Chat Completions API endpoint accepts a rich set of parameters. Most have sensible defaults and you will rarely touch them, but understanding what is available saves you from reaching for workarounds that already exist in the API. The table below covers the current non-deprecated parameters from the API reference: Parameter Type Description model string ID of the model to use messages array Conversation history as an ordered list of messages response_format object Output format: text, json_object, or json_schema reasoning_effort string Reasoning intensity for reasoning models: low, medium, high temperature number Sampling temperature from 0 to 2; higher values produce more random output top_p number Alternative to temperature; nucleus sampling probability mass max_completion_tokens integer Maximum tokens the model may generate in the response n integer Number of completion choices to return stream boolean Stream partial responses as server-sent events stop string/array Sequences at which the API stops generating presence_penalty number Penalises new tokens based on whether they appear in the text so far frequency_penalty number Penalises new tokens based on their frequency in the text so far tools array List of tools (functions) the model may call tool_choice string/object Controls which tool the model calls seed integer Seed for deterministic sampling user string Unique identifier for the end user In this article we focus on response_format and reasoning_effort — two parameters with a direct, visible impact on production systems. Information extraction with response_format # The response_format parameter controls how the model structures its output. The default is plain text. Setting it to json_object tells the model to return valid JSON, but gives you no control over the schema. Setting it to json_schema goes further: you provide a JSON Schema document and the model guarantees its output will conform to it. OpenAI calls this structured output.

LLM and Go: OpenAI integration via Responses API

·2365 words·12 mins· loading · loading
The previous two articles in this series covered the Chat Completions API — how to set up a client, maintain conversation history manually, call external tools, and control output structure with response_format. That API gives you full control and a clear mental model of what goes over the wire. This article covers the other primary OpenAI interface: the Responses API. The Responses API moves conversation state from the client to OpenAI’s servers. You no longer maintain a history slice and re-send it with every call. Instead, you track a response ID and pass it back on the next request. That is a meaningful shift for agent-oriented applications — less maintenance, but also less transparency. Understanding the trade-offs between the two APIs is worth doing before choosing which one to build on. Responses API # OpenAI introduced the Responses API in 2025, positioning it as the foundation for building agents. The Chat Completions API is stateless — every request must carry the full conversation history, and the client owns that state entirely. The Responses API inverts this: conversation state lives on OpenAI’s servers, and you reference previous turns by ID rather than re-sending them. Both APIs give you access to the same underlying models and tool-calling mechanics. The difference is where the orchestration responsibility sits. The table below, first introduced in the Chat Completions API article, summarizes the trade-offs: Feature Chat Completions API Responses API Conversation state Client-managed Server-managed History management Manual — sent with every request Automatic Tool support Manual function calling Built-in tools (web search, code interpreter) Streaming Yes Yes Control Full Limited Vendor coupling Low Higher Best for Custom agents, full control Rapid prototyping, built-in tooling The Chat Completions API is the right default when you want to control exactly what the model sees and when you need portability across providers. The Responses API reduces boilerplate and fits well when you want to prototype quickly or lean on OpenAI’s managed tooling. In this article we build the same conversational agent we built before — but with the Responses API driving state management. First AI agent # The openai-go SDK covers both APIs under one package. The same client initialization you used for Chat Completions works here. To call the API, you need a secret key from platform.openai.com — navigate to the API Keys section, generate a key, and store it in an environment variable.