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.

  1. 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.
  2. 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.
  3. Vendor neutrality — nothing in the agent names a provider or a runtime. The LLM arrives as an injected LargeLanguageModel rather 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 to Trigger + 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, null or Result(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
    LargeLanguageModel model;   // jakarta.ai.agent — no vendor code here

    @Trigger
    void ask(Question question) { }   // a CDI event starts the workflow

    @Action
    void generate(Question question, AnswerStore answers) {
        String answer = model.query("Answer concisely: {}", question.text());
        answers.put(question.text(), answer);
    }
}

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
    LargeLanguageModel model;

    @Trigger
    Question receive(@Observes Question question) {   // a CDI event starts the workflow
        return question;
    }

    @Decision
    boolean isAnswerable(Question question) {          // may stop the workflow
        return question.text() != null && !question.text().isBlank();
    }

    @Action
    Answer answer(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 HandleException cutting across every phase:

  • @Trigger — the single entry point; a CDI event observer that starts the workflow.
  • @Decision — optional gate(s); returning false/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.
  • @HandleException — 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 beans live 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 @HandleException 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 LargeLanguageModel always resolves, even with nothing configured.
  • Anthropic without an API key → IllegalStateException at 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 quickstart uses gemma3:4b). Set provider=ollama and, if needed, ollama.base-url.
  • Anthropic — Claude via the Messages API. Provide the API key via anthropic.api-key or the ANTHROPIC_API_KEY env var (export it in the same shell that starts the domain — the server inherits its environment). When a system prompt 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.region instead 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 quickstart teaches the programming model in 5 classes; the tutorial generator shows a real use case with a chat refinement loop; and the Course Content Studio raises the bar to two agents chained by CDI events, with a human approval gate in the middle and a final student-facing view.

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, and docs-agent, which watches pull requests and drafts documentation updates. The examples README describes them all.


Example 1 — Quickstart

📦 Source: examples/quickstart

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 LargeLanguageModel model;
    @Inject AnswerStore answers;

    @Trigger
    void onQuestion(@Valid Question question) { /* ... */ }

    @Decision
    boolean notYetAnswered(Question question) {
        return answers.get(question.text()) == null;   // a repeat stops the workflow here
    }

    @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 LargeLanguageModel and the application's own AnswerStore arrive through the same @Inject.
  • The event type stays plain. Question is a record carrying one Bean Validation constraint and nothing else — no interface, no base class, nothing from jakarta.ai.agent — and @Observes on the trigger parameter is optional, so both spellings mean the same thing.
  • @Valid on the trigger parameter — the phase model honours Jakarta Validation, and a violation is raised before the body runs. Here it is the @NotBlank on Question, so an agent never starts on an empty question.
  • Nothing is passed between phases by hand — the runtime invokes each phase and fills its parameters by type. Question matches the triggering event; Answer matches what @Action returned, which is how @Outcome receives it.
  • A @Decision says whether the workflow continues, and has three ways to say it: a boolean, a Result(success, details), or a domain object where null means stop. This one returns false for a question already in the store, so a repeat never reaches the model.
  • Prompts are parameterised, not concatenated — the {} is filled from the argument that follows the prompt, positionally.

Example 2 — Tutorial Generator

📦 Source: examples/tutorial-generator

A real use case: an agent writes a field-by-field guide for a web form (a customer registration form) and allows refining the guide via chat. The page shows the form on the left, the generated guide on the right, and a refinement chat box below.

One agent covers both behaviors — the same @Action generates from scratch or refines an existing guide, decided by whether the request already carries one (full source at the Source link above):

@Trigger  void onRequest(TutorialRequest request)      // logs generate|refine
@Decision Result hasFields(TutorialRequest request)    // any fields? if not, stop
@Action   void render(TutorialRequest request) {
    if (generate)  content = model.query("Generate the field-guide JSON ... : {}", request.formSpec());
    else           content = model.query("Current field-guide JSON:\n{}\n\nApply this change...: {}",
                                         request.currentHtml(), request.instruction());  // two {} placeholders
    store.put(stripCodeFences(content));                // LLM output needs defensive post-processing
}
@Outcome  void complete(TutorialRequest request)       // logs the guide's size

The LLM backend and even the system prompt are configuration — here Claude for HTML quality, switchable to local Ollama without touching code:

payara.agentic.llm.provider=anthropic
payara.agentic.llm.model=claude-sonnet-4-6
payara.agentic.llm.max-tokens=8192
payara.agentic.llm.system=You are a senior technical writer...

The strong design ideas

  1. Single source of truth: CustomerFormSpec defines the form; the page renders the live form from it and the agent explains those same fields — they cannot diverge.
  2. The event carries the mode: TutorialRequest(formSpec, instruction, currentHtml). currentHtml null/blank → generate from scratch; filled → refine applying instruction. One agent, two behaviors, decided in the @Action.
  3. Refinement passes the real artifact: on every chat turn, the current guide + the instruction go into the prompt — the model edits the actual artifact instead of relying on conversational memory alone. (An important agent-engineering pattern worth remembering.)
  4. Per-field refinement with a merge: refine-field extracts only the target field's description (JSON-P), runs the workflow over that fragment, and merges the result back into the full guide — preserving the other fields and saving tokens.
  5. LLM robustness: stripCodeFences removes the code fences that models sometimes insist on adding — an honest reminder that LLM output requires defensive post-processing.

Example 3 — Course Content Studio

📦 Source: examples/course-content-studio

An advanced use case: the teacher pastes a chapter's content and picks a subject (mathematics, physics, English); an agent generates an introduction, a quiz and a conclusion; the teacher refines by section and, on approval, a second agent builds the published lesson that the student sees. It is the example that exercises almost the whole specification and shows the architectural differentiator: agent composition through CDI events.

The authoring agent shows ordered phases, per-workflow state, workflow memory, defensive JSON-B parsing and an exception handler — all in one class (full source at the Source link above):

// Agent 1 — authoring (ordered, workflow memory, defensive parsing)
@Agent(name = "CourseContentAgent")
class CourseContentAgent {
    private CoursePacket draft;                                   // @WorkflowScoped state
    @Trigger  void onRequest(@Valid CoursePacketRequest r)        // generate | refine
    @Decision(order = 5)  boolean hasTeachableContent(...)        // gate
    @Action(order = 10)   void writeIntro(...)                    // prose (per-subject rubric)
    @Action(order = 20)   void writeQuiz(...)  { draft.setQuiz(parseQuiz(model.query(...))); }
    @Action(order = 30)   void writeConclusion(...)               // uses workflow memory
    @Outcome  void publish(...)                                   // writes to PacketStore
    @HandleException void onLlmFailure(LLMException e)            // resilience
}

On approval the REST layer fires a LessonApproved event, which is the @Trigger of a second agent (PublishAgent) — the two are composed purely through CDI events, with the human approval as the gate between them.

The strong design ideas

  1. Two agents, chained only by CDI events. CourseContentAgent generates/ refines the packet; on approval, the REST layer fires the LessonApproved event, which is the @Trigger of a second @Agent (PublishAgent). There is no orchestrator: the human approval is the gate between them. Each agent has its own workflow and its own LLM conversation.
  2. Truly ordered phases. The phases carry an explicit order (@Decision(order=5), @Action(order=10/20/30)), guaranteeing intro → quiz → conclusion. Note: if one phase is ordered, all must be, otherwise the deploy fails with "Inconsistent order".
  3. Per-workflow state in the agent itself. No scope annotation → the runtime applies @WorkflowScoped, so the draft instance field accumulates the packet across phases safely (one instance per execution).
  4. Workflow conversational memory. The conclusion does not re-send the chapter: it relies on the earlier turns (intro and quiz) of the same workflow conversation.
  5. Typed result via JSON-B, with defensive parsing. The quiz becomes a Quiz record. Because small models wrap JSON in code fences, parseQuiz strips the fences and extracts the object before binding, with a placeholder quiz as a last resort — the workflow never aborts on bad JSON.
  6. Per-section HITL. The event carries the mode (currentDraftJson blank → generate; filled → refine) and the section, so refine-section rewrites only the quiz (or only the intro), preserving the rest.
  7. Live progress (SSE). Since Event.fire is synchronous, each phase reports to a ProgressTracker that streams over Server-Sent Events; the browser popup evolves with the agent's real steps.
  8. Polymorphic quiz + LLM grading. QuizQuestion has a type (multiple_choice | open). For an open question the student answers in a <textarea> and the quiz/grade endpoint asks the LLM for a semantic-similarity score against the sampleAnswer, mapped on the server to a verdict. A specification detail worth citing: this endpoint injects the LargeLanguageModel directly into a @RequestScoped resource — it works because the runtime's LLM is @Dependent (it needs no active workflow), so not every use of the model must be inside an agent.

Details worth highlighting

  • The student view (student.html) shows the lesson with an interactive quiz that closes the generate → review → publish → consume narrative.
  • Maths/physics formulas are rendered with self-hosted MathJax, so rendering works offline.
  • Everything is single-author (single-slot stores), consistent with a live demo.

Wrap-up

Recap: the whole story, end to end

The pieces you have seen fit together like this:

  1. The problem. Everyone wants AI agents; in Java, every framework has its own proprietary model. The guiding question: "what would the Jakarta Persistence of agents look like?"
  2. The specification. The phase model (Trigger → Decision* → Action* → Outcome + HandleException); a complete agent fits in one class; the LargeLanguageModel facade with {} placeholders; @WorkflowScoped — with the specification as the authoritative reference for the rules.
  3. Running on Payara. Deploy the app and Payara runs the workflow; choosing an LLM backend (Ollama, Anthropic, Vertex) is MicroProfile Config, no code change.
  4. The quickstart. POST a real question → [TRIGGER] → [DECISION] → [ACTION] → [OUTCOME] in server.log; POST an empty question → early termination. It runs on local Ollama (no network, no cost).
  5. The tutorial generator and the studio. A guide generated with Claude and refined via chat; then two agents chained by CDI events with a human approval gate. Switching Ollama↔Claude↔Vertex is one properties file.

Running the examples — checklist

  • Ollama installed, ollama pull gemma3:4b done, the service answering at http://localhost:11434 (test: ollama run gemma3:4b "hi").
  • The current agentic-ai-core.jar copied into the distribution's glassfish/modules/ + domain restarted clearing the OSGi cache (classic gotcha: new JAR with an old cache = old class).
  • ANTHROPIC_API_KEY exported in the same shell/environment that starts the domain ($env:ANTHROPIC_API_KEY = "sk-ant-..." before asadmin restart-domain) — the server process inherits its parent's environment.
  • Both WARs deployed and tested.
  • server.log open in a terminal (Get-Content -Wait -Tail 0).
  • Fully-local option: the quickstart runs on Ollama; the tutorial generator can also fall back to provider=ollama / model=gemma3:12b (pull the model first).
  • Requests ready (no typing JSON by hand): a script/.http file with the valid POST, the empty POST and the refines.

Common questions — how this compares with LangChain4j / Spring AI, error handling, asynchrony, why CDI events, multi-agent collaboration and running costs — are answered in the project README FAQ.

Key takeaways

  1. Agents as CDI beans — the phase model (@Trigger/@Decision/@Action/@Outcome/@HandleException) turns "calling an LLM" into a container-managed workflow, with standardized scoping, injection, validation and error handling.
  2. Real vendor neutrality — the agent's code does not know which LLM serves it; switching Ollama↔Claude↔Vertex is configuration (MicroProfile Config in Payara).
  3. A real specification with a working implementation — read the specification for the rules, run the examples on Payara to see them in action.

🏁 End of the tutorial. Now run the examples yourself and explore the source.