pendingOf
JavaScript · package toolnexus · SPEC §10 · js/src/types.ts
function pendingOf(result: ToolResult): Request | undefinedThe read side of pending: given any ToolResult, return
the Request riding on metadata.pending if there is one, undefined otherwise. It is a plain
type guard — (result.metadata as { pending?: Request } | undefined)?.pending — with no I/O and
no dependency on the client loop. This page goes beyond the basic reflection example already on
ToolResult to show where pendingOf is genuinely useful,
and one place it is not: a durable halt’s RunResult.toolCalls[] entries.
When to use it
Section titled “When to use it”Reach for pendingOf whenever you hold a ToolResult from outside the built-in client loop and
need to branch on “did this suspend, or did it actually answer” — your own orchestration calling
toolkit.execute(...) directly, a beforeTool/afterTool hook inspecting a result, a test
asserting a tool’s suspending branch fired. isError: true alone cannot tell you this (§10): a
suspension and an ordinary failure both set it.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The basic check — suspension vs. an ordinary result
Section titled “1. The basic check — suspension vs. an ordinary result”import assert from "node:assert"import { pending, pendingOf } from "toolnexus"
const suspended = pending({ kind: "input", prompt: "Which environment?" })const ordinary = { output: "done", isError: false }
assert.ok(pendingOf(suspended))assert.equal(pendingOf(suspended)?.kind, "input")assert.equal(pendingOf(ordinary), undefined)
console.log("ok:", pendingOf(suspended)?.kind, "|", pendingOf(ordinary))2. Driving your own orchestration — call toolkit.execute directly, branch with pendingOf
Section titled “2. Driving your own orchestration — call toolkit.execute directly, branch with pendingOf”This bypasses createClient entirely — useful when you’re building a custom loop (or, as here,
just testing a tool’s suspending branch) and need the same suspend/resolve pattern the built-in
loop uses internally.
import assert from "node:assert"import { createToolkit, defineTool, pending, pendingOf } from "toolnexus"
const approve = defineTool({ name: "approve", description: "Requires a human's yes before proceeding", inputSchema: { type: "object", properties: {} }, run: (_args, ctx) => (ctx?.answer ? "approved" : pending({ kind: "approval", prompt: "Approve?" })),})const tk = await createToolkit({ builtins: false, extraTools: [approve] })
const first = await tk.execute("approve", {})const req = pendingOf(first)assert.ok(req, "the first call suspends")
// Resolve it yourself and retry with Context.answer — exactly what the client loop does for you.const second = await tk.execute("approve", {}, { answer: { id: req!.id, ok: true } })assert.equal(pendingOf(second), undefined, "the retry is an ordinary result")assert.equal(second.output, "approved")
await tk.close()console.log("ok:", req?.kind, "->", second.output)3. The surprise: a durable halt’s toolCalls[] entry does NOT carry metadata.pending
Section titled “3. The surprise: a durable halt’s toolCalls[] entry does NOT carry metadata.pending”RunResult.pending is the durable Request — read it there. The matching toolCalls[] record’s
metadata is not a copy of it: on the no-waitFor halt path the loop replaces the tool’s
result with a fresh { output, isError } before recording it, precisely so a caller does not
reconstruct request state from two different places. pendingOf on that record correctly returns
undefined — it isn’t a bug in pendingOf, it’s a reason to always prefer RunResult.pending.
import assert from "node:assert"import { createClient, createToolkit, defineTool, pending, pendingOf } from "toolnexus"
const deleteDb = defineTool({ name: "delete_database", description: "Destructive — needs approval", inputSchema: { type: "object", properties: {} }, run: () => "deleted", // never reached in this example; the hook guards it first})
const canned: typeof fetch = async () => new Response(JSON.stringify({ choices: [{ message: { content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "delete_database", arguments: "{}" } }] } }], 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, hooks: { // A guard-raised suspension (§10): short-circuit with a Pending instead of running the tool. beforeTool: async ({ name }) => name === "delete_database" ? { result: pending({ kind: "approval", prompt: `Approve destructive call: ${name}?` }) } : undefined, },})const tk = await createToolkit({ builtins: false, extraTools: [deleteDb] })
const res = await client.run("delete it", { toolkit: tk })
assert.equal(res.status, "pending")assert.equal(pendingOf(res.toolCalls[0]), undefined) // NOT here — metadata was replaced on haltassert.ok(res.pending) // the real Request is HEREassert.equal(res.pending?.prompt, "Approve destructive call: delete_database?")
await tk.close()console.log("ok: toolCalls[0] carries no pending; RunResult.pending does:", res.pending?.prompt)Signature
Section titled “Signature”| Parameter | Type | What it is |
|---|---|---|
result |
ToolResult |
Any tool result — from toolkit.execute, a hook, or a fixture. |
| returns | Request | undefined |
The suspension, if result.metadata.pending is set. |
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.waitFor— The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.ToolResult— The basicmetadata.pendingreflection example lives here.