Skip to content

Relay tools — declaration-only

Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §10 · clojure/src/toolnexus/client.cljc

Declare a tool the host executes, not the library: the call rides out on the suspension.

Build the tool normally and have its :execute suspend instead of doing the work. The arguments the model supplied are already in your hands, so putting them on the Request’s :data gives you the same thing a relay does — a declared, schema’d tool whose execution happens somewhere else.

(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client]
'[koine.json :as json])
(defn relayed
"A tool the model can call but this process cannot run: the call is handed
out on the suspension and the answer comes back as the result."
[nm description schema]
(tool/tool
{:name nm
:description description
:input-schema schema
:execute
(fn ([args]
;; nothing is executed here — the call itself is the payload
(client/suspend
(client/make-request "input" (str "Execute " nm " on the device")
{:data {:tool nm :args args :schema schema}})
(str "Awaiting host execution of " nm)))
([_args ctx]
;; whatever the host ran, verbatim
(tool/success (json/write-str (get-in ctx [:answer :data])))))}))
(def device-toolkit
(tool/toolkit
[(relayed "read_sensor" "Read a sensor on the paired device"
{:type "object"
:properties {:sensor {:type "string"}}
:required ["sensor"]})]))

The host side is the :wait-for slot, or the "pending" outcome if the executor is out of process:

(def llm
(client/create-client
{:base-url "https://api.openai.com/v1"
:model "gpt-4.1"
:wait-for (fn [request]
(let [{:keys [tool args]} (:data request)]
;; ship the call to whoever can actually run it, block for the reply
(client/make-answer (:id request) true
(invoke-on-device! tool args))))}))
(client/run llm "What is the boiler temperature?" {:toolkit device-toolkit})

Two differences from a real relay are worth knowing before you rely on this. The tool still needs a local :execute, so the declaration is not quite free — that is a few lines, not a design problem. More importantly, only the first suspension in a turn survives when there is no :wait-for: later ones are dropped from the transcript and re-suspend on the next run. With :wait-for set, every suspension in the turn resolves, sequentially in call order, so a batch of relayed calls works — but a fire-and-forget host that relies on collecting several parked calls at once will only ever see one.