Tool Registry Boundary Skill
You keep the 5,000+-tool executable registry out of module graphs that don't execute tools.
The rule
Client-reachable code reads tool metadata. Only code that actually executes a tool imports the registry.
@/tools/registry is a 10,000+-line barrel importing every tool. External ToolConfig entries mix
plain data (params, outputs, name) with request/response closures, while
InternalToolConfig entries contain semantic input projection and load their server implementation
through lib/internal/tool-operations/registry.server.ts. Request closures can still reach SDK
clients, API helpers, and parsers, which is what makes the executable barrel expensive: reaching it
costs roughly 4,700 additional modules (measured; re-measure with --verbose).
getTool() returns the whole ToolConfig, so a single getTool import anywhere in a client-reachable file drags all of it in.
Which module to import
| you need | import | notes |
|---|---|---|
| whether a tool id exists | hasToolId from @/tools/tool-ids |
~110 KB — the cheapest module |
| to resolve an unversioned name | resolveToolId from @/tools/tool-ids |
|
| every tool id | getToolIds from @/tools/tool-ids |
|
| a tool's params | getToolParams / getToolMetadata from @/tools/metadata |
~4 MB |
| a tool's declared outputs | getToolOutputsMetadata from @/tools/metadata-outputs |
~4 MB, separate on purpose |
| to execute a tool | getTool from @/tools/utils, or @/tools/utils.server |
server paths only |
Three modules, cheapest first. Ids are their own artifact because resolution and existence checks need only the key set; outputs are their own because they are the larger half of the data with a single consumer. @/tools/metadata and @/tools/metadata-outputs both resolve ids through @/tools/tool-ids, which is what keeps them independent of each other — do not "helpfully" re-export one from another, or every caller pays for all three.
All lookups guard with Object.hasOwn. JSON.parse yields an object with the normal prototype, so a bare bracket lookup returns inherited members: a bare bracket lookup would answer getToolMetadata('constructor') with a function typed as tool metadata.
The generated artifacts
apps/sim/tools/generated/tool-ids.ts, tool-metadata.ts and tool-outputs.ts are produced by scripts/sync-tool-metadata.ts:
bun run tool-metadata:generate # after adding/changing a tool
bun run tool-metadata:check # what CI runs; fails if stale
Never hand-edit them. If you add a tool or change a tool's params/outputs, regenerate and commit the result, or CI fails.
Three non-obvious properties, each of which was measured and is easy to undo by accident:
- The data is a JSON string parsed at runtime, not an imported
.jsonand not an object literal. WithresolveJsonModule(which this repo enables), a.jsonimport makes TypeScript infer a literal type for all 5,000+ entries and takestsc --noEmitfrom 12.6s to 8m07s — a 38x regression. An ambientdeclare moduledoes not short-circuit it, and an object literal costs the same. A single string literal is one cheap token for both the compiler and the bundler, andJSON.parsebeats evaluating the equivalent literal at runtime. Do not "clean this up" into a.jsonimport. - The generator refuses to emit function values. If you add a field to
METADATA_FIELDSthat contains a closure, generation fails loudly rather than shipping executable config to the client.hostingandschemaEnrichmentare excluded for exactly this reason (hosting.enabled,pricing, andenrichSchemaare functions) — they are server-only. - Empty param entries are stripped. The registry contains one (
stt_deepgram_v2), which crashes callers that readparam.typewhile iterating. - Lookups resolve versions.
getToolmaps an unversioned name onto the newest version, and a few hundred tools are versioned. A plain key lookup would silently report them missing — a quiet correctness bug, not a crash.resolveToolIdreproduces that against the id set and is differentially tested against the original.
Testing code that reads tool metadata
Mock the module the code under test actually reads. vi.mock('@/tools/utils', () => toolsUtilsMock) only controls getTool; code that reads params/outputs/name goes through @/tools/metadata, so mocking tools/utils there is a no-op that still passes — because the real generated artifacts happen to agree with the mock fixtures. The test looks green while controlling nothing.
import { blocksMock, toolsMetadataMock, toolsUtilsMock } from '@sim/testing/mocks'
vi.mock('@/tools/utils', () => toolsUtilsMock) // executable lookup
vi.mock('@/tools/metadata', () => toolsMetadataMock) // params / outputs / name
Both are backed by the same mockToolConfigs, so mocking both gives one consistent tool universe. If you are unsure whether a mock is load-bearing, change a fixture value to a sentinel and confirm the test fails.
The guard
bun run check:tool-registry-boundary (CI: "Tool registry client-boundary audit") walks the module graph from each workspace route and fails if @/tools/registry is reachable, printing the exact import chain that reintroduced it.
If it fails, do not add the entry to an allowlist — there isn't one. Find the symbol the offending file actually needs and move it to a registry-free module, exactly as mergeToolParameters and formatParameterLabel were.
Run it with --verbose to print per-route module counts, which is also the quickest way to see whether a change moved the graph.
The same command also ratchets those counts against check-tool-registry-boundary.baseline.json. --check (what CI runs) fails when an entry exceeds its baseline by more than max(25 modules, 2%), naming the import chain responsible. This catches bloat the registry rule misses — a prefetch importing listTables cost the Tables page 444 modules without ever touching @/tools/registry.
Re-record with --update-baseline and commit the JSON when growth is deliberate. A shrink passes but is reported — re-record then too, or the win is silently spendable again.
How to verify an edge actually got cut
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
- From the entry you care about, follow
importandexport … from(skippingimport type), resolving@/againstapps/sim. - Check whether
apps/sim/tools/registry.tsis in the reachable set, and print the parent chain if it is. - Compare the reachable module count before and after.
Reference points measured on this repo:
| entry | modules |
|---|---|
tools/registry.ts reachable |
~4,900 |
tools/merge-params.ts (leaf) |
2 |
providers/utils.ts after cutting its params edge |
22 |
app/workspace/[workspaceId]/w/page.tsx (canvas) |
6,592 before, 1,908 after |
The canvas route reached the registry through four redundant edges — providers/utils (via tools/params), lib/workflows/blocks/block-outputs, lib/workflows/sanitization/validation, and serializer/index. Cutting any one alone moved the module count by ~1. They all had to go before anything improved; measure the route, not the file you edited.
When adding a new caller
Ask what the caller does with the config. If it reads params, outputs, name, description or just checks existence, it belongs on @/tools/metadata — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph.
If it genuinely executes — builds an external request, transforms a response, or dispatches a
registered internal operation — use getTool, and keep that file off client-reachable paths.