Quickstart

Your first Java agent, in five minutes

There is no server to start, no configuration file, no registry to populate and no code generation step. Add one dependency, point it at a model, and write a class.

Step 1

Prerequisites

Two things, and one of them you almost certainly already have.

A JDK 21 or newer

jOpenAgent targets the JavaSE-21 execution environment. Records and pattern matching are used throughout, and everything else is plain Java. Any distribution works: Temurin, Corretto, Zulu, Liberica, Oracle.

Access to a model

An Anthropic or OpenAI API key, or a local LM Studio or Ollama server, which needs no key and no account at all. Local models are the recommended way to work through this quickstart, since it costs nothing and nothing leaves your machine.

No build tool? That works too. jOpenAgent has no runtime dependencies, so the jar on your classpath is a complete installation. You can drop it into a plain Eclipse or IntelliJ project and start writing agents immediately. See the download page.
Optional, but do it first: teach your AI assistant. The repository ships a SKILL.md that makes coding assistants fluent in jOpenAgent, so they stop reaching for LangChain4j or Spring AI patterns that will not compile here. One copy and you are done: cp -r skills/jopenagent .claude/skills/, or paste the body into your Cursor rule or Copilot instructions file. More about the skill.

Step 2

Add the dependency

jOpenAgent is published to Maven Central. There is exactly one artefact, and it pulls in nothing else.

pom.xmlMaven
<dependency>
    <groupId>org.jopenagent</groupId>
    <artifactId>jopenagent</artifactId>
    <version>1.0.0</version>
</dependency>
build.gradleGradle
dependencies {
    implementation 'org.jopenagent:jopenagent:1.0.0'
}

Two compiler and runtime settings

Both are one line. The first is strongly recommended for every project. The second only matters if you use the CODE_ACT strategy.

Compile with -parameters

This lets the harness match generate(review) back to the real parameter name of the enclosing method, and it means tool and record schemas carry your actual field names instead of arg0.

pom.xmlmaven-compiler-plugin
<compilerArgs><arg>-parameters</arg></compilerArgs>

In Eclipse: Java Compiler → Store information about method parameters.

Run with --add-modules jdk.jshell

Only for agents that use StrategyKind.CODE_ACT. jdk.jshell is a JDK tool module and is not resolved by default for classpath code, so it has to be requested explicitly.

VM argumentsrun configuration
java --add-modules jdk.jshell -cp ... Main

The trace viewer needs no such flag, because jdk.httpserver is in the default module set.

Step 3

Point it at a model

LlmClient is a small interface with two implementations, both built entirely on java.net.http. Switching providers is one line.

Choosing a clientorg.jopenagent.llm
// Anthropic: the Messages API
LlmClient llm = new AnthropicClient(System.getenv("ANTHROPIC_API_KEY"), "claude-sonnet-4-5");

// OpenAI: the Chat Completions API
LlmClient llm = new OpenAiClient(System.getenv("OPENAI_API_KEY"), "gpt-4o-mini");

// Local LM Studio: no API key, nothing leaves the machine
LlmClient llm = OpenAiClient.forLmStudio("qwen/qwen3-coder-30b", "http://localhost:1234");

// Local Ollama: same deal
LlmClient llm = OpenAiClient.forOllama("llama3.1", "http://localhost:11434");

// ...or any other OpenAI-compatible server
LlmClient llm = OpenAiClient.forLocalServer("my-model", "http://gpu-box:8000");
Working locally? Every example on this site runs against a local model. A 30B code-specialised model handles the tool-calling and code-as-action loops comfortably. Very small models sometimes fail to follow the conventions, and when that happens the harness raises GenerationException rather than guessing.

Step 4

Write your first agent

An agent is a class extending Agent. The @SystemPrompt annotation value is the system prompt. A method annotated @Generative is delegated to the model: its annotation value is the instruction, and its body ends in generate(…), which is the hand-off point.

GreeterAgent.javathe smallest agent
import org.jopenagent.core.Agent;
import org.jopenagent.core.AgentConfig;
import org.jopenagent.core.Generative;
import org.jopenagent.core.SystemPrompt;

@SystemPrompt("You are a warm, concise greeter.")
public class GreeterAgent extends Agent {

    public GreeterAgent(AgentConfig config) {
        super(config);
    }

    @Generative("Write a one-sentence, friendly greeting for the given person.")
    public String greet(String name) {
        return generate(name);
    }
}
Main.javarun it
public static void main(String[] args) {
    LlmClient llm = OpenAiClient.forLmStudio("qwen/qwen3-coder-30b", "http://localhost:1234");

    GreeterAgent agent = new GreeterAgent(
            AgentConfig.builder().llmClient(llm).build());

    System.out.println(agent.greet("Ada"));
}

What just happened

generate(name) walked the stack to find its calling method, read the @Generative instruction and the declared return type by reflection, built the prompt, dispatched to the default PREDICT strategy and bound the result back to String. No proxy, no bytecode generation, no annotation processor.

Change the behaviour by changing the sentence. The @Generative value is the prompt. Edit it, recompile, and the capability behaves differently, with the method signature still enforcing the contract.

Step 5

Ask for a typed result

Declare a record as the return type and you are done. The harness generates the JSON schema from the record, asks the model for structured output, binds the response onto the canonical constructor, and if the model returns something that does not fit, retries with the validation error fed back to it, up to a configurable bound.

ReviewAgent.javastructured output
@SystemPrompt("You extract structured information from restaurant reviews.")
public class ReviewAgent extends Agent {

    public record ReviewSummary(int rating, String sentiment,
                                List<String> highlights) { }

    public ReviewAgent(AgentConfig config) {
        super(config);
    }

    @Generative("Extract a rating out of 5, an overall sentiment word, "
              + "and up to 3 highlight phrases from the review.")
    public ReviewSummary summarize(String review) {
        return generate(review);
    }
}
outputtyped, not text
summary.rating()      -> 4
summary.sentiment()   -> "positive"
summary.highlights()  -> ["incredible ramen",
                          "fast service",
                          "loud room"]

What binds

Records, POJOs, List, Set, Map, Optional, enums, primitives and nested combinations of all of them. Reflectively, with no per-class registration and no code generation.

Step 6

Give it tools by writing methods

There is no tool-registration API. Ordinary visible methods on the agent are the tools. Annotate them with @Doc to describe them to the model, switch the generative method to StrategyKind.TOOL_CALLING, and the harness derives JSON schemas from the real signatures and runs the function-calling loop.

InventoryAgent.javaTOOL_CALLING
@SystemPrompt("You are an inventory assistant.")
public class InventoryAgent extends Agent {

    private final Map<String, Integer> inventory = new HashMap<>();

    public InventoryAgent(AgentConfig config) {
        super(config);
        inventory.put("widget", 12);
        inventory.put("gadget", 0);
        inventory.put("gizmo", 5);
    }

    @Doc("Returns the current stock count for an item, or 0 if unknown.")
    public int getStock(String item) {
        return inventory.getOrDefault(item, 0);
    }

    @Doc("Returns the full list of known item names.")
    public List<String> knownItems() {
        return new ArrayList<>(inventory.keySet());
    }

    @Generative(value = "Answer the user's question about inventory using the available tools.",
                strategy = StrategyKind.TOOL_CALLING)
    public String answer(String question) {
        return generate(question);
    }
}
MCP servers plug in the same way. Attach an McpClient as a plain field and its tools are merged into the same schema list automatically, named fieldName__toolName. They are also callable directly from generated code as self.wiki.callTool(…).

Step 7

Let it write code instead

When a task needs loops, conditionals or composition, one tool call per turn is the wrong shape. StrategyKind.CODE_ACT hands the model a live self and lets it write Java, executed in a JShell-backed sandbox, iterating until it calls return_result.

OrdersAgent.javaCODE_ACT
@Doc("Returns the list of order totals, in cents.")
public List<Integer> orderTotalsCents() {
    return new ArrayList<>(orderTotalsCents);
}

@Generative(value = "Compute the average order total in whole "
                  + "dollars (rounded), by writing code that "
                  + "reads self.orderTotalsCents().",
            strategy = StrategyKind.CODE_ACT)
public int averageOrderDollars() {
    return generate();
}
AgentConfigreal hard-kill timeouts
AgentConfig config = AgentConfig.builder()
        .llmClient(llm)
        .sandboxConfig(SandboxConfig.builder()
                .mode(SandboxMode.OUT_OF_PROCESS)
                .build())
        .build();
Read this before you ship it. The source-level validator is defence-in-depth, not a containment boundary, and a determined program can still reach the filesystem or the network. OUT_OF_PROCESS upgrades timeout containment, so a hung generated call gets its whole child JVM killed, but it adds no filesystem or network isolation. Run agents that execute generated code inside a container or VM.

Step 8

See exactly what happened

Tracing is on by default, so every LLM call, tool invocation and sandbox execution is already a nested span. All you choose is where it goes. To watch a run live, start the built-in trace viewer: it is a real web app on the JDK’s own HTTP server, with no Node toolchain anywhere in sight.

Main.javathe built-in web viewer
TraceViewerServer viewer = new TraceViewerServer();
Tracer.addExporter(viewer);
viewer.start(5001);

// ... run your agent, then open http://localhost:5001

viewer.stop();
Other exporterspick one, or several
// Exporters are registered globally on the Tracer.
Tracer.addExporter(new JsonTraceExporter(new File("trace.jsonl")));
Tracer.addExporter(new AtifExporter(new File("trace.atif.json")));
Tracer.addExporter(new OtlpHttpExporter("http://collector:4318"));
Tracer.addExporter(new LangfuseExporter(publicKey, secretKey));

// Or pretty-print any span tree straight to a console:
TerminalTraceExplorer.print(rootSpan, System.out);