This tutorial provides a step-by-step guide on how to build a Java application that can send emails based on natural language commands. We will create an AI agent using Quarkus and Langchain4j, equipping it with a “tool” to send emails. This agent will run entirely on your local machine, leveraging Ollama to serve a local Large Language Model (LLM).
Instead of just processing information, our agent will be an actor, capable of interacting with external systems. The goal is to move beyond simple information retrieval and construct a system that can understand a request, reason about the necessary steps, and execute a real-world action: sending an email. By the end of this guide, you will have a working example of an agentic AI system built on a modern, efficient Java stack.
Preparing Your Local AI Environment
Before writing any Java code, you need to set up a local development environment. A local setup gives you full control, ensures data privacy, and creates a rapid development loop.
The Foundation: Ollama and Local Models
Ollama is the cornerstone of our local AI setup. It is a lightweight server that runs large language models on your machine, exposing them through a simple API that your applications can use.
First, install Ollama by following the instructions on its official website. Once installed, use the command line to pull the two models required for this project. This ensures both the reasoning and data-embedding capabilities are available locally.
Pull the chat model, which will act as the agent’s “brain” for understanding prompts and using tools:
ollama pull gpt-oss:20b
Next, pull the embedding model. This model will be used by the Retrieval-Augmented Generation (RAG) system to convert text into numerical representations for searching.
ollama pull nomic-embed-text:v1.5
The Scaffolding: A New Quarkus Project
With the AI models ready, create the application structure using the Quarkus command line interface. This tool scaffolds the project and configures the necessary extensions from the start.
Execute the following command in your terminal:
quarkus create app com.eldermoraes:email-agent \
--extension='rest,quarkus-langchain4j-ollama,quarkus-langchain4j-easy-rag,quarkus-mailer'
This command creates a new project with four critical Quarkus extensions. Each plays a specific role in the agent’s architecture.
Table 1: Project Dependencies (Quarkus Extensions)
Extension | Purpose |
---|---|
quarkus-rest |
Provides the JAX-RS implementation to create a REST endpoint for interacting with the AI agent. |
quarkus-langchain4j-ollama |
Connects the Langchain4j framework to the local Ollama inference server. |
quarkus-langchain4j-easy-rag |
Offers a high-level abstraction that simplifies implementing a Retrieval-Augmented Generation pipeline. |
quarkus-mailer |
Delivers the core functionality for sending emails, which will become the agent’s primary “tool”. |
These extensions are designed to work together, making complex integrations like AI feel native to the Quarkus ecosystem. This allows you to focus on application logic rather than on wiring components together.
The Agent’s Mind: Configuring the AI Service
With the project structure in place, it is time to connect the Java application to the language model. This enables the agent to process language and reason. The integration between Quarkus and Langchain4j provides a declarative, low-boilerplate approach.
Declarative AI in Practice
A core abstraction in Langchain4j is the AiService
. This feature allows you to interact with LLMs through a standard Java interface, abstracting away the complexities of HTTP clients and JSON parsing.
Create a new Java interface named EmailAgent
. By annotating it with @RegisterAiService
, you instruct Quarkus to automatically generate and configure an implementation that communicates with the LLM.
package com.eldermoraes;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface EmailAgent {
String chat(@UserMessage String message);
}
Connecting to Ollama
The @RegisterAiService
annotation tells Quarkus to create a bean, but it needs to know which LLM to use. This connection is defined in the src/main/resources/application.properties
file. These properties configure the bridge to the local gpt-oss:20b
model running in Ollama.
# Configure the chat model to use the gpt-oss:20b model ID from Ollama
quarkus.langchain4j.ollama.chat-model.model-id=gpt-oss:20b
# Set the base URL for the local Ollama server
quarkus.langchain4j.ollama.base-url=http://localhost:11434
# Increase the timeout, as local models can take longer to respond than cloud APIs
quarkus.langchain4j.timeout=60s
Verifying the Connection
To confirm the setup is working, create a simple test endpoint. This provides an immediate feedback loop and verifies communication with the LLM before adding more complex features.
Create a new JAX-RS resource class named AgentResource
and inject the EmailAgent
interface.
package com.eldermoraes;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/agent")
public class AgentResource {
@Inject
EmailAgent agent;
@POST
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
public String agent(String prompt) {
return agent.chat(prompt);
}
}
This integration leverages Quarkus’s Contexts and Dependency Injection (CDI) model. The @RegisterAiService
annotation makes the AI interaction layer behave like any other injectable CDI bean. For developers familiar with CDI, adopting AI services requires no new mental model, making the developer experience smooth and intuitive.
The Agent’s Hands: Forging an Email Tool
An agent that can reason is impressive, but an agent that can act is transformative. In this section, we will give the agent a tool that allows it to perform a real-world action: sending an email.
Introducing Function Calling
This is enabled by a mechanism known as function calling, or tool use. The LLM does not execute Java code directly. Instead, when given a user prompt and a list of available tools, it can generate a structured JSON object requesting to invoke a specific function with parameters it infers from the prompt. The application receives this request, executes the corresponding Java method, and sends the result back to the LLM. The LLM then uses this result to formulate its final response.
Building the EmailService
To encapsulate the email-sending logic, create a new CDI bean named EmailService
. This class will contain the methods exposed to the LLM as tools.
Implement a public method, sendEmail
, that accepts a recipient, subject, and body. The @Tool
annotation from Langchain4j makes this method available to the LLM. The annotation’s description is critical; it’s a natural language explanation that the LLM uses to understand the tool’s purpose and decide when to use it.
package com.eldermoraes;
import dev.langchain4j.agent.tool.Tool;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class EmailService {
@Inject
Mailer mailer;
@Tool("Sends an email to a specified recipient with a given subject and body")
public String sendEmail(String recipient, String subject, String body) {
mailer.send(Mail.withText(recipient, subject, body));
return "OK";
}
}
Implementing the Action with Quarkus Mailer
Inside the sendEmail
method, the Quarkus Mailer
is used to send the email. For development, it’s best to configure the mailer in mock mode. This prevents sending actual emails and instead logs them to the console, providing a safe way to verify the tool’s execution.
Add the following line to application.properties
:
# Enable mock mode to prevent sending real emails during development
quarkus.mailer.mock=true
Enabling the Tool
Finally, make the agent aware of the tool by updating the EmailAgent
interface. The EmailService
class, which contains our @Tool
annotated method, is registered directly within the @RegisterAiService
annotation. This tells Langchain4j that all methods annotated with @Tool
inside EmailService
are available for the LLM to use across all methods in this interface.
package com.eldermoraes;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService(tools = EmailService.class)
public interface EmailAgent {
String chat(@UserMessage String message);
}
Testing the Tool
Now, the agent is equipped to act. Start the application in development mode (./mvnw quarkus:dev
) and trigger the tool using a natural language command via curl
.
curl -X POST -H "Content-Type: text/plain" \
-d "Send an email to test@example.com with the subject 'Hello from your AI Agent' and the body 'This is a test email.'" \
http://localhost:8080/agent
Observe the application console logs. You should see output from the mock mailer confirming the email was “sent.” This proves the LLM correctly interpreted the prompt, identified the sendEmail
tool, extracted the parameters, and initiated the function call.
This is a different way of building applications. Instead of writing step-by-step orchestration logic, you define capabilities using the @Tool
annotation and give the LLM a high-level goal. The LLM then acts as the orchestrator, figuring out which tools to call and with what parameters. This declarative, goal-oriented approach is more flexible and powerful than traditional imperative code.
The Agent’s Memory: Providing Context with Easy RAG
An agent that can act is powerful. An agent that can find and use information to inform its actions is intelligent. To elevate our system, we will provide it with a knowledge base it can consult using Retrieval-Augmented Generation (RAG).
The Need for External Knowledge
Consider a new requirement: “A customer’s new Quantum Widget is broken. Send them an email explaining how to handle a defective product return.” The agent has the tool to send an email, but it lacks a critical piece of information: the correct procedure and contact email for defective returns. This is a classic use case for RAG. The agent must first retrieve relevant information from an external source to complete its task.
Configuring Easy RAG
The quarkus-langchain4j-easy-rag
extension simplifies setting up a RAG pipeline. By adding a few properties, a complete ingestion and retrieval mechanism can be enabled.
Add the following configuration to application.properties
:
# Tell Easy RAG where to find the knowledge documents
quarkus.langchain4j.easy-rag.path=src/main/resources/rag
# Specify that the embedding model is provided by Ollama
quarkus.langchain4j.embedding-model.provider=ollama
# Configure the specific embedding model ID from Ollama
quarkus.langchain4j.ollama.embedding-model.model-id=nomic-embed-text:v1.5
Creating the Knowledge Source
With the configuration in place, create the knowledge source. In the src/main/resources
directory, create a new subdirectory named rag
. Inside this rag
directory, create a Markdown file named product-policy.md
with the following content.
product-policy.md (Knowledge Source)
# Product Return Policy for "Quantum Widget"
## Standard Returns
- Customers can return the Quantum Widget within 30 days of purchase for a full refund.
- The product must be in its original packaging and in unused condition.
- The original receipt is required for all returns.
## Defective Products
- If the Quantum Widget is defective, customers can return it within 90 days for a replacement or a full refund.
- Proof of defect (e.g., photo, description of the issue) may be required.
## Contact for Returns
- To initiate a return, please contact our support team at support@quantumcorp.com.
- For defective product inquiries, please email defects@quantumcorp.com.
Upon startup, Quarkus Easy RAG will automatically find this directory, read the file, split it into chunks, use the nomic-embed-text:v1.5
model to convert those chunks into vector embeddings, and store them in an in-memory vector database.
A Smarter Interaction: The RAG + Tool Synergy
Now, test the complete workflow that combines information retrieval with action execution. Use a prompt that requires the agent to use its new knowledge.
curl -X POST -H "Content-Type: text/plain" \
-d "A customer's Quantum Widget is broken. Send an email to them at customer@example.com explaining how to handle a defective product return." \
http://localhost:8080/agent
The application now follows a more sophisticated execution path:
- The user’s prompt is sent to the
EmailAgent
. - A
RetrievalAugmentor
intercepts the prompt before it goes to the LLM. - The
RetrievalAugmentor
analyzes the prompt, identifies key terms like “Quantum Widget” and “broken,” and uses thenomic-embed-text
model to create a vector embedding of this query. - It performs a similarity search against the vectorized content of
product-policy.md
and finds a strong match with the chunk containing information about defective products, including the emaildefects@quantumcorp.com
. - The augmentor enriches the original prompt with this retrieved context.
- This augmented prompt is sent to the
gpt-oss:20b
model. - The LLM now has all the information it needs: the customer’s request, the correct procedure, and the contact email. It reasons that it should use the
sendEmail
tool. - The LLM generates the JSON request to call
sendEmail(recipient="customer@example.com", subject="Return Policy for Defective Quantum Widget", body="...")
, creating a suitable subject and body based on the retrieved policy. - The application executes the tool call, and the mock email is logged to the console.
Combining RAG and Tools creates a complete perception-action loop. RAG provides the “perception” by allowing the agent to query its knowledge base. Tools provide the “action” by allowing it to interact with external systems. This combination is the foundation for building intelligent, autonomous agents. You can extend this pattern by adding more knowledge sources and more tools.
The Complete Blueprint: Code and Configuration
This section consolidates the complete source code and configuration for the project.
Final Code Review
The following are the final versions of the three Java classes created.
AgentResource.java (The JAX-RS Endpoint)
package com.eldermoraes;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/agent")
public class AgentResource {
@Inject
EmailAgent agent;
@POST
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
public String agent(String prompt) {
return agent.chat(prompt);
}
}
EmailAgent.java (The AiService Interface)
package com.eldermoraes;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService(tools = EmailService.class)
public interface EmailAgent {
String chat(@UserMessage String message);
}
EmailService.java (The Tool Provider)
package com.eldermoraes;
import dev.langchain4j.agent.tool.Tool;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class EmailService {
@Inject
Mailer mailer;
@Tool("Sends an email to a specified recipient with a given subject and body")
public String sendEmail(String recipient, String subject, String body) {
mailer.send(Mail.withText(recipient, subject, body));
return "Email sent successfully.";
}
}
Unified Configuration
The application.properties
file contains all the necessary configuration.
Table 2: Key Configuration Properties (application.properties
)
Property Group | Purpose |
---|---|
quarkus.langchain4j.ollama.* |
Configures the connection to the local Ollama server, specifying the model IDs for both the chat and embedding models, the server URL, and a request timeout. |
quarkus.langchain4j.embedding-model.* |
Explicitly sets the provider for embedding models to Ollama, ensuring Easy RAG uses the correct service. |
quarkus.langchain4j.easy-rag.* |
Enables and configures the Easy RAG pipeline, pointing it to the local directory containing knowledge documents. |
quarkus.mailer.* |
Configures the Quarkus Mailer, specifically enabling mock mode for safe local development and testing. |
application.properties (Complete File)
# ---------------------------------
# Ollama Language Model Configuration
# ---------------------------------
quarkus.langchain4j.ollama.base-url=http://localhost:11434
quarkus.langchain4j.ollama.chat-model.model-id=gpt-oss:20b
quarkus.langchain4j.ollama.embedding-model.model-id=nomic-embed-text:v1.5
quarkus.langchain4j.timeout=60s
# ---------------------------------
# Langchain4j Embedding Model Configuration
# ---------------------------------
quarkus.langchain4j.embedding-model.provider=ollama
# ---------------------------------
# Easy RAG Configuration
# ---------------------------------
quarkus.langchain4j.easy-rag.path=src/main/resources/rag
# ---------------------------------
# Quarkus Mailer Configuration
# ---------------------------------
quarkus.mailer.mock=true
Running the Application
With all code and configuration in place, run the application:
./mvnw quarkus:dev
Once started, execute the final curl
command to trigger the full RAG-and-Tool workflow.
curl -X POST -H "Content-Type: text/plain" \
-d "A customer's Quantum Widget is broken. Send an email to them at customer@example.com explaining how to handle a defective product return." \
http://localhost:8080/agent
Check the console output. You will see logs indicating that the mock mailer has sent an email to customer@example.com
with the correct return information, confirming that the agent successfully completed its perception-action loop.
The Age of the Java Agents
We have constructed a sophisticated AI agent capable of reasoning, retrieving information from a knowledge base, and executing actions in an external system. This was all accomplished using the Java ecosystem and a completely local, private, and open-source AI stack.
This example is just a starting point. The architectural pattern is extensible. Imagine giving the agent a tool to query a customer database or call external REST APIs for real-time data. The agent’s capabilities are limited only by the tools you provide.
The combination of Quarkus’s developer experience and Langchain4j’s powerful abstractions positions Java as a premier platform for building the next generation of agentic AI systems. The future of software is about empowering applications to understand our goals and act on our behalf.
If you run Java at scale, grab the free whitepaper “The Enterprise Guide to AI in Java (POC to Production)”. Download it here.
Excellent, Elder. Would the same scheme work for RAG with a vector database instead of a file.md?
Thanks, Anderson! Definitely yes, but the approach it’s a little bit different. I’ll publish another article to talk about it!