JobRunr shipped ClawRunr, an open-source Java AI agent that runs on your own hardware and executes scheduled, recurring, and one-off background tasks. The part worth an architect’s attention is where the agent lives: inside the same runtime that already runs those jobs.
That placement is the whole story. Most “AI agent for the JVM” pitches arrive as a new platform you adopt, a new process to operate, a new place your data has to travel to. ClawRunr reframes the agent as a property of infrastructure you already run: a background-job scheduler that now reasons before it acts. For a Java team, that reframing forces a concrete architectural decision, one worth making deliberately rather than by default.
What ClawRunr actually is
The runtime supports OpenAI, Anthropic, and Ollama as LLM providers, so the model behind the agent is your choice rather than a vendor’s. A Channel interface, implemented by the built-in web chat in the app and by the Telegram and Discord plugins, decouples message sources from the agent itself. An optional Playwright integration adds browser automation: page navigation, element clicks, JavaScript execution, and screenshots. The JobRunr team rewrote the implementation and released the project to the Java community under the LGPL-3.0 license.
Strip the AI vocabulary and what remains is a background-job runtime: schedule work, trigger work, run it once or on a recurrence, route inputs from external sources, drive a browser as a side effect. The LLM is the judgment step in the middle of jobs that already had a place to run.
The properties a scheduler already gives you
Here is why the placement matters. A background-job runtime exists to guarantee a specific set of properties that are tedious and error-prone to rebuild:
- Durability. A scheduled job survives a process restart and resumes on the recurrence it was promised.
- Retry with backoff. Transient failures get retried on a policy you configure once, not hand-rolled per call site.
- Run history and audit. Every execution leaves a record of when it ran, what it did, and whether it succeeded.
- Observability. Job state, queue depth, and failure rates are already surfaced.
An LLM call is a network call to a flaky, slow, occasionally-wrong dependency. It is exactly the kind of work that benefits from every property in that list. When the agent’s reasoning step runs as a job, it inherits durability, retry, audit, and observability for free. When it runs as a bespoke service you wrote this quarter, you own all four, and you will get at least one of them subtly wrong.
JobRunr putting the agent inside the job runtime is the strong version of that argument: the team that builds the scheduler decided the scheduler was the right home for the agent.
The decision rule for your own code
ClawRunr answers this question for its own runtime. For the code you own, the same decision plays out, and it has a clean rule.
Put the agent inside your scheduler when the work is recurring, triggered by a backlog, or fired on a schedule, and you need it to survive restarts, retry on failure, and leave an audit trail. A nightly reconciliation that asks an LLM to classify anomalies, an ops queue that triages incoming alerts, a recurring sweep that summarizes overnight logs: all of these want durability and run history, and your scheduler already provides them.
Run the agent as a standalone @RegisterAiService when the invocation is synchronous and request-scoped, a user is waiting on the other end, and there is nothing to recover if the process dies mid-call. A REST endpoint that asks a model to draft a reply, classify a ticket, or extract fields from a document is request/response work. Wrapping it in a job buys you durability you do not need and latency you do not want.
The two are not in competition. The honest design often uses the same judgment node from both surfaces: a synchronous path for “a human is waiting” and a scheduled path for “this runs on its own.” LangChain4j on Quarkus makes that concrete, because the AI service is a plain interface you can inject anywhere.
Both surfaces, one judgment node
Here is the decision made concrete in Quarkus and LangChain4j. The dependencies, with the OpenAI provider and the scheduler:
<dependencies>
<dependency>
<groupId>io.quarkiverse.langchain4j</groupId>
<artifactId>quarkus-langchain4j-openai</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-scheduler</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
</dependencies>
The judgment node is a declarative LangChain4j AI service. The @RegisterAiService annotation makes the Quarkus extension generate a CDI bean, so the interface injects anywhere without a manual builder:
// src/main/java/com/example/triage/OpsClassifier.java
package com.example.triage;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface OpsClassifier {
@SystemMessage("""
You are an on-call triage assistant for a Java platform team.
Given a raw alert line, answer with a single line in the form:
<SEVERITY> | <suggested first action>
SEVERITY is one of P1, P2, P3. Be terse, no preamble.
""")
@UserMessage("Alert: {alert}")
String triage(@V("alert") String alert);
}
The triage logic lives in a service, not inline in the resource or the job. Both invocation surfaces delegate here, which is exactly why it is worth having a service: it is the one place where retry, deduplication, or an audit write would attach later. It also holds a simple in-memory backlog for the scheduled path:
// src/main/java/com/example/triage/IncidentTriageService.java
package com.example.triage;
import java.util.Optional;
import java.util.concurrent.ConcurrentLinkedQueue;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class IncidentTriageService {
private final ConcurrentLinkedQueue<String> backlog = new ConcurrentLinkedQueue<>();
@Inject
OpsClassifier classifier;
public String triage(String alert) {
return classifier.triage(alert);
}
public void submit(String alert) {
backlog.add(alert);
}
public Optional<String> triageNext() {
String alert = backlog.poll();
return alert == null ? Optional.empty() : Optional.of(classifier.triage(alert));
}
}
The standalone @RegisterAiService surface is a thin REST resource. POST /alerts/triage is the synchronous path, a human pasting an alert and waiting for the answer:
// src/main/java/com/example/triage/TriageResource.java
package com.example.triage;
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("/alerts")
public class TriageResource {
@Inject
IncidentTriageService service;
@POST
@Path("/triage")
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
public String triageNow(String alert) {
return service.triage(alert);
}
@POST
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
public String submit(String alert) {
service.submit(alert);
return "queued";
}
}
The in-scheduler surface is the same judgment node driven by a recurring job. This is the path that inherits the scheduler’s guarantees:
// src/main/java/com/example/triage/AlertPollingJob.java
package com.example.triage;
import org.jboss.logging.Logger;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class AlertPollingJob {
private static final Logger LOG = Logger.getLogger(AlertPollingJob.class);
@Inject
IncidentTriageService service;
@Scheduled(every = "30s")
void drainBacklog() {
service.triageNext().ifPresent(result -> LOG.infof("Triaged: %s", result));
}
}
The provider configuration stays out of the code and in application.properties, which is where you swap OpenAI for a local Ollama model without touching a line of Java:
quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY:demo}
quarkus.langchain4j.openai.chat-model.model-name=gpt-4o-mini
OpsClassifier does not change between the two surfaces. The REST resource gives you the request/response path; the @Scheduled job gives you the recurring background path with run history and restart safety. Choosing where the agent lives is choosing which of these two it runs as, and the rule above tells you which. Quarkus’s own scheduler stands in here for the durable job runtime; ClawRunr’s point is that JobRunr’s runtime already is one.
What “runs on your own hardware” buys you
For a regulated shop, running on your own hardware decides whether an agent is usable at all, and that has nothing to do with packaging.
A bank or a healthcare provider operates cron logic and background jobs that touch data which cannot leave the perimeter: account movements, claims, patient records. Shipping that logic to a third-party agent platform means shipping the data it reads, or the prompts that describe it, to someone else’s infrastructure. For a lot of these shops, that is a non-starter before the first line of code.
ClawRunr running on your own hardware keeps the orchestration inside the boundary. Pairing it with a local provider like Ollama keeps the model inside the boundary too, so a sensitive job can reason without a single byte crossing the network edge. The choice of OpenAI or Anthropic remains available for the work where it is allowed; the architecture does not force the regulated and the unregulated workloads onto the same provider. That optionality, on your hardware, under LGPL-3.0, is what a compliance team can actually sign off on.
The Channel interface as your integration seam
The Channel interface is where this lands for a team that already runs ops bots. Because it decouples the message source from the agent, the question “how does work reach the agent” is answered by an implementation, not by the agent’s core.
If your operations already live in Telegram or Discord, the bundled plugins drop in and your existing bot becomes an input source for the agent. If they live in Slack or Teams instead, the seam is the same interface those plugins implement: you write a Channel for your platform and the agent does not know the difference. An enterprise with a mature ops-bot footprint does not rip anything out to adopt this. The existing bot keeps owning the human conversation, and the Channel implementation is the adapter that hands relevant messages to the agent for the work that warrants a scheduled, durable job behind it.
That is the consistent shape across every part of ClawRunr. The agent is the new piece. Everything around it is infrastructure a serious Java shop already has opinions about: the scheduling, the durability, the message routing, the on-prem execution. The contribution is wiring an LLM into that infrastructure cleanly enough that the decision of where the agent belongs becomes a design choice you can reason about, with a rule simple enough to apply in code review: durable and recurring goes in the scheduler, synchronous and request-scoped stays a standalone @RegisterAiService.