AI Chatbot for a Courier System

Add an AI assistant to a Spring Boot courier app with role-based access.

Introduction

30 Second Summary

Every day, millions of people track packages by typing tracking numbers into static search bars. What if your customers could just ask "Where is my parcel?" and get an intelligent answer backed by real data?

In this project, you will integrate an AI chat assistant into your existing Spring Boot courier management system using Spring AI and Google Gemini. The assistant answers logistics questions with live MySQL data while restricting what each user role can access.

What You'll Build

Type "Where is parcel TRK-12345?" into your chat widget and watch the AI query your database, find the parcel, and respond with its current status, sender, and location.

By the end of this project, you'll have:

  • A scoped chat endpoint that only answers courier and logistics questions. Ask it to write a poem and it politely declines.
  • Three database tools the AI calls automatically to track parcels, find failed deliveries, and total up cash-on-delivery collections from your live MySQL data.
  • Role-based tool filtering where you switch between ADMIN, COURIER, USER, and GUEST in a dropdown and watch the AI's capabilities change instantly.
  • Secret Mission: Add Groq as a second AI provider and switch between Gemini and Groq using a single Spring profile flag.

Are there any prerequisites?

This project builds on an existing Spring Boot 4.1.0 project with MySQL and Spring Security already configured, including USER, ADMIN, GUEST, and COURIER roles.

You also need IntelliJ IDEA with Maven set up, and a Google account for obtaining a Gemini API key.

Before We Start

Before writing any code, take a moment to commit to what you're about to build. This step is where you lock in the goal and understand why adding AI to your existing courier management system matters.

Add Spring AI and Google Gemini to Your Project

You have committed to building an AI-powered chat assistant for your courier system. The next question is: how do you actually connect your Spring Boot backend to an AI model?

Spring AI is the framework that bridges Spring Boot and large language models like Google Gemini. In this step, you will add the Spring AI dependency, grab a free Gemini API key, and prove the connection works with a test endpoint.

In this step, get ready to:
  • Add the Spring AI 2.0.0 dependencies to your pom.xml.
  • Get a free Google Gemini API key and configure it in your project.
  • Verify the AI connection with a minimal test endpoint.
Add the Spring AI dependencies

Spring AI uses a Bill of Materials (BOM) to manage version compatibility across all its modules. You need two additions to your pom.xml: the BOM inside <dependencyManagement> and the Google GenAI starter inside <dependencies>.

  • Switch back to IntelliJ IDEA and open your pom.xml file.
  • Find the <dependencyManagement> section. If your pom.xml does not have one, add a <dependencyManagement><dependencies>...</dependencies></dependencyManagement> block above your <dependencies> section.
  • Add the Spring AI BOM inside <dependencyManagement><dependencies>:
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-bom</artifactId>
    <version>2.0.0</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>
  • Now find your main <dependencies> section (the one that lists your Spring Boot starters, MySQL connector, etc.).
  • Add the Google GenAI starter dependency:
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-google-genai</artifactId>
</dependency>

What do these two dependencies do?

The spring-ai-bom is a Bill of Materials. It locks every Spring AI module to version 2.0.0 so you never have to specify a version on individual Spring AI dependencies.

The spring-ai-starter-model-google-genai starter auto-configures a ChatClient.Builder bean that talks to Google Gemini. You can inject it anywhere in your Spring app and start prompting the model immediately.

  • Save pom.xml.
  • IntelliJ will show a floating notification to reload Maven changes. Click the reload icon, or press Ctrl+Shift+O (macOS/Windows) to reload.
  • In the External Libraries section of IntelliJ's Project panel, confirm you see libraries starting with spring-ai.

Maven reload not resolving Spring AI?

Make sure you placed the BOM inside <dependencyManagement><dependencies>, not inside the main <dependencies> block. The BOM must be a separate managed entry with <type>pom</type> and <scope>import</scope>.

help me fix my Spring AI Maven dependency

Get a Google Gemini API key and configure your project

Google Gemini is the AI model your chatbot will use. Its free tier gives you free input and output tokens with no billing setup required.

  • Open your browser and go to Google AI Studio.
  • Sign in with your Google account.
  • Click Get your API key on the landing page.
  • If you are a new user, Google AI Studio automatically creates a project and API key after you accept the Terms of Service. Copy the key from the API Keys page.
  • If you already have a Google Cloud project, click Create API key and follow the dialog to create a new key. Copy it once created.

Is the Gemini API free?

Yes. The free tier provides free input and output tokens. You do not need to set up billing for this project.

On the free tier, your content is used to improve Google products. Keep that in mind if you plan to use this in production.

Now you need to tell Spring AI where to find Gemini and which model to use.

  • Return to IntelliJ IDEA and open src/main/resources/application.properties.
  • Add the following configuration at the bottom of the file, replacing your-gemini-api-key-here with the API key you just copied:
# Google Gemini AI Configuration
spring.ai.google.genai.api-key=your-gemini-api-key-here
spring.ai.google.genai.chat.model=gemini-2.5-flash
spring.ai.google.genai.chat.temperature=0.3

What do these properties do?

  • spring.ai.google.genai.api-key authenticates your requests to Google Gemini.
  • spring.ai.google.genai.chat.model sets the model to gemini-2.5-flash, a fast and capable model on the free tier.
  • spring.ai.google.genai.chat.temperature at 0.3 keeps responses focused and consistent, which is ideal for a logistics assistant.
  • Save application.properties.

Keep your API key out of version control

Make sure application.properties is listed in your .gitignore file, or use environment variables for the API key in a real project. For this learning project, hardcoding the key in properties is fine.

Create a test endpoint and verify the connection

The dependencies are installed and the API key is configured. Time to prove the connection works by sending a prompt to Gemini and getting a response back.

  • In IntelliJ, right-click the com.example.courier.controller package in the Project panel.
  • Select New, then Java Class.
  • Name it ChatTestController and press Enter.
  • Replace the contents of the file with this code:
package com.example.courier.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatTestController {

    private final ChatClient chatClient;

    // Spring AI auto-configures ChatClient.Builder when a model starter is on the classpath
    public ChatTestController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @GetMapping("/api/chat/test")
    public String testChat() {
        // Send a simple prompt to Gemini and return the response
        return chatClient.prompt()
            .user("Say hello in one sentence")
            .call()
            .content();
    }
}

What does this code do?

  • ChatClient.Builder is auto-configured by Spring AI when the Google GenAI starter is on the classpath. You inject it through the constructor and call .build() to create a ready-to-use ChatClient.
  • The .prompt().user("Say hello in one sentence").call().content() chain sends a hardcoded prompt to Gemini and returns the AI's text response as a plain string.
  • The @GetMapping("/api/chat/test") makes this a simple GET endpoint you can hit directly in your browser.
  • Save ChatTestController.java.
  • Run your Spring Boot application by clicking the green Run button in IntelliJ's toolbar, or press Shift+F10 (Windows) / Ctrl+R (macOS).
  • Wait for the console to show that the application has started on port 8080.

What if your project uses Spring Security?

Your existing project has Spring Security configured. If the /api/chat/test endpoint is blocked, you may need to allow it in your security configuration. Add /api/chat/** to your permitted request matchers, or access the endpoint through an authenticated session.

Before you open the endpoint, what do you think the response will look like? Will it be a JSON object, or plain text?

  • Open your browser and go to:
http://localhost:8080/api/chat/test

You should see a one-sentence greeting generated by Gemini displayed as plain text in your browser. The exact wording varies each time because the AI generates a fresh response.

Seeing a blank page or an error?

  • Check your IntelliJ console for errors. If you see an error mentioning the API key, make sure the value in application.properties matches the key you copied from Google AI Studio.
  • If you see a 403 or login redirect, your Spring Security configuration is blocking the endpoint. Allow /api/chat/** in your security filter chain.
  • If the application fails to start entirely, double-check that the BOM is inside <dependencyManagement> and the starter is inside <dependencies>.

help me debug my Spring AI test endpoint

✔️ Awesome, I've got everything!

Great. Double check you have saved both pom.xml and application.properties.

ⓧ I'd like to double check the full code

Here are the additions you made to pom.xml:

<!-- Inside <dependencyManagement><dependencies> -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-bom</artifactId>
    <version>2.0.0</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>

<!-- Inside <dependencies> -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-google-genai</artifactId>
</dependency>

Here are the additions you made to application.properties:

# Google Gemini AI Configuration
spring.ai.google.genai.api-key=your-gemini-api-key-here
spring.ai.google.genai.chat.model=gemini-2.5-flash
spring.ai.google.genai.chat.temperature=0.3

Your Spring Boot app is now connected to Google Gemini and can send prompts and receive responses. Next up, you will build a proper chat endpoint that restricts the AI to courier and logistics topics only.

Build the Chat Endpoint with Scope Restriction

In the previous step, you connected Google Gemini to your Spring Boot project and proved it works with a test endpoint that generates AI responses.

That test endpoint has a problem. It accepts any question, from poetry requests to philosophy debates. For a courier management system, the AI needs guardrails that keep it focused on logistics.

In this step, get ready to:
  • Create DTO classes for the chat request and response.
  • Build a ChatController with a system prompt restricting the AI to courier logistics.
  • Test that off-topic questions are politely declined.
Create the request and response DTOs

Before building the controller, you need two data transfer objects. One represents the incoming chat message from the user, and the other wraps the AI's response.

  • In IntelliJ IDEA, right-click the com.example.courier.dto package in the Project sidebar.
  • Select New, then Java Class.
  • Name it ChatRequest and press Enter.
  • Replace the entire contents of the new file with the following code:
package com.example.courier.dto;

// DTO for incoming chat requests
public class ChatRequest {
    private String message;
    private String role;

    public ChatRequest() {}

    public ChatRequest(String message, String role) {
        this.message = message;
        this.role = role;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getRole() {
        return role;
    }

    public void setRole(String role) {
        this.role = role;
    }
}

What does this code do?

This DTO holds two fields that arrive in the JSON body of each chat request.

  • message is the question the user typed.
  • role identifies what kind of user is asking (ADMIN, COURIER, USER, or GUEST). You will use this field in a later step.
  • Save ChatRequest.java.
  • Confirm the file appears under the dto package in the Project sidebar.

Don't see ChatRequest.java in the dto package?

  • Make sure the package declaration at the top says com.example.courier.dto. If it does not match, IntelliJ will flag a package mismatch error.
  • Check that the class name ChatRequest exactly matches the filename (case-sensitive).

Why isn't my ChatRequest.java appearing in the dto package?

Now create the response DTO. This wraps the AI's answer so it can be sent back as JSON.

  • Right-click the com.example.courier.dto package again.
  • Select New, then Java Class.
  • Name it ChatResponse and press Enter.
  • Replace the entire contents of the new file with the following code:
package com.example.courier.dto;

// DTO for outgoing chat responses
public class ChatResponse {

    private String response;

    public ChatResponse() {}

    public ChatResponse(String response) {
        this.response = response;
    }

    public String getResponse() {
        return response;
    }

    public void setResponse(String response) {
        this.response = response;
    }
}

What does this code do?

This DTO has a single response field. Spring automatically serializes it to JSON like {"response": "..."} when the controller returns it.

  • Save ChatResponse.java.
  • Confirm both ChatRequest and ChatResponse now appear under the dto package in the Project sidebar.

Seeing red error highlights in either DTO?

  • Check that both files have the exact package declaration package com.example.courier.dto; at the top.
  • Verify that every opening brace { has a matching closing brace }.

Help me fix red errors in my ChatRequest or ChatResponse DTOs in IntelliJ.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete ChatRequest.java:

package com.example.courier.dto;

public class ChatRequest {

    private String message;
    private String role;

    public ChatRequest() {}

    public ChatRequest(String message, String role) {
        this.message = message;
        this.role = role;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getRole() {
        return role;
    }

    public void setRole(String role) {
        this.role = role;
    }
}

Here is the complete ChatResponse.java:

package com.example.courier.dto;

public class ChatResponse {

    private String response;

    public ChatResponse() {}

    public ChatResponse(String response) {
        this.response = response;
    }

    public String getResponse() {
        return response;
    }

    public void setResponse(String response) {
        this.response = response;
    }
}
Create the chat controller with scope restriction

With both DTOs in place, you can now build the controller that receives chat messages and passes them to Gemini with a scoped system prompt.

  • Right-click the com.example.courier.controller package in the Project sidebar.
  • Select New, then Java Class.
  • Name it ChatController and press Enter.
  • Replace the entire contents with the following class skeleton:
package com.example.courier.controller;

import com.example.courier.dto.ChatRequest;
import com.example.courier.dto.ChatResponse;
import org.springframework.ai.chat.client.ChatClient;
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;

// Handles AI chat requests for the courier management system
@RestController
@RequestMapping("/api")
public class ChatController {

    // Spring AI auto-configures this builder from your Gemini settings
    private final ChatClient.Builder chatClientBuilder;

    public ChatController(ChatClient.Builder chatClientBuilder) {
        this.chatClientBuilder = chatClientBuilder;
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON responses directly.
  • @RequestMapping("/api") prefixes all endpoints in this controller with /api.
  • ChatClient.Builder is auto-configured by Spring AI from the Gemini settings you added to application.properties. Spring injects it through the constructor automatically.
  • Save ChatController.java.
  • Confirm that IntelliJ shows no red error highlights in the file. The imports should all resolve cleanly.

Now add the endpoint method that receives chat messages and applies scope restriction. A system prompt is a hidden instruction you give to the AI before the user's message. It defines the AI's personality and boundaries.

  • In ChatController.java, place your cursor after the closing } of the constructor (but still inside the class).
  • Add the following method:
    @PostMapping("/chat")
    public ChatResponse chat(@RequestBody ChatRequest request) {
        // System prompt constrains the AI to courier topics only
        String systemPrompt = "You are a courier logistics assistant. You can ONLY answer questions about parcels, shipments, deliveries, and COD collections. If the user asks about anything outside courier operations, politely decline and explain you can only help with logistics queries.";

        // Build a ChatClient with the system prompt, send the user's message, and get the response
        String aiResponse = chatClientBuilder
            .defaultSystem(systemPrompt)
            .build()
            .prompt()
            .user(request.getMessage())
            .call()
            .content();

        return new ChatResponse(aiResponse);
    }

What does this code do?

  • @PostMapping("/chat") combined with the class-level /api mapping creates the endpoint POST /api/chat.
  • systemPrompt tells Gemini it is a courier logistics assistant. It instructs the AI to politely decline anything outside parcels, shipments, deliveries, and COD collections.
  • The ChatClient builder chain sets the system prompt, then passes the user's message, calls Gemini, and extracts the text response.
  • Save ChatController.java.
  • Stop your running Spring Boot app by pressing Ctrl+C in the terminal (or clicking the red Stop button in IntelliJ).
  • Restart the app by running it again from IntelliJ (click the green Run button or press Shift+F10 on Windows / Ctrl+R on macOS).
  • Confirm the app starts without errors. You should see the Spring Boot startup banner in the console output.

App fails to start?

  • Check that the imports in ChatController.java include org.springframework.ai.chat.client.ChatClient. A missing import causes a compilation failure.
  • Make sure ChatRequest and ChatResponse are in the com.example.courier.dto package, matching the import statements in the controller.
  • Verify the chat method's closing brace } is inside the class body. The class should end with two closing braces: one for the method and one for the class.

Help me fix startup errors in my ChatController.

✔️ Awesome, I've got everything!

Great. Make sure ChatController.java is saved and your app is running.

ⓧ I'd like to double check the full code

Your ChatController.java has two sections you built above. Scroll up and confirm both are present, in order:

  • The class skeleton: package declaration, imports, @RestController and @RequestMapping("/api") annotations, the chatClientBuilder field, and the constructor.
  • The chat() method: @PostMapping("/chat"), the systemPrompt string, the chatClientBuilder chain, and the return new ChatResponse(aiResponse) line.
Test scope restriction

The endpoint is live. Time to verify that the system prompt actually constrains the AI to courier topics.

  • First, test with a logistics question. Send a POST request to your endpoint by running this command in a new terminal:

macOS

curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "What services do you offer for parcel delivery?", "role": "ADMIN"}'

Windows

curl.exe -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d "{\"message\": \"What services do you offer for parcel delivery?\", \"role\": \"ADMIN\"}"

You should see a JSON response with the AI answering your logistics question. The response will mention parcel delivery services.

Why curl.exe on Windows?

PowerShell aliases curl to its own Invoke-WebRequest cmdlet, which uses different syntax. Typing curl.exe invokes the real curl binary. Windows also requires double quotes around the JSON body with escaped inner quotes.

Now for the real test. Before you run the next command, what do you think will happen if you ask the AI to write you a poem?

  • Send an off-topic request to test scope restriction:

macOS

curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "Write me a poem about the ocean", "role": "ADMIN"}'

Windows

curl.exe -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d "{\"message\": \"Write me a poem about the ocean\", \"role\": \"ADMIN\"}"

The AI responds with a polite refusal, explaining it can only help with courier logistics. That is the system prompt doing its job.

AI wrote the poem anyway?

  • Double-check that the systemPrompt string in your chat() method includes the phrase "politely decline". If the system prompt text is incomplete or missing, the AI will answer anything.
  • Make sure you restarted the Spring Boot app after saving your changes. Changes to Java files do not take effect until the app is restarted.

Help me troubleshoot my system prompt scope restriction.

What is a system prompt?

A system prompt is an instruction that the user never sees. It runs before every conversation and shapes how the AI behaves. In your case, it tells Gemini to act as a courier logistics assistant and refuse anything unrelated.

This is scope restriction at the prompt level. The AI still has broad knowledge, but the system prompt creates a boundary around what it will respond to.

Your AI can chat about logistics and politely declines everything else. In the next step, you will give it the ability to answer specific questions by querying your MySQL database directly.

Create Database Tools the AI Can Call

Your AI chatbot can talk about courier logistics in general terms, but try asking it "Where is parcel TRK-12345?" and it has nothing useful to say. The AI has no connection to your MySQL database, so it cannot answer questions about real parcels, deliveries, or collections.

Spring AI solves this with tool calling. You annotate a Java method with @Tool, and the AI model decides when to call it during a conversation. In this step, you will create three database tools and wire them into your chat endpoint.

In this step, get ready to:
  • Create a parcel tracking tool that looks up parcels by tracking number.
  • Create failed delivery and COD collection tools that query aggregated data.
  • Wire all three tools into your chat controller so the AI can call them.
Create the parcel tracking tool

Each tool is a Spring @Component class with a method annotated @Tool. The AI reads the annotation's description to decide when to call the method. The first tool handles the most common question: "Where is my parcel?"

  • In IntelliJ, create a new package called ai inside com.example.courier.
  • Right-click the new ai package, select New, then Java Class.
  • Name the class ParcelTrackingTool and press Enter.
  • Replace the contents of the file with this class shell, which sets up dependency injection for JdbcTemplate:
package com.example.courier.ai;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@Component
public class ParcelTrackingTool {

    private final JdbcTemplate jdbcTemplate;

    public ParcelTrackingTool(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

What does this code do?

@Component registers this class as a Spring bean so it can be injected into your controller later.

JdbcTemplate is Spring's lightweight database query helper. It is auto-configured because your project already has a MySQL datasource.

  • Now add the trackParcel method inside the class, below the constructor. Copy this code before the closing } of the class:
    @Tool(description = "Look up a parcel by its tracking number and return its current status, sender, recipient, and location")
    public String trackParcel(@ToolParam(description = "The parcel tracking number, e.g. TRK-12345") String trackingNumber) {
        List<Map<String, Object>> results = jdbcTemplate.queryForList(
            "SELECT tracking_number, status, sender_name, recipient_name, current_location FROM parcels WHERE tracking_number = ?",
            trackingNumber
        );

        if (results.isEmpty()) {
            return "No parcel found with tracking number: " + trackingNumber;
        }

        Map<String, Object> parcel = results.get(0);
        return String.format("Parcel %s: Status=%s, Sender=%s, Recipient=%s, Location=%s",
            parcel.get("tracking_number"),
            parcel.get("status"),
            parcel.get("sender_name"),
            parcel.get("recipient_name"),
            parcel.get("current_location")
        );
    }

What does this code do?

  • @Tool tells the AI model this method exists and what it does. The model reads the description to decide when calling it would answer the user's question.
  • @ToolParam describes the parameter so the model knows what value to pass. Spring AI auto-generates a JSON schema from these annotations.
  • The ? placeholder in the SQL prevents SQL injection. JdbcTemplate safely substitutes the tracking number.
  • Save ParcelTrackingTool.java.
  • Check that IntelliJ shows no red underlines in the file. If you see errors on the import lines, wait a moment for IntelliJ to re-index, then try File then Invalidate Caches / Restart.

Seeing red underlines on @Tool or @ToolParam?

  • Make sure your pom.xml has the Spring AI BOM and the spring-ai-starter-model-google-genai dependency from Step 1.
  • Click the Maven tab on the right side of IntelliJ, then click the refresh icon to reload dependencies.
  • Confirm the package declaration says com.example.courier.ai and not com.example.courier.

Why can't IntelliJ find the @Tool annotation?

✔️ Awesome, I've got everything!

Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your ParcelTrackingTool.java file has two parts. Scroll up and confirm both are present in order:

  • The class shell with the package declaration, imports, and the constructor that injects JdbcTemplate.
  • The trackParcel method with @Tool and @ToolParam annotations, inside the class body.
Create the failed delivery and COD collection tools

The next two tools follow the same pattern as ParcelTrackingTool. Each is a @Component with a @Tool method that queries your MySQL database and returns a formatted string.

  • Right-click the ai package, select New, then Java Class.
  • Name the class FailedDeliveryTool and press Enter.
  • Replace the file contents with this class shell:
package com.example.courier.ai;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@Component
public class FailedDeliveryTool {

    private final JdbcTemplate jdbcTemplate;

    public FailedDeliveryTool(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}
  • Add the getFailedDeliveries method inside the class, below the constructor:
    @Tool(description = "Query failed delivery attempts and return a summary of failed deliveries with tracking numbers and reasons")
    public String getFailedDeliveries(@ToolParam(description = "Optional date filter in YYYY-MM-DD format, or 'all' for all failed deliveries") String dateFilter) {
        List<Map<String, Object>> results;

        if (dateFilter == null || dateFilter.equalsIgnoreCase("all")) {
            results = jdbcTemplate.queryForList(
                "SELECT da.id, p.tracking_number, da.outcome, da.failure_reason, da.attempted_at " +
                "FROM delivery_attempts da JOIN parcels p ON da.parcel_id = p.id " +
                "WHERE da.outcome = 'FAILED' ORDER BY da.attempted_at DESC LIMIT 20"
            );
        } else {
            results = jdbcTemplate.queryForList(
                "SELECT da.id, p.tracking_number, da.outcome, da.failure_reason, da.attempted_at " +
                "FROM delivery_attempts da JOIN parcels p ON da.parcel_id = p.id " +
                "WHERE da.outcome = 'FAILED' AND DATE(da.attempted_at) = ? ORDER BY da.attempted_at DESC LIMIT 20",
                dateFilter
            );
        }

        if (results.isEmpty()) {
            return "No failed deliveries found.";
        }

        StringBuilder sb = new StringBuilder("Failed Deliveries (" + results.size() + " found):\n");
        for (Map<String, Object> row : results) {
            sb.append(String.format("- Parcel %s: %s (at %s)\n",
                row.get("tracking_number"),
                row.get("failure_reason"),
                row.get("attempted_at")
            ));
        }
        return sb.toString();
    }

What does this query do?

This tool joins the delivery_attempts and parcels tables to find deliveries where the outcome was FAILED. It returns up to 20 results, sorted by most recent first.

The dateFilter parameter lets the AI narrow results to a specific day. If the user says "show me today's failures" the model passes the current date. If the user asks broadly, the model passes all.

  • Save FailedDeliveryTool.java and confirm IntelliJ shows no red underlines.

Now create the third tool for Cash on Delivery totals.

  • Right-click the ai package, select New, then Java Class.
  • Name the class CodCollectionTool and press Enter.
  • Replace the file contents with this class shell:
package com.example.courier.ai;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class CodCollectionTool {

    private final JdbcTemplate jdbcTemplate;

    public CodCollectionTool(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}
  • Add the getCodTotals method inside the class, below the constructor:
    @Tool(description = "Get the total Cash on Delivery (COD) collection amount, optionally filtered by date")
    public String getCodTotals(@ToolParam(description = "Optional date filter in YYYY-MM-DD format, or 'all' for all time totals") String dateFilter) {
        Map<String, Object> result;

        if (dateFilter == null || dateFilter.equalsIgnoreCase("all")) {
            result = jdbcTemplate.queryForMap(
                "SELECT COUNT(*) as total_collections, COALESCE(SUM(amount), 0) as total_amount FROM cod_collections"
            );
        } else {
            result = jdbcTemplate.queryForMap(
                "SELECT COUNT(*) as total_collections, COALESCE(SUM(amount), 0) as total_amount FROM cod_collections WHERE DATE(collected_at) = ?",
                dateFilter
            );
        }

        return String.format("COD Collections: %s transactions totaling %s",
            result.get("total_collections"),
            result.get("total_amount")
        );
    }

What does this query do?

This tool aggregates the cod_collections table to return a total transaction count and the sum of all collected amounts. COALESCE ensures you get 0 instead of null when the table is empty.

  • Save CodCollectionTool.java and confirm IntelliJ shows no red underlines.

Seeing errors in FailedDeliveryTool or CodCollectionTool?

  • Confirm both files are in the com.example.courier.ai package, not in the root courier package.
  • Make sure the method is pasted inside the class body (between the opening and closing braces of the class), not outside it.
  • Check that your import lines match exactly. CodCollectionTool only needs java.util.Map, while FailedDeliveryTool needs both java.util.List and java.util.Map.

Why are my tool classes showing compilation errors?

Wire the tools into your chat controller

The three tool classes exist as Spring beans, but your ChatController does not know about them yet. You need to inject them and pass them to the ChatClient via .tools() so the AI can call them during a conversation.

  • Switch back to ChatController.java in IntelliJ.
  • Find the import section at the top of the file. Add these three imports below the existing ones:
import com.example.courier.ai.CodCollectionTool;
import com.example.courier.ai.FailedDeliveryTool;
import com.example.courier.ai.ParcelTrackingTool;
  • Find the existing field and constructor in your controller. They currently look like this:
    private final ChatClient.Builder chatClientBuilder;

    public ChatController(ChatClient.Builder chatClientBuilder) {
        this.chatClientBuilder = chatClientBuilder;
    }
  • Replace those lines with the updated fields and constructor that inject all three tools:
    private final ChatClient.Builder chatClientBuilder;
    private final ParcelTrackingTool parcelTrackingTool;
    private final FailedDeliveryTool failedDeliveryTool;
    private final CodCollectionTool codCollectionTool;

    public ChatController(ChatClient.Builder chatClientBuilder,
                          ParcelTrackingTool parcelTrackingTool,
                          FailedDeliveryTool failedDeliveryTool,
                          CodCollectionTool codCollectionTool) {
        this.chatClientBuilder = chatClientBuilder;
        this.parcelTrackingTool = parcelTrackingTool;
        this.failedDeliveryTool = failedDeliveryTool;
        this.codCollectionTool = codCollectionTool;
    }

What is happening here?

Spring's constructor injection automatically provides instances of each tool bean when the controller is created. You now have references to all three tools inside the controller.

  • Now find the .call() line inside the chat method. It currently looks like this:
            .user(request.getMessage())
            .call()
            .content();
  • Add a .tools() call between .user() and .call(), so those three lines now look like this:
            .user(request.getMessage())
            .tools(parcelTrackingTool, failedDeliveryTool, codCollectionTool)
            .call()
            .content();

How does tool calling work under the hood?

When you pass tools via .tools(), Spring AI's ChatClient auto-registers a ToolCallingAdvisor that drives the full round-trip. The AI model sees the tool descriptions, decides which tool to call, and Spring AI executes the method and feeds the result back to the model so it can compose a natural-language answer.

  • Save ChatController.java.

✔️ Awesome, I've got everything!

Make sure you have saved all four files before running the app.

ⓧ I'd like to double check the full code

Your ChatController.java has several changes spread across the file. Scroll up and confirm each is present in order:

  • Three new imports for CodCollectionTool, FailedDeliveryTool, and ParcelTrackingTool at the top.
  • Four private final fields (the builder plus three tools).
  • A constructor that accepts all four parameters and assigns them.
  • The .tools(parcelTrackingTool, failedDeliveryTool, codCollectionTool) call between .user() and .call().

Time to test. Before you run the app, think about what will happen when you ask the AI about failed deliveries. Do you think it will query your database, or respond with generic text?

Insert test data if your tables are empty

If your delivery_attempts table has no rows with a FAILED outcome, the AI will report zero failures. Insert at least one test row so you can see the tool working with real data.

  • Restart your Spring Boot application.
  • Send a test request to your chat endpoint by running this command in a new terminal:
curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d '{"message": "How many failed deliveries were there?", "role": "ADMIN"}'

The AI should call your FailedDeliveryTool, query MySQL, and respond with actual data from your database. You will see a JSON response with a response field containing details about failed deliveries from your delivery_attempts table.

Getting an error or a generic response?

  • If you see a connection error, make sure your Spring Boot app is fully started and listening on port 8080.
  • If the AI responds with generic text about deliveries instead of actual data, check that you added .tools(parcelTrackingTool, failedDeliveryTool, codCollectionTool) in the controller's chat method. Without it, the AI cannot see or call any tools.
  • If you see a SQL error, confirm your delivery_attempts and parcels tables exist in your MySQL database. The column names in the SQL queries must match your schema.

Why is the AI not calling my database tools?

Your AI assistant can now pull real data from MySQL to answer specific questions. The next problem: every role gets access to every tool. A GUEST can ask for COD collection totals and see financial data they should not have access to. Next up, you will add role-based filtering so each role only sees the tools they are authorized to use.

Build the Frontend Chat Widget

Your backend now enforces role-based tool filtering, restricts the AI to courier topics, and logs every query. The entire AI pipeline is working.

The only way to interact with it right now is by sending raw HTTP requests. In this step, you will build an HTML chat widget that lets you pick a role, type a question, and watch the AI respond with live MySQL data directly in your browser.

In this step, get ready to:
  • Create an HTML/CSS chat interface with a role selector dropdown.
  • Add JavaScript to send messages to your backend and display AI responses.
  • Test the full flow with different roles to confirm access control works end to end.
Create the chat page with CSS styles

Spring Boot automatically serves any files placed inside src/main/resources/static/. You will create an index.html file in that directory with embedded CSS to style a chat window.

  • In IntelliJ IDEA, right-click the src/main/resources/static folder in the Project sidebar.
  • Select New, then File.
  • Name the file index.html and press Enter.
  • Start building the page by pasting the HTML head and CSS styles into the empty file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Courier AI Assistant</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: Arial, sans-serif; background: #f0f2f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
        .chat-container { width: 500px; background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
        .chat-header { background: #1a73e8; color: white; padding: 16px 20px; font-size: 18px; font-weight: bold; }
        .role-selector { padding: 10px 20px; background: #e8f0fe; display: flex; align-items: center; gap: 10px; }
        .role-selector label { font-size: 14px; font-weight: bold; color: #333; }
        .role-selector select { padding: 6px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; }
        .chat-messages { height: 400px; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
        .message { max-width: 80%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
        .message.user { align-self: flex-end; background: #1a73e8; color: white; }
        .message.ai { align-self: flex-start; background: #f1f3f4; color: #333; }
        .chat-input { display: flex; padding: 12px; border-top: 1px solid #e0e0e0; gap: 8px; }
        .chat-input input { flex: 1; padding: 10px 14px; border: 1px solid #ccc; border-radius: 20px; font-size: 14px; outline: none; }
        .chat-input button { padding: 10px 20px; background: #1a73e8; color: white; border: none; border-radius: 20px; font-size: 14px; cursor: pointer; }
        .chat-input button:disabled { background: #ccc; cursor: not-allowed; }
        .chat-input button:hover:not(:disabled) { background: #1557b0; }
    </style>
</head>

What does this CSS do?

Each CSS rule targets a specific part of the chat widget. .chat-container creates a centered white card with rounded corners. .chat-messages is a scrollable area where messages will stack vertically.

.message.user and .message.ai use align-self to push user messages to the right in blue and AI responses to the left in grey, mimicking a real chat layout.

  • Directly below the closing </head> tag, add the HTML body with the chat container structure:
<body>
    <div class="chat-container">
        <div class="chat-header">Courier AI Assistant</div>
        <div class="role-selector">
            <label for="role">Role:</label>
            <select id="role">
                <option value="ADMIN">ADMIN</option>
                <option value="COURIER">COURIER</option>
                <option value="USER">USER</option>
                <option value="GUEST" selected>GUEST</option>
            </select>
        </div>
        <div class="chat-messages" id="messages"></div>
        <div class="chat-input">
            <input type="text" id="userInput" placeholder="Ask about parcels, deliveries..." />
            <button id="sendBtn" onclick="sendMessage()">Send</button>
        </div>
    </div>

What does this HTML do?

The <select id="role"> dropdown lets the user switch between ADMIN, COURIER, USER, and GUEST. GUEST is selected by default. The JavaScript you add next will read this value and pass it to your /api/chat endpoint.

The #messages div is empty for now. JavaScript will dynamically append message bubbles into it. The #sendBtn button calls sendMessage() on click, which you will define next.

  • Save index.html.
  • Restart your Spring Boot application so it picks up the new static file.
  • Open http://localhost:8080/index.html in your browser.

You should see a centered white card with a blue header reading "Courier AI Assistant", a role dropdown defaulting to GUEST, an empty message area, and a text input with a Send button.

Seeing a blank page or 404?

  • Confirm the file is at src/main/resources/static/index.html. Spring Boot only auto-serves files from static/ inside resources/.
  • Make sure you restarted the Spring Boot application after creating the file.
  • Check for any Spring Security configuration that might block access to static resources. Your security config may need to permit requests to /index.html.

Still stuck? Ask for help.

Add JavaScript for message handling

The chat layout is visible, but clicking Send does nothing yet. You need JavaScript to capture the user's message and role, send them to your /api/chat endpoint, and display the response as a chat bubble.

  • In index.html, add a <script> block directly below the closing </div> of chat-container. Start with DOM references, a keypress listener, and the addMessage helper:
    <script>
        const messagesDiv = document.getElementById('messages');
        const userInput = document.getElementById('userInput');
        const sendBtn = document.getElementById('sendBtn');

        userInput.addEventListener('keypress', function(e) {
            if (e.key === 'Enter') sendMessage();
        });

        function addMessage(text, sender) {
            const div = document.createElement('div');
            div.className = 'message ' + sender;
            div.textContent = text;
            messagesDiv.appendChild(div);
            messagesDiv.scrollTop = messagesDiv.scrollHeight;
        }

What does this code do?

  • The three const lines cache references to the messages area, the text input, and the send button so they can be reused without repeated DOM lookups.
  • The keypress listener lets the user press Enter to send a message instead of clicking the button.
  • addMessage() creates a new <div> with the CSS class message user or message ai, appends it to the chat area, and auto-scrolls to the bottom.
  • Directly below the addMessage function, add the sendMessage function that calls your backend and closes out the HTML file:
        async function sendMessage() {
            const message = userInput.value.trim();
            if (!message) return;

            const role = document.getElementById('role').value;
            addMessage(message, 'user');
            userInput.value = '';
            sendBtn.disabled = true;

            try {
                const response = await fetch('/api/chat', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ message: message, role: role })
                });
                const data = await response.json();
                addMessage(data.response, 'ai');
            } catch (error) {
                addMessage('Error: Could not reach the AI service.', 'ai');
            } finally {
                sendBtn.disabled = false;
            }
        }
    </script>
</body>
</html>

What does sendMessage do?

  • It reads the current text and selected role, then immediately appends the user's message as a right-aligned blue bubble.
  • It disables the Send button while the request is in flight so the user cannot fire duplicate requests.
  • fetch('/api/chat', ...) POSTs the message and role as JSON to your ChatController. When the AI responds, it appends data.response as a left-aligned grey bubble.
  • The catch block displays an error message in the chat if the backend is unreachable. The finally block re-enables the Send button regardless of success or failure.
  • Save index.html.
  • Restart your Spring Boot application.
  • Refresh http://localhost:8080/index.html in your browser.
  • Leave the role set to GUEST and type Hello in the input, then click Send.

Your message should appear as a blue bubble on the right. After a moment, the AI's response appears as a grey bubble on the left. The Send button dims while the request is in flight and re-enables once the response arrives.

Send button does nothing or you see an error bubble?

  • Open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and check for JavaScript errors. A red error pointing to a specific line number tells you exactly where to look.
  • If you see a CORS or 403 error, your Spring Security configuration may be blocking the /api/chat endpoint for unauthenticated requests from the browser. Permit POST requests to /api/chat in your security config.
  • Confirm the Spring Boot app is still running. The error bubble with the text about not reaching the AI service means the frontend could not connect to your backend at all.

Need more help debugging?

✔️ Awesome, I've got everything!

Great. Make sure you have saved index.html and your app is running before the next substep.

ⓧ I'd like to double check the full code

The full index.html file spans four code blocks you built above. Scroll up and confirm each section is present in this order:

  • The <!DOCTYPE html> declaration through the closing </head> tag, containing all CSS styles inside a <style> block.
  • The <body> with the chat-container div containing the header, role selector, messages area, and input area.
  • A <script> block with messagesDiv, userInput, and sendBtn DOM references, the keypress listener, and the addMessage function.
  • The sendMessage async function with fetch('/api/chat', ...), followed by the closing </script>, </body>, and </html> tags.
Test role-based access in the chat widget

The chat widget sends the selected role along with every message. Your backend uses that role to decide which tools the AI receives. Time to prove it works by asking the same question under two different roles.

  • In the chat widget, make sure the role dropdown is set to GUEST.
  • Type Show me COD totals and click Send.

Before you look at the response, what do you think the AI will say? Remember that GUEST only has access to the parcel tracking tool.

The AI should politely decline, explaining that GUEST does not have permission to view COD data. The CodCollectionTool was never passed to the AI for this role, so the AI physically cannot call it.

  • Now change the role dropdown to ADMIN.
  • Type the same question: Show me COD totals and click Send.

This time, the AI calls your CodCollectionTool, queries your MySQL database, and responds with the actual COD collection figures. Same question, different role, completely different result.

Why is this more secure than prompt-only restriction?

A system prompt that says "do not answer COD questions" can be bypassed with clever prompt injection. Your approach is stronger because the AI never receives the CodCollectionTool for GUEST or USER roles.

Even if someone tricks the AI into wanting to look up COD data, the tool does not exist in its available toolset. The restriction is enforced at the application layer in RoleToolFilter, not by the AI's willingness to comply.

ADMIN also gets declined?

  • Make sure you selected ADMIN from the dropdown before clicking Send. The role value is read at the moment you send, not when the page loaded.
  • Check your RoleToolFilter class. The ADMIN case should add all three tools: parcelTrackingTool, failedDeliveryTool, and codCollectionTool.
  • If the AI responds but with no data, confirm you have rows in your cod_collections table. Insert a test row if the table is empty.

Need help figuring out why ADMIN is restricted?

Secret mission

Add Groq as a Second AI Provider

Your courier assistant currently relies on a single AI provider. What happens when you want to compare response quality across different models, or need a fallback if one provider is unavailable? In this secret mission, you will add Groq as a second AI provider and switch between Gemini and Groq by changing a single Spring profile flag.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on free-tier API keys, so there are no ongoing costs. Decide whether to keep your API keys active, leave them idle, or revoke them entirely.

Resources you used:

  • Google Gemini API key (free tier, no charges).
  • Groq API key (free tier, no charges, Secret Mission only).
  • Spring AI code files added to your existing courier management project.

Keep everything running

No action needed. Choose this if you plan to keep building on your AI-powered courier assistant.

  • Both your Google Gemini and Groq API keys are on the free tier. There are no idle charges or subscription fees.
  • Your Spring Boot project files remain in place as part of your courier management system.
  • You can continue using the chat endpoint and frontend widget at any time by starting your Spring Boot app.

Pause - I'll come back to this later

Shut down your running Spring Boot app to free up local resources. Your code and API keys stay intact.

  • Stop your Spring Boot app in IntelliJ IDEA by clicking the red square stop button in the Run tool window.
  • Both API keys are free tier, so there is nothing to pause on the provider side.
  • When you are ready to resume, start the app again from IntelliJ IDEA and your chat assistant picks up right where you left off.

Delete - I don't want to use this again

Remove all project resources and revoke your API keys to start fresh.

  • Revoke your Google Gemini API key:
  • Open the Google AI Studio API Keys page at aistudio.google.com/app/apikey.
  • Find the API key you created for this project in the list.
  • Click the trash or delete icon next to the key to remove it.
  • Revoke your Groq API key (Secret Mission only):
  • Open the Groq Console API Keys page at console.groq.com/keys.
  • Find the API key you created for this project and delete it.
  • Remove the API key values from your properties files:
  • In application.properties, clear or remove the spring.ai.google.genai.api-key value.
  • In application-groq.properties, clear or remove the spring.ai.openai.api-key value.
  • If you want to remove the AI integration entirely, delete the following files from your project:
  • src/main/java/com/example/courier/ai/ParcelTrackingTool.java.
  • src/main/java/com/example/courier/ai/FailedDeliveryTool.java.
  • src/main/java/com/example/courier/ai/CodCollectionTool.java.
  • src/main/java/com/example/courier/ai/RoleToolFilter.java.
  • src/main/java/com/example/courier/controller/ChatController.java.
  • src/main/java/com/example/courier/dto/ChatRequest.java.
  • src/main/java/com/example/courier/dto/ChatResponse.java.
  • src/main/resources/static/index.html.
  • src/main/resources/application-groq.properties.
  • Remove the Spring AI BOM and starter dependencies (spring-ai-bom, spring-ai-starter-model-google-genai, and spring-ai-starter-model-openai) from your pom.xml.

Nice Work!

Nice Work!

Nice work! You just built a fully integrated AI assistant for your courier management system using Spring AI and Google Gemini. Your chatbot queries live MySQL data, enforces role-based access, and stays scoped to courier operations.

You've learned how to:

  • Integrate Spring AI 2.0.0 into an existing Spring Boot 4.1.0 backend and connect it to Google Gemini's free tier, giving your courier system an AI assistant that answers logistics questions with real database results.
  • Build three database tools (parcel tracking, failed deliveries, COD collections) that the AI calls automatically using Spring AI's @Tool annotation, plus scope restriction via system prompts so the AI only handles courier operations.
  • Implement role-based tool filtering so ADMIN, COURIER, USER, and GUEST each get different AI capabilities, with a frontend chat widget and query logging to your ai_query_logs table to prove the full end-to-end flow.
  • Secret Mission: Add Groq as a second AI provider switchable via Spring profiles, enabling a multi-provider AI architecture.

Ready to quiz yourself?