Suspension & the human loop
Your agent is halfway through a task and hits a wall only a person can clear: approve a refund, log into a bank, pick between two options, sign off on a risky action. What should the loop do — crash? Block a thread for six hours? Guess?
toolnexus gives you a fourth answer: suspend. Any tool can pause the loop, surface a Request,
and resume once it’s answered — the same way in all seven ports.
The one decision: can the human answer now, or later?
Section titled “The one decision: can the human answer now, or later?”This is the whole mental model. Everything else follows from it.
| The answer arrives… | Give the client… | What run() does |
|---|---|---|
| Now — a person is right there, or a service resolves it inside the same request (seconds) | a waitFor host |
Blocks on waitFor, feeds the answer back, runs on to status: "done". The pause is invisible. |
| Later — minutes to days later, maybe in another process, after the web request already returned | no waitFor |
Returns status: "pending" with the Request. You persist it and come back through the answer-carrying entry point — which is golang-only today, so read Scenario 3 before you build on it. |
Same primitive, two postures. Pick by when the human answers, not by what you’re asking.
The primitive
Section titled “The primitive”A tool suspends by returning pending(Request). The client resolves it to an Answer.
// A tool returns this instead of a result:pending({ kind: "question", prompt: "Ship it?", data: { questions: [...] } })
// Request → { id, kind, prompt, url?, data?, expiresAt? }// Answer → { id, ok, data?, reason? }ok: false declines; the optional reason ("declined" | "cancelled" | "expired") is
advisory — the loop always branches on ok. kind is just data: "question", "input",
"authorization". There is no auth subsystem — authorization is the same primitive with a url.
Scenario 1 — Approval gate (answer now)
Section titled “Scenario 1 — Approval gate (answer now)”An agent is about to do something irreversible — issue a refund, delete a resource, send money.
You want a human to approve first, and they’re on the other end of a CLI or a chat you can await.
Give the client a waitFor: the loop pauses, asks, and resumes to done in one call.
import { createClient, type Answer, type Request } from "toolnexus"import { createInterface } from "node:readline/promises"
const rl = createInterface({ input: process.stdin, output: process.stdout })
const client = createClient({ baseUrl, style: "openai", model, // Called whenever a tool suspends. Read the operator's decision straight off the terminal. waitFor: async (req: Request): Promise<Answer> => { const line = await rl.question(`${req.prompt} [y/N] `) return { id: req.id, ok: line.trim().toLowerCase() === "y" } // ok:false → loop tells the model it was declined },})
const result = await client.run("Refund order #4021 if policy allows.", { toolkit })// result.status === "done" — the refund tool ran only after you typed yfrom toolnexus import create_client, Answer
async def wait_for(req): line = input(f"{req.prompt} [y/N] ") # read the operator's decision off the terminal return Answer(id=req.id, ok=line.strip().lower() == "y")
client = create_client(base_url=base, style="openai", model=model, wait_for=wait_for)result = await client.run("Refund order #4021 if policy allows.", toolkit)# result.status == "done" — the refund tool ran only after you typed yreader := bufio.NewReader(os.Stdin)c := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: baseURL, Style: toolnexus.StyleOpenAI, Model: model, WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { fmt.Printf("%s [y/N] ", req.Prompt) // read the operator's decision off the terminal line, _ := reader.ReadString('\n') return toolnexus.Answer{ID: req.ID, Ok: strings.TrimSpace(line) == "y"}, nil },})res, _ := c.Run(ctx, "Refund order #4021 if policy allows.", tk)// res.Status == "done" — the refund tool ran only after you typed yScanner in = new Scanner(System.in);LlmClient client = LlmClient.create(new LlmClient.Options() .baseUrl(base).style("openai").model(model) .waitFor(req -> { System.out.print(req.prompt() + " [y/N] "); // read the operator's decision off the terminal return new Answer(req.id(), in.nextLine().trim().equalsIgnoreCase("y")); }));
LlmClient.RunResult result = client.run("Refund order #4021 if policy allows.", tk);// result.status.equals("done") — the refund tool ran only after you typed yvar client = LlmClient.Create(new LlmClient.Options{ BaseUrl = baseUrl, Style = "openai", Model = model, WaitFor = req => { Console.Write($"{req.Prompt} [y/N] "); // read the operator's decision off the terminal var line = Console.ReadLine(); return Task.FromResult(new Answer { Id = req.Id, Ok = line?.Trim().ToLower() == "y" }); },});var result = await client.RunAsync("Refund order #4021 if policy allows.", tk);// result.Status == "done" — the refund tool ran only after you typed yalias Toolnexus.{Answer, Client}
wait_for = fn req -> line = IO.gets("#{req.prompt} [y/N] ") # read the operator's decision off the terminal %Answer{id: req.id, ok: String.trim(line || "") |> String.downcase() == "y"}end
client = Client.create(base_url: base, style: "openai", model: model, wait_for: wait_for)result = Client.run(client, "Refund order #4021 if policy allows.", tk)# result.status == "done" — the refund tool ran only after you typed y(require '[clojure.string :as str] '[toolnexus.client :as client])
(def llm (client/create-client {:base-url base-url :style "openai" :model model ;; Called whenever a tool suspends. `wait-for` is a plain blocking ;; (fn [request] answer) — read the operator's decision off the terminal. :wait-for (fn [request] (println (:prompt request) "[y/N] ") ;; ok false → the loop tells the model it was declined (client/make-answer (:id request) (= "y" (str/lower-case (str/trim (str (read-line)))))))}))
(def result (client/run llm "Refund order #4021 if policy allows." {:toolkit tk}));; (:status result) => "done" — the refund tool ran only after you typed yThe tool that gated the action simply returned pending({ kind: "question", prompt: "Approve refund?" }).
The loop did the rest.
Scenario 2 — Login / OAuth wait (authorization)
Section titled “Scenario 2 — Login / OAuth wait (authorization)”A tool needs the user to authenticate — connect a bank, grant an OAuth scope, click a consent
link. There’s no auth subsystem: the tool returns a suspension whose kind is "authorization"
and whose url is where the human goes. Your waitFor delivers that link (open a browser, text it
to a channel) and returns once the world has changed — the session is now valid, so the retried
tool just succeeds.
import { defineTool, authRequired } from "toolnexus"
let authed = falseconst getBalance = defineTool({ name: "get_balance", description: "Return the account balance. Requires login first.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, run: (_args, ctx) => authed ? `balance: ₹67,417` : authRequired("https://bank.example/login?t=abc", "Log in to view your balance"),})
const client = createClient({ baseUrl, style: "openai", model, waitFor: async (req) => { await deliverLink(req.url!) // open a browser, OR text the link to #ops, OR forward over A2A authed = true // the world changed out-of-band; the retry now succeeds return { id: req.id, ok: true } // kind:"authorization" ignores answer.data — the session is enough },})Scenario 3 — Answer later, in chat (the durable halt)
Section titled “Scenario 3 — Answer later, in chat (the durable halt)”This is the one most frameworks can’t do. The agent asks a question your user won’t answer for
hours — it posts to a Slack thread, or an email, and the web request that started the run has to
return now. There’s no thread to block. So you give the client no waitFor: run() halts and
hands you the Request. You persist it, post it, and let the HTTP request finish.
Getting back in is where you have to choose a posture, and the two are not equally portable:
| posture | how the answer gets back in | ports |
|---|---|---|
A — deferred waitFor |
the run stays alive; its waitFor awaits a promise/future/channel that your reply handler resolves |
all seven |
| B — durable halt + answer-carrying resume | the process may exit; you come back through runWithAnswer / askWithAnswer carrying the Answer |
golang only today — a preview, outside the conformance contract |
Phase 1 — halt and hand back the request:
// No waitFor set → the run halts durably instead of blocking.// ask(…, { id }) records the transcript under that id so you can resume it later.const result = await client.ask(prompt, { toolkit, id: conversationId })if (result.status === "pending") { const req = result.pending! // { id, kind, prompt, url?, data? } await db.save(conversationId, req) // persist — plain serializable data await slack.post(channel, req.prompt) // ask the human, out of band return // the HTTP request returns; nothing is blocked}result = await client.ask(prompt, toolkit, id=conversation_id) # no wait_for; ask records the idif result.status == "pending": req = result.pending # Request(id, kind, prompt, url, data) await db.save(conversation_id, req) # persist — plain serializable data await slack.post(channel, req.prompt) # ask the human, out of band return # request returns; nothing is blockedres, _ := c.Ask(ctx, prompt, tk, conversationID) // no WaitFor; Ask records the idif res.Status == "pending" { req := res.Pending // *toolnexus.Request{ ID, Kind, Prompt, URL, Data } db.Save(conversationID, req) // persist — plain serializable data slack.Post(channel, req.Prompt) // ask the human, out of band return // request returns; nothing is blocked}LlmClient.RunResult res = client.ask(prompt, tk, conversationId); // no waitForif ("pending".equals(res.status)) { Request req = res.pending; // Request{ id(), kind(), prompt(), url(), data() } db.save(conversationId, req); // persist — plain serializable data slack.post(channel, req.prompt()); // ask the human, out of band return; // request returns; nothing is blocked}var res = await client.AskAsync(prompt, tk, conversationId); // no WaitForif (res.Status == "pending"){ var req = res.Pending!; // Request{ Id, Kind, Prompt, Url, Data } await db.SaveAsync(conversationId, req); // persist — plain serializable data await slack.PostAsync(channel, req.Prompt); // ask the human, out of band return; // request returns; nothing is blocked}res = Toolnexus.Client.ask(client, prompt, tk, conversation_id) # no wait_for; ask records the id
if res.status == "pending" do req = res.pending # %Toolnexus.Request{id, kind, prompt, url, data} DB.save(conversation_id, req) # persist — plain serializable data Slack.post(channel, req.prompt) # ask the human, out of bandend # the caller returns; nothing is blocked;; No :wait-for on the client → the run halts durably instead of blocking.;; :conversation-id records the transcript under that id so you can resume it later.(let [res (client/run llm prompt {:toolkit toolkit :conversation-id conversation-id})] (when (= "pending" (:status res)) (let [req (:pending res)] ; {:id :kind :prompt :url :data} (db-save! conversation-id req) ; persist — plain serializable data (slack-post! channel (:prompt req))))) ; ask the human, out of band;; the handler returns; nothing is blockedPhase 2, posture A — a waitFor that waits (works in all seven ports):
The run never ended. Its waitFor parks on a promise / future / channel / process mailbox, and your
chat webhook resolves it whenever the reply lands. Because this is the inline path, the whole
Answer reaches the tool as ctx.answer — no key is reserved, so answers, value or text
all work, and the suspended tool re-runs exactly once with the answer in hand.
const parked = new Map<string, (a: Answer) => void>()
const client = createClient({ baseUrl, style: "openai", model, waitFor: (req: Request) => new Promise<Answer>((resolve) => { slack.post(channel, req.prompt) // ask, out of band parked.set(req.id, resolve) // …and park the run on the reply }),})
// your Slack webhook, minutes or hours later — a different handler, same process:export function onSlackReply(requestId: string, reply: string) { parked.get(requestId)?.({ id: requestId, ok: true, data: { answers: [reply] } }) parked.delete(requestId)}
const done = await client.run(prompt, { toolkit }) // settles when the human answers// done.status === "done" — the tool re-ran with ctx.answer.data = { answers: [reply] }parked: dict[str, asyncio.Future] = {}
async def wait_for(req): fut = asyncio.get_running_loop().create_future() parked[req.id] = fut await slack.post(channel, req.prompt) # ask, out of band return await fut # park the run on the reply
def on_slack_reply(request_id: str, reply: str): # your webhook handler parked.pop(request_id).set_result( Answer(id=request_id, ok=True, data={"answers": [reply]}))
client = create_client(base_url=base, style="openai", model=model, wait_for=wait_for)done = await client.run(prompt, toolkit)# done.status == "done" — the tool re-ran with ctx.answer.data == {"answers": [reply]}var parked sync.Map // request id -> chan toolnexus.Answer
c := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: baseURL, Style: toolnexus.StyleOpenAI, Model: model, WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { ch := make(chan toolnexus.Answer, 1) parked.Store(req.ID, ch) slack.Post(channel, req.Prompt) // ask, out of band return <-ch, nil // park the run on the reply },})
// your Slack webhook, minutes or hours later:func onSlackReply(requestID, reply string) { if ch, ok := parked.LoadAndDelete(requestID); ok { ch.(chan toolnexus.Answer) <- toolnexus.Answer{ID: requestID, Ok: true, Data: map[string]any{"answers": []string{reply}}} }}
done, _ := c.Run(ctx, prompt, tk)// done.Status == "done" — the tool re-ran with ctx.Answer.DataMap<String, CompletableFuture<Answer>> parked = new ConcurrentHashMap<>();
LlmClient client = LlmClient.create(new LlmClient.Options() .baseUrl(base).style("openai").model(model) .waitFor(req -> { CompletableFuture<Answer> f = new CompletableFuture<>(); parked.put(req.id(), f); slack.post(channel, req.prompt()); // ask, out of band return f.join(); // park the run on the reply }));
// your Slack webhook, minutes or hours later:void onSlackReply(String requestId, String reply) { parked.remove(requestId) .complete(new Answer(requestId, true, Map.of("answers", List.of(reply))));}
LlmClient.RunResult done = client.run(prompt, tk);// done.status.equals("done") — the tool re-ran with ctx.answer().data()var parked = new ConcurrentDictionary<string, TaskCompletionSource<Answer>>();
var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = baseUrl, Style = "openai", Model = model, WaitFor = async req => { var tcs = new TaskCompletionSource<Answer>(); parked[req.Id] = tcs; await slack.PostAsync(channel, req.Prompt); // ask, out of band return await tcs.Task; // park the run on the reply },});
// your Slack webhook, minutes or hours later:void OnSlackReply(string requestId, string reply) => parked[requestId].SetResult(new Answer { Id = requestId, Ok = true, Data = new Dictionary<string, object?> { ["answers"] = new[] { reply } }, });
var done = await client.RunAsync(prompt, tk);// done.Status == "done" — the tool re-ran with ctx.Answer.Data# The run is a process; park it on its own mailbox and let the webhook send to it.wait_for = fn req -> Registry.register(MyApp.Parked, req.id, nil) Slack.post(channel, req.prompt) # ask, out of band receive do: ({:reply, reply} -> %Toolnexus.Answer{ id: req.id, ok: true, data: %{"answers" => [reply]}})end
# your Slack webhook, minutes or hours later:def on_slack_reply(request_id, reply) do Registry.dispatch(MyApp.Parked, request_id, fn entries -> for {pid, _} <- entries, do: send(pid, {:reply, reply}) end)end
client = Toolnexus.Client.create(base_url: base, style: "openai", model: model, wait_for: wait_for)done = Toolnexus.Client.run(client, prompt, tk)# done.status == "done" — the tool re-ran with ctx.answer.data(def parked (atom {})) ; request id -> promise
(def llm (client/create-client {:base-url base-url :style "openai" :model model :wait-for (fn [req] (let [p (promise)] (swap! parked assoc (:id req) p) (slack-post! channel (:prompt req)) ; ask, out of band @p))})) ; park the run on the reply
;; your Slack webhook, minutes or hours later:(defn on-slack-reply [request-id reply] (when-let [p (get @parked request-id)] (swap! parked dissoc request-id) (deliver p (client/make-answer request-id true {:answers [reply]}))))
(def done (client/run llm prompt {:toolkit toolkit}));; (:status done) => "done" — the tool re-ran with ctx :answerPhase 2, posture B — the answer-carrying entry point (golang only, a preview):
Survives a restart: phase 1 can run on one machine and phase 2 on another, a day later, because
Request and Answer are plain serializable data. This entry point exists only in golang
today and is explicitly outside the §0 conformance contract — js, python, java, csharp,
elixir and clojure can raise the durable halt but have no way back in yet, which is why posture
A is the portable answer.
// A different process, the next day. You hold the persisted Request and the human's reply.req := db.LoadRequest(conversationID)
done, err := c.AskWithAnswer(ctx, tk, conversationID, req, toolnexus.AnswerOutput(req.ID, reply)) // the typed constructor — do not hand-build the mapif err != nil { // e.g. the answer echoed the wrong id, or carried no determinable result: // an ERROR TO YOU, never a fabricated tool result handed to the model.}// done.Status == "done"The question tool
Section titled “The question tool”You don’t have to write a suspending tool to use any of this. The built-in question tool
suspends to ask the user structured questions and resumes with their answers — the same
sync-or-durable fork applies. It’s how the agent asks you something mid-run without any custom code.
MCP elicitation bridge
Section titled “MCP elicitation bridge”MCP servers can send a reverse request — elicitation/create — mid tool-call, asking the client
to collect input. toolnexus bridges that onto the same primitive: a form request maps to
kind: "input" (with the server’s schema in data.schema), a URL request maps to
kind: "authorization". Your single waitFor answers both your own agent’s questions and any MCP
server’s elicitations — one human-in-the-loop surface for everything. The capability is advertised
to servers only when a waitFor is present.
Guarantees
Section titled “Guarantees”- A suspension is never counted as a tool error — the loop emits
pending, notisError, so error-rate metrics and circuit-breakers don’t trip on a human wait. - If several tools suspend at once with no
waitFor, the first in tool-call order is surfaced — deterministically, with no transcript leak — in both the non-streaming and streaming loops. - Streaming emits a
{ type: "pending", request }event beforewaitForruns, so a channel handler can push the link the instant the agent asks.