Home / Articles

LangChain4j for Java Teams: Tools, Agents, Skills and A2A in Spring Services

How to bring tool calling, agent pipelines, on-demand skills, pause-and-resume workflows and remote A2A agents into an existing Java service with LangChain4j.

Publisher: REACTAPP.TOP7 min read1718 words

This article is published in English.

Sources: Java AI in 2026, LangChain4j Turns Agents into Ordinary Interfaces

Picture a company that runs on Java. Orders, inventory, risk control and customer service have lived in Spring Boot for years, and now a support agent is wanted that can check an order, issue a refund and pass the case to a person. The tutorials found online start with FastAPI, move on to LangChain and then to a Python environment setup, while the Java service sits idle in the background.

LangChain4j pulls those capabilities into a Java project. It isn't a port of the Python framework, having been rebuilt around how Java developers work. The short version: building agents with it means wiring three layers into a service you already have, namely tools, skills and agents, with no language switch first.

Tools: letting the model call business methods

Plenty of teams get stuck here, not on the model itself but on how it learns which Java method to call. The @Tool annotation handles that. Mark a method with it and the framework turns the method into a JSON Schema the model can read.

Start with dependencies. The OpenAI-compatible module is just one choice. Moving to Azure, Ollama or Qwen mostly means replacing the chat model implementation while the business code stays much the same.

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-open-ai</artifactId>
  <version>1.19.0</version>
</dependency>
<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-agentic</artifactId>
  <version>1.19.0</version>
</dependency>
<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-skills</artifactId>
  <version>1.19.0-beta29</version>
</dependency>

Next, a class of order tools. Nothing exotic: ordinary Java methods whose parameters and return values are described clearly.

The code needs Java 17 or newer. In a plain Maven project, turn on the -parameters compiler flag, or a parameter name like the question placeholder may compile to arg0 and mean nothing to the model. Spring and Quarkus take care of this by default.

class OrderTools {

    @Tool("Look up the status of an order by ID")
    String queryOrder(@P("Order ID") String orderId) {
        return "Order " + orderId + " has shipped and should arrive tomorrow";
    }
    @Tool("Refund the specified order")
    String refund(@P("Order ID") String orderId, @P("Refund reason") String reason) {
        return "Order " + orderId + " refund initiated. Reason: " + reason;
    }
}

Then attach the tools to an interface. The customer simply asks, and the model decides whether to look up an order or start a refund.

interface CustomerServiceAssistant {

@UserMessage("You are an e-commerce customer service assistant. Answer questions about orders and refunds. The customer's question is {{question}}")
    String answer(String question);
}
CustomerServiceAssistant assistant = AiServices.builder(CustomerServiceAssistant.class)
        .chatModel(model)
        .tools(new OrderTools())
        .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
        .build();
String reply = assistant.answer("Where is order 20260907001?");

This is the layer where Java developers feel at home, because writing a query endpoint and exposing a @Tool method to a model turn out to be close cousins.

Agents: an interface plus a shared scope

Orchestration comes down to two ideas. The @Agent annotation sits on a method instead of a class, and an AgenticScope carries results from one agent to the next.

Take three small agents. They never call one another directly. Each writes its result into a shared variable and the next picks it up.

public interface DraftWriter {

@UserMessage("""
            You are a technical editor. Write an opening of no more than 200 words on the topic {{topic}}.
            Return only the opening, with no explanation.
            """)
    @Agent(outputKey = "draft", description = "Writes an opening based on a topic")
    String write(@V("topic") String topic);
}

public interface Sharpener {
    @UserMessage("""
            You are a reviewer. Make the following opening more specific and remove filler.
            The opening is {{draft}}.
            Return only the revised text.
            """)
    @Agent(outputKey = "draft", description = "Makes the opening more specific")
    String polish(@V("draft") String draft);
}

public interface TitlePicker {
    @UserMessage("""
            Write 3 headlines suitable for a technical blog for the following content, one per line.
            The content is {{draft}}.
            """)
    @Agent(outputKey = "titles", description = "Writes headlines for the content")
    String pickTitle(@V("draft") String draft);
}

Every agent stores its output under the variable named by outputKey. The following agent reads that variable via @V, and the chain becomes a pipeline.

DraftWriter writer = AgenticServices
        .agentBuilder(DraftWriter.class)
        .chatModel(model)
        .build();


Sharpener sharpener = AgenticServices
        .agentBuilder(Sharpener.class)
        .chatModel(model)
        .build();
TitlePicker titlePicker = AgenticServices
        .agentBuilder(TitlePicker.class)
        .chatModel(model)
        .build();
UntypedAgent editor = AgenticServices
        .sequenceBuilder()
        .subAgents(writer, sharpener, titlePicker)
        .outputKey("titles")
        .build();
Object result = editor.invoke(Map.of("topic", "Building agents with LangChain4j"));

The benefit is that orchestration turns into reusable parts. Drafting, polishing and titling can each be tested alone and reordered. Loop, parallel and conditional patterns ship with the framework too, so to keep refining output until it clears a quality bar, use loopBuilder with a scoring agent and an exit condition.

Skills: instructions loaded on demand

LangChain4j skills resemble the skill packages of Claude Code more than any annotation. A skill is a directory holding a SKILL.md file. By default the model doesn't see the full text. Only when it judges a task to match does it call activate_skill to load the content, which keeps the starting context small and each call cheaper.

Begin by creating a skill file:

skills/refund/SKILL.md

---
name: refund
description: Handle refund requests. Look up the order, check the conditions, then either refund or escalate to a human.
---
When a customer requests a refund:
1. First call queryOrder to check the order status.
2. Only call refund when the order has shipped or is complete.
3. If the order is still awaiting payment, tell the customer to cancel it.
4. If the amount exceeds 500, escalate to a human instead of refunding directly.

Then connect the skills to the service. Tool mode is the officially recommended route, and the model can reach only the tools you registered, with no filesystem or shell access.

List<FileSystemSkill> loadedSkills = FileSystemSkillLoader.loadSkills(Path.of("skills"));
Skills skillSet = Skills.from(loadedSkills);

CustomerServiceAssistant assistant = AiServices.builder(CustomerServiceAssistant.class)
        .chatModel(model)
        .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
        .tools(new OrderTools())
        .toolProvider(skillSet.toolProvider())
        .systemMessage("You have access to the following skills. When a request relates to one of them, activate it first.\n"
                + skillSet.formatAvailableSkills())
        .build();

The alternative is shell mode, which gives the model a run_shell_command tool. It's handy for prototypes, but in production it deserves caution: commands run straight on the host without any sandbox. Unless input is fully trusted, leave it off.

Pausing and resuming long workflows

In an enterprise setting the hard case isn't a one-question answer but a workflow that gets interrupted in the middle of dozens of steps. Persistence and human approval in LangChain4j target exactly that. To make a workflow recoverable, have its interface extend AgenticScopeAccess and put @MemoryId on the method, which switches on a persistent scope.

public interface OrderWorkflow extends AgenticScopeAccess {

    @Agent
    String processOrder(@MemoryId String orderId, @V("order") String orderDetails);
}

Then chain three steps: validation, human approval and shipping. The approval step returns a SuspendedResponse, so the system saves the current state and frees the thread instead of holding on to it.

AgenticScopeAction validateOrder = AgenticServices.agentAction(scope -> {
    String order = scope.readState("order", "");
    scope.writeState("validated_order", "VALIDATED: " + order);
});


HumanInTheLoop approvalGate = AgenticServices.humanInTheLoopBuilder()
        .description("Large orders require manual approval")
        .outputKey("approval")
        .responseProvider(scope -> new SuspendedResponse<>("manager-approval"))
        .build();
AgenticScopeAction shipOrder = AgenticServices.agentAction(scope -> {
    String validated = scope.readState("validated_order", "");
    String approval = scope.readState("approval", "");
    scope.writeState("result", "Order processed " + approval);
});
OrderWorkflow workflow = AgenticServices.sequenceBuilder(OrderWorkflow.class)
        .subAgents(validateOrder, approvalGate, shipOrder)
        .outputKey("result")
        .build();

The caller deals with suspension through ResultWithAgenticScope. Once a manager approves, the workflow picks up from its checkpoint.

ResultWithAgenticScope<String> result =
        workflow.processOrder("order-10001", "1000 units");

if (result.suspended()) {
    result = result.completePendingResponse("Manager approved");
}

In-memory state alone won't survive a restart, so implement an AgenticScopeStore backed by Redis, a database or the filesystem. The framework checkpoints after each agent finishes and records the planner's position, so on recovery completed steps aren't repeated.

AgenticScopePersister.setStore(new MyAgenticScopeStore());

It looks unremarkable, yet this is what separates a demo agent from a production one. Outages, restarts and approval delays are ordinary events, not corner cases.

A2A: remote agents as local pieces

Across teams, the risk agent may run in one service while customer service belongs to another group. A2A is the glue for such remote agents: pull in the langchain4j-agentic-a2a module, and the orchestration code treats a far-away agent just as it would one in the same process.

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-agentic-a2a</artifactId>
  <version>1.19.0</version>
</dependency>

Declare a local interface that points at the remote A2A service.

public interface RemoteRiskAgent {


@Agent
    String check(@V("transaction") String transaction);
}
UntypedAgent riskAgent = AgenticServices
        .a2aBuilder("https://risk.internal/a2a", RemoteRiskAgent.class)
        .outputKey("risk_result")
        .build();

For multi-turn chats, write contextId and taskId back into the shared scope: the server generates them on the first turn and the client returns them on the second.

public interface ChatAgent {


@A2AClientAgent(a2aServerUrl = "http://localhost:8080", outputKey = "response")
    ResultWithAgenticScope<String> chat(
            @V("question") String question,
            @A2AContextId @V("contextId") String contextId,
            @A2ATaskId @V("taskId") String taskId);
}
ResultWithAgenticScope<String> first = chatAgent.chat("Hello", null, null);
String contextId = (String) first.agenticScope().readState("contextId");
String taskId = (String) first.agenticScope().readState("taskId");
ResultWithAgenticScope<String> second = chatAgent.chat("Continue", contextId, taskId);

Multi-agent cooperation then no longer requires everything to sit in one repository. Teams can own and run the A2A service for their respective agents, while a higher layer snaps the pieces together.

Advice for Java teams

Start narrow. Skip the fully autonomous operations agent and build a support agent that checks orders and issues refunds, then layer skills and human approval on top.

Keep tools and skills apart. Tools stand for what the code is able to do. Skills are the step-by-step procedure presented to the model. Keep tools small and stable, and let skills change often as business rules do, with neither interfering with the other.

Add persistence early for critical workflows. If a flow includes approval waits, remote calls or steps lasting more than a few dozen seconds, configure an AgenticScopeStore up front, since retrofitting recovery usually costs more.

The comfort LangChain4j offers is that agents return to ordinary Java engineering. No single flashy feature carries it. Interfaces, dependency injection, tests, packaging and monitoring all stay, and AI becomes one more capability instead of a reason to rebuild everything.