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 five 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, resume when the world changes. A durable halt. |
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 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. When the human
replies — maybe in a different process, the next day — you resume by running the same conversation
again, this time with a waitFor that returns the answer you now hold.
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}Phase 2 — hours later, the reply arrives; resume the same conversation:
// A different request handler, maybe a different process. You now hold the human's answer.const resumeClient = createClient({ baseUrl, style: "openai", model, waitFor: async (req) => ({ id: req.id, ok: true, data: { answers: [reply] } }),})// Same conversation id → memory replays the thread; the suspended tool re-runs and resolves.const done = await resumeClient.ask(prompt, { toolkit, id: conversationId })// done.status === "done"resume_client = create_client( base_url=base, style="openai", model=model, wait_for=lambda req: Answer(id=req.id, ok=True, data={"answers": [reply]}),)done = await resume_client.ask(prompt, toolkit, id=conversation_id)# done.status == "done"resume := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: baseURL, Style: toolnexus.StyleOpenAI, Model: model, WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { return toolnexus.Answer{ID: req.ID, Ok: true, Data: map[string]any{"answers": []string{reply}}}, nil },})done, _ := resume.Ask(ctx, prompt, tk, conversationID)// done.Status == "done"LlmClient resume = LlmClient.create(new LlmClient.Options() .baseUrl(base).style("openai").model(model) .waitFor(req -> new Answer(req.id(), true, Map.of("answers", List.of(reply)))));LlmClient.RunResult done = resume.ask(prompt, tk, conversationId);// done.status.equals("done")var resume = LlmClient.Create(new LlmClient.Options{ BaseUrl = baseUrl, Style = "openai", Model = model, WaitFor = req => Task.FromResult(new Answer { Id = req.Id, Ok = true, Data = new Dictionary<string, object?> { ["answers"] = new[] { reply } }, }),});var done = await resume.AskAsync(prompt, tk, conversationId);// 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.