AI agents by framework / Java

How to add an AI agent to a Spring Boot app

An AI agent in a Spring Boot app is a controller that calls a model through Spring AI's ChatClient, lets it call @Tool methods bound to the signed-in user, and streams the reply to the page as server-sent events. Spring AI runs the tool loop; Spring Security still decides who the user is and checks the CSRF token on every write. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in a Spring Boot app is made of

  • A controller endpoint: a @RestController method such as POST /agent that sits behind your existing SecurityFilterChain, keeps the model API key on the server and returns a Flux that Spring MVC writes as text/event-stream.
  • Tools: methods annotated with Spring AI's @Tool, each wrapping a service your app already has. They live on an object the controller creates per request, so every tool already knows the user.
  • The agent loop: ChatClient registers a tool-calling advisor that runs the tools the model asks for and calls the model again until it can answer. Cap the number of tool calls.
  • A chat UI: a Thymeleaf template and a small script that posts the message with the CSRF token and renders the stream.
  • Authorization and approval: tools act only for the authenticated principal, and a write the model proposes is confirmed by the user through your normal, CSRF-protected endpoint.

Ways to add an agent to Spring Boot

ApproachGood forWhat you take on
Spring AI ChatClient in a controllerA custom agent with your own tools, model provider and UI, inside the Spring app you already run.Tool definitions, authorization in every tool, the confirmation flow, rate and cost limits.
LangChain4j with its Spring Boot startersTeams already using LangChain4j, or wanting its agentic module for multi-agent workflows.A second library's conventions next to Spring's, 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 Spring AI

Spring AI is the Spring project's library for model calls, tool calling and chat memory. The examples below use Spring AI 2.0, which supports Spring Boot 4.0 and 4.1, with the Anthropic starter; the ChatClient code is the same for other providers. Import the Spring AI BOM and add the starter. The ChatClient reference notes that streaming needs the reactive stack, so a Spring MVC app also adds the WebFlux starter; the app keeps running on the servlet stack.

pom.xml (excerpt)
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>2.0.2</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-anthropic</artifactId>
  </dependency>
  <!-- ChatClient streaming needs the reactive stack, also in a Spring MVC app -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
  </dependency>
</dependencies>

Set the model and a hard cap on tool calls per user message. With the default THROW behaviour, a turn that reaches the cap ends there and the limit message becomes the final answer. See Tool Calling for the per-tool limits and the other settings.

src/main/resources/application.properties
spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
spring.ai.anthropic.chat.model=claude-sonnet-5-5
# At most 5 tool calls per user message
spring.ai.tools.limits.max-total-tool-calls=5
spring.mvc.async.request-timeout=120s

1. Tools bound to the signed-in user

The tools are @Tool methods on a plain class, not a singleton bean. The controller creates one instance per request with the authenticated user's ID, so the model can choose an order ID but never whose orders it reads. This is more than style: when the reply is streamed, Spring AI runs the tools on a Reactor worker thread, where Spring Security's thread-bound SecurityContextHolder is not populated.

The cancelOrder tool does not cancel anything. Spring AI has no built-in step that pauses the loop for a user's approval (you could build one in a custom tool-calling advisor), so the simplest honest pattern is a tool that checks the order and records a pending cancellation, which the user then confirms in the page.

src/main/java/com/example/shop/OrderTools.java
package com.example.shop;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

/** The agent's tools for one request. Not a bean: the controller creates one per request for the signed-in user. */
class OrderTools {

  private final OrderService orders;
  private final String userId;
  final List<String> pendingCancellations = new CopyOnWriteArrayList<>();

  OrderTools(OrderService orders, String userId) {
    this.orders = orders;
    this.userId = userId;
  }

  @Tool(description = "List the signed-in user's recent orders")
  List<Order> listOrders(@ToolParam(description = "open, shipped or cancelled", required = false) String status) {
    return orders.list(userId, status);
  }

  @Tool(description = "Ask the user to confirm cancelling one of their open orders. Nothing is cancelled until they confirm.")
  String cancelOrder(String orderId) {
    Order order = orders.find(userId, orderId); // null unless the order belongs to this user
    if (order == null || !order.isOpen()) return "There is no open order " + orderId + " for this user.";
    pendingCancellations.add(orderId);
    return "The user has been asked to confirm cancelling order " + orderId + ".";
  }
}

2. The streaming controller

The controller builds one ChatClient with a system prompt and a MessageChatMemoryAdvisor, using the in-memory ChatMemory that Spring AI auto-configures. The conversation ID is the user ID, so conversations never mix. Each request passes that user's tools, streams the reply with .stream().content() and maps every chunk to a small JSON event. Because the method returns a Flux and produces text/event-stream, Spring MVC writes each item as an SSE data: line, as described in its asynchronous requests reference.

When the model finishes, the controller sends a confirm-cancel event for every cancellation the model proposed. The cancel endpoint is the one your app would have anyway: Spring Security authenticates the request and checks its CSRF token, and the service scopes the update to the user. AppUser stands for your own principal type.

src/main/java/com/example/shop/AgentController.java
package com.example.shop;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
class AgentController {

  record AgentRequest(String message) {}
  record AgentEvent(String type, String value) {}

  private final ChatClient chatClient;
  private final OrderService orders;

  AgentController(ChatClient.Builder builder, ChatMemory chatMemory, OrderService orders) {
    this.chatClient = builder
        .defaultSystem("You help the signed-in user manage their orders. Use the tools; never guess order data.")
        .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
        .build();
    this.orders = orders;
  }

  @PostMapping(path = "/agent", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  Flux<AgentEvent> agent(@RequestBody AgentRequest request, @AuthenticationPrincipal AppUser user) {
    OrderTools tools = new OrderTools(orders, user.id());

    Flux<AgentEvent> text = chatClient.prompt()
        .user(request.message())
        .tools(tools)
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, user.id()))
        .stream()
        .content()
        .map(chunk -> new AgentEvent("text", chunk));

    // After the model finishes, ask the browser to confirm any cancellation the model proposed.
    Flux<AgentEvent> confirmations = Flux.defer(() -> Flux.fromIterable(tools.pendingCancellations))
        .map(orderId -> new AgentEvent("confirm-cancel", orderId));

    return text.concatWith(confirmations)
        .onErrorResume(e -> Flux.just(new AgentEvent("error", "The agent stopped before finishing.")));
  }

  // Your normal endpoint: authenticated, CSRF-protected by Spring Security, scoped to the user.
  @PostMapping("/orders/{orderId}/cancel")
  ResponseEntity<Void> cancel(@PathVariable String orderId, @AuthenticationPrincipal AppUser user) {
    orders.cancel(user.id(), orderId);
    return ResponseEntity.noContent().build();
  }
}

3. The page and script

Spring Security's CSRF reference describes rendering the token and its header name into _csrf and _csrf_header meta tags for JavaScript. The script posts the message with that header and reads the stream with fetch, because EventSource cannot send a POST body or custom headers. For each proposed cancellation it renders a button that posts to the normal cancel endpoint with the same header.

src/main/resources/templates/orders.html (excerpt)
<head>
  <meta name="_csrf" th:content="${_csrf.token}">
  <meta name="_csrf_header" th:content="${_csrf.headerName}">
  <script src="/agent.js" defer><\/script>
</head>
<body>
  <div id="agent-log" aria-live="polite"></div>
  <form id="agent-form">
    <input name="message" aria-label="Ask the agent" autocomplete="off" required>
    <button>Send</button>
  </form>
</body>
src/main/resources/static/agent.js
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').content;
const csrfToken = document.querySelector('meta[name="_csrf"]').content;
const log = document.getElementById("agent-log");

document.getElementById("agent-form").addEventListener("submit", async (event) => {
  event.preventDefault();
  const input = event.currentTarget.elements.message;
  const reply = log.appendChild(document.createElement("p"));
  const res = await fetch("/agent", {
    method: "POST",
    headers: { "Content-Type": "application/json", [csrfHeader]: csrfToken },
    body: JSON.stringify({ message: input.value }),
  });
  input.value = "";
  if (!res.ok) {
    reply.textContent = "The agent is unavailable.";
    return;
  }
  for await (const { type, value } of readEvents(res)) {
    if (type === "text" || type === "error") reply.textContent += value;
    if (type === "confirm-cancel") log.appendChild(confirmButton(value));
  }
});

// The cancellation itself goes through your normal endpoint, with the session cookie and CSRF token.
function confirmButton(orderId) {
  const button = document.createElement("button");
  button.textContent = `Cancel order ${orderId}`;
  button.addEventListener("click", async () => {
    button.disabled = true;
    const res = await fetch(`/orders/${encodeURIComponent(orderId)}/cancel`, {
      method: "POST",
      headers: { [csrfHeader]: csrfToken },
    });
    button.textContent = res.ok ? `Order ${orderId} cancelled` : "Could not cancel the order";
  });
  return button;
}

// Reads the text/event-stream body: one JSON object per "data:" line.
async function* readEvents(res) {
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) return;
    buffer += value;
    const lines = buffer.split("\n");
    buffer = lines.pop();
    for (const line of lines) {
      if (line.startsWith("data:")) yield JSON.parse(line.slice(5));
    }
  }
}

Production checklist for Spring Boot

  • Authorize in the controller and in every tool. Keep /agent behind your SecurityFilterChain, read the user with @AuthenticationPrincipal and pass the ID into the tools. Do not read SecurityContextHolder inside a tool, and scope every query to the user.
  • Keep CSRF protection on. Do not add /agent to ignoringRequestMatchers; send the token from the meta tags, as the script does. The confirmation endpoint is a normal POST and needs the token too.
  • 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 against your own business rules and keep irreversible actions behind the user's confirmation.
  • Limit cost. Without configuration Spring AI allows 40 calls per tool and 150 tool calls per turn; set spring.ai.tools.limits.max-total-tool-calls much lower. Rate-limit /agent per user and set spending limits with your model provider.
  • Plan for long streams. Set spring.mvc.async.request-timeout, because the default depends on the servlet container. Spring MVC writes streamed items on the AsyncTaskExecutor that Spring Boot auto-configures, so size it with the spring.task.execution.pool properties or enable virtual threads with spring.threads.virtual.enabled. The Servlet API does not report a closed browser tab until the next write fails.
  • Persist memory deliberately. The auto-configured chat memory keeps the last 20 messages per conversation in the JVM, so it is lost on restart and not shared between instances. Spring AI ships JDBC, Redis and other repositories in its chat memory support.
  • Keep secrets server-side. Read the model key from an environment variable or your secret store, never from a template or a committed properties file.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Spring Boot 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 Spring Boot backend keeps enforcing its own authorization on every request.

In Spring Boot, put the loader in the <head> of your shared Thymeleaf layout, after the two page hooks and before your own scripts, so it loads once per page. For signed-in users, add a GET /api/agent-identity controller method that signs a short-lived token for the @AuthenticationPrincipal Spring Security resolved; the signing secret stays in a server environment variable. The getAuthHeaders hook in agent-auth-headers.js returns the token from the _csrf meta tags, so the assistant's writes pass Spring Security's CSRF check.

src/main/resources/templates/layout.html (excerpt)
<head>
  <meta name="_csrf" th:content="${_csrf.token}">
  <meta name="_csrf_header" th:content="${_csrf.headerName}">
  <!-- Page hooks first, then the loader, then your application's scripts -->
  <script src="/agent-identity.js"><\/script>
  <script src="/agent-auth-headers.js"><\/script>
  <script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
  <script src="/app.js" defer><\/script>
</head>

The tested Spring Boot 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 Spring Boot 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 Spring Boot 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 Spring Boot page at http://localhost:8080.

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.