AI agents by framework / Go

How to add an AI agent to an Echo app

An AI agent in an Echo app is a handler that calls a model with tools, runs the tool calls the model makes against your own data layer as the signed-in user, and streams the reply to the page as Server-Sent Events. The Anthropic Go SDK's tool runner handles the model loop, and Echo's middleware covers authentication, CSRF and rate limiting around it. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in an Echo app is made of

  • A server endpoint: an echo.HandlerFunc on a group such as e.Group("/api"). It holds the model API key, read from the server environment; the key never goes into templates or static files.
  • Tools: Go functions the model may call, each wrapping a query or service call your handlers already make. The input schema is generated from a struct's jsonschema tags.
  • The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. Cap the number of steps.
  • A chat UI: a small script on a server-rendered page that posts the conversation and reads the events the handler writes to c.Response().
  • Authorization and approval: group middleware puts the signed-in user on the *echo.Context, every tool is bound to that user, and changes to data wait until the user confirms them.

Ways to add an agent to Echo

ApproachGood forWhat you take on
A model provider's Go SDK in a handler (Anthropic, OpenAI or Google Gen AI)A custom agent with your own tools, prompts and UI.Tool definitions, authorization in every tool, streaming, a confirmation flow for writes, rate and cost limits.
An agent framework (for example Genkit for Go or Eino)Multi-step workflows, several model providers, orchestration and tracing.Another layer to learn and deploy, plus everything in the row above.
Hosted answer-only chat widgetQuestions about your docs or help center.Keeping its content current. It does not act in your app.
Embedded in-app agent (Bunkhouse)Letting signed-in users get tasks done through the pages and API you already have.A script tag, an identity endpoint and hints for operations discovery cannot see.

Build an agent with the Anthropic Go SDK

The Anthropic Go SDK is Anthropic's official Go client. Its tool runner, currently in beta, runs the agent loop: it calls the model, runs the tools the model asks for, sends the results back and repeats until the model answers. The examples below use SDK v2.4.0 with Echo 5.4 and Go 1.25 or later. They use Echo v5 signatures (func(c *echo.Context) error); in Echo v4, handlers take the echo.Context interface instead. Add the SDK to your module:

Terminal
go get github.com/anthropics/anthropic-sdk-go/[email protected]

anthropic.NewClient() reads the API key from the ANTHROPIC_API_KEY environment variable, and the model constant anthropic.ModelClaudeSonnet5_5 selects claude-sonnet-5-5. The OpenAI (openai-go) and Google (google.golang.org/genai) Go SDKs fit the same handler; only the tool-calling calls change.

1. Tools bound to the signed-in user

This file has no Echo code in it. newRunner builds both tools as closures over the user ID, so the model can choose an order ID but never whose orders it reads. toolrunner.NewBetaToolFromJSONSchema derives each tool's input schema from its struct. The cancelOrder tool does not cancel anything: it checks that the order belongs to the user and is open, records it as pending and tells the model the user has to confirm. The runner executes tools in parallel when the model requests several at once, so the pending list has a mutex. An error a tool returns is sent back to the model as an error result.

The browser sends the conversation as plain text turns, and history rebuilds it without any tool calls or tool results, so a client cannot forge them. MaxIterations: 5 caps the run at five model calls.

tools.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"sync"

	"github.com/anthropics/anthropic-sdk-go/v2"
	"github.com/anthropics/anthropic-sdk-go/v2/toolrunner"
)

type chatRequest struct {
	Messages []struct {
		Role string `json:"role"`
		Text string `json:"text"`
	} `json:"messages"`
}

// history accepts plain text turns only, so a client cannot forge tool calls or tool results.
func (r chatRequest) history() ([]anthropic.BetaMessageParam, error) {
	n := len(r.Messages)
	if n == 0 || n > 40 || r.Messages[n-1].Role != "user" {
		return nil, errors.New("invalid conversation")
	}
	out := make([]anthropic.BetaMessageParam, 0, n)
	for _, m := range r.Messages {
		if m.Text == "" {
			return nil, errors.New("empty message")
		}
		text := anthropic.NewBetaTextBlock(m.Text)
		switch m.Role {
		case "user":
			out = append(out, anthropic.NewBetaUserMessage(text))
		case "assistant":
			out = append(out, anthropic.BetaMessageParam{OfAssistant: &anthropic.BetaAssistantMessageParam{
				Content: []anthropic.BetaContentBlockParamUnion{text},
			}})
		default:
			return nil, errors.New("invalid role")
		}
	}
	return out, nil
}

// pendingCancels collects the cancellations the model proposed. The runner may run tools in parallel.
type pendingCancels struct {
	sync.Mutex
	ids []string
}

type listOrdersInput struct {
	Status string `json:"status,omitempty" jsonschema:"enum=open,enum=shipped,enum=cancelled"`
}

type cancelOrderInput struct {
	OrderID string `json:"orderId" jsonschema:"required,description=ID of the order to cancel"`
}

func jsonResult(v any) (anthropic.BetaToolResultBlockParamContentUnion, error) {
	data, err := json.Marshal(v)
	return anthropic.BetaToolResultBlockParamContentUnion{OfText: &anthropic.BetaTextBlockParam{Text: string(data)}}, err
}

// newRunner gives the model two tools that close over the authenticated user:
// the model chooses arguments, never whose orders it reads or cancels.
func newRunner(client anthropic.Client, userID string, history []anthropic.BetaMessageParam) (
	*anthropic.BetaToolRunnerStreaming, *pendingCancels, error) {
	pending := &pendingCancels{}
	listOrders, err := toolrunner.NewBetaToolFromJSONSchema("listOrders", "List the signed-in user's recent orders",
		func(ctx context.Context, in listOrdersInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
			orders, err := ListOrders(ctx, userID, in.Status) // your data layer
			if err != nil {
				return anthropic.BetaToolResultBlockParamContentUnion{}, err
			}
			return jsonResult(orders)
		})
	if err != nil {
		return nil, nil, err
	}
	cancelOrder, err := toolrunner.NewBetaToolFromJSONSchema("cancelOrder",
		"Propose cancelling one of the signed-in user's open orders. Nothing changes until the user confirms.",
		func(ctx context.Context, in cancelOrderInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
			order, err := GetOrder(ctx, userID, in.OrderID) // not found unless it belongs to userID
			if err != nil {
				return anthropic.BetaToolResultBlockParamContentUnion{}, err
			}
			if order.Status != "open" {
				return anthropic.BetaToolResultBlockParamContentUnion{}, errors.New("only open orders can be cancelled")
			}
			pending.Lock()
			pending.ids = append(pending.ids, order.ID)
			pending.Unlock()
			return jsonResult(map[string]string{"orderId": order.ID, "status": "waiting for the user to confirm"})
		})
	if err != nil {
		return nil, nil, err
	}
	runner := client.Beta.Messages.NewToolRunnerStreaming([]anthropic.BetaTool{listOrders, cancelOrder},
		anthropic.BetaToolRunnerParams{
			BetaMessageNewParams: anthropic.BetaMessageNewParams{
				Model:     anthropic.ModelClaudeSonnet5_5,
				MaxTokens: 2048,
				System: []anthropic.BetaTextBlockParam{
					{Text: "You help the signed-in user manage their orders. Use the tools; never guess order data."},
				},
				Messages: history,
			},
			MaxIterations: 5, // at most five model calls per request
		})
	return runner, pending, nil
}

2. The Echo handler and routes

requireUser is ordinary Echo middleware: it returns echo.ErrUnauthorized for signed-out requests, which Echo's error handler turns into a 401, and stores the user ID with c.Set. The handlers read it back with the generic echo.ContextGet[string]. The agent route also gets Echo's rate limiter, keyed by user instead of by IP address.

The handler binds and validates the body first, while it can still return an *echo.HTTPError. Then it commits a text/event-stream response and writes each event to c.Response(), flushing it with http.NewResponseController as Echo's SSE cookbook does. Once the response is committed the handler returns nil even after a failed run, because Echo's error handler can no longer change the status. The run gets c.Request().Context(), which Go cancels when the browser disconnects, with a two-minute limit on top.

When the loop ends, the handler sends one confirm event per pending cancellation. The POST /api/orders/:id/cancel route is the only code that cancels an order, and it runs as a normal authenticated request from the user.

agent.go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"github.com/anthropics/anthropic-sdk-go/v2"
	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

// requireUser rejects signed-out requests and stores the user your session middleware found.
func requireUser(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c *echo.Context) error {
		userID := currentUserID(c) // your existing session lookup
		if userID == "" {
			return echo.ErrUnauthorized
		}
		c.Set("userID", userID)
		return next(c)
	}
}

func registerAgentRoutes(e *echo.Echo, client anthropic.Client) {
	api := e.Group("/api", requireUser)
	perUser := middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
		Store: middleware.NewRateLimiterMemoryStore(10.0 / 60), // 10 runs a minute
		IdentifierExtractor: func(c *echo.Context) (string, error) {
			return echo.ContextGet[string](c, "userID")
		},
	})
	api.POST("/agent", agentHandler(client), perUser)
	// The only place an order is cancelled: a request the signed-in user confirmed.
	api.POST("/orders/:id/cancel", func(c *echo.Context) error {
		userID, _ := echo.ContextGet[string](c, "userID")
		if err := CancelOrder(c.Request().Context(), userID, c.Param("id")); err != nil {
			return echo.ErrNotFound
		}
		return c.NoContent(http.StatusNoContent)
	})
}

func agentHandler(client anthropic.Client) echo.HandlerFunc {
	return func(c *echo.Context) error {
		var req chatRequest
		if err := c.Bind(&req); err != nil {
			return echo.ErrBadRequest
		}
		history, err := req.history()
		if err != nil {
			return echo.NewHTTPError(http.StatusBadRequest, err.Error())
		}
		userID, _ := echo.ContextGet[string](c, "userID")
		runner, pending, err := newRunner(client, userID, history)
		if err != nil {
			return err
		}

		// Canceled when the browser disconnects or the run takes too long.
		ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Minute)
		defer cancel()
		w := c.Response()
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		w.WriteHeader(http.StatusOK)
		rc := http.NewResponseController(w)
		send := func(event string, data map[string]string) {
			payload, _ := json.Marshal(data) // a map[string]string always encodes
			fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload)
			rc.Flush() // a client that went away cancels ctx, which ends the loop
		}

		for !runner.IsCompleted() {
			for event, err := range runner.NextStreaming(ctx) {
				if err != nil {
					c.Logger().Error("agent run failed", "error", err)
					send("error", map[string]string{"message": "The assistant stopped. Try again."})
					return nil // the response is committed; nothing for Echo's error handler to do
				}
				switch e := event.AsAny().(type) {
				case anthropic.BetaRawContentBlockStartEvent:
					if tool, ok := e.ContentBlock.AsAny().(anthropic.BetaToolUseBlock); ok {
						send("tool", map[string]string{"name": tool.Name})
					}
				case anthropic.BetaRawContentBlockDeltaEvent:
					if delta, ok := e.Delta.AsAny().(anthropic.BetaTextDelta); ok {
						send("text", map[string]string{"text": delta.Text})
					}
				}
			}
		}
		for _, id := range pending.ids {
			send("confirm", map[string]string{"orderId": id})
		}
		send("done", map[string]string{})
		return nil
	}
}

Echo's middleware.CSRF() allows requests whose Sec-Fetch-Site header is same-origin, blocks cross-site POSTs, and falls back to a token for browsers that do not send the header. e.Start runs an http.Server without a write timeout, which the stream needs.

main.go (excerpt)
func main() {
	e := echo.New()
	e.Use(middleware.Recover())
	e.Use(middleware.CSRF()) // Sec-Fetch-Site check, X-CSRF-Token as the fallback
	// e.Use(yourSessionMiddleware)
	registerAgentRoutes(e, anthropic.NewClient()) // reads ANTHROPIC_API_KEY

	// Echo's server leaves WriteTimeout unset, so long streams are not cut off.
	if err := e.Start(":1323"); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

3. The page script

EventSource can only send GET requests, so the script posts the conversation with fetch and parses the events from the response body. It keeps the text turns in memory, sends them with each message and turns every confirm event into a button that posts to the cancel route with the user's cookies. The CSRF token is only needed by browsers without Sec-Fetch-Site; the middleware stores it in the context under "csrf" for your template to render.

public/agent.js
// Posts the conversation, then reads the Server-Sent Events from the response body.
// Expects <div id="agent-log"></div> and <form id="agent-form"><input name="message"></form> in the page.
const messages = [];
const log = document.querySelector("#agent-log");
const form = document.querySelector("#agent-form");
// Echo's CSRF middleware passes same-origin requests by their Sec-Fetch-Site header. Browsers that
// don't send it fall back to a token: render c.Get("csrf") into <meta name="csrf-token" content="...">.
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
const headers = { "Content-Type": "application/json", "X-CSRF-Token": csrf };

function add(text) {
  const p = log.appendChild(document.createElement("p"));
  p.textContent = text;
  return p;
}

function confirmCancel(orderId) {
  const button = log.appendChild(document.createElement("button"));
  button.textContent = "Cancel order " + orderId;
  button.onclick = async () => {
    const res = await fetch("/api/orders/" + encodeURIComponent(orderId) + "/cancel", { method: "POST", headers });
    button.replaceWith(add(res.ok ? "Order " + orderId + " cancelled." : "Cancelling failed."));
  };
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const text = form.elements.message.value.trim();
  if (!text) return;
  form.reset();
  messages.push({ role: "user", text });
  add(text);

  const res = await fetch("/api/agent", { method: "POST", headers, body: JSON.stringify({ messages }) });
  if (!res.ok) {
    add("Request failed (" + res.status + ").");
    messages.pop();
    return;
  }
  let reply = add("");
  let answer = "";
  let buffer = "";
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    let end;
    while ((end = buffer.indexOf("\n\n")) >= 0) {
      const frame = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);
      const name = frame.match(/^event: ?(.*)$/m)?.[1];
      const data = JSON.parse(frame.match(/^data: ?(.*)$/m)?.[1] ?? "{}");
      if (name === "text") {
        reply.textContent += data.text;
        answer += data.text;
      } else if (name === "tool") {
        add("Running " + data.name + "...");
        reply = add("");
      } else if (name === "confirm") {
        confirmCancel(data.orderId);
      } else if (name === "error") {
        add(data.message);
      }
    }
  }
  if (answer) messages.push({ role: "assistant", text: answer });
});

Production checklist for Echo

  • Authorize in middleware and in every tool. Put the agent and every route it relies on behind the group middleware, and scope each query to the user ID from the context, never to an ID the model or the browser supplies.
  • Keep CSRF protection on. Register middleware.CSRF() globally so the agent endpoint and the confirmation route are both covered. If you add TrustedOrigins, list only origins you control.
  • Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments with your business rules, keep irreversible actions behind a user confirmation, and keep internal details out of tool error messages, because they go to the model.
  • Allow for long streams. If you set a WriteTimeout (for example in echo.StartConfig's BeforeServeFunc), make it longer than a run or extend it for the agent route with http.NewResponseController(c.Response()).SetWriteDeadline. Bound each run with a context timeout instead, and turn off response buffering in proxies in front of Echo (for nginx, the X-Accel-Buffering: no header).
  • Limit cost. Cap model calls with MaxIterations and output with MaxTokens, keep the per-user rate limit (the memory store counts per process, so several instances need a shared RateLimiterStore), and set spending limits with your model provider.
  • Keep secrets server-side. The model API key belongs in the server environment or a secret manager, never in templates or static files. Log run failures with c.Logger() on the server and send the browser a generic message.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Echo app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.

The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Echo backend keeps enforcing its own authorization on every request.

In Echo, render the loader in the <head> of your layout template, after the identity hook and before your application's scripts, so it loads once per page. For signed-in users, add a GET /api/agent-identity route where your authentication middleware runs (for example after a global e.Use) that signs a short-lived token for the user your session already authenticated; the signing secret stays in a server environment variable.

templates/layout.html (excerpt)
<!-- In <head>, before your application's own scripts -->
<script src="/agent-identity.js"><\/script>
<script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>

The tested Echo integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.

When Bunkhouse fits, and when to build it yourself

  • Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
  • Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
  • Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
  • Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.

Try it on your Echo app first

A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Echo app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Echo page at http://localhost:1323.

Use a page with disposable data: everything the assistant reads is sent to your agent.

The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.