A Java Multi-Turn, Multi-Agent Conversational System
Welcome to the tutorial for building java agentic apps powered by LangChain4j and LangGraph4j.
We will build a conversational application with two specialized agents: a career advisor and an education advisor. The graph keeps the conversation history in an in-memory checkpoint and hands the conversation between advisors when the user's intent changes.
You will learn how to:
- Define LangChain4j tools in Java
- Wrap specialized agents in LangGraph4j nodes
- Pause between turns while retaining conversation state
- Route a follow-up turn to another agent using the same thread ID
Prerequisites
Install the following before running the notebook:
- Java 11 or newer. Check with
java -version. - Python and JupyterLab or Jupyter Notebook. On macOS, the Jupyter documentation's Homebrew recipe is
brew install jupyter. On Windows, install Python from python.org and then runpip install jupyterlab(orpip install notebook). - The JJava kernel. Download
jjava-${version}-kernelspec.zipfrom the JJava GitHub releases, unzip it, and install the kernel from the directory containing the unzipped folder:
jupyter kernelspec install jjava-${version}-kernelspec --user --name=java
- Maven 3.9 or newer, available as
mvnonPATH. The dependency cell uses it to download LangGraph4j, LangChain4j, and their transitive dependencies. - Network access to Maven Central for the first dependency download.
- An
OPENAI_API_KEYenvironment variable. The key is read by Java and is never stored in this notebook.
Verify the installation with jupyter kernelspec list, then start Jupyter with jupyter lab or jupyter notebook and select the Java (jjava) kernel. If another Java kernel is already installed under the same name, remove it first with jupyter kernelspec remove java.
These requirements follow the JJava prerequisites.
Step 1: Prepare the Java dependencies
This notebook uses the Java (jjava) kernel and the LangGraph4j/LangChain4j libraries. The tutorial implementation itself is embedded below; only the third-party JARs need to be placed on the kernel classpath. Run the next Java cell once from this notebook. It writes a standalone Maven POM and downloads the dependencies without relying on the repository's Java source tree.
mkdir -p java-notebook-dependencies
cat > java-notebook-dependencies/pom.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>local.notebook</groupId>
<artifactId>langgraph4j-notebook-dependencies</artifactId>
<version>1.0.0</version>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>1.19.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-agent-executor</artifactId>
<version>1.8.26</version>
</dependency>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-langchain4j</artifactId>
<version>1.8.26</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
</dependencies>
</project>
EOF
mvn -f java-notebook-dependencies/pom.xml dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=lib
The standalone POM downloads the direct and transitive dependencies from Maven Central into java-notebook-dependencies/lib. The optional shell commands above are equivalent to the runnable Java cell. The following classpath cell adds those JARs to jjava. No application source files are imported by this notebook.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
Path dependencyDirectory = Path.of("java-notebook-dependencies");
Path pomFile = dependencyDirectory.resolve("pom.xml");
try {
Files.createDirectories(dependencyDirectory);
String pom = """
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>local.notebook</groupId>
<artifactId>langgraph4j-notebook-dependencies</artifactId>
<version>1.0.0</version>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>1.19.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-agent-executor</artifactId>
<version>1.8.26</version>
</dependency>
<dependency>
<groupId>org.bsc.langgraph4j</groupId>
<artifactId>langgraph4j-langchain4j</artifactId>
<version>1.8.26</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
</dependencies>
</project>
""";
Files.writeString(pomFile, pom);
Process maven = new ProcessBuilder(
"mvn", "-f", pomFile.toString(), "dependency:copy-dependencies",
"-DincludeScope=runtime", "-DoutputDirectory=lib")
.inheritIO()
.start();
int exitCode = maven.waitFor();
if (exitCode != 0) {
throw new IllegalStateException("Maven dependency download failed with exit code " + exitCode);
}
System.out.println("Dependencies are ready in " + dependencyDirectory.resolve("lib").toAbsolutePath());
} catch (IOException | InterruptedException exception) {
if (exception instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new IllegalStateException("Could not download Maven dependencies", exception);
}
Dependencies are ready in java-notebook-dependencies/lib
%classpath java-notebook-dependencies/lib/*
Step 2: Configure the model
Set OPENAI_API_KEY in the environment before starting Jupyter. Do not paste an API key into a notebook cell. The model and endpoint can also be overridden for an OpenAI-compatible provider such as DeepInfra.
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.ReturnBehavior;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.GraphDefinition;
import org.bsc.langgraph4j.GraphInput;
import org.bsc.langgraph4j.GraphStateException;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.agentexecutor.AgentExecutor;
import org.bsc.langgraph4j.action.AsyncNodeAction;
import org.bsc.langgraph4j.checkpoint.MemorySaver;
import org.bsc.langgraph4j.langchain4j.serializer.std.LC4jStateSerializer;
import org.bsc.langgraph4j.prebuilt.MessagesState;
import org.bsc.langgraph4j.prebuilt.MessagesStateGraph;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.List;
import java.util.Map;
String apiKey = System.getenv("OPENAI_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("Set OPENAI_API_KEY before running the model cells.");
}
String modelName = System.getenv().getOrDefault("OPENAI_MODEL", "gpt-4o-mini");
String baseUrl = System.getenv().getOrDefault("OPENAI_BASE_URL", "https://api.openai.com/v1");
System.out.println("Using model: " + modelName);
System.out.println("Using endpoint: " + baseUrl);
Using model: gpt-4o-mini
Using endpoint: https://api.openai.com/v1
Step 3: Define the agent tools
The Java implementation expresses tools with LangChain4j's @Tool and @P annotations. All tool classes are defined in the next cell, so the notebook does not depend on application source files. The advisor-specific wrappers expose only the tools that belong to each agent: getCareerPaths, getLearningResources, transfer_to_education_advisor, and transfer_to_career_advisor.
interface Advisor {
List<ChatMessage> respond(List<ChatMessage> messages);
}
class CareerEducationTools {
private static final List<String> CAREER_PATHS =
List.of("data science", "product management", "cybersecurity");
private static final Map<String, List<String>> LEARNING_RESOURCES = Map.of(
"data science", List.of("Coursera: IBM Data Science", "edX: Harvard's Data Science Series"),
"product management", List.of("Udemy: Become a Product Manager", "Reforge Programs"),
"cybersecurity", List.of("Cybrary", "CompTIA Security+ Certification"));
@Tool("Suggest career options based on general user interest.")
public String getCareerPaths() {
return CAREER_PATHS.get(java.util.concurrent.ThreadLocalRandom.current()
.nextInt(CAREER_PATHS.size()));
}
@Tool("Provide online resources or certifications for a given career path.")
public List<String> getLearningResources(
@P("One of: data science, product management, cybersecurity") String career) {
List<String> resources = LEARNING_RESOURCES.get(career.toLowerCase());
if (resources == null) {
throw new IllegalArgumentException("Unsupported career path: " + career);
}
return resources;
}
@Tool(value = "Ask the education advisor agent for help.",
returnBehavior = ReturnBehavior.IMMEDIATE)
public String transferToEducationAdvisor() {
return "Successfully transferred to education advisor.";
}
@Tool(value = "Ask the career advisor agent for help.",
returnBehavior = ReturnBehavior.IMMEDIATE)
public String transferToCareerAdvisor() {
return "Successfully transferred to career advisor.";
}
}
class CareerAdvisorTools {
private final CareerEducationTools tools;
CareerAdvisorTools(CareerEducationTools tools) {
this.tools = tools;
}
@Tool("Suggest career options based on general user interest.")
public String getCareerPaths() {
return tools.getCareerPaths();
}
@Tool(name = "transfer_to_education_advisor",
value = "Ask the education advisor agent for help.",
returnBehavior = ReturnBehavior.IMMEDIATE)
public String transferToEducationAdvisor() {
return tools.transferToEducationAdvisor();
}
}
class EducationAdvisorTools {
private final CareerEducationTools tools;
EducationAdvisorTools(CareerEducationTools tools) {
this.tools = tools;
}
@Tool("Provide online resources or certifications for a given career path.")
public List<String> getLearningResources(
@P("One of: data science, product management, cybersecurity") String career) {
return tools.getLearningResources(career);
}
@Tool(name = "transfer_to_career_advisor",
value = "Ask the career advisor agent for help.",
returnBehavior = ReturnBehavior.IMMEDIATE)
public String transferToCareerAdvisor() {
return tools.transferToCareerAdvisor();
}
}
CareerEducationTools toolSet = new CareerEducationTools();
CareerAdvisorTools careerTools = new CareerAdvisorTools(toolSet);
EducationAdvisorTools educationTools = new EducationAdvisorTools(toolSet);
System.out.println("Example career: " + toolSet.getCareerPaths());
System.out.println("Data science resources: " + toolSet.getLearningResources("data science"));
Example career: data science
Data science resources: [Coursera: IBM Data Science, edX: Harvard's Data Science Series]
Step 4: Create the specialized agents
Each agent uses the same chat model but has a different system prompt and tool set. AgentExecutor provides the ReAct-style tool-calling loop, while the embedded graph controller will decide which advisor receives the next turn.
ChatModel model = OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName(modelName)
.baseUrl(baseUrl)
.temperature(0.0)
.maxRetries(2)
.build();
final CompiledGraph<AgentExecutor.State> careerAgent;
final CompiledGraph<AgentExecutor.State> educationAgent;
try {
careerAgent = AgentExecutor.builder()
.chatModel(model)
.systemMessage(SystemMessage.from(
"You are a career expert. Help users explore career options. "
+ "If they ask about courses or education, transfer to the education advisor. "
+ "Always explain your reasoning before transferring."))
.toolsFromObject(careerTools)
.build()
.compile();
educationAgent = AgentExecutor.builder()
.chatModel(model)
.systemMessage(SystemMessage.from(
"You are an education expert. Recommend learning paths for specific careers. "
+ "If the user changes their career preference, transfer back to the career advisor. "
+ "Always explain your reasoning before transferring."))
.toolsFromObject(educationTools)
.build()
.compile();
} catch (GraphStateException exception) {
throw new IllegalStateException("Could not compile the advisor agents", exception);
}
Step 5: Wrap the agents in LangGraph4j advisors
The embedded Advisor functional interface accepts the complete ChatMessage history and returns the messages generated by an agent. This is the Java equivalent of the Python @task functions.
Advisor careerAdvisor = messages -> careerAgent.invoke(Map.of("messages", messages))
.orElseThrow(() -> new IllegalStateException("Career advisor produced no state"))
.messages();
Advisor educationAdvisor = messages -> educationAgent.invoke(Map.of("messages", messages))
.orElseThrow(() -> new IllegalStateException("Education advisor produced no state"))
.messages();
Step 6: Create the multi-turn controller
The embedded CareerEducationGraph is the LangGraph4j controller. It starts at the career advisor, stores messages in a MemorySaver, interrupts after each answer, and routes the next turn to the education advisor when the user asks about courses, learning, or resources. A request using the same thread ID resumes the checkpointed conversation.
class CareerEducationGraph {
static final String CAREER_ADVISOR = "career_advisor";
static final String EDUCATION_ADVISOR = "education_advisor";
private static final String CAREER_WAIT = "career_wait";
private static final String EDUCATION_WAIT = "education_wait";
private final CompiledGraph<MessagesState<ChatMessage>> graph;
CareerEducationGraph(Advisor careerAdvisor, Advisor educationAdvisor) {
Objects.requireNonNull(careerAdvisor, "careerAdvisor");
Objects.requireNonNull(educationAdvisor, "educationAdvisor");
try {
var serializer = new LC4jStateSerializer<MessagesState<ChatMessage>>(MessagesState::new);
StateGraph<MessagesState<ChatMessage>> workflow = new MessagesStateGraph<>(serializer);
workflow.addNode(CAREER_ADVISOR,
AsyncNodeAction.node_async(state -> invoke(careerAdvisor, state)));
workflow.addNode(EDUCATION_ADVISOR,
AsyncNodeAction.node_async(state -> invoke(educationAdvisor, state)));
workflow.addNode(CAREER_WAIT, AsyncNodeAction.node_async(state -> Map.of()));
workflow.addNode(EDUCATION_WAIT, AsyncNodeAction.node_async(state -> Map.of()));
workflow.addEdge(GraphDefinition.START, CAREER_ADVISOR);
workflow.addEdge(CAREER_ADVISOR, CAREER_WAIT);
workflow.addEdge(EDUCATION_ADVISOR, EDUCATION_WAIT);
workflow.addConditionalEdges(CAREER_WAIT,
state -> CompletableFuture.completedFuture(nextAdvisor(state, CAREER_ADVISOR)),
Map.of(CAREER_ADVISOR, CAREER_ADVISOR, EDUCATION_ADVISOR, EDUCATION_ADVISOR));
workflow.addConditionalEdges(EDUCATION_WAIT,
state -> CompletableFuture.completedFuture(nextAdvisor(state, EDUCATION_ADVISOR)),
Map.of(CAREER_ADVISOR, CAREER_ADVISOR, EDUCATION_ADVISOR, EDUCATION_ADVISOR));
graph = workflow.compile(org.bsc.langgraph4j.CompileConfig.builder()
.checkpointSaver(new MemorySaver())
.interruptAfter(CAREER_WAIT, EDUCATION_WAIT)
.interruptBeforeEdge(true)
.releaseThread(false)
.build());
} catch (GraphStateException exception) {
throw new IllegalStateException("Could not compile the career conversation graph", exception);
}
}
ConversationTurn turn(String threadId, String userInput) {
if (threadId == null || threadId.isBlank()) {
throw new IllegalArgumentException("threadId must not be blank");
}
if (userInput == null || userInput.isBlank()) {
throw new IllegalArgumentException("userInput must not be blank");
}
var config = RunnableConfig.builder().threadId(threadId).build();
Map<String, Object> input = Map.of(MessagesState.MESSAGES_STATE,
List.of(UserMessage.from(userInput)));
GraphInput graphInput = graph.stateOf(config).isPresent()
? GraphInput.resume(input) : GraphInput.args(input);
var outputs = graph.stream(graphInput, config).stream().toList();
if (outputs.isEmpty()) {
throw new IllegalStateException("The conversation graph produced no output");
}
var output = outputs.get(outputs.size() - 1);
String response = output.state().messages().stream()
.filter(AiMessage.class::isInstance).map(AiMessage.class::cast)
.reduce((first, second) -> second).map(AiMessage::text).orElse("");
String advisor = CAREER_WAIT.equals(output.node()) ? CAREER_ADVISOR
: EDUCATION_WAIT.equals(output.node()) ? EDUCATION_ADVISOR : output.node();
return new ConversationTurn(threadId, advisor, response, true);
}
private static Map<String, Object> invoke(Advisor advisor, MessagesState<ChatMessage> state) {
int previousSize = state.messages().size();
List<ChatMessage> generated = advisor.respond(List.copyOf(state.messages()));
if (generated == null || generated.isEmpty()) {
throw new IllegalStateException("Advisor produced no messages");
}
List<ChatMessage> newMessages = generated.size() >= previousSize
&& generated.subList(0, previousSize).equals(state.messages())
? generated.subList(previousSize, generated.size()) : generated;
return Map.of(MessagesState.MESSAGES_STATE, List.copyOf(newMessages));
}
private static String nextAdvisor(MessagesState<ChatMessage> state, String currentAdvisor) {
for (int index = state.messages().size() - 1; index >= 0; index--) {
var message = state.messages().get(index);
if (message instanceof ToolExecutionResultMessage toolResult) {
if ("transfer_to_education_advisor".equals(toolResult.toolName())) {
return EDUCATION_ADVISOR;
}
if ("transfer_to_career_advisor".equals(toolResult.toolName())) {
return CAREER_ADVISOR;
}
}
}
String latestUserText = state.messages().stream()
.filter(UserMessage.class::isInstance).map(UserMessage.class::cast)
.reduce((first, second) -> second).map(UserMessage::singleText)
.orElse("").toLowerCase();
if (CAREER_ADVISOR.equals(currentAdvisor)
&& containsAny(latestUserText, "course", "education", "learn", "resource")) {
return EDUCATION_ADVISOR;
}
if (EDUCATION_ADVISOR.equals(currentAdvisor)
&& containsAny(latestUserText, "career", "change", "different path")) {
return CAREER_ADVISOR;
}
return currentAdvisor;
}
private static boolean containsAny(String text, String... terms) {
for (String term : terms) {
if (text.contains(term)) return true;
}
return false;
}
record ConversationTurn(String threadId, String advisor, String response,
boolean waitingForUser) {}
}
CareerEducationGraph conversation = new CareerEducationGraph(careerAdvisor, educationAdvisor);
String threadId = java.util.UUID.randomUUID().toString();
System.out.println("Conversation thread: " + threadId);
Conversation thread: c67b69e6-2788-48ec-897d-70ae2398ae46
Step 7: Test the multi-turn conversation
The three prompts below use one stable thread ID. The first turn starts with the career advisor; the second asks about courses and is routed to the education advisor; the third remains in the education conversation. This is the Java equivalent of resuming the Python graph with Command(resume=...).
List<String> prompts = List.of(
"I'm interested in technology but not sure what career fits me.",
"That sounds good. What courses should I take to get started?",
"Awesome! Are these resources beginner friendly?");
for (int index = 0; index < prompts.size(); index++) {
String prompt = prompts.get(index);
var turn = conversation.turn(threadId, prompt);
System.out.println("\n--- Conversation Turn " + (index + 1) + " ---");
System.out.println("User: " + prompt);
System.out.println("Advisor: " + turn.advisor());
System.out.println("Assistant: " + turn.response());
}
--- Conversation Turn 1 ---
User: I'm interested in technology but not sure what career fits me.
Advisor: career_advisor
Assistant: Based on your interest in technology, one potential career path to consider is **Data Science**.
Data Science involves using statistical and computational methods to extract insights from structured and unstructured data. It's a broad field that can lead to roles like Data Analyst, Data Scientist, or Machine Learning Engineer. It often appeals to people who enjoy problem-solving, working with numbers, and finding patterns.
Would you like to explore other tech careers, or do you have questions about the education or courses needed to become a Data Scientist? If you're interested in the educational path, I can transfer you to an education advisor.
--- Conversation Turn 2 ---
User: That sounds good. What courses should I take to get started?
Advisor: education_advisor
Assistant: To get started in Data Science, here are two highly recommended courses:
1. **Coursera: IBM Data Science** - This course provides a comprehensive introduction to data science, covering Python, SQL, and data visualization. It's a great starting point for beginners.
2. **edX: Harvard's Data Science Series** - This series offers a more academic approach, starting with foundational statistics and programming in R, which is excellent for building a strong theoretical background.
Would you like more details on either of these courses, or are you interested in exploring a different career path?
--- Conversation Turn 3 ---
User: Awesome! Are these resources beginner friendly?
Advisor: education_advisor
Assistant: Yes, both resources are designed to be beginner-friendly.
* **Coursera: IBM Data Science** is structured to take you from the basics to more advanced topics, making it accessible for those new to the field.
* **edX: Harvard's Data Science Series** also starts with foundational concepts, ensuring that even if you have no prior experience, you can build up your skills step-by-step.
Would you like to dive deeper into either of these courses, or is there anything else I can help you with?
Notes
MemorySaver is process-local and is intended for this tutorial. For a deployed application, replace it with a persistent checkpoint saver and keep the thread ID stable across requests. The same graph is also available through the Java service endpoint documented in java/README.md.
You can find the downloadable jupyter notebook here. It should be fully executable provided you have fulfilled the prequisites.
Enjoy!
Dikran