Skip to content

Trusted execution hooks

SchemaRouter supports ordered local callbacks immediately around validated tool execution.

Hooks are intended for organization-specific audit, policy integration, metrics, local vetoes, and other trusted middleware that should not become part of the model-visible tool contract.

Configure hooks

from schemarouter import ExecutionHooks, SchemaRouter


def before(tool, endpoint, call):
    audit_call(call)


def after(tool, endpoint, call, result):
    audit_result(result)


router = SchemaRouter(
    execution_hooks=ExecutionHooks(
        before_call=[before],
        after_call=[after],
    )
)

Both sync and async callables are supported. Hooks run in declaration order. Async hooks are bounded by the remaining ExecutionBudget.max_elapsed_seconds budget; synchronous hooks cannot be preempted, but elapsed time is checked immediately after they return.

Execution order

For one logical tool call, the relevant boundary is:

plan
 -> current schema + policy validation
 -> elapsed-budget clock
 -> optional trusted approval
 -> schema/binding revalidation
 -> budget logical-call accounting
 -> before hooks
 -> schema/binding revalidation
 -> invoker attempts / retries
 -> raw output JSON Schema validation
 -> field projection + ToolResult construction
 -> after hooks
 -> return ToolResult

A before hook cannot bypass schema validation, execution policy, approval, or binding checks. Because a hook may await, SchemaRouter refreshes executable state after all before hooks finish.

Snapshot-only contract

Hooks receive deep copies of the models supplied to them.

A before hook receives:

  • ToolSpec;
  • EndpointSpec;
  • ToolCall.

An after hook additionally receives the final projected ToolResult.

Mutating those objects does not mutate the executable call, registry schema, or result returned to the caller.

Hooks must return None. A non-None return from a before hook raises ExecutionHookError. A non-None return from an after hook raises PostInvocationHookError, because the external operation and result validation have already succeeded. This avoids an implicit transformation API that could alter arguments, fields, schemas, or execution authority.

Failure behavior

Hook failures are fail-closed, but pre- and post-invocation failures have different semantics.

  • A failing before hook prevents the invoker from running and is surfaced as ExecutionHookError.
  • A failing after hook withholds the normal return path, but the successful projected ToolResult is preserved on PostInvocationHookError.result.
  • PostInvocationHookError.execution_succeeded is always True.
  • Post-invocation failures are non-retryable, including when trusted code explicitly enabled retries for non-read-only endpoints. A successful mutation is therefore never repeated because an audit/metrics hook failed.
  • Event streams record the successful operation as tool.end, then surface run.error with stage="post_invocation_hook"; they do not rewrite that operation as tool.error.

Before-hook elapsed-budget expiration remains an ExecutionBudgetExceededError. Once the tool has successfully produced a validated/projected result, an exception, cancellation, invalid return, or elapsed-budget failure in an after hook is wrapped as PostInvocationHookError so callers can distinguish tool success from post-processing failure.

Privacy

Execution hooks are trusted local code, not redacted telemetry.

Before hooks receive the validated call arguments. After hooks receive the projected result payload. Do not register third-party or remote callbacks as hooks unless they are trusted to receive that data.

For privacy-preserving observability across less-trusted sinks, prefer the redacted RunEvent stream or the OpenTelemetry exporter.

Relationship to approval

Approval and execution hooks serve different purposes.

  • Approval is an explicit boolean authority gate controlled by ExecutionPolicy.approval_mode.
  • Hooks are ordered middleware around already-authorized execution and may only observe or fail closed.

Hooks cannot approve an operation that policy or the approval callback rejected.

Direct executor use

executor = RegistryExecutor(
    registry,
    hooks=ExecutionHooks(
        before_call=[before],
        after_call=[after],
    ),
)

The same contract applies whether hooks are configured through SchemaRouter or directly on RegistryExecutor.