Home | Send Feedback | Share on Bluesky |

Eino: A Go Framework for Building LLM Applications

Published: 12. April 2026  •  go, llm

This blog post looks at Eino, a Go-based framework for building LLM applications. Eino is designed to help Go developers build everything from simple chat features to agent systems with tools, middleware, and orchestration.

Basic Chat

The most basic thing you can do with an LLM is send it a prompt and get a response. Eino supports a wide variety of model providers and abstracts them behind its ChatModel component, so you can build against Eino's interfaces and swap providers without large code changes.

The following example shows how to create a chat model that talks to an OpenAI-compatible endpoint. The program reads the API key and model name from environment variables and calls openai.NewChatModel to create the model instance.

func NewChatModel(ctx context.Context) (model.BaseChatModel, error) {
  apiKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
  modelName := strings.TrimSpace(os.Getenv("OPENAI_MODEL"))
  if apiKey == "" {
    return nil, fmt.Errorf("OPENAI_API_KEY is required")
  }
  if modelName == "" {
    return nil, fmt.Errorf("OPENAI_MODEL is required")
  }

  chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
    APIKey:     apiKey,
    Model:      modelName,
    BaseURL:    strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")),
    APIVersion: strings.TrimSpace(os.Getenv("OPENAI_API_VERSION")),
    ByAzure:    strings.EqualFold(strings.TrimSpace(os.Getenv("OPENAI_BY_AZURE")), "true"),
  })
  if err != nil {
    return nil, err
  }

  return chatModel, nil
}

model.go

In the main program, the example calls this helper and uses the chat model to send messages. In Eino, messages are typed objects with a role such as system, user, or assistant. This code snippet builds a short system message and a user message, then calls Generate to get a response.

  ctx := context.Background()
  chatModel, err := shared.NewChatModel(ctx)
  if err != nil {
    log.Fatal(err)
  }

  messages := []*schema.Message{
    schema.SystemMessage("You are a concise assistant."),
    schema.UserMessage(strings.TrimSpace(*prompt)),
  }

  reply, err := chatModel.Generate(ctx, messages)
  if err != nil {
    log.Fatal(err)
  }

  fmt.Println(reply.Content)

main.go

To run the demos in this post, set the OPENAI_API_KEY and OPENAI_MODEL environment variables and execute the program with a command line argument for the prompt.

export OPENAI_API_KEY="sk-..."
export OPENAI_MODEL="gpt-5.4-mini"
go run ./cmd/basic-chat --prompt "Where is Munich?"

Tool Calling

Tool calling is one of Eino's core application patterns. The following example uses a mock weather lookup tool. The program first defines the tool input, output, and implementation. In this demo, the function returns hardcoded values, but in a real application it could call an external API.

type weatherInput struct {
  City string `json:"city" jsonschema:"required" jsonschema_description:"The city to look up"`
}

type weatherOutput struct {
  Forecast string `json:"forecast"`
}

main.go

func lookupWeather(_ context.Context, input *weatherInput) (*weatherOutput, error) {
  forecasts := map[string]string{
    "hangzhou": "Light rain, 21C",
    "beijing":  "Sunny, 24C",
    "shanghai": "Cloudy, 23C",
  }

  city := strings.TrimSpace(input.City)
  forecast, ok := forecasts[strings.ToLower(city)]
  if !ok {
    forecast = "Weather data unavailable, assume mild conditions."
  }

  return &weatherOutput{
    Forecast: fmt.Sprintf("%s: %s", city, forecast),
  }, nil
}

main.go

Like the previous example, this demo application starts by creating a chat model.

  ctx := context.Background()
  chatModel, err := shared.NewChatModel(ctx)
  if err != nil {
    log.Fatal(err)
  }

main.go

The next step wraps the raw Go function in Eino's Tool abstraction. InferTool takes a tool name, a description, and the function implementation.

  weatherTool, err := toolutils.InferTool("lookup_weather", "Look up the current weather for a city.", lookupWeather)
  if err != nil {
    log.Fatal(err)
  }

main.go

Next, the example creates a ChatModelAgent and registers the tool in ToolsConfig. In Eino ADK, ChatModelAgent is the main prebuilt agent implementation. When tools are configured, it follows a ReAct-style loop: the model decides whether to call a tool, the agent executes that tool, and the result is fed back to the model until the model can produce a final answer.

  agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "weather-agent",
    Description: "An assistant that can answer questions and call a weather tool.",
    Instruction: "You are a helpful assistant. Use tools when needed.",
    Model:       chatModel,
    ToolsConfig: adk.ToolsConfig{
      ToolsNodeConfig: compose.ToolsNodeConfig{
        Tools: []toolcomp.BaseTool{weatherTool},
      },
    },
  })
  if err != nil {
    log.Fatal(err)
  }

main.go

The final piece is the Runner. In Eino ADK, the runner is the execution engine for agents. It manages the lifecycle of an agent run, including streaming output, multi-agent execution, and cross-cutting concerns such as interrupts and checkpoints.

  runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,
  })

  prompt := strings.TrimSpace(*question)
  if _, err := shared.PrintQueryAgentEvents(prompt, runner.Query(ctx, prompt)); err != nil {
    log.Fatal(err)
  }

main.go

When you run this example, the model first returns a tool call with the tool name and arguments. The agent executes the tool, appends the tool result to the in-flight message history for that run, and sends the updated context back to the model. That loop continues until the model produces a normal assistant response. For longer-lived, multi-turn state across runs, Eino also provides session and memory facilities.

MCP

Eino can integrate MCP tools through the eino-ext MCP tool package. MCP stands for Model Context Protocol, an open standard for connecting AI applications to external tools and data sources. In practice, that means you can connect to an MCP server, turn its exposed capabilities into Eino tools, and hand them to an agent like any other tool.

This example uses the Playwright MCP server running inside Docker. First, the code creates a stdio MCP client that launches the official Playwright MCP Docker image as a subprocess. Because the example uses the Docker CLI, you need Docker installed and working locally.

  mcpClient, err := client.NewStdioMCPClient(
    "docker",
    nil,
    "run",
    "-i",
    "--rm",
    "--init",
    "--pull=always",
    strings.TrimSpace(*image),
  )

main.go

The application then initializes the MCP client by sending an Initialize request. This handshake is required by the MCP protocol so the client and server can exchange protocol and implementation details before tool discovery begins.

  initRequest := mcp.InitializeRequest{}
  initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
  initRequest.Params.ClientInfo = mcp.Implementation{
    Name:    "eino-playwright-example",
    Version: "1.0.0",
  }

  initResult, err := mcpClient.Initialize(ctx, initRequest)
  if err != nil {
    log.Fatalf("initialize MCP client: %v", err)
  }

main.go

After initialization, the example calls mcpp.GetTools to load the tools exposed by the MCP server. That function returns Eino tool objects that wrap the remote MCP tools.

  mcpTools, err := mcpp.GetTools(ctx, &mcpp.Config{Cli: mcpClient})
  if err != nil {
    log.Fatalf("load MCP tools: %v", err)
  }
  if len(mcpTools) == 0 {
    log.Fatal("the Playwright MCP server exposed no tools")
  }

main.go

From the agent's perspective, these MCP-backed tools look like ordinary tools with names, descriptions, and schemas. That means the same ChatModelAgent pattern still applies.

  agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "playwright-mcp-agent",
    Description: "An agent that can browse web pages through Playwright MCP tools running in Docker.",
    Instruction: strings.Join([]string{
      "You are a browser automation assistant.",
      "Use the Playwright MCP tools to inspect and interact with pages when the task requires browsing.",
      "Prefer the smallest set of actions needed to answer accurately.",
    }, " "),
    Model: chatModel,
    ToolsConfig: adk.ToolsConfig{
      ToolsNodeConfig: compose.ToolsNodeConfig{
        Tools: mcpTools,
      },
    },
  })
  if err != nil {
    log.Fatal(err)
  }

main.go

Finally, the runner executes the agent and streams the intermediate events.

  runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,
  })

  prompt := strings.TrimSpace(*question)
  if _, err := shared.PrintQueryAgentEvents(prompt, runner.Query(ctx, prompt)); err != nil {
    log.Fatal(err)
  }

main.go

Skills

Middleware is an Eino ADK abstraction for cross-cutting concerns. Middleware can inspect and modify an agent's in-flight message history, tool calls, and other internal state on every turn.

Skills are reusable task packages, typically centered on a SKILL.md file, that give an agent specialized instructions for a specific kind of work. For example, a skill could describe how to write an Excel file, how to use a particular API, or how to perform a specific kind of reasoning.

Eino has a built-in skill middleware that can discover skills from a configured backend and load them into the agent's message history when relevant. The example uses a small custom disk-backed backend. That part is fully customizable: you could store skills on disk, in a database, or behind an API as long as you implement the Backend interface.

type Backend interface {
  List(ctx context.Context) ([]FrontMatter, error)
  Get(ctx context.Context, name string) (Skill, error)
}

skill.go

diskSkillBackend implements these two methods. List reads the available skills and returns their front matter. Get loads a specific skill by name and returns the full SKILL.md content.

The main program wires the skill middleware into the agent's middleware stack. The agent can then discover skills from their metadata and load them on demand through the generated skill tool.

  skillsDir, err := filepath.Abs(strings.TrimSpace(*skillsDirFlag))
  if err != nil {
    log.Fatal(err)
  }

  backend := &diskSkillBackend{baseDir: skillsDir}
  skillMiddleware, err := skill.NewMiddleware(ctx, &skill.Config{Backend: backend})
  if err != nil {
    log.Fatal(err)
  }

  agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "skill-agent",
    Description: "An agent that can discover and load reusable Eino skills from SKILL.md files.",
    Instruction: "You are a helpful assistant. When a matching skill exists, use it before answering.",
    Model:       chatModel,
    Handlers:    []adk.ChatModelAgentMiddleware{skillMiddleware},
  })
  if err != nil {
    log.Fatal(err)
  }

  fmt.Printf("Skills dir: %s\n", skillsDir)

  runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,
  })

main.go

Advanced Orchestration

Eino also supports more advanced agent orchestration patterns.

The following example shows how multiple agents can be composed into a graph-based workflow. It starts by creating several specialized agents. In this demo, each one is a ChatModelAgent with a different prompt. Agents can also have different tools and middleware configured, depending on their role in the overall workflow.

  ctx := context.Background()
  chatModel, err := shared.NewChatModel(ctx)
  if err != nil {
    log.Fatal(err)
  }

  routerAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "router-agent",
    Description: "Chooses whether a request should go to a direct responder or to the full analysis team.",
    Instruction: "You route requests. Reply with exactly one lowercase word: direct or team. Use team for multi-step, architectural, comparative, or strategy questions. Use direct for simple factual questions or short explanations.",
    Model:       chatModel,
  })
  if err != nil {
    log.Fatal(err)
  }

  directAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "direct-agent",
    Description: "Answers straightforward requests directly.",
    Instruction: "You are the fast-response agent. Answer directly in one or two tight paragraphs. Do not mention other agents.",
    Model:       chatModel,
  })
  if err != nil {
    log.Fatal(err)
  }

main.go

Methods such as NewSequentialAgent and NewParallelAgent let you combine these agents into workflow agents. In this example, reviewTeam runs the researcher and critic in parallel, while analysisTeam runs the planner, then the review team, and finally the drafter.

  reviewTeam, err := adk.NewParallelAgent(ctx, &adk.ParallelAgentConfig{
    Name:        "review-team",
    Description: "Runs the researcher and critic in parallel so the draft gets both supporting detail and pushback.",
    SubAgents:   []adk.Agent{researcherAgent, criticAgent},
  })
  if err != nil {
    log.Fatal(err)
  }

  analysisTeam, err := adk.NewSequentialAgent(ctx, &adk.SequentialAgentConfig{
    Name:        "analysis-team",
    Description: "For complex requests, plan the work, review it in parallel, and synthesize a draft.",
    SubAgents:   []adk.Agent{plannerAgent, reviewTeam, drafterAgent},
  })
  if err != nil {
    log.Fatal(err)
  }

main.go

With those agents in place, the example uses the compose package to build an explicit graph. Each node is a small lambda that runs one of the agents through its own runner and returns the result in the shape needed by the next node.

  routerNode := compose.InvokableLambda(func(ctx context.Context, input graphInput) (routeDecision, error) {
    runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: routerAgent, EnableStreaming: true})
    reply, err := runAgentNode(ctx, "router-agent", runner, input.Question)
    if err != nil {
      return routeDecision{}, err
    }

    return routeDecision{
      Question: input.Question,
      Route:    normalizeRoute(reply),
    }, nil
  })

  directNode := compose.InvokableLambda(func(ctx context.Context, input routeDecision) (string, error) {
    runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: directAgent, EnableStreaming: true})
    return runAgentNode(ctx, "direct-agent", runner, input.Question)
  })

  analysisNode := compose.InvokableLambda(func(ctx context.Context, input routeDecision) (teamDraft, error) {
    runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: analysisTeam, EnableStreaming: true})
    draft, err := runAgentNode(ctx, "analysis-team", runner, input.Question)
    if err != nil {
      return teamDraft{}, err
    }

    return teamDraft{
      Question: input.Question,
      Draft:    draft,
    }, nil
  })

  finalWriterNode := compose.InvokableLambda(func(ctx context.Context, input teamDraft) (string, error) {
    runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: writerAgent, EnableStreaming: true})
    prompt := fmt.Sprintf("Original question:\n%s\n\nDraft to polish:\n%s", input.Question, input.Draft)
    return runAgentNode(ctx, "writer-agent", runner, prompt)
  })

main.go

Those lambdas are then registered as graph nodes with AddLambdaNode.

  if err := graph.AddLambdaNode("router", routerNode); err != nil {
    return nil, err
  }
  if err := graph.AddLambdaNode("direct_answer", directNode); err != nil {
    return nil, err
  }
  if err := graph.AddLambdaNode("analysis_team", analysisNode); err != nil {
    return nil, err
  }
  if err := graph.AddLambdaNode("final_writer", finalWriterNode); err != nil {
    return nil, err
  }

main.go

Next, the graph is wired together with edges and a branch. It starts at the router node, which returns a routeDecision containing the original question and a routing decision. Based on that decision, the graph either goes to direct_answer or analysis_team. If it goes through the analysis team, it then proceeds to the final writer before ending.

AddEdge connects two nodes in a linear flow, while AddBranch allows conditional branching based on the output of a node. In this case, the router node's output determines which path the graph takes.

  if err := graph.AddEdge(compose.START, "router"); err != nil {
    return nil, err
  }
  if err := graph.AddBranch("router", compose.NewGraphBranch(func(_ context.Context, decision routeDecision) (string, error) {
    if decision.Route == "direct" {
      return "direct_answer", nil
    }
    return "analysis_team", nil
  }, map[string]bool{
    "direct_answer": true,
    "analysis_team": true,
  })); err != nil {
    return nil, err
  }
  if err := graph.AddEdge("direct_answer", compose.END); err != nil {
    return nil, err
  }
  if err := graph.AddEdge("analysis_team", "final_writer"); err != nil {
    return nil, err
  }
  if err := graph.AddEdge("final_writer", compose.END); err != nil {
    return nil, err
  }

  return graph.Compile(ctx, compose.WithGraphName("advanced-agent-graph"))

main.go

Compile turns the graph into a runnable object. The main program then calls Invoke on this object, and the graph executes the nodes in the correct order until it reaches the end node.

  graph, err := buildAdvancedGraph(ctx, routerAgent, directAgent, analysisTeam, writerAgent)
  if err != nil {
    log.Fatal(err)
  }

  printTopology()

  answer, err := graph.Invoke(ctx, graphInput{Question: strings.TrimSpace(*question)})
  if err != nil {
    log.Fatal(err)
  }

  fmt.Println("\n[final answer]")
  fmt.Println(answer)

main.go

Wrapping Up

Eino provides a strong set of abstractions for building LLM applications in Go, from direct model calls to tool-enabled agents, MCP integrations, skills, and explicit orchestration graphs. The examples in this post only cover a small part of the surface area. For the broader picture, see the Eino documentation, the ADK documentation, and the Cookbook.