In Part 1 we learned two ideas:
- Orchestration decides the next step. It's either a loop (the model decides) or a graph (your code decides).
- Agent = Model + Harness. The model is the brain; the harness is everything around it: orchestration, tools, memory, rules and logs.
Now let's see what that looks like in real Java code.
We'll build a tiny order-support agent with Spring AI, using the same example as before:
A customer asks: "Where's my order #123? If it's late, I want a refund."
The map: which code is the harness?
In Spring AI:
-
The model is the
ChatModel: Azure OpenAI, OpenAI, Anthropic or another provider. It only decides what to say, or which tool to ask for. It runs nothing itself. -
The harness is your Spring code:
ChatClient, your@Toolmethods, chat memory, advisors and the checks you write.
Let's build each piece.
Step 1: Tools (harness: the toolbox)
@Component
public class OrderTools {
@Tool(description = "Get the status of an order by its ID")
public String getOrderStatus(String orderId) {
return orderService.findStatus(orderId); // real DB/API call
}
@Tool(description = "Issue a refund for an order")
public String issueRefund(String orderId, double amount) {
if (amount > 100) { // guardrail
return "Refund needs manager approval.";
}
return refundService.refund(orderId, amount);
}
}
The model never runs this code. It can only ask for it, by name. The harness runs it and passes the result back.
The description matters: it's how the model knows when to use each tool. Treat it like documentation written for the model.
Step 2: Wire the harness together
@Configuration
public class AgentConfig {
@Bean
ChatClient supportAgent(ChatClient.Builder builder, OrderTools tools) {
ChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(20) // context management
.build();
return builder
.defaultSystem("You are a support agent. Be brief and polite.")
.defaultTools(tools) // register the toolbox
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(memory).build(), // memory
new SimpleLoggerAdvisor() // logs
)
.build();
}
}
Every line here is harness:
-
defaultSystem(...): the rules and instructions -
defaultTools(tools): the toolbox -
MessageWindowChatMemory: memory, capped at the last 20 messages so the context doesn't grow forever -
SimpleLoggerAdvisor: logs of each request and response
Step 3: The endpoint
@RestController
class SupportController {
private final ChatClient agent;
SupportController(ChatClient supportAgent) {
this.agent = supportAgent;
}
@PostMapping("/chat")
String chat(@RequestParam String sessionId, @RequestBody String message) {
return agent.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.call() // the loop runs inside here
.content();
}
}
The sessionId keeps each customer's conversation separate in memory.
Step 4: What actually happens inside .call()
1. Harness → sends prompt + tool list to the model
2. Model → "call getOrderStatus(123)"
3. Harness → runs getOrderStatus("123") → "Shipped, arriving today"
4. Harness → sends the result back to the model
5. Model → "Your order arrives today!" (no more tool calls)
6. Harness → no tool call, so the loop stops → returns the answer
Only steps 2 and 5 are the model. Everything else is the harness.
| Part of the app | Model or harness? |
|---|---|
The LLM (ChatModel) |
Model |
@Tool methods |
Harness: toolbox |
The loop inside .call()
|
Harness: orchestration |
MessageWindowChatMemory |
Harness: memory |
defaultSystem(...) |
Harness: instructions |
amount > 100 check |
Harness: rules |
SimpleLoggerAdvisor |
Harness: logs |
Step 5: Take control of the loop
Spring AI hides the loop inside .call(). You can switch that off and write it yourself, which makes the harness visible:
ChatOptions options = ToolCallingChatOptions.builder()
.toolCallbacks(ToolCallbacks.from(orderTools))
.internalToolExecutionEnabled(false) // "I'll run the loop myself"
.build();
Prompt prompt = new Prompt(userMessage, options);
ChatResponse response = chatModel.call(prompt);
while (response.hasToolCalls()) { // the loop
ToolExecutionResult result =
toolCallingManager.executeToolCalls(prompt, response); // run tools
prompt = new Prompt(result.conversationHistory(), options); // feed back
response = chatModel.call(prompt); // ask again
}
return response.getResult().getOutput().getText();
This while loop is exactly what .call() normally does for you. Owning it lets you add things like:
- a max-turns limit, so a confused model can't loop forever
- a cost cap
- an approval pause before a risky tool such as
issueRefund
Bonus: the same agent as a graph
Everything above is a loop: the model decides which tool to call. What if the refund decision must never be the model's?
Spring AI doesn't include a graph engine, but a simple graph is just plain Java where your code chooses the path and the model only writes text:
public String handle(String orderId) {
Order order = orderService.find(orderId); // node: plain code
if (!order.isLate()) { // edge: YOUR code decides
return chatClient.prompt()
.user("Write a short, friendly tracking update: " + order.tracking())
.call()
.content(); // node: LLM writes text
}
String offer = chatClient.prompt()
.user("Draft a polite refund offer for order " + orderId)
.call()
.content(); // node: LLM writes text
approvalService.requestApproval(orderId, offer); // human-in-the-loop
return "Your refund request is waiting for manager approval.";
}
No tools are registered here, so the model can't refund anything. It can only write the messages.
For bigger graphs with saved state, checkpoints and pause/resume, use a graph library such as LangGraph4j, or a workflow engine you already trust.
Cheat sheet
-
ChatModelis the model. Everything else you write is the harness. -
.call()runs the tool loop for you. Turn it off withinternalToolExecutionEnabled(false)to own the loop. -
@Toolmethods are the toolbox. The model asks; your code runs them. - Memory, system prompt, advisors and checks in your tools are the rest of the harness.
-
Need a graph? Plain Java
if/elsefor small flows; a graph library for big ones.
Next in this series: who builds the harness when you build agents on Azure: Copilot Studio vs Foundry Agent Service vs your own code.
What does your Spring AI harness look like? Let me know in the comments 👇
Code & Learn


Top comments (0)