Building an Agentic Workflow in Java: Multi-Step AI Agents with Spring AI and Tool Calling

Architecture of the Civic Research Assistant: a single ask endpoint driving an agentic tool-call loop over UK Parliament APIs

In this tutorial we’ll build a multi-step AI agent in Java. It answers a question a lot of people actually have (“I care about this issue and I live here, so what’s the most effective way to raise it with Parliament?”), and it answers with cited, checkable facts rather than confident waffle. The languages and frameworks used are Java 25, Spring Boot 4.1, Spring AI 2.0 and OpenAI, with live data from the UK Parliament open APIs and postcodes.io.

One thing up front, because the example touches politics: this is a technical demonstration over public open data, not political commentary. The agent recommends a process, never a party, a politician or a policy position. That isn’t just good manners. As you’ll see, it’s the single design constraint that shapes the whole build.

You should be comfortable with Spring Boot and REST, and happy reading Java records and a bit of Lombok. You don’t need any prior Spring AI experience; we’ll start from an empty Spring Initializr project. If you’ve used Spring AI 1.x, keep your wits about you: 2.0 moved a lot of the tool-calling machinery, and I’ll flag the traps as we hit them.

Here’s the whole thing working. With the app running and an OpenAI key set, fire one request at it from the terminal:

curl -s -X POST http://localhost:8080/assistant/ask \
  -H 'Content-Type: application/json' \
  -d '{"issue":"safer cycling infrastructure near schools","postcode":"BS1 5TR"}' | jq

and it comes back with a structured, fully cited recommendation. One route is chosen, the rationale behind each option carries its own parliament.uk citations, and any data gaps are flagged rather than glossed over:

{
  "options": [
    {
      "route": "WRITE_TO_MP",
      "target": "MP for Bristol Central",
      "rationale": [
        {
          "claim": "Your MP is the sitting Member for the Bristol Central constituency.",
          "citation": {
            "sourceUrl": "https://members-api.parliament.uk/api/Members/5102",
            "sourceType": "member",
            "retrievedAt": "2026-06-30T10:58:47.382880800Z"
          }
        },
        {
          "claim": "The postcode BS1 5TR is in the Bristol Central Westminster constituency.",
          "citation": {
            "sourceUrl": "https://api.postcodes.io/postcodes/BS1%205TR",
            "sourceType": "postcode",
            "retrievedAt": "2026-06-30T10:58:45.438530200Z"
          }
        }
      ],
      "dataGaps": ["HUMAN_REVIEW_REQUIRED"]
    },
    {
      "route": "LOBBY_COMMITTEE_CHAIR",
      "target": "Chair, Transport Committee",
      "rationale": [
        {
          "claim": "The Transport Committee scrutinises transport matters relevant to cycling and road safety.",
          "citation": {
            "sourceUrl": "https://committees-api.parliament.uk/api/Committees/153",
            "sourceType": "committee",
            "retrievedAt": "2026-06-30T10:58:53.460544100Z"
          }
        }
      ],
      "dataGaps": []
    },
    {
      "route": "SUBMIT_EVIDENCE",
      "target": "Relevant select committee inquiry (when open)",
      "rationale": [
        {
          "claim": "No open select committee inquiries matching cycling, active travel, road safety or transport were found at this time.",
          "citation": {
            "sourceUrl": "https://committees-api.parliament.uk/api/Committees/153",
            "sourceType": "committee inquiries search",
            "retrievedAt": "2026-06-30T10:59:00.343383200Z"
          }
        }
      ],
      "dataGaps": ["NO_OPEN_INQUIRY"]
    }
  ],
  "recommended": "LOBBY_COMMITTEE_CHAIR",
  "caveats": ["NO_OPEN_INQUIRY", "HUMAN_REVIEW_REQUIRED"],
  "attribution": "Contains Parliamentary information licensed under the Open Parliament Licence v3.0."
}

That’s a real run. Notice what the agent did: it recommended lobbying the Transport Committee chair rather than writing to the MP, because no committee inquiry was open to submit evidence to, and it said so with a NO_OPEN_INQUIRY caveat instead of inventing one. Every claim points at a live parliament.uk or postcodes.io URL you can click and check, and the Open Parliament Licence attribution rides along in the response. Because the app uses live data, your own run may recommend a different route as inquiries open and close.

All the code is on GitHub, and it’s the real, runnable thing rather than fragments stitched together for the article: github.com/tucanoo/spring-ai-civic-research-assistant. Clone it, set an OpenAI key, and you can run every step below.

What we’re building

The app is a small REST service with a single endpoint. You POST it a plain-English issue and a UK postcode:

POST /assistant/ask
{ "issue": "safer cycling infrastructure near schools", "postcode": "BS1 5TR" }

and it returns a structured recommendation of the most effective way to raise that issue, choosing between three civic routes: write to your constituency MP, lobby the chair of the relevant select committee, or submit evidence to a committee inquiry that’s currently open. Each option comes with a factual rationale, every claim in that rationale carries its own citation back to parliament.uk, and any gaps (“no open inquiry found”) are flagged rather than papered over.

What makes this a real agent and not a glorified search box is that the steps depend on each other, and the model decides how to chain them. You can’t answer the question with one query. First you have to turn a postcode into a constituency, then the constituency into a sitting MP. Separately, you have to turn the issue into search terms, then use those to find live bills, relevant committees and open inquiries. Only once you know whether an inquiry is actually accepting evidence right now can you reason about which route is most effective. The model orchestrates that chain itself, calling a tool, reading the result, then deciding what to call next. That’s the “agentic” pattern this article is about.

Now the constraint that makes this example worth writing about. The agent talks about real, named people (sitting MPs and committee chairs) using real public data. In that setting, a hallucinated fact isn’t a cosmetic bug. “Your MP voted against this” when they didn’t is, potentially, defamation. So the entire build is organised around one rule:

The agent may state only what a tool actually returned, every claim must carry its own citation, and it must never infer motive or intent or take a political side.

That rule is why the production-grade parts of this tutorial (grounding, structured output, the citation check, testing) aren’t bolted-on extras. They’re the actual subject. Most tool-calling tutorials stop at “look, the model called my function”. The interesting work starts straight after that, and it’s where we’ll spend most of our time.

One caveat about the data: the app uses live Parliament data by default, so the demonstration is real. The flip side is that live data drifts. Inquiries close, bills move to the next stage, MPs change. So treat any specific transcript in this article as “results as of the day it ran”; your own run will differ, and that’s expected.

That’s the lay of the land.

What “agentic” actually means in Spring AI 2.0

Let’s be precise about the word, because “agent” is doing a lot of marketing work these days. There’s a useful distinction between a workflow and an agent. In a workflow, you write the orchestration: call this, then that, then branch. In an agent, the model does the orchestrating: it’s handed a set of tools and a goal, and decides which tools to call and in what order until it’s done. Spring AI gives you the loop that makes that possible, plus the hooks to keep it on a lead. It does not hand you a turnkey multi-agent platform, and you don’t need one here.

The loop itself is simple to describe: the model receives your prompt and the list of available tools; it responds either with a final answer or with one or more tool calls; the framework executes those calls and feeds the results back; the model looks at the results and decides again; repeat until it produces a final answer. That round-trip is the whole game.

If you’re coming from Spring AI 1.x, this is the part that moved. The tool-call loop used to be buried inside each ChatModel. In 2.0 it’s an auto-registered advisor, ToolCallingAdvisor, that sits in the advisor chain and runs the loop for you. You build a ChatClient, register your tools, call it, and the loop runs itself. A few other 1.x reflexes will bite you, so here’s the short list to unlearn before we start:

  • internalToolExecutionEnabled is gone. To take manual control you opt out of the auto-advisor instead (we’ll get to that).
  • Bare Function beans with .functions()/.toolNames() are gone. Tools are @Tool methods now.
  • On ChatClient it’s .defaultTools(...); there is no .defaultToolCallbacks(...).
  • The config keys dropped the .options. segment: it’s spring.ai.openai.chat.model, not spring.ai.openai.chat.options.model.
  • 2.0 removed the old built-in default temperature, so set it explicitly.

And one Spring Boot 4 wrinkle while we’re here: the starters were split and renamed. You want spring-boot-starter-webmvc, not spring-boot-starter-web, and the test starters are split too. If you follow a Boot 3 tutorial and the build can’t find a starter, that’s usually why.

Project setup

As usual I generated the skeleton with Spring Initializr (Gradle, Java 25, Spring Boot 4.1.0) and added the web, validation and actuator starters plus the OpenAI model starter. The one thing Initializr won’t do for you is line up the Spring AI BOM, so here’s the complete build.gradle:

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.0'
    id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.tucanoo'
version = '0.0.1-SNAPSHOT'
description = 'civicResearchAssistant'
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}
repositories {
    mavenCentral()
}
ext {
    set('springAiVersion', "2.0.0")
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.ai:spring-ai-starter-model-openai'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testCompileOnly 'org.projectlombok:lombok'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    testAnnotationProcessor 'org.projectlombok:lombok'
}
dependencyManagement {
    imports {
        mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
    }
}
tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
    options.compilerArgs.add('-parameters')
}
tasks.named('test') {
    useJUnitPlatform()
}

A couple of things in there earn their keep. The -parameters compiler flag matters more than it looks: Spring AI reads your tool parameter names to build the schema it shows the model, and without this flag they come through as arg0, arg1 and the model has no idea what they mean. Boot’s plugin usually enables it, but I set it explicitly rather than trust it. Lombok is here because it’s fine on the Java 25 toolchain and I like @RequiredArgsConstructor and @Slf4j on services; all the DTOs are plain records, so if Lombok ever gives you grief on a newer JDK you can drop it without touching the data model.

Now the configuration. Here’s application.yaml:

spring:
  application:
    name: CivicResearchAssistant
  main:
    web-application-type: servlet
  ai:
    openai:
      api-key: ${OPENAI_API_KEY:sk-placeholder-set-OPENAI_API_KEY}
      chat:
        model: gpt-5
        temperature: 1
        max-completion-tokens: 6000
        reasoning-effort: minimal
logging:
  level:
    org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor: DEBUG
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
parliament:
  postcodes-base-url: https://api.postcodes.io
  members-base-url: https://members-api.parliament.uk
  bills-base-url: https://bills-api.parliament.uk
  committees-base-url: https://committees-api.parliament.uk
  votes-base-url: https://commonsvotes-api.parliament.uk

The web-application-type: servlet line is deliberate. The OpenAI starter drags in WebFlux, and if you don’t pin the app to MVC you can end up with a reactive web stack you didn’t ask for. The API key uses the ${OPENAI_API_KEY:...} placeholder pattern so the context still loads and the tests still run with no key set; you only need a real key for an actual model call.

A word on the model line, because this is exactly the kind of detail that dates a tutorial. I’m pointing at gpt-5 with reasoning-effort: minimal and temperature left at the value the reasoning models require. Model names move fast, so set this to whatever the current flagship tool-calling model is when you build it, and don’t copy a retired name from an old post. What matters is the wiring, not the specific string.

The data layer: live Parliament data behind one interface

Everything the agent is allowed to assert has to come from somewhere checkable, so the data layer is where the grounding rule starts being enforced, in the architecture, before a single line of prompt. The design is one interface with a live implementation behind it. The interface isn’t ceremony here: it’s what lets the tests run without hitting the network, which matters a great deal when your “facts” are about real people.

Here’s the interface. Note the licence attribution sitting right at the top of the file. Under the Open Parliament Licence v3.0 you’re required to carry it, and the data layer is a sensible place for it to live.

package com.tucanoo.civicagent.data;
import com.tucanoo.civicagent.agent.CivicRoute;
import java.util.List;
/**
 * Contains Parliamentary information licensed under the Open Parliament Licence v3.0.
 */
public interface ParliamentDataSource {
    ResolvedConstituency resolveConstituency(String postcode);
    MemberSummary getMemberForConstituency(String constituency);
    List<BillSummary> searchBills(List<String> keywords);
    List<CommitteeSummary> findRelevantCommittees(List<String> keywords);
    List<InquirySummary> findOpenInquiries(List<String> keywords);
    List<DivisionSummary> getVotingRecord(Long memberId, List<String> keywords);
    CorrespondenceDraft draftCorrespondence(CivicRoute route, String target, String issue);
}

The return types are small records, each one carrying its own Citation. That citation isn’t decoration; it’s the thread we pull on later when we check that everything the model said is real. Here are a few of them; the rest follow the same shape and are in the repo.

package com.tucanoo.civicagent.data;
import com.tucanoo.civicagent.agent.Citation;
public record MemberSummary(
        Long memberId,
        String name,
        String party,
        String constituency,
        Citation citation
) {
}
package com.tucanoo.civicagent.data;
import com.tucanoo.civicagent.agent.Citation;
import java.time.LocalDate;
public record InquirySummary(
        Long inquiryId,
        String title,
        String committeeName,
        LocalDate evidenceDeadline,
        Citation citation
) {
}

And the Citation itself, which every record above hangs off:

package com.tucanoo.civicagent.agent;
import java.time.Instant;
public record Citation(String sourceUrl, String sourceType, Instant retrievedAt) {
}

The live implementation is a RestClient over five public, no-key endpoints: postcodes.io for the postcode lookup, and the Parliament Members, Bills, Committees and Commons Votes APIs for everything else. The class is mostly the unglamorous work of calling each API and mapping its real JSON shape into the records above. I’ll show the skeleton and one representative method; the full set of mappings is in the repo, because reading eight HTTP-and-parse methods in a row teaches you nothing the first one didn’t.

package com.tucanoo.civicagent.data;
import com.tucanoo.civicagent.agent.Citation;
import com.tucanoo.civicagent.config.ParliamentProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.JsonNode;
// ... imports trimmed for the article
@Service
public class LiveDataSource implements ParliamentDataSource {
    private final ParliamentProperties properties;
    private final RestClient restClient;
    public LiveDataSource(ParliamentProperties properties, RestClient.Builder restClientBuilder) {
        this.properties = properties;
        this.restClient = restClientBuilder.build();
    }
    // Representative method: postcode to Westminster constituency via postcodes.io.
    // Every method ends the same way: build a Citation pointing at the real source URL.
    @Override
    public ResolvedConstituency resolveConstituency(String postcode) {
        JsonNode body = restClient.get()
                .uri(properties.postcodesBaseUrl() + "/postcodes/{pc}", postcode)
                .retrieve()
                .body(JsonNode.class);
        String constituency = body.path("result").path("parliamentary_constituency").asString();
        Citation citation = new Citation(
                properties.postcodesBaseUrl() + "/postcodes/" + postcode,
                "postcodes.io",
                java.time.Instant.now());
        return new ResolvedConstituency(postcode, constituency, citation);
    }
    // getMemberForConstituency, searchBills, findRelevantCommittees, findOpenInquiries,
    // getVotingRecord and draftCorrespondence follow the same pattern; see the repo.
}

Two things to flag. First, the Jackson import is tools.jackson.databind.JsonNode, not the old com.fasterxml.jackson: Spring Boot 4 / Framework 7 ship Jackson 3, and that package move catches everyone out once. Second, postcodes.io is a separate service with its own terms, so it gets its own credit in the README alongside the Parliament attribution. Note too that Northern Ireland (“BT”) postcodes need a commercial licence for commercial use, which is the sort of caveat you want to know before you ship, not after.

That’s the data layer.

Defining the tools

Now the bit the model actually sees. A tool in Spring AI 2.0 is a method annotated with @Tool, with its parameters described by @ToolParam. The description text isn’t a comment; it’s the spec the model reads to decide whether and how to call the method, so write it carefully. Our tools live in com.tucanoo.civicagent.agent, in a CivicTools service that delegates to the data layer. (Each read tool also records its result for the citation guard with a single line. I’ve left those out of the listing below to keep it readable, and we’ll add them when we build the guard.)

package com.tucanoo.civicagent.agent;
import com.tucanoo.civicagent.data.*;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CivicTools {
    private final ParliamentDataSource parliamentDataSource;
    private final KeywordExtractor keywordExtractor;
    @Tool(description = "Resolve a UK postcode to its Westminster constituency.")
    public ResolvedConstituency resolveConstituency(
            @ToolParam(description = "UK postcode supplied by the user.") String postcode) {
        return parliamentDataSource.resolveConstituency(postcode);
    }
    @Tool(description = "Find the sitting MP for a Westminster constituency.")
    public MemberSummary getMemberForConstituency(
            @ToolParam(description = "Westminster constituency name.") String constituency) {
        return parliamentDataSource.getMemberForConstituency(constituency);
    }
    @Tool(description = "Extract deterministic Parliament search keywords from the user's civic issue.")
    public List<String> extractTopicKeywords(
            @ToolParam(description = "Plain-English civic issue.") String issue) {
        return keywordExtractor.extract(issue);
    }
    @Tool(description = "Search Parliament bills for legislation matching the supplied topic keywords.")
    public List<BillSummary> searchBills(
            @ToolParam(description = "Topic keywords produced by extractTopicKeywords.") List<String> keywords) {
        return parliamentDataSource.searchBills(keywords);
    }
    @Tool(description = "Find select committees whose remit matches the supplied topic keywords.")
    public List<CommitteeSummary> findRelevantCommittees(
            @ToolParam(description = "Topic keywords produced by extractTopicKeywords.") List<String> keywords) {
        return parliamentDataSource.findRelevantCommittees(keywords);
    }
    @Tool(description = "Find open committee inquiries accepting evidence for the supplied topic keywords.")
    public List<InquirySummary> findOpenInquiries(
            @ToolParam(description = "Topic keywords produced by extractTopicKeywords.") List<String> keywords) {
        return parliamentDataSource.findOpenInquiries(keywords);
    }
    @Tool(description = "Fetch the MP's relevant Commons division record for the supplied topic keywords.")
    public List<DivisionSummary> getVotingRecord(
            @ToolParam(description = "Parliament member id returned by getMemberForConstituency.") Long memberId,
            @ToolParam(description = "Topic keywords produced by extractTopicKeywords.") List<String> keywords) {
        return parliamentDataSource.getVotingRecord(memberId, keywords);
    }
    @Tool(description = "Draft correspondence for human approval only. This tool never sends anything.",
          returnDirect = true)
    public CorrespondenceDraft draftCorrespondence(
            @ToolParam(description = "Recommended civic route.") CivicRoute route,
            @ToolParam(description = "Person or body the draft is addressed to.") String target,
            @ToolParam(description = "Plain-English civic issue.") String issue) {
        return parliamentDataSource.draftCorrespondence(route, target, issue);
    }
}

That’s seven read tools plus one gated write tool, eight in total. The split matters. The granularity is a deliberate design decision, and it’s the easiest thing to get wrong: if you collapse the discovery tools into one tidy getEverythingAbout(issue) method, the model makes a single call, the loop collapses, and you no longer have an agent. You have a function with a chatbot bolted to the front. Keeping the tools small is what forces the model to chain them, which is the entire reason the loop exists.

Two tools deserve a closer look. The first is extractTopicKeywords. It’s a tool rather than a prompt instruction because it’s a deterministic, testable normalisation step (issue text in, a controlled vocabulary the Parliament search endpoints understand out), and I want to unit-test it without a model in the loop. In this build it’s pure logic with a sensible fallback, no model call at all:

package com.tucanoo.civicagent.agent;
import java.util.*;
import org.springframework.stereotype.Service;
@Service
public class KeywordExtractor {
    private static final Map<List<String>, List<String>> FALLBACK_KEYWORDS = Map.of(
            List.of("cycling", "cycle", "active travel", "road safety"),
            List.of("cycling", "active travel", "road safety", "transport"),
            List.of("broadband", "internet", "connectivity", "rural"),
            List.of("broadband", "digital infrastructure", "rural connectivity"),
            List.of("housing", "rent", "planning", "homes"),
            List.of("housing", "planning", "social housing")
    );
    public List<String> extract(String issue) {
        String normalised = issue.toLowerCase(Locale.UK);
        Set<String> matches = new LinkedHashSet<>();
        FALLBACK_KEYWORDS.forEach((patterns, keywords) -> {
            if (patterns.stream().anyMatch(normalised::contains)) {
                matches.addAll(keywords);
            }
        });
        if (matches.isEmpty()) {
            matches.addAll(defaultKeywords(normalised));
        }
        return List.copyOf(matches);
    }
    private static List<String> defaultKeywords(String issue) {
        return Arrays.stream(issue.replaceAll("[^a-z0-9 ]", " ").split("\\s+"))
                .filter(word -> word.length() > 3)
                .distinct()
                .limit(5)
                .toList();
    }
}

If you’d rather have the model do this normalisation, you can. But if you do, give it its own bare ChatClient with no tools registered, and never call your main tool-bearing agent client from inside a tool. Calling the agent from within a tool nests one tool loop inside another, and you get a confusing, expensive mess. Keeping the extractor deterministic side-steps that entirely, and it’s one fewer paid call per request.

The second is draftCorrespondence. It’s the only tool that could ever do something, so it’s deliberately toothless: it produces a CorrespondenceDraft flagged requiresHumanApproval, and the application has no way to send it. There is no send code anywhere in the build. The returnDirect = true marking means that if the model does call it, the draft ends the loop instead of being fed back. Worth being clear about scope here: this tool isn’t exercised by the single /assistant/ask recommendation call, which asks the model for a RouteRecommendation. draftCorrespondence is here for a “now draft the letter” follow-up. Building the approve-and-send step, where a person reviews an edited draft and dispatches it, is a production exercise, not something this demo does. The agent can draft; it can’t send.

You’ll notice there’s no tool for a member’s registered financial interests. That was a conscious decision to leave it out, not an oversight. Pairing someone’s financial interests with their voting record is the classic raw material for an implied-accusation narrative (“voted against X, holds an interest in Y”), and citations don’t defuse innuendo. The route recommendation doesn’t need it, so the cleanest, lowest-risk choice is not to expose it at all. Scoping a capability out is itself a safety control, and a decision worth making on purpose.

That’s the tools.

Wiring the agent with ChatClient

With tools in hand, the agent itself is small. We build one ChatClient bean, give it the system prompt that encodes the rules, register the tools with .defaultTools(...), and add a logging advisor so we can watch the tool calls fire during development. Here’s ChatClientConfig:

package com.tucanoo.civicagent.config;
import com.tucanoo.civicagent.agent.CivicTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
    private static final String SYSTEM_PROMPT = """
            You are a neutral UK civic research assistant.
            Use the available tools to answer questions about routes through Parliament.
            You may state only facts returned by tools in this turn, and every factual claim in the final
            answer must carry its own citation. Use citation sourceUrl values exactly as the tools returned them.
            Recommend a civic route, not a party or politician. Do not infer motive, intent, or character.
            If a data source is incomplete or unavailable, say so through the structured caveats rather than
            inventing a fact. Registered interests are outside the tool scope and must not be discussed.
            The write tool drafts text for human approval only. It never sends correspondence.
            """;
    @Bean
    ChatClient agentChatClient(ChatClient.Builder builder, CivicTools tools) {
        return builder.clone()
                .defaultSystem(SYSTEM_PROMPT)
                .defaultTools(tools)
                .defaultAdvisors(new SimpleLoggerAdvisor())
                .build();
    }
}

That system prompt is the heart of the application, so read it slowly. Every sentence in it maps to a rule we then enforce in code: only state what a tool returned, cite everything, recommend a route not a person, never infer motive, surface gaps as structured caveats rather than inventing facts. A prompt is a request, though, not a guarantee, which is exactly why the citation guard later in the article exists to check the model kept its word.

The agent service that calls this client is equally thin. It builds the user prompt, asks for the answer as a typed object, and makes sure the licence attribution is present. Here’s the first cut; we’ll wrap it with the citation guard in a moment:

package com.tucanoo.civicagent.agent;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class CivicResearchAgent {
    private final ChatClient chatClient;
    public CivicResearchAgent(@Qualifier("agentChatClient") ChatClient chatClient) {
        this.chatClient = chatClient;
    }
    public RouteRecommendation ask(String issue, String postcode) {
        String prompt = """
                Issue: %s
                Postcode: %s
                Return a structured RouteRecommendation with the required licence attribution.
                """.formatted(issue, postcode);
        RouteRecommendation recommendation = chatClient.prompt()
                .user(prompt)
                .call()
                .entity(RouteRecommendation.class, spec -> spec.validateSchema());
        return withRequiredAttribution(recommendation);
    }
    private static RouteRecommendation withRequiredAttribution(RouteRecommendation recommendation) {
        if (RouteRecommendation.OPEN_PARLIAMENT_LICENCE_ATTRIBUTION.equals(recommendation.attribution())) {
            return recommendation;
        }
        return new RouteRecommendation(
                recommendation.options(),
                recommendation.recommended(),
                recommendation.caveats(),
                RouteRecommendation.OPEN_PARLIAMENT_LICENCE_ATTRIBUTION
        );
    }
}

Notice what isn’t here: there’s no loop. That single .call() drives the entire multi-step sequence, because the auto-registered ToolCallingAdvisor runs the round-trip for us: the model asks for resolveConstituency, the framework runs it, the model reads the result and asks for getMemberForConstituency, and so on, until it has enough to produce the final RouteRecommendation. With the SimpleLoggerAdvisor wired in and that DEBUG log level from the YAML, you can watch the whole chain in the console on the first run, which is the best way to convince yourself it’s really chaining tools and not faking it.

The controller is the last mile, one POST endpoint:

@RestController
@RequestMapping("/assistant")
@RequiredArgsConstructor
public class AssistantController {
    private final CivicResearchAgent agent;
    @PostMapping("/ask")
    RouteRecommendation ask(@Valid @RequestBody AssistantRequest request) {
        return agent.ask(request.issue(), request.postcode());
    }
}

That’s a complete, runnable agent.

Grounded, structured output

This is where the safety thesis becomes concrete. The naive way to make an agent “cite its sources” is a free-text rationale plus a rule like “include at least one citation”. That doesn’t actually close the hole: the model can write a paragraph that’s mostly grounded and slips one un-cited assertion in the middle, and a single citation at the end makes it look honest. So instead we design the schema so that an un-cited factual sentence is unrepresentable. There’s simply no field for one.

The output is a RouteRecommendation. Its rationale isn’t a string; it’s a list of GroundedClaim, and every GroundedClaim carries its own Citation, one to one. Caveats are a controlled enum, not free prose. Here are the records:

public record RouteRecommendation(
        @Valid @NotEmpty List<RouteOption> options,
        @NotNull CivicRoute recommended,
        @NotNull List<Caveat> caveats,
        @NotEmpty String attribution
) {
    public static final String OPEN_PARLIAMENT_LICENCE_ATTRIBUTION =
            "Contains Parliamentary information licensed under the Open Parliament Licence v3.0.";
}
public record RouteOption(
        @NotNull CivicRoute route,
        @NotEmpty String target,
        @Valid @NotEmpty List<GroundedClaim> rationale,
        @NotNull List<Caveat> dataGaps
) {
}
public record GroundedClaim(
        @NotBlank String claim,
        @Valid @NotNull Citation citation
) {
}

The two enums keep the model on rails: it can only recommend a process, and it can only flag a gap from a fixed vocabulary:

public enum CivicRoute {
    WRITE_TO_MP,
    LOBBY_COMMITTEE_CHAIR,
    SUBMIT_EVIDENCE
}
public enum Caveat {
    DATA_INCOMPLETE,
    NO_OPEN_INQUIRY,
    NO_RELEVANT_BILL_FOUND,
    RECORD_PREDATES_ISSUE,
    HUMAN_REVIEW_REQUIRED
}

We get this object out of the model with .entity(RouteRecommendation.class, spec -> spec.validateSchema()), which you’ve already seen in the agent. Spring AI hands the model the JSON schema for the record, and the validateSchema() call gives you bounded self-correction when the model’s first attempt doesn’t conform. The Bean Validation annotations (@NotEmpty on the options and rationale, @NotNull on the citation) mean a response with an empty rationale or a missing citation fails validation rather than sailing through. The model literally cannot return a well-formed answer that contains an un-cited claim, because there’s no field for the claim to live in without a citation beside it.

That closes the uncited-fact hole at the schema level. But a determined or confused model could still emit a Citation with a URL it made up: well-formed, validates fine, points at nothing real. Closing that gap is the next section.

The safety core: a citation-integrity guard

The schema guarantees every claim has a citation. It does not guarantee the citation is real: that the URL actually corresponds to a record a tool returned during this turn. That’s the last gap, and for an app that talks about named politicians it’s the one that matters most. We close it with a small service that keeps a per-turn record of every source URL the tools actually returned, and then checks the finished answer against it: walk every GroundedClaim, and if any citation URL wasn’t among the records the tools produced this turn, reject the whole turn.

The “this turn” part is the only fiddly bit. The agent marks the start and end of a turn, each tool records its result as it returns, and the guard validates at the end. Because the synchronous .call() path runs the tool loop on the request thread, a ThreadLocal registry is enough to scope the recorded sources to one request:

package com.tucanoo.civicagent.agent;
import com.tucanoo.civicagent.data.*;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springframework.stereotype.Service;
/**
 * Verifies citation provenance, not whether the claim wording fully represents the source.
 */
@Service
public class CitationIntegrityGuard {
    private final ThreadLocal<Set<String>> returnedSources = ThreadLocal.withInitial(LinkedHashSet::new);
    public void beginTurn() {
        returnedSources.set(new LinkedHashSet<>());
    }
    public void endTurn() {
        returnedSources.remove();
    }
    public void record(Object toolResult) {
        switch (toolResult) {
            case null -> { }
            case Iterable<?> values -> values.forEach(this::record);
            case Citation citation -> record(citation);
            case ResolvedConstituency value -> record(value.citation());
            case MemberSummary value -> record(value.citation());
            case BillSummary value -> record(value.citation());
            case CommitteeSummary value -> record(value.citation());
            case InquirySummary value -> record(value.citation());
            case DivisionSummary value -> record(value.citation());
            default -> { }
        }
    }
    public void validate(RouteRecommendation recommendation) {
        Set<String> allowed = returnedSources.get();
        Set<String> rejected = new LinkedHashSet<>();
        for (RouteOption option : recommendation.options()) {
            for (GroundedClaim claim : option.rationale()) {
                String sourceUrl = claim.citation().sourceUrl();
                if (!allowed.contains(sourceUrl)) {
                    rejected.add(sourceUrl);
                }
            }
        }
        if (!rejected.isEmpty()) {
            throw new IllegalStateException(
                    "Response cited sources that were not returned by tools this turn: "
                            + String.join(", ", rejected));
        }
    }
    private void record(Citation citation) {
        if (citation.sourceUrl() != null && !citation.sourceUrl().isBlank()) {
            returnedSources.get().add(citation.sourceUrl());
        }
    }
}

The pattern-matching switch on the tool result keeps the recording in one place: hand it any tool return (a single record, or a list of them) and it pulls out the citation URLs. The one-line change in each tool is just to feed it. For example, resolveConstituency becomes:

@Tool(description = "Resolve a UK postcode to its Westminster constituency.")
public ResolvedConstituency resolveConstituency(
        @ToolParam(description = "UK postcode supplied by the user.") String postcode) {
    ResolvedConstituency result = parliamentDataSource.resolveConstituency(postcode);
    citationIntegrityGuard.record(result);
    return result;
}

And the agent wraps the model call in a turn, validating before it returns and clearing the registry in a finally so nothing leaks between requests:

public RouteRecommendation ask(String issue, String postcode) {
    String prompt = """
            Issue: %s
            Postcode: %s
            Return a structured RouteRecommendation with the required licence attribution.
            """.formatted(issue, postcode);
    citationIntegrityGuard.beginTurn();
    try {
        RouteRecommendation recommendation = chatClient.prompt()
                .user(prompt)
                .call()
                .entity(RouteRecommendation.class, spec -> spec.validateSchema());
        RouteRecommendation attributed = withRequiredAttribution(recommendation);
        citationIntegrityGuard.validate(attributed);
        return attributed;
    } finally {
        citationIntegrityGuard.endTurn();
    }
}

One caveat to file away: the ThreadLocal works precisely because the blocking .call() runs the tool loop on the request thread. If you switched to the streaming/reactive path, or executed tools on a separate pool, you’d propagate the registry differently, passing it through ToolContext, say, rather than leaning on thread affinity. It’s the right simple choice here, but it is a choice.

Be careful how you describe this thing, because it’s tempting to oversell. It is a citation-integrity guard: it proves every cited source exists and was returned this turn. That’s a strong guarantee, and more than most agents bother with. Be clear about what it does not prove, though. It doesn’t prove the cited record actually supports the sentence the model wrote next to it; a real URL can be attached to a claim it doesn’t back up. And it can’t catch innuendo by juxtaposition, where two individually-true, properly-cited facts defame by arrangement. Those are matters of wording and judgement, which is why this build never sends anything itself (it stops at a draft for a person to take forward) and why we scoped registered interests out of the build entirely. The guard closes the “made-up source” hole hard. It reduces the others. It doesn’t eliminate them. Say that plainly in your own version, because over-claiming a safety control is its own kind of risk.

With the guard in place, step back and look at the whole request end to end: the model driving the tool-calling loop, every tool result recorded for the guard, and the final recommendation validated before it ever leaves the service:

Sequence diagram of one /assistant/ask request: the agent runs the model's tool-calling loop over the UK Parliament tools, records each citation to the guard, and returns a validated, cited recommendation.

If you want to go further with grounding, two of our other tutorials pick up neighbouring threads: our Spring AI RAG tutorial covers grounding answers in your own document corpus rather than an API, and our guide to building an MCP server in Java shows how you’d expose tools like these to other agents over a standard protocol.

Controlling the loop yourself (optional)

The build above lets the framework drive the loop, and for a single request-response endpoint that’s the right call: less code, and nothing to get wrong. But it’s worth knowing how to take the wheel, because the moment you want a hard budget on tool iterations, or you want to run a check after every step rather than only at the end, you need the manual loop. Spring AI 2.0 supports it cleanly: you opt out of the auto-registered advisor and drive ToolCallingManager yourself.

ToolCallingManager manager = ToolCallingManager.builder().build();
ToolCallingChatOptions options = ToolCallingChatOptions.builder()
        .toolCallbacks(ToolCallbacks.from(tools))
        .build();
Prompt prompt = new Prompt(List.of(new UserMessage(question)), options);
ChatClientResponse response = chatClient.prompt()
        .messages(prompt.getInstructions())
        .options(options)
        .advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
        .call()
        .chatClientResponse();
int iteration = 0;
while (response.chatResponse() != null
        && response.chatResponse().hasToolCalls()
        && iteration++ < maxIterations) {
    ToolExecutionResult result = manager.executeToolCalls(prompt, response.chatResponse());
    prompt = new Prompt(result.conversationHistory(), options);
    response = chatClient.prompt()
            .messages(result.conversationHistory())
            .options(options)
            .advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
            .call()
            .chatClientResponse();
}

The iteration++ < maxIterations guard is the thing you came for: a hard cap so a confused model can’t spend your token budget in an infinite tool-calling spiral. One trap to avoid: don’t register your tools twice. The default agent client carries tools via .defaultTools(...); the manual loop supplies them a different way, through ToolCallingChatOptions.toolCallbacks(...). If you reuse the default client here you’ll register them both ways. So for the manual path use a separate, bare ChatClient with no default tools and no auto-advisor, and let the options be the only source of tools. The base repo ships the framework path; this is here so you know where the seam is when you need it.

Validating input and handling failure

Two kinds of things go wrong: bad input, and a data source or model that falls over mid-request. Both have to fail cleanly, and under the grounding rule a failure must never become a fabricated fact.

Input validation is ordinary Bean Validation on the request record. A blank issue or a malformed postcode is rejected before the model is ever called. No point paying for a model round-trip to discover the postcode was gibberish:

public record AssistantRequest(
        @NotBlank String issue,
        @NotBlank
        @Pattern(regexp = "^[A-Z]{1,2}\\d[A-Z\\d]?\\s*\\d[A-Z]{2}$", flags = Pattern.Flag.CASE_INSENSITIVE)
        String postcode
) {
}

The exception handler turns validation failures into a clean 400 and anything unexpected into a 500 with a logged cause, rather than leaking a stack trace or, worse, returning half an answer:

@Slf4j
@RestControllerAdvice
public class ApiExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ErrorResponse> validation(MethodArgumentNotValidException exception) {
        List<String> details = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .toList();
        return ResponseEntity.badRequest().body(new ErrorResponse(
                Instant.now(), HttpStatus.BAD_REQUEST.value(), "Validation failed", details));
    }
    @ExceptionHandler(Exception.class)
    ResponseEntity<ErrorResponse> unexpected(Exception exception) {
        log.error("Assistant request failed", exception);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse(
                Instant.now(), HttpStatus.INTERNAL_SERVER_ERROR.value(), "Request failed",
                List.of(exception.getMessage())));
    }
}

For a data source that’s down (say the Committees API 500s while the rest succeed), the right behaviour isn’t to guess. It’s to surface a gap: the affected route comes back with a Caveat like DATA_INCOMPLETE rather than a confident claim the tool never actually returned. A dead source produces a hole in the answer, never a fabricated fact. Spring AI 2.0 gives you a first-class hook for shaping what the model sees when a tool throws: a ToolExecutionExceptionProcessor, plus the spring.ai.tools.throw-exception-on-error property to choose fail-soft versus fail-fast. Use it to decide deliberately rather than letting an exception bubble into the model’s context as noise.

One thing this build deliberately doesn’t do is wrap the model call in a retry or circuit breaker. That’s a real production concern, but it’s not this article’s subject, and a half-hearted version would be worse than an honest gap. To production you’d wrap the model call with Spring Retry or Resilience4J for transient timeouts and rate limits; and if you ever wire up the actual sending of a drafted letter, put an idempotency key on it so a retry after a timeout can’t submit the same evidence twice. The draft itself is side-effect-free, which is the safe default.

Testing a non-deterministic agent over real people

You cannot unit-test an LLM into determinism, and pretending otherwise is how you end up with a flaky suite everyone learns to ignore. The approach that works is to test the scaffolding deterministically and evaluate the model probabilistically, and to keep the two apart. Crucially, the whole suite runs with no API key: the deterministic tests never touch the live model or the live network.

The trick is the interface from the data layer. Tools are unit-tested against a stub ParliamentDataSource, so the keyword logic, the delegation and the gated draft tool are all checked without a single HTTP call:

class CivicToolsTest {
    private final CivicTools tools = new CivicTools(new StubParliamentDataSource(), new KeywordExtractor());
    @Test
    void delegatesReadToolsToDataSource() {
        ResolvedConstituency constituency = tools.resolveConstituency("BS1 5TR");
        MemberSummary member = tools.getMemberForConstituency(constituency.constituency());
        List<String> keywords = tools.extractTopicKeywords("safer cycling infrastructure");
        List<BillSummary> bills = tools.searchBills(keywords);
        assertThat(member.name()).isEqualTo("Carla Denyer");
        assertThat(bills).hasSize(1);
    }
    @Test
    void draftToolReturnsDraftOnly() {
        CorrespondenceDraft draft = tools.draftCorrespondence(
                CivicRoute.WRITE_TO_MP, "Carla Denyer", "safer cycling infrastructure");
        assertThat(draft.requiresHumanApproval()).isTrue();
        assertThat(draft.subject()).contains("safer cycling infrastructure");
    }
}

That second test is the one I care about most: it’s the executable proof that the write tool produces a draft and never sends. Validation gets the same treatment: a bad postcode or empty issue is asserted to come back as a 400, using the JDK’s own HttpClient against a random port. (If you’re used to TestRestTemplate, note it isn’t on the Boot 4 test classpath; the built-in HttpClient is the replacement.)

The live data source has its own test, but it’s gated behind an environment variable so it only runs when you explicitly want to hit the network:

@SpringBootTest
@EnabledIfEnvironmentVariable(named = "RUN_LIVE_TESTS", matches = "true")
class LiveDataSourceNetworkTest {
    // resolves a real postcode and member against the live Parliament APIs
}

The citation guard gets its own test, and it’s the one that proves the safety thesis actually holds. It records a source as if a tool had returned it, then feeds the guard a response citing a different, never-returned URL and asserts the turn is rejected. Separately, it checks that a properly-grounded response passes and that the registry is cleared between turns:

class CitationIntegrityGuardTest {
    private final CitationIntegrityGuard guard = new CitationIntegrityGuard();
    @Test
    void rejectsCitationsNotReturnedByToolsThisTurn() {
        guard.beginTurn();
        guard.record(new Citation("https://members-api.parliament.uk/api/Members/5102", "member", Instant.EPOCH));
        RouteRecommendation recommendation = recommendationWith(
                new Citation("https://example.test/not-returned", "member", Instant.EPOCH));
        assertThatThrownBy(() -> guard.validate(recommendation))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("not returned by tools");
        guard.endTurn();
    }
    @Test
    void acceptsCitationsReturnedByToolsThisTurn() {
        Citation citation = new Citation("https://members-api.parliament.uk/api/Members/5102", "member", Instant.EPOCH);
        guard.beginTurn();
        guard.record(new MemberSummary(5102L, "Carla Denyer", "Green Party", "Bristol Central", citation));
        assertThatNoException().isThrownBy(() -> guard.validate(recommendationWith(citation)));
        guard.endTurn();
    }
}

That test is what separates claiming the app is grounded from showing it: a made-up source fails the build. The one thing I’d add next is a neutrality evaluation, using the model itself as a judge to score outputs for partisan framing, but keep that out of the blocking CI gate. It’s a signal rather than a pass/fail, and treating a probabilistic judge as a hard gate just makes your pipeline flaky.

Common pitfalls

A grab-bag of things that’ll cost you an afternoon, most of them learned the hard way:

  • Following a Spring AI 1.x tutorial against 2.0. The loop moved into the advisor chain; internalToolExecutionEnabled is gone; bare Function beans with .functions() are gone; it’s .defaultTools(...) not .defaultToolCallbacks(...); and the .options. property segment was dropped.
  • Forgetting -parameters. Your tool parameter names come through as arg0/arg1 and the model can’t tell what they’re for.
  • One fat getEverythingAbout() tool. The model makes one call, the loop disappears, and you no longer have an agent.
  • A free-text rationale plus “at least one citation”. Ungrounded prose slips through. Make the rationale a list of one-to-one cited claims so an un-cited sentence has nowhere to live.
  • Assuming citations make output safe. Innuendo by juxtaposition of true facts can still defame; that’s why this build never sends anything itself and only ever produces a draft for a person to take forward.
  • Treating tool output as trusted instructions. A bill title or member biography is model-visible text that could contain “ignore previous instructions”; treat tool results as data, not commands.
  • Pinning a retired model name. Hard-coding last year’s model breaks the build on the day you publish. Set the current one at build time.
  • Testing for exact response text. Flaky forever. Assert on structure, tool sequence and citation resolution instead.
  • Forgetting the Open Parliament Licence attribution. “Contains Parliamentary information licensed under the Open Parliament Licence v3.0.” needs to be present. It’s a licence condition, not a nicety.

Final thoughts

You should now have a running Spring Boot 4.1 / Spring AI 2.0 service that takes a plain-English civic question and a postcode and returns a structured, fully cited recommendation, produced by a multi-step agent that chains real Parliament data tools, refuses to state anything it can’t ground in a source, and never sends anything itself. The write tool only ever produces a draft. That’s a useful shape, and it’s a world away from a chatbot that confidently makes things up.

It’s also, deliberately, a simplified version. The grounding controls reduce legal and reputational risk; they don’t eliminate it. Innuendo, and whether a citation truly supports its claim, remain matters of judgement, which is exactly why this build stops at a draft and never sends anything itself. An approve-and-send step, with a person signing off before anything leaves, is a deliberate next exercise, not something I’ve pretended into the demo. This is a foundation you could responsibly pilot, not a system to turn loose on the public unattended, and being clear about that line is part of building these things well.

If you want to take it further, a few worthwhile exercises:

  • Add memory. Wire up MessageWindowChatMemory with a conversation id so a reader can ask a follow-up (“what about the education angle?”) without restating everything. Note that 2.0 makes the conversation id required.
  • Grow the guard test. Add the ungrounded-response rejection test described above, and a non-blocking neutrality eval, so the safety behaviour is pinned down by the suite.
  • Add observability. Lean on Spring AI 2.0’s Micrometer/OTel instrumentation to trace a run, with each tool call as a span and token counts surfaced as metrics.
  • Add the human-in-the-loop send. Build the approve-and-send step the write tool only implies today: a second endpoint that takes an edited, approved draft and actually dispatches it, with a person signing off and an idempotency key so a retry can’t send twice.
  • Wrap the model call for resilience. Spring Retry or Resilience4J for transient failures, and an idempotency key if you ever implement the actual send.
  • Expose the tools over MCP. The data layer would make a tidy MCP server, so other agents could use the same grounded Parliament tools.

Don’t forget the full, runnable code is on GitHub: github.com/tucanoo/spring-ai-civic-research-assistant. Clone it, set a key, and have a poke around.

The unglamorous parts of agentic features, the grounding and safety and cost and testing, are the parts most tutorials skip, and they’re the parts that decide whether the thing is fit to ship. That’s the work we do for clients every day. If you’re weighing up adding AI agents to a Java system and want a second opinion, get in touch.

David Brown founded Tucanoo Solutions in 2011 and has written software in a professional capacity for over 30 years, back to the Java/J2EE days of Sun Microsystems. He builds cloud and web applications for clients in legal tech, government and beyond, including several years consulting to the United Nations on customs and transport systems. He writes these tutorials himself, with the full code on GitHub.

Leave a Reply

Your email address will not be published. Required fields are marked *