Skip to Content
Deriving AI Tools from Procedures

Deriving AI Tools from Controllers and RPC Modules

deriveTools turns controllers and generated RPC or API modules into AI tools for LLM function calling. AI models, MCP clients included, can then call your back end. The function takes a modules record with:

  • Controllers, which run in the current context, on the back end.
  • RPC modules generated from controllers, which make HTTP calls, on the front end or anywhere else with fetch.
  • Third-party modules generated from OpenAPI (OpenAPI mixins in these docs), so one agent can use both your back end and external APIs.

A tool comes from a procedure or an RPC method that has an operation object, given by @operation, @operation.tool or @operation.error. Every OpenAPI mixin method has one. Other members of a module are left out.

src/lib/tools.ts
import { deriveTools } from 'vovk'; import { TaskRPC, PetstoreAPI } from '@/client'; import UserController from '@/modules/user/user-controller'; const tools = deriveTools({ modules: { UserController, TaskRPC, PetstoreAPI, }, }); console.log('Derived tools:', tools); // [{ name, description, inputSchema, execute, ... }, ...]

The function returns an array of tools. Each tool satisfies the StandardToolV0 interface of the standard-tool  convention. vovk doesn’t export the type; import it from the standard-tool  package (import type { StandardToolV0 } from 'standard-tool'). A package that emits declarations and exports derived tools annotates them with it: export const tools: StandardToolV0[] = deriveTools({ modules }). Each tool has these fields:

  • name: string - the tool name, made from the module and method names as ${moduleName}_${handlerName}. x-tool.name overrides it (see below). As model APIs require, each character other than A-Z, a-z, 0-9, _ and - becomes _. A name longer than 64 characters is cut to 64 and ends with a hash of the whole name. Two tools with the same name make deriveTools throw.
  • title?: string - an optional title, used mainly by MCP. It comes from x-tool.title or the OpenAPI summary, if there is one.
  • description: string - the tool description: the summary and description of the OpenAPI operation, joined. x-tool.description overrides it (see below).
  • inputSchema?: StandardSchemaV1 & StandardJSONSchemaV1 - one Standard Schema that merges the procedure’s body, query and params, when there are any.
  • outputSchema?: StandardSchemaV1 & StandardJSONSchemaV1 - the procedure’s output schema, when there is one.
  • meta?: Record<string, unknown> - static data about the tool, copied as is from x-tool.meta (see below). The code that uses the tools reads it; the LLM never gets it.
  • execute: (input: unknown) => Promise<unknown> - runs the tool with the model’s { body, query, params }. The type takes unknown, as the model wrote it; the procedure validates it. For RPC modules, it sends an HTTP request. For controllers, it calls the fn method, which runs in the current context without HTTP. A JSON Lines result, from a generator or a JSONLinesResponder, reaches the model as the array of its items.

inputSchema validates the { body, query, params } envelope itself, and leaves each slot’s value to your original library schema (Zod, Valibot, ArkType). For RPC modules and OpenAPI mixins, it’s rebuilt from the generated JSON Schemas, so it checks only the envelope, and the values are validated when the tool runs.

deriveTools Options

deriveTools takes an options object with these properties:

  • modules: Record<string, object> - the modules (RPC or API modules, or controllers) to derive tools from.

  • onExecute?: (result: unknown, tool: StandardToolV0, req: Pick<VovkRequest, 'vovk'> | null) => void - an optional callback that runs when a tool’s execute function succeeds. For a controller, req is the call’s request object, with req.vovk; for an RPC method, it’s null.

  • onError?: (error: Error, tool: StandardToolV0, req: Pick<VovkRequest, 'vovk'> | null) => void - an optional callback that runs when a tool’s execute function throws, or when a controller returns a Response with an error status. For a Response, the error is an HttpException with its status and the message from the body: the message, detail or title of a JSON body, else its text. ToModelOutput.DEFAULT gives the model { error: message } in both cases. req is as in onExecute, or null for a thrown error.

  • toModelOutput?: ToModelOutputFn<TInput, TOutput, TFormattedOutput> - an optional function that formats the output for the LLM. Set it to your own function or to a built-in formatter from the ToModelOutput object, exported from vovk:

    • ToModelOutput.MCP for MCP formatting.
    • ToModelOutput.DEFAULT, the default when toModelOutput isn’t set.
    • ToModelOutput.RAW: the result as the procedure gives it, and a failed call throws its error after onError. For callers that need the throw, such as uicast.

    A formatter that returns an Error for a failed call makes the tool throw it.

  • meta?: Record<string, unknown> - optional metadata for each controller or RPC method. The back end reads it with req.vovk.meta. A controller procedure merges it with its own meta as usual. An RPC method gets it under the xMetaHeader key. OpenAPI mixins don’t get it, since their hosts are third parties.

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

To override the default tool name and description, or to add other tool attributes, use the custom x-tool attribute in the @operation decorator.

src/modules/user/user-controller.ts
import { z } from 'zod'; import { prefix, get, operation, procedure } 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 attributes } }) @get('{id}') static getUser = procedure({ params: z.object({ id: z.string() }), }).handle(async (req, { id }) => { // ... }); }

@operation.tool sets the same attributes with shorter syntax. They go under the x-tool key of the OpenAPI operation object.

src/modules/user/user-controller.ts
import { z } from 'zod'; import { prefix, get, operation, procedure } 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 = procedure({ params: z.object({ id: z.string() }), }).handle(async (req, { id }) => { // ... }); }

The attributes under x-tool are:

  • hidden?: boolean - true leaves the tool out of the derived tools.
  • name?: string - overrides the generated tool name.
  • title?: string - an optional title for the tool, used mainly by MCP.
  • description?: string - overrides the generated tool description.
  • meta?: Record<string, unknown> - static data that becomes the derived tool’s meta field. It’s not the meta option of deriveTools, which is runtime metadata for the procedure.

Tips

Selecting Specific Procedures

To include only some procedures of a module, besides the hidden attribute, use pick or omit from lodash or a similar utility.

import { deriveTools } from 'vovk'; import { PostRPC } from '@/client'; import { pick, omit } from 'lodash'; import UserController from '../user/user-controller'; 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 of UserController except deleteUser.

Authorizing API Calls

Third-party APIs may need authorization headers. Pass them with the withDefaults function, which all generated RPC/API modules have. With the GithubIssuesAPI module from OpenAPI mixins, you can create authorized tools for the GitHub Issues API:

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

Standalone Tools

The createTool utility was removed in v4. Derived tools follow the standard-tool convention. So you can create standalone tools, which don’t map to your back end, with the standard-tool  package, or write them as plain objects of the same shape. Mix them with derived tools as you like:

import { deriveTools } from 'vovk'; import { standardTool } from 'standard-tool'; import { z } from 'zod'; import UserController from '@/modules/user/user-controller'; const sumNumbers = standardTool({ name: 'sum_numbers', description: 'Returns the sum of two numbers provided as input.', inputSchema: z.object({ a: z.number(), b: z.number() }), outputSchema: z.number(), execute({ a, b }) { return a + b; }, }); const tools = deriveTools({ modules: { UserController } }); const allTools = [...tools, sumNumbers];

Vercel AI SDK Example

This example is a Vercel AI SDK chat that derives its tools from UserController. The Realtime Kanban app runs a fuller version of it: source on GitHub » 

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

npx vovk new controller aiSdk --empty

Paste this into the new src/modules/ai-sdk/ai-sdk-controller.ts, and fix the imports if needed:

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

The tool’s inputSchema goes to the Vercel AI SDK as is. The SDK supports Standard Schema and Standard JSON Schema, and uses the schema both to validate arguments and to convert to JSON Schema. A procedure without input has no inputSchema, and the SDK requires one, so the example passes an empty object schema instead. Other libraries may need the tools mapped another way.

On the client side, create a component with the useChat  hook:

src/components/chat.tsx
'use client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { useState } from 'react'; export default function Chat() { 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> ); }

useChat reads a random value when it renders. With cacheComponents on, which create-next-app 16.4+ sets, next build refuses a random value outside <Suspense>, so render the component inside <Suspense> from a server page:

src/app/page.tsx
import { Suspense } from 'react'; import Chat from '@/components/chat'; export default function Page() { return ( <Suspense> <Chat /> </Suspense> ); }

See Realtime Kanban / Text AI Chat for more.

Roadmap

  • ✨ Add a router option to deriveTools, so it can serve hundreds of functions within the LLM tool limits. Routing could use vector search or other methods.
Last updated on