Examples
Nineteen agents you can run today
A progressive tutorial. Each example is a single class with a main(), adding exactly
one idea to the one before it. All nineteen ship in org.jopenagent.examples and have
been run for real against a live model, not merely compiled.
E01
The smallest possible agent
One class, one LLM-backed capability. The @Generative value is the prompt,
the return type is the contract, and generate(name) is where the harness takes
over.
Note what is not here: no client wiring inside the agent, no prompt template file, no output parser, no registration call.
@SystemPrompt("You are a warm, concise greeter.")
public class E01_FirstGenerationMethod extends Agent {
public E01_FirstGenerationMethod(AgentConfig config) {
super(config);
}
@Generative("Write a one-sentence, friendly greeting for the given person.")
public String greet(String name) {
return generate(name);
}
public static void main(String[] args) {
E01_FirstGenerationMethod agent = new E01_FirstGenerationMethod(
AgentConfig.builder().llmClient(ExampleSetup.createLlmClient()).build());
System.out.println(agent.greet("Ada"));
}
}
E02
Structured output from a record
A record return type is all it takes. ObjectBinder binds the model’s
JSON straight onto the canonical constructor, validating types as it goes, and retries with
the validation error fed back if the shape does not fit.
List<String> is bound element by element. Nested generics, enums,
Optional and further records all work the same way.
@SystemPrompt("You extract structured information from restaurant reviews.")
public class E02_StructuredOutput extends Agent {
public record ReviewSummary(int rating, String sentiment,
java.util.List<String> highlights) {
}
@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);
}
public static void main(String[] args) {
E02_StructuredOutput agent = new E02_StructuredOutput(
AgentConfig.builder().llmClient(ExampleSetup.createLlmClient()).build());
ReviewSummary summary = agent.summarize(
"The ramen was incredible and the service was fast, but the "
+ "restaurant was quite loud and cramped.");
System.out.println("rating: " + summary.rating());
System.out.println("sentiment: " + summary.sentiment());
System.out.println("highlights: " + summary.highlights());
}
}
E03
Tools are just your methods
Annotate ordinary methods with @Doc, pick
StrategyKind.TOOL_CALLING, and the harness derives JSON-schema tool
definitions from the real signatures and runs the function-calling loop against them.
The agent’s own inventory map is private state the model never sees
directly. It reaches that data only through the two methods you chose to expose.
@SystemPrompt("You are an inventory assistant.")
public class E03_ToolsViaSelf extends Agent {
private final Map<String, Integer> inventory = new HashMap<>();
public E03_ToolsViaSelf(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 java.util.List<String> knownItems() {
return new java.util.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);
}
}
E07
Context blocks, static and live
put stores a block once. setDynamic stores an expression that is
re-evaluated every single time a prompt is built, so when the backlog changes the
model’s view of it changes too, with no refresh call anywhere in your code.
That is the difference between context that is configured and context that is current.
@SystemPrompt("You are a sprint-planning assistant.")
public class E07_ContextBlocks extends Agent {
private int backlogSize = 5;
public String formatBacklog() {
return backlogSize + " item(s) remaining";
}
public void addBacklogItem() {
backlogSize++;
}
@Generative("Given the sprint plan and current backlog in context, "
+ "suggest what to tackle next.")
public String suggestNextStep() {
return generate();
}
public static void main(String[] args) {
E07_ContextBlocks agent = new E07_ContextBlocks(
AgentConfig.builder().llmClient(ExampleSetup.createLlmClient()).build());
agent.context.put("plan", "Sprint goal: ship the billing export by Friday.");
agent.context.setDynamic("backlog", "self.formatBacklog()");
System.out.println(agent.suggestNextStep());
agent.addBacklogItem();
System.out.println(agent.context.get("backlog")); // "6 item(s) remaining"
}
}
E08
Code as action
The instruction tells the model to write code that reads
self.orderTotalsCents(). It does exactly that: real Java, executed in a JShell
sandbox with self bound to this live instance, iterating until it calls
return_result with a value that fits the declared int.
--add-modules jdk.jshell to the run
configuration. jdk.jshell is a JDK tool module and is not resolved by default
for classpath code.
@SystemPrompt("You are a data-processing agent.")
public class E08_CodeAsAction extends Agent {
private final List<Integer> orderTotalsCents = new ArrayList<>();
public E08_CodeAsAction(AgentConfig config) {
super(config);
orderTotalsCents.add(1299);
orderTotalsCents.add(499);
orderTotalsCents.add(2599);
}
@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();
}
}
SandboxConfig outOfProcess = SandboxConfig.builder()
.mode(SandboxMode.OUT_OF_PROCESS)
.timeoutMillis(10_000L)
.build();
AgentConfig config = AgentConfig.builder()
.llmClient(ExampleSetup.createLlmClient())
.sandboxConfig(outOfProcess)
.build();
// E18 then proves the timeout with no LLM involved at all:
try (CodeExecutor sandbox = new OutOfProcessCodeSandbox(agent, 2000L)) {
SandboxResult result = sandbox.execute("while (true) { }");
System.out.println(result.getStatus()); // killed, not left hanging
}
E12
Memory across conversations
Two ordinary methods wrap self.memory, and the model calls them as tools. Ask
the agent something in the second call and it recalls what you told it in the first. Across
sessions too, if you back it with JsonFileMemoryStore.
By default this runs completely offline on a deterministic HashingEmbedder.
Pass a real Embedder to AgentConfig and nothing else changes.
@SystemPrompt("You are a support agent with long-term memory of past interactions.")
public class E12_Memory extends Agent {
@Doc("Remembers a fact about a customer for future reference.")
public String rememberFact(String fact) {
Memory memory = this.memory.remember(fact);
return "stored as " + memory.getId();
}
@Doc("Recalls the most relevant remembered facts for a query.")
public String recallFacts(String query) {
StringBuilder sb = new StringBuilder();
for (Memory memory : this.memory.recall(query, 3)) {
sb.append("- ").append(memory.getContent()).append("\n");
}
return sb.length() == 0 ? "(nothing relevant remembered)" : sb.toString();
}
@Generative(value = "Using self.recallFacts if useful, answer the customer's "
+ "question. Remember any new fact they share via self.rememberFact.",
strategy = StrategyKind.TOOL_CALLING)
public String converse(String customerMessage) {
return generate(customerMessage);
}
public static void main(String[] args) {
E12_Memory agent = new E12_Memory(AgentConfig.builder()
.llmClient(ExampleSetup.createLlmClient())
.embedder(ExampleSetup.createEmbedder())
.build());
System.out.println(agent.converse(
"Hi, I'm on the Enterprise plan and prefer email over phone."));
System.out.println(agent.converse("How do you prefer to reach me again?"));
}
}
E10
Skills, loaded on demand
A skill is a SKILL.md bundle: frontmatter plus a markdown body. The model
sees only the name and description until it needs the rest.
Files.writeString(skillDir.resolve("SKILL.md"),
"---\n"
+ "name: reply-tone\n"
+ "description: House style for customer replies.\n"
+ "---\n\n"
+ "Always thank the customer first, then answer directly. "
+ "Never apologize more than once.\n");
agent.skills.load(TextSkill.load(skillDir));
for (Skill skill : agent.skills.all()) {
System.out.println(skill.getId() + ": " + skill.getDescription());
}
TextSkill toneSkill = (TextSkill) agent.skills.get("reply-tone");
System.out.println(toneSkill.getInstructions()); // pulled on demand
Same file format as the skill we ship for AI coding assistants.
E13
Images as real parameters
An Image parameter gets attached to the request as native media content in
the provider’s own wire format, instead of being stringified into the prompt.
@SystemPrompt("You describe the contents of images concisely.")
public class E13_Multimodal extends Agent {
@Generative("Describe what shape and color is shown in the image, "
+ "in one short sentence.")
public String describeImage(Image image) {
return generate(image);
}
public static void main(String[] args) throws IOException {
Image image = Image.fromBytes(renderRedCirclePng(), "image/png");
// ...or Image.fromFile(path) / Image.fromUrl(url)
E13_Multimodal agent = new E13_Multimodal(
AgentConfig.builder().llmClient(ExampleSetup.createLlmClient()).build());
System.out.println(agent.describeImage(image));
}
}
E15
Self-correction, as a strategy
REFLEXION generates an answer, produces a structured critique of it, then
retries with that critique as feedback, up to three attempts, before returning. The whole
cycle is traced, so you can see exactly what the model talked itself out of.
Note the return type: int. The critique loop and the type contract compose,
and the caller still just gets a number.
@SystemPrompt("You are a precise arithmetic word-problem solver. "
+ "Show your final numeric answer only.")
public class E15_Reflexion extends Agent {
@Generative(value = "Solve the word problem and return just the final integer answer.",
strategy = StrategyKind.REFLEXION)
public int solve(String wordProblem) {
return generate(wordProblem);
}
public static void main(String[] args) {
E15_Reflexion agent = new E15_Reflexion(
AgentConfig.builder().llmClient(ExampleSetup.createLlmClient()).build());
int answer = agent.solve(
"A warehouse had 84 pallets. It shipped out 3 trucks with 12 pallets "
+ "each, then received a delivery of 25 more pallets. How many pallets "
+ "are in the warehouse now?");
System.out.println("Answer: " + answer); // 84 - 36 + 25 = 73
}
}
E19
Evaluate it like software
An EvalSuite of tiered cases, a Scorer, and a run tagged with the
model name. Results come back as a plain list, and because every case is also its own trace
span, the trace viewer’s Experiments tab shows the same run as a
pass-rate table you can click into.
Tier.STABLE versus Tier.FRONTIER lets you separate “this
must never regress” from “this is the hard case we are chasing”.
TraceViewerServer viewer = new TraceViewerServer();
Tracer.addExporter(viewer);
viewer.start(5001);
EvalSuite<String> suite = EvalSuite.<String>builder("ticket-triage-v1")
.addCase(EvalCase.builder("billing-1", new EvalTask<String>() {
@Override
public String run() {
return agent.classify("I was charged twice for my subscription.");
}
}).displayName("Duplicate charge")
.expected("billing")
.tier(Tier.STABLE)
.metadata("difficulty", "easy").build())
.addCase(EvalCase.builder("ambiguous-1", new EvalTask<String>() {
@Override
public String run() {
return agent.classify("My invoice shows a technical-looking "
+ "error code instead of the amount due.");
}
}).displayName("Invoice shows an error code")
.expected("billing")
.tier(Tier.FRONTIER)
.metadata("difficulty", "hard").build())
.build();
List<EvalResult<String>> results =
EvalRunner.run(suite, new ContainsScorer(), agent.getLlmClient().modelName());
for (EvalResult<String> result : results) {
System.out.println((result.isPassed() ? "PASS" : "FAIL")
+ " " + result.getCaseId()
+ " expected=" + result.getExpected()
+ " actual=" + result.getActual());
}