waitFor
JavaScript · package toolnexus · SPEC §10 · js/src/client.ts
interface ClientOptions { // ... waitFor?: (request: Request) => Promise<Answer>}waitFor is a ClientOptions field, not a standalone function — the one host slot §10 exists
around. Its signature is data in, data out: it receives the Request
a tool suspended with and must resolve with an Answer. Its
interior is entirely unconstrained — open a browser and poll, message a Slack channel and wait for
a reply, forward the request over A2A to another agent. toolnexus does not care how you decide;
it only reads answer.ok (and, when ok is true, retries the tool once with
ctx.answer = answer).
When to use it
Section titled “When to use it”Set waitFor when you want suspension resolved in-process — the run blocks, your function
resolves the request however it likes, and the loop transparently retries the tool and continues.
This is the simplest posture: one call to createClient, one function, done.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. In-process resolution — the loop resolves and retries transparently
Section titled “1. In-process resolution — the loop resolves and retries transparently”import assert from "node:assert"import { createClient, createToolkit, defineTool, pending } from "toolnexus"
let authed = falseconst getBalance = defineTool({ name: "get_balance", description: "Return the account balance. Requires login first.", inputSchema: { type: "object", properties: {} }, run: () => (authed ? "balance: $500" : pending({ kind: "authorization", prompt: "Log in", url: "https://x.com/login" })),})
const canned: typeof fetch = async (_url, init) => { const body = JSON.parse(String((init as any).body)) const sawToolResult = body.messages.some((m: any) => m.role === "tool") const message = sawToolResult ? { role: "assistant", content: "Your balance is $500." } : { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_balance", arguments: "{}" } }] } return new Response(JSON.stringify({ choices: [{ message }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { status: 200, headers: { "content-type": "application/json" } })}
const client = createClient({ apiKey: "test-key", baseUrl: "http://stub", style: "openai", model: "m", fetch: canned, waitFor: async (request) => { authed = true; return { id: request.id, ok: true } },})
const tk = await createToolkit({ builtins: false, extraTools: [getBalance] })const res = await client.run("what is my balance?", { toolkit: tk })
assert.equal(res.status, "done") // waitFor absorbed the suspension entirely — the caller never saw "pending"assert.match(res.text, /\$500/)
await tk.close()console.log("ok:", res.text)2. kind: "input" — the resolution IS the answer, delivered via ctx.answer.data
Section titled “2. kind: "input" — the resolution IS the answer, delivered via ctx.answer.data”import assert from "node:assert"import { createClient, createToolkit, defineTool, pending } from "toolnexus"
const pickEnv = defineTool({ name: "pick_env", description: "Deploy to an environment the user picks", inputSchema: { type: "object", properties: {} }, run: (_args, ctx) => (ctx?.answer ? `deploying to ${ctx.answer.data?.env}` : pending({ kind: "input", prompt: "Which environment?" })),})
const canned: typeof fetch = async (_url, init) => { const body = JSON.parse(String((init as any).body)) const sawToolResult = body.messages.some((m: any) => m.role === "tool") const message = sawToolResult ? { role: "assistant", content: "Deployment started." } : { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "pick_env", arguments: "{}" } }] } return new Response(JSON.stringify({ choices: [{ message }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { status: 200, headers: { "content-type": "application/json" } })}
const client = createClient({ apiKey: "test-key", baseUrl: "http://stub", style: "openai", model: "m", fetch: canned, waitFor: async (request) => ({ id: request.id, ok: true, data: { env: "staging" } }), // e.g. read from a Slack reply})
const tk = await createToolkit({ builtins: false, extraTools: [pickEnv] })const res = await client.run("deploy it", { toolkit: tk })
assert.match(res.toolCalls[0].output, /deploying to staging/)
await tk.close()console.log("ok:", res.toolCalls[0].output)3. ok: false declines the call; a tool that re-suspends anyway is stopped from looping forever
Section titled “3. ok: false declines the call; a tool that re-suspends anyway is stopped from looping forever”The loop rule (§10) branches only on answer.ok. A decline feeds back a fixed error result and
the model decides what to do next — the run does not abort. Separately, if a retried tool
suspends again with the same or a fresh request, the loop refuses to ask twice: it feeds back
"unresolved: <prompt>" instead of calling waitFor a second time for that call.
import assert from "node:assert"import { createClient, createToolkit, defineTool, pending } from "toolnexus"
// Always re-suspends, even after an answer — proves the "never loop forever" guard.const stubborn = defineTool({ name: "stubborn", description: "Always needs approval", inputSchema: { type: "object", properties: {} }, run: () => pending({ kind: "approval", prompt: "still need approval" }),})
let turn = 0const canned: typeof fetch = async () => { turn++ const body = turn === 1 ? { choices: [{ message: { content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "stubborn", arguments: "{}" } }] } }] } : { choices: [{ message: { content: "Gave up asking." } }] } return new Response(JSON.stringify({ ...body, usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { status: 200, headers: { "content-type": "application/json" } })}
const client = createClient({ apiKey: "test-key", baseUrl: "http://stub", style: "openai", model: "m", fetch: canned, waitFor: async (request) => ({ id: request.id, ok: true }), // always approves — the tool re-suspends anyway})
const tk = await createToolkit({ builtins: false, extraTools: [stubborn] })const res = await client.run("try", { toolkit: tk })
assert.equal(res.status, "done") // the RUN finishes — the loop never hangs waiting for a resolved request to stickassert.equal(res.toolCalls[0].output, "unresolved: still need approval")
await tk.close()console.log("ok:", res.toolCalls[0].output)Options
Section titled “Options”| Field | Type | What it does |
|---|---|---|
request (in) |
Request |
What a suspended tool call is waiting on. |
| return (out) | Promise<Answer> |
{ id, ok, data?, reason? } — id must echo request.id; ok is the only field the loop rule branches on. |
See also
Section titled “See also”pending— Return a Pending from a tool to park the run until someone answers.authRequired— The auth-shaped suspension: hand back a URL, resume once the user has granted access.pendingOf— Detect that a RunResult is parked rather than finished, and get the Request that parked it.agents.AgentRuntime.resume— The shipped way back in when you omitwaitForon anagents.Agentrun.