Skip to content

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.

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.

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 y

The 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 = false
const 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
}

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"

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 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.

  • A suspension is never counted as a tool error — the loop emits pending, not isError, 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 before waitFor runs, so a channel handler can push the link the instant the agent asks.
See it in the five-source demo