Confirmation modes
Consequential and destructive tools need the user's approval before they run, unless the caller is human. Toolmark ships no confirmation UI (D13): it hands the app a request and waits for the app's answer, in one of two modes (spec §7, D16).
Deferred (default for inapp)
The call returns immediately with needs_confirmation:
{
"status": "needs_confirmation",
"confirmId": "f3…",
"summary": "Create challenge \"Robotics\"",
"changes": []
}The agent tells the user a confirmation is pending; the app renders it (for example as a card in the chat). On approval the app calls tm.confirmPending(confirmId, { approved: true }) (optionally with edited input, which is re-validated). The tool then runs as caller human, and the bridge delivers the outcome to the agent as a confirmed message. In React, usePendingConfirmations() lists and answers them.
Inline (always for webmcp, mcp, tour)
Those callers wait on their own, so the call stays open while the app's confirm handler asks the user:
import { createConfirmQueue, createToolmark } from '@toolmark/core'
const queue = createConfirmQueue()
const tm = createToolmark({ confirm: queue.handler })
// Render queue.getPending() (or useConfirmQueue(queue) in React) and call approve()/reject().Rules
- Only
inappandtestmodes are configurable (confirmMode: { inapp: 'inline' });webmcp,mcpandtourare always inline, so they never seeneeds_confirmation. Anything else isinvalid_confirm_mode. - A pending confirmation expires (default 10 minutes,
confirmExpiryMs) and is dropped when its scope is disposed. AconfirmIdis single use: the firstconfirmPendingconsumes it, any later one getsrefusedconfirmation_expired. At most 100 are pending; the oldest expire first. Expiry is checked against the clock at approval time, not only by a timer: browsers delay timers in background tabs and across device sleep, so an approval that arrives afterexpiresAt(inline or deferred) is expired and the tool does not run. - Sensitive values stay out of confirmation payloads.
ConfirmRequest.input,PendingConfirmation.input(tm.pendingConfirmations(),usePendingConfirmations) andctx.confirmchanges carry'[redacted]'at the tool's sensitive paths (sensitivePaths(), mapped onto the input shape for form and wizard fills; the whole input when that list cannot be read). The approved run still gets the real input. An approval that editsinput(inline, deferred or throughctx.confirm) may send the public input back with its changes: every sensitive path that still holds the literal'[redacted]'gets its real value back before the edit is validated, so a secret is never replaced by the placeholder; a sensitive path the approver changed keeps the new value.[]in a sensitive path matches any array index, also inctx.confirmchanges: a change atcards.0.cvcis redacted, and a change ofcardsorcards.0keeps its value with eachcvcinside it redacted. A secret is only restored onto the same array row: when the edited array has a different length, or the row's non-sensitive values changed (a row deleted, inserted, reordered or edited), a'[redacted]'left in that row has no real value to take. Such a placeholder, one under a key the approver restructured, or any other'[redacted]'the tool was not sent at that position (outside the sensitive paths, or anywhere when the sensitive paths cannot be read at approval), is refused asinvalidwith the issue "Re-enter sensitive field" at its path (insidectx.confirm, the outcome is{ approved: false, reason: 'invalid' }); the approver re-enters the value and approves again. - A deferred form or wizard submit approved after the form's values changed is refused
stale("Form changed since confirmation was requested"). - Registration check. Registering a consequential or destructive tool fails (
missing_confirm_handler) only when no allowed non-human caller has a confirmation path. An inline-mode caller without aconfirmhandler simply does not see such tools (and a call from it isrefusednot_allowed); a development-onlymissing_confirm_handlerwarning event says so. ctx.confirm({ summary, changes? })insiderunasks mid-run:human→ approved; an inline caller → awaits the handler (bounded by the call signal and the expiry, and not bycallTimeoutMs: the call deadline is paused while the confirmation is open and resumes with the time that was left); a deferred caller or no handler →{ approved: false, reason: 'confirmation_unavailable' }plus the development eventctx_confirm_unavailable. It never throws. Tools that always need confirmation should declareconsequentialinstead.createTestToolmark()keeps these production modes and installs a default-approve inline handler.