- 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 and 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 repository. A huge shout-out to the langgraphgo examples directory — 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.
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
.envfile viagodotenv. 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.
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:
-
openai.WithResponseFormat(openai.ResponseFormatJSON)— tells the LLM to respond in JSON mode. -
JSON Schema in the prompt — use
jsonschema.Reflectorto 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. -
json.Unmarshal— parse the LLM response directly into your Go struct. If the schema and prompt are right, this just works. -
jsonschemastruct tags — use tags likejsonschema:"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 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 object. You'll reach for it in two places:
- Structured output — embed the schema in a prompt so the LLM knows what JSON shape to produce (as shown above).
-
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:
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 expecttype: "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).
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]anywith 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().
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:
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:
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:
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) callsave_todo_itemswith structured input, (3) produce a final summary. - The
get_current_date_timetool uses the default schema ({"input": "..."}) whilesave_todo_itemsuses 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, create_agent.go, and the langchaingo tools/tool.go.
Step 1: The tools.Tool interface (langchaingo)
The foundation is langchaingo's tools.Tool — just three methods:
// 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:
// 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:
// 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:
// 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:
// 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'sArgumentsJSON (e.g.{"todo_items":[...]}) is passed as-is toCall(). 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 toCall().
Here's the full flow as a diagram:
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:
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)
- Define your input struct with
jsontags andjsonschematags for enums/descriptions. - Create a tool struct implementing
Name(),Description(),Call(), andSchema(). - In
Schema(), usejsonschema.Reflector{ExpandedStruct: true}to generate the schema (must beExpandedStruct: trueso the root istype: "object", not a$ref). - In
Call(), unmarshal the raw JSON input into your input struct. - 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.
flowchart LR
START([START]) --> summarize[summarize_email]
summarize --> extract[extract_todo_items]
extract --> END([END])
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:
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):
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:
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.
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
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
llm, _ := openai.New(
// ...
openai.WithResponseFormat(openai.ResponseFormatJSON),
)
// Include JSON schema in prompt, then json.Unmarshal(resp, &myStruct)
Defining a Simple Tool
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
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
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
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)
Top comments (0)