Hooks — before / after / error / denied
One hook API, three scopes. Function-first, state-based.
beforemutatesinputas it flows,aftermutatesoutput,errorobserves failures,deniedhandles HITL rejections. All logs go to console + visible hook log in the demo.
Hooks wrap only the agent execute path (document.modelContext.registerTool → execute via the Chrome WebMCP API). Direct human calls tool({input}) stay pure — search({query:'alice'}) never triggers hooks.
Demo: open /demo → Hooks & HITL card. Toggle “Require approval for checkout”, then Inspect → Invoke checkout. Watch hook log + console ([webmcp:hook]) and the approval modal.
Quick start
import { webmcp } from 'simple-webmcp';
async function deleteUser({id}:{id:string}){ /* … */ }
// tool-level — arrays, order matters
const tool = webmcp(deleteUser, {
description: 'Delete a user',
hooks: {
before: [addTenant, requireApproval], // input flows: addTenant → requireApproval → fn
after: [redactResult],
error: [trackError],
denied: [trackDenied],
}
});
// global — every tool inherits
import { webmcp } from 'simple-webmcp';
webmcp.configure({
hooks: {
before: [trackInvocation],
after: [trackResult],
error: [reportError],
denied: [trackDenied],
}
});
// React scoped — provider nests, merges additively
import { WebMCPProvider } from 'simple-webmcp/react';
<WebMCPProvider hooks={{ before: [addTenantContext] }}>
<Scope tools={[tool]}>{children}</Scope>
</WebMCPProvider>See Analytics Step-by-Step for the 4-step setup and Reference — Hooks for types.
Lifecycle & ordering
input
↓
global.before[] → scoped.before[] → tool.before[] // outer→inner
↓ validate (StandardSchema after enrichment)
↓ fn(input) — your function
↓
tool.after[] → scoped.after[] → global.after[] // inner→outer (onion)
↓ normalizeResult → agent receives contentOn deny (before returns {action:'deny'}): run denied[] (tool→scoped→global), return {isError:true, content:[{text:'Denied: …'}]} — after never runs. On throw (fn or hook): run error[] (tool→scoped→global) as safe observer (throws inside error are swallowed, never recurse), return normalizeError.
signal (AbortSignal from registry) is cooperative — if (signal.aborted) short-circuit returns abort error; fn must honor signal itself if it wants interruptible work. metadata: Record<string,unknown> is a mutable shared bag for the whole invocation (metadata.start = performance.now() in before, read in after).
invocationId is crypto.randomUUID() when available, else webmcp_${Date.now()}_${counter}_${rand} fallback.
Before — mutate input, or deny
const addTenant: BeforeHook<typeof deleteUser> = ({input, metadata}) => {
metadata.tenantId = 'tenant_123'; // shared bag
return { input: { ...(input as any), tenantId: 'tenant_123' } };
};
const requireApproval: BeforeHook<typeof checkout> = async ({input, tool, signal}) => {
// HITL — show modal, await human
const approved = await showApprovalModal({ tool, input }); // your UI
if (!approved) {
return { action: 'deny', message: 'User declined checkout', code: 'USER_DENIED' };
}
// continue — don't return, or return {input} to enrich
};- Return
void→ continue. - Return
{input: enriched}→ nextbeforesees enriched input. - Return
{action:'deny', message, code}→ stop, rundenied[], agent getsDenied: …(isError:true). - Throw → treat as error (run
error[]).
No action:'continue' ceremony. For checkout in the demo, toggle Require approval for checkout to see deny/approve flows in hook log.
After — transform output
const redact: AfterHook<typeof checkout> = ({output}) => {
// output is Awaited<ReturnType<F>> — typed
if (output?.order?.email) {
return { output: { ...output, order: { ...output.order, email: output.order.email.replace(/(.).+@/, '$1***@') } } };
}
};- Return
void→ keep output. - Return
{output: newOutput}→ nextaftersees new output. - Throw → run
error[], agent getsError: ….
After hooks receive raw output before normalizeResult, then final output is normalized to {content:[{type:'text',…}]}.
Error — observational
const trackError: ErrorHook<typeof deleteUser> = ({error, input, tool, invocationId}) => {
console.warn('[hook:error]', tool.tool.name, error);
// send to Sentry / analytics — don't throw
};- Return
voidonly. Throwing insideerroris swallowed, seconderrorstill runs. - Return value is ignored — error hooks are observational.
Denied — HITL analytics
const trackDenied: DeniedHook<typeof checkout> = ({reason, code, input}) => {
analytics.track('webmcp.denied', { code, reason, input });
};Only runs when a before returned action:'deny'. Useful to distinguish “agent called tool but human said no” from success/error.
Tool vs global vs scoped
// global — accumulated via concat
webmcp.configure({ hooks:{ before:[a] }});
webmcp.configure({ hooks:{ before:[b] }}); // => [a,b]
webmcp.configure({ hooks:{ before:[c] }, replace:true }); // => [c]
// tool — re-wrapping concats, not replaces
const t1 = webmcp(fn, { hooks:{ before:[a] }});
const t2 = webmcp(t1, { hooks:{ before:[b] }}); // before=[a,b]
// scoped — provider merges additively; nesting accumulates
<WebMCPProvider hooks={{before:[outer]}}>
<WebMCPProvider hooks={{before:[inner]}}>
<Scope tools={[t2]} /> {/* before = [outer, inner] + global + tool */}
</WebMCPProvider>
</WebMCPProvider>In tests, clear global hooks with resetGlobalHooks() and registry with registry.clear() after each test. See testing notes in Reference — Hooks.
Direct calls are not hooked
const tool = webmcp(fn, { hooks:{ before:[enrich] }});
await tool({query:'hi'}); // human → no hooks, raw fn
await invokeTool('search', {query:'hi'}); // agent → hooks runIf you need the same enrichment for human calls, call the function directly or share a helper.
Patterns
- Tenant enrichment:
beforereturns{input: {...input, tenantId}}. - AuthZ:
beforechecks permissions, returnsdenyif not allowed. - Approval (HITL):
beforeawaits modal/confirm, returnsdenyon cancel — see demorequireApproval. - Redaction:
afterreturns{output: redacted}. - Timing:
beforesetsmetadata.start = performance.now(),afterreadsmetadata.start. - Abort: check
signal.abortedinside long-runningbefore; return deny or throw.
See also
- Reference — Hooks — type reference
- Analytics — Overview — PostHog, Sentry, GA4
- Analytics — Step-by-Step — 4-step setup
- Guide — React —
WebMCPProviderscoped hooks - Demo — Shopping cart + hook log — live
before/after/error/deniedwith console[webmcp:hook] - External: Chrome WebMCP API