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
    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); 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.
  • @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 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 ask-agent example 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 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, and docs-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 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 the form here and the explicit @Observes Question question form shown in chapter 2 are equivalent.
  • @Valid on the trigger parameter — the phase model honors 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 — 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. 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 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 @Action produces a typed record (Identity, Classification, Title, PolishedMessage). Later phases declare the records they need and the runtime threads them through by type. The @Outcome depends on all four and packs them into the final IntakeRecord.
  • The LLM facade returns typed records, not just strings. enrichIdentity calls model.query(prompt, Identity.class, …) and the model's JSON reply is bound to an Identity record via JSON Binding — no manual parsing. Prompts also take multiple {} arguments of mixed types (strings, enums, a List).
  • Validation and @Decision form two independent layers of defense on input. Bean Validation on IntakeRequest (@NotBlank, @Size, @Email) rejects malformed submissions automatically, before the agent even starts. looksLikeRealInquiry catches 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. looksLikeRealInquiry sits before any LLM call, saving work. hasEnoughContent sits mid-pipeline, so it can look at both the raw request and what enrichIdentity pulled out before deciding whether to continue.
  • @OnException gives the whole agent declarative recovery. A single method, matched by exception type, catches LLMException from 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. CatalogAgent runs because a lesson was published — it observes LessonPublished as 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. LessonAgent runs intro → quiz → conclusion. CatalogAgent extends the pattern to its decisions, so the two checks sit at 2 and 3 between the actions.
  • Conversational memory. writeConclusion sends no material and has no {}; its prompt refers to "the intro and quiz you just produced", which the per-workflow conversation still holds. describe uses 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:

  1. 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.
  2. 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. LargeLanguageModel is a thin facade with {} placeholders. Typed replies bind through JSON Binding. @WorkflowScoped is the default agent scope.
  3. 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.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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!