Agentic Patterns

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21, spring-ai-agent-utils 0.12.0 and Embabel 1.5.2 (all verified 2026-09-29). Spring AI itself has no agent runtime. It has the parts an agent is made of — a model behind ChatClient, tools, memory, advisors, structured output — and a short guide, Building Effective Agents, that shows how to combine them. Two libraries go further: spring-ai-agent-utils, a Spring AI Community project that ports the building blocks of coding-agent products to ChatClient tools, and Embabel, a separate JVM framework that plans a sequence of typed actions toward a goal. This page adds the agentic capability to DocsAssistant: first as workflows you write yourself, then as an agent that plans, loads skills, delegates and remembers. The concepts are on the sibling pages Workflow Patterns and Agent Loop & Architectures; this page is the Spring AI code.

Workflow or agent

A workflow is code that decides the steps and calls the model inside them. An agent is a model that decides the steps, using tools, until it thinks it is done. Workflows are cheaper, faster and easier to test; agents handle open-ended tasks that cannot be written as a fixed sequence. Start with the simplest thing that works and add autonomy only where a fixed sequence fails. The rest of this page follows that order.

flowchart TD Q["Task for DocsAssistant"] --> A{"Are the steps known in advance?"} A -- yes --> B{"Independent sub-tasks?"} B -- yes --> P["Parallelization"] B -- no --> C{"One input, several kinds of handling?"} C -- yes --> R["Routing"] C -- no --> H["Prompt chaining"] A -- no --> D{"Can the sub-tasks be listed before starting?"} D -- no --> AG["Agent: tools, skills, subagents, or Embabel"] D -- yes, but they vary --> O["Orchestrator-workers"] H --> E{"Is there a quality bar to reach?"} R --> E P --> E O --> E E -- yes --> EO["Add evaluator-optimizer around it"]

The five workflows as Spring AI code

The five patterns come from Anthropic’s essay on building effective agents and are implemented for Spring AI in the agentic-patterns folder of the spring-ai-examples repository. The classes below are written for DocsAssistant, follow the same shape and use only ChatClient and structured output. Each is a Spring bean that takes the auto-configured ChatClient.Builder, so the model is whatever spring.ai.model.chat selects; no class knows which provider it runs on.

Table 1. Where each pattern is explained
Pattern Use it when Concept page

Prompt chaining

The task splits into fixed steps and each step is easier when the previous one is done

Prompt chaining

Routing

Different inputs need different prompts, tools or models

Routing

Parallelization

Sub-tasks are independent (sectioning), or you want several answers to vote on

Parallelisation

Orchestrator-workers

You cannot list the sub-tasks in advance, so a model decides them per input

Orchestrator-workers

Evaluator-optimizer

A clear quality criterion exists and a second pass with feedback improves the result

Evaluator-optimizer

Prompt chaining

Each step is a system prompt, and the output of one call is the user message of the next. Between steps you can add a programmatic gate (check the length, parse the JSON) and stop early; the loop below shows only the chain.

DocsChainWorkflow.java: three steps, each fed by the last
package com.example.docsassistant;

import java.util.List;

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

/** Prompt chaining: each step receives the previous step's output. */
@Component
public class DocsChainWorkflow {

    private static final List<String> STEPS = List.of(
            "Extract the technical claims from the text below as a bullet list.",
            "Remove every claim that is not about Spring AI, then keep the bullet list format.",
            "Rewrite the bullets as a two-sentence summary for a release note.");

    private final ChatClient chatClient;

    public DocsChainWorkflow(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String run(String text) {
        String current = text;
        for (String step : STEPS) {
            current = this.chatClient.prompt()
                    .system(step)
                    .user(current)
                    .call()
                    .content();
        }
        return current;
    }
}

Routing

Routing is a typed classification followed by a specialist prompt. entity(Classification.class) asks the model for JSON and converts it, as described on Structured Output. The fallback to HOW_TO matters: a model can return nothing usable, and a router that throws on a bad classification turns a formatting slip into an outage. Routing is also where a cheap model belongs; classify with a small model and answer with a larger one by building two ChatClient instances from two models.

DocsRoutingWorkflow.java: classify, then answer with the specialist
package com.example.docsassistant;

import java.util.Map;

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

/** Routing: classify first with a typed reply, then answer with the specialist prompt. */
@Component
public class DocsRoutingWorkflow {

    public enum Route { HOW_TO, REFERENCE, TROUBLESHOOTING }

    record Classification(Route route, String reason) { }

    private static final Map<Route, String> SPECIALISTS = Map.of(
            Route.HOW_TO, "You write short step-by-step guides. Use numbered steps.",
            Route.REFERENCE, "You answer with exact names and property keys, no prose around them.",
            Route.TROUBLESHOOTING, "You ask for the error message first, then list the three likeliest causes.");

    private final ChatClient chatClient;

    public DocsRoutingWorkflow(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String run(String question) {
        Classification c = this.chatClient.prompt()
                .system("Classify the question into one route: HOW_TO, REFERENCE or TROUBLESHOOTING.")
                .user(question)
                .call()
                .entity(Classification.class);
        Route route = c == null || c.route() == null ? Route.HOW_TO : c.route();
        return this.chatClient.prompt()
                .system(SPECIALISTS.get(route))
                .user(question)
                .call()
                .content();
    }
}

Parallelization

Parallelization runs independent calls at once. Java 21 makes the plumbing small: a virtual thread per call, joined in the original order. Threads are cheap but the provider’s rate limit is not, so a large fan-out needs the limits from Production & Deployment. The example is the sectioning variant; the voting variant sends the same prompt several times and compares the answers.

DocsParallelWorkflow.java: one call per page on virtual threads
package com.example.docsassistant;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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

/** Parallelisation (sectioning): the same instruction applied to independent inputs at once. */
@Component
public class DocsParallelWorkflow {

    private final ChatClient chatClient;

    // one virtual thread per call; the model provider's rate limit is the real bound
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

    public DocsParallelWorkflow(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public List<String> summarise(List<String> pages) {
        List<CompletableFuture<String>> futures = pages.stream()
                .map(page -> CompletableFuture.supplyAsync(() -> this.chatClient.prompt()
                        .system("Summarise the page in one sentence.")
                        .user(page)
                        .call()
                        .content(), this.executor))
                .toList();
        return futures.stream().map(CompletableFuture::join).toList();
    }
}

Orchestrator-workers

The orchestrator asks the model for a plan whose length it does not know in advance, then a worker call per sub-task. Compared with parallelization, the sub-tasks are chosen at run time. The cap in the system prompt ("at most four") is a cost control; the model does not enforce it, so enforce it in code if it matters. A defensive version truncates plan.tasks().

DocsOrchestratorWorkers.java: the model plans, workers write
package com.example.docsassistant;

import java.util.List;

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

/** Orchestrator-workers: the model decides the sub-tasks at run time, workers execute them. */
@Component
public class DocsOrchestratorWorkers {

    record Plan(String analysis, List<SubTask> tasks) { }

    record SubTask(String title, String instruction) { }

    private final ChatClient chatClient;

    public DocsOrchestratorWorkers(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String run(String request) {
        Plan plan = this.chatClient.prompt()
                .system("Split the documentation request into at most four independent sub-tasks.")
                .user(request)
                .call()
                .entity(Plan.class);
        if (plan == null || plan.tasks() == null) {
            return "";
        }
        StringBuilder result = new StringBuilder();
        for (SubTask task : plan.tasks()) {
            String section = this.chatClient.prompt()
                    .system("You are a technical writer. Do exactly the sub-task you are given.")
                    .user(task.instruction())
                    .call()
                    .content();
            result.append("## ").append(task.title()).append("\n\n").append(section).append("\n\n");
        }
        return result.toString();
    }
}

Evaluator-optimizer

One call writes a draft, another judges it against the task, and the critique goes back into the next draft. The loop needs a stop rule other than "the judge is happy", because judges are not always satisfied; MAX_ROUNDS bounds the cost. The judge here is a plain model call with a typed verdict. The evaluators that Spring AI ships (RelevancyEvaluator, FactCheckingEvaluator) can take its place and are covered on Evaluation.

DocsEvaluatorOptimizer.java: draft, judge, revise, at most three rounds
package com.example.docsassistant;

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

/** Evaluator-optimizer: generate, judge, feed the critique back, stop on PASS or after a fixed budget. */
@Component
public class DocsEvaluatorOptimizer {

    public enum Verdict { PASS, NEEDS_WORK }

    record Evaluation(Verdict verdict, String feedback) { }

    private static final int MAX_ROUNDS = 3;

    private final ChatClient chatClient;

    public DocsEvaluatorOptimizer(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String run(String task) {
        String draft = this.chatClient.prompt().user(task).call().content();
        for (int round = 1; round < MAX_ROUNDS; round++) {
            Evaluation evaluation = this.chatClient.prompt()
                    .system("Judge the draft against the task. Reply PASS only if it is complete and accurate.")
                    .user("Task: " + task + "\n\nDraft:\n" + draft)
                    .call()
                    .entity(Evaluation.class);
            if (evaluation == null || evaluation.verdict() == Verdict.PASS) {
                break;
            }
            draft = this.chatClient.prompt()
                    .user("Task: " + task + "\n\nPrevious draft:\n" + draft
                            + "\n\nFeedback to address:\n" + evaluation.feedback())
                    .call()
                    .content();
        }
        return draft;
    }
}

Test them without a model

All five are ordinary beans, so a scripted ChatModel tests them: the scripted replies stand in for the model, and the recorded prompts show what each step received. The tests below ran against the classes above (five tests, all passing). The stub is the one from Testing; a plain lambda is enough when no tool is involved.

WorkflowsTest.java: the chain, the router, the orchestrator and the loop
package com.example.docsassistant;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;

class WorkflowsTest {

    @Test
    void chainPassesEachOutputToTheNextStep() {
        ScriptedChatModel model = new ScriptedChatModel(false)
                .then(ScriptedChatModel.text("claims"))
                .then(ScriptedChatModel.text("spring ai claims"))
                .then(ScriptedChatModel.text("summary"));
        String result = new DocsChainWorkflow(ChatClient.builder(model)).run("input");
        assertThat(result).isEqualTo("summary");
        assertThat(model.prompts.get(1).getContents()).contains("claims");
        assertThat(model.prompts.get(2).getContents()).contains("spring ai claims");
    }

    @Test
    void routingUsesTheSpecialistForTheClassifiedRoute() {
        ScriptedChatModel model = new ScriptedChatModel(false)
                .then(ScriptedChatModel.text("{\"route\":\"REFERENCE\",\"reason\":\"asks for a key\"}"))
                .then(ScriptedChatModel.text("spring.ai.retry.max-attempts"));
        String result = new DocsRoutingWorkflow(ChatClient.builder(model)).run("Which key sets retries?");
        assertThat(result).isEqualTo("spring.ai.retry.max-attempts");
        assertThat(model.prompts.get(1).getSystemMessage().getText()).contains("exact names");
    }

    @Test
    void orchestratorRunsOneWorkerPerSubTask() {
        ScriptedChatModel model = new ScriptedChatModel(false)
                .then(ScriptedChatModel.text("{\"analysis\":\"two parts\",\"tasks\":["
                        + "{\"title\":\"Setup\",\"instruction\":\"write setup\"},"
                        + "{\"title\":\"Usage\",\"instruction\":\"write usage\"}]}"))
                .then(ScriptedChatModel.text("setup text"))
                .then(ScriptedChatModel.text("usage text"));
        String result = new DocsOrchestratorWorkers(ChatClient.builder(model)).run("document the starter");
        assertThat(result).contains("## Setup", "setup text", "## Usage", "usage text");
        assertThat(model.prompts).hasSize(3);
    }

    @Test
    void evaluatorOptimizerStopsOnPassAndOtherwiseAfterThreeRounds() {
        ScriptedChatModel passes = new ScriptedChatModel(false)
                .then(ScriptedChatModel.text("draft 1"))
                .then(ScriptedChatModel.text("{\"verdict\":\"PASS\",\"feedback\":\"\"}"));
        assertThat(new DocsEvaluatorOptimizer(ChatClient.builder(passes)).run("task")).isEqualTo("draft 1");
        assertThat(passes.prompts).hasSize(2);

        ScriptedChatModel stubborn = new ScriptedChatModel(false)
                .then(ScriptedChatModel.text("draft 1"))
                .then(ScriptedChatModel.text("{\"verdict\":\"NEEDS_WORK\",\"feedback\":\"add units\"}"))
                .then(ScriptedChatModel.text("draft 2"))
                .then(ScriptedChatModel.text("{\"verdict\":\"NEEDS_WORK\",\"feedback\":\"add example\"}"))
                .then(ScriptedChatModel.text("draft 3"));
        assertThat(new DocsEvaluatorOptimizer(ChatClient.builder(stubborn)).run("task")).isEqualTo("draft 3");
        assertThat(stubborn.prompts).hasSize(5);
    }

    @Test
    void parallelWorkflowKeepsInputOrder() {
        // a model that answers with the page it was given, so the order can be checked
        org.springframework.ai.chat.model.ChatModel echo = prompt -> ScriptedChatModel.text(
                "sum:" + prompt.getUserMessage().getText());
        List<String> result = new DocsParallelWorkflow(ChatClient.builder(echo)).summarise(List.of("a", "b", "c"));
        assertThat(result).containsExactly("sum:a", "sum:b", "sum:c");
    }
}

Source (the workflows under test): Spring AI: Building Effective Agents.

The evaluator test is the important one. It shows both exits of the loop: a PASS after one round and the round limit when the judge never approves.

From workflows to an agent: spring-ai-agent-utils

In the 2026 Spring AI Agentic Patterns series on the Spring blog, the Spring AI team built the pieces that coding agents such as Claude Code rely on and published them as the community library spring-ai-agent-utils. It is not part of the Spring AI BOM and it is versioned separately: the current release on Maven Central is 0.12.0 (2026-08-30), for Java 17+ and Spring AI 2.0.0 or later. Because it is pre-1.0, expect changes between releases: the blog posts show TaskToolCallbackProvider, which 0.12.0 no longer has, and the working form is TaskTool.builder().

<dependency>
    <groupId>org.springaicommunity</groupId>
    <artifactId>spring-ai-agent-utils</artifactId>
    <version>0.12.0</version>
</dependency>
Table 2. The series, and the tool each part adds
Part Class in 0.12.0 What the model gets

1: Agent Skills

SkillsTool

A tool whose description lists each skill’s name and description (from SKILL.md front matter). The model calls it with a skill name and receives the full instructions, so skills cost context only when used

2: AskUserQuestionTool

AskUserQuestionTool

A way to ask the user a clarifying question with options before acting; you supply a QuestionHandler

3: TodoWriteTool

TodoWriteTool

A to-do list it rewrites as it works, so a long task does not lose its plan; a TodoEventHandler lets you show progress

4: Subagent orchestration

TaskTool, TaskOutputTool

A tool that starts a named subagent in its own context window and returns only its result; subagents are Markdown files with front matter

5: A2A integration

community spring-ai-a2a-server

An agent exposed over the Agent2Agent protocol, or a remote agent used as a subagent

6: AutoMemoryTools

AutoMemoryTools, AutoMemoryToolsAdvisor

Six file tools (MemoryView, MemoryCreate, MemoryStrReplace, MemoryInsert, MemoryDelete, MemoryRename) over a sandboxed directory with a MEMORY.md index

7: Session API

community spring-ai-session

Event-sourced short-term memory with turn-safe compaction, meant to replace ChatMemory

A small DocsAssistant agent

The configuration below turns the DocsAssistant ChatClient into an agent with five extras: skills loaded on demand from src/main/resources/skills, subagents defined in src/main/resources/agents, a to-do list, a memory directory per user, and the existing searchDocs tool and chat memory. It compiled against 0.12.0; it was not run against a real model, so how well a given model uses the tools is yours to measure. The subagents bring a shell with them (see the notes after the listing), so this configuration belongs in a sandbox.

DocsAgentConfig.java: skills, subagents, to-do list and memory on one client
package com.example.docsassistant;

import org.springaicommunity.agent.advisors.AutoMemoryToolsAdvisor;
import org.springaicommunity.agent.tools.SkillsTool;
import org.springaicommunity.agent.tools.TodoWriteTool;
import org.springaicommunity.agent.tools.task.TaskTool;
import org.springaicommunity.agent.tools.task.claude.ClaudeSubagentReferences;
import org.springaicommunity.agent.tools.task.claude.ClaudeSubagentType;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * DocsAssistant as a small agent: skills loaded on demand, a task tool that delegates to subagents defined in
 * Markdown files, a to-do list the model keeps up to date, and a per-user memory directory it can read and write through six tools.
 * The main agent has no ShellTools or FileSystemTools, but TaskTool with ClaudeSubagentType always adds the built-in
 * Bash and general-purpose subagents, which have a shell and file writes: run this only inside a sandbox.
 */
@Configuration(proxyBeanMethods = false)
class DocsAgentConfig {

    @Bean
    ChatClient docsAgent(ChatClient.Builder builder, ChatMemory chatMemory, DocsTools docsTools) {
        ToolCallback skills = SkillsTool.builder()
                .addSkillsDirectory("src/main/resources/skills")
                .build();

        // the "default" builder every subagent starts from; tools set here reach every subagent
        ChatClient.Builder subagentBuilder = builder.clone().defaultTools(docsTools);

        ToolCallback task = TaskTool.builder()
                .subagentTypes(ClaudeSubagentType.builder()
                        .chatClientBuilder("default", subagentBuilder)
                        .build())
                .subagentReferences(ClaudeSubagentReferences.fromRootDirectory("src/main/resources/agents"))
                .build();

        return builder
                .defaultSystem("You are DocsAssistant. Plan with the to-do list, load a skill before using its topic, "
                        + "and delegate research to a subagent instead of doing it in this conversation.")
                .defaultTools(docsTools, skills, task,
                        TodoWriteTool.builder().build())
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }

    /**
     * The six memory tools and their system prompt, confined to one user's directory. This client is shared by every
     * user, so attach this per request: docsAgent.prompt(question).advisors(memoryFor(userId)).call().content().
     */
    static AutoMemoryToolsAdvisor memoryFor(String userId) {
        // an internal id, not an e-mail address: it becomes a directory name
        if (!userId.matches("[A-Za-z0-9_-]{1,64}")) {
            throw new IllegalArgumentException("userId is not a safe directory name");
        }
        return AutoMemoryToolsAdvisor.builder().memoriesRootDirectory("build/agent-memory/" + userId).build();
    }
}

A skill is a folder with a SKILL.md; a subagent is one Markdown file. Both start with front matter:

src/main/resources/skills/release-notes/SKILL.md
---
name: release-notes
description: How DocsAssistant writes release notes. Use when asked to summarise changes between versions.
---

# Release notes

1. Group changes as Added, Changed, Fixed.
2. Name each property key exactly as it appears in the docs.
3. Never invent a version number; ask the user if it is missing.
src/main/resources/agents/docs-researcher.md
---
name: docs-researcher
description: Finds and quotes the documentation sections that answer a question. Use for research before writing.
tools: searchDocs
---

You research documentation. Call searchDocs as often as needed, then return the section names and one sentence each.
Do not answer the user's question yourself.

A few points about how these pieces behave, checked against the 0.12.0 jar and, for the tool sets, by running the subagent lookup with a scripted model:

  • ClaudeSubagentType requires a ChatClient.Builder registered under the key default; subagents start from a clone of it, so its default tools and advisors reach every subagent. Other keys are chosen by the subagent’s model front matter, and the library maps the aliases opus, haiku and sonnet to fixed Claude model names that date from 2025, so with a local or a current hosted model use default or register your own keys.

  • The tools line is a permission, and leaving it out grants everything. ClaudeSubagentType brings a set of built-in tools, and the subagent’s tools and disallowedTools filter that set by name. Tools already on the registered builder, like searchDocs here, are always present. The tool names the model saw in the run were:

    Table 3. Tools the subagent’s model was offered, by front matter
    Front matter Tools offered to the subagent

    no tools line

    searchDocs plus TodoWrite, Grep, Glob, Bash, BashOutput, KillShell, Edit, Write, Read, WebFetch

    tools: Grep, Glob

    searchDocs, Grep, Glob

    tools: searchDocs

    searchDocs only

    So a subagent file that forgets the line gets a shell, file writes and web fetches. Name the tools it needs, and only those. A name that matches no built-in tool, as in tools: searchDocs, turns the built-ins off.

  • TaskTool adds four subagents of its own. When any subagent type is a ClaudeSubagentType, TaskTool.builder().build() also registers the built-in general-purpose, Explore, Plan and Bash subagents from the jar, whatever the agents directory holds, and 0.12.0 has no switch to leave them out. Bash and Plan list Bash in their tools line, and general-purpose and Explore have none, so the main agent can reach a shell and file writes by delegating to one of them. Treat an agent with TaskTool as one that has ShellTools.

  • The subagent runs in a separate context and only its final text comes back. This is the point (see Sub-agent isolation) and also the limit: what the subagent found but did not say is gone.

sequenceDiagram participant U as User participant M as Main agent (ChatClient) participant T as TaskTool participant S as docs-researcher (own context) participant D as searchDocs U->>M: Draft release notes for 2.0.1 M->>M: TodoWrite: research, draft, review M->>T: task(docs-researcher, "find the 2.0.1 changes") T->>S: start with its own system prompt S->>D: searchDocs("2.0.1 upgrade notes") D-->>S: sections S-->>T: short list of sections T-->>M: result only M-->>U: release notes

What not to give an agent

The blog examples also use ShellTools and FileSystemTools, which are the right tools for a coding agent working in a checkout and the wrong tools for a service. ShellTools runs commands and a skill’s scripts run on the host without a sandbox, as the skills post itself warns; FileSystemTools reads and writes anywhere the process can. The configuration above leaves both off the main agent, but its TaskTool brings the same power through the built-in Bash and general-purpose subagents, so it needs the same treatment. Run such an agent in a container with a read-only root file system, a dedicated user and no credentials it does not need, and put the approval step for writes in front of it, as in Security & Guardrails and Human in the Loop. The agent’s openIssue tool keeps its @PreAuthorize and its approval queue however clever the planning becomes.

AutoMemoryTools is different: its paths are relative and confined to the memory directory, so a path outside it is refused. It still stores whatever the model decides is worth keeping, including things a user said, and every conversation that uses a directory can read what the others stored there. That is why the configuration above builds the advisor per user and attaches it per request instead of as a default on the shared client; one fixed directory is only safe for a single-user tool. Treat each directory as personal data: control who can read it, decide how long it lives and give users a way to delete their entries. Long-term memory of this kind, and the difference between it and ChatMemory, is the subject of Agent Memory.

Agents over A2A

The Agent2Agent protocol lets a ChatClient be served as an agent that other agents discover through an Agent Card, and lets a remote agent act as a subagent. Spring AI’s core has no A2A support; the community spring-ai-a2a-server (0.3.0, built on the A2A Java SDK 0.3.3, so on the pre-1.0 protocol) provides the server side and spring-ai-agent-utils-a2a provides the client side. The protocol, the Agent Card and the version caveat are covered with code on Spring AI and A2A; this page does not repeat them.

The Session API

ChatMemory stores a list of messages and drops the oldest when the window is full, which can separate a tool call from its result or a question from its answer. The community spring-ai-session project (org.springaicommunity:spring-ai-starter-session-jdbc, 0.10.0 on Maven Central on 2026-09-29; the blog post used 0.2.0) keeps an append-only log of events and compacts it at turn boundaries, with sliding-window, turn-window, token-count and summarising strategies. The Spring AI upgrade notes say it is planned to replace ChatMemory in Spring AI 2.1. Until then the supported memory is the one on Chat Memory and Chat Memory Repositories. Adopting a 0.x library for the part of the application that holds user conversations is a decision to make deliberately, with a pinned version and a migration test.

Embabel: plan toward a goal

Embabel (1.5.2 on Maven Central, 2026-09-16) is a separate framework by Rod Johnson and colleagues, built on Spring and Spring AI: its embabel-agent-api 1.5.2 depends on Spring Boot 4.1.1 and Spring AI 2.0.1, the versions used on this page. Where the workflows above are fixed by your code and a tool-calling agent is steered by the model, Embabel puts a planner between them. You declare typed actions and a goal, and the platform works out the order.

The vocabulary, from the Embabel guide:

  • An action is a method with @Action. Its parameter types are its preconditions and its return type is its effect.

  • A goal is an action marked @AchievesGoal. The platform looks for a path of actions from the objects it has to one that produces the goal’s type.

  • A condition (@Condition) is an explicit check that actions can require; most conditions are inferred from types.

  • The blackboard holds the typed objects produced so far.

  • @Agent marks the class as an agent. Its planner attribute chooses between GOAP, UTILITY, HYBRID and SUPERVISOR (PlannerType in 1.5.2).

GOAP, goal-oriented action planning, is a technique from game AI: search (Embabel uses A*) for the cheapest sequence of actions whose effects reach the goal from the current state. The planner is ordinary code, not a model, so the same inputs give the same plan, and after every action it can re-plan from what actually happened. The model is used inside actions, to draft or to extract, and not to decide the sequence. That is what separates it from a tool-calling agent, and it is also the limit: the space of actions is the one you wrote.

DocsAnswerAgent.java: a two-action agent for DocsAssistant
package com.example.docsassistant;

import com.embabel.agent.api.annotation.AchievesGoal;
import com.embabel.agent.api.annotation.Action;
import com.embabel.agent.api.annotation.Agent;
import com.embabel.agent.api.common.OperationContext;

@Agent(description = "Answers a documentation question by drafting and then reviewing the answer")
public class DocsAnswerAgent {

    public record Question(String text) { }

    public record Draft(String text) { }

    public record Answer(String text) { }

    @Action(description = "Write a first answer")
    public Draft draft(Question question, OperationContext context) {
        return context.ai().withDefaultLlm()
                .createObject("Answer briefly: " + question.text(), Draft.class);
    }

    @AchievesGoal(description = "A reviewed answer is ready")
    @Action(description = "Review the draft and keep only what the docs support")
    public Answer review(Draft draft, OperationContext context) {
        String text = context.ai().withDefaultLlm()
                .generateText("Tighten this answer and remove unsupported claims: " + draft.text());
        return new Answer(text);
    }
}

The types carry the plan. Given a Question, the only action that can run is draft, which produces a Draft; review needs a Draft and produces an Answer, the goal. Nothing in the code calls review after draft. Running the agent means asking the platform for the goal’s type:

EmbabelRunner.java: ask the platform for an Answer
package com.example.docsassistant;

import com.embabel.agent.api.invocation.AgentInvocation;
import com.embabel.agent.core.AgentPlatform;
import org.springframework.stereotype.Service;

@Service
public class EmbabelRunner {

    private final AgentPlatform agentPlatform;

    public EmbabelRunner(AgentPlatform agentPlatform) {
        this.agentPlatform = agentPlatform;
    }

    public String ask(String text) {
        AgentInvocation<DocsAnswerAgent.Answer> invocation =
                AgentInvocation.create(this.agentPlatform, DocsAnswerAgent.Answer.class);
        return invocation.invoke(new DocsAnswerAgent.Question(text)).text();
    }
}

Both classes compile against embabel-agent-api 1.5.2 (context.ai().withDefaultLlm() is the way to reach a model; createObject returns a typed object and generateText a string). They were not run against a model or the full platform, because that needs an Embabel starter and a configured model. The model choice belongs to Embabel, through its own model configuration (embabel.models.default-llm per the guide), and not to the Spring AI ChatModel bean of the rest of the application. The dependency for a local model is the platform starter plus a model starter:

<dependency>
    <groupId>com.embabel.agent</groupId>
    <artifactId>embabel-agent-starter</artifactId>
    <version>1.5.2</version>
</dependency>
<dependency>
    <groupId>com.embabel.agent</groupId>
    <artifactId>embabel-agent-starter-ollama</artifactId>
    <version>1.5.2</version>
</dependency>

Other starters in the same group are -openai, -anthropic, -gemini, -mistral-ai, -deepseek, -lmstudio, -dockermodels, -shell (an interactive shell for development), -mcpserver and -a2a. Embabel can also serve its agents as MCP tools, which connects it to MCP Server.

Table 4. Choosing between the three approaches
Workflows in ChatClient spring-ai-agent-utils Embabel

Who decides the steps

Your code

The model, through tools

A planner, from typed actions and a goal

Predictability

High

Low; depends on the model

High for the sequence; the content of each step is still a model’s output

Best for

Known processes with model calls inside

Open-ended tasks that need files, search, delegation

Business processes with several possible routes to the same outcome

Cost of being wrong

A step fails

A loop of tool calls

A missing action; no route to the goal

Maturity

Part of Spring AI

Community, 0.12.0

Independent framework, 1.5.2

Keep agents bounded

Every pattern on this page multiplies model calls, so every one needs a budget:

  • Rounds. The evaluator loop has MAX_ROUNDS; any loop you write needs one.

  • Tool calls. Spring AI caps a turn at 150 tool calls in total and 40 per tool by default (since 2.0.1). Lower them for DocsAssistant, for example spring.ai.tools.limits.max-total-tool-calls=20 and spring.ai.tools.limits.max-calls-per-tool-default=5. See Tool Calling.

  • Tokens. Cap the output with maxTokens in the options and watch the token counters described on Observability. A subagent has its own context, so its tokens are extra, not shared.

  • Approval. Any tool that changes something outside the application goes through an approval step.

Testing an agent means testing the parts you own: the tools, the plan limits and the guardrails, with a scripted model, and separately running a small set of end-to-end tasks against a real model and scoring the outcome, as on Evaluation and Agent Evaluation.

Where older material differs

Table 5. Older agent material and the 2.0.1 line
Older text Spring AI 2.0.1 and the libraries above

Embabel 0.1.x or 0.3.0 in the README and in book chapters

The 1.5 line: @Agent, @Action, @AchievesGoal, @Condition remain, with PlannerType (GOAP, UTILITY, HYBRID, SUPERVISOR); the README on the project page still shows 0.3.0

Blog: TaskToolCallbackProvider

TaskTool.builder() in spring-ai-agent-utils 0.12.0

A tool loop inside every ChatModel, switched off with internalToolExecutionEnabled

Removed; ToolCallingAdvisor runs the loop, see ToolCallingAdvisor & Tool Search

Agent memory as ChatMemory only

ChatMemory for the conversation; AutoMemoryTools for long-term notes; the Session API is a planned successor