Skip to main content

LangGraph4j: Stateful Agent Graphs in Java

Draw an agent as a graph of nodes, edges and shared state, pause it for a human with a checkpoint, and resume it later. A loan-approval agent on LangGraph4j 1.9.5, with the one default (releaseThread) that silently erases a finished run.

Most “agent” demos are a loop: ask the model, run a tool, ask the model again. That works until the day you need the agent to stop and wait for a person, come back an hour later, and carry on from exactly where it left off. A loop that lives on a call stack cannot do that. If the process restarts, or the reviewer takes until tomorrow, the stack is gone. LangGraph4j is the Java port of LangGraph, and it answers that with one idea: draw the agent as a graph, keep everything it knows in one state object, and save that state after every step. A saved state is called a checkpoint. A paused agent is simply a checkpoint nobody has resumed yet. This post builds a loan-approval agent that way. Low-risk applications are approved automatically, high-risk ones rejected, and medium-risk ones stop and wait for a human. Every node is deterministic, with no model and no API key, so every line of output below came from a test you can re-run. Swapping the risk node for a model call changes one lambda, not the graph.
Versions this was tested against. JDK 25 (Temurin 25.0.4.1+1), org.bsc.langgraph4j:langgraph4j-core 1.9.5, JUnit 6.1.3. Everything is in the langgraph4j module of the java-ai-agents repository; five tests, all green.
Where this sits among the other agent posts. Embabel plans for you: you declare typed actions and a goal, and the planner works out the order at runtime. LangChain4j’s agentic module gives you ready-made shapes (sequential, parallel, loop, supervisor). LangGraph4j sits at the “I will draw the flow myself” end: you name every node and every edge, and in return you get explicit control over where the run can pause.

What goes wrong first: the run that finishes and forgets

Start with the failure, because it is the one that cost a debugging cycle while writing the companion module. LangGraph4j can be told to release a conversation thread once its run finishes, and that is the default. Release means “erase this thread’s checkpoints”. So the natural thing to write after a run — “show me the final state” — throws.
invoke returned a state: true, decision=APPROVED
getState after the run finished: IllegalStateException: Missing Checkpoint!
That transcript is output/05-release-thread-erases-finished-run.txt. The run succeeded and returned a result, and the very next call said there was nothing there. The fix is one flag on the compile config, releaseThread(false), shown in the next section. Keep it in mind as you read: the graph below sets it, and the reason is this block.
The fingerprint of this bug. IllegalStateException: Missing Checkpoint! from getState or getStateHistory, but only after a run has finished. Runs that paused at an interrupt still have their checkpoint, which is why the pause-and-resume demo works either way and the bug hides until you inspect a completed run.

The mental model: nodes, edges, state

Three words are enough to read any LangGraph4j program. A node is a function that receives the current state and returns a small map of changes. An edge says which node runs next. The state is one object every node reads from and writes to. Nodes never call each other; they only change the state and the edges decide what happens next. That separation is what makes pausing possible, because the whole situation of a run is “the state, plus the name of the next node”.
START intake creditCheck assessRisk autoApprove humanReview autoReject notify END LOWMEDIUMHIGH Dashed = the run stops before this node and waits. Everything else runs straight through.
The figure is the whole program. The three arrows leaving assessRisk are one conditional edge: a function looks at the state and returns a label (LOW, MEDIUM or HIGH), and a map turns the label into the next node. The dashed box is the only place the run stops. Everything to the left of the split is the same for every applicant; everything to the right is decided by data.

The state

State is a map with typed accessors. One key needs special treatment: the audit trail. If every node simply overwrote it, you would keep only the last line. An appender channel tells LangGraph4j to add new values to the list instead of replacing it. Every other key is overwritten by whichever node wrote it last.
public class LoanState extends AgentState {
    public static final String TRAIL = "trail";

    /** Channel schema: trail appends, everything else replaces. */
    public static final Map<String, Channel<?>> SCHEMA =
            Map.of(TRAIL, Channels.appender(ArrayList::new));

    public LoanState(Map<String, Object> data) { super(data); }

    public String decision() { return this.<String>value(DECISION).orElse("PENDING"); }
    public List<String> trail() { return this.<List<String>>value(TRAIL).orElse(List.of()); }
    // applicant(), amount(), income(), creditScore(), risk() follow the same pattern
}
Excerpt of LoanState.java. Note the defaults: a missing decision reads as PENDING, which is what you will see in the output while the run is paused.

The graph

var graph = new StateGraph<>(LoanState.SCHEMA, LoanState::new)
        .addNode("intake", node_async(s -> Map.of(
                LoanState.TRAIL, "intake: " + s.applicant() + " asks for " + s.amount())))
        // ... creditCheck, assessRisk, autoApprove, autoReject, humanReview, notify
        .addEdge(START, "intake")
        .addEdge("intake", "creditCheck")
        .addEdge("creditCheck", "assessRisk")
        .addConditionalEdges("assessRisk",
                edge_async(s -> s.risk()),
                Map.of("LOW", "autoApprove", "HIGH", "autoReject", "MEDIUM", "humanReview"))
        .addEdge("autoApprove", "notify")
        .addEdge("autoReject", "notify")
        .addEdge("humanReview", "notify")
        .addEdge("notify", END);

var config = CompileConfig.builder()
        .checkpointSaver(saver)
        .interruptBefore("humanReview")
        .releaseThread(releaseThread)   // false, or the trap above
        .build();
return graph.compile(config);
Condensed from LoanGraph.java; the full node bodies are there. node_async and edge_async wrap plain lambdas so the engine can run them asynchronously; for deterministic code they are just ceremony.

Going deeper: graph basics

The smallest run: a low-risk application goes straight through

Streaming the graph yields one item per node as it finishes. Printing the node name and the decision shows the run advancing:
__START__    decision=PENDING
intake       decision=PENDING
creditCheck  decision=PENDING
assessRisk   decision=PENDING
autoApprove  decision=APPROVED
notify       decision=APPROVED
__END__      decision=APPROVED
final decision: APPROVED
trail:
  intake: Asha asks for 40000
  creditCheck: score 850
  assessRisk: ratio 0.33 -> LOW
  autoApprove: approved by policy
  notify: told Asha the result is APPROVED
Transcript: output/01-low-risk-straight-through.txt. Two things to notice. The trail has five lines, one per real node, because the appender channel accumulated them. And decision stayed PENDING until autoApprove wrote it; reading a key a node has not written yet gives you your default, not an error.

Pausing for a human: interrupt, edit the state, resume

This is the reason to use a graph at all. Compiling with interruptBefore("humanReview") makes the engine stop before running that node, save a checkpoint, and return control. The medium-risk applicant stops there:
--- first run ---
__START__    decision=PENDING
intake       decision=PENDING
creditCheck  decision=PENDING
assessRisk   decision=PENDING
paused, next node: humanReview
decision while paused: PENDING
--- resumed after reviewer wrote decision=APPROVED ---
humanReview  decision=APPROVED
notify       decision=APPROVED
__END__      decision=APPROVED
final decision: APPROVED
  intake: Ravi asks for 300000
  creditCheck: score 750
  assessRisk: ratio 3.33 -> MEDIUM
  humanReview: reviewer decided APPROVED
  notify: told Ravi the result is APPROVED
Transcript: output/02-medium-risk-pause-and-resume.txt. Read it as a story. The stream ends after assessRisk and the graph reports its next node is humanReview. Nothing is running; the process could exit here. The reviewer’s decision is then written into the saved state with updateState, and a second stream with GraphInput.resume() continues from the checkpoint. The humanReview node does not make the decision; it only records it, because the human already wrote it.
var paused = app.getState(cfg);                       // next() == "humanReview"
var cfg2 = app.updateState(cfg, Map.of(LoanState.DECISION, "APPROVED"));
for (NodeOutput<LoanState> step : app.stream(GraphInput.resume(), cfg2)) {
    // humanReview, notify, END
}
From LoanGraphTest.java. The cfg carries a thread id; that id is the key under which checkpoints are stored, and it is how the second call knows which paused run to continue. Note that updateState returns a new config. Resume with that one.
Tested limit. These runs use MemorySaver, an in-process saver. A paused run survives as long as the JVM does, which proves the mechanism but not the “come back tomorrow” story. For that you need a saver that writes to a database or file; I did not run one, so I make no claim about their behaviour here. The 1.9.5 core jar does contain a FileSystemSaver class next to MemorySaver (I listed the jar; I did not run it), so that is the first thing to try.

Going deeper: human-in-the-loop

Checkpoints are a history, not a single slot

Because state is saved after every step, you can list all of them. A high-risk application runs through to the end, and getStateHistory shows each saved moment, newest first:
checkpoints saved: 6
  next=__END__      trail-size=5
  next=notify       trail-size=4
  next=autoReject   trail-size=3
  next=assessRisk   trail-size=2
  next=creditCheck  trail-size=1
  next=intake       trail-size=0
final decision: REJECTED
Transcript: output/03-high-risk-and-checkpoint-history.txt. Each row is “what would run next” and “how much of the trail existed at that moment”. Six checkpoints for a five-node run is the shape: one before each node, plus the final one. This history is what makes replay and “what did the agent know when it decided?” possible. It is also exactly what releaseThread(true) deletes at the end of a run, which is the first section’s bug.

Many applicants at once: thread ids keep runs apart

One compiled graph serves many runs. The thread id separates them:
thread A: Asha -> APPROVED
thread B: Mohan -> REJECTED
Transcript: output/04-thread-isolation.txt. Two applicants went through the same graph object and each kept their own state. In a web application the thread id is whatever identifies the case: a loan application id, a ticket number. Reusing one id for unrelated cases would mix their states, so treat it as a primary key.

Going deeper: state and threads

  • Custom channels beyond the appender (for example a reducer that merges maps) exist; I used only the appender and the default overwrite.
  • All five tests — each writes its transcript and asserts the same facts.
Reference: how to run it and what the module contains
$ git clone https://ankurm.com/git.app/asmhatre/java-ai-agents.git
$ cd java-ai-agents
$ mvn test -pl langgraph4j
The tests rewrite the five files under langgraph4j/output, so a diff after a run shows whether behaviour changed. The Maven dependency is org.bsc.langgraph4j:langgraph4j-core:1.9.5; the version is a property in the parent pom. A first guess at the coordinates (org.bsc.langgraph4j versus a similarly named group) is easy to get wrong; that group id is the one that resolved.

Where the model goes

Nothing above calls a model, on purpose, so the output could be asserted. In a real agent assessRisk would become a call to a chat model that returns LOW, MEDIUM or HIGH, and the conditional edge would route on that. The graph, the interrupt and the checkpoints would not change. That is the practical argument for the shape: the non-deterministic part is one node, and everything around it — routing, pausing, history — stays testable without a model. If you want the Spring AI side of that call, see the Embabel post, which includes a plain Spring AI comparison.
Should you even do this? Use a graph when you have a real reason to stop in the middle: a human approval, a long wait, a need to replay what the agent saw. If your agent is “call the model, call a tool, answer” in one request, a graph is more structure than it needs, and a LangChain4j agentic workflow or a plain method is simpler. Also be honest about maturity: this post exercised the in-memory saver only and no model, so durable checkpoint storage and real LLM nodes are things you still have to test yourself.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.