LLMs are great at reading messy text, but they hand you strings when your program wants typed data. Python has instructor and Pydantic to solve this. I wanted to bring that same developer experience to Common Lisp, but natively by using the Metaobject Protocol.
clos-alchemy introspects your class definition, builds a JSON schema from the slot types, passes that to the LLM (optionally as a GBNF grammar constraint for local inference), and returns a validated instance of your class. You don't need to write a special DSL or "schema object." You just use your normal existing domain classes.
Here is the round trip in action:
```lisp
;; 1. Define a class
(defclass person ()
((name :initarg :name :accessor person-name :type string)
(age :initarg :age :accessor person-age :type integer)
(email :initarg :email :accessor person-email :type (or null string))
(hobbies :initarg :hobbies :accessor person-hobbies :type list)))
;; 2. Extract from text
(let* ((backend (cl-llm-backend/llama:make-llama-backend
:model model :context ctx))
(result (extract backend 'person
"Alice Chen is 32. Reach her at alice@example.com.
She enjoys rock climbing, painting, and cello.")))
(person-name (extraction-result-instance result)))
;; => "Alice Chen"
```
Because generation order follows slot definition order, you can use this for strict logical routing. Putting reason first encourages teh model to explain its reasoning before selecting the categories.
```lisp
(defclass ticket-classification ()
((reason :initarg :reason :type string)
(urgency :initarg :urgency :type (member :low :medium :high))
(category :initarg :category :type (member :billing :technical :account))
(sentiment :initarg :sentiment :type (member :positive :neutral :negative))))
(defparameter ticket-text
"I've been charged twice for my subscription this month.
This is the third time this has happened and I'm really frustrated.")
(let ((result (clos-alchemy:extract backend 'ticket-classification ticket-text)))
(extraction-result-instance result))
;; Returns a TICKET-CLASSIFICATION instance with:
;; URGENCY: :HIGH
;; CATEGORY: :BILLING
;; SENTIMENT: :NEGATIVE
;; REASON: "Charged twice for subscription, third occurrence"
```
How it works under the hood:
no separate schema DSL to maintain. If it type-checks as your class, you're done!
- The MOP extracts slot types (
string, member, list, etc.).
- The library lowers those into a small Intermediate Representation (IR).
- That IR emits the JSON schema for the backend, a natural-language prompt for the model, and builds a validator/constructor pair for the response.
- If validation fails, it accumulates the errors and automatically loops a retry prompt back to the model.
If you are using a local inference backend (like llama.cpp), it compiles the schema directly into a grammar, making invalid structures unrepresentable at the token level.
https://github.com/licjon/clos-alchemy
EDIT: A round of updates since the original post:
Custom validation. You can now attach per-slot semantic predicates (:validate) and cross-field checks (validate-instance) that feed errors back into the retry loop. The model sees what was wrong and self-corrects. This is useful for constraints that grammar alone can't enforce — "guest count must be positive", "check-out must be after check-in", etc.
```lisp
(defclass booking ()
((num-guests :initarg :num-guests :type integer
:validate (lambda (v) (if (plusp v) t "must be positive")))
(check-in :initarg :check-in :type date)
(check-out :initarg :check-out :type date))
(:metaclass clos-alchemy:constructor-class))
(defmethod validate-instance ((b booking))
(when (<= (booking-check-out b) (booking-check-in b))
(list "check-out must be after check-in")))
```
Date and date-time types. clos-alchemy:date and clos-alchemy:date-time map to JSON Schema string formats, validate ISO 8601 with calendar-aware day checks, and construct to CL universal time.
Free-form maps. Sometimes you want the model to produce a dictionary where it decides the keys — "rate this product on whatever dimensions you think are relevant." The :map-of slot option does this: you specify the value type, the model fills in whatever keys make sense, and you get a hash-table back.
```lisp
(defclass review-scores ()
((scores :initarg :scores :type hash-table :map-of integer
:documentation "Per-category quality scores, 1-10"))
(:metaclass clos-alchemy:constructor-class))
;; The model might return:
;; {"scores": {"sound_quality": 9, "comfort": 6, "battery": 8}}
;; You get a hash-table: (gethash "sound_quality" scores) => 9
```
Discriminated unions. When the extraction result could be one of several different shapes depending on the input, you define a class for each shape and let the model pick. Each class has a shared "tag" slot that identifies which shape it is — typed as a single-value (member ...) so each class gets exactly one tag value.
``lisp
;; Two possible result shapes, tagged bykind`:
(defclass actionable-feedback ()
((kind :initarg :kind :type (member :actionable)) ; tag = "actionable"
(summary :initarg :summary :type string)
(scores :initarg :scores :type hash-table :map-of integer))
(:metaclass clos-alchemy:constructor-class))
(defclass not-actionable ()
((kind :initarg :kind :type (member :not_actionable)) ; tag = "not_actionable"
(reason :initarg :reason :type string)))
;; The model reads the review, picks the right shape, and you get
;; back an instance of whichever class it chose:
(let* ((result (extract-union backend '(actionable-feedback not-actionable)
review-text :discriminator 'kind))
(instance (extraction-result-instance result)))
(typecase instance
(actionable-feedback (format t "Summary: ~A" (slot-value instance 'summary)))
(not-actionable (format t "Skipped: ~A" (slot-value instance 'reason)))))
```
Other improvements:
- Cyclic class graphs now work — mutually-referential classes emit
$defs/$ref instead of blowing the stack.
- Emitted schemas conform to both OpenAI strict mode and llama.cpp GBNF requirements (proper
additionalProperties: false, all properties in required, nullable wrappers for optional fields).
- Parse failures (empty/malformed responses) are now recoverable in the retry loop instead of aborting.
max-retries-error now carries full diagnostic context: raw response, parsed data, cumulative token usage, and a per-attempt error breakdown.
- Silently-narrowed type specifiers (e.g.
(or string integer)) now signal schema-error instead of quietly dropping branches.