clj-llm run-agent: Happy little tool loops

2026-03-29 Sun 03:45 article clojure llm publish

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

prompt

Build a TODO REST API with Node.js native http module (no dependencies!), start it on port 3000, add 3 todos via curl, show all, delete one, show remaining.

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])
prompt

Fetch the first 3 posts from JSONPlaceholder API (https://jsonplaceholder.typicode.com/posts), save them to /tmp/api_data/posts.json, then read the file and extract the titles into a summary file.

📍 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

prompt

Create a sample Clojure file at /tmp/code_sample.clj with a function that calculates factorial using recursion. Then read the file, identify any potential issues, and create a refactored version at /tmp/code_sample_improved.clj using tail recursion.

📍 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 CaseIterationsPrompt TokensCompletion TokensCost
TODO API108922847$0.003
HTTP + Files5456892$0.001
Code refactor3312445$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.