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.
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.nameoverrides it (see below). As model APIs require, each character other thanA-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 makederiveToolsthrow.title?: string- an optional title, used mainly by MCP. It comes fromx-tool.titleor the OpenAPIsummary, if there is one.description: string- the tool description: thesummaryanddescriptionof the OpenAPI operation, joined.x-tool.descriptionoverrides it (see below).inputSchema?: StandardSchemaV1 & StandardJSONSchemaV1- one Standard Schema that merges the procedure’sbody,queryandparams, when there are any.outputSchema?: StandardSchemaV1 & StandardJSONSchemaV1- the procedure’soutputschema, when there is one.meta?: Record<string, unknown>- static data about the tool, copied as is fromx-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 takesunknown, as the model wrote it; the procedure validates it. For RPC modules, it sends an HTTP request. For controllers, it calls thefnmethod, which runs in the current context without HTTP. A JSON Lines result, from a generator or aJSONLinesResponder, reaches the model as the array of its items.
inputSchemavalidates 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’sexecutefunction succeeds. For a controller,reqis the call’s request object, withreq.vovk; for an RPC method, it’snull. -
onError?: (error: Error, tool: StandardToolV0, req: Pick<VovkRequest, 'vovk'> | null) => void- an optional callback that runs when a tool’sexecutefunction throws, or when a controller returns aResponsewith an error status. For aResponse, the error is anHttpExceptionwith its status and the message from the body: themessage,detailortitleof a JSON body, else its text.ToModelOutput.DEFAULTgives the model{ error: message }in both cases.reqis as inonExecute, ornullfor 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 theToModelOutputobject, exported fromvovk:ToModelOutput.MCPfor MCP formatting.ToModelOutput.DEFAULT, the default whentoModelOutputisn’t set.ToModelOutput.RAW: the result as the procedure gives it, and a failed call throws its error afteronError. For callers that need the throw, such as uicast.
A formatter that returns an
Errorfor 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 thexMetaHeaderkey. 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.
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.
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-trueleaves 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’smetafield. It’s not themetaoption ofderiveTools, 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 --emptyPaste this into the new src/modules/ai-sdk/ai-sdk-controller.ts, and fix the imports if needed:
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:
'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:
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
routeroption toderiveTools, so it can serve hundreds of functions within the LLM tool limits. Routing could use vector search or other methods.