oml
2026-10-07docs

Recipes

Features other agents build into their core are user code here: short modules on top of the primitives.

Each module lives in examples/lisp/my/, is loaded by examples/init.clj, and runs end to end in test/oml/examples_test.clj. To use them, copy examples/init.clj to ~/.config/oml/init.clj and examples/lisp/ to ~/.config/oml/lisp/.

ModuleWhatBuilt on
my.contextAGENTS.md (or CLAUDE.md) from the session cwd in the system promptadvise! on system-prompt
my.usagetoken usage per sessionadvise! on call-model
my.toolsls, find, grep tools (rg when on PATH, else grep -rn), output cappedtool-namespaces
my.commands/model shows or switches the model, /usage, /ls-here runs ls as the agentcommand-namespaces, setq, call-tool
my.permissionsask before write, edit, bash; a rejection blocks the call and the model sees why; "always" is remembered per tool for the sessionadvise! on execute-tool, request-permission

Project context

Advice on oml.agent/system-prompt appends the project's instructions, read from the session's directory on every request:

(def context-files
  "Files read into the system prompt; the first one found in the working
  directory is used."
  ["AGENTS.md" "CLAUDE.md"])

(advise! #'agent/system-prompt ::project-context
         (fn [system-prompt]
           (let [f (some #(let [f (fs/path (session/cwd) %)] (when (fs/regular-file? f) f)) context-files)]
             (cond-> (system-prompt)
               f (str "\n\nProject instructions (" (fs/file-name f) "):\n\n" (slurp (str f)))))))

Token usage

Advice on oml.agent/call-model adds up the usage the endpoint reports, per session:

(defonce ^{:doc "session id -> {:calls :prompt_tokens :completion_tokens}"} totals (atom {}))

(advise! #'agent/call-model ::count
         (fn [call-model request]
           (let [{:keys [usage] :as response} (call-model request)]
             (when usage
               (swap! totals update (:id (session/session)) (partial merge-with +)
                      {:calls 1 :prompt_tokens (:prompt_tokens usage 0)
                       :completion_tokens (:completion_tokens usage 0)}))
             response)))

Search tools

ls, find and grep are documented public functions of my.tools; listing the namespace in oml.agent/tool-namespaces makes them tools. ls, for example:

(defn ls
  "List a directory. Subdirectories end with /."
  {:kind "read"
   :title (fn [{:keys [path]}] (str "List " (or path ".")))
   :params {:path [:string "Directory, default the working directory" :optional]}}
  [{:keys [path]}]
  (let [dir (resolve-path (or path "."))]
    (when-not (fs/directory? dir) (throw (ex-info (str "Not a directory: " dir) {})))
    (let [names (sort (for [f (fs/list-dir dir)] (str (fs/file-name f) (when (fs/directory? f) "/"))))]
      (if (empty? names) "(empty)" (capped names)))))

Commands

/model switches the model with setq; /ls-here calls a tool as the agent, so the client shows the call and the model sees it on the next turn:

(defn model
  "Show the model, or switch to another one: /model <id>."
  {:hint "model id (empty: show the current one)"}
  [input]
  (if (str/blank? input)
    (str "Model: " (or llm/model "none"))
    (do (setq oml.llm/model input)
        (str "Model set to " input))))

(defn usage
  "Show the tokens used in this session."
  [_]
  (if-let [{:keys [calls prompt_tokens completion_tokens]} (get @my.usage/totals (:id (session/session)))]
    (str calls " model calls, " prompt_tokens " prompt tokens, " completion_tokens " completion tokens")
    "No model calls yet."))

(defn ls-here
  "List the working directory with the ls tool, as the agent: the client
  shows the tool call and the model sees it on the next turn."
  [_]
  (agent/call-tool 'ls {:path "."} :record? true)
  nil)

Permissions

Advice on oml.agent/execute-tool asks the client before write, edit and bash. The client already shows the call; a rejection becomes an error result the model reads. The answer comes from oml.acp/request-permission:

(def ask-tools
  "Tools that need the user's permission."
  #{"write" "edit" "bash"})

(defonce ^{:doc "session id -> tool name -> :allow or :reject"} remembered (atom {}))

(defn- decide
  "Nil to run the call, or why it is blocked."
  [{:keys [id name args]}]
  (let [k [(:id (session/session)) name]]
    (case (get-in @remembered k)
      :allow nil
      :reject (str "the user always rejects " name)
      (let [{:keys [outcome kind]} (acp/request-permission {:toolCallId id :rawInput args})]
        (case kind
          "allow_once" nil
          "allow_always" (do (swap! remembered assoc-in k :allow) nil)
          "reject_always" (do (swap! remembered assoc-in k :reject) "the user rejected it")
          (if (= "cancelled" outcome)
            "the prompt was cancelled before the user answered"
            "the user rejected it"))))))

(advise! #'agent/execute-tool ::ask
         (fn [execute-tool {:keys [name] :as call}]
           (if-let [reason (and (contains? ask-tools name) (decide call))]
             {:content (str "Blocked: " reason) :error? true}
             (execute-tool call))))

Replacing the loop

oml.agent/prompt calls oml.agent/run, the loop, through its var, so a different control flow is one advise! (or defn) away. This sketch wraps the built-in loop in plan → execute → review: one tool-less call writes a plan, the loop carries it out, another call reviews the result and sends the loop back with what is missing, at most twice. It is not in examples/ and not covered by the test suite; it has only been run against the tests' scripted fake model.

(ns my.graph
  "Sketch: plan -> execute -> review around the built-in loop."
  (:require [clojure.string :as str]
            [oml.agent :as agent]
            [oml.custom :refer [advise!]]
            [oml.session :as session]))

(def max-reviews "Review rounds before giving up." 2)

(defn- ask [instruction]
  (agent/complete (session/transcript) :system instruction))

(advise! #'agent/run ::plan-execute-review
  (fn [run]
    (let [plan (ask "Write a short numbered plan for the user's last request. No code.")]
      (session/say "Plan:\n" plan "\n\n")
      (session/append-message! {:role "assistant" :content plan}
                               {:role "user" :content "Carry out the plan."})
      (loop [round 0]
        (let [reason (run)]
          (if (or (not= :end-turn reason) (= round max-reviews))
            reason
            (let [review (ask "Review the work above against the plan. Answer DONE if it is complete, else list what is missing.")]
              (if (str/starts-with? (str/trim review) "DONE")
                reason
                (do (session/say "\n\nReview:\n" review "\n\n")
                    (session/append-message! {:role "user" :content (str "Not done yet:\n" review)})
                    (recur (inc round)))))))))))

Each node is an ordinary function call, so a node can use another model (complete takes :model) or be a graph of its own. (unadvise! #'oml.agent/run :my.graph/plan-execute-review) restores the plain loop.