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

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.

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
}

Phase 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] }

Phase 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 map
if 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"

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.
Start with the quickstart