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:
- Models - The LLM provider and source of intelligence
- Tools - The capabilities provided to the model
- Anchors - Any pre-processing happening on a repository or directory
- Loops - The orchestrating code handling model output and tool requests
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 8080Then add an allowed directory:
(add-allowed-dir "~/projects/my-app")And now, chat!
(chat "What does the main function in src/main.rs do?")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))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))))))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)))))))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)))))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.
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.
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.
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.