oml
2026-10-07docs

Primitives

What the core gives Lisp code, for what an init file could not do on its own. Everything in Recipes is built on these.

The current session

oml.session. *session* is the id of the current session: bound while a prompt runs (commands, /eval, tools and the loop included). Its root value is the most recently active session, so nREPL evaluations work on that one. Tools, commands and init code take no context argument: they call these functions.

(oml.session/session)        ; => {:id "sess_..." :cwd "/project" :cancel <token or nil> ...}
(oml.session/cwd)            ; => "/project"
(oml.session/resolve-path "src/a.clj")  ; against the session cwd
(oml.session/transcript)     ; => [{:role "user" :content "..."} {:role "assistant" ...} ...]
(oml.session/append-message! {:role "user" :content "Remember: tabs."})  ; the next request sees it
(oml.session/set-transcript! compacted)                                 ; replace it (compaction)
(oml.session/say "Indexing...")  ; agent_message_chunk to the client

The transcript holds OpenAI-format messages without the system message, which is rebuilt for every request. The loop appends each assistant message together with its tool results, so code appending messages meanwhile never splits a tool_calls message from its tool messages.

oml.session/on-session-start is called with no arguments when a session starts, with it current, after its project init file is loaded. It does nothing; redefine or advise it.

Call a tool as the agent

oml.agent/call-tool runs a tool through run-tool-call, like a model tool call: the client shows a tool_call card and its tool_call_update, and advice on execute-tool applies. Returns {:content :error?}. With :record? true the call is appended to the transcript as the model would have made it, so the model sees it next turn:

(oml.agent/call-tool 'ls {:path "."} :record? true)   ; a symbol, a name or a var
;; appends
{:role "assistant" :content nil
 :tool_calls [{:id "call_..." :type "function"
               :function {:name "ls" :arguments "{\"path\":\".\"}"}}]}
{:role "tool" :tool_call_id "call_..." :content "AGENTS.md\nsrc/"}

One-shot model call

oml.agent/complete: the configured model, no tools, nothing shown to the user; returns the text. prompt is a string (one user message) or a vector of messages. It goes through call-model, so its advice applies. Cancelling the prompt cancels it, and it throws.

(oml.agent/complete "Name this session in three words." :system "Be terse.")
(oml.agent/complete (oml.session/transcript) :model "small/model")

Requests to the client

oml.acp/request! sends a JSON-RPC request to the ACP client of the current session and blocks until it answers; it throws on an error response, when the client goes away, or when the prompt is cancelled. oml.acp/request-permission is ACP session/request_permission on top of it, with oml.acp/permission-options as the default options. The core has no permission policy; it only asks.

(oml.acp/request-permission {:toolCallId id :rawInput args})
;; => {:outcome "selected" :optionId "allow_always" :kind "allow_always"}
;;    or {:outcome "cancelled"}
;; default options: allow_once, allow_always, reject_once, reject_always

Responses are read by the stdin reader thread, so request! refuses to run on it (in on-session-start, which runs there); prompts, commands, tools and nREPL are fine.

Reasoning and raw chunks

reasoning_content or reasoning deltas are shown as agent_thought_chunk (loop event :thought-delta) and kept out of the transcript unless oml.llm/send-reasoning? is true. oml.llm/on-chunk is called with every parsed SSE chunk as it arrives and does nothing; redefine or advise it for anything else a provider sends.

Extension points

Every step is a public, documented function; these are the ones meant to be redefined or wrapped. Each takes and returns plain data.

FunctionWhat it does
oml.agent/system-prompt[] the system prompt, built for every request; wrap it to add a section
oml.agent/tools, oml.agent/commands[] the tool and command vars, from the namespaces
oml.agent/build-request[messages] the request: system prompt, transcript, tool specs
oml.agent/call-model[request] one model call; returns {:message :usage :cancelled?}; wrap it to rewrite requests or account usage
oml.agent/run-tool-call[call] one OpenAI tool call: show it, execute-tool, show the result
oml.agent/execute-tool[{:id :name :args}] run the tool; wrap it for a permission policy (the client already shows the call) or to rewrite results
oml.agent/tool-result-message[call result] the tool message for the transcript
oml.agent/stop-reason[{:turn :response}] nil to go on, or :end-turn, :cancelled, :max-turns
oml.agent/run[] the loop itself, on the current transcript; returns the stop reason
oml.agent/parse-prompt, oml.agent/run-command, oml.agent/prompthow the user's text becomes a command or a turn
oml.session/on-session-start[] called when a session starts; does nothing
oml.llm/on-chunk[chunk] called with every raw SSE chunk; does nothing
oml.acp/render-event[event] the ACP update for a loop event
oml.acp/handlea multimethod per ACP method; defmethod adds one

Settings

Documented vars holding data; /settings lists them with their values, including those of user code.

SettingDefault
oml.llm/base-url, oml.llm/api-key, oml.llm/modelfrom OPENAI_BASE_URL (else https://api.openai.com/v1), OPENAI_API_KEY, OPENAI_MODEL; the key is ^:secret and never shown
oml.llm/send-reasoning?false
oml.agent/max-turns30 model calls per prompt
oml.agent/tool-namespaces, oml.agent/command-namespaces[oml.tools], [oml.commands]
oml.tools/bash-timeout, oml.tools/bash-max-chars120 seconds, 50000 characters (the tail is kept)
oml.tools/read-max-chars, oml.tools/read-default-limit50000 characters, 2000 lines
oml.init/load-project-init?true
oml.repl/nrepl?, oml.repl/nrepl-host, oml.repl/nrepl-porttrue, "127.0.0.1", 0 (a free port)