# AI CLI — Building a Production Newsletter Engine with Java, Spring Boot & LangChain4j

## TL;DR

AI CLI is a Java 21 terminal application built on Spring Boot, PicoCLI, and LangChain4j that transforms files using LLMs. It powers three fully automated newsletters — researched, written, validated, and delivered entirely by AI agents, on a schedule, with zero human intervention.

This article walks you through the stack, the architecture, the production lessons, and the live output.

## Why Build This?

You probably subscribe to a handful of newsletters. Some of them are great. Most are cluttered, irrelevant, and clearly optimized for someone else's agenda — not yours.

That was the starting point: what if the newsletter was built for you, by you, covering exactly what you care about?

AI CLI was born out of a simple idea — a personal information feed that is 100% in your control. No vendor lock-in. No algorithmic feed deciding what you should see. No attention-harvesting social media platform reducing your world to engagement bait.

Instead, you define exactly what you want through the power of natural language. You write prompt files in Markdown. You choose the LLM, the search engine, the data sources, and the output format. You own the code, the infrastructure, and the data.

With AI CLI, you can spin up a highly specialized newsletter in a matter of hours and run it indefinitely on a schedule via CI/CD — for example:

* Morocco Run Radar: all verified running events near Casablanca for the next 60 days

* IT Events Casablanca: tech meetups and conferences for developers in the city

* Assistant Professor Jobs: academic job openings matching specific criteria

Each of these newsletters is a configuration, not a product. The engine is the same. The prompts are different.

And unlike any SaaS newsletter builder, you can swap the LLM, the embedding model, the search engine, or the delivery platform at any time — because the architecture was designed around pluggability, not dependency.

This is also the follow-up and natural evolution from [JBang Meets Spring Boot &amp; LangChain4j](jbang-meets-spring-boot-langchain4j-a-powerhouse-for-java-scripting-and-ai-pipelines.html) and [Easy RAG — Using Embeddings in LangChain4j](easy-rag-using-embeddings-in-langchain4j-to-improve-llm-responses.html), where we built the foundations of chaining LLM calls and embedding data for context. This time, we went to production.

## The Stack

| Technology | Version | Role |
| --- | --- | --- |
| Java | 21 | Runtime — Records, Virtual Threads, modern Stream/Optional APIs |
| Spring Boot | 3.4.11 | Application framework and dependency injection |
| PicoCLI | 4.7.7 | Command-line interface (type-safe argument parsing) |
| LangChain4j | 1.11.0 | AI/LLM orchestration — the main course |
| Playwright + Stealth4j | 1.58 / 1.1.2 | Browser automation for JS-rendered page crawling |
| MailerSend SDK | 1.4.1 | Email delivery |
| Commonmark | 0.27.1 | Markdown → HTML rendering (with GFM tables) |
| DuckDB | — | In-process vector store for RAG |

### The Beauty of Spring Boot + PicoCLI

Before talking about LangChain4j, let's appreciate the backbone: Spring Boot and PicoCLI working together.

PicoCLI gives us a declarative, type-safe CLI layer where every argument (`--chat-model`, `--tools`, `--embedding-model`, `--search-engine`) is parsed, validated, and converted before it reaches business logic.

Spring Boot then takes those parsed values and engineers the perfect ephemeral context for each execution. This is the key design insight: the Spring context is different for every run, because the beans loaded depend entirely on what the user requested via CLI flags.

Here is how the `ChatModel` bean is resolved at startup:

```JAVA
@Configuration
public class AIChatModelConfig {

    @Bean
    ChatModel chatModel(ApplicationArguments aa,
                    ProviderProperties providerProperties, Environment environment,
                    List<ChatModelFactory> factories) {

        ContextUtils.ParsingMainCommand parsingCommand = ContextUtils
                        .parseIntoArgs(new ContextUtils.ParsingMainCommand(), aa, environment);
        String finalModelName = parsingCommand.getMainArgs().getChatModel();

        return factories.stream()
                        .filter(factory -> factory.supports(finalModelName, providerProperties))
                        .findFirst()
                        .map(factory -> factory.create(finalModelName, providerProperties))
                        .orElseThrow(() -> new IllegalArgumentException(
                                        "Unsupported chat model: " + finalModelName));
    }
}
```

The same factory pattern is replicated for embedding models, vector stores, search engines, and scoring models. This means swapping from OpenAI GPT-5 to a local Ollama model is nothing more than changing a CLI flag — no code change, no reconfiguration, no rebuild. The factory resolves the right bean, and Spring wires it.

Here is the full factory resolution flow across all 5 component types:

```MERMAID
graph TB
    subgraph CLI["CLI Args (PicoCLI)"]
        chatArg["--chat-model"]
        embArg["--embedding-model"]
        storeArg["--embedding-store"]
        searchArg["--search-engine"]
        scoreArg["--scoring-model"]
    end

    subgraph ChatModelFactories["ChatModel Factories"]
        CMC["AIChatModelConfig"]
        OACMF["OpenAiChatModelFactory"]
        GCMF["GeminiChatModelFactory"]
        DSCMF["DeepSeekChatModelFactory"]
        GRCMF["GroqChatModelFactory"]
        OLCMF["OllamaChatModelFactory"]
    end

    subgraph EmbeddingModelFactories["EmbeddingModel Factories"]
        EMC["AIEmbeddingModelConfig"]
        OAEMF["OpenAiEmbeddingModelFactory"]
        GEMF["GeminiEmbeddingModelFactory"]
        VAEMF["VoyageAiEmbeddingModelFactory"]
        OLEMF["OllamaEmbeddingModelFactory"]
        LEMF["LocalEmbeddingModelFactory"]
    end

    subgraph StoreFactories["EmbeddingStore Factories"]
        ESC["AIEmbeddingStoreConfig"]
        DDBF["DuckDbEmbeddingStoreFactory"]
        LUF["LuceneEmbeddingStoreFactory"]
        QDF["QdrantEmbeddingStoreFactory"]
    end

    subgraph SearchFactories["SearchEngine Factories"]
        SEC["AISearchEngineConfig"]
        TSEF["TavilySearchEngineFactory"]
        GSEF["GoogleSearchEngineFactory"]
        DDGF["DuckDuckGoSearchEngineFactory"]
        STBF["StubSearchEngineFactory"]
    end

    subgraph ScoringFactories["ScoringModel Factories"]
        SMC["AIScoringModelConfig"]
        VASMF["VoyageAiScoringModelFactory"]
        OSMF["OnnxScoringModelFactory"]
    end

    subgraph Beans["Resolved Beans"]
        CM(["ChatModel"])
        EM(["EmbeddingModel"])
        ES(["EmbeddingStore"])
        WSE(["WebSearchEngine"])
        SM(["ScoringModel"])
    end

    chatArg --> CMC
    CMC --> OACMF & GCMF & DSCMF & GRCMF & OLCMF
    OACMF & GCMF & DSCMF & GRCMF & OLCMF -->|"supports()?"| CM

    embArg --> EMC
    EMC --> OAEMF & GEMF & VAEMF & OLEMF & LEMF
    OAEMF & GEMF & VAEMF & OLEMF & LEMF -->|"supports()?"| EM

    storeArg --> ESC
    ESC --> DDBF & LUF & QDF
    DDBF & LUF & QDF -->|"supports()?"| ES

    searchArg --> SEC
    SEC --> TSEF & GSEF & DDGF & STBF
    TSEF & GSEF & DDGF & STBF -->|"supports()?"| WSE

    scoreArg --> SMC
    SMC --> VASMF & OSMF
    VASMF & OSMF -->|"supports()?"| SM

    classDef config fill:#ecc94b,stroke:#b7791f,color:#333
    classDef factory fill:#fc8181,stroke:#c53030,color:#333
    classDef bean fill:#48bb78,stroke:#276749,color:#fff

    class CMC,EMC,ESC,SEC,SMC config
    class OACMF,GCMF,DSCMF,GRCMF,OLCMF,OAEMF,GEMF,VAEMF,OLEMF,LEMF,DDBF,LUF,QDF,TSEF,GSEF,DDGF,STBF,VASMF,OSMF factory
    class CM,EM,ES,WSE,SM bean
```

Similarly, every tool (search, crawler, RAG, validation) is a Spring `@Component` guarded by a custom `@Conditional` annotation. If you don't ask for `search`, the `WebSearchTool` bean is never instantiated. This keeps the runtime lean and the configuration explicit.

```
--tools=search,rag,content_crawler,json_schema_validate
```

This single flag composes a different Spring context every time. You get the flexibility and versatility of a plugin architecture without writing a single plugin loader — because Spring's dependency injection is the plugin loader.

### LangChain4j — The Main Course

LangChain4j is the orchestration layer that connects your Java code to the LLM world. The project uses 16 LangChain4j modules covering:

* Chat models (OpenAI, Gemini, DeepSeek, Groq, Ollama)

* Embedding models (OpenAI, Gemini, Voyage AI, Ollama, local ONNX)

* Vector stores (DuckDB, Lucene, Qdrant)

* Search engines (Tavily, Google Custom Search, DuckDuckGo)

* RAG infrastructure (document splitting, content retrieval, query routing, reranking)

* Agentic workflows (UntypedAgent, AgenticScope)

It's an impressive library that lets you go from "call an LLM" to "build a multi-agent pipeline with RAG, tool calling, and structured output" — all in pure Java.

## Architecture Overview

The application follows a layered architecture, where each layer has a clear responsibility:

```MERMAID
graph TB
    subgraph CLI["CLI Layer (PicoCLI)"]
        Main["MainCommand (global options)"]
        TC["TransformCommand"]
        TAC["TransformAgenticCommand"]
        FC["ForwardCommand"]
    end

    subgraph Services["Service Layer"]
        TS["TransformService"]
        TAS["TransformAgenticService"]
        FS["ForwardService"]
    end

    subgraph Pipeline["Pipeline Builders"]
        AS["AssistantService"]
        AAS["AgenticAssistantService"]
        SAWS["SequentialAgenticWorkflowService"]
    end

    subgraph RAG["RAG System"]
        IS["IngestionService"]
        CRS["ContentRetrievalService"]
        CDS["ConfigurableDocumentSplitter"]
        METST["MetadataEnrichedTransformer"]
    end

    subgraph Tools["Tool Layer (LLM-callable)"]
        WST["WebSearchTool"]
        SMST["SocialMediaSearchTool"]
        CCT["ContentCrawlerTool"]
        TT["TimeTool"]
        JSVT["JsonSchemaValidationTool"]
        HVT["HtmlValidationTool"]
        MVT["MarkdownValidationTool"]
        AIST["ApifyInstagramScraperTool"]
        AFST["ApifyFacebookScraperTool"]
        DT["DistanceTool"]
        TRL["ToolRateLimiter"]
    end

    subgraph Output["Output Dispatch"]
        OSP["OutputServiceProvider"]
        NFO["NewFileOutputService"]
        RFO["ReplaceFileOutputService"]
        MO["MailOutputService"]
        BO["ButtondownOutputService"]
    end

    subgraph Config["Configuration Layer"]
        CCMC["AIChatModelConfig"]
        CEMC["AIEmbeddingModelConfig"]
        CESC["AIEmbeddingStoreConfig"]
        CSMC["AIScoringModelConfig"]
        CSEC["AISearchEngineConfig"]
    end

    subgraph External["External Services"]
        LLM["LLM Providers (OpenAI, Gemini, DeepSeek, Groq, Ollama)"]
        EMB["Embedding Providers (Voyage AI, OpenAI, Gemini, Ollama, ONNX)"]
        VDB["Vector Stores (DuckDB, Lucene, Qdrant)"]
        SE["Search Engines (Tavily, Google, DuckDuckGo)"]
        MAIL["MailerSend"]
        BD["Buttondown API"]
        APIFY["Apify Actors"]
        WR["Google Web Risk"]
        NOM["Nominatim Geocoding"]
        PW["Playwright Browser"]
    end

    Main --> TC & TAC & FC

    TC --> TS
    TAC --> TAS
    FC --> FS

    TS --> AS & IS & OSP
    TAS --> SAWS & IS & OSP
    FS --> OSP

    AS --> CRS
    AAS --> CRS
    SAWS --> AAS

    IS --> CDS & METST

    OSP --> NFO & RFO & MO & BO

    Config -.->|produces beans| LLM & EMB & VDB & SE

    classDef cli fill:#4a90d9,stroke:#2c5282,color:#fff
    classDef service fill:#48bb78,stroke:#276749,color:#fff
    classDef pipeline fill:#ed8936,stroke:#c05621,color:#fff
    classDef rag fill:#9f7aea,stroke:#6b46c1,color:#fff
    classDef tool fill:#f56565,stroke:#c53030,color:#fff
    classDef output fill:#38b2ac,stroke:#285e61,color:#fff
    classDef config fill:#ecc94b,stroke:#b7791f,color:#333
    classDef external fill:#a0aec0,stroke:#718096,color:#333

    class Main,TC,TAC,FC cli
    class TS,TAS,FS service
    class AS,AAS,SAWS pipeline
    class IS,CRS,CDS,METST rag
    class WST,SMST,CCT,TT,JSVT,HVT,MVT,AIST,AFST,DT,TRL tool
    class OSP,NFO,RFO,MO,BO output
    class CCMC,CEMC,CESC,CSMC,CSEC config
    class LLM,EMB,VDB,SE,MAIL,BD,APIFY,WR,NOM,PW external
```

Four key design principles power this architecture:

1. Factory Pattern for AI Components — Every LLM component is resolved via a `*Factory` interface matched against CLI arguments.

2. Spring `@Conditional` for Tool Activation — Each tool bean is conditionally instantiated based on the `--tools` flag.

3. `Optional<>` Constructor Injection — Services accept optional dependencies (`Optional<IngestionService>`, `Optional<WebSearchTool>`) so that missing beans don't break the wiring.

4. Stage-Scoped Tools — Each prompt file can declare its own tools in YAML front matter. Tools are resolved per-stage, not globally.

## From Multi-Stage Pipelines to Agentic Workflows

This section tells the evolution story — from simple chaining to production-grade agent orchestration.

### Level 1: Simple Multi-Stage Prompt Pipeline

The simplest building block is the multi-stage prompt pipeline. You define a directory of numbered Markdown files, each containing a prompt.

Here is a visual overview of the pipeline flow:

```MERMAID
sequenceDiagram
    participant User
    participant AssistantService
    participant Stage1 as Stage 1 - Researcher
    participant Stage2 as Stage 2 - Writer
    participant Tools as Tool Layer
    participant RAG as RAG Store
    participant Output as Output Service

    User->>AssistantService: Input + Prompt Directory
    AssistantService->>Stage1: Prompt 1 + tools config
    Stage1->>Tools: search, crawl, validate
    Tools->>RAG: Auto-ingest search results
    Tools-->>Stage1: Tool results
    Stage1-->>AssistantService: Stage 1 output (JSON)
    AssistantService->>Stage2: Stage 1 output as input
    Stage2-->>AssistantService: Stage 2 output (Markdown)
    AssistantService->>Output: Final result
    Output-->>User: Email / File / API
```

You define a directory of numbered Markdown files:

```
transformations/moroccan_runners/
  ├── 1-research.md         # Stage 1: research via web search
  └── 2-presentation.md     # Stage 2: format into newsletter
```

Each file can optionally declare tools in YAML front matter:

```YAML
---
tools: [search, search_social_media, content_crawler, rag, rerank,
        now, json_schema_validate, apify_instagram_scraper, distance]
---
# Prompt: Morocco Running Events Researcher (Phase 1)

You are an expert researcher tasked with finding running events in Morocco...
```

At runtime, these files are loaded, parsed, and chained together using `Function::andThen`:

```JAVA
public Function<String, String> build(AssistantRequest ar) {
    return ar.prompts().stream()
            .map(pd -> build(pd, ar))        // Build a GenericAssistant per stage
            .map(this::safeAssistantStep)     // Wrap with null-safety
            .reduce(Function.identity(), Function::andThen);  // Chain stages
}
```

Each stage gets its own isolated `InMemoryChatMemoryStore` — this is critical for defeating context pollution, where the tool-call noise from Stage 1 leaks into Stage 2 and causes hallucinations:

```JAVA
InMemoryChatMemoryStore isolatedMemoryStore = new InMemoryChatMemoryStore();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
        .maxMessages(1000)
        .chatMemoryStore(isolatedMemoryStore)
        .build();
```

The output of Stage 1 becomes the input of Stage 2 — clean, focused, no residual tool-call artifacts.

###  Level 2: Agentic Workflows with `transform-agentic` 

The multi-stage pipeline was powerful but had limitations: all stages shared the same flat `Function<String, String>` interface. There was no structured memory, no explicit input/output contracts, and no way for Stage 3 to directly read Stage 1's output without it being piped through Stage 2.

Enter `transform-agentic` — the evolution from chaining functions to orchestrating LangChain4j UntypedAgents via an AgenticScope (a shared state dictionary).

Each stage is now defined by a richer frontmatter contract:

```YAML
---
name: editor-in-chief
description: Produces the editorial brief for downstream agents.
tools: [now, json_schema_validate]
input_keys: [input]
output_key: brief
---
You are the editor-in-chief for "Morocco Run Radar"...
---
Use {{input}} as the raw operator request and convert it into the editorial brief.
```

Key differences from the simple pipeline:

* Explicit `input_keys` and `output_key` — each agent declares what it reads and what it writes

* Separate system message and user message sections (separated by `---`)

* Named agents — each stage has a human-readable name for logging and debugging

* State dictionary (AgenticScope) — agents communicate via a shared map, not piping strings

Here is how agents are built and composed:

```JAVA
// Build each agent from its frontmatter definition
var builder = AgenticServices.agentBuilder()
        .chatModel(chatModel)
        .chatMemory(chatMemory)          // isolated per stage
        .name(prompt.name())
        .description(prompt.description())
        .systemMessage(prompt.systemMessage())
        .userMessage(prompt.userMessage())
        .inputs(prompt.inputKeys().stream()
                .map(key -> new AgentArgument(String.class, key))
                .toArray(AgentArgument[]::new))
        .outputKey(prompt.outputKey())
        .tools(stageTools)
        .maxSequentialToolsInvocations(hardLimit);

// Compose all agents into a sequential workflow
return AgenticServices.sequenceBuilder()
        .name("transform-agentic-sequence")
        .subAgents(promptAgents.toArray())
        .outputKey(request.transformation().lastOutputKey())
        .build();
```

The real-world Moroccan Runners agentic pipeline is a 3-agent sequence:

| Agent | Role | Input Key | Output Key | Tools |
| --- | --- | --- | --- | --- |
| Editor-in-Chief | Produces the editorial brief | `input` | `brief` | `now`, `json_schema_validate` |
| Search Specialist | Researches and verifies events | `brief` | `research` | `search`, `crawler`, `apify`, `distance`, `rag`, `rerank` |
| Output Specialist | Formats the newsletter | `research` | `newsletter` | `markdown_validate`, `markdown_security` |

The state flows through the scope as shown in this diagram:

```MERMAID
graph TB
    subgraph PromptFiles["Prompt-as-Code (Markdown Files)"]
        P1["1-editor.md - tools: now, json_schema_validate - input → brief"]
        P2["2-search-specialist.md - tools: search, crawler, rag, rerank, apify - brief → research"]
        P3["3-output-specialist.md - tools: markdown_validate, markdown_security - research → newsletter"]
    end

    subgraph AgentFactory["AgenticAssistantService (Per-Agent Factory)"]
        subgraph A1_Config["Agent 1: Editor-in-Chief"]
            A1M["Isolated ChatMemory"]
            A1T["Tools: now, json_validate"]
            A1R["No RAG"]
        end

        subgraph A2_Config["Agent 2: Search Specialist"]
            A2M["Isolated ChatMemory"]
            A2T["Tools: search, crawler, apify, social_media, distance"]
            A2R["RAG + Reranker"]
        end

        subgraph A3_Config["Agent 3: Output Specialist"]
            A3M["Isolated ChatMemory"]
            A3T["Tools: markdown_validate"]
            A3R["Security Scan"]
        end
    end

    subgraph Composer["SequentialAgenticWorkflowService"]
        SB["sequenceBuilder().subAgents(editor, researcher, writer).outputKey('newsletter')"]
    end

    subgraph Execution["AgenticScope (State Dictionary)"]
        S0["input: 'Find Morocco running events'"]
        S1["brief: '{editorial JSON...}'"]
        S2["research: '{verified events JSON...}'"]
        S3["newsletter: '# Morocco Run Radar ...'"]
    end

    subgraph Output["Post-Processing"]
        SEC["MarkdownSecurityValidation (Google Web Risk API)"]
        OUT["OutputServiceProvider (MailerSend / Buttondown / File)"]
    end

    P1 --> A1_Config
    P2 --> A2_Config
    P3 --> A3_Config

    A1_Config & A2_Config & A3_Config --> SB

    SB -->|"invokeWithAgenticScope"| S0
    S0 -->|"Editor reads 'input'"| S1
    S1 -->|"Researcher reads 'brief'"| S2
    S2 -->|"Writer reads 'research'"| S3

    S3 --> SEC --> OUT

    classDef prompt fill:#fbd38d,stroke:#b7791f,color:#333
    classDef config fill:#e2e8f0,stroke:#a0aec0,color:#2d3748
    classDef builder fill:#f6ad55,stroke:#c05621,color:#fff
    classDef scope fill:#68d391,stroke:#276749,color:#fff
    classDef output fill:#38b2ac,stroke:#285e61,color:#fff

    class P1,P2,P3 prompt
    class A1M,A1T,A1R,A2M,A2T,A2R,A3M,A3T,A3R config
    class SB builder
    class S0,S1,S2,S3 scope
    class SEC,OUT output
```

Each agent reads from specific keys and writes to its designated output key. The state dictionary accumulates context across the pipeline — Stage 3 can read Stage 1's output directly without it being piped through Stage 2.

This is a significant leap from `Function::andThen`. Each agent operates with full awareness of its role, its inputs, and its outputs — and the state dictionary provides structured memory across the pipeline.

## Tool System — Empowering the LLM

Tools are what turn an LLM from a text generator into an agent that can act on the world. AI CLI ships with 11 callable tools, each guarded by a `@Conditional` annotation so it only loads when requested.

The activation flow works like this:

```MERMAID
graph TB
    subgraph CLIFlag["CLI --tools flag"]
        TF["--tools=search,rag,content_crawler,..."]
    end

    subgraph Conditions["@Conditional Conditions"]
        SrchC["SearchEnabledCondition"]
        SMC["SocialMediaSearchEnabledCondition"]
        CCC["ContentCrawlerEnabledCondition"]
        NowC["NowEnabledCondition"]
        JSVC["JsonSchemaValidationEnabledCondition"]
        HVC["HtmlValidationEnabledCondition"]
        MVC["MarkdownValidationEnabledCondition"]
        AIC["ApifyInstagramEnabledCondition"]
        AFC["ApifyFacebookEnabledCondition"]
        DC["DistanceEnabledCondition"]
        MSC["MarkdownSecurityEnabledCondition"]
        RC["RagEnabledCondition"]
        RSC["RagSearchEnabledCondition"]
        ReC["RerankerEnabledCondition"]
    end

    subgraph ToolBeans["Activated Tool Beans"]
        WST["WebSearchTool"]
        SMST["SocialMediaSearchTool"]
        CCT["ContentCrawlerTool"]
        TT["TimeTool"]
        JSVT["JsonSchemaValidationTool"]
        HVT["HtmlValidationTool"]
        MVT["MarkdownValidationTool"]
        AIST["ApifyInstagramScraperTool"]
        AFST["ApifyFacebookScraperTool"]
        DT["DistanceTool"]
    end

    subgraph RAGBeans["Activated RAG Beans"]
        IS["IngestionService"]
        ESCR["EmbeddingStoreContentRetriever"]
        WSCR["WebSearchContentRetriever"]
        SM["ScoringModel"]
    end

    TF --> SrchC & SMC & CCC & NowC & JSVC & HVC & MVC & AIC & AFC & DC & MSC & RC & RSC & ReC

    SrchC -->|"✓"| WST
    SMC -->|"✓"| SMST
    CCC -->|"✓"| CCT
    NowC -->|"✓"| TT
    JSVC -->|"✓"| JSVT
    HVC -->|"✓"| HVT
    MVC -->|"✓"| MVT
    AIC -->|"✓"| AIST
    AFC -->|"✓"| AFST
    DC -->|"✓"| DT
    RC -->|"✓"| IS & ESCR
    RSC -->|"✓"| WSCR
    ReC -->|"✓"| SM

    subgraph Dispatch["ToolsService Dispatch"]
        TS["ToolsService.getTools(stageToolEnums)"]
    end

    WST & SMST & CCT & TT & JSVT & HVT & MVT & AIST & AFST & DT -.->|"Optional injection"| TS

    classDef condition fill:#fbd38d,stroke:#b7791f,color:#333
    classDef tool fill:#f56565,stroke:#c53030,color:#fff
    classDef rag fill:#9f7aea,stroke:#6b46c1,color:#fff

    class SrchC,SMC,CCC,NowC,JSVC,HVC,MVC,AIC,AFC,DC,MSC,RC,RSC,ReC condition
    class WST,SMST,CCT,TT,JSVT,HVT,MVT,AIST,AFST,DT tool
    class IS,ESCR,WSCR,SM rag
```

###  How Spring `@Conditional` Actually Works 

The `@Conditional` annotation is one of Spring's most powerful mechanisms, and it runs very early in the application lifecycle — during the bean definition phase, before any bean is actually instantiated.

Here's the contract: you implement `org.springframework.context.annotation.Condition`, which gives you a single method — `matches()`. Spring calls this method while scanning your `@Component` or `@Bean` classes. If `matches()` returns `false`, the bean is never registered in the application context. It doesn't exist. No constructor is called, no dependencies are wired, no memory is allocated.

This is what our `SearchEnabledCondition` looks like:

```JAVA
public class SearchEnabledCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {

        // Access the already-parsed CLI arguments from Spring's bean factory
        ApplicationArguments aa = context.getBeanFactory()
                .getBean(ApplicationArguments.class);

        // Resolve which tools the user requested via --tools=...
        List<Tool> tools = ContextUtils.resolveRequestedTools(
                aa, context.getEnvironment());

        // Only activate if "search" or "rag_search" was requested
        return Optional.ofNullable(tools)
                .filter(l -> l.contains(Tool.SEARCH) || l.contains(Tool.RAG_SEARCH))
                .isPresent();
    }
}
```

Key things to notice:

1. The `ConditionContext` — Spring gives you access to the `BeanFactory`, the `Environment`, the `ClassLoader`, and the `ResourceLoader`. You can inspect anything about the current application state.

2. It runs before instantiation — this is not a runtime check. When `matches()` returns `false`, the `WebSearchTool` class is never constructed. This means its dependencies (the `WebSearchEngine` bean, for example) also don't need to exist.

3. CLI → Condition → Bean graph — PicoCLI parses `--tools=search,rag`, Spring stores the arguments as `ApplicationArguments`, and the `Condition` reads them to decide which beans to load. This is how a single CLI flag reshapes the entire Spring context.

You then annotate the tool with it:

```JAVA
@Component
@Conditional(SearchEnabledCondition.class)
public class WebSearchTool {
    // Only exists if --tools contains "search"
}
```

Every tool in AI CLI follows this pattern. The result is that the bean graph is perfectly tailored to whatever the user requested — no unused beans, no wasted connections, no accidental API calls.

| Tool | Description |
| --- | --- |
| `search` | Web search via Tavily, Google, or DuckDuckGo |
| `search_social_media` | Targeted `site:` queries for Instagram, Facebook, TikTok |
| `content_crawler` | Full-page extraction via Playwright + Stealth4j (JS-rendered pages) |
| `now` | Current date/time in a timezone |
| `json_schema_validate` | Draft 2020-12 JSON Schema validation with actionable diagnostics |
| `html_validate` | Newsletter/email HTML safety and compatibility checks |
| `markdown_validate` | GFM-aware Markdown syntax validation |
| `apify_instagram_scraper` | Instagram profile scraping via Apify actors |
| `apify_facebook_scraper` | Facebook page scraping via Apify actors |
| `distance` | Geocoding + Haversine distance between cities (OpenStreetMap Nominatim) |
| `markdown_security` | URL extraction + Google Web Risk scanning (malware, phishing) |

Here is how the `WebSearchTool` is implemented — note the constructor injection, rate limiting check, query sanitization, and auto-ingestion into the RAG store:

```JAVA
@Component
@Conditional(SearchEnabledCondition.class)
public class WebSearchTool {

    private final WebSearchEngine webSearchEngine;
    private final Optional<IngestionService> ingestionService;
    private final ToolRateLimiter rateLimiter;

    // Constructor injection...

    @Tool("Performs a web search to find relevant information.")
    public List<WebSearchOrganicResult> search(String query) {
        var limitReached = rateLimiter.tryAcquire("search");
        if (limitReached.isPresent()) {
            return limitReached.get();
        }

        var sanitizedQuery = sanitize(query);
        var results = webSearchEngine.search(request);

        // Auto-ingest into RAG store if IngestionService is available
        ingestionService.ifPresent(service ->
                service.ingestSearchResults(results.results()));

        return results.results();
    }
}
```

The auto-ingestion is the bridge between the Tool layer and the RAG layer — every search result is automatically embedded into the vector store for retrieval during the same run or future runs. This is the self-feeding loop that makes the system progressively more informed.

That said, this approach has a clear tradeoff: ingesting everything makes the RAG layer noisy over time. Not every search result is relevant, and low-quality pages dilute the store, making retrieval less precise. Some ideas for future releases:

* Reranker as a pre-ingestion gate — the scoring model is already wired for retrieval. Running search results through it before ingesting and only keeping segments above a relevance threshold would filter out noise at the source.

* TTL-based expiration — the `ingestion_timestamp` metadata is already stored. Adding a time-to-live filter at retrieval time would let stale entries (e.g., past events) naturally age out of the results.

For now, the current approach works well enough for weekly newsletters where the store is rebuilt frequently. But for long-running stores, smarter ingestion filtering will be necessary.

### The Infinite Tool-Loop Caveat

One of the hardest production lessons: LLMs obsessively retry failing tools. A confused model can enter an infinite loop of calling `search("running events Morocco")` hundreds of times, draining your API budget in minutes.

The `ToolRateLimiter` is the deterministic Java boundary that stops this:

```JAVA
@Component
public class ToolRateLimiter {

    private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();

    public Optional<List<WebSearchOrganicResult>> tryAcquire(String toolName) {
        int limit = resolveLimit(toolName);
        AtomicInteger counter = counters.computeIfAbsent(toolName, k -> new AtomicInteger(0));

        if (counter.get() >= limit) {
            return Optional.of(Collections.singletonList(new WebSearchOrganicResult(
                    "SYSTEM", URI.create("https://system"), "LIMIT_REACHED",
                    "SYSTEM NOTIFICATION: You have reached the maximum number of allowed "
                    + toolName + " calls. Do not search again.")));
        }

        counter.incrementAndGet();
        return Optional.empty();
    }
}
```

Two levels of defense:

1. Soft limit (per-tool): The `ToolRateLimiter` returns a `LIMIT_REACHED` sentinel response — the LLM reads this as a signal to stop.

2. Hard limit (global): LangChain4j's `maxSequentialToolsInvocations()` throws a Java exception and forcefully terminates if the LLM keeps calling tools beyond the hard ceiling.

The soft limit allows the LLM one last strategic attempt; the hard limit is the kill switch.

## RAG — The Self-Feeding Intelligence Layer

RAG (Retrieval-Augmented Generation) in AI CLI is not a simple "embed some files and query them." It's a carefully designed two-phase pipeline that feeds itself.

```MERMAID
graph TB
    subgraph Ingestion["Ingestion Pipeline"]
        DataFiles["--data files"]
        SearchResults["WebSearchTool results"]
        IS["IngestionService"]
        CDS["ConfigurableDocumentSplitter"]
        METST["MetadataEnrichedTransformer"]
        ESI["EmbeddingStoreIngestor"]
    end

    subgraph Retrieval["Retrieval Pipeline"]
        CRS["ContentRetrievalService"]
        ESCR["EmbeddingStoreContentRetriever (vector similarity)"]
        WSCR["WebSearchContentRetriever (live web search)"]
        QR["LanguageModelQueryRouter (LLM-based routing)"]
        RR["ReRankingContentAggregator (minScore: 0.3)"]
        DRA["DefaultRetrievalAugmentor"]
    end

    subgraph Store["Vector Store"]
        EM["EmbeddingModel"]
        ES["EmbeddingStore (DuckDB / Lucene / Qdrant)"]
    end

    DataFiles --> IS
    SearchResults -.->|"auto-ingest (dedup by URL)"| IS
    IS --> CDS --> METST --> ESI
    ESI --> EM --> ES

    CRS -->|"rag tool"| ESCR
    CRS -->|"rag_search tool"| WSCR
    CRS -->|"rerank tool"| RR

    ESCR --> ES
    ESCR --> EM

    ESCR & WSCR -->|"single"| DRA
    ESCR & WSCR -->|"multiple"| QR --> DRA

    RR --> DRA

    DRA -->|"attached to"| AiSvc["AiServices.builder().retrievalAugmentor()"]

    classDef ingest fill:#9f7aea,stroke:#6b46c1,color:#fff
    classDef retrieve fill:#4a90d9,stroke:#2c5282,color:#fff
    classDef store fill:#48bb78,stroke:#276749,color:#fff

    class DataFiles,SearchResults,IS,CDS,METST,ESI ingest
    class CRS,ESCR,WSCR,QR,RR,DRA retrieve
    class EM,ES store
```

### Phase 1: Ingestion

Documents enter the embedding store through two paths:

1. Static data files (`--data=docs/`) — loaded at startup

2. Dynamic web search results — auto-ingested during execution by the `WebSearchTool`

Every document goes through the same pipeline:

```
Documents → ConfigurableDocumentSplitter → MetadataEnrichedTransformer → EmbeddingStoreIngestor → VectorStore
```

The `MetadataEnrichedTextSegmentTransformer` is where the magic happens. It doesn't just store the text — it enriches every segment with contextual labels:

```JAVA
@Component
public class MetadataEnrichedTextSegmentTransformer implements TextSegmentTransformer {

    @Override
    public TextSegment transform(TextSegment textSegment) {
        var metadata = textSegment.metadata().copy();

        // Store statistics and timestamps
        metadata.put("original_text", originalText);
        metadata.put("character_count", String.valueOf(originalText.length()));
        metadata.put("ingestion_timestamp", String.valueOf(System.currentTimeMillis()));

        // Contextualize: prepend filename or title to the embedded text
        String contextPrefix = "";
        if (metadata.containsKey("file_name")) {
            contextPrefix = metadata.getString("file_name") + "\n";
        } else if (metadata.containsKey("title")) {
            contextPrefix = "Title: " + metadata.getString("title") + "\n";
        }

        return TextSegment.from(contextPrefix + originalText, metadata);
    }
}
```

Why prepend the filename or title to the embedded text? Because embedding models retrieve based on semantic similarity, and a naked paragraph of text about "registration opens April 15" is meaningless without the context of which event it belongs to. The label grounds the embedding.

For web search results, the `IngestionService` also handles deduplication by URL before ingesting — it queries the store's metadata filter to check if it has already been seen:

```JAVA
private boolean exists(String url) {
    var request = EmbeddingSearchRequest.builder()
            .queryEmbedding(embeddingModel.embed(url).content())
            .filter(MetadataFilterBuilder.metadataKey("url").isEqualTo(url))
            .maxResults(1)
            .build();
    return !embeddingStore.search(request).matches().isEmpty();
}
```

### Phase 2: Retrieval

When a stage requests RAG (via `tools: [rag]` in front matter), the `ContentRetrievalService` builds a `DefaultRetrievalAugmentor`:

* Single retriever (just `rag`) → direct attachment for efficiency

* Multiple retrievers (`rag` + `rag_search`) → `LanguageModelQueryRouter` with LLM-based routing and `ROUTE_TO_ALL` fallback

* Reranking (when `rerank` is active) → `ReRankingContentAggregator` with a `minScore(0.3)` threshold

Reranking is essential in production. Embedding similarity alone returns many segments, but not all are relevant. The reranker (Voyage AI API or a local ONNX model) re-scores the results based on semantic relevance to the actual query, filtering out noise.

This two-phase architecture means the RAG layer is not static. It grows during every pipeline run as web search results are auto-ingested, and future queries benefit from the enriched store. It's a self-improving loop.

## Output System & Newsletter Delivery

After the pipeline finishes, the result needs to go somewhere. The `OutputServiceProvider` dispatches to the right handler based on the `--output-mode` flag:

| Mode | Handler | Flow |
| --- | --- | --- |
| `new_file` | `NewFileOutputService` | Write to a new timestamped file |
| `replace_file` | `ReplaceFileOutputService` | Overwrite the input file |
| `mail` | `MailOutputService` | Markdown → HTML (Commonmark with GFM tables) → MailerSend email |
| `buttondown` | `ButtondownOutputService` | Raw Markdown → Buttondown newsletter API |

Here is the full output dispatch flow:

```MERMAID
graph LR
    subgraph Input["Pipeline Output"]
        Content["LLM Output (Markdown/Text)"]
    end

    subgraph Request["OutputCapableRequest"]
        OM["outputMode()"]
    end

    OSP["OutputServiceProvider.provide(request, output)"]

    subgraph Handlers["OutputHandler implementations"]
        NFO["NewFileOutputService - mode: new_file"]
        RFO["ReplaceFileOutputService - mode: replace_file"]
        MO["MailOutputService - mode: mail"]
        BO["ButtondownOutputService - mode: buttondown"]
    end

    subgraph MailFlow["Mail Processing"]
        CMP["Commonmark Parser"]
        HR["HtmlRenderer (+GFM Tables)"]
        MS["MailerSend SDK"]
    end

    subgraph BDFlow["Buttondown Processing"]
        BDAPI["POST /v1/emails {body: markdown}"]
    end

    Content --> OSP
    OM --> OSP
    OSP -->|"supports()?"| NFO & RFO & MO & BO

    NFO -->|write| File["New File"]
    RFO -->|overwrite| InputFile["Input File"]
    MO --> CMP --> HR --> MS --> Email["Email Delivery"]
    BO --> BDAPI --> Newsletter["Newsletter Published"]

    classDef handler fill:#38b2ac,stroke:#285e61,color:#fff
    classDef external fill:#a0aec0,stroke:#718096,color:#333

    class NFO,RFO,MO,BO handler
    class MS,BDAPI,File,InputFile,Email,Newsletter external
```

The mail flow is particularly interesting: Commonmark parses the Markdown, renders it to HTML with GFM table extensions, and the MailerSend SDK delivers it to configured recipients. All automated, all from a cron-triggered GitLab CI job.

The CI/CD pipeline for newsletters is straightforward:

```MERMAID
graph TB
    subgraph Trigger["GitLab CI Scheduled Pipeline"]
        CRON["Cron Schedule"]
    end

    subgraph Build["Build Stage"]
        MVN["mvn clean package"]
        JAR["ai-cli.jar"]
    end

    subgraph Newsletters["Newsletter Jobs"]
        MR["Morocco Run Radar"]
        ITE["IT Events Casablanca"]
        APJ["Assistant Professor Jobs"]
    end

    subgraph Pipeline["2-Stage Transform Pipeline"]
        S1["Stage 1: Research (search, scrape, validate JSON)"]
        S2["Stage 2: Presentation (format, validate Markdown)"]
    end

    subgraph Delivery["Output Delivery"]
        MAIL["MailerSend (Markdown to HTML email)"]
        BD["Buttondown (raw Markdown newsletter)"]
    end

    CRON --> MVN --> JAR
    JAR --> MR & ITE & APJ
    MR & ITE & APJ --> S1 --> S2
    S2 -->|"--output=mail"| MAIL
    S2 -->|"--output=buttondown"| BD

    classDef trigger fill:#fbd38d,stroke:#b7791f,color:#333
    classDef build fill:#ecc94b,stroke:#b7791f,color:#333
    classDef newsletter fill:#48bb78,stroke:#276749,color:#fff
    classDef delivery fill:#38b2ac,stroke:#285e61,color:#fff

    class CRON trigger
    class MVN,JAR build
    class MR,ITE,APJ newsletter
    class MAIL,BD delivery
```

Each newsletter is a separate CI job with its own transformation directory, tools, and delivery configuration. Adding a new newsletter is creating a new prompt directory and a new CI job — nothing else.

## Production Realities — Hard-Won Lessons

This is the most important section. Building an LLM application is easy. Keeping it running reliably in production is hard.

### Context Pollution

When multiple stages share the same chat memory, Stage 2 sees all of Stage 1's tool calls — including failed attempts, retries, and debugging noise. The LLM starts hallucinating based on stale tool-call artifacts.

Fix: Every stage gets its own isolated `InMemoryChatMemoryStore`. Stage 2 never sees Stage 1's conversations.

### Tool Isolation

The generation agent (Stage 2) must never have access to web-scraping or search tools. If it does, it will try to "verify" its own output by searching, find contradictory results, and enter a loop of self-correction that produces garbage.

Fix: Front matter tool declarations per stage. The researcher has `search`, `crawler`, `apify`. The writer has `markdown_validate`. They never overlap.

### Hybrid Predictability

LLMs are non-deterministic. But newsletters need consistent structure. The solution is deterministic Java guardrails around non-deterministic LLM output:

* JSON Schema validation (`json_schema_validate`) enforces the exact structure of Stage 1's output

* Markdown validation (`markdown_validate`) catches malformed formatting before delivery

* Security scanning (`markdown_security`) checks every URL against Google Web Risk before the email goes out

The LLM is creative. Java is the enforcer.

### The Refeed Loop

The best newsletter outputs are validated, curated, and structurally sound. By ingesting these outputs back into the vector database, future generations are improved — they can reference previous editions as examples of good formatting, successful event verification, and proper structure. The system improves itself.

## Testing — The Quest for the Right Model

### The Model Testing Journey

We tested multiple models across different providers and scenarios.

Tool calling turned out to be a bit of a challenge. The LLM doesn't just generate text — it needs to decide when to call tools, how to interpret the results, and when to stop. Not all models handle this well.

Some models would enter infinite tool loops, calling `search` 200 times with the same query. Others would skip the validation tool before returning their final answer. Some would ignore the JSON schema entirely.

We tested across OpenAI, Gemini, DeepSeek, Groq, and local Ollama models. For local zero-cost testing, Qwen 3 (1.7B) turned out to be well suited for the job. At 1.7 billion parameters it runs fast on local machines, and it was fairly consistent with tool calling — it follows structured prompts, calls tools in the right order, and respects validation cycles. It became the model powering our entire zero-cost test suite.

### Zero-Cost Integration Testing

The project runs 43 integration tests — all via shell scripts, no unit tests. The entire test suite runs against a local stack that costs exactly $0:

| Production (Paid) | Zero-Cost Alternative |
| --- | --- |
| OpenAI / DeepSeek chat | Ollama `qwen3:1.7b` |
| Voyage AI embeddings | Ollama `mxbai-embed-large` |
| Google Custom Search | DuckDuckGo / Stub engine |
| Qdrant Cloud vector store | DuckDB (in-process) |
| Voyage AI reranker | ONNX `ms-marco-mini-l6-v2` |

This is possible because of the factory pattern. The same code, same tests, different beans. Swapping `--chat-model=gpt-5` to `--chat-model=qwen3:1.7b` loads a completely different Spring context with zero code changes.

```MERMAID
graph LR
    subgraph Prod["Production (Paid $$$)"]
        PC["Chat Model: OpenAI, DeepSeek, Anthropic, Google"]
        PE["Embedding Model: Voyage AI, OpenAI, Cohere"]
        PS["Search Engine: Google Custom Search, Serper"]
        PV["Vector Store: Qdrant Cloud, Pinecone, Weaviate"]
        PR["Scoring / Reranker: Cohere, Voyage AI"]
    end

    subgraph Factory["Spring @Conditional Factory Pattern"]
        F["Same Code - Same Tests - Different Bean"]
    end

    subgraph Test["Zero-Cost Alternatives"]
        TC["Ollama (qwen3, llama3) / Llama.cpp, GPT4All"]
        TE["Ollama (mxbai, nomic) / ONNX all-MiniLM-L6-v2"]
        TS["DuckDuckGo / Tavily (free tier)"]
        TV["DuckDB (in-process) / Chroma, In-Memory"]
        TR["ONNX ms-marco-mini (local, no API)"]
    end

    PC -.-> F
    PE -.-> F
    PS -.-> F
    PV -.-> F
    PR -.-> F

    TC --> F
    TE --> F
    TS --> F
    TV --> F
    TR --> F

    F --> R["43 Integration Tests - $0 API Cost"]

    classDef prod fill:#fc8181,stroke:#c53030,color:#fff
    classDef factory fill:#63b3ed,stroke:#2b6cb0,color:#fff
    classDef free fill:#68d391,stroke:#276749,color:#fff
    classDef result fill:#fbd38d,stroke:#b7791f,color:#333

    class PC,PE,PS,PV,PR prod
    class F factory
    class TC,TE,TS,TV,TR free
    class R result
```

```BASH
# Full zero-cost suite (43 tests)
./scripts/test_integration_ollama.sh

# Dedicated agentic workflow coverage
./scripts/test_transform_agentic_ollama.sh
```

The tests cover: basic transformations, RAG, data ingestion, search tools, social media search, content crawler (JS-rendered pages), tool execution limits, validation tools (JSON, HTML, Markdown), reranking (ONNX), and output modes.

### Assertion Strategy: Structure Over Content

LLM output is non-deterministic, so exact-string assertions are a recipe for flaky tests. Instead, we assert on structure:

* "Did it return valid JSON with the required fields?"

* "Does the output contain at least one event?"

* "Was the validation tool called before the final answer?"

This approach gives us reliable CI/CD without fighting non-determinism.

## The Real Output — Live Newsletters

This is not a demo. These newsletters run on a schedule and land in real inboxes.

Morocco Run Radar: Every week, the pipeline researches all upcoming running events in Morocco through web search, Instagram scraping, Facebook scraping, and official race websites. It computes the distance of each event from Casablanca, validates the data against a JSON schema, formats it into a Markdown newsletter, scans every URL for malware, and delivers it via email.

IT Events Casablanca: Targets tech professionals in Casablanca with upcoming meetups, conferences, and workshops.

Assistant Professor Jobs: Monitors academic job openings matching specific criteria and delivers a curated digest.

Each pipeline follows the same 2-stage (classic) or 3-agent (agentic) architecture. The prompt files are the only difference.

## What's Next

The engine works, the newsletters ship, and the architecture holds up. But there is plenty of room to grow.

### Multimodality

Right now, AI CLI operates in a text-only world. The LLM reads text prompts, searches text results, and produces text output. But the real world is not text-only — race organizers post flyers as images, trail maps are PDFs, and results are sometimes scanned documents. Introducing multimodal inputs (image understanding, PDF extraction) would let the pipeline process these richer sources directly rather than relying on whatever text happens to be on the webpage.

### Streaming & Batch

The current pipeline runs synchronously — the LLM generates its full response before anything is returned. For long-running agentic workflows, streaming would provide real-time feedback and reduce perceived latency. On the other end of the spectrum, batch APIs would allow high-volume processing (e.g., ingesting hundreds of data sources at once) at reduced cost, since most providers offer batch endpoints at a discount.

### Stateful Newsletters — Delta Reporting

Weekly readers lose engagement when they see the same 50 events repeated. The next evolution is giving the pipeline memory across runs by injecting the previous edition's structured JSON into the agentic scope. The agents can then highlight what changed: "3 new marathons added," "Rabat Marathon now sold out," or "Early bird registration ends tomorrow." This shifts the newsletter from a static list to a living update.

### Smarter Ingestion

As discussed in the RAG section, blindly ingesting all search results makes the vector store noisy over time. Future iterations could introduce relevance-scored ingestion (using the reranker as a gate), TTL-based expiration, or even limiting ingestion to only the final validated output — so the RAG layer learns from curated content, not raw web noise.

### Beyond Sequential — Other Workflow Patterns

It's worth being honest about what this engine is and what it isn't. Everything described in this article uses sequential workflows — agents execute one after another in a fixed order, passing state forward. This was the right choice for newsletters: there is a natural pipeline from research → editorial → formatting → delivery, and sequence gives you predictability and debuggability.

But sequential is not the only pattern, and it's not always the best one. LangChain4j supports several others that would suit different use cases:

* Parallel workflows — multiple agents running concurrently. Useful when you have independent research tasks (e.g., one agent searches web, another scrapes social media, a third queries a database) and want to merge results at the end.

* Loop workflows — self-correcting agents that iterate until a quality threshold is met. Instead of validating once and hoping, the agent retries with feedback until the output passes.

* Supervisor workflows — a manager agent that dynamically decides which worker agents to call, in what order, and how many times. This is the most powerful pattern — it handles complex, branching tasks where the right next step depends on what was learned so far.

For a newsletter engine, sequential gives us exactly the control we need. But if the use case grows into something more complex — say, a real-time research assistant that needs to decide dynamically whether to search, crawl, or ask a follow-up question — a supervisor or loop pattern would be the right tool for the job. The factory-based architecture makes that transition straightforward: the workflow pattern is just another bean.

## Further Reading

This article builds on concepts introduced in two predecessor articles. If you want to understand the foundations before diving into the production engine:

* [JBang Meets Spring Boot &amp; LangChain4j](jbang-meets-spring-boot-langchain4j-a-powerhouse-for-java-scripting-and-ai-pipelines.html) — how single-file Java scripts evolve into Spring Boot applications with LLM chaining

* [Easy RAG — Using Embeddings in LangChain4j](easy-rag-using-embeddings-in-langchain4j-to-improve-llm-responses.html) — embedding models, vector stores, and content retrieval fundamentals

## Downloads — See It in Action

### Live Presentation

[LangChain4j in Action: A Walkthrough from Basic Chaining to Agentic Workflows (PDF)](resources/LangChain4j%20in%20Action%20A%20Walkthrough%20from%20Basic%20Chaining%20to%20Agentic%20Workflows.pdf)

### Newsletter Examples (Real Output)

[Morocco Run Radar — Agentic Edition (April 2026)](resources/Gmail%20-%20Moroccan%20Runners%20Radar%20(Agentic)%20-%2011_04_2026.pdf)

[IT Events Casablanca Newsletter (April 2026)](resources/Gmail%20-%20Moroccan%20IT%20Events%20Newsletter%20-%2007_04_2026.pdf)

[Assistant Professor Jobs Newsletter (April 2026)](resources/Gmail%20-%20Assistant%20Professor%20Jobs%20Newsletter%20-%2006_04_2026.pdf)

> **Tip:**
> The best newsletter is one that is built for you, by you. AI CLI is the engine that makes that possible.

