anchor
Freshcode
  /  
Insights
  /  

LLM Agents in Clojure

LLM Agents in Clojure

Last updated:

December 31, 2025

15 min read

Clojure

By

Oleksandr Druk

Clojure Developer

Sofiia Yurkevska

Content Writer

Contents

See more

This is some text inside of a div block.
TL;DR

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))})))))
35

Tools 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}}]}
8

The 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))))))
52

The 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;"}}))))
22

This 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))))
18

Calling 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)))))
10

Testing 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"])
102

This 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?")
25

The 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.

I still think that many companies are using Clojure so well that in fact there's not much fuss about it anymore.
Adam Tornhill
CodeScene
Adam Tornhill

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.

Build Your Team
with Freshcode
Author
linkedin
Oleksandr Druk
Clojure Developer

Self-taught developer. Programming languages design enthusiast. 3 years of experience with Clojure.

linkedin
Sofiia Yurkevska
Content Writer

Infodumper, storyteller and linguist in love with programming - what a mixture for your guide to the technology landscape!

Share your idea

Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
What happens after
you fill this form?
We review your inquiry and respond within 24 hours
A 30-minute discovery call is scheduled with you
We address your requirements and manage the paperwork
You receive a tailored budget and timeline estimation

Talk to our expert

Kareryna Hruzkova

Kate Hruzkova

Elixir Partnerships

Our team scaling strategy means Elixir developers perform from day one, so you keep your product on track, on time.

We review your inquiry and respond within 24 hours

A 30-minute discovery call is scheduled with you

We address your requirements and manage the paperwork

You receive a tailored budget and timeline estimation

elixir logo

Talk to our expert

Nick Fursenko

Nick Fursenko

Account Executive

With our proven expertise in web technology and project management, we deliver the solution you need.

We review your inquiry and respond within 24 hours

A 30-minute discovery call is scheduled with you

We address your requirements and manage the paperwork

You receive a tailored budget and timeline estimation

Looking for a Trusted Outsourcing Partner?