Key Takeaways
- Embabel lets Java developers express agent behavior as typed actions and goals.
- The GOAP-style planner uses available objects and action signatures to find a path to the goal.
- OCI Generative AI can be introduced through a starter without rewriting the agent’s action code.
- Deterministic Java code can prepare trusted context before the model writes the language-heavy part of the response.
- Observability matters because agent behavior is a sequence of decisions and calls; Jaeger gives you a concrete way to inspect that sequence.
I was lucky enough to see a presentation from Rod Johnson at a meetup in New York recently. Rod is the creator of Spring, and his new project, Embabel, is fascinating. It’s a different kind of take on building agentic AI applications. I am particularly attracted to its use of method signatures as a way to deterministically create a goal, and the ease with which you can mix deterministic Java code with probabalistic LLM reasoning. It plays nice in existing Spring applications, uses the same conventions as Spring, so it’s already familiar to you if you come from that ecosystem.
Embabel is a very interesting project – because it does not ask Java developers to choose between “real code” and “AI code.” It treats the LLM as one participant in a typed application flow. Ordinary Java methods still do the parts that should be deterministic. The model does the parts where language, synthesis, and judgment are useful. Embabel sits between those pieces and works out how to get from the input you have to the goal you asked for.
That is a useful place to be if you already live on the JVM. A lot of enterprise software is not waiting to be rewritten into a new stack just because LLMs arrived. It already has Spring services, domain objects, tests, configuration, and deployment habits. Embabel plugs into that world and gives agentic code a shape Java developers can reason about: methods, parameters, return types, goals, and tests.
In this article we will use a new OCI Generative AI starter for Embabel. The starter lets an Embabel application use OCI Generative AI models through the same model abstraction the rest of the framework expects. The demo is a small travel briefing agent, but the interesting part is the architecture. It puts three kinds of work into one planned flow:
- Parse a user request into a typed Java record.
- Gather deterministic local facts with ordinary Java code.
- Ask OCI Generative AI to turn those facts into a typed briefing.
The point is not the travel domain. The point is the shape of the application: deterministic code and probabilistic model calls in one planned flow, with Java types describing what each step consumes and produces.

What Embabel Is Doing
Embabel models an agent around a few core concepts: actions, goals, conditions, domain objects, and plans. The developer writes action methods. Each action method advertises what it needs through its parameters and what it produces through its return type. A goal describes a useful final state. At runtime, Embabel can infer a plan from the available objects, the actions it knows about, and the goal it needs to reach.
The code for this article is available in GitHub at https://github.com/markxnelson/embabel-oci-travel-agent
That makes the Java method signature more than a convenience. It is part of the agent contract. The method signature says, in a way the framework and the compiler can both see, “if you can give me a TravelRequest, I can give you LocalFacts.”
In an Embabel application, a method signature is not just implementation detail. It is part of the map the planner uses to decide what can happen next.
@Actionpublic LocalFacts gatherLocalFacts(TravelRequest request) { return new LocalFacts( request.destination(), List.of( "The Chicago Riverwalk is a good base for architecture walks.", "The lakefront trail gives an easy outdoor reset between indoor stops.", "River North and Fulton Market have reliable coffee options near transit." ) );}
This action does not call an LLM. It does not need to. It accepts a TravelRequest and returns LocalFacts. When a later action needs LocalFacts, Embabel has a way to produce them.
Now compare that with the action that writes the final answer:
@AchievesGoal(description = "A concise travel briefing has been prepared")@Actionpublic TravelBriefing writeBriefing( TravelRequest request, LocalFacts facts, Ai ai) { var prompt = """ Write a concise two-day travel briefing in Markdown. Use the supplied local facts. Keep the plan practical. Mention why the deterministic facts and the LLM-written narrative both matter. # Request %s # Local facts %s """.formatted(request.originalText(), String.join("n", facts.facts())); return ai.withLlm(LlmOptions.withDefaultLlm().withTemperature(0.2)) .createObject(prompt, TravelBriefing.class);}
This method is still typed. It still takes explicit inputs. It still returns a Java record. Inside the method, the Ai helper calls the configured LLM and asks it to create a TravelBriefing. The model has room to write, but the application still decides where the model is allowed to act and what shape the result must have.
Why Goals Matter
Embabel’s default planning approach is based on Goal-Oriented Action Planning. In practical terms, GOAP lets the framework ask: given the objects currently available, which action can move the process closer to a desired goal?
For this demo, the starting object is a UserInput. The goal is a TravelBriefing. Embabel can see that:
parseRequest(UserInput)can produceTravelRequest.gatherLocalFacts(TravelRequest)can produceLocalFacts.writeBriefing(TravelRequest, LocalFacts, Ai)can produceTravelBriefing.
So the path is straightforward. In a larger application, this same mechanism becomes more valuable. You can add actions without rewriting a giant orchestration method. If a new action produces a type that another goal needs, it becomes part of the planning vocabulary.
That is the key difference between “call an LLM from Java” and “build an agentic Java application.” The former is a client call. The latter is a system of typed capabilities.
There is another practical benefit here: the plan is inspectable. If an agent run surprises you, you can look at the objects that were present, the actions that were eligible, and the goal Embabel was trying to satisfy. That fits the way many Java teams already debug applications. You do not have to treat the whole system as one giant prompt. You can ask smaller questions. Did the parser produce the right domain object? Did the deterministic action add the facts the model needed? Did the LLM action receive the right prompt? Did the final object satisfy the goal?
That debugging posture is one reason this style works well for enterprise Java demos. It keeps the model important, but it does not make the model responsible for everything. The more important the application is, the more you want boring pieces around the surprising piece: typed inputs, explicit outputs, repeatable tests, configuration that can be reviewed, and traces that show what happened.
Adding OCI Generative AI
The OCI starter is intentionally small from the application developer’s point of view. The demo depends on the starter and imports the Embabel dependency BOM:
<dependencyManagement> <dependencies> <dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-dependencies</artifactId> <version>${embabel-agent.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies></dependencyManagement><dependencies> <dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter-oci-genai</artifactId> <version>${embabel-agent.version}</version> </dependency></dependencies>
The local OCI GenAI starter autoconfiguration registers OCI-backed chat and embedding model beans. The model definitions live in oci-genai-models.yml; the application can then select a default model through Embabel configuration:
embabel.models.default-llm=google.gemini-2.5-proembabel.models.default-embedding-model=cohere.embed-v4.0embabel.agent.platform.models.ocigenai.compartment-id=ocid1.compartment.oc1...embabel.agent.platform.models.ocigenai.region=us-chicago-1
For local development, the OCI starter can use the normal OCI config file authentication path. The important design detail is that the agent code does not change when the model connection is configured. The action asks for the default LLM; the platform supplies the OCI-backed model.
That separation keeps the demo from becoming a provider-specific tangle. The agent action is written in terms of Embabel’s Ai abstraction and the target Java type. OCI Generative AI appears in configuration and model loading. The Java method still says, “given this request and these facts, produce a TravelBriefing.” That is a good boundary. Application code owns the domain. Platform configuration owns the model connection.
The same boundary also helps with testing. The integration test does not need to contact OCI Generative AI to prove that Embabel can discover the actions, build the plan, and pass deterministic facts into the LLM step. It stubs the model response and verifies the application behavior around the call. Local deterministic tests catch ordinary mistakes quickly: missing beans, wrong action signatures, a prompt that forgot the facts, or a goal that cannot be reached.
The Demo Agent
Here are the domain records:
public record TravelRequest( String destination, int days, List<String> interests, String originalText) {}public record LocalFacts( String destination, List<String> facts) {}public record TravelBriefing( String destination, String tripStyle, String markdown) {}
These records are not ceremony. They are the backbone of the agent. They tell Embabel what exists in the process at each step, and they tell the LLM exactly what object it must produce when the flow reaches the probabilistic part of the application.
The first action turns a free-form request into a simple typed object:
@Actionpublic TravelRequest parseRequest(UserInput userInput) { var text = userInput.getContent(); var destination = text.contains("Chicago") ? "Chicago" : "the requested city"; var interests = List.of("architecture", "coffee", "lakefront walk", "Java community"); return new TravelRequest(destination, 2, interests, text);}
This is deliberately deterministic. A real application might use a database lookup, a rules engine, a customer profile, or a known catalog. Here, it is just enough code to show the boundary: not every step belongs in the model.
The final action uses the LLM, but it does so with a typed result:
return ai.withLlm(LlmOptions.withDefaultLlm().withTemperature(0.2)) .createObject(prompt, TravelBriefing.class);
Temperature is low because this is a practical briefing. We want enough language ability to make the result readable, not a wildly creative itinerary.
Notice what is not happening in this design. The prompt is not being asked to rediscover the whole workflow. It does not decide whether local facts should be gathered. It does not choose the action order. It receives a constrained job at the point where language generation is useful. The rest of the workflow remains normal Java. That is the difference between an agent that is merely “LLM-shaped” and an agent that can live inside an application architecture.
That split also gives reviewers a cleaner way to reason about risk. Deterministic code can be reviewed like deterministic code. The LLM prompt can be reviewed as a bounded language task. The trace can show whether the handoff between those two worlds happened where expected.
Running It
Start Jaeger first:
docker compose up -d
The example application assume you have install the OCI CLI and set up a profile so you can authenticate to OCI. Assuming you have done that, run the application with your OCI compartment and region:
mvn spring-boot:run -Dspring-boot.run.arguments="Plan a two day visit to Chicago for a Java developer who likes architecture, coffee, and the lake." -Dspring-boot.run.jvmArguments="-Dembabel.agent.platform.models.ocigenai.compartment-id=ocid1.compartment.oc1... -Dembabel.agent.platform.models.ocigenai.region=us-chicago-1"
The console output includes the question-driven answer produced by the agent:
=== Embabel OCI GenAI travel briefing ===Destination: ChicagoTrip style: Java developer# Chicago Briefing: A Developer's Two-Day ItineraryThis plan balances structured sightseeing with time for discovery, focusing on architecture, coffee, and the lakefront. It's built on deterministic facts (the reliable APIs of your trip) and a narrative layer (the application logic that creates a seamless user experience).### Day 1: River, Loop & Classic Structures* **Morning:** Start your day in River North. Grab a high-quality coffee at a local spot like Intelligentsia to fuel your exploration. The area is well-connected by transit.* **Mid-day:** Head to the Chicago Riverwalk for the Chicago Architecture Foundation Center's River Cruise. It's the most efficient way to see dozens of iconic buildings and understand the city's history.* **Afternoon:** After the cruise, walk along the Riverwalk for different ground-level perspectives of the canyon-like urban core.* **Late Afternoon:** Walk south into the Loop to see foundational skyscrapers up close, including the Rookery Building and the Monadnock Building.### Day 2: Lakefront, Modern Design & Fulton Market* **Morning:** Explore Fulton Market, a former industrial district now known for tech offices and a vibrant food scene. Start with coffee at a neighborhood roaster.* **Mid-day:** Head east to the Lakefront Trail for an outdoor reset. Rent a Divvy bike or walk a segment north of Millennium Park for skyline and water views.* **Afternoon:** Make your way to Millennium Park. See Cloud Gate and Frank Gehry's Pritzker Pavilion.* **Evening:** Return to Fulton Market for dinner.
The test suite also validates the same typed agent flow with mocked LLM output. That is useful because it proves the planner can get from UserInput to TravelBriefing and that the prompt receives the deterministic facts before you spend any cloud tokens.
mvn test
A successful run shows Embabel planning and executing the three typed actions, then Maven reports a clean test result:
Embabel - Deployed agent TravelPlanningAgentEmbabel - formulated plan: TravelPlanningAgent.parseRequest -> TravelPlanningAgent.gatherLocalFacts -> TravelPlanningAgent.writeBriefingEmbabel - goal TravelPlanningAgent.writeBriefing achieved[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0[INFO] BUILD SUCCESS
Observability
Embabel has observability built in (which is great!) and so the demo code also includes the Embabel observability starter and an OTLP exporter:
<dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter-observability</artifactId> <version>${embabel-agent.version}</version></dependency><dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId></dependency>
Tracing is enabled in application.properties:
embabel.observability.enabled=trueembabel.observability.service-name=embabel-oci-travel-agentmanagement.tracing.enabled=truemanagement.tracing.sampling.probability=1.0management.otlp.tracing.endpoint=http://localhost:4318/v1/traces
Open Jaeger at http://localhost:16686 and search for embabel-oci-travel-agent. The useful trace is not only “there was a request.” It shows whether the agent run, action spans, and LLM call line up with the flow you intended to demonstrate. That makes tracing a sanity check for the agent design, not just an operations checkbox.

In the trace, the most useful thing to inspect is the shape of the run. You want to see the deterministic actions before the model-backed action, and you want the LLM call to appear under the action that writes the briefing. If the trace shows the model call happening before the facts are gathered, the problem is not the model. It is the application flow.
Here’s a different view of the trace, called a “flame graph”:

Enjoy!


















You must be logged in to post a comment.