n4nAI

Building an LLM CLI tool in Go with Cobra and Claude Opus 4.8

Step-by-step guide to building a Go CLI with Cobra that queries Claude Opus 4.8 via the Anthropic API, including runnable code and expected output.

n4n Team3 min read610 words

Audio narration

Coming soon — every post will get a voice note here.

Building a go cli cobra claude opus 4.8 tool is a practical way to wrap model calls in a scriptable interface your team can pipe into CI or local workflows. This tutorial ships a minimal but production-shaped CLI that authenticates to Anthropic, sends a prompt to claude-opus-4-8, and prints the response, using Cobra for command structure and nothing but the standard library for HTTP.

Prerequisites

  • Go 1.22 or newer installed and on PATH
  • The cobra package (go get github.com/spf13/cobra@latest)
  • An Anthropic API key exported as ANTHROPIC_API_KEY
  • Basic comfort with encoding/json and net/http
  • curl handy for sanity-checking the endpoint outside Go

If you prefer a single gateway instead of per-vendor keys, n4n.ai exposes one OpenAI-compatible endpoint that fronts Claude Opus 4.8 among 240+ models and handles provider fallback automatically. The code below targets Anthropic directly; swapping the base URL and auth header is trivial.

Initialize the module

mkdir opuscli && cd opuscli
go mod init github.com/you/opuscli
go get github.com/spf13/cobra@latest

Keep the module path consistent with your repo. Cobra pulls in pflag and yaml optionally, but we won’t need YAML for the core flow.

Root command skeleton

Create main.go with a root command that only prints help for now.

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
	Use:   "opuscli",
	Short: "CLI for querying Claude Opus 4.8",
	Long:  "A minimal Go CLI built with Cobra to call claude-opus-4-8 via Anthropic.",
}

func execute() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func main() {
	execute()
}

Build and run to confirm Cobra is wired:

go build -o opuscli .
./opuscli --help

Expected output includes the Usage: opuscli block and the Flags: --help line. If you see command not found, check your go bin path or run ./opuscli from the project root.

Add the ask command

The core of our go cli cobra claude opus 4.8 is the ask subcommand. It takes exactly one positional argument (the prompt) and sends it to the model.

var askCmd = &cobra.Command{
	Use:   "ask [prompt]",
	Short: "Send a prompt to Claude Opus 4.8",
	Args:  cobra.ExactArgs(1),
	RunE:  runAsk,
}

func init() {
	rootCmd.AddCommand(askCmd)
}

We separate RunE to return errors cleanly. Cobra maps a non-nil error to a non-zero exit code via our execute() wrapper.

Implement the API call

Anthropic’s Messages API expects a JSON body. Here is the exact shape we send:

{
  "model": "claude-opus-4-8",
  "max_tokens": 1024,
  "messages": [
    {"role": "user", "content": "Explain rate limiting in one sentence."}
  ]
}

The Go function that performs the POST uses only stdlib:

func runAsk(cmd *cobra.Command, args []string) error {
	apiKey := os.Getenv("ANTHROPIC_API_KEY")
	if apiKey == "" {
		return fmt.Errorf("ANTHROPIC_API_KEY not set")
	}

	body := map[string]interface{}{
		"model":      "claude-opus-4-8",
		"max_tokens": 1024,
		"messages": []map[string]string{
			{"role": "user", "content": args[0]},
		},
	}
	buf, _ := json.Marshal(body)

	req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(buf))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("anthropic-version", "2023-06-01")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("api error %d: %s", resp.StatusCode, respBody)
	}

	var out struct {
		Content []struct {
			Text string `json:"text"`
		} `json:"content"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return err
	}
	for _, c := range out.Content {
		fmt.Println(c.Text)
	}
	return nil
}

Note the anthropic-version header—Anthropic requires it on every request. The response content array holds generated text blocks; we print each.

Add the missing imports to the top of the file:

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/spf13/cobra"
)

Run the tool

Export your key and fire a prompt:

export ANTHROPIC_API_KEY=sk-ant-...
./opuscli ask "What is the difference between TCP and UDP in one line?"

Expected output is a single sentence from Claude Opus 4.8, for example:

TCP provides ordered, reliable delivery with connection setup, while UDP sends datagrams without guarantees for speed.

If the key is missing, you get ANTHROPIC_API_KEY not set on stderr and exit code 1. If you typed the model name wrong, Anthropic returns 404 with a JSON error; our code prints api error 404: ....

Persist configuration with flags

Hard-coding env reliance is fine, but a real go cli cobra claude opus 4.8 should accept a --key flag and maybe --max-tokens. Extend askCmd:

var (
	flagKey       string
	flagMaxTokens int
)

func init() {
	askCmd.Flags().StringVar(&flagKey, "key", "", "Anthropic API key (overrides env)")
	askCmd.Flags().IntVar(&flagMaxTokens, "max-tokens", 1024, "Max tokens to generate")
	rootCmd.AddCommand(askCmd)
}

Then modify runAsk to prefer the flag:

apiKey := flagKey
if apiKey == "" {
	apiKey = os.Getenv("ANTHROPIC_API_KEY")
}

And use flagMaxTokens in the request body instead of the literal 1024. This takes five minutes and makes the CLI usable in scripts where env injection is annoying.

Full source listing

For reference, the complete main.go after the above edits:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
	Use:   "opuscli",
	Short: "CLI for querying Claude Opus 4.8",
}

var askCmd = &cobra.Command{
	Use:   "ask [prompt]",
	Short: "Send a prompt to Claude Opus 4.8",
	Args:  cobra.ExactArgs(1),
	RunE:  runAsk,
}

var (
	flagKey       string
	flagMaxTokens int
)

func init() {
	askCmd.Flags().StringVar(&flagKey, "key", "", "Anthropic API key (overrides env)")
	askCmd.Flags().IntVar(&flagMaxTokens, "max-tokens", 1024, "Max tokens to generate")
	rootCmd.AddCommand(askCmd)
}

func runAsk(cmd *cobra.Command, args []string) error {
	apiKey := flagKey
	if apiKey == "" {
		apiKey = os.Getenv("ANTHROPIC_API_KEY")
	}
	if apiKey == "" {
		return fmt.Errorf("ANTHROPIC_API_KEY not set")
	}

	body := map[string]interface{}{
		"model":      "claude-opus-4-8",
		"max_tokens": flagMaxTokens,
		"messages": []map[string]string{
			{"role": "user", "content": args[0]},
		},
	}
	buf, _ := json.Marshal(body)

	req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(buf))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("anthropic-version", "2023-06-01")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("api error %d: %s", resp.StatusCode, respBody)
	}

	var out struct {
		Content []struct {
			Text string `json:"text"`
		} `json:"content"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return err
	}
	for _, c := range out.Content {
		fmt.Println(c.Text)
	}
	return nil
}

func execute() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func main() {
	execute()
}

Testing the CLI with httptest

Before shipping, stub the API with net/http/httptest to avoid burning tokens in unit tests:

// in a ask_test.go file
func TestRunAsk(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"content":[{"text":"ok"}]}`)
	}))
	defer srv.Close()
	// override the URL via a package-level var or flag in real code
}

This pattern keeps your go cli cobra claude opus 4.8 logic covered without network dependence.

Swapping to a gateway

If you route through a gateway, change the POST URL and auth scheme. For an OpenAI-compatible front, the request body uses "model": "claude-opus-4-8" and Bearer auth instead of x-api-key. The Cobra surface stays identical; only runAsk transports differ.

Why Cobra

Cobra gives you --help, subcommands, and flag binding with near-zero boilerplate. For a go cli cobra claude opus 4.8, it means you spend time on the API contract, not on arg parsing. The alternative—hand-rolling flag.FlagSet—is fine for one command but falls apart when you add ask, stream, models, etc.

Final notes

Set a client timeout before shipping:

client := &http.Client{Timeout: 30 * time.Second}

Otherwise a stalled connection hangs your pipeline. Build the binary, drop it in /usr/local/bin, and you have a reusable model call from any shell script. The pattern—Cobra for surface, stdlib for transport—scales to any model endpoint without dragging in heavy SDKs.

Tagsgocobraclaude-opus-4-8cli

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building cli tools for llm apis posts →