Exploring AI with Groovy™

Author:  Paul King
PMC Member

Published: 2025-10-15 07:06AM (Last updated: 2026-09-19 08:00PM)


Introduction

In this post, we’ll look at several ways to integrate Groovy with AI tools, including Ollama4j, LangChain4j, Spring AI, Embabel, Micronaut LangChain4j, and Quarkus LangChain4j.

Bulcock Beach at Sunset looking towards Pumicestone Passage

We’ll use a simple chat example, perhaps similar to what you might have done yourself when trying out your favourite LLM. We’ll ask for activities to do while on vacation.

To make the examples as accessible as possible, we’ll use Ollama, and use some not-too-large open-source models, which can be run locally. So, no need to get keys, or use up your token limits. But feel free to try out other models and services. Most of the libraries we use here can connect to remote models and services, sometimes just by changing a line or two of config in a properties file.

The examples mostly use the mistral:7b model, which you’ll need to download to run the examples unchanged, but feel free to try other models and see what results you get. Some of the examples also use the qwen3:8b model. It seems to give better results when using tools. See the example repo for how to run the needed services using docker, or within GitHub actions if you don’t have Ollama already running locally. We also used Groovy 6 and JDK 25, but the examples should work on other Groovy and Java versions.

Using Ollama4j

Since we are using Ollama, we’ll start with a library aimed directly at that tool. Ollama4j provides a client for interacting with local or remote Ollama models. The examples here are geared towards local models, but see the Ollama documentation if you want to use Ollama cloud models.

We first create an instance of the Ollama class. We set a generous timeout to allow for longer-running models but otherwise leave the defaults as is. While not strictly necessary, we can call the ping method to verify that the Ollama server is reachable.

var ollama = new Ollama(requestTimeoutSeconds: 300)
println "Found ollama: ${ollama.ping()}"

Which gives this output:

Found ollama: true

Now we can send our prompt to the model:

var prompt = 'What are 4 interesting things to do while I am on vacation in Glasgow?'
var builder = OllamaChatRequest.builder()
    .withModel('mistral:7b')

var request = builder
    .withMessage(OllamaChatMessageRole.USER, prompt)
    .build()

var result = ollama.chat(request, null)
println "Four things:\n$result.responseModel.message.response"

It will respond with something like:

Four things:
 1. Visit the Glasgow Cathedral: This historic cathedral is a must-see in Glasgow. It's one of the 5 great medieval churches
 of Scotland and offers a glimpse into the city's rich history. The adjoining Necropolis cemetery also provides stunning views of the city.

2. Explore the Kelvingrove Art Gallery and Museum: This world-class museum houses over 8,000 objects, including works by
artists like Botticelli, Monet, and Rembrandt. It's a great place to spend a day learning about art, history, and science.

3. Stroll through Glasgow Green Park: One of the city's oldest parks, Glasgow Green offers beautiful landscapes,
a bandstand, a skate park, and even a mini-golf course. It's a perfect spot for a picnic or a leisurely walk.

4. Take a tour of The Tenement House: This well-preserved Victorian dwelling provides a unique insight into the lives of
Glasgow's middle class during the early 20th century. The guided tours are informative and engaging, offering a glimpse into a bygone era.

AI chat calls are stateless, but we can simulate continuing the conversation by including the previous chat history as additional messages in a subsequent request:

var prompt2 = 'If I had half a day and can only go to one, which would you recommend?'
request = builder
    .withMessages(result.chatHistory)
    .withMessage(OllamaChatMessageRole.USER, prompt2)
    .build()

result = ollama.chat(request, null)
println "Best thing:\n$result.responseModel.message.response"

The output might be:

Best thing:
Given that you have only half a day and want to see a mix of history, culture, and local life, I would recommend visiting
the Kelvingrove Art Gallery and Museum. It's centrally located, easily accessible, and offers an extensive collection of
art and artifacts that provide a fascinating glimpse into Glasgow's past and present. Plus, it's free to enter, making it
a great value for your time.
If you prefer outdoor activities or want to experience the local vibe, Glasgow Green Park is another excellent choice.
You can take a leisurely stroll through the park, enjoy the scenery, and even grab a bite at one of the nearby cafes or food trucks.

Using LangChain4j

LangChain4j brings LangChain’s composable AI approach to the JVM. Its unified API might be a good option if you want to experiment with different models and providers later.

Here’s the same initial prompt using its OllamaChatModel interface:

var chatModel = OllamaChatModel.builder()
    .baseUrl("http://localhost:11434")
    .timeout(Duration.ofMinutes(5))
    .modelName("mistral:7b")
    .build()

String prompt = 'What are 4 interesting things to do while I am on vacation in Caloundra?'
println "Response:\n" + chatModel.chat(prompt)

The output might look something like:

Response:
 1. Visit the beautiful beaches: Caloundra is known for its stunning beaches, with Kings Beach and Moffat Beach being some of the most popular ones. You can spend your days sunbathing, swimming, or surfing.

2. Explore the underwater world: Take a trip to the UnderWater World Sea Life Mooloolaba, an aquarium that houses a variety of marine life including sharks, turtles, and seahorses. It's a great way to learn about and appreciate the ocean's wonders.

3. Visit the Glastonbury Estate: This historic homestead offers a glimpse into Australia's past. The estate features beautiful gardens, a tea room, and often hosts various events throughout the year.

4. Take a day trip to the Glass House Mountains: Just a short drive from Caloundra, these iconic volcanic plugs offer breathtaking views and hiking trails for all levels of fitness. You can also visit the Kondalilla National Park for waterfalls and rainforest walks.

Similarly to Ollama4j, we can manually include previous response messages to have a conversation with memory:

var prompt = 'What are 4 interesting things to do while I am on vacation in Caloundra?'
var response = model.chat(new UserMessage(prompt))

var prompt2 = 'If I had half a day and can only go to one, which would you recommend?'
var response2 = model.chat(response.aiMessage(), new UserMessage(prompt2))

println """
Four things:
${response.aiMessage().text()}

Best thing:
${response2.aiMessage().text()}
"""

The output might be something like this:

Best thing:
 If you only have half a day and can only choose one attraction, I would recommend visiting the UnderWater World SEA LIFE Mooloolaba. It's an excellent aquarium that offers a fascinating glimpse into the marine life of the region, and it's suitable for people of all ages.

The UnderWater World is home to a variety of marine animals, including sharks, turtles, stingrays, seahorses, and many more. You can also participate in interactive experiences such as feeding the sharks or holding a starfish. The aquarium also offers educational programs and behind-the-scenes tours for those interested in learning more about marine conservation.

While the Coastal Walk and Glass House Mountains are worth visiting if you have more time, they require more planning and travel time, so I would recommend UnderWater World SEA LIFE Mooloolaba as the best option for a half-day visit.

LangChain4j however, also provides more friendly support to have a conversation with memory using its AiServices builder. We declare the interface of our chat assistant and provide some additional configuration information, and the builder will create our service for us:

interface HolidayAssistant {
    String chat(String message)
}

var model = OllamaChatModel.builder()
    .baseUrl("http://localhost:11434")
    .timeout(Duration.ofMinutes(5))
    .modelName("mistral:7b")
    .build()

var chatMemory = MessageWindowChatMemory.withMaxMessages(10)

var assistant = AiServices.builder(HolidayAssistant)
    .chatModel(model)
    .chatMemory(chatMemory)
    .build()

var prompt = 'What are 4 interesting things to do while I am on vacation in Caloundra?'
var response = assistant.chat(prompt)

var prompt2 = '''
It might rain at some point on the weekend, so can you give me
a very short description of a single backup alternative if it rains?
Make it different to your previous suggestions since I am not
sure which ones I will have already seen by the time it rains.
'''
var response2 = assistant.chat(prompt2)

println """
Four things:
$response

If it rains:
$response2
"""

MessageWindowChatMemory is one of several supported memory implementations. This ones keeps a window of in-memory messages available. Once the max configured number of messages is reached, they fall out of the cache.

The output might be something like this:

Four things:
 1. Visit the beautiful beaches: Caloundra is known for its stunning beaches, with Mooloolaba Beach and Kings Beach being particularly popular. You can spend your days swimming, sunbathing, or even surfing.

2. Explore the Underwater World SEA LIFE Sunshine Coast: This aquarium offers an amazing opportunity to get up close and personal with a variety of marine life, including sharks, stingrays, turtles, and seals.

3. Visit the Bulcock Beach Esplanade: This is a great spot for shopping, dining, and people-watching. The esplanade offers a range of boutiques, cafes, and restaurants. Don't forget to check out the local markets that are held regularly.

4. Take a day trip to Australia Zoo: Made famous by the Crocodile Hunter, Steve Irwin, this zoo is a must-visit for animal lovers. It's home to a wide variety of Australian wildlife and offers interactive experiences and shows throughout the day.

If it rains:
 If it rains, an indoor activity that you might enjoy is visiting the Queensland Air Museum in Caboolture, which is a short drive from Caloundra. The museum houses one of Australia's largest collections of aircraft and aviation artifacts, including military planes, helicopters, and memorabilia. It offers a fascinating look at the history of Australian aviation and is suitable for all ages.

AiServices also supports structured output if we declare that when defining our model, as this example shows:

interface HolidayBot {
    List<Activity> extractActivitiesFrom(String text)
}

var model = OllamaChatModel.builder()
    .baseUrl("http://localhost:11434")
    .supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)
    .timeout(Duration.ofMinutes(5))
    .modelName("mistral:7b")
    .build()

var chatMemory = MessageWindowChatMemory.withMaxMessages(10)

var bot = AiServices.builder(HolidayBot)
    .chatModel(model)
    .chatMemory(chatMemory)
    .build()

var prompt = '''
What are 4 interesting things to do for a weekend away in Caloundra?
Return holiday activity suggestions as structured JSON matching Itinerary.
Give a description, location, day of weekend, and suggested time of day for each of the four activities.
'''
var response = bot.extractActivitiesFrom(prompt)

var prompt2 = '''
If my only spare time is Sunday morning, and I can only go to one activity, which would you recommend?
'''
var response2 = bot.extractActivitiesFrom(prompt2)

println """
Four things:
${response.join('\n')}

Best thing:
${response2.join('\n')}
"""

Instead of returning a String, our chat service is now returning a List<Activity> where Activity is a domain record defined as follows:

@ToString(includePackage = false)
record Activity(String activity, String location, String day, String time) {
}

The RESPONSE_FORMAT_JSON_SCHEMA configuration will represent our domain record in JSON using its record component names and values.

The output might look something like:

Four things:
Activity(Visit Kings Beach, Kings Beach, Caloundra, Saturday, Morning/Afternoon)
Activity(Explore the Coastal Walk from Bulcock Beach to Shelly Beach, Bulcock Beach to Shelly Beach, Caloundra, Saturday, Early Afternoon/Late Afternoon)
Activity(Relax at the Day Spa on Golden Beach, Golden Beach, Caloundra, Sunday, Morning/Afternoon)
Activity(Enjoy Sunset Dinner Cruise, Pelican Waters or Mooloolaba, Caloundra, Sunday, Late Afternoon/Evening)

Best thing:
Activity(Relax at the Day Spa on Golden Beach, Golden Beach, Caloundra, Sunday, Morning)

AiServices also supports tools. Tools allow the LLM to query for information different to what it was trained on when the model was built.

We’ll tweak our example to have a tool for finding "next weekend" and a tool for finding the weather forecast given a location and date. We’ll just have fake weather forecasts but we could call a REST service that provided real-time forecasting information.

Our script now includes two tool definitions and might look something like this:

interface HolidayAssistantTools {
    String chat(String message)
}

@Tool("The LocalDate of the start of the coming weekend")
LocalDate getWeekend() {
    LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
}

@Field static Integer fakeDay = 0

@Tool('The expected domain.Weather including weather forecast, min and max temperature in Celsius for a given location and LocalDate')
Weather getWeather(String location, LocalDate date) {
    var fakeWeather = [0: [Caloundra: new Weather('Sunny and Hot', 30, 37)],
                       1: [Caloundra: new Weather('Raining', 5, 15)]]
    fakeWeather[fakeDay++ % fakeWeather.size()][location]
}

var model = OllamaChatModel.builder()
    .baseUrl("http://localhost:11434")
    .timeout(Duration.ofMinutes(5))
    .modelName("qwen3:8b")
//    .logRequests(true)
//    .logResponses(true)
    .build()

var chatMemory = MessageWindowChatMemory.withMaxMessages(10)

var assistant = AiServices.builder(HolidayAssistantTools)
    .chatModel(model)
    .chatMemory(chatMemory)
    .tools(this)
    .build()

var prompt = '''
Recommend an interesting thing to see in Caloundra for each day of this coming weekend.
Factor in expected weather when making recommendations. Do not hallucinate weather or dates.
'''
var response = assistant.chat(prompt)

println """
Preparing recommendations as at: ${LocalDate.now()}
Interesting things:
$response
"""

We switched to the qwen3:8b model. It is slightly larger to download but does a more reliable job calling tools correctly. The tools are annotated with @Tool and will be automatically found.

The getWeather tool has return type Weather which is another domain object:

@ToString(includePackage = false)
record Weather(String forecast, int minTemp, int maxTemp) {
}

The output might look something like:

Preparing recommendations as at: 2025-11-12
Interesting things:
Here’s a weather-aware recommendation for Caloundra this coming weekend (November 15–16, 2025):

**Saturday, November 15 (Sunny & Hot: 30°C–37°C)**
☀️ **Beach Day at Caloundra Spit**
- Explore the scenic Caloundra Spit, a 12km stretch of sand with wildlife, birdlife, and picnic spots.
- Try snorkeling or swimming in the calm waters (avoid midday sun; visit early morning or late afternoon).
- Tip: Stay hydrated, wear sunscreen, and bring a hat.

**Sunday, November 16 (Raining & Cool: 5°C–15°C)**
🌧️ **Indoor Cultural Activities**
- Visit the **Caloundra Art Gallery** or **Caloundra Library** for indoor browsing and local art exhibits.
- Enjoy a cozy café visit (e.g., **The Coffee Bean & Tea Leaf**) with a book or light meal.
- Tip: Pack an umbrella, layer clothing, and prioritize dry footwear.

Safe travels! 🌊📚

Note that it gave accurate recommendations that include our two fake weather forecasts.

Using Spring AI

Spring AI provides first-class integration with the Spring ecosystem. This would be a good option if you are already using Spring Boot or need its integration and deployment capabilities.

In Groovy, it’s simple to embed AI capabilities into a Spring Boot app. Our entire script is shown below:

@SpringBootApplication
void main() {
    try(var context = SpringApplication.run(Holiday)) {
        var chatClient = context.getBean(ChatClient.Builder).build()
        println chatClient
                .prompt("What are four interesting things to do while I am on vacation in Caloundra?")
                .call()
                .content()
    }
}

We also need to set up a few properties, e.g. in application.properties, to tell Spring AI to use Ollama and our chosen model.

The output might look something like:

 Caloundra, located on the Sunshine Coast of Australia, offers a variety of activities that cater to different interests. Here are some suggestions for an enjoyable vacation:

1. Beaches: Caloundry has several beautiful beaches, including Kings Beach, Moffat Beach, and Bulcock Beach. You can swim, sunbathe, surf, or just enjoy the stunning views.

2. Visit the Underwater World SEA LIFE Mooloolaba: This aquarium is home to a diverse range of marine life, including sharks, turtles, and seahorses. It's a great place for both children and adults to learn about and interact with marine creatures.

3. Explore the Glass House Mountains: These are a series of 12 granite peaks that offer stunning views of the surrounding area. You can hike, picnic, or simply enjoy the panoramic vistas.

4. Visit the Eumundi Markets: Open on Saturdays and Wednesdays, these markets feature over 600 stalls selling art, crafts, produce, and food. It's a great place to pick up unique souvenirs and sample local delicacies.

Spring AI also supports structured outputs — where responses are deserialized into domain objects.

We saw the Activity record previously. Rather than just having a list of Activity, let’s also define a record to capture itineraries of activities:

record Itinerary(List<Activity> itinerary) {
    String display() {
        itinerary.join('\n')
    }
}

These simple records let the AI models return structured data that Groovy can manipulate easily. With our domain records defined, our earlier example can be tweaked as follows:

@SpringBootApplication
void main() {
    try(var context = SpringApplication.run(Holiday)) {
        var chatClient = context.getBean(ChatClient.Builder).build()
        var response = chatClient
                .prompt("What are some interesting things to do while over a long weekend in Caloundra?")
                .call()
                .entity(Itinerary)
        println "Response:\n" + response.display()
    }
}

The output might look something like:

Response:
Activity(Visit Kings Beach, Caloundra, Day 1, Morning)
Activity(Explore Bulcock Beach, Caloundra, Day 1, Afternoon)
Activity(Sunset at Moffat Headland, Caloundra, Day 1, Evening)
Activity(Visit the Australian Zoo, Beerwah, Day 2, Whole Day)
Activity(Relax at Shelly Beach, Caloundra, Day 3, Morning)
Activity(Explore Pumicestone Passage by boat tour, Caloundra, Day 3, Afternoon)

Spring AI also supports tools. Our script might look like this:

@Component
class WeekendTool {
    @Tool(description = 'The LocalDate of the start of the coming weekend')
    LocalDate getWeekend() {
        LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
    }
}

@Component
class WeatherTool {
    static Integer fakeDay = 0

    @Tool(description = 'The expected weather including forecast, min and max temperature in Celsius for a given location and LocalDate')
    Weather getWeather(String location, LocalDate date) {
        var fakeWeather = [0: [Caloundra: new Weather('Sunny and Hot', 30, 37)],
                           1: [Caloundra: new Weather('Raining', 5, 15)]]
        fakeWeather[fakeDay++ % fakeWeather.size()][location]
    }
}

@SpringBootApplication
void main() {
    var prompt = '''
Recommend an interesting thing to see in Caloundra for each day of this coming weekend.
Factor in expected weather when making recommendations. Do not hallucinate weather or dates.
'''
    try(var context = SpringApplication.run(Holiday)) {
        var chatClient = context.getBean(ChatClient.Builder).build()
        var weekend = context.getBean(WeekendTool)
        var weather = context.getBean(WeatherTool)
        var options = OllamaChatOptions.builder().model('qwen3:8b')
        println chatClient
                .prompt(prompt)
                .options(options)
                .tools(weekend, weather)
                .call()
                .content()
    }
}

Note that as of Spring AI 2.0, the options method takes the options builder rather than the built options, so we no longer call build() ourselves.

And the output might look like this:

**Saturday, November 15 (Sunny & Hot: 30°C–37°C)**
Perfect for outdoor adventures!
- **Caloundra Spit** – Explore the scenic coastal walk with panoramic ocean views.
- **Snorkeling at Mooloolaba Beach** – Clear waters and vibrant marine life.
- **Glass House Mountains Day Trip** – A short drive offers dramatic landscapes and hiking.

**Sunday, November 16 (Raining: 5°C–15°C)**
Opt for indoor attractions or sheltered activities:
- **Caloundra Cultural Centre** – Discover local art and history.
- **Caloundra Regional Gallery** – Enjoy contemporary exhibitions.
- **Indoor Water Playground** – A splash-filled escape from the rain.

Always check for real-time weather updates closer to the date! 🌞🌧️

Using Embabel

Embabel is a newer JVM library that provides agent orchestration and LLM integration through a declarative approach.

A simple text-generation example:

@SpringBootApplication
@EnableAgents(loggingTheme = LoggingThemes.STAR_WARS)
void main() {
    try(var context = SpringApplication.run(Holiday)) {
        println context.getBean(OperationContext)
            .ai()
            .withDefaultLlm()
            .generateText('What are four interesting things to do while I am on vacation in Caloundra?')
    }
}

The output might look something like:

 1. Visit the Bulcock Beach: This is a popular beach in Caloundra, perfect for swimming, sunbathing, and enjoying various water sports. There's also a picturesque esplanade with cafes, shops, and art galleries nearby.

2. Explore the Kings Beach Park: Located next to Kings Beach, this park offers a variety of facilities including picnic areas, BBQ facilities, playgrounds, and beautiful views of the ocean. It's a great spot for families with children.

3. Visit the Australian Zoo: Made famous by Steve Irwin, the Australian Zoo is just a short drive from Caloundra. Here you can see a wide variety of Australian wildlife, including kangaroos, koalas, and crocodiles.

4. Take a day trip to the Glass House Mountains: These are a series of 13 steep sided, volcanic plugs that dominate the local landscape. You can hike some of the mountains, or simply enjoy their unique beauty from various lookout points. Some popular ones include Mount Ngungun and Mount Beerwah.

Similarly to Spring AI, Embabel also supports structured data generation:

@SpringBootApplication
@EnableAgents(loggingTheme = LoggingThemes.STAR_WARS)
void main() {
    try(var context = SpringApplication.run(Structured)) {
        println context.getBean(OperationContext)
            .ai()
            .withDefaultLlm()
            .createObject('What are some interesting things to do while I am on vacation in Caloundra?', Itinerary)
            .display()
    }
}

The output might look something like:

Activity(Visit the Kings Beach, Kings Beach, Caloundra, Day 1, Morning)
Activity(Explore Bulcock Beach, Bulcock Beach, Caloundra, Day 1, Afternoon)
Activity(Dine at Mooloolaba Seafood Market, Mooloolaba Seafood Market, Mooloolaba, Day 1, Evening)
Activity(Visit the Aussie World Theme Park, Aussie World, Palmview, Day 2, Whole day)
Activity(Relax at Shelly Beach, Shelly Beach, Caloundra, Day 3, Morning)
Activity(Try Surfing at Currimundi Beach, Currimundi Beach, Currimundi, Day 3, Afternoon)
Activity(Explore the Eumundi Markets, Eumundi Markets, Eumundi, Day 4, Morning to Afternoon)

Autonomous Agents with Embabel

As a final example, let’s look at how Embabel can orchestrate multiple AI calls using its agent model.

Let’s first extend our domain model to be able to support a set of alternative itineraries and allow them to be rated.

record Alternatives(Set<Itinerary> content) {
}

record RatedAlternatives(List<RatedItinerary> content) {
}

record RatedItinerary(Itinerary itinerary, Rating rating) {
}

record Rating(double percentage) { }

Our example uses an @Agent class to coordinate multiple AI actions — generating, rating, and selecting itineraries. The @Action methods that make up the agent’s capabilities are simple to write, with our domain records as inputs and outputs.

@Agent(description = "Creates and ranks itineraries for a holiday at a given location")
class ItineraryAgent {
    @Action
    Alternatives generateItineraries(UserInput userInput, OperationContext context) {
        context.ai()
            .withLlm('qwen3:8b')
            .createObject("Generate 5 alternative sets of: $userInput.content?", Alternatives)
    }

    @Action
    RatedAlternatives rateItineraries(Alternatives alternatives, OperationContext context) {
        new RatedAlternatives(alternatives.content.collect { itinerary ->
            var rating = context.ai()
                .withLlm('mistral:7b')
                .createObject("Rate this itinerary on variety and number of activities: $itinerary?", Rating)
            new RatedItinerary(itinerary, rating)
        })
    }

    @Action
    @AchievesGoal(description = 'Best itinerary')
    RatedItinerary bestItinerary(RatedAlternatives ratedAlternatives) {
        ratedAlternatives.content.max { it.rating.percentage }
    }
}

@SpringBootApplication
@EnableAgents(loggingTheme = LoggingThemes.STAR_WARS)
void main() {
    try(var context = SpringApplication.run(Rated)) {
        println context.getBean(Autonomy)
            .chooseAndRunAgent('Itinerary for a relaxing long-weekend holiday in Caloundra', ProcessOptions.DEFAULT).output
    }
}

Here we are using two different models for different tasks — qwen3:8b for generating itineraries and mistral:7b for rating them. Embabel provides richer ways to control and configure the models, but this simple approach works well for our example. The @AchievesGoal annotation makes use of Embabel’s goal-oriented action planning (GOAP) capabilities.

The output might look something like (slightly formatted here for readability):

RatedItinerary[
    itinerary=Itinerary[itinerary=[
        Activity(Arrival, Caloundra, Friday, Afternoon),
        Activity(Kings Beach Leisure Park, Kings Beach, Friday, Evening),
        Activity(Dinner at Bulcock Street Tavern, Caloundra, Friday, Night),
        Activity(Explore Powerboat Racing Complex, Caloundra, Saturday, Morning),
        Activity(Lunch at Point Cartwright, Point Cartwright, Saturday, Afternoon),
        Activity(Sunset Yoga on Mooloolaba Beach, Mooloolaba Beach, Saturday, Evening),
        Activity(Dinner at Ricky's River Bar & Restaurant, Eumundi, Saturday, Night),
        Activity(Visit the Ginger Factory, Yandina, Sunday, Morning),
        Activity(Relax at Golden Beach, Golden Beach, Sunday, Afternoon),
        Activity(Farewell Dinner, Caloundra, Sunday, Evening),
        Activity(Departure, Caloundra, Monday, Morning)]],
    rating=Rating[percentage=90.0]]

This demonstrates how Embabel’s agent model and Groovy’s expressive syntax can work together to orchestrate multiple AI calls with minimal boilerplate.

Using Micronaut LangChain4j

Micronaut LangChain4j provides integration between Micronaut and LangChain4j. This module is regarded as somewhat experimental and subject to change, but is already quite feature rich. We’ll just look at some basic capabilities.

First, let’s do a basic chat example. This time asking for recommendations for Auckland. Our code might look like this:

@Singleton
class AppRunner {
    @Inject
    HolidayAssistant assistant

    void run() {
        println assistant.activities('What are four good things to see while I am in Auckland?')
    }
}

@AiService
interface HolidayAssistant {
    @SystemMessage('''
    You are knowledgeable about places tourists might like to visit.
    Answer using New Zealand slang but keep it family friendly.
    ''')
    String activities(String userMessage)
}

try(var context = ApplicationContext.run()) {
    context.getBean(AppRunner).run()
}

Micronaut’s sweet spot is creating microservices. Here we are just creating a command-line application, so we aren’t use many Micronaut features, but we will use its dependency injection capabilities. While not strictly needed, a common convention is to have an AppRunner class with a run method to run our application.

We saw previously that LangChain4j had an AiServices builder. Micronaut provides instead the more declarative approach of providing an @AiServices annotation. The code for our assistant will be generated at compile time. Note that we can provide a system message as part of that annotation. Watch out for the NZ slang in some of the responses!

The output might look something like:

 Blimey mate, while you're roaming around Auckland, here are four top-notch spots ya gotta check out:

1. The Sky Tower - It's like the tallest bloke in town, with a view that'll make ya heart race.
2. Waitomo Glowworm Caves - It's a magical spot where tiny luminescent critters put on a light show.
3. Waiheke Island - A chilled-out paradise with beaches and vineyards, perfect for a day trip or longer stay.
4. Auckland Zoo - Get up close and personal with some of New Zealand's native critters, as well as exotic animals from around the world. Kiwi, eh?

Structured output is also supported.

@AiService
interface HolidayBot {
    @SystemMessage('''
    Return holiday activity suggestions as structured JSON matching Itinerary.
    Timebox activities if needed to fit within the holiday length and not overlap other
    activities while still giving enough time to see all major aspects of each attraction.
    Exclude other information.
    ''')
    Itinerary itinerary(String userMessage)
}

try(var context = ApplicationContext.run()) {
    println context.getBean(HolidayBot)
        .itinerary('Four great things to see in Auckland over a weekend.')
        .display()
}

Here we didn’t use the AppRunner convention. It’s just a single bean that we want to invoke after all.

The output might look like:

Activity(Visit Auckland War Memorial Museum, Auckland, Day 1, 9:00 AM - 5:00 PM)
Activity(Explore Viaduct Harbour and Wynyard Quarter, Auckland, Day 1, 6:00 PM - 8:00 PM)
Activity(Hike up Mount Eden, Auckland, Day 2, 9:00 AM - 12:00 PM)
Activity(Visit Waiheke Island and its vineyards, Waiheke Island, Day 2, 1:00 PM - 6:00 PM)

Micronaut supports tools too. We’ll reuse the idea of a fake weather service from earlier, but ask about New Zealand destinations this time.

Our tools are singleton beans with @Tool annotated methods:

@Singleton
class WeekendTool {
    @Tool("The LocalDate of the coming weekend")
    LocalDate getWeekend() {
        LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
    }
}

@Singleton
class WeatherTool {
    @Tool('Gets the expected weather forecast including temperature for a given city and LocalDate')
    Weather getWeather(String city, LocalDate date) {
        println "Looking up weather for $city on $date"
        var fakeWeather = [Auckland: new Weather('sunny', 16, 26),
                           'Mt Hutt': new Weather('hot', 30, 38)]
        fakeWeather[city]
    }
}

Rather than passing tool instances to a builder, as we did with LangChain4j and Spring AI, we name the tool classes in the @AiService annotation, and explain in the system message how we’d like them used:

@AiService(tools = [WeatherTool, WeekendTool])
interface WeatherAwareBot {
    @SystemMessage('''
    You are a travel planning assistant that helps users plan activities for their holidays.
    Before finalizing the itinerary, use the WeatherTool tool for each destination city and each day of the itinerary.
    Use the WeatherTool by creating a JSON object:
    { "tool": "getWeather", "arguments": { "city": "...", "date": "YYYY-MM-DD" } }
    Only after getting results from the tool, produce final Itinerary JSON.
    Use the WeekendTool to work out the date for "the coming weekend".
    Use the tool information to adjust your activity suggestions accordingly.
    Return only an Itinerary JSON structure.
    ''')
    Itinerary chat(String userMessage)
}

For the @Tool methods to be callable at runtime, we need the micronaut-langchain4j-processor annotation processor on our compile classpath. It marks those methods as executable and arranges for them to be registered at startup, so no additional wiring is needed in our script:

try(var context = ApplicationContext.run()) {
    var bot = context.getBean(WeatherAwareBot)
    println bot
        .chat('Four great things to see in Auckland this coming weekend.')
        .display()
    println bot
        .chat('A few things to do in Mt Hutt this coming weekend.')
        .display()
}

The second question is the interesting one. Mt Hutt is a ski field, but our fake forecast says it will be hot, so we don’t want to be offered skiing.

For Auckland, the output might look like:

Activity(Auckland Museum, 155 Queen Street, Auckland, 2025-11-14, 10:00 AM)
Activity(Beachside Stroll, Mission Bay Beach, 2025-11-14, 3:00 PM)
Activity(Harbour Bridge Walk, Auckland Harbour Bridge, 2025-11-15, 9:00 AM)
Activity(Sky Tower Panorama, Sky Tower, 666 Queen Street, 2025-11-15, 2:00 PM)

And for Mt Hutt:

Activity(Mt Hutt Summit Walk, Mt Hutt Summit Track, 2025-11-15, 9:00 AM)
Activity(Lake Hutt Picnic, Lake Hutt, 2025-11-15, 1:00 PM)
Activity(Scenic Drive to Tutea Valley, Tutea Valley, Mt Hutt, 2025-11-16, 10:00 AM)
Activity(Water Activities at Lake Hutt, Lake Hutt, 2025-11-16, 2:00 PM)

Sure enough, no skiing.

Micronaut native images

Groovy 6 improves support for GraalVM native image, and the Micronaut examples can be compiled ahead-of-time into native executables. Micronaut is a good fit here, since its dependency injection and the LangChain4j AI service implementations are generated at compile time rather than being created reflectively at runtime.

The build applies the org.graalvm.buildtools.native plugin and registers one native binary for each of the three Micronaut scripts we’ve just seen. Each gets a compile task, so building and running the tools example looks like this:

./gradlew :micronaut:nativeToolsCompile
micronaut/build/native/nativeToolsCompile/tools

Use GraalVM 25.2 or later to run the build. Earlier versions fail with Could not find target method …​ invalidateSwitchPoints; they substitute a Groovy method which current Groovy versions no longer have.

Native image needs to be told about anything reached reflectively. For our examples, that metadata lives in micronaut/src/main/resources/META-INF/native-image. If you change the examples, you can regenerate it by running them with the native-image agent and copying the result:

./gradlew run.micronaut.Holiday run.micronaut.Structured run.micronaut.Tools -Pagent
./gradlew :micronaut:metadataCopy

New entries are merged into the existing metadata, so delete it first if you want to drop stale entries.

Using Quarkus LangChain4j

The Quarkus LangChain4j extension integrates Large Language Models (LLMs) into your Quarkus applications. Let’s just build a simple chat example. Quarkus also follows the now familiar declarative approach. We can define an AI service like this:

@RegisterAiService
@ApplicationScoped
interface HolidayAssistant {
    @SystemMessage('You are knowledgeable about places tourists might like to visit.')
    String ask(@UserMessage String question)
}

Then our main script would be this:

@QuarkusMain
class Holiday implements QuarkusApplication {

    @Inject
    HolidayAssistant assistant

    @Override
    int run(String... args) {
        def question = 'What are four things to do while visiting Minneapolis?'
        println "Asking: $question"
        def answer = assistant.ask(question)
        println "Answer: $answer"
        return 0
    }
}

Running the example, and note we are asking about Minneapolis this time, might give output like:

Asking: What are four things to do while visiting Minneapolis?
Answer:  1. Explore the Minneapolis Sculpture Garden: This 11-acre outdoor museum located in downtown Minneapolis is home to over 40 works of art, including the iconic "Spoonbridge and Cherry" sculpture. The garden also features walking trails, picnic areas, and a conservatory.

2. Visit the Mill City Museum: Located in the former Washburn A Mill, this museum tells the story of Minneapolis' milling heritage. You can explore exhibits on the city's flour-milling past, take a tour of the restored flour mill elevators, and enjoy panoramic views of the Mississippi River from the rooftop observation deck.

3. Stroll through the Minnehaha Park: This beautiful urban park features hiking trails, a waterfall, and breathtaking views of the Mississippi River. You can also visit Minnehaha Falls, a 53-foot waterfall that is one of the most popular attractions in Minneapolis.

4. Attend a concert or sporting event: Minneapolis is home to several major sports teams, including the Minnesota Vikings (NFL), Minnesota Timberwolves (NBA), and Minnesota Twins (MLB). The city also has a thriving music scene, with venues like First Avenue and the Orpheum Theatre hosting concerts by popular artists. Additionally, the Walker Art Center offers free outdoor performances during the summer months at its Sculpture Garden.

Conclusion

Groovy’s interoperability, concise syntax, and powerful DSL capabilities make it an excellent language for prototyping and composing AI workflows on the JVM. Whether you’re chatting with Ollama, integrating via Spring, Micronaut, or Quarkus, or orchestrating agents with Embabel, Groovy keeps your code clear and compact. And thanks to Groovy 6’s native image support, those examples don’t have to stay on the JVM — the Micronaut ones compile to native executables. Feel free to experiment with different models and prompts to see what interesting results you can achieve!

You can find the full source for all these examples at:
https://github.com/paulk-asert/groovy-ai
Other examples of using Groovy with Spring AI can be found at:
https://github.com/danvega/groovyai

Update history

15/Oct/2025: Initial version
14/Nov/2025: Updated with Micronaut, Quarkus, and AI tools examples.
19/Sep/2026: Updated for the latest library versions and added Micronaut tools and native image examples.