Skip to Content
DocumentationExtendTool Interceptors

Tool Interceptors

Tool interceptors are a chain-based extension point that wraps every tool.call() in your app. They are the right surface for cross-cutting concerns that should run around every tool call without changing tool implementations — quota enforcement, response caching, structured tracing, custom error handling, billing accounting.

This is an advanced extension point. Most apps don’t need a custom interceptor — the framework already ships ToolLoggingInterceptor for timing/logging, and the @loopstack/quota registry feature includes a working QuotaInterceptor you can copy.

How They Work

Interceptors form a NestJS-style chain. Each interceptor calls next() to pass control to the next interceptor (or, eventually, to the tool’s handle()). You can:

  • run logic before and after the tool call
  • transform the result
  • short-circuit by not calling next() (e.g. cache hit returns a result directly)
  • handle errors with try/catch around next()

The chain is built once at app bootstrap from every NestJS provider decorated with @UseToolInterceptor(). Ordering is controlled by prioritylower runs first / outermost. The built-in ToolLoggingInterceptor uses priority 0 so its timing includes every other interceptor.

caller → ToolLoggingInterceptor(0) → CacheInterceptor(50) → QuotaInterceptor(80) → tool.handle()

Implementing an Interceptor

Implement ToolInterceptor and decorate with @UseToolInterceptor({ priority? }). The decorator applies @Injectable() for you, so the class only needs to be registered in a NestJS module like any other provider.

import { ToolExecutionContext, ToolInterceptor, ToolResult, UseToolInterceptor } from '@loopstack/common'; @UseToolInterceptor({ priority: 50 }) export class CacheInterceptor implements ToolInterceptor { private readonly cache = new Map<string, ToolResult>(); async intercept(context: ToolExecutionContext, next: () => Promise<ToolResult>): Promise<ToolResult> { const key = `${context.tool.constructor.name}:${JSON.stringify(context.args)}`; const hit = this.cache.get(key); if (hit) { context.metadata.cacheHit = true; return hit; // short-circuit — `next()` not called, tool doesn't run } const result = await next(); this.cache.set(key, result); return result; } }

Register it in a module:

@Module({ providers: [CacheInterceptor /*, ...your tools and workflows */], }) export class MyAppModule {}

That’s it — bootstrap-time discovery picks it up via @UseToolInterceptor() metadata. No manual registration list.

ToolExecutionContext

The first argument to intercept() carries everything an interceptor needs.

FieldTypeNotes
toolobjectThe tool instance. Use context.tool.constructor.name for the class name.
argsRecord<string, unknown> | undefinedThe arguments passed to tool.call() (post-validation when reaching handle()).
runContextRunContextThe per-job framework context: userId, workspaceId, workflowId, args.
metadataRecord<string, unknown>Mutable. Use this to pass data between interceptors in the chain (e.g. cache key, timings, quota cost). The built-in logging interceptor writes durationMs here.

Priority Ordering

priority is a number. Lower runs first / outermost — that interceptor wraps every later one.

PriorityPositionUse for
0outermostLogging, tracing — see everything that happens inside.
1–50earlyAuth gates, request validation, kill-switches.
50–100middleCaching, idempotency, response transformation.
>100innerPer-tool accounting (quota debit, billing) close to handle().

@UseToolInterceptor() defaults to 100 when omitted.

Built-in Interceptors

  • ToolLoggingInterceptor (priority 0) — auto-registered by LoopCoreModule. Logs each tool’s start/finish/timing and writes context.metadata.durationMs. Source: loopstack/packages/core/src/workflow-processor/services/tool-logging.interceptor.ts.

Real-world Example: Quota

The @loopstack/quota package ships a QuotaInterceptor that uses the chain pattern to enforce per-user quotas before the tool runs and report usage after:

@UseToolInterceptor({ priority: 80 }) export class QuotaInterceptor implements ToolInterceptor { async intercept(context: ToolExecutionContext, next: () => Promise<ToolResult>): Promise<ToolResult> { const userId = context.runContext.userId; await this.checkQuota(userId, context.tool); const result = await next(); await this.reportUsage(userId, context.tool, result); return result; } }

See loopstack/registry/features/quota-module/src/services/quota.interceptor.ts for the full implementation.

Last updated on