diff --git a/README.md b/README.md
index 95c6d88..0b055e6 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ facts, so the build fails when a claim stops being true.
| `a2a` | A2A Protocol in Java: Agents That Talk to Each Other | agent card, tasks, streaming, input-required and agent-to-agent calls with the A2A Java SDK |
| `onnx-djl` | Running ONNX Models in Java with ONNX Runtime and DJL | a DistilBERT sentiment model: DJL tokenizer, ONNX Runtime directly and through DJL's engine, batching, and a Spring Boot endpoint with latency numbers |
| `jlama` | Local LLM Inference in Pure Java with Jlama | load and run a 4-bit TinyLlama inside the JVM on the Vector API, tokens per second, streaming, and the same measurement against Ollama |
+| `langgraph4j` | LangGraph4j: Stateful Agent Graphs in Java | a loan-approval graph with conditional edges, checkpoints, a human-in-the-loop pause and resume, and the `releaseThread` default that erases a finished run |
## Versions (verified on Maven Central, 2026-10-11)
@@ -23,6 +24,7 @@ facts, so the build fails when a claim stops being true.
| DJL (`ai.djl`) | 0.38.0 | `api`, `huggingface:tokenizers`, `onnxruntime:onnxruntime-engine` |
| Spring Boot | 4.1.1 | `onnx-djl` endpoint only |
| Model | `distilbert/distilbert-base-uncased-finetuned-sst-2-english` | `onnx/model.onnx` (268 MB), `onnx/tokenizer.json`, `config.json` in `onnx-djl/models/sst2/` (git-ignored) |
+| LangGraph4j `org.bsc.langgraph4j:langgraph4j-core` | 1.9.5 | verified on Maven Central 2026-10-14; `langgraph4j` module only |
| JDK | 25 LTS | |
| JUnit | 6.1.3 | |
diff --git a/langgraph4j/README.md b/langgraph4j/README.md
new file mode 100644
index 0000000..d729b9b
--- /dev/null
+++ b/langgraph4j/README.md
@@ -0,0 +1,19 @@
+# langgraph4j
+
+Companion code for *LangGraph4j: Stateful Agent Graphs in Java* on [ankurm.com](https://ankurm.com).
+A loan-approval agent drawn as a graph with conditional routing, checkpoints, a human-in-the-loop
+pause and resume, and per-thread state. No model and no API key: every node is deterministic so the
+run is reproducible; swapping `assessRisk` for a model call changes one lambda.
+
+```bash
+mvn test -pl langgraph4j # rewrites output/*.txt
+```
+
+| File | Role |
+|---|---|
+| `LoanState` | The shared state: typed accessors over a map, with an appender channel for the audit trail |
+| `LoanGraph` | Nodes, edges, the conditional route on risk, `interruptBefore("humanReview")`, the checkpoint saver |
+| `LoanGraphTest` | Five tests; each writes the matching `output/NN-*.txt` and asserts the same facts |
+
+Verified against `org.bsc.langgraph4j:langgraph4j-core` **1.9.5**, JDK 25, JUnit 6.1.3. The article carries
+the explanations; there is no `docs/` folder.
diff --git a/langgraph4j/output/01-low-risk-straight-through.txt b/langgraph4j/output/01-low-risk-straight-through.txt
new file mode 100644
index 0000000..11bd09d
--- /dev/null
+++ b/langgraph4j/output/01-low-risk-straight-through.txt
@@ -0,0 +1,14 @@
+__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
diff --git a/langgraph4j/output/02-medium-risk-pause-and-resume.txt b/langgraph4j/output/02-medium-risk-pause-and-resume.txt
new file mode 100644
index 0000000..b2e1c85
--- /dev/null
+++ b/langgraph4j/output/02-medium-risk-pause-and-resume.txt
@@ -0,0 +1,17 @@
+--- 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
diff --git a/langgraph4j/output/03-high-risk-and-checkpoint-history.txt b/langgraph4j/output/03-high-risk-and-checkpoint-history.txt
new file mode 100644
index 0000000..decf0bf
--- /dev/null
+++ b/langgraph4j/output/03-high-risk-and-checkpoint-history.txt
@@ -0,0 +1,8 @@
+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
diff --git a/langgraph4j/output/04-thread-isolation.txt b/langgraph4j/output/04-thread-isolation.txt
new file mode 100644
index 0000000..79c9447
--- /dev/null
+++ b/langgraph4j/output/04-thread-isolation.txt
@@ -0,0 +1,2 @@
+thread A: Asha -> APPROVED
+thread B: Mohan -> REJECTED
diff --git a/langgraph4j/output/05-release-thread-erases-finished-run.txt b/langgraph4j/output/05-release-thread-erases-finished-run.txt
new file mode 100644
index 0000000..36ca15d
--- /dev/null
+++ b/langgraph4j/output/05-release-thread-erases-finished-run.txt
@@ -0,0 +1,2 @@
+invoke returned a state: true, decision=APPROVED
+getState after the run finished: IllegalStateException: Missing Checkpoint!
diff --git a/langgraph4j/pom.xml b/langgraph4j/pom.xml
new file mode 100644
index 0000000..7cf4931
--- /dev/null
+++ b/langgraph4j/pom.xml
@@ -0,0 +1,28 @@
+
+
+ 4.0.0
+
+
+ com.ankurm.agents
+ java-ai-agents
+ 1.0.0
+
+
+ langgraph4j
+ LangGraph4j loan-approval agent
+
+
+
+ org.bsc.langgraph4j
+ langgraph4j-core
+ ${langgraph4j.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
diff --git a/langgraph4j/src/main/java/com/ankurm/lg4j/LoanGraph.java b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanGraph.java
new file mode 100644
index 0000000..cdfe509
--- /dev/null
+++ b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanGraph.java
@@ -0,0 +1,88 @@
+package com.ankurm.lg4j;
+
+import org.bsc.langgraph4j.CompileConfig;
+import org.bsc.langgraph4j.CompiledGraph;
+import org.bsc.langgraph4j.GraphStateException;
+import org.bsc.langgraph4j.StateGraph;
+import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver;
+import org.bsc.langgraph4j.checkpoint.MemorySaver;
+
+import java.util.Map;
+
+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;
+
+/**
+ * A loan-approval agent drawn as a graph.
+ *
+ *
+ * START -> intake -> creditCheck -> assessRisk --LOW-----> autoApprove --+
+ * --HIGH----> autoReject --+--> notify -> END
+ * --MEDIUM--> humanReview --+
+ *
+ *
+ * The graph is compiled with {@code interruptBefore("humanReview")}, so a MEDIUM-risk
+ * application pauses with a checkpoint instead of deciding on its own. There is no LLM here
+ * on purpose: every node is deterministic so the run is reproducible. Swapping
+ * {@code assessRisk} for a model call changes one lambda, not the graph.
+ */
+public final class LoanGraph {
+
+ private LoanGraph() {}
+
+ public static CompiledGraph build(BaseCheckpointSaver saver, boolean releaseThread) throws GraphStateException {
+ var graph = new StateGraph<>(LoanState.SCHEMA, LoanState::new)
+ .addNode("intake", node_async(s -> Map.of(
+ LoanState.TRAIL, "intake: " + s.applicant() + " asks for " + s.amount())))
+ .addNode("creditCheck", node_async(s -> {
+ int score = 300 + (s.income() / 1000) * 5; // stand-in for a bureau call
+ return Map.of(
+ LoanState.CREDIT_SCORE, Math.min(score, 850),
+ LoanState.TRAIL, "creditCheck: score " + Math.min(score, 850));
+ }))
+ .addNode("assessRisk", node_async(s -> {
+ double ratio = (double) s.amount() / Math.max(1, s.income());
+ String risk = (s.creditScore() >= 700 && ratio < 1.0) ? "LOW"
+ : (s.creditScore() < 550 || ratio > 4.0) ? "HIGH" : "MEDIUM";
+ return Map.of(
+ LoanState.RISK, risk,
+ LoanState.TRAIL, "assessRisk: ratio " + String.format("%.2f", ratio) + " -> " + risk);
+ }))
+ .addNode("autoApprove", node_async(s -> Map.of(
+ LoanState.DECISION, "APPROVED", LoanState.TRAIL, "autoApprove: approved by policy")))
+ .addNode("autoReject", node_async(s -> Map.of(
+ LoanState.DECISION, "REJECTED", LoanState.TRAIL, "autoReject: rejected by policy")))
+ .addNode("humanReview", node_async(s -> Map.of(
+ LoanState.TRAIL, "humanReview: reviewer decided " + s.decision())))
+ .addNode("notify", node_async(s -> Map.of(
+ LoanState.TRAIL, "notify: told " + s.applicant() + " the result is " + s.decision())))
+ .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)
+ .build();
+ return graph.compile(config);
+ }
+
+ /** Keeps checkpoints after a run finishes, so getState / getStateHistory still work. */
+ public static CompiledGraph build() throws GraphStateException {
+ return build(new MemorySaver(), false);
+ }
+
+ public static Map application(String name, int amount, int income) {
+ return Map.of(LoanState.APPLICANT, name, LoanState.AMOUNT, amount, LoanState.INCOME, income);
+ }
+}
diff --git a/langgraph4j/src/main/java/com/ankurm/lg4j/LoanState.java b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanState.java
new file mode 100644
index 0000000..a9ba71e
--- /dev/null
+++ b/langgraph4j/src/main/java/com/ankurm/lg4j/LoanState.java
@@ -0,0 +1,43 @@
+package com.ankurm.lg4j;
+
+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;
+
+/**
+ * The shared state every node reads and writes.
+ *
+ * State is just a {@code Map} with typed accessors. A key with an
+ * appender channel ({@code trail}) accumulates values; any other key is
+ * overwritten by the latest node that writes it.
+ */
+public class LoanState extends AgentState {
+
+ public static final String APPLICANT = "applicant";
+ public static final String AMOUNT = "amount";
+ public static final String INCOME = "income";
+ public static final String CREDIT_SCORE = "creditScore";
+ public static final String RISK = "risk";
+ public static final String DECISION = "decision";
+ public static final String TRAIL = "trail";
+
+ /** Channel schema: {@code trail} appends, everything else replaces. */
+ public static final Map> SCHEMA =
+ Map.of(TRAIL, Channels.appender(ArrayList::new));
+
+ public LoanState(Map data) {
+ super(data);
+ }
+
+ public String applicant() { return this.value(APPLICANT).orElse("?"); }
+ public int amount() { return this.value(AMOUNT).orElse(0); }
+ public int income() { return this.value(INCOME).orElse(0); }
+ public int creditScore() { return this.value(CREDIT_SCORE).orElse(0); }
+ public String risk() { return this.value(RISK).orElse("UNKNOWN"); }
+ public String decision() { return this.value(DECISION).orElse("PENDING"); }
+ public List trail() { return this.>value(TRAIL).orElse(List.of()); }
+}
diff --git a/langgraph4j/src/test/java/com/ankurm/lg4j/LoanGraphTest.java b/langgraph4j/src/test/java/com/ankurm/lg4j/LoanGraphTest.java
new file mode 100644
index 0000000..5e2f101
--- /dev/null
+++ b/langgraph4j/src/test/java/com/ankurm/lg4j/LoanGraphTest.java
@@ -0,0 +1,115 @@
+package com.ankurm.lg4j;
+
+import org.bsc.langgraph4j.GraphInput;
+import org.bsc.langgraph4j.NodeOutput;
+import org.bsc.langgraph4j.RunnableConfig;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LoanGraphTest {
+
+ @Test
+ void lowRiskIsApprovedWithoutAHuman() throws Exception {
+ var t = new Transcript("01-low-risk-straight-through.txt");
+ var app = LoanGraph.build();
+ var cfg = RunnableConfig.builder().threadId("low").build();
+
+ for (NodeOutput step : app.stream(LoanGraph.application("Asha", 40_000, 120_000), cfg)) {
+ t.line(String.format("%-12s decision=%s", step.node(), step.state().decision()));
+ }
+ var last = app.getState(cfg).state();
+ t.line("final decision: " + last.decision());
+ t.line("trail:");
+ last.trail().forEach(x -> t.line(" " + x));
+ t.write();
+
+ assertEquals("APPROVED", last.decision());
+ assertEquals("LOW", last.risk());
+ assertEquals(5, last.trail().size());
+ }
+
+ @Test
+ void mediumRiskPausesBeforeHumanReviewThenResumes() throws Exception {
+ var t = new Transcript("02-medium-risk-pause-and-resume.txt");
+ var app = LoanGraph.build();
+ var cfg = RunnableConfig.builder().threadId("medium").build();
+
+ t.line("--- first run ---");
+ for (NodeOutput step : app.stream(LoanGraph.application("Ravi", 300_000, 90_000), cfg)) {
+ t.line(String.format("%-12s decision=%s", step.node(), step.state().decision()));
+ }
+ var paused = app.getState(cfg);
+ t.line("paused, next node: " + paused.next());
+ t.line("decision while paused: " + paused.state().decision());
+ assertEquals("humanReview", paused.next());
+ assertEquals("PENDING", paused.state().decision());
+
+ // The human writes their decision into the state at the checkpoint, then we resume.
+ var cfg2 = app.updateState(cfg, java.util.Map.of(LoanState.DECISION, "APPROVED"));
+ t.line("--- resumed after reviewer wrote decision=APPROVED ---");
+ for (NodeOutput step : app.stream(GraphInput.resume(), cfg2)) {
+ t.line(String.format("%-12s decision=%s", step.node(), step.state().decision()));
+ }
+ var done = app.getState(cfg).state();
+ t.line("final decision: " + done.decision());
+ done.trail().forEach(x -> t.line(" " + x));
+ t.write();
+
+ assertEquals("APPROVED", done.decision());
+ assertTrue(done.trail().stream().anyMatch(x -> x.startsWith("humanReview:")));
+ }
+
+ @Test
+ void highRiskIsRejectedAndCheckpointsExistForEveryStep() throws Exception {
+ var t = new Transcript("03-high-risk-and-checkpoint-history.txt");
+ var app = LoanGraph.build();
+ var cfg = RunnableConfig.builder().threadId("high").build();
+
+ app.invoke(LoanGraph.application("Mohan", 900_000, 100_000), cfg);
+ var history = app.getStateHistory(cfg);
+ t.line("checkpoints saved: " + history.size());
+ history.forEach(h -> t.line(String.format(" next=%-12s trail-size=%d", h.next(), h.state().trail().size())));
+ t.line("final decision: " + app.getState(cfg).state().decision());
+ t.write();
+
+ assertEquals("REJECTED", app.getState(cfg).state().decision());
+ assertTrue(history.size() >= 5);
+ }
+
+ @Test
+ void threadsAreIsolated() throws Exception {
+ var t = new Transcript("04-thread-isolation.txt");
+ var app = LoanGraph.build();
+ var a = RunnableConfig.builder().threadId("A").build();
+ var b = RunnableConfig.builder().threadId("B").build();
+
+ app.invoke(LoanGraph.application("Asha", 40_000, 120_000), a);
+ app.invoke(LoanGraph.application("Mohan", 900_000, 100_000), b);
+ t.line("thread A: " + app.getState(a).state().applicant() + " -> " + app.getState(a).state().decision());
+ t.line("thread B: " + app.getState(b).state().applicant() + " -> " + app.getState(b).state().decision());
+ t.write();
+
+ assertEquals("APPROVED", app.getState(a).state().decision());
+ assertEquals("REJECTED", app.getState(b).state().decision());
+ }
+
+ @Test
+ void releasingTheThreadErasesTheStateOfAFinishedRun() throws Exception {
+ var t = new Transcript("05-release-thread-erases-finished-run.txt");
+ var app = LoanGraph.build(new org.bsc.langgraph4j.checkpoint.MemorySaver(), true);
+ var cfg = RunnableConfig.builder().threadId("released").build();
+
+ var result = app.invoke(LoanGraph.application("Asha", 40_000, 120_000), cfg);
+ t.line("invoke returned a state: " + result.isPresent() + ", decision=" + result.get().decision());
+ try {
+ app.getState(cfg);
+ t.line("getState: found a checkpoint");
+ fail("expected the checkpoint to be gone");
+ } catch (IllegalStateException e) {
+ t.line("getState after the run finished: IllegalStateException: " + e.getMessage());
+ t.write();
+ assertEquals("Missing Checkpoint!", e.getMessage());
+ }
+ }
+}
diff --git a/langgraph4j/src/test/java/com/ankurm/lg4j/Transcript.java b/langgraph4j/src/test/java/com/ankurm/lg4j/Transcript.java
new file mode 100644
index 0000000..28c7411
--- /dev/null
+++ b/langgraph4j/src/test/java/com/ankurm/lg4j/Transcript.java
@@ -0,0 +1,23 @@
+package com.ankurm.lg4j;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */
+final class Transcript {
+ private final List lines = new ArrayList<>();
+ private final String file;
+
+ Transcript(String file) { this.file = file; }
+
+ void line(String s) { lines.add(s); }
+
+ void write() throws IOException {
+ Path dir = Path.of("output");
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(file), lines);
+ }
+}
diff --git a/pom.xml b/pom.xml
index f34b42d..1e49008 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,6 +14,7 @@
a2a
jlama
onnx-djl
+ langgraph4j
@@ -24,6 +25,8 @@
1.0.0.Alpha3
0.8.4
0.38.0
+
+ 1.9.5
1.31.0
4.1.1
6.1.3