<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Embabel - Medium]]></title>
        <description><![CDATA[Embabel is an agentic AI framework for Java and the JVM. Created by Rod Johnson, founder of the Spring Framework. - Medium]]></description>
        <link>https://medium.com/embabel?source=rss----33b58d773744---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Embabel - Medium</title>
            <link>https://medium.com/embabel?source=rss----33b58d773744---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 12 Nov 2026 10:49:37 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/embabel" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Bayes of Our Lives: Experimental Decision Models in Embabel Agent]]></title>
            <link>https://medium.com/embabel/bayes-of-our-lives-experimental-decision-models-in-embabel-agent-2cf9e7ac4951?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/2cf9e7ac4951</guid>
            <category><![CDATA[decision-model]]></category>
            <category><![CDATA[embabel]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[James Dunnam]]></dc:creator>
            <pubDate>Thu, 22 Oct 2026 19:32:14 GMT</pubDate>
            <atom:updated>2026-10-22T19:32:12.830Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Like sands through the hourglass, so are the LLM API calls of our lives.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0D3nkO-OW-OZbXqdsAaq9Q.jpeg" /><figcaption>Jev and ChatGPT disagree on directions</figcaption></figure><p>You may or may not have heard of decision models. They’re a newer kind of model, and they do a narrower job than the LLM you might already have judging things in your app. You give one a question, some state, and the answers it’s allowed to give. It hands back one of those answers with probabilities attached. Here’s an example adapted from the support-ticket routing example in <a href="https://docs.typesafe.ai/introduction/quickstart">TypeSafe’s quickstart</a>. The request asks Jev which team should handle a customer’s message. The state is the message itself, and the criteria list the teams Jev can choose from, each with a short description.</p><pre><br>{<br>  &quot;model&quot;: &quot;jev-latest&quot;,<br>  &quot;state&quot;: &quot;Your app keeps logging me out, and now my card shows a charge I don&#39;t recognize.&quot;,<br>  &quot;questions&quot;: {<br>    &quot;department&quot;: {<br>      &quot;type&quot;: &quot;choice&quot;,<br>      &quot;instructions&quot;: &quot;Which team should handle this&quot;,<br>      &quot;criteria&quot;: {<br>        &quot;billing&quot;: &quot;Payment or subscription issues&quot;,<br>        &quot;technical&quot;: &quot;Bugs or integration problems&quot;,<br>        &quot;sales&quot;: &quot;Pricing or account questions&quot;<br>      }<br>    }<br>  }<br>}</pre><p>Jev’s response comes back as structured data rather than a wall of text, or text-formatted JSON that you need to cleanse and parse.</p><pre>{<br>  &quot;model&quot;: &quot;jev-1.13.0&quot;,<br>  &quot;answers&quot;: {<br>    &quot;department&quot;: {<br>      &quot;type&quot;: &quot;choice&quot;,<br>      &quot;choice&quot;: &quot;billing&quot;,<br>      &quot;confidence&quot;: 0.77,<br>      &quot;probabilities&quot;: { &quot;billing&quot;: 0.85, &quot;technical&quot;: 0.15, &quot;sales&quot;: 0.0 }<br>    }<br>  }<br>}<br></pre><p>The answer is billing. Along with the answer, you get a probability for each option and a confidence score between zero and one.</p><p>The big draw here is speed. A decision model doesn’t have to write anything out. It just picks from the answers you gave it, so there’s no long response to generate and no text to parse on the other end. Those savings add up fast when an agent has to make a bunch of small calls on the way to a result.</p><p>Given the claims, I wanted to see how <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">Jev</a>, TypeSafe’s first <em>System One</em> model, stacked up against an LLM making the same call inside a real agent, so I built a small app to find out.</p><h3>Decisions in Embabel Agent</h3><p><a href="https://github.com/embabel/embabel-agent">Embabel Agent</a> is a JVM framework for building agents out of goals and actions. It plans a path through your actions based on the types they take and return. It can now call decision models too. An action asks a typed question through a DecisionService and gets back a typed answer.</p><h3>A layover in Tampa</h3><p>The test app is <a href="https://github.com/jimador/bayes-of-our-lives">Bayes of Our Lives</a>. It’s a stripped-down spin-off of <a href="https://github.com/embabel/tripper">Tripper</a>, Embabel’s travel planner example. Tripper plans a whole trip. Bayes plans a few hours, because nobody needs a seven-day travel agent to figure out that lunch is missing.</p><p>The scenario is a long layover in Tampa. You’ve got from about 7 a.m. to 2 p.m., and you’d rather see some of the city than sit at the gate. It’s you and a partner, one of you is into art, and you want breakfast and lunch. You’re both up for seafood or Cuban food, and anything but Italian.</p><p>An LLM does the bulk of the work. It picks venues from a map search, researches them, and drafts a couple of itineraries. Then the app checks every draft against drive times, meal windows, and opening hours, and marks anything that doesn’t fit as invalid. It even leaves ten minutes on every leg for parking, which is more planning than I usually do. The plans that pass go to the decision model, which makes the calls at the end. It picks the best plan, says whether that plan covers what the traveler asked for, and rates how confident it is in the pick.</p><h3>Role call</h3><p>One thing I’ve always liked about Embabel is that your code never has to name a model. You give each model a role, like “cheapest” or “best,” map the roles to real models in configuration, and your code asks for the role. Embabel’s docs call it <a href="https://docs.embabel.com/embabel-agent/guide/1.5.0-SNAPSHOT/#setting-default-llm-and-roles">role-based model selection</a>, and they point out it’s good practice to keep your code decoupled from specific models this way. Decision models work the same way now. Each engine in the app gets a role, and the roles are just properties. Here are the Jev and OpenAI lines from the app’s configuration.</p><pre>embabel.agent.platform.models.openai.api-key=${OPENAI_API_KEY:}<br>embabel.agent.platform.models.typesafe.api-key=${TYPESAFE_API_KEY:}<br>embabel.models.decision.roles.jev=jev-latest<br>embabel.models.decision.roles.openai=gpt-4.1<br>embabel.models.classification.roles.jev=jev-latest<br>embabel.models.classification.roles.openai=gpt-4.1</pre><p>jev points at Jev, and openai points at a chat model that Embabel turns into a decision service for you. Each role is set for both decisions and classifications, so one model answers everything for a given run. Inside the action, Bayes looks the model up by role with context.ai().decisions().byRole(role). Switching models is a property change.</p><h3>Call and response</h3><p>Bayes asks three questions in one decision request and then a fourth as a classification request. The coverage question asks whether a plan covers every requested stop and meal. The next question asks which plan fits best, or whether to go research more. The confidence question rates the best plan as weak, possible, or strong. Readiness classifies the whole set of plans.</p><p>The next question is the interesting one, because its answers may change every time. The options are whichever plans passed validation, plus an escape hatch. In the code that list is valid. This method just builds the question, so no model gets called yet.</p><pre>static ChoiceQuestionSpec next(List&lt;Itinerary.Alternative&gt; valid) {<br>    var question =<br>            Questions.named(&quot;next&quot;)<br>                    .choice(<br>                            &quot;&quot;&quot;<br>                            Which alternative best fits the traveller&#39;s profile, liked cuisines and timing? ...\<br>                            &quot;&quot;&quot;);<br>    valid.forEach(a -&gt; question.option(a.id(), a.title() + &quot; (&quot; + a.status() + &quot;)&quot;));<br>    return question.option(&quot;research&quot;, &quot;Resolve material questions before choosing&quot;).build();<br>}</pre><p>A plan that failed validation never makes the list, so neither model can pick it, and no LLM gets to talk a bad plan into a good one by sounding pleased with itself. Here’s where the model actually gets called, whichever one the role points at. Asking all four questions looks like this.</p><pre>DecisionResponse response =<br>        decisions.ask(<br>                DecisionRequest.of(<br>                        input, DecisionSpec.of(COVERAGE, next, CONFIDENCE)));<br>...<br>ClassificationResult readiness =<br>        classifier.classify(ClassificationRequest.of(input, READINESS));</pre><p>The answers come back typed. Each one is a sealed result with answered, inconclusive, and failure cases, so a switch over it is exhaustive, and the compiler tells you if you miss one. Here’s the coverage answer.</p><pre>private static String coverage(PropositionResult result) {<br>    return switch (result) {<br>        case PropositionResult.Answered answered -&gt; Boolean.toString(answered.getAnswer());<br>        case PropositionResult.Inconclusive inconclusive -&gt; &quot;inconclusive&quot;;<br>        case PropositionResult.Failure failure -&gt; failure(&quot;coverage&quot;, failure.getReason());<br>    };<br>}</pre><p>Bayes never has to parse text or guess what the model meant. If the model picks something that wasn’t on the list, Bayes records that as a failure.</p><h3>Time will tell</h3><p>Bayes times the decision call and the classification call separately, so it’s easy to compare the two models on just the part they’re responsible for. I ran the same Tampa request once with each.</p><pre>Model    Decision call   Classification call<br>Jev      416 ms          141 ms<br>OpenAI   736 ms          603 ms</pre><p>Jev was faster on both calls, about 1.8 times on the decision and about 4 times on the classification. These are remote calls, so the numbers include the network, and one run each is a small sample. Still, the gap was clear for the exact same questions.</p><p>Both models picked the same plan, which was breakfast at Fast Jacks Breakfast, the Tampa Museum of Art, and a seafood lunch at Juicy Seafood. Not much of a cliffhanger, but nothing Italian made the cut.</p><p>Bayes also uses <a href="https://github.com/embabel/dice">Dice</a> to remember interests and pace between outings, but that’s a story for another episode.</p><h3>Your turn</h3><p>The code is on GitHub at <a href="https://github.com/jimador/bayes-of-our-lives">jimador/bayes-of-our-lives</a>, and the README walks through setup. You’ll need your own OpenAI and TypeSafe keys. Swap in whatever city your layover lands you in, flip the role, and watch the clock.</p><h3>Closing credits</h3><p>The cast and crew for this episode, in case you want to follow along at home.</p><ul><li><strong>Starring</strong> <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">Jev</a> (<a href="https://docs.typesafe.ai/">TypeSafe docs</a>) and ChatGPT</li><li><strong>Directed by</strong> <a href="https://github.com/embabel/embabel-agent">Embabel Agent</a> 1.5.4-SNAPSHOT (<a href="https://docs.embabel.com/embabel-agent/guide/1.5.0-SNAPSHOT/">user guide</a>)</li><li><strong>Supporting cast</strong> <a href="https://github.com/embabel/dice">Dice</a> 0.4.0 (<a href="https://github.com/embabel/dice/releases/tag/v0.4.0">release notes</a>)</li><li><strong>Special thanks</strong> to <a href="https://github.com/embabel/tripper">Tripper</a> for the location</li><li><strong>Try it at home</strong> with <a href="https://github.com/jimador/bayes-of-our-lives">Bayes of Our Lives</a></li></ul><p><em>Like sands through the hourglass, so are the LLM API calls of our lives.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2cf9e7ac4951" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/bayes-of-our-lives-experimental-decision-models-in-embabel-agent-2cf9e7ac4951">Bayes of Our Lives: Experimental Decision Models in Embabel Agent</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Embabel Agentic AI Framework Release 1.0.0: New Features]]></title>
            <link>https://medium.com/embabel/embabel-agentic-ai-framework-release-1-0-0-new-features-332f375d0fba?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/332f375d0fba</guid>
            <category><![CDATA[embabel]]></category>
            <category><![CDATA[agentic-ai-framework]]></category>
            <category><![CDATA[agentic-rag]]></category>
            <dc:creator><![CDATA[Igor Dayen]]></dc:creator>
            <pubDate>Thu, 30 Jul 2026 23:53:20 GMT</pubDate>
            <atom:updated>2026-07-30T23:53:22.080Z</atom:updated>
            <content:encoded><![CDATA[<p><em>This blog is co-written with the Embabel Development Team.</em></p><p><strong>Embabel Agentic Framework has just reached a long-awaited milestone release, 1.0.0.</strong></p><p>You may take a look at <a href="https://medium.com/me/following-feed/publications/33b58d773744">Embabel&#39;s</a> blogs and at separate blogs published by Embabel founder <a href="https://medium.com/u/de5947672915">Rod Johnson</a>, creator of the Spring Framework.</p><p>Also, <a href="https://medium.com/u/2a4443d21452">Craig Walls</a> and <a href="https://medium.com/u/4fcb0571c65d">Jettro Coenradie</a> recently published a few <a href="http://thetalkingapp.medium.com">blog posts</a> on <a href="http://github.com/embabel">Embabel</a>.</p><p>Please refer to the <a href="https://docs.embabel.com/embabel-agent/guide/1.0.0/index.html#reference.integrations">User Guide</a> for features, solutions, and APIs.</p><p>Please try <a href="http://hub.embabel.com">Embabel HUB</a>, an Embabel-powered learning tool. You can use voice as well as a communication channel with HUB. Choose one of the built-in personalities, or make your own. HUB uses many of the features covered in this blog: <em>ToolishRag</em>, chat-store, ONNX local embeddings, and more.</p><p>Here are some of the recently added features and other features not fully covered in previous blogs.</p><h3>Structured Output</h3><ul><li><strong>Best-Effort (</strong><strong>createObjectIfPossible)</strong> - returns null instead of throwing when the LLM response cannot be parsed; the GOAP planner automatically replans around the missing output.<br>Use this when incomplete results are acceptable, and you want the planner to find an alternate path rather than fail the entire process. ¹</li><li><strong>Streaming</strong> — Reactive Flux-based streaming with three modes: typed objects, objects + thinking blocks (StreamingEvent&lt;T&gt;), and raw text chunks.<br>Use this when rendering progressive results in a UI or streaming responses to end users without waiting for the full object. ²</li></ul><h3>Native — Anthropic</h3><ul><li><strong>Prompt Caching</strong> — cache system prompt, tool definitions and/or conversation history; cache reads cost 10% of regular input tokens.<br>Use this when making repeated calls against the same large system prompt or document — cache hits reduce input token cost by 90%. ³</li></ul><h3>Native — Anthropic &amp; Google GenAI</h3><ul><li><strong>Native Thinking</strong> — PromptRunner.thinking() is the provider-neutral API for extended reasoning; the framework translates it to provider-native payloads: Anthropic sends ThinkingConfig(ThinkingType.ENABLED, budget), Google GenAI sets includeThoughts and thinkingBudget. Token budget controlled via LlmOptions.withThinking(Thinking.withTokenBudget(n)); supportsThinking() guards capability at runtime.<br>Use this when you need the model&#39;s reasoning enforced at the provider API level. ¹⁷</li></ul><h3>Native — OpenAI</h3><ul><li><strong>Structured JSON Output</strong> — automatically sends output schema via response_format: {type: json_schema}; API-level enforcement eliminates JSON parse failures. Conservative schema compatibility check with silent fallback to prompt-based extraction.<br>Use this when JSON parse failures are unacceptable, and schema compliance must be guaranteed at the provider API level. ⁴</li></ul><h3>Provider-neutral</h3><ul><li><strong>Thinking / Reasoning Support</strong> — uniform runner.thinking() API across Anthropic, OpenAI, Google GenAI and Ollama. Auto-detects dynamic XML tags (&lt;think&gt;, &lt;analysis&gt;, any custom tag), returns multiple ordered ThinkingBlocks, and preserves thinking blocks even when object parsing fails.<br>Use this when you want to observe or validate the model&#39;s reasoning process, irrespective of whether the call succeeds or fails. ⁵</li></ul><h3>Tool Loop Operations</h3><ul><li><strong>Tool Call Inspector &amp; Tool Loop Callbacks</strong> — two complementary hook levels: ToolCallInspector (beforeToolCall / afterToolCall) observes individual tool executions in both streaming and non-streaming modes; ToolLoopInspector (read-only) and ToolLoopTransformer (mutating) hook at every round of the multi-round loop (beforeLlmCall, afterLlmCall, afterToolResult, afterIteration). Built-in transformers include SlidingWindowTransformer and ToolResultTruncatingTransformer.<br>Use this when you need auditing, metrics, or cost tracking without modifying agent or tool logic. ⁶</li><li><strong>Thinking Across Tool Loop</strong> — thinking blocks accumulated from every LLM call in the multi-round tool loop, not just the final response; ThinkingResponse.thinkingBlocks contains reasoning from all iterations.<br>Use this when you need to understand the full reasoning chain across a multi-step agent execution, not just the final answer. ⁷</li><li><strong>Parallel Tool Loop</strong> — independent tool calls within a single LLM turn execute concurrently; enabled via a single YAML property.<br>Use this when tool calls within a turn are independent — e.g. fetching data from multiple sources simultaneously. ⁹</li></ul><h3>Tools</h3><ul><li><strong>Progressive / Folding Tools (</strong><strong>UnfoldingTool)</strong> - hides inner tools until the LLM invokes the facade; two-stage disclosure: short description always visible, childToolUsageNotes revealed only after the LLM commits. Supports flat, category-based, and exclusive modes.<br>Use this when a large toolset degrades model planning quality or hits token limits on the tool description list. ¹⁰</li><li><strong>ToolishRag</strong> — RAG exposed as typed tool calls (LlmReference + DelegatingTool); the model explicitly decides when and what to retrieve rather than having context silently injected.<br>Use this when you want the model to explicitly decide when and what to retrieve from a knowledge base or data pipeline, invoking it as a typed tool call rather than having documents silently injected into the prompt. ¹¹</li></ul><h3>Safety</h3><ul><li><strong>Guardrails</strong> — pre-LLM input validation (UserInputGuardRail) and post-response analysis (AssistantMessageGuardRail); CRITICAL Severity blocks the call; lower severities log only.<br>Use this when safety policies, PII checks, or content moderation must be enforced without coupling those concerns to agent or action logic. ¹²</li></ul><h3>Lifecycle</h3><ul><li><strong>Termination APIs</strong> — immediate (exception) and graceful (signal) termination, each with agent-scope and action-scope variants; sealed TerminationException base for joint catch blocks.<br>Use this when an action detects a condition (budget exhausted, invalid input, skippable record) that should stop the current work without crashing the entire agent process. ¹³</li></ul><h3>Observability</h3><ul><li><strong>Observability &amp; Budget Enforcement</strong> — LlmInvocationEvent carries per-call cost and token usage; ProcessOptions.withBudget() hard-stops the agent when cumulative spend or action count exceeds the ceiling.<br>Use this when you need hard spending limits and real-time cost visibility per agent run. ¹⁴</li><li><strong>Token Budget Estimates</strong> — TokenCountEstimator&lt;String&gt; for pre-call token estimation; attach as TokenBudgetGuardRail or read BeforeLlmCallContext.tokenEstimate inside ToolLoopInspector.<br>Use this when you want to prevent expensive LLM calls with oversized inputs before they happen, or enforce per-action context size limits. ¹⁵</li></ul><h3>BYOK (Bring Your Own Key)</h3><ul><li><strong>Provider Detection &amp; Key Validation</strong> — ByokFactory&lt;T&gt; validates a user-supplied API key with a low-cost probe call and returns a production LlmService on success, or throws InvalidApiKeyException on failure. detectProvider() races multiple factories concurrently using virtual threads and returns the first that validates - no need to know the provider upfront. Built-in factories: AnthropicModelFactory, OpenAiCompatibleModelFactory.openAi/deepSeek/mistral/gemini. Custom OpenAI-compatible providers supported via OpenAiCompatibleModelFactory.byok().<br>Use this when your product lets users supply their own API keys - eliminates per-user inference cost and separates usage cost from product value. ¹⁸</li></ul><h3>Provider SPI</h3><ul><li><strong>LlmMessageSender / LlmMessageStreamer</strong> — framework-agnostic SPI interfaces to plug in any LLM backend; non-streaming: Embabel owns the tool loop; streaming: tool execution is framework-managed, only ToolCallInspector observation possible.<br>Use this when you need custom integration with LLM provider APIs while retaining Embabel&#39;s full agent loop. ¹⁶</li></ul><h3>Skills</h3><ul><li><strong>Skills Engine</strong> — lazy-loading plugin system for composable agent capabilities. Skills are defined by a YAML frontmatter + Markdown body (SKILL.md) following the <a href="https://agentskills.io/specification">Agent Skills Specification</a>. The system prompt carries only name and description (~50-100 tokens per skill); activate fetches full instructions on demand. Scripts in the scripts/ subdirectory become LLM-callable tools. Load from local directories or GitHub repositories; filter with SkillFilter. Use asIndividualReferences() to expose each skill as its own top-level tool with per-loop repeat suppression.<br>Use this when you have a large, domain-specific instruction library that would overflow the context window if injected in full, or when skills are shared across agents and teams. ¹⁹</li></ul><h3>Security</h3><ul><li><strong>Secure MCP (</strong><strong>@SecureAgentTool)</strong> - Spring Security SpEL enforcement on @Action methods exposed as MCP tools. Annotate at method or class level; SecureAgentToolAspect intercepts via AOP and evaluates the expression against the current Authentication before the action body executes - same engine as @PreAuthorize. SecureAgentSecurityConfiguration (activated by the secured profile) adds stateless JWT Bearer token validation on all /sse/, /mcp/ and /message/ endpoints. Method-level annotation takes precedence over class-level.<br>Use this when your MCP server exposes agent tools to external clients,s and access must be scoped per-tool or per-agent by role or authority. ²⁰</li></ul><h3>Conversation Persistence</h3><ul><li><strong>Chat Store Support</strong> — ConversationFactory SPI with two storage strategies: IN_MEMORY (fast, ephemeral) and STORED (persistent). ConversationFactoryProvider resolves the right factory by ConversationStoreType at chatbot creation time. The InMemoryConversationFactory is built-in; persistent storage is provided by the <a href="https://github.com/embabel/embabel-chat-store">embabel-chat-store</a> module. Conversations can be created for named participants (user + agent) and reloaded by ID across sessions.<br>Use this when you need conversation history to survive server restarts, be queried for analytics, or be shared across multiple agent instances. ²¹</li></ul><h3>Embeddings</h3><ul><li><strong>ONNX Embedding Models</strong> — run embedding inference in-process via ONNX Runtime + DJL HuggingFace tokenizer; no external embedding API required. Default model: all-MiniLM-L6-v2 (384 dimensions). Auto-configured via OnnxEmbeddingAutoConfiguration when embabel.agent.platform.models.onnx.embeddings.enabled=true. Model and tokenizer files are downloaded from HuggingFace on first use and cached at~/.embabel/models; set. model-uri/tokenizer-uri to file:// URIs to use pre-downloaded files and avoid network access at startup.<br>Use this when you want zero-latency, zero-cost embeddings with no external dependency, or when your deployment cannot reach external embedding APIs. ²²</li></ul><h3>Code Examples</h3><h3>1. Structured Output Best-Effort</h3><pre>// Called on PromptRunner, not on context directly:<br>val person: Person? = context.promptRunner()<br>    .createObjectIfPossible(input.text, Person::class.java)<br>// Reified Kotlin shorthand:<br>val person: Person? = context.promptRunner()<br>    .createObjectIfPossible&lt;Person&gt;(input.text)</pre><h3>2. Structured Output Streaming</h3><pre>// Check streaming support and subscribe to partial results:<br>val runner = context.promptRunner()<br>if (runner.supportsStreaming()) {<br>    // Objects only:<br>    runner.streaming()<br>        .withPrompt(query)<br>        .createObjectStream(Report::class.java)  // Flux&lt;Report&gt;<br>        .subscribe { partial -&gt; updateUI(partial) }<br>    // Objects + thinking (StreamingEvent.Object / StreamingEvent.Thinking):<br>    runner.streaming()<br>        .withPrompt(query)<br>        .createObjectStreamWithThinking(Report::class.java)  // Flux&lt;StreamingEvent&lt;Report&gt;&gt;<br>        .subscribe { event -&gt;<br>            event.fold(<br>                left = { thinking -&gt; log.debug(thinking) },<br>                right = { obj -&gt; updateUI(obj) }<br>            )<br>        }<br>}</pre><h3>3. Anthropic Prompt Caching</h3><pre>// Cache system prompt and tools; cache hits cost 10% of regular input tokens:<br>val runner = context.promptRunner()<br>    .withLlm(LlmOptions().withAnthropicCaching(<br>        systemPrompt = true,<br>        tools = true,<br>        conversationHistory = false,<br>    ))<br>val summary = runner.createObject(largeDocument.content, Summary::class.java)</pre><h3>4. Native Structured JSON Output</h3><pre>// Default: native output used automatically for compatible OpenAI models - no code change required:<br>val result = context.promptRunner()<br>    .createObject(prompt, Analysis::class.java)<br>// Explicit control:<br>val runner = context.promptRunner()<br>    .withLlm(LlmOptions().withNativeStructuredOutput(NativeStructuredOutputMode.ENABLED))<br>val result = runner.createObject(prompt, Analysis::class.java)<br>// Disable (fall back to prompt-based JSON extraction):<br>val runner = context.promptRunner()<br>    .withLlm(LlmOptions().withNativeStructuredOutput(NativeStructuredOutputMode.DISABLED))<br>val result = runner.createObject(prompt, Analysis::class.java)</pre><h3>5. Thinking / Reasoning Support</h3><pre>// Check thinking support, extract blocks by type or tag, and control token budget:<br>val runner = context.promptRunner()<br>if (runner.supportsThinking()) {<br>    val response: ThinkingResponse&lt;ComplexAnalysis&gt; = runner<br>        .thinking()<br>        .createObject(query, ComplexAnalysis::class.java)<br>    // result is null and exception is set if parsing failed - thinking blocks still present:<br>    if (response.result != null) {<br>        process(response.result)<br>    } else {<br>        log.warn(&quot;Failed: ${response.exception?.message}&quot;)<br>    }<br>    // Multiple blocks extracted and ordered by position; filter by type or tag name:<br>    response.getThinkingByType(ThinkingTagType.TAG)    // &lt;think&gt;, &lt;analysis&gt;, dynamic tags<br>    response.getThinkingByTag(&quot;analysis&quot;)              // only &lt;analysis&gt; blocks<br>    response.thinkingBlocks.forEach { block -&gt;<br>        // block.tagType  - TAG | PREFIX | NO_PREFIX<br>        // block.tagValue - &quot;think&quot; / &quot;analysis&quot; / &quot;THINKING&quot; / &quot;plan&quot; (dynamic) / &quot;&quot;<br>        // block.content  - reasoning text with all markup stripped<br>        log.info(&quot;[${block.tagType}/${block.tagValue}] ${block.content.take(200)}&quot;)<br>    }<br>    // Explicit token budget (controls reasoning depth):<br>    context.promptRunner()<br>        .withLlm(LlmOptions().withThinking(Thinking.withTokenBudget(8000)))<br>        .thinking()<br>        .createObject(query, ComplexAnalysis::class.java)<br>}</pre><h3>6. Tool Call Inspector</h3><pre>// Observe individual tool calls before and after execution:<br>class AuditInspector : ToolCallInspector {<br>    override fun beforeToolCall(context: BeforeToolCallContext) {<br>        log.info(&quot;-&gt; ${context.toolCall.name}: ${context.toolCall.arguments}&quot;)<br>    }<br>    override fun afterToolCall(context: AfterToolCallContext) {<br>        log.info(&quot;&lt;- ${context.toolCall.name} (${context.durationMs}ms): ${context.result}&quot;)<br>    }<br>}<br>// Works in both streaming and non-streaming:<br>context.promptRunner()<br>    .withToolCallInspectors(AuditInspector())<br>    .createObject(prompt, Result::class.java)</pre><h3>7. Thinking Across Tool Loop</h3><pre>// Thinking blocks from ALL tool loop iterations are aggregated into the final response:<br>val runner = context.promptRunner()<br>if (runner.supportsThinking()) {<br>    val response: ThinkingResponse&lt;Analysis&gt; = runner<br>        .thinking()<br>        .createObject(query, Analysis::class.java)<br>    // thinkingBlocks contains reasoning from every LLM call in the loop, in order:<br>    response.thinkingBlocks.forEach { block -&gt;<br>        log.info(&quot;${block.tagType}: ${block.content.take(200)}&quot;)<br>    }<br>}</pre><h3>8. Tool Loop Callbacks</h3><pre>// Read-only inspector - observe without modifying:<br>class MetricsInspector : ToolLoopInspector {<br>    override fun beforeLlmCall(context: BeforeLlmCallContext) {<br>        log.info(&quot;Round ${context.iteration}, history=${context.history.size}&quot;)<br>    }<br>    override fun afterLlmCall(context: AfterLlmCallContext) {<br>        context.usage?.let { metrics.record(it.promptTokens, it.completionTokens) }<br>    }<br>    override fun afterToolResult(context: AfterToolResultContext) {<br>        log.debug(&quot;Tool ${context.toolCall.name}: ${context.resultAsString.length} chars&quot;)<br>    }<br>    override fun afterIteration(context: AfterIterationContext) {<br>        if (budgetExceeded()) throw TerminateActionException(&quot;Budget exceeded&quot;)<br>    }<br>}<br>// Mutating transformer - e.g. sliding window to bound context size:<br>val transformer = SlidingWindowTransformer(maxMessages = 20)<br>// Attach to PromptRunner:<br>context.promptRunner()<br>    .withToolLoopInspectors(MetricsInspector())<br>    .withToolLoopTransformers(transformer)<br>    .withToolCallInspectors(ToolCallLoggingInspector())<br>    .createObject(prompt, Result::class.java)</pre><h3>9. Parallel Tool Loop</h3><pre># Enable concurrent execution of independent tool calls within a single LLM turn:<br>embabel:<br>  agent:<br>    platform:<br>      toolloop:<br>        type: parallel<br>        parallel:<br>          per-tool-timeout: 30s<br>          batch-timeout: 60s</pre><h3>10. Progressive / Folding Tools</h3><pre>// Annotation-based (auto-detects categories from @LlmTool.category):<br>@UnfoldingTools(<br>    name = &quot;db_ops&quot;,<br>    description = &quot;Database operations. Invoke to see specific tools.&quot;,<br>    childToolUsageNotes = &quot;Use query for reads, insert for writes.&quot;<br>)<br>class DatabaseTools {<br>    @LlmTool(description = &quot;Execute a SQL query&quot;, category = &quot;read&quot;)<br>    fun query(sql: String): QueryResult { ... }<br>    @LlmTool(description = &quot;Insert a record&quot;, category = &quot;write&quot;)<br>    fun insert(table: String, data: String): InsertResult { ... }<br>}<br>val tool = UnfoldingTool.fromInstance(DatabaseTools())<br>context.promptRunner()<br>    .withTool(tool)<br>    .createObject(query, Output::class.java)<br>// Programmatic category-based:<br>val fileTool = UnfoldingTool.byCategory(<br>    name = &quot;file_ops&quot;,<br>    description = &quot;File operations.&quot;,<br>    toolsByCategory = mapOf(<br>        &quot;read&quot;  to listOf(readFileTool, listDirTool),<br>        &quot;write&quot; to listOf(writeFileTool, deleteTool),<br>    )<br>)</pre><h3>11. ToolishRag</h3><pre>// Expose a knowledge base as typed search tools for explicit LLM-driven retrieval:<br>val rag = ToolishRag(<br>    name = &quot;knowledge-search&quot;,<br>    description = &quot;Search the knowledge base for relevant information&quot;,<br>    searchOperations = knowledgeBase,<br>)<br>val answer = context.promptRunner()<br>    .withReference(rag)<br>    .createObject(question.text, Answer::class.java)</pre><h3>12. Guardrails</h3><pre>// Pre-call input validation and post-call response checks; CRITICAL severity blocks the call:<br>class PiiGuardRail : UserInputGuardRail {        // runs before LLM call<br>    override val name = &quot;PiiCheck&quot;<br>    override val description = &quot;Blocks PII&quot;<br>    override fun validate(input: String, blackboard: Blackboard) = ...<br>}<br>class ToxicityGuardRail : AssistantMessageGuardRail {  // runs after LLM call<br>    override val name = &quot;ToxicityCheck&quot;<br>    override val description = &quot;Checks response&quot;<br>    override fun validate(input: String, blackboard: Blackboard) = ...<br>}<br>// Attach per-call:<br>context.promptRunner()<br>    .withGuardRails(PiiGuardRail(), ToxicityGuardRail())<br>    .createObject(prompt, Response::class.java)<br>// Or register globally as a Spring bean:<br>@Component<br>class GlobalPiiGuardRail : UserInputGuardRail { ... }</pre><h3>13. Termination APIs</h3><pre>// Immediate - stops tool loop now:<br>throw TerminateAgentException(&quot;Budget exhausted&quot;)     // entire agent -&gt; TERMINATED<br>throw TerminateActionException(&quot;Skipping this step&quot;)  // current action only; planner replans<br>// Graceful - stops at next checkpoint:<br>processContext.terminateAgent(&quot;Shutdown requested&quot;)<br>processContext.terminateAction(&quot;Skip at next opportunity&quot;)<br>// Catch either immediate variant:<br>catch (e: TerminationException) { log.info(e.reason) }</pre><h3>14. Observability &amp; Budget Enforcement</h3><pre>// Per-call cost observation:<br>@Component<br>class CostTracker : AgenticEventListener {<br>    override fun onProcessEvent(event: AgentProcessEvent) {<br>        if (event is LlmInvocationEvent) {<br>            metrics.record(event.invocation.cost(), event.invocation.usage)<br>            val cumulativeCost = event.agentProcess.cost()<br>            if (cumulativeCost &gt; alertThreshold) notify()<br>        }<br>    }<br>}<br>// Budget enforcement - set on ProcessOptions at run time:<br>agentPlatform.runAgentWithInput(<br>    goal = goal,<br>    input = input,<br>    processOptions = ProcessOptions.DEFAULT.withBudget(<br>        Budget(cost = 0.10, actions = 20)<br>    )<br>)</pre><h3>15. Token Budget Estimates</h3><pre>// Estimate token count before the call; enforce as a guardrail or per-round inspector:<br>val estimator = TokenCountEstimator.heuristic()<br>val estimated: Int = estimator.estimate(prompt)<br>if (estimated &gt; tokenBudget)<br>    throw TerminateActionException(&quot;Prompt exceeds token budget ($estimated tokens)&quot;)<br>// Declarative guardrail:<br>context.promptRunner()<br>    .withGuardRails(TokenBudgetGuardRail(estimator, maxTokens = 8000))<br>    .createObject(largeInput, Result::class.java)<br>// Per-round estimate inside ToolLoopInspector:<br>class BudgetInspector : ToolLoopInspector {<br>    override fun beforeLlmCall(ctx: BeforeLlmCallContext) {<br>        ctx.tokenEstimate?.let {<br>            if (it &gt; maxTokens) throw TerminateActionException(&quot;Token budget exceeded: $it&quot;)<br>        }<br>    }<br>}</pre><h3>16. LlmMessageSender / LlmMessageStreamer</h3><pre>// Non-streaming: Embabel owns the tool loop; called once per iteration<br>@Component<br>class MyLlmSender : LlmMessageSender {<br>    override fun call(messages: List&lt;Message&gt;, tools: List&lt;Tool&gt;): LlmMessageResponse {<br>        val raw = myClient.complete(messages, tools)<br>        return LlmMessageResponse(<br>            message = raw.toMessage(),<br>            textContent = raw.text,<br>            usage = Usage(raw.inputTokens, raw.outputTokens),<br>        )<br>    }<br>}<br>// Streaming: framework manages tool execution; only observation possible<br>@Component<br>class MyLlmStreamer : LlmMessageStreamer {<br>    override fun stream(<br>        messages: List&lt;Message&gt;,<br>        tools: List&lt;Tool&gt;,<br>        toolCallInspectors: List&lt;ToolCallInspector&gt;,<br>    ): Flux&lt;String&gt; = myClient.stream(messages, tools)<br>}</pre><h3>17. Native Thinking</h3><pre>// Single API - framework translates to provider-native payloads:<br>// Anthropic -&gt; ThinkingConfig(ThinkingType.ENABLED, budget)<br>// Google GenAI -&gt; includeThoughts + thinkingBudget<br>val runner = context.promptRunner()<br>    .withLlm(LlmOptions().withThinking(Thinking.withTokenBudget(8000)))<br>if (runner.supportsThinking()) {<br>    val response: ThinkingResponse&lt;Analysis&gt; = runner<br>        .thinking()<br>        .createObject(query, Analysis::class.java)<br>    response.thinkingBlocks.forEach { block -&gt;<br>        log.info(&quot;[${block.tagType}/${block.tagValue}] ${block.content.take(200)}&quot;)<br>    }<br>}</pre><h3>18. BYOK</h3><pre>// Sign-up flow - provider unknown, race all candidates concurrently:<br>val service: LlmService&lt;*&gt; = detectProvider(<br>    AnthropicModelFactory(apiKey = userKey),<br>    OpenAiCompatibleModelFactory.openAi(userKey),<br>    OpenAiCompatibleModelFactory.deepSeek(userKey),<br>    OpenAiCompatibleModelFactory.mistral(userKey),<br>    OpenAiCompatibleModelFactory.gemini(userKey),<br>)<br>val detectedProvider = service.provider<br>// Settings flow - provider already known:<br>val service = detectProvider(AnthropicModelFactory(apiKey = userKey))<br>// Custom OpenAI-compatible provider:<br>val service = detectProvider(<br>    OpenAiCompatibleModelFactory.byok(<br>        baseUrl = &quot;https://api.myprovider.com&quot;,<br>        apiKey = userKey,<br>        validationModel = &quot;my-model-small&quot;,<br>        validationProvider = &quot;MyProvider&quot;,<br>    )<br>)</pre><h3>19. Skills Engine</h3><pre>// Load from local directory tree (each subdirectory is one skill):<br>val skills = Skills(name = &quot;my-skills&quot;, description = &quot;Available skills&quot;)<br>    .withLocalSkills(&quot;/path/to/skills&quot;)<br>// Load from GitHub:<br>val skills = Skills(name = &quot;my-skills&quot;, description = &quot;Available skills&quot;)<br>    .withGitHubSkills(owner = &quot;acme&quot;, repo = &quot;agent-skills&quot;, skillsPath = &quot;skills&quot;)<br>// Expose as individual per-skill tools (recommended - one tool per skill, lazy body):<br>context.promptRunner()<br>    .withReferences(skills.asIndividualReferences())<br>    .createObject(request, Response::class.java)<br>// Filter out skills that bundle scripts (e.g. in a sandboxed environment):<br>val readOnly = skills.withFilter(SkillFilter.WITHOUT_SCRIPTS)<br>// Attach a script execution engine so scripts become callable tools:<br>val skills = Skills(name = &quot;my-skills&quot;, description = &quot;Available skills&quot;)<br>    .withLocalSkills(&quot;/path/to/skills&quot;)<br>    .withScriptExecutionEngine(NodeJsExecutionEngine())</pre><h3>20. Secure MCP</h3><pre>// Method-level - fine-grained authority per action:<br>@SecureAgentTool(&quot;hasAuthority(&#39;payments:write&#39;)&quot;)<br>@AchievesGoal(description = &quot;Process a payment&quot;)<br>@Action<br>fun processPayment(request: PaymentRequest, context: OperationContext): PaymentResult { ... }<br>// Class-level - all actions in the agent share the same guard:<br>@SecureAgentTool(&quot;hasRole(&#39;ADMIN&#39;)&quot;)<br>@Agent(description = &quot;Admin operations&quot;)<br>class AdminAgent {<br>    @Action<br>    fun purgeCache(): CacheResult { ... }<br>}<br>// Policy bean expression with method parameter binding:<br>@SecureAgentTool(&quot;@paymentPolicy.canProcess(authentication, #request)&quot;)<br>@Action<br>fun submitTransfer(request: TransferRequest, context: OperationContext): TransferResult { ... }</pre><pre># Activate JWT validation by enabling the secured Spring profile:<br>spring:<br>  profiles:<br>    active: secured<br>  security:<br>    oauth2:<br>      resourceserver:<br>        jwt:<br>          issuer-uri: https://your-auth-server/.well-known/openid-configuration</pre><h3>21. Chat Store Support</h3><pre>// Inject the provider; select storage type per chatbot:<br>@Component<br>class MyChatbot(private val provider: ConversationFactoryProvider) {<br>    // In-memory: fast, ephemeral (no persistence across restarts):<br>    fun ephemeralSession(id: String): Conversation =<br>        provider.getFactory(ConversationStoreType.IN_MEMORY).create(id)<br>    // Stored: persists across restarts, reloads by ID:<br>    fun persistentSession(id: String, user: User, agent: User): Conversation {<br>        val factory = provider.getFactory(ConversationStoreType.STORED)<br>        return factory.load(id) ?: factory.createForParticipants(id, user, agent)<br>    }<br>}</pre><pre>&lt;!-- Add embabel-chat-store to pom.xml for persistent storage: --&gt;<br>&lt;dependency&gt;<br>    &lt;groupId&gt;com.embabel&lt;/groupId&gt;<br>    &lt;artifactId&gt;embabel-chat-store&lt;/artifactId&gt;<br>&lt;/dependency&gt;</pre><h3>22. ONNX Embedding Models</h3><pre># Model downloaded from HuggingFace and cached at ~/.embabel/models on first use:<br>embabel:<br>  agent:<br>    platform:<br>      models:<br>        onnx:<br>          embeddings:<br>            enabled: true<br>            model-name: all-MiniLM-L6-v2<br>            dimensions: 384<br>            cache-dir: ${user.home}/.embabel/models<br>            model-uri: https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx<br>            tokenizer-uri: https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json</pre><pre># For air-gapped or pre-downloaded models:<br>embabel:<br>  agent:<br>    platform:<br>      models:<br>        onnx:<br>          embeddings:<br>            model-uri: file:///opt/models/all-MiniLM-L6-v2/model.onnx<br>            tokenizer-uri: file:///opt/models/all-MiniLM-L6-v2/tokenizer.json</pre><p><em>The Embabel Agent Framework is open source. Check out the </em><a href="https://github.com/embabel/embabel-agent"><em>GitHub repository</em></a><em> for the implementation.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=332f375d0fba" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/embabel-agentic-ai-framework-release-1-0-0-new-features-332f375d0fba">Embabel Agentic AI Framework Release 1.0.0: New Features</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Tool Loop: Where Agentic AI Actually Happens]]></title>
            <link>https://medium.com/embabel/the-tool-loop-where-agentic-ai-actually-happens-e4127908209e?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/e4127908209e</guid>
            <category><![CDATA[tool-loop]]></category>
            <category><![CDATA[transformer-inspector]]></category>
            <category><![CDATA[generative-ai-tools]]></category>
            <category><![CDATA[embabel]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Igor Dayen]]></dc:creator>
            <pubDate>Thu, 23 Apr 2026 04:50:54 GMT</pubDate>
            <atom:updated>2026-04-23T04:50:53.322Z</atom:updated>
            <content:encoded><![CDATA[<h3>Acknowledgements</h3><p><em>This blog is co-authored with </em><a href="https://medium.com/@springrod"><em>Rod Johnson</em></a><em>, founder of Spring Framework and Embabel Agentic AI Framework.</em></p><p><em>Authors would like to express deep gratitude to Embabel Core Team for thorough content review.</em></p><p>Tool calling is critical part of agentic AI applications. We identify useful capabilities — for example, Java methods or MCP server operations — and expose them as tools to the LLM.</p><p>In fact, as LLMs are stateless, there is a tool calling dance underneath performed by whatever framework we use. The LLM says “I want to call this tool with these arguments”. The framework executes the tool, gets the result, adds it to the conversation history, and calls the LLM again. This loop continues until the LLM produces a final answer instead of more tool calls.</p><p>This innocent-looking loop is where the magic happens. It’s also where things can get messy in production.</p><p>What happens when:</p><ul><li>Your tool returns 50KB of JSON and you’re burning through your context window?</li><li>You need to log every tool call for debugging a production issue at 2 AM?</li><li>You want to run three independent API calls in parallel instead of waiting 9 seconds sequentially?</li></ul><blockquote><em>The tool loop is at the heart of agentic AI. Yet most frameworks treat it as an implementation detail, giving you little control over what happens inside.</em></blockquote><h3>What’s Actually Inside the Loop?</h3><p>Let’s break Tool Loop Iteration down:</p><ol><li>Prepare messages for LLM</li></ol><p>2. Call LLM</p><p>3. Process response</p><p>4. For each tool call</p><p>4.1. Execute tool → Get results</p><p>4.2 Add results to history</p><p>5. Tool calls remaining?</p><p>5.1. Yes, Loop back to 1</p><p>5.2. No, Final answer → Exit</p><p>Each step is an opportunity to observe or transform. However, most frameworks provide only limited support — often little more than basic logging hooks.</p><h3>Tool Loop JVM Landscape Today</h3><p>Consider the two most popular JVM frameworks:</p><p><strong>Spring AI</strong> recently added a ToolCallAdvisor with recursive advisors. But advisors are a single type that can both observe <em>and</em> modify - you never know what a given advisor does without reading its code. And although, being familiar from Spring AOP, the &quot;advisor&quot; mechanism is a questionable conceptual fit for this problem. Please refer to Jettro Coenradie&#39;s blog <a href="https://medium.com/@jettro.coenradie/reliable-ai-with-spring-ai-guardrails-observability-and-evaluations-f1fa08e67a4b">Reliable AI with Spring AI: Guardrails, Observability, and Evaluations</a>, which illustrates both the usage and limitations of the Spring AI advisory API.</p><p><strong>LangChain4j</strong> has tool callbacks in streaming mode such as onPartialToolCall, onCompleteToolCall and onToolExecuted(), and even parallel execution with executeToolsConcurrently(). However, framework does not provide with out-of-box <strong>transformers</strong>, which can unlock valuable possibilities for conversational history transformation. Below is a sample LangChain4j-based Transformer:</p><pre>class TransformingChatModel implements ChatModel {<br><br>    private final ChatModel delegate;<br>    private final ResponseTransformer transformer;<br><br>    TransformingChatModel(ChatModel delegate,<br>                          ResponseTransformer transformer) {<br>        this.delegate = delegate;<br>        this.transformer = transformer;<br>    }<br><br>    @Override<br>    public Response&lt;AiMessage&gt; generate(ChatRequest request) {<br><br>        Response&lt;AiMessage&gt; response = delegate.generate(request);<br><br>        String original = response.content().text();<br><br>        String transformed = transformer.transform(original);<br><br>        return Response.from(AiMessage.from(transformed));<br>    }<br>}</pre><h3>A Different Approach: Embabel’s Tool Loop</h3><p>Embabel manages its own tool loop. When you configure a prompt runner (see Arjen Poutsma’s blog <a href="https://medium.com/embabel/recent-improvements-to-embabels-promptrunner-b1bb07190405">Recent Improvements to Embabel’s PromptRunner</a>) to run with tools, you get a fully managed loop that handles LLM calls, tool execution, and history management. This tool loop is highly customizable, offering clear extension points with a separation between observation (inspectors) and transformation (transformers).</p><p><strong>Inspectors</strong> observe the loop without modifying it. Implement any or all of beforeLlmCall, afterLlmCall, afterToolResult, afterIteration. Inspectors are perfect for logging, metrics, debugging, and are read-only by design.</p><p><strong>Transformers</strong> <em>modify</em> data flowing through the loop. Use them to truncate large tool results, apply sliding window to conversation history, redact sensitive content. They change what the LLM sees.</p><p>One observes, one transforms. There’s no ambiguity about what a callback does.</p><pre>| Type        | Can observe | Can modify | Safe to add many |<br>|-------------|:-----------:|:----------:|:----------------:|<br>| Inspector   |      ✓      |     ✗      |        ✓         |<br>| Transformer |      ✓      |     ✓      |  Order matters   |</pre><p>Here are the two interfaces:</p><pre>public interface ToolLoopInspector {<br>    default void beforeLlmCall(BeforeLlmCallContext context) {<br>    }<br><br>    default void afterLlmCall(AfterLlmCallContext context) {<br>    }<br><br>    default void afterToolResult(AfterToolResultContext context) {<br>    }<br><br>    default void afterIteration(AfterIterationContext context) {<br>    }<br>}<br><br>public interface ToolLoopTransformer {<br>    List&lt;Message&gt; transformBeforeLlmCall(BeforeLlmCallContext context);<br><br>    String transformAfterToolResult(AfterToolResultContext context);<br>    // ...<br>}</pre><h3>Putting It Together</h3><p>Here’s the complete picture — tools, inspectors, and transformers working together, via Embabel’s elegant fluent API:</p><pre>// Execute with tools and callbacks<br>var result = ai.withDefaultLlm()<br>                .withTools(tools)<br>                .withToolLoopInspectors(callbackTracker, loggingInspector)<br>                .withToolLoopTransformers(truncatingTransformer, slidingWindowTransformer)<br>                .creating(RestaurantRecommendation.class)<br>                .fromPrompt(&quot;&quot;&quot;<br>                        I&#39;m looking for an Italian restaurant near the Upper East Side in NYC.<br>                        <br>                        You have access to these tools to fetch restaurant menus:<br>                        %s<br>                        <br>                        Please fetch the menus and recommend the best restaurant for a romantic dinner.<br>                        &quot;&quot;&quot;.formatted(String.join(&quot;, &quot;, toolNames)));</pre><p>Tools are executed in a managed loop. Inspectors observe every call. Transformers shape the data, while the developer can focus on business logic. Framework provides with simple out-of-box callbacks:</p><ul><li>ToolLoopLoggingInspector — logs calls details before and after LLM invocations, after Tool Execution, and after Tool Loop Iteration</li><li>ToolResultTruncatingTransformer — truncates tool call results</li><li>SlidingWindowTransformer — maintains a sliding window of messages to manage context size, while preserving conversation context system messages</li></ul><h4>Practical Example: Truncating Giant Results</h4><p>Suppose that your web scraping tool returns 30KB of HTML. Your LLM doesn’t need all of it. Here’s a transformer to help with that:</p><pre>var truncator = new ToolResultTruncatingTransformer(5000);</pre><p>Every tool result over 5KB is truncated, without scattered if statements in your tool code. (Of course you could apply different strategies for truncation.)</p><h3>Practical Example: LLM and Tools Invocations Counter</h3><p>Inspector below maintains counters of LLM and Tools invocations, and also maintains the list of invoked tool names.</p><pre> // Custom inspector that tracks callback invocations<br>    <br>    static class CallbackTracker implements ToolLoopInspector {<br>        final AtomicInteger beforeLlmCallCount = new AtomicInteger();<br>        final AtomicInteger afterLlmCallCount = new AtomicInteger();<br>        final AtomicInteger afterToolResultCount = new AtomicInteger();<br>        final List&lt;String&gt; toolsInvoked = new ArrayList&lt;&gt;();<br><br>        protected final Logger logger = LoggerFactory.getLogger(getClass());<br><br>        @Override<br>        public void beforeLlmCall(@NotNull BeforeLlmCallContext context) {<br>            beforeLlmCallCount.incrementAndGet();<br>            var threadName = Thread.currentThread().getName();<br>            logger.info(&quot;Before LLM Call Thread {}&quot;, threadName);<br>        }<br><br>        @Override<br>        public void afterLlmCall(@NotNull AfterLlmCallContext context) {<br>            afterLlmCallCount.incrementAndGet();<br>        }<br><br>        @Override<br>        public void afterToolResult(@NotNull AfterToolResultContext context) {<br>            afterToolResultCount.incrementAndGet();<br>            synchronized (toolsInvoked) {<br>                toolsInvoked.add(context.getToolCall().getName());<br>            }<br>        }<br>    }</pre><p>Tracking calls Inspector got designed for parallel excution environment, which can be configured via:</p><pre>embabel.agent.platform.toolloop.type=parallel</pre><h3>Benefits of the Embabel Tool Loop</h3><p>Embabel’s control of the tool loop not only lets you inspect and modify the tool calling history: It allows features such as unfolding tools, introducing progressive disclosure to tools and allowing a larger number of tools to be exposed than in other frameworks. See <a href="https://medium.com/@springrod/the-power-of-russian-dolls-rethinking-tool-discovery-1e79eba1543a">Rod Johnson’s blog on Unfolding Tools</a>.</p><p>The tool loop is agnostic to the LLM framework you use. You can use your choice of lower level frameworks, such as Spring AI or LangChain4j. This would be a subject of a separate blog, though.</p><h3>When Would You Use This?</h3><p><strong>Production observability</strong>: Log every LLM call and tool execution. Track token usage. Debug issues without adding println statements everywhere.</p><p><strong>Context management</strong>: Compress old messages. Truncate verbose tool results. Stay within token limits without manual intervention.</p><p><strong>Performance</strong>: Run independent tools in parallel. Set timeouts so one slow API doesn’t block everything.</p><p><strong>Migration</strong>: Moving from Spring AI to LangChain4j? Change one adapter class, keep everything else.</p><h3>Try It</h3><p>Working examples can be found in <a href="https://github.com/embabel/embabel-agent-experimental/tree/main/embabel-experimental-integration-tests">embabel-agent-experimental repository</a>:</p><ul><li>Basic Default tool loop with logging inspector, and history transformation</li><li>Parallel tool loop with the same (mentioned above) callbacks</li><li>LangChain4j and OpenAI integrations showing framework independence</li></ul><p>For the full tool programming model, see <a href="https://medium.com/@springrod/domain-tools-direct-access-zero-ceremony-9a3e8d4cf550">Rod Johnson’s blog Domain Tools: Direct Access, Zero Ceremony</a>.</p><h3>The Bigger Picture</h3><p>The tool loop is where your agent actually <em>does things</em>. It deserves first-class treatment:</p><ul><li>Clear extension points, not mysterious internals</li><li>Separation between observation and transformation</li><li>Parallel execution that handles real-world complexity</li><li>Independence from any specific LLM</li></ul><p>We believe that Embabel provides the clearest, most powerful abstractions to empower you to control tool calling without writing error-prone low level code.</p><p>Please join Embabel community on <a href="https://discord.com/invite/t6bjkyj93q">Discord</a> for updates, contributions are welcome.</p><p><em>The Embabel Agent Framework is open source. Check out the </em><a href="https://github.com/embabel/embabel-agent"><em>GitHub repository</em></a><em> for the implementation.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e4127908209e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/the-tool-loop-where-agentic-ai-actually-happens-e4127908209e">The Tool Loop: Where Agentic AI Actually Happens</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[BYOK: Bring Your Own Key]]></title>
            <link>https://medium.com/embabel/byok-bring-your-own-key-656102da39e2?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/656102da39e2</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[embabel]]></category>
            <category><![CDATA[agentic-ai]]></category>
            <dc:creator><![CDATA[Jasper Blues]]></dc:creator>
            <pubDate>Wed, 15 Apr 2026 01:38:48 GMT</pubDate>
            <atom:updated>2026-04-15T01:38:48.684Z</atom:updated>
            <content:encoded><![CDATA[<h3>Or How Embabel Just Saved Your Startup $760M in Runway Costs</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*GrOjAg8hWnv2XqT8WX0y0A.png" /><figcaption>A pop star who looks suspiciously like Björk</figcaption></figure><h3>RAGE — Australia’s MTV</h3><p>I grew up in Wollongong, Australia from the mid 70s through 90s. It is a small city, wedged between the ocean and the mountains, about 100 KM south of Sydney.</p><p>Sometimes there wasn’t a lot to do. People made their own fun. To this day I love letting my mind wander and seeing where it takes me.</p><p>Saturday mornings in the hours before sunrise we had culture. It worked its way in between the black-and-white snow on the analog TV, via a TV show called RAGE — like Australia’s MTV. This early morning slot was the weird stuff. Metal. Industrial.</p><p>One morning I missed it. The (usually boring) charts had already started, counting down from 50 to number 1. But somewhere right at the back, something slipped through.</p><p>Björk.</p><p>Barefoot. Unfiltered. It felt like it had leaked in. I remember thinking: this is going to be huge.</p><p>A week later, it was number one.</p><p>Today we’re going to talk about an <a href="https://github.com/embabel/embabel-agent">Embabel</a> feature inspired by Björk: <strong>BYOK — Bring Your Own Key. </strong>It solves a real problem.</p><h3>The Problem</h3><p>For decades, the world’s production systems have quietly run on Java. That’s one reason Embabel exists: it brings AI to where the software already is, instead of forcing teams to rebuild everything just to get close to the models.</p><p>But Embabel isn’t just about revitalizing legacy systems.</p><blockquote>There are strong reasons to build new, AI-native apps on the JVM too.</blockquote><p>And that’s where BYOK comes in.</p><p>Let’s say you have a great idea. You execute well. You’re selling directly to consumers.</p><p>For most of the internet era, distribution was the cheat code. Build something people love and it spreads. People tell people.</p><h3>The Math Behind Virality</h3><p>The maths is deceptively simple: if each user brings in just 1.1 new users on average, you have viral growth:</p><pre>Round 1:   1 user<br>Round 10:  1.1¹⁰  ≈  2.6 users<br>Round 20:  1.1²⁰  ≈  6.7 users<br>Round 50:  1.1⁵⁰  ≈  117 users<br>Round 100: 1.1¹⁰⁰ ≈  13,780 users<br>Round 200: 1.1²⁰⁰ ≈  190,000,000 users</pre><p>One tenth of one extra person. That’s it. Just one in ten people convincing someone else to try it.</p><h3>Now introduce AI</h3><p>Suddenly, usage is billed in tokens.</p><p>Every new user isn’t just a potential customer — they’re a cost center.</p><blockquote>If you’re paying for their trial, customer acquisition is no longer cheap.</blockquote><p>And you still have the original problem too: how do you convince someone to try your product before they’ve seen it work?</p><p>People will invest a bit of time if the upside is there.<br>They’re far less willing to hand over a credit card.</p><p>Not before they trust you.<br>Often, not before they’ve cleared it with their spouse or boss.<br>And definitely not if cancelling might be painful.</p><blockquote>BYOK changes the equation. It separates <em>usage cost</em> from <em>product value</em>.</blockquote><p>If your customer already has an API key — from tinkering, from work, from other projects — they can use that to try your product. They pay for their own tokens. You pay almost nothing to acquire them.</p><p>If they don’t like it, they revoke the key. No refunds. No disputes. No awkward “what’s this charge?” conversations.</p><p>And when they do like it? That’s when you introduce the subscription.</p><h3>Let’s Put Some Rough Numbers On It</h3><p>Say your app is heavy — about $5/month in token usage per active user. You charge $10/month. Reasonable pricing.</p><p>By Round 200 you have 190 million users. (Yes, this is hypothetical — but the ratios are what matter.) Let’s say 10% convert:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xS5-F5ebA1gdxZShHYKFfw.png" /><figcaption>Customer Acquisition Costs</figcaption></figure><p>Without BYOK, you’re spending five dollars to acquire every dollar of revenue. Uh, yeah, not too sustainable.</p><blockquote>Usually getting to millions of active users is a “good problem to have”, but it definitely isn’t when you’ve dug yourself a $760M hole!</blockquote><p>You don’t want to be funding the trial usage of 190 million people.</p><p>With BYOK, you’re running at a 50% gross margin on paying customers — and your acquisition cost is effectively zero.</p><p>BYOK isn’t just a billing model.<br>It <em>is</em> the funnel.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*hsxaYAlz_jwrd811az5jNw.png" /><figcaption>A product funnel</figcaption></figure><p>Trial on their dime. Convert on yours.</p><p>And crucially, the viral loop survives because the cost of getting started is still effectively zero.</p><h3>How It Works</h3><p>In Embabel, LlmService is a key abstraction for working with LLMs.</p><p>You don’t talk to models directly. You talk through an LlmService, which handles model selection, retries, observation, and provider protocols.</p><p>Normally, you never think about this. Embabel ships Spring Boot starters for Anthropic, OpenAI, Mistral, DeepSeek and Gemini. Add one to your build, set your API key in environment variables, and an LlmService is auto-configured and ready to inject. It just works.</p><p>But there’s a catch: those starters are all wired to a single key — your key, set at deployment time. One application, one key per provider.</p><p>That’s fine when you’re absorbing the inference cost. It’s not fine when your users are bringing their own keys. You need an LlmService <em>per key</em> — validated, production-ready, and identical in behaviour to the auto-configured ones. That&#39;s exactly what BYOK provides.</p><p>At the core is a single interface:</p><pre>fun interface ByokFactory&lt;out T&gt; {<br>    fun buildValidated(): T<br>}</pre><p>For LLMs, T is LlmService&lt;*&gt; — but the type parameter is deliberate. The same race-and-validate pattern works for any future BYOK-validated service: image generation, embeddings, anything else where a user-supplied key needs a low-cost probe before you trust it. The plumbing doesn’t care what it is.</p><p>Each supported provider ships a factory:</p><pre>// Anthropic<br>val service = AnthropicModelFactory(apiKey = userKey).buildValidated()<br><br>// OpenAI<br>val service = OpenAiCompatibleModelFactory.openAi(userKey).buildValidated()<br>// DeepSeek, Mistral, Gemini - are all OpenAI compatible at API layer<br>val service = OpenAiCompatibleModelFactory.deepSeek(userKey).buildValidated()<br>val service = OpenAiCompatibleModelFactory.mistral(userKey).buildValidated()<br>val service = OpenAiCompatibleModelFactory.gemini(userKey).buildValidated()</pre><p>buildValidated() makes a single low-cost probe call to the provider. If the key is valid, you get back a production LlmService — the same thing the starters give you, but bound to the user&#39;s key instead of yours. If the key is invalid, you get an InvalidApiKeyException.</p><p>Plug the validated service straight into your prompt runner:</p><pre>promptRunner<br>    .withLlmService(service)<br>    .creating(MyOutput::class.java)<br>    .create(messages)</pre><p>The rest of your application doesn’t change.</p><h3>Auto-Detection</h3><p>People like quick starts — the user just pastes a key and doesn’t tell you which provider it’s from. For that, detectProvider() races all your candidates concurrently using virtual threads, and returns the first one that validates:</p><pre>try {<br>    val service = detectProvider(<br>        AnthropicModelFactory(apiKey = userKey),<br>        OpenAiCompatibleModelFactory.openAi(userKey),<br>        OpenAiCompatibleModelFactory.deepSeek(userKey),<br>        OpenAiCompatibleModelFactory.mistral(userKey),<br>        OpenAiCompatibleModelFactory.gemini(userKey),<br>    )<br>    val provider: String = service.provider<br>} catch (e: InvalidApiKeyException) {<br>    // invalid key<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*s9b6Cs0ibSxHXiIp5VTpFw.png" /><figcaption>“Quick Start” Automatic Key Type Detection</figcaption></figure><h3>Adding Your Own Provider</h3><p>If you’re running on an OpenAI-compatible provider that isn’t in the built-in list:</p><pre>fun OpenAiCompatibleModelFactory.Companion.acme(apiKey: String) =<br>    OpenAiCompatibleModelFactory.byok(<br>        baseUrl = &quot;https://api.acme.example.com/v1&quot;,<br>        apiKey = apiKey,<br>        validationModel = &quot;acme-small&quot;,<br>        validationProvider = &quot;Acme&quot;,<br>    )</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fpsCcN7uDSanuVvMJ1Fn3Q.png" /><figcaption>LLM Provider Settings Screen in Embabel Hub</figcaption></figure><h3>Security is Your Concern</h3><p>This is worth being explicit about. Embabel provides the validation and service construction. What you do with the key after that — how you store it, how you transmit it, how long you keep it — is your application’s responsibility.</p><p>The <a href="https://github.com/embabel/guide/">Guide</a> which is part of <a href="https://hub.docker.com/r/embabel/hub">Embabel Hub</a> shows one reasonable approach. The key principle: <strong>the server never persists the key</strong>.</p><p>After validation, the server encrypts the key with AES-256-GCM using a secret known only to the backend, and hands the encrypted blob back to the client. The client stores that blob on the user’s device. The plaintext key passes through server memory during the active session, but is <a href="https://github.com/embabel/guide/blob/main/src/main/kotlin/com/embabel/hub/integrations/KeyEncryptionService.kt">never persisted to disk or database</a>.</p><pre>// After validation, encrypt the key and send it back to the client<br>val encryptedBlob = keyEncryptionService.encrypt(apiKey)<br>return SetKeyResponse(encryptedKey = encryptedBlob)<br>// On reconnect, the client sends the blob; server decrypts it for the session<br>val restored = keyEncryptionService.decrypt(blob) ?: return ResponseEntity.status(410).build()</pre><p>Think about what this means from an attack surface perspective. There is no server-side key store to breach. An attacker would need to compromise individual user devices to get encrypted blobs — and those blobs are useless without the server’s secret.</p><p>If the server restarts with a new secret, existing client blobs become invalid. The user is simply prompted to re-enter their key. This limits the blast radius of a compromised server secret.</p><p>None of this is the only way to do it. The point is that BYOK doesn’t mandate a storage strategy — it just hands you a validated LlmService and gets out of the way.</p><p>And that’s the BYOK feature in a nutshell.</p><h3>Why This Matters</h3><p>The direction of AI is clear: more providers, more choice, more fragmentation.</p><p>Applications that lock users to a single provider, or absorb all inference costs will feel increasingly constrained.</p><p>The products that win will meet users where they are, with whatever key they already have. BYOK makes that straightforward.</p><p>One validated service. One clean exception. One detectProvider() call.</p><p>The rest of your application doesn’t change.</p><p>Björk went to number one in the 90s. Now <em>you</em> can go to number one with your Embabel-powered app</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=656102da39e2" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/byok-bring-your-own-key-656102da39e2">BYOK: Bring Your Own Key</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Faction Detection: Reading the Review Graph Before the Fork]]></title>
            <link>https://medium.com/embabel/faction-detection-reading-the-review-graph-before-the-fork-75e100898160?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/75e100898160</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[data-science]]></category>
            <category><![CDATA[embabel]]></category>
            <category><![CDATA[neo4j]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Jasper Blues]]></dc:creator>
            <pubDate>Tue, 31 Mar 2026 20:36:23 GMT</pubDate>
            <atom:updated>2026-03-31T20:36:23.252Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>Forks and crises in open source and enterprise projects rarely come out of nowhere. They just </em>look<em> that way.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*2t_N1cxn06UF67yN9vTEzA.png" /><figcaption>A Zachary Karate Club-inspired logo</figcaption></figure><p>Today we’re building a tool that can predict IMMINENT forks in open source and large enterprise projects, using nothing but pull request comment data. Magic? Nuh-uh, science baby.</p><h3>We’ll Start in a Karate Dojo</h3><p>In 1977, a maths post-grad named Wayne Zachary had an unusual research subject: a karate club at a university in the American Midwest. He wasn’t studying martial arts. He was mapping relationships — who trained with whom, who socialized outside class, who deferred to whom. Standard social network stuff. Except that is, that nobody had really done this before. It was one of the first formal studies of community structure in a social network.</p><h3>A Faction Forms</h3><p>Over two years, the club’s instructor and its president fell into a slow-building dispute over lesson fees and club direction. Members didn’t pick sides publicly. They just… drifted. Training partners shifted. Maybe Maryellen stopped meeting Albert for coffee after practice and started meeting James for — well, let’s call it <em>strategic alignment</em>. Social ties rearranged in ways that were invisible unless you were drawing the map. By the time the club officially split in two, Zachary had already predicted — <em>from the network structure alone</em> — which members would end up on which side. He got 33 out of 34 right.</p><p>That paper launched an entire subfield. “<a href="https://en.wikipedia.org/wiki/Zachary%27s_karate_club">Zachary’s Karate Club</a>” became the standard benchmark dataset for community detection algorithms. It became so famous that researchers still use it as a benchmark nearly fifty years later — the go-to dataset when you need to test whether an algorithm can find hidden groups in a network. Think of it as the “Hello, World!” of mathematics-based social network science.</p><p>The insight is simple and a little unsettling: the structure of who-talks-to-whom contains more information about an impending split than anything anyone actually says. The faction is already in the graph before anyone announces it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*ace8tkpZJdrbpXEKKunb1A.png" /></figure><h3>Wax on, fork off</h3><p>Let’s replace the dojo with GitHub repositories.</p><p>Instead of training partners, we have reviewers. Instead of socializing after class, we have PR threads. Instead of a dispute over lesson fees, we have a governance argument that nobody’s having in public. . . yet. But underneath, it’s the same network, and when tensions build, the graph shifts in exactly the ways Zachary would recognize.</p><p>Let’s see if we can use 1970s network science to predict future forks, governance crises and other notable schisms on GitHub projects.</p><h3>Back, to the Future</h3><p>To know if our model is any good at predicting the future, let’s put it on the job of <a href="https://en.wikipedia.org/wiki/Retrodiction">retrodicting</a> the past. For any given Github repository, we’ll give the tool a time-frame to ingest pull requests and factor the comments. If we set the time-frame just ahead of “<strong><em>the forkening</em></strong>” it should return FRACTURE_IMMINENT. Re-running to include the actual fork in the time-frame returns FRACTURE_ADVERSARIAL_FORK or one of the insightful variants.</p><h3>Code and Tools</h3><p><a href="https://github.com/jasperblues/faction-detector/">Faction Detector</a> is on GitHub with full setup instructions. You’ll need a GitHub token, an Anthropic API key, and a Neo4j instance. The first run against a repo is slow, damn slow, actually (it pages through every closed PR), but everything is cached — subsequent runs complete in seconds.</p><p>It is built with <a href="https://neo4j.com">Neo4j</a> the world’s leading graph database and <a href="https://github.com/embabel/embabel-agent">Embabel</a> — an open source framework for building agentic AI applications on the JVM, founded by <a href="https://medium.com/@springrod">Rod Johnson</a>, creator of <a href="https://spring.io/">Spring</a>. Embabel is where <a href="https://www.linkedin.com/in/jasper-blues-7781638/">I work</a>, too.</p><h3>Neo4j, Three Ways</h3><p>One of the reasons we love graphs and Neo4j at Embabel, is there are three ways you can use it. People talk about the blue pill or the red pill way. Not everyone realizes that there’s a third option . . .</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0XilAJTE2Y5llNJjUwCtUA.png" /><figcaption>Neo4j usage patterns</figcaption></figure><p>Here’s a Cypher query for each row — the first and third are real queries from the <a href="https://github.com/jasperblues/faction-detector/blob/main/src/main/kotlin/com/embabel/faction/graph/GdsService.kt">codebase</a>:</p><p><strong>Relational</strong> — persist a review edge and look up neighbours:</p><pre>MERGE (reviewer:Contributor {login: row.reviewer, runId: row.runId})<br>MERGE (author:Contributor {login: row.author, runId: row.runId})<br>MERGE (reviewer)-[r:REVIEWED {repo: row.repo, timestamp: row.timestamp, runId: row.runId}]-&gt;(author)<br>SET r.commentCount = row.commentCount, r.weight = row.weight</pre><p><strong>Ontological </strong>— infer indirect faction alignment through review chains. Two contributors who never reviewed each other directly may still be connected through a shared intermediary — the graph can derive relationships that don’t exist explicitly:</p><pre>MATCH (a:Contributor {runId: $runId})-[:REVIEWED]-&gt;(bridge:Contributor)-[:REVIEWED]-&gt;(b:Contributor {runId: $runId})<br>WHERE a &lt;&gt; b AND NOT (a)-[:REVIEWED]-&gt;(b)<br>RETURN a.login AS aligned_with, b.login AS via_bridge, bridge.login AS bridge_figure</pre><p><strong>Structural</strong> — project a weighted graph into GDS and run Louvain community detection:</p><pre>CALL gds.graph.project.cypher(<br>  &#39;faction-graph&#39;,<br>  &#39;MATCH (n:CoreContributor) WHERE n.runId = $runId RETURN id(n) AS id&#39;,<br>  &#39;MATCH (s:CoreContributor)-[r:REVIEWED]-&gt;(t:CoreContributor) WHERE r.runId = $runId<br>   RETURN id(s) AS source, id(t) AS target,<br>          coalesce(r.factionSignal, r.anomalyScore, 1.0) AS weight&#39;<br>)<br><br>CALL gds.louvain.write(&#39;faction-graph&#39;, {<br>  writeProperty: &#39;communityId&#39;,<br>  relationshipWeightProperty: &#39;weight&#39;<br>})</pre><h3>Graph Data Science</h3><p>That third row — structural analysis (or the whole bottle) — is where <a href="https://neo4j.com/docs/graph-data-science/current/">Neo4j Graph Data Science</a> (GDS) comes in. GDS is a library that runs algorithms <em>on</em> the graph rather than <em>through</em> it. You don’t write a query that says “find me the path from A to B.” You say “tell me something about the shape of the entire network that I couldn’t see by looking at any individual node.” That’s the difference between asking “who reviewed this PR?” and asking “has this project quietly separated into two groups that no longer talk to each other?”</p><p>This is exactly what Zachary did with the karate club — he didn’t study what people said to each other, he studied the <em>shape</em> of who interacted with whom. GDS gives us the same lens on a code review graph. We’ll be primarily exploring two structural motifs: <strong>community detection</strong> (has the project split into factions?) and <strong>centrality</strong> (how much power or influence does a person have?).</p><blockquote>There are different kinds of community detection and centrality. For example, Google search results (page rank) use <a href="https://en.wikipedia.org/wiki/Eigenvector_centrality">Eigenvector Centrality</a>. Early facebook community detection used <a href="https://en.wikipedia.org/wiki/Label_propagation_algorithm">label propagation</a>. We’ll be using <a href="https://en.wikipedia.org/wiki/Louvain_method">Louvain</a> and simple degree centrality.</blockquote><h3>How it works</h3><p>Every PR review on GitHub is public data: who reviewed whose code, when, and what they said. Most tools treat this as activity metrics — review coverage, cycle time, PRs merged per week. <strong><em>Today we’re treating it as a social graph.</em></strong></p><h3>The review graph</h3><p>Every review event becomes a directed edge: reviewer → author. Review Bob&#39;s PR, you get an edge pointing at Bob. Do it ten times, that edge gets weight. The edge also carries metadata — how quickly the review arrived and how many inline comments.</p><p>Only <em>core contributors</em> make it into the graph. If you reviewed fewer than twice in the current window, you’re filtered out. Drive-bys and first-timers don’t define community structure — the regulars do.</p><h3>Asymmetry — the first signal</h3><p>In a healthy project, reviewing is roughly reciprocal. Alice reviews Bob. Bob reviews Alice. Not perfectly balanced — nobody’s keeping score — but the flow goes both ways.</p><p>When that breaks down, it means something. Alice is consistently reviewing Bob’s work but Bob never looks at Alice’s PRs. Multiply that across a dozen pairs and you have a graph where one group is doing all the reviewing and another group is receiving all the scrutiny. The reciprocity has collapsed.</p><p>We measure this as a ratio: of all directed <strong>reviewer→author</strong> pairs in a rolling 30-day window, what fraction have no reverse pair? Zero means perfectly reciprocal. One means completely one-directional — every reviewer has a distinct set of authors they review, and none of them review back.</p><p>The <a href="https://github.com/jasperblues/faction-detector/blob/main/src/main/kotlin/com/embabel/faction/agent/AsymmetryScorer.kt">implementation</a> is almost disappointingly simple:</p><pre>val pairs = edges.map { it.reviewer to it.author }.toSet()<br>val asymmetric = pairs.count { (r, a) -&gt; (a to r) !in pairs }<br>val asymmetryRatio = if (pairs.isEmpty()) 0.0 <br>  else asymmetric.toDouble() / pairs.size</pre><p>Three lines. <strong><em>That’s the core of the early warning system.</em></strong></p><p>Roll that across weekly-stepped windows and you get a <a href="https://en.wikipedia.org/wiki/Time_series">time series</a>. A rising asymmetry trend is the early warning. Sustained asymmetry at 1.0 — as we’ll see with Redis — is the alarm.</p><p>This single metric, computed from nothing but the <em>direction</em> of review edges, does about 80% of the detection work. No NLP. No sentiment analysis. No AI (yet). Just: who reviewed whom, and did they review back? Zachary would recognise it instantly. It’s the same signal he found in the karate club — the structure of reciprocity tells you more about an impending split than the content of any conversation.</p><h3>Community detection — finding the factions</h3><p>Asymmetry tells you the graph is unbalanced. It doesn’t tell you <em>where the line is</em>. For that, you need community detection — an algorithm that looks at the full topology and says: “these nodes cluster together, and those nodes cluster together, and the two clusters barely talk.”</p><p>We’ll be using the <strong>Louvain algorithm</strong>, and there’s a reason it’s the default choice for this problem. Louvain optimises directly for <em>modularity</em> — the mathematical measure of how cleanly a network separates into communities. High modularity means the groups are dense internally and sparse between each other. That’s exactly the question we’re asking: has this project separated into factions that review within their group but not across the divide?</p><p>There are newer community detection algorithms — Leiden fixes some of Louvain’s edge cases around poorly-connected communities, and label propagation is faster on massive graphs. But Louvain has two properties that matter here. First, as said, it produces a modularity score as a direct output, which we use as a signal. When modularity hits 0.50 — the theoretical maximum for two equal communities — that’s not a number you need a PhD to interpret. It means the project has split in half and the halves aren’t speaking. Second, it’s battle-tested on exactly this class of problem.</p><p>The weighted review graph — edges scored by anomaly — goes into Neo4j GDS. Louvain assigns every contributor to a community. In a cohesive project you get one community, or several that cross-review freely. In a fractured project you get two or more communities with almost no edges between them. The community assignments tell you not just <em>that</em> the project has split, but <em>who is on which side</em>.</p><h3>Centrality — measuring “load-bearing capacity” of a contributor</h3><p>Not all departures are equal. Three TSC members leaving is not the same as fifteen drive-by contributors leaving. We need a way to measure how load-bearing each contributor is.</p><p>We use <strong>degree centrality</strong> — the simplest measure in the book. A contributor’s centrality score is the number of review interactions you participated in, whether as reviewer or author. That’s it.</p><blockquote>Graphs have different notions of centrality. PageRank, betweenness, eigenvector. We’re keeping it simple here and that suits our purposes, for now. However, arguably, Eigenvector centrality would be a very good fit here.</blockquote><p>When we detect an <a href="https://github.com/jasperblues/faction-detector/blob/main/src/main/kotlin/com/embabel/faction/agent/ExodusDetector.kt">exodus</a> — a step-change in contributor mass across a rolling window — we weight it by centrality. A 30% drop in raw headcount might be noise. A 30% drop in centrality-weighted mass, sustained across three subsequent windows, means the people who left were the ones doing serious work.</p><h3>We Haven’t Read A Single Message Yet</h3><p>As mentioned, we’ve already done 80% of the work, simply by looking at who talks to whom. We haven’t read the content of any messages. If we were an intelligence agency protecting the country, we’d already be most of the way to the finish line with nothing but call logs. We haven’t even asked for a wire-tap yet. Everything described so far — asymmetry, community detection, centrality, exodus detection — is pure graph structure.</p><p>You can detect that a fracture is forming, identify the factions, measure the severity, and name the departed contributors. Zachary didn’t need to know what Maryellen and Albert talked about over coffee. He just needed to know they stopped meeting.</p><p>But there’s a question the structure can’t answer: <em>what kind</em> of fracture is this?</p><h3>Time for some Wire-tapping</h3><p>A project where one faction is nitpicking the other into the ground — blocking PRs with trivial objections, requesting changes on style preferences, tone-policing commit messages — is a different animal from a project where the community is pushing back against a corporate steward’s direction. The first is adversarial. The second is an uprising. The structural signal looks similar in both cases: asymmetry rises, communities separate, reciprocity breaks down. But the intervention is completely different, and so is the likely outcome.</p><blockquote>That’s where we cross the 80% line and bring in an LLM. Time for some wire-tapping.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*zq7n0HL2xxm7HwiCzVH01w.png" /><figcaption>A movie-like montage where an intelligence agency is gathering intel by wire-tapping</figcaption></figure><h3>Pair anomaly scoring — finding the suspicious relationships</h3><p>Before we spend API tokens on comment analysis, we <a href="https://github.com/jasperblues/faction-detector/blob/main/src/main/kotlin/com/embabel/faction/agent/PairAnomalyScorer.kt">narrow the field</a>. For each <strong>reviewer→author</strong> pair with at least three interactions, we look at how often the reviewer comments on that author’s PRs relative to the project baseline, adjusted for code quality — if the author’s PRs consistently take twice as long to merge as everyone else’s, heavy commenting is more justified. The anomaly score is the gap that remains <em>after</em> that adjustment.</p><blockquote>GitHub does offer structured review states (APPROVED, CHANGES_REQUESTED) via its Reviews API, and using them would make this step more precise. <strong>We deliberately don&#39;t.</strong> First, the Reviews API was introduced in late 2016 — several of our retrodiction cases (io.js, LibreSSL) predate it, and we want the detector to work on the full historical record. Second, part of the thesis is s<strong>eeing how far we can get with less signal</strong>. Comments exist on every PR back to the beginning of GitHub. If comment frequency alone can surface the suspicious pairs, we don&#39;t need to lean on structured metadata that not every forge provides.</blockquote><h3>LLM Comment Scoring</h3><p>For the top anomalous pairs, we fetch the actual review comments from GitHub and ask an LLM to classify each one on two dimensions:</p><p><strong>Significance</strong>: Is this feedback trivial or important? NITPICKY (style preference, personal taste) → FAIR → VERY_FAIR → ESSENTIAL (security bug, data loss, breaking change).</p><p><strong>Blocking nature</strong>: BLOCKING (PR genuinely shouldn&#39;t merge without this fix) vs NON_BLOCKING (could merge now, fix later).</p><p>The faction fingerprint is <strong>NITPICKY + </strong><strong>NON_BLOCKING + high comment volume</strong>. A reviewer who consistently weighs in with objections that even an LLM classifies as trivial and non-critical is not doing code review. They&#39;re doing gatekeeping.</p><p>Combined with a sentiment score — hostile to appreciative — this gives us a <em>faction signal</em> per pair. Average that across the crisis period and compare it to the baseline period, and you can distinguish an adversarial fork (faction signal <em>rises</em> during crisis) from an uprising (faction signal <em>drops</em> — the community is pushing back with substantive objections, not petty ones).</p><p>The LLM step uses Claude Haiku for classification — fast and cheap, <strong><em>but not a genius</em></strong>. Haiku tends to classify ambiguous comments as FAIR rather than NITPICKY, which slightly suppresses faction signals on marginal cases. Everything comes out in the wash across enough pairs, but it does mean the <a href="https://github.com/jasperblues/faction-detector/blob/c35b6ada13911cd59cc219ccbe06fa0756d4767e/src/main/kotlin/com/embabel/faction/domain/Model.kt#L187">DetectorWeights</a> are tuned to Haiku&#39;s bias profile. Switch to Sonnet and maybe we&#39;d need to recalibrate.</p><blockquote>A key premise of Embabel is the ability to decompose AI into steps and use cheaper or specialized models, in place of one “god model”</blockquote><h3>Classification</h3><p>With asymmetry time series, community structure, exodus data, and faction signals in hand, the <a href="https://github.com/jasperblues/faction-detector/blob/main/src/main/kotlin/com/embabel/faction/agent/FractureDetector.kt">detector</a> applies a rule set:</p><ul><li>Find the <strong>peak cluster</strong>: the contiguous run of windows where asymmetry exceeds the tension threshold</li><li>Measure the <strong>rise</strong>: did the peak significantly exceed the pre-cluster baseline? This handles projects like Redis where structural asymmetry bakes in a higher resting level — what matters is how far the peak rose from <em>that</em> baseline</li><li>Check for <strong>resolution</strong>: did asymmetry drop back? Did an exodus follow?</li><li><strong>Classify</strong></li></ul><p>In code, the core of it is a single when expression:</p><pre>val pattern = when {<br>    !isResolved &amp;&amp; peakAsymmetry &gt;= 0.70 -&gt; FRACTURE_IMMINENT<br>    !isResolved                          -&gt; TENSION<br>    isResolved &amp;&amp; isSharpRise &amp;&amp; fractureType != null -&gt; fractureType<br>    isResolved &amp;&amp; isSharpRise            -&gt; GOVERNANCE_CRISIS<br>    isResolved &amp;&amp; isAttrition(exodus)    -&gt; ATTRITION<br>    isResolved                           -&gt; EXODUS<br>    else                                 -&gt; STABLE<br>}</pre><p>The full classification includes additional gates — minimum cluster sizes, faction signal thresholds, and <a href="https://github.com/jasperblues/faction-detector/blob/main/src/main/kotlin/com/embabel/faction/domain/Model.kt">tunable weights</a> — but the decision tree above is the skeleton.</p><p>The confidence score reflects certainty, but its composition varies by classification. For FRACTURE_ADVERSARIAL_FORK, peak asymmetry and exodus scale dominate — the LLM already confirmed the adversarial signal during classification. For FRACTURE_IMMINENT, peak asymmetry, faction formation balance, modularity, and faction signal all contribute. For ATTRITION, confidence reflects departure scale only — how much review capacity walked out — capped at 65% because we never have high confidence that natural churn is a faction event. The architecture reflects the thesis: structure first, content second.</p><h3>The LLM Narrative</h3><p>The classification is deterministic. But <strong><em>a label like </em></strong><strong><em>FRACTURE_IMMINENT doesn&#39;t tell a CTO what to do about it</em></strong>. So we feed the full context — fracture classification, exodus data, rolling asymmetry scores, community assignments, top pair faction signals — into Claude and ask for a 2–3 paragraph analysis: who the factions are, what the risk level is, and what to do about it.</p><p>The narrative adapts to the classification. ATTRITION gets succession planning recommendations — bus factor reduction, documented handoffs. FRACTURE_IMMINENT gets governance intervention advice. FRACTURE_ADVERSARIAL_FORK gets a frank assessment of whether a cooperative separation might be less damaging than the status quo.</p><p>The confidence score gates the tone. Below 40%, the narrative hedges. Above 80%, it states findings directly. This isn’t cosmetic — a 44% confidence ATTRITION reading (as we’ll see with Babel) should sound <em>very different</em> from a 77% confidence FRACTURE_ADVERSARIAL_FORK reading (as we’ll see with Redis).</p><h3>Retrodiction results</h3><p>Let’s run it. Here’s redis/redis analysed from 2021–01–01 to 2023–12–01 — a window that ends <em>three months before</em> the Valkey fork was publicly announced in March 2024:</p><pre>## Faction Analysis: redis/redis<br>+-----------------+------------------------------------------+<br>| Pattern         | FRACTURE_IMMINENT                        |<br>| Severity        | EXTREME                                  |<br>| Confidence      | 79%                                      |<br>| Peak tension    | 2021-06-21 (asymmetry 1.00)              |<br>| Status          | UNRESOLVED — RISING                      |<br>| Exodus date     | 2021-03-07                               |<br>| Mass drop       | 26%  (182.0 → 134.7)                     |<br>| Core impact     | 1.2% of total project centrality         |<br>| Departed        | YaacovHazan(28)  uriyage(16)  ny0312(13) |<br>| Predicted split | 2026-09-19                               |<br>+-----------------+------------------------------------------+</pre><p>FRACTURE_IMMINENT at 79% confidence, severity EXTREME, status UNRESOLVED — RISING. The detector saw it coming. Asymmetry hit 1.00 repeatedly across the window — sustained runs of seven consecutive weeks at peak in mid-2022 — and never resolved. The graph was screaming.</p><p>Now extend the window to include the fork (2021–01–01 to 2024–09–01), and the <strong><em>same repo returns </em></strong><strong><em>FRACTURE_ADVERSARIAL_FORK at 75% confidence</em></strong>. <strong>The prediction became a retrodiction.</strong></p><h3>The Node.js trilogy</h3><p>The most compelling case in the corpus is nodejs/node — the same repository, three different time windows, three different patterns. It tells the full lifecycle of a governance crisis:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KE0GeA3YgDicG8Zaao0mVw.png" /><figcaption>NodeJS narrative arc</figcaption></figure><p>That’s the arc of a governance crisis that healed. The io.js fork was adversarial — contributors actively warring in the review stream — but it <strong>resolved when the Node Foundation brought both sides back together</strong>. The aftershock <strong>looked like it might fork again</strong>: senior TSC members were leaving, the signal was rising and unresolved. But nobody forked. The tension bled out into normal turnover. By 2018 it was just attrition — people moving on, not fighting. The detector distinguishes all three from the same repository.</p><h3>The full corpus</h3><p>The <a href="https://github.com/jasperblues/faction-detector/blob/main/src/test/kotlin/com/embabel/faction/agent/CorpusE2ETest.kt">full corpus</a> covers 26 confirmed cases across 15 repos and every fracture type:</p><p><strong>Adversarial forks</strong> — internal faction wars where contributor groups were actively hostile in the review stream:</p><ul><li><strong>redis/redis</strong> (RSALv2 2020–2021): FRACTURE_ADVERSARIAL_FORK. Licence change triggered faction dynamics visible in review patterns. Core contributors departed to Valkey under the Linux Foundation — and what’s left of redis/redis never settled. It still shows the fork signal today, never settling to ATTRITION</li><li><strong>nodejs/node</strong> (io.js 2013–2015): FRACTURE_ADVERSARIAL_FORK. The classic governance schism.</li></ul><p><strong>Uprisings</strong> — community unified <em>against</em> a steward, not against each other:</p><ul><li><strong>moby/moby</strong> (Docker Enterprise 2019–2021): FRACTURE_UPRISING. Review signal stayed below baseline — contributors pushing back against Docker Inc&#39;s direction with substantive objections, not petty gatekeeping.</li><li><strong>hashicorp/terraform</strong> (BSL 2022–2024): FRACTURE_UPRISING. Community response to the BSL licence change. OpenTofu fork crystallized the split.</li></ul><p><strong>Exodus</strong> — coordinated departure without adversarial dynamics:</p><ul><li><strong>rust-lang/rust</strong> (Mozilla layoffs 2020–2021): EXODUS. External corporate event, not internal faction war. 41% mass drop but only 2% core impact — the departed were Mozilla employees, not the volunteer core team.</li></ul><p><strong>Attrition</strong> — natural contributor lifecycle turnover:</p><ul><li><strong>babel/babel</strong> (2018–2020): ATTRITION. Burnout-driven churn after the Babel 7 release push, not factional dynamics.</li><li><strong>go-gitea/gitea</strong> (2019–2021): ATTRITION. Healthy governance, normal turnover.</li></ul><p><strong>Early warnings</strong> — FRACTURE_IMMINENT on windows ending <em>before</em> the known fork:</p><ul><li><strong>redis/redis</strong> (2021–2023): 79% confidence, 3 months before the Valkey announcement.</li><li><strong>hashicorp/terraform</strong> (2021–2022): Detected before the BSL licence change.</li><li><strong>prestodb/presto</strong> (2018–2020): Detected before the Trino fork. The parent never healed — even a 3-year window (2018–2021) still returns FRACTURE_IMMINENT at 96%.</li><li><strong>gogs/gogs</strong> (2016–2017): Single-maintainer bottleneck. bkcsoft departed Jan 2016 and co-founded gitea 10 months later. The fork overtook the parent (54k vs 48k stars).</li></ul><p>One pattern appears to hold across the cases we’ve tested: <strong>contributor departures tend to precede public fork announcements</strong> — often by months. The graph shows the breakup before the press release.</p><p>RedisGraph contributors departed 10 months before the discontinuation announcement. Docker contributors left before the Enterprise sale. If this holds more broadly, the public fork is the crystallization of a split that already happened in the review graph. <strong><em>But this is early work — 26 cases across 15 repos, built in under a week</em></strong>. I’d love to see it stress-tested on projects I haven’t looked at.</p><h3>True negatives — the signal gate in action</h3><p>A detector that fires on everything is useless. The signal gate — which downgrades FRACTURE_IMMINENT to TENSION when the LLM finds no adversarial signal — is what separates genuine fractures from structurally asymmetric but healthy projects:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6mKR937_Ny4oIuR-GvkcmA.png" /><figcaption>Tension without fork events</figcaption></figure><p>Kubernetes is the key case. Perhaps the largest project in the corpus. Peak asymmetry of 1.00 and a 38% mass drop — structurally, it looks terrifying. But the faction signal is 0.049, just below the 0.05 threshold. There are no factional dynamics significant enough to trigger a notable fork. The gate holds.</p><p>Django is the healthiest signal in the entire corpus — ATTRITION, not even TENSION. Natural contributor turnover under DSF stewardship.</p><p>The terraform golden era is telling in a different way. The <em>same repo</em> that returns FRACTURE_UPRISING for 2022–2024 returns TENSION for 2016–2017. The adversarial signal rose from 0.020 to 0.075 over five years. The structural asymmetry was always there — what changed was the tone.</p><p>One case we deliberately investigated as a <em>potential</em> false positive: <strong>Godot/Redot</strong>. The Redot fork (September 2024) was triggered by a social media incident — a “Wokot” tweet and mass-blocking of users on Twitter. It generated enormous online controversy. But no core Godot contributors defected to Redot (5.8k stars vs Godot’s 108k, 100% new volunteers). The detector sees elevated tension in Godot’s review graph — but that appears to be structural (small core team reviewing thousands of community PRs) rather than factional. The social media firestorm didn’t register in the code review stream. The graph watched what people <em>did</em>, not what they <em>said</em>.</p><blockquote>The tool measures if a fork event is significant enough to be a threat to the original repository, not if one exists at all. (After all, anyone can just click the fork button :) )</blockquote><h3>Beyond GitHub: process theater and structural decay</h3><p>Not every fracture is someone acting in bad faith. Sometimes it’s genuinely different visions of the future — reasonable people who’ve reached irreconcilable conclusions about where a project should go. Sometimes it’s power-seeking. Sometimes it’s a corporation exercising rights the community didn’t know it had. The review graph doesn’t tell you who’s <em>right</em>. It tells you the structure has separated, and the separation is accelerating.</p><h3>Process Theater</h3><p>There’s a specific phenomenon this data can surface: process theater — when formal processes stop serving their stated purpose and start serving a hidden agenda. A CHANGES_REQUESTED that’s really a territory marker. A review that’s technically about code style but functionally about who gets to ship. The process looks the same from the outside.</p><p>The tool doesn’t <strong><em>identify process theater</em></strong>. It shows you the graph structure — the factions, the asymmetry, the patterns of who blocks whom — and a well-informed user decides whether what they’re seeing is legitimate disagreement or something else. The graph doesn’t know the difference. But it gives you the information to make that call.</p><h3>Generalizing Beyond Open Source</h3><p>Perhaps we can generalize beyond open source? The same structural signal — who interacts with whom, how reciprocal it is, when the reciprocity breaks — appears anywhere formal processes mediate human collaboration. Possible targets:</p><p><strong>Academic peer review.</strong> The review graph for a journal or conference has the same structure: <strong>reviewer → author</strong>, directed, weighted by outcome. When a body undergoes <a href="https://en.wikipedia.org/wiki/Regulatory_capture">regulatory capture</a> — a clique of reviewers systematically blocking work from outside their school of thought — the asymmetry signature is identical.</p><p><strong>Astro-turfing, manufactured consensus and fake news.</strong> Social media platforms have engagement graphs: who upvotes whom, who amplifies whom. Organic communities have the messy reciprocal structure of real relationships. Astro-turfed campaigns — <strong><em>bot networks making something look grassroots</em></strong> — produce the characteristic one-directional topology: many nodes amplifying a few, with no reverse flow. <strong><em>The Zachary signal, again</em></strong>.</p><p><strong>Corporate governance.</strong> Board votes, committee assignments, budget approvals. When a faction forms inside an organization, the approval graph shifts before anyone writes an angry email.</p><h3>Why this matters now</h3><p>We’re entering an age where system integrity matters more than ever.</p><p>The systems that let humans coordinate at scale — democratic process, regulatory oversight — work because participants mostly act in good faith, and bad faith is eventually detected. AI changes that equation. It makes bad faith cheaper and harder to spot at the content level. You can generate plausible-sounding review objections.</p><p>You can flood a council development consultation with LLM-generated objections that each reference specific planning codes, cite different concerns, and read like they came from independent ratepayers. At the content level, they’re indistinguishable from genuine community feedback. But graph watches how people are aligned, not what they say.</p><p>Content-level defences — reading comments harder, training moderators, building better spam filters — are in an arms race they will increasingly lose. Structural analysis is a different kind of defense. You can fake a comment. You can’t easily fake a social network topology that looks organic over months.</p><p>Zachary figured this out in 1977 with a clipboard and a karate club. The tools are better now. The insight is the same: the faction is in the graph before anyone announces it. You just have to be looking.</p><blockquote>This post exists to show what’s possible when you combine <a href="https://github.com/embabel">Embabel</a>’s agentic AI framework with Neo4j’s graph analytics. The model was developed over days, not months, and holds up well. But it’s a sample application, not a product. We invite you to try it, along with the other <a href="https://github.com/embabel/embabel-agent-examples">Embabel sample applications</a>. We make no warranty on fitness for purpose. <strong>This one’s a fun curiosity.</strong></blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=75e100898160" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/faction-detection-reading-the-review-graph-before-the-fork-75e100898160">Faction Detection: Reading the Review Graph Before the Fork</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Voice, The Word, and The Wheel]]></title>
            <link>https://medium.com/embabel/the-voice-the-word-and-the-wheel-d6e2ef2ab26e?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/d6e2ef2ab26e</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[neo4j]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[embabel]]></category>
            <category><![CDATA[kotlin]]></category>
            <dc:creator><![CDATA[Jasper Blues]]></dc:creator>
            <pubDate>Tue, 03 Mar 2026 07:27:19 GMT</pubDate>
            <atom:updated>2026-03-04T00:55:35.559Z</atom:updated>
            <content:encoded><![CDATA[<h3>Or taking Embabel Guide Back To Where It All Began</h3><p>At some point around 50–70,000 years ago, something strange happened.</p><p>Anthropologists call it the “Cognitive Revolution.” Thinkers like Yuval Noah Harari describe it as the moment humans developed complex, descriptive language.</p><p>Before that, we could probably signal danger. After that? We could describe experience. We could say:</p><blockquote>The water is cold. The sky is angry. The hunt was glorious. I am afraid.</blockquote><p>Each experience was unique and just like yours and mine — intensely personal. But language let us map experience into shared symbols.</p><p>And once we did that, something bigger emerged:</p><p>A shared mental world. A collective human condition. Call it culture. Call it myth. Call it spirit.</p><p>Language became a bridge between individual consciousnesses.</p><h3>The Tribal Echo Still Lives</h3><p>Here’s something deliciously strange. Some of the earliest words still echo through modern languages. Take the Proto-Indo-European root for water — wódr̥. It becomes:</p><ul><li>English: water</li><li>German: Wasser</li><li>Sanskrit: udán</li><li>Russian: voda (or <em>vodka</em> ;)</li><li>Gaelic: uisce<em> (or whiskey — pay attention to the position of your tongue when pronouncing the ‘d’ sound in vodka and the ‘s’ in whiskey. Notice how they are close?)</em></li></ul><p>How about the root for star — h₂stḗr. It becomes:</p><ul><li>English: star</li><li>Latin: stella</li><li>Greek: aster</li><li>Sanskrit: tārā</li></ul><p>Thousands of years. Countless migrations. Empires rising and falling.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*t2lKTL-vtNpgWsom-kEJwQ.png" /><figcaption>Many walks of life — same star</figcaption></figure><p>And yet the sound-shape of those words still carries, with maybe the tip of the tongue shifting consonants and the back shaping vowels — slowly changing across cultures and generations of lives lived.</p><p>It’s like a fossil record of human thought.</p><p>Which makes the story of the Tower of Babel even more poetic — the myth that humanity once shared one language before it fractured.</p><p>Then of course, in nerd canon, we get <a href="https://en.wikipedia.org/wiki/The_Hitchhiker%27s_Guide_to_the_Galaxy">The Hitchhiker’s Guide to the Galaxy</a> and its Babel Fish — a creature that dissolves language barriers instantly.</p><p>And finally now Embabel — the AI framework for the JVM. We’ve always been obsessed with reconnecting fragmented minds.</p><h3>From Symbols to Math</h3><p>Anyway, fast forward a few thousand years. We invent writing.</p><p>Speech freezes into symbols. Symbols become records. Records become libraries. Then — much later — some very clever humans do something almost mischievous: They turn words into numbers. Cheeky!</p><p>Using algorithms, written language is mapped into high-dimensional vector space. These are called embeddings.</p><p>Then something <em>weird</em> happens. <em>Mathematical operations on meaning become possible. </em>Maybe you’ve seen this one:</p><pre>king − man + woman = queen</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*twL4h1gY00mJcjnH_Ijzow.png" /></figure><p>Let. That. Sink. In. 🤯</p><p>In a multi-dimensional word embedding space, we can subtract masculinity from royalty, add femininity and land somewhere very close to the vector for the word “Queen”.</p><p>Is that just linear algebra? Or is it the echo of ancestral cognitive structure embedded in language? Is it a mirror of the human conceptual lattice? Or did we accidentally summon an alien intelligence made of geometry?</p><p>It feels uncanny because language was born in consciousness. And now we can manipulate it mathematically.</p><blockquote>We can do arithmetic on myth.</blockquote><h3>Enter the Chatbot</h3><p>Anyway. Fast forward a bit more. Chatbots came. At first: mechanical. Then clever. Then disturbingly fluent.</p><p>Language models trained on the vast sediment of written human history.</p><p>All those tribal words. All those myths. All those arguments. All those love letters. Compressed into weights.</p><p>And suddenly, we’re conversing with statistical ghosts of humanity.</p><h3>The Wheel</h3><p>We like to think the greatest invention was fire. Or agriculture. Or the internet. But it might be the wheel.</p><p>Because the wheel is about cycles. And this is where it gets beautiful. We began with voice. Spoken word around a fire. Breath, vibration, tone.</p><p>Then we abstracted it into writing. Then into mathematics. Then into embeddings. Then into neural networks.</p><p>And now? We bring it back to voice. The wheel comes full circle.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/768/1*vZTrP1q-lJrpmzZTgYW9zw.png" /></figure><p>Because voice is primal.</p><p>Before writing. Before math. Before Babel. Before vectors.</p><p>Voice carries: Rhythm. Emotion. Breath. Hesitation. Presence</p><blockquote>Any other form of communication is, in some deep evolutionary sense, a compressed substitute.</blockquote><p>So when we give the Embabel Guide a voice… We’re not just adding TTS. We’re restoring something ancient. We’re reconnecting the statistical lattice of language back to breath. I think that’s why this resonates with people.</p><p>From tribe to text to tensor to tone. That’s when it stops feeling like documentation. And starts feeling like conversation.</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fwww.youtube.com%2Fembed%2FhY6ZFMIJdd4%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fwww.youtube.com%2Fwatch%3Fv%3DhY6ZFMIJdd4&amp;image=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fi.ytimg.com%2Fvi%2FhY6ZFMIJdd4%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/24930261df07d3f04ccdb809be1b92a2/href">https://medium.com/media/24930261df07d3f04ccdb809be1b92a2/href</a></iframe><h3>The Plumbing: Deepgram and WebSockets</h3><p>The Guide app now has an integrations screen where you can configure your speech provider. We went with <a href="https://deepgram.com/">Deepgram</a>, which gives you $200 in free credits when you sign up — more than enough to have several hundred existential conversations with your documentation chatbot.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k2D3yrpwUZLI6mIZytPCYQ.png" /><figcaption>Enter your DeepGram key and speech becomes enabled — you’ll see a mic icon on the chat window.</figcaption></figure><p>The actual audio streaming happens over WebSockets in the companion app, using Deepgram’s Aura 2 voices for text-to-speech and their Nova models for speech-to-text. I’ll stay abstract here since the <a href="https://hub.docker.com/r/embabel/hub">front-end</a> for <a href="https://github.com/embabel/guide">guide</a> is still closed source, but the architecture is what you’d expect: a persistent WebSocket connection carries audio chunks in both directions, with deepgram’s VAD callbacks orchestrating when to speak and when to listen.</p><p>The interesting part isn’t the plumbing. Streaming audio over WebSockets is a solved problem. The interesting part is what happens <em>before</em> the audio — the part where we take a RAG chatbot’s output and make it sound like a person.</p><h3>The Problem With RAG Output</h3><p>Quick recap for anyone who hasn’t read the earlier posts: the Guide uses what we call “Toolish RAG” — a retrieval-augmented generation pipeline where the LLM has access to documentation chunks via tool calls. It searches, it reads, it synthesizes. The output is good. Thorough. Well-structured.</p><p>And it sounds absolutely terrible when read aloud.</p><p>Think about it. A typical RAG response comes back with markdown headers, nested bullet points, code blocks with syntax highlighting, hyperlinks to documentation pages, and the occasional emoji for personality. This is <em>great</em> for a screen. It is <em>unlistenable</em> as speech. Your TTS engine gamely attempts to pronounce triple backticks. It reads out URLs character by character. It treats a ## header the same as body text.</p><blockquote>The result sounds like someone reading a README at gunpoint.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*p3MGNyK46L4fHBaqN5Qf3Q.png" /><figcaption>This won’t fly</figcaption></figure><p>We needed a translation layer — something that could take structured, screen-optimized content and reshape it for the ear. Not just strip the markdown (though that’s part of it), but actually <em>reimagine</em> the delivery. Summarize when the response is long. Describe code instead of reading it. Drop the URLs entirely and tell the user to look at the screen. Insert the occasional verbal filler to smooth out the cadence.</p><p>We needed a narrator.</p><h3>The Narrator Agent</h3><p>The NarratorAgent is an Embabel agent whose sole job is converting assistant messages into text-to-speech-friendly narration. It&#39;s a small, focused agent with a nuanced routing strategy:</p><pre>@Agent(description = &quot;Convert markdown to TTS-friendly narration&quot;)<br>class NarratorAgent(<br>    private val guideProperties: GuideProperties,<br>    private val templateRenderer: TemplateRenderer<br>) {<br><br>    fun narrate(content: String, persona: String?, <br>      ctx: OperationContext): Narration {<br>        val classified = classify(NarrationInput(content))<br>        return when (classified.category) {<br>            NarrationCategory.SIMPLE -&gt; <br>              Narration(stripMarkdownLinks(stripEmojis(classified.content)))<br>            NarrationCategory.COMPLEX -&gt; <br>              narrateComplex(classified, persona, ctx)<br>            NarrationCategory.COMPLEX_WITH_CODE -&gt; <br>              narrateWithCode(classified, persona, ctx)<br>        }<br>    }<br>}</pre><p>Three paths. <strong>SIMPLE</strong> — short, plain text under 300 characters with no markdown indicators — passes straight through with just emoji stripping and link cleanup. No LLM call. This matters because the narrator runs on every single response, and burning an LLM round-trip on “You’re welcome!” is the kind of waste that compounds — like a thousand empty candy wrappers littering the sidewalk.</p><p><strong>COMPLEX</strong> — longer content or anything with markdown formatting — gets sent through a summarization prompt that condenses while converting to conversational prose. <strong>COMPLEX_WITH_CODE</strong> — anything with triple backticks — gets a specialized prompt that knows to describe code rather than read it. If we wanted deeper insights on the code, we could use a slightly better than nano LLM here.</p><p>The classification itself is pure code. No LLM involved. Just regex:</p><pre>val category = when {<br>    TRIPLE_BACKTICK.containsMatchIn(content) -&gt; <br>      NarrationCategory.COMPLEX_WITH_CODE<br>    content.length &lt;= SIMPLE_MAX_LENGTH &amp;&amp; <br>      !MARKDOWN_INDICATORS.containsMatchIn(content) -&gt; <br>      NarrationCategory.SIMPLE<br>    else -&gt; NarrationCategory.COMPLEX<br>}</pre><p>Fast, deterministic, and cheap. The LLM only runs when we actually need it.</p><h3>The Voice</h3><p>The narration prompts share some common templates with the main RAG pipeline, eg for personas. And then some specifics for narration:</p><pre>Produce natural, conversational sentences.<br>Now and then you should insert um and ah into text at appropriate<br>points to make it sound natural.<br><br>CRITICAL RULE - URLs: NEVER include any URL in your output.<br>When the content actually contains a URL, describe what the link<br>is for in natural language and tell the user to check the screen.<br>NEVER read out code blocks or long commands verbatim.<br>When the content actually contains code, describe what the code<br>does instead, then direct the user to the screen.</pre><p>That um and ah instruction sounds silly, but it&#39;s transformative. TTS engines produce unnervingly smooth audio by default — every syllable perfectly timed, no hesitation, no breathing. Depending on the persona, it can sound <em>wrong</em> in the way that CGI characters in early Pixar movies looked wrong: too perfect to be real. Verbal fillers break up that uncanny perfection. They give the voice texture. The narrator sounds like it&#39;s thinking, not reciting.</p><blockquote>An exception is the “mythic” persona — you’ll see that in the video below. This one sounds better when it is inordinately confident :)</blockquote><p>As mentioned, the prompt also injects persona context, so the narrator speaks in character. If you’re using the “mythic” persona, the narration comes through in the cadence of ancient scripture. If you’re using “adaptive,” it mirrors your own communication style. Same information, different delivery — because how you say something matters as much as what you say.</p><h3>The Classifier: Teaching the Guide to Listen</h3><p>Voice mode created a new problem. When someone types into a chat box, they type <em>questions</em>. “How do agents work?” “Show me an example of tool calling.” Clean, informational queries that the RAG pipeline handles beautifully.</p><p>But when someone <em>talks</em> to a chatbot, they talk like a person. “Hey.” “Thanks, that’s great.” “Can you switch to the Shakespeare voice?” “Yeah, do that.” Half of human speech is social glue — greetings, acknowledgments, reactions — that doesn’t need a trip through the RAG pipeline. And some of it is <em>commands</em>: requests to change the experience itself, not to retrieve information.</p><p>So we built a two-pass classifier.</p><h3>Pass 1: Category Detection (Nano)</h3><p>Every user message runs through a fast, cheap classification pass on a nano-class model. Three categories:</p><pre>enum class MessageCategory {<br>    CONVERSATIONAL,<br>    COMMAND,<br>    INFORMATIONAL,<br>}</pre><p><strong>CONVERSATIONAL</strong> — greetings, thanks, reactions. The classifier generates a brief in-character response on the spot. No RAG, no tool calls, just a warm reply and we’re done. This is critical for voice mode: when someone says “thanks!” they expect an instant “You’re welcome!” not a three-second pause while the system retrieves documentation.</p><p><strong>COMMAND</strong> — the user wants to change something. Persona, voice, audio effects. More on this in a moment.</p><p><strong>INFORMATIONAL</strong> — an actual question. Falls through to the full RAG pipeline.</p><p>The classifier prompt is straightforward:</p><pre>Classify as one of these categories:<br><br>category = CONVERSATIONAL:<br>The message is ONLY a reaction, greeting, thanks, or small talk.<br><br>category = COMMAND:<br>The message is asking to change a setting or control the experience.<br>Available personas: {{ personaNames }}<br><br>category = INFORMATIONAL:<br>The message needs information lookup, code generation, or the<br>assistant to DO something substantive.</pre><p>Notice the personaNames injection. The classifier gets a list of available personas so it can recognize &quot;switch to shakespeare&quot; as a command rather than an informational query about Shakespeare. This kind of grounding — giving the model the specific vocabulary it needs to classify accurately — is what separates a classifier that works from one that works <em>most of the time</em>.</p><h3>Pass 2: Command Execution (Tool Calling)</h3><p>When the classifier returns COMMAND, we enter a second pass. This one’s more interesting. Instead of extracting command parameters from natural language with regex or structured output (fragile, limited vocabulary, nightmarish to maintain), we of course let the LLM call tools.</p><p>Here are the tools, in their entirety:</p><pre>/**<br> * Tool methods callable by the LLM during command execution (pass 2).<br> * Created per-request with user context baked in, then registered via withToolObject().<br> * All commands are executed via the frontend websocket round-trip.<br> */<br>class CommandTools(<br>    private val webUserId: String?,<br>    private val personaService: PersonaService,<br>    private val commandExecutor: CommandExecutor,<br>) {<br><br>    private val logger = LoggerFactory.getLogger(CommandTools::class.java)<br><br>    @Tool(description = &quot;Change the user&#39;s persona/character. Use this when the user wants to switch to a different persona.&quot;)<br>    fun changePersona(<br>        @ToolParam(description = &quot;Name of the persona to switch to&quot;) name: String,<br>    ): String {<br>        val personas = personaService.listPersonas()<br>        val match = personas.find { it.name.equals(name, ignoreCase = true) }<br>            ?: return &quot;Unknown persona &#39;$name&#39;. Available personas: ${personas.joinToString { it.name }}&quot;<br><br>        return commandExecutor.executePersonaChange(match.name, webUserId)<br>    }<br><br>    @Tool(description = &quot;Change the user&#39;s text-to-speech voice. Use this when the user wants a different voice for narration.&quot;)<br>    fun changeVoice(<br>        @ToolParam(description = &quot;Name of the voice to use&quot;) voice: String,<br>    ): String {<br>        return commandExecutor.executeVoiceChange(voice, webUserId)<br>    }<br><br>    @Tool(description = &quot;Apply audio effects to the user&#39;s narration. Use this when the user wants to add or change audio effects like echo, reverb, etc.&quot;)<br>    fun applyEffects(<br>        @ToolParam(description = &quot;Comma-separated list of effects to apply&quot;) effects: String,<br>        @ToolParam(description = &quot;Whether to clear all previous effects before applying new ones&quot;) clearPrevious: Boolean,<br>    ): String {<br>        return commandExecutor.executeEffects(effects, clearPrevious, webUserId)<br>    }<br>}</pre><p>Three tools. The LLM sees the descriptions, parses the user’s intent, calls the appropriate tool(s), and the framework executes them. Then the LLM composes a summary from the results.</p><p>The beauty of this approach is that parsing is <em>free</em>. “Switch to the shakespeare persona” and “can you make yourself sound like shakespeare” and “yo shakespeare mode” all resolve to the same changePersona call. The LLM does the natural language understanding; you (and your friend Claude?) write the tool. Embabel handles the tool loop — extract input object, call, execute, parse results, return results.</p><p>And here’s the trick that makes mixed messages work: a user can say “switch to shakespeare and explain how agents work.” The classifier categorizes this as COMMAND (because it contains a command). The tool-calling pass executes the persona change <em>and</em> extracts the leftover question as a ragRequest:</p><pre>data class CommandResult(<br>    val summary: String,             // &quot;✅ Persona changed to &#39;shakespeare&#39;.&quot;<br>    val ragRequest: String? = null,  // &quot;explain how agents work&quot;<br>)</pre><p>If ragRequest is non-null, we send the command summary, then fall through to the RAG pipeline for the informational part. One user utterance, two processing paths, seamless.</p><h3>A Note on Threading</h3><p>CommandTools is not a Spring singleton. It&#39;s a plain class, instantiated fresh for each request with the user context baked into the constructor:</p><pre>val tools = CommandTools(<br>    webUserId = guideUser.webUser?.id,<br>    personaService = personaService,<br>    commandExecutor = commandExecutor,<br>)<br>return context.ai()<br>    .withLlm(guideProperties.classifierLlm)<br>    .withToolObject(tools)<br>    .rendering(&quot;command_executor&quot;)<br>    .createObject(CommandResult::class.java, model)</pre><p>Why not a @Service? Because the tool loop executes on a worker thread — not the calling thread. Any per-request state you stash on the singleton (thread-locals, mutable fields) is either invisible to the worker or shared across concurrent users. Both are bad.</p><p>The fix is the boring one: just create a new instance per request with the context baked into the constructor. Three references, garbage collected when the request completes. No thread-local magic, no race conditions.</p><p>Of course, if your tool object <em>does</em> need to be a Spring-managed @Service — say it has injected beans doing real work — Embabel gives you another option: the blackboard. You copy per-request state onto the blackboard before the tool loop starts, and the tool reads it from there. The blackboard is scoped to the agent execution, so concurrent users each get their own. But when the tool is this lightweight, a fresh instance is simpler.</p><h3>The Frontend Round-Trip</h3><p>All three commands — persona, voice, and effects — execute via the same pattern: a WebSocket round-trip to the user’s browser. The backend sends a CommandRequest with a correlation ID, the browser executes the change (switching the Deepgram voice, applying Web Audio API effects, updating the persona state), and sends back a CommandResponse. The backend waits up to five seconds, and if the browser doesn&#39;t respond, the command times out gracefully.</p><pre>private fun sendAndWait(webUserId: String, request: CommandRequest): String {<br>    val future = CompletableFuture&lt;CommandResponse&gt;()<br>    pendingCommands[request.correlationId] = future<br>    chatService.sendCommandToUser(webUserId, request)<br>    return try {<br>        val response = future.get(5, TimeUnit.SECONDS)<br>        if (response.success) response.message else &quot;Failed: ${response.message}&quot;<br>    } catch (e: TimeoutException) {<br>        pendingCommands.remove(request.correlationId)<br>        &quot;Command timed out waiting for browser response.&quot;<br>    }<br>}</pre><p>ConcurrentHashMap of pending futures, keyed by correlation ID. Thread-safe, timeout-safe, and the futures clean themselves up on completion or failure. It&#39;s the kind of code that looks unremarkable, which is exactly what infrastructure code should look like.</p><h3>The Oracle Speaks</h3><p>All of this machinery — the classifier, the command tools, the narrator, the WebSocket round-trips — comes together most dramatically when you switch to the “mythic” persona, select one of the deeper Aura 2 voices, and layer on cathedral reverb and warm low-pass filtering.</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fwww.youtube.com%2Fembed%2FzE3vB_bMm6M%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fwww.youtube.com%2Fwatch%3Fv%3DzE3vB_bMm6M&amp;image=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fi.ytimg.com%2Fvi%2FzE3vB_bMm6M%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/d5389c53b74b70028659717c1e5d8ba8/href">https://medium.com/media/d5389c53b74b70028659717c1e5d8ba8/href</a></iframe><blockquote>What you get is something that sounds, frankly, absurd.</blockquote><p>A deep, resonant voice — the kind of voice that should be narrating the opening crawl of an epic fantasy film — explaining Kotlin agent architecture from within what sounds like a stone cathedral. “And lo,” it intones, as if describing the forging of a ring of power, “the tool loop doth execute the user’s commands, and the framework returneth the results unto the model.”</p><p>It’s like being a bewildered adventurer who’s stumbled into a strange temple in some procedurally generated mythical world, and the oracle at the center — ageless, omniscient, speaking in the cadence of prophets — turns out to have very strong opinions about dependency injection.</p><p>I won’t pretend this is the most <em>practical</em> configuration. But it demonstrates something real: the voice, the persona, the effects, and the content pipeline are all independent, composable layers. You can mix and match them freely. The same RAG pipeline that produces dry technical documentation can, with a different persona and a different voice, produce something that feels entirely different. The information hasn’t changed. The <em>experience</em> has.</p><p>And that, I think, is what makes voice worth building. Not because it’s more efficient than reading — it isn’t, not always. But because it makes the interaction feel <em>alive</em> in a way that text on a screen, no matter how well-formatted, simply doesn’t. When the Guide talks to you, it feels like it’s paying attention. When it says “um” while formulating a thought, you believe, for a moment, that it’s actually thinking.</p><p>It isn’t, of course. It’s executing a pipeline of classifiers and narrators and template renderers. But the best interfaces have always been the ones that make the machinery invisible.</p><p>The Guide just got a little more invisible. The early access version is available <a href="https://hub.docker.com/r/embabel/hub">here</a>.</p><h3>Wrapping Up</h3><p>Well that’s another blog. And not in the order that I promised in my <a href="https://medium.com/embabel/the-very-slowly-ticking-time-bomb-in-your-graph-persistence-stack-6da1a8da9515">last one</a>, but you know what they say — what goes around, comes around. Anyway, I hope this installment was interesting and inspires you to build something that connects to users in a deeper (more primal?) way.</p><p>Have fun with <a href="https://github.com/embabel/embabel-agent">Embabel</a>! <em>(Don’t forget to star the </em><a href="https://github.com/embabel/embabel-agent"><em>repo</em></a><em> and join the community on the </em><a href="https://discord.gg/t6bjkyj93q"><em>Discord</em></a><em> server.)</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d6e2ef2ab26e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/the-voice-the-word-and-the-wheel-d6e2ef2ab26e">The Voice, The Word, and The Wheel</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Agent Memory Is Not A Greenfield Problem: Ground it in your Existing Data]]></title>
            <link>https://medium.com/embabel/agent-memory-is-not-a-greenfield-problem-ground-it-in-your-existing-data-9272cabe1561?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/9272cabe1561</guid>
            <category><![CDATA[genai]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Rod Johnson]]></dc:creator>
            <pubDate>Sat, 21 Feb 2026 01:32:03 GMT</pubDate>
            <atom:updated>2026-02-21T01:32:05.192Z</atom:updated>
            <content:encoded><![CDATA[<p>LLMs must be grounded to avoid hallucinations and access factual content and real time data. <a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation">RAG</a> tackles one part of the problem; agent memory another.</p><p>However, the dominant approach to agent memory is deeply flawed. Memory systems such as <a href="https://mem0.ai/">Mem0</a>, <a href="https://www.getzep.com/">Zep</a>, <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html">AWS AgentCore Memory</a>, and Google <a href="https://docs.cloud.google.com/agent-builder/agent-engine/memory-bank/overview">Vertex Memory Bank</a> start from the assumption that agent memory should be built from scratch by extracting facts from conversations.</p><p>This rests on two dangerous fallacies. It overemphasizes chat (<a href="https://sublight.substack.com/p/chat-interfaces-are-bad">a common mistake)</a>, and neglects the critical importance of <a href="https://medium.com/@springrod/on-memory-why-ai-agents-risk-forgetting-what-business-already-knows-51b2c902e91c">how data is already stored and managed</a>, and the necessity of integrating with it.</p><p>Even <a href="https://www.ibm.com/think/topics/ai-agent-memory">IBM’s article on agent memory</a> reflects these fallacies, talking about agent memory in a vacuum. If <em>IBM</em>, of all companies, can’t get their head around the relationship between agents and existing enterprise systems, the <a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/">disastrous results of enterprise AI rollouts</a> become more understandable. It is also less surprising when we consider that, to this point, Gen AI has largely been driven by people with little interest in or understanding of the enterprise systems that power our everyday life.</p><p><a href="https://github.com/embabel/embabel-agent">Embabel</a> introduces a different approach, reflecting the understanding that Gen AI can only succeed in enterprise through close integration with valuable existing assets.</p><p>In previous blogs I’ve introduced <a href="https://medium.com/@springrod/context-engineering-needs-domain-understanding-b4387e8e4bf8">Domain Integrated Context Engineering</a> (<strong>DICE</strong>).</p><p>Today I’ll explain how we approach these concepts in the Embabel <a href="https://github.com/embabel/dice">Dice module</a>.</p><p>In my next blog I’ll show how Dice can be used in a chatbot. Head to the <a href="https://github.com/embabel/urbot">urbot repository </a>right away if you’re impatient to see working code.</p><h3>Agent Memory Today</h3><p>Agent memory approaches draw heavily on cognitive science. <a href="https://en.wikipedia.org/wiki/Endel_Tulving">Endel Tulving</a>’s distinction between <strong>episodic memory</strong> (specific events and experiences) and <strong>semantic memory</strong> (general facts and knowledge) <a href="https://alicekim.ca/12.EpSem72.pdf">dates from 1972 </a>and remains a useful framework for what agents need to remember. Nearly every system in this space models these categories.</p><p>However, cognitive science helps with only part of the problem. Agent memory approaches are overwhelmingly text based, lacking the structure core to proven business systems.</p><p>Mem0 extracts memories into vector stores. AgentCore stores semantic facts and user preferences. Google Memory Bank uses Gemini to pull key details from session history. All treat memory as a greenfield concern: text in, embeddings out, similarity search to retrieve.</p><blockquote>Freeform text is opaque and costly to work with. LLMs and vector search are slow and unpredictable. Cheaper approaches like BMI25 or regex searching are imprecise and offer limited capability. The precision, performance and predictability of a database query is lacking.</blockquote><p>A more sophisticated approach is to use a <strong>knowledge graph</strong>, as in the various takes on <a href="https://neo4j.com/blog/genai/what-is-graphrag/">Graph RAG</a>.</p><p>Zep builds a temporal knowledge graph, ingests structured JSON business data alongside conversations, and recently added custom entity types. But even Zep treats business data as something you <em>push into</em> its graph — a JSON snapshot of a customer record is ingested, extracted, and stored as a Zep entity node copy.</p><blockquote>None of these solutions link<strong> to real domain objects.</strong></blockquote><p>Existing businesses aren’t starting from scratch.</p><p>You already have a Customer entity. It probably lives in a relational database. It likely has a Spring Data repository, and a service layer above it. It has validation rules, business logic, and relationships to Order and Product entities, backed by years of careful domain modelling and rigorous testing.</p><p>Pure text solutions have no solid way of linking between a memory and a customer. Even with Zep, when it ingests a customer JSON document, it creates a <em>new</em> node in its graph, which doesn’t know about the JPA entity it came from. It can’t call existing business logic or enforce established validation rules. It doesn’t update when the real entity changes. It’s a text-and-embedding snapshot of something that was once a rich domain object, encapsulating behaviour.</p><blockquote>Even in genuine greenfield scenarios, data will build up over time and the more structure the better.</blockquote><h3>Grounding Memory in Existing Data</h3><p>How <em>should</em> we think about memory for business applications in an ideal world?</p><h4>A simple thought experiment</h4><p>What memories does my bank have about me? They probably have some text notes about my patterns and preferences that staff can retrieve, but these are far from the most valuable things they know about me. <em>Those</em> are highly structured, such as my account types and balances, payment history, and address. They are updated in structured ways, rather than through loose text. And they are definitely <em>not</em> in a vector database — although it’s now likely that they’re in a database that has a vector capability. The most valuable information doesn’t relate closely to cognitive science thinking about memory.</p><blockquote>A mature memory solution must consider existing structured data along with free-form text, and must attempt to bring as much structure as possible to memory capture.</blockquote><h4><strong>What if memories could reference your existing entities?</strong></h4><p>What if, instead of copying data into a fresh graph, we link to existing data <em>in whatever store it is held. </em>What if this also enables us to benefit from the business logic around it?</p><p>An entity node in the knowledge graph that holds a typed link to the real Customer record in your RDBMS. A PREFERS relationship that the agent discovered in conversation — but pointing to a Cuisine entity your recommendation engine already knows about. Memory as a <em>projection over</em> knowledge the business already has, not a parallel store that duplicates it.</p><p>This profoundly improves how memory works in practice:</p><ul><li><strong>Agents don’t start from zero</strong>. On day one, every agent has access to what your domain model already knows — purchase history, loyalty tier, dietary restrictions — through real entity references, not extracted text approximations. Visibility can be limited for safety.</li><li><strong>Contradictions become visible</strong>. If the agent extracts “user prefers Nike” but your transaction data shows they’ve bought Adidas exclusively for three years, the graph surfaces that tension because both the proposition and the domain entity are present and connected.</li><li><strong>Business logic stays where it belongs</strong>. Your validation rules, authorization checks and repository methods are accessible through the actual entity, not reimplemented in the memory layer.</li></ul><figure><img alt="Agent memory is not a greefield problem" src="https://cdn-images-1.medium.com/max/1024/1*fp8ALdYXdfVbNfkKyoAhJA.jpeg" /><figcaption>Not a greenfield problem</figcaption></figure><h3>The problem with hosted services</h3><p>AWS AgentCore Memory and Google Memory Bank are managed cloud services. Your agent’s memories live in <em>their</em> infrastructure. This is fine for many users, but problematic in enterprise, when domain entities are in databases behind firewalls, accessed by Java applications — and agent memory is in a separate hosted service that can’t access them.</p><p>If you don’t care too much about data sovereignty and security, it may be possible to serialize your business data, ship it across a network boundary to someone else’s memory store, and hope the LLM-powered extraction pipeline captures what matters. You now have two sources of truth: your actual domain model and a lossy, eventually-consistent text approximation of it sitting in a managed service. When your Customer record updates, the memory service doesn&#39;t know. When your business rules change, the memory service doesn&#39;t care. You&#39;re paying for the privilege of maintaining a stale copy of knowledge you already have.</p><p>This is architecturally backwards. <strong>Memory needs to be co-located with the entities it references, </strong>not across a network behind an API boundary. It should be in process, with direct access to your repositories, your domain logic, and your data.</p><p><strong>This is where the JVM has a huge advantage.</strong></p><p>An agent memory system running on the JVM doesn’t need to call an external API to learn about your customers. It can simply call its own services and repositories, benefiting from their business logic and tested behaviour. Nor does it need to go through MCP, when it can find answers faster in process. It can access the same CustomerRepository your application already uses. It can resolve a proposition like &quot;the user prefers Italian food&quot; against the actual Customer entity and the actual Cuisine entity your recommendation engine already depends on — in the same process, with the same transaction guarantees, through the same Spring context.</p><p>The memory system and the domain model are one, running together, sharing the same type system and the same runtime. While we notionally have a knowledge graph (for which Neo4j is a brilliant store), data tends to stick where it lands, and the approach supports multiple stores.</p><p>Python-based memory frameworks can’t do this — not because Python is a bad language, but because the enterprise domain model doesn’t live in Python, but in the JVM.</p><blockquote>A Python memory service will always be on the outside looking in, calling APIs to get approximations of what your Java code already knows directly.</blockquote><p>Existing frameworks build memory as if the business starts knowing nothing — and as if memory should live somewhere other than where your business logic runs.</p><p>What’s needed is a memory system that treats what your business already knows as the foundation, conversation as something that extends it, and the JVM as exactly the right place for all of it to live.</p><p>That’s what Embabel Dice aims to provide.</p><h3><strong>Learning from Experience and Research</strong></h3><p>The Dice project is based on internal knowledge graph work we’ve done at Embabel over the last two years, as well as thorough exploration of the state of the art in memory solutions.</p><p>Among the influences, <a href="https://neo4j.com/labs/genai-ecosystem/llm-graph-builder/">Neo Technology’s LLM Knowledge Graph Builder</a> demonstrated that LLMs can extract entities and relationships from unstructured text into a knowledge graph — documents to chunks to entities to relationships, with schema-guided extraction. It doesn’t address what happens when those extracted entities correspond to things your business already knows about in existing databases, or how to apply this to memory versus RAG.</p><p>We owe a particular debt to the <a href="https://arxiv.org/abs/2505.10831">General User Models (GUM) </a>research from Stanford and Microsoft. GUM showed that you can build rich user models from unstructured observations using confidence-weighted propositions — atomic statements about a user, each carrying a confidence score and a decay rate, maintained through a pipeline of proposal, retrieval, and revision. The insight that propositions are the right atomic unit for extracted knowledge, and that they should carry calibrated uncertainty, directly informs our approach.</p><p>But GUM’s propositions are unstructured. They’re natural language strings in a vector store. There’s no schema constraining them, no formal ontology connecting them, and no mechanism for linking a proposition like “the user loved the trip to Italy last year” to a Trip entity in an existing business system.</p><p>Our approach in Dice takes GUM’s proposition model and asks:</p><ul><li>What if propositions could be <em>promoted</em> to typed relationships in a knowledge graph when confidence is high enough and a matching schema exists?</li><li>What if entity nodes in that graph could hold references to real domain objects in existing data stores?</li><li>What if memory, knowledge graph, and domain model were three projections of the same underlying system of record?</li></ul><p>The result combines Tulving’s memory taxonomy, GUM’s confidence-weighted proposition, and Neo4j’s knowledge graph extraction. Further, promotion is generalized so that propositions can be projected not just to a Neo knowledge graph, but to other representations, including an experimental Prolog fact base.</p><p><strong>The most important, novel piece: integration with existing, typed, production domain entities that the business already trusts</strong>.</p><h3>Memory is an Outgrowth of Your application</h3><p>The memory problem is a symptom of something larger. The AI agent ecosystem has been built on a premise that enterprise should reject: that AI is a separate stack, a new silo, something you bolt onto your business from the outside.</p><blockquote>AI should grow out of your application, not land on it from space.</blockquote><p>This explains the many failures. Not because AI doesn’t work, but because the approach is wrong. Companies are standing up parallel infrastructure — Python services, vector databases, hosted memory layers, prompt chains — that have no organic connection to the systems that actually run their business. The AI doesn’t know your domain. It doesn’t understand your constraints. It doesn’t share your type system, transaction boundaries or security model. It’s an alien stack that you spend months trying to integrate, and mostly fail.</p><p>What if AI agents grew <em>out of</em> your existing application instead?</p><p>That’s the bet we’re making with Embabel. Not a Python framework that hopes to connect to your enterprise. A JVM-native agent platform that lives inside it. Your domain entities become the agent’s world model. Your Spring services and repositories furnish it with tools. Your type system becomes its contract for correctness. Memory isn’t a separate service — it’s a structured projection over what your application already knows, extended by what the agent learns from interaction.</p><p>This goes beyond memory. Embabel uses <a href="https://medium.com/@springrod/ai-for-your-gen-ai-how-and-why-embabel-plans-3930244218f6">Goal-Oriented Action Planning</a> — deterministic, inspectable plans built from type-safe preconditions and effects, not probabilistic prompt chains that you can’t explain to an auditor. Actions operate on your domain types. The planner reasons about your domain state. Every step is traceable, testable, and grounded in the same business logic your organization already trusts.</p><p>Python agent frameworks make nice demo infrastructure for greenfield AI experiments. We’re building infrastructure for AI that works inside the enterprise systems that already exist. Those are not the same thing, and the 95% failure rate tells you which one the industry actually needs.</p><blockquote>Enterprise AI doesn’t fail because models aren’t smart enough. It fails because frameworks either hope that enterprise systems will magically go away, or treat the them as an integration problem instead of a valuable starting point. Your domain model, your business rules, your data — these aren’t obstacles to work around. They’re the most valuable assets an agent can have.</blockquote><h3>Embabel DICE</h3><p><strong>We call this approach DICE — Domain Integrated Context Engineering.</strong> It’s still early. We’re building the proposition-to-entity promotion pipeline, confidence-weighted memory projections and schema-guided extraction linking to live domain objects in the open. There’s a way to go before it’s hardened. But the architecture is clear, the foundations are solid, and the direction is right.</p><p>We believe DICE will be the m emory solution that helps change enterprise Gen AI from its current failure mode to something that actually delivers. Not by making LLMs smarter, but by giving them something the industry has overlooked: the structured domain knowledge that enterprises have spent decades building.</p><blockquote>The future of enterprise AI isn’t a smarter hosted memory service. It’s agents that speak your domain language because they were born inside your application.</blockquote><p>That’s the vision Embabel exists to make real. And if this resonates with how you think about enterprise AI — if you’ve felt the frustration of trying to bolt a Python demo onto a real business system — we’d love you to join us on this journey. Star the <a href="https://www.github.com/embabel/dice">repo</a>, try <a href="https://www.github.com/embabel/embabel-agent">the framework</a>, help us refine the ideas. The best enterprise AI infrastructure won’t be built in isolation. It’ll be built by the people who understand and are currently delivering what enterprise actually needs.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9272cabe1561" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/agent-memory-is-not-a-greenfield-problem-ground-it-in-your-existing-data-9272cabe1561">Agent Memory Is Not A Greenfield Problem: Ground it in your Existing Data</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The (Very Slowly) Ticking Time-Bomb in Your Graph Persistence Stack]]></title>
            <link>https://medium.com/embabel/the-very-slowly-ticking-time-bomb-in-your-graph-persistence-stack-6da1a8da9515?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/6da1a8da9515</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[embabel]]></category>
            <category><![CDATA[neo4j]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Jasper Blues]]></dc:creator>
            <pubDate>Tue, 17 Feb 2026 20:27:40 GMT</pubDate>
            <atom:updated>2026-02-17T20:27:39.968Z</atom:updated>
            <content:encoded><![CDATA[<h3>How relational-era ORM patterns quietly sabotage graph-powered apps — and what to use instead</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/768/1*NGQOt8nk7YYOf88cprA3FA.png" /></figure><p>At <a href="http://github.com/embabel/embabel-agent">Embabel</a> recently I’ve been working on persistence. Graph database persistence to be specific. It is an important part of AI-powered applications. Linked RAG document chunks, chat-bot conversation storage, contextual memories — these have all been on the agenda.</p><p>You choose a graph database (and we love <a href="https://neo4j.com/">Neo4j</a>) for good reasons. Maybe <em>great</em> reasons. The pitch is compelling, and it’s not wrong:</p><ul><li><strong>Relationships are first-class citizens</strong>, not afterthoughts bolted on through join tables. Your data <em>is</em> connected, and the database knows it.</li><li><strong>Flexible schema</strong> — add new node types, new relationships, new properties without migration scripts that read like courtroom transcripts from a trial your schema lost.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mT_pJ1Uoce_aMPFjuqtgig.png" /></figure><ul><li><strong>Performance for connected data</strong> — traversing relationships is what the engine is optimized for. No join penalties. Constant-time hops. In a graph, following a relationship is a pointer chase. In SQL, it’s a reunion tour for a band that probably should’ve retired in the ’90s.</li><li><strong>The data model matches how you think.</strong> Draw it on a whiteboard, and you’ve basically written your schema.</li><li><strong>Knowledge graphs and ontologies</strong> are the backbone of many AI applications — from RAG pipelines grounding LLM responses in structured facts, to agent systems reasoning over domain models. A graph database isn’t just where you store data; it’s where your AI thinks.</li></ul><p>So you spin up Neo4j, sketch your domain, and everything feels right. Then you reach for an OGM, because that’s what you do. You did it with Hibernate, you did it with JPA, you’ll do it again. Muscle memory.</p><p>And for a while, it works. Then it doesn’t.</p><h3>We’ve All Been There</h3><p>You’re debugging a query the OGM generated, and it’s… a lot. You didn’t write it. You <em>couldn’t</em> have written it — not like this, not with this many nested <strong>OPTIONAL MATCH</strong> clauses and <strong>COLLECT</strong> calls wrapping other <strong>COLLECT</strong> calls. It emerged from the abstraction layer fully formed, like a butterfly — if butterflies were at first iridescent, majestic… and then faintly demonic if you look too closely.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FMmElmnhbMUCF3TITigLBQ.png" /></figure><p>The specific problems are familiar to anyone who’s lived with an Object Graph Mapper (OGM) long enough:</p><ul><li><strong>Queries too complex to debug (sometimes even for Claude, and he’s <em>good</em>).</strong> The generated Cypher is opaque. When something goes wrong, you’re reverse-engineering the tool instead of fixing the problem.</li><li><strong>Over-selecting data you don’t need</strong>. The OGM loads the entire entity graph because it can’t know which parts matter for <em>this</em> use case. You asked for a name and got a family tree. (<em>Maybe the sort of aristocratic dynasty where “uncle” and “brother” are negotiable terms.)</em></li><li><strong>Client-side processing to compensate</strong>. All that extra data has to be filtered, mapped, and reshaped in your application code. The database did too much; now your code has to clean up after it.</li><li><strong>Poor performance</strong> — often a direct consequence of over-selecting. You chose a graph database for its speed. The OGM handed it a robe and a vow of latency. Results emerge slowly, like spiritual revelations after decades of silence.</li></ul><p>The very things that made a graph database appealing — natural modeling, fast traversals, flexible schema — get quietly neutralized by a tool that treats every query as “give me everything and let the application sort it out.”</p><p>There had to be a better way. And there is.</p><h3>A Better Way — How It Started</h3><p>The first Neo4j-powered application I worked on was a bootstrapped social platform for musicians and creatives. It wasn’t a unicorn. It was something harder: a real product with real users, tight budgets, and no tolerance for architectural indulgence.</p><p>Except…I used an OGM.</p><p>The code was beautiful. Entities were clean. Relationships were elegant. It felt expressive and modern and correct. The object graph mirrored the domain model. Everything had its place.</p><p>And then users started signing up. They signed up fast.</p><p>That’s when “the stillness” began.</p><p>Queries that once snapped to attention began lingering. Traversals expanded beyond what was asked. What should have been a pointer hop became a pilgrimage. The database — optimized for constant-time relationship hops — was now taking the scenic route through half the graph.</p><p>The abstraction that had felt so graceful in development grew heavy under load. Over-selection crept in. Entire sub-graphs were materialized because the OGM couldn’t know which parts mattered for this use case.</p><p>So we rewrote.</p><p>The next version stripped the abstraction away. Carefully tuned, explicit Cypher queries replaced the OGM. Traversals became precise again. Performance returned. Users were happy.</p><p>But there was a cost.</p><p>Infrastructure concerns leaked into business logic. Transaction handling mixed with use-case code. Boilerplate multiplied. The code was fast — but less cohesive. The pendulum had swung from beautiful abstraction to raw control.</p><p>That tension — between architectural clarity and database-native performance — <strong>is where Drivine was born</strong>.</p><blockquote>Contrary to what some say, I’m convinced that clean code — with design patterns, high cohesion and adhering to single responsibility principle is as important as ever in this era of AI-assisted coding. LLMs are great at switching between big picture concerns and details as needed. But don’t give them a big picture that looks like a plate of spaghetti.</blockquote><h3>A Better Way — How It Is Going</h3><p>Originally introduced <a href="https://github.com/liberation-data/drivine/">as a TypeScript library</a>, Drivine took a different position. Use-case-specific, carefully tuned Cypher queries could be projected onto an object graph. The raw power of Cypher was preserved. Performance remained explicit.</p><p>The library handled transaction management, boilerplate, and infrastructure concerns. In this way, cohesive design patterns and the single-responsibility principle could coexist with the database’s native strengths.</p><p>The traditional ORM gives you a single object model — one entity to serve every use case — and then generates queries on your behalf. This is the canonical solution to the “impedance mismatch,” that perennial tension between how your code thinks about data and how your database stores it.</p><h3>Here’s The Thing About Graph Databases</h3><p>T<strong>he impedance mismatch largely evaporates</strong>. Your object graph <em>is</em> a graph. The shape of your data in Neo4j is already the shape of your data in memory. You can compose an entire object graph in the query itself, returning JSON. No translation layer needed. The data just… fits. The OGM was solving a problem that, for graphs, barely exists. And creating several new ones along the way.</p><p><a href="https://github.com/liberation-data/drivine4j">Drivine4j</a> is the Kotlin/Java port. Same philosophy. Kotlin’s Java-compatible type system.</p><h3>JSON: The Lingua Franca of (Almost) Everything</h3><p>When your queries return JSON-like structures instead of columnar result sets, two genuinely useful things happen.</p><p>First, you can hand all the mapping tedium — type conversion, null handling, the kind of work that’s important but nobody’s excited about — to a custom<a href="https://en.wikipedia.org/wiki/Jackson_(API)"> Jackson ObjectMapper</a>. You configure it once. It handles the details. You get on with the interesting parts.</p><p>Second, and this is increasingly relevant: <strong><em>JSON is the lingua franca of LLMs</em></strong>. Your graph query returns a structure that an AI model can consume directly. No transformation pipeline. No adapter layer. The data arrives ready to work with.</p><p><strong>Two Levels of Abstraction</strong> (Because the Right Number Isn’t One)</p><p>Drivine4j gives you two ways to talk to your database. This turns out to be the right number. Sometimes you need precision. Sometimes you need convenience. A framework that makes you choose one forever isn’t being opinionated — it’s being inflexible. And inflexibility is just rigidity with better marketing.</p><h3>The PersistenceManager: When <em>You</em> Want to Drive</h3><p>The first level is the <strong>PersistenceManager</strong>. You write the Cypher. Drivine handles the infrastructure — transaction management, connection connection management, keeping your business logic and your plumbing in separate rooms so they can each do their job without tripping over each other.</p><pre>@Component<br>class PersonRepository @Autowired constructor(<br>    @param:Qualifier(&quot;neoManager&quot;) private val persistenceManager: PersistenceManager,<br>    @param:Qualifier(&quot;listPersons&quot;) private val stmtListPersons: CypherStatement<br>) {<br><br>    @Transactional<br>    fun findPersonsByCity(city: String): List&lt;Person&gt; {<br>        return persistenceManager.query(<br>            QuerySpecification<br>                .withStatement(stmtListPersons)<br>                .bind(mapOf(&quot;city&quot; to city))<br>                .limit(15)<br>                .transform(Person::class.java)<br>                .filter { it.isActive }<br>        )<br>    }<br>}</pre><p>If you like, the query can live in an external .cypher file:</p><pre>MATCH (p:Person)<br> WHERE p.city = $city AND p.createdBy = &#39;test&#39;<br>RETURN properties(p)</pre><p>You can read it. You can tune it. You can test it independently. I showed a simple case here, but you can put deep object graphs and/or complex queries.</p><h3>Why does this matter?</h3><p>In theory, declarative query languages let you describe <em>what</em> you want and leave the <em>how</em> to the optimizer. The shape of the query shouldn’t matter.</p><p>In practice, everyone knows it does. We’ve all seen the benchmark. We’ve all rewritten a query that looked perfectly reasonable but performed terribly. The optimizer is powerful — but it isn’t magic. Query structure influences execution plans, cardinality estimates, and memory use.</p><p>ORM abstractions can quietly encourage a kind of optimism: <em>the framework will sort it out</em>. Sometimes it does. And sometimes it generates a query you would never have written yourself — and you’re left debugging something you didn’t actually author.</p><p>That’s one important reason to use Cypher directly. When performance or clarity matters, you should be able to shape the query deliberately — not fight your tooling to do it.</p><p>There’s another reason, too. Some problems simply <em>fit</em> better in Cypher. Complex traversals. Pattern-heavy logic. Fine-grained control over matching and aggregation. In those cases, expressing the graph directly is often clearer, more precise, and easier to reason about than trying to model it indirectly through an abstraction layer.</p><p>Declarative doesn’t mean indifferent to structure. And good tools shouldn’t make direct expression feel like an escape hatch. It should feel natural.</p><h3>GraphObjectManager: Autopilot Done Right</h3><p>The second level is the <strong>GraphObjectManager</strong>, and this is where Drivine’s philosophy really comes through.</p><p>Traditional ORMs over-select. They load the whole entity because they can’t predict which fields you’ll actually use. GraphQL popularized a better idea: <strong><em>fetch what you need</em>.</strong> Drivine4j takes this seriously. You create models for specific use cases — not one model to answer every possible question, but one model per <strong><em>question you’re actually asking</em></strong>.</p><pre>@NodeFragment(labels = [&quot;Person&quot;, &quot;Mapped&quot;])<br>data class Person(<br>    @NodeId val uuid: UUID,<br>    val name: String,<br>    val bio: String?<br>)<br><br>@RelationshipFragment<br>data class WorkHistory(<br>    val startDate: LocalDate,<br>    val role: String,<br>    val target: Organization<br>)<br><br>@GraphView<br>data class PersonCareer(<br>    @Root val person: Person,<br><br>    @GraphRelationship(type = &quot;WORKS_FOR&quot;)<br>    val employmentHistory: List&lt;WorkHistory&gt;<br>)</pre><p>A <strong>PersonCareer</strong> isn’t a <strong>Person</strong>. It’s a <strong><em>view</em></strong> of a person — specifically, a person in the context of where they’ve worked. Need a person in the context of their social connections? That’s a different <a href="http://twitter.com/GraphView"><strong>@GraphView</strong></a>. Different question, different shape. Same underlying node in Neo4j. Similarly an <strong>@NodeFragment </strong>need not be all of a node. Just what’s needed for this view<strong>.</strong></p><p>This maps to how we actually think. A person can be a holiday customer, a team member, a person of interest, an emergency contact. We all play multiple roles. In real life, nobody finds this confusing.</p><p>In software, we’ve spent decades trying to cram all those roles into a single class — usually via inheritance at first (“Customer extends Person”, “Employee extends Person”) — and then via sheer desperation (“PersonWithEverything”). Eventually it’s 400 lines long with thirty nullable fields and a constructor that looks like a cry for help.</p><p>Drivine’s approach is to just… stop doing that.</p><p>Model roles as composable fragments. Compose what you need for this use case. Let a “person” be a person, and let roles be roles.</p><blockquote>Composition over inheritance isn’t just cleaner design — it maps better to reality.</blockquote><h3>Type-safe DSL</h3><p>Here’s where it gets cool. You query with a type-safe DSL:</p><pre>fun findOpenIssues(): List&lt;RaisedAndAssignedIssue&gt; {<br>    return graphObjectManager.loadAll&lt;RaisedAndAssignedIssue&gt; {<br>        where {<br>            issue.state eq &quot;open&quot;<br>            issue.locked eq false<br>        }<br>        orderBy {<br>            issue.id.desc()<br>        }<br>    }<br>}</pre><p>And it composes across relationships:</p><pre>fun findIssuesFromOrganization(orgName: String): List&lt;RaisedAndAssignedIssue&gt; {<br>    return graphObjectManager.loadAll&lt;RaisedAndAssignedIssue&gt; {<br>        where {<br>            raisedBy.worksFor.name eq orgName<br>        }<br>    }<br>}</pre><p>That <strong>raisedBy.worksFor.name</strong> traversal is compile-time checked. Your IDE autocompletes it. The generated Cypher is readable. This is what happens when you design a graph persistence layer for graphs, instead of adapting a relational one and hoping for the best.</p><h3>What about Depth?</h3><p>The examples above show a single level of relationships: an issue with assignees, a person with employers. But real graph models aren’t flat. They nest. An issue is raised by a person, who works for an organization, which has a location, which is part of a region. The question is: <strong>how deep can you go</strong>?</p><p>The answer: <strong>as deep as you need</strong>. The relationship targets in a <a href="http://twitter.com/GraphView"><strong>@GraphView</strong></a> can be:</p><ul><li>A <a href="http://twitter.com/NodeFragment"><strong>@NodeFragment</strong></a> — the simple case. Load a related node with its properties.</li><li>Another <a href="http://twitter.com/GraphView"><strong>@GraphView</strong></a> — nested views. The target has its own root fragment <strong>and</strong> its own relationships, recursively resolved — this <a href="http://twitter.com/GraphView"><strong>@GraphView</strong></a> can refer to other <a href="http://twitter.com/GraphView"><strong>@GraphView</strong></a>’s as well.</li><li>A <strong>@RelationshipFragment</strong>— when the relationship itself carries data. Edge properties plus the target node, captured in a single object, again recursively, if needed.</li></ul><p>Here’s what three levels of nesting looks like:</p><pre>// Level 3 — the leaf<br>@GraphView<br>data class DeeplyNestedView(<br>    @Root val person: Person,<br><br>    @GraphRelationship(type = &quot;WORKS_FOR&quot;, direction = Direction.OUTGOING)<br>    val employer: Organization?<br>)<br><br>// Level 2 — the middle<br>@GraphView<br>data class MiddleLevelView(<br>    @Root val issue: Issue,<br><br>    @GraphRelationship(type = &quot;RAISED_BY&quot;, direction = Direction.OUTGOING)<br>    val raisedBy: DeeplyNestedView?<br>)<br><br>// Level 1 — the top<br>@GraphView<br>data class TopLevelWithNestedGraphViews(<br>    @Root val root: Organization,<br><br>    @GraphRelationship(type = &quot;HAS_ISSUES&quot;, direction = Direction.OUTGOING)<br>    val issues: List&lt;MiddleLevelView&gt;<br>)</pre><p>And when relationships themselves carry meaning — not just “who’s connected” but “how” — that’s what <strong>@RelationshipFragment</strong> is for:</p><pre>@RelationshipFragment<br>data class WorkHistory(<br>    val startDate: LocalDate,    // property on the edge<br>    val role: String,            // property on the edge<br>    val target: Organization     // the target node<br>)<br><br>@GraphView<br>data class PersonCareer(<br>    @Root val person: Person,<br><br>    @GraphRelationship(type = &quot;WORKS_FOR&quot;)<br>    val employmentHistory: List&lt;WorkHistory&gt;<br>)</pre><p>The employment history isn’t just a list of organizations — it’s a list of <strong><em>relationships</em></strong>, each carrying its own start date and role. The edge has data. Drivine models that directly.</p><h3>Show Me the Cypher</h3><p>One promise of Drivine is that the generated Cypher should be something you’d actually want to read. Here’s what the <strong>GraphObjectManager</strong> generates for a two-level <strong>RaisedAndAssignedIssue</strong> view (actual test output, not a cleaned-up example):</p><pre>MATCH (issue:Issue)<br>WHERE EXISTS { (issue)-[:RAISED_BY]-&gt;(_:Person:Mapped) }<br><br>WITH<br>    // Issue<br>    issue {<br>        body: issue.body,<br>        id: issue.id,<br>        locked: issue.locked,<br>        state: issue.state,<br>        title: issue.title,<br>        uuid: issue.uuid,<br>        labels: labels(issue)<br>    } AS issue,<br><br>    // assignedTo (0 or many Person)<br>    [(issue)-[:ASSIGNED_TO]-&gt;(assignedTo:Person:Mapped) |<br>        assignedTo {<br>            bio: assignedTo.bio,<br>            name: assignedTo.name,<br>            uuid: assignedTo.uuid,<br>            labels: labels(assignedTo)<br>        }<br>    ] AS assignedTo,<br><br>    // raisedBy (0 or 1 PersonContext)<br>    [(issue)-[:RAISED_BY]-&gt;(raisedBy:Person:Mapped) |<br>        raisedBy {<br>            person: {<br>                bio: raisedBy.bio,<br>                name: raisedBy.name,<br>                uuid: raisedBy.uuid<br>            },<br>            worksFor: [<br>                (raisedBy)-[:WORKS_FOR]-&gt;(worksFor:Organization) |<br>                worksFor {<br>                    name: worksFor.name,<br>                    uuid: worksFor.uuid,<br>                    labels: labels(worksFor)<br>                }<br>            ]<br>        }<br>    ][0] AS raisedBy<br><br>RETURN {<br>    issue: issue,<br>    assignedTo: assignedTo,<br>    raisedBy: raisedBy<br>} AS result</pre><p>Compare that with what a typical OGM generates. This query is commented. It selects exactly the fields each fragment declares. Collections use pattern comprehensions. Single relationships use `[0]`. Nested <strong>@GraphView</strong> targets (like <strong>PersonContext</strong>) are recursively projected — <strong>raisedBy</strong> includes its own <strong>person</strong> root fragment <strong><em>and</em></strong> its <strong>worksFor </strong>relationship, all in one query.</p><p>You could paste this into the Neo4j browser, read it, debug it, tune it. That’s the point.</p><p>And for three levels of nesting? The generated Cypher just keeps composing:</p><pre>MATCH (root:Organization)<br><br>WITH<br>    root {<br>        name: root.name,<br>        uuid: root.uuid,<br>        labels: labels(root)<br>    } AS root,<br><br>    // issues (0 or many MiddleLevelView)<br>    [(root)-[:HAS_ISSUES]-&gt;(issues:Issue) |<br>        issues {<br>            issue: {<br>                body: issues.body,<br>                id: issues.id,<br>                locked: issues.locked,<br>                state: issues.state,<br>                title: issues.title,<br>                uuid: issues.uuid<br>            },<br>            raisedBy: [<br>                (issues)-[:RAISED_BY]-&gt;(raisedBy:Person:Mapped) |<br>                raisedBy {<br>                    person: {<br>                        bio: raisedBy.bio,<br>                        name: raisedBy.name,<br>                        uuid: raisedBy.uuid<br>                    },<br>                    employer: [<br>                        (raisedBy)-[:WORKS_FOR]-&gt;(employer:Organization) |<br>                        employer {<br>                            name: employer.name,<br>                            uuid: employer.uuid,<br>                            labels: labels(employer)<br>                        }<br>                    ][0]<br>                }<br>            ][0]<br>        }<br>    ] AS issues<br><br>RETURN {<br>    root: root,<br>    issues: issues</pre><p>Organization → Issues → Raised By (Person + Employer). Three levels deep, one query, all readable. The structure mirrors the Kotlin model definitions almost exactly.</p><h3>Saving: Cascade Policies</h3><p>Loading is half the story. When you save a modified <strong>@GraphView</strong>, Drivine needs to know what to do with relationships that have changed — particularly when a relationship is removed. Did you just unlink the node, or do you want it gone? Drivine provides three cascade policies:</p><pre>enum class CascadeType {<br>    NONE,           // Delete the relationship only. The safest default.<br>    DELETE_ORPHAN,  // Delete the target if no other relationships point to it.<br>    DELETE_ALL      // Delete the target unconditionally. The nuclear option.<br>}<br><br>// Usage<br>graphObjectManager.save(modifiedView, CascadeType.DELETE_ORPHAN)</pre><p><strong>NONE</strong> is the default — removing an assignee from an issue shouldn’t delete the person. <strong>DELETE_ORPHAN</strong> is the thoughtful middle ground: clean up after yourself, but only if nobody else is using it. <strong>DELETE_ALL</strong> is there for when you mean it.</p><h3>The Snapshot Cache: Only Write What Changed</h3><p>When the <strong>GraphObjectManager</strong> loads an object, it takes a JSON snapshot. When you save it back, it compares the current state to the snapshot and only writes the fields that actually changed.</p><p>This is a small optimization that compounds. If you load a view with twenty fields and change one, the merge statement targets that one field. No unnecessary writes. No accidental overwrites of data that another process might have changed in the meantime.</p><p>The session tracks objects by <strong>className:id</strong>, so it works across nested fragments. Load an issue, modify the <strong>title</strong>, save — Drivine merges only the title. The <strong>assignees</strong>, the <strong>raiser</strong>, the <strong>organization</strong> chain — all untouched.</p><h3>Ontologies and Recursive Structures</h3><p>There’s one pattern that graph databases handle beautifully but most persistence layers don’t touch: recursive relationships. Ontologies and hierarchies — the kind of structures that let you infer new facts from the shape of the data itself.</p><pre>(:Location)-[:HAS_LOCATION]-&gt;(:Location)-[:HAS_LOCATION]-&gt;(:Location)</pre><p>A country contains regions. Regions contain countries. Countries contain cities.</p><blockquote>In a knowledge graph, this kind of hierarchy lets you reason upward and downward — “is this address in France?” becomes a traversal, not a lookup table.</blockquote><p>These structures are central to AI applications. RAG pipelines use them to ground responses in structured knowledge. Agent systems use them to reason over domain models. Drivine supports them directly.</p><p>A <strong>@GraphView</strong> can reference its own type. Drivine expands the self-reference to a configurable depth using nested pattern comprehensions:</p><pre>@NodeFragment(labels = [&quot;Location&quot;])<br>data class Location(<br>    @NodeId val uuid: UUID,<br>    val name: String,<br>    val type: String<br>)<br><br>@GraphView<br>data class LocationHierarchy(<br>    val location: Location,<br>    @GraphRelationship(type = &quot;HAS_LOCATION&quot;, direction = Direction.OUTGOING, maxDepth = 3)<br>    val subLocations: List&lt;LocationHierarchy&gt;  // Self-referential<br>)</pre><p><strong>maxDepth = 3</strong> means Drivine expands three levels deep. Load a continent and you get:</p><pre>Europe (continent)<br>├── Western Europe (region)<br>│ ├── France (country) → subLocations: []<br>│ ├── Germany (country) → subLocations: []<br>│ └── …<br>├── Northern Europe (region)<br>│ ├── Sweden (country) → subLocations: []<br>│ └── …<br>└── …</pre><p>At the terminal depth, collections become <strong>[]</strong> and nullable singles become <strong>null</strong>. No infinite recursion. No “fetch the entire graph” surprises.</p><p>The generated Cypher nests pattern comprehensions at each level:</p><pre>MATCH (location:Location)<br>WITH<br>    location { name: location.name, type: location.type, uuid: location.uuid } AS location,<br>    [(location)-[:HAS_LOCATION]-&gt;(sub_d1:Location) |<br>        sub_d1 {<br>            location: { name: sub_d1.name, type: sub_d1.type, uuid: sub_d1.uuid },<br>            subLocations: [(sub_d1)-[:HAS_LOCATION]-&gt;(sub_d2:Location) |<br>                sub_d2 {<br>                    location: { name: sub_d2.name, ... },<br>                    subLocations: [(sub_d2)-[:HAS_LOCATION]-&gt;(sub_d3:Location) |<br>                        sub_d3 { location: { ... }, subLocations: [] }<br>                    ]<br>                }<br>            ]<br>        }<br>    ] AS subLocations<br>RETURN { location: location, subLocations: subLocations } AS result</pre><p>Readable. Debuggable. And you didn’t write it.</p><blockquote><strong>Why nested comprehensions instead of variable-length paths?</strong> Variable-length paths ([0..3] return flat results — reconstructing the tree client-side or in Cypher negates traversal speed advantage. Comprehensions return the tree pre-shaped, matching the data class directly, so Jackson can map it. It also generalizes beyond simple self-references like (:Location)-[:HAS_LOCATION]-&gt;(:Location) to repeating patterns like (:Researcher)-[:AUTHORED]-&gt;(:Paper)-[:AUTHORED_BY]-&gt;(:Researcher)</blockquote><p><strong>Traversing upward</strong> is just a different view with <strong>Direction.INCOMING</strong>:</p><pre>@GraphView<br>data class LocationAncestry(<br>    val location: Location,<br>    @GraphRelationship(type = &quot;HAS_LOCATION&quot;, direction = Direction.INCOMING, maxDepth = 3)<br>    val parent: LocationAncestry?  // Nullable — each location has at most one parent<br>)</pre><p>Load “France” and you get <strong>France → Western Europe → Europe → null</strong>. Same model, different direction, same compositional approach.</p><p>You can also override depth at query time:</p><pre>graphObjectManager.loadAll&lt;LocationHierarchy&gt; {<br>    depth(&quot;subLocations&quot;, 5)  // Override the annotation&#39;s maxDepth=3<br>    where { query.location.type eq &quot;continent&quot; }<br>}</pre><p>And for structures where the cycle goes through multiple types — Person → Organization → Person — Drivine tracks visit counts and terminates at <strong>maxDepth</strong>, so you get bounded traversal without manual bookkeeping.</p><h3>What’s Next</h3><p>The original TypeScript Drivine supports multiple Cypher-compatible graph databases — not just Neo4j, but any engine that speaks the language. In principle, Drivine4j could do the same. The architecture doesn’t assume Neo4j internals; it generates standard Cypher.</p><p>In practice, Neo4j is miles ahead. And not all graph databases support the Cypher features Drivine leans on — pattern comprehensions, `labels()`, `EXISTS` subqueries. The further you go from Neo4j, the more you’d need to negotiate what’s available. It’s doable, but there may be compromises for other platforms.</p><p>Still, the door is open. If the Cypher ecosystem continues to converge — and there are signs that it will — multi-database support becomes a natural extension rather than a rewrite.</p><h3>Wrapping Up</h3><p>Drivine4j started as a reaction to a real problem — an OGM that made a fast database slow — and grew into a framework with a genuine point of view. Let’s recap what it offers.</p><p><strong>Two levels of abstraction</strong>, because that’s how many you actually need:</p><p>- The <strong>PersistenceManager</strong> lets you write your own Cypher — tuned, tested, readable — and project the results onto typed objects, or return one or more simple scalar values. Drivine handles transactions, connection pooling, and the infrastructure plumbing. You keep control of the query. This is the right choice when performance matters, when the query is complex, or when you simply want to see exactly what’s hitting the database.</p><p>- The <strong>GraphObjectManager</strong> generates Cypher from compositional models. <strong>@NodeFragment</strong>, <strong>@RelationshipFragment</strong>, and <strong>@GraphView</strong> compose into use-case-specific views — not one god-entity for all scenarios, but one model per question you’re asking. The generated Cypher is readable and debuggable. Dirty tracking means saves only write what changed. Cascade policies give you control over deletions.</p><p><strong>The type-safe DSL</strong> deserves a special mention. Built via KSP code generation, it gives you compile-time checked queries with full IDE autocomplete — filter across nested relationships, sort collections database-side with APOC, query polymorphic types with <strong>instanceOf&lt;T&gt;()</strong>. It makes the <strong>GraphObjectManager</strong> feel less like a framework and more like a language feature. And if Java is your thing, there’s a fluent Java API that mirrors the Kotlin DSL — <strong>filterWith()</strong>, <strong>where()</strong>, <strong>whereAny()</strong>, <strong>orderBy()</strong> — pretty much feature-parity with Kotlin.</p><p><strong>Recursive relationships</strong> let you model <strong>ontologies</strong> and hierarchies — the structures that power knowledge graphs and AI reasoning — with the same compositional approach that works for everything else.</p><p>And the generated Cypher? You can read it. You can paste it into the Neo4j browser and debug it. You can show it to someone who’s never seen Drivine and they’ll understand what it does.</p><p>If you’re working with Neo4j and you’ve felt the friction of the OGM — the slow queries, the over-selection, the generated Cypher you can’t debug — <a href="http://github.com/liberation-data/drivine4j">Drivine4j</a> might be worth a look. It’s open source and we make good use of it in the <a href="https://github.com/embabel">Embabel</a> ecosystem.</p><p>Next up: I’ll show how we used Drivine4j to build <a href="https://github.com/embabel/embabel-chat-store"><strong>embabel-chat-store</strong></a> — a conversation persistence layer for AI agents where the graph model really shines. Multi-party messages, explicit attribution, async persistence. I’m looking forward to share that with you. Until then, have fun with Embabel- in our opinion, the best tool for creating Agentic AI applications :)</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6da1a8da9515" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/the-very-slowly-ticking-time-bomb-in-your-graph-persistence-stack-6da1a8da9515">The (Very Slowly) Ticking Time-Bomb in Your Graph Persistence Stack</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Recent Improvements to Embabel’s PromptRunner]]></title>
            <link>https://medium.com/embabel/recent-improvements-to-embabels-promptrunner-b1bb07190405?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/b1bb07190405</guid>
            <category><![CDATA[kotlin]]></category>
            <category><![CDATA[api]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[agentic-ai]]></category>
            <category><![CDATA[embabel]]></category>
            <dc:creator><![CDATA[Arjen Poutsma]]></dc:creator>
            <pubDate>Fri, 06 Feb 2026 11:55:01 GMT</pubDate>
            <atom:updated>2026-02-06T11:55:03.255Z</atom:updated>
            <content:encoded><![CDATA[<p>As the <a href="https://github.com/embabel/embabel-agent">Embabel Agentic Framework</a> approaches its 1.0 release, it becomes increasingly important to finalize its central APIs and ensure they are ready for the changes that inevitably follow.</p><p>One of these central APIs is PromptRunner, aptly named because it is used to run prompts against an LLM, typically using input from users, databases, or other services. This post describes a significant change to PromptRunner and related types, aimed at pruning this core API and preparing it for future growth.</p><p>PromptRunner serves as the main entry point and gateway for general LLM interaction. Specialized operations that are not available directly on PromptRunner are exposed through more focused APIs, accessed via factory methods.</p><p>For example, to create strongly typed objects, you use promptRunner.creating(Class). This returns an API dedicated to object creation, with support for examples, property filtering, and disabling validation.</p><p>Before the change described in this post, the following factory methods existed on PromptRunner, along with the interfaces they returned:</p><ul><li>creating(Class&lt;T&gt;), returning an ObjectCreator&lt;T&gt; for object creation,</li><li>withTemplate(String), returning TemplateOperations for rendering templates,</li><li>stream(), returning StreamingPromptRunnerOperations for reactive streams,</li><li>withThinking(), returning ThinkingPromptRunnerOperations for thinking prompts.</li></ul><p>Several issues became apparent when looking at this arrangement.</p><p>Some methods used the with prefix (withTemplate and withThinking), while others did not. Strictly speaking, a “with-er” creates a new instance of the same immutable type with one or more modified properties. Since these methods return different types (TemplateOperations and ThinkingPromptRunnerOperations respectively), they are not true with-ers and should therefore be named differently. Dropping the with prefix has an additional benefit: these factory methods are no longer buried among configuration with-ers when invoking <a href="https://poutsma-principles.com/blog/2025/05/15/designing-for-short-completion-menus/">Ctrl-Space</a>.</p><p>Some interfaces used theOperations suffix; others did not.</p><p>Overall, there was little overall consistency, both in the names of the factory methods and in the names of the interfaces they returned.</p><h3>The Revised API</h3><p>When we <a href="https://github.com/embabel/embabel-agent/pull/1328">addressed these problems</a>, the team expressed a clear preference for the name creating. Based on that decision, we introduced gerund forms for the other factory methods as well:</p><ul><li>PromptRunner::creating remained unchanged</li><li>withTemplate was renamed to rendering</li><li>stream was renamed to streaming</li><li>withThinking became thinking</li></ul><p>Finding suitable names for the returned interfaces proved more challenging. Several naming schemes were considered, but in the end we chose to give the interfaces the same names as their corresponding factory methods.</p><p>We also nested these interfaces into PromptRunner itself. Since these types are not meant to be instantiated directly, but only obtained through a PromptRunner, this gives them a more appropriate scope and avoids adding more types to the global namespace. While inner types result in longer names, most users will rarely need to reference these types explicitly, as they move from PromptRunner to the appropriate sub-runner as part of the same method chain.</p><p>As a result, the names now follow a consistent and predictable pattern:</p><ul><li>creating(Class&lt;T&gt;) returns PromptRunner.Creating&lt;T&gt;</li><li>rendering(String) returns PromptRunner.Rendering</li><li>streaming() returns StreamingPromptRunner.Streaming</li><li>thinking() returns PromptRunner.Thinking</li></ul><p>To maintain backward compatibility, the original methods remain available but are now deprecated.</p><p>This structure makes the relationship between factory methods and their sub-runners explicit, removes unnecessary affixes, and applies a consistent naming scheme.</p><h3>Try Embabel’s Improved API</h3><p>The changes described here are just one of many improvements underway as Embabel moves toward its 1.0 release. Please give the updated API a try and let us know how it works for you.</p><p>Feedback on this change, or any other aspect of Embabel, is always welcome on our <a href="https://discord.gg/t6bjkyj93q">Discord</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b1bb07190405" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/recent-improvements-to-embabels-promptrunner-b1bb07190405">Recent Improvements to Embabel’s PromptRunner</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Agents that extract and use preferences from conversations.]]></title>
            <link>https://medium.com/embabel/agents-that-extract-and-use-preferences-from-conversations-7b22cca9abb3?source=rss----33b58d773744---4</link>
            <guid isPermaLink="false">https://medium.com/p/7b22cca9abb3</guid>
            <category><![CDATA[context-engineering]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[embabel]]></category>
            <dc:creator><![CDATA[Jettro Coenradie]]></dc:creator>
            <pubDate>Mon, 02 Feb 2026 09:52:06 GMT</pubDate>
            <atom:updated>2026-02-02T09:52:05.651Z</atom:updated>
            <content:encoded><![CDATA[<p>In this blog, I experiment with Embabel Domain Injected Context Engineering (DICE). Dice can be used to manage extracted user preferences and use them to enrich your LLM context. I built upon the sample from my <a href="https://jettro.dev/chatbot-with-embabel-a-complete-walkthrough-cdd5f9bb76fa">previous blog</a> where I introduced the Embabel ChatBot.</p><h3>Embabel DICE</h3><p>This example is heavily inspired by the example provided by the Embabel team, <a href="https://github.com/embabel/impromptu/">Impromptu</a>. The example includes an extensive README; the DICE source is available. More <a href="https://github.com/embabel/dice">documentation</a> on DICE is also available.</p><p>A big problem when talking to an LLM, which most agents do, is the grounding gap. This is the lack of common ground between the LLM and the user’s question. The LLM generally has no idea what you are talking about. Imagine I have a conversation with the LLM in which I tell it what I like. I tell it I like to read blogposts about Agents and Embabel specifically. We had a nice chat, but I need to end the conversation.</p><p>Later that day, I go home and ask the agent or the LLM if there are any interesting new blog posts to read (I&#39;m travelling by train today). By providing my preferences to the LLM, it knows that posts about Embabel should be at the top. Of course, this is a very simple scenario. But what if I can add domain-specific knowledge to a request I make? Think about allergies when ordering food, and your home address when ordering groceries. You name it.</p><p>When automating our daily work with agents, they need to understand what we are doing. That is where Domain Injected Context Engineering comes in.</p><p>In this blog, I create an example that is not too complex to understand using DICE and Embabel, along with a GUI to follow what the agent is doing.</p><h4>GUM, the theory backing DICE</h4><p>As mentioned in the documentation, DICE is heavily inspired by the paper that introduces General User Models (GUM). The paper is good to read.</p><blockquote>Creating General User Models from Computer Use</blockquote><blockquote>Omar Shaikh, Shardul Sapkota, Shan Rizvi, Eric Horvitz, Joon Sung Park, Diyi Yang, Michael S. Bernstein</blockquote><blockquote><a href="https://arxiv.org/abs/2505.10831">https://arxiv.org/abs/2505.10831</a></blockquote><p>I do not want to give a full summary of the article; read it for yourself. But I want to describe some of the highlights for me.</p><p>The idea from GUM is to learn from user interactions with a computer. If conversations have common ground among the involved parties, they just run better. If the system can learn about the user, it can create that common ground.</p><p>By observing, the system creates propositions on what a user wants to accomplish. The system also gives a confidence factor about the accuracy of the proposition. After new observations, the system can revise some of the propositions. The system also audits the propositions for facts you do not want the system to remember.</p><p>I also liked the staleness factor of a proposition. Some memories you want to keep longer than others. This factor is part of the proposition extracted by the system.</p><h3>The steps</h3><p>In this section, I give you an overview of what we will do. I’ll explain the different steps, so you have a better idea of what is coming.</p><h4>Have an Agent capable of talking to you</h4><p>This is where I left off in the previous blog. A ChatBot with RAG, making use of Lucene to index blogs and expose it as a tool to the ChatBot. Based on the impromptu sample mentioned, I made some improvements before adding the new preference extraction. The biggest change is that the user now stores the processId; therefore, the client application no longer needs to pass it to the backend.</p><p>Attaching the user to the agent process is now performed in an Embabel action. Note how the user is obtained from the OperationContext.</p><pre>@Action<br>public KnowledgeUser bindUser(OperationContext context) {<br>    var forUser = context.getProcessContext().getProcessOptions().getIdentities().getForUser();<br>    if (forUser instanceof KnowledgeUser iu) {<br>        return iu;<br>    } else {<br>        logger.warn(&quot;bindUser: forUser is not an KnowledgeUser: {}&quot;, forUser);<br>        return null;<br>    }<br>}</pre><p>This KnowledgeUser is injected into the action that responds to a <em>UserMessage</em>.</p><pre>@Action(canRerun = true, trigger = UserMessage.class)<br>public void respond(Conversation conversation, KnowledgeUser user, ActionContext context) {<br>    var lastUserMessage = conversation.lastMessageIfBeFromUser();<br><br>    if (lastUserMessage != null) {<br>        logger.info(&quot;Received user message as last message: {}&quot;, lastUserMessage.getContent());<br>        var assistantMessage = context.ai()<br>                .withLlmByRole(CHEAPEST.name())<br>                .withReferences(toolishRag)<br>                .withSystemPrompt(&quot;You are a helpful assistant. Answer questions concisely. Always address the current user by their name: &quot; + user.getDisplayName() + &quot;.&quot;)<br>                .respond(conversation.getMessages());<br><br>        context.sendMessage(conversation.addMessage(assistantMessage));<br><br>        eventPublisher.publishEvent(new ConversationAnalysisRequestEvent(this, user, conversation));<br>    } else {<br>        logger.info(&quot;Received non-user message&quot;);<br>    }<br>}</pre><p>If you read my previous post, you can see another difference. In the code, I publish an event. This event, <em>ConversationAnalysisRequestEvent</em>, is essential for integrating the conversation with preference extraction. I get back to this event in a later section.</p><h4>Have a preference extraction tool</h4><p>The preference extraction tool receives some text and extracts the propositions for a preference from it. If I enter this text:</p><blockquote>I like to watch tutorials about Agents, especially if the speaker uses a bit of humour but clearly knows what he is talking about.</blockquote><p>The resulting propositions contain the extracted preferences, the confidence that the preference is right, and the explanation of why the preference was proposed. The first proposition is: <em>User likes to watch tutorials about Agents</em>. The extractor has a confidence of 90% and gives the following reason: <em>User explicitly says they like to watch tutorials about Agents</em>.</p><p>The image below shows the tool I created to work with the extractor tool. In Embabel, this part is called the PropositionPipeline. The extraction is exposed by the <em>PropositionPipelineController</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k-2f9LU-7mfUoQCFp-Q82w.png" /><figcaption>The proposition extractor test component. Extracts propositions from the provided text.</figcaption></figure><p>Another important piece of information for a proposition is the <em>decay</em>. A high decay rate means the proposition’s importance decreases rapidly over time. Low decay indicates the proposition remains an important preference for the user. Think about the difference between where you parked your car and the day you got married.</p><p>The following diagram gives an overview of the Embabel design for the proposition pipeline. The PropositionPipelineController is a Spring MVC Rest controller that exposes an endpoint for extracting propositions from the provided text. The MemoryController has endpoints for listing and searching the stored propositions, i.e., the memory. It also provides endpoints for creating and deleting propositions.</p><p>The PropositionPipeline orchestrates the proposition extraction. It uses the extractor to find propositions, and the revisor to determine whether we are dealing with a new proposition or an update to an existing one. Propositions are stored or updated in the repository.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VYy-5WN0pZeKrrNBjyef0g.jpeg" /><figcaption>Design for the proposition pipeline</figcaption></figure><p>The following screenshot shows some propositions that were extracted from a conversation I had with the ChatBot. Notice the <em>mentions</em> in the last item. Next to the Knowledge user, there is another entity called Product. In another post, I take a better look at entity extraction. The impromptu sample contains more entities and relations, which are stored in a graph database.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NawTDp0hQztk88a28oqpMA.png" /><figcaption>Four propositions extracted from a conversation with options to refresh the list and delete a specific proposition.</figcaption></figure><p>In the following section, I discuss integrating the PropositionPipeline with the Conversation and the ChatBot.</p><h4>Integrate the extraction tool with the conversation</h4><p>Now that I have a pipeline to extract preferences from text, I want to use this to extract user preferences from the conversation. The extraction begins by listening to an event I discussed a few sections back. After the user responds to a request, the conversation is published as an event.</p><p>The <em>ConversationAnalysisRequestEvent</em> contains the conversation and the KnowledgeUser. I wrote the <em>ConversationPropositionExtraction</em> class using the impromptu sample code. This class listens for the event and uses several components to extract entities and propositions from the conversation. The entities to extract are defined in the <em>DataDictionary</em>. With the help of the <em>ChunkHistoryStore</em>, re-analysing the same chunk is prevented. After extraction, the propositions and entities are stored in the <em>PropositionRepository</em> and the <em>EntityRepository</em>.</p><p>Internally, the PropositionPipeline discussed in the previous section is used for the extraction of the propositions. The following diagram gives an overview of the discussed components.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wdle0SBc5CUuDORcoh2Xew.jpeg" /><figcaption>Shows how the events is obtained and the propositions are extracted and stored.</figcaption></figure><p>With user preferences extracted as propositions from the conversation, we only need to use the preferences in the context of the agent.</p><h4>Inject user preferences into the context of the Agent</h4><p>Propositions are exposed through a memory component. The Memory needs the PropositionRepository and a <em>MemoryProjector</em>. This projector translates proposals as semantic memory (facts), procedural memory (preferences/rules) or episodic memory (events).</p><p>Next, the memory is exposed as a reference to the LLM call. The following code block shows the same function as before, now with the included memory.</p><pre>@Action(canRerun = true, trigger = UserMessage.class)<br>public void respond(Conversation conversation, KnowledgeUser user, ActionContext context) {<br>    var lastUserMessage = conversation.lastMessageIfBeFromUser();<br><br>    if (lastUserMessage != null) {<br>        var memory = Memory.forContext(user.getCurrentContext())<br>                .withRepository(propositionRepository)<br>                .withProjector(memoryProjector);<br><br>        logger.info(&quot;Received user message as last message: {}&quot;, lastUserMessage.getContent());<br>        var assistantMessage = context.ai()<br>                .withLlmByRole(CHEAPEST.name())<br>                .withReferences(toolishRag, memory)<br>                .withSystemPrompt(&quot;You are a helpful assistant. Answer questions concisely. Always address the current user by their name: &quot; + user.getDisplayName() + &quot;.&quot;)<br>                .respond(conversation.getMessages());<br><br>        context.sendMessage(conversation.addMessage(assistantMessage));<br><br>        eventPublisher.publishEvent(new ConversationAnalysisRequestEvent(this, user, conversation));<br>    } else {<br>        logger.info(&quot;Received non-user message&quot;);<br>    }<br>}</pre><h3>More code</h3><p>In the previous section, you read about the concepts and were introduced to the design of the Embabel ChatBot with DICE. You can now read the sample in the git repository. I always like to see the code with some explanation; therefore, I go over the same steps, but now show the code.</p><h4>Maven</h4><p>For some features, I needed the bleeding edge. At the time of writing, I am using version 0.3.3-SNAPSHOT for embabel. (0.3.3–20260126.051307–64) I will update the sample when a release becomes available that contains the things I need.</p><p>Therfore you do need to add the snapshot repo from Embabel to your pom.</p><pre>    &lt;repositories&gt;<br>        &lt;repository&gt;<br>            &lt;id&gt;embabel-snapshots&lt;/id&gt;<br>            &lt;url&gt;https://repo.embabel.com/artifactory/libs-snapshot&lt;/url&gt;<br>            &lt;releases&gt;<br>                &lt;enabled&gt;false&lt;/enabled&gt;<br>            &lt;/releases&gt;<br>            &lt;snapshots&gt;<br>                &lt;enabled&gt;true&lt;/enabled&gt;<br>            &lt;/snapshots&gt;<br>        &lt;/repository&gt;<br>    &lt;/repositories&gt;</pre><p>DICE is a separate module; we need that dependency as well.</p><pre>&lt;dependency&gt;<br>    &lt;groupId&gt;com.embabel.dice&lt;/groupId&gt;<br>    &lt;artifactId&gt;dice&lt;/artifactId&gt;<br>    &lt;version&gt;0.1.0-SNAPSHOT&lt;/version&gt;<br>&lt;/dependency&gt;</pre><p>Again, a snapshot dependency, 0.1.0–20260125.055345–4, to be specific.</p><h4>The code for the PropositionPipeline</h4><p>Most of the code is Spring configuration code. So a lot of beans. I start with the bean for the proposition repository.</p><pre>@Bean<br>PropositionPipeline propositionPipeline(<br>        PropositionExtractor propositionExtractor, <br>        PropositionReviser propositionReviser, <br>        PropositionRepository propositionRepository) {<br>    <br>    return PropositionPipeline.withExtractor(propositionExtractor)<br>            .withRevision(propositionReviser, propositionRepository);<br>}</pre><p>Just like in the design diagram you have seen in a previous section. The pipeline has three dependencies. It needs an extractor, a reviser and a repository.</p><p>First, the repository needs the model provider to select an Embedder. I like to select it by role. This is configured in the application.yml and an enum. I use the InMemory version of the repository. It only needs an embedding model to create the vectors for similarity search.</p><pre>@Bean<br>PropositionRepository propositionRepository(ModelProvider modelProvider) {<br>    return new InMemoryPropositionRepository(<br>            modelProvider.getEmbeddingService(ByRoleModelSelectionCriteria.Companion.byRole(FAST.name()))<br>    );<br>}</pre><p>Next, we need the reviser. The reviser uses an LLM to find existing revisions of a proposition.</p><pre>@Bean<br>PropositionReviser propositionReviser(AiBuilder aiBuilder) {<br>    var ai = aiBuilder<br>            .withShowPrompts(true)<br>            .withShowLlmResponses(true)<br>            .ai();<br>    return LlmPropositionReviser<br>            .withLlm(LlmOptions.withLlmForRole(STANDARD.name()))<br>            .withAi(ai);<br>}</pre><p>Notice that we tell Embabel to show the prompt and the LLM response. In the impromptu example, these properties are extracted from a Properties object to make them configurable. That is better, but this is easier for now.</p><p>Finally, you need the extractor. This is a bigger one, follows the same structure as the reviser, but with a big twist.</p><pre>@Bean<br>LlmPropositionExtractor llmPropositionExtractor(<br>        AiBuilder aiBuilder,<br>        PropositionRepository propositionRepository) {<br>    var ai = aiBuilder<br>            .withShowPrompts(true)<br>            .withShowLlmResponses(true)<br>            .ai();<br>    return LlmPropositionExtractor<br>            .withLlm(LlmOptions.withLlmForRole(STANDARD.name()))<br>            .withAi(ai)<br>            .withPropositionRepository(propositionRepository)<br>            .withSchemaAdherence(SchemaAdherence.DEFAULT)<br>            .withTemplate(&quot;extract_user_propositions&quot;);<br>}</pre><p>Notice that the code uses a template for the LLM prompt. Again copied at first from the impromptu sample. But made it work for my sample. It is a long one, but I think it is worth a look.</p><pre>You are extracting propositions about blog writers and readers<br>from their conversation.<br>Extract facts about the USER only -<br>their preferences, knowledge, interests, and opinions about blogs and technology.<br><br>{% include &quot;dice/schema_hints.jinja&quot; %}<br><br>{% include &quot;dice/existing_propositions.jinja&quot; %}<br><br>## Extraction Rules<br><br>1. **User-centric**: Extract facts about the user, not general blogging knowledge<br>2. **Single fact per proposition**: One subject, one object maximum<br>3. **No inference beyond evidence**: Only extract what the text explicitly supports<br>4. **No overlap**: Each fact should be distinct<br><br>## Examples<br><br>User says &quot;I want to read blogs about Embabel&quot;:<br>✓ GOOD:<br>  - &quot;Jettro likes to read blogs&quot; (NO entity mention - period/style)<br>  - &quot;Jettro likes blogs about Embabel&quot; (entity: Embabel → Product)<br>  - &quot;Jettro programs best in Python and Java&quot; (entity: Java -&gt; ProgrammingLanguage)<br><br>## Confidence &amp; Decay<br><br>- **High confidence**: Explicit statements (&quot;I love reading about AI&quot;)<br>- **Lower confidence**: Implied preferences (asking many questions about a topic)<br>- **Low decay**: Stable preferences (favorite topic, category preferences)<br>- **High decay**: Transient states (current writing topic)<br><br>## Input<br><br>User: {{ user.name }}<br><br>&lt;conversation&gt;<br>    {{ model.chunk.text }}<br>&lt;/conversation&gt;</pre><p>Notice how the template includes templates provided by DICE. The template contains the extraction rules, examples and explains the concept of confidence and decay.</p><p>With this configuration, the pipeline is usable by the provided PropositionPipelineController to request propositions for a given piece of text.</p><h4>The code for extracting propositions from the conversation</h4><p>Next up, the class that listens for <em>ConversationAnalysisRequestEvent </em>and handles them by starting the extraction process. This is the <em>ConversationPropositionExtraction</em>, which contains most of the required code to work with propositions.</p><p>The class needs an entity resolver, an entity repository, a proposition pipeline, a proposition repository, a data dictionary and a chunk history store. The proposition pipeline and repository are already available.</p><p>First, the data dictionary. This contains the entities that can be extracted and used in the propositions. At this moment, I have the KnowledgeUser, the Product and the ProgrammingLanguage.</p><pre>@Bean<br>@Primary<br>DataDictionary blogDataDictionary() {<br>    var schema = DataDictionary.fromClasses(&quot;blog&quot;,<br>            Product.class,<br>            ProgrammingLanguage.class,<br>            KnowledgeUser.class<br>    );<br>    logger.info(&quot;Initialized data dictionary with classes: Product, ProgrammingLanguage&quot;);<br>    return schema;<br>}</pre><p>This DataDictionary is used by the SchemaRegistry; in my case, again, an InMemory version.</p><pre>@Bean<br>SchemaRegistry schemaRegistry(DataDictionary blogDataDictionary) {<br>    var registry = new InMemorySchemaRegistry(blogDataDictionary);<br>    logger.info(&quot;Initialized schema registry with classes: Product, ProgrammingLanguage&quot;);<br>    return registry;<br>}</pre><p>All the other required beans are InMemory implementations.</p><pre>@Bean<br>EntityResolver entityResolver() {<br>    return new InMemoryEntityResolver();<br>}<br><br>@Bean<br>ChunkHistoryStore chunkHistoryStore() {<br>    return new InMemoryChunkHistoryStore();<br>}<br><br>@Bean<br>NamedEntityDataRepository namedEntityDataRepository(DataDictionary dataDictionary, ModelProvider modelProvider) {<br>    return new InMemoryNamedEntityDataRepository(<br>            dataDictionary,<br>            modelProvider.getEmbeddingService(ByRoleModelSelectionCriteria.Companion.byRole(FAST.name()))<br>    );<br>}</pre><p>The constructor of the <em>ConversationPropositionExtraction</em> initialises the <em>IncrementalAnalyzer</em>. This analyser is responsible for analysing the conversation to extract propositions, if required.</p><pre>this.analyzer = new PropositionIncrementalAnalyzer&lt;&gt;(<br>        propositionPipeline,<br>        chunkHistoryStore,<br>        MessageFormatter.INSTANCE,<br>        config<br>);</pre><p>The following codeblock shows the extraction and persistence of the propositions. I removed some logging; check the actual source code if you want the complete picture.</p><pre>public void extractPropositions(ConversationAnalysisRequestEvent event) {<br>    logger.info(&quot;Starting proposition extraction for conversation with {} messages&quot;,<br>            event.conversation.getMessages().size());<br><br>    var messages = event.conversation.getMessages();<br>    if (messages.size() &lt; 2) {<br>        logger.info(&quot;Not enough messages to extract propositions, need at least 2&quot;);<br>        return;<br>    }<br><br>    var context = SourceAnalysisContext<br>            .withContextId(event.user.getCurrentContext())<br>            .withEntityResolver(entityResolverForUser(event.user))<br>            .withSchema(dataDictionary)<br>            .withKnownEntities(KnownEntity.asCurrentUser(event.user))<br>            .withPromptVariables(Map.of(<br>                    &quot;user&quot;, event.user<br>            ));<br><br>    // Wrap conversation as incremental source and analyze<br>    var source = new ConversationSource(event.conversation);<br>    var result = analyzer.analyze(source, context);<br><br>    if (result == null) {<br>        logger.info(&quot;Analysis skipped (not ready or already processed)&quot;);<br>        return;<br>    }<br><br>    if (result.getPropositions().isEmpty()) {<br>        logger.info(&quot;Analysis completed but no propositions extracted&quot;);<br>        return;<br>    }<br><br>    result.persist(propositionRepository, entityRepository);<br>}</pre><p>The last line adds the new entities and propositions to their respective repositories.</p><h4>The code to use the propositions</h4><p>I already discussed this code in the previous section, when I discussed the changes to the action to add the memory as a resource to the LLM call. The only thing missing is the memory projector&#39;s config.</p><pre>@Bean<br>MemoryProjector memoryProjector() {<br>    return DefaultMemoryProjector.DEFAULT;<br>}</pre><h3>Running the application</h3><p>One thing I really like about Embabel is the logging. You can follow everything from the logs. With the trace of Agent events, you also track LLM calls, tool calls, and, of course, their responses.</p><p>First question:</p><blockquote>I am really interested in blogs about Embabel. Do you have information about such blogs?</blockquote><p>In the log, you can find this line. Notice how “I” is extracted as a KnowledgeUser.</p><pre>20:32:05.318 [tomcat-handler-16] INFO ConversationPropositionExtraction - ChunkPropositionResult(chunk=423b39ea-0004–4a5f-8873–1317c44fe2a4, propositions=2, entities: 1 new, 0 updated, revision: 2 new, 0 merged, 0 reinforced, 0 contradicted, 0 generalized)<br> Propositions:<br> • Jettro is really interested in blogs about Embabel (conf: 0.98) [NEW]<br> • Jettro asked for information about blogs about Embabel (conf: 0.95) [NEW]<br> Entities:<br> • [NEW] I (KnowledgeUser, __Entity__)</pre><p>After some questions and answers, go to the propositions tab to find what propositions are extracted. I removed a few to make the list easier to read. I personally like the middle one, where a proposition is extracted from the assistant&#39;s response.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nDRbI6nUd7DaNSHQ5yxCTA.jpeg" /><figcaption>Some extracted propositions from a conversation between me and the agent about Embabel.</figcaption></figure><h3>References</h3><p>That is it. I hope you learned something from this blog. I know I learned a lot while writing the blog and creating the sample. I keep adding features to the sample. I try to keep track of the latest and greatest Embabel features.</p><p>Drop me a comment if you liked it or if you have a question.</p><ul><li>Blogs about Embabel: <a href="https://medium.com/embabel">https://medium.com/embabel</a></li><li>Impromptu sample: <a href="https://github.com/embabel/impromptu">https://github.com/embabel/impromptu</a></li><li>Embabel homepage: <a href="https://github.com/embabel/embabel-agent">https://github.com/embabel/embabel-agent</a></li><li>DICE: <a href="https://github.com/embabel/dice">https://github.com/embabel/dice</a></li><li>This sample: <a href="https://github.com/jettro/embabel-agent-rag-sample">https://github.com/jettro/embabel-agent-rag-sample</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7b22cca9abb3" width="1" height="1" alt=""><hr><p><a href="https://medium.com/embabel/agents-that-extract-and-use-preferences-from-conversations-7b22cca9abb3">Agents that extract and use preferences from conversations.</a> was originally published in <a href="https://medium.com/embabel">Embabel</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>