LLM Agents in Clojure
Last updated:
December 31, 2025
15 min read
Clojure

Oleksandr Druk
Clojure Developer

Sofiia Yurkevska
Content Writer
.avif)
Contents
See more
TL;DR: In this article, we'll build an analytics agent in Clojure, see how a data-first design creates testable and traceable systems, compare it with Python implementations, and understand the trade-offs. The approach centers on data-as-code, immutable state, pure functions, and REPL-driven development. You get transparent agent loops you can inspect and test, tools represented as plain data, and state you can serialize, diff, and replay. Everything runs on your existing JVM infrastructure.
When we explored agentic workflows in Elixir earlier this year, we argued that AI agents represented the future of how software gets built and deployed. Six months later, the data support this view: 85% of enterprises now use AI agents in at least one workflow, up from practically zero two years ago.
The market grew from $5.4 billion in 2024 to $7.6 billion in 2025 and is projected to reach $47 billion by 2030. 23% of organizations are already scaling agentic AI systems across their enterprises. Gartner predicts that by 2028, 33% of enterprise software applications will include agentic AI, up from less than 1% in 2024.
The agent ecosystem is overwhelmingly Python-centric. LangChain, AutoGen, CrewAI, and LangGraph are all Python-first. For organizations on the JVM with existing infrastructure, operational expertise, and production services, this creates a challenge. Moving to Python just to build agents means abandoning significant investments.
We'll build a working analytics agent in Clojure that queries databases, generates charts, and maintains conversation state. The implementation will be compared with Python equivalents to highlight how Clojure's data-oriented design affects agent development.
This might interest you if:
- You're on the JVM and need to build agent systems
- You prefer simple, inspectable code over framework abstractions
- You value debuggability and testability in production
- You're evaluating functional programming approaches to AI orchestration
What is an LLM Agent?
An LLM agent is a system that combines a language model with the ability to take actions. Unlike a standard chatbot that only generates text responses, an agent can call functions, query databases, interact with APIs, and generally perform tasks in the real world.
The core components are simple. An agent needs an LLM (GPT-4, Claude, or similar), a set of tools it can call, and some form of state management. Tools are just functions. The LLM decides which function to call and with what arguments based on the conversation context. State typically includes the conversation history, any intermediate results from tool calls, and a trace of the agent's actions.
The agent operates in a loop. First, the LLM examines the current conversation and available tools. It then decides: either call a specific tool with certain arguments or respond directly to the user. If it calls a tool, the system executes that function and feeds the result back into the conversation. The loop continues until the agent produces a final answer or hits a configured step limit.
This loop is often called the ReAct pattern (Reasoning and Acting). The LLM reasons about what to do next, acts by calling a tool, observes the result, and then reasons again based on the new information. The pattern repeats until the task is complete. The key difference from traditional programming is that the LLM makes these decisions dynamically. You don't write explicit control flow. You provide tools and context, and the model figures out the sequence of operations needed to accomplish the task.
Building a simple analytics agent example in Clojure
We'll build a basic analytics agent for a hypothetical product. The agent should answer questions like "How many active users did we have last week?" and "Show me a chart of signups per day over the last month." The requirements are straightforward. The agent needs to query a user database, generate chart configurations, and maintain a trace of its operations. This is enough complexity to demonstrate the core concepts without getting lost in details.
We'll implement this agent in both Clojure and Python. The goal is to show how the same functionality looks in each language, particularly how Clojure's data-oriented approach differs from typical Python implementations.
The agent will be deliberately simple. No authentication, no complex error handling, no production concerns. This is a teaching example. Once you understand the pattern, adding production features is straightforward.
Defining agent's tools with data schemas
1(ns analytics.agent.tools
2 (:require [malli.core :as m]
3 [malli.error :as me]
4 [analytics.agent.db :as db]
5 [analytics.agent.charts :as charts]))
6
7(def run-sql-tool
8 {:name "run_sql"
9 :description "Run an SQL query on the analytics db"
10 :params [:map
11 [:query string?]]
12 :run (fn [{:keys [query]}]
13 (db/run-sql query))})
14
15(def render-chart-tool
16 {:name "render_chart"
17 :description "Create a chart config from rows and a chart type"
18 :params [:map
19 [:rows vector?]
20 [:chart-type [:enum "bar" "line" "pie"]]]
21 :run (fn [{:keys [rows chart-type]}]
22 (charts/make-chart-config rows chart-type))})
23
24(def tools
25 {"run_sql" run-sql-tool
26 "render_chart" render-chart-tool})
27
28(defn validate-params [tool params]
29 (let [schema (:params tool)]
30 (if (m/validate schema params)
31 params
32 (throw (ex-info "Invalid tool params"
33 {:tool (:name tool)
34 :errors (me/humanize (m/explain schema params))})))))
35Tools are represented as plain maps. Each tool has a name, description, parameter schema, and a function to run. The tool registry is just a map from names to tool definitions.
The parameter schemas use Malli, which is Clojure's data-driven schema library. Schemas are data structures, not classes or decorators. This means you can programmatically generate, serialize, and transform them. For an agent system, this matters because you need to convert these schemas into the JSON format that OpenAI or Anthropic expects.
In Python agent frameworks, tools are typically expressed through decorators (@tool), classes (Tool(...)), or framework-specific APIs. You can write them as plain dictionaries and functions, but the mainstream libraries push you toward class-based or decorator-based approaches.
The difference is cultural. In Python, objects and classes are the default abstraction. In Clojure, data is the default abstraction. This affects how you think about and manipulate your agent's capabilities.
Agent state management in Clojure
Agent state is also represented as a plain map. This makes it easy to inspect, debug, persist, and test.
1{:conversation [{:role "user" :content "How many active users..."}
2 {:role "tool" :name "run_sql" :content "..."}
3 {:role "assistant" :content "We have 123..."}]
4:trace [{:step 1
5 :tool "run_sql"
6 :params {:query "..."}
7 :result {:rows [...] :row-count 3}}]}
8The state contains two main components. The conversation is a sequence of messages that follows the standard format used by OpenAI and Anthropic APIs. Each message has a role (user, assistant, or tool) and content. This is what gets sent to the LLM on each iteration.
The trace is separate. It records what the agent actually did: which tools it called, with what parameters, and what results it received. This is useful for debugging, analytics, and audit trails. You can look at a trace and understand exactly what happened during agent execution.
Because a state is just data, you can do things that are awkward in object-oriented systems. You can assoc in new fields for experiments. You can dissoc fields you don't need. You can diff two states to see what changed. You can serialize the entire state to EDN and save it to disk or send it over the network.
This is particularly useful during development. You can capture a problematic state from production, load it in your REPL, and replay the agent execution step by step. No need to reproduce the exact sequence of user interactions that led to the problem.
Both ecosystems support logging and metrics. Clojure’s data-first style makes it especially natural to treat logs, metrics, and traces as part of the same immutable state map, whereas Python code more often routes those concerns through global logging/metrics APIs.
Core agent loop
1(ns analytics.agent.core
2 (:require [analytics.agent.tools :as tools]
3 [analytics.agent.llm :as llm]))
4
5(defn append-message [state role content]
6 (update state :conversation conj {:role role :content content}))
7
8(defn append-tool-result [state tool-name params result]
9 (-> state
10 (update :conversation conj {:role "tool"
11 :name tool-name
12 :content (pr-str {:params params
13 :result result})})
14 (update :trace conj {:step (inc (count (:trace state)))
15 :tool tool-name
16 :params params
17 :result result})))
18
19(defn initial-state [user-question]
20 {:conversation [{:role "user" :content user-question}]
21 :trace []})
22
23(defn run-agent-once
24 [state {:keys [model api-key] :as config}]
25 (let [decision (llm/call-llm-with-tools
26 model api-key tools/tools (:conversation state))]
27 (case (:type decision)
28 :message
29 {:state (append-message state "assistant" (:content decision))
30 :done? true}
31
32 :tool-call
33 (let [{:keys [tool params]} decision
34 tool-def (get tools/tools tool)]
35 (if-not tool-def
36 {:state (append-message state "assistant"
37 (str "Unknown tool: " tool))
38 :done? true}
39 (let [params' (tools/validate-params tool-def params)
40 result ((:run tool-def) params')]
41 {:state (append-tool-result state tool params' result)
42 :done? false}))))))
43
44(defn run-agent
45 [user-question config]
46 (loop [state (initial-state user-question)
47 steps 0]
48 (let [{:keys [state done?]} (run-agent-once state config)]
49 (if (or done? (>= steps (or (:max-steps config) 8)))
50 state
51 (recur state (inc steps))))))
52The agent follows a functional approach. Each iteration is a pure function: state -> new-state. The run-agent-once function takes the current state and returns a map with the new state and a done flag.
The loop uses recur for tail recursion. It continues until the agent produces a final message or hits the configured step limit. This makes the agent easy to unit test and allows you to plug in logging, metrics, or snapshots at any point.
In many Python agent frameworks, this logic is encapsulated inside framework-specific APIs. The loop runs internally when you call methods on agent objects, which makes it harder to inspect or modify the execution flow.
To keep things simple, here's an LLM stub:
1(ns analytics.agent.llm
2 (:require [clojure.string :as str]))
3
4(defn call-llm-with-tools
5 [model api-key tools conversation]
6 (let [has-tool-result? (some #(= "tool" (:role %)) conversation)
7 user-question (-> conversation first :content str/lower-case)]
8 (if has-tool-result?
9 ;; Pretend we're done and summarise
10 {:type :message
11 :content "Here is your result based on the data I fetched."}
12 ;; Decide which tool to call
13 (if (str/includes? user-question "chart")
14 {:type :tool-call
15 :tool "render_chart"
16 :params {:rows [{:id 1 :active? true}
17 {:id 2 :active? false}]
18 :chart-type "bar"}}
19 {:type :tool-call
20 :tool "run_sql"
21 :params {:query "SELECT * FROM users WHERE active = true;"}}))))
22This stub can be swapped with a real LLM integration later. The agent loop doesn't care as long as the data structure is correct.
Agentic workflow in Clojure: CLI and REPL
Here's how to run the agent. This is the main function:
1(ns analytics.agent.main
2 (:require [clojure.pprint :as pp]
3 [analytics.agent.core :as core])
4 (:gen-class))
5
6(defn -main
7 [& args]
8 (let [default-question "How many active users did we have last week?"
9 question (or (first args) default-question)
10 config {:model :stub
11 :api-key nil
12 :max-steps 4}
13 state (core/run-agent question config)]
14 (println "=== Final agent state ===")
15 (pp/pprint state)
16 (println "\n=== Trace ===")
17 (pp/pprint (:trace state))))
18Calling it will give us something like this:
λ clj -M -m analytics.agent.main
[db] executing SQL: SELECT * FROM users WHERE active = true;
=== Final agent state ===
{:conversation
[{:role "user",
:content "How many active users did we have last week?"}
{:role "tool",
:name "run_sql",
:content
"{:params {:query \"SELECT * FROM users WHERE active = true;\"}, :result {:rows [{:id 1, :region \"EU\", :active? true} {:id 2, :region \"US\", :active? false} {:id 3, :region \"EU\", :active? true}], :row-count 3, :query-used \"SELECT * FROM users WHERE active = true;\"}}"}
{:role "assistant",
:content "Here is your result based on the data I fetched."}],
:trace
[{:step 1,
:tool "run_sql",
:params {:query "SELECT * FROM users WHERE active = true;"},
:result
{:rows
[{:id 1, :region "EU", :active? true}
{:id 2, :region "US", :active? false}
{:id 3, :region "EU", :active? true}],
:row-count 3,
:query-used "SELECT * FROM users WHERE active = true;"}}]}
=== Trace ===
[{:step 1,
:tool "run_sql",
:params {:query "SELECT * FROM users WHERE active = true;"},
:result
{:rows
[{:id 1, :region "EU", :active? true}
{:id 2, :region "US", :active? false}
{:id 3, :region "EU", :active? true}],
:row-count 3,
:query-used "SELECT * FROM users WHERE active = true;"}}]
But Clojure, of course, also allows us to use a REPL-driven workflow, which gives much more freedom for testing, inspection, simulating different LLM behaviours, and all of that by changing our code on the fly.
analytics.agent.core
λ (run-agent "How many active users did we have last week? Draw a chart."
{:model :stub :max-steps 4})
{:conversa
[{:role "user",
:content
"How many active users did we have last week? Draw a chart."}
{:role "tool",
:name "render_chart",
:content
"{:params {:rows [{:id 1, :active? true} {:id 2, :active? false}],
:chart-type \"bar\"},
:result {:chart/type \"bar\",
:chart/x \"id\",
:chart/y \"active?\",
:data/points [{:id 1, :active? true}
{:id 2, :active? false}]}}"}
{:role "assistant",
:content "Here is your result based on the data I fetched."}],
:trace
[{:step 1,
:tool "render_chart",
:params
{:rows [{:id 1, :active? true} {:id 2, :active? false}],
:chart-type "bar"},
:result
{:chart/type "bar",
:chart/x "id",
:chart/y "active?",
:data/points [{:id 1, :active? true} {:id 2, :active? false}]}}]}
This REPL workflow allows you to test different scenarios, inspect state at any point, and simulate different LLM behaviors by swapping the stub implementation. You can change code and see results immediately without recompilation or restart cycles.
Testing LLM agent in Clojure
Because everything is just data and pure functions, unit tests are straightforward. Simple example:
1(ns analytics.agent.core-test
2 (:require [clojure.test :refer [deftest is]]
3 [analytics.agent.core :as core]))
4
5(deftest agent-produces-trace
6 (let [config {:model :stub :api-key nil :max-steps 4}
7 state (core/run-agent "How many active users?" config)]
8 (is (= 1 (count (:trace state))))
9 (is (= "run_sql" (-> state :trace first :tool)))))
10Testing an agent means calling a function and asserting on the returned data structure. No special test framework needed. No mocking libraries required. The stub LLM makes the behavior deterministic.
You can test individual state transitions by calling run-agent-once directly. You can verify tool parameter validation by calling validate-params with test data. You can check state transformations by calling append-message or append-tool-result with sample states.
In Python, you can achieve similar results with dependency injection and mocking. The difference appears when using frameworks like LangChain or AutoGen. Tests often require mocking framework-specific objects, working with callback APIs, and reverse-engineering how internal state is represented. The framework abstraction that simplifies development also complicates testing.
Comparing a Python implementation with Clojure
Here's a Python version using the same approach. This is intentionally framework-free to make the comparison fair:
1import sys
2
3def run_sql(params):
4 query = params["query"]
5 print("[db] executing SQL:", query)
6 rows = [
7 {"id": 1, "region": "EU", "active": True},
8 {"id": 2, "region": "US", "active": False},
9 {"id": 3, "region": "EU", "active": True},
10 ]
11 return {"rows": rows, "row_count": len(rows), "query_used": query}
12
13
14def render_chart(params):
15 rows = params["rows"]
16 chart_type = params["chart_type"]
17 return {
18 "chart_type": chart_type,
19 "x": "id",
20 "y": "active",
21 "points": rows,
22 }
23
24
25TOOLS = {
26 "run_sql": {"run": run_sql},
27 "render_chart": {"run": render_chart},
28}
29
30
31def stub_llm(conversation, tools):
32 user_question = conversation[0]["content"]
33 text = user_question.lower()
34
35 if "chart" in text:
36 return {
37 "type": "tool_call",
38 "tool": "render_chart",
39 "params": {
40 "rows": [
41 {"id": 1, "active": True},
42 {"id": 2, "active": False},
43 ],
44 "chart_type": "bar",
45 },
46 }
47 else:
48 return {
49 "type": "tool_call",
50 "tool": "run_sql",
51 "params": {
52 "query": "SELECT * FROM users WHERE active = true;",
53 },
54 }
55
56
57def run_agent(question: str) -> dict:
58 state = {
59 "conversation": [{"role": "user", "content": question}],
60 "trace": [],
61 }
62
63 decision = stub_llm(state["conversation"], TOOLS)
64
65 if decision["type"] == "tool_call":
66 tool_name = decision["tool"]
67 tool = TOOLS[tool_name]
68 params = decision["params"]
69 result = tool["run"](params)
70
71 state["conversation"].append(
72 {
73 "role": "tool",
74 "name": tool_name,
75 "content": repr({"params": params, "result": result}),
76 }
77 )
78 state["trace"].append(
79 {"step": 1, "tool": tool_name, "params": params, "result": result}
80 )
81
82 return state
83
84
85if __name__ == "__main__":
86 from pprint import pprint
87
88 if len(sys.argv) > 1:
89 question = " ".join(sys.argv[1:])
90 else:
91 question = "How many active users did we have last week?"
92
93 print(f"Question: {question!r}\n")
94
95 state = run_agent(question)
96
97 print("=== Final agent state ===")
98 pprint(state)
99
100 print("\n=== Trace only ===")
101 pprint(state["trace"])
102This Python implementation is close to the Clojure version. Tools are dictionaries. State is a dictionary. The agent function takes a state and returns a new state. This works fine and is testable.
In real Python projects, you'll more often see something like this:
1from langchain_openai import ChatOpenAI
2from langchain.agents import initialize_agent, Tool
3
4def run_sql(query: str):
5 ...
6
7llm = ChatOpenAI(model="gpt-4.1-mini")
8
9tools = [
10 Tool(
11 name="run_sql",
12 func=run_sql,
13 description="Run an SQL query on the analytics db."
14 )
15]
16
17agent = initialize_agent(
18 tools=tools,
19 llm=llm,
20 agent="zero-shot-react-description",
21 verbose=True,
22)
23
24result = agent.run("How many active users did we have last week?")
25The framework version is shorter and handles LLM integration automatically. But the agent loop is now inside initialize_agent. State and trace are accessed through framework-specific APIs or external services rather than plain dictionaries you control. Tools are Tool class instances rather than raw data.
This is the tradeoff. Frameworks reduce boilerplate and provide integration with the broader ecosystem. But they also hide execution flow and make it harder to inspect or modify behavior outside the intended APIs.
Summary
Data-oriented design, immutable state, REPL-driven workflows, and composable functions make Clojure a practical choice for building LLM agents. The approach shown here uses plain data structures and pure functions, which makes agents testable, traceable, and straightforward to reason about.
Python's ecosystem will likely continue to dominate for the fastest-moving frameworks and newest models. But if you're already on the JVM and you value reliability, traceability, and integration with existing infrastructure, building agents in Clojure is a solid option.
For teams requiring enterprise-scale orchestration, the ecosystem includes tools like Agent-o-Rama, which provides distributed execution and monitoring built on Rama. For most use cases, though, the simple data-first approach demonstrated here is sufficient and can be extended with production features as needed.
If you're building agent systems on the JVM or evaluating functional approaches to AI orchestration, we'd be interested in hearing about your experience.
with Freshcode

.avif)



