All articles

AI Agent for Warehouse in Jmix. Part 2: Write Tools, Security, Metadata

This is the second part of the article about Spring AI in Jmix. A quick recap of part one, in case it has been a while:

  • We built a read-only agent inside a Jmix application.
  • The user asks a question in natural language.
  • Spring AI's ChatClient runs the agent loop - calling @Tool methods until it has enough to answer - and each tool reads through DataManager with an explicit fetch plan.
  • It stays almost entirely inside Jmix security and returns only the fields the model needs.
  • The UI was a plain Jmix view, with no REST layer in between.
  • We also saw that model choice is not a detail: a model without reliable native tool calling breaks the whole thing.

If you haven't read part one, start there - the code below builds directly on it.

In this part we give the agent the right to modify data. And this is where, unlike the first half, questions arise that neither Spring AI nor most agent tutorials typically raise: which user executes a tool, how to handle transactions, how to audit actions initiated by the model, and how to make the agent work with your domain model without manually listing entities in the prompt.

These are not cosmetic changes, but real challenges that must be solved before going into production.

The full source code for everything discussed here is at: https://github.com/jmix-edu/ai-warehouse - clone and run it immediately.

What we're adding

One entity is added to the domain model is ReplenishmentRequest. This is a stock replenishment request: product, target warehouse, quantity, status, and author.

Two write methods are added to the tool set:

  • reserveStock(productId, warehouseId, quantity) - increments StockItem.reserved.
  • createReplenishmentRequest(productId, warehouseId, quantity, reason) - creates a new replenishment request.

The scenario: the user says "There are only eight bags of Ethiopia Yirgacheffe coffee beans left in Hamburg, reserve twelve and create a replenishment request for twenty".

The agent must first find the corresponding ids (skills from part one), then call reserveStock, notice that reserving twelve with only eight in stock is impossible, and make a decision - either reserve what's available and file a request for the rest, or return a human-readable explanation to the user.

The ReplenishmentRequest – new entity, that will be created by the AI-agent

@JmixEntity
@Table(name = "REPLENISHMENT\_REQUEST")
@Entity
public class ReplenishmentRequest {

    @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 = "REASON", length = 512)
    private String reason;

    @Column(name = "STATUS", nullable = false)
    @NotNull
    private String status;

    @CreatedBy
    @Column(name = "CREATED\_BY")
    private String createdBy;

    @CreatedDate
    @Column(name = "CREATED\_DATE")
    private OffsetDateTime createdDate;

    @Column(name = "INITIATED\_BY\_AGENT")
    private Boolean initiatedByAgent;
}

Two technical points worth noting:

  • @CreatedBy / @CreatedDate - Jmix audit will populate the author and timestamp automatically, as long as there is an authentication in the SecurityContext. What happens when there is no authentication (and in the agent's background thread there is none by default) is covered below.
  • initiatedByAgent - an explicit technical flag meaning "this was created by the agent". This is not a replacement for createdBy, but an addition to it. An auditor needs to see both the user who initiated the conversation and the fact that the entity came from a tool call rather than a screen.

The status field is stored as a String in the column but exposed through the ReplenishmentStatus enum so the rest of the code never touches raw strings. It is a standard Jmix EnumClass<String>:

public enum ReplenishmentStatus implements EnumClass<String> {

    NEW("NEW"),
    IN\_PROGRESS("IN\_PROGRESS"),
    DONE("DONE");

    private final String id;

    ReplenishmentStatus(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Nullable
    public static ReplenishmentStatus fromId(@Nullable String id) {
        for (ReplenishmentStatus status : values()) {
            if (status.getId().equals(id)) {
                return status;
            }
        }
        return null;
    }
}

The entity accessors convert between the stored String and the enum:

public ReplenishmentStatus getStatus() {
    return status == null ? null : ReplenishmentStatus.fromId(status);
}

public void setStatus(ReplenishmentStatus status) {
    this.status = status == null ? null : status.getId();
}

getId() is the stable value stored in the database; fromId() returns null for an unknown id, so callers must null-check. We lean on this same enum again in the validation section - there the value comes not from our code but from the model.

Next step is to introduce data changing tools. Let's do this.

Write tools

@Component
public class WarehouseWriteTools {

    private final DataManager dataManager;

    static final int MAX\_REASON\_LENGTH = 512;
    static final int MAX\_QUANTITY\_PER\_REQUEST = 1000;

    public WarehouseWriteTools(DataManager dataManager) {
        this.dataManager = dataManager;
    }

    @Tool(description =
            "Reserve a given quantity of a product at a specific warehouse. " +
            "Returns the actually reserved quantity, which may be less than requested if stock is insufficient. " +
            "Use only after stock has been checked with getStock.")
    @Transactional
    public ReserveResult reserveStock(
            @ToolParam(description = "product id (UUID)") String productId,
            @ToolParam(description = "warehouse id (UUID)") String warehouseId,
            @ToolParam(description = "quantity to reserve, positive integer") int quantity) {

        if (quantity <= 0) {
            return new ReserveResult(0, "Quantity must be positive");
        }

        UUID pid = UUID.fromString(productId);
        UUID wid = UUID.fromString(warehouseId);

        StockItem item = dataManager.load(StockItem.class)
                .query("select s from StockItem s " +
                       "where s.product.id = :pid and s.warehouse.id = :wid")
                .parameter("pid", pid)
                .parameter("wid", wid)
                .optional()
                .orElse(null);

        if (item == null) {
            return new ReserveResult(0, "No stock record for this product at this warehouse");
        }

        int available = item.getQuantity() - item.getReserved();

        if (available <= 0) {
            return new ReserveResult(0, "No stock available to reserve");
        }

        int toReserve = Math.min(available, quantity);
        item.setReserved(item.getReserved() + toReserve);
        dataManager.save(item);

        String note = toReserve < quantity
                ? "Reserved less than requested: only " + toReserve + " available"
                : null;
        return new ReserveResult(toReserve, note);
    }

    @Tool(description =
            "Create a replenishment request for a product at a specific warehouse. " +
            "Use when the user explicitly asks to order or replenish stock.")
    @Transactional
    public ReplenishmentRequest createReplenishmentRequest(
            @ToolParam(description = "product id (UUID)") String productId,
            @ToolParam(description = "warehouse id (UUID)") String warehouseId,
            @ToolParam(description = "quantity to order, positive integer") int quantity,
            @ToolParam(description = "free-form reason from the user, optional") String reason) {

        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        if (quantity > MAX\_QUANTITY\_PER\_REQUEST) {
            throw new IllegalArgumentException(
                    "Quantity exceeds the per-request limit of " + MAX\_QUANTITY\_PER\_REQUEST);
        }

        String safeReason = reason == null
                ? null
                : reason.substring(0, Math.min(reason.length(), MAX\_REASON\_LENGTH));

        Product product = dataManager.load(Product.class)
                .id(UUID.fromString(productId))
                .fetchPlan(fp -> fp.addAll("name"))
                .one();
        Warehouse warehouse = dataManager.load(Warehouse.class)
                .id(UUID.fromString(warehouseId))
                .fetchPlan(fp -> fp.addAll("name"))
                .one();

        ReplenishmentRequest req = dataManager.create(ReplenishmentRequest.class);
        req.setProduct(product);
        req.setWarehouse(warehouse);
        req.setQuantity(quantity);
        req.setReason(safeReason);
        req.setStatus(ReplenishmentStatus.NEW);
        req.setInitiatedByAgent(true);

        ReplenishmentRequest saved = dataManager.save(req);
        return dataManager.load(ReplenishmentRequest.class)
                .id(saved.getId())
                .fetchPlan(fp -> fp
                        .addAll("quantity", "reason", "status", "initiatedByAgent")
                        .add("product", pFp -> pFp.addAll("name"))
                        .add("warehouse", wFp -> wFp.addAll("name")))
                .one();
    }
}

ReserveResult is a computed result, not an entity DTO:

public record ReserveResult(int reserved, String note) {}

The tool set also includes a small helper, parseStatusFromLlm, for safely converting a model-supplied status string into the ReplenishmentStatus enum. We don't need it during creation (status is always NEW there), but it's the canonical place to validate a status that comes from the model - we show it in full in the validation section below.

What a write run looks like

For the request "Reserve 12 bags of Ethiopia Yirgacheffe coffee beans in Hamburg, and create a replenishment request for 20 more" while there are only 8 bags in stock at Hamburg DC, the actual tool-call log is:

13:54:36  >>> listWarehouses()
          <<< 3 warehouse(s): \[Hamburg DC, Rotterdam DC, Antwerp DC]

13:54:55  >>> findProducts(keyword="ethiopia yirgacheffe")
          <<< 1 match: \[Ethiopia Yirgacheffe 1kg]

13:55:17  >>> getStock(productId="...")
          <<< Hamburg DC=8/8, Antwerp DC=5/5

13:55:54  >>> reserveStock(productId="...", warehouseId="...hamburg...", quantity=12)
          <<< reserved=8, note="Reserved less than requested: only 8 available"

13:56:23  >>> createReplenishmentRequest(productId="...", warehouseId="...hamburg...",
                                          quantity=20, reason="User requested replenishment")
          <<< id=019e5ec7-..., status=NEW

Two things worth noting. The model called getStock before reserveStock, exactly as the system prompt requires. And on the partial reservation - it asked for 12 while only 8 were available. It didn't panic or invent the missing quantity: it took the reserved=8 result and moved on to the replenishment request. The logic that capped 12 to 8 lives in reserveStock, not in the model; the agent just reacts to the returned value.

One honest observation: the reason the model passed is because "User requested replenishment" is bland. The user didn't state a reason, and the model filled the field with a template rather than something useful like "Shortfall after reservation: requested 12, only 8 available". This is typical of free-text tool parameters: the model fills them generically unless the @ToolParam description or the system prompt asks for specifics. A reminder that, as we said in part one, a tool's description shapes not just which tool is called but the quality of the data it writes.

Transactions: where the boundaries are

@Tool methods are written with @Transactional. This is a deliberate choice, and it's worth discussing what it means in practice.

First, a technical detail that is often misunderstood. Spring AI calls a tool method via the bean from the context: MethodToolCallback retrieves the bean from ApplicationContext and invokes the method on it. It means the call goes through a Spring proxy - and @Transactional fires correctly. Self-invocation, where the proxy is bypassed, only happens when one method of the same class calls another via this. We don't have that here.

Now about transaction boundaries. A single user request produces multiple tool calls. Each tool has its own transaction. This means:

  • Between the calls to reserveStock and createReplenishmentRequest, other transactions can modify the database.
  • If the first tool succeeded but the second failed, the first will not be rolled back. You'll have a reserved stock item and no request.
  • Concurrent sessions can over-reserve stock: between getStock and reserveStock, another user may grab the same stock. The fix is a PESSIMISTIC\_WRITE lock in reserveStock or an "expected value" check at write time (optimistic style). We don't include this in the demo, but it's mandatory in production.

Can you wrap the entire agent loop in a single transaction? Technically, yes. Practically - no. A long-running transaction held open during LLM round-trips (seconds, sometimes tens of seconds) means locks, deadlocks, and connection pool exhaustion. Don't do this.

The right approach is to design tools to be idempotent or compensable:

  • reserveStock is idempotent on retry with the same arguments within reason.
  • If createReplenishmentRequest failed after reserveStock, the system needs a way to release the reservation - either manually or via a nightly job with a TTL.

What looks like "transactional complexity caused by the agent" is ordinary distributed transaction complexity. The agent doesn't introduce it. It just makes it visible.

Security: the most important section

This is the part that agent tutorials typically skip entirely because they are written without considering the application's access-control layer. In Jmix that layer exists and ignoring it when connecting an AI agent means bypassing security by design.

The problem: background thread loses authentication

Recall the code from part one:

CompletableFuture.supplyAsync(() ->
        warehouseAgentClient.prompt()
                .user(question)
                .call()
                .content()
).whenComplete(...);

CompletableFuture.supplyAsync submits the task to ForkJoinPool.commonPool(). That pool has neither your SecurityContext, nor Jmix authentication, nor a Vaadin UI context. It is simply a worker thread.

When a tool inside that thread calls dataManager.load(…), and DataManager asks "who is the user?", it gets anonymous. Depending on your roles, this means either an access denied error, or what is worse, a silent data read under an anonymous identity whose permissions are nothing like those of the logged-in user.

This must be handled explicitly. Jmix provides SystemAuthenticator for this; Spring Security provides propagation via an executor. We will show you both.

Solution 1: propagate user authentication into the thread

When we want the agent to act on behalf of the user, applying exactly the policies that take effect when the user works with the UI directly, we need to explicitly carry the authentication into the background thread.

@Subscribe(id = "askButton", subject = "clickListener")
public void onAskButtonClick(ClickEvent<JmixButton> event) {
    String question = questionField.getValue();
    if (question == null || question.isBlank()) {
        return;
    }

    // Capture SecurityContext before leaving the UI thread.
    // DelegatingSecurityContextExecutor propagates it into the worker thread
    // and cleans up automatically after each task - no manual try/finally needed.
    Executor secureExecutor = new DelegatingSecurityContextExecutor(
            ForkJoinPool.commonPool(),
            SecurityContextHolder.getContext());

    progressBar.setVisible(true);
    askButton.setEnabled(false);

    UI ui = UI.getCurrent();
    CompletableFuture.supplyAsync(() ->
            warehouseAgentClient.prompt()
                    .user(question)
                    .call()
                    .content(),
            secureExecutor
    ).whenComplete((answer, ex) -> ui.access(() -> {
        progressBar.setVisible(false);
        askButton.setEnabled(true);
        answerField.setValue(ex != null ? "Error: " + ex.getMessage() : answer);
    }));
}

Now all tool calls inside the agent see the Authentication of the user who clicked the button. Row-level security, soft delete, @CreatedBy - everything works as usual. From Jmix perspective the agent is simply "a long user scenario" where the client is an LLM rather than a browser.

This is the correct default behavior for most scenarios. If a user doesn't have the right to create replenishment requests, the agent must not do it on their behalf either.

DelegatingSecurityContextExecutor is Spring Security's standard mechanism for propagating SecurityContext into background threads. It captures the context at the moment when the executor is created (i.e., in the UI thread where authentication is guaranteed to be present) and restores it before each task, cleaning up automatically afterward. No manual try/finally with SecurityContextHolder.clearContext() is needed - and there's no way to forget it. ForkJoinPool.commonPool() reuses threads across tasks, so automatic cleanup is critical here: without it, the next task on the same thread could see a different user's identity.

Solution 2: run the tool as a system user

Sometimes you need the opposite: a tool must execute with broader permissions than the user has. Typical scenarios:

  • Writing to an audit table that regular users cannot access.
  • Reading from an internal reference table that isn't worth granting access to.
  • Creating technical artifacts (e.g., a conversation log) that must not depend on the user's permissions.

For these cases Jmix provides SystemAuthenticator:

@Component
public class AgentAuditTools {

    private final DataManager dataManager;
    private final SystemAuthenticator systemAuthenticator;
    private final CurrentAuthentication currentAuthentication;

    public AgentAuditTools(DataManager dataManager,
                            SystemAuthenticator systemAuthenticator,
                            CurrentAuthentication currentAuthentication) {
        this.dataManager = dataManager;
        this.systemAuthenticator = systemAuthenticator;
        this.currentAuthentication = currentAuthentication;
    }

    public void logAgentAction(String toolName, String arguments, String result) {
        String initiator = currentAuthentication.getUser().getUsername();

        systemAuthenticator.runWithSystem(() -> {
            AgentActionLog entry = dataManager.create(AgentActionLog.class);
            entry.setToolName(toolName);
            entry.setArguments(arguments);
            entry.setResult(result);
            entry.setInitiator(initiator);
            entry.setLoggedAt(OffsetDateTime.now());
            dataManager.save(entry);
        });
    }
}

Key points:

  • The initiator name (initiator) is read before switching to the system user. After the switch, currentAuthentication.getUser() returns the system user, and the audit entry becomes useless.
  • runWithSystem guarantees that inside the block execution runs with system privileges, and the context is restored after the block even if an exception was thrown inside.

logAgentAction is not annotated with @Tool - it is a utility method that you call from other tools or from a wrapper around the agent. The model should not know if it exists.

Which approach to use for each tool

A simple rule:

  • Business tools (product search, reservation, requests) -- use the user's authentication, no switching to the system user. If the user is not allowed to do something, the agent acting on their behalf must not do it either.
  • Technical tools (writing agent logs, reading internal configs, sending notifications) -- use SystemAuthenticator.runWithSystem(…).
  • Never use runWithSystem for business operations "to work around an access error". This is always a design problem, not a security one.

How this shows up in @CreatedBy

If you did everything right, e.g. both the authentication propagation and @CreatedBy on ReplenishmentRequest, the created request will carry the name of the user who initiated the conversation with the agent. Combined with initiatedByAgent = true, this gives an auditor the full picture: who initiated the action and that it was done through the AI agent, not through a form.

If you ran the tool in a background thread without propagating authentication, @CreatedBy will be either empty or equal to the system user's name, depending on your configuration. This is the first thing to check in the log after startup: if the createdBy field on a request shows system, anonymous, or null, the context is not being propagated, and all the rest of the security isn't working either.

Attribute access permissions: what DataManager does not check automatically

In part one we promised to come back to one caveat. DataManager applies entity (CRUD) permissions and row-level constraints automatically - but read access to individual attributes is not enforced at the data store level. That check lives in the UI layer: when a field is rendered on a form, the component binding (UiEntityAttributeContext) hides or makes read-only an attribute the user is not allowed to see (or modify). In the agent's background scenario there is no UI - and therefore no such check.

In practice this means: if Product.description is denied for reading by a role, but a tool loaded it into the fetch plan, the value goes to the model bypassing permissions. No exception, no warning. You must do this check yourself - and there is no strict need to switch to loadValues for it, as we do in our B2B CRM. The same AccessManager used in the UI is enough, just via the data-layer EntityAttributeContext (the counterpart of UiEntityAttributeContext):

@Component
public class AttributeAccessSupport {

    private final Metadata metadata;
    private final AccessManager accessManager;

    public AttributeAccessSupport(Metadata metadata, AccessManager accessManager) {
        this.metadata = metadata;
        this.accessManager = accessManager;
    }

    /\*\* Of the given attributes, keeps only those the current user may read. \*/
    public List<String> viewable(Class<?> entityClass, String... attributes) {
        MetaClass mc = metadata.getClass(entityClass);
        List<String> allowed = new ArrayList<>();
        for (String attr : attributes) {
            EntityAttributeContext ctx = new EntityAttributeContext(mc, attr);
            accessManager.applyRegisteredConstraints(ctx);
            if (ctx.canView()) {
                allowed.add(attr);
            }
        }
        return allowed;
    }
}

Inject AttributeAccessSupport into the tools and build the fetch plan not from a hard-coded list but from the permitted attributes - a denied attribute will never be loaded:

@Tool(description = "...")
public List<Product> findProducts(@ToolParam(description = "...") String keyword) {
    List<String> fields = attributeAccess.viewable(
            Product.class, "name", "description", "category");

    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(fields.toArray(String\[]::new)))
            .list();
}

Now the graph that goes to the LLM is constrained not only by our fetch plan, but also by the user's roles. If instead of silently hiding a field you want to explicitly refuse ("you have no access to this attribute"), check canView() and throw an exception: its text is returned to the model just like any other tool error.

One honest detail, so as not to create a false sense of complete protection: in the example above we still search by description (it stayed in the where clause) even if the user may not read it - the mere fact of a match lets you infer something about the content. If that leak matters, exclude inaccessible attributes from the query conditions too, using the same viewable(…).

Fetch plans in tool methods

In part one we agreed: tool methods return entities with an explicit fetch plan. Let's look at what that means and which plan to choose.

Jmix has three built-in plans; a custom one is always available too:

  • \_instance\_name -- only the attribute (or attributes, in the case of a method) annotated with @InstanceName. These may be local or reference attributes.
  • \_local -- loads only local (non-reference) fields. Does not touch associations.
  • \_base -- same as \_local, plus the field or fields from @InstanceName when they are references. This is one additional JOIN per such reference field, not the full graph. \_base is often accused of "pulling all associations" - that's inaccurate.
  • Custom plan -- an explicit list of fields you build via a lambda or declaratively.

For tool methods the right choice is almost always a custom plan:

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();

A few rules:

  • Include only the fields the model needs. Extra fields are extra tokens and potential data leakage.
  • Don't use \_base "just in case". Not just because it "pulls everything" (it doesn't), but because with a custom plan you know exactly what will enter the model's context.
  • Do not include binary fields or large text blobs in the fetch plan for the agent.
  • If a tool returns an entity after save(), reload it via dataManager.load() with an explicit plan. The result of save() carries the same plan that was used when the arguments were loaded, and that may not be what the model needs.

Metadata-aware prompt

The system prompt from part one lists the tools but says nothing about the domain model. The model knows there is a findProducts tool, but it doesn't know what product categories exist in the system, what fields a request has, or what statuses are possible.

You can enumerate all of this manually. It works, but it bloats the prompt and goes stale the moment you add a field to an entity.
Jmix has Metadata and MetadataTools. They let you generate a domain description automatically:

@Component
public class DomainPromptBuilder {

    private final Metadata metadata;
    private final MetadataTools metadataTools;
    private final MessageTools messageTools;

    public DomainPromptBuilder(Metadata metadata,
                                MetadataTools metadataTools,
                                MessageTools messageTools) {
        this.metadata = metadata;
        this.metadataTools = metadataTools;
        this.messageTools = messageTools;
    }

    public String build(Class<?>... entityClasses) {
        StringBuilder sb = new StringBuilder("Domain model available via tools:\\n\\n");
        for (Class<?> cls : entityClasses) {
            MetaClass mc = metadata.getClass(cls);
            sb.append("- ").append(mc.getName())
              .append(" (").append(messageTools.getEntityCaption(mc)).append(")\\n");
            for (MetaProperty p : mc.getProperties()) {
                if (metadataTools.isSystem(p)) {
                    continue;
                }
                sb.append("    ").append(p.getName())
                  .append(": ").append(p.getJavaType().getSimpleName())
                  .append("\\n");
            }
        }
        return sb.toString();
    }
}

And in the ChatClient configuration:

@Bean
public ChatClient warehouseAgentClient(
        ChatClient.Builder builder,
        WarehouseAgentTools readTools,
        WarehouseWriteTools writeTools,
        DomainPromptBuilder promptBuilder,
        SystemAuthenticator systemAuthenticator) {

    // MessageTools.getEntityCaption reads Locale from CurrentAuthentication.
    // We are in a bean factory at startup - no user. Borrow system identity.
    String domain = systemAuthenticator.withSystem(() -> promptBuilder.build(
            Product.class, Warehouse.class, StockItem.class, ReplenishmentRequest.class));

    return builder
            .defaultSystem("""
                    You are a warehouse assistant.
                    %s
                    Rules:
                    - When the user mentions a city, first call listWarehouses.
                    - When the user describes a product, first call findProducts.
                    - For write operations, only use ids returned by previous tool calls.
                    - For reserveStock, always check stock first with getStock.
                    """.formatted(domain))
            .defaultTools(readTools, writeTools)
            .build();
}

A small but important detail - systemAuthenticator.withSystem(…) wrapping the promptBuilder.build(…) call. On the first run the application failed with IllegalStateException: Authentication is not set - because MessageTools.getEntityCaption reads the locale from CurrentAuthentication, and during bean creation there is no user yet. This is exactly the situation SystemAuthenticator exists for: the tool (or configuration code) is not executing on behalf of a user, and an explicit technical identity is needed. We wrote about this above in the security section - and here is a live instance of it right inside the agent's own infrastructure.

What we got:

  • The domain model description always matches the code. Add a field to Product - and it appears in the prompt without any manual edits.
  • Localized captions from message bundles are included in the prompt automatically. If in a customer's setup a category is called not "Category" but "Product line", the model will see that.
  • System fields (version, deletedDate, etc.) are filtered out via metadataTools.isSystem(p). They are not needed in the prompt and only confuse the model.

This is the feature that makes it worth building the agent as part of a Jmix application rather than as a sidecar service. The metadata is there -- it would be a shame not to use it.

Validating what the LLM returns

An LLM returns strings. Everything that arrives in a tool via @ToolParam was generated by the model - and didn't have any validation at all.

What to do about it:

  • UUID: UUID.fromString(…) throws IllegalArgumentException on garbage. That is already validation. Spring AI wraps the exception into a message for the model, and the model will try again - often successfully.
  • Numbers: don't trust the sign or the order of magnitude. quantity < 0 is an explicit check at the top of the tool.
  • Enums / statuses: in Jmix, enums implement EnumClass<T>. Safe conversion of a model-supplied string via fromId():
static ReplenishmentStatus parseStatusFromLlm(String rawValue) {
    if (rawValue == null) {
        throw new IllegalArgumentException("Status value from LLM is null");
    }
    String normalized = rawValue.strip().toUpperCase();
    ReplenishmentStatus status = ReplenishmentStatus.fromId(normalized);
    if (status == null) {
        throw new IllegalArgumentException(
                "Unknown status value from LLM: '" + rawValue + "'. " +
                "Allowed: " + Arrays.toString(ReplenishmentStatus.values()));
    }
    return status;
}

Normalization (trim + toUpperCase) is needed because the model may return "new", "New", or "NEW ". fromId() returns null for an unknown value. The null check is mandatory. Never pass a raw string from the model directly to setStatus() without conversion.

  • Free text: if a field is persisted to the database (e.g., reason in ReplenishmentRequest), limit the length explicitly. The model can return several thousand characters if you don't stop it.

Note: in findProducts (part 1) the search keyword goes into the query via :kw - a bound parameter, the JPQL equivalent of a prepared statement. So even if the model returns "%' OR 1=1 --", it goes into the query as a literal string, not as a SQL construct. This is a concrete example of how DataManager + bound parameters protect you by design. No String.format(…) or string concatenation in query construction - and then there are no model-triggered injections.

The rule is simple: treat tool parameters the same way you would treat parameters of an HTTP endpoint from an anonymous user. That's exactly the same level of trust.

One common anti-pattern is writing something like the following code in a tool, without any control:

@Tool(description = "Run a custom query")
public List<?> runQuery(@ToolParam(description = "JPQL query") String jpql) { ... }

In some scenarios the urge to hand the model a String jpql value and execute it as-is is indeed an anti-pattern: the equivalent of SQL injection. Only here the injection is initiated not by an attacker but by your own model, which can make mistakes (or be nudged into them via prompt injection).

But a categorical "never let the model formulate queries itself" would be inaccurate. Jmix AI tooling in our B2B CRM, for example, gives the model exactly this capability. The difference is not the fact itself, but the harness around it. You can expose an open-ended query to the model, as long as it goes through a layer that cannot step outside the user's permissions:

  • the query runs through Jmix's loadValues() mechanism, and entity permissions are checked by LoadValuesAccessContext - a query touching an entity the user can't access fails with AccessDeniedException;
  • named parameters only, no concatenation - the same injection protection we discussed above;
  • mandatory result pagination, so the model can't pull a whole table in one query;
  • and, as noted in the security section, attribute filtering, which loadValues does not do on its own either.

For this article we deliberately chose a different default - narrow, typed tools for specific scenarios. They are simpler, more predictable, and don't require a separate query-validation harness. An open-ended query is justified when you don't know in advance which slices of data will be needed (a typical analytics assistant). In such cases build it the way Jmix's built-in AI tooling does, not as the runQuery(String jpql) from the example above.

Cost, non-determinism, limits

A few practical observations that are easy to miss in a demo and expensive to rediscover in production.

  • Each user request involves multiple model calls. One conversation with the agent is 3-7 round-trips to the model, not one. Budget your tokens from that number.
  • Context grows. All tool calls and their results are stored in the current conversation context. A long conversation hits the model's context limit and starts losing the beginning.
  • A local model is not free either. If you're using a GPU - that's power consumption and hardware wear. If CPU - that's seconds to minutes per request, and users won't wait.
  • Non-determinism. For the same question, the agent may call tools in a slightly different order. This is not a bug - it's the nature of the model. Don't build logic on the assumption "the model will do X, then Y" - build logic on the correctness of each tool individually.
  • Idempotency. If a tool can be called twice with the same arguments without side effects - the agent is more stable. This isn't always possible, but it's worth aiming for.

When you don't need an AI agent

Having written two articles on how to build one, it's only fair to say when not to:

  • A simple search form with filters. If you have a screen with five filter fields and a "Search" button -- just keep it. It's faster, cheaper, more predictable, and requires no explanation for why the report looks slightly different sometimes.
  • High load. If this operation is being performed not by one user occasionally but by a thousand per second - an LLM in the hot path won't survive, neither by latency nor by cost.
  • Hard reproducibility requirements. In compliance scenarios, agent non-determinism is a problem, not a feature.
  • Users who dislike free-text input. This is a real user segment. Many people prefer click-through over formulating a request in text, especially in a non-native language.

An agent works well for tasks with highly variable input, where multiple operations need to be combined and the user doesn't want to learn an interface. Wherever that's not the case, classic CRUD wins.

What we ended up with

By the end of part two our application has:

  • read and write tools going through DataManager and respecting Jmix security;
  • correct authentication propagation into the agent's background thread;
  • deliberate use of SystemAuthenticator where it is appropriate, and explicit avoidance of it where it is not;
  • fetch plans for the cases where a tool does return an entity;
  • a metadata-aware prompt that stays current without manual editing;
  • validation of everything the model sends into a tool;
  • and a clear understanding of where this approach wins and where it doesn't.

Things that were left outside the scope of these two articles and deserve their own material:

  • Conversation memory across sessions. ChatMemory in Spring AI, its implementations, and how to persist history across UI sessions.
  • RAG over documentation. If you have a set of warehouse regulations, the agent can answer with references to specific clauses.
  • Multi-agent. When a task is split across multiple roles - "the one that searches", "the one that checks permissions", "the one that writes".
  • Response streaming. So the user sees text as it is generated rather than waiting for the full block.

All of these are continuations, not alternatives to what we built. The core scaffold - "Jmix + Spring AI + tools via DataManager + proper security" - stays the same. Tell us which other topics you'd like to see covered as articles.

Further reading

github.com/jmix-edu/ai-warehouse - full source code for the demo in this article.
Spring AI Reference - especially the sections on ChatMemory and Advisors.
Jmix Documentation: Security - details on roles, policies, and SystemAuthenticator.
Jmix Documentation: DataManager - the nuances of working with DataManager.
Jmix Documentation: Fetch Plans - fetch plan details.
Our open-source B2B CRM with an AI assistant - source code.
Part one of this article.

Jmix is an open-source platform for building enterprise applications in Java

Recommended Reading