Go stdlib net/http to OpenRouter

Welcome. I am Param, and for the next few hours we are building a real coding agent in Go. No SDKs, no framework, no clever abstractions. Just net/http, encoding/json, and an OpenAI-compatible endpoint. By the end you will have a static binary that reads files, writes files, and runs shell commands on your behalf.

Why Go for an agent? The binary is a single file with no runtime. The standard library already has everything an agent needs, HTTP, JSON, subprocess, timeouts. And Go forces you to make the wire format obvious, which is exactly what you want when the whole point is to see how tool calling actually works.

terminal
bash
# Clone the workshop repository
git clone https://github.com/learnwithparam/coding-agents-from-scratch-go.git
cd coding-agents-from-scratch-go

# Copy the env template and paste your OpenRouter key
make setup

# Start the REPL
make run

make setup copies .env.example to .env and runs go mod tidy. make run starts the agent REPL.

go.mod
go
module github.com/learnwithparam/coding-agents-from-scratch-go

go 1.22

Notice what is missing here. No require block. The whole agent runs on the Go standard library.

agent/llm.go
go
type Message struct {
	Role       string     `json:"role"`
	Content    string     `json:"content,omitempty"`
	ToolCalls  []ToolCall `json:"tool_calls,omitempty"`
	ToolCallID string     `json:"tool_call_id,omitempty"`
	Name       string     `json:"name,omitempty"`
}

type ChatRequest struct {
	Model    string     `json:"model"`
	Messages []Message  `json:"messages"`
	Tools    []ToolSpec `json:"tools,omitempty"`
}

type ChatResponse struct {
	Choices []struct {
		Index        int     `json:"index"`
		Message      Message `json:"message"`
		FinishReason string  `json:"finish_reason"`
	} `json:"choices"`
}

These structs match the OpenAI-compatible chat completions wire format. omitempty is critical so tool-only fields do not leak when Content is the only thing set.

agent/llm.go
go
func (c *OpenRouterClient) Chat(req ChatRequest) (*ChatResponse, error) {
	if req.Model == "" {
		req.Model = c.Model
	}
	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}
	httpReq, err := http.NewRequest("POST", c.BaseURL+"/chat/completions", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)

	resp, err := c.HTTP.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("http: %w", err)
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	var out ChatResponse
	return &out, json.Unmarshal(raw, &out)
}

json.Marshal encodes the request, http.NewRequest targets /chat/completions, and http.Client.Do sends it. No SDK. You can see every byte on the wire.

OpenRouter is OpenAI-compatible on the wire, so the same Go code works against dozens of models from different vendors. You can default to a small cheap model like Gemma 3 12B while iterating, then switch to Claude or GPT-4o by changing one environment variable. If you want to point at OpenAI directly later, you change BaseURL and keep everything else.

Quiz: Quiz

Loading practiceโ€ฆ

Validation checklist: Environment setup checklist

Loading practiceโ€ฆ