A Common Lisp coding harness designed around extreme configurability.
Common Lisp
2
39 commits
updated Sep 18, 2026
A Common Lisp coding harness designed around extreme configurability. Designed to be hacked, modified, and expanded.
Most harnesses today aren't that configurable. They determine what agent loops can run, what models are available, the tools agents can use, and what context they receive. The goal of this project is to define abstractions which make harness customization easy, while still giving users ultimate freedom.
Why Common Lisp? Because Lisp macros allow us to make harness abstractions native to the language. Lisp's REPL-driven development perfectly fits a chat interface. And finally, Lisp's image-based runtime makes it easy to modify the harness and see changes instantly.
Apprentice is based on four key harness abstractions:
See the Extending The Harness section on how to define these abstractions on your own.
First, clone the repository and load and enter the apprentice package:
git clone https://github.com/skarnati20/apprentice.git ~/quicklisp/local-projects/apprentice
(ql:quickload :apprentice)
(in-package :apprentice)
Look at the available models and choose one:
(available-models) ; => ("llama-cpp" "claude-sonnet-5" "gpt-5.6-terra" ...)
(set-model "claude-sonnet-5")
Make sure you have the proper environment variables set:
export ANTHROPIC_API_KEY=...
export OPENAI_API_KEY=...
export GEMINI_API_KEY=...
export OPENROUTER_API_KEY=...
Or
(setf (uiop:getenv "ANTHROPIC_API_KEY") "...")
(setf (uiop:getenv "OPENAI_API_KEY") "...")
(setf (uiop:getenv "GEMINI_API_KEY") "...")
(setf (uiop:getenv "OPENROUTER_API_KEY") "...")
Or
llama-server -m /path/to/model.gguf --port 8080
Then add an allowed directory:
(add-allowed-dir "~/projects/my-app")
And now, chat!
(chat "What does the main function in src/main.rs do?")
| Command | Description | Output |
|---|---|---|
(available-models) | Returns all available models | ("llama-cpp" "claude-sonnet-5" "gpt-5.6-terra" ...) |
(set-model "claude-sonnet-5") | Sets the active model | "claude-sonnet-5" |
(model) | Returns the active model name | "claude-sonnet-5" |
(add-allowed-dir "~/projects/my-app") | Adds a directory to the allowed directories list | ("/Users/you/projects/my-app/") |
(allowed-dirs) | Returns all allowed directories | ("/Users/you/projects/my-app/") |
(clear-allowed-dirs) | Clears all allowed directories | NIL |
(available-loops) | Returns all available coding loops | (:STANDARD :LITTLE-CODER :APPRENTICE) |
(set-loop :apprentice) | Sets the active coding loop | :APPRENTICE |
(current-loop) | Returns the active coding loop | :APPRENTICE |
(available-anchors) | Returns all available anchors | ("dense-vector-search" "file-tree") |
(add-anchor "file-tree") | Enables an anchor by name | ("file-tree") |
(set-anchor-dir "~/projects/my-app") | Sets anchor directory and processes/indexes files | "/Users/you/projects/my-app/" |
(anchors) | Returns currently enabled anchors | ("dense-vector-search" "file-tree") |
(clear-anchors) | Clears all enabled anchors | NIL |
(save-anchors) | Saves currently loaded anchor state to disk | "/Users/you/projects/my-app/.apprentice" |
(add-option :temperature 0.2) | Adds or sets an option for the coding loop | ((:TEMPERATURE . 0.2)) |
(options) | Returns currently set options | ((:TEMPERATURE . 0.2)) |
(clear-options) | Clears all currently set options | NIL |
(chat "Summarise this repository" :standard :max-tokens 2000) | Runs a conversation turn using the specified loop and options | Model response text |
(show-turns) | Prints conversation history turns | Formatted turn output |
(show-turns 3) | Prints turn at specific index | Formatted turn output |
(show-turns -1) | Prints the most recent turn | Formatted turn output |
(show-turns 2 :limit 500) | Prints turn with custom preview character limit | Formatted turn output |
(set-preview-limit 300) | Sets the preview character limit for show-turns | 300 |
(drop-turns 3) | Drops turn at specified index from history | Updated chat history |
(drop-turns '(2 . 4)) | Drops inclusive range of turns from history | Updated chat history |
(clear) | Clears conversation history | NIL |
Create a new model with defmodel:
(defmodel openrouter
:endpoint "https://openrouter.ai/api/v1/chat/completions"
:headers (("Content-Type" "application/json")
("Authorization" (format nil "Bearer ~a"
(uiop:getenv "OPENROUTER_API_KEY")))
("HTTP-Referer" (uiop:getenv "OPENROUTER_REFERER"))
("X-Title" (uiop:getenv "OPENROUTER_TITLE")))
:params ((model-id "model" :default "anthropic/claude-sonnet-5")
(max-tokens :default 4096)
(temperature :default 0.2)
(top-p)
(stop)
(stream :default nil :as (if value t :false)))
:format-message (openai-format-message msg)
:format-tool (tool->openai tool)
:parse (openai-parse raw))
| Argument | Description |
|---|---|
name | Symbol naming the model. Binds to *<NAME>-MODEL*. |
:endpoint | URL endpoint for HTTP POST requests. |
:headers | List of (NAME VALUE) pairs sent as request headers. |
:params | Option specifications in the form (OPTION [JSON-KEY] [:default VALUE] [:as FORM]). |
:messages-key | JSON key for the message history payload. Defaults to "messages". |
:format-message | Function with msg object bound. Outputs a formatted message. |
:format-tool | Function with tool bound. Transforms a tool struct into the provider's wire format. |
:parse | Body form with raw bound to decoded response JSON. Returns a turn struct. |
Then add it to *models-list*:
(defparameter *models-list*
(list ...
*openrouter-model*))
Create a new tool with deftool:
(deftool read
"Read the contents of a file, with line numbers prefixed."
((path :string "Absolute path to the file to read")
&optional
(offset :integer "1-based line to start from, default 1")
(limit :integer "Maximum lines to read, default 2000"))
:checks (((is-allowed-path *allowed-dirs* path)
(format nil "Not allowed to access this path. Allowed dirs: ~a"
(format nil "~{~A~^, ~}" *allowed-dirs*))))
:fn (let ((start (or offset 1)) (n (or limit 2000)))
(with-open-file (in path :external-format :utf-8)
(loop for i from 1
for line = (read-line in nil)
while (and line (< (- i start) n))
when (>= i start)
collect (format nil "~5d~a~a" i #\Tab line) into out
finally (return (format nil "~{~a~^~%~}" out))))))
| Argument | Description |
|---|---|
name | Symbol naming the tool. Binds to *<NAME>-TOOL*. |
description | String description sent to the model explaining what the tool does. |
params | Parameter list of (NAME TYPE DESCRIPTION) specs (:string, :integer, (:array :string)). Arguments after &optional are optional. |
:checks | List of (TEST MESSAGE) condition pairs. When a TEST fails, MESSAGE is returned to the model and :fn does not run. |
:fn | Body form executed once all checks pass, with parameter variables bound to arguments. Returns the tool result string. |
Then add it to *standard-tools*:
(defparameter *standard-tools*
(list ...
*read-tool*))
Create a new anchor with defanchor:
(defvar *file-tree-store-name* "file-tree.sexp")
(defanchor file-tree
"A simple anchor that tracks every file's path, so a tool can
print the directory as a tree without touching disk again."
:bindings ((paths nil))
:process
(lambda (files)
(setf paths (mapcar #'file-path files))
(list :files (length paths)))
:serialize
(lambda (folder)
(with-open-file (out (merge-pathnames *file-tree-store-name* folder)
:direction :output
:if-exists :supersede
:if-does-not-exist :create)
(prin1 (list :version 1 :paths paths) out)))
:deserialize
(lambda (folder)
(let ((path (merge-pathnames *file-tree-store-name* folder)))
(when (probe-file path)
(let ((data (with-open-file (in path) (read in))))
(setf paths (getf data :paths)))))))
| Argument | Description |
|---|---|
name | Symbol naming the anchor. Binds to *<NAME>-ANCHOR*. |
description | String description of the anchor. |
:bindings | List of (VAR INIT) state declarations held and modified by the anchor. |
:process | Function of files structs that updates bindings before chat requests. |
:serialize | Function of folder that writes bindings to disk. |
:deserialize | Function of folder that reads data back into bindings. |
Then add it to *anchors-list*:
(defparameter *anchors-list*
(list ...
*file-tree-anchor*))
Create a new loop by defining a function like so. The only required argument here is prompt:
(defparameter *escalate-after* 10)
(defun apprentice-loop (prompt &rest options
&key (model *model*)
(system-prompt *apprentice-prompt*)
(tools *apprentice-tools*)
(max-turns 50)
(history nil)
(max-parallel-calls 3)
(escalate-after *escalate-after*)
&allow-other-keys)
(let ((*max-parallel-calls* max-parallel-calls)
(opts (model-options options))
(msgs (seed-messages system-prompt prompt history))
(escalated nil))
(setf *subagent-calls* 0)
(loop repeat max-turns do
(let* ((escalate (>= *subagent-calls* escalate-after))
(kit (if escalate *standard-tools* tools)))
(when (and escalate (not escalated))
(setf escalated t)
(format t "~&⇧ ~a subagent tasks run: the standard tool kit is now available~%"
*subagent-calls*))
(let ((turn (apply #'run-model model msgs kit opts)))
(when (eq (turn-stop turn) :error)
(return (list (turn-text turn) msgs)))
(setf msgs (append msgs (list turn)))
(if (turn-calls turn)
(setf msgs (append msgs (list (make-turn
:role :tool-results
:results (run-calls (turn-calls turn) kit)))))
(return (list (turn-text turn) msgs)))))
finally (return (apply #'force-final-answer
model msgs max-turns opts)))))
| Argument | Description |
|---|---|
prompt | String containing the user prompt. |
&rest options | Additional keyword options forwarded to model calls. |
:model | The model struct used for generation (defaults to *model*). |
:system-prompt | System instructions string for the agent loop. |
:tools | List of tool structs initially available to the loop. |
:max-turns | Maximum number of turns before completing (default 50). |
:history | Conversation message history list. |
Then add it to *loops-list*:
(defparameter *loops-list*
'(...
(:apprentice . apprentice-loop)))
One benefit of Apprentice is that you can create your own loops. While other harnesses allow you to customize their behaviour, by defining a loop you are able to change the literal infrastructure the LLM depends on, which lets you do really creative things.
:standard)A vanilla agent loop which takes a user's prompt and keeps looping until the model has no more tool calls. The output of each tool call is fed back into the model for it to think again.
:little-coder)This is based on the Little Coder project, which tries to optimize the harness to work better with smaller local agents. Use this with llama-cpp and look at the original project repository here.
:apprentice)This loop restricts the primary model from reading and writing files in order to save its context. Instead, it must delegate investigations and tasks to a subagent, which only provides a brief and limited report to the primary model. Use this loop if you want to save on cost for long-running tasks in the background.
This project was developed with LLM-assistance. Most of the core functionality like plumbing, main functions, and the macros (deftool, defmodel, and defanchor) were hand-written and validated with LLMs. Some other code such as the specific model definitions (Gemini, Claude, GPT, etc.) and tool definitions (subagent, exa web-search, etc.) used LLMs extensively.
It is encouraged to improve the existing models, tools, anchors, and loops in this repository and define your own! LLMs are one way to do it, but they won't be perfect. A project like this benefits from using LLMs to extend the capabilities quickly and allow for quicker harness experimentation and iteration.
39 commits
Common Lisp
100.0%
A Common Lisp coding harness designed around extreme configurability.
Common Lisp
2
39 commits
updated Sep 18, 2026
A Common Lisp coding harness designed around extreme configurability. Designed to be hacked, modified, and expanded.
Most harnesses today aren't that configurable. They determine what agent loops can run, what models are available, the tools agents can use, and what context they receive. The goal of this project is to define abstractions which make harness customization easy, while still giving users ultimate freedom.
Why Common Lisp? Because Lisp macros allow us to make harness abstractions native to the language. Lisp's REPL-driven development perfectly fits a chat interface. And finally, Lisp's image-based runtime makes it easy to modify the harness and see changes instantly.
Apprentice is based on four key harness abstractions:
See the Extending The Harness section on how to define these abstractions on your own.
First, clone the repository and load and enter the apprentice package:
git clone https://github.com/skarnati20/apprentice.git ~/quicklisp/local-projects/apprentice
(ql:quickload :apprentice)
(in-package :apprentice)
Look at the available models and choose one:
(available-models) ; => ("llama-cpp" "claude-sonnet-5" "gpt-5.6-terra" ...)
(set-model "claude-sonnet-5")
Make sure you have the proper environment variables set:
export ANTHROPIC_API_KEY=...
export OPENAI_API_KEY=...
export GEMINI_API_KEY=...
export OPENROUTER_API_KEY=...
Or
(setf (uiop:getenv "ANTHROPIC_API_KEY") "...")
(setf (uiop:getenv "OPENAI_API_KEY") "...")
(setf (uiop:getenv "GEMINI_API_KEY") "...")
(setf (uiop:getenv "OPENROUTER_API_KEY") "...")
Or
llama-server -m /path/to/model.gguf --port 8080
Then add an allowed directory:
(add-allowed-dir "~/projects/my-app")
And now, chat!
(chat "What does the main function in src/main.rs do?")
| Command | Description | Output |
|---|---|---|
(available-models) | Returns all available models | ("llama-cpp" "claude-sonnet-5" "gpt-5.6-terra" ...) |
(set-model "claude-sonnet-5") | Sets the active model | "claude-sonnet-5" |
(model) | Returns the active model name | "claude-sonnet-5" |
(add-allowed-dir "~/projects/my-app") | Adds a directory to the allowed directories list | ("/Users/you/projects/my-app/") |
(allowed-dirs) | Returns all allowed directories | ("/Users/you/projects/my-app/") |
(clear-allowed-dirs) | Clears all allowed directories | NIL |
(available-loops) | Returns all available coding loops | (:STANDARD :LITTLE-CODER :APPRENTICE) |
(set-loop :apprentice) | Sets the active coding loop | :APPRENTICE |
(current-loop) | Returns the active coding loop | :APPRENTICE |
(available-anchors) | Returns all available anchors | ("dense-vector-search" "file-tree") |
(add-anchor "file-tree") | Enables an anchor by name | ("file-tree") |
(set-anchor-dir "~/projects/my-app") | Sets anchor directory and processes/indexes files | "/Users/you/projects/my-app/" |
(anchors) | Returns currently enabled anchors | ("dense-vector-search" "file-tree") |
(clear-anchors) | Clears all enabled anchors | NIL |
(save-anchors) | Saves currently loaded anchor state to disk | "/Users/you/projects/my-app/.apprentice" |
(add-option :temperature 0.2) | Adds or sets an option for the coding loop | ((:TEMPERATURE . 0.2)) |
(options) | Returns currently set options | ((:TEMPERATURE . 0.2)) |
(clear-options) | Clears all currently set options | NIL |
(chat "Summarise this repository" :standard :max-tokens 2000) | Runs a conversation turn using the specified loop and options | Model response text |
(show-turns) | Prints conversation history turns | Formatted turn output |
(show-turns 3) | Prints turn at specific index | Formatted turn output |
(show-turns -1) | Prints the most recent turn | Formatted turn output |
(show-turns 2 :limit 500) | Prints turn with custom preview character limit | Formatted turn output |
(set-preview-limit 300) | Sets the preview character limit for show-turns | 300 |
(drop-turns 3) | Drops turn at specified index from history | Updated chat history |
(drop-turns '(2 . 4)) | Drops inclusive range of turns from history | Updated chat history |
(clear) | Clears conversation history | NIL |
Create a new model with defmodel:
(defmodel openrouter
:endpoint "https://openrouter.ai/api/v1/chat/completions"
:headers (("Content-Type" "application/json")
("Authorization" (format nil "Bearer ~a"
(uiop:getenv "OPENROUTER_API_KEY")))
("HTTP-Referer" (uiop:getenv "OPENROUTER_REFERER"))
("X-Title" (uiop:getenv "OPENROUTER_TITLE")))
:params ((model-id "model" :default "anthropic/claude-sonnet-5")
(max-tokens :default 4096)
(temperature :default 0.2)
(top-p)
(stop)
(stream :default nil :as (if value t :false)))
:format-message (openai-format-message msg)
:format-tool (tool->openai tool)
:parse (openai-parse raw))
| Argument | Description |
|---|---|
name | Symbol naming the model. Binds to *<NAME>-MODEL*. |
:endpoint | URL endpoint for HTTP POST requests. |
:headers | List of (NAME VALUE) pairs sent as request headers. |
:params | Option specifications in the form (OPTION [JSON-KEY] [:default VALUE] [:as FORM]). |
:messages-key | JSON key for the message history payload. Defaults to "messages". |
:format-message | Function with msg object bound. Outputs a formatted message. |
:format-tool | Function with tool bound. Transforms a tool struct into the provider's wire format. |
:parse | Body form with raw bound to decoded response JSON. Returns a turn struct. |
Then add it to *models-list*:
(defparameter *models-list*
(list ...
*openrouter-model*))
Create a new tool with deftool:
(deftool read
"Read the contents of a file, with line numbers prefixed."
((path :string "Absolute path to the file to read")
&optional
(offset :integer "1-based line to start from, default 1")
(limit :integer "Maximum lines to read, default 2000"))
:checks (((is-allowed-path *allowed-dirs* path)
(format nil "Not allowed to access this path. Allowed dirs: ~a"
(format nil "~{~A~^, ~}" *allowed-dirs*))))
:fn (let ((start (or offset 1)) (n (or limit 2000)))
(with-open-file (in path :external-format :utf-8)
(loop for i from 1
for line = (read-line in nil)
while (and line (< (- i start) n))
when (>= i start)
collect (format nil "~5d~a~a" i #\Tab line) into out
finally (return (format nil "~{~a~^~%~}" out))))))
| Argument | Description |
|---|---|
name | Symbol naming the tool. Binds to *<NAME>-TOOL*. |
description | String description sent to the model explaining what the tool does. |
params | Parameter list of (NAME TYPE DESCRIPTION) specs (:string, :integer, (:array :string)). Arguments after &optional are optional. |
:checks | List of (TEST MESSAGE) condition pairs. When a TEST fails, MESSAGE is returned to the model and :fn does not run. |
:fn | Body form executed once all checks pass, with parameter variables bound to arguments. Returns the tool result string. |
Then add it to *standard-tools*:
(defparameter *standard-tools*
(list ...
*read-tool*))
Create a new anchor with defanchor:
(defvar *file-tree-store-name* "file-tree.sexp")
(defanchor file-tree
"A simple anchor that tracks every file's path, so a tool can
print the directory as a tree without touching disk again."
:bindings ((paths nil))
:process
(lambda (files)
(setf paths (mapcar #'file-path files))
(list :files (length paths)))
:serialize
(lambda (folder)
(with-open-file (out (merge-pathnames *file-tree-store-name* folder)
:direction :output
:if-exists :supersede
:if-does-not-exist :create)
(prin1 (list :version 1 :paths paths) out)))
:deserialize
(lambda (folder)
(let ((path (merge-pathnames *file-tree-store-name* folder)))
(when (probe-file path)
(let ((data (with-open-file (in path) (read in))))
(setf paths (getf data :paths)))))))
| Argument | Description |
|---|---|
name | Symbol naming the anchor. Binds to *<NAME>-ANCHOR*. |
description | String description of the anchor. |
:bindings | List of (VAR INIT) state declarations held and modified by the anchor. |
:process | Function of files structs that updates bindings before chat requests. |
:serialize | Function of folder that writes bindings to disk. |
:deserialize | Function of folder that reads data back into bindings. |
Then add it to *anchors-list*:
(defparameter *anchors-list*
(list ...
*file-tree-anchor*))
Create a new loop by defining a function like so. The only required argument here is prompt:
(defparameter *escalate-after* 10)
(defun apprentice-loop (prompt &rest options
&key (model *model*)
(system-prompt *apprentice-prompt*)
(tools *apprentice-tools*)
(max-turns 50)
(history nil)
(max-parallel-calls 3)
(escalate-after *escalate-after*)
&allow-other-keys)
(let ((*max-parallel-calls* max-parallel-calls)
(opts (model-options options))
(msgs (seed-messages system-prompt prompt history))
(escalated nil))
(setf *subagent-calls* 0)
(loop repeat max-turns do
(let* ((escalate (>= *subagent-calls* escalate-after))
(kit (if escalate *standard-tools* tools)))
(when (and escalate (not escalated))
(setf escalated t)
(format t "~&⇧ ~a subagent tasks run: the standard tool kit is now available~%"
*subagent-calls*))
(let ((turn (apply #'run-model model msgs kit opts)))
(when (eq (turn-stop turn) :error)
(return (list (turn-text turn) msgs)))
(setf msgs (append msgs (list turn)))
(if (turn-calls turn)
(setf msgs (append msgs (list (make-turn
:role :tool-results
:results (run-calls (turn-calls turn) kit)))))
(return (list (turn-text turn) msgs)))))
finally (return (apply #'force-final-answer
model msgs max-turns opts)))))
| Argument | Description |
|---|---|
prompt | String containing the user prompt. |
&rest options | Additional keyword options forwarded to model calls. |
:model | The model struct used for generation (defaults to *model*). |
:system-prompt | System instructions string for the agent loop. |
:tools | List of tool structs initially available to the loop. |
:max-turns | Maximum number of turns before completing (default 50). |
:history | Conversation message history list. |
Then add it to *loops-list*:
(defparameter *loops-list*
'(...
(:apprentice . apprentice-loop)))
One benefit of Apprentice is that you can create your own loops. While other harnesses allow you to customize their behaviour, by defining a loop you are able to change the literal infrastructure the LLM depends on, which lets you do really creative things.
:standard)A vanilla agent loop which takes a user's prompt and keeps looping until the model has no more tool calls. The output of each tool call is fed back into the model for it to think again.
:little-coder)This is based on the Little Coder project, which tries to optimize the harness to work better with smaller local agents. Use this with llama-cpp and look at the original project repository here.
:apprentice)This loop restricts the primary model from reading and writing files in order to save its context. Instead, it must delegate investigations and tasks to a subagent, which only provides a brief and limited report to the primary model. Use this loop if you want to save on cost for long-running tasks in the background.
This project was developed with LLM-assistance. Most of the core functionality like plumbing, main functions, and the macros (deftool, defmodel, and defanchor) were hand-written and validated with LLMs. Some other code such as the specific model definitions (Gemini, Claude, GPT, etc.) and tool definitions (subagent, exa web-search, etc.) used LLMs extensively.
It is encouraged to improve the existing models, tools, anchors, and loops in this repository and define your own! LLMs are one way to do it, but they won't be perfect. A project like this benefits from using LLMs to extend the capabilities quickly and allow for quicker harness experimentation and iteration.
39 commits
Common Lisp
100.0%