New LLM Library With Tool Support

Dear reader, as I write this chapter in March 2026 I already have six chapters in this book covering the use of LLMs from different providers. I experiment a lot rewriting code, and I have a new library included in the GitHub repository for this book called llm that is the result of spending the last week refactoring my older code and adding new functionality with the goal of having a small library that supports Ollama local models, Anthropic Claude APIs, and Google Gemini APIs with similar functionality except for adding support for Google’s integrated web search and Gemini APIs.

Library Structure Overview

The llm library is organized into three focused source files that work together: llm.lisp provides shared low-level utilities, simple-tools.lisp defines a provider-agnostic tool registry and schema system, and provider-specific files like claude.lisp and ollama.lisp implement the actual API calls. This separation of concerns makes it straightforward to add new providers without touching the tool infrastructure.

Source Code

The llm library code is in the book Github repository in the directory loving-common-lisp/src/llm.

The test code is in the directory loving-common-lisp/src/llm_test.

llm.lisp — Shared Utilities

 1 (defpackage #:llm
 2   (:use #:cl)
 3   (:export #:run-curl-command
 4            #:escape-json
 5            #:substitute-subseq))
 6 (in-package #:llm)
 7 (defun run-curl-command (curl-command)
 8   (multiple-value-bind (output error-output exit-code)
 9       (uiop:run-program curl-command
10                         :output :string
11                         :error-output :string
12                         :ignore-error-status t)
13     (if (zerop exit-code)
14         output
15         (error "Curl command failed: ~A~%Error: ~A" curl-command error-output))))
16 (defun escape-json (str)
17   (with-output-to-string (out)
18     (loop for ch across str do
19           (if (char= ch #\")
20               (write-string "\\\"" out)
21               (if (char= ch #\\)
22                   (write-string "\\\\" out)
23                   (write-char ch out))))))
24 (defun substitute-subseq (string old new &key (test #'eql))
25   (let ((pos (search old string :test test)))
26     (if pos
27         (concatenate 'string
28                      (subseq string 0 pos)
29                      new
30                      (subseq string (+ pos (length old))))
31         string)))

This utility package exports just three functions. Function run-curl-command shells out to curl via UIOP and returns the response body as a string, signaling an error if the exit code is non-zero. Function escape-json walks a string character by character and escapes double-quotes and backslashes so the JSON payload can be safely embedded inside a shell double-quoted string argument — a necessary workaround when building curl commands this way. Finally, function substitute-subseq does a single-pass string substitution; it is used to replace :null with :false in serialized JSON, which corrects a quirk in how library cl-json renders nil values that would otherwise confuse the LLM APIs.

simple-tools.lisp — Provider-Agnostic Tool Registry

 1 (defpackage #:simple-tools
 2   (:use #:cl)
 3   (:export #:*tools*
 4            #:tool
 5        #:define-tool
 6            #:make-tool
 7            #:tool-name
 8            #:tool-description
 9            #:tool-parameters
10            #:tool-fn
11            #:define-tool
12            #:call-tool
13            #:render-tool
14            #:map-args-to-parameters))
15 
16 (in-package #:simple-tools)
17 
18 (defstruct tool
19   "A tool callable by LLM completions."
20   name
21   description
22   parameters
23   fn)
24 
25 (defvar *tools* (make-hash-table :test 'equalp))
26 
27 (defmacro define-tool (name args description &body body)
28   "Define a tool callable by LLM completions.
29 ARGS is a list of (param-name type description) triples."
30   (let ((name-str (if (symbolp name) (string-downcase (symbol-name name)) name))
31         (arg-names (mapcar (lambda (arg) (intern (string-upcase (first arg)))) args)))
32     `(setf (gethash ,name-str *tools*)
33            (make-tool
34             :name ,name-str
35             :description ,description
36             :parameters ',args
37             :fn (lambda ,arg-names ,@body)))))
38 
39 (defun call-tool (tool-name &rest args)
40   "Call a tool by name with the given arguments."
41   (let ((tool (gethash tool-name *tools*)))
42     (if tool
43         (apply (tool-fn tool) args)
44         (error "Unknown tool: ~A" tool-name))))
45 
46 (defun render-tool (tool)
47   "Render a tool as a JSON schema alist for LLM API requests."
48   `((:type . "function")
49     (:function . ,(remove nil
50                     `((:name . ,(tool-name tool))
51                       (:description . ,(tool-description tool))
52                       ,(when (tool-parameters tool)
53                          `(:parameters . ((:type . "object")
54                                           (:properties
55                        .
56                        ,(loop for p in (tool-parameters tool)
57                                                   collect (list (first p)
58                                                                 (cons :type (second p))
59                                                                 (cons :description (third p)))))
60                                           (:required
61                        .
62                        ,(loop for p in (tool-parameters tool)
63                                                   collect (first p)))))))))))
64 
65 (defun map-args-to-parameters (tool args)
66   "Map an alist of JSON arguments to positional values in the tool's declared parameter order."
67   (loop for (param-name param-type param-desc) in (tool-parameters tool)
68         collect (rest (assoc (intern (string-upcase param-name) :keyword) args))))

The simple-tools package is the heart of the tool support system. Tools are defined with the define-tool macro which takes a name, a list of parameter triples of the form (param-name type description), a docstring description, and a body. Internally each tool is stored as a struct in the tools hash table, keyed by its lowercase string name. This means tools defined once are immediately available to all provider backends — you define a tool in one place and can pass it by name to claude:completions, ollama:completions, or any future Gemini wrapper without modification. The render-tool function serializes a tool into the OpenAI-compatible JSON schema format used by Ollama. Claude’s API uses a slightly different schema key (input_schema rather than parameters), so the claude.lisp file provides its own render-tool-for-claude function that produces the correct structure while still reading from the same tools registry. Function map-args-to-parameters handles the impedance mismatch between the alist of named arguments returned by the JSON parser and the positional argument list expected by the tool’s underlying lambda. It walks the tool’s declared parameter list in order and looks up each parameter by its keyword-interned name in the response alist, returning an ordered list of values ready for apply.

claude.lisp — Anthropic Claude Backend

  1 ;;; Copyright (C) 2026 Mark Watson <markw@markwatson.com>
  2 ;;; Apache 2 License
  3 
  4 (defpackage #:claude
  5   (:use #:cl #:llm)
  6   (:export #:claude-llm
  7            #:completions
  8            #:completions-with-search
  9            #:completions-with-search-and-citations
 10            #:answer-question))
 11 
 12 (in-package #:claude)
 13 
 14 (defvar *claude-endpoint* "https://api.anthropic.com/v1/messages")
 15 (defvar *claude-model* "claude-sonnet-4-6")
 16 (defvar *claude-max-tokens* 1000)
 17 
 18 (defun get-claude-api-key ()
 19   (uiop:getenv "CLAUDE_API"))
 20 
 21 (defun render-tool-for-claude (tool)
 22   "Render a tool as a JSON schema alist in Claude's input_schema format."
 23   (let* ((params (simple-tools:tool-parameters tool))
 24          (properties (loop for p in params
 25                            collect (let ((desc (third p)))
 26                                      (if desc
 27                                          (list (first p)
 28                                                (cons :type (second p))
 29                                                (cons :description desc))
 30                                          (list (first p)
 31                                                (cons :type (second p)))))))
 32          (required (loop for p in params collect (first p)))
 33          (schema (append '((:type . "object"))
 34                          (when properties (list (cons :properties properties)))
 35                          (when required (list (cons :required required))))))
 36     `((:name . ,(simple-tools:tool-name tool))
 37       (:description . ,(simple-tools:tool-description tool))
 38       (:input--schema . ,schema))))
 39 
 40 (defun completions (starter-text &key tools (model-id *claude-model*))
 41   (let* ((tools-rendered (when tools
 42                            (loop for tool-symbol in tools
 43                                  collect (let ((tool (gethash (string tool-symbol) simple-tools:*tools*)))
 44                                            (if tool
 45                                                (render-tool-for-claude tool)
 46                                                (error "Undefined tool function: ~A" tool-symbol))))))
 47          (messages (cond
 48                      ((stringp starter-text)
 49                       (list `((:role . "user")
 50                               (:content . (((:type . "text") (:text . ,starter-text)))))))
 51                      (t starter-text)))
 52          (base-data `((:model . ,model-id)
 53                       (:max--tokens . ,*claude-max-tokens*)
 54                       (:temperature . 0)
 55                       (:messages . ,messages)))
 56          (data (if tools-rendered
 57                    (append base-data (list (cons :tools tools-rendered)))
 58                    base-data))
 59          (request-body (cl-json:encode-json-to-string data))
 60          (fixed-json-data (llm:substitute-subseq request-body ":null" ":false" :test #'string=))
 61          (escaped-json (llm:escape-json fixed-json-data))
 62          (curl-command (format nil "curl ~A -H \"x-api-key: ~A\" -H \"anthropic-version: 2023-06-01\" -H \"content-type: application/json\" -d \"~A\""
 63                                *claude-endpoint*
 64                                (get-claude-api-key)
 65                                escaped-json)))
 66     (format t "$$ data:~%~A~%" data)
 67     (let ((response (llm:run-curl-command curl-command)))
 68       (with-input-from-string (s response)
 69         (let* ((json-as-list (cl-json:decode-json s))
 70                (content (cdr (assoc :content json-as-list)))
 71                (stop-reason (cdr (assoc :stop--reason json-as-list)))
 72                (tool-use-blocks (when (string= stop-reason "tool_use")
 73                                   (remove-if-not (lambda (block)
 74                                                    (string= (cdr (assoc :type block)) "tool_use"))
 75                                                  content))))
 76           (if tool-use-blocks
 77               (let ((results
 78                      (loop for block in tool-use-blocks
 79                            collect (let* ((name (cdr (assoc :name block)))
 80                                          (input (cdr (assoc :input block)))
 81                                          (tool (gethash name simple-tools:*tools*))
 82                                          (mapped-args (simple-tools:map-args-to-parameters tool input)))
 83                                      (apply (simple-tools:tool-fn tool) mapped-args)))))
 84                 (format nil "~{~A~^~%~}" results))
 85               (let ((first-block (car content)))
 86                 (or (cdr (assoc :text first-block)) "No response content"))))))))
 87 
 88 (defun completions-with-search (prompt &optional (model-id *claude-model*))
 89   "Call Claude with the built-in web search tool enabled. Returns the text response."
 90   (let* ((messages (list `((:role . "user")
 91                            (:content . (((:type . "text") (:text . ,prompt)))))))
 92          (search-tool `((:type . "web_search_20250305") (:name . "web_search")))
 93          (data `((:model . ,model-id)
 94                  (:max--tokens . ,*claude-max-tokens*)
 95                  (:temperature . 0)
 96                  (:messages . ,messages)
 97                  (:tools . (,search-tool))))
 98          (request-body (cl-json:encode-json-to-string data))
 99          (fixed-json-data (llm:substitute-subseq request-body ":null" ":false" :test #'string=))
100          (escaped-json (llm:escape-json fixed-json-data))
101          (curl-command (format nil "curl ~A -H \"x-api-key: ~A\" -H \"anthropic-version: 2023-06-01\" -H \"anthropic-beta: web-search-2025-03-05\" -H \"content-type: application/json\" -d \"~A\""
102                                *claude-endpoint*
103                                (get-claude-api-key)
104                                escaped-json))
105          (response (llm:run-curl-command curl-command)))
106     (with-input-from-string (s response)
107       (let* ((json-as-list (cl-json:decode-json s))
108              (content (cdr (assoc :content json-as-list)))
109              (text-blocks (remove-if-not (lambda (block)
110                                            (string= (cdr (assoc :type block)) "text"))
111                                          content))
112              (last-text-block (car (last text-blocks))))
113         (or (cdr (assoc :text last-text-block)) "No response content")))))
114 
115 (defun completions-with-search-and-citations (prompt &optional (model-id *claude-model*))
116   "Call Claude with the built-in web search tool enabled.
117 Returns (values text citations) where citations is a list of (title . url) pairs."
118   (let* ((messages (list `((:role . "user")
119                            (:content . (((:type . "text") (:text . ,prompt)))))))
120          (search-tool `((:type . "web_search_20250305") (:name . "web_search")))
121          (data `((:model . ,model-id)
122                  (:max--tokens . ,*claude-max-tokens*)
123                  (:temperature . 0)
124                  (:messages . ,messages)
125                  (:tools . (,search-tool))))
126          (request-body (cl-json:encode-json-to-string data))
127          (fixed-json-data (llm:substitute-subseq request-body ":null" ":false" :test #'string=))
128          (escaped-json (llm:escape-json fixed-json-data))
129          (curl-command (format nil "curl ~A -H \"x-api-key: ~A\" -H \"anthropic-version: 2023-06-01\" -H \"anthropic-beta: web-search-2025-03-05\" -H \"content-type: application/json\" -d \"~A\""
130                                *claude-endpoint*
131                                (get-claude-api-key)
132                                escaped-json))
133          (response (llm:run-curl-command curl-command)))
134     (with-input-from-string (s response)
135       (let* ((json-as-list (cl-json:decode-json s))
136              (content (cdr (assoc :content json-as-list)))
137              (text-blocks (remove-if-not (lambda (block)
138                                            (string= (cdr (assoc :type block)) "text"))
139                                          content))
140              (last-text-block (car (last text-blocks)))
141              (text (or (cdr (assoc :text last-text-block)) "No response content"))
142              (result-blocks (remove-if-not (lambda (block)
143                                              (string= (cdr (assoc :type block)) "web_search_tool_result"))
144                                            content))
145              (citations (loop for block in result-blocks
146                               for block-content = (cdr (assoc :content block))
147                               append (loop for result in block-content
148                                            when (string= (cdr (assoc :type result)) "web_search_result")
149                                            collect (cons (cdr (assoc :title result))
150                                                          (cdr (assoc :url result)))))))
151         (values text citations)))))
152 
153 (defun answer-question (question)
154   (completions (concatenate 'string "Concisely answer the question: " question)))

The claude.lisp backend differs from the Ollama backend in a few notable ways. First, the API key is read from the CLAUDE_API environment variable and passed as an x-api-key header alongside an anthropic-version header, both required by Anthropic’s API. Second, because Claude’s messages API wraps content in typed blocks, plain string input is converted into the structured message format ]} before serialization, while a pre-built message list is passed through unchanged, giving callers flexibility for multi-turn conversations. Tool schema rendering diverges from the Ollama format: Claude expects a top-level :input_schema key (rendered as :input—schema to satisfy cl-json’s hyphen-to-double-hyphen convention) directly on the tool object rather than nesting under a :function key. The stop reason detection also differs — Claude signals tool use via a stop_reason field set to “tool_use” in the response, at which point the code filters the content array for blocks of type “tool_use”, dispatches each to the corresponding function in tools, and concatenates the results.

ollama.lisp — Local Ollama Backend

 1 ;;; Copyright (C) 2026 Mark Watson <markw@markwatson.com>
 2 ;;; Apache 2 License
 3 
 4 (defpackage #:ollama
 5   (:use #:cl #:llm)
 6   (:export #:ollama-llm
 7            #:completions
 8            #:summarize
 9            #:answer-question))
10 
11 (in-package #:ollama)
12 
13 (defvar *ollama-endpoint* "http://localhost:11434/api/chat")
14 ;;(defvar *ollama-model* "mistral:v0.3")
15 ;;(defvar *ollama-model* "qwen3.5:9b")
16 (defvar *ollama-model* "qwen3.5:0.8b")
17 
18 (defun completions (starter-text &key tools (model-id *ollama-model*) (think t))
19   (let* ((tools-rendered
20           (when tools
21             (loop for tool-symbol in tools
22                   collect (let ((tool (gethash (string tool-symbol)
23                                         simple-tools:*tools*)))
24                             (if tool
25                                 (simple-tools:render-tool tool)
26                                 (error "Undefined tool function: ~A"
27                                   tool-symbol))))))
28          (message (list (cons :|role| "user")
29                         (cons :|content| starter-text)))
30          (data (list (cons :|model| model-id)
31                      (cons :|stream| nil)
32                      (cons :|think| think)
33                      (cons :|messages| (list message))))
34          (data-with-tools (if tools-rendered
35                               (append data (list (cons :|tools| tools-rendered)))
36                               data))
37          (json-data (cl-json:encode-json-to-string data-with-tools))
38          (fixed-json-data
39           (llm:substitute-subseq json-data ":null" ":false" :test #'string=))
40          (escaped-json (llm:escape-json fixed-json-data))
41          (curl-command (format nil "curl ~a -d \"~A\""
42                                *ollama-endpoint*
43                                escaped-json)))
44     (let ((response (llm:run-curl-command curl-command)))
45       (with-input-from-string (s response)
46         (let* ((json-as-list (cl-json:decode-json s))
47                (message-resp (cdr (assoc :message json-as-list)))
48                (tool-calls (cdr (assoc :tool--calls message-resp)))
49                (content (cdr (assoc :content message-resp))))
50           (if tool-calls
51               (let ((results
52                      (loop for call in tool-calls
53                            collect (let* ((func (cdr (assoc :function call)))
54                                          (name (cdr (assoc :name func)))
55                                          (args (cdr (assoc :arguments func)))
56                                          (tool (gethash name simple-tools:*tools*))
57                                          (mapped-args
58                                            (simple-tools:map-args-to-parameters tool args)))
59                                      (apply (simple-tools:tool-fn tool) mapped-args)))))
60                 (format nil "~{~A~^~%~}" results))
61               (or content "No response content")))))))
62 
63 (defun summarize (some-text)
64   (completions (concatenate 'string "Summarize: " some-text)))
65 
66 (defun answer-question (some-text)
67   (completions (concatenate 'string "Q: " some-text " A:")))

The Ollama backend targets a locally running Ollama server on localhost:11434 and defaults to mistral:v0.3, though any model name supported by your local Ollama installation can be passed as the optional third argument. Unlike the Claude backend, no authentication header is needed. The request format follows the OpenAI chat completions convention that Ollama implements, so simple-tools:render-tool (which produces the } shape) is used directly without a custom renderer. Tool call detection reads from message.tool_calls in the response rather than from a top-level stop_reason, reflecting the structural difference between the two APIs. The package also exports convenience wrappers summarize and answer-question that prepend simple prompt prefixes.

Defining Tools — example_tools.lisp

 1 (ql:quickload :llm)
 2 (defpackage #:example-tools
 3   (:use #:cl #:simple-tools))
 4 (in-package #:example-tools)
 5 (define-tool get-weather
 6     ((location string "The city and state, e.g. San Francisco, CA")
 7      (unit string "The unit of temperature, e.g. 'c' or 'f'"))
 8     "Get the current weather in a given location"
 9   (format t "~%[TOOL EXECUTION] Getting weather for ~A in ~A~%" location unit)
10   (if (equal unit "c")
11       "22"
12       "72"))
13 (define-tool add-numbers ((a number) (b number))
14   "Add two numbers together"
15   (format nil "The sum of ~A and ~A is ~A" a b (+ a b)))
16 (define-tool get-current-time ()
17   "Get the current time"
18   (multiple-value-bind (sec min hour date month year)
19       (get-decoded-time)
20     (format nil "Current time: ~2,'0D:~2,'0D:~2,'0D on ~2,'0D/~2,'0D/~A"
21             hour min sec month date year)))
22 (define-tool capitalize-text ((text string))
23   "Convert text to uppercase"
24   (format nil "Capitalized: ~A" (string-upcase text)))

This file demonstrates the define-tool macro in practice. Notice that get-weather stubs out real weather data — it simply returns “22” for Celsius and “72” for Fahrenheit — but the important point is the dispatch mechanism: The LLM correctly identifies which tool to call and with what arguments based solely on the natural language query and the tool’s description and parameter metadata. Tools add-numbers and get-current-time show that tools can have purely numeric parameters or no parameters at all, and the tool capitalize-text shows a simple single-parameter string tool. All four end up in the tools hash table under their lowercase string names the moment the file is loaded.

Testing the Claude Backend — claude_test.lisp

 1 (load (merge-pathnames "example_tools.lisp"
 2                        (or *load-pathname* *default-pathname-defaults*)))
 3 
 4 (defpackage #:claude-test
 5   (:use #:cl #:llm #:claude))
 6 
 7 (in-package #:claude-test)
 8 
 9 (format t "~%--- Testing Claude Completions ---~%")
10 (let ((response (claude:completions "Why is the sky blue?")))
11   (format t "Response:~%~A~%" response))
12 
13 (format t "~%--- Testing Claude Tool Calling ---~%")
14 (let ((response (claude:completions "What is the weather in Paris, France in celsius?" :tools '("get-weather"))))
15   (format t "Response:~%~A~%" response))
16 
17 (format t "~%--- Testing Claude Tool Calling: add-numbers ---~%")
18 (let ((response (claude:completions "What is 42 plus 58?" :tools '("add-numbers"))))
19   (format t "Response:~%~A~%" response))
20 
21 (format t "~%--- Testing Claude Tool Calling: get-current-time ---~%")
22 (let ((response (claude:completions "What is the current time?" :tools '("get-current-time"))))
23   (format t "Response:~%~A~%" response))
24 
25 (format t "~%--- Testing Claude Tool Calling: capitalize-text ---~%")
26 (let ((response (claude:completions "Please capitalize the text 'hello world'" :tools '("capitalize-text"))))
27   (format t "Response:~%~A~%" response))
28 
29 (format t "~%--- Testing Claude Completions with Search ---~%")
30 (let ((response (claude:completions-with-search "What are the latest developments in fusion energy research?")))
31   (format t "Response:~%~A~%" response))
32 
33 (format t "~%--- Testing Claude Completions with Search and Citations ---~%")
34 (multiple-value-bind (text citations)
35     (claude:completions-with-search-and-citations "What is the current population of Tokyo?")
36   (format t "Response:~%~A~%" text)
37   (format t "Citations:~%")
38   (loop for (title . url) in citations
39         do (format t "  ~A~%     ~A~%" title url)))

The test file loads the example tools first to populate tools, then exercises claude:completions in two modes: Without tools (the plain sky-blue question) and with a single named tool passed as a one-element list of strings. Each tool name string must exactly match the key registered in tools by define-tool, which uses string-downcase on the symbol name — so ’(“get-weather”) matches the tool defined as get-weather. Running this file with a valid CLAUDE_API environment variable should produce five blocks of output showing both a normal text completion and four successful tool dispatches.

Design Notes and Tradeoffs

The decision to shell out to curl rather than using a native HTTP client library keeps the dependency footprint minimal. The tradeoff is that JSON must be escaped for shell embedding, which is what llm:escape-json handles. For production use you would likely want to replace the curl layer with dexador or usocket-based HTTP, but for experimentation and book examples the curl approach is refreshingly transparent — you can paste the printed curl command directly into a terminal to inspect the raw API interaction. One limitation of the current tool dispatch is that only a single round-trip is performed. If the model’s tool call result should be fed back to the model for a follow-up response (the full agentic loop), the caller must manage that conversation state manually by building a message list and passing it as starter-text. The completions function’s acceptance of either a plain string or a pre-built message list makes this possible, and it is a natural extension to explore in the next chapter when we look at multi-step agent loops.

Example Program Output

I left debug printout in my code that always starts with two dollar signs. I also edited some output for brevity:

 1 $ sbcl
 2 This is SBCL 2.5.10, an implementation of ANSI Common Lisp.
 3 * (load "claude_test.lisp")
 4 To load "llm":
 5   Load 1 ASDF system:
 6     llm
 7 ; Loading "llm"
 8 
 9 
10 --- Testing Claude Completions ---
11 $$ data:
12 ((model . claude-sonnet-4-6) (max--tokens . 1000) (temperature . 0)
13  (messages
14   ((role . user) (content ((type . text) (text . Why is the sky blue?))))))
15 Response:
16 ## Why the Sky is Blue
17 
18 The sky appears blue because of a phenomenon called **Rayleigh scattering**.
19 
20 ### Here's how it works:
21 
22 1. **Sunlight contains all colors** - White sunlight is made up of all the colors of the rainbow, each with different wavelengths.
23 
24   ...
25   
26 
27 --- Testing Claude Tool Calling ---
28 $$ data:
29 ((model . claude-sonnet-4-6) (max--tokens . 1000) (temperature . 0)
30  (messages
31   ((role . user)
32    (content
33     ((type . text)
34      (text . What is the weather in Paris, France in celsius?)))))
35  (tools
36   ((name . get-weather)
37    (description . Get the current weather in a given location)
38    (input--schema (type . object)
39     (properties
40      (location (type . string)
41       (description . The city and state, e.g. San Francisco, CA))
42      (unit (type . string)
43       (description . The unit of temperature, e.g. 'c' or 'f')))
44     (required location unit)))))
45 
46 [TOOL EXECUTION] Getting weather for Paris, France in c
47 Response:
48 22
49 
50 --- Testing Claude Tool Calling: add-numbers ---
51 $$ data:
52 ((model . claude-sonnet-4-6) (max--tokens . 1000) (temperature . 0)
53  (messages
54   ((role . user) (content ((type . text) (text . What is 42 plus 58?)))))
55  (tools
56   ((name . add-numbers) (description . Add two numbers together)
57    (input--schema (type . object)
58     (properties (a (type . number)) (b (type . number))) (required a b)))))
59 Response:
60 The sum of 42 and 58 is 100
61 
62 --- Testing Claude Tool Calling: get-current-time ---
63 $$ data:
64 ((model . claude-sonnet-4-6) (max--tokens . 1000) (temperature . 0)
65  (messages
66   ((role . user) (content ((type . text) (text . What is the current time?)))))
67  (tools
68   ((name . get-current-time) (description . Get the current time)
69    (input--schema (type . object)))))
70 Response:
71 Current time: 16:06:33 on 03/01/2026
72 
73 --- Testing Claude Tool Calling: capitalize-text ---
74 $$ data:
75 ((model . claude-sonnet-4-6) (max--tokens . 1000) (temperature . 0)
76  (messages
77   ((role . user)
78    (content
79     ((type . text) (text . Please capitalize the text 'hello world')))))
80  (tools
81   ((name . capitalize-text) (description . Convert text to uppercase)
82    (input--schema (type . object) (properties (text (type . string)))
83     (required text)))))
84 Response:
85 Capitalized: HELLO WORLD
86 t
87 * 

We didn’t cover the implementation of the Gemini backend but here is example test code using the combined search and LLM capability:

1 (format t "~%--- Testing Gemini Completions With Google Search ---~%")
2 (let ((response (gemini:generate-with-search "What sci-fi movies are playing in Flagstaff Arizona today?")))
3   (format t "Response:~%~A~%" response))

The output (edited for brevity) looks like this:

 1 --- Testing Gemini Completions With Google Search ---
 2 Response:
 3 Today, Monday, March 2, 2026, there are several science fiction movies playing in Flagstaff, Arizona, primarily at the **Harkins Flagstaff 16** theater.
 4 
 5 The following sci-fi films have confirmed showtimes for today:
 6 
 7 ### **Harkins Flagstaff 16**
 8 *   **Avatar: Fire and Ash** (Sci-Fi/Adventure)
 9     *   **Showtime:** 12:55 PM (3D HFR)
10 *   **Bugonia** (Sci-Fi/Thriller/Comedy)
11     *   **Showtime:** 5:30 PM
12 
13   ...

Wrap Up

In this chapter we built a small but complete multi-provider LLM library in Common Lisp from the ground up. Starting with the shared llm.lisp utility layer, we established a clean foundation of curl-based HTTP communication and JSON manipulation that both the Claude and Ollama backends depend on without duplicating. The simple-tools.lisp package gave us a provider-agnostic tool registry built around the define-tool macro, making it possible to define a tool once and have it immediately available to any backend that knows how to read from tools.

The two provider backends demonstrated how different APIs can be wrapped behind a consistent completions interface despite having meaningfully different request and response shapes. Claude’s input_schema key, typed content blocks, and stop_reason-based tool detection contrast with Ollama’s OpenAI-compatible function schema and tool_calls response structure — yet from the caller’s perspective both are invoked the same way, with the same tool names, and return the same kind of string result.

The example tools and test file showed the library working end to end: natural language queries being correctly routed to the right tool functions, arguments extracted from the model’s JSON response and mapped to positional lambda parameters, and results returned as plain strings ready for further use or display.

A few things are worth keeping in mind as you build on this foundation. The single-pass tool dispatch is intentional in its simplicity but will need to grow into a proper agentic loop if you want the model to reason over tool results and decide on follow-up actions. The curl-based HTTP layer is easy to inspect and debug but would benefit from replacement with a native HTTP client in any long-running or high-throughput context.