Overview and Motivation
What is Jakarta Agentic AI
Jakarta Agentic AI is a Jakarta EE specification (package
jakarta.ai.agent) for building AI agents on Jakarta EE
runtimes. An agent is a CDI bean that encapsulates autonomous,
goal-driven behavior: it perceives an event, reasons
(typically by querying an LLM), decides whether and how to proceed, and
acts — all inside a workflow with well-defined phases.
Just as Jakarta Persistence standardized the API, usage patterns, and paradigms for
object-relational data access, Jakarta Agentic AI standardizes agent construction (the
same analogy applies for basically any other Jakarta technology such as Servlet, REST, or
Batch). You program against well-understood standard annotations and interfaces such as
@Agent and LargeLanguageModel. A compatible runtime provides the
actual orchestration engine, connectivity to the LLM provider, as well as natural
integration with other Jakarta EE technologies.
This tutorial uses Payara as its running implementation, so setup steps, configuration file paths, and configuration property names are Payara-specific. The specification APIs work identically on any compatible runtime — only how you configure the runtime varies. In the future some of these configurations will be standardized as well.
📖 This tutorial gives you basic orientation and runnable examples. The single source of truth for the programming model is the specification itself, which is short and meant to be read by everyone: read the Jakarta Agentic AI 1.0 specification →. Throughout, "Read the specification →" links take you to the exact section.
Why a specification for agents?
Before this specification, there was no standard way to build an AI agent in a Jakarta EE application. Teams either adopt a vendor-specific framework, assemble the pieces themselves, or have no cohesive idea of an agent. The specification addresses three main gaps.
- Familiar structure instead of chaos — without a
defined lifecycle based on common understanding, every application invents its own usage patterns and
APIs. Here you annotate ordinary methods and the runtime orchestrates them in a well-defined way:
Trigger → Decision* → Action* → Outcome. The API is also deliberately designed to be familiar to Jakarta EE developers, even those new to AI. - Out-of-the-box integrations — agents still need common capabilities like
dependency injection, scopes, events, validation, JSON processing, transactions, and persistence.
Rather than reinventing that, the specification simply integrates with existing Jakarta
technologies. The component model itself builds on CDI: the trigger is a CDI
event, the agent is a bean, and the workflow scope is a custom CDI scope. Everything else
in the platform —
@Inject,@Valid,@Transactional,EntityManager— just works. Furthermore, other common integrations such as with LLMs (and maybe even A2A in the future) are simply included in a way that makes sense to a Java/Jakarta EE developer. - Vendor neutrality — nothing in the agent names a provider or
a runtime. The LLM arrives as an injected
LargeLanguageModelrather than a vendor client, so the provider is runtime configuration and the runtime is wherever you deploy. Like all Jakarta EE specifications, Agentic AI makes vendor neutrality possible.
The mental model — a workflow of phases
CDI event
|
▼
+----------+ +-----------+ +---------+ +----------+
| @Trigger | -→ | @Decision | -→ | @Action | -→ | @Outcome |
+----------+ +-----------+ +---------+ +----------+
(required, (0..N, may (0..N) (0..1, void,
exactly 1) stop the flow) ends the context)
Key points:
- Trigger is the only required phase — exactly one method per agent in version 1.0 (a restriction expected to be relaxed in the future).
- Decisions and actions can be intermixed in any sequence, enabling anything
from
Trigger + Action(simple execution) up toTrigger + Decision + Action + Decision + Action + ...(complex branching). In the future workflows can be defined programmatically and change dynamically at runtime. - A Decision can end the workflow (by returning
false,nullorResult(false, ...)) — the remaining phases and the Outcome do not run. - Outcome marks the successful end of the workflow; after it, the runtime destroys the workflow context.
- Data flows between phases by type: whatever a phase returns becomes available as a parameter of later phases (type-based injection, no manual parameter passing).
Pluggability in action — swapping GPT (OpenAI) for Claude (Anthropic)
Let's take a quick look at some code, specifically highlighting easy, pluggable
integration with LLMs. Consider a running application faced with the requirement
"starting today we use Claude instead of GPT". With LangChain4j's
ChatModel as an example, the provider choice is compiled into the
application:
// BEFORE — pom.xml: dev.langchain4j:langchain4j-open-ai
ChatModel model = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o")
.build();
// AFTER — swap the pom.xml dependency to dev.langchain4j:langchain4j-anthropic,
// rewrite the wiring, recompile, repackage, redeploy
ChatModel model = AnthropicChatModel.builder()
.apiKey(System.getenv("ANTHROPIC_API_KEY"))
.modelName("claude-sonnet-4-6")
.build();
The switch requires changing a dependency, rewriting construction code, and rebuilding the application. The abstraction is a library API, so it ships inside your WAR.
With Jakarta Agentic AI, the agent injects the API interface and names no provider:
@Agent
public class QuestionAgent {
@Inject
private LargeLanguageModel model; // jakarta.ai.agent — no vendor code here
@Trigger
void onQuestion(Question question) { // CDI event that starts the workflow
if (question.text() == null || question.text().isBlank()) {
throw new IllegalArgumentException("Question text is required.");
}
}
@Action
Answer generate(Question question) {
return new Answer(model.query("Answer concisely: {}", question.text()));
}
}
The whole switch is one configuration file. In Payara that is
microprofile-config.properties:
# BEFORE # AFTER
payara.agentic.llm.provider=ollama payara.agentic.llm.provider=anthropic
payara.agentic.llm.model=gemma3:4b payara.agentic.llm.model=claude-sonnet-4-6
No code changes, no pom.xml changes (the provider's client infrastructure
lives in the runtime, not in the WAR), no recompilation — at most a
redeploy. It is the same leap Jakarta Persistence made over hand-rolled JDBC: the provider
became a configuration detail. The examples run this live — two identically written
agents, one on local Ollama and one on Claude, differing only in
microprofile-config.properties.
Next, let's tour these concepts in a bit more detail.
The Specification in Brief
This is a fast tour of the programming model — just enough to read examples and know what to look for. Each key concept ends with a "Read the specification →" link to the exact specification section with further details.
An agent, at a glance
An agent is a CDI bean annotated with @Agent. Its behavior is expressed as
a small set of annotated methods (the phases). A complete agent can fit in one
class:
@Agent
public class QuestionAgent {
@Inject
private LargeLanguageModel model;
@Trigger
void onQuestion(@Observes Question question) { // starts the workflow
if (question.text() == null || question.text().isBlank()) {
throw new IllegalArgumentException("Question text is required.");
}
}
@Decision
boolean notYetAnswered(Question question, // may end the workflow
AnswerStore answers) {
return answers.get(question.text()) == null;
}
@Action
Answer generate(Question question) { // does the work
return new Answer(model.query("Answer concisely: {}", question.text()));
}
@Outcome
void store(Question question, Answer answer, // ends the workflow
AnswerStore answers) {
answers.put(question.text(), answer.text());
}
}
The runtime discovers the bean, wires the phases into a workflow and passes data between them by type. Read the specification → the programming model.
The phases
The lifecycle is Trigger → Decision* → Action* → Outcome, with
OnException cutting across every phase:
@Trigger— the single entry point; a CDI event observer that starts the workflow.@Decision— optional gate(s); returningfalse/null/Result(false, …)ends the workflow early.@Action— optional step(s) that do the work (usually an LLM call).@Outcome— the successful end; afterwards the runtime destroys the workflow context.@OnException— recovery: return normally to continue, rethrow to stop.
Decisions and actions may be intermixed, and data flows between phases by return type — no manual parameter passing. Read the specification → the agent lifecycle and execution order.
Scopes
Two scopes matter. @WorkflowScoped — the default, applied when an agent
does not explicitly declare a scope — means the agent lives for exactly one workflow execution,
ideal for state shared only across phases without leaking between concurrent runs.
@ApplicationScoped agents are shared, but their LLM conversation is still
isolated per workflow, so two concurrent executions never mix history. Read the specification → scopes.
The LargeLanguageModel facade
LargeLanguageModel is the simple interface an agent injects to talk to an
LLM. query(...) takes a prompt with {} placeholders and returns
text; an overload maps the response to a typed object via JSON-B. If the facade does not
expose something you need, unwrap(...) reaches the underlying implementation.
Read the specification → LLM integration.
Errors
LLM service failures surface as LLMException (unchecked). A typed
query that cannot be mapped to the requested type also raises
LLMException rather than returning corrupted data. Catch these and any other
errors (including invalid parameters that Jakarta Validation detects) in an
@OnException method with clear semantics: return normally to continue the
workflow, rethrow to stop it. Read the specification → exceptions.
That is the whole basic model. For the complete, authoritative rules — annotations, return patterns, ordering, cardinalities — and for more worked examples, go to the specification: read the specification → examples.
Next, let's get a runtime under things and put the API to work.
Running on Payara
Payara is a compatible implementation of the specification. You write the agent, Payara
runs the workflow. It also implements the LargeLanguageModel facade and plugs
in a provider-specific backend behind it (such as Ollama, Anthropic, or Vertex). There is
really just one thing you configure: the LLM backend.
LLM backend with MicroProfile Config
Configuration is done through MicroProfile Config, so it can come from the
application's META-INF/microprofile-config.properties, from system
properties, or from environment variables. All keys have the
payara.agentic.llm. prefix:
| Key | Values/default |
|---|---|
provider |
none (default → no-op), ollama, anthropic (alias claude), vertex |
model |
Ollama default gemma; Anthropic/Vertex default claude-sonnet-4-6 |
ollama.base-url |
http://localhost:11434 |
anthropic.base-url |
https://api.anthropic.com |
anthropic.api-key |
or the ANTHROPIC_API_KEY env var |
vertex.project-id |
or the ANTHROPIC_VERTEX_PROJECT_ID env var (required) |
vertex.region |
or the CLOUD_ML_REGION env var; default global |
max-tokens |
4096 |
system |
optional system prompt (also used as the cache prefix) |
Two behaviors worth knowing:
- Unknown or absent provider → no-op, never a failed deployment: injecting
LargeLanguageModelalways resolves, even with nothing configured. - Anthropic without an API key →
IllegalStateExceptionat startup, with a message naming exactly which key/env var to set — fail fast with a diagnosis.
The backends
- Ollama — local models over HTTP; the no-API-key, zero-cost option for
offline demos (the ask-agent example uses
gemma3:4b). Setprovider=ollamaand, if needed,ollama.base-url. - Anthropic — Claude via the Messages API. Provide the API key via
anthropic.api-keyor theANTHROPIC_API_KEYenv var (export it in the same shell that starts the domain — the server inherits its environment). When asystemprompt is configured, Payara sends it with prompt caching so the stable prefix is reused across the workflow's phases, cutting cost and latency; note that Claude only caches prefixes above a minimum size, so short system prompts simply do not cache (silently — it is not an error). - Vertex — Claude served by Google Vertex AI: same model family, GCP
authentication and billing (
vertex.project-id+vertex.regioninstead of an API key). - none (default) — an inert backend that returns a fixed response; it lets you deploy and exercise the workflow before wiring a real provider.
If you ever need something the Agentic AI facade does not expose,
llm.unwrap(...) reaches the concrete backend.
Next, let's take a look at some examples to crystallize all this.
The Examples
Three examples, three levels: the ask agent teaches the programming
model; the customer inquiry intake example runs a short
multi-phase pipeline inside one agent — four actions and two decisions wired together by
parameter type, with declarative validation on the trigger, typed LLM output and
an @OnException fallback for an LLM outage; and the Lesson
Builder raises the bar to two agents chained by a CDI event, with
the human editing and approving in between.
All three live in the examples/ directory of the Jakarta Agentic AI repository —
the single place where all examples are maintained. This chapter explains what each one teaches and the
design ideas behind it; for the full source, configuration and run instructions, follow
the Source link on each example.
These three are not the whole set — they deliberately build on each other, so they are worth reading in order here. Others in the repository stand on their own, such as
fraud-detection, which flags suspicious bank transactions, anddocs-agent, which watches pull requests and drafts documentation updates. The examples README describes them all.
Example 1 — Ask Agent
📦 Source: examples/ask-agent
A compact agent that exercises all four phases. A REST POST fires a CDI event and the agent answers the question with the configured LLM, unless it has answered that question before. It fits in one class, shown here in full apart from the logging:
@Agent(name = "QuestionAgent",
description = "Answers a question using the configured LLM backend.")
public class QuestionAgent {
@Inject private LargeLanguageModel model;
@Inject private AnswerStore answers;
@Trigger
void onQuestion(@Valid Question question) { /* ... */ }
@Decision
boolean notYetAnswered(Question question) {
return answers.get(question.text()) == null; // a repeat stops the workflow
}
@Action
Answer generate(Question question) {
return new Answer(model.query(
"Answer concisely in one short paragraph: {}", question.text()));
}
@Outcome
void store(Question question, Answer answer) {
answers.put(question.text(), answer.text());
}
}
Choosing the LLM backend is configuration, not code — this example runs fully local on Ollama at zero cost:
payara.agentic.llm.provider=ollama
payara.agentic.llm.model=gemma3:4b
payara.agentic.llm.ollama.base-url=http://localhost:11434
What it teaches
- No scope annotation → the runtime applies
@WorkflowScoped, the specification's default. - An agent is an ordinary CDI bean, so injection works as it does anywhere:
the platform's
LargeLanguageModeland the application's ownAnswerStorearrive through the same@Inject. - The event type stays plain.
Questionis a record carrying one Bean Validation constraint and nothing else — no interface, no base class, nothing fromjakarta.ai.agent— and@Observeson the trigger parameter is optional, so the form here and the explicit@Observes Question questionform shown in chapter 2 are equivalent. @Validon the trigger parameter — the phase model honors Jakarta Validation, and a violation is raised before the body runs. Here it is the@NotBlankonQuestion, so an agent never starts on an empty question — the declarative version of the manual null/blank check sketched in the earlier chapters.- Nothing is passed between phases by hand — the runtime invokes each phase and
fills its parameters by type.
Questionmatches the triggering event;Answermatches what@Actionreturned, which is how@Outcomereceives it. - A
@Decisionsays whether the workflow continues, and has three ways to say it: aboolean, aResult(success, details), or a domain object wherenullmeans stop. This one returnsfalsefor a question already in the store, so a repeat never reaches the model. - Prompts are parameterized, not concatenated — the
{}is filled from the argument that follows the prompt, positionally.
Example 2 — Customer Inquiry Intake
📦 Source: examples/customer-inquiry-intake
A realistic multi-phase pipeline behind a customer contact form. The customer types what they have — even just the message — and clicks Prepare inquiry; the agent runs a short pipeline end-to-end (pulling the sender's name and email out of the message when they aren't already filled in, inferring the sender's company from the business-email domain, picking a routing category, drafting a subject line, and rewriting the message in a tone that matches that category) and the customer reviews the prepared record before confirming.
The whole agent fits in one class — four actions, two decisions; the runtime chains the phases by matching each method's parameters by type:
@Agent
public class IntakeAgent {
@Trigger void onSubmit(@Valid IntakeRequest r) { /* ... */ }
// drop junk submissions before any LLM call
@Decision boolean looksLikeRealInquiry(IntakeRequest r) { /* ... */ }
// fill in missing name/email; infer company from the email domain
@Action Identity enrichIdentity(IntakeRequest r) { /* ... */ }
// pick which team should handle it
@Action Classification classify(IntakeRequest r) { /* ... */ }
// draft a short subject line for ticketing
@Action Title draftTitle(IntakeRequest r) { /* ... */ }
// stop if there's still no identity and no substantive content
@Decision boolean hasEnoughContent(Identity id, IntakeRequest r) { /* ... */ }
// rewrite the message in a tone that fits the category
@Action PolishedMessage polish(IntakeRequest r, Classification c) { /* ... */ }
// assemble the reviewed record from the four phase results
@Outcome void publishPreparedInquiry(Identity i, Classification c,
Title t, PolishedMessage p) { /* ... */ }
// handle LLM failure
@OnException void onLlmFailure(LLMException e, IntakeRequest r) { /* ... */ }
}
Both examples call the same LargeLanguageModel.query(...) facade, but
where Example 1 took the plain String overload, enrichIdentity
passes Identity.class as a target type so the model's JSON reply is bound
straight to the Identity record via JSON Binding:
@Action
Identity enrichIdentity(IntakeRequest r) {
return model.query(
"Extract the sender's identity from the inquiry below. ... "
+ Identity.SCHEMA,
Identity.class,
r.name(), r.email(), r.message());
}
What it teaches
- Real workflows are multi-stage pipelines composed from typed data.
Each
@Actionproduces a typed record (Identity,Classification,Title,PolishedMessage). Later phases declare the records they need and the runtime threads them through by type. The@Outcomedepends on all four and packs them into the finalIntakeRecord. - The LLM facade returns typed records, not just strings.
enrichIdentitycallsmodel.query(prompt, Identity.class, …)and the model's JSON reply is bound to anIdentityrecord via JSON Binding — no manual parsing. Prompts also take multiple{}arguments of mixed types (strings, enums, aList). - Validation and
@Decisionform two independent layers of defense on input. Bean Validation onIntakeRequest(@NotBlank,@Size,@Email) rejects malformed submissions automatically, before the agent even starts.looksLikeRealInquirycatches obvious placeholder submissions that annotations can't easily express. Together they stop junk from ever reaching the LLM. - Decisions aren't limited to the top of the workflow.
looksLikeRealInquirysits before any LLM call, saving work.hasEnoughContentsits mid-pipeline, so it can look at both the raw request and whatenrichIdentitypulled out before deciding whether to continue. @OnExceptiongives the whole agent declarative recovery. A single method, matched by exception type, catchesLLMExceptionfrom any phase.
Example 3 — Lesson Builder
📦 Source: examples/lesson-builder
Two agents with two different jobs, chained by a CDI event, with the teacher
editing and publishing in between. The teacher names a topic, pastes the material and picks a
subject. LessonAgent in turn drafts an introduction, a quiz and a conclusion.
The teacher then edits anything and finally publishes. The REST layer stores the lesson and fires
LessonPublished, which triggers CatalogAgent — a cataloging job the
author would never do by hand. It works out what the lesson teaches, checks that the
content matches the subject and title, checks that the catalog does not already carry
it, tags the lesson against the curriculum standards it meets, estimates its class
time, and writes a short abstract. It then files the lesson in the catalog under those
tags. If a check fails, it sends the teacher back to fix a mismatch or merge a
duplicate.
The authoring agent:
@Agent
public class LessonAgent {
// start a lesson draft
@Trigger
Lesson onTopic(@Valid Topic t) {
return new Lesson(t.subject(), t.title());
}
// have the LLM draft a two-paragraph introduction
@Action(order = 1) void writeIntro(Topic t, Lesson draft) { /* ... */ }
// have the LLM draft a multiple-choice quiz
@Action(order = 2) void writeQuiz(Topic t, Lesson draft) { /* ... */ }
// have the LLM draft the conclusion from the earlier phases' context
@Action(order = 3) void writeConclusion(Lesson draft) {
draft.setConclusion(model.query(
"Conclude the lesson from the intro and quiz you just produced."));
}
// store the draft for the teacher to review
@Outcome void storeDraft(Lesson draft) { /* ... */ }
}
The catalog agent:
@Agent
public class CatalogAgent {
// receive the published lesson
@Trigger
Lesson onPublished(LessonPublished e) {
return e.lesson();
}
// have the LLM work out what the lesson teaches and assumes
@Action(order = 1) Coverage extractCoverage(Lesson l) { /* ... */ }
// halt if the content doesn't match the title
@Decision(order = 2) boolean contentMatchesClaim(Lesson l, Coverage c) { /* ... */ }
// halt if the catalog already carries it
@Decision(order = 3) boolean notDuplicate(Lesson l, Coverage c) { /* ... */ }
// have the LLM detect the curriculum standards the lesson meets
@Action(order = 4) TaggedStandards tagStandards(Lesson l, Coverage c) { /* ... */ }
// have the LLM estimate class time and difficulty
@Action(order = 5) Estimate classify(Lesson l, Coverage c) { /* ... */ }
// have the LLM draft a short abstract from the earlier phases' context
@Action(order = 6) Summary describe() { /* ... */ }
// assemble the catalog entry and file it
@Outcome void file(Lesson l, Coverage c, TaggedStandards t,
Estimate e, Summary s) { /* ... */ }
}
What it teaches
- A back-end task triggered by an event.
CatalogAgentruns because a lesson was published — it observesLessonPublishedas its@Trigger. Nothing orchestrates the two agents; the teacher's edit-and-publish is the step between their workflows, and each has its own LLM conversation. - Explicit order. Both agents number their phases with
order = N, and once one phase declares an order, all must.LessonAgentruns intro → quiz → conclusion.CatalogAgentextends the pattern to its decisions, so the two checks sit at 2 and 3 between the actions. - Conversational memory.
writeConclusionsends no material and has no{}; its prompt refers to "the intro and quiz you just produced", which the per-workflow conversation still holds.describeuses the same trick to write its abstract from the earlier phases' context.
Finally, let's wrap up.
Wrap-up
Recap: the whole story, end to end
The pieces you have seen fit together like this:
- The problem. Every Java agent framework defines its own proprietary model. Jakarta Agentic AI is the first attempt at a specification — a Jakarta Persistence for agents.
- The specification. A complete agent fits in one class. Methods
are annotated as phases —
@Trigger,@Decision,@Action,@Outcome,@OnException— and the runtime chains them by matching each method's parameters by type.LargeLanguageModelis a thin facade with{}placeholders. Typed replies bind through JSON Binding.@WorkflowScopedis the default agent scope. - The runtime. Payara is the implementation used throughout this tutorial. Deploying a WAR starts the workflow. Which LLM backend serves it — local or hosted — is a configuration choice.
- Three examples at increasing scale. The ask agent fits in one class and shows all four phases plus a decision that stops the workflow on a repeat question. The customer inquiry intake runs a six-phase pipeline inside one agent with declarative validation and typed records flowing between phases. The lesson builder chains two agents with a CDI event, with the teacher editing and publishing in between.
Key takeaways
- Agents are container-managed workflows. The phase model turns "calling an LLM" into a sequence of ordinary CDI methods, and the usual Jakarta EE pieces — Validation, REST, JSON Binding, Transactions, Persistence, Security — all apply to them without special wiring.
- Not an LLM API. Jakarta Agentic AI is an agent specification with a thin LLM facade. Implementations plug in existing APIs like LangChain4j or Spring AI behind it.
- Vendor neutrality. Agent code programs against the standard API, so it runs on any compatible implementation and talks to any configured LLM backend without change.
- A real specification with a working implementation. Read the specification for the normative rules. Each example's README has its exact commands and URLs so you can run it against Payara.
Jakarta Agentic AI 1.0 is deliberately minimal. The 1.1 roadmap adds a
programmatic workflow API, async CDI event support, standardized LLM
configuration, and richer flow control
(@Await, @Condition). Later work extends to resilience
(retry, circuit breaker) and multi-agent collaboration.
You can follow the project on GitHub.
Now go build a Jakarta EE agent!