Build a Multi-Agent AI Planner Using Spring Boot, Java & Spring AI
Learn How to Build an LLM-Based Planner Agent That Orchestrates Multiple AI Agents
Artificial Intelligence is rapidly evolving from simple chatbots to Agentic AI systems capable of reasoning, planning, and executing complex tasks.
Instead of asking a single Large Language Model (LLM) to perform everything, modern AI applications divide work among multiple specialized agents. A Planner Agent analyzes the user’s request, generates an execution plan, delegates tasks to specialized agents, and finally consolidates the results into a meaningful response.
In this article, we’ll build the planning layer of a Multi-Agent AI application using Spring Boot, Java, and Spring AI.
By the end of this article, you’ll understand how to:
- Build a Planner Agent using Spring AI
- Convert natural language into an execution plan
- Design a clean Multi-Agent architecture
- Create reusable DTOs and domain models
- Use Spring AI’s
ChatClientto communicate with OpenAI
In Part 2, we’ll implement the Orchestrator Service and specialized agents such as Flight, Hotel, Weather, and Summary agents.
What is Agentic AI?
Traditional AI applications work like this:
User
│
▼
LLM
│
▼
Response
The LLM is responsible for everything.
Although this works well for simple conversations, it becomes difficult when applications require multiple independent tasks.
Imagine the following request:
Plan a 5-day trip from London to Paris with a budget of $2500.
The AI needs to:
- Find flights
- Recommend hotels
- Check weather
- Estimate budget
- Create an itinerary
Asking one prompt to perform all these tasks often leads to inconsistent or hallucinated responses.
Multi-Agent Architecture
Instead of using one intelligent model for everything, we divide responsibilities among multiple specialized agents.
Planner Agent
│
┌──────────┬─────────┴───────────┬──────────┐
▼ ▼ ▼ ▼
Flight Agent Hotel Agent Weather Agent Budget Agent
│ │ │ │
└──────────┴──────────────┬──────┴──────────┘
▼
Summary Agent
│
▼
User
Each agent has a single responsibility.
| Agent | Responsibility |
|---|---|
| Planner Agent | Understands user request |
| Flight Agent | Flight recommendations |
| Hotel Agent | Hotel recommendations |
| Weather Agent | Weather forecast |
| Budget Agent | Cost estimation |
| Summary Agent | Final itinerary generation |
This follows the Single Responsibility Principle and makes the application easier to maintain and extend.
High-Level Architecture
Our application consists of two major layers.
Planning Layer
The LLM analyzes the user’s request.
User
│
▼
Planner Controller
│
▼
Planner Service
│
▼
LLM Planner Service
│
▼
OpenAI
│
▼
Execution Plan
The Planner Service does not invoke Flight or Hotel agents directly.
Instead, it asks the LLM:
“Given this user request, what tasks should be executed?”
The LLM returns a structured execution plan.
Execution Layer
The execution layer will be implemented in Part 2.
Execution Plan
│
▼
Orchestrator
│
┌────┼─────┐
▼ ▼ ▼
Flight Hotel Weather
│
▼
Summary Agent
│
▼
User
Project Structure
planner-agent
│
├── config
│ OpenAIConfig.java
│
├── controller
│
├── service
│ PlannerService.java
│ LLMPlannerService.java
│
├── dto
│ UserRequest.java
│ ExecutionPlan.java
│ AgentRequest.java
│ AgentResponse.java
│ SummaryRequest.java
│
├── model
│ Goal.java
│ Context.java
│ Task.java
│ MissingInformation.java
│
└── PlannerApplication.java
The execution-related classes will be added in Part 2.
Maven Configuration
Our project uses:
- Spring Boot 3.5
- Java 21
- Spring AI
- OpenAI
- Lombok
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Spring AI provides a clean abstraction over OpenAI, Anthropic, Azure OpenAI, Ollama, Gemini, and many other models.
application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4.1-mini
temperature: 0
Keeping the temperature at 0 ensures deterministic execution plans.
OpenAI Configuration
Spring AI makes configuration extremely simple.
@Configuration
public class OpenAIConfig {
@Bean
ChatClient chatClient(OpenAiChatModel model){
return ChatClient.create(model);
}
}
Every service can now inject ChatClient.
Designing the Domain Model
Before calling the LLM, we need classes that represent an execution plan.
Instead of returning free-form text, we’ll ask the model to return structured JSON.
ExecutionPlan
Goal
Context
Tasks
MissingInformation
Let’s look at each model.
Goal
Represents the user’s primary objective.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Goal {
private String description;
}
Example:
Plan a travel itinerary
Context
Stores extracted information.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Context {
private Map<String,Object> attributes;
}
Example:
source → London
destination → Paris
budget → 2500
Task
Represents work assigned to a specialized agent.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Task {
private int order;
private String agent;
private String action;
}
Example
Task 1
FlightAgent
Search Flights
Missing Information
Sometimes users don’t provide enough information.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MissingInformation {
private String field;
private String reason;
}
Example
Missing Field
Travel Date
Reason
Required for searching flights.
This prevents the LLM from hallucinating missing values.
Data Transfer Objects (DTOs)
DTOs define how information flows through our application.
UserRequest
@Data
public class UserRequest {
@NotBlank
private String prompt;
}
Example request:
{
"prompt":"Plan a trip from London to Paris with a budget of $2500."
}
AgentRequest
Represents requests sent to specialized agents.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AgentRequest {
private String agentName;
private String instruction;
private Map<String,Object> context;
}
AgentResponse
Represents the response from any specialized agent.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AgentResponse {
private String agentName;
private String response;
}
SummaryRequest
Passed to the Summary Agent.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SummaryRequest {
private String originalPrompt;
private List<AgentResponse> responses;
}
ExecutionPlan
This is the most important object in the application.
@Data
public class ExecutionPlan {
private Goal goal;
private Context context;
private List<Task> tasks;
private List<MissingInformation> missingInformation;
}
The Planner Agent generates this object before any execution begins.
Building the LLM Planner Service
Now comes the most interesting part.
Instead of manually parsing the user’s prompt, we’ll let the LLM create the execution plan.
@Service
@RequiredArgsConstructor
public class LLMPlannerService {
private final ChatClient chatClient;
public ExecutionPlan createExecutionPlan(String userPrompt) {
String prompt = buildPrompt(userPrompt);
return chatClient.prompt()
.user(prompt)
.call()
.entity(ExecutionPlan.class);
}
private String buildPrompt(String userPrompt) {
return """
You are an AI Planner Agent.
Analyze the user's request.
Return ONLY valid JSON.
{
"goal":{},
"context":{},
"tasks":[],
"missingInformation":[]
}
User Request:
%s
""".formatted(userPrompt);
}
}
Notice how simple this is.
Spring AI automatically converts the JSON response into our Java object.
No manual parsing.
No Jackson mapping.
No custom deserializers.
Example Execution Plan
User Prompt
Plan a 5-day trip from London to Paris with a budget of $2500.
Generated by the LLM
{
"goal": {
"description": "Plan a travel itinerary"
},
"context": {
"attributes": {
"source": "London",
"destination": "Paris",
"budget":2500
}
},
"tasks": [
{
"order":1,
"agent":"FlightAgent",
"action":"Search Flights"
},
{
"order":2,
"agent":"HotelAgent",
"action":"Search Hotels"
},
{
"order":3,
"agent":"WeatherAgent",
"action":"Check Weather"
},
{
"order":4,
"agent":"SummaryAgent",
"action":"Generate Summary"
}
],
"missingInformation":[]
}
This plan becomes the input for the execution layer.
Benefits of This Architecture
This design offers several advantages:
- Clear separation of planning and execution
- Modular, reusable agents
- Easy to add new agents
- Supports parallel execution
- Simple integration with external APIs
- Cleaner prompts for specialized tasks
- Scalable architecture for enterprise AI applications
Instead of writing hundreds of lines of conditional logic, we allow the LLM to decide what needs to be done, while our application focuses on executing the plan.
We learned how to:
- Accept a natural language request
- Use Spring AI’s
ChatClient - Ask the LLM to generate an
ExecutionPlan - Map the JSON response directly into Java objects
Now it’s time to execute that plan.
In this article, we’ll build the Execution Layer, where the Planner delegates work to multiple specialized AI agents and combines their outputs into a single travel itinerary.
By the end of this article, you’ll have a complete end-to-end Multi-Agent AI application using Spring Boot, Java, and Spring AI.
Execution Layer Architecture
Once the Planner Agent generates the execution plan, the application moves to the execution phase.
User
│
▼
PlannerController
│
▼
PlannerService
│
▼
LLMPlannerService
│
▼
ExecutionPlan
│
▼
OrchestratorService
┌─────────┼─────────┐
▼ ▼ ▼
FlightAgent HotelAgent WeatherAgent
│ │ │
└─────────┼─────────┘
▼
SummaryAgent
│
▼
Final Travel Plan
The Orchestrator becomes the central execution engine.
Implementing the Orchestrator Service
The Orchestrator is responsible for:
- Reading the execution plan
- Invoking specialized agents
- Collecting their responses
- Calling the Summary Agent
@Service
@RequiredArgsConstructor
public class OrchestratorService {
private final FlightAgentClient flightAgentClient;
private final HotelAgentClient hotelAgentClient;
private final WeatherAgentClient weatherAgentClient;
private final SummaryAgentClient summaryAgentClient;
public String executePlan(ExecutionPlan plan,
String originalPrompt) {
List<AgentResponse> responses = new ArrayList<>();
plan.getTasks()
.stream()
.sorted(Comparator.comparing(Task::getOrder))
.forEach(task -> {
if ("SummaryAgent".equalsIgnoreCase(task.getAgent())) {
return;
}
AgentRequest request =
new AgentRequest(
task.getAgent(),
task.getAction(),
plan.getContext().getAttributes());
responses.add(invokeAgent(request));
});
SummaryRequest summaryRequest =
new SummaryRequest(originalPrompt, responses);
return summaryAgentClient.generateSummary(summaryRequest);
}
private AgentResponse invokeAgent(AgentRequest request){
return switch (request.getAgentName()){
case "FlightAgent" ->
flightAgentClient.execute(request);
case "HotelAgent" ->
hotelAgentClient.execute(request);
case "WeatherAgent" ->
weatherAgentClient.execute(request);
default ->
throw new IllegalArgumentException("Unknown Agent");
};
}
}
Why Use an Orchestrator?
Without an orchestrator, the Planner Service would contain all the execution logic.
That quickly becomes difficult to maintain.
The Orchestrator gives us:
- Single Responsibility
- Dynamic execution
- Easy agent addition
- Better testing
- Cleaner architecture
Flight Agent
Every specialized agent has one responsibility.
The Flight Agent recommends flights.
@Component
@RequiredArgsConstructor
public class FlightAgentClient {
private final ChatClient chatClient;
public AgentResponse execute(AgentRequest request){
String response = chatClient.prompt()
.system("""
You are a Flight Expert.
Recommend the best flight.
Include:
Airline
Flight Number
Departure
Arrival
Estimated Price
""")
.user(request.getContext().toString())
.call()
.content();
return new AgentResponse(
"FlightAgent",
response);
}
}
Example Response
British Airways
Flight BA304
Departure : 09:30 AM
Arrival : 11:45 AM
Price : $620
Hotel Agent
The Hotel Agent specializes in accommodation.
@Component
@RequiredArgsConstructor
public class HotelAgentClient {
private final ChatClient chatClient;
public AgentResponse execute(AgentRequest request){
String response = chatClient.prompt()
.system("""
You are a Hotel Expert.
Recommend hotels.
Include
Hotel Name
Rating
Price Per Night
Location
""")
.user(request.getContext().toString())
.call()
.content();
return new AgentResponse(
"HotelAgent",
response);
}
}
Example Response
Hilton Paris
★★★★☆
$210/Night
Central Paris
Weather Agent
The Weather Agent provides travel advice.
@Component
@RequiredArgsConstructor
public class WeatherAgentClient {
private final ChatClient chatClient;
public AgentResponse execute(AgentRequest request){
String response = chatClient.prompt()
.system("""
You are a Weather Expert.
Provide
Temperature
Weather
Travel Advice
""")
.user(request.getContext().toString())
.call()
.content();
return new AgentResponse(
"WeatherAgent",
response);
}
}
Example Response
Temperature : 23°C
Weather : Sunny
Advice
Carry light clothing.
Summary Agent
Instead of returning multiple independent responses, we ask another LLM call to consolidate everything.
@Component
@RequiredArgsConstructor
public class SummaryAgentClient {
private final ChatClient chatClient;
public String generateSummary(
SummaryRequest request){
StringBuilder builder =
new StringBuilder();
builder.append("User Request\n");
builder.append(request.getOriginalPrompt());
builder.append("\n\n");
builder.append("Agent Responses\n");
for(AgentResponse response :
request.getResponses()){
builder.append(response.getAgentName())
.append("\n")
.append(response.getResponse())
.append("\n\n");
}
return chatClient.prompt()
.system("""
You are an AI Travel Planner.
Combine all agent outputs.
Produce a beautiful itinerary.
""")
.user(builder.toString())
.call()
.content();
}
}
Planner Service
The Planner Service coordinates the planning and execution phases.
@Service
@RequiredArgsConstructor
public class PlannerService {
private final LLMPlannerService llmPlannerService;
private final OrchestratorService orchestratorService;
public String plan(UserRequest request){
ExecutionPlan plan =
llmPlannerService
.createExecutionPlan(
request.getPrompt());
if(!plan.getMissingInformation().isEmpty()){
return "Missing Information : "
+ plan.getMissingInformation();
}
return orchestratorService.executePlan(
plan,
request.getPrompt());
}
}
Notice how simple this service becomes.
Planning and execution are delegated to dedicated components.
REST Controller
The controller remains very lightweight.
@RestController
@RequestMapping("/planner")
@RequiredArgsConstructor
public class PlannerController {
private final PlannerService plannerService;
@PostMapping
public String plan(
@Valid
@RequestBody
UserRequest request){
return plannerService.plan(request);
}
}
Testing the API
Endpoint
POST /planner
Request
{
"prompt":"Plan a 5 day trip from London to Paris with a budget of $2500."
}
End-to-End Flow
Let’s see everything together.
User Prompt
│
▼
Planner Controller
│
▼
Planner Service
│
▼
LLM Planner
│
▼
Execution Plan
│
▼
Orchestrator
│
├────────► Flight Agent
│
├────────► Hotel Agent
│
├────────► Weather Agent
│
▼
Summary Agent
│
▼
Final Response
│
▼
User
Sample Response
Travel Itinerary
Destination
Paris
Flights
British Airways BA304
Departure
09:30 AM
Arrival
11:45 AM
Estimated Price
$620
Hotel
Hilton Paris
4.5★
$210 per night
Weather
Sunny
23°C
Recommendation
Carry light clothing.
Estimated Budget
Within your $2500 budget.
Enjoy your trip!
Why This Architecture Scales
Although this tutorial uses Flight, Hotel, and Weather agents, the architecture can support dozens of specialized agents.
For example:
Planner Agent
├── Flight Agent
├── Hotel Agent
├── Weather Agent
├── Budget Agent
├── Currency Agent
├── Visa Agent
├── Insurance Agent
├── Maps Agent
├── Taxi Agent
├── Restaurant Agent
├── Shopping Agent
└── Summary Agent
Adding a new agent only requires:
- A new client
- A new system prompt
- One additional case in the orchestrator
The Planner itself remains unchanged.
Production Enhancements
Our implementation demonstrates the core Multi-Agent workflow, but production applications require additional capabilities.
Some important enhancements include:
- Parallel Execution using
CompletableFutureto run independent agents concurrently. - Retry, Timeout, and Circuit Breaker with Resilience4j to improve resilience.
- Observability using Micrometer and OpenTelemetry for tracing agent execution.
- Spring AI Advisors for reusable prompts, conversation memory, and cross-cutting AI concerns.
- Tool Calling and Function Calling to invoke external systems such as weather services or booking platforms.
- Model Context Protocol (MCP) to standardize communication with external tools and services.
- Vector Databases and RAG to provide enterprise knowledge and reduce hallucinations.
- Real Flight, Hotel, and Weather APIs instead of relying on LLM-generated recommendations.
- Security with API authentication, authorization, and prompt validation.
- Monitoring and Logging for production deployments.
These enhancements transform a simple demo into a robust, enterprise-ready AI application.
Conclusion
In this two-part series, we built a complete Multi-Agent AI Planner using Spring Boot, Java, and Spring AI.
We started by designing a Planner Agent that converts natural language into a structured execution plan. We then implemented an Orchestrator Service that dynamically invokes specialized agents and consolidates their outputs into a polished response.
This architecture clearly separates planning from execution, making the application modular, maintainable, and extensible. As your AI applications grow, you can add new agents, integrate real-world APIs, or adopt advanced capabilities such as parallel execution, MCP, RAG, and Tool Calling without redesigning the overall architecture.
This pattern serves as a solid foundation for building modern Agentic AI applications with Spring Boot and Java.
Source code : https://github.com/beladasharrow-netizen/multi-agent