Build a Quarkus Supervisor with LangChain4j Skills
A hands-on walkthrough that shows how skill files, routing tools, and tests turn vague agent names into explicit ownership.
The org chart is not a routing strategy. Put more than one agent behind a supervisor and the first version usually looks fine: give each specialist a name, add a short description, and hope the model reads the room. That works until checkout money, APIs, and deploy timing all land in the same sentence.
That is the part toy demos quietly skip. Production prompts mix domains, and the model only sees text. If finance owns invoices, devops owns deploys, and support owns customer-visible incidents, you need a sharper contract than “Alex handles billing stuff.”
Skills (see Agent Skills and LangChain4j Skills) put those ownership rules in files: name, description, and a body the model loads through tools instead of one more swollen system message. In Quarkus, Skills integrate in tool mode only today. That is a good fit for a service. Java @Tool methods stay your execution surface, and the skill becomes the policy layer.
We build ClearDesk around that idea: three specialists (support, finance, devops), a supervisor that can run with filesystem Skills or without them, JSON at GET /.well-known/agent-skills, and tests that prove the routing difference with RestAssured plus a deterministic ChatModel.
Prerequisites
You should be comfortable reading Quarkus application.properties and normal Java package layout. Ollama is only for manual runs. The test profile swaps in a stubbed ChatModel, so the build stays local and boring.
Java 21+
Quarkus CLI (
quarkuson yourPATH)Ollama for live
./mvnw quarkus:devruns
Project setup
Start from a normal generated Quarkus app:
quarkus create app dev.cleardesk:cleardesk-skills \
--package-name=dev.cleardesk \
--extensions=rest-jackson,langchain4j-ollama,quarkus-langchain4j-skills \
--no-code \
-DjavaVersion=21Change into cleardesk-skills after generation. rest-jackson gives us the HTTP and JSON surface. langchain4j-ollama wires the local chat model. Everything else we add by hand because the interesting part of this sample starts above the generated baseline. The full source code is available in my Github repository.
Add RestAssured
For testing.
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>At this point ./mvnw compile should succeed:
./mvnw compileImplementation
Start with the properties that shape the tool loop
Set src/main/resources/application.properties early:
# Ollama (local, no API keys)
quarkus.langchain4j.ollama.base-url=http://localhost:11434
quarkus.langchain4j.ollama.chat-model.model-id=${OLLAMA_MODEL:qwen2.5:7b}
quarkus.langchain4j.ollama.chat-model.log-requests=true
quarkus.langchain4j.ollama.chat-model.log-responses=true
# LangChain4j Skills (tool mode only in Quarkus)
quarkus.langchain4j.skills.directories=classpath:skills
quarkus.langchain4j.chat-memory.memory-window.max-messages=30
quarkus.langchain4j.ai-service.max-tool-executions=20Three lines do most of the work here. skills.directories must match the SKILL.md trees under src/main/resources. max-tool-executions needs headroom for activate_skill plus one delegate call. max-messages keeps a tool-heavy chat from burning the whole memory window.
Put ownership in skill files
This is the whole point of the sample, so do it early. Create three directories and three SKILL.md files. YAML front matter is required.
src/main/resources/skills/support-triage/SKILL.md
---
name: support-triage
description: Customer-facing support desk — ticket intake, severity classification, and routing incidents that affect end users (not money movement and not build pipelines).
---
When this skill applies, the user is asking about **user-visible outages**, **SLAs**, **ticket numbers**, or **customer impact**.
After you activate this skill, call `routeToSupport` with a one-line reason. Then follow up using support-only tools if needed.
Do not use this skill for invoice disputes, refunds, or payment processors — that is finance. Do not use it for CI, Kubernetes, or deploy pipelines — that is devops.
src/main/resources/skills/finance-ops/SKILL.md
---
name: finance-ops
description: Billing, refunds, invoices, payment processors, revenue recognition, and anything where money or invoices move — including when a broken API blocks checkout or invoicing.
---
When this skill applies, the user mentions **refunds**, **invoices**, **charges**, **billing**, **payment**, **checkout totals**, or **accounts receivable**.
After you activate this skill, call `routeToFinance` with a one-line reason.
Even if the words "API" or "production" appear, choose finance when the failure is about **money capture**, **invoices**, or **billing workflows** — not about deploy infrastructure.
src/main/resources/skills/dev-ops/SKILL.md
---
name: dev-ops
description: Build systems, CI/CD, Kubernetes, deploy pipelines, feature flags in delivery infrastructure, and service runtime operations owned by platform — not customer ticket queues and not accounts payable.
---
When this skill applies, the user is focused on **pipelines**, **deploys**, **clusters**, **rollbacks**, **build failures**, or **infrastructure** changes.
After you activate this skill, call `routeToDevOps` with a one-line reason.
Do not pick this skill when the problem is really about **refunds or invoices** — finance owns money paths even when an HTTP API is mentioned.
Record the routing decision
src/main/java/dev/cleardesk/routing/Specialist.java
package dev.cleardesk.routing;
public enum Specialist {
SUPPORT,
FINANCE,
DEVOPS
}src/main/java/dev/cleardesk/routing/RoutingTrace.java
package dev.cleardesk.routing;
import jakarta.enterprise.context.RequestScoped;
@RequestScoped
public class RoutingTrace {
private Specialist last;
public void record(Specialist specialist) {
this.last = specialist;
}
public Specialist getLastRoutedSpecialist() {
return last;
}
public void reset() {
this.last = null;
}
}Give each specialist a small surface area
Keep the specialist tools deliberately small. We want routing mistakes to show up clearly in logs and tests, not disappear under fake enterprise depth.
Create three @ApplicationScoped beans under src/main/java/dev/cleardesk/specialists/. Each exposes two @Tool methods and uses JBoss Logging (org.jboss.logging.Logger), not System.out.
SupportSpecialistTools.java
package dev.cleardesk.specialists;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import dev.langchain4j.agent.tool.Tool;
@ApplicationScoped
public class SupportSpecialistTools {
private static final Logger LOG = Logger.getLogger(SupportSpecialistTools.class);
@Tool("Creates a support intake record for triage (support scope only).")
public String recordSupportIntake(String summary) {
LOG.debugf("support intake: %s", summary);
return "support:intake:" + summary;
}
@Tool("Looks up a ticket id in the mock support system.")
public String lookupTicket(String ticketId) {
return "support:ticket:" + ticketId;
}
}FinanceSpecialistTools.java
package dev.cleardesk.specialists;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import dev.langchain4j.agent.tool.Tool;
@ApplicationScoped
public class FinanceSpecialistTools {
private static final Logger LOG = Logger.getLogger(FinanceSpecialistTools.class);
@Tool("Requests a refund for a charge (finance scope only).")
public String requestRefund(String chargeId, String reason) {
LOG.debugf("refund %s: %s", chargeId, reason);
return "finance:refund:" + chargeId;
}
@Tool("Looks up an invoice by id.")
public String lookupInvoice(String invoiceId) {
return "finance:invoice:" + invoiceId;
}
}DevOpsSpecialistTools.java
package dev.cleardesk.specialists;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import dev.langchain4j.agent.tool.Tool;
@ApplicationScoped
public class DevOpsSpecialistTools {
private static final Logger LOG = Logger.getLogger(DevOpsSpecialistTools.class);
@Tool("Triggers a deploy placeholder for a service (platform scope).")
public String triggerDeployPreview(String serviceName) {
LOG.debugf("deploy preview %s", serviceName);
return "devops:deploy:" + serviceName;
}
@Tool("Reads mock CI pipeline status.")
public String checkPipeline(String pipelineId) {
return "devops:pipeline:" + pipelineId;
}
}Keep the supervisor on one boundary
The supervisor should delegate once and get out of the way.
src/main/java/dev/cleardesk/supervisor/SupervisorDelegateTools.java
package dev.cleardesk.supervisor;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import dev.cleardesk.routing.RoutingTrace;
import dev.cleardesk.routing.Specialist;
import dev.cleardesk.specialists.DevOpsSpecialistTools;
import dev.cleardesk.specialists.FinanceSpecialistTools;
import dev.cleardesk.specialists.SupportSpecialistTools;
import dev.langchain4j.agent.tool.Tool;
@ApplicationScoped
public class SupervisorDelegateTools {
private static final Logger LOG = Logger.getLogger(SupervisorDelegateTools.class);
@Inject
RoutingTrace routingTrace;
@Inject
SupportSpecialistTools supportSpecialistTools;
@Inject
FinanceSpecialistTools financeSpecialistTools;
@Inject
DevOpsSpecialistTools devOpsSpecialistTools;
@Tool("Routes the request to Support (tickets, customer-visible incidents, SLAs).")
public String routeToSupport(String reason) {
LOG.infof("route support: %s", reason);
routingTrace.record(Specialist.SUPPORT);
return supportSpecialistTools.recordSupportIntake(reason);
}
@Tool("Routes the request to Finance (refunds, invoices, billing, payment capture).")
public String routeToFinance(String reason) {
LOG.infof("route finance: %s", reason);
routingTrace.record(Specialist.FINANCE);
return financeSpecialistTools.lookupInvoice("auto-from:" + reason);
}
@Tool("Routes the request to DevOps (CI/CD, deploys, clusters, build pipelines).")
public String routeToDevOps(String reason) {
LOG.infof("route devops: %s", reason);
routingTrace.record(Specialist.DEVOPS);
return devOpsSpecialistTools.checkPipeline("auto-from:" + reason);
}
}Keep chat memory predictable
src/main/java/dev/cleardesk/supervisor/ClearDeskChatMemoryProviderSupplier.java
package dev.cleardesk.supervisor;
import java.util.function.Supplier;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
public class ClearDeskChatMemoryProviderSupplier implements Supplier<ChatMemoryProvider> {
@Override
public ChatMemoryProvider get() {
return new ChatMemoryProvider() {
@Override
public ChatMemory get(Object memoryId) {
return MessageWindowChatMemory.withMaxMessages(30);
}
};
}
}Build the Skills-backed contract
This provider does one important job: pull the live skill catalogue into the system message. Resolve SkillsToolProvider with CDI.current() so injection still works when the AI service synthetic bean calls your provider.
src/main/java/dev/cleardesk/supervisor/ClearDeskSkillsSystemMessageProvider.java
package dev.cleardesk.supervisor;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.enterprise.inject.spi.CDI;
import io.quarkiverse.langchain4j.runtime.aiservice.SystemMessageProvider;
import io.quarkiverse.langchain4j.skills.runtime.SkillsToolProvider;
@ApplicationScoped
public class ClearDeskSkillsSystemMessageProvider implements SystemMessageProvider {
@Override
public Optional<String> getSystemMessage(Object memoryId) {
Instance<SkillsToolProvider> skillsToolProvider = CDI.current().select(SkillsToolProvider.class);
if (skillsToolProvider.isResolvable()) {
String available = skillsToolProvider.get().getSkills().formatAvailableSkills();
String body = """
You are ClearDesk, an internal supervisor for three specialists.
Routing rules:
- When the user request matches one of the skills below, call `activate_skill` with that skill name first,
read the skill body, then call exactly one of `routeToSupport`, `routeToFinance`, or `routeToDevOps`
as instructed by the skill.
- If none of the skills fit, still pick the single best specialist using the skill descriptions as a contract,
then call the matching route tool (you may activate the closest skill if helpful).
Available skills:
%s
"""
.formatted(available);
return Optional.of(body);
}
return Optional.empty();
}
}Keep a real baseline beside the Skills path
With skills — default tool provider (Skills extension registers activate_skill), plus @ToolBox(SupervisorDelegateTools.class), ClearDeskSkillsSystemMessageProvider, and user template:
package dev.cleardesk.supervisor;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
import io.quarkiverse.langchain4j.ToolBox;
import jakarta.enterprise.context.ApplicationScoped;
@RegisterAiService(
chatMemoryProviderSupplier = ClearDeskChatMemoryProviderSupplier.class,
systemMessageProviderSupplier = ClearDeskSkillsSystemMessageProvider.class)
@ApplicationScoped
public interface SupervisorWithSkillsAssistant {
@UserMessage("{{prompt}}")
@ToolBox(SupervisorDelegateTools.class)
String handle(@MemoryId String memoryId, String prompt);
}
Baseline (no Skills tools) — toolProviderSupplier = RegisterAiService.NoToolProviderSupplier.class so only delegate tools exist; vague @SystemMessage:
package dev.cleardesk.supervisor;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
import io.quarkiverse.langchain4j.ToolBox;
import jakarta.enterprise.context.ApplicationScoped;
@RegisterAiService(
chatMemoryProviderSupplier = ClearDeskChatMemoryProviderSupplier.class,
toolProviderSupplier = RegisterAiService.NoToolProviderSupplier.class)
@ApplicationScoped
public interface SupervisorBaselineAssistant {
@SystemMessage(
"""
You are ClearDesk supervisor. You must call exactly one routing tool: routeToSupport, routeToFinance, or routeToDevOps.
Vague org chart: Alex is support, Sam is finance, Jordan is devops. When the user message is ambiguous, guess from loose keywords
(for example: "API" and "production" could sound like devops even if the issue is about checkout money).
""")
@UserMessage("{{prompt}}")
@ToolBox(SupervisorDelegateTools.class)
String handle(@MemoryId String memoryId, String prompt);
}Use dev.langchain4j.service.MemoryId for the memory parameter.
Expose the same catalogue over HTTP
The catalogue should not exist only inside prompt assembly. Expose the same data over HTTP so humans and scripts can inspect what the supervisor sees.
src/main/java/dev/cleardesk/catalog/SkillSummary.java
package dev.cleardesk.catalog;
public record SkillSummary(String name, String description) {
}src/main/java/dev/cleardesk/catalog/SkillCatalogService.java
package dev.cleardesk.catalog;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import dev.langchain4j.skills.ClassPathSkillLoader;
import dev.langchain4j.skills.FileSystemSkill;
@ApplicationScoped
public class SkillCatalogService {
private final List<FileSystemSkill> skills;
public SkillCatalogService() {
this.skills = List.copyOf(ClassPathSkillLoader.loadSkills("skills"));
}
public List<FileSystemSkill> fileSystemSkills() {
return skills;
}
public List<SkillSummary> summaries() {
return skills.stream().map(s -> new SkillSummary(s.name(), s.description())).toList();
}
}src/main/java/dev/cleardesk/wellknown/AgentSkillsDocument.java
package dev.cleardesk.wellknown;
import java.util.List;
import dev.cleardesk.catalog.SkillSummary;
public record AgentSkillsDocument(String version, List<SkillSummary> skills) {
}
src/main/java/dev/cleardesk/wellknown/AgentSkillsResource.java — single class-level @Path so curl hits the same URL the LangChain4j skills dir describes:
package dev.cleardesk.wellknown;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import dev.cleardesk.catalog.SkillCatalogService;
@Path("/.well-known/agent-skills")
public class AgentSkillsResource {
@Inject
SkillCatalogService skillCatalogService;
@GET
@Produces(MediaType.APPLICATION_JSON)
public AgentSkillsDocument list() {
return new AgentSkillsDocument("cleardesk-v1", skillCatalogService.summaries());
}
}Add the REST entry point
src/main/java/dev/cleardesk/api/ClearDeskChatRequest.java
package dev.cleardesk.api;
public class ClearDeskChatRequest {
public String prompt;
public boolean skillsEnabled = true;
public String memoryId;
}src/main/java/dev/cleardesk/api/ClearDeskChatResponse.java
package dev.cleardesk.api;
public class ClearDeskChatResponse {
public String reply;
public String memoryId;
public boolean skillsEnabled;
public String routedSpecialist;
public ClearDeskChatResponse() {
}
public ClearDeskChatResponse(String reply, String memoryId, boolean skillsEnabled, String routedSpecialist) {
this.reply = reply;
this.memoryId = memoryId;
this.skillsEnabled = skillsEnabled;
this.routedSpecialist = routedSpecialist;
}
}src/main/java/dev/cleardesk/api/SupervisorResource.java
package dev.cleardesk.api;
import java.util.UUID;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import dev.cleardesk.routing.RoutingTrace;
import dev.cleardesk.routing.Specialist;
import dev.cleardesk.supervisor.SupervisorBaselineAssistant;
import dev.cleardesk.supervisor.SupervisorWithSkillsAssistant;
@Path("/clear-desk")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class SupervisorResource {
@Inject
SupervisorWithSkillsAssistant withSkillsAssistant;
@Inject
SupervisorBaselineAssistant baselineAssistant;
@Inject
RoutingTrace routingTrace;
@POST
@Path("/chat")
public ClearDeskChatResponse chat(ClearDeskChatRequest request) {
if (request.prompt == null || request.prompt.isBlank()) {
throw new IllegalArgumentException("prompt is required");
}
String memoryId = request.memoryId != null && !request.memoryId.isBlank()
? request.memoryId
: UUID.randomUUID().toString();
String reply = request.skillsEnabled
? withSkillsAssistant.handle(memoryId, request.prompt)
: baselineAssistant.handle(memoryId, request.prompt);
Specialist specialist = routingTrace.getLastRoutedSpecialist();
String routed = specialist != null ? specialist.name() : null;
return new ClearDeskChatResponse(reply, memoryId, request.skillsEnabled, routed);
}
@GET
@Path("/compare")
@Produces(MediaType.APPLICATION_JSON)
public CompareResponse compare(@QueryParam("prompt") String prompt) {
if (prompt == null || prompt.isBlank()) {
throw new IllegalArgumentException("prompt query parameter is required");
}
String memoryBaseline = UUID.randomUUID().toString();
String memorySkills = UUID.randomUUID().toString();
routingTrace.reset();
String baselineReply = baselineAssistant.handle(memoryBaseline, prompt);
Specialist baselineSpecialist = routingTrace.getLastRoutedSpecialist();
routingTrace.reset();
String skillsReply = withSkillsAssistant.handle(memorySkills, prompt);
Specialist skillsSpecialist = routingTrace.getLastRoutedSpecialist();
return new CompareResponse(
prompt,
new Side(baselineReply, memoryBaseline, specialistName(baselineSpecialist)),
new Side(skillsReply, memorySkills, specialistName(skillsSpecialist)));
}
private static String specialistName(Specialist s) {
return s != null ? s.name() : null;
}
public record CompareResponse(String prompt, Side baseline, Side withSkills) {
}
public record Side(String reply, String memoryId, String routedSpecialist) {
}
}Make the tests deterministic
src/test/resources/application.properties
%test.quarkus.langchain4j.ollama.devservices.enabled=false
%test.quarkus.arc.selected-alternatives=dev.cleardesk.testsupport.ClearDeskStubChatModelKeep Ollama chat model enabled in the main application.properties so Quarkus can build AI service wiring at deployment time. The test profile selects ClearDeskStubChatModel as an @Alternative ChatModel, so CI does not need a live Ollama.
src/main/java/dev/cleardesk/testsupport/ClearDeskStubChatModel.java lives on the main classpath so the build always compiles; tests turn it on with %test.quarkus.arc.selected-alternatives. The stub returns valid JSON for tool arguments (for example {"reason":"..."} for routeTo*), and after a delegate tool runs it returns text only on the next model call so the tool loop stops before max-tool-executions.
package dev.cleardesk.testsupport;
import java.util.List;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Alternative;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
@Alternative
@Priority(1)
@ApplicationScoped
public class ClearDeskStubChatModel implements ChatModel {
public static final String AMBIGUOUS_PROMPT =
"Production checkout API returns 500 when applying the corporate VAT invoice; money must flow before the weekend deploy.";
@Override
public ChatResponse chat(ChatRequest request) {
List<ChatMessage> messages = request.messages();
boolean delegateCompleted = messages.stream()
.anyMatch(m -> m instanceof ToolExecutionResultMessage tr
&& (tr.toolName().equals("routeToSupport")
|| tr.toolName().equals("routeToFinance")
|| tr.toolName().equals("routeToDevOps")));
if (delegateCompleted) {
return ChatResponse.builder()
.aiMessage(dev.langchain4j.data.message.AiMessage.from("stub routing complete"))
.build();
}
String userText = lastUserText(messages);
String systemText = lastSystemText(messages);
boolean baseline = systemText != null && systemText.contains("Vague org chart");
boolean skills = systemText != null && systemText.contains("You are ClearDesk, an internal supervisor");
if (userText != null && userText.contains("invoice refund for ticket INC-9")) {
return respondTool("routeToFinance", "{\"reason\":\"Finance owns refunds linked to tickets.\"}");
}
if (userText != null && userText.contains("pipeline release-42 keeps failing on the build agent")) {
return respondTool("routeToDevOps", "{\"reason\":\"CI failures go to platform.\"}");
}
if (userText != null && userText.contains("customer cannot authenticate; open SEV-2 ticket")) {
return respondTool("routeToSupport", "{\"reason\":\"Login outage is support intake.\"}");
}
if (userText != null && userText.contains("checkout API") && userText.contains("VAT invoice")) {
if (baseline) {
return respondTool("routeToDevOps", "{\"reason\":\"Sounds like production API — baseline chooses devops.\"}");
}
if (skills) {
if (!hasToolResult(messages, "activate_skill")) {
return respondTool(
"activate_skill",
"{\"skill_name\":\"finance-ops\"}",
"1");
}
return respondTool("routeToFinance", "{\"reason\":\"invoice/VAT path\"}", "2");
}
}
return respondTool("routeToSupport", "{\"reason\":\"default\"}");
}
private static boolean hasToolResult(List<ChatMessage> messages, String toolName) {
for (ChatMessage m : messages) {
if (m instanceof ToolExecutionResultMessage tr && toolName.equals(tr.toolName())) {
return true;
}
}
return false;
}
private static String lastUserText(List<ChatMessage> messages) {
for (int i = messages.size() - 1; i >= 0; i--) {
ChatMessage m = messages.get(i);
if (m instanceof UserMessage um) {
return um.singleText();
}
}
return null;
}
private static String lastSystemText(List<ChatMessage> messages) {
for (int i = messages.size() - 1; i >= 0; i--) {
ChatMessage m = messages.get(i);
if (m instanceof SystemMessage sm) {
return sm.text();
}
}
return null;
}
private static ChatResponse respondTool(String name, String arguments) {
return respondTool(name, arguments, "stub-id");
}
private static ChatResponse respondTool(String name, String arguments, String id) {
ToolExecutionRequest req = ToolExecutionRequest.builder()
.id(id)
.name(name)
.arguments(arguments)
.build();
return ChatResponse.builder().aiMessage(dev.langchain4j.data.message.AiMessage.from(req)).build();
}
}Prove the routes with HTTP tests
src/test/java/dev/cleardesk/wellknown/AgentSkillsResourceTest.java
package dev.cleardesk.wellknown;
import static org.hamcrest.Matchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import static io.restassured.RestAssured.given;
@QuarkusTest
class AgentSkillsResourceTest {
@Test
void wellKnownListsThreeSkillsFromClasspath() {
given().when()
.get("/.well-known/agent-skills")
.then()
.statusCode(200)
.body("version", is("cleardesk-v1"))
.body("skills.size()", is(3))
.body("skills.name", org.hamcrest.Matchers.hasItems("support-triage", "finance-ops", "dev-ops"));
}
}src/test/java/dev/cleardesk/api/SupervisorRoutingTest.java
package dev.cleardesk.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import dev.cleardesk.testsupport.ClearDeskStubChatModel;
import io.quarkus.test.junit.QuarkusTest;
import static io.restassured.RestAssured.given;
@QuarkusTest
class SupervisorRoutingTest {
@Test
void routesSupportWhenPromptIsClearlySupport() {
ClearDeskChatResponse body = given().contentType("application/json")
.body("{\"prompt\":\"customer cannot authenticate; open SEV-2 ticket\",\"skillsEnabled\":true}")
.post("/clear-desk/chat")
.then()
.statusCode(200)
.extract()
.as(ClearDeskChatResponse.class);
assertEquals("SUPPORT", body.routedSpecialist);
}
@Test
void routesFinanceWhenPromptIsClearlyFinance() {
ClearDeskChatResponse body = given().contentType("application/json")
.body("{\"prompt\":\"invoice refund for ticket INC-9\",\"skillsEnabled\":true}")
.post("/clear-desk/chat")
.then()
.statusCode(200)
.extract()
.as(ClearDeskChatResponse.class);
assertEquals("FINANCE", body.routedSpecialist);
}
@Test
void routesDevOpsWhenPromptIsClearlyPlatform() {
ClearDeskChatResponse body = given().contentType("application/json")
.body("{\"prompt\":\"pipeline release-42 keeps failing on the build agent\",\"skillsEnabled\":true}")
.post("/clear-desk/chat")
.then()
.statusCode(200)
.extract()
.as(ClearDeskChatResponse.class);
assertEquals("DEVOPS", body.routedSpecialist);
}
@Test
void ambiguousPromptRoutesToDevOpsWithoutSkillsBaseline() {
String json = """
{"prompt":"%s","skillsEnabled":false}
"""
.formatted(ClearDeskStubChatModel.AMBIGUOUS_PROMPT.replace("\\", "\\\\").replace("\"", "\\\""));
ClearDeskChatResponse body = given().contentType("application/json")
.body(json)
.post("/clear-desk/chat")
.then()
.statusCode(200)
.extract()
.as(ClearDeskChatResponse.class);
assertEquals("DEVOPS", body.routedSpecialist);
}
@Test
void ambiguousPromptRoutesToFinanceWithSkillsEnabled() {
String json = """
{"prompt":"%s","skillsEnabled":true}
"""
.formatted(ClearDeskStubChatModel.AMBIGUOUS_PROMPT.replace("\\", "\\\\").replace("\"", "\\\""));
ClearDeskChatResponse body = given().contentType("application/json")
.body(json)
.post("/clear-desk/chat")
.then()
.statusCode(200)
.extract()
.as(ClearDeskChatResponse.class);
assertEquals("FINANCE", body.routedSpecialist);
}
}Verification
Pull a model once:
ollama pull qwen2.5:7bRun dev mode (watch the log for the HTTP port, usually 8080):
./mvnw quarkus:devSmoke checks:
curl -s http://127.0.0.1:8080/.well-known/agent-skills
curl -s -X POST http://127.0.0.1:8080/clear-desk/chat \
-H 'Content-Type: application/json' \
-d '{"prompt":"customer cannot authenticate; open SEV-2 ticket","skillsEnabled":true}'
curl -s -X POST http://127.0.0.1:8080/clear-desk/chat \
-H 'Content-Type: application/json' \
-d '{"prompt":"pipeline release-42 keeps failing on the build agent","skillsEnabled":true}' | jqThe first call should list three skills. The second should return JSON with "routedSpecialist":"SUPPORT" for that prompt. The third curl routes to the DEVOPS specialist.
Automated checks:
./mvnw testOn this sample, that suite finishes with six tests and zero failures.
Conclusion
ClearDesk proves one narrow point, which is enough: an org chart can name owners, but it cannot settle an ambiguous production sentence. Skill files can. Quarkus keeps the execution surface in Java tools, the baseline path gives you a fair comparison, and the HTTP plus test layer makes the routing visible instead of hand-waved.


