How a Single @Tool Annotation Lets AI Take Over Your Business System

The article explains how the Spring AI @Tool annotation transforms large language models from guesswork to real‑time data retrieval and action execution, presenting ten concrete scenarios—query, write, aggregation, cross‑system integration, proactive push, Text‑to‑SQL, role‑based access, external services, workflow triggers, and intelligent diagnostics—each illustrated with Java code, LLM decision flow, best‑practice tips, and cost considerations.

Java Web Project
Java Web Project
Java Web Project
How a Single @Tool Annotation Lets AI Take Over Your Business System

What @Tool Does

LLMs are trained only on static data and cannot access your business data. When a user asks a question such as “What is today’s sales?”, the model would fabricate an answer. The @Tool annotation equips the model with a "hand": Spring AI converts the annotated Java method into a JSON Schema, sends the schema with every request, lets the LLM decide which tool to invoke and with what parameters, then calls the method via reflection and returns the result so the model can generate a factual response.

Token cost : each tool call creates two HTTP requests, roughly doubling token consumption. Multiple calls multiply the cost, so token usage must be accounted for.

Scenario 1 – Query Tools (Read‑Only Business Data)

Typical use cases: order status, customer follow‑up, attendance, service monitoring.

@Service
public class OrderQueryTools {
    @Autowired
    private OrderRepository orderRepo;

    @Tool(description = "Query real‑time order status and logistics by order number. "
            + "Applicable: user asks \"Where is my order?\", \"Has it shipped?\", \"Tracking number?\". "
            + "Returns: order status, product info, carrier, tracking number, latest location. "
            + "If order not found, return a clear error object, never null.")
    public OrderStatusVO queryOrderStatus(
            @ToolParam(description = "Order number, format ORD‑YYYY‑NNN") String orderId) {
        Order order = orderRepo.findByOrderId(orderId);
        if (order == null) {
            return OrderStatusVO.notFound(orderId); // never return null
        }
        return OrderStatusVO.from(order);
    }
}

Three must‑do points for query tools :

Never return null; return an error object with a message.

Mask sensitive fields (e.g., phone, ID) before returning.

Add timeout handling to avoid blocking the whole conversation.

@Tool(description = "Query logistics trace")
public LogisticsVO queryLogistics(String trackingNo) {
    try {
        return logisticsClient.query(trackingNo, Duration.ofSeconds(3));
    } catch (TimeoutException e) {
        return LogisticsVO.error("Logistics query timed out, please retry");
    }
}

Scenario 2 – Write Operations (Execute Business Actions)

Write operations are irreversible, so the description must define explicit trigger conditions.

@Tool(description = "Initiate a refund request. This operation has real business impact and cannot be undone. "
        + "Must meet all conditions before invoking: "
        + "- User explicitly mentions \"refund\", \"return\", or \"apply for refund\". "
        + "- Specific order ID is known. "
        + "- Reason is provided. "
        + "Do NOT invoke if the user is merely complaining. "
        + "Before calling, repeat order number and amount for user confirmation.")
public RefundResult applyRefund(
        @ToolParam(description = "Order number") String orderId,
        @ToolParam(description = "Refund reason, one of: QUALITY_ISSUE, WRONG_ITEM, NOT_RECEIVED, CHANGE_MIND") String reason,
        @ToolParam(description = "User‑provided refund note") String note) {
    return refundService.apply(orderId, reason, note);
}

Because the trigger conditions are described, the LLM automatically asks the user to confirm before executing:

User: This order's product quality is bad, I want a refund.
AI: Please confirm the following refund information:
    Order: ORD‑2025‑042, MacBook Pro 14" × 1
    Amount: ¥12,999.00
    Reason: QUALITY_ISSUE
    Confirm refund?
User: Confirm
AI: Refund applied successfully ✓
    Refund ID: REF‑2025‑001
    ¥12,999.00 will be returned within 1‑3 business days.

If the description lacks clear conditions, the model may execute the action on a mere complaint, which is undesirable.

Scenario 3 – Aggregated Statistics (Parallel Tool Calls)

When a user asks for a comparison, the LLM can invoke the same tool twice in parallel.

@Tool(description = "Query follow‑up statistics for a salesperson within a given period. "
        + "For period comparison, invoke twice with different period values. "
        + "Period values: this_month / last_month / this_week / last_week")
public FollowUpStats getFollowUpStats(
        @ToolParam(description = "Salesperson ID") Long salesId,
        @ToolParam(description = "Time period") String period) {
    DateRange range = DateRange.parse(period);
    return statsService.calculate(salesId, range);
}

LLM issues two concurrent calls (e.g., this_month and last_month) and merges the results. Note: the method must be thread‑safe; avoid ThreadLocal or shared mutable state.

Scenario 4 – Cross‑System Data Integration

Register tools from multiple systems (CRM, ERP, Finance) into a single ChatClient. The system prompt tells the LLM to label data from each source.

@Bean
public ChatClient enterpriseChatClient(ChatModel chatModel,
        CrmTools crmTools, ErpTools erpTools, FinanceTools financeTools) {
    return ChatClient.builder(chatModel)
        .defaultTools(crmTools, erpTools, financeTools)
        .defaultSystem("You are an internal data assistant. You can query CRM, ERP, and Finance systems. "
                + "When a user question spans multiple systems, query them in parallel, aggregate, and label each section.")
        .build();
}

Example response aggregates data from the three systems into a single answer.

Scenario 5 – Proactive Push (System Prompt Trigger)

A daily‑briefing tool is called automatically at the start of every conversation. The system prompt includes a rule that the tool is only spoken when there are urgent items.

@Tool(description = "Query today's follow‑up customers and contracts expiring soon for a salesperson. "
        + "[Invoked automatically at conversation start; if hasUrgentItems is true, prepend a short reminder (≤30 characters)].")
public DailyBriefingVO getDailyBriefing(
        @ToolParam(description = "Salesperson ID") Long salesId) {
    List<Customer> todayFollowUps = customerRepo.findByOwnerIdAndNextFollowUpDate(salesId, LocalDate.now());
    List<Contract> expiringContracts = contractRepo.findByOwnerIdAndEndDateBeforeAndRenewedFalse(
            salesId, LocalDate.now().plusDays(7));
    return DailyBriefingVO.builder()
            .todayFollowUps(todayFollowUps)
            .expiringContracts(expiringContracts)
            .hasUrgentItems(!todayFollowUps.isEmpty() || !expiringContracts.isEmpty())
            .build();
}

System prompt snippet:

.defaultSystem("Each conversation start, call getDailyBriefing(). "
        + "If hasUrgentItems is true, prepend \"Reminder: [content]\" (max 30 chars) and then handle the user query. "
        + "If no urgent items, stay silent.")

Result differs based on urgency:

// With urgent contract
User: What customers should I follow up today?
AI: Reminder: Manager Wang's contract expires on Apr 28. 
    ... (list of customers)

// Without urgent contract
User: What customers should I follow up today?
AI: You have 2 customers to follow up today: ...

Scenario 6 – Text‑to‑SQL

When no API exists, the LLM can generate a SELECT statement. The tool validates that only read‑only statements are allowed and blocks dangerous keywords.

@Tool(description = "Convert natural‑language query to SQL and execute it. "
        + "Applicable when flexible data retrieval is needed. "
        + "Security: only SELECT allowed; block any write operation. "
        + "Return up to 100 rows.")
public SqlQueryResult executeQuery(
        @ToolParam(description = "User's natural‑language query") String naturalLanguageQuery) {
    String sql = sqlGenerationService.generate(naturalLanguageQuery, schemaContext);
    // Allow only SELECT
    if (!sql.trim().toUpperCase().startsWith("SELECT")) {
        return SqlQueryResult.denied("Only query operations are supported");
    }
    // Blacklist dangerous keywords
    String upper = sql.toUpperCase();
    boolean hasDanger = Stream.of("DROP", "DELETE", "UPDATE", "INSERT", "TRUNCATE", "ALTER")
            .anyMatch(upper::contains);
    if (hasDanger) {
        return SqlQueryResult.denied("Unsafe statement detected and blocked");
    }
    return jdbcTemplate.queryForResult(sql, 100);
}

Demo:

// Normal query
User: List users registered this month who have never placed an order.
AI: (shows 47 rows) SELECT user_id, created_at, channel FROM users ...

// Blocked dangerous operation
User: Delete all orders before 2023.
AI: Sorry, I only support data queries, not delete operations.

Scenario 7 – Conditional Tools (Role‑Based Access)

Define a basic tool set for all employees and a privileged set for HR. Register different ChatClient beans per role so low‑privilege users never see HR‑only tools.

// Basic tools (available to everyone)
@Service
public class BasicTools {
    @Autowired
    private EmployeeRepository employeeRepo;

    @Tool(description = "Query the logged‑in employee's own salary slip. "
            + "Applicable: employee asks \"What is my salary this month?\".")
    public SalaryVO getMyOwnSalary(@ToolParam(description = "Employee ID (must be the logged‑in user)") Long employeeId) {
        return salaryRepo.findByEmployeeId(employeeId);
    }

    @Tool(description = "Query generic company policies such as holidays, reimbursement rules, office address.")
    public PolicyVO queryCompanyPolicy(@ToolParam(description = "Keyword, e.g., holiday, reimbursement") String keyword) {
        return policyRepo.search(keyword);
    }
}

// HR‑only tools
@Service
public class HrAdminTools {
    @Autowired
    private SalaryRepository salaryRepo;
    @Autowired
    private AttendanceRepository attendanceRepo;

    @Tool(description = "Query any employee's salary details. [HR role only].")
    public SalaryVO queryEmployeeSalary(@ToolParam(description = "Employee ID or badge, e.g., E10234") String employeeId) {
        return salaryRepo.findByEmployeeId(Long.valueOf(employeeId));
    }

    @Tool(description = "Query employee attendance records, including late, early leave, leave, overtime.")
    public AttendanceVO queryAttendance(@ToolParam(description = "Employee ID") String employeeId,
                                        @ToolParam(description = "Month, format yyyy‑MM") String month) {
        return attendanceRepo.findByEmployeeAndMonth(employeeId, month);
    }

    @Tool(description = "Export department salary report. [HR role only].")
    public List<SalarySummaryVO> exportDeptSalaryReport(@ToolParam(description = "Department ID") String deptId,
                                                        @ToolParam(description = "Month, format yyyy‑MM") String month) {
        return salaryRepo.findByDeptAndMonth(deptId, month);
    }
}

// ChatClient beans per role
@Bean("employeeChatClient")
public ChatClient employeeChatClient(ChatModel chatModel, BasicTools basic) {
    return ChatClient.builder(chatModel)
            .defaultTools(basic)
            .defaultSystem("You are an employee assistant; you can only query the logged‑in employee's own data.")
            .build();
}

@Bean("hrChatClient")
public ChatClient hrChatClient(ChatModel chatModel, BasicTools basic, HrAdminTools hr) {
    return ChatClient.builder(chatModel)
            .defaultTools(basic, hr)
            .defaultSystem("You are an HR assistant; you can query and manage all employees' salary and attendance.")
            .build();
}

@Bean("adminChatClient")
public ChatClient adminChatClient(ChatModel chatModel, BasicTools basic, HrAdminTools hr, FinanceTools finance) {
    return ChatClient.builder(chatModel)
            .defaultTools(basic, hr, finance)
            .build();
}

// Controller selects client based on session role
@RestController
@RequestMapping("/chat")
public class ChatController {
    @Autowired
    private ChatClient employeeChatClient;
    @Autowired
    private ChatClient hrChatClient;
    @Autowired
    private ChatClient adminChatClient;

    @PostMapping
    public String chat(@RequestParam String message, HttpSession session) {
        String role = (String) session.getAttribute("userRole");
        ChatClient client = switch (role) {
            case "HR" -> hrChatClient;
            case "ADMIN" -> adminChatClient;
            default -> employeeChatClient;
        };
        return client.prompt().user(message).call().content();
    }
}

Result examples show that a regular employee cannot query another employee's salary because the corresponding tool is absent from its tool set.

Scenario 8 – External Real‑Time Services

Wrap weather and exchange‑rate APIs as tools. Weather queries are cached for 10 minutes to avoid repeated API calls.

@Service
public class ExternalDataTools {
    private final Cache<String, WeatherVO> weatherCache = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();

    @Tool(description = "Query real‑time weather and 7‑day forecast for a city. "
            + "Applicable: user asks \"weather today\", \"Should I bring an umbrella tomorrow?\".")
    public WeatherVO getWeather(@ToolParam(description = "City name, e.g., Beijing") String city) {
        return weatherCache.get(city, k -> weatherApiClient.query(k));
    }

    @Tool(description = "Query real‑time currency exchange rate. "
            + "Applicable: user asks \"How much is 100 USD in CNY?\".")
    public ExchangeRateVO getExchangeRate(@ToolParam(description = "Source currency code, e.g., USD") String from,
                                         @ToolParam(description = "Target currency code, e.g., CNY") String to) {
        return exchangeRateClient.query(from, to);
    }
}

Demo shows the AI calling both tools in parallel to answer a combined question about Shanghai weather and a USD‑to‑CNY conversion.

Scenario 9 – Workflow Trigger (Multi‑Step Operation)

A single tool can encapsulate several steps (PDF generation, email sending, follow‑up logging).

@Tool(description = "Send a contract draft email and automatically log a follow‑up. "
        + "Applicable when a salesperson confirms contract sending. "
        + "Steps: generate contract → send email → write follow‑up record. "
        + "Pre‑condition: verify customer email and amount.")
public SendContractResult sendContractEmail(
        @ToolParam(description = "Customer ID") Long customerId,
        @ToolParam(description = "Contract amount, yuan") BigDecimal amount,
        @ToolParam(description = "Validity date, format yyyy‑MM‑dd") String validUntil) {
    Customer customer = customerRepo.findById(customerId).orElseThrow();
    byte[] pdf = contractService.generate(customer, amount, validUntil);
    emailService.send(customer.getEmail(), pdf);
    followUpRepo.save(FollowUpRecord.builder()
            .customerId(customerId)
            .content("Sent contract draft, amount " + amount + " yuan, valid until " + validUntil)
            .followType("EMAIL")
            .createdAt(LocalDateTime.now())
            .build());
    return SendContractResult.success(customer.getEmail());
}

Dialogue example demonstrates confirmation, execution, and automatic logging.

Scenario 10 – Intelligent Diagnosis

Combine three diagnostic tools (error logs, resource metrics, slow SQL) so the AI can produce a root‑cause analysis.

@Service
public class DiagnosticTools {
    @Tool(description = "Query recent error logs for a service, returning frequency and typical messages")
    public ErrorSummary queryErrorLogs(@ToolParam(description = "Service name") String serviceName,
                                      @ToolParam(description = "Minutes to look back, 1‑60") int minutes) {
        return elkClient.queryErrors(serviceName, minutes);
    }

    @Tool(description = "Query CPU, memory, and DB connection‑pool usage for a service")
    public ResourceMetrics queryMetrics(@ToolParam(description = "Service name") String serviceName) {
        return prometheusClient.query(serviceName);
    }

    @Tool(description = "Query slow SQL (>500 ms) ordered by execution frequency")
    public List<SlowQuery> querySlowSql(@ToolParam(description = "Database name") String database,
                                        @ToolParam(description = "Minutes to look back") int minutes) {
        return dbMonitorClient.querySlowSql(database, minutes);
    }
}

When an alert arrives, the LLM invokes all three tools in parallel and returns a concise diagnosis with a remediation suggestion (e.g., adding an index).

Configuration Summary

Dependency

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>

Spring AI properties

spring:
  ai:
    openai:
      api-key: ${AI_API_KEY}
    chat:
      options:
        model: gpt-4o
        temperature: 0.3   # lower temperature for tool calls
logging:
  level:
    org.springframework.ai: DEBUG   # enable during development

Standard ChatClient bean

@Bean
public ChatClient chatClient(ChatModel chatModel, MyTools myTools) {
    return ChatClient.builder(chatModel)
            .defaultTools(myTools)
            .defaultOptions(ChatOptions.builder()
                    .maxToolCalls(5)   // prevent endless loops
                    .maxTokens(2000)
                    .build())
            .defaultSystem("You are a business assistant.")
            .build();
}

Tool class template

@Service
@Slf4j
public class MyTools {
    @Tool(description = "Brief description (60‑80 chars). Specify applicable user intents, return type, and constraints.")
    public ResultVO doSomething(@ToolParam(description = "Parameter description, format, allowed values") String param) {
        log.info("[Tool] doSomething | param={}", param);
        try {
            return service.execute(param);
        } catch (TimeoutException e) {
            return ResultVO.error("Operation timed out, please retry");
        } catch (Exception e) {
            log.error("[Tool] doSomething exception | param={}", param, e);
            return ResultVO.error("Operation failed: " + e.getMessage());
        }
    }
}

REST controller for chat

@RestController
@RequestMapping("/api/chat")
public class ChatController {
    @Autowired
    private ChatClient chatClient;

    @PostMapping
    public String chat(@RequestBody ChatRequest req) {
        return chatClient.prompt()
                .user(req.getMessage())
                .call()
                .content();
    }

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content();
    }
}

AOP audit for tool calls

@Aspect
@Component
@Slf4j
public class ToolAuditAspect {
    @Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
    public Object audit(ProceedingJoinPoint pjp) throws Throwable {
        String method = pjp.getSignature().getName();
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();
            log.info("[ToolAudit] {} | cost={}ms | OK", method, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable t) {
            log.error("[ToolAudit] {} | cost={}ms | ERR={}", method, System.currentTimeMillis() - start, t.getMessage());
            throw t;
        }
    }
}

Token‑cost controls

Keep description under 80 characters; each tool consumes tokens.

Limit maximum tool‑call rounds via

.defaultOptions(ChatOptions.builder().maxToolCalls(5).build())

.

Optional Sentinel rate limiting on the chat endpoint.

Scheduled job to alert when daily token usage exceeds a threshold.

.defaultOptions(ChatOptions.builder().maxToolCalls(5).build())

@SentinelResource(value = "ai-chat", blockHandler = "handleBlock")
public String chat(String message) { ... }

@Scheduled(cron = "0 0 * * * *")
public void checkTokenUsage() {
    long today = tokenCounter.getTodayUsage();
    if (today > TOKEN_DAILY_LIMIT) {
        alertService.send("Token daily usage exceeded: " + today);
    }
}

Scenario Summary (Key Takeaways)

Query tools : read data, never return null, mask sensitive info, add timeout.

Write tools : describe explicit trigger conditions to avoid accidental execution.

Aggregated statistics : parallel tool calls; ensure thread safety.

Cross‑system integration : map IDs inside tools; label data from each source.

Proactive push : use system prompt to call a briefing tool only when urgent.

Text‑to‑SQL : enforce SELECT‑only, blacklist dangerous keywords, limit rows.

Conditional tools : register role‑specific tool sets; low‑privilege users never see high‑privilege tools.

External services : cache results to avoid API rate limits.

Workflow trigger : encapsulate multi‑step business processes in a single tool; describe side effects in the description.

Intelligent diagnosis : parallel invocation of log, metric, and slow‑SQL tools yields fast root‑cause analysis.

These ten patterns can be combined. For example, a full‑service assistant may use query + write + proactive push; a data‑analysis AI may combine query + aggregation + cross‑system; an operations bot may use query + diagnosis + workflow trigger.

The @Tool mechanism itself is straightforward; the real effort lies in identifying valuable business moments and writing precise description strings.

backendJavaSpring AIAI integrationTool annotation
Java Web Project
Written by

Java Web Project

Focused on Java backend technologies, trending internet tech, and the latest industry developments. The platform serves over 200,000 Java developers, inviting you to learn and exchange ideas together. Check the menu for Java learning resources.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.