CtrlK
BlogDocsLog inGet started
Tessl Logo

tool-registry-boundary

Keep the executable tool registry out of client-reachable module graphs — when to read `@/tools/metadata` instead of `getTool`, how to measure whether an import edge pulls the registry, and how to regenerate the metadata artifacts. Use when touching `apps/sim/tools/registry.ts`, `tools/utils.ts`, `tools/params.ts`, or anything that calls `getTool`.

68

Quality

83%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

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 needimportnotes
whether a tool id existshasToolId from @/tools/tool-ids~110 KB — the cheapest module
to resolve an unversioned nameresolveToolId from @/tools/tool-ids
every tool idgetToolIds from @/tools/tool-ids
a tool's paramsgetToolParams / getToolMetadata from @/tools/metadata~4 MB
a tool's declared outputsgetToolOutputsMetadata from @/tools/metadata-outputs~4 MB, separate on purpose
to execute a toolgetTool from @/tools/utils, or @/tools/utils.serverserver 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 .json and not an object literal. With resolveJsonModule (which this repo enables), a .json import makes TypeScript infer a literal type for all 5,000+ entries and takes tsc --noEmit from 12.6s to 8m07s — a 38x regression. An ambient declare module does 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, and JSON.parse beats evaluating the equivalent literal at runtime. Do not "clean this up" into a .json import.
  • The generator refuses to emit function values. If you add a field to METADATA_FIELDS that contains a closure, generation fails loudly rather than shipping executable config to the client. hosting and schemaEnrichment are excluded for exactly this reason (hosting.enabled, pricing, and enrichSchema are functions) — they are server-only.
  • Empty param entries are stripped. The registry contains one (stt_deepgram_v2), which crashes callers that read param.type while iterating.
  • Lookups resolve versions. getTool maps 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. resolveToolId reproduces 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:

  1. From the entry you care about, follow import and export … from (skipping import type), resolving @/ against apps/sim.
  2. Check whether apps/sim/tools/registry.ts is in the reachable set, and print the parent chain if it is.
  3. Compare the reachable module count before and after.

Reference points measured on this repo:

entrymodules
tools/registry.ts reachable~4,900
tools/merge-params.ts (leaf)2
providers/utils.ts after cutting its params edge22
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.

Repository
simstudioai/sim
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.