Agentic AI

Building an Agentic AI Ticket-Triage System with Spring AI, Java 21, and Tool Calling

A production-minded agentic workflow that enriches support tickets with tools, returns a validated triage decision, applies deterministic safety policy, and autonomously assigns only low-risk cases.

Published
Published
Updated
Updated
Reading time
16 minute read
Spring AIAgentic AITool CallingJava 21Spring BootStructured Output
A Spring AI agent calling three read-only tools, then a deterministic policy gate splitting its structured decision into automatic assignment and human review

An agentic application is useful when a model must do more than generate text. It needs to inspect the current state of a system, choose an allowed next step, invoke tools, and stop safely. Ticket triage is a good example because the task combines unstructured language with deterministic business rules.

This guide builds a complete Spring AI agentic workflow: a Spring Boot service that accepts a support ticket, lets a chat client call read-only tools for service status and runbook lookup, converts the final model response into a Java record, validates the decision, and automatically assigns only routine cases. High-impact or uncertain tickets are sent to a human review queue.

The interesting part is not that the model can call tools — that takes a single annotation. It is where the authority sits. In the design below the model proposes and deterministic Java decides, which is what makes the workflow safe enough to run unattended on the cases it is allowed to touch.

Agent, workflow, and autonomy boundaries

A model-driven loop is not automatically safer or more capable than an ordinary workflow. For ticket triage, the reliable design is a bounded agentic workflow:

  1. The application validates the incoming ticket.
  2. The model may call approved read-only tools to gather current context.
  3. The model returns one structured TriageDecision.
  4. Java code validates every enum, required field, and confidence value.
  5. A deterministic policy decides whether assignment can proceed without a person.
  6. The application records the decision and performs an idempotent assignment.
  7. The workflow stops after one assignment or one escalation.

The model never receives a database connection, arbitrary HTTP client, shell access, or a generic “execute” tool. The application remains responsible for tool execution and authorization.

Responsibility split in the ticket-triage workflow
ConcernModel responsibilityJava application responsibility
Understand ticket languageClassify category, urgency, and likely queueReject malformed input and enforce size limits
Gather contextDecide whether a status or runbook lookup is usefulExpose narrow, allowlisted tools and apply timeouts
Recommend actionProduce a structured triage decisionValidate output and apply business policy
Execute actionNone directly in this sampleAssign only approved tickets idempotently
Stop conditionReturn one final decisionLimit the workflow to one decision and one action

Prerequisites

  • JDK 21 and Maven 3.9 or newer.
  • Either an OpenAI API key in OPENAI_API_KEY, or Ollama (opens in a new tab) running locally.
  • A tool-capable model. This is the one hard requirement: an agent that cannot call tools is just a chat endpoint.
  • Basic familiarity with Spring Boot REST controllers and Java records.
  • A clear list of categories, queues, and actions that your organisation permits the automation to use.

Project setup

Create a Maven project named ticket-triage-agent. The Spring AI BOM keeps Spring AI modules on one compatible release line.

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>ticket-triage-agent</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>21</java.version>
        <spring-ai.version>2.0.1</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-ollama</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Configuration

Use environment variables for credentials and model selection. The low temperature makes classification more repeatable, but it does not replace output validation.

src/main/resources/application.yml
spring:
  application:
    name: ticket-triage-agent
  ai:
    # Provider selection. Both the OpenAI and Ollama starters are on the classpath,
    # and this property decides which chat auto-configuration activates.
    # Default profile  -> OpenAI (hosted).
    # "ollama" profile -> Ollama (local). Run with --spring.profiles.active=ollama
    model:
      chat: openai
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        model: ${OPENAI_CHAT_MODEL:gpt-4.1-mini}
        temperature: 0.1

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics

server:
  error:
    include-message: never

triage:
  autonomous-confidence-threshold: 0.85

---
# Local model through Ollama. No API key and no outbound network call.
# This agent depends on tool calling, so the model must support it.
# llama3.1 does; many smaller models do not. Pull it once before starting:
#   ollama pull llama3.1
spring:
  config:
    activate:
      on-profile: ollama
  ai:
    model:
      chat: ollama
    # The OpenAI auto-configuration is inactive on this profile, so no key is needed.
    # This placeholder only stops the unresolved ${OPENAI_API_KEY} reference above
    # from failing startup when the variable is not set at all.
    openai:
      api-key: not-used-on-the-ollama-profile
    ollama:
      base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
      chat:
        model: ${OLLAMA_CHAT_MODEL:llama3.1}
        temperature: 0.1
      init:
        # Keep startup predictable: fail with a clear Ollama error if the model is
        # missing rather than silently downloading gigabytes on first request.
        pull-model-strategy: never

triage:
  # Local models are less reliable at calibrating their own confidence, so raise
  # the bar for acting without a human. The safety policy is unchanged either way:
  # it is deterministic Java, not something the model can talk its way past.
  autonomous-confidence-threshold: 0.95

Bind the policy settings with a validated configuration record.

src/main/java/com/example/triage/config/TriageProperties.java
package com.example.triage.config;

import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties(prefix = "triage")
public record TriageProperties(
        @DecimalMin("0.0") @DecimalMax("1.0") double autonomousConfidenceThreshold) {
}
src/main/java/com/example/triage/TicketTriageApplication.java
package com.example.triage;

import com.example.triage.config.TriageProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties(TriageProperties.class)
public class TicketTriageApplication {

    public static void main(String[] args) {
        SpringApplication.run(TicketTriageApplication.class, args);
    }
}

Switch between OpenAI and a local Ollama model

spring.ai.model.chat selects the active chat auto-configuration. Both starters are on the classpath, the default is openai, and the ollama profile overrides it. No Java code changes.

Run against OpenAI
export OPENAI_API_KEY=sk-your-key
mvn spring-boot:run
Run against a local model
ollama pull llama3.1
mvn spring-boot:run -Dspring-boot.run.profiles=ollama

Expect weaker confidence calibration from a local model. That is why the ollama profile raises triage.autonomous-confidence-threshold from 0.85 to 0.95: a smaller model is more likely to report high confidence in a decision it should have escalated, so fewer tickets qualify for unattended assignment.

What does not change with the provider is the safety boundary. TriagePolicy is deterministic Java, so a weaker model produces more escalations to humans rather than more risky automatic assignments. That is the intended failure direction, and it is worth verifying deliberately — the policy test below runs without a model precisely so this guarantee is provable rather than assumed.

Define the domain model

Use closed enums for values that drive business behaviour. Free-form queue names would make policy enforcement fragile.

src/main/java/com/example/triage/domain/TicketModels.java
package com.example.triage.domain;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.time.Instant;
import java.util.List;

public final class TicketModels {

    private TicketModels() {
    }

    public enum TicketCategory {
        ACCESS, BILLING, INCIDENT, PERFORMANCE, OTHER
    }

    public enum Priority {
        P1, P2, P3, P4
    }

    public enum SupportQueue {
        IDENTITY, PAYMENTS, PLATFORM, GENERAL, HUMAN_REVIEW
    }

    public record TicketRequest(
            @NotBlank @Size(max = 80) String ticketId,
            @NotBlank @Size(max = 200) String subject,
            @NotBlank @Size(max = 6000) String description,
            @Size(max = 80) String affectedService) {
    }

    public record TriageDecision(
            TicketCategory category,
            Priority priority,
            SupportQueue queue,
            double confidence,
            String summary,
            String rationale,
            List<String> evidence,
            boolean requiresHumanReview) {
    }

    public record ServiceStatus(String service, String state, Instant checkedAt) {
    }

    public record RunbookMatch(String id, String title, String excerpt) {
    }

    public record AssignmentReceipt(
            String ticketId,
            SupportQueue queue,
            String status,
            Instant assignedAt) {
    }

    public record TriageResponse(
            TriageDecision decision,
            AssignmentReceipt assignment,
            Instant completedAt) {
    }
}

Implement narrow read-only tools

Spring AI turns methods annotated with @Tool into tool definitions and derives their input schemas. Each tool should perform one domain operation, validate its input, and return a small serialisable response.

The following implementation is intentionally deterministic so the example runs without another service. Replace the maps with clients that have explicit timeouts, authentication, and resilience controls.

src/main/java/com/example/triage/tools/TicketContextTools.java
package com.example.triage.tools;

import com.example.triage.domain.TicketModels.RunbookMatch;
import com.example.triage.domain.TicketModels.ServiceStatus;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.Locale;
import java.util.Map;

@Component
public class TicketContextTools {

    private static final Map<String, String> SERVICE_STATES = Map.of(
            "identity", "OPERATIONAL",
            "payments", "DEGRADED",
            "orders", "OPERATIONAL");

    private static final Map<String, RunbookMatch> RUNBOOKS = Map.of(
            "access", new RunbookMatch(
                    "RB-101", "Restore account access",
                    "Verify identity, check lock state, and use the approved reset flow."),
            "payments", new RunbookMatch(
                    "RB-204", "Investigate payment degradation",
                    "Check provider health, error rate, and retry backlog before escalation."),
            "performance", new RunbookMatch(
                    "RB-305", "Investigate API latency",
                    "Compare latency, saturation, and dependency health against the baseline."));

    @Tool(description = "Get the current operational state of an allowlisted service")
    public ServiceStatus getServiceStatus(
            @ToolParam(description = "Service name such as identity, payments, or orders")
            String service) {
        String key = normalise(service);
        String state = SERVICE_STATES.getOrDefault(key, "UNKNOWN");
        return new ServiceStatus(key, state, Instant.now());
    }

    @Tool(description = "Find the best approved support runbook for a short issue category")
    public RunbookMatch findRunbook(
            @ToolParam(description = "Issue category such as access, payments, or performance")
            String category) {
        return RUNBOOKS.getOrDefault(
                normalise(category),
                new RunbookMatch("NONE", "No matching runbook", "Escalate for human review."));
    }

    private String normalise(String value) {
        if (value == null || value.isBlank()) {
            return "unknown";
        }
        return value.strip().toLowerCase(Locale.ROOT);
    }
}

Build the agentic workflow

Create one reusable ChatClient. Runtime tools are passed only for the triage request, so they are not accidentally available to unrelated prompts.

src/main/java/com/example/triage/config/AiConfiguration.java
package com.example.triage.config;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfiguration {

    @Bean
    ChatClient triageChatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

Bound the tool loop with Spring AI 2.0 tool-call limits

The workflow above stops because the prompt asks for one decision and the application acts on one result. That is a convention, not an enforced limit. A model that keeps calling tools produces a loop that costs tokens and time, and the earlier defence against this was to hand-roll a counter.

Spring AI 2.0 rearchitected tool calling and made the limit a first-class concern. ToolCallingManager now carries a ToolCallLimits configuration, and ToolCallingAdvisor enforces it.

Enforcing tool-call limits through the ToolCallingManager
package com.example.triage.config;

import org.springframework.ai.model.tool.DefaultToolCallingManager;
import org.springframework.ai.model.tool.ToolCallLimitBehavior;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ToolLimitConfiguration {

    @Bean
    ToolCallingManager toolCallingManager() {
        return DefaultToolCallingManager.builder()
                // A triage decision should never need more than a handful of lookups.
                .maxTotalToolCalls(6)
                .defaultMaxCallsPerTool(2)
                .onLimitExceeded(ToolCallLimitBehavior.THROW)
                .build();
    }
}

Two knobs, two different failures. maxTotalToolCalls catches a model that keeps working without converging. defaultMaxCallsPerTool catches the narrower and more common case of one tool being called repeatedly with slightly different arguments — usually a sign the tool's description is ambiguous rather than that the model is misbehaving.

onLimitExceeded chooses what a breach does:

  • THROW raises ToolCallLimitExceededException. The request fails, and for a workflow that assigns work to people this is the right default: no decision is safer than a decision produced by a loop that ran away.
  • RETURN_ERROR_RESPONSE tells the model the limit was reached and lets it answer from what it already has. Reasonable for a conversational assistant, wrong here, because the resulting decision would be based on partial context while looking exactly like a normal one.

excludeToolFromLimit exempts a specific tool where repeated calls are legitimate, such as paginating a list.

The assignment service owns the state-changing action. ConcurrentHashMap.computeIfAbsent makes repeated requests with the same ticket ID idempotent for this single-process demo. Use a database uniqueness constraint or transactional outbox in production.

src/main/java/com/example/triage/service/AssignmentService.java
package com.example.triage.service;

import com.example.triage.domain.TicketModels.AssignmentReceipt;
import com.example.triage.domain.TicketModels.SupportQueue;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class AssignmentService {

    private final ConcurrentHashMap<String, AssignmentReceipt> assignments =
            new ConcurrentHashMap<>();

    public AssignmentReceipt assign(String ticketId, SupportQueue queue) {
        return assignments.computeIfAbsent(ticketId, ignored ->
                new AssignmentReceipt(ticketId, queue, "ASSIGNED", Instant.now()));
    }

    public AssignmentReceipt escalate(String ticketId) {
        return assignments.computeIfAbsent(ticketId, ignored ->
                new AssignmentReceipt(
                        ticketId, SupportQueue.HUMAN_REVIEW, "REVIEW_REQUIRED", Instant.now()));
    }
}

Keep the final policy deterministic and independently testable.

src/main/java/com/example/triage/service/TriagePolicy.java
package com.example.triage.service;

import com.example.triage.config.TriageProperties;
import com.example.triage.domain.TicketModels.Priority;
import com.example.triage.domain.TicketModels.SupportQueue;
import com.example.triage.domain.TicketModels.TriageDecision;
import org.springframework.stereotype.Component;

import java.util.EnumSet;

@Component
public class TriagePolicy {

    private static final EnumSet<SupportQueue> AUTONOMOUS_QUEUES =
            EnumSet.of(SupportQueue.IDENTITY, SupportQueue.PAYMENTS,
                    SupportQueue.PLATFORM, SupportQueue.GENERAL);

    private final TriageProperties properties;

    public TriagePolicy(TriageProperties properties) {
        this.properties = properties;
    }

    public boolean mayAssignAutomatically(TriageDecision decision) {
        return decision != null
                && !decision.requiresHumanReview()
                && decision.priority() != Priority.P1
                && decision.confidence() >= properties.autonomousConfidenceThreshold()
                && AUTONOMOUS_QUEUES.contains(decision.queue());
    }
}

The agent supplies precise instructions, allows the model to call the two read tools, converts the response into TriageDecision, and then applies the policy. validateSchema() validates the converted response against the generated schema so malformed output is rejected instead of reaching the policy layer.

src/main/java/com/example/triage/service/TicketTriageAgent.java
package com.example.triage.service;

import com.example.triage.domain.TicketModels.AssignmentReceipt;
import com.example.triage.domain.TicketModels.TicketRequest;
import com.example.triage.domain.TicketModels.TriageDecision;
import com.example.triage.config.TriageProperties;
import com.example.triage.domain.TicketModels.TriageResponse;
import com.example.triage.tools.TicketContextTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.time.Instant;

@Service
public class TicketTriageAgent {

    private static final String SYSTEM_PROMPT = """
            You triage software support tickets.
            Use the service-status tool when an affected service is supplied.
            Use the runbook tool when an approved runbook could support the decision.
            Return exactly one structured triage decision.

            Rules:
            - P1 means active critical impact and always requires human review.
            - Use HUMAN_REVIEW when information is missing, contradictory, sensitive,
              or confidence is below {threshold}.
            - Never claim that a tool returned information that it did not return.
            - Evidence must contain only short facts observed in the ticket or tool results.
            - Do not ask tools to modify data; the application owns all actions.
            """;

    private final ChatClient chatClient;
    private final TicketContextTools tools;
    private final TriageProperties properties;
    private final TriagePolicy policy;
    private final AssignmentService assignmentService;

    public TicketTriageAgent(
            ChatClient triageChatClient,
            TicketContextTools tools,
            TriageProperties properties,
            TriagePolicy policy,
            AssignmentService assignmentService) {
        this.chatClient = triageChatClient;
        this.tools = tools;
        this.properties = properties;
        this.policy = policy;
        this.assignmentService = assignmentService;
    }

    public TriageResponse triage(TicketRequest ticket) {
        TriageDecision decision = chatClient.prompt()
                .system(system -> system
                        .text(SYSTEM_PROMPT)
                        .param("threshold", properties.autonomousConfidenceThreshold()))
                .user(user -> user.text("""
                                Ticket ID: {ticketId}
                                Subject: {subject}
                                Description: {description}
                                Affected service: {affectedService}
                                """)
                        .param("ticketId", ticket.ticketId())
                        .param("subject", ticket.subject())
                        .param("description", ticket.description())
                        .param("affectedService",
                                ticket.affectedService() == null ? "not supplied" : ticket.affectedService()))
                .tools(tools)
                .call()
                .entity(TriageDecision.class, specification -> specification.validateSchema());

        validateDecision(decision);

        AssignmentReceipt receipt = policy.mayAssignAutomatically(decision)
                ? assignmentService.assign(ticket.ticketId(), decision.queue())
                : assignmentService.escalate(ticket.ticketId());

        return new TriageResponse(decision, receipt, Instant.now());
    }

    private void validateDecision(TriageDecision decision) {
        if (decision == null
                || decision.category() == null
                || decision.priority() == null
                || decision.queue() == null
                || decision.summary() == null
                || decision.summary().isBlank()
                || decision.rationale() == null
                || decision.rationale().isBlank()
                || decision.evidence() == null
                || decision.evidence().isEmpty()
                || decision.evidence().stream().anyMatch(item -> item == null || item.isBlank())
                || !Double.isFinite(decision.confidence())
                || decision.confidence() < 0.0
                || decision.confidence() > 1.0) {
            throw new InvalidAgentDecisionException("The model returned an invalid triage decision");
        }
    }
}
src/main/java/com/example/triage/service/InvalidAgentDecisionException.java
package com.example.triage.service;

public class InvalidAgentDecisionException extends RuntimeException {

    public InvalidAgentDecisionException(String message) {
        super(message);
    }
}

Expose the REST API

src/main/java/com/example/triage/web/TicketTriageController.java
package com.example.triage.web;

import com.example.triage.domain.TicketModels.TicketRequest;
import com.example.triage.domain.TicketModels.TriageResponse;
import com.example.triage.service.TicketTriageAgent;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/tickets")
public class TicketTriageController {

    private final TicketTriageAgent agent;

    public TicketTriageController(TicketTriageAgent agent) {
        this.agent = agent;
    }

    @PostMapping("/triage")
    ResponseEntity<TriageResponse> triage(@Valid @RequestBody TicketRequest request) {
        return ResponseEntity.ok(agent.triage(request));
    }
}

Return a stable error shape without exposing model-provider details.

src/main/java/com/example/triage/web/ApiExceptionHandler.java
package com.example.triage.web;

import com.example.triage.service.InvalidAgentDecisionException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalidRequest(MethodArgumentNotValidException exception) {
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "The ticket request is invalid");
        detail.setTitle("Invalid ticket");
        return detail;
    }

    @ExceptionHandler(InvalidAgentDecisionException.class)
    ProblemDetail invalidDecision(InvalidAgentDecisionException exception) {
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_GATEWAY,
                "The AI provider did not return a decision that passed validation");
        detail.setTitle("Invalid agent decision");
        return detail;
    }
}

For provider timeouts and rate limits, map the exception to 503 Service Unavailable, add a request correlation ID, and let the caller retry only when the operation is idempotent.

Test the deterministic safety boundary

The most important unit test does not call a model. It proves that the application will never auto-assign a P1, low-confidence, or review-required decision.

src/test/java/com/example/triage/service/TriagePolicyTest.java
package com.example.triage.service;

import com.example.triage.config.TriageProperties;
import com.example.triage.domain.TicketModels.Priority;
import com.example.triage.domain.TicketModels.SupportQueue;
import com.example.triage.domain.TicketModels.TicketCategory;
import com.example.triage.domain.TicketModels.TriageDecision;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class TriagePolicyTest {

    private final TriagePolicy policy = new TriagePolicy(new TriageProperties(0.85));

    @Test
    void allowsAHighConfidenceRoutineTicket() {
        TriageDecision decision = decision(Priority.P3, 0.93, false, SupportQueue.IDENTITY);
        assertThat(policy.mayAssignAutomatically(decision)).isTrue();
    }

    @Test
    void blocksCriticalLowConfidenceAndReviewCases() {
        assertThat(policy.mayAssignAutomatically(
                decision(Priority.P1, 0.99, false, SupportQueue.PLATFORM))).isFalse();
        assertThat(policy.mayAssignAutomatically(
                decision(Priority.P3, 0.60, false, SupportQueue.PLATFORM))).isFalse();
        assertThat(policy.mayAssignAutomatically(
                decision(Priority.P3, 0.95, true, SupportQueue.PLATFORM))).isFalse();
    }

    private TriageDecision decision(
            Priority priority,
            double confidence,
            boolean review,
            SupportQueue queue) {
        return new TriageDecision(
                TicketCategory.ACCESS,
                priority,
                queue,
                confidence,
                "Account access issue",
                "The ticket matches the access category",
                List.of("User reports a locked account"),
                review);
    }
}

Add contract tests for the tool methods, controller validation tests, and a small evaluation dataset for the model-facing workflow. The evaluation set should contain routine tickets, ambiguous tickets, prompt-injection attempts, P1 incidents, and cases with misleading service names.

Run and verify

Terminal
export OPENAI_API_KEY="replace-with-your-key"
export OPENAI_CHAT_MODEL="replace-with-a-tool-capable-model"

mvn clean test
mvn spring-boot:run

Send a routine ticket:

Terminal
curl --fail-with-body \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
    "ticketId": "TCK-1042",
    "subject": "Account locked after password change",
    "description": "The user cannot sign in after changing a password. No wider outage is reported.",
    "affectedService": "identity"
  }' \
  http://localhost:8080/api/tickets/triage

Verify that the JSON contains a structured decision and either an ASSIGNED or REVIEW_REQUIRED receipt. Repeating the same ticket ID should return the original assignment instead of creating another one.

Security and production considerations

  • Treat ticket text and tool output as untrusted input. A ticket can contain prompt injection. The system prompt should state that ticket text is data, and the application must still enforce every action in Java.
  • Separate read tools from write operations. Pass only the tools needed for this request. Avoid globally configured write tools.
  • Authorise on the server. A tool description is not an access-control rule. Verify tenant, user, and ticket permissions inside the underlying service.
  • Limit the loop. This workflow permits one model decision and one application action. More complex agents need explicit step, time, and token budgets.
  • Redact sensitive data. Do not send secrets, credentials, health data, payment data, or unnecessary personal information to a model provider.
  • Audit decisions and actions. Store the prompt template version, model identifier, tool names, tool outcomes, policy result, correlation ID, and final action. Do not log raw secrets.
  • Use idempotency and transactions. A retry after a timeout must not assign the ticket twice. Persist an idempotency key with a unique constraint.
  • Evaluate before release. Measure classification accuracy, escalation recall, unsafe-action rate, tool-selection accuracy, latency, and cost on representative tickets.
  • Design a kill switch. Operations must be able to disable autonomous assignment without redeploying the service.

Common problems and troubleshooting

Typical agentic workflow failures
SymptomLikely causeCorrective action
The model never calls a toolTool description is vague, model lacks tool support, or context is unnecessaryConfirm model capability, improve the description, and inspect provider request logs without exposing secrets
Structured conversion failsModel returned prose or a field outside the schemaKeep a closed record, use schema validation, lower temperature, and route repeated failures to review
The agent selects an unsafe queuePrompt-only policy is being trustedEnforce allowed queues and priority rules in TriagePolicy
Duplicate assignments appearThe action is not idempotentUse a database uniqueness constraint and transactional update
Latency grows unexpectedlyToo many tools, slow dependencies, or repeated model callsSet tool timeouts, cap candidates, instrument each step, and stop after a bounded number of operations
Tool output contains sensitive dataThe tool returns more than the decision needsReturn a minimal DTO, redact fields, and apply tenant-aware authorization

Spring AI tool calling vs MCP

Both give a model the ability to do something. They differ in where the capability lives, and the decision is an architectural one rather than a matter of preference.

A local @Tool method is a Java method in this application. The model receives its name, description, and parameter schema; Spring AI invokes it in-process. An MCP tool lives in a separate process and is reached over a protocol, so it can be discovered and called by any MCP-capable client.

Choosing between local tools and MCP
Local @ToolMCP tool
Where it runsSame JVM as the agentSeparate process, reached over a protocol
Who can call itOnly this applicationAny authorised MCP client
Call overheadA method callA network round trip
AuthorisationInside your own code pathMust be enforced on the wire, per client
Failure modesOrdinary Java exceptionsPlus transport, timeout, and version mismatch
Refactoring costRename a methodA published contract other clients depend on
Right whenOne application owns the capabilitySeveral clients need the same capability

Start local. A local tool is cheaper to build, cheaper to secure, and cheaper to change. The overhead of MCP buys distribution, and distribution is only worth paying for when something is actually distributed.

Move to MCP when a second consumer appears. The moment a different application — another service, a desktop assistant, a colleague's agent — needs the same capability, duplicating the tool means duplicating its authorisation rules and its bugs. That is the point at which a published contract earns its cost.

Do not move to MCP to make tools "reusable" in principle. A single-consumer MCP server is a network hop and an authorisation surface bought for nothing.

One thing does not change with the decision. Exposing a capability remotely does not make it safer, and it does not move responsibility for what the model is allowed to do. The policy gate in this article stays exactly where it is, on the side that performs the action. The secure Spring AI MCP server and client guide continues this design across a process boundary, including the authorisation and confused-deputy problems that a local tool never has to solve.

Conclusion

The production value of an agent comes from its boundaries, not from maximising autonomy. Spring AI handles the model-facing tool loop and structured conversion, while Java code retains authority over validation, policy, idempotency, auditing, and stop conditions. That split lets routine ticket assignments run without human intervention while keeping critical and uncertain cases under human control.

The same pattern extends in three directions. Give the agent better evidence to work from with hybrid search and reranking, which is worth doing before blaming a weak decision on the model. Move the capability across a process boundary with a secure MCP server once a second client needs it. Or take the whole workflow off the request thread with asynchronous Spring AI and Kafka when tool loops grow long enough to time out an HTTP call. If retrieval itself is new, start with the Spring AI RAG example.

Frequently asked questions

What is the difference between Spring AI tool calling and MCP?

A local @Tool method runs in the same JVM as the agent and is only callable by that application. An MCP tool runs in a separate process and any authorised MCP client can discover and call it. Start local; adopt MCP when a second consumer genuinely needs the same capability, because that is when a published contract stops being pure overhead.

How do I stop an agent from looping through tool calls?

Spring AI 2.0 enforces limits through ToolCallingManager. Set maxTotalToolCalls and defaultMaxCallsPerTool on DefaultToolCallingManager.builder(), and choose ToolCallLimitBehavior.THROW when a partial answer would be worse than a failure. A limit bounds effort; it does not decide whether the result is safe to act on, which is what the deterministic policy is for.

Can I run this Spring AI agent with a local Ollama model?

Yes, with --spring.profiles.active=ollama, but the model must support tool calling. llama3.1 does; many smaller local models do not, and one that lacks tool support will answer in prose instead of calling a tool. The ollama profile also raises the autonomous-confidence threshold, because local models calibrate their own confidence less reliably.

Should the model decide whether an action is safe?

No. Ask the model for a structured recommendation and let deterministic Java code decide whether to act on it. A confidence value is model output like any other field, so it is evidence for a policy rather than an authorisation. Anything the model can talk its way past is not a boundary.

Why validate structured output when the converter already maps it?

The converter guarantees shape, not sense. It will happily produce a record with a confidence of 1.4, an empty summary, or a low-confidence decision that does not request review. Those checks are business rules, and they belong in your code rather than the prompt.

How many tools should one agent have?

Fewer than feels natural. Every additional tool enlarges the decision space, raises latency and token cost, and increases the chance of the wrong tool being selected. Three narrow, well-described tools beat ten broad ones, and a tool called repeatedly with near-identical arguments usually means its description is ambiguous rather than that the model is at fault.

Official references

Complete implementation

Run the matching repository

The repository contains the complete project, configuration examples, tests, and operating notes used by this guide.

View the complete project on GitHub

Portfolio context

Continue reading

Next step

Need help with Java backend development, architecture, or AI integration?

Send a short message about the role, project, stack, and timeline, and I will get back to you.

Open to senior Java backend roles in the Netherlands and the wider EU, including hybrid and remote, plus architecture reviews and backend-led AI integration work.