Skip to content

    Spring AI

    Spring AI Prompts, Structured Output and Tools

    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.

    By Published 15 min read

    Share

    This lesson is part of Spring AI: From Beginner to Expert. Enroll for free, then sign in, to read it in full.

    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:

    String answer = 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.

    How a ChatClient request is assembled before it leaves your serviceSystem text to ChatClient; User template to ChatClient; Parameters to ChatClient (fill); Options to ChatClient; ChatClient to Prompt (build); Prompt to ChatModelSystem text: rules, personaSystem textrules, personaUser template: {placeholders}User template{placeholders}Parameters: request dataParametersrequest dataChatClient: renders and mergesChatClientrenders andmergesOptions: model, temperatureOptionsmodel,temperaturePrompt: role-tagged messagesPromptrole-taggedmessagesChatModel: provider callChatModelprovider callfillbuild

    Templates

    Placeholders in {braces} are filled from param(...). The default renderer is StringTemplate-based (StTemplateRenderer).

    String reply = 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:

    1. 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:

      // import org.springframework.ai.template.st.StTemplateRenderer;
      .templateRenderer(StTemplateRenderer.builder()
          .startDelimiterToken('<').endDelimiterToken('>').build())
    2. 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(...):

      @Service
      class TriageService {
          private final ChatClient chatClient;
      
          TriageService(ChatClient.Builder builder,
                        @Value("classpath:/prompts/triage-system.st") Resource systemPrompt) {
              this.chatClient = builder.defaultSystem(systemPrompt).build();
          }
      }

      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.

    The broader craft of prompt design has its own lesson in Prompt engineering for the enterprise.

    Part 2: structured output

    From text to a Java type

    entity(...) asks the model for JSON that matches a Java type, then deserialises it:

    public enum Category { BILLING, TECHNICAL, ACCOUNT, OTHER }
    public enum Priority { LOW, NORMAL, HIGH }
    
    public record TicketTriage(Category category, Priority priority, String reason) { }
    
    TicketTriage triage = 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:

    List<TicketTriage> batch = chatClient.prompt()
        .user("Classify these emails: ...")
        .call()
        .entity(new ParameterizedTypeReference<List<TicketTriage>>() {});

    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.

    1. 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.
    2. 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.
    3. Your own validation after you have a typed object (next section).
    TicketTriage triage = 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;
    
    public record TicketTriage(
        @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;
    
    @Component
    public class OrderTools {
    
        private final OrderService orders;
    
        public OrderTools(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.
            String customerId = (String) toolContext.getContext().get("customerId");
            return orders.statusFor(customerId, orderId);
        }
    }
    
    // In its own file (a Java file holds one public top-level type).
    public record OrderStatus(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

    String answer = 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:

    @Test
    void statusIsScopedToTheCustomer() {
        OrderTools tools = new OrderTools(new FakeOrderService());
        ToolContext ctx = new ToolContext(Map.of("customerId", "cust-7"));
    
        OrderStatus status = tools.getOrderStatus("A-1042", ctx);
    
        assertThat(status.state()).isEqualTo("SHIPPED");
    }

    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.

    For a deeper walk-through of a single tool with the Anthropic starter, see Tool calling with Spring AI. For the difference between in-process tools and a shared protocol, see MCP vs function calling.

    Three use cases

    1. Support email triage (structured output, no tools). Input: an email. Output: TicketTriage. Controls: closed enums, low temperature, validateSchema(), a human queue on failure.
    2. 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.
    3. 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.

    1. Add the triage endpoint: entity(TicketTriage.class) with enums, temperature 0, validateSchema() and Bean Validation. Failures go to the existing manual queue.
    2. 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.
    3. 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.
    4. 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.
    5. 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.

    Official documentation

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments