The Curious Case of the Talking CSV: Building an AI-Powered Java Agent with Quarkus
Follow a Java detective as they investigate a suspicious CSV file with a little help from Quarkus, LangChain4j, and a local LLM sidekick.
Case Introduction: A Spreadsheet Full of Secrets
It started with a CSV file: harmless at first glance. Six months of sales data. No headers missing, no rogue commas. And yet, our detective, let’s call them Dev, knew there was more hidden in those numbers than met the eye.
But Dev wasn’t in the mood for pivot tables. No spreadsheets. No manual parsing. This was 2025, and Dev had something better: an AI sidekick built in Java, trained to read data, reason over it, and talk like a human.
The mission? Build a data analysis agent using Quarkus, LangChain4j, and a local LLM served by Ollama. The result? A web-based detective’s tool, ready to interrogate the CSV and spill its secrets. If you’re an impatient detective or need the tool to work right now without implementing it yourself, go grab it from my Github repository!
Gearing Up: Tools of the Trade
Before we pop open the case file, we need the right equipment:
Java 17+ (detectives don't do outdated tooling)
Maven for build-time rituals
Podman (or Docker) to cage our local LLM
Ollama: the model whisperer, already installed from ollama.com
A model that supports tool calling like
qwen2.5
:
ollama pull qwen2.5
You can use both, Ollama installed with a pulled model or use Quarkus Ollama Dev Services which can spin up a model container when needed. You choose.
Setting Up the Agent’s Lair
Dev fires up a terminal and starts the build.
mvn io.quarkus.platform:quarkus-maven-plugin:3.22.3:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=data-agent \
-Dextensions="rest-jackson,rest-qute,langchain4j-ollama"
cd data-agent
The framework is lightweight and blazing fast which is perfect for a fast-paced investigation. Above Maven command uses latest build as of writing this article. Make sure to check the latest release on quarkus.io.
The Evidence: A Suspicious CSV
Dev copies the dataset into the evidence locker:src/main/resources/data/sales.csv
Month,Revenue
January,1200
February,1500
March,1700
April,1600
May,1800
June,2000
July,1800
August,2400
September,2300
October,1300
November,1400
December,900
Twelve months. One number each. Innocent-looking, but you never know.
Translating the Evidence into Java
To talk JSON, the model needs structure. So Dev sketches a simple POJO:
package org.acme.agent;
public class SaleRecord {
public String month;
public int revenue;
public SaleRecord(String month, int revenue) {
this.month = month;
this.revenue = revenue;
}
}
This becomes the shape of truth, ready to be serialized and handed off to our AI.
Equipping the Toolbelt: The CSV Extraction Tool
Time to build the sidekick’s scanner. It doesn’t just analyzes, it extracts. There are multiple ways to load CSV files in Java. For this example we are using the Apache Commons library. Make sure to add below to your pom.xml:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.10.0</version>
</dependency>
And create the CsvDataTool
afterwards:
package org.acme.agent;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.List;
import dev.langchain4j.agent.tool.Tool;
import jakarta.enterprise.context.ApplicationScoped;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
@ApplicationScoped
public class CsvDataTool {
@Tool("get the sales data for the year")
public List<SaleRecord> getSalesData() {
try {
Reader in = new InputStreamReader(
CsvDataTool.class.getResourceAsStream("/data/sales.csv"));
CSVFormat format = CSVFormat.Builder.create()
.setHeader()
.setSkipHeaderRecord(true)
.build();
CSVParser parser = format.parse(in);
List<CSVRecord> records = parser.getRecords();
List<SaleRecord> result = new ArrayList<>();
for (CSVRecord record : records) {
String month = record.get("Month");
int revenue = Integer.parseInt(record.get("Revenue"));
result.add(new SaleRecord(month, revenue));
}
return result;
} catch (Exception e) {
return List.of(new SaleRecord("Error", 0));
}
}
}
No reasoning. No formatting. Just structured evidence.
Giving the Agent Its Voice and Purpose
Dev wires up the agent interface that is giving the model both the tool and the mission.
package org.acme.agent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService(tools = CsvDataTool.class)
public interface DataAgent {
@SystemMessage("""
You are a helpful data analysis assistant. If the user ask about sales, revenue, or trends,
use the 'getSalesData' tool to gather the sales dataset for the year in JSON format.
Use it to calculate, compare, and summarize as needed.
Respond with the result in a single sentence.
""")
String chat(@UserMessage String userInput);
}
The AI understands context. Knows when to call its tools. Knows how to speak. But who’s the secret helper? Lets not forget to tell Quarkus dev services which model to use and give it some more time to think for it’s answer. Add below to the resources/application.properties
quarkus.langchain4j.ollama.chat-model.model-id=qwen2.5
quarkus.langchain4j.ollama.timeout=40s
A Public Interface for Case Briefings
The detective builds a REST interface to talk to the agent:
package org.acme.agent;
import org.jboss.logging.Logger;
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("/chat")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.TEXT_PLAIN)
public class ChatResource {
public static Logger LOG = Logger.getLogger(ChatResource.class);
@Inject
DataAgent agent;
@POST
public String chat(String userInput) {
LOG.info("Chat request received: " + userInput);
return agent.chat(userInput);
}
}
Lets start the interview by getting the dev mode up and running:
quarkus dev
The machine might need to warm up a little. Wait for the model to be downloaded locally if you haven’t preloaded it or are running the Dev Services.
Interrogation begins. Curl is their first line of questioning:
curl -X 'POST' \
'http://localhost:8080/chat' \
-H 'accept: text/plain' \
-H 'Content-Type: text/plain' \
-d 'Which month had the highest revenue?'
The Front Office: A Qute-Based Chat Terminal
The public needed access. Dev knew not all detectives liked terminals.
src/main/resources/templates/chat.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CSV Chat Agent</title>
</head>
<body>
<h1>Talk to your CSV</h1>
<form action="/" method="post">
<label for="userInput">Ask a question:</label><br/>
<input type="text" id="userInput" name="userInput" value="{userInput ?: ' '}" size="50"/><br/><br/>
<button type="submit">Send</button>
</form>
{#if response.orEmpty}
<h3>Agent says:</h3>
<p>{response}</p>
{/if}
</body>
</html>
Qute REST Controller
package org.acme;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.acme.agent.DataAgent;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.RestForm;
@Path("/")
public class ChatPageResource {
public static Logger LOG = Logger.getLogger(ChatPageResource.class);
@Inject
DataAgent agent;
@Inject
Template chat;
@POST
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getPage(@RestForm String userInput) {
String response = agent.chat(userInput);
LOG.info("Chat request received: " + userInput);
return chat.data("userInput", userInput, "response", response);
}
@GET
@Produces(MediaType.TEXT_HTML)
public String get() {
return chat.render();
}
}
Interrogate the Evidence
quarkus dev
Then head to
http://localhost:8080
Ask:
What is the average revenue?
Which month performed best?
Summarize the second quarter’s performance.
The agent responds, most of the time accurately, conversationally, intelligently.
When It Works—and When It Doesn’t
Passing structured data to the model as JSON works beautifully when:
The dataset is small enough for the context window
You want flexible, human-like interpretation
You're asking questions that go beyond “just math”
But if you're dealing with:
Large datasets
Auditable financial logic
Compliance and precision
…then good old Java-side computation still earns its trench coat and badge.
This architecture lets you mix both: let the model handle soft logic, and enforce hard rules on the backend if needed.
What’s Next? The Data Detective’s Challenge
You’ve just built a Quarkus-powered, JSON-wielding, LLM-guided agent that reads and reasons about structured data. Locally. Securely. In Java.
Don’t forget to check out my other agent tutorial:
Local AI with LangChain4j and Quarkus: Build an Email Task Extractor with Tool Calling
This tutorial walks you through building a simple but powerful Quarkus application that uses two local LLMs (running via Ollama) to simulate a workflow: generating team emails, extracting actionable tasks from them using tool calling, and logging a simulated AI reply.
But this case also isn’t closed yet. There’s more to add:
What if you swapped CSVs for live databases?
Visualized responses using Qute and Chart.js?
Stored chat histories for deeper insights?
Hooked in more tools for geo-analysis, anomaly detection, or even natural language SQL?
The data is out there.
The tools are ready.
You are the detective.
So… what will your agent investigate next?