Build autonomous AI agents and multi-agent systems in plain Java 21. LLM integration, tool calling, deterministic I/O contracts, RAG and human-in-the-loop — no annotations, no reflection, no Spring, no Kotlin.
Quick start · Documentation · Examples · Website · Discussions
ARA is a Java 21 agent runtime: a place where agents actually run, with sessions, memory, execution strategies, cost budgets, approval gates and a full execution trace — not a thin wrapper around a chat completion endpoint.
It works with OpenAI, Anthropic, Ollama, Mistral, LM Studio, Groq and any
OpenAI-compatible endpoint, and it is published on Maven Central as
io.github.xmor:ara-runtime.
Your code ──▶ AraRuntime ──▶ Agent (strategy + contract + session)
│
┌──────────┼───────────┐
▼ ▼ ▼
LlmClient Tools Retriever
OpenAI/Claude/ (yours, (in-memory
Ollama/Mistral parallel or Qdrant)
on vthreads)
1. Add the dependency (check the badge above for the latest version):
<dependency>
<groupId>io.github.xmor</groupId>
<artifactId>ara-runtime</artifactId>
<version>1.0.2</version>
</dependency>2. Run an agent offline. ScriptedLlmClient replays canned responses, so the first
run needs no key, no network and no local model:
import io.ara.core.agent.*;
import io.ara.runtime.AraRuntime;
import io.ara.runtime.stubs.ScriptedLlmClient;
try (AraRuntime runtime = AraRuntime.builder()
.llmClient(ScriptedLlmClient.script()
.thenFinalAnswer("Virtual threads are lightweight JVM threads.")
.build())
.build()) {
AraAgent agent = runtime.createAgent(AgentConfig.defaults()
.agentType("assistant")
.systemPrompt("You are a concise technical assistant.")
.build());
AgentResponse response = agent.execute(AgentTask.of("Explain virtual threads"));
System.out.println(response.content());
}3. Swap in a real model — one line changes, everything else stays:
LlmClient gpt4o = AraLlmClientFactory.openAi()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o")
.build();
// or fully local, no API key:
LlmClient llama = AraLlmClientFactory.ollama()
.model(OllamaLlmClient.Models.LLAMA_3_2)
.build();Requires Java 21+ and Maven 3.9+. Add ara-adapters for real providers →
Providers guide.
Build from source
git clone https://github.com/xmor/ara.git && cd ara
mvn clean install -DskipTests- Plain Java, no magic. Pure interfaces — zero annotations, zero reflection, no Kotlin runtime, no Spring. The call stack you debug is the call stack you wrote.
- Deterministic I/O contracts.
AgentContractvalidates, sanitises and transforms in plain Java before and after every call, spending zero tokens. - Java 21 by design. Virtual threads are the concurrency model, not an option: when the LLM asks for several tools at once they are dispatched in parallel automatically, with no executor to wire up.
- A runtime, not a toolkit. Execution strategies, FSM pipelines, session isolation, human-in-the-loop and cost budgets come in the box rather than assembled from parts.
- Built on LangChain4j, not against it. Provider integration is inherited through
ara-adapters, so you get LangChain4j's provider coverage plus the runtime on top.
| If you use… | ARA's relationship |
|---|---|
| LangChain4j | ARA builds on it. ara-adapters wraps LangChain4j clients and adds the runtime layer above: sessions, strategies, contracts, HITL. |
| A Spring-based AI stack | ARA has no DI container and no Spring dependency. It drops into a Spring app as an ordinary library, or into a plain main() with none. |
| Python agent frameworks | ARA keeps agentic workloads on the JVM stack you already deploy, monitor and secure — no Python service to operate alongside it. |
| Raw provider SDKs | Everything above the HTTP call — retries, failover, circuit breaking, tracing, output validation, approval gates — is already written. |
- Human-in-the-loop as a runtime primitive — an
ApprovalGatewired into the tool dispatch chain;ApprovalDecisionis a sealed interface, so approve / reject / modify is exhaustive at compile time. → HITL guide - Conversation and self-correction inside the loop —
"respact"asks a clarifying question mid-task and resumes on the same session;"reflact"recovers from a failed tool call without discarding what the run already accomplished. - Private per-agent data —
AgentInstanceContextholds API keys or tenant ids that both prompt shaping and tool execution can read, and that never reach the LLM. - Per-agent cost accounting — unit prices and a
costBudgetcap are part of the LLM profile, not an afterthought. - Multimodal input — attach a PDF or an image and the model reads it natively; a provider that can't handle the type fails loudly before the request goes out.
- A full execution trace, always —
AgentResponse.steps()records the reasoning and tool trace on every run, including the partial trace when a run fails. - LLM failover and circuit breaking — an ordered model chain with a passive breaker per candidate, so an outage stops costing a timeout per request.
- Single agents on any LLM (OpenAI, Anthropic, Ollama, Mistral, LM Studio, Groq, …)
- Deterministic I/O contracts: sanitize input, validate output, strip markdown fences — zero tokens consumed
- Multi-agent pipelines with conditional routing and FSM-style state machines
- Classify-and-act triage: one classification decides the single worker that handles the task, escalating from keyword rules to a model to a human as confidence drops — the whole dispatch table loadable as a JSON document
- Tool calling from LLM responses, including parallel dispatch on virtual threads
- Conversational agents that ask clarifying questions mid-task (
"respact") and self-correcting ones that recover from failed tool calls without restarting ("reflact") - RAG as a strategy decorator — retrieval before every LLM call, no tool configuration needed
- Fully offline testing with
ScriptedLlmClientandAssociativeLlmClient
Tool calling — implement AraTool, opt in per agent
class WeatherTool implements AraTool {
@Override public String toolId() { return "get_weather"; }
@Override public String description() { return "Returns current weather for a city."; }
@Override public String argumentSchema() {
return """
{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}
""";
}
@Override public ToolResult execute(String argumentJson) {
return ToolResult.success(toolId(), "Milan: Sunny, 22°C");
}
}
AgentConfig config = AgentConfig.defaults()
.agentType("travel-assistant")
.plannerStrategy("react")
.enabledTools(List.of("get_weather"))
.build();When the LLM requests several tools in one response, ARA dispatches them concurrently on virtual threads — no configuration. → Tool calling in depth
Deterministic I/O contracts — validate and transform without spending tokens
AgentContract contract = AgentContract.builder()
.addInputProcessor(InputSanitizer.instance()) // blocks prompt-injection patterns
.addInputProcessor(ContentTruncator.to(4000))
.addPromptShaper(PromptTemplate.withDefaults(Map.of("date", LocalDate.now().toString())))
.outputSchema(JsonSchemaValidator.forOutput(SCHEMA))
.addOutputProcessor(MarkdownFenceStripper.instance())
.build();
AraAgent agent = runtime.createAgent(config, contract);Validators, extractors, PII redaction and media limits ship built in. → Contracts & processors
Multi-agent pipeline — an FSM over agents
AgentPipeline pipeline = AgentPipeline.fsmBuilder()
.state("draft", draftAgent)
.state("review", reviewAgent)
.state("revise", reviseAgent)
.state("done", doneAgent)
.initial("draft")
.terminal("done")
.transition("draft", "review")
.transition("review", execution -> execution.lastOutput().contains("APPROVED") ? "done" : "revise")
.transition("revise", "review")
.maxSteps(12)
.build();PipelineAgents.of(pipeline) hosts it inside a real agent, so it gains session
isolation, cancellation and telemetry — and can be nested inside another pipeline.
→ Pipeline README
Classify-and-act — triage with confidence-driven escalation
IntentRouter router = IntentRouter.onField("intent")
.route("BILLING", "billing")
.route("TECH", "tech")
.confidenceField("confidence")
.escalateBelow(0.7, "human") // low confidence → escalate, not guess
.orElse("fallback");
AgentPipeline triage = AgentPipeline.builder()
.classify("classify", classifierAgent, router)
.worker("billing", billingAgent)
.worker("tech", techAgent)
.worker("human", humanReviewAgent)
.worker("fallback", fallbackAgent)
.build();Three interchangeable classifiers fill the same slot — keyword rules (zero tokens), a
prompted model, or a human behind an ApprovalGate — and the whole dispatch table can be
loaded from JSON. → Classify-and-act
| Strategy | plannerStrategy |
What it does |
|---|---|---|
ReactStrategy |
"react" |
Reasoning–Action loop: Think → Act → Observe. The default |
ReSpActStrategy |
"respact" |
ReAct + a speak action: converse with the user mid-task without closing it |
ReflActStrategy |
"reflact" |
ReAct + in-loop self-correction on tool failures or stalled reasoning |
PlanExecuteStrategy |
"plan_execute" |
Generate a structured plan, then execute each step |
ReflexionStrategy |
"reflexion" |
Generate → critique → revise, restarting the whole episode |
RetrievalAugmentedStrategy |
"rag+<name>" |
Inject retrieved context before every LLM call |
All strategies support cooperative cancellation and record a full execution trace, including on failure paths. → Strategy README
| Module | What's inside |
|---|---|
ara-core |
Pure interfaces and domain model: AraAgent, LlmClient, LlmException, MemoryManager, ToolRegistry, AgentContract, ExecutionStrategy, … |
ara-runtime |
AraRuntime, the execution strategies, ContractEnforcer, AgentPipeline, the classify-and-act building blocks, the offline LLM stubs and the built-in processors |
ara-adapters |
LangChain4j-backed LlmClient adapters for OpenAI, Anthropic, Ollama, Mistral and ChatJimmy. No Kotlin, no OkHttp, no Spring |
ara-examples |
Runnable examples for offline (stub) and live (real LLM) scenarios |
ara-gateway — an optional HTTP layer (Javalin/Jetty) for HTTP-side HITL approvals —
ships separately and is not part of this build.
Everything below lives in ara-examples and runs with main().
| Class | LLM | What it shows |
|---|---|---|
basics/AraSimpleExample |
stub | End-to-end: ReAct loop, tool call, interceptor, agent reuse |
basics/AraSimpleExampleLive |
live | The same run against a real model |
basics/SimpleStreamingExample |
stub | The smallest streaming agent, tokens printed as they arrive |
basics/StreamingWithToolExample |
stub / live | Token streaming through a ReAct loop that calls a tool |
basics/InterceptorEventsExample |
stub | Every AgentInterceptor event in order, around one run |
pipeline/ClassifyAndActExample |
none | Classify-and-act at its smallest — no model, no API key |
pipeline/TicketTriageCascadeExample |
stub | The three-tier cascade: rules → model → human |
hitl/HumanInTheLoopExample |
stub | A tool call parked on an ApprovalGate until an operator decides |
rag/RagAgentExample |
stub | rag+react over an InMemoryDocumentStore, plus delegation |
failover/FailoverExample |
stub | Failover and circuit breaking across LLM and embedding endpoints |
multimodal/MultimodalInputExample |
live | A PDF to Mistral and an image to Ollama, one provider-agnostic method |
scheduler/AgentSchedulerExample |
none | Interval and cron schedules, with pause / resume / trigger |
web/StreamingChatWebExample |
stub / live | A chat page on a JDK HttpServer, streaming over SSE |
| Guide | Covers |
|---|---|
| Providers & resilience | Connecting OpenAI / Anthropic / Ollama / Mistral / OpenAI-compatible endpoints, multi-provider runtimes, LlmException, failover, circuit breaker, LLM I/O logging, OpenTelemetry |
| Contracts & processors | AgentContract, the built-in validator/transform/security processors, PromptShaper, multimodal input and media limits |
| Configuration reference | Every AgentConfig and LlmProfile field, sessions and concurrency, cancellation, agent instance context, scheduling |
| RAG & human-in-the-loop | Knowledge bases (in-memory or Qdrant), retrieval as a strategy vs. as a tool, embedding failover, approval gates and notifiers |
| Advanced usage | Custom strategies, extension points |
| Coding guidelines | What a PR is expected to look like |
Do I need Spring, or any DI container?
No. ARA is a plain library with a builder API. It runs in a bare main(), and drops into
a Spring or Quarkus application as an ordinary dependency.
Can I run it without any API key?
Yes, two ways. ScriptedLlmClient and AssociativeLlmClient replay scripted responses
for tests and demos, and the Ollama adapter talks to a local model with no key at all.
Which providers are supported?
OpenAI, Anthropic, Ollama, Mistral and ChatJimmy have first-class adapters, and the OpenAI client points at any OpenAI-compatible endpoint — LM Studio, Groq, Together AI, a corporate gateway. Provider coverage is inherited from LangChain4j.
Why Java 21 and not 17?
Virtual threads. Parallel tool dispatch, session concurrency and the cheap parking of a thread waiting on a human approval are all built on them.
How is this different from calling a chat API in a loop?
The loop is the easy part. Session isolation, cooperative cancellation, output schema validation, failover with circuit breaking, cost budgets, approval gates and a complete execution trace are the parts you would otherwise write, and they are what ARA is.
Issues, discussions and PRs are welcome — including README and documentation fixes, which are the easiest first contribution.
Code style is enforced through docs/CODING-GUIDELINES.md:
simplicity first, no premature abstractions, short single-responsibility functions,
honest naming. Read it before opening a PR.
ARA is developed with heavy AI assistance under human architectural review.
If ARA is useful to you, a ⭐ helps other Java developers find it.
Apache 2.0 — see LICENSE.
Keywords — Java AI agent framework · LLM orchestration on the JVM · multi-agent systems in Java · ReAct agents · tool calling · RAG · human-in-the-loop · OpenAI · Anthropic Claude · Ollama · Mistral · Java 21 virtual threads