Deterministic I/O contracts
AgentContract runs a processor chain before and after every call — sanitise input, strip markdown fences, validate JSON against a schema, redact PII. Pure Java, zero tokens. No other JVM framework ships an equivalent.
Agent Runtime Architecture · Apache 2.0
ARA is a Java 21 runtime for autonomous agents and multi-agent systems. Zero annotations. Zero reflection. No Kotlin runtime, no Spring. The call stack you debug is the call stack you wrote.
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()); // deterministic, in CI, for free
}
Why ARA
Most JVM agent stacks are integration toolkits: they give you a client and leave orchestration, determinism and testing to you. ARA is the other half — a runtime, built on top of LangChain4j providers rather than against them.
AgentContract runs a processor chain before and after every call — sanitise input, strip markdown fences, validate JSON against a schema, redact PII. Pure Java, zero tokens. No other JVM framework ships an equivalent.
When the model asks for five tools in one response, ARA dispatches all five concurrently on Java 21 virtual threads. No executor to wire, no pool to tune, no flag to flip.
ReAct, ReSpAct (converse mid-task), ReflAct (self-correct inside the loop), PlanExecute, Reflexion, plus a RAG decorator — "rag+react". Swap by changing one string in AgentConfig.
ScriptedLlmClient replays scripted turns — tool calls included — so an entire multi-agent flow is a plain JUnit test. Deterministic CI, no API key, no bill.
AgentInstanceContext holds API keys and tenant ids that prompt shaping and tool execution can read — never in the prompt text, never in a tool's argument schema. Updatable live, without recreating the agent.
The code tour
Every snippet below is copy-paste from the README. Interfaces you can read in one sitting, no starter template required.
// One runtime, many providers — agents reference them by name.
AraRuntime runtime = AraRuntime.builder()
.llmClient("fast", AraLlmClientFactory.openAi().apiKey(KEY).modelName("gpt-4o-mini").build())
.llmClient("smart", AraLlmClientFactory.openAi().apiKey(KEY).modelName("gpt-4o").build())
.llmClient("local", AraLlmClientFactory.ollama().modelName("gpt-oss-20b").build())
.build();
AgentConfig config = AgentConfig.defaults()
.agentType("analyst")
.primaryLlm(LlmProfile.of("smart")) // zero credentials inside AgentConfig
.build();
Providers come from ara-adapters (OpenAI · Anthropic · Ollama · any OpenAI-compatible endpoint), built on LangChain4j — no Kotlin, no OkHttp, no Spring.
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");
}
}
// Two calls in one LLM response → two virtual threads → ~1x the latency, not 2x.
AgentConfig config = AgentConfig.defaults()
.agentType("travel-assistant")
.plannerStrategy("react")
.enabledTools(List.of("get_weather"))
.build();
Or skip writing tools entirely: ara-tools ships file, code, git, shell, project and search tools — always opt-in per agent, never enabled by default.
AgentContract contract = AgentContract.builder()
.addInputProcessor(InputSanitizer.instance()) // blocks prompt injection (EN + IT)
.addInputProcessor(ContentTruncator.to(4000))
.addPromptShaper(PromptTemplate.withDefaults(
Map.of("date", LocalDate.now().toString())))
.outputSchema(JsonSchemaValidator.forOutput(SCHEMA))
.addOutputProcessor(MarkdownFenceStripper.instance()) // no more ```json wrappers
.addOutputProcessor(JsonSchemaValidator.forOutput(SCHEMA))
.build();
AraAgent agent = runtime.createAgent(config, contract);
Built-in processors cover validation (JsonSchemaValidator, RegexValidator, range and length checks), transformation (CodeFenceExtractor, JsonFieldExtractor, WhitespaceNormalizer) and security (InputSanitizer, PiiRedactor).
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 -> {
if (execution.lastOutput().contains("APPROVED")) return "done";
long revises = execution.history().stream()
.filter(s -> s.stepName().equals("revise")).count();
return revises >= 3 ? "done" : "revise";
})
.transition("revise", "review")
.maxSteps(12)
.build();
Routing decisions are plain Java lambdas over the execution history — debuggable, unit-testable, and impossible to get wrong in YAML.
Execution strategies
One string in AgentConfig.plannerStrategy(...) changes how the agent thinks. All of them support cooperative cancellation and record a full execution trace — including the partial trace on failure.
| Strategy | Value | What it does |
|---|---|---|
| ReactStrategy | "react" | Think → Act → Observe. The default. |
| ReSpActStrategy | "respact" | ReAct plus a speak action — ask a clarifying question mid-task and resume on the same session instead of restarting. |
| ReflActStrategy | "reflact" | ReAct plus in-loop self-correction: a failed tool call injects a course-correction into the same working memory, keeping everything already accomplished. |
| PlanExecuteStrategy | "plan_execute" | Produce a structured plan, then execute it step by step. |
| ReflexionStrategy | "reflexion" | Generate → critique → revise, restarting the episode with the critique injected. |
| RetrievalAugmented | "rag+<name>" | Decorator: inject retrieved context before every LLM call — "rag+react", "rag+respact", "rag+plan_execute", "rag+reflact". |
"reflexion" reacts to a whole failed pass: wipe working memory, retry the episode. "reflact" reacts inside one pass and the loop simply continues. Use reflact when individual steps fail recoverably, reflexion when failure is only detectable after a complete pass.
AgentState.WAITING, an approval gate and pluggable notifiers. ApprovalDecision is a sealed interface — handling approve / reject / modify exhaustively is enforced by the compiler, not by convention.
Beyond the open-source runtime
The runtime above is the core — public, and enough on its own. On top of it, two private modules
expose the same AraAgent to channels other than a direct Java call — already in
production, but outside the public repository.
Exposes an AraRuntime over HTTP with synchronous, background and streaming (SSE) runs,
plus an opt-in AgentOS surface: an unmodified Agno RemoteAgent
— or the os.agno.com control plane — can drive an ARA agent as if it were a native Agno one. A
runtime with no gateway around it opens no port: ara-runtime never gains an HTTP
dependency, the gateway stays an optional layer on top of it.
A separate module that brings the same agent onto a phone call: WebRTC signaling, streaming speech recognition and speech synthesis with barge-in, so the user can interrupt the agent while it's still talking. It lives outside the public repository and is not distributed.
Example — Agno as client and as server
Expose the AGENTOS surface: an unmodified agno.agent.RemoteAgent treats it
like any other AgentOS instance.
AraRuntime runtime = AraRuntime.builder().llmClient(llm).build();
runtime.start();
AraAgent agent = runtime.createAgent(AgentConfig.defaults()
.agentId(AgentId.of("research-agent"))
.agentType("research")
.name("Research Agent")
.build());
AraGateway gateway = AraGateway.builder(runtime)
.port(8090)
.expose(Surface.AGENTOS) // Agno-compatible wire format
.build();
gateway.start();
// -> http://localhost:8090/agentos
from agno.agent import RemoteAgent
remote = RemoteAgent(
base_url="http://localhost:8090/agentos",
agent_id="research-agent",
protocol="agentos",
)
response = remote.run("Summarize the latest ARA release notes")
print(response.content)
No dedicated Agno client needed: any AraTool that speaks the same wire format, in the
opposite direction, is enough to let the ARA agent delegate to a specialist hosted on Agno.
class AgnoAgentTool implements AraTool {
private final HttpClient http = HttpClient.newHttpClient();
private final String agnoBaseUrl; // e.g. an Agno AgentOS instance
private final String agnoAgentId;
@Override public String toolId() { return "ask_agno_specialist"; }
@Override public String description() { return "Delegates a question to a specialist hosted on Agno."; }
@Override public String argumentSchema() {
return """
{"type":"object","properties":{"question":{"type":"string"}},"required":["question"]}
""";
}
@Override
public ToolResult execute(String argumentJson) {
String question = JsonFieldExtractor.field(argumentJson, "question");
String body = "message=" + URLEncoder.encode(question, UTF_8) + "&stream=false";
HttpRequest req = HttpRequest.newBuilder(
URI.create(agnoBaseUrl + "/agents/" + agnoAgentId + "/runs"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString());
return ToolResult.success(toolId(), resp.body());
}
}
// Register it like any other tool — the ARA agent can now call Agno.
AgentConfig config = AgentConfig.defaults()
.agentType("coordinator")
.enabledTools(List.of("ask_agno_specialist"))
.build();
All gateway surfaces
Every route is always registered — whether it answers or returns 404 surface_disabled is
decided by configuration, per request. No surface is on by accident.
| Surface | Main endpoints | Auth | Typical client |
|---|---|---|---|
| SYSTEM | GET /health, /info | none | monitoring, load balancer |
| DISCOVERY | GET /agents, /agents/{id}, /agents/type/{type}, /agents/state/{state} | none | dashboard, service discovery |
| RUNS | POST /agents/{id}/runs (sync, ?background=true, ?stream=true), GET/DELETE /runs/{taskId} | bearer/custom | your own frontend, internal backend |
| CONTROL | POST .../terminate, session management, PUT .../config | bearer/custom | admin panel |
| SESSIONS | GET .../history, .../state, .../memory | bearer/custom | debugging, audit, support |
| AGENTOS | GET/POST under /agentos | bearer/custom | Agno RemoteAgent, os.agno.com |
| A2A | GET /.well-known/agent.json, POST /.well-known/a2a (JSON-RPC 2.0) | bearer/custom (AgentCard excluded) | AWS Bedrock AgentCore, Google ADK, LangGraph, ag2 |
| APPROVALS | GET /approvals, POST /approvals/{id}/decision | bearer/custom | human-in-the-loop panel |
No compatibility wire format: plain JSON in, JSON or Server-Sent Events out — for any frontend or backend that speaks HTTP.
curl -X POST http://localhost:8080/agents/research-agent/runs \
-H "Authorization: Bearer $ARA_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"input": "Summarize the latest ARA release notes"}'
curl -N -X POST "http://localhost:8080/agents/research-agent/runs?stream=true" \
-H "Authorization: Bearer $ARA_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"input": "Summarize the latest ARA release notes", "sessionId": "s-1"}'
# event: token {"text":"ARA"}
# event: token {"text":" is"}
# event: tool_call {"toolId":"web_search"}
# event: final {"content":"...", "tokensUsed":...}
JSON-RPC 2.0 over the official A2A protocol types — the same standard AWS Bedrock AgentCore, Google ADK, LangGraph and ag2 already speak, no ARA-specific code required on their side.
curl -X POST http://localhost:8080/.well-known/a2a \
-H "Authorization: Bearer $ARA_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"parts": [{"kind": "text", "text": "Summarize the latest ARA release notes"}],
"metadata": {"agentId": "research-agent"}
}
}
}'
ara-private, a separate, non-public repository.ara-core interfaces: an agent written for the open-source runtime runs on both channels unchanged.ara-gateway-only — it exposes the AgentOS surface, no changes required on the Agno side.How ARA compares
Compared against LangChain4j 1.17, Spring AI 2.0 and Koog (JetBrains) — last verified 2026-08-03. This ecosystem moves fast; open an issue if a row has gone stale.
| ARA | LangChain4j | Spring AI | Koog | |
|---|---|---|---|---|
| Primary focus | Multi-agent runtime + orchestration | LLM integration toolkit + agentic workflows | LLM integration for Spring apps | Multiplatform agent framework |
| Deterministic I/O contracts | ✓ AgentContract, zero tokens |
— | — | — |
| FSM pipeline with routing | ✓ fsmBuilder() |
— | — | ✓ typed FSM graphs |
| Parallel tool dispatch | ✓ automatic, virtual threads | opt-in executeToolsConcurrently(Executor) |
sequential by default | coroutines; per-stage ExecutorService |
| Annotations | none | none | Spring annotations | @Tool / @LLMDescription |
| Framework dependency | none — pure Java interfaces | none | Spring Boot required | Kotlin runtime; Spring optional |
| Agent execution loop | 6 built-in strategies + RAG decorator | workflows + supervisor (langchain4j-agentic) |
advisor chain + Spring AI Agents | graph and functional strategies |
| Multi-agent graph with cycles | ✓ parallel branches + back-edges | composable workflows; no cyclic graph API | not built in | ✓ nodes, edges, subgraphs |
| Test without an LLM | ✓ ScriptedLlmClient |
partial | partial | ✓ MockLLMBuilder |
| Typed error handling | LlmException · isRetryable() · ErrorType |
Retriable / NonRetriable hierarchy | provider-specific | built-in retries and fault tolerance |
| Java version | 21+ | 17+ | 21+ | 17+ |
| Durable checkpointing / resume | ✗ not implemented | via LangGraph4j | via LangGraph4j | ✓ persistence + restore points |
StateGraph, Embabel takes the Goal-Oriented Action Planning route, and Google's ADK for Java targets hierarchical multi-agent systems around A2A.Architecture
ara-core is interfaces and domain model — nothing else. Everything above it is an implementation you can replace.
Pure interfaces: AraAgent, LlmClient, MemoryManager, ToolRegistry, AgentContract, ExecutionStrategy.
AraRuntime, the four strategies, ContractEnforcer, AgentPipeline, ScriptedLlmClient, built-in processors.
LangChain4j-backed clients for OpenAI, Anthropic and Ollama. No Kotlin, no OkHttp, no Spring.
Runnable demos: offline stubs, live LLMs, and an autonomous coding loop with quality gates.
Quickstart
Java 21+ and Maven 3.9+. The first agent needs no API key at all.
ARA is currently a 1.0.0 — install it into your local repository.
git clone https://github.com/xmor/ara.git
cd ara
mvn clean install -DskipTests
ara-runtime pulls in ara-core transitively. Add ara-adapters when you want a real model.
<dependency>
<groupId>io.github.xmor</groupId>
<artifactId>ara-runtime</artifactId>
<version>1.0.0</version>
</dependency>
OpenAI, Anthropic, Ollama, or any OpenAI-compatible endpoint — LM Studio, Groq, Together AI.
LlmClient claude = AraLlmClientFactory.anthropic()
.apiKey(System.getenv("ANTHROPIC_API_KEY"))
.model(AnthropicLlmClient.Models.CLAUDE_SONNET_4_6)
.build();
// or fully local, no key required:
LlmClient llama = AraLlmClientFactory.ollama()
.model(OllamaLlmClient.Models.LLAMA_3_2)
.build();
ARA is Apache 2.0, built in the open, and developed with heavy AI assistance under human architectural review. Issues, ADR arguments and PRs all welcome.