Full Stack • Java • System Design • Cloud • AI Engineering

Build a Multi-Agent System with Spring AI

A detailed step-by-step guide to build a practical multi-agent customer support system using Spring Boot, Spring AI ChatClient, structured output, tool calling, and agent orchestration.

A multi-agent system is an application where multiple specialized AI workers collaborate to solve a user request.

In Spring AI, there is no need to wait for a special "agent framework" to start building agentic applications. You can build agents using the core Spring AI building blocks:

  • ChatClient for model calls.
  • Structured output for routing and decisions.
  • Tool calling for live data and business actions.
  • RAG for knowledge lookup.
  • Spring services for orchestration.
  • Java validation and authorization for safety.

In this guide, we will build a real multi-agent customer support system.

What We Are Building

We will build an AI support desk with these agents:

Agent Responsibility
Router Agent Understand the user request and choose the correct specialist
Order Agent Handle order status and delivery questions
Refund Agent Check refund eligibility and create refund requests
Knowledge Agent Answer general product/support policy questions
Supervisor Agent Review the specialist answer and make it clear for the user

The system will expose these APIs:

API Method Purpose
/api/agents/health GET Check service health
/api/agents/chat POST Ask the multi-agent system
/api/agents/orders GET View sample orders
/api/agents/refunds GET View refund requests created by agents

Multi-Agent Architecture

flowchart TD
    User["User Request"] --> Controller["AgentController"]
    Controller --> Orchestrator["SupportOrchestrator"]
    Orchestrator --> Router["Router Agent"]

    Router --> Decision{"Route Decision"}
    Decision -->|ORDER| OrderAgent["Order Agent"]
    Decision -->|REFUND| RefundAgent["Refund Agent"]
    Decision -->|KNOWLEDGE| KnowledgeAgent["Knowledge Agent"]
    Decision -->|UNKNOWN| Clarifier["Ask Follow-up Question"]

    OrderAgent --> Tools["Order Tools"]
    RefundAgent --> Tools
    KnowledgeAgent --> Policy["Policy Knowledge"]

    OrderAgent --> Supervisor["Supervisor Agent"]
    RefundAgent --> Supervisor
    KnowledgeAgent --> Supervisor
    Clarifier --> Supervisor

    Supervisor --> Response["Final Response"]
    Response --> User

This is a simple but powerful pattern:

  1. Route the request.
  2. Let one specialist handle it.
  3. Let a supervisor clean and validate the final answer.

Why Multi-Agent Instead of One Big Prompt?

You can put everything in one giant prompt, but it becomes hard to maintain.

One Big Assistant Multi-Agent System
One prompt does everything Each agent has one focused job
Hard to debug Easier to see routing and specialist output
Tool access can become too broad Tools can be scoped to specific agents
Hard to test Test each agent separately
Prompt becomes long Prompts stay smaller and clearer

Multi-agent systems are useful when the workflow has different skills, tools, or decision stages.

Real Use Case

Customer message:

My order ORD-1002 arrived damaged. Can I get a refund?

The system should:

  1. Router Agent classifies the request as REFUND.
  2. Refund Agent checks order details.
  3. Refund Agent checks refund eligibility.
  4. Refund Agent creates a refund request if eligible.
  5. Supervisor Agent returns a clear final answer.

Expected answer:

Your order ORD-1002 is eligible for a refund because it was delivered recently and the issue is a damaged item. I created refund request REF-123456. You will receive an email update at [email protected].

Tools and Frameworks

Tool Recommended Version Purpose
Java 21 or later Application runtime
Spring Boot 4.0.x REST API framework
Spring AI 2.0.0 ChatClient, structured output, tools
OpenAI Current API Chat model
Maven 3.9+ Build tool
curl or Postman Any current version API testing

Spring AI 2.0.x supports Spring Boot 4.0.x and 4.1.x. If your project is on Spring Boot 3.x, use the matching Spring AI 1.x documentation.

Project Structure

spring-ai-multi-agent-system/
├── pom.xml
└── src/
    └── main/
        ├── java/
        │   └── com/
        │       └── codewithvenu/
        │           └── multiagent/
        │               ├── MultiAgentApplication.java
        │               ├── agent/
        │               │   ├── KnowledgeAgent.java
        │               │   ├── OrderAgent.java
        │               │   ├── RefundAgent.java
        │               │   ├── RouterAgent.java
        │               │   └── SupervisorAgent.java
        │               ├── controller/
        │               │   └── AgentController.java
        │               ├── dto/
        │               │   ├── AgentChatRequest.java
        │               │   ├── AgentChatResponse.java
        │               │   ├── OrderDto.java
        │               │   └── RefundDto.java
        │               ├── model/
        │               │   ├── AgentRoute.java
        │               │   ├── AgentStep.java
        │               │   ├── Order.java
        │               │   ├── OrderStatus.java
        │               │   ├── RefundRequest.java
        │               │   ├── RefundStatus.java
        │               │   └── RouteDecision.java
        │               ├── orchestrator/
        │               │   └── SupportOrchestrator.java
        │               ├── repository/
        │               │   └── DemoSupportRepository.java
        │               └── tools/
        │                   └── SupportTools.java
        └── resources/
            └── application.yml

Step 1: Create Maven Project

File: 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.0.0</version>
        <relativePath/>
    </parent>

    <groupId>com.codewithvenu</groupId>
    <artifactId>spring-ai-multi-agent-system</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-ai-multi-agent-system</name>
    <description>Multi-agent system with Spring AI</description>

    <properties>
        <java.version>21</java.version>
        <spring-ai.version>2.0.0</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-web</artifactId>
        </dependency>

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

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-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>

Step 2: Configure OpenAI

File: src/main/resources/application.yml

server:
  port: 8080

spring:
  application:
    name: spring-ai-multi-agent-system
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4.1-mini
          temperature: 0.2

Set your API key:

export OPENAI_API_KEY="your-openai-api-key-here"

Windows PowerShell:

$env:OPENAI_API_KEY="your-openai-api-key-here"

Step 3: Main Application Class

File: src/main/java/com/codewithvenu/multiagent/MultiAgentApplication.java

package com.codewithvenu.multiagent;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MultiAgentApplication {

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

Step 4: Create Models

AgentRoute

File: src/main/java/com/codewithvenu/multiagent/model/AgentRoute.java

package com.codewithvenu.multiagent.model;

public enum AgentRoute {
    ORDER,
    REFUND,
    KNOWLEDGE,
    UNKNOWN
}

RouteDecision

File: src/main/java/com/codewithvenu/multiagent/model/RouteDecision.java

package com.codewithvenu.multiagent.model;

public record RouteDecision(
    AgentRoute route,
    String reason,
    String orderId,
    String customerEmail,
    boolean needsFollowUp,
    String followUpQuestion
) {
}

This record is returned by the Router Agent using structured output.

AgentStep

File: src/main/java/com/codewithvenu/multiagent/model/AgentStep.java

package com.codewithvenu.multiagent.model;

import java.time.Instant;

public record AgentStep(
    String agentName,
    String input,
    String output,
    Instant createdAt
) {
    public static AgentStep of(String agentName, String input, String output) {
        return new AgentStep(agentName, input, output, Instant.now());
    }
}

We use AgentStep to show what each agent did.

OrderStatus

File: src/main/java/com/codewithvenu/multiagent/model/OrderStatus.java

package com.codewithvenu.multiagent.model;

public enum OrderStatus {
    PLACED,
    PROCESSING,
    SHIPPED,
    DELIVERED,
    CANCELLED
}

RefundStatus

File: src/main/java/com/codewithvenu/multiagent/model/RefundStatus.java

package com.codewithvenu.multiagent.model;

public enum RefundStatus {
    REQUESTED,
    APPROVED,
    REJECTED
}

Order

File: src/main/java/com/codewithvenu/multiagent/model/Order.java

package com.codewithvenu.multiagent.model;

import java.math.BigDecimal;
import java.time.LocalDate;

public record Order(
    String orderId,
    String customerEmail,
    String itemName,
    OrderStatus status,
    BigDecimal amount,
    LocalDate orderDate,
    LocalDate deliveredDate,
    boolean refundable
) {
}

RefundRequest

File: src/main/java/com/codewithvenu/multiagent/model/RefundRequest.java

package com.codewithvenu.multiagent.model;

import java.math.BigDecimal;
import java.time.Instant;

public record RefundRequest(
    String refundId,
    String orderId,
    String customerEmail,
    BigDecimal amount,
    String reason,
    RefundStatus status,
    Instant createdAt
) {
}

Step 5: Create DTOs

AgentChatRequest

File: src/main/java/com/codewithvenu/multiagent/dto/AgentChatRequest.java

package com.codewithvenu.multiagent.dto;

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

public record AgentChatRequest(
    @NotBlank(message = "message is required")
    @Size(max = 4000, message = "message must be less than 4000 characters")
    String message
) {
}

AgentChatResponse

File: src/main/java/com/codewithvenu/multiagent/dto/AgentChatResponse.java

package com.codewithvenu.multiagent.dto;

import com.codewithvenu.multiagent.model.AgentRoute;
import com.codewithvenu.multiagent.model.AgentStep;

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

public record AgentChatResponse(
    String answer,
    AgentRoute route,
    List<AgentStep> steps,
    Instant createdAt
) {
    public static AgentChatResponse of(String answer, AgentRoute route, List<AgentStep> steps) {
        return new AgentChatResponse(answer, route, steps, Instant.now());
    }
}

OrderDto

File: src/main/java/com/codewithvenu/multiagent/dto/OrderDto.java

package com.codewithvenu.multiagent.dto;

import com.codewithvenu.multiagent.model.Order;

import java.math.BigDecimal;
import java.time.LocalDate;

public record OrderDto(
    String orderId,
    String customerEmail,
    String itemName,
    String status,
    BigDecimal amount,
    LocalDate orderDate,
    LocalDate deliveredDate,
    boolean refundable
) {
    public static OrderDto from(Order order) {
        return new OrderDto(
            order.orderId(),
            order.customerEmail(),
            order.itemName(),
            order.status().name(),
            order.amount(),
            order.orderDate(),
            order.deliveredDate(),
            order.refundable()
        );
    }
}

RefundDto

File: src/main/java/com/codewithvenu/multiagent/dto/RefundDto.java

package com.codewithvenu.multiagent.dto;

import com.codewithvenu.multiagent.model.RefundRequest;

import java.math.BigDecimal;
import java.time.Instant;

public record RefundDto(
    String refundId,
    String orderId,
    String customerEmail,
    BigDecimal amount,
    String reason,
    String status,
    Instant createdAt
) {
    public static RefundDto from(RefundRequest refund) {
        return new RefundDto(
            refund.refundId(),
            refund.orderId(),
            refund.customerEmail(),
            refund.amount(),
            refund.reason(),
            refund.status().name(),
            refund.createdAt()
        );
    }
}

Step 6: Create Demo Repository

File: src/main/java/com/codewithvenu/multiagent/repository/DemoSupportRepository.java

package com.codewithvenu.multiagent.repository;

import com.codewithvenu.multiagent.model.Order;
import com.codewithvenu.multiagent.model.OrderStatus;
import com.codewithvenu.multiagent.model.RefundRequest;
import com.codewithvenu.multiagent.model.RefundStatus;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class DemoSupportRepository {

    private final Map<String, Order> orders = new ConcurrentHashMap<>();
    private final Map<String, RefundRequest> refunds = new ConcurrentHashMap<>();

    public DemoSupportRepository() {
        saveOrder(new Order(
            "ORD-1001",
            "[email protected]",
            "Spring AI Course",
            OrderStatus.SHIPPED,
            new BigDecimal("49.99"),
            LocalDate.now().minusDays(2),
            null,
            false
        ));

        saveOrder(new Order(
            "ORD-1002",
            "[email protected]",
            "Java Interview Guide",
            OrderStatus.DELIVERED,
            new BigDecimal("19.99"),
            LocalDate.now().minusDays(8),
            LocalDate.now().minusDays(1),
            true
        ));

        saveOrder(new Order(
            "ORD-1003",
            "[email protected]",
            "System Design Notes",
            OrderStatus.DELIVERED,
            new BigDecimal("29.99"),
            LocalDate.now().minusDays(60),
            LocalDate.now().minusDays(50),
            false
        ));
    }

    public Optional<Order> findOrder(String orderId) {
        return Optional.ofNullable(orders.get(orderId));
    }

    public List<Order> findAllOrders() {
        return new ArrayList<>(orders.values());
    }

    public Order saveOrder(Order order) {
        orders.put(order.orderId(), order);
        return order;
    }

    public RefundRequest createRefund(String orderId, String customerEmail, BigDecimal amount, String reason) {
        String refundId = "REF-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();

        RefundRequest refund = new RefundRequest(
            refundId,
            orderId,
            customerEmail,
            amount,
            reason,
            RefundStatus.REQUESTED,
            Instant.now()
        );

        refunds.put(refundId, refund);
        return refund;
    }

    public List<RefundRequest> findAllRefunds() {
        return new ArrayList<>(refunds.values());
    }
}

Step 7: Create Tools

Tools let specialist agents fetch live data or perform actions.

File: src/main/java/com/codewithvenu/multiagent/tools/SupportTools.java

package com.codewithvenu.multiagent.tools;

import com.codewithvenu.multiagent.dto.OrderDto;
import com.codewithvenu.multiagent.dto.RefundDto;
import com.codewithvenu.multiagent.model.Order;
import com.codewithvenu.multiagent.repository.DemoSupportRepository;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.time.LocalDate;

@Component
public class SupportTools {

    private final DemoSupportRepository repository;

    public SupportTools(DemoSupportRepository repository) {
        this.repository = repository;
    }

    @Tool(description = "Get order details including status, customer email, item name, delivery date, amount, and refund eligibility")
    public OrderDto getOrderDetails(
        @ToolParam(description = "Order ID such as ORD-1001") String orderId
    ) {
        Order order = repository.findOrder(orderId)
            .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));

        return OrderDto.from(order);
    }

    @Tool(description = "Check whether an order is eligible for a refund based on order status, delivery date, and refundable flag")
    public String checkRefundEligibility(
        @ToolParam(description = "Order ID such as ORD-1002") String orderId
    ) {
        Order order = repository.findOrder(orderId)
            .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));

        if (!order.refundable()) {
            return "Order " + orderId + " is not eligible for refund.";
        }

        if (order.deliveredDate() == null) {
            return "Order " + orderId + " has not been delivered yet, so refund eligibility cannot be confirmed.";
        }

        LocalDate lastRefundDate = order.deliveredDate().plusDays(30);
        if (LocalDate.now().isAfter(lastRefundDate)) {
            return "Order " + orderId + " is outside the 30-day refund window.";
        }

        return "Order " + orderId + " is eligible for refund until " + lastRefundDate + ".";
    }

    @Tool(description = "Create a refund request for an eligible order. Use only after checking refund eligibility.")
    public RefundDto createRefundRequest(
        @ToolParam(description = "Order ID such as ORD-1002") String orderId,
        @ToolParam(description = "Reason for refund request") String reason
    ) {
        Order order = repository.findOrder(orderId)
            .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));

        if (!order.refundable()) {
            throw new IllegalArgumentException("Order is not refundable: " + orderId);
        }

        return RefundDto.from(repository.createRefund(
            order.orderId(),
            order.customerEmail(),
            order.amount(),
            reason
        ));
    }
}

Security note:

The model can request a tool call, but Java decides whether to execute the action. Always validate inside tools.

Step 8: Create Router Agent

The Router Agent uses structured output to choose the right specialist.

File: src/main/java/com/codewithvenu/multiagent/agent/RouterAgent.java

package com.codewithvenu.multiagent.agent;

import com.codewithvenu.multiagent.model.RouteDecision;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class RouterAgent {

    private final ChatClient chatClient;

    public RouterAgent(ChatClient.Builder builder) {
        this.chatClient = builder
            .defaultSystem("""
                You are a routing agent for a customer support multi-agent system.

                Choose exactly one route:
                - ORDER: order status, delivery, tracking, shipment questions
                - REFUND: refund, return, damaged item, cancellation after delivery
                - KNOWLEDGE: general policy, product, FAQ, account, or support information
                - UNKNOWN: unclear request or missing important information

                Extract orderId and customerEmail if present.
                If the request is missing required information, set needsFollowUp to true and write a followUpQuestion.
                """)
            .build();
    }

    public RouteDecision route(String userMessage) {
        return chatClient
            .prompt()
            .user(userSpec -> userSpec
                .text("""
                    Route this customer support message:

                    {message}
                    """)
                .param("message", userMessage))
            .call()
            .entity(RouteDecision.class);
    }
}

Important:

  • .entity(RouteDecision.class) asks Spring AI to map the model response into a Java record.
  • The router should not solve the full problem.
  • The router only decides where the request should go.

Step 9: Create Specialist Agents

Order Agent

File: src/main/java/com/codewithvenu/multiagent/agent/OrderAgent.java

package com.codewithvenu.multiagent.agent;

import com.codewithvenu.multiagent.tools.SupportTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class OrderAgent {

    private final ChatClient chatClient;
    private final SupportTools supportTools;

    public OrderAgent(ChatClient.Builder builder, SupportTools supportTools) {
        this.supportTools = supportTools;
        this.chatClient = builder
            .defaultSystem("""
                You are the Order Agent.
                You answer questions about order status, delivery, tracking, and order details.
                Use tools when live order data is required.
                If the order ID is missing, ask for it.
                Keep the answer concise.
                """)
            .build();
    }

    public String handle(String userMessage) {
        return chatClient
            .prompt()
            .user(userMessage)
            .tools(supportTools)
            .call()
            .content();
    }
}

Refund Agent

File: src/main/java/com/codewithvenu/multiagent/agent/RefundAgent.java

package com.codewithvenu.multiagent.agent;

import com.codewithvenu.multiagent.tools.SupportTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class RefundAgent {

    private final ChatClient chatClient;
    private final SupportTools supportTools;

    public RefundAgent(ChatClient.Builder builder, SupportTools supportTools) {
        this.supportTools = supportTools;
        this.chatClient = builder
            .defaultSystem("""
                You are the Refund Agent.
                You handle refund, return, damaged item, and refund eligibility requests.

                Rules:
                - Use getOrderDetails when order information is needed.
                - Use checkRefundEligibility before creating a refund request.
                - Use createRefundRequest only when the user clearly wants a refund and the order is eligible.
                - If required information is missing, ask a follow-up question.
                - Explain the outcome clearly.
                """)
            .build();
    }

    public String handle(String userMessage) {
        return chatClient
            .prompt()
            .user(userMessage)
            .tools(supportTools)
            .call()
            .content();
    }
}

Knowledge Agent

File: src/main/java/com/codewithvenu/multiagent/agent/KnowledgeAgent.java

package com.codewithvenu.multiagent.agent;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class KnowledgeAgent {

    private final ChatClient chatClient;

    public KnowledgeAgent(ChatClient.Builder builder) {
        this.chatClient = builder
            .defaultSystem("""
                You are the Knowledge Agent for CodeWithVenu Store.

                Use these support policies:
                - Refund window is 30 days after delivery.
                - Digital course purchases are refundable only if marked refundable in the order system.
                - Damaged or incorrect items should be routed to refund support.
                - General response time for support is 24 to 48 hours.
                - Users need an order ID for order-specific help.

                If the question needs live order data, say that the Order Agent should handle it.
                """)
            .build();
    }

    public String handle(String userMessage) {
        return chatClient
            .prompt()
            .user(userMessage)
            .call()
            .content();
    }
}

Step 10: Create Supervisor Agent

The Supervisor Agent reviews the specialist answer and makes it user-ready.

File: src/main/java/com/codewithvenu/multiagent/agent/SupervisorAgent.java

package com.codewithvenu.multiagent.agent;

import com.codewithvenu.multiagent.model.AgentRoute;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class SupervisorAgent {

    private final ChatClient chatClient;

    public SupervisorAgent(ChatClient.Builder builder) {
        this.chatClient = builder
            .defaultSystem("""
                You are the Supervisor Agent.
                Review the specialist answer before sending it to the customer.

                Rules:
                - Keep the final answer concise and polite.
                - Do not invent facts.
                - Preserve important IDs such as order IDs and refund IDs.
                - If the specialist asked for missing information, keep that follow-up question.
                - Do not mention internal agent names unless useful.
                """)
            .build();
    }

    public String review(String originalMessage, AgentRoute route, String specialistAnswer) {
        return chatClient
            .prompt()
            .user(userSpec -> userSpec
                .text("""
                    Original customer message:
                    {message}

                    Route:
                    {route}

                    Specialist answer:
                    {answer}

                    Create the final response to the customer.
                    """)
                .param("message", originalMessage)
                .param("route", route.name())
                .param("answer", specialistAnswer))
            .call()
            .content();
    }
}

Step 11: Create the Orchestrator

The orchestrator coordinates the agents.

File: src/main/java/com/codewithvenu/multiagent/orchestrator/SupportOrchestrator.java

package com.codewithvenu.multiagent.orchestrator;

import com.codewithvenu.multiagent.agent.KnowledgeAgent;
import com.codewithvenu.multiagent.agent.OrderAgent;
import com.codewithvenu.multiagent.agent.RefundAgent;
import com.codewithvenu.multiagent.agent.RouterAgent;
import com.codewithvenu.multiagent.agent.SupervisorAgent;
import com.codewithvenu.multiagent.dto.AgentChatResponse;
import com.codewithvenu.multiagent.model.AgentRoute;
import com.codewithvenu.multiagent.model.AgentStep;
import com.codewithvenu.multiagent.model.RouteDecision;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class SupportOrchestrator {

    private final RouterAgent routerAgent;
    private final OrderAgent orderAgent;
    private final RefundAgent refundAgent;
    private final KnowledgeAgent knowledgeAgent;
    private final SupervisorAgent supervisorAgent;

    public SupportOrchestrator(
        RouterAgent routerAgent,
        OrderAgent orderAgent,
        RefundAgent refundAgent,
        KnowledgeAgent knowledgeAgent,
        SupervisorAgent supervisorAgent
    ) {
        this.routerAgent = routerAgent;
        this.orderAgent = orderAgent;
        this.refundAgent = refundAgent;
        this.knowledgeAgent = knowledgeAgent;
        this.supervisorAgent = supervisorAgent;
    }

    public AgentChatResponse handle(String userMessage) {
        List<AgentStep> steps = new ArrayList<>();

        RouteDecision decision = routerAgent.route(userMessage);
        steps.add(AgentStep.of("RouterAgent", userMessage, decision.toString()));

        String specialistAnswer = callSpecialist(userMessage, decision);
        steps.add(AgentStep.of(decision.route().name() + " Agent", userMessage, specialistAnswer));

        String finalAnswer = supervisorAgent.review(userMessage, decision.route(), specialistAnswer);
        steps.add(AgentStep.of("SupervisorAgent", specialistAnswer, finalAnswer));

        return AgentChatResponse.of(finalAnswer, decision.route(), steps);
    }

    private String callSpecialist(String userMessage, RouteDecision decision) {
        if (decision.needsFollowUp()) {
            return decision.followUpQuestion();
        }

        AgentRoute route = decision.route();

        return switch (route) {
            case ORDER -> orderAgent.handle(userMessage);
            case REFUND -> refundAgent.handle(userMessage);
            case KNOWLEDGE -> knowledgeAgent.handle(userMessage);
            case UNKNOWN -> "I need a little more information to help. Please provide your order ID or describe the issue.";
        };
    }
}

This is the core multi-agent pattern:

Router -> Specialist -> Supervisor -> Final Answer

Step 12: Create Controller

File: src/main/java/com/codewithvenu/multiagent/controller/AgentController.java

package com.codewithvenu.multiagent.controller;

import com.codewithvenu.multiagent.dto.AgentChatRequest;
import com.codewithvenu.multiagent.dto.AgentChatResponse;
import com.codewithvenu.multiagent.dto.OrderDto;
import com.codewithvenu.multiagent.dto.RefundDto;
import com.codewithvenu.multiagent.orchestrator.SupportOrchestrator;
import com.codewithvenu.multiagent.repository.DemoSupportRepository;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
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;

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

@RestController
@RequestMapping("/api/agents")
public class AgentController {

    private final SupportOrchestrator orchestrator;
    private final DemoSupportRepository repository;

    public AgentController(SupportOrchestrator orchestrator, DemoSupportRepository repository) {
        this.orchestrator = orchestrator;
        this.repository = repository;
    }

    @GetMapping("/health")
    public Map<String, String> health() {
        return Map.of("status", "UP", "service", "spring-ai-multi-agent-system");
    }

    @PostMapping("/chat")
    public AgentChatResponse chat(@Valid @RequestBody AgentChatRequest request) {
        return orchestrator.handle(request.message());
    }

    @GetMapping("/orders")
    public List<OrderDto> orders() {
        return repository.findAllOrders()
            .stream()
            .map(OrderDto::from)
            .toList();
    }

    @GetMapping("/refunds")
    public List<RefundDto> refunds() {
        return repository.findAllRefunds()
            .stream()
            .map(RefundDto::from)
            .toList();
    }
}

Step 13: Run the Application

mvn spring-boot:run

Health check:

curl http://localhost:8080/api/agents/health

Expected output:

{
  "service": "spring-ai-multi-agent-system",
  "status": "UP"
}

View sample orders:

curl http://localhost:8080/api/agents/orders

Expected output shape:

[
  {
    "orderId": "ORD-1002",
    "customerEmail": "[email protected]",
    "itemName": "Java Interview Guide",
    "status": "DELIVERED",
    "amount": 19.99,
    "refundable": true
  }
]

Step 14: Test Multi-Agent Use Cases

Use Case 1: Order Status

Input:

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Where is order ORD-1001?"
  }'

Expected flow:

RouterAgent -> ORDER
OrderAgent -> calls getOrderDetails
SupervisorAgent -> final answer

Expected output shape:

{
  "answer": "Order ORD-1001 for Spring AI Course has shipped. It has not been marked delivered yet, and it is not refundable.",
  "route": "ORDER",
  "steps": [
    {
      "agentName": "RouterAgent",
      "output": "RouteDecision[route=ORDER...]"
    },
    {
      "agentName": "ORDER Agent",
      "output": "Order ORD-1001..."
    },
    {
      "agentName": "SupervisorAgent",
      "output": "Order ORD-1001..."
    }
  ]
}

Use Case 2: Refund Request

Input:

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{
    "message": "My order ORD-1002 arrived damaged. Can I get a refund?"
  }'

Expected flow:

RouterAgent -> REFUND
RefundAgent -> getOrderDetails
RefundAgent -> checkRefundEligibility
RefundAgent -> createRefundRequest
SupervisorAgent -> final answer

Expected output shape:

{
  "answer": "Your order ORD-1002 is eligible for a refund because it is within the refund window. I created a refund request for 19.99. Your refund request ID is REF-8A91B2C3.",
  "route": "REFUND",
  "steps": []
}

Verify refund was created:

curl http://localhost:8080/api/agents/refunds

Expected output:

[
  {
    "refundId": "REF-8A91B2C3",
    "orderId": "ORD-1002",
    "customerEmail": "[email protected]",
    "amount": 19.99,
    "reason": "Damaged item",
    "status": "REQUESTED"
  }
]

Use Case 3: General Policy Question

Input:

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{
    "message": "What is your refund policy?"
  }'

Expected flow:

RouterAgent -> KNOWLEDGE
KnowledgeAgent -> answers from policy prompt
SupervisorAgent -> final answer

Expected output:

{
  "answer": "The refund window is 30 days after delivery. Digital course purchases are refundable only if the order system marks them as refundable. For a specific order, please provide your order ID.",
  "route": "KNOWLEDGE"
}

Use Case 4: Missing Information

Input:

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Where is my order?"
  }'

Expected output:

{
  "answer": "Please provide your order ID so I can check the order status.",
  "route": "UNKNOWN"
}

Multi-Agent Request Lifecycle

sequenceDiagram
    participant U as User
    participant C as Controller
    participant O as Orchestrator
    participant R as Router Agent
    participant S as Specialist Agent
    participant T as Tools
    participant V as Supervisor Agent

    U->>C: POST /api/agents/chat
    C->>O: handle(message)
    O->>R: route(message)
    R-->>O: RouteDecision
    O->>S: handle(message)
    S->>T: Optional tool calls
    T-->>S: Tool results
    S-->>O: Specialist answer
    O->>V: review(message, route, answer)
    V-->>O: Final answer
    O-->>C: AgentChatResponse
    C-->>U: JSON response

Agent Design Rules

Rule Why It Matters
Give each agent one clear job Prevents prompt confusion
Keep tools scoped to the agent that needs them Reduces unsafe tool usage
Use structured output for routing Easier to test and debug
Put business validation in Java Do not trust model-only decisions
Keep an agent trace Helps debug production issues
Use supervisor review carefully Improves tone without inventing facts

When To Use Multi-Agent Systems

Use multi-agent design when:

  • One request may require different specialist skills.
  • Different tools should be isolated.
  • You need a routing step.
  • You need review or validation before final response.
  • You want better observability of model decisions.
  • The prompt is becoming too large.

Avoid multi-agent design when:

  • A single prompt is enough.
  • You only need one model call.
  • Latency must be very low.
  • The workflow does not need routing or tools.

Production Improvements

Before production, add:

  1. Authentication and authorization.
  2. Tenant and user ownership checks inside tools.
  3. Persistent database instead of in-memory repository.
  4. Tool-call audit logging.
  5. Retry and timeout handling.
  6. Agent-level metrics.
  7. Evaluation tests for routing accuracy.
  8. Human approval for high-impact actions.
  9. Conversation memory per user.
  10. RAG for support policies instead of hard-coded prompt text.

Common Mistakes

Mistake Problem Better Approach
Too many agents Hard to debug and slow Start with 2 or 3 agents
Router solves the whole problem Duplicates specialist work Router only routes
All tools available to all agents Unsafe and confusing Scope tools by agent
No trace of agent steps Debugging is hard Return or log AgentStep
No Java validation Unsafe actions Validate in tools/services
Supervisor changes facts Hallucination risk Tell supervisor to preserve facts

Complete Test Script

curl http://localhost:8080/api/agents/health

curl http://localhost:8080/api/agents/orders

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{"message":"Where is order ORD-1001?"}'

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{"message":"My order ORD-1002 arrived damaged. Can I get a refund?"}'

curl http://localhost:8080/api/agents/refunds

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{"message":"What is your refund policy?"}'

curl -X POST http://localhost:8080/api/agents/chat \
  -H "Content-Type: application/json" \
  -d '{"message":"Where is my order?"}'

Summary

You built a practical multi-agent system with Spring AI.

The system used:

  • Router Agent for structured routing.
  • Order Agent for order-specific questions.
  • Refund Agent for refund workflows.
  • Knowledge Agent for general support policies.
  • Supervisor Agent for final answer review.
  • Java tools for live order and refund actions.
  • Spring service orchestration for control and safety.

The key lesson:

In Spring AI, a multi-agent system is usually a well-designed Spring workflow where each agent is a focused ChatClient service, tools are scoped carefully, and Java owns the business rules.

This pattern can be extended to banking assistants, insurance claim assistants, HR policy assistants, developer copilots, and enterprise workflow automation.

References