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.
# 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 runmake setup copies .env.example to .env and runs go mod tidy. make run starts the agent REPL.
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.
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โฆ