Tool Middleware
toolMiddleware(), approvalMiddleware(), resumable tool approvals, and the per-call ToolLifecycle session.
import {
// Authoring
approvalMiddleware,
toolMiddleware,
// App-facing approval helpers
appendToolApprovalResponse,
findToolApprovalRequests,
toolApprovalResponse,
toolApprovalResponseMessage,
// Consumption (adapter authors only)
createToolLifecycle,
} from '@crux/core/adapter/tool'The tool lifecycle is one deep module. Middleware and approvals are authored as frozen objects; everything between "the model emitted tool calls" and "tool results are ready" is executed through a single per-call ToolLifecycle session that every adapter constructs internally — merge precedence, middleware chaining, the approval suspend/resume protocol, instrumentation, skill loads, and memory capture live in the session, never in adapter code. See the ToolLifecycle session below.
Tool middleware wraps tool definitions before an adapter executes them. Use it for policies that should apply across many tools: audit logging, timing, validation, approvals, and consistent error handling.
toolMiddleware() can observe or intercept execution. Use beforeExecute, afterExecute, and onError for simple hooks. Use aroundExecute(call, next) when middleware needs to modify input, call the wrapped tool conditionally, or return early.
Middleware can be attached to a prompt:
const assistant = prompt({
id: 'assistant',
tools: { sendEmail, createRefund },
toolMiddleware: [auditTools, approvals],
})or supplied per call:
await generate(assistant, {
model,
input,
toolMiddleware: [approvalForThisRoute],
})toolMiddleware(config)
const auditTools = toolMiddleware({
id: 'audit-tools',
match: ['sendEmail', /^admin/],
beforeExecute: ({ toolName, toolCallId, input }) => {
audit.write({ phase: 'start', toolName, toolCallId, input })
},
afterExecute: ({ toolName, toolCallId, output, durationMs }) => {
audit.write({ phase: 'end', toolName, toolCallId, output, durationMs })
},
onError: ({ toolName, toolCallId, error }) => {
audit.write({ phase: 'error', toolName, toolCallId, error: String(error) })
},
})Config
| Field | Type | Description |
|---|---|---|
id | string | Stable middleware identifier for debugging. |
match | readonly ToolMatcher[]? | Optional tool matcher list. Omit to wrap every tool. |
beforeExecute | (call) => void | PromiseLike<void> | Runs before matched tool execution. |
aroundExecute | (call, next) => unknown | PromiseLike<unknown> | Intercepts matched execution. Call next(input, options) to continue, or return without calling next() to short-circuit. |
afterExecute | (result) => void | PromiseLike<void> | Runs after matched tool execution succeeds. |
onError | (error) => void | PromiseLike<void> | Runs when matched tool execution throws, then rethrows. |
ToolMatcher can be a tool name, a regular expression, or a predicate:
const requiresAudit = toolMiddleware({
id: 'customer-data-audit',
match: [
'exportCustomers',
/^admin/,
({ toolName, input }) =>
toolName === 'searchCustomers' && typeof input === 'object' && input !== null && 'email' in input,
],
})Intercept execution
const cacheLookup = toolMiddleware({
id: 'cache-lookup',
match: ['lookupCustomer'],
aroundExecute: async ({ input, options }, next) => {
const cached = await cache.get(input)
if (cached) return cached
const output = await next(input, options)
await cache.set(input, output)
return output
},
})Pass a different input to next() to modify arguments:
const trimSubjects = toolMiddleware({
id: 'trim-subjects',
match: ['sendEmail'],
aroundExecute: ({ input, options }, next) =>
typeof input === 'object' && input !== null && 'subject' in input
? next({ ...input, subject: String(input.subject).trim() }, options)
: next(input, options),
})approvalMiddleware(config)
approvalMiddleware() is a convenience wrapper over toolMiddleware() for human-in-the-loop approval. It marks matched tool calls as approval-gated and wires approval responses back to callbacks on the next request.
const approvals = approvalMiddleware({
id: 'dangerous-tools',
match: ['sendEmail', 'createRefund'],
onRequest: ({ toolName, input }) => {
audit.write({ phase: 'approval-requested', toolName, input })
},
onApproved: ({ approvalId, toolName }) => {
audit.write({ phase: 'approved', approvalId, toolName })
},
onDenied: ({ approvalId, toolName, reason }) => {
audit.write({ phase: 'denied', approvalId, toolName, reason })
},
})Config
| Field | Type | Description |
|---|---|---|
id | string | Stable middleware identifier. |
match | readonly ToolMatcher[] | Required matcher list. At least one matcher is required. |
onRequest | (call) => void | PromiseLike<void> | Called when a matched tool call asks for approval. |
onApproved | (event) => void | PromiseLike<void> | Called once when a matching approval response approves the tool. |
onDenied | (event) => void | PromiseLike<void> | Called once when a matching approval response denies the tool. |
Resume Shape
Approvals are designed for serverless and Convex-style runtimes. Crux does not hold a request open while the user decides. The runtime shape is:
- The model asks to call a gated tool.
- The adapter returns a
tool-approval-requestmessage part. - Your UI renders the approval UX.
- The next request appends a
tool-approval-responsemessage part. - Crux sees the response, calls
onApprovedoronDenied, and lets the adapter execute or deny the tool.
const first = await generate(assistant, { model, input })
const approvalId = findApprovalId(first.response.messages)
const response = toolApprovalResponse({
approvalId,
approved: true,
})
await generate(assistant, {
model,
input,
messages: [
...first.response.messages,
{
role: 'tool',
content: [response],
},
],
})@crux/ai maps this to the AI SDK approval protocol. The shared native adapter layer for @crux/openai, @crux/google, and @crux/anthropic uses the same Crux approval protocol through result.messages, findToolApprovalRequests(), and appendToolApprovalResponse().
toolApprovalResponse(options)
const response = toolApprovalResponse({
approvalId: 'approval_123',
approved: false,
reason: 'Refund exceeds the approval limit.',
})Returns a portable tool-approval-response message part:
type ToolApprovalResponsePart = {
type: 'tool-approval-response'
approvalId: string
approved: boolean
reason?: string
approvalToken?: string
}Use approvalToken when your app needs an extra signed or one-time token in addition to the provider-generated approval id.
Native adapter helpers
const first = await openai.generate(assistant, { model, input })
const [request] = findToolApprovalRequests(first.messages)
const final = await openai.generate(assistant, {
model,
messages: appendToolApprovalResponse(first.messages, {
approvalId: request.approvalId,
approved: true,
}),
})Use toolApprovalResponseMessage() when you want to construct the response message yourself instead of appending it.
The ToolLifecycle session (adapter authors)
Apps never call this — both built-in dialect factories (adapter() for core-driven loops, executorAdapter() for SDK-driven loops) construct one session per generate()/stream() call. You only need it when building a custom adapter outside the factories.
import { createToolLifecycle } from '@crux/core/adapter/tool'
const lifecycle = createToolLifecycle({
regime: 'core', // 'core' (you drive rounds) | 'sdk' (the SDK drives)
resolved, // the resolved prompt — tools, middleware, skills, memory
call: { tools, toolMiddleware }, // per-call additions (highest precedence)
promptId: prompt.id,
input,
reresolve: () => prompt.resolve(resolveOpts), // for LoadSkill re-resolution
appendToolRound, // provider tool-round shape (core regime)
sanitizeToolSchema, // provider JSON Schema quirks (core regime)
})
// Once, before the first provider call:
let messages = (await lifecycle.resume(history)).messages
// Core regime — drive rounds yourself:
const round = await lifecycle.executeRound(extracted, messages)
if (round.kind === 'suspended') {
// round.messages ends with the approval-request message plus one tool
// message per already-executed sibling (side effects happened; resume()
// will not replay them). Persist as-is, return round.request.
}
// SDK regime — hand the armed map to the SDK instead:
const outcome = await sdk.runLoop({ tools: lifecycle.tools /* … */ })
if (outcome.status === 'suspended') {
const sealed = lifecycle.suspend(outcome.pendingApprovals, outcome.assistantResponse, outcome.messages)
}
// Per step, both regimes:
const amendment = await lifecycle.applySkillLoads(step.toolCalls) // LoadSkill → re-resolve + re-arm + refund
// At the end, both regimes (at-most-once internally):
await lifecycle.captureTurn({ messages, assistantText, toolCalls })The session owns: tool merge precedence (call tools shadow prompt tools), middleware chain order (prompt middleware before call middleware), the full approval protocol (deterministic approval_<toolCallId> ids, anti-forgery tokens, decision matching, idempotent resume replay), per-call instrumentation (canonical tool.call spans, consumed tool.args, raw and model-facing tool.result artifacts, onToolStart/onToolEnd/onToolApprovalRequest hooks), output normalization (toModelOutput, default shaping, rendering), the LoadSkill side effect, and memory-capture fan-out. Both regimes drive the same private gate → execute → settle verdict kernel, so live and resumed tool calls have the same observability contract whether core or the SDK drives the loop — the cross-dialect parity suite verifies it mechanically.
stream() paths that never execute tools call lifecycle.notifyDecisions(messages) so approvalMiddleware onApproved/onDenied callbacks still fire for decisions found in history.
Exports
// @crux/core/adapter/tool (the deep module)
export {
// The per-call session
createToolLifecycle,
// Authoring
approvalMiddleware,
toolMiddleware,
// App-facing approval helpers
appendToolApprovalResponse,
deniedToolModelOutput,
findToolApprovalDecision,
findToolApprovalRequests,
toolApprovalResponse,
toolApprovalResponseMessage,
}
export type {
ToolLifecycle,
ToolLifecycleOptions,
ToolDescriptor,
AppendToolRound,
ToolResumeOutcome,
ToolRoundOutcome,
SkillAmendment,
SuspendedRound,
ToolProtocolEvent,
ApprovalRequestInfo,
ApprovalMiddlewareConfig,
ToolApprovalDecision,
ToolApprovalDecisionEvent,
ToolApprovalRequest,
ToolApprovalRequestPart,
ToolApprovalResponsePart,
ToolCallContext,
ToolErrorContext,
ToolMatcher,
ToolMiddleware,
ToolMiddlewareConfig,
ToolResultContext,
}The authoring surface and approval helpers are also exported from the @crux/core root and @crux/core/tool-middleware. The former @crux/core/tool-approvals subpath is gone — import the same helpers from @crux/core/adapter/tool (or the root). The orchestration internals the session replaced (applyToolMiddleware, notifyToolApprovalResponses, instrumentToolSet, the approval id/token/message builders, resume scanning) are no longer exported anywhere — every removed call pattern is a session method now.