What Embabel is
Embabel is an agent framework for the
JVM, built on Spring by Rod Johnson. Its distinguishing bet is stated plainly in
the docs: instead of a finite state machine or a sequential chain with nesting,
it introduces a true planning step, using a non-LLM AI algorithm.
The vocabulary is small. From the framework's own Core Concepts:
- Actions — steps an agent takes.
- Goals — what an agent is trying to achieve.
- Conditions — evaluated while planning, and reassessed after each action.
- Domain Model — the objects underpinning the flow.
From those, the framework builds a plan: a sequence of actions that reaches a
goal.
Plans are dynamically formulated by the system, not the programmer.
Planning is Goal-Oriented Action Planning, or GOAP. After every action the
framework looks at the world again, searches with A* for the cheapest path from
where it now is to the goal, and executes the next step. The docs call the result
an OODA loop: observe, orient, decide, act. The practical consequence is that
nothing in your code says "first check stock, then pick, then pack". You
declare what each action needs and what it produces, and the order is derived.
State lives in a blackboard, the shared memory of one agent process. Actions
read their inputs from it and their return values are added to it automatically;
conditions are evaluated against its contents. A process moves through explicit
states: RUNNING, COMPLETED, WAITING, FAILED, and STUCK, which the docs
define as "the process cannot formulate a plan to progress".
That is the whole conceptual surface. Two annotations and a domain model in Java
or Kotlin, and the framework infers the rest, conditions included: a method
signature retrieveHoroscope(StarPerson) is the precondition "a StarPerson
exists", and its return type is the postcondition.
Why Clojure
Actions with preconditions, postconditions and costs. Goals with values.
Conditions that are names with truth values. A blackboard that is a bag of named
things. That is a graph plus a table of numbers: a world model. It is data, and
the framework searches it with A*.
In Java and Kotlin that model is expressed indirectly, through the type system
and annotations: the compiler and AgentMetadataReader extract the graph from
your class. That works, and the type inference in the annotated model is
genuinely clever. But the graph itself never exists as a value you can hold.
Clojure's whole proposition is that data is the interface. So the question this
library asks is narrow and testable:
If the planner's world model is a data structure, what happens when you write
it as one, literally, as a value?
Four things follow, and this post checks each one:
-
The agent becomes a value. You can
pprintit, diff two versions, store it as EDN, generate it, send it over a wire. -
The LLM speaks the language's own data syntax. When a model answers with
{:biome "Taiga" :confidence 0.8},edn/read-stringhands you a Clojure map. No Jackson, no POJO, no binding layer. - Schemas are data too. One malli schema generates the prompt, validates the answer, coerces the types, and re-asks the model on failure.
- The REPL runs against a live platform. Redefine an action body and run again, with no rebuild and no redeploy.
None of this is an argument that you should write Embabel agents in Clojure
rather than in Kotlin or Java. The framework is excellent in its home languages
and the annotated model is the shortest path for most people. This post is about
what a different representation makes possible, and what it costs.
A Rosetta stone: the same agent, in both languages
Before any Clojure, here is the agent the Embabel documentation itself uses to
introduce the framework: StarNewsFinder, shortened to three actions. If you
have read the docs, you have seen this:
@Agent(description = "Find news based on a person's star sign")
class StarNewsFinder(
private val horoscopeService: HoroscopeService,
) {
@Action
fun extractStarPerson(userInput: UserInput, context: OperationContext): StarPerson =
context.ai()
.withLlm(OpenAiModels.GPT_41)
.createObject("Create a person from this user input…", StarPerson::class)
@Action
fun retrieveHoroscope(starPerson: StarPerson): Horoscope =
Horoscope(horoscopeService.dailyHoroscope(starPerson.sign))
@AchievesGoal(description = "Write an amusing writeup")
@Action
fun writeup(person: StarPerson, horoscope: Horoscope, context: OperationContext): Writeup =
context.ai().withDefaultLlm().createObject("Write something amusing…", Writeup::class)
}
Nothing there declares the order, and nothing declares the conditions. The
framework reads both off the type signatures:
-
extractStarPerson(UserInput)→ precondition "aUserInputexists"; returningStarPerson→ postcondition "aStarPersonnow exists". -
retrieveHoroscope(StarPerson)therefore cannot run before it. -
@AchievesGoalmarks the terminal action, so the goal is "produce aWriteup".
Nobody wrote the plan UserInput → extractStarPerson → retrieveHoroscope →. The framework derived it.
writeup
Now the same structure in Clojure. The shape is different but the pieces map one
to one; read the map keys as the annotations they replace:
(deftype StarPerson [name sign])
(deftype Horoscope [summary])
(deftype Writeup [text])
(def star-news
(ec/agent
{:name "star-news-finder"
:description "Find news based on a person's star sign"
;; @AchievesGoal — the goal is a type that must exist
:goals [{:name "written" :description "an amusing writeup"
:inputs [{:name "writeup" :type Writeup}] :value 1.0}]
:actions
[;; @Action fun extractStarPerson(userInput: UserInput): StarPerson
{:name "extract-star-person"
:inputs [{:name "input" :type UserInput}]
:outputs [{:name "person" :type StarPerson}]
:llm? true
:fn (fn [ctx]
(let [{:keys [name sign]}
(schema/create-edn! ctx {:schema Person
:llm "openai/gpt-4.1"
:prompt "Create a person from this user input…"})]
(bb/put! ctx "person" (->StarPerson name sign))))}
;; @Action fun retrieveHoroscope(starPerson: StarPerson): Horoscope
{:name "retrieve-horoscope"
:inputs [{:name "person" :type StarPerson}]
:outputs [{:name "horoscope" :type Horoscope}]
:fn (fn [ctx]
(let [p (bb/fetch ctx "person")]
(bb/put! ctx "horoscope"
(->Horoscope (horoscope-service/daily (.sign p))))))}
;; @AchievesGoal @Action fun writeup(person, horoscope): Writeup
{:name "writeup"
:inputs [{:name "person" :type StarPerson} {:name "horoscope" :type Horoscope}]
:outputs [{:name "writeup" :type Writeup}]
:llm? true
:fn (fn [ctx]
(bb/put! ctx "writeup"
(->Writeup (schema/ask ctx {:prompt "Write something amusing…"}))))}]}))
Same graph, same derivation, same plan. What moved is where the graph lives: in
Kotlin it is implied by the signatures and recovered by an annotation reader; in
Clojure it is written out as a value.
The full correspondence:
| Embabel (Kotlin/Java) | embabel-clj |
|---|---|
@Agent class |
(ec/agent {…}) — one map |
@Action fun + annotation fields |
{:name … :pre […] :post […] :cost 0.1 :rerun? true :fn (fn [ctx] …)} |
| parameter types (implicit precondition) | :inputs [{:name "person" :type StarPerson}] |
| return type (implicit postcondition) | :outputs [{:name "person" :type StarPerson}] |
@Condition fun (lazy, on demand) |
{:name :co/needs-evidence? :fn (fn [ctx] …)} under :conditions
|
@AchievesGoal on the terminal action |
{:name "done" :pre […] :value 1.0} under :goals
|
blackboard.set / getCondition / setCondition
|
bb/put! · bb/fetch · bb/condition? · bb/set-condition!
|
createObject<T>() (Jackson → data class) |
(schema/create-edn! ctx {:schema …}) (EDN → malli) |
ProcessOptions / Budget / planner |
{:options {:budget {…} :planner :goap}} |
AgenticEventListener class |
{:options {:listeners [(fn [ev] …)]}} |
EarlyTerminationPolicy / StuckHandler class |
a fn under :early-termination / :stuck-handler
|
@Bean in a @Configuration class |
{:beans {:myService (reify …)}} in platform/start!
|
@SpringBootApplication + your pom |
(platform/start! {…}) |
If you prefer the annotations
There is a third form, my own favourite, and it is the closest of all to the
Kotlin. Clojure vars carry metadata, so the annotation model translates almost
literally: tags on a defn, read by agent-from-ns.
Here is the same StarNewsFinder, a third time. Compare it with the Kotlin
above and the layout on the page corresponds too: one function per action, the
annotation directly above the body, the docstring where the description was.
(ns starnews
(:require [embabel-clj.core :as ec]
[embabel-clj.blackboard :as bb]
[embabel-clj.schema :as schema]))
;; the domain types — deftype, not defrecord (see the warning below)
(deftype StarPerson [name sign])
(deftype Horoscope [summary])
(deftype Writeup [text])
;; @Action
;; fun extractStarPerson(userInput: UserInput, ctx): StarPerson
(defn extract-star-person
"Create a person from the user input, extracting name and star sign."
{:action/post [] ; <- the tag that MARKS the var as an action
:action/inputs ["input:com.embabel.agent.domain.io.UserInput"]
:action/outputs ["person:starnews.StarPerson"]
:action/llm true}
[ctx]
(let [{:keys [name sign]}
(schema/create-edn! ctx {:schema Person
:llm "openai/gpt-4.1"
:prompt "Create a person from this user input"})]
(bb/put! ctx "person" (->StarPerson name sign))))
;; @Action
;; fun retrieveHoroscope(starPerson: StarPerson): Horoscope
(defn retrieve-horoscope
"Retrieve today's horoscope for the person's sign."
{:action/post []
:action/inputs ["person:starnews.StarPerson"]
:action/outputs ["horoscope:starnews.Horoscope"]}
[ctx]
(let [p (bb/fetch ctx "person")]
(bb/put! ctx "horoscope" (->Horoscope (horoscope-service/daily (.sign p))))))
;; @AchievesGoal(description = "Write an amusing writeup")
;; @Action
;; fun writeup(person, horoscope, ctx): Writeup
(defn writeup
"Write an amusing piece based on the horoscope."
{:action/post []
:action/inputs ["person:starnews.StarPerson"
"horoscope:starnews.Horoscope"]
:action/outputs ["writeup:starnews.Writeup"]
:action/llm true}
[ctx]
(bb/put! ctx "writeup"
(->Writeup (schema/ask ctx {:prompt "Write something amusing"}))))
;; and the whole agent is the namespace scan
(def star-news
(ec/agent-from-ns 'starnews
{:name "star-news-finder"
:description "Find news based on a person's star sign"
:goals [{:name "written"
:description "an amusing writeup" ; the @AchievesGoal
:inputs ["writeup:starnews.Writeup"]
:value 1.0}]}))
The correspondence, annotation by tag:
| Kotlin | metadata tag |
|---|---|
@Action |
:action/post (present, even if empty) |
| parameter types | :action/inputs ["name:pkg.Type"] |
| return type | :action/outputs ["name:pkg.Type"] |
@Action(canRerun = true) |
:action/rerun true |
| planning cost | :action/cost 0.2 |
KDoc / description
|
the docstring, which becomes description by itself |
@Condition fun |
:condition/name :co/something? |
@AchievesGoal |
a goal in the opts map (see below) |
The type strings take the form "binding-name:FQCN". That is what the tags
accept, and it breaks in fewer places: metadata is read when the var is defined,
so a string requires neither the class to be loaded already nor the namespace's
imports to be in the right order. deftype generates the class in
the namespace's package, hence starnews.StarPerson.
One difference that cannot be hidden. In Kotlin @AchievesGoal sits on the
terminal action: the action and the goal are one declaration. With
agent-from-ns the goal goes into the opts map, apart from the actions. That
is less elegant, and it is fair to say why. The scan reads vars one at a time,
and a goal needs its own name, value and precondition, which does not fit in the
signature of the function achieving it. What you get in exchange is that the
agent's goals end up listed in one place, which helps when there are several (a
terminal one and a fallback, the common case).
[!warning] The tag that decides whether your function exists
The action detector is literally(contains? (meta v) :action/post). The
presence of the:action/postkey is what marks a var as an action.
:action/inputs,:action/outputsand the docstring do not.With named conditions this goes unnoticed, because every action has a real
:post. In the typed layer it bites: a function with:action/outputsand no
:action/postis silently ignored by the scan. If all of them are like that,
agent-from-nsthrows "no var with:action/postmetadata"; if only one is,
you get an incomplete agent and aSTUCKto investigate.That is why
:action/post []appears on all three actions above. Empty but
present: it declares no postcondition, since:action/outputsdoes the
chaining, and exists only to tell the scan "this var is an action".
The reason I prefer this form comes last. agent-from-ns registers the fns as
vars, not values, so redefining a defn in the REPL takes effect on the next
run, with no redeploy and without rebuilding the agent. With the platform up, the
edit cycle for an action body is recompile-the-function and run again, which is
why I wrote the library in the first place.
The same tags, with named conditions
The star-news above is typed, so it uses no conditions at all. In the other
layer the tags show up in full: :action/pre and :action/post with real
conditions, and the lazy @Condition, the one tag with no sibling in the action
map.
(defn generate-verify
"Generate candidate e-mails and verify them."
{:action/pre [:co/domain-known?]
:action/post [:mail/verified?]
:action/cost 0.2
:action/rerun true
:action/llm true}
[ctx] …)
(defn needs-evidence?
{:condition/name :co/needs-evidence?} ; a lazy @Condition
[ctx]
(and (not (bb/condition? ctx :co/evidence-ready?))
(bb/condition? ctx :co/ambiguous?)))
(ec/agent-from-ns 'my.agents.hunter
{:name "email-hunter" :description "…"
:goals [{:name "email-found" :pre [:mail/verified?] :value 1.0}]})
Note that needs-evidence? carries no :action/post at all. It is a condition,
and :condition/name is what marks it. The scan tells the two families of var
apart by those two keys, and only those.
[!warning] In the typed layer, use
deftype, notdefrecord
The obvious Clojure choice for a domain type isdefrecord, and it is the
wrong one here. Two properties of records collide with type-based matching on
the blackboard, and both have tests in the repo.One: every
defrecordis ajava.util.Map. A map-shaped value counts as
bound without its type being checked, so a goal requiring aFaturacompletes
with aProdutoon the blackboard. Withdeftype, the same run correctly
reportsSTUCK.Two:
defrecord's.equalsis map equality without the type. Hidden
objects are kept in aSet, so hiding one record also hides unrelated records
that happen to have the same field values.
deftypeis not a Map and compares by identity. It avoids both.
The hook: an agent is 626 bytes
Here is an entire working agent as a literal: four actions, one goal, the
complete plan graph.
{:name "order"
:description "Checks stock, picks, packs and ships an order"
:goals [{:name "shipped" :description "order on its way" :pre ["delivered"]}]
:actions [{:name "check-stock" :description "checks the stock"
:post ["checked"] :cost 0.1 :body :body/check-stock}
{:name "pick" :description "picks into boxes"
:pre ["checked"] :post ["picked"] :cost 0.2 :body :body/pick}
{:name "pack" :description "seals the box"
:pre ["picked"] :post ["packed"] :cost 0.2 :body :body/pack}
{:name "ship" :description "hands over to the carrier"
:pre ["packed"] :post ["delivered"] :cost 0.5 :body :body/ship}]}
pr-str that and you get 626 bytes of EDN. Write it to disk, read it back in
a different JVM, resolve the four :body keywords against a registry of
functions, and it runs: ["check-stock" "pick" "pack" "ship"], COMPLETED.
Open the .edn file in a text editor and delete the pack action. Run again,
with no recompilation and no code touched:
| edit, made only in the data file | what the planner does |
|---|---|
delete the pack action |
goal unreachable → STUCK
|
change ship's precondition to "picked"
|
plan becomes ["check-stock" "pick" "ship"]
|
The topology of the plan is in the file. That EDN has 58 scalar leaves, of
which 54 are data and 4 are references to function bodies: 93.1% data. The
remaining 6.9% is the honest part, because a data format can name a behavior but
cannot contain it. A YAML agent spec would hit exactly the same ceiling.
So the agent's definition is data and its bodies are code. The rest of this post
is what that buys.
Getting a platform, without a Java shell
Embabel is a Spring framework. Normally you get an App.java with
@SpringBootApplication, a Maven pom, and a build step. The library removes all
three from your project:
(require '[embabel-clj.platform :as platform])
(def sys
(platform/start!
{:properties {:embabel.agent.platform.models.openai.base-url "https://openrouter.ai/api"
:embabel.agent.platform.models.openai.api-key (System/getenv "OPENROUTER_APIKEY")
:embabel.models.default-llm "openai/gpt-4o-mini"}}))
(:platform sys) ;; => the live AgentPlatform
There is no src-java directory behind that call and no javac in the build.
The @SpringBootApplication class is an empty gen-class carrying the
annotation as metadata, compiled on demand at runtime into a temp dir and
defined straight into Clojure's DynamicClassLoader. Add the library as a git
dep or a :local/root and the first start! produces the class. Nothing to
prepare, ever.
Two details that are not cosmetic:
-
Properties are passed as command-line args (
--k=v), which is the highest precedence Spring offers. The obvious alternative,builder.properties(), isdefaultProperties, the lowest precedence, and loses to the framework's own embedded defaults. That was found the hard way whenembabel.models.default-llmrefused to take effect. -
Your own Spring beans don't need a
@Configurationclass.:beansgoes in through anApplicationContextInitializer, which runs before the context refresh, so a plainreifycan be registered as a ready-made singleton. Since Embabel resolvesLlmService,EmbeddingServiceand friends by type, a reified interface is indistinguishable from an@Bean.
(platform/start!
{:properties {…}
:beans {:myLlmService (reify com.embabel.agent.spi.LlmService …)}
:initializers [(fn [ctx] (.setId ctx "my-context"))]})
This is the interop tax. It is real, and it is paid once, inside the library,
instead of in every project.
Agent #1: the smallest thing that is still an agent
Two actions and one goal. Nothing in this code says which runs first:
(require '[embabel-clj.core :as ec]
'[embabel-clj.blackboard :as bb])
(def hello
(ec/agent
{:name "hello"
:description "looks a fact up, then writes an answer"
:goals [{:name "done" :pre [:answered?] :value 1.0}]
:actions [{:name "look-up" :post [:facts?]
:fn (fn [ctx]
(bb/put! ctx :facts ["Clojure is a Lisp on the JVM."])
(bb/set-condition! ctx :facts? true))}
{:name "answer" :pre [:facts?] :post [:answered?]
:fn (fn [ctx]
(bb/put! ctx :answer (str "Here: " (first (bb/fetch ctx :facts))))
(bb/set-condition! ctx :answered? true))}]}))
(ec/deploy! (:platform sys) hello)
(-> (ec/run! (:platform sys) hello {})
(ec/result {:slots [:answer] :conditions [:answered?]}))
;; => {:status "COMPLETED"
;; :slots {:answer "Here: Clojure is a Lisp on the JVM."}
;; :conditions {:answered? true}}
The A* search connects three facts: to reach :answered? you need answer,
which requires :facts?, which only look-up produces. Delete the answer
action from the vector and the agent doesn't become half an agent. It has no plan
at all, and the process reports STUCK.
A word on :pre [:facts?]. Those keywords are named boolean conditions, the
other flavor from the typed one used in the Rosetta section above. Both are
first-class in the framework and both work from Clojure. Named conditions suit
derived boolean state ("is this blocked?", "do we have evidence yet?"); the typed
layer suits data flow, where each step consumes and produces domain objects. The
rest of this post uses named conditions, because the agent it follows is a state
machine over a physical process rather than a chain of transformations.
Agent #2: the order pipeline, and how modeling actually goes
The hello agent is a straight line, which hides the point of a planner. Here is
the agent from the hook, written out in full. It is the one the experiments at
the end of this post measure:
(def order
(ec/agent
{:name "order"
:description "Checks stock, picks, packs and ships an order"
:goals [{:name "shipped" :description "order on its way"
:pre [:delivered?] :value 1.0}]
:actions [{:name "check-stock" :description "checks the stock"
:post [:checked?] :cost 0.1
:fn (fn [ctx]
(bb/put! ctx :stock 9)
(bb/set-condition! ctx :checked? true))}
{:name "pick" :description "picks into boxes"
:pre [:checked?] :post [:picked?] :cost 0.2
:fn (fn [ctx]
(bb/put! ctx :boxes (quot (bb/fetch ctx :stock 1) 3))
(bb/set-condition! ctx :picked? true))}
{:name "pack" :description "seals the box"
:pre [:picked?] :post [:packed?] :cost 0.2
:fn (fn [ctx]
(bb/put! ctx :seal "S-77")
(bb/set-condition! ctx :packed? true))}
{:name "ship" :description "hands over to the carrier"
:pre [:packed?] :post [:delivered?] :cost 0.5
:fn (fn [ctx]
(bb/put! ctx :tracking "BR-0001")
(bb/set-condition! ctx :delivered? true))}]}))
:cost is not decoration. When more than one action can produce what the planner
needs, A* picks by cost, which is how you express "try the cheap local lookup
before the expensive LLM call" without writing a single if.
Five modeling rules are worth stating, because each of them was learned by
getting it wrong first:
-
A condition never set is
FALSE, notUNKNOWN. Model positive poles:ok?set true when there is no error. There is nopre = NOT x. -
No
:inside a condition *name*, because it triggers the determiner's data-binding branch. Namespaced keywords (:mail/verified?) are the happy path; the library's schemas reject the broken form at construction. -
Worker actions need
:rerun? true. The defaultcanRerun=falseinjects ahasRun_<name>precondition, so the action fires once per process. Fine for a pipeline step, wrong for a retry-able worker. -
Declare
:postoptimistically. An action should declare the goal condition it may achieve, so A* can chain to the goal; re-derive the real value at runtime. Otherwise the plan never forms. -
Use a gate. Give worker actions a single positive gate
(
:work/unblocked?) that every remedy action optimistically posts. Without it, A* will happily retry a doomed action until the budget is gone.
And the one that saves the most time in practice: retries are fail-fast by
default in this library, one attempt. The framework's own default is 5
attempts with 10s → 60s backoff, which turns a typo in an action body into
minutes of watching nothing happen. Opt back in per action with :retries 2.
Budgets and planners, as data
Everything about how a run behaves is another map:
(ec/run! (:platform sys) order
{:bindings {:order "P-4711"}
:options {:budget {:cost 2.0 :actions 40 :tokens 200000}
:planner :goap
:verbosity {:show-planning true}}})
:budget is worth understanding rather than just setting: it is three
early-termination policies composed together (max actions, max tokens, hard
budget limit). Which is why adding your own doesn't replace it:
:early-termination composes.
{:options {:budget {:cost 0.10}
:early-termination [(fn [proc] (when (good-enough? proc) "close enough"))]}}
nil means carry on; a string is the reason it stopped.
Agent #3: lazy conditions, the @Condition equivalent
Named conditions that actions set have a stale-state window: the value is
whatever was last written. The framework's answer is @Condition, a function the
planner calls on demand, during world-state determination. From Clojure it is
another entry in the map:
(def triage
(ec/agent
{:name "triage" :description "escalates only when evidence is thin"
:conditions [{:name :co/needs-evidence?
:fn (fn [ctx]
(and (not (bb/condition? ctx :co/evidence-ready?))
(or (bb/condition? ctx :co/ambiguous?)
(bb/condition? ctx :co/empty-result?))))}]
:goals [{:name "resolved" :pre [:co/resolved?] :value 1.0}]
:actions [{:name "gather-more" :pre [:co/needs-evidence?] :post [:co/evidence-ready?]
:cost 0.8 :rerun? true
:fn (fn [ctx] …)}
{:name "resolve" :pre [:co/evidence-ready?] :post [:co/resolved?]
:cost 0.2
:fn (fn [ctx] …)}]}))
:co/needs-evidence? is never set by anyone. It is a
ComputedBooleanCondition, the framework's own class, evaluated by the planner
each time it determines the world state. No :after refresh hook, no stale
window. Verified end to end: a goal whose only precondition is a lazy condition
does get achieved.
Agent #4: talking to the model, with malli on both borders
An action asks for LLM access by tagging itself :llm? true; the context it
receives then carries the handle. The interesting part is the contract with the
model:
(require '[embabel-clj.schema :as schema])
(def Insights
[:map
[:summary {:description "2-3 sentence summary"} :string]
[:biome {:description "identified biome"} :string]
[:confidence {:optional true :description "0.0 to 1.0"}
[:double {:min 0.0 :max 1.0}]]])
(def naturalist
(ec/agent
{:name "naturalist" :description "reads a photo and reports the biome"
:goals [{:name "reported" :pre [:reported?] :value 1.0}]
:actions [{:name "analyse" :post [:reported?] :llm? true
:fn (fn [ctx]
(let [insights (schema/create-edn! ctx
{:schema Insights
:llm "openai/gpt-4o"
:image (bb/fetch ctx :image)
:max-tokens 1200
:retries 1
:prompt (schema/edn-prompt
Insights
{:preamble "You are a field naturalist."})})]
(bb/put! ctx :insights insights)
(bb/set-condition! ctx :reported? true)))}]}))
One value, Insights, does four jobs: it generates the prompt describing the
expected shape, parses the EDN the model returns, coerces the types, and on a
validation failure re-asks the model with the humanized errors included. That
last one is a self-healing loop, and it is a loop precisely because the schema is
a value the code can inspect and turn back into prose.
Two field notes that cost real money to learn:
-
Set
:max-tokenson OpenRouter. It pre-authorizes the cap against your balance. Absent, the model's maximum output (16k for gpt-4o) is reserved, and a low-credit account gets a402before generating a single token. -
edn-promptonly lists top-level fields. With a nested schema and a small model, the model cannot see the shape it should produce, so pass an explicit:promptshowing the exact form. The:schemakeeps validating either way.
Clojure functions as tools
No @Tool annotation is needed: Embabel has had a functional tool API since
0.4.0, and the library bridges it to malli. One schema describes the arguments,
becomes the JSON Schema the model sees, and validates the call before your
function ever runs:
(require '[embabel-clj.tools :as tools])
(def freight-quote
(tools/tool
{:name "freight_quote"
:description "Quotes freight for a destination and weight."
:schema [:map
[:destination {:description "destination city"} :string]
[:kg {:description "package weight in kg"} :double]]
:fn (fn [{:keys [destination kg]}]
(quote-service/price destination kg))}))
;; inside an action tagged :llm? true
(schema/ask ctx {:llm "openai/gpt-4o-mini"
:tools [freight-quote]
:max-tokens 200
:prompt "How much to ship 3.2 kg to Belo Horizonte?"})
Invalid arguments come back to the model as a readable error so it can
self-correct, and an exception in your function does not kill the plan. For
platform-provided tool groups (including MCP servers), an action declares
:tool-groups [:web].
Extension points, as functions
Embabel's extension surface is a set of small interfaces: one or two methods,
called by the framework at a known moment. In Kotlin each one is a class. In
Clojure each one is a fn, and the object the framework hands you arrives as a
plain map.
Watching a run (AgenticEventListener):
(require '[embabel-clj.events :as events])
(ec/run! platform order
{:options {:listeners [(fn [ev] (println (:event ev) (:process-id ev)))]}})
;; :agent-process-creation a1b2…
;; :agent-process-plan-formulated a1b2…
;; :action-execution-start a1b2…
;; or record the whole run in one line:
(let [[l log] (events/recording-listener)]
(ec/run! platform order {:options {:listeners [l]}})
(map :event @log))
Intercepting the tool loop (ToolLoopInspector, ToolLoopTransformer,
ToolCallInspector). Inspectors observe; transformers rewrite what flows
through, so the return value replaces the original and nil keeps it:
(schema/ask ctx
{:prompt "…" :tools [freight-quote]
:tool-loop-inspectors [{:after-llm-call (fn [c] (log/info :usage (:usage c)))}]
:tool-loop-transformers [{:after-tool-result
(fn [c] (subs (:result-as-string c)
0 (min 500 (count (:result-as-string c)))))}]})
A typo like :after-tool-results is a closed-schema error at construction, not a
hook that silently never fires. All three interfaces were verified with javap
against 0.4.0, 0.5.0 and 1.0.0. This is not new 1.0 surface; it was never bridged
to Clojure.
Guarding the borders (UserInputGuardRail, AssistantMessageGuardRail). A
guardrail is a validator with a name, and a malli schema already is exactly that,
so the schema can be the guard:
(schema/ask ctx
{:prompt "…"
:guardrails [{:on :user-input :name "no-secrets"
:fn (fn [{:keys [content]}]
(when (re-find #"sk-[A-Za-z0-9]{20,}" content)
"the prompt contains what looks like an API key"))}
(gr/assistant-message {:name "length" :schema [:string {:max 4000}]})]})
Your fn returns a verdict in whatever shape is natural: nil/true passes, a
string is one violation, a vector several, a map carries :code and :severity.
[!note] Severity is what decides
Only:criticalaborts the call (GuardRailViolationException);:error,
:warningand:infolog and let the call through. Enforcement keys on the
severity of the errors, so a verdict carrying no errors has nothing to act
on. The library never produces one: a bare{:valid? false}is materialized as
a critical violation, which is also why:criticalis the default severity
here.
Stopping. Three kinds, and they are genuinely different. The platform
deciding from outside, once per tick (:early-termination); the planner failing
to reach any goal (:stuck-handler); and the cooperative kind, where the
action body itself says it is done:
(require '[embabel-clj.termination :as term])
;; the stuck handler resolves by side effect — AgentProcess IS a Blackboard
{:stuck-handler (fn [proc]
(bb/set-condition! proc :fallback/ok? true)
"opened the fallback path")} ; => REPLAN
;; cooperative, from inside an action
(term/terminate-agent! ctx "found what we came for")
None of this makes your project import com.embabel.
Durable history, and a process that survives its JVM
An Embabel process already carries its own log. AgentProcess.history
exists, and the framework calls update(this) on the AgentProcessRepository on
every tick: two ready-made hooks, in the right place. The default repository
keeps everything in memory, which is the sensible default for development, and
replacing it is a declared extension point.
That is what embabel-clj.process-store does. A decorating repository delegates
the live object and, on every save/update, projects the process into EDN and
appends a record to a log. The object stays ephemeral; the history becomes
queryable data.
(require '[embabel-clj.process-store :as ps])
(def repo (ps/edn-repository {:file "target/processes.edn"}))
(platform/start! {:initializers [(ps/as-primary-bean repo)]})
;; later, including from another JVM, with the first one long dead:
(def log (ps/read-log "target/processes.edn"))
(ps/summary log) ; => {:records 128 :processes 7 :cost 0.0142 …}
(ps/runs log) ; => one row per process
(ps/timeline log "a1b2…") ; => that process's trajectory
(ps/as-of log "2026-07-30T18:00:00Z") ; => what the system knew at 18:00
Two things had to be right for that to work, and both are worth knowing if you
ever swap a framework bean:
- The bean must go in as
@Primary, because Embabel's@Bean agentProcessRepositoryis unconditional. A plainregisterSingletoncreates a second candidate of the same type and Spring raisesNoUniqueBeanDefinitionException. -
AgentProcessrefuses to serialize. It is annotated@JsonSerialize(using = ComputerSaysNoSerializer::class), so persisting it means projecting field by field, not handing it to an ObjectMapper.
And the honest boundary, stated up front: this makes the history durable.
Making the process resumable is a further step, and its price is a domain
discipline, only values go on the blackboard. Anything that is not a value
comes back as a tombstone marking exactly where the discipline broke.
What the measurements say
Claims here come with numbers, and the experiments were written so they could
come out negative. Five live in the repo and run offline, with no LLM key, no
Docker and no Neo4j. Everything below is the median of three consecutive runs
on the same machine, and where a figure moved between runs it is given as a
range rather than as a single number:
| Question | Result | |
|---|---|---|
| E1 | Is a process resumable, or only its history durable? |
Resumable, identically in all three runs. Phase 1 calls System/exit 9 inside the third action: real JVM death, no finally, no shutdown hook. Phase 2 is a different OS process. It reads the log, restores blackboard and conditions, and runs ["pack" "ship"], not the two already done, to COMPLETED, with 0 tombstones. |
| E2 | What does keeping the log cost? | ~0.25 ms/tick (0.244–0.269 across runs) and ~5,250 B/process, over 1500 runs. Measured on an agent with no LLM, where a tick lasts microseconds: the worst possible case for the log. On a tick that calls a model, it disappears into the noise. |
| E3 | How much of an agent is really data? | 93.1%, byte-identical in all three runs. 626 bytes of EDN read in another JVM: 58 leaves, 54 data, 4 code references. Deleting an action in the file makes the goal unreachable; changing one precondition reroutes the plan. |
| E4 | Does a log-derived cache save tokens? | Yes, 100%, with a label. See below. |
| E5 | Can the log alone drive an LLM judge? |
Yes. 40 runs, half with a planted routing bug invisible in the results (all 40 orders delivered, all COMPLETED) and visible only in the path. Recall and precision were 1.0 in all three runs, with 0 false positives: 9/9 detectable bugs caught in the first, 12/12 in the other two. |
Three of these have a caveat, and in each case the caveat is the finding.
E1's ceiling. The resumed process is a new process, with a new id and a
history starting from zero. What continues is the world, not the identity.
Stitching the two timelines together is the next step, and it is a small one.
E4 is the one to read carefully, and running it three times made the point
better than one run could. Without the cache the agent genuinely oscillates on
the same input: the original path came up 11, 11 and 4 times out of 20 across the
three runs. With the cache: 20/20 identical and zero calls, every time. So the
cache does not fail under non-determinism. It erases it. The agent stops
deciding and starts replaying a recorded decision, silently. That makes
log-derived replay excellent for audit and regression testing (it pins the path
on purpose) and unsafe as transparent memoisation anywhere the model chooses
the branch. Without the control arm the experiment would have lied: 20/20
identical paths reads as a stable agent, when it was the cache pinning the route.
E5's methodological note. Of the 20 buggy agents only 9 to 12 were detectable
at all, depending on the run, because when the agent classifies "simple" the bug
and the correct behavior coincide. The judge caught every detectable one in all
three runs. Evaluation measures what the trace exposes, not what you intended.
Honest limits
-
The interop tax is real. It is paid once, inside the library, but it is
paid. Roughly fifteen Kotlin-interop gotchas had to be found by hitting them:
value classes with hyphenated mangled members reachable only through
java.lang.reflect;@JvmStatic valcompiling to a method, not a field; Kotlin data classes with non-null defaults that must be constructed through the synthetic bit-mask constructor. - 6.9% of the agent is not data, and no format changes that. A spec can name a behavior; it cannot contain it.
-
The library does not expose everything as data yet. Cost tracking
aggregation, streaming and thinking, closed/open execution modes and explicit
domainTypesregistration are still in the queue. The framework offers them; the Clojure bridge is what does not exist yet. - The library is experimental, and it is one person's. The framework is production-grade; this is a bet on a representation.
Running it yourself
Everything in this post is in
github.com/raidenario/embabel-clj,
Apache-2.0, the same licence as Embabel.
The five experiments run offline, with no LLM key and no Docker:
git clone https://github.com/raidenario/embabel-clj
cd embabel-clj/experiments && ./run-all.sh
The agents in this post live under examples/, and hello starts with a key
from any OpenAI-compatible provider. The quickest read of what the library does
is nature: one namespace, zero interop, a photo in and validated EDN out.
Top comments (0)