What you'll learn: how to build prompts from system and user messages and templates, turn model replies into validated Java records, implement tool calling end to end with @Tool methods, and decide when a model should not be given a tool at all.
A chat string is a demo. A production feature needs two upgrades. The reply must arrive as data your code can use (a record with fields, not a paragraph), and sometimes the model must be able to ask your application for facts or actions it cannot know by itself. This lesson covers both, and spends equal time on the part that makes them safe: validation and limits.
The core idea
Think of the model as a very capable contractor who works in a sealed room. You pass instructions and materials through a slot (the prompt). The contractor passes back a document (the reply). The contractor cannot open your filing cabinet or call your suppliers.
Structured output is a form the contractor must fill in. You describe the form (a Java type), the contractor fills it, and your code checks the form before using it.
Tool calling is a short list of approved requests the contractor may pass out through the slot: "please look up order 1234". Your application reads the request, decides whether to run it, runs it, and passes the result back in. The contractor never touches your systems directly.
That second point is the security model. The reference documentation puts it plainly: the application owns the tool-calling logic, and the model never receives direct access to the APIs behind your tools.
NOTE
Version note. Examples were checked against the Spring AI 2.0.x reference documentation. The 2.0 upgrade notes record several changes that older tutorials do not match: FunctionCallback became ToolCallback, defaultFunctions became defaultTools, ChatClient auto-registers a ToolCallingAdvisor, and options(...) takes a builder. If your project uses an earlier release line, compare with the documentation for that line before copying.
Part 1: prompts
Roles and the request you are really sending
A prompt is a list of messages. Each has a role: system (rules and persona), user (the request), assistant (earlier replies) and tool (results of tool calls). Spring AI exposes the roles through ChatClient:
Stringanswer= chatClient.prompt()
.system("You are a support triage assistant. Answer only from the text provided.")
.user("How do I reset my password?")
.call()
.content();
Put what stays the same in the system message, and what changes per request in the user message. A stable system message is easier to review, test and reuse across requests.
Templates
Placeholders in {braces} are filled from param(...). The default renderer is StringTemplate-based (StTemplateRenderer).
Stringreply= chatClient.prompt()
.system(s -> s.text("You write replies for a {team} team in a {tone} tone.")
.param("team", "billing")
.param("tone", "calm"))
.user(u -> u.text("Draft a reply to this email:\n\n{email}")
.param("email", emailText))
.call()
.content();
Two practical points:
Braces in your own text. If a prompt contains JSON examples, { and } collide with placeholders. The reference shows switching the delimiters per call, for example to angle brackets:
Long prompts belong in files. Keep them under src/main/resources/prompts/ and load them as a Resource. The builder accepts a Resource for defaultSystem(...):
Prompts in files get code review, history and diffs like any other source. That matters because a one-word change to a prompt can change behaviour as much as a code change.
Writing prompts that behave
Spring AI cannot make a vague prompt precise. A few habits matter more than any API:
State the task, the audience and the constraints. "Summarise for a claims adjuster in three bullets, using only the note" beats "summarise this".
Separate instructions from data. Mark where the data starts and ends (for example with labelled markers) and tell the model to treat it as content to analyse, not as instructions. This reduces, but does not remove, prompt-injection risk.
Give the closed set. If the answer must be one of five categories, list them.
Allow "I don't know". Tell the model what to output when the text does not contain the answer; otherwise it may guess.
Keep temperature low for extraction and classification. Higher values suit drafting.
entity(...) asks the model for JSON that matches a Java type, then deserialises it:
publicenumCategory { BILLING, TECHNICAL, ACCOUNT, OTHER }
publicenumPriority { LOW, NORMAL, HIGH }
publicrecordTicketTriage(Category category, Priority priority, String reason) { }
TicketTriagetriage= chatClient.prompt()
.system("Classify support emails. Use OTHER when none of the categories fit.")
.user(u -> u.text("Email between the markers:\n<<<EMAIL\n{email}\nEMAIL>>>")
.param("email", email))
.options(ChatOptions.builder().temperature(0.0))
.call()
.entity(TicketTriage.class);
Behind the scenes, BeanOutputConverter derives a JSON Schema from the record and appends format instructions to the prompt. For generic types such as lists, use ParameterizedTypeReference:
Records are a natural fit: they are immutable and the field names are the contract.
Why best-effort is the right mental model
The documentation is explicit: by default the schema is appended to the prompt as text, so the model is asked to comply and the response is parsed afterwards. The converter is best-effort. A model may wrap the JSON in prose or a Markdown fence, omit a field or add one, and BeanOutputConverter is strict, so parsing then throws. You have three levels of defence, and you can combine them.
Provider-native structured output.spec.useProviderStructuredOutput() sends the schema to the provider as an API-level constraint, so the provider enforces it. The documentation lists support for OpenAI, Anthropic, Google GenAI, Mistral AI and Ollama (model-specific), says it is off by default because support varies, and notes that if the model's options do not support it the flag is silently ignored. Known limits include partial support for features such as $ref, anyOf and recursive types, and OpenAI does not accept a top-level array, so wrap a list in a record.
Schema validation with retry.spec.validateSchema() validates the reply against the schema, appends the validation error to the prompt on failure, and retries (3 attempts by default per the reference). It does not support streaming, and token usage accumulates across attempts.
Your own validation after you have a typed object (next section).
TicketTriagetriage= chatClient.prompt()
.user(u -> u.text("...{email}...").param("email", email))
.call()
.entity(TicketTriage.class, spec -> spec
.useProviderStructuredOutput() // request-side constraint, where the provider supports it
.validateSchema()); // response-side check with a retry loop
sequenceDiagram
participant S as Your service
participant CC as ChatClient
participant M as Model
S->>CC: entity(TicketTriage.class, validateSchema)
CC->>M: prompt plus schema
M-->>CC: reply 1
CC->>CC: validate against schema
alt reply is valid
CC-->>S: TicketTriage
else reply is invalid
CC->>M: prompt plus the validation error
M-->>CC: reply 2
CC->>CC: validate again, up to the attempt limit
CC-->>S: TicketTriage or an exception
end
Read the diagram as a bounded loop: every retry is another paid model call, which is why the number of attempts is capped.
Validate what the schema cannot
A schema proves shape, not truth. A reply can be perfectly shaped and wrong. Add checks in layers:
Layer
Question
Example
Shape
Is it parseable and complete?
entity() plus validateSchema()
Constraints
Are field values within bounds?
Bean Validation annotations on the record
Business rules
Does it agree with your data?
The referenced order id exists and belongs to this customer
Authorisation
May this caller do this?
Normal Spring Security checks, never the model's say-so
Review
Is the risk high enough for a person?
Queue low-confidence or high-impact results for approval
Bean Validation works on records:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
publicrecordTicketTriage(
@NotNull Category category,
@NotNull Priority priority,
@NotBlank @Size(max = 300) String reason) { }
// In the service, after entity(...). "validator" is an injected jakarta.validation.Validator.
Set<ConstraintViolation<TicketTriage>> problems = validator.validate(triage);
if (!problems.isEmpty()) {
return TicketTriage.manualReview(); // a static factory you define on the record; routes to a human
}
The schema that entity() generates is built from Jackson and Swagger-style annotations, not from Bean Validation, so these constraints are enforced only by your own validator.validate(...) call; they are not sent to the model.
Define the failure path first: what does your system do when the output is unusable? A safe default (send to a human queue, return "try again", fall back to a rule) is part of the feature, not an afterthought.
Part 3: tool calling end to end
What happens in one request
You register tools; the framework sends their definitions with the prompt; the model may answer with a request to call one; your code runs it; the result goes back; the model continues. The reference describes the loop in four steps: send the request with all tool definitions, let the model respond, execute any tool calls through ToolCallingManager and append the results to the history, and repeat until a reply arrives with no tool calls.
sequenceDiagram
participant U as User
participant CC as ChatClient
participant M as Model
participant T as Your @Tool method
U->>CC: "Where is order A-1042?"
CC->>M: messages plus tool definitions
M-->>CC: tool call: getOrderStatus(orderId = "A-1042")
CC->>T: execute with the arguments
T-->>CC: result: SHIPPED, arrives Friday
CC->>M: history plus tool result
M-->>CC: final answer in natural language
CC-->>U: "Order A-1042 shipped and should arrive Friday."
Every tool round trip is a full extra model call, with the whole history re-sent. Tool calls add latency and tokens; they are not free.
Tool calling: the model asks, your application decides and executesChat endpoint to ChatClient; ChatClient to Model (prompt and tools); ChatClient to OrderTools (validated call); OrderTools to Order service; Order service to Order databaseTRUST BOUNDARY: YOUR CODE DECIDES WHAT RUNSChat endpoint: authenticates the userChat endpointauthenticates theuserChatClient: tool-calling loopChatClienttool-calling loopModel: proposes tool callsModelproposes toolcallsOrderTools: @Tool methodsOrderTools@Tool methodsOrder service: authorisation, rulesOrder serviceauthorisation,rulesOrder databaseOrder databaseprompt and toolsvalidated call
Step 1: write a tool
A tool is a method with a description. The description is the only thing the model sees to decide whether to call it, so write it as guidance, not a label.
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.stereotype.Component;
@ComponentpublicclassOrderTools {
privatefinal OrderService orders;
publicOrderTools(OrderService orders) {
this.orders = orders;
}
@Tool(description = "Look up the status of one order by its id. "
+ "Use this when the customer asks where an order is. Read-only.")public OrderStatus getOrderStatus(
@ToolParam(description = "Order id such as A-1042") String orderId,
ToolContext toolContext) {
// The customer id comes from the server-side context, never from the model.StringcustomerId= (String) toolContext.getContext().get("customerId");
return orders.statusFor(customerId, orderId);
}
}
// In its own file (a Java file holds one public top-level type).publicrecordOrderStatus(String orderId, String state, String expectedDelivery) { }
Notes on this tool:
@Tool takes name (default: the method name), description, returnDirect and resultConverter. All parameters are required unless you mark them @ToolParam(required = false) or @Nullable.
The ToolContext argument carries data for your code that is not sent to the model. Putting the customer id there, not in a parameter, means the model cannot ask for someone else's order.
Return types are converted to a string, with Jackson by default. The reference lists types that are not supported for parameters or results: Optional, Future/CompletableFuture, reactive types (Mono, Flux, Flow) and functional types.
For native image builds, the class holding @Tool methods must be a Spring bean (here @Component) or be registered for reflection.
Step 2: hand the tool to the model
Stringanswer= chatClient.prompt()
.system("You help customers with their own orders. Use tools for facts; never guess.")
.user(question)
.tools(orderTools) // only for this request
.toolContext(Map.of("customerId", authenticatedCustomerId))
.call()
.content();
tools(...) on the request adds to any defaultTools(...) on the client rather than replacing them. The reference notes that default tools "can also be dangerous if used carelessly" and recommends adding risk-tier or destructive tools per call, not as defaults. In Spring AI 2.0 the ToolCallingAdvisor is registered for you automatically. The reference says that supplying your own ToolCallingAdvisor suppresses the automatic one, and that exactly one tool advisor may be present in the chain.
Step 3: control the failure modes
Concern
What the reference documents
A tool throws
By default DefaultToolExecutionExceptionProcessor sends a RuntimeException's message back to the model so it can recover; checked exceptions and errors are re-thrown. Set spring.ai.tools.throw-exception-on-error=true to throw everything to your code.
Runaway loops
ToolCallingManager can cap calls per tool and in total; the 2.0.1 upgrade notes state defaults of 40 per tool and 150 per turn. You can choose to throw or return an error response when a limit is exceeded.
Result goes straight to the caller
returnDirect = true skips the model's final wording. It is honoured only if every tool called in that round is returnDirect.
Taking control
AdvisorParams.toolCallingAdvisorAutoRegister(false) turns off the automatic loop so you can run ToolCallingManager.executeToolCalls(...) yourself, for example to add approval. This bypasses observability and advisor composition.
Resolution fallback
spring.ai.tools.resolution.fallback.enabled is false by default; keep it that way so only tools you attached to the request can run.
If a tool failure message goes back to the model, make sure the message contains nothing sensitive: exception text from a database layer can include table names or values.
Step 4: test the tool as plain Java
The tool method is a normal method. Test the logic without a model at all:
Then test the surrounding behaviour (the system message, the tool wiring) with a stub ChatModel, as in lesson 2. Whether a real model chooses the right tool for a real question is a quality question; keep a small set of such questions to run on demand.
Support email triage (structured output, no tools). Input: an email. Output: TicketTriage. Controls: closed enums, low temperature, validateSchema(), a human queue on failure.
Order status assistant (one read-only tool). Input: a customer question. Tool: getOrderStatus. Controls: customer id from ToolContext, read-only, call limits, a system message that forbids guessing.
Invoice field extraction (structured output plus business validation). Input: OCR text of an invoice. Output: a record with supplier, total and currency. Controls: Bean Validation, a check that the total equals the sum of the lines, and a review queue when any check fails.
When not to give a model a tool
Tools are powerful and easy to overuse. Use the flowchart before you add one.
flowchart TD
A[Task needs outside data or an action] --> B{Can your code fetch it before the call?}
B -- Yes --> C[Fetch it, put it in the prompt, no tool]
B -- No --> D{Does the action change data or cost money?}
D -- No, read-only --> E[Tool is reasonable: scope by ToolContext, add limits]
D -- Yes --> F{Is it reversible and low impact?}
F -- Yes --> G[Tool with validation, audit log and a call cap]
F -- No --> H[Do not expose it. Return a draft for a person to approve]
Concrete cases where a tool is the wrong choice:
The data is known up front. If every request about an order needs the order record, load it and include it in the prompt. One model call beats two, and there is no chance of the model forgetting to ask.
The logic is deterministic. Currency conversion, eligibility rules and date arithmetic belong in code. Let the model write the explanation, not do the calculation.
The action is destructive or hard to undo. Refunds, deletions, account changes and emails to customers should create a draft or a request that a person or a stricter workflow approves.
The tool is broad. A "run any SQL" tool hands the model your database. Expose narrow, named operations with typed arguments.
Latency is tight. Each round trip adds a model call. A tool-using flow can take several times longer than a single call.
Trade-offs
Approach
Strength
Weakness
Use when
Plain prompt, text reply
Simplest
Parsing is yours
Drafting, summaries shown to a person
entity()
Typed result, one call
Best-effort without extras
Extraction, classification
entity() plus native output and validation
Strongest shape guarantees
Provider limits, retries cost tokens
Machine-consumed results
Prefetch into the prompt
Predictable, fast
Larger prompts
Data is known per request
Tool calling
Model chooses what to fetch
Extra calls, security surface
Needed data depends on the conversation
Human-approved draft
Safest for risky actions
Slower
Anything irreversible
In practice: an order assistant for a retail support team
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation. A retailer's support team spends much of its day answering "where is my order?" and sorting incoming emails by topic. The team wants a triage step and a customer-facing order assistant, with no risk that the assistant exposes another customer's data or issues refunds.
Starting architecture. The order service already enforces who can see which order. Support agents read emails in a ticketing tool.
Starting architecture: tickets, a web chat and an order serviceSupport inbox to Support API; Customer web chat to Support API; Support API to Order service; Order service to Order databaseSupport inbox: emailsSupport inboxemailsCustomer web chatCustomer webchatSupport API: Spring BootSupport APISpring BootOrder service: owns authorisationOrder serviceownsauthorisationOrder database: PostgreSQLOrder databasePostgreSQL
What changes.
Add the triage endpoint: entity(TicketTriage.class) with enums, temperature 0, validateSchema() and Bean Validation. Failures go to the existing manual queue.
Add OrderTools with a single read-only getOrderStatus, the customer id passed through toolContext, and the tool attached per request in the chat endpoint only.
Leave refunds out of the tool set. When a customer asks for one, the assistant is told in the system message to say that a person will follow up, and the endpoint creates a draft refund request for an agent to approve.
Cap tool calls per turn, set output limits, and turn on tool observations. Keep spring.ai.tools.observations.include-content off so arguments and results do not land in traces by default.
Write unit tests for the tool method and the triage service (stub model returning JSON), and a twenty-question on-demand suite for the real model.
Result. The team should see the inbox tagged with category and priority in seconds, with unusable outputs landing in the existing manual queue rather than failing silently. The chat assistant answers status questions using real data, cannot read another customer's orders because the id never comes from the model, and cannot perform actions at all. The things to watch after launch are the share of triage results that fail validation (assumed to be a small minority) and the number of tool calls per conversation.
Architect's notes
Treat tool descriptions as part of the interface. Vague descriptions cause wrong calls or no calls. Review them like API documentation.
Keep tools narrow and named after the business operation.getOrderStatus is safer than queryDatabase. Narrow tools are easier to scope, test and audit.
Identity belongs on the server. Use ToolContext, the security context or a request-scoped bean for who the user is and which tenant applies. Never take them from model-supplied arguments.
Read-only first. Ship read-only tools, watch real behaviour, and only then consider write tools, each behind approval or strict validation.
Budget the loop. Set call limits, a timeout and token limits. A confused model can call the same tool repeatedly.
Prompt injection is real. Text a tool returns (an email body, a web page, a document) can contain instructions. Treat tool results as untrusted data and keep authority in your code.
Log decisions, not secrets. Log which tool ran, with which ids and how long it took. Keep arguments and results out of general logs unless you have a protected store.
Pin and re-test models. Tool choice and JSON compliance vary by model and version, so rerun your question set when you change either.
Common mistakes
Trusting entity() blindly. The result may be shaped correctly and still wrong. Fix: add constraints and business checks, and a failure path.
JSON in a template. Unescaped braces break rendering. Fix: switch the delimiters with a custom StTemplateRenderer.
Passing the user id as a tool parameter. The model can change it. Fix: use ToolContext or the security context.
Registering write tools as defaults. Every request then carries the capability. Fix: attach risky tools per call, or do not expose them.
Returning a Mono or Optional from a tool. These types are not supported for tool parameters and results. Fix: return a plain object and use @Nullable for optional values.
Vague tool descriptions. The model cannot tell when to use the tool. Fix: say what it does, when to use it and whether it is read-only.
Streaming with validateSchema(). The reference says validation is not supported with streaming. Fix: use call() for validated output.
Putting sensitive data in thrown messages. By default a RuntimeException message is sent to the model. Fix: throw errors with safe, short messages, or customise the exception processor.
Key takeaways
A prompt is system, user, assistant and tool messages; keep stable rules in the system message and per-request data in templates.
entity() gives typed output but is best-effort: add useProviderStructuredOutput() where supported and validateSchema() for a bounded retry loop.
Validate in layers: shape, constraints, business rules, authorisation and review for risky results.
Tool calling is a loop run by your application; the model only proposes calls and never touches your systems.
Identity and tenant come from the server (ToolContext, security context), never from model arguments.
Keep tools narrow, read-only first, with call limits, and attach risky ones per request or not at all.
If you can fetch the data before the call, do that instead of using a tool.
Test tool methods as plain Java and use stubs for the model; keep a small live evaluation set.
Practice exercises
Starter (about 30 minutes). Create TicketTriage with the two enums and a reason field. Implement the triage method with a system message that lists the categories and returns entity(TicketTriage.class). Feed it five emails and note how the output varies at temperature 0.0 versus 0.8. Then write a stub-model test that returns a JSON string and asserts that your record is populated.
Intermediate (about 2 hours). Add Bean Validation to the record and a manualReview() fallback. Enable useProviderStructuredOutput() and validateSchema() on a provider that supports them and compare the number of failures over twenty crafted emails (include a few that are empty, in another language, or contain instructions such as "ignore the rules above"). Then implement OrderTools.getOrderStatus over an in-memory map, wire it with toolContext, and write the plain-Java tool test shown above.
Advanced (half a day). Add a second tool, createRefundDraft, that only creates a draft and never moves money, and attach it only when the authenticated user is a support agent. Disable the automatic loop for that endpoint with AdvisorParams.toolCallingAdvisorAutoRegister(false) and drive ToolCallingManager yourself so that a person approves each draft. Add a Micrometer timer around the loop, set a total call limit, and write down the three abuse cases you tested (prompt injection in an order note, a request for another customer's order, a loop of repeated calls) and the result of each.
Tool calling — @Tool, ToolContext, errors, limits and security notes.
Upgrade notes — renamed classes and changed defaults.
Enroll to keep reading
Lesson 3 of 7 · Spring AI: From Beginner to Expert
Write prompt templates, get typed Java records from Spring AI, let a model call your @Tool methods safely, and validate output before you trust it.
In the rest of this lesson
Part 2: structured output
Part 3: tool calling end to end
Three use cases
When not to give a model a tool
Trade-offs
In practice: an order assistant for a retail support team
Enrolling is free. It unlocks every lesson in Spring AI: From Beginner to Expert, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.