Building AI Apps with Go: A Practical Guide with LangChainGo and LangGraphGo
Source: Dev.to
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. 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 .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. 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.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. json.Unmarshal — parse the LLM response directly into your Go struct. If the schema and prompt are right, this just works. 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. 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 J
SON 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 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. 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]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. 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 th
e {“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). 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) 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. 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), // B{tool implements ToolWithSchema?} B —>|Yes| C[Call tool.Schema] B —>|No| D[“Use default schema {input: string}”] C —> E[“Build llms.FunctionDefinition Parameters = schema”] D —> E E —> F[“Pass to LLM via llms.WithTools”] end
subgraph "Agent Loop (each iteration)"
direction TB
G[LLM responds with
tool call] —> H{tool implements ToolWithSchema?} H —>|Yes| I[“Pass raw JSON to Call()”] H —>|No| J[“Extract args.input then pass to Call()”] I —> K[tool.Call executes] J —> K K —> L[Result fed back 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”. Define your input struct with json tags and jsonschema tags for enums/descriptions. Create a tool struct implementing Name(), Description(), Call(), and Schema(). In Schema(), use jsonschema.Reflector{ExpandedStruct: true} to generate the schema (must be ExpandedStruct: true so the root is type: “object”, not a $ref). In Call(), unmarshal the raw JSON input into your input struct. Register the tool: inputTools := []tools.Tool{&MyTool{}}. 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. 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.NewListenableStateGraphMyState
g.AddNode( “summarize_email”, “Summarize the email”, func(ctx context.Context, state MyState) (MyState, error) { promp
tTemplate := 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.” 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.” 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). After spending time with these libraries, here are the things I wish I’d known upfront:
- 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.
- 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.
- 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.
- LLMs are sloppy with types. Dates without timezones, numbers as strings, etc. Build defensive unmarshaling (like FlexibleDate) rather than expecting perfect output.
- 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.
- 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”).
- 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.
- 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. 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”)
llm, _ := openai.New( // … openai.WithResponseFormat(openai.ResponseFormatJSON), ) // Include JSON schema in prompt, then json.Unmarshal(resp, &myStruct)
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 }
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 }
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)
g := graph.NewListenableStateGraphmap[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)