Skip to Content
🐺 Vovk.ts is released. Read the blog post →
Deriving AI Tools from Procedures

Deriving AI Tools from Controllers and RPC Modules

Controllers as well as generated RPC/API modules can be converted into AI tools for LLM function calling, using deriveTools utility. This makes your back-end functionality accessible to AI models (including MCP clients) with minimum code. The function accepts modules record with

  • Controllers for same-context execution to be used on back-end.
  • RPC modules generated from controllers for HTTP calls or be used on front-end or other environments that support fetch.
  • Third-party OpenAPI-based modules (called OpenAPI mixins in this documentation), enabling to combine back-end functionality with external APIs in a single agent.
src/modules/user/UserController.ts
import { deriveTools } from 'vovk'; import { TaskRPC, PetstoreAPI } from 'vovk-client'; import UserController from '@/modules/user/UserController'; const { tools, toolsByName } = deriveTools({ modules: { UserController, TaskRPC, PetstoreAPI, }, }); console.log('Derived tools:', tools); // [{ name, description, parameters, execute }, ...]

The function returns an array of tools and a toolsByName object for easy access by tool name. Each tool implements VovkTool type and includes:

  • name: string - the name of the tool, derived from the module and method names as ${moduleName}_${handlerName} (can be overridden with x-tool.name, see below).
  • description: string - the description of the tool, derived concatenating summary and description from OpenAPI operation (can be overridden with x-tool.description, see below).
  • parameters: JSONSchema - the JSON Schema of the tool input, derived from procedure’s body, query, and params schemas.
  • execute: (input: Record<string, unknown>) => Promise<unknown> - the function to execute the tool logic. For RPC modules, it performs an HTTP request; for controllers, it calls the fn method to execute in the current context without HTTP.

Additional properties available on each tool:

  • type: "function" - always set to "function".
  • title?: string - optional title for the tool. Used mainly for MCPs, derived from OpenAPI summary or x-tool.title if available.
  • outputSchema?: StandardSchemaV1 & StandardJSONSchemaV1 - equals to the procedure’s output schema, when available.
  • inputSchemas: Partial<Record<'query' | 'body' | 'params', StandardSchemaV1 & StandardJSONSchemaV1>> - key-value schemas of procedure’s body, query, and params, when available.

deriveTools Options

The deriveTools function accepts an options object with the following properties:

  • modules: Record<string, object> - a record of modules (RPC/API modules or controllers) to derive tools from.
  • onExecute?: (tool: VovkTool, result: unknown) => void - optional callback invoked when a tool’s execute function completes successfully.
  • onError?: (tool: VovkTool, error: Error) => void - optional callback invoked when a tool’s execute function throws an error.
  • toModelOutput?: ToModelOutputFn<TInput, TOutput, TFormattedOutput> - optional function to format the output returned to the LLM. Can be set to a custom function or one of the built-in formatters, defined in ToModelOutput object, exported from vovk, such as
    • ToModelOutput.MCP for MCP formatting.
    • ToModelOutput.DEFAULT that’s used by default when toModelOutput is not provided.
  • meta?: Record<string, unknown> - optional metadata passed to each controller/RPC method. The meta can be read on the back end using req.vovk.meta. When passed to a controller procedure, it’s merged with procedure-level meta normally. When passed to an RPC method, it’s available as xMetaHeader key.

Custom Operation Attributes with x-tool or @operation.tool Decorator

By default, tool description is derived from OpenAPI summary and description fields and the tool name is generated in the form of ${moduleName}_${handlerName}. You can override these values and add tool-specific attributes using x-tool custom attributes in the @operation decorator.

src/modules/user/UserController.ts
import { prefix, get, operation } from 'vovk'; @prefix('user') export default class UserController { @operation({ summary: 'Get user by ID', description: 'Retrieves a user by their unique ID.', 'x-tool': { // tool-specific attributes } }) @get('{id}') static getUser() { // ... } }

@operation also provides tool property that defines tool-specific attributes for deriveTools function. It’s set under x-tool key in the OpenAPI operation object and created for cleaner syntax.

src/modules/user/UserController.ts
import { prefix, get, operation } from 'vovk'; @prefix('user') export default class UserController { @operation.tool({ title: 'Get user by ID', name: 'get_user_by_id', description: 'Retrieves a user by their unique ID, including name and email.', }) @operation({ summary: 'Get user by ID', description: 'Retrieves a user by their unique ID.', }) @get('{id}') static getUser() { // ... } }

The tool attributes available under x-tool are:

  • hidden?: boolean - if set to true, the tool is excluded from the derived tools.
  • name?: string - overrides the generated tool name.
  • title?: string - optional title for the tool. Used mainly for MCPs.
  • description?: string - overrides the generated tool description.

Tips

Selecting Specific Procedures

To include only certain procedures from a module (besides using hidden attribute), use the pick/omit pattern from lodash or a similar utility.

import { deriveTools } from 'vovk'; import { PostRPC } from 'vovk-client'; import { pick, omit } from 'lodash'; import UserController from '../user/UserController'; const { tools } = deriveTools({ modules: { PostRPC: pick(PostRPC, ['createPost', 'getPost']), UserController: omit(UserController, ['deleteUser']), }, });

The resulting tools include createPost and getPost from PostRPC, and all methods from UserController except deleteUser.

Authorizing API Calls

Third-party API calls may require authorization headers that can be passed by using withDefaults function, available for all generated RPC/API modules. Having GithubIssuesAPI module, described in OpenAPI mixins, you can create authorized tools for Github Issues API:

import { deriveTools } from 'vovk'; import { GithubIssuesAPI } from 'vovk-client'; const { tools } = deriveTools({ modules: { AuthorizedGithubIssuesAPI: GithubIssuesAPI.withDefaults({ init: { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 'X-GitHub-Api-Version': '2022-11-28' }, }, }), }, });

Vercel AI SDK Example

That’s an example of Vercel AI SDK chat that uses UserController to derive tools. View live example on examples.vovk.dev » 

First, create an empty controller. The command will also update the root route.ts file.

npx vovk new controller aiSdk --empty

Paste the following into the newly created src/modules/ai-sdk/AiSdkController.ts, adjusting imports as needed:

src/modules/ai-sdk/AiSdkController.ts
import { deriveTools, post, prefix, type VovkRequest, } from 'vovk'; import { jsonSchema, streamText, tool, convertToModelMessages, type UIMessage, } from 'ai'; import { openai } from '@ai-sdk/openai'; import UserController from '@/modules/user/UserController'; @prefix('ai-sdk') export default class AiSdkController { @post('tools') static async functionCalling(req: VovkRequest<{ messages: UIMessage[] }>) { const { messages } = await req.json(); const { tools: llmTools } = deriveTools({ modules: { UserController }, }); const tools = Object.fromEntries( llmTools.map(({ name, execute, description, parameters }) => [ name, tool({ execute: (input) => execute(input), description, inputSchema: jsonSchema(parameters), }), ]) ); return streamText({ model: openai('gpt-5-nano'), system: 'You are a helpful assistant', messages: await convertToModelMessages(messages), tools, }).toUIMessageStreamResponse(); } }

Here, the tools are mapped to Vercel AI SDK tool instances using jsonSchema. For other libraries, you can map them differently.

On the client-side, create a component using the useChat  hook:

src/app/page.tsx
'use client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { useState } from 'react'; export default function Page() { const [input, setInput] = useState(''); const { messages, sendMessage, error, status } = useChat({ transport: new DefaultChatTransport({ api: '/api/ai-sdk/tools', }), }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (input.trim()) { sendMessage({ text: input }); setInput(''); } }; return ( <form onSubmit={handleSubmit}> {messages.map((message) => ( <div key={message.id}> {message.role === 'assistant' ? '🤖' : '👤'}{' '} {message.parts.map((part, partIndex) => ( <span key={partIndex}>{part.type === 'text' ? part.text : ''}</span> ))} </div> ))} {error && <div>❌ {error.message}</div>} <div className="input-group"> <input type="text" placeholder="Send a message..." value={input} onChange={(e) => setInput(e.target.value)} /> <button>Send</button> </div> </form> ); }

See Realtime UI / Text AI Chat for more info.

Roadmap

  • ✨ Add a router option to deriveTools to support hundreds of functions without hitting LLM tools limits. Routing can be implemented using vector search or other approaches.
Last updated on