This is the first of two articles on building an AI agent inside a Jmix application. In this part we assemble a minimal but working example: the user asks a question in natural language, the agent decides which backend operations to invoke, calls them, and returns a meaningful answer. The domain is a warehouse - a scenario recognizable in most business applications and broad enough that in the second part we can discuss not only reads but also writes, security, fetch plans, and metadata.
Why would you want this at all? Enterprise applications keep their data behind browser screens and filter forms. That works well when the user knows what to filter by - and poorly for fuzzy, multi-criteria questions like "where are we low on dark-roast coffee across the northern warehouses?". Such a request would require opening several screens and combining the results manually. An AI agent lets users simply ask, and assembles the answer from backend operations you already have.
Why build it inside a Jmix application rather than as a separate service: the agent uses the same data access and security you already have. Its tools go through DataManager, so it sees exactly what the current user is allowed to see - no parallel data path, no bypassed permissions. That one property is what makes an agent acceptable in an enterprise setting, and it is the thread running through both parts.
This article is for developers comfortable with Java and Spring who want to add a natural-language layer to a real application. We won't argue whether you need an agent - there will be an honest "when you don't" section at the end of part two. Here we go straight to building one.
The article is based on the Spring AI tutorial: Building AI agents with Spring AI from InfoWorld. That article also shows a pure Spring variant without Jmix - worth comparing.
The full demo source is here: https://github.com/jmix-edu/ai-warehouse - clone it and run it locally.
What is an agent loop and why it's not a chatbot
A chatbot is a function: text in, text out. Everything it knows is contained in the prompt and in the model's trained weights. If a task requires access to application data or external systems, a plain chatbot cannot handle it.
An AI agent is fundamentally different. It is a loop in which the model does not just respond but chooses an action from a predefined set. Each action is a regular Java method annotated with @Tool from Spring AI. The model returns a tool call with arguments, the framework executes it, the result is placed back into the context, and the loop repeats until the model decides it has enough data for a final answer.
In pseudocode it looks roughly like this:
loop:
response = model.call(messages + system_prompt + tools_spec)
if response is final_answer:
return response.text
for tool_call in response.tool_calls:
result = invoke(tool_call.name, tool_call.arguments)
messages += tool_result(result)
if iterations > MAX:
return "could not finish"
This loop can be implemented manually - and it is useful to see exactly how, so you later understand what Spring AI hides behind ChatClient.call(…). Let's look at the real code.
The loop explained: what the framework does for you
If you look at the agent loop without Spring AI, it has just four building blocks:
- Messages - the conversation history. There are usually three types:
SystemMessage(role and instructions),UserMessage(user requests),AssistantMessage(model responses; may contain tool calls instead of text). PlusToolMessage- the tool result that we place back into the history, so the model sees it on the next iteration. - Tools spec - the JSON schema of all available tools (names, descriptions, parameter types), sent to the model with every request.
- Decision - what we receive from the model: either a final answer or a tool call with arguments.
- Loop with an iteration limit - to avoid infinite loops if the model does not converge.
Code example:
record AgentDecision(String action, // "tool" or "done"
String toolName,
Map<String, Object> arguments,
String finalAnswer) {}
public String run(String userQuestion) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(SYSTEM_PROMPT));
messages.add(new UserMessage(userQuestion));
for (int i = 0; i < MAX_ITERATIONS; i++) {
// 1. ask the model with current history and tools spec
ChatResponse response = chatModel.call(new Prompt(messages, withTools(toolsSpec)));
AssistantMessage assistantMsg = response.getResult().getOutput();
messages.add(assistantMsg);
AgentDecision decision = parseDecision(assistantMsg.getText());
// 2. done?
if ("done".equals(decision.action())) {
return decision.finalAnswer();
}
// 3. dispatch to the requested tool
Object result = invokeTool(decision.toolName(), decision.arguments());
messages.add(new ToolMessage(result.toString(), decision.toolName()));
}
return "Could not finish within " + MAX_ITERATIONS + " iterations.";
}
We will not use this code further, as Spring AI handles all of this automatically. But there are three details, that can result in the least obvious bugsworth to remember:
- The context growth. The
messageslist accumulates the entire history, including results from every tool call. A long conversation quickly hits the model's context limit. MAX_ITERATIONSis mandatory. Without it, when a tool fails, the model may keep re-invoking it indefinitely. Spring AI has a limit baked into its default settings, but you need to be aware of it.- Decision parsing is fragile. If the model does not execute a tool call in the correct format (and weaker models like to print JSON as plain text), the entire scheme breaks. We will come back to this in the "Where things can break" section.
The complete implementation of the manual variant is in the original Spring AI article on InfoWorld. From here on we do the same thing, but through ChatClient.
What we're building
The business scenario: a warehouse system. The user, without opening the usual list and filter screens, asks in plain language:
- "Do we have any dark-roast coffee available in Hamburg?"
- "Which warehouses are running low on Espresso blend? Less than five units."
- "Show me products in the accessories category that are out of stock everywhere."
The agent must:
- Understand the request.
- Choose the appropriate tool (product search by description, stock retrieval, warehouse listing).
- Call several tools in sequence when needed.
- Produce the final response for the user.
The UI is a regular Jmix View with an input field, a submit button, and a result area. No REST controller: the agent is invoked directly from the View controller.
In the first part of the article we focus on read-only operations. Writing data, reserving stock, creating replenishment requests will be covered in part two. This is a whole separate set of topics to be reviewed such as security, transactions, audit, etc. that are not relevant to a read-only scenario.
Project setup
It is assumed that you already have a Jmix project. If not, the fastest way is to create one in Jmix Studio via File → New → Project → Jmix Project; the basic project infrastructure is barely our concern here - we just need a full-stack Jmix app. The getting-started tutorial for creating an empty Jmix project is in the official documentation.
Spring AI is added as a regular Spring Boot starter. In build.gradle:
ext {
set('springAiVersion', "1.0.0")
}
dependencies {
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
// ... other jmix dependencies
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
}
}
The starter here is ollama, not openai - that is a deliberate choice. For basic search and query tasks a local model via Ollama is often sufficient, and you may not need external keys or tokens. If you later want to switch to OpenAI, Anthropic, or another provider, you change the starter and the spring.ai.* block in the configuration; the rest of the code stays untouched. This is essentially the main value of Spring AI as an abstraction.
Model selection is critical, and must be taken seriously: not every model supports native tool calling, and weaker models are shy about warning you. By the time you figure out something is wrong, you are already convinced you have a bug in your code. More on this in the "Where things can break" section. Here we use qwen3:8b, as it supports tools reliably enough in Ollama, fits in 5 GB, and runs acceptably on CPU sufficient for demo purposes. When choosing a different model, look for the tools tag on the model's page in the Ollama registry. It is the explicit marker of native tool calling support, though not a 100% guarantee.
Model configuration in application.properties:
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.options.model=qwen3:8b
spring.ai.ollama.chat.options.think=false
spring.ai.ollama.chat.options.temperature=0.2
The low temperature is intentional, as we need the model to select a tool consistently, not to be "creative". Creativity is useful for text generation tasks, not for call dispatching.
Start Ollama locally:
ollama pull qwen3:8b
ollama serve
Domain model
The minimal set of entities:
Product- SKU, name, description, category.Warehouse- name and location.StockItem- the "product at a warehouse" record with quantity and reserved fields.
This is enough for the read-only tools of part one. In part two we will add ReplenishmentRequest, but that is covered there.
Product:
@JmixEntity
@Table(name = "PRODUCT")
@Entity
public class Product {
@JmixGeneratedValue
@Column(name = "ID", nullable = false)
@Id
private UUID id;
@InstanceName
@Column(name = "NAME", nullable = false)
@NotNull
private String name;
@Column(name = "DESCRIPTION", length = 1024)
private String description;
@Column(name = "CATEGORY")
private String category;
// getters/setters
}
Note @InstanceName: when a tool returns an entity as its result, Jmix can automatically use it to build a textual representation. This simplifies serialization of the JSON that goes back to the model.
Warehouse:
@JmixEntity
@Table(name = "WAREHOUSE")
@Entity
public class Warehouse {
@JmixGeneratedValue
@Column(name = "ID", nullable = false)
@Id
private UUID id;
@InstanceName
@Column(name = "NAME", nullable = false)
@NotNull
private String name;
@Column(name = "CITY")
private String city;
}
StockItem:
@JmixEntity
@Table(name = "STOCK_ITEM")
@Entity
public class StockItem {
@JmixGeneratedValue
@Column(name = "ID", nullable = false)
@Id
private UUID id;
@JoinColumn(name = "PRODUCT_ID", nullable = false)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@NotNull
private Product product;
@JoinColumn(name = "WAREHOUSE_ID", nullable = false)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@NotNull
private Warehouse warehouse;
@Column(name = "QUANTITY", nullable = false)
@NotNull
private Integer quantity;
@Column(name = "RESERVED", nullable = false)
@NotNull
private Integer reserved = 0;
}
The Liquibase changelog is generated automatically by Studio at application startup after you create the entities; we won't dwell on that here. For information on migrations in Jmix, see the documentation section.
Tools: operations the model will see
A tool in Spring AI is a regular Spring bean method annotated with @Tool. The tool name, its description, and parameter descriptions are injected into the system prompt automatically, so the wording in description matters: the model uses it to decide which tool to call.
Let's build the WarehouseAgentTools service. A few key decisions in this code deserve separate discussion, so we'll show the full code first and then break it down.
@Component
public class WarehouseAgentTools {
private final DataManager dataManager;
public WarehouseAgentTools(DataManager dataManager) {
this.dataManager = dataManager;
}
@Tool(description =
"Find products by a keyword that may appear in the product name or description. " +
"Returns up to 20 matches. Use this first when the user asks for a product by description.")
public List<Product> findProducts(
@ToolParam(description = "search keyword, lower case") String keyword) {
return dataManager.load(Product.class)
.query("select p from Product p " +
"where lower(p.name) like :kw or lower(p.description) like :kw")
.parameter("kw", "%" + keyword.toLowerCase() + "%")
.maxResults(20)
.fetchPlan(fp -> fp.addAll("name", "description", "category"))
.list();
}
@Tool(description =
"List available warehouses with their city. " +
"Use this to map a city name from the user request to a warehouse id.")
public List<Warehouse> listWarehouses() {
return dataManager.load(Warehouse.class)
.all()
.fetchPlan(fp -> fp.addAll("name", "city"))
.list();
}
@Tool(description =
"Get current stock of a specific product across all warehouses. " +
"Returns quantity, reserved and available amount per warehouse, where available = quantity - reserved.")
public List<StockItem> getStock(
@ToolParam(description = "product id (UUID)") String productId) {
UUID id = UUID.fromString(productId);
return dataManager.load(StockItem.class)
.query("select s from StockItem s " +
"where s.product.id = :pid")
.parameter("pid", id)
.fetchPlan(fp -> fp
.addAll("quantity", "reserved")
.add("product", pFp -> pFp.addAll("name"))
.add("warehouse", wFp -> wFp.addAll("name")))
.list();
}
@Tool(description =
"Find products that have zero available stock (available = quantity - reserved) " +
"across all warehouses, filtered by category. " +
"Use when the user asks about out-of-stock items.")
public List<Product> findOutOfStockByCategory(
@ToolParam(description = "product category") String category) {
return dataManager.load(Product.class)
.query("select p from Product p " +
"where p.category = :cat " +
"and not exists (select s from StockItem s " +
" where s.product = p and (s.quantity - s.reserved) > 0)")
.parameter("cat", category)
.fetchPlan(fp -> fp.addAll("name", "description", "category"))
.list();
}
}
Three decisions that need explanation.
Why DataManager, not a JpaRepository
The original Spring AI article uses a plain JpaRepository. In Jmix that is an anti-pattern: JpaRepository goes directly to the EntityManager and bypasses security, soft delete, and auditing. This is especially dangerous with an AI agent: the model might build a query the user could never have run in the UI, and the tool would execute it silently.
DataManager (or JmixDataRepository if you prefer the repository style) goes through the standard Jmix security layer. Entity operation permissions (CRUD) and row-level constraints are applied here just as they are in the UI: a query the user cannot run in the interface won't silently run from a tool either.
One important caveat that is easy to forget. Entity- and row-level permissions are applied automatically at the data store level; but read access to individual attributes is something DataManager does not enforce - Jmix handles it on the UI level. If an attribute (say, description) is denied for reading by a role, but you put it into the fetch plan, it will still be loaded - and sent to the model. In other words, the fetch plan limits the selection by your decision, but it is not a substitute for an attribute permission check. When a tool hands entities to the LLM, attribute permissions must be checked explicitly; we'll cover how in part two, in the security section.
Keep this in mind: tools must go through DataManager, not around it. Anything that bypasses it is either intentional and deliberate (we will return to this in part two when we discuss SystemAuthenticator), or a mistake - and a security hole.
Entities or DTOs from tool methods
The original Spring AI article returned DTO wrappers from tool methods (ProductDto, StockDto, etc.). This is a common approach that solves real problems:
- JSON serialization of an entity may trigger unloaded lazy associations and throw a
LazyInitializationExceptionat the moment Spring AI serializes the result for the model. - An entity contains technical fields (
version,deletedDate) that clutter the model's context with unnecessary tokens. - A DTO explicitly defines what the model will see.
In Jmix the same goals are achieved through a fetch plan directly on the load call, and that is exactly how our demo works:
dataManager.load(Product.class)
.query("...")
.fetchPlan(fp -> fp.addAll("name", "description", "category"))
.list();
The fetch plan guarantees that exactly the needed fields are loaded; unnecessary associations will not be initialized during serialization. Technical fields (version, deletedDate) are not included in the plan and will not appear in the model's context.
DTOs remain the correct option for projections with no direct entity correspondence. For everything else, the fetch plan is the idiomatic Jmix way.
Importance of writing good Tool descriptions
The fact that a tool's description is part of the system prompt that the model reads on every request is often underestimated by developers. A few practical observations:
- Write descriptions in English even if end users speak another language. Models work more reliably with English instructions, especially small local models.
- State when to use the tool in the description, not just what it does. Compare "Search products by keyword" with "Use this first when the user asks for a product by description". The second form is clearer for the agent, and the result is more predictable.
- If you have two or more tools with similar descriptions, the model will get confused. One tool with a clear contract is better than two overlapping ones.
Assembling the ChatClient
ChatClient is the Spring AI facade over a concrete model. Tool registration and the system prompt are configured through the builder.
@Configuration
public class AgentConfig {
@Bean
public ChatClient warehouseAgentClient(
ChatClient.Builder builder,
WarehouseAgentTools tools) {
return builder
.defaultSystem("""
You are a warehouse assistant.
You help the user find products and check stock levels.
Tools available to you operate on the warehouse database.
Important rules:
- When the user mentions a city, first call listWarehouses to get warehouse ids.
- When the user describes a product, first call findProducts to get product ids.
- Only after you have ids, call getStock or other detail tools.
- If you cannot find a match, say so plainly. Do not invent data.
Search strategy for findProducts:
- Use SHORT keywords. Single word is best. Never pass compound phrases
with hyphens (e.g. "dark-roast coffee" is bad; "dark roast" is good).
- If findProducts returns 0 results, retry with a simpler or alternative
keyword before concluding the product is unavailable.
""")
.defaultTools(tools)
.build();
}
}
A few notes:
defaultTools(tools)accepts any Spring bean - Spring AI walks it via reflection and registers all methods annotated with@Tool.- The system prompt is intentionally directive. Phrases like "First call X, then Y" are not superfluous: they substantially improve behavioral stability, especially with smaller models.
- The separate search strategy block is the result of a real first run. The model assembled the keyword "dark-roast coffee" (with a hyphen, compound), found nothing in the database, and gave up. After the explicit instruction "try short keywords, retry on empty result" it started behaving predictably. More on this in "Where things can break".
UI: Jmix View
Create the warehouse-agent-view View through Studio. Descriptor:
<view xmlns="http://jmix.io/schema/flowui/view"
title="msg://warehouseAgentView.title">
<layout>
<vbox padding="true" width="100%" height="100%">
<textArea id="questionField"
width="100%"
minHeight="3em"
helperText="msg://warehouseAgentView.placeholder"/>
<button id="askButton"
text="msg://warehouseAgentView.ask"
icon="MAGIC"
themeNames="primary"/>
<progressBar id="progressBar"
visible="false"
indeterminate="true"
width="100%"/>
<textArea id="answerField"
width="100%"
minHeight="20em"
readOnly="true"/>
</vbox>
</layout>
</view>
Controller:
@Route(value = "warehouse-agent", layout = MainView.class)
@ViewController("warehouseAgentView")
@ViewDescriptor("warehouse-agent-view.xml")
public class WarehouseAgentView extends StandardView {
@Autowired
private ChatClient warehouseAgentClient;
@ViewComponent
private TextArea questionField;
@ViewComponent
private TextArea answerField;
@ViewComponent
private ProgressBar progressBar;
@ViewComponent
private JmixButton askButton;
@Subscribe(id = "askButton", subject = "clickListener")
public void onAskButtonClick(ClickEvent<JmixButton> event) {
String question = questionField.getValue();
if (question == null || question.isBlank()) {
return;
}
progressBar.setVisible(true);
askButton.setEnabled(false);
answerField.setValue("");
UI ui = UI.getCurrent();
CompletableFuture.supplyAsync(() ->
warehouseAgentClient.prompt()
.user(question)
.call()
.content()
).whenComplete((answer, ex) -> ui.access(() -> {
progressBar.setVisible(false);
askButton.setEnabled(true);
if (ex != null) {
answerField.setValue("Error: " + ex.getMessage());
} else {
answerField.setValue(answer);
}
}));
}
}
A few practical notes:
- The
warehouseAgentClient.prompt().call()call is blocking and may take considerable time - the model makes several rounds of tool calls. That is why we run it inside aCompletableFutureand return to the UI thread viaui.access(…). Without this the browser tab will freeze for the duration of the response. ProgressBarin indeterminate mode is a cheap way to show that something is happening. Streaming is possible (Spring AI supports it), but that is a topic for a separate article.- If you are not familiar with the Jmix Views programming model, see the Views section in the Jmix documentation.
What we'll see
For the request "What dark roast coffee do we have in Hamburg?" the actual tool call log looks like this (format warehouse=available/quantity, where available = quantity - reserved):
13:45:29 >>> listWarehouses()
<<< 3 warehouse(s): [Hamburg DC, Rotterdam DC, Antwerp DC]
13:45:40 >>> findProducts(keyword="dark roast")
<<< 2 match(es): [Colombia Supremo 1kg, Espresso blend dark roast 1kg]
13:46:02 >>> getStock(productId="...Colombia Supremo...")
<<< Hamburg DC=0/0, Rotterdam DC=24/30
13:46:32 >>> getStock(productId="...Espresso blend...")
<<< Rotterdam DC=30/40, Hamburg DC=15/18
After that the model assembled the final answer:
We have Espresso blend dark roast 1kg in Hamburg DC: 15 units available out of 18 in stock. Colombia Supremo 1kg is currently out of stock at Hamburg DC (available in Rotterdam DC: 24 out of 30).Notice: none of this was explicitly programmed. The "call a tool, look at the result, decide what to do next" cycle is implemented by Spring AI itself based on the model's responses.
It takes 63 seconds from question to final answer on a CPU without a GPU across four round-trips to the model, each taking 10-30 seconds depending on context length. This trace was captured on qwen2.5:7b. Model qwen3:8b, recommended above behaves equivalently -- the bottleneck is CPU inference, not the model itself. The database responds instantly in tens of ms per tool call. On a server with a GPU or via an API, those 63 seconds would drop to 5-10. This raises the question of where to use or not to use local models. This will be covered separately below.
Where things can break
Several common problems you will encounter as soon as you run this example against a real database. The first one I faced myself when ran it for the first time.
Model prints tool call as plain text instead of calling it
The first run of this demo was on llama3.1:8b. On the question "do we have any coffee in the warehouses?" the model responded:
Since the question does not specify a city or product description,
I will assume it's asking for a general availability of coffee products.
To answer this question, we need to call `findProducts` with a keyword "кофе"
to find matching product IDs.
{"name": "findProducts", "parameters": {"keyword": "кофе"}}
No tool was called. The JSON went straight into the final answer to the user.
What happened: the model understands the concept of tools but does not emit the call in the required protocol format (tool_calls in the response metadata). Spring AI receives a plain assistant message with no tool-calls metadata and treats it as a final text response. This is not a Spring AI bug and not a prompt error. It is simply a weak model: llama3.1:8b executes native tool calls unreliably in Ollama.
The fix is switching models. After switching to a model with reliable tool calling (qwen2.5:7b at the time; the demo now ships with qwen3:8b), the same request on the same configuration worked correctly: the model called findProducts, then getStock for several products, and assembled a text response. No prompt engineering was required, and it wouldn't have helped anyway.
Lesson learned: model choice matters more than prompt quality. A demo makes it easy to show ChatClient + tools and forget that the model is a separate variable that can break everything without a single error in your code. For production purposes, use server-side models (OpenAI, Anthropic) or verified local models with explicit tool calling support (qwen2.5, qwen3, newer llama3.x with correct chat templates) from the start. A rule of thumb from our experience: you need a lot of VRAM, e.g. tens or even hundreds of gigabytes for genuinely large models.
The anecdote above is specific to llama3.1:8b. Newer versions such as llama3.2 or llama3.3 have fixed tool calling support for most configurations. The principle remains the same: check the tools tag in the Ollama registry before choosing a model and then test the model itself.
Other common pitfalls
- Models confuse tool names. Fixed by being specific in
descriptionand by stating an explicit call order in the system prompt. - Models return invented ids. Validation in the tool methods themselves helps (
UUID.fromString(productId)will throw on garbage), along with an explicit instruction in the system prompt: "Only use ids returned by previous tool calls". - Large result sets.
findProductsreturns 20 products, which is a deliberate limit. If you hand the model 500 rows, it will get lost and/or consume the entire context on the very first request. - Local model is slow when running on CPU. One response of a 7-8B model on CPU takes seconds or even tens of seconds. That is not an interactive experience. Minimum requirement for comfortable work: a GPU with 16 GB VRAM. For production, frontier models via API, or a lot of your own hardware.
Summary
By the end of this part we have an application where the user asks - questions about the warehouse and receives meaningful answers. No REST endpoints, no filter forms - just a text interface and a set of tool methods that the model assembles in the right order.
This does not mean classic CRUD is unnecessary. A products browser with filters and sorting is faster than a conversation with a model, and more predictable. But in scenarios with multi-dimensional search (multiple criteria, imprecise phrasing) the agent interface starts to win.
In part two we will:
- Give the agent the right to change data: reserve stocks and create replenishment requests.
- Understand which user runs agent tools, when and how to use
SystemAuthenticator, and how to audit actions that were initiated by the agent. - Build a metadata-aware prompt, so the model knows your domain model without manual enumeration.
- Discuss how to validate what the LLM returns before passing it to
DataManager.
And finally, a short section, "When You Don't Need an AI Agent," because not every nail needs a hammer.
Further reading
- github.com/jmix-edu/ai-warehouse - full demo source for this article.
- Spring AI Reference - official Spring AI documentation.
- Building AI agents with Spring AI - the original article this one is based on, without Jmix specifics.
- Jmix Documentation: DataManager - a deep dive into the standard data access layer.
- Jmix Documentation: Security - how and which security policies apply when working with
DataManager.







