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/.
| Module | What | Built on |
|---|---|---|
my.context | AGENTS.md (or CLAUDE.md) from the session cwd in the system prompt | advise! on system-prompt |
my.usage | token usage per session | advise! on call-model |
my.tools | ls, find, grep tools (rg when on PATH, else grep -rn), output capped | tool-namespaces |
my.commands | /model shows or switches the model, /usage, /ls-here runs ls as the agent | command-namespaces, setq, call-tool |
my.permissions | ask before write, edit, bash; a rejection blocks the call and the model sees why; "always" is remembered per tool for the session | advise! 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.