clj-llm run-agent: Happy little tool loops
What is an Agent?
At Anthropic's developer conference, Hannah Moran put it simply:
Agents are models using tools in a loop
Simon Willison notes that this definition cuts through the hype.
That's exactly what clj-llm's run-agent does. A minimal implementation of this pattern, with some hooks for control.
The Components
Tools
Tools are Clojure functions with Malli schemas:
(defn read-file [{:keys [path]}]
{:malli/schema [:=> [:cat :string] :string]}
(try
{:success true :content (slurp path)}
(catch Exception e
{:success false :error (.getMessage e)})))
(defn create-file [{:keys [path content]}]
{:malli/schema [:=> [:cat :string :string] :string]}
(try
(spit path content)
{:success true :path path}
(catch Exception e
{:success false :error (.getMessage e)})))
(defn run-shell [{:keys [command]}]
{:malli/schema [:=> [:cat :string] :string]}
(let [result (shell/sh "sh" "-c" command :out :string :err :string)]
{:success (zero? (:exit result))
:output (:out result)}))
(def ^:dynamic *tools* [#'read-file #'create-file #'run-shell])
The Loop
(llm/run-agent provider {
:tools *tools*
:max-steps 20
:on-tool-calls (fn [{:keys [step tool-calls]}]
(println "📍 STEP" step)
(doseq [tc tool-calls]
(println " 🔧" (:name tc) "->" (:arguments tc))))
:on-tool-result (fn [{:keys [step tool-call result]}]
(println " ✅" (:name tool-call) "=>" (pr-str result)})}
"...")
Each iteration:
LLM receives conversation history + tool schemas
→ decides which tools to call
→ tools execute
→ results added to history
→ repeat until no more tool calls.
How the Loop Stops
By default, run-agent continues until the LLM produces no tool calls (an empty :tool-calls array). This signals the model has determined the task is complete.
You can customize this behavior with the :stop-when callback, which is called before and after tool execution:
(llm/run-agent provider {
:tools *tools*
:max-steps 20
:stop-when (fn [{:keys [step tool-calls tool-result]}]
;; Return truthy to stop early
(or (empty? tool-calls) ; default: stop when no tools called
(and tool-result
(not (:success tool-result))) ; stop on failure
(:done? tool-calls)))} ; custom signal
"...")
The :stop-when function receives a map with:
:step- current iteration number:tool-calls- the tool calls about to be executed (or just executed):tool-result- (optional) the result after tool execution
Return a truthy value to stop the loop immediately.
In Action: Building a TODO API
The agent in action
📍 STEP 0
🔧 run-shell -> {:command "mkdir -p /tmp/todo_api"}
✅ run-shell => {:success true, :output ""}
📍 STEP 1
🔧 create-file -> {:path "/tmp/todo_api/server.js",
:content "const http = require('http');..."}
✅ create-file => {:success true, :path "/tmp/todo_api/server.js"}
📍 STEP 2
🔧 run-shell -> {:command "cd /tmp/todo_api && node server.js &"}
✅ run-shell => {:success true, :output ""}
📍 STEP 3
🔧 run-shell -> {:command "curl -X POST http://localhost:3000/todos -d '{\"text\":\"Learn Clojure\"}'"}
✅ run-shell => {:success true, :output "{\"id\":1,\"text\":\"Learn Clojure\"}
"}
📍 STEP 4
🔧 run-shell -> {:command "curl -X POST http://localhost:3000/todos -d '{\"text\":\"Build an agent\"}'"}
✅ run-shell => {:success true, :output "{\"id\":2,\"text\":\"Build an agent\"}
"}
📍 STEP 5
🔧 run-shell -> {:command "curl -X POST http://localhost:3000/todos -d '{\"text\":\"Ship to prod\"}'"}
✅ run-shell => {:success true, :output "{\"id\":3,\"text\":\"Ship to prod\"}
"}
📍 STEP 6
🔧 run-shell -> {:command "curl http://localhost:3000/todos"}
✅ run-shell => {:success true, :output "[{\"id\":1,...},{\"id\":2,...},{\"id\":3,...}]
"}
📍 STEP 7
🔧 run-shell -> {:command "curl -X DELETE http://localhost:3000/todos/2"}
✅ run-shell => {:success true, :output "\"deleted\"
"}
📍 STEP 8
🔧 run-shell -> {:command "curl http://localhost:3000/todos"}
✅ run-shell => {:success true, :output "[{\"id\":1,...},{\"id\":3,...}]
"}
📍 STEP 9
🔧 run-shell -> {:command "pkill -f 'node server.js'"}
✅ run-shell => {:success true, :output ""}
Ten iterations. The model planned, executed, observed results, and adjusted. After step 9, it determined the task complete and stopped calling tools.
In Action: HTTP + File Operations
Added an HTTP request tool:
(defn http-request [{:keys [url method body]}]
{:malli/schema [:=> [:cat :string] :string]}
(let [response (clj-http.client/request
{:url url
:method (keyword (or method "get"))
:body body})]
{:success true
:status (:status response)
:body (:body response)}))
(def ^:dynamic *tools* [#'read-file #'create-file #'run-shell #'http-request])
📍 STEP 0
🔧 http-request -> {:url "https://jsonplaceholder.typicode.com/posts",
:method "get"}
✅ http-request => {:success true, :status 200,
:body "[{\"id\":1,\"title\":\"sunt aut...\"},...]"}
📍 STEP 1
🔧 run-shell -> {:command "mkdir -p /tmp/api_data"}
✅ run-shell => {:success true, :output ""}
📍 STEP 2
🔧 create-file -> {:path "/tmp/api_data/posts.json",
:content "[{\"id\":1,\"title\":\"sunt aut...\"},...]"}
✅ create-file => {:success true, :path "/tmp/api_data/posts.json"}
📍 STEP 3
🔧 read-file -> {:path "/tmp/api_data/posts.json"}
✅ read-file => {:success true,
:content "[{\"id\":1,\"title\":\"sunt aut...\"},...]"}
📍 STEP 4
🔧 create-file -> {:path "/tmp/api_data/summary.txt",
:content "1. sunt aut facere...
2. qui est esse...
3. ea molestias..."}
✅ create-file => {:success true, :path "/tmp/api_data/summary.txt"}
Five iterations. The agent fetched external data, persisted it, then performed analysis.
In Action: Code Analysis and Refactoring
📍 STEP 0
🔧 create-file -> {:path "/tmp/code_sample.clj",
:content "(ns sample)
(defn factorial [n]
(if (<= n 1)
1
(* n (factorial (dec n))))"}
✅ create-file => {:success true, :path "/tmp/code_sample.clj"}
📍 STEP 1
🔧 read-file -> {:path "/tmp/code_sample.clj"}
✅ read-file => {:success true,
:content "(ns sample)
(defn factorial [n]
(if (<= n 1)
1
(* n (factorial (dec n))))"}
📍 STEP 2
🔧 create-file -> {:path "/tmp/code_sample_improved.clj",
:content "(ns sample)
(defn factorial [n]
(loop [acc 1, n n]
(if (<= n 1)
acc
(recur (* acc n) (dec n)))))"}
✅ create-file => {:success true, :path "/tmp/code_sample_improved.clj"}
Three iterations. Create, read, analyze, improve.
Cost
Using minimax/m2.5 via OpenRouter:
| Use Case | Iterations | Prompt Tokens | Completion Tokens | Cost |
|---|---|---|---|---|
| TODO API | 10 | 892 | 2847 | $0.003 |
| HTTP + Files | 5 | 456 | 892 | $0.001 |
| Code refactor | 3 | 312 | 445 | $0.001 |
That's the agent pattern in its simplest form. An LLM, some tools, a loop. The model decides what to do at each step, executes, observes, and continues until satisfied. The complexity comes from the tools you provide and the tasks you assign - the pattern itself stays the same.