Projects / Open Source Package

Open Source Package

tool-registry

The moment you build anything that calls more than one LLM provider, you run into a specific, tedious problem: OpenAI, Anthropic, and Gemini all support function calling, or "tools," and all three describe a tool in a subtly different JSON shape.

The moment you build anything that calls more than one LLM provider, you run into a specific, tedious problem: OpenAI, Anthropic, and Gemini all support function calling, or "tools," and all three describe a tool in a subtly different JSON shape. Same concept: name, description, parameters, a way for the model to say "call this with these arguments." Three incompatible schemas. Write a tool once, and if you want it to work across providers, you either maintain three definitions by hand and keep them in sync manually, or you write an adapter. I got tired of the manual version after the second provider swap, which is really the origin story of most of these small packages: not a grand plan, just the exact moment repetition stopped being tolerable.

tool-registry is the adapter. Define a tool once (name, description, a parameter schema, and the handler function that actually runs when it's called), and export that same definition in whichever provider's shape you need, on demand.

The actual API

A ToolRegistry class, backed by a Map, with register() and unregister() for adding and removing tools, call(name, input) for invoking one directly, and three exporters (toOpenAI(), toAnthropic(), toGemini()) that walk the registered tools and emit each provider's expected schema. There's also dispatch(provider, callBlock), which is the part I actually use the most: instead of writing three different parsers for "here's how OpenAI hands back a tool call" versus "here's how Anthropic does it," you hand dispatch the raw response block from whichever provider and it normalizes the shape and routes it to the right handler. That's the actual time savings: not just defining tools once, but not re-writing the response-handling glue every time I swap which model is doing the calling.

It also exports a pre-built singleton registry for quick scripts where instantiating your own class feels like overhead, and a tool() decorator-style helper for registering something in one line instead of three.

Where I'll stop myself before I oversell it

The README calls it "schema validation." The code does not do schema validation. _validate checks that fields marked required are present (obj[req] === undefined) and nothing else. If your schema says a parameter should be a number and someone passes a string, tool-registry will not catch that. It'll pass it straight through to your handler and let your handler find out the hard way. That's a real gap between what I called the feature and what the feature actually does, and I noticed it while going back through this to write honestly about it rather than while building it, which tells you something about how easy it is to describe your own code more generously than it deserves without meaning to.

The fix isn't complicated (a real JSON-schema validator instead of a required-field check); I just haven't done it, because in practice the LLMs I've used are reliable enough about respecting declared types that the gap hasn't bitten me yet. That's not the same as the gap not existing. It's closer to: I've been lucky so far, and I know it.

Why this over an existing framework

There are heavier agent frameworks that solve this and a dozen other things at once. I didn't want a dozen other things. I wanted the one specific piece, define once, export everywhere, without inheriting a framework's opinions about how my agent loop, my state management, or my prompt construction should work. This is the same instinct behind most of the small packages I ship: take the one genuinely reusable idea out of a bigger, more opinionated tool, and package just that.

It's published scoped, @yashveerlabs/tool-registry, rather than as a bare package name: a small detail, but a deliberate one. The unscoped packages are the ones I built earliest and fastest; the scoped ones tend to be the slightly more considered releases, where I thought about naming and ownership a little more before publishing. Not a hard rule. Just a pattern I notice looking back at my own npm history.

What it's actually for, concretely

Any time I'm building something that needs to hand an LLM a set of capabilities (search a database, look something up, take an action) and I don't want to lock that definition to one provider's API forever. Local-first tooling especially benefits from this: local-llm-router already lets me swap between Ollama, LM Studio, and llama.cpp at the transport layer; tool-registry does the equivalent swap at the tool-definition layer, so an agent built against one provider's function-calling format isn't stranded the day I decide to try a different model.

What I'd fix first

Real schema validation, properly typed against the declared parameter types, not just required-field presence. After that, tests: the validation gap is exactly the kind of bug a test suite exists to catch before a real user does, and I don't currently have one watching this package's back. Both are known, both are small, and both are the kind of thing that's easy to deprioritize right up until the day they aren't.

The three shapes, side by side

It's worth naming exactly how different the three provider formats actually are, because "they're all slightly different" undersells it. OpenAI wants a functions array with a parameters field that's a raw JSON Schema object. Anthropic wants tools with input_schema instead of parameters: same idea, different key name, and it expects the whole array passed alongside a separate top-level system field rather than folded into the message list. Gemini wants functionDeclarations nested inside a tools array that itself sits inside a different part of the request body entirely, and its parameter schema uses its own type enum that doesn't map one-to-one onto JSON Schema's. None of these are hard individually. Writing all three by hand, correctly, for every tool, every time you add one, is where the tedium lives, and tedium is exactly the kind of thing that produces copy-paste bugs, because the fourth time you write the same transformation by hand you stop actually reading it and just pattern-match off the third one.

toOpenAI(), toAnthropic(), and toGemini() exist specifically to make that transformation happen in one place, correctly, once, instead of three slightly-wrong places that drift apart the first time I add a tool and forget to update one of them.

Where the registry pattern actually pays off

I noticed the value of this most clearly the day I swapped a project from Anthropic to a local Ollama model routed through local-llm-router, mid-build. Every tool definition in that project was already registered once. Nothing about the tool definitions needed to change; I just called a different export method on the same registry, because the local model spoke the OpenAI-compatible shape and toOpenAI() already existed. That's the entire reason this package exists: not because provider-swapping is something I do constantly, but because on the occasions I do, I wanted it to cost one line instead of an afternoon of rewriting schemas by hand and hoping I didn't typo a parameter name in the process.

Full stack developer. Founder of Yashveer Labs. Define the tool once. Let the provider be someone else's problem.

Start a conversation about this.

Whether it's tool-registry itself or the next system worth building, the lab is reachable.