AI agents by framework / Java
How to add an AI agent to a Micronaut application
An AI agent in a Micronaut application is a controller that calls a Micronaut LangChain4j @AiService, lets the model call @Tool methods for the authenticated user and streams the reply as server-sent events. Micronaut LangChain4j wires the service, the tools and chat memory into dependency injection; you supply the tools, authorization and limits. If the agent only needs to do what your pages already do, you can embed one instead.
What an agent in a Micronaut application is made of
- A controller endpoint: a
@Controllermethod such asPOST /agent, secured with Micronaut Security, that returns a reactive stream astext/event-stream. The model API key stays in server configuration. - Tools: methods annotated with
@Toolon a@Singletonbean, each wrapping a service your app already has. The controller hands them the user's ID for each call; the model never sees it. - The agent loop: an interface annotated with
@AiService. Micronaut creates the implementation, which calls the model, runs the requested tools and calls the model again until it can answer, up to a configured number of tool-calling round trips. - A chat UI: a Micronaut Views 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 user, and a write the model proposes is confirmed by the user through your normal, CSRF-protected endpoint.
Ways to add an agent to Micronaut
| Approach | Good for | What you take on |
|---|---|---|
Micronaut LangChain4j @AiService | A custom agent with your own tools, model provider and UI, wired through Micronaut's dependency injection. | Tool definitions, authorization in every tool, the confirmation flow, rate and cost limits, and a module its documentation calls experimental. |
LangChain4j agentic services (micronaut-langchain4j-agentic) | Several cooperating agents in sequence, in parallel or in a loop. | Another layer of agent definitions to design and test, plus everything in the row above. |
| Hosted answer-only chat widget | Questions 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 Micronaut LangChain4j
Micronaut LangChain4j integrates LangChain4j with Micronaut: @AiService interfaces become beans that use your @Tool beans and chat memory, with its annotation processor on the compiler path. The examples use the versions the Micronaut 5.2 platform manages (Micronaut LangChain4j 2.3 with LangChain4j 1.20) in an app that already uses Micronaut Security with sessions and CSRF protection.
Two details of version 2.3.0 shape the setup. Its provider modules send model requests through a Micronaut HTTP client adapter that, for streaming requests, sends no request body, so the example defines the streaming model with LangChain4j's own Anthropic module instead. And an @AiService method that returns Flux<String> is not wired to the streaming model, so the service returns LangChain4j's TokenStream and the controller adapts it.
<!-- Versions come from the Micronaut platform parent (io.micronaut.platform:micronaut-parent) -->
<dependency>
<groupId>io.micronaut.langchain4j</groupId>
<artifactId>micronaut-langchain4j-core</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-anthropic</artifactId>
</dependency>
<dependency>
<groupId>io.micronaut.reactor</groupId>
<artifactId>micronaut-reactor</artifactId>
</dependency>
<!-- In the maven-compiler-plugin configuration -->
<annotationProcessorPaths combine.children="append">
<path>
<groupId>io.micronaut.langchain4j</groupId>
<artifactId>micronaut-langchain4j-processor</artifactId>
<version>${micronaut.langchain4j.version}</version>
</path>
</annotationProcessorPaths>anthropic.api-key=${ANTHROPIC_API_KEY}
# Check the CSRF token on JSON requests too (the default covers form posts only)
micronaut.security.csrf.filter.content-types=application/x-www-form-urlencoded,multipart/form-data,application/json
micronaut.security.csrf.signature-key=${CSRF_SIGNATURE_KEY}1. The model and the tools
A factory exposes the streaming chat model as a bean, which @AiService picks up. The tools are a @Singleton bean, so they cannot hold the current user. Instead each tool declares an InvocationParameters argument: LangChain4j passes the parameters given to the AI service call on to the tools and leaves them out of the tool schema the model sees (see LangChain4j tools). The controller puts the user ID and a list for pending cancellations there. This works across threads: while streaming, LangChain4j runs the tools on a different thread from the request.
The cancelOrder tool does not cancel anything. LangChain4j has no built-in step that pauses the loop for the user's approval, so the tool checks the order and records a pending cancellation, which the user then confirms in the page.
package com.example.shop;
import dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;
import dev.langchain4j.model.chat.StreamingChatModel;
import io.micronaut.context.annotation.Factory;
import io.micronaut.context.annotation.Value;
import jakarta.inject.Singleton;
import java.time.Duration;
@Factory
class ModelFactory {
@Singleton
StreamingChatModel streamingChatModel(@Value("${anthropic.api-key}") String apiKey) {
return AnthropicStreamingChatModel.builder()
.apiKey(apiKey)
.modelName("claude-sonnet-5-5")
.timeout(Duration.ofSeconds(60))
.build();
}
}package com.example.shop;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.invocation.InvocationParameters;
import jakarta.inject.Singleton;
import java.util.List;
@Singleton
public class OrderTools {
private final OrderService orders;
OrderTools(OrderService orders) {
this.orders = orders;
}
// InvocationParameters is filled in by the controller for each request; the model never sees it.
@Tool("List the signed-in user's recent orders")
public List<Order> listOrders(@P(value = "open, shipped or cancelled", required = false) String status,
InvocationParameters request) {
return orders.list(request.get("userId"), status);
}
@Tool("Ask the user to confirm cancelling one of their open orders. Nothing is cancelled until they confirm.")
public String cancelOrder(String orderId, InvocationParameters request) {
Order order = orders.find(request.get("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.";
List<String> pending = request.get("pendingCancellations");
pending.add(orderId);
return "The user has been asked to confirm cancelling order " + orderId + ".";
}
}2. The AI service and the streaming controller
@AiService(tools = OrderTools.class) creates the service bean, and an AiServiceCustomizer caps the loop with maxToolCallingRoundTrips on the underlying LangChain4j builder: each model response that asks for tools counts once, however many tool calls it contains. @MemoryId keys the in-memory chat memory by the user ID, which the controller takes from Micronaut Security's Authentication, never from the request body.
package com.example.shop;
import dev.langchain4j.invocation.InvocationParameters;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.TokenStream;
import dev.langchain4j.service.UserMessage;
import io.micronaut.langchain4j.aiservices.AiServiceCreationContext;
import io.micronaut.langchain4j.aiservices.AiServiceCustomizer;
import io.micronaut.langchain4j.annotation.AiService;
import jakarta.inject.Singleton;
@AiService(tools = OrderTools.class, customizer = OrderAgent.Limits.class)
public interface OrderAgent {
@SystemMessage("You help the signed-in user manage their orders. Use the tools; never guess order data.")
TokenStream chat(@MemoryId String userId, @UserMessage String message, InvocationParameters request);
@Singleton
class Limits implements AiServiceCustomizer<OrderAgent> {
@Override
public void customize(AiServiceCreationContext<OrderAgent> context) {
context.aiServices().maxToolCallingRoundTrips(5);
}
}
}The controller adapts the TokenStream to a Reactor Flux. A controller method that produces text/event-stream and returns a publisher is sent as server-sent events, one JSON data: line per item. After the model finishes, the controller sends a confirm-cancel event for every cancellation the model proposed. Reaching the round-trip cap fails the stream, so the controller turns any failure into an error event. The cancel endpoint is a normal authenticated POST that the CSRF filter checks.
package com.example.shop;
import dev.langchain4j.invocation.InvocationParameters;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.serde.annotation.Serdeable;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import reactor.core.publisher.Flux;
@Controller
@Secured(SecurityRule.IS_AUTHENTICATED)
class AgentController {
@Serdeable record AgentRequest(String message) {}
@Serdeable record AgentEvent(String type, String value) {}
private final OrderAgent agent;
private final OrderService orders;
AgentController(OrderAgent agent, OrderService orders) {
this.agent = agent;
this.orders = orders;
}
@Post(uri = "/agent", produces = MediaType.TEXT_EVENT_STREAM)
Flux<AgentEvent> chat(@Body AgentRequest request, Authentication user) {
String userId = user.getName(); // your stable user ID
List<String> pending = new CopyOnWriteArrayList<>();
InvocationParameters params = InvocationParameters.from(Map.of("userId", userId, "pendingCancellations", pending));
Flux<AgentEvent> text = Flux.create(sink -> agent.chat(userId, request.message(), params)
.onPartialResponse(chunk -> sink.next(new AgentEvent("text", chunk)))
.onCompleteResponse(response -> sink.complete())
.onError(sink::error)
.start());
// After the model finishes, ask the browser to confirm any cancellation the model proposed.
Flux<AgentEvent> confirmations = Flux.defer(() -> Flux.fromIterable(pending))
.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, checked by the CSRF filter, scoped to the user.
@Post("/orders/{orderId}/cancel")
HttpResponse<Void> cancel(String orderId, Authentication user) {
orders.cancel(user.getName(), orderId);
return HttpResponse.noContent();
}
}3. The page and script
With Micronaut Security CSRF and session authentication, Micronaut Views adds the session's csrfToken to the template model. The script sends it in the X-CSRF-TOKEN header, the default header name, 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.
<head>
<meta name="csrf-token" th:content="${csrfToken}">
<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>const csrfHeader = "X-CSRF-TOKEN";
const csrfToken = document.querySelector('meta[name="csrf-token"]').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 Micronaut
- Authorize in the controller and in every tool. Secure the controller with
@Secured(SecurityRule.IS_AUTHENTICATED), take the user from theAuthenticationargument and scope every tool query to the ID passed inInvocationParameters. - Check CSRF on JSON requests. The CSRF filter only processes the content types in
micronaut.security.csrf.filter.content-types, which by default are form posts. Addapplication/jsonso/agentis covered, and send the token from the confirmation button 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. Keep the round-trip cap low, since one round trip can contain several tool calls. Rate-limit
/agentper user and set spending limits with your model provider. Set a model timeout in the factory, as above. - Pin and re-test versions. Micronaut LangChain4j is documented as experimental and subject to change. Pin the platform version and re-run your streaming test when you upgrade.
- Persist memory deliberately. The default chat memory store lives in the JVM, so it is lost on restart and not shared between instances. Configure a persistent chat memory store when you run more than one instance.
- Keep secrets server-side. Read the model key and the CSRF signature key from environment variables 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 Micronaut 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 Micronaut backend keeps enforcing its own authorization on every request.
In Micronaut, put the loader in the <head> of your Micronaut Views layout, after the two page hooks (static files in src/main/resources/public) and before your own scripts, so it loads once per page. For signed-in users, add GET /api/agent-identity to a controller that signs a short-lived token for the Authentication Micronaut Security resolved. That controller is @Secured(SecurityRule.IS_ANONYMOUS) and answers 401 itself, so a signed-out request never gets a login redirect, and the signing secret stays in a server environment variable. The getAuthHeaders hook returns the session's CSRF token for the assistant's writes.
<head>
<meta name="csrf-token" th:content="${csrfToken}">
<!-- 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 Micronaut 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 Micronaut 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 Micronaut 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:
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Micronaut 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.
Related guides
- How to add AI to your website: chat assistants, in-app agents and custom builds compared
- MCP vs WebMCP: choosing where outside agents act on your site
- AI agents in other Java frameworks: Quarkus, Spring Boot