Class 4: Safety: Approval Gates, Budgets, and the Server's Last Word
Duration: ~55 minutes | Difficulty: Advanced | Prerequisites: Class 2 completed, with its allow-listed catalog; Ollama with a tool-calling model for the agent runs.
This lesson carries on from Class 2. If you followed along, keep working on the project you already have. If you skipped it, clone the agents_class_2 branch to start from the same place:
git clone --branch agents_class_2 https://github.com/graphqlguy/graphQLBeyondMCP.git
Code blocks are titled with the file they belong to, and the note in brackets says what to do with it. (new file) is a file to create. (edited, whole file) replaces a file you already have. Anything else names the one part to add or replace, as in (edited, the call method). Where a block shows part of a new file, the complete file follows in a collapsed block underneath.
Class 2 decided what an agent may see. This lesson decides what an agent may do, and where those decisions are enforced. By the end of the lesson, we will run a complete agent task against the live Movie Database service:
- The application logs in as a real user.
- A local model reads the watchlists to find the right one.
- The model proposes a write, and the write waits for your yes at the console.
- After your yes, the service makes the change.
The whole run stays within hard ceilings on how many calls it may make.
We will also see the lower layers catch what the upper layers miss, three times. While we built this lesson, the agent ran into three problems:
- a serialization bug in the service
- a model that sent its arguments in the wrong shape
- a write that a human approved and the server still refused as invalid
Each time, a lower layer caught the problem before it did any harm. Safety engineering means placing those layers so that surprises like these stay harmless.
What We Build, and How to Run It
Class 2 ended with a catalog of seven tools and a call command that executes one of them directly, without a model. This lesson adds the four pieces that sit between a model and that catalog, and then runs them together:
| What | Where it lives | What it stops |
|---|---|---|
| A real login | AuthSession, one call at startup | The model choosing which user it acts as |
| An approval gate | ApprovalGate, called from the tool callback | A write executing before a human has seen it |
| A run budget | RunBudget, counted in the loop and the callback | A stuck agent spending without limit |
| The agent loop | AgentRunner, driving the model and the tools | Nothing by itself: it is what the three controls attach to |
By the end, one command runs all four together:
mvn clean spring-boot:run
Choose agent, then answer the prompt:
add The Matrix to my To watch this weekend watchlist
Started without arguments, the application lists every command this build has and waits for a number or a name. It then asks for whatever that command needs:
Movie Database agents. Choose a command by number or name:
1 catalog generate and print the tool catalog, optionally for one role
2 call execute one generated tool against the endpoint, with no model involved
3 agent run the agent loop: login, approval gate, and run budget
4 probe-depth send a pathological query straight to the server and read its refusal
> 3
add The Matrix to my To watch this weekend watchlist
Two things have to be running before that works. The first is the Movie Database service on port 8081, which we started in Class 2 and left running. The second is Ollama on its default port 11434, with the model named in agents.model pulled (ollama pull qwen3:8b). Every transcript in this lesson is output from that command or from another command in the same menu.
This lesson uses two low-level pieces of Spring AI. The ChatModel interface offers one method for calling a model, and ToolCallingManager executes the tool calls that a model asked for. We write the loop around them by hand in AgentRunner, and we write every control in the table above ourselves.
In Class 7, the framework takes over that loop. ChatClient and its advisor chain then own the conversation, the retries and the tool execution. That lesson asks where our controls go once we no longer own the loop. Classes 5 and 6 ask the same question about LangChain4j and LangGraph4j. Because we write the loop by hand once here, those three lessons have a baseline to compare against.
The Threat Model, Stated Plainly
An agent is an untrusted actor that works inside your authorization boundary. Each half of that sentence has a reason:
- Inside your authorization boundary. The agent acts with a real user's credentials, so the server cannot tell the agent's requests from that user's own.
- Untrusted. A statistical model chooses the sequence of actions by reading text, and someone else can write some of that text: user messages, tool results and retrieved content.
We can trust the model's intentions and still withhold the access that would make a mistake expensive. A team does the same for a capable new colleague who does not yet know which actions are hard to undo.
One property of agents shapes this whole lesson: an agent keeps calling and keeps exploring. A developer who integrates with an API tries a few queries, keeps the ones that work, and then reuses them. An agent explores every path its tools allow and retries after every failure, at machine speed. This lesson assumes that behavior and designs around it. The first thing the agent found in our service was a bug that every hand-written query had missed.
The design that follows has three layers, in a strict order: a prompt is a request, the tool layer is a guarantee, and the server has the last word.
- Instructions in the system prompt shape the model's behavior. They guarantee only as much as a persuasive paragraph can.
- Controls in the tool callback, the code that actually performs each action, hold whatever the model believes.
- The server's own validation, authorization and caps hold even if the whole agent process is compromised, because they hold against any client.
One control from Class 2 belongs in this picture before we add anything new. Every tool in the agent's catalog executes a persisted document that a person reviewed. So the only operations that can ever run are the ones a human approved in advance. An instruction injected into the prompt has to work with that set of operations, and it cannot add to it. That is the strongest kind of safety feature: it removes the attack instead of catching it.
Identity: The Model Never Chooses the User
The first new control is authentication, and its design rule matters more than its code. The user the agent acts as comes from a real login, never from anything the model controls.
In our project, the credentials come from configuration. agents.auth.username and agents.auth.password in application.yaml name the seeded user account, and the agent command reads them when it starts. AuthSession.login then sends the service's own login mutation, as a browser would, and keeps the returned JWT in memory for that one run. Every later tool call sends that token in the Authorization header.
Credentials in a configuration file suit only a course companion. In a real deployment, the token already exists. The person signed in to your web application, or to the MCP host, has a session, and the agent runs with that session's token. The code is different and the rule is the same, because in both cases the token reaches the tool layer from outside the conversation, where the model cannot reach it. This code does not refresh the token either. A long-running agent needs to renew an expiring token, and whatever manages the user's session should do that.
This lesson does the login itself, so we can see the whole path in one application:
package com.graphqlguy.moviedb.agents.auth;
import org.springframework.web.client.RestClient;
import java.util.Map;
/**
* The agent acts on behalf of a user, and the user's identity comes from a real
* login, never from an argument the model controls. This session logs in through
* the service's own login mutation and holds the JWT; every tool call carries it.
* <p>
* The principle behind the plumbing: if the model could choose the user (a userId
* argument, a name in the prompt), a confused or manipulated agent could act as
* anyone. Because identity rides in the Authorization header and the server scopes
* every watchlist operation to the authenticated user, a confused agent can only
* affect the data of the one user it is already signed in as.
*/
public class AuthSession {
private static final String LOGIN_DOCUMENT =
"mutation Login($input: LoginInput!) { login(input: $input) { token } }";
private final String token;
private AuthSession(String token) {
this.token = token;
}
public static AuthSession login(String endpoint, String username, String password) {
Map<?, ?> response = RestClient.create()
.post()
.uri(endpoint)
.header("Content-Type", "application/json")
.body(Map.of(
"query", LOGIN_DOCUMENT,
"variables", Map.of("input", Map.of(
"username", username, "password", password))))
.retrieve()
.body(Map.class);
Object data = response == null ? null : response.get("data");
if (data instanceof Map<?, ?> dataMap
&& dataMap.get("login") instanceof Map<?, ?> login
&& login.get("token") instanceof String jwt) {
return new AuthSession(jwt);
}
throw new IllegalStateException(
"Login as '" + username + "' failed; the service answered: " + response);
}
/** No credentials configured: tools run anonymously, and protected ones will be refused by the server. */
public static AuthSession anonymous() {
return new AuthSession(null);
}
public String bearerOrNull() {
return token == null ? null : "Bearer " + token;
}
}
The login document is a constant here instead of a generated tool. The application logs in once, on its own behalf, before any agent exists. Because the document stays out of the catalog, the model cannot find a tool named login. The anonymous() factory is the fallback when no credentials are configured: tools still run, and the server refuses the protected ones.
Consider what the rule forbids: an addWatchListItem tool with a userId argument. The model fills in arguments, so a confused or manipulated model could fill in any user, and the tool would act on that user's data. In our design, the identity travels in the header, and the server limits every watchlist operation to the authenticated user. The Movie Database service already does this, from the authorization work in the Spring GraphQL tutorial. The model then chooses only which movie to add. At worst, a confused agent puts the wrong movie on the signed-in user's own watchlist. Only the application sets the header, so every failure in this lesson stays inside the one signed-in account.
The service enforces that limit with a refusal. A caller who does not own the watchlist gets an authorization error, for reads and for writes. For an agent, the point is that the refusal is the same one a browser would receive. The API answers a model exactly as it answers any other caller.
Check It Works: The Login
We can test the identity change before the gate or the budget exists. Start the application and call one protected read directly, without a model:
mvn clean spring-boot:run
Choose call, then answer the prompt:
tool name and JSON arguments, e.g. movie {"id":"1"}: myWatchLists {}
With agents.auth.username set, the answer holds the two watch lists that the seeded user owns. Clear the username in application.yaml and run the same command again. This time the same operation returns an authorization error, because the anonymous session does not send a token. Only the configuration changed between the two runs, and that difference is the identity rule at work.
The Approval Gate: A Pause Where Execution Happens
Reads run without a pause, and writes wait for a human. The layer order from the threat model decides where that pause goes. It goes in the callback, around the code that performs the write, so it holds whichever loop, framework or model is driving.
The gate itself is a small class. Its most important design decision is where it sits, and its Javadoc explains that decision:
package com.graphqlguy.moviedb.agents.safety;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import java.util.Scanner;
/**
* The human-in-the-loop checkpoint for writes. Reads are automatic; a mutation
* pauses the run, shows the human exactly what would execute (the tool, the
* persisted operation, and the model's arguments), and waits for a yes.
* <p>
* Where this gate sits matters as much as what it does. It lives at the tool
* layer, wrapped around the callback that performs the write, so it holds no
* matter which loop, framework, or model is driving. A gate implemented in the
* prompt ("always ask before writing") is a request to the model; a gate
* implemented at the execution layer is a property of the system.
*/
public class ApprovalGate {
private final Scanner input;
/**
* The reader belongs to the session, not to the gate: the application keeps one
* Scanner over System.in, because a second one would buffer away input the first
* is waiting for.
*/
public ApprovalGate(Scanner input) {
this.input = input;
}
public boolean approve(OperationTool tool, String jsonArguments) {
System.out.println();
System.out.println("APPROVAL REQUIRED: the agent wants to run a write.");
System.out.println(" tool : " + tool.name());
System.out.println(" operation : " + tool.operationDocument());
System.out.println(" arguments : " + jsonArguments);
System.out.print("Execute this mutation? [y/N] ");
String answer = input.hasNextLine() ? input.nextLine().strip() : "";
boolean approved = answer.equalsIgnoreCase("y") || answer.equalsIgnoreCase("yes");
if (approved) {
System.out.println(" approved by the human.");
} else if (answer.isEmpty()) {
// Anything other than yes declines, and a blank line is the default. Saying
// which one happened matters: a newline left over from an earlier prompt
// reads exactly like a deliberate Enter, and the run looks refused.
System.out.println(" no answer given, so the write was declined.");
} else {
System.out.println(" DENIED by the human.");
}
return approved;
}
}
The console Scanner is deliberately simple. The gate's contract is a yes-or-no answer to a fully described question. Replacing the console with a Slack approval or a web form changes only where the answer comes from. A blank answer also declines the write, and the gate prints a separate message for it. A newline left over from an earlier prompt looks exactly like a deliberate Enter, and the separate message shows which one happened.
The callback needs two facts about each tool before it can enforce anything. Both come from the schema, so OperationTool gets two new components next to the six that Class 2 gave it:
public record OperationTool(String name, String description, Map<String, Object> inputSchema,
String operationDocument, List<String> roles, int tokenCount,
boolean mutation, // writes pass through the approval gate
String singleObjectArgument // the lone input-object argument's name, or null
) {
}
The generator fills in both while it walks the root types, because both are properties of the schema. mutation is true when the field came from the Mutation root. singleObjectArgument holds the argument's name when the operation takes exactly one argument whose type is an input object, and null otherwise. Neither value is a policy decision, which is why both belong on the generated tool instead of in the callback's configuration.
So we edit the loop that builds each tool, inside ToolCatalogGenerator.addRoot. The last statement of the loop gets two more arguments:
String singleObjectArgument = null;
if (field.getArguments().size() == 1) {
GraphQLArgument only = field.getArguments().get(0);
if (JsonSchemaMapper.unwrapNonNull(only.getType()) instanceof GraphQLInputObjectType) {
singleObjectArgument = only.getName();
}
}
tools.add(new OperationTool(field.getName(), description, inputSchema,
document, allowList.rolesFor(field.getName()), tokens,
"mutation".equals(operationKind), singleObjectArgument));
The callback enforces the contract. This lesson turns the small Class 2 version into the layer that holds all three controls:
@Override
public @NonNull String call(@NonNull String toolInput) {
if (budget != null) {
budget.countToolCall();
}
if (tool.mutation() && approvalGate != null && !approvalGate.approve(tool, toolInput)) {
return "{\"denied\": \"The human declined this write. Do not retry it;"
+ " explain the situation and ask what they would like instead.\"}";
}
Map<?, ?> variables = json.readValue(toolInput, Map.class);
// Small models often flatten a mutation's single input object, sending its
// fields as top-level arguments. The schema knows the intended shape, so the
// repair is lossless: when the expected key is absent, wrap what arrived.
if (tool.singleObjectArgument() != null
&& !variables.containsKey(tool.singleObjectArgument())) {
variables = Map.of(tool.singleObjectArgument(), variables);
}
Map<String, Object> body = Map.of(
"query", tool.operationDocument(),
"variables", variables);
RestClient.RequestBodySpec request = RestClient.create()
.post()
.uri(endpoint)
.header("Content-Type", "application/json");
if (auth.bearerOrNull() != null) {
request = request.header(HttpHeaders.AUTHORIZATION, auth.bearerOrNull());
}
String response = request.body(body).retrieve().body(String.class);
return response == null ? "" : response;
}
The order of those three steps is deliberate:
Counting comes first, so a run that is already over its ceiling stops before anyone is asked anything. Authentication comes last because it belongs to the transport: when the token is attached, the decision to run this call is already made.
The Argument Repair
The method does one more thing besides those three controls. Small models often flatten a mutation's single input object. They send {"titleId": "1"} where the schema expects {"input": {"titleId": "1"}}, and the server rejects the call before it executes. The argument repair is the four lines between the gate and the request. When a tool has exactly one input-object argument and the model left out that key, the callback nests the fields that arrived under it.
This lesson otherwise argues against that kind of leniency, so here is why it is safe in this case. The schema says that the operation takes exactly one input-object argument. A missing key therefore leaves only one legal shape for the fields that arrived, and every value in the repaired arguments came from the model. A repair that had to choose between two possible shapes would belong somewhere else.
Approving and Denying a Write
Before it asks, the gate shows the human three things, because a person can only approve what they can see:
- the tool's name
- the exact persisted operation
- the model's arguments
When the human declines, the model receives a structured answer with instructions for what to do next, instead of an exception.
Here is a real denied run: the same task as before, with N at the approval prompt.
APPROVAL REQUIRED: the agent wants to run a write.
tool : addWatchListItem
operation : mutation Tool_addWatchListItem($input: AddWatchListItemInput!) { ... }
arguments : {"input":{"titleId":"1","titleType":"MOVIE","watchListId":"1"}}
Execute this mutation? [y/N] DENIED by the human.
result: {"denied": "The human declined this write. Do not retry it; explain the
situation and ask what they would like instead."}
answer: The system denied adding the movie to your watchlist. ... Could you clarify
if you still want to add this movie, adjust the watchlist settings, or choose a
different action?
The model accepted the refusal, explained it, and handed the decision back to the user, as the denial message asked. That works because the refusal is a message the model can read. An exception would end the run, while a readable denial lets the model ask a better question.
Spring AI has its own place for this pattern, which is where the gate goes in production. Since 2.0, the framework's tool-execution loop lives in a ToolCallingAdvisor. Its documentation names the approval gate ("pause before executing a destructive tool and wait for human confirmation") as a reason to subclass it and override its doBeforeCall hook. The console gate in this lesson is the same idea, with all of its code in view. When the course reaches the framework-driven loop, the gate moves into the advisor and keeps its meaning.
Budgets: The Control That Survives Bad Judgment
Every control so far assumes that the agent does something wrong. The run budget only assumes that the agent does too much. So it still works when judgment fails higher up, whether the model's judgment fails or ours.
The agent we are building enforces two ceilings per run:
- The loop limits model calls. A stuck agent typically tries once more, then once more again, and keeps going.
- The callback limits tool calls, which limits how many requests one run can send to the API.
Each agent run prints this accounting at the end. The closing run in this lesson reports 4 model calls (ceiling 8), 3 tool calls (ceiling 6). A Movie Database concierge that needs ten model calls has already gone wrong. The useful response is to stop and say so, because more attempts cost money and do not make success more likely.
package com.graphqlguy.moviedb.agents.safety;
/**
* Hard ceilings for one agent run. A budget is the safety control that survives
* every failure of judgment upstream: whatever the model believes, however the
* prompt was manipulated, the loop stops when the numbers say stop.
* <p>
* Two ceilings cover the two ways runs go bad: too many model calls (the agent
* loops on a problem it cannot solve) and too many tool calls (the agent keeps
* calling the API without making progress). Spring AI's own ToolCallingManager enforces a related pair
* (40 calls per tool, 150 in total per turn, added in 2.0.1); the run budget here is
* deliberately far tighter, because a Movie Database concierge that needs ten
* model calls has already gone wrong.
*/
public class RunBudget {
private final int maxModelCalls;
private final int maxToolCalls;
private int modelCalls;
private int toolCalls;
public RunBudget(int maxModelCalls, int maxToolCalls) {
this.maxModelCalls = maxModelCalls;
this.maxToolCalls = maxToolCalls;
}
/** Returns false when the budget is spent; the loop must stop, whatever the model wants. */
public boolean allowModelCall() {
modelCalls++;
return modelCalls <= maxModelCalls;
}
public void countToolCall() {
toolCalls++;
if (toolCalls > maxToolCalls) {
throw new IllegalStateException(
"Run budget exceeded: " + toolCalls + " tool calls against a ceiling of "
+ maxToolCalls + ". The loop stops here by design.");
}
}
/** The ceiling this run was given, for frameworks that enforce it themselves. */
public int maxModelCalls() {
return maxModelCalls;
}
/** The tool-call ceiling, likewise. */
public int maxToolCalls() {
return maxToolCalls;
}
public String summary() {
return modelCalls + " model calls (ceiling " + maxModelCalls + "), "
+ toolCalls + " tool calls (ceiling " + maxToolCalls + ")";
}
}
The two ceilings stop a run in different ways on purpose. allowModelCall returns a boolean, so the loop can stop cleanly and report what the run finished before it reached the ceiling. countToolCall throws an exception, because it runs deep inside a callback, where an exception is the only way to stop the run.
Spring AI applies the same idea with much larger numbers. Its DefaultToolCallingManager has its own ceilings, 40 calls per tool and 150 in total per turn, and our project gets them from the 2.0.1 BOM it pins. Those numbers exist to stop a runaway loop, and a reasonable run of your application stays far below them, so set your own ceilings much lower. Class 10 covers the fuller cost picture, with budgets measured in money and charged to each tenant. The run budget here is a first, simple version of that.
The Runner That Ties Them Together
Login, gate and budget are three separate objects, and a loop that we write by hand connects them. The loop calls the model, executes the tools the model asks for, and sends the results back. It repeats those steps until the model answers or the budget stops it. Classes 5, 6 and 7 hand this same loop to LangChain4j, LangGraph4j and Spring AI in turn. Writing it once in plain Java gives those three lessons a baseline to compare against.
The loop needs a model to call, so our project gets the Ollama starter:
<!-- The local chat model driving the agent command (any tool-calling model Ollama serves) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
That starter brings in reactor-netty, which includes Netty's native macOS DNS resolver only in its Intel build. On an Apple-silicon Mac, that binary fails to load. Netty then logs an UnsatisfiedLinkError at startup and falls back to the JDK resolver. A Maven profile adds the matching aarch64 binary. The profile activates only on an Apple-silicon Mac, so every other platform resolves the same dependencies as before:
<profiles>
<!-- reactor-netty already ships Netty's native macOS DNS resolver, but only the
Intel build (osx-x86_64), which cannot load on Apple silicon: Netty then logs
an ERROR at startup and falls back to the JDK resolver. This profile adds the
matching aarch64 binary, and it activates only on an Apple-silicon Mac, so
Intel Macs, Linux and Windows resolve exactly what they resolved before.
The version comes from Spring Boot's Netty BOM. -->
<profile>
<id>macos-apple-silicon</id>
<activation>
<os><family>mac</family><arch>aarch64</arch></os>
</activation>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-resolver-dns-native-macos</artifactId>
<classifier>osx-aarch_64</classifier>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
</profiles>
Everything the run needs to know comes from configuration, so we can change the identity, the ceilings or the model without editing any Java:
# The identity the agent acts as: a real login against the service's own
# mutation. The seeded `user` account owns the two private demo watch lists,
# so myWatchLists is a genuine lookup with more than one answer. Blank
# credentials mean anonymous, and protected operations will be refused.
auth:
username: user
password: user123
# Which slice of the catalog the agent sees.
role: concierge
# Hard ceilings for one run; the loop stops when they are reached.
budget:
max-model-calls: 8
max-tool-calls: 6
# The local chat model for the agent command.
model: qwen3:8b
spring.ai.ollama.base-url: http://localhost:11434
The runner itself is one class. Read the while condition first, because the budget decides whether another model call happens:
The complete AgentRunner.java, collapsed because it runs to 93 lines
package com.graphqlguy.moviedb.agents.agent;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionResult;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.ai.tool.ToolCallback;
import java.util.ArrayList;
import java.util.List;
/**
* The agent loop, driven by hand as in the schema-navigation lesson, with one
* addition: the run budget decides when the loop ends. The model proposes tool
* calls, the tools run them, and the budget stops the run at its ceiling. When the
* ceiling is reached mid-task, the loop ends with a plain statement. A stuck agent
* tends to repeat the same attempt, and every extra model call costs tokens.
*/
public class AgentRunner {
private static final String SYSTEM = """
You are a concierge for the Movie Database. Use the tools to answer and to act;
never invent ids, and never claim an action succeeded without a tool result
proving it. Writes may require the human's approval; if a write is denied,
accept the decision, explain it, and ask what they would like instead.
When you need an id (a watchlist, a movie), find it with a read tool first.""";
private final ChatModel chatModel;
private final String modelName;
private final ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
/**
* The conversation is kept between calls to {@link #run}, so a reply continues
* where the last answer stopped. Small models often answer a task with a
* question, and the kept conversation lets the user answer it.
*/
private final List<Message> conversation = new ArrayList<>();
public AgentRunner(ChatModel chatModel, String modelName) {
this.chatModel = chatModel;
this.modelName = modelName;
}
public void run(String task, List<ToolCallback> tools, RunBudget budget) {
ToolCallingChatOptions options = OllamaChatOptions.builder()
.model(modelName)
.toolCallbacks(tools)
.build();
if (conversation.isEmpty()) {
conversation.add(new SystemMessage(SYSTEM));
}
conversation.add(new UserMessage(task));
String finalAnswer = null;
while (budget.allowModelCall()) {
Prompt prompt = new Prompt(conversation, options);
ChatResponse response = chatModel.call(prompt);
Generation generation = response.getResult();
if (generation == null) {
break; // the provider returned an empty response; there is nothing to act on
}
if (response.hasToolCalls()) {
generation.getOutput().getToolCalls().forEach(call ->
System.out.println("model asks for: " + call.name()));
ToolExecutionResult executed =
toolCallingManager.executeToolCalls(prompt, response);
conversation.clear();
conversation.addAll(executed.conversationHistory());
if (!conversation.isEmpty()
&& conversation.get(conversation.size() - 1)
instanceof ToolResponseMessage toolResponses) {
toolResponses.getResponses().forEach(toolResponse -> {
String data = toolResponse.responseData();
System.out.println(" result: " + (data.length() > 220
? data.substring(0, 220) + "..." : data));
});
}
} else {
finalAnswer = generation.getOutput().getText();
conversation.add(generation.getOutput()); // keep the answer for the next turn
break;
}
}
System.out.println();
if (finalAnswer == null) {
System.out.println("The run budget stopped the loop before a final answer: "
+ budget.summary());
} else {
System.out.println("answer: " + finalAnswer);
System.out.println("budget: " + budget.summary());
}
}
}
A task that names a film is the realistic case, because people know films by their titles. Such a task also shows why the runner keeps the conversation. Given an ambiguous request, a small model does the sensible thing: it searches first, and then it asks a question.
task : add matrix to my watchlist
model asks for: search
result: {"data":{"search":[{"__typename":"Movie","id":"42","title":"The Matrix",...
model asks for: myWatchLists
result: {"data":{"myWatchLists":[{"id":"1","name":"To watch this weekend",...
answer: Which movie would you like to add to your watchlist? There are multiple titles
matching "matrix" in your search results. Also, please specify which watchlist.
budget: 3 model calls (ceiling 8), 2 tool calls (ceiling 6)
reply, or blank to return to the menu: The Matrix 1999 to the To watch this weekend list
model asks for: myWatchLists
model asks for: addWatchListItem
APPROVAL REQUIRED: the agent wants to run a write.
arguments : {"input":{"titleId":"42","titleType":"MOVIE","watchListId":"1"}}
Execute this mutation? [y/N] approved by the human.
answer: "The Matrix" (1999) has been successfully added to your To watch this weekend list!
budget: 5 model calls (ceiling 8), 3 tool calls (ceiling 6)
Look at the two budget lines. The ceilings cover the whole exchange, so the count carries on across turns: three model calls before the question, and five after the reply. For spending, a conversation counts as one run. That matches the real cost, because the second turn sends the model everything the first turn added to the context.
Two details in the loop matter. First, ToolCallingManager executes the calls that the model asked for and returns the whole conversation, with the tool results in it. That is why the next iteration can send conversation again. Second, the loop prints what happens, so the transcripts in this lesson are the application's own output. Every model asks for: line in them comes from that forEach.
The agent command assembles the pieces in the order the controls need them, and hands the finished list of callbacks to the runner. Before we write the command, CommandContext needs five new methods, and the block below shows four of them. Each method turns configuration into one of this lesson's objects, so a command does not need to know where the username or the ceilings came from:
/** The identity the run acts as. A real login, never anything the model chose. */
public AuthSession login() {
return username().isBlank()
? AuthSession.anonymous()
: AuthSession.login(endpoint, username(),
env.getProperty("agents.auth.password", ""));
}
/** The ceilings this run may spend, from configuration. */
public RunBudget budget() {
return new RunBudget(
env.getProperty("agents.budget.max-model-calls", Integer.class, 8),
env.getProperty("agents.budget.max-tool-calls", Integer.class, 6));
}
/** Each generated tool wrapped in the callback carrying login, gate and budget. */
public List<ToolCallback> callbacks(List<OperationTool> tools,
AuthSession auth, ApprovalGate gate, RunBudget budget) {
return tools.stream()
.map(tool -> (ToolCallback) new GraphQlToolCallback(tool, endpoint, auth, gate, budget))
.toList();
}
/** The chat model name this run should use. */
public String modelName() {
return env.getProperty("agents.model", "qwen3:8b");
}
The complete CommandContext.java, with the additions above in place
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.auth.AuthSession;
import com.graphqlguy.moviedb.agents.catalog.GraphQlToolCallback;
import com.graphqlguy.moviedb.agents.safety.ApprovalGate;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import com.graphqlguy.moviedb.agents.toolgen.AllowList;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import com.graphqlguy.moviedb.agents.toolgen.ToolCatalogGenerator;
import graphql.schema.GraphQLSchema;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.core.env.Environment;
import java.util.List;
import java.util.Scanner;
/**
* Everything a command needs, built once at startup: the parsed schema, the
* allow-list, the generator, the endpoint tools execute against, the environment,
* and one console reader. The reader is shared deliberately: two Scanners on
* System.in each buffer what they read, so the second one loses input the first
* already pulled in.
*/
public record CommandContext(Environment env,
GraphQLSchema schema,
AllowList allowList,
ToolCatalogGenerator generator,
String schemaPath,
String endpoint,
Scanner console) {
/** The catalog for one role, or every allow-listed operation when role is null. */
public List<OperationTool> tools(String role) {
return generator.generate(schema, allowList, role);
}
/** The role this run advertises, from configuration. */
public String role() {
return env.getProperty("agents.role", "concierge");
}
/** The configured username, blank when the run is anonymous. */
public String username() {
return env.getProperty("agents.auth.username", "");
}
/**
* Class 4: the identity the run acts as. A real login against the service's own
* mutation, never anything the model chose.
*/
public AuthSession login() {
return username().isBlank()
? AuthSession.anonymous()
: AuthSession.login(endpoint, username(),
env.getProperty("agents.auth.password", ""));
}
/** Class 4: the ceilings this run may spend, from configuration. */
public RunBudget budget() {
return new RunBudget(
env.getProperty("agents.budget.max-model-calls", Integer.class, 8),
env.getProperty("agents.budget.max-tool-calls", Integer.class, 6));
}
/** Class 4: each generated tool wrapped in the callback carrying login, gate and budget. */
public List<ToolCallback> callbacks(List<OperationTool> tools,
AuthSession auth, ApprovalGate gate, RunBudget budget) {
return tools.stream()
.map(tool -> (ToolCallback) new GraphQlToolCallback(tool, endpoint, auth, gate, budget))
.toList();
}
/** The chat model name this run should use. */
public String modelName() {
return env.getProperty("agents.model", "qwen3:8b");
}
}
username() is the fifth new method, a one-line read of agents.auth.username that returns blank for an anonymous run. The complete file above includes it. Later lessons call these same methods, which is why they live on the context instead of in each command.
The complete RunAgentCommand.java, collapsed because it runs to 70 lines
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.agent.AgentRunner;
import com.graphqlguy.moviedb.agents.auth.AuthSession;
import com.graphqlguy.moviedb.agents.safety.ApprovalGate;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Class 4: the whole loop with every control attached. The login happens once,
* before any tool exists; the gate and the budget are handed to every callback,
* so all three apply to the catalog by construction.
*/
@Component
public class RunAgentCommand implements AgentCommand {
private final ObjectProvider<ChatModel> chatModels;
public RunAgentCommand(ObjectProvider<ChatModel> chatModels) {
this.chatModels = chatModels;
}
@Override
public String name() {
return "agent";
}
@Override
public int order() {
return 40;
}
@Override
public String summary() {
return "run the agent loop: login, approval gate, and run budget";
}
@Override
public String prompt() {
return "task, e.g. add The Matrix to my To watch this weekend watchlist";
}
@Override
public void run(List<String> args, CommandContext context) {
String task = String.join(" ", args);
ChatModel chatModel = chatModels.getIfAvailable();
if (task.isBlank() || chatModel == null) {
System.out.println(task.isBlank()
? "agent needs a task, e.g.: agent add movie 1 to my watchlist"
: "No chat model configured.");
return;
}
String role = context.role();
AuthSession auth = context.login();
RunBudget budget = context.budget();
ApprovalGate gate = new ApprovalGate(context.console());
List<ToolCallback> tools = context.callbacks(context.tools(role), auth, gate, budget);
System.out.println("role : " + role + " (" + tools.size() + " tools)");
System.out.println("user : " + (context.username().isBlank()
? "(anonymous)" : context.username()));
System.out.println("task : " + task);
System.out.println();
// One runner for the whole exchange: a model that answers with a question
// gets an answer back, and the budget keeps counting across the turns.
AgentRunner runner = new AgentRunner(chatModel, context.modelName());
String turn = task;
while (!turn.isBlank()) {
runner.run(turn, tools, budget);
System.out.println();
System.out.print("reply, or blank to return to the menu: ");
turn = context.console().hasNextLine() ? context.console().nextLine().strip() : "";
System.out.println();
}
}
}
The login happens once, before any tool exists, which is the rule from the start of this lesson written as code. Every callback then holds the same AuthSession, the same gate and the same budget, so the three controls cover every tool in the catalog. To reach the model, the command needs one more parameter than the commands from Class 2: ObjectProvider<ChatModel> chatModels. It returns null when the application context does not contain a ChatModel bean, so the command can print a message instead of failing with a bean error.
Check It Works: The Whole Loop
Everything in this lesson is now wired, so run a task that has to read before it writes:
mvn clean spring-boot:run
Choose agent, then give it the task:
add The Matrix to my To watch this weekend watchlist
The Movie Database service must be running on port 8081 and Ollama on 11434, with the model named in agents.model pulled. Expect four things, in this order:
- The
role,userandtaskheader. - One or more
model asks for:lines, as the agent locates the watchlist. - The approval prompt, waiting for your
y. - A
budget:line at the end.
Answer N at the prompt to see the denial path. Approving and Denying a Write shows a real denied run, and the end of this lesson shows a real approved run.
The Server's Last Word
Every layer above can fail:
- someone can manipulate the prompt
- the tool layer is code, and we can get it wrong
- someone can configure the budget with absurd numbers
The server is the layer that remains, and a second command shows that it holds. The command builds a deliberately pathological query, twenty levels of the schema's movie → cast → movie cycle. It sends that query straight to the endpoint, without an agent, an allow-list or a callback in between:
package com.graphqlguy.moviedb.agents.command;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
/**
* Class 4: the server's own depth cap, shown with a query sent straight to the
* server, without the agent, the allow-list or a callback. Whatever the layers
* above allow, the server enforces this cap.
*/
@Component
public class ProbeDepthCommand implements AgentCommand {
@Override
public String name() {
return "probe-depth";
}
@Override
public int order() {
return 41;
}
@Override
public String summary() {
return "send a pathological query straight to the server and read its refusal";
}
@Override
public void run(List<String> args, CommandContext context) {
// Movie -> cast -> movie -> cast ... : the schema's real cycle,
// nested far deeper than a real query needs.
StringBuilder query = new StringBuilder("query { movie(id: \"1\")");
int pairs = 9;
for (int i = 0; i < pairs; i++) {
query.append(" { cast { movie");
}
query.append(" { title ").append("} ".repeat(pairs * 2 + 2));
String pathological = query.toString();
System.out.println("query : " + pathological);
System.out.println();
System.out.println(RestClient.create().post().uri(context.endpoint())
.header("Content-Type", "application/json")
.body(Map.of("query", pathological))
.retrieve().body(String.class));
}
}
Run it, and the server answers:
mvn clean spring-boot:run
Choose probe-depth from the menu:
{"errors":[{"message":"Query depth 20 exceeds maximum allowed depth of 15",
"extensions":{"classification":"ExecutionAborted"}}]}
That refusal comes from the QueryDepthInstrumentation that the Spring GraphQL tutorial built into the service, and every client meets it, whether it is an agent or not. The ceiling of 15 is set in the service's application.yaml, and the number has a reason. The standard introspection query is itself 13 levels deep. A much tighter cap would break GraphiQL's documentation pane and every code generation tool in the build.
Complexity caps belong in the same layer, and this service does not have them yet. A depth limit counts how deeply a query nests. A complexity cap counts how much a query asks for in total, so it also catches a shallow query that requests hundreds of fields at one level. The production lesson of our Spring GraphQL course covers complexity caps. An agent-facing deployment should treat them as required, because a generated selection asks for every scalar field of the type it returns.
The server also had the last word in a run we did not plan, while we built this lesson. The model flattened a mutation's arguments, the human at the gate approved the call, and the server refused it: Variable 'input' has an invalid value. Both answers were right, because the gate and the server judge different things. The gate asked the human whether they wanted this movie on this watchlist, and they did. Whether the arguments matched the schema is a separate question, and the server answers it. Approval is consent to an action, and the server still decides whether the request is well-formed and permitted.
What the First Runs Actually Found
We built this lesson against the live service, and the first runs failed. The project's git history keeps those runs, because each one teaches something about a control.
A Real Server Bug, Found in Two Tool Calls
The tool for myWatchLists selects every scalar field of its type, so it asked for createdAt, a valid field in the schema. The server answered with an error. The schema declared the field as DateTime, the entity stored a LocalDateTime, and the scalar's serializer refuses that conversion at runtime. Any query that asked for createdAt would have failed the same way. Before this run, every hand-written query against the service had asked only for the fields its own example needed, and none of them included createdAt. The bug stayed hidden until the first generated selection asked for every scalar field of the type.
This is the property from Class 1, an agent that keeps calling and keeps exploring, seen in practice. It has a bad side and a good side. Agents find the hidden bugs in your service, which is uncomfortable. They find them in your own terminal on the first run, instead of months later in front of a customer. That is the better time to find them, as long as the layers below catch what the agent finds.
That bug is fixed in the backend you cloned, so your runs will not reproduce it. The three timestamp fields are OffsetDateTime now, and a test checks their types so the mistake cannot return. We describe the bug here because of how little it took to find: one generated selection, on its first run, while nobody was looking at the output. A second bug appeared the same way while we assembled this course's backend. A full selection over items threw a lazy-loading exception that no partial selection had triggered. Both bugs came from asking for everything the schema offers. A generated selection always does that, while a hand-written query asks for the few fields that one screen needs.
The Write That Succeeds While Its Response Fails
The generated selection for the write included addedAt, another broken DateTime field. So the approved mutation reported a serialization error even when the write itself succeeded, and an agent that retries on error would add the item again. Our data was not damaged (we checked that the failed runs left the data unchanged), but the problem applies to any API, so we state it as a rule. A mutation's error response proves only that the response failed. Agent-facing mutations should be idempotent where possible, and an agent should read the state again before it retries a write, because part of the work may already be done.
The Wiring Check Keeps Its Plain Callback
One older command needs a change before we get to the overrides. call from Class 2 executes a tool directly, without a model, and it should keep working that way, without a login, a gate or a budget. The callback's main constructor now takes all three, so the command switches to a factory that passes an anonymous session and null for the gate and the budget:
/** The Class 2 shape: a tool without the safety checks, for direct calls. */
public static GraphQlToolCallback plain(OperationTool tool, String endpoint) {
return new GraphQlToolCallback(tool, endpoint, AuthSession.anonymous(), null, null);
}
The complete GraphQlToolCallback.java, with the additions above in place
package com.graphqlguy.moviedb.agents.catalog;
import com.graphqlguy.moviedb.agents.auth.AuthSession;
import com.graphqlguy.moviedb.agents.safety.ApprovalGate;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import org.jspecify.annotations.NonNull;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.ObjectMapper;
import java.util.Map;
/**
* The bridge between a generated tool and Spring AI, and, since Class 4, the layer
* where the safety controls actually live. Three things happen on every call, in
* order: the run budget counts it (and stops runaway loops), a mutation must pass
* the human approval gate, and the request carries the authenticated user's token,
* so the server's own authorization still has the last word.
* <p>
* The controls sit here, at the execution layer, on purpose. A system prompt can
* ask a model to behave; only the layer that performs the action can guarantee it.
*/
public class GraphQlToolCallback implements ToolCallback {
private final OperationTool tool;
private final String endpoint;
private final AuthSession auth;
private final ApprovalGate approvalGate;
private final RunBudget budget;
private final ObjectMapper json = new ObjectMapper();
public GraphQlToolCallback(OperationTool tool, String endpoint, AuthSession auth,
ApprovalGate approvalGate, RunBudget budget) {
this.tool = tool;
this.endpoint = endpoint;
this.auth = auth;
this.approvalGate = approvalGate;
this.budget = budget;
}
/** The Class 2 shape: a tool without the safety checks, for direct calls. */
public static GraphQlToolCallback plain(OperationTool tool, String endpoint) {
return new GraphQlToolCallback(tool, endpoint, AuthSession.anonymous(), null, null);
}
@Override
public @NonNull ToolDefinition getToolDefinition() {
return DefaultToolDefinition.builder()
.name(tool.name())
.description(tool.description())
.inputSchema(json.writeValueAsString(tool.inputSchema()))
.build();
}
@Override
public @NonNull String call(@NonNull String toolInput) {
if (budget != null) {
budget.countToolCall();
}
if (tool.mutation() && approvalGate != null && !approvalGate.approve(tool, toolInput)) {
return "{\"denied\": \"The human declined this write. Do not retry it;"
+ " explain the situation and ask what they would like instead.\"}";
}
Map<?, ?> variables = json.readValue(toolInput, Map.class);
// Small models often flatten a mutation's single input object, sending its
// fields as top-level arguments. The schema knows the intended shape, so the
// repair is lossless: when the expected key is absent, wrap what arrived.
if (tool.singleObjectArgument() != null
&& !variables.containsKey(tool.singleObjectArgument())) {
variables = Map.of(tool.singleObjectArgument(), variables);
}
Map<String, Object> body = Map.of(
"query", tool.operationDocument(),
"variables", variables);
RestClient.RequestBodySpec request = RestClient.create()
.post()
.uri(endpoint)
.header("Content-Type", "application/json");
if (auth.bearerOrNull() != null) {
request = request.header(HttpHeaders.AUTHORIZATION, auth.bearerOrNull());
}
String response = request.body(body).retrieve().body(String.class);
return response == null ? "" : response;
}
}
// plain() skips the login, the gate and the budget, so this command stays
// the wiring check from Class 2.
System.out.println(GraphQlToolCallback.plain(tool, context.endpoint()).call(jsonArgs));
The complete CallCommand.java, with the additions above in place
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.catalog.GraphQlToolCallback;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import org.springframework.stereotype.Component;
import java.util.List;
/** Class 2: execute one generated tool directly, proving the wiring with no model in the loop. */
@Component
public class CallCommand implements AgentCommand {
@Override
public String name() {
return "call";
}
@Override
public int order() {
return 21;
}
@Override
public String summary() {
return "execute one generated tool against the endpoint, with no model involved";
}
@Override
public String prompt() {
return "tool name and JSON arguments, e.g. movie {\"id\":\"1\"}";
}
@Override
public void run(List<String> args, CommandContext context) throws Exception {
if (args.size() < 2) {
System.out.println("call needs a tool name and a JSON argument object");
return;
}
String toolName = args.get(0);
String jsonArgs = String.join(" ", args.subList(1, args.size()));
OperationTool tool = context.tools(null).stream()
.filter(candidate -> candidate.name().equals(toolName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"No allow-listed tool named '" + toolName + "'"));
System.out.println("operation : " + tool.operationDocument());
System.out.println("arguments : " + jsonArgs);
System.out.println();
// plain() skips the login, the gate and the budget, so this command stays
// the wiring check from Class 2.
System.out.println(GraphQlToolCallback.plain(tool, context.endpoint()).call(jsonArgs));
}
}
The null gate and the null budget are why the call method checks both before using them. This unguarded path exists on purpose, and only a command that a person types can reach it.
Overriding a Generated Selection
Those two bugs are why our project can override a generated selection. A file for a tool under tool-selections/ holds a hand-written operation document, and the tool uses that document in place of the generated one. One method in the generator implements the override, and one line in the field loop calls it:
// in the field loop, replacing the direct call to operationDocument(...)
String document = curatedOverride(field.getName())
.orElseGet(() -> operationDocument(operationKind, field));
/**
* A hand-curated operation document wins over the generated default when a file
* named after the tool exists under tool-selections/. Generation gets you a
* reviewed starting point; curation is how a human overrides it, richer or
* narrower, without touching the generator.
*/
private Optional<String> curatedOverride(String toolName) {
Path file = Path.of("src/main/resources/tool-selections", toolName + ".graphql");
if (!Files.isRegularFile(file)) {
return Optional.empty();
}
try {
String document = Files.readAllLines(file).stream()
.filter(line -> !line.strip().startsWith("#"))
.collect(Collectors.joining(" "))
.strip();
return document.isBlank() ? Optional.empty() : Optional.of(document);
} catch (IOException failure) {
throw new IllegalStateException("Cannot read " + file, failure);
}
}
The complete ToolCatalogGenerator.java, with the additions above in place
package com.graphqlguy.moviedb.agents.toolgen;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingType;
import graphql.schema.GraphQLArgument;
import graphql.schema.GraphQLEnumType;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLInputObjectType;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLScalarType;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLType;
import graphql.schema.GraphQLTypeUtil;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Walks the schema's Query and Mutation roots and emits one tool per allow-listed
* operation. Generation is a build-time activity: the catalog is a reviewable
* artifact, regenerated when the schema changes, never assembled at request time.
* <p>
* The selection set each tool executes is generated with a deliberate default: the
* scalar and enum fields of the returned object type, one level deep. That default
* keeps generated operations cheap and predictable; anything richer (nested
* relations, connections) is a curation decision a human makes by editing the
* generated document, exactly as they would curate a persisted query.
*/
public class ToolCatalogGenerator {
private final JsonSchemaMapper schemaMapper = new JsonSchemaMapper();
private final ObjectMapper json = new ObjectMapper();
private final Encoding encoding =
Encodings.newDefaultEncodingRegistry().getEncoding(EncodingType.O200K_BASE);
public List<OperationTool> generate(GraphQLSchema schema, AllowList allowList, String role) {
List<OperationTool> tools = new ArrayList<>();
addRoot(tools, schema.getQueryType(), "query", allowList, role);
addRoot(tools, schema.getMutationType(), "mutation", allowList, role);
return tools;
}
private void addRoot(List<OperationTool> tools, GraphQLObjectType root, String operationKind,
AllowList allowList, String role) {
if (root == null) {
return;
}
for (GraphQLFieldDefinition field : root.getFieldDefinitions()) {
if (!allowList.allows(field.getName(), role)) {
continue;
}
if (field.getDescription() == null || field.getDescription().isBlank()) {
throw new IllegalStateException(
"Operation '" + field.getName() + "' is on the allow-list without a schema"
+ " description. The model cannot tell such a tool apart from its neighbours,"
+ " so describe the operation in the schema or remove it from the"
+ " allow-list.");
}
String description = field.getDescription().strip();
Map<String, Object> inputSchema = new LinkedHashMap<>();
inputSchema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
for (GraphQLArgument argument : field.getArguments()) {
Map<String, Object> argSchema = schemaMapper.toJsonSchema(argument.getType());
if (argument.getDescription() != null) {
argSchema.put("description", argument.getDescription().strip());
}
properties.put(argument.getName(), argSchema);
if (JsonSchemaMapper.isRequired(argument.getType())) {
required.add(argument.getName());
}
}
inputSchema.put("properties", properties);
if (!required.isEmpty()) {
inputSchema.put("required", required);
}
inputSchema.put("additionalProperties", false);
String document = curatedOverride(field.getName())
.orElseGet(() -> operationDocument(operationKind, field));
int tokens = encoding.countTokens(
field.getName() + " " + description + " " + json.writeValueAsString(inputSchema));
String singleObjectArgument = null;
if (field.getArguments().size() == 1) {
GraphQLArgument only = field.getArguments().get(0);
if (JsonSchemaMapper.unwrapNonNull(only.getType())
instanceof GraphQLInputObjectType) {
singleObjectArgument = only.getName();
}
}
tools.add(new OperationTool(field.getName(), description, inputSchema,
document, allowList.rolesFor(field.getName()), tokens,
"mutation".equals(operationKind), singleObjectArgument));
}
}
/**
* A hand-curated operation document wins over the generated default when a file
* named after the tool exists under tool-selections/. Generation gets you a
* reviewed starting point; curation is how a human overrides it, richer or
* narrower, without touching the generator.
*/
private Optional<String> curatedOverride(String toolName) {
Path file = Path.of("src/main/resources/tool-selections", toolName + ".graphql");
if (!Files.isRegularFile(file)) {
return Optional.empty();
}
try {
String document = Files.readAllLines(file).stream()
.filter(line -> !line.strip().startsWith("#"))
.collect(Collectors.joining(" "))
.strip();
return document.isEmpty() ? Optional.empty() : Optional.of(document);
} catch (IOException e) {
throw new IllegalStateException("Could not read the curated selection " + file, e);
}
}
/**
* Builds the persisted operation the tool executes, forwarding every argument as
* a variable with its original GraphQL type, so the server's own validation and
* coercion still apply to whatever the model supplies.
*/
private String operationDocument(String operationKind, GraphQLFieldDefinition field) {
StringBuilder doc = new StringBuilder(operationKind).append(" Tool_").append(field.getName());
if (!field.getArguments().isEmpty()) {
List<String> varDefs = field.getArguments().stream()
.map(argument -> "$" + argument.getName() + ": "
+ GraphQLTypeUtil.simplePrint(argument.getType()))
.toList();
doc.append("(").append(String.join(", ", varDefs)).append(")");
}
doc.append(" { ").append(field.getName());
if (!field.getArguments().isEmpty()) {
List<String> args = field.getArguments().stream()
.map(argument -> argument.getName() + ": $" + argument.getName())
.toList();
doc.append("(").append(String.join(", ", args)).append(")");
}
doc.append(defaultSelection(field.getType()));
doc.append(" }");
return doc.toString();
}
private String defaultSelection(GraphQLType returnType) {
GraphQLType unwrapped = GraphQLTypeUtil.unwrapAll(returnType);
if (!(unwrapped instanceof GraphQLObjectType objectType)) {
return ""; // scalar, enum, union, or interface returns: nothing generatable safely
}
List<String> scalarFields = objectType.getFieldDefinitions().stream()
.filter(field -> {
GraphQLType fieldType = GraphQLTypeUtil.unwrapAll(field.getType());
return fieldType instanceof GraphQLScalarType || fieldType instanceof GraphQLEnumType;
})
.map(GraphQLFieldDefinition::getName)
.toList();
if (scalarFields.isEmpty()) {
return "";
}
return " { " + String.join(" ", scalarFields) + " }";
}
public String toJson(List<OperationTool> tools) {
List<Map<String, Object>> out = new ArrayList<>();
for (OperationTool tool : tools) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", tool.name());
entry.put("description", tool.description());
entry.put("inputSchema", tool.inputSchema());
entry.put("operation", tool.operationDocument());
entry.put("roles", tool.roles());
entry.put("tokens", tool.tokenCount());
out.add(entry);
}
return json.writerWithDefaultPrettyPrinter().writeValueAsString(out);
}
}
The method drops comment lines and joins the rest into one line. An override file can therefore explain itself to the next reader, and the tool still sends a single string. Without a file, the generated default stays in place, so every tool without an override keeps working.
Both bugs are fixed in the service now, so the overrides that remain have a different purpose. addWatchListItem returns AddWatchListItemResponse, whose only scalar field is success. The generated default selects only that field, so the agent has to guess what was added, or why a write failed. The two parts an agent needs, the item and the typed error, are objects, and the default selection stays at scalar fields. The override adds them:
# Curated override. addWatchListItem returns AddWatchListItemResponse, whose only
# scalar field is `success`, so the generated default selects that alone. The agent
# then cannot see what was added or why a write failed. Both useful halves
# (`item` and the typed `error`) are object-shaped, which the conservative default
# refuses to follow. This is the errors-as-data response the agent has to read to
# decide between retrying, correcting an id, and giving up.
mutation Tool_addWatchListItem($input: AddWatchListItemInput!) {
addWatchListItem(input: $input) {
success
item { id titleType watched userNotes }
error { code message }
}
}
The second override solves the union problem from Class 2. search returns SearchResult, a union of Movie, TvShow and Person. A selection on a union field has to name the fields it wants from each member type. The generated document does not select any of them, so the server rejects it as invalid. The generator stops there on purpose, because choosing fields across three member types is a judgement about which of them this agent needs. A person makes that judgement once, with inline fragments:
# Curated override, and the one the generator cannot write for you. `search`
# returns the union SearchResult = Movie | Person | TvShow, and a union field is
# invalid without a selection, so the generated document fails validation at the
# server with SubselectionRequired. Choosing fields across union members needs
# inline fragments and a judgement about which members matter, and a person makes
# that judgement when writing this file. __typename is selected so the agent can tell
# the branches apart in the result.
query Tool_search($query: String!) {
search(query: $query) {
__typename
... on Movie { id title releaseYear rating }
... on TvShow { id title startYear rating }
... on Person { id name }
}
}
__typename is in that selection because a movie and a TV show come back with almost the same fields, and the agent uses the type name to tell them apart.
Together, the two files show how the work is divided. The generator writes a starting point for every operation, and a person reviews it. When the generated document is wrong, too limited, or impossible to generate, a person replaces it with a hand-written one. That file sits in src/main/resources/tool-selections/ next to the allow-list, so both go through the same review and the same git history.
Where the Model Failed, and the Tool Layer Met It
The 8-billion-parameter local model repeatedly flattened the mutation's single input object. It sent {"watchListId": "1", ...} instead of {"input": {"watchListId": "1", ...}}, although a correct JSON Schema described the wrapped shape. Smaller models often do this, and better prompt wording only makes it less frequent.
The argument repair that we built into the callback earlier handles this. When a tool has exactly one input-object argument and the expected key is missing, the callback nests the fields that arrived under it. Every value is kept, because the schema defines the intended shape. With the repair in place, we ran the same task again for the closing transcript of this lesson:
add The Matrix to my To watch this weekend watchlist
(Recorded with qwen3:8b through Ollama on 1 September 2026. Model behavior varies between runs, so your run may flatten the object, may send it correctly, or may make a different mistake. Whichever mistake happens, a layer below should catch it.)
Approving with y at the prompt gives:
role : concierge (7 tools)
user : user
task : add The Matrix to my To watch this weekend watchlist
model asks for: myWatchLists
result: {"data":{"myWatchLists":[{"id":"1","name":"To watch this weekend","description":"Short list for
the next couple of evenings","isPublic":false,"itemCount":2},{"id":"2","name":"A...
model asks for: search
result: {"data":{"search":[{"__typename":"Movie","id":"42","title":"The Matrix","releaseYear":1999,
"rating":8.7},{"__typename":"Movie","id":"43","title":"The Matrix Reloaded",...
model asks for: addWatchListItem
APPROVAL REQUIRED: the agent wants to run a write.
tool : addWatchListItem
operation : mutation Tool_addWatchListItem($input: AddWatchListItemInput!) { addWatchListItem(input: $input)
{ success item { id titleType watched userNotes } error { code message } } }
arguments : {"input":{"titleId":"42","titleType":"MOVIE","watchListId":"1"}}
Execute this mutation? [y/N] approved by the human.
result: {"data":{"addWatchListItem":{"success":true,"item":{"id":"5","titleType":"MOVIE",
"watched":false,"userNotes":null},"error":null}}}
answer: "The Matrix" (1999, 8.7/10) has been successfully added to your "To watch this weekend"
watchlist! Would you like to add another title to this list?
budget: 4 model calls (ceiling 8), 3 tool calls (ceiling 6)
That transcript shows every layer this lesson built:
- a read that finds the watchlist by name
- one write that waits at the gate until a human approves it
- a confirmed result
- a budget line that shows what the run spent
Two details matter here. In this run the model sent the input wrapper itself. The approval prompt prints the arguments before the repair runs, so it shows what the model sent, and the repair left the arguments as they were. The result is the typed response: success, the item that was added, and a null error. That is the shape the curated selection override asked for.
Summary
Safety for GraphQL agents means layers that enforce rules in a strict order: prompts request, the tool layer guarantees, and the server decides.
- Identity comes from a real login that the model cannot influence, so every failure stays inside one user's own data.
- Writes pause at an approval gate in the callback, where execution happens. The gate shows the human the exact operation and arguments. A denial goes back to the model as structured guidance that the model can act on. Spring AI's
ToolCallingAdvisorhooks are where this gate goes in production. - Run budgets cap model calls and tool calls far below the framework's own limits of 40 calls per tool and 150 per turn, because a stuck agent should stop.
- The server's validation, authorization and depth caps catch what the layers above miss.
Three runs in this lesson showed the lower layers at work:
- The server refused a 20-level query, because the service's ceiling is 15.
- Validation refused a malformed call that a human had approved, because approval is consent and the validator checks the call.
- The agent's first run through its generated selections found a hidden
DateTimeserialization bug. That bug showed the danger of a write that succeeds while its response fails, and it showed why curated selection overrides are useful as ordinary, reviewable files.
Further Reading
- Spring AI: Tool Calling: the
ToolCallingAdvisorloop, its hooks that can be overridden (the documented place for approval gates), and the built-in call limits, which this lesson's budgets stay far below. - graphql-java: Instrumentation: the mechanism behind the depth cap that the probe hit, and where complexity caps are added.
- OWASP Top 10 for LLM Applications: the standard list of risks for LLM applications. This lesson defends against parts of it, prompt injection and excessive agency in particular.
- Spring for GraphQL: Security: how the service enforces the authorization that the agent inherits.
- GraphQL over HTTP and persisted documents: the earlier use of allow-lists for clients that people write, which this course applies to agents.
What's Next
The agent now acts safely:
- It acts for one signed-in user.
- Its writes wait for a human's approval.
- Each run has a budget.
- The server has the last word.
The next lessons move the hand-written loop into agent frameworks on the JVM: LangChain4j in Class 5, LangGraph4j in Class 6 and Spring AI's ChatClient in Class 7. There, this lesson's loop and console gate become framework features such as advisors, memory and streaming, and every safety property stays the same.