# Go Agent

> Scaffold a complete Go agent using the OpenAI-compatible SDK against Ollama. Covers structured responses, tools, required in-session conversation history for multi-turn behavior, optional persistent memory, and optional Large Language Platform observability.

- **Type:** Skill
- **Install:** `agentstack add skill-llpsdk-agent-skills-go-agent`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [llpsdk](https://agentstack.voostack.com/s/llpsdk)
- **Installs:** 0
- **Category:** [AI & ML](https://agentstack.voostack.com/c/ai-and-ml)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [llpsdk](https://github.com/llpsdk)
- **Source:** https://github.com/llpsdk/agent-skills/tree/main/go-agent

## Install

```sh
agentstack add skill-llpsdk-agent-skills-go-agent
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Go Agent

Scaffold a complete, runnable Go agent using the OpenAI-compatible SDK against Ollama.

## When to use

- When building an agent in Go against an Ollama endpoint using the OpenAI-compatible SDK.
- When you want structured responses, tool calling, and multi-turn behavior with in-session conversation history and minimal dependencies.

---

## Instructions

### Step 1 — Gather requirements

Ask only for the minimum needed to scaffold a useful first version. Do not block on optional details.

**Required:**
1. **Agent name** — snake_case (e.g. `loan_advisor`, `code_reviewer`)
2. **Domain** — one sentence describing what the agent specialises in
3. **Tools** — list the tools this agent needs. For each: name, description, inputs, return value. If the agent needs no tools, require the user to confirm that explicitly.

**Optional:**
4. **Model** — default: `gpt-oss:120b`
5. **Response schema** — what structured data should the agent return? Default: derive from domain.
6. **Persistent memory** — persist memory across sessions? Default: no.
7. **Large Language Platform observability** — include Large Language Platform connectivity? Default: no.

If the user does not specify an optional item, proceed with the default and note the assumption briefly. Do not proceed until the required tools input is provided. An explicit "no tools" answer satisfies that requirement and should produce an agent with no tool definitions or tool loop configuration.

---

### Step 2 — Core pattern

Every Go agent follows this pattern:

```
User message → buildPrompt() → LLM call → [tool loop, manual] → parseResponse() → format → reply
```

Go requires a **manual tool loop** — check the stop reason after each LLM call, execute any requested tools, feed results back, and repeat until the model produces a final text response.

#### A. Structured responses

Define a Go struct for the response. Use `json` tags with `omitempty` for optional fields. Validate required fields manually after unmarshalling.

```go
// Example only — design your own struct to fit the domain
type AgentResponse struct {
    Type            string   `json:"type"`
    Category        string   `json:"category,omitempty"`
    Recommendation  string   `json:"recommendation,omitempty"`
    Considerations  []string `json:"considerations,omitempty"`
    Reason          string   `json:"reason,omitempty"`
}

func validateResponse(r *AgentResponse) error {
    if r.Type == "" {
        return fmt.Errorf("missing required field: type")
    }
    return nil
}
```

Valid `type` values must include at minimum:
- A primary response type (e.g. `"analysis"`) with domain-specific fields
- `"capabilities"` — for "what can you do?" questions
- `"decline"` — for out-of-scope questions

#### B. System prompt

The system prompt must:
- Instruct the LLM to return ONLY valid JSON matching the struct
- Show the exact JSON format for every response type
- Define the agent's expertise areas and categories
- State behavioural rules (what the agent will and won't do)

#### C. Tools

Tools are functions the LLM requests by name. In Go, you execute them manually in a loop.

Design principles:
- One tool, one responsibility
- Return a plain string — the LLM reads tool output as text
- **Catch all errors inside `dispatchTool`** and return a graceful error string — never return an error to the LLM call loop

```go
func dispatchTool(name string, input json.RawMessage) string {
    switch name {
    case "tool_name":
        var args struct {
            Param string `json:"param"`
        }
        if err := json.Unmarshal(input, &args); err != nil {
            return fmt.Sprintf("Tool error: %v", err)
        }
        result, err := myTool(args.Param)
        if err != nil {
            return fmt.Sprintf("Tool error: %v", err)
        }
        return result
    default:
        return fmt.Sprintf("Unknown tool: %s", name)
    }
}
```

#### D. Agent struct

The `Agent` struct and `NewAgent()` constructor are SDK-specific — see Step 3 for the full definition.

#### E. Response parsing

```go
func extractJSON(text string) string {
    re := regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
    if m := re.FindStringSubmatch(text); m != nil {
        return strings.TrimSpace(m[1])
    }
    return strings.TrimSpace(text)
}

func parseResponse(text string) (*AgentResponse, error) {
    var resp AgentResponse
    if err := json.Unmarshal([]byte(extractJSON(text)), &resp); err != nil {
        return nil, err
    }
    return &resp, validateResponse(&resp)
}
```

#### F. Response formatters

```go
func formatCapabilities() string { /* bullet list of what the agent can help with */ }
func formatResult(r *AgentResponse) string { /* domain-specific fields as plain text */ }
func formatDecline(r *AgentResponse) string {
    if r.Reason != "" { return r.Reason }
    return "I can only help with  questions."
}
```

#### G. Prompt builder

```go
func buildPrompt(message string) string {
    return message
}
```

#### H. Conversation history

Maintain conversation history per session keyed by a stable sender or session identifier.

**Key rule:** the system prompt is never stored in history — it is prepended fresh on every call. This keeps it authoritative and prevents it from being diluted as context grows.

See `processMessage` in Step 3 for the full implementation.

#### I. Entry point

Requires `github.com/joho/godotenv` in your import block.

```go
func main() {
    if err := godotenv.Load(); err != nil {
        log.Println("No .env file found, using environment variables")
    }
    agent := NewAgent()
    // wire up your I/O here (HTTP, stdin, etc.)
}
```

Env defaults:

| Variable | Default |
|----------|---------|
| `MODEL` | `gpt-oss:120b` |
| `MODEL_BASE_URL` | `https://ollama.com` |
| `MODEL_API_KEY` | `your_ollama_api_key` |

---

### Step 3 — Ollama implementation via the OpenAI-compatible SDK

**Import:**
```go
import (
    "fmt"
    "net/url"
    "strings"
    "sync"
    "github.com/openai/openai-go"
    "github.com/openai/openai-go/option"
)
```

**Client init:**
```go
func ollamaBaseURL() string {
    base := strings.TrimRight(getEnv("MODEL_BASE_URL", "https://ollama.com"), "/")
    if base == "" {
        base = "https://ollama.com"
    }
    parsed, err := url.Parse(base)
    if err != nil {
        return "https://ollama.com/v1"
    }
    if !strings.HasSuffix(parsed.Path, "/v1") {
        parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1"
    }
    return parsed.String()
}

client := openai.NewClient(
    option.WithBaseURL(ollamaBaseURL()),
    option.WithAPIKey(getEnv("MODEL_API_KEY", "")),
)
```

**Add client to Agent struct:**
```go
type Agent struct {
    client *openai.Client
    model  string
}
```

**NewAgent constructor:**
```go
func NewAgent() *Agent {
    client := openai.NewClient(
        option.WithBaseURL(ollamaBaseURL()),
        option.WithAPIKey(getEnv("MODEL_API_KEY", "")),
    )

    return &Agent{
        client: &client,
        model:  getEnv("MODEL", "gpt-oss:120b"),
    }
}
```

**Tool definitions** — declare as a package-level variable so it's accessible from both `main()` and `handleMessage()`:
```go
var toolDefs = []openai.ChatCompletionToolParam{{
    Function: openai.FunctionDefinitionParam{
        Name:        "tool_name",
        Description: openai.String("What this tool does and when to use it."),
        Parameters: openai.FunctionParameters{
            "type": "object",
            "properties": map[string]any{
                "param": map[string]any{"type": "string", "description": "..."},
            },
            "required": []string{"param"},
        },
    },
}}
```

**LLM call with manual tool loop:**
```go
func (a *Agent) call(ctx context.Context, messages []openai.ChatCompletionMessageParamUnion, toolDefs []openai.ChatCompletionToolParam) (string, error) {
    for {
        params := openai.ChatCompletionNewParams{
            Model:    a.model,
            Messages: messages,
        }
        if len(toolDefs) > 0 {
            params.Tools = toolDefs
            params.ToolChoice = openai.ChatCompletionToolChoiceOptionUnionParam{
                OfAuto: openai.String("auto"),
            }
        }

        resp, err := a.client.Chat.Completions.New(ctx, params)
        if err != nil {
            return "", err
        }

        choice := resp.Choices[0]
        if choice.FinishReason != openai.ChatCompletionChoicesFinishReasonToolCalls {
            return choice.Message.Content, nil
        }

        // execute tool calls and continue
        messages = append(messages, choice.Message.ToParam())
        for _, tc := range choice.Message.ToolCalls {
            result := dispatchTool(tc.Function.Name, json.RawMessage(tc.Function.Arguments))
            messages = append(messages, openai.ToolMessage(result, tc.ID))
        }
    }
}
```

Use the Chat Completions API shape shown above for OpenAI-compatible backends. If the selected backend requires a different API surface or URL shape, adapt the scaffold accordingly.

**Multi-turn:** Maintain a `[]openai.ChatCompletionMessageParamUnion` slice per session. Append raw user and assistant turns after each exchange.

**Build messages slice — system prompt prepended fresh, history in the middle:**
```go
func buildMessages(prompt string, history []openai.ChatCompletionMessageParamUnion) []openai.ChatCompletionMessageParamUnion {
    msgs := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(SYSTEM_PROMPT)}
    msgs = append(msgs, history...)
    msgs = append(msgs, openai.UserMessage(buildPrompt(prompt)))
    return msgs
}
```

**Process a message:**
```go
type sessionStore struct {
    mu   sync.RWMutex
    data map[string][]openai.ChatCompletionMessageParamUnion
}

func newSessionStore() *sessionStore {
    return &sessionStore{
        data: make(map[string][]openai.ChatCompletionMessageParamUnion),
    }
}

func (s *sessionStore) Get(sessionID string) []openai.ChatCompletionMessageParamUnion {
    s.mu.RLock()
    defer s.mu.RUnlock()

    history := s.data[sessionID]
    out := make([]openai.ChatCompletionMessageParamUnion, len(history))
    copy(out, history)
    return out
}

func (s *sessionStore) Set(sessionID string, history []openai.ChatCompletionMessageParamUnion) {
    s.mu.Lock()
    defer s.mu.Unlock()

    next := make([]openai.ChatCompletionMessageParamUnion, len(history))
    copy(next, history)
    s.data[sessionID] = next
}

var sessions = newSessionStore()

func processMessage(agent *Agent, sessionID, prompt string) string {
    history := sessions.Get(sessionID)

    rawText, err := agent.call(context.Background(), buildMessages(prompt, history), toolDefs)
    if err != nil {
        return "I'm sorry, I encountered an error processing your request."
    }

    history = append(history,
        openai.UserMessage(prompt),
        openai.AssistantMessage(rawText),
    )
    if len(history) > 40 {
        history = history[2:]
    }
    sessions.Set(sessionID, history)

    resp, err := parseResponse(rawText)
    if err != nil {
        return "I'm sorry, I encountered an error processing your request."
    }

    switch resp.Type {
    case "capabilities": return formatCapabilities()
    case "decline":      return formatDecline(resp)
    default:             return formatResult(resp)
    }
}
```

---

### Step 4 — Optional building blocks

#### Persistent memory across sessions

In-session conversation history above is what enables multi-turn behavior.

For persistent memory across sessions, store and retrieve keyed by a stable user or session identifier:
- **Simple:** serialise history to a JSON file on disk
- **Production:** Redis or a database, keyed by sender ID
- **Semantic:** embed past exchanges and retrieve relevant ones by similarity before each call

Always wrap memory retrieval in error handling — a failure must never block the agent from responding.

---

### Step 5 — Observability (optional)

Add Large Language Platform connectivity for managed routing, tool-call tracing, and observability after the core agent is working.

**Add dependency:** `github.com/llpsdk/llp-go`

**Implement `handleMessage(agent, msg)`:**

```
1. Build messages slice and call agent
2. Parse response
3. Switch on type → format → return string
4. On error: return safe fallback string
```

**Utility helper** — add this alongside the other helpers:
```go
func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}
```

`handleMessage` is the Large Language Platform equivalent of `processMessage` — it replaces it when using Large Language Platform, reusing the same session store keyed by `msg.Sender`:
```go
var sessions = newSessionStore()
```

```go
func handleMessage(agent *Agent, msg llp.TextMessage) string {
    history := sessions.Get(msg.Sender)

    rawText, err := agent.call(context.Background(), buildMessages(msg.Prompt, history), toolDefs)
    if err != nil {
        return "I'm sorry, I encountered an error processing your request."
    }

    history = append(history,
        openai.UserMessage(msg.Prompt),
        openai.AssistantMessage(rawText),
    )
    if len(history) > 40 {
        history = history[2:]
    }
    sessions.Set(msg.Sender, history)

    resp, err := parseResponse(rawText)
    if err != nil {
        return "I'm sorry, I encountered an error processing your request."
    }

    switch resp.Type {
    case "capabilities": return formatCapabilities()
    case "decline":      return formatDecline(resp)
    default:             return formatResult(resp)
    }
}
```

**Wire up the Large Language Platform client — replaces the standalone `main()` from Step 2H.**

Add to your import block:
```go
"context"
"os/signal"
"sync"
"syscall"
llp "github.com/llpsdk/llp-go"
```

```go
func main() {
    if err := godotenv.Load(); err != nil {
        log.Println("No .env file found, using environment variables")
    }

    agent := NewAgent()

    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

    client, err := llp.NewClient(getEnv("LLP_AGENT_NAME", ""), getEnv("LLP_API_KEY", "")).
        OnMessage(func(ctx context.Context, msg llp.TextMessage) (llp.TextMessage, error) {
            return msg.Reply(handleMessage(agent, msg)), nil
        }).
        Connect(ctx)

    if err != nil {
        log.Fatalf("Fatal: %v", err)
    }

    log.Println("Agent connected")
    ` |
| `LLP_API_KEY` | `""` |
| `MODEL` | `gpt-oss:120b` |
| `MODEL_BASE_URL` | `https://ollama.com` |
| `MODEL_API_KEY` | `your_ollama_api_key` |

---

### Step 6 — Generate the output

Generate a dedicated directory named `/` containing:

**`main.go`** — package `main`, sections in this order:
1. File header comment
2. `package main`
3. `import` block
4. Constants (`SYSTEM_PROMPT`)
5. `AgentResponse` struct + `validateResponse()`
6. Tool definitions + `dispatchTool()` (if any)
7. `Agent` struct + `NewAgent()` + `call()`
8. Formatters
9. `buildPrompt()`, `buildMessages()`, `extractJSON()`, `parseResponse()`
10. `sessionStore` + `processMessage()` (standalone) — or `getEnv()` + `handleMessage()` (if Large Language Platform observability requested)
11. `main()`

Separate sections with:
```go
// =============================================================================
// Section Name
// =============================================================================
```

Supporting files:
- `go.mod` — `module `, `go 1.21`, `github.com/openai/openai-go`, `github.com/joho/godotenv` (add `github.com/llpsdk/llp-go` if observability requested)
- `.env.example` — all env vars with inline comments
- `.gitignore` — binary output (e.g. ``) and `.env`
- `README.md` — what it does, prerequisites (`go 1.21+`), `go mod tidy`, config table, `go run main.go`

`.env.example` must use the generic model env names and include inline comments that explain the API key c

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [llpsdk](https://github.com/llpsdk)
- **Source:** [llpsdk/agent-skills](https://github.com/llpsdk/agent-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-llpsdk-agent-skills-go-agent
- Seller: https://agentstack.voostack.com/s/llpsdk
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
