How Roo Code's Custom Tools System Uses esbuild for Dynamic Tool Execution

Roo Code's custom tools system uses esbuild-wasm as a cross-platform CLI process to transpile TypeScript files on-demand, bundling dependencies while externalizing Node.js built-ins, then caches the output as executable ES modules.

The Roo-Code repository provides a plug-in-like framework that allows developers to drop arbitrary TypeScript or JavaScript files into a directory and execute them at runtime within the VS Code extension. This custom tools system leverages esbuild-wasm to handle just-in-time compilation, isolating the build process from the host extension while maintaining compatibility across macOS, Windows, and Linux environments.

Architecture Overview

The system centers on the CustomToolRegistry class defined in packages/core/src/custom-tools/custom-tool-registry.ts. During extension activation in src/extension.ts, the registry's setExtensionPath() method stores the extension root path, enabling the system to locate the esbuild binary in both production and development contexts. The modular architecture separates binary resolution (esbuild-runner.ts) from tool orchestration, ensuring clean separation between the compilation layer and execution environment.

The Three-Stage Execution Flow

Stage 1: Resolving the esbuild Binary

In packages/core/src/custom-tools/esbuild-runner.ts, the getEsbuildScriptPath() function locates the appropriate esbuild executable. The implementation checks for a bundled dist/bin/esbuild path first (used in production builds), falling back to node_modules/esbuild-wasm/bin/esbuild during development. This resolution strategy ensures the WASM-based compiler is available regardless of the host platform or sandbox restrictions.

The binary path is determined at runtime through logic in lines 19-27 of esbuild-runner.ts, allowing the same codebase to function both in packaged extensions and local development environments.

Stage 2: On-the-Fly Transpilation and Bundling

When CustomToolRegistry.import() detects a .ts file, it first validates the tool definition (name, description, and optional Zod schema) then checks an in-memory cache. If the tool is uncached, the registry invokes runEsbuild() with a tightly controlled configuration:

  • Entry point bundling combines the tool code and npm dependencies into a single file
  • External Node built-ins prevent bundling of core modules using external: NODE_BUILTIN_MODULES
  • CommonJS compatibility is achieved via banner: COMMONJS_REQUIRE_BANNER, which injects a require shim
  • Dependency resolution uses nodePaths to include the tool's local node_modules directory

The CLI process, spawned via execa(process.execPath, args), writes a bundle.mjs file to a tool-specific cache directory. This output is an ES module containing the transpiled code and dependency shims, ready for immediate execution.

Stage 3: Dynamic Execution

The generated bundle.mjs is loaded using dynamic import() with a file:// URL. Because the bundle includes the CommonJS shim, tools can freely use require() for dependencies despite running in an ESM context. The registry stores loaded modules in this.tsCache, a Map that persists for the duration of the extension session, while the on-disk cache ensures fast reloads across extension restarts.

Subsequent invocations of the same tool skip the esbuild process entirely, reading directly from the in-memory cache or the cached bundle.mjs file.

Why esbuild-wasm Uses a CLI Approach

The decision to invoke esbuild as a subprocess rather than using the JavaScript API provides three architectural advantages:

  • Cross-platform guarantee: The WASM implementation works uniformly on macOS, Windows, Linux, and within VS Code's extension host without platform-specific native binaries
  • Process isolation: Running as a separate Node.js process prevents build-time state or memory leaks from affecting the host extension
  • Deterministic caching: The CLI writes output to predictable filesystem locations, enabling reliable cache reuse and simplifying cache invalidation logic

Implementation Examples

TypeScript Custom Tool Definition

// tools/helloWorld.ts
import { z } from "zod"

export const helloWorld = {
  name: "helloWorld",
  description: "Returns a friendly greeting",
  parameters: z.object({ person: z.string() }),
  async execute({ person }: { person: string }) {
    return `Hello, ${person}! 👋`
  },
}

Loading and Executing Tools at Runtime

import { customToolRegistry } from "@roo-code/core/custom-tools"

// Called during extension activation in src/extension.ts
customToolRegistry.setExtensionPath(context.extensionPath)

// Load all tools from an absolute directory path
await customToolRegistry.loadFromDirectory("/absolute/path/to/tools")

// Retrieve the bundled tool and execute
const tool = customToolRegistry.get("helloWorld")
if (tool) {
  const result = await tool.execute({ person: "Alice" })
  console.log(result) // → Hello, Alice! 👋
}

Build-Time Binary Packaging

The build process in packages/build/src/esbuild.ts ensures the esbuild-wasm binary is available in production by copying it to the distribution directory:

// From packages/build/src/esbuild.ts (lines 162-195)
await fs.copyFile(
  require.resolve("esbuild-wasm/bin/esbuild"),
  join(outputDir, "bin", "esbuild")
)
// Also copies the associated .wasm file

Summary

  • Roo Code's custom tools system uses esbuild-wasm invoked as a CLI process for cross-platform TypeScript compilation
  • The CustomToolRegistry class orchestrates tool discovery through loadFromDirectory() and manages execution via the import() method
  • Tools are bundled with npm dependencies while Node.js built-ins remain external, producing a cached bundle.mjs output
  • A two-tier caching system (in-memory this.tsCache and filesystem storage) eliminates redundant compilation overhead
  • The CommonJS shim injected via the banner option enables tools to use require() despite the ESM-based execution environment

Frequently Asked Questions

Why does Roo Code use esbuild-wasm instead of the native esbuild binary?

Roo Code uses esbuild-wasm to ensure the custom tools system works across all platforms—including VS Code's sandboxed extension host—without requiring platform-specific native binaries. The WASM implementation provides identical bundling performance while maintaining compatibility with macOS, Windows, and Linux environments without additional configuration.

How does the caching mechanism work for custom tools?

The system implements a two-tier cache: an in-memory Map (this.tsCache) stores loaded modules during the current extension session, while a filesystem cache preserves bundled bundle.mjs files in tool-specific subdirectories. When CustomToolRegistry.import() encounters a previously compiled tool, it bypasses the esbuild invocation entirely and loads the cached version directly from memory or disk.

Can custom tools use CommonJS require statements?

Yes. The bundling process in esbuild-runner.ts injects a CommonJS require shim via the banner: COMMONJS_REQUIRE_BANNER option, allowing tools to use require() for dependencies. The output bundle.mjs is an ES module that includes this shim, enabling seamless interoperability between CommonJS npm packages and the ESM-based execution environment of the extension.

Where is the esbuild binary located in production builds?

In production builds, the esbuild binary is bundled at dist/bin/esbuild within the extension package, copied there by the build script in packages/build/src/esbuild.ts. The getEsbuildScriptPath() function checks this location first, only falling back to the development node_modules path when the bundled binary is unavailable.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →