Guide to Subagent Orchestration in Spring AI

Building multi-agent systems in Spring AI sounds simple until you're knee-deep in cascading failures and wondering where it all went wrong. I learned a lot the hard way, so you don't have to. Here's everything I wish I had known about subagent orchestration before I started.

Spring AI had been on my radar for a while (we were already using Spring Boot 3.5 for most of our backend services), but I hadn't dug into its agent abstractions yet. Turns out there's a surprisingly workable pattern for building multi-agent pipelines in it, especially once you pull in the Spring AI Community Agent Utils library. Let me walk through how I actually set it up. What "Subagent Orchestration" Even Means Here Before getting into code, a quick framing note. When I say orchestration, I mean a supervisor agent that breaks a task down and delegates pieces of it to specialized subagents. Each subagent has its own tools, its own prompt context, maybe even its own model. The supervisor collects the results and synthesizes them. This isn't the same as a simple chain where prompt A feeds into prompt B. It's closer to a small team: one person coordinates, others execute. The supervisor decides which agents to call, in what order, and what to do with their outputs. Think of it like the difference between a single contractor doing everything and a general contractor managing specialists. The second approach scales. The first one doesn't, past a certain complexity. Project Setup I'm using Java 25 and Spring AI 1.1 for this. Spring AI 1.1 is the first GA-stable release that properly settles the agent abstractions, so if you were burned by the M-series milestone churn, this is the version worth committing to. The Spring AI Community Agent Utils dependency is what gives you the SubAgentOrchestrator and related tooling. In your pom.xml : <properties> <java.version>25</java.version> <spring-ai.version>1.1.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.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-community-agent-utils</artifactId> <version>0.9.0</version> </dependency> </dependencies> Using the BOM is worth it here. Spring AI pulls in a lot of transitive dependencies and version conflicts were a real headache in earlier milestones. The BOM keeps everything aligned without you having to think about it. And in application.yml : spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o I prefer keeping the model config in YAML rather than hardcoding it in beans. Easier to swap out during testing without touching application code. Defining Subagents Each subagent is essentially a ChatClient with a specific system prompt and, optionally, a set of tools. The specialization lives in the system prompt. I built three for our research assistant use case: A summarizer agent that reads a document and returns a tight summary An anomaly detector that looks for outliers or unexpected patterns in structured data A question generator that takes context and produces follow-up questions. This one surprised me most; it consistently produced better questions than I expected when given a decent summary to work from. Java 25 text blocks have been stable for a while now, and they make system prompts a lot more readable than concatenated strings. Here's the summarizer: @Bean public ChatClient summarizerAgent(ChatClient.Builder builder) { return builder .defaultSystem(""" You are a summarization specialist. Given any document or text, produce a concise, accurate summary in 3-5 sentences. Do not add opinions or infer information not present in the source. """) .build(); } The anomaly detector gets slightly different treatment because it needs structured output. Spring AI 1.1 ships with a cleaner StructuredOutputConverter API, and I'm using it here rather than the older BeanOutputConverter approach: @Bean public ChatClient anomalyDetectorAgent(ChatClient.Builder builder) { return builder .defaultSystem(""" You are a data analyst specializing in identifying anomalies. Given structured data, identify any values that deviate significantly from expected ranges or historical patterns. Return results as a JSON list of anomaly objects with fields: field, value, reason. """) .build(); } And the question generator: @Bean public ChatClient questionGeneratorAgent(ChatClient.Builder builder) { return builder .defaultSystem(""" You are a research assistant. Given a summary and context, generate exactly 3 insightful follow-up questions a reader might ask. Be specific, not generic. """) .build(); } Simple beans. Nothing fancy. The interesting part is how the supervisor wires them together. Building the Supervisor The supervisor's job is to parse the user's original request, decide which agents to invoke, pass them the right inputs, and assemble a coherent final response. In Spring AI's model, you do this by building an orchestration ChatClient that treats each subagent as a callable tool. Spring AI Community Agent Utils gives you a SubAgentTool wrapper for exactly this. You register each subagent as a named tool the supervisor can call. @Bean public SubAgentTool summarizerTool( @Qualifier("summarizerAgent") ChatClient summarizerAgent) { return SubAgentTool.builder() .name("summarize_document") .description(""" Summarizes a document or text passage. Input: raw text content. Output: a 3-5 sentence summary. """) .agent(summarizerAgent) .build(); } @Bean public SubAgentTool anomalyTool( @Qualifier("anomalyDetectorAgent") ChatClient anomalyDetectorAgent) { return SubAgentTool.builder() .name("detect_anomalies") .description(""" Detects anomalies in structured data. Input: JSON-formatted data. Output: JSON list of anomaly objects, each with field, value, and reason. """) .agent(anomalyDetectorAgent) .build(); } @Bean public SubAgentTool questionTool( @Qualifier("questionGeneratorAgent") ChatClient questionGeneratorAgent) { return SubAgentTool.builder() .name("generate_questions") .description(""" Generates follow-up research questions from context. Input: a summary or contextual passage. Output: exactly 3 specific follow-up questions. """) .agent(questionGeneratorAgent) .build(); } I switched the descriptions to text blocks here too. It reads better and makes them easier to iterate on when you're tuning routing behavior (and you will be tuning them). Then the supervisor: @Bean public ChatClient supervisorAgent( ChatClient.Builder builder, SubAgentTool summarizerTool, SubAgentTool anomalyTool, SubAgentTool questionTool) { return builder .defaultSystem(""" You are a research coordinator. When given a user request, break it into subtasks and delegate each to the appropriate tool. Collect the results and produce a unified, well-structured response. Always use all relevant tools before responding. Once you have gathered results from all relevant tools, produce the final response immediately. """) .defaultTools(summarizerTool, anomalyTool, questionTool) .build(); } That last instruction about producing the final response immediately is no...