Skip to content

toolnexus.client/auth-required

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

(auth-required url)
(auth-required url prompt)
;; => {:output "Login required: <url>"
;; :isError true
;; :metadata {:pending {:id "sus-…" :kind "authorization"
;; :prompt "<prompt>" :url "<url>"}}}

auth-required is suspend with the authorization shape filled in: kind is "authorization", the URL rides on the Request’s :url, and the transcript output is "Login required: <url>" whatever prompt you pass. One argument gives you a default prompt of the same text; two lets you say something the user will actually understand.

The "authorization" kind is the one case where the answer’s payload is irrelevant. The host’s job is to get the human to the URL; when they come back and the answer is ok, the world changed out of band — the session is simply valid now — and your tool re-runs and succeeds without reading answer.data. That is why the retry arity typically only checks (get-in ctx [:answer :ok]).

The URL is the whole point: a chat front end renders it as a link, a CLI prints it, a web app redirects to it. Everything else about the suspension is identical to any other.

  • A token expired mid-run and the user must re-consent — OAuth, an SSO session, a device-code flow.
  • A tool hits 401 or 403 against an upstream API it can authorize interactively.
  • Not for a missing server-side key — if your process is misconfigured, no human click fixes it. Return toolnexus.tool/failure and let the run end honestly.

Both produce the same ToolResult shape, so a host that handles one handles the other.

(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client])
(def list-repos
(tool/tool
{:name "list_repos"
:description "List the user's repositories"
:execute
(fn ([_args]
(if (session-valid?)
(tool/success (fetch-repos!))
(client/auth-required "https://example.com/oauth/authorize?scope=repo"
"Connect your GitHub account to continue")))
([_args ctx]
;; the retry: the answer carries no payload — the session is simply valid now
(if (get-in ctx [:answer :ok])
(tool/success (fetch-repos!))
(tool/failure "still not connected"))))}))

Re-check the session in the retry arity rather than trusting ok blindly if a failed login is plausible in your flow; the loop will not call you a third time either way.

Section titled “Pushing the link to the user, then resolving it”

The pending event fires before :wait-for runs, which is what lets a front end show the link at the moment the tool parks rather than after the human is already waiting.

(def llm
(client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
;; blocks the loop until the human comes back
:wait-for (fn [request]
(if (= "authorization" (:kind request))
(let [ok? (block-until-oauth-callback! (:id request) (:url request))]
(client/make-answer (:id request) ok? nil
(when-not ok? "the user cancelled")))
(client/make-answer (:id request) false nil "unsupported")))}))
(client/run llm "Which of my repos have failing builds?"
{:toolkit (tool/toolkit [list-repos])
:on-event (fn [ev]
(when (= "pending" (:type ev))
(send-to-chat! (str (get-in ev [:request :prompt]) "\n"
(get-in ev [:request :url])))))})

A declined answer is not a crash: the loop feeds "declined/expired: <prompt>" back to the model as an error result and keeps going, so the model can explain the situation instead of the run dying.

Omit :wait-for and the run returns as soon as the tool parks, with everything needed to resume later.

(let [r (client/run llm "Which repos have failing builds?"
{:toolkit (tool/toolkit [list-repos])})]
(when (= "pending" (:status r))
(let [req (:pending r)]
(queue-for-human! {:id (:id req) ; correlate the eventual answer
:kind (:kind req) ; "authorization"
:url (:url req) ; where to send them
:prompt (:prompt req)})
;; :messages holds the transcript up to and including the parked call
(persist-transcript! (:messages r)))))

There is no cross-process resume in this port: when the human is done, start a new run with the saved :messages as :history and a :wait-for that now answers immediately. See resume for the shape of that gap.

Call prompt Output written to the transcript
(auth-required url) "Login required: <url>" "Login required: <url>"
(auth-required url prompt) your text "Login required: <url>"

The output is fixed on purpose: the model should see a consistent, unmistakable marker, while the prompt is what a human reads.