Class 6: LangGraph4j Integration
Duration: ~30 minutes | Difficulty: Advanced | Prerequisites: Class 5 completed; Ollama with a tool-calling model.
This is the middle lesson of the framework track (Classes 5 to 7). It uses the same catalog and the same safety controls with a third programming model. Classes 8 to 12 write their code against Spring AI, the framework that Class 7 covers, and what those lessons teach applies to all three frameworks.
This lesson carries on from Class 5. If you followed along, keep working on the project you already have. If you skipped it, clone the agents_class_5 branch to start from the same place:
git clone --branch agents_class_5 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.
In Class 5, AiServices hid the agent loop behind an interface, and the hidden loop kept our code simple. This lesson makes the loop visible. LangGraph4j turns it into an explicit, typed state machine, where we can print, checkpoint and pause every step, routing decision and piece of state. By the end, the same Movie Database concierge runs as a graph. A read passes through an interrupt without a prompt, and a write waits for your approval. On the way, we look at the two failures we hit while building it. One was about serialization, and in the other the model skipped a lookup it needed.
First, when is an explicit graph worth the extra code? A concierge with a single conversation works well with Class 5's hidden loop. Explicit graphs help when the control flow is the product, as in these cases:
- multi-step workflows with branches that depend on data
- retries with a different strategy for each kind of failure
- pauses of hours while a human decides
- audits that have to show which path a run took
In those systems the loop is business logic, and business logic should be written down where people can read it.
LangGraph4j, Located Precisely
LangGraph4j is the JVM port of LangGraph. It is an MIT-licensed community project under the langgraph4j organization, separate from LangChain4j and its maintainers. The project is actively developed, and it implements the LangGraph model, including the parts that matter for production:
- state channels with merge rules
- conditional edges
- checkpoint savers for a range of stores: in-memory for development, Postgres, Redis, DynamoDB, Hazelcast and more
- interrupts, subgraphs and time travel
Our project pins version 1.8.24, and the pin has a reason. In late August 2026, the project's Maven metadata marked a 1.9.0 beta as both latest and release. A build without a pinned version would therefore pick up the beta without any warning, while the 1.8 line kept shipping stable patches. Check what a dependency's latest points to before you rely on it, and review your pin from time to time. The 1.8 line has continued past 1.8.24, so this pin is now two patch releases behind the newest stable 1.8 release.
LangGraph4j comes as two dependencies: the graph engine, and the module that adds support for LangChain4j's message types:
<properties>
<!-- Pinned explicitly: the Maven release tag currently points at a 1.9.0 beta,
and the course pins stable versions only. -->
<langgraph4j.version>1.8.24</langgraph4j.version>
</properties>
<!-- LangGraph4j: the agent's control flow as an explicit, typed state machine -->
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-core</artifactId>
<version>${langgraph4j.version}</version>
</dependency>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-langchain4j</artifactId>
<version>${langgraph4j.version}</version>
</dependency>
We need the second one at the first checkpoint, for a reason explained in What Construction Actually Found.
State as Data
Everything in LangGraph4j starts from a question that the previous lessons answered only implicitly: what is the agent's state? Here the state becomes a Java class with a schema:
package com.graphqlguy.moviedb.agents.langgraph;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import org.bsc.langgraph4j.state.AgentState;
import org.bsc.langgraph4j.state.Channel;
import org.bsc.langgraph4j.state.Channels;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* The agent's whole condition, as data. LangGraph4j models a run as a typed state
* flowing through nodes, and the schema declares how each key merges: "messages"
* is an appender channel (every node's contribution is appended, which is exactly
* how a conversation grows), while "rounds" is a plain value the agent node
* overwrites as its loop counter.
*
* LangGraph4j is built around explicit state. The hand loop of
* Class 4 and the AiServices proxy of Class 5 both HAVE state; here it is a value
* you can inspect, checkpoint, and edit between steps. That is what makes
* interrupts and time-travel possible.
*/
public class ConciergeState extends AgentState {
public static final Map<String, Channel<?>> SCHEMA = Map.of(
"messages", Channels.appender(ArrayList::new),
"rounds", Channels.base(() -> 0));
public ConciergeState(Map<String, Object> initData) {
super(initData);
}
public List<ChatMessage> messages() {
return this.<List<ChatMessage>>value("messages").orElse(List.of());
}
public int rounds() {
return this.<Integer>value("rounds").orElse(0);
}
/** The most recent model message, when the last message is one. */
public Optional<AiMessage> lastAiMessage() {
List<ChatMessage> messages = messages();
if (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof AiMessage aiMessage) {
return Optional.of(aiMessage);
}
return Optional.empty();
}
}
The accessors matter as much as the schema. State arrives as an untyped map, so without them every read would need a cast where it is used. With the accessors, the routing logic reads state.rounds() > maxRounds instead of a lookup and a cast. That keeps the conditional edge in the next section easy to read.
The schema declares how each key merges when a node returns an update, and its two entries show two kinds of channel. messages is an appender channel: whatever a node returns under that key is added to the end of the list, which is how a conversation grows. rounds is a plain value that the agent node overwrites. It is this lesson's budget, a counter kept in the state instead of in a loop variable. Because the counter lives in the state:
- the routing logic can read it
- checkpoints save it
- anyone who inspects a paused run can see how much the run has spent
The Graph
Here is the standard agent loop as a diagram:
The code follows the diagram closely:
- The agent node calls the model with the conversation and the tool specifications, from the same catalog bridged as in Class 5.
- The tools node executes whatever the model asked for, through the same
GraphQlToolCallbackobjects. - The conditional edge decides what happens next, and this lesson's budget check lives there.
Three names in the code below are static imports from LangGraph4j:
node_asyncandedge_asyncwrap plain synchronous lambdas in theCompletableFuture-returning form that the graph API expects. They do not change what the lambdas do.ENDis the framework's constant for the final node. Routing to it stops the run.
The complete LangGraphConcierge.java, collapsed because it runs to 108 lines
package com.graphqlguy.moviedb.agents.langgraph;
import com.graphqlguy.moviedb.agents.catalog.GraphQlToolCallback;
import com.graphqlguy.moviedb.agents.langchain.LangChainSchemaMapper;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.agent.tool.ToolSpecification;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import org.bsc.langgraph4j.CompileConfig;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.checkpoint.MemorySaver;
import org.bsc.langgraph4j.langchain4j.serializer.jackson.LC4jJacksonStateSerializer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.StateGraph.START;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
/**
* The canonical agent loop, drawn instead of hidden: an agent node that calls the
* model, a tools node that executes what the model asked for, a conditional edge
* that decides between another round and the end, and a budget expressed as data
* the edge can read. Compare Class 4, where this exact control flow lived in a
* while-loop; here it is a value you can print, checkpoint, and interrupt.
*
* The approval gate moves with the loop: compiling with interruptBefore("tools")
* makes the graph pause and checkpoint BEFORE any tool executes, which is where
* a human (or a policy service) inspects the proposed calls and resumes, edits,
* or abandons the run. The callbacks underneath are the plain Class 2 ones, with
* the gate deliberately left out: this class's gate is the interrupt.
*/
public class LangGraphConcierge {
private final ChatModel chatModel;
private final List<ToolSpecification> specifications = new ArrayList<>();
private final Map<String, GraphQlToolCallback> executors = new LinkedHashMap<>();
private final Set<String> mutationTools;
private final int maxRounds;
public LangGraphConcierge(ChatModel chatModel, List<OperationTool> tools,
Map<String, GraphQlToolCallback> callbacksByName,
Set<String> mutationTools, int maxRounds) {
this.chatModel = chatModel;
this.mutationTools = mutationTools;
this.maxRounds = maxRounds;
LangChainSchemaMapper schemaMapper = new LangChainSchemaMapper();
for (OperationTool tool : tools) {
specifications.add(ToolSpecification.builder()
.name(tool.name())
.description(tool.description())
.parameters(schemaMapper.toObjectSchema(tool.inputSchema()))
.build());
executors.put(tool.name(), callbacksByName.get(tool.name()));
}
}
public CompiledGraph<ConciergeState> compile() throws Exception {
// Checkpointing serializes the state, and LangChain4j's message classes are
// not java.io.Serializable, so the integration module's Jackson serializer
// does the job; the default ObjectStream serializer fails at the first
// checkpoint. We found this when a run failed at that checkpoint.
StateGraph<ConciergeState> graph = new StateGraph<>(ConciergeState.SCHEMA,
new LC4jJacksonStateSerializer<>(ConciergeState::new));
graph.addNode("agent", node_async(state -> {
ChatRequest request = ChatRequest.builder()
.messages(state.messages())
.toolSpecifications(specifications)
.build();
AiMessage answer = chatModel.chat(request).aiMessage();
return Map.of("messages", List.of(answer), "rounds", state.rounds() + 1);
}));
graph.addNode("tools", node_async(state -> {
AiMessage last = state.lastAiMessage().orElseThrow();
List<ChatMessage> results = new ArrayList<>();
for (ToolExecutionRequest request : last.toolExecutionRequests()) {
System.out.println("tool call: " + request.name() + " " + request.arguments());
String result = executors.get(request.name()).call(request.arguments());
results.add(ToolExecutionResultMessage.from(request, result));
}
return Map.of("messages", results);
}));
graph.addEdge(START, "agent");
graph.addConditionalEdges("agent", edge_async(state -> {
boolean wantsTools = state.lastAiMessage()
.map(AiMessage::hasToolExecutionRequests).orElse(false);
if (state.rounds() > maxRounds) {
return "end"; // the budget, readable right here in the routing decision
}
return wantsTools ? "tools" : "end";
}), Map.of("tools", "tools", "end", END));
graph.addEdge("tools", "agent");
return graph.compile(CompileConfig.builder()
.checkpointSaver(new MemorySaver())
.interruptBefore("tools")
.build());
}
/** The proposed calls waiting at an interrupt that would write, if any. */
public List<ToolExecutionRequest> pendingMutations(ConciergeState state) {
return state.lastAiMessage()
.map(AiMessage::toolExecutionRequests).orElse(List.of()).stream()
.filter(request -> mutationTools.contains(request.name()))
.toList();
}
}
The constructor receives the callbacks by name instead of building them, for the same reason that Class 5's bridge took a factory. Identity, gate and budget belong to one run, and the command that starts the run assembles them. pendingMutations serves the interrupt described below, which needs to know whether any call waiting at the pause would write data.
Class 4 enforced its budget with a counter inside a while loop, and Class 5 left it to a framework option. Here the budget is three lines of routing logic in the graph definition. That is the main argument for explicit graphs. A policy that used to be hidden in the control flow becomes part of the graph's structure, where a reader can find it without tracing the execution.
The Interrupt: The Approval Gate's Third Home
The gate has now lived in three places, and following where it moves is the main topic of the framework track. Class 4 put it in the callback, where it works with any framework. Class 5 kept it there while the framework took over the loop. This lesson moves it up into the graph itself:
return graph.compile(CompileConfig.builder()
.checkpointSaver(new MemorySaver())
.interruptBefore("tools")
.build());
The gate itself needs one small addition for this caller. At the interrupt, the runner holds a ToolExecutionRequest, which carries the tool's name and arguments as separate strings, instead of the OperationTool record that Class 4's gate expected. So the gate gets a second approve method that takes the tool name, the operation document and the arguments as three separate values:
public boolean approve(OperationTool tool, String jsonArguments) {
return approve(tool.name(), tool.operationDocument(), jsonArguments);
}
/** The same contract for callers that hold the pieces instead of the record. */
public boolean approve(String toolName, String operationDocument, String jsonArguments) {
The original method keeps its signature and passes the three values on, so Class 4's callers keep working as they are. The body moves into the new method, and only the two lines that read from the record change.
The complete ApprovalGate.java, with the additions above in place
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) {
return approve(tool.name(), tool.operationDocument(), jsonArguments);
}
/** The same contract for callers that hold the pieces instead of the record. */
public boolean approve(String toolName, String operationDocument, String jsonArguments) {
System.out.println();
System.out.println("APPROVAL REQUIRED: the agent wants to run a write.");
System.out.println(" tool : " + toolName);
System.out.println(" operation : " + 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;
}
}
interruptBefore("tools") means: every time the graph is about to enter the tools node, save a checkpoint of the whole state and stop. The runner then inspects the paused state and applies this lesson's policy:
- If the proposed calls include a mutation, the runner shows it to the human and waits.
- If they are all reads, the runner resumes at once, without a prompt.
The checkpoint makes this more than an if statement in the runner. The paused run is a saved value:
- we can inspect it
- we can resume it minutes or days later
- we can edit it with
updateStatebefore resuming - with a database-backed saver, it survives a process restart
An approval that used to be a console prompt inside a call stack is now saved state, which a workflow system could route to a person's task queue.
Here are the same three layers as in Class 5. The approval gate now sits in the loop, and the model-call budget has moved again:
The interrupt gate and the callback gate each have strengths and limits:
- The interrupt gate sees the whole proposed step before anything executes, can pause for as long as needed, and can edit state. It belongs to this graph, though, so only calls that go through this graph meet it.
- The callback gate sees only its own call and can pause only while that call runs. It holds whatever drives the tools.
Production systems with real stakes use both. After three lessons, we have seen what each layer protects against.
Continuing a conversation works differently again. The checkpointer takes the place of a memory object. When we submit a new user message under the same threadId, the saver restores everything in the thread so far, and the messages appender channel adds the new message to it. The restore brings back rounds too, so the round counter carries on from the number the last turn reached instead of starting at zero.
A turn can now pause several times before it ends, so the code that drives the graph has its own method, driveToEnd. The outer loop handles turns, and the inner loop handles interrupts.
What Construction Actually Found
Building this lesson produced two failures. The branch history keeps both, because each one teaches a rule.
Checkpointing means serialization, and some objects in the state cannot be serialized. The first run failed at once with NotSerializableException: dev.langchain4j.data.message.SystemMessage. The reasoning behind that failure has three steps:
- A checkpoint must be able to outlive the process, so the state must be serializable.
- LangChain4j's message classes do not implement
java.io.Serializable. - The default serializer relies on
java.io.Serializable.
The fix is the integration module's LC4jJacksonStateSerializer, which supports LangChain4j's message types. The general rule applies to any stack. As soon as you use durable state (checkpoints, event stores, workflow engines), every object in that state has to be serializable. The first run is the best time to find out which objects fail that requirement. An incident is a worse time, because the state you cannot serialize is the state you most need to inspect.
The model passed a name where an id belonged, and the human approved it. In one run the model skipped the myWatchLists read and sent "watchListId": "To watch this weekend". The server correctly refused it with a typed INVALID_ID error.
This failure confirms two points from earlier lessons:
- Approval is consent, and it does not validate the call. Class 4 recorded the same failure: a human approved a call that the server then refused.
- A prompt about process ("look up ids first") also needs to be concrete about data ("ids are numeric strings like "2"; a name is never an id").
One sentence about ids in the system prompt turned the failure into the clean run below.
(Recorded with qwen3:8b on 1 September 2026. When we re-ran this lesson, the failure happened in two runs out of three. A different model, or the same model on another day, may avoid this mistake. The backend now answers a non-numeric id with a typed INVALID_ID error that says what an id looks like, so a model that makes the mistake can read the error and correct itself.)
The Run
Check It Works
mvn clean spring-boot:run
Choose graph-agent, then answer the prompt:
task: put movie 2 on my weekend list, look up the list id first
Watch for two things:
- The
node:lines name each node as it runs, so the output shows the path through the state machine. - The approval prompt now comes from the interrupt instead of from inside a tool call.
If you decline the write, the run stops and the checkpoint stays in place.
The graph-agent command builds the callbacks without a gate and compiles the graph. Then it drives the graph: it reads outputs until the stream stops, decides at each pause, and resumes. Read that drive loop closely, because it shows in plain Java what it means for the gate to live in the graph:
The complete GraphAgentCommand.java, collapsed because it runs to 145 lines
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.langgraph.ConciergeState;
import com.graphqlguy.moviedb.agents.langgraph.LangGraphConcierge;
import com.graphqlguy.moviedb.agents.safety.ApprovalGate;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import com.graphqlguy.moviedb.agents.toolgen.OperationTool;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.ollama.OllamaChatModel;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.StateGraph;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Class 6: the loop as an explicit state machine. The gate moves up into the
* graph, so the callbacks are built without one: the interrupt does the asking.
*/
@Component
public class GraphAgentCommand implements AgentCommand {
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. Ids are numeric strings like "2";
a name is never an id. Before any write, you MUST first call the
read tools to obtain every id the write needs.""";
@Override
public String name() {
return "graph-agent";
}
@Override
public int order() {
return 60;
}
@Override
public String summary() {
return "the loop as a LangGraph4j state machine, gated by an interrupt";
}
@Override
public String prompt() {
return "task";
}
@Override
public void run(List<String> args, CommandContext context) throws Exception {
String task = String.join(" ", args);
if (task.isBlank()) {
System.out.println("graph-agent needs a task");
return;
}
String role = context.role();
AuthSession auth = context.login();
RunBudget budget = context.budget();
List<OperationTool> toolList = context.tools(role);
// The gate is deliberately absent from the callbacks here: in this class the
// gate is the graph's interrupt, one level up.
Map<String, GraphQlToolCallback> callbacks = new LinkedHashMap<>();
Map<String, OperationTool> byName = new LinkedHashMap<>();
for (OperationTool tool : toolList) {
callbacks.put(tool.name(),
new GraphQlToolCallback(tool, context.endpoint(), auth, null, budget));
byName.put(tool.name(), tool);
}
Set<String> mutations = toolList.stream()
.filter(OperationTool::mutation)
.map(OperationTool::name)
.collect(Collectors.toSet());
var model = OllamaChatModel.builder()
.baseUrl(context.env().getProperty("spring.ai.ollama.base-url", "http://localhost:11434"))
.modelName(context.modelName())
.build();
var concierge = new LangGraphConcierge(model, toolList, callbacks, mutations,
context.env().getProperty("agents.budget.max-model-calls", Integer.class, 8));
var graph = concierge.compile();
var config = RunnableConfig.builder().threadId("class-6").build();
List<ChatMessage> initial = List.of(SystemMessage.from(SYSTEM), UserMessage.from(task));
System.out.println("framework : LangGraph4j (StateGraph, interruptBefore tools)");
System.out.println("role : " + role + " (" + toolList.size() + " tools)");
System.out.println("task : " + task);
System.out.println();
ApprovalGate gate = new ApprovalGate(context.console());
List<ChatMessage> submission = initial;
String turn = task;
while (!turn.isBlank()) {
ConciergeState finalState = driveToEnd(graph, config, submission, concierge, byName, gate);
if (finalState == null) {
System.out.println("Run abandoned at the interrupt; the checkpoint"
+ " keeps the paused state for inspection.");
return;
}
System.out.println();
System.out.println("answer: " + finalState.lastAiMessage()
.map(AiMessage::text).orElse("(no final message)"));
System.out.println("budget: " + budget.summary());
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 thread id stays the same, so the checkpointer restores everything
// said so far and the appender channel adds this message to it.
submission = List.of(UserMessage.from(turn));
}
}
/**
* Runs the graph until it reaches the end, pausing at each interrupt to ask about
* the writes waiting there. Returns the final state, or null when a human declined.
*/
private ConciergeState driveToEnd(CompiledGraph<ConciergeState> graph,
RunnableConfig config,
List<ChatMessage> submission,
LangGraphConcierge concierge,
Map<String, OperationTool> byName,
ApprovalGate gate) {
var stream = graph.stream(Map.<String, Object>of("messages", submission), config);
while (true) {
String lastNode = null;
ConciergeState lastState = null;
for (var output : stream) {
lastNode = output.node();
lastState = output.state();
System.out.println("node: " + output.node() + " (round " + output.state().rounds() + ")");
}
if (StateGraph.END.equals(lastNode) || lastState == null) {
return lastState;
}
// The graph checkpointed and paused before the tools node.
for (var request : concierge.pendingMutations(lastState)) {
if (!gate.approve(request.name(),
byName.get(request.name()).operationDocument(),
request.arguments())) {
return null;
}
}
stream = graph.stream(GraphInput.resume(), config);
}
}
}
The while (true) loop moves from one pause to the next. Each pass reads the stream until the graph stops, and a stop is either the end of the run or an interrupt before the tools node. At an interrupt, the command asks the gate about the writes waiting there, and GraphInput.resume() continues the same thread from the checkpoint. A refusal leaves the checkpoint in place.
The fourth argument to each GraphQlToolCallback is null, where Class 4 passed a gate. That is the change this lesson is about: the callback does not ask anyone, because the graph pauses before the callback runs.
framework : LangGraph4j (StateGraph, interruptBefore tools)
role : concierge (7 tools)
task : put movie 2 on my weekend list, look up the list id first
node: __START__ (round 0)
node: agent (round 1)
tool call: myWatchLists {}
node: tools (round 1)
node: agent (round 2)
APPROVAL REQUIRED: the agent wants to run a write.
tool : addWatchListItem
arguments : {"input":{"titleId":"2","titleType":"MOVIE","watchListId":"1"}}
Execute this mutation? [y/N] approved by the human.
tool call: addWatchListItem {"input":{"titleId":"2","titleType":"MOVIE","watchListId":"1"}}
node: tools (round 2)
node: agent (round 3)
node: __END__ (round 3)
answer: The movie with ID 2 has been successfully added to your "To watch this
weekend" list. It's now ready to watch!
budget: 0 model calls (ceiling 8), 2 tool calls (ceiling 6)
The 0 model calls comes from the same move that Class 5 described, and the counter works correctly. Three agent nodes ran, and the graph counted them in its own rounds channel, where the conditional edge could read them. Class 4's loop-side counter was not part of this path. The tool-call ceiling lives in the callback, so it counted normally.
Compare the trace with the diagram. The myWatchLists read did stop at the interrupt, because every entry into the tools node does. The runner resumed it without a prompt, because the read did not write anything. Only the mutation reached you, so the graph paused twice and asked you once. The graph's structure decides where a pause can happen, and a four-line policy decides which pauses need a person. That split is worth copying: pause before every tool call, and ask a person only about writes.
Summary
LangGraph4j turns the agent loop from hidden code into an explicit, typed graph:
- State is a Java class whose channels define how updates merge, and the conversation is an appender channel.
- The budget is a value that the conditional edge reads.
- The control flow is a graph structure that we can print.
The framework is a community port. We pin a stable 1.8 release, because in August 2026 its Maven metadata marked a beta as the release.
The approval gate moved to its third place, interruptBefore("tools"), a pause backed by a checkpoint before any tool executes. Reads resume without a prompt, and writes go to the human. With a database-backed saver, the pause survives a restart. The interrupt gate adds to the callback gate, which works with any framework, and does not replace it. Building the lesson taught two rules that apply to any stack:
- Durable state requires every object in it to be serializable.
- Approval is consent, and it does not validate the call. This course has now shown that three times.
Further Reading
- LangGraph4j on GitHub and its documentation site: the StateGraph API, the checkpointer saver modules, Studio, and the release history behind the version-pinning note.
- LangGraph conceptual documentation: the Python original's explanation of channels, checkpoints, and interrupts; the concepts apply to the Java port almost unchanged.
- LangChain4j integration module: the serializers and helpers that connect the two frameworks, including the one that fixed this lesson's first failure.
What's Next
The framework track ends with the stack that the rest of this course uses. Class 7 introduces Spring AI's ChatClient, the framework that the remaining lessons build on. The framework manages the loop again, this time through an advisor chain that we can extend. We have now looked at three questions from three angles: where gates live, who owns budgets, and what state is. Each of them maps directly onto Spring AI's vocabulary.