# Building AI Apps with Go: A Practical Guide with LangChainGo and LangGraphGo

- **URL:** https://isaacfei.com/posts/building-ai-apps-with-go
- **Date:** 2026-03-15
- **Tags:** Go, AI, LangChain
- **Description:** Hands-on exploration of building AI applications in Go — from basic LLM calls to tools, agents, and graph-based workflows using langchaingo and langgraphgo.

---

I've been experimenting with building AI-powered applications in Go using [langchaingo](https://github.com/tmc/langchaingo) and [langgraphgo](https://github.com/smallnest/langgraphgo). This post is a brain dump of everything I learned — from the simplest LLM call to designing full agent and workflow systems. I'll walk through the code, share my mental model for choosing between agent-based and workflow-based designs, and leave you with quick-reference cheat sheets.

All demo code lives in my [gotryai](https://github.com/Isaac-Fate/gotryai) repository. A huge shout-out to the [langgraphgo examples directory](https://github.com/smallnest/langgraphgo/tree/master/examples) — it has 85+ examples covering everything from basic graphs to RAG pipelines, MCP agents, and multi-agent swarms. That's where I learned most of what I know about using langgraphgo, and it's the best place to go when you want to explore beyond what this post covers.

## Calling an LLM

The absolute minimum. No agents, no tools — just send a prompt to an LLM and get a response.

```go
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/joho/godotenv"
	"github.com/tmc/langchaingo/llms/openai"
)

func main() {
	godotenv.Load()

	llm, err := openai.New(
		openai.WithBaseURL("https://api.deepseek.com"),
		openai.WithToken(os.Getenv("DEEPSEEK_API_KEY")),
		openai.WithModel("deepseek-chat"),
	)
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	resp, err := llm.Call(ctx, "Who are you?")
	if err != nil {
		panic(err)
	}

	fmt.Println(resp)
}
```

A few things to note:

- **langchaingo uses the OpenAI-compatible interface.** Since DeepSeek (and many other providers) expose an OpenAI-compatible API, you just swap the base URL and token. The `openai.New(...)` constructor is the universal entry point — don't be confused by the package name.
- **`llm.Call(ctx, prompt)`** is the simplest invocation. One string in, one string out.
- **Environment variables** are loaded from a `.env` file via `godotenv`. Keep your API keys out of source code.

Running this:

```
┌─────────────────────────────────────────────────────────────
│ LLM Response
└─────────────────────────────────────────────────────────────

你好！我是DeepSeek，由深度求索公司创造的AI助手！😊
...
```

One string in, one string out. This is the foundation. Everything else builds on top of this.

***

## Structured Output

Sometimes you don't want free-form text — you want the LLM to return parseable JSON that matches a specific schema. This is useful when LLM output feeds directly into downstream code.

```go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	"github.com/invopop/jsonschema"
	"github.com/joho/godotenv"
	"github.com/tmc/langchaingo/llms/openai"
	"github.com/tmc/langchaingo/prompts"
)

func main() {
	godotenv.Load()

	reflector := jsonschema.Reflector{DoNotReference: true}
	schema := reflector.Reflect(&[]TodoItem{})

	b, _ := json.MarshalIndent(schema, "", "  ")
	schemaString := string(b)

	llm, err := openai.New(
		openai.WithBaseURL("https://api.deepseek.com"),
		openai.WithToken(os.Getenv("DEEPSEEK_API_KEY")),
		openai.WithModel("deepseek-chat"),
		openai.WithResponseFormat(openai.ResponseFormatJSON),
	)
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	promptTemplate := prompts.NewPromptTemplate(`{{.input}}
	You must return a JSON object that matches the following schema:
	{{ .schema }}
	`, []string{"input", "schema"})

	prompt, _ := promptTemplate.Format(map[string]any{
		"input":  "Need to buy a cup of coffee and then learn langchaingo package. After that, watch a movie if there is time.",
		"schema": schemaString,
	})

	resp, err := llm.Call(ctx, prompt)
	if err != nil {
		panic(err)
	}

	todoItems := []TodoItem{}
	json.Unmarshal([]byte(resp), &todoItems)

	for i, item := range todoItems {
		fmt.Printf("%d. %s (priority: %s)\n", i+1, item.Title, item.Priority)
	}
}

type TodoItem struct {
	Title       string           `json:"title"`
	Description string           `json:"description,omitempty"`
	Priority    TodoItemPriority `json:"priority" jsonschema:"enum=low,enum=normal,enum=high,default=normal"`
}

type TodoItemPriority string

const (
	TodoItemPriorityLow    TodoItemPriority = "low"
	TodoItemPriorityNormal TodoItemPriority = "normal"
	TodoItemPriorityHigh   TodoItemPriority = "high"
)
```

Running this produces structured, typed output:

```
┌─────────────────────────────────────────────────────────────
│ Structured output (Todo items)
└─────────────────────────────────────────────────────────────

  📋 Todo items:
    1. Buy a cup of coffee
       Purchase coffee to drink
       priority: high
    2. Learn langchaingo package
       Study the langchaingo programming package
       priority: normal
    3. Watch a movie
       Watch a movie if there is time available
       priority: low
```

The LLM returned valid JSON matching our schema, and we parsed it directly into Go structs. The key pieces:

1. **`openai.WithResponseFormat(openai.ResponseFormatJSON)`** — tells the LLM to respond in JSON mode.
2. **JSON Schema in the prompt** — use `jsonschema.Reflector` to generate a JSON Schema from your Go struct, then embed it in the prompt. The LLM uses this schema as a contract for its output format.
3. **`json.Unmarshal`** — parse the LLM response directly into your Go struct. If the schema and prompt are right, this just works.
4. **`jsonschema` struct tags** — use tags like `jsonschema:"enum=low,enum=normal,enum=high"` to constrain the LLM's output to valid values.

This pattern — "define a struct, reflect its schema, embed in prompt, parse the response" — is one you'll use constantly.

### The `invopop/jsonschema` Package

The [`invopop/jsonschema`](https://github.com/invopop/jsonschema) package is quietly one of the most useful dependencies in this entire stack. It does one thing well: reflect a Go struct into a [JSON Schema](https://json-schema.org/) object. You'll reach for it in two places:

1. **Structured output** — embed the schema in a prompt so the LLM knows what JSON shape to produce (as shown above).
2. **Tool input schemas** — return the schema from `ToolWithSchema.Schema()` so the LLM knows what arguments a tool expects (covered in the next section).

The core API is two lines:

```go
reflector := jsonschema.Reflector{DoNotReference: true}
schema := reflector.Reflect(&MyStruct{})
```

`Reflect` walks your struct's fields and builds a `*jsonschema.Schema` from the `json` tags (field names, `omitempty`) and `jsonschema` tags (constraints). The most useful `jsonschema` struct tags:

| Tag | Effect | Example |
|-----|--------|---------|
| `enum=a,enum=b,enum=c` | Restricts to allowed values | `jsonschema:"enum=low,enum=normal,enum=high"` |
| `default=x` | Sets a default value | `jsonschema:"default=normal"` |
| `description=...` | Adds a field description | `jsonschema:"description=Due date in ISO 8601"` |

Two reflector options matter:

- **`DoNotReference: true`** — inlines all types instead of using `$ref`. Useful for structured output prompts where you want the LLM to see the full schema in one shot.
- **`ExpandedStruct: true`** — inlines the *root* struct's fields so the top-level schema is `{"type":"object","properties":{...}}` instead of `{"$ref":"#/$defs/MyStruct"}`. Required for tool schemas because LLM APIs expect `type: "object"` at the root.

You can also implement the `jsonschema.JSONSchema()` interface on custom types to control their schema representation — like `FlexibleDate` returning `{"type":"string","format":"date-time"}` instead of whatever the reflector would guess.

***

## Invoking an Agent

An "agent" in langgraphgo is a loop: the LLM decides whether to respond or call a tool, tools execute, results go back to the LLM, repeat until done. Even without tools, the agent pattern gives you message-based conversation (instead of raw string in/out).

```go
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/joho/godotenv"
	"github.com/smallnest/langgraphgo/prebuilt"
	"github.com/tmc/langchaingo/llms"
	"github.com/tmc/langchaingo/llms/openai"
	"github.com/tmc/langchaingo/tools"
)

func main() {
	godotenv.Load()

	llm, err := openai.New(
		openai.WithBaseURL("https://api.deepseek.com"),
		openai.WithToken(os.Getenv("DEEPSEEK_API_KEY")),
		openai.WithModel("deepseek-chat"),
	)
	if err != nil {
		panic(err)
	}

	inputTools := []tools.Tool{}

	runnable, err := prebuilt.CreateAgentMap(llm, inputTools, 10)
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	initialState := map[string]any{
		"messages": []llms.MessageContent{
			llms.TextParts(llms.ChatMessageTypeHuman, "Who are you?"),
		},
	}

	resp, err := runnable.Invoke(ctx, initialState)
	if err != nil {
		panic(err)
	}

	fmt.Printf("%+v\n", resp)
}
```

Output:

```
┌─────────────────────────────────────────────────────────────
│ Agent completed in 1 iteration(s)
└─────────────────────────────────────────────────────────────

▸ [1] Human
    Who are you?

▸ [2] AI
    你好！我是DeepSeek，由深度求索公司创造的AI助手！😊
    ...
```

With no tools and a single question, the agent completed in 1 iteration — it's essentially a raw LLM call wrapped in the agent protocol. Key differences from `llm.Call()`:

- **`prebuilt.CreateAgentMap(llm, tools, maxIterations)`** builds the agent loop. The third argument caps how many LLM↔tool round-trips can happen before it stops.
- **State is a `map[string]any`** with a `"messages"` key holding the conversation as `[]llms.MessageContent`. This is the standard state shape for the prebuilt agent.
- **`runnable.Invoke(ctx, state)`** runs the agent loop and returns the final state (including all messages from the conversation).
- Even with an empty tools slice, the agent still works — it just can't call any tools, so it behaves like a single LLM call wrapped in the agent protocol.

***

## Defining a Simple Tool

Tools are how you give the LLM the ability to *do things* — query a database, call an API, roll dice, whatever. The simplest tool implements three methods: `Name()`, `Description()`, and `Call()`.

```go
type RollDiceTool struct{}

func (t *RollDiceTool) Name() string {
	return "roll_dice"
}

func (t *RollDiceTool) Description() string {
	return "Roll a 6-sided dice and return the result."
}

func (t *RollDiceTool) Call(ctx context.Context, input string) (string, error) {
	return strconv.Itoa(rand.Intn(6) + 1), nil
}
```

That's it. The `tools.Tool` interface is just those three methods. When you register this tool with an agent, the LLM sees its name and description, decides when to call it, and the agent framework routes the call to your `Call()` method.

To use it with an agent:

```go
inputTools := []tools.Tool{&RollDiceTool{}}

runnable, err := prebuilt.CreateAgentMap(llm, inputTools, 10)
if err != nil {
    panic(err)
}

initialState := map[string]any{
    "messages": []llms.MessageContent{
        llms.TextParts(
            llms.ChatMessageTypeHuman,
            "Roll a dice for 3 times and tell me the result.",
        ),
    },
}

resp, err := runnable.Invoke(ctx, initialState)
```

Here's what happens when we ask the agent to roll 3 times:

```
┌─────────────────────────────────────────────────────────────
│ Agent completed in 2 iteration(s)
└─────────────────────────────────────────────────────────────

▸ [1] Human
    Roll a dice for 3 times and tell me the result.

▸ [2] AI
    I'll roll a dice for you 3 times and show you the results.
    → tool: roll_dice({"input": "First roll"})
    → tool: roll_dice({"input": "Second roll"})
    → tool: roll_dice({"input": "Third roll"})

▸ [3] Tool
    ← roll_dice: 3

▸ [4] Tool
    ← roll_dice: 6

▸ [5] Tool
    ← roll_dice: 5

▸ [6] AI
    Here are the results of rolling a 6-sided dice 3 times:

    1. First roll: **3**
    2. Second roll: **6**
    3. Third roll: **5**
```

Notice the agent loop: iteration 1 — the LLM decides to call `roll_dice` three times in parallel (messages 2–5); iteration 2 — the LLM sees all the tool results and produces a final answer (message 6). Two iterations total. Also notice the `{"input": "First roll"}` arguments — that's the default schema at work. The LLM sends `{"input":"..."}` and the framework extracts the string before passing it to `Call()`.

When you don't implement `ToolWithSchema`, the framework generates a default schema: `{"type":"object","properties":{"input":{"type":"string"}}}`. This is fine for tools that take a single string (or no meaningful input at all, like our dice roller).

***

## Tools with Custom Input Schema

Real-world tools often need structured input — not just a single string. For example, a "save todo items" tool needs an array of objects with titles, priorities, and due dates. This is where `ToolWithSchema` comes in.

The interface adds one method on top of `tools.Tool`:

```go
type ToolWithSchema interface {
    tools.Tool
    Schema() map[string]any
}
```

Here's a full example — a tool that extracts and saves todo items from an email:

```go
type SaveTodoItemsInput struct {
	TodoItems []TodoItem `json:"todo_items"`
}

type TodoItem struct {
	Title       string           `json:"title"`
	Description string           `json:"description,omitempty"`
	Priority    TodoItemPriority `json:"priority" jsonschema:"enum=low,enum=normal,enum=high,default=normal"`
	DueDate     *FlexibleDate    `json:"due_date,omitempty" jsonschema:"description=Due date (YYYY-MM-DD or ISO 8601)"`
}

type SaveTodoItemsTool struct{}

func (t *SaveTodoItemsTool) Name() string        { return "save_todo_items" }
func (t *SaveTodoItemsTool) Description() string { return "Save the todo items to the database." }

func (t *SaveTodoItemsTool) Schema() map[string]any {
	r := &jsonschema.Reflector{ExpandedStruct: true}
	schema := r.Reflect(&SaveTodoItemsInput{})
	data, _ := json.Marshal(schema)
	var schemaMap map[string]any
	_ = json.Unmarshal(data, &schemaMap)
	return schemaMap
}

func (t *SaveTodoItemsTool) Call(ctx context.Context, input string) (string, error) {
	var req SaveTodoItemsInput
	if err := json.Unmarshal([]byte(input), &req); err != nil {
		return "", err
	}
	for _, item := range req.TodoItems {
		fmt.Printf("Saved: %s (priority: %s)\n", item.Title, item.Priority)
	}
	return "Todo items saved successfully.", nil
}
```

When paired with an agent that reads an email and extracts action items, the full output looks like this:

```
┌─────────────────────────────────────────────────────────────
│ Schema for save_todo_items (Parameters sent to LLM)
└─────────────────────────────────────────────────────────────
{
  "properties": {
    "todo_items": {
      "items": { "$ref": "#/$defs/TodoItem" },
      "type": "array"
    }
  },
  "required": ["todo_items"],
  "type": "object",
  "$defs": {
    "TodoItem": {
      "properties": {
        "title":       { "type": "string" },
        "description": { "type": "string" },
        "priority":    { "type": "string", "enum": ["low","normal","high"], "default": "normal" },
        "due_date":    { "type": "string", "format": "date-time" }
      },
      "required": ["title", "priority"],
      "type": "object"
    }
  }
}

  Saved:
    • Review and sign off on API documentation (priority: high due 2026-03-14)
    • Coordinate with DevOps team on staging environment setup (priority: high due 2026-03-18)
    • Update runbook with new monitoring alerts (priority: normal)

┌─────────────────────────────────────────────────────────────
│ Agent completed in 3 iteration(s)
└─────────────────────────────────────────────────────────────

▸ [1] Human
    Extract todo items from the email below and save them using
    the save_todo_items tool. Call get_current_date_time first.
    ...

▸ [2] AI
    → tool: get_current_date_time({"input": "Get current date and time"})

▸ [3] Tool
    ← get_current_date_time: 2026-03-15T21:51:22+08:00

▸ [4] AI
    → tool: save_todo_items({"todo_items": [{"title": "Review and sign off on API documentation", ...}]})

▸ [5] Tool
    ← save_todo_items: Todo items saved successfully.

▸ [6] AI
    Perfect! I've successfully extracted and saved 3 todo items from the email.
```

A few things to notice from the output:

- The **schema** printed at the top is exactly what gets sent to the LLM as `FunctionDefinition.Parameters`. The LLM uses this to know the expected JSON shape.
- The agent took **3 iterations**: (1) call `get_current_date_time`, (2) call `save_todo_items` with structured input, (3) produce a final summary.
- The `get_current_date_time` tool uses the **default schema** (`{"input": "..."}`) while `save_todo_items` uses a **custom schema** — both work seamlessly in the same agent.

### How `Schema()` Is Wired Under the Hood

To understand what's really happening, let's trace through the langgraphgo source code. The journey of a tool schema touches three files in the `prebuilt` package: [`tool_executor.go`](https://github.com/smallnest/langgraphgo/blob/main/prebuilt/tool_executor.go), [`create_agent.go`](https://github.com/smallnest/langgraphgo/blob/main/prebuilt/create_agent.go), and the langchaingo [`tools/tool.go`](https://github.com/tmc/langchaingo/blob/main/tools/tool.go).

**Step 1: The `tools.Tool` interface (langchaingo)**

The foundation is langchaingo's `tools.Tool` — just three methods:

```go
// github.com/tmc/langchaingo/tools/tool.go
type Tool interface {
    Name() string
    Description() string
    Call(ctx context.Context, input string) (string, error)
}
```

Note that `Call` always takes a plain `string`. This is important — whether your tool has structured input or not, the framework ultimately passes a string to `Call()`.

**Step 2: `ToolWithSchema` (langgraphgo)**

langgraphgo extends this with an optional interface in `tool_executor.go`:

```go
// github.com/smallnest/langgraphgo/prebuilt/tool_executor.go
type ToolWithSchema interface {
    Schema() map[string]any
}
```

It's not embedded in `tools.Tool` — it's a separate interface that tools *may* implement. The framework uses Go's interface type assertion to check at runtime.

**Step 3: `getToolSchema` — the branching point**

Also in `tool_executor.go`, this function decides which schema to use:

```go
// github.com/smallnest/langgraphgo/prebuilt/tool_executor.go
func getToolSchema(tool tools.Tool) map[string]any {
    if st, ok := tool.(ToolWithSchema); ok {
        return st.Schema()
    }
    return map[string]any{
        "type": "object",
        "properties": map[string]any{
            "input": map[string]any{
                "type":        "string",
                "description": "The input query for the tool",
            },
        },
        "required":             []string{"input"},
        "additionalProperties": false,
    }
}
```

If your tool implements `ToolWithSchema`, it calls `Schema()` and uses whatever you return. Otherwise, it produces a default schema with a single `input` string field. This is the default that simple tools (like our dice roller) get automatically.

**Step 4: Agent node — building tool definitions for the LLM**

In `create_agent.go`, the agent node builds `llms.Tool` definitions and passes them to the LLM:

```go
// github.com/smallnest/langgraphgo/prebuilt/create_agent.go (agent node)
var toolDefs []llms.Tool
for _, t := range allTools {
    toolDefs = append(toolDefs, llms.Tool{
        Type: "function",
        Function: &llms.FunctionDefinition{
            Name:        t.Name(),
            Description: t.Description(),
            Parameters:  getToolSchema(t),  // <-- your Schema() lands here
        },
    })
}

resp, err := model.GenerateContent(ctx, msgsToSend, llms.WithTools(toolDefs))
```

The `llms.FunctionDefinition` (from langchaingo) has a `Parameters any` field — it accepts any JSON-serializable value, which is exactly what `getToolSchema` returns. This gets serialized and sent to the LLM API as the function's parameter specification.

**Step 5: Tools node — dispatching tool calls back to your code**

When the LLM responds with tool calls, the tools node in `create_agent.go` handles the dispatch. This is where the `ToolWithSchema` check matters again:

```go
// github.com/smallnest/langgraphgo/prebuilt/create_agent.go (tools node)
tool, hasTool := toolExecutor.Tools[tc.FunctionCall.Name]

var inputVal string
if hasTool {
    if _, hasCustomSchema := tool.(ToolWithSchema); hasCustomSchema {
        // Tool has custom schema — pass raw JSON arguments directly
        inputVal = tc.FunctionCall.Arguments
    } else {
        // Default schema — extract the "input" field
        var args map[string]any
        _ = json.Unmarshal([]byte(tc.FunctionCall.Arguments), &args)
        if val, ok := args["input"].(string); ok {
            inputVal = val
        } else {
            inputVal = tc.FunctionCall.Arguments
        }
    }
}

res, err := toolExecutor.Execute(ctx, ToolInvocation{Tool: tc.FunctionCall.Name, ToolInput: inputVal})
```

Two paths:

- **With `ToolWithSchema`**: the LLM's `Arguments` JSON (e.g. `{"todo_items":[...]}`) is passed as-is to `Call()`. You unmarshal it yourself.
- **Without `ToolWithSchema`**: the framework assumes the LLM sent `{"input":"some string"}` (because that's the default schema it told the LLM to use), extracts the `"input"` field, and passes just that string to `Call()`.

Here's the full flow as a diagram:

```mermaid
flowchart TB
    subgraph "Agent Creation (once)"
    direction TB
        A[CreateAgentMap] --> B{tool implements<br/>ToolWithSchema?}
        B -->|Yes| C[Call tool.Schema]
        B -->|No| D["Use default schema<br/>{input: string}"]
        C --> E["Build llms.FunctionDefinition<br/>Parameters = schema"]
        D --> E
        E --> F["Pass to LLM via<br/>llms.WithTools"]
    end

    subgraph "Agent Loop (each iteration)"
    direction TB
        G[LLM responds with<br/>tool call] --> H{tool implements<br/>ToolWithSchema?}
        H -->|Yes| I["Pass raw JSON<br/>to Call()"]
        H -->|No| J["Extract args.input<br/>then pass to Call()"]
        I --> K[tool.Call executes]
        J --> K
        K --> L[Result fed back<br/>to LLM]
    end
```

This design is clean: **the same interface check (`ToolWithSchema`) gates both schema generation and argument dispatch**, keeping the two sides consistent.

One practical gotcha: **LLMs are sloppy with dates.** They often return `"2025-03-14T23:59:59"` (no timezone), which fails `time.Time`'s strict RFC3339 parsing. The `FlexibleDate` type in the example handles this by trying multiple layouts:

```go
type FlexibleDate time.Time

func (f *FlexibleDate) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	for _, layout := range []string{
		time.RFC3339,
		"2006-01-02T15:04:05Z",
		"2006-01-02T15:04:05",
		"2006-01-02",
	} {
		if t, err := time.Parse(layout, s); err == nil {
			*f = FlexibleDate(t)
			return nil
		}
	}
	return fmt.Errorf("invalid date: %q", s)
}
```

It also implements `jsonschema.JSONSchema()` so the generated schema tells the LLM to use `"format": "date-time"`.

### Steps to define a tool (cheat sheet)

1. Define your input struct with `json` tags and `jsonschema` tags for enums/descriptions.
2. Create a tool struct implementing `Name()`, `Description()`, `Call()`, and `Schema()`.
3. In `Schema()`, use `jsonschema.Reflector{ExpandedStruct: true}` to generate the schema (must be `ExpandedStruct: true` so the root is `type: "object"`, not a `$ref`).
4. In `Call()`, unmarshal the raw JSON input into your input struct.
5. Register the tool: `inputTools := []tools.Tool{&MyTool{}}`.

***

## Two Philosophies: Workflow vs. Agent

When you're building an AI-powered application, you'll face a fundamental design decision: do you want a **fixed workflow** where you control the execution path, or do you want an **agent** that decides what to do on its own?

This is not just a langchaingo/langgraphgo distinction — it's a general architectural question that applies to any AI application framework.

### Workflow: Fixed Graph of LLM-Powered Steps

A workflow is a directed graph where **you define the nodes (steps) and edges (transitions)**. Each node can use an LLM, but the overall execution path is predetermined. Think of it as a state machine where some states happen to call an LLM.

Here's an example: given an email, first summarize it, then extract todo items from the summary.

```mermaid
flowchart LR
    START([START]) --> summarize[summarize_email]
    summarize --> extract[extract_todo_items]
    extract --> END([END])
```

```go
g := graph.NewListenableStateGraph[MyState]()

g.AddNode(
    "summarize_email",
    "Summarize the email",
    func(ctx context.Context, state MyState) (MyState, error) {
        promptTemplate := prompts.NewPromptTemplate(`
        You are a helpful assistant that summarizes emails.
        The email is: {{.email}}
        Your summary is:
        `, []string{"email"})

        prompt, _ := promptTemplate.Format(map[string]any{"email": email})
        resp, err := llm.Call(ctx, prompt)
        if err != nil {
            return state, err
        }

        state["summary"] = resp
        return state, nil
    },
)

g.AddNode(
    "extract_todo_items",
    "Extract todo items from the summary",
    func(ctx context.Context, state MyState) (MyState, error) {
        // Uses llmStructured (JSON mode) to extract todo items
        // from the summary produced by the previous node
        // ...
        state["todo_items"] = result.TodoItems
        return state, nil
    },
)

g.AddEdge("summarize_email", "extract_todo_items")
g.AddEdge("extract_todo_items", graph.END)
g.SetEntryPoint("summarize_email")
```

The graph state (`MyState`, which is just `map[string]any`) flows through each node. Each node reads what it needs from the state, does its work (often involving an LLM call), and writes its results back to the state.

Here's the actual output when streaming this graph:

```
[22:37:27.943] 🚀 Chain started
[22:37:27.943] ▶️  Node 'summarize_email' started
[22:37:32.848] ✅ Node 'summarize_email' completed
  state:
    {
      "summary": "Sarah requests John to: 1) Review and sign off on the
       API documentation by March 14th. 2) Coordinate with DevOps on
       staging environment setup by March 18th. 3) Update the runbook
       with new monitoring alerts (no hard deadline)."
    }
[22:37:32.848] ▶️  Node 'extract_todo_items' started
[22:37:40.533] ✅ Node 'extract_todo_items' completed
  state:
    {
      "summary": "...",
      "todo_items": [
        { "title": "Review and sign off on the API documentation",
          "due_date": "2026-03-14T23:59:59+08:00" },
        { "title": "Coordinate with DevOps on staging environment setup",
          "due_date": "2026-03-18T23:59:59+08:00" },
        { "title": "Update the runbook with new monitoring alerts",
          "description": "Emma from SRE can assist" }
      ]
    }
[22:37:40.533] 🏁 Chain ended
```

You can see the state accumulating as it flows through nodes: `summarize_email` writes `"summary"`, then `extract_todo_items` reads it and writes `"todo_items"`. The entire pipeline took about 12 seconds (two LLM calls back to back).

**Streaming** — `graph.NewStreamingStateGraph` lets you compile with `CompileListenable()` and call `Stream()` to get a channel of events (chain start/end, node start/complete/error) as they happen:

```go
runnable, _ := g.CompileListenable()
events := runnable.Stream(ctx, initialState)
for event := range events {
    // EventChainStart, NodeEventStart, NodeEventComplete, EventChainEnd, etc.
}
```

**Listeners** — `graph.NewListenableStateGraph` gives you a listener pattern. You can attach global listeners (see all node events) or per-node listeners (see only that node's events):

```go
g.AddGlobalListener(&EventLogger{})
extractNode.AddListener(&TodoItemReporter{})

runnable, _ := g.CompileListenable()
result, _ := runnable.Invoke(ctx, initialState)
```

Listeners implement `OnNodeEvent(ctx, event, nodeName, state, err)` and are great for decoupling concerns like logging, metrics, persistence, or triggering side effects from the node logic itself.

**When to use a workflow:**

- The steps are **known and fixed** — you know exactly what needs to happen and in what order.
- You want **explicit control** over the execution flow.
- Different steps may need **different LLM configurations** (e.g., one node uses JSON mode, another uses free-form text).
- You want to **observe and react** to individual step completions (via listeners or streaming).

The workflow approach is essentially: "I know the recipe, I just need an LLM to help me execute some of the steps."

### Agent: Let the LLM Decide

The agent approach is fundamentally different. Instead of you defining the execution path, you **define tools and let the LLM decide** which tools to call, in what order, and when to stop.

Internally, `prebuilt.CreateAgentMap` builds a simple 2-node graph:

```mermaid
flowchart TD
    Agent[Agent / LLM]
    Tools[Tools execute]
    END[END]

    Agent -->|tool calls| Tools
    Tools -->|results| Agent
    Agent -->|final answer| END
```

The agent node sends the conversation to the LLM. If the LLM responds with tool calls, the tools node executes them and feeds the results back. If the LLM responds with a final answer (no tool calls), execution ends.

```go
inputTools := []tools.Tool{
    &GetCurrentDateTimeTool{},
    &SaveTodoItemsTool{},
}

runnable, _ := prebuilt.CreateAgentMap(llm, inputTools, 10,
    prebuilt.WithSystemMessage(
        "You must call get_current_date_time first to get the current date, "+
        "then extract todo items and save them.",
    ),
)

initialState := map[string]any{
    "messages": []llms.MessageContent{
        llms.TextParts(
            llms.ChatMessageTypeHuman,
            "Extract todo items from the email below and save them.\n\n"+email,
        ),
    },
}

resp, _ := runnable.Invoke(ctx, initialState)
```

The LLM autonomously decides: "First I'll call `get_current_date_time` to know what today is, then I'll analyze the email and call `save_todo_items` with the extracted items." You didn't hardcode this sequence — the LLM figured it out.

**When to use an agent:**

- The execution path is **dynamic** — it depends on the input, intermediate results, or external state.
- You want the LLM to **reason about what to do**, not just execute a step.
- The problem is naturally described as: "here are the capabilities (tools), figure out how to accomplish the goal."

### My Practical Experience

After building with both approaches, here's my mental model:

**Workflows are for when you know the "what" but need help with the "how."** You know you need to summarize, then extract, then save. The LLM helps with the summarization and extraction (the "how"), but you control the pipeline (the "what" and "when"). This is great when the process is well-understood and you want predictability and debuggability. The graph structure makes it easy to reason about what happens when, and listeners let you observe each step.

**Agents are for when you want to replace if-else logic with language-based reasoning.** This is the insight that clicked for me. Traditionally, when we write business logic, we translate real-world decision-making into code: `if conditionA { doX() } else if conditionB { doY() }`. An agent flips this — instead of us translating the decision tree into code, we describe the available actions (tools) and the context (in natural language), and the LLM figures out the branching. It's replacing hard-coded control flow with language-based control flow.

This is powerful when:

- The decision logic is complex and hard to enumerate in code.
- The branching depends on understanding natural language context.
- You want to add new capabilities (tools) without rewriting control flow.

But it comes with trade-offs:

- **Less predictable** — the LLM might not always choose the optimal tool sequence.
- **Harder to debug** — "why did it call tool X before tool Y?" requires inspecting the LLM's reasoning.
- **More expensive** — each decision point is an LLM call.

In practice, I find myself using **workflows for structured pipelines** (ETL-like processes, multi-step content processing) and **agents for open-ended tasks** (chatbots with capabilities, email triage, anything where the "right" sequence depends on the input).

***

## My Takeaways

After spending time with these libraries, here are the things I wish I'd known upfront:

**1. langchaingo's `openai` package is a universal OpenAI-compatible client.** Don't think of it as "only for OpenAI." Any provider with an OpenAI-compatible API (DeepSeek, Azure OpenAI, local models via Ollama/LiteLLM) works by swapping the base URL.

**2. The `tools.Tool` → `ToolWithSchema` progression is natural.** Start with simple tools (just `Name/Description/Call`), and only add `Schema()` when you need structured input. The default schema (`{"input":"string"}`) is fine for many tools.

**3. JSON Schema is your contract with the LLM.** Whether it's structured output via `ResponseFormatJSON` or tool input via `ToolWithSchema`, the pattern is the same: define a Go struct, reflect it into a JSON Schema, and let the LLM conform to it. The `invopop/jsonschema` package with struct tags (`jsonschema:"enum=..."`) is your friend here.

**4. LLMs are sloppy with types.** Dates without timezones, numbers as strings, etc. Build defensive unmarshaling (like `FlexibleDate`) rather than expecting perfect output.

**5. `ExpandedStruct: true` is critical for tool schemas.** Without it, `jsonschema.Reflector` produces `$ref`-based schemas that LLM APIs don't understand. Always use `ExpandedStruct: true` when generating schemas for tool definitions.

**6. System messages guide agent behavior.** Use `prebuilt.WithSystemMessage(...)` to tell the agent *how* to approach the task. This is especially important when tools have a natural ordering (e.g., "get the current date first").

**7. Workflow graphs are state machines.** Think of each node as a state transition: it reads from the shared state, does work, writes results back. The graph edges define the transition sequence. This mental model makes complex workflows easy to reason about.

**8. Listeners decouple concerns.** Don't put logging, metrics, or side effects inside your node functions. Use listeners. Global listeners for cross-cutting concerns, node-specific listeners for targeted reactions.

***

## Quick Reference

### Calling an LLM

```go
llm, _ := openai.New(
    openai.WithBaseURL("https://api.deepseek.com"),
    openai.WithToken(os.Getenv("DEEPSEEK_API_KEY")),
    openai.WithModel("deepseek-chat"),
)
resp, _ := llm.Call(ctx, "your prompt")
```

### Structured Output

```go
llm, _ := openai.New(
    // ...
    openai.WithResponseFormat(openai.ResponseFormatJSON),
)
// Include JSON schema in prompt, then json.Unmarshal(resp, &myStruct)
```

### Defining a Simple Tool

```go
type MyTool struct{}
func (t *MyTool) Name() string                                      { return "my_tool" }
func (t *MyTool) Description() string                               { return "Does something." }
func (t *MyTool) Call(ctx context.Context, input string) (string, error) { return "result", nil }
```

### Defining a Tool with Schema

```go
type MyTool struct{}
func (t *MyTool) Name() string        { return "my_tool" }
func (t *MyTool) Description() string { return "Does something structured." }
func (t *MyTool) Schema() map[string]any {
    r := &jsonschema.Reflector{ExpandedStruct: true}
    schema := r.Reflect(&MyInput{})
    data, _ := json.Marshal(schema)
    var m map[string]any
    json.Unmarshal(data, &m)
    return m
}
func (t *MyTool) Call(ctx context.Context, input string) (string, error) {
    var req MyInput
    json.Unmarshal([]byte(input), &req)
    return "result", nil
}
```

### Creating an Agent

```go
runnable, _ := prebuilt.CreateAgentMap(llm, tools, maxIterations,
    prebuilt.WithSystemMessage("instructions"),
)
state := map[string]any{
    "messages": []llms.MessageContent{
        llms.TextParts(llms.ChatMessageTypeHuman, "user input"),
    },
}
resp, _ := runnable.Invoke(ctx, state)
```

### Building a Workflow Graph

```go
g := graph.NewListenableStateGraph[map[string]any]()
g.AddNode("step1", "description", func(ctx context.Context, s map[string]any) (map[string]any, error) {
    // use LLM, update state
    return s, nil
})
g.AddNode("step2", "description", stepTwoFunc)
g.AddEdge("step1", "step2")
g.AddEdge("step2", graph.END)
g.SetEntryPoint("step1")
runnable, _ := g.CompileListenable()
result, _ := runnable.Invoke(ctx, initialState)
```