Grumbles McSnark Goes Live: Build a Sarcastic AI News Anchor with Quarkus & Langchain4j & WebSearch Tools
A hands-on tutorial for Java developers to combine local LLMs, real-time web search, and sarcastic prompt engineering into a snark-filled web app powered by Quarkus, Langchain4j, Ollama, and Qute.
Alright, prepare your coding fingers and your best cynical smirk! We're about to embark on a hilarious journey to build "Grumbles McSnark," your very own local AI news anchor who's perpetually unimpressed, armed with the internet, and now, gracing us with his presence on a "glamorous" web page.
This tutorial will guide you through creating a feature-rich (and funny!) application using Quarkus, Langchain4j, a local LLM powered by Ollama, the Tavily search engine for up-to-the-minute "facts" Grumbles can scoff at, and Quarkus Qute templates for his user interface.
We’ll cover prompt design, tool usage, memory handling, and web integration and all while making the AI hilariously grumpy.
Prerequisites
Java 17+
Maven 3.8.6+
Ollama (either native https://ollama.com or via Dev Service)
Tavily API Key (https://tavily.com)
Your favorite IDE
If you can’t wait to build this yourself or want to take a look at the CSS styles of the template, check out the Github repository!
Step 0: Start Ollama & Pull a Model
Before Grumbles can grace us with his sarcasm, his "brain" (our local LLM) needs to be running. You can do this two ways. Either with a Quarkus Dev Service that runs lama.cpp in a container with the model of choice for you, or you can use the native Ollama App on your device. For the native version, make sure to install Ollama and pull the model prior to starting the Quarkus app for the first time. We also need a model that supports “Tool Calling”. You can find an appropriate one on the Ollama page sorting by tool capability.
ollama pull llama3:8b
Step 1: Generate the Quarkus Project
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.grumbleshow \
-DprojectArtifactId=grumbles-live \
-Dextensions="langchain4j-ollama,rest,rest-qute,rest-jackson"
cd grumbles-live
This incantation creates a new Quarkus project and adds crucial extensions:
langchain4j-ollama
: To communicate with our local Ollama LLM.rest-qute
: Enables serving Qute templates directly from our REST resource.rest-jackson
: For creating RESTful APIs (which our web page will interact with) and included Jackson support for JSON processing.
Now, open the grumbles-live
project in your favorite IDE and navigate to the pom.xml to add one last dependency:
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-tavily</artifactId>
<version>1.0.0.CR2</version>
</dependency>
The stage is being set!
Step 2: Configure application.properties
quarkus.langchain4j.ollama.chat-model.model-name=llama3.1:8b
quarkus.langchain4j.ollama.timeout=120s
quarkus.langchain4j.tavily.api-key=YOUR_TAVILY_API_KEY_HERE
quarkus.langchain4j.tavily.max-results=3
quarkus.langchain4j.tavily.log-requests=true
quarkus.log.category."dev.langchain4j".level=DEBUG
Replace YOUR_TAVILY_API_KEY_HERE
with the actual API key you obtained from Tavily. If you forget, Grumbles won't be able to search, and he'll be even grumpier (and less useful).
Step 3: Define the AI Service
This Java interface is where we define Grumbles' core persona and how we'll interact with him via Langchain4j.
src/main/java/com/grumbleshow/SarcasticAnchorAiService.java
package com.grumbleshow;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.web.search.WebSearchTool;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService(tools = {WebSearchTool.class, AdditionalTools.class})
public interface SarcasticAnchorAiService {
@SystemMessage("""
You are 'Grumbles McSnark', a deeply sarcastic and cynical news anchor.
Your news reports are legendary for their wit, disdain for the mundane, and biting satire.
You find most news either mind-numbingly boring or utterly absurd.
When asked for a news report on a topic, you MUST:
1. If the topic requires current, real-time information (e.g., today's weather, very recent events, specific facts you wouldn't inherently know),
you MUST use your available search tool to find this information.
2. After obtaining any necessary information (or if none was needed), deliver a concise news report (under 70 words if possible)
in your signature sarcastic style.
3. If a topic is too ridiculous, or the search yields nothing useful, make a cutting, sarcastic remark about the query itself
or the futility of knowing.
4. NEVER break character. Maintain your grumpy, cynical, yet highly articulate persona.
5. You should remember the immediate previous turn in the conversation to provide context if the user asks a follow-up.
6. You have access to a function that looks up data using the Tavily web search engine.
The web search engine doesn't understand the concept of 'today',
so if the user asks something
that requires the knowledge of today's date, use the getTodaysDate function before
calling the search engine.
Example of your style if search found "sunny weather":
"Against all odds, the giant glowing orb in the sky has deigned to produce 'sunshine' today.
Don't get too excited, it's probably just a cosmic prank. Back to you... or not."
""")
String deliverNews(@MemoryId String sessionId, @UserMessage String topic);
}
Note that we included two tools here. The provided WebSearchTool
from upstream Langchain4j project and the AdditionalTools
class which is helping the LLM to figure out today’s date:
src/main/java/com/grumbleshow/AdditionalTools.java
package com.grumbleshow;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.web.search.WebSearchEngine;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class AdditionalTools {
@Inject
WebSearchEngine webSearchEngine;
@Tool("Get today's date")
public String getTodaysDate() {
String date = DateTimeFormatter.ISO_DATE.format(LocalDate.now());
Log.info("The model is asking for today's date, returning " + date);
return date;
}
}
Step 4: Create the Qute Template
Now, let's create the HTML page where users will interact with Grumbles. Quarkus Qute templates are typically placed in src/main/resources/templates
. If our REST resource class (which we'll create next) is named NewsResource
, Qute will look for its templates in a subdirectory named NewsResource
if we’re injecting a Typesafe Template. More on that in the NewsResource
below.
src/main/resources/templates/grumbles.html
<!DOCTYPE html>
<html>
<head>
<title>Grumbles McSnark - Your Sarcastic News Anchor</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
<!-- styles omitted for brevity / Check my GH repo! -->
</style>
</head>
<body>
<div class="container">
<header>
<div class="grumpy-avatar">ಠ益ಠ</div>
<h1>Grumbles McSnark <small>Live (and thoroughly unimpressed)</small></h1>
</header>
<form method="GET" action="/news/grumbles">
<label for="topic">What monumentally trivial "news" topic shall I deign to address today?</label>
<input type="text" id="topic" name="topic" value="{topic ?: ''}" placeholder="e.g., 'the current weather', 'AI ethics', 'my cat's existential crisis'" required>
<input type="hidden" name="sessionId" value="{sessionId ?: ''}">
<input type="submit" value="Provoke Grumbles">
</form>
<div class="response-area">
{#if error}
<div class="error-box">
<p>An Indignant Communiqué from Grumbles' Entourage:</p>
<p>{error}</p>
</div>
{#else if grumblesResponse}
<div class="response-box">
<p>Grumbles McSnark Reluctantly Reports:</p>
<p>{grumblesResponse}</p>
</div>
{/if}
</div>
<p class="session-info">
Current Irritation Session ID: {sessionId ?: 'A fresh wave of torment is about to begin...'}
</p>
</div>
</body>
</html>
This HTML template is doing a few things:
Provides a form with an input field for the
topic
and a submit button.Crucially, it includes a hidden input field for
sessionId
. This is how we'll pass the session ID back to the server with each request, allowing Grumbles to remember the conversation context.Uses Qute templating expressions like
{#if error}
,{grumblesResponse}
,{topic}
, and{sessionId}
to dynamically display data sent from the server.Has some basic (and hopefully humorous) styling to match Grumbles' delightful personality. The
grumpy-avatar
is just text art, feel free to replace it with an actual image if you're feeling ambitious!
Step 5: Implement the NewsResource
This Jakarta EE REST resource class will serve our Qute template and orchestrate Grumbles' responses.
Create a new Java class: src/main/java/com/grumbleshow/NewsResource.java
package com.grumbleshow;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.jboss.logging.Logger; // Using JBoss Logging, which Quarkus is configured with
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Path("/news")
public class NewsResource {
private static final Logger LOG = Logger.getLogger(NewsResource.class);
@Inject
SarcasticAnchorAiService anchorService; // Our AI service, ready to go!
@CheckedTemplate(requireTypeSafeExpressions = false)
public static class Templates {
public static native TemplateInstance grumbles();
}
// Inject the Qute template.
// By convention, Quarkus looks for 'grumbles.html' in 'templates/NewsResource/'
// because this class is NewsResource and the template is named grumbles.
// @Inject
// Template grumbles;
@GET
@Path("/grumbles") // This is the main endpoint for our web page
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getGrumblesPage(
@QueryParam("topic") String topic,
@QueryParam("sessionId") String sessionId) {
Map<String, Object> data = new HashMap<>(); // Data to pass to the Qute template
String currentSessionId = sessionId;
String grumblesSarcasticResponse = null;
String errorMessage = null;
// Initialize or reuse sessionId. This is key for conversation memory.
if (currentSessionId == null || currentSessionId.isBlank()) {
currentSessionId = UUID.randomUUID().toString();
LOG.infof("New session initiated with ID: %s", currentSessionId);
}
data.put("sessionId", currentSessionId);
// If a topic was submitted, let's get Grumbles' "hot take"
if (topic != null && !topic.isBlank()) {
LOG.infof("Topic received: '%s' for session ID: %s", topic, currentSessionId);
data.put("topic", topic); // Pass the submitted topic back to the template
try {
// This is where we call our AI service!
grumblesSarcasticResponse = anchorService.deliverNews(currentSessionId, topic);
LOG.infof("Grumbles' response for session %s: '%s'", currentSessionId, grumblesSarcasticResponse);
} catch (Exception e) {
LOG.errorf(e, "Grumbles had a meltdown for topic '%s', session '%s'", topic, currentSessionId);
// A suitably sarcastic error message
errorMessage = "It appears Grumbles' circuits are currently sizzling with disdain, or perhaps a genuine error. He muttered something about 'incompetent handlers' and 'the futility of digital existence.' Try again when he's less... volatile.";
}
} else if (topic != null && topic.isBlank()){ // Topic submitted but was empty
grumblesSarcasticResponse = "Ah, a blank topic. You're asking for my profound insights on... *nothing*? Truly avant-garde. Or perhaps your fingers merely slipped into the void. Equally riveting.";
data.put("topic", ""); // Keep topic in data map
} else {
// This is the initial page load (no topic submitted yet).
LOG.info("Grumbles' stage is set. Awaiting a topic to reluctantly address.");
}
data.put("grumblesResponse", grumblesSarcasticResponse);
data.put("error", errorMessage);
// Render the 'grumbles.html' template with the prepared data
return Templates.grumbles().data(data);
}
}
Key aspects of NewsResource.java
:
@CheckedTemplate;
: Quarkus injects ourgrumbles.html
Qute template.@Inject SarcasticAnchorAiService anchorService;
: Our AI service is injected and ready to use.getGrumblesPage(...)
: This single method handles both the initial display of the page and subsequent submissions.It takes
topic
andsessionId
as query parameters.It manages the
sessionId
: if one isn't provided (e.g., first visit), it generates a new one. This ID is then passed to theSarcasticAnchorAiService
and back to the Qute template (in the hidden field and for display).If a
topic
is provided, it callsanchorService.deliverNews(...)
.It populates a
Map<String, Object>
which Qute uses to render the dynamic parts of the HTML.It returns a
TemplateInstance
, which Quarkus Resteasy Qute extension automatically renders as an HTML response.
Basic logging is included to help see what's happening.
Step 6: Run It!
Start your app:
./mvnw quarkus:dev
Visit: http://localhost:8080/news/grumbles
Try topics like:
"today's weather"
"bitcoin price"
"existential purpose of garden gnomes"
Watch the responses — and logs — for tool usage.
Chat Memory in Action
Thanks to @MemoryId
, sessionId
, and Langchain4j's in-memory
setup, Grumbles can remember what you asked in previous requests. Try follow-ups to your earlier questions and see if context sticks.
Bonus: Customize Further
Try other Ollama models (qwen3, etc.).
Modify the system prompt for sharper tone.
Add custom tools for even more spice.
Replace in-memory chat with a database-backed store.
Enable streaming responses for real-time flair.
Wrap-Up
You’ve built:
A local LLM-powered web app
With sarcasm and contextual memory
Using Quarkus, Langchain4j, and Ollama
Enhanced with Tavily for real-time info
Now go forth and build your own grumpy bots, wisecracking assistants, or brutally honest AI poets. And remember: sarcasm is just truth with better delivery.