AI agents by framework / Java

How to add an AI agent to a Quarkus application

An AI agent in a Quarkus application is a Quarkus REST endpoint that calls a Quarkus LangChain4j AI service, which runs @Tool methods on CDI beans as the authenticated caller and streams the reply as a Mutiny Multi over server-sent events. The extension wires the model, the tool loop and chat memory; 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 Quarkus application is made of

  • A REST endpoint: a Quarkus REST resource method such as POST /agent, protected by Quarkus Security, that returns a Multi as text/event-stream. The model API key stays in server configuration.
  • Tools: methods annotated with @Tool on CDI beans, each wrapping a service your app already has. They read the user from the injected SecurityIdentity, not from anything the model sends.
  • The agent loop: an interface annotated with @RegisterAiService. Quarkus generates 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 Qute 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 identity, and a write the model proposes is confirmed by the user through your normal, CSRF-protected endpoint.

Ways to add an agent to Quarkus

ApproachGood forWhat you take on
Quarkus LangChain4j AI serviceA custom agent with your own tools, model provider and UI, wired through CDI and configured like any other extension.Tool definitions, authorization in every tool, the confirmation flow, rate and cost limits.
Quarkus LangChain4j agentic workflows (quarkus-langchain4j-agentic)Several cooperating agents in sequence, in parallel or under a supervisor.Another layer of agent definitions to design and test, 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 Quarkus LangChain4j

Quarkus LangChain4j is the Quarkiverse extension family that integrates LangChain4j with Quarkus: AI services are annotated interfaces, tools are CDI beans and streamed replies are Mutiny Multi values. The examples use the version managed by the Quarkus 3.40 platform (Quarkus LangChain4j 1.13) with the Anthropic extension. Add it, together with the REST CSRF extension, using the Quarkus Maven plugin:

Terminal
./mvnw quarkus:add-extension -Dextensions="io.quarkiverse.langchain4j:quarkus-langchain4j-anthropic,rest-csrf"

Configure the key, the model and a request timeout. The chat memory window counts tool calls and tool results as messages, so the default of 10 can drop the user's question in the middle of a multi-step turn; raise it for an agent that uses tools.

src/main/resources/application.properties
quarkus.langchain4j.anthropic.api-key=${ANTHROPIC_API_KEY}
quarkus.langchain4j.anthropic.chat-model.model-name=claude-sonnet-5-5
quarkus.langchain4j.anthropic.timeout=60s
# Tool calls and results count toward the memory window
quarkus.langchain4j.chat-memory.memory-window.max-messages=30

1. Tools that act as the caller

Tools are methods annotated with LangChain4j's @Tool on a CDI bean. According to the function calling reference, Quarkus LangChain4j propagates the request scope to tool invocations, also when a streamed response moves them to a worker thread. The tool bean can therefore inject SecurityIdentity and take the user from it, so the model can choose an order ID but never whose orders it reads. The bean is @RequestScoped, which gives each request its own list of pending cancellations.

The cancelOrder tool does not cancel anything. Quarkus 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.

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

import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

@RequestScoped
public class OrderTools {

    @Inject SecurityIdentity identity; // the caller of the AI service: tool calls run in its request scope
    @Inject OrderService orders;

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

    @Tool("List the signed-in user's recent orders")
    public List<Order> listOrders(@P(value = "open, shipped or cancelled", required = false) String status) {
        return orders.list(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) {
        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 + ".";
    }

    public List<String> pendingCancellations() {
        return pendingCancellations;
    }

    private String userId() {
        return identity.getPrincipal().getName(); // your stable user ID
    }
}

2. The AI service and the streaming endpoint

@RegisterAiService turns the interface into a CDI bean that runs the tool loop, and maxToolCallingRoundTrips caps the tool-calling round trips per request: each model response that asks for tools counts once, however many tool calls it contains (otherwise quarkus.langchain4j.ai-service.max-tool-calling-round-trips applies, 10 by default). AI services are request-scoped by default, which also limits their memory to one request. This one is @ApplicationScoped and keys its memory with @MemoryId, which the resource fills from SecurityIdentity; never take a memory ID from the client. See Messages and Memory.

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

import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
import io.smallrye.mutiny.Multi;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
@RegisterAiService(tools = OrderTools.class, maxToolCallingRoundTrips = 5)
@SystemMessage("You help the signed-in user manage their orders. Use the tools; never guess order data.")
public interface OrderAgent {

    // The memory ID keeps each user's conversation separate. Always pass it from the server-side identity.
    Multi<String> chat(@MemoryId String userId, @UserMessage String message);
}

A Quarkus REST method that returns a Multi and produces SERVER_SENT_EVENTS sends each item as an event, and @RestStreamElementType(APPLICATION_JSON) writes each item as JSON on its own data: line. The resource takes this request's pending-cancellation list before streaming and sends a confirm-cancel event for each entry after the model finishes. Reaching the round-trip cap fails the stream, so the resource turns any failure into an error event. The cancel endpoint is a normal authenticated POST that quarkus-rest-csrf protects.

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

import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.mutiny.Multi;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import org.jboss.resteasy.reactive.RestPath;
import org.jboss.resteasy.reactive.RestStreamElementType;

@Path("/")
@Authenticated
public class AgentResource {

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

    @Inject SecurityIdentity identity;
    @Inject OrderAgent agent;
    @Inject OrderTools tools;
    @Inject OrderService orders;

    @POST
    @Path("agent")
    @Produces(MediaType.SERVER_SENT_EVENTS)
    @RestStreamElementType(MediaType.APPLICATION_JSON)
    public Multi<AgentEvent> chat(AgentRequest request) {
        String userId = identity.getPrincipal().getName();
        List<String> pending = tools.pendingCancellations(); // this request's list, filled by cancelOrder

        Multi<AgentEvent> text = agent.chat(userId, request.message())
                .map(chunk -> new AgentEvent("text", chunk));
        // After the model finishes, ask the browser to confirm any cancellation the model proposed.
        Multi<AgentEvent> confirmations = Multi.createFrom().deferred(() -> Multi.createFrom().iterable(pending))
                .map(orderId -> new AgentEvent("confirm-cancel", orderId));

        return Multi.createBy().concatenating().streams(text, confirmations)
                .onFailure().recoverWithItem(new AgentEvent("error", "The agent stopped before finishing."));
    }

    // Your normal endpoint: authenticated, CSRF-protected by quarkus-rest-csrf, scoped to the user.
    @POST
    @Path("orders/{orderId}/cancel")
    public void cancel(@RestPath String orderId) {
        orders.cancel(identity.getPrincipal().getName(), orderId);
    }
}

3. The page and script

The Quarkus CSRF guide shows how to inject the token and its header name into a Qute template with {inject:csrf.token} and {inject:csrf.headerName}. 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-header" content="{inject:csrf.headerName}">
  <meta name="csrf-token" content="{inject:csrf.token}">
  <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/META-INF/resources/agent.js
const csrfHeader = document.querySelector('meta[name="csrf-header"]').content;
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 Quarkus

  • Authorize in the resource and in every tool. Annotate the resource with @Authenticated (or a role check), read the user from SecurityIdentity in the tools and scope every query to that user.
  • Keep CSRF protection on. With quarkus-rest-csrf installed, a JSON POST without the token is rejected with a 400. Send the token in the X-CSRF-TOKEN header, from /agent and from the confirmation button alike.
  • 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 business rules, for example with a @ToolInputGuardrails guardrail, and keep irreversible actions behind the user's confirmation.
  • Limit cost. Keep maxToolCallingRoundTrips low; one round trip can contain several tool calls, and maxToolCallsPerResponse caps those. Rate-limit /agent per user and set spending limits with your model provider.
  • Respect the threading model. Methods that return Multi run on the event loop. Quarkus LangChain4j moves blocking tools to a worker thread for you, but do not add blocking calls to the stream pipeline yourself. Set quarkus.langchain4j.anthropic.timeout for each model call.
  • Persist memory deliberately. The default chat memory store lives in the JVM, so it is lost on restart and not shared between instances. Provide your own ChatMemoryStore bean (the memory reference shows one backed by Redis) when you run more than one instance.
  • Keep secrets server-side. Set the key through an environment variable such as QUARKUS_LANGCHAIN4J_ANTHROPIC_API_KEY or your secret store, never in 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 Quarkus 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 Quarkus backend keeps enforcing its own authorization on every request.

In Quarkus, put the loader in the <head> of your Qute layout, after the two page hooks (static files in src/main/resources/META-INF/resources) and before your own scripts, so it loads once per page. For signed-in users, add GET /api/agent-identity to a Quarkus REST resource that signs a short-lived token for the injected SecurityIdentity. The resource checks isAnonymous() itself and answers 401, so a signed-out request never gets a login redirect, and the signing secret stays in a server environment variable. The getAuthHeaders hook returns your CSRF token for the assistant's writes.

src/main/resources/templates/layout.html (excerpt)
<head>
  <meta name="csrf-token" content="{inject:csrf.token}">
  <!-- 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 Quarkus 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 Quarkus 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 Quarkus 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 Quarkus 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.