Vovk.ts LLM tools

SkillAI & models

Building LLM tools with Vovk.ts — `deriveTools()` (procedures → tools), `createTool()` (standalone tools, no controller/procedure needed), `@operation.tool({ name, title, description, hidden })` decorator, `x-tool` metadata, `ToModelOutput.DEFAULT` vs `ToModelOutput.MCP` formatters, the `tools` + `toolsByName` return shape, the `meta` option for passing context to procedures, `withDefaults` for baking auth into third-party API tools, and the `pick` / `omit` pattern for selecting specific procedures. Use whenever the user asks to expose an API to an LLM, wire up tool calling, build an MCP server, "let Claude / GPT call this", "turn my controllers into tools", "function calling with Vercel AI SDK", "createTool", "standalone tool", "hide a procedure from the LLM", "tool that wraps a third-party SDK", or any variation. Does NOT cover procedure authoring → `procedure` skill. Does NOT cover the OpenAPI spec beyond `@operation`/`x-tool` → `openapi` skill. Does NOT cover third-party OpenAPI mixin setup → `mixins` skill.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Vovk.ts LLM tools skill

What this skill tells your AI

The instructions your AI receives, as published by finom/vovk in skills/tools/SKILL.md and read by ahel’s review.

Vovk produces LLM-consumable tool definitions — { name, description, parameters, execute, … } — via two entry points:

  • deriveTools({ modules }) — turn existing procedures (controllers, RPC modules, OpenAPI mixins) into tools auto.
  • createTool({ name, description, inputSchema?, outputSchema?, execute, … }) — hand-build standalone tool, no procedure backing. Use when body is plain code (SDK wrappers, calculators, file ops).

Both shapes interoperable — mix in one array, feed to OpenAI / Anthropic / Vercel AI SDK / MCP servers.

Out of scope: procedure authoring (procedure), @operation for OpenAPI docs (openapi), third-party OpenAPI mixin setup (mixins). MCP server transport / hosting outside Vovk; for Next.js, recommended runtime is mcp-handler npm package — example below.

Import path note. Code samples import from 'vovk-client'composed client + js template default, re-exported from node_modules/.vovk-client. With ts template, import from composedClient.outDir (e.g. @/client). With segmented client, import from @/client/<segment>. Call shape identical. See rpc skill.

deriveTools — core shape

import { deriveTools } from 'vovk';
import { TaskRPC, PetstoreAPI } from 'vovk-client';
import UserController from '@/modules/user/user-controller';

const { tools, toolsByName } = deriveTools({
  modules: { UserController, TaskRPC, PetstoreAPI },
});

modules accepts record of:

  • Controllers — execute via .fn() in-process, no HTTP.
  • RPC modules (from vovk-client or @/client[/segment] per layout) — execute via HTTP using standard fetcher.
  • OpenAPI mixins (same client surface) — execute via HTTP against third-party API. See mixins skill.

Each module yields one tool per procedure with @operation schema, not hidden.

Return shape

const { tools, toolsByName } = deriveTools({ modules: { TaskRPC } });

// Array — feed to LLM SDKs
llm.chat({ tools: tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters })) });

// Map — dispatch tool calls without scanning
const result = await toolsByName[call.name].execute(call.arguments);

Each tool's fields

type VovkTool = {
  type: 'function';                     // always
  name: string;                         // default: `${moduleName}_${handlerName}` — e.g. UserController_getUser
  title?: string;                       // from x-tool.title or operation.summary
  description: string;                  // x-tool.description, OR `${summary}\n${description}`, OR handlerName fallback
  parameters: JSONSchema;               // see below
  execute: (input) => Promise<unknown>; // calls the procedure (HTTP for RPC/mixins, .fn() for controllers)
  inputSchemas?: { body?, query?, params? };  // per-key Standard Schemas (Zod/Valibot/ArkType)
  outputSchema?: StandardSchema;        // procedure's output schema if declared
};

parameters nested, not flat. Each procedure schema sits under own key:

parameters: {
  type: 'object',
  properties: { body?: <body schema>, query?: <query schema>, params?: <params schema> },
  required: ['body', 'query', ...], // keys that exist in properties
  additionalProperties: false,
}

So execute called as tool.execute({ body, query, params }), not tool.execute({ ...flat }).

Customizing tool name + description

Default name = ${ModuleName}_${handlerName}, default description = ${summary}\n${description} from @operation. Override via @operation.tool({ … }) (recommended cleaner form):

import { prefix, get, operation, procedure } from 'vovk';

@prefix('user')
export default class UserController {
  @operation.tool({
    name: 'get_user_by_id',                // overrides UserController_getUser
    title: 'Get user by ID',               // shown in MCP clients
    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({ /* ... */ });
}

Equivalent long form: @operation({ ..., 'x-tool': { name, title, description, hidden } }). Keys (all optional):

KeyEffect
hidden: trueProcedure excluded from deriveTools output.
nameOverrides default ModuleName_handlerName.
titleOptional title — used by MCP clients in tool-list UI.
descriptionOverrides summary\ndescription concatenation.

Hide procedures

Canonical: hidden: true.

@operation.tool({ hidden: true })
@operation({ summary: 'Internal helper' })
@get()
static internalDebug = procedure({ /* ... */ });

For coarser selection — when same controller serves both REST and tool-exposed callers, don't want to annotate every method — use pick/omit from lodash:

import { pick, omit } from 'lodash';

const { tools } = deriveTools({
  modules: {
    PostRPC: pick(PostRPC, ['createPost', 'getPost']),
    UserController: omit(UserController, ['deleteUser']),
  },
});

meta — pass context into procedures

deriveTools({ meta }) flows into every tool execution. Controllers read via req.vovk.meta(); RPC modules send as xMetaHeader request header.

const { tools } = deriveTools({
  modules: { UserController },
  meta: { tenantId: 'acme', actorRole: 'admin' },
});

@get()
static getUser = procedure({ /* ... */ }).handle(async (req) => {
  const { tenantId, actorRole } = req.vovk.meta<{ tenantId: string; actorRole: string }>();
  // ...
});

Use for ambient context (tenant, actor, request ID) that applies to every LLM-triggered call without surfacing to LLM as tool argument.

Authorize third-party API tools

Mixin modules carry withDefaults({ init?, apiRoot? }) method returning pre-configured copy. Wrap before passing to deriveTools to bake auth headers without leaking secrets to LLM:

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',
        },
      },
    }),
  },
});

LLM sees AuthorizedGithubIssuesAPI_listForOrg etc. with operation parameters — never token. See mixins for withDefaults setup details.

createTool — standalone tools

For tools with no procedure backing — SDK wrappers, calculators, file ops, plain code:

import { createTool } from 'vovk';
import { z } from 'zod';

const getWeather = createTool({
  name: 'get_weather',
  description: 'Returns current weather for a city.',
  inputSchema: z.object({ city: z.string() }),
  outputSchema: z.object({ tempC: z.number(), conditions: z.string() }),
  execute: async ({ city }) => {
    const data = await weatherSdk.current(city);
    return { tempC: data.t, conditions: data.desc };
  },
});

Options

FieldPurpose
name (required)Tool name.
description (required)Shown to LLM. Action-oriented language works best.
titleOptional human-readable label, used by MCP clients.
inputSchemaStandard Schema (Zod / Valibot / ArkType). Validated before execute. Becomes JSON Schema. Omit for no-arg tools.
outputSchemaValidates execute's return value. Optional, recommended.
execute(input, processingMeta?)Tool body. input post-validation.
toModelOutputOutput formatter. Default ToModelOutput.DEFAULT; use ToModelOutput.MCP for MCP servers.
onExecute(result, tool) / onError(err, tool)Success / failure callbacks.
targetValidation target override (rarely needed).

No-input tools. Omit inputSchema. execute receives null (not undefined — don't destructure):

createTool({
  name: 'current_time',
  description: 'Returns current server time as ISO 8601.',
  execute: () => ({ now: new Date().toISOString() }),
});

Error handling. Errors from execute, input validation, or output validation caught (never re-thrown), monkey-patched with toJSON() = { isError, message }, then handed to toModelOutput:

  • ToModelOutput.DEFAULT{ error: string }
  • ToModelOutput.MCP{ content: [{ type: 'text', text: message }], isError: true }

onError fires on error path. Tool never crashes chat loop — LLM can retry.

Combine derived + standalone. Same shape, same chat loop:

const { tools: derived, toolsByName: derivedByName } = deriveTools({ modules: { TaskRPC } });
const allTools = [...derived, getWeather, sendEmail];
const allByName = { ...derivedByName, [getWeather.name]: getWeather, [sendEmail.name]: sendEmail };

ToModelOutput — format the result

toModelOutput controls what execute returns. Set on deriveTools and/or createTool; pick consistently across tools in one chat loop.

FormatterOutput shapeUse for
ToModelOutput.DEFAULTRaw result, or { error: string } on failure.OpenAI / Anthropic function calling, Vercel AI SDK.
ToModelOutput.MCP{ content: [{ type: 'text' | 'image' | 'audio', … }], structuredContent?, isError? }. Detects Response objects with binary content types and emits correct MCP block.MCP servers.

Custom formatters: write function matching ToModelOutputFn<TInput, TOutput, TFormattedOutput>.

Return binary / file content via Response (MCP)

When ToModelOutput.MCP active, returning Response from procedure auto-converts based on Content-Type header:

Content-TypeMCP block
image/*{ type: 'image', mimeType, data: <base64> }
audio/*{ type: 'audio', mimeType, data: <base64> }
application/json{ type: 'text', text: <stringified> } + structuredContent from parsed JSON
text/*, application/xml, application/javascript, application/yaml{ type: 'text', text }
anything elseisError: true with "Unsupported response content type" message
import { procedure, get, operation, prefix, toDownloadResponse } from 'vovk';

@prefix('files')
export default class FilesController {
  @operation.tool({ name: 'render_chart' })
  @get('chart')
  static renderChart = procedure({}).handle(async () => {
    const png = await renderChartPng(/* ... */);
    return toDownloadResponse(png, { type: 'image/png', filename: 'chart.png' });
  });

  // fetch() works too — same MCP image block, no manual base64 wiring.
  @operation.tool({ name: 'avatar' })
  @get('avatar')
  static avatar = procedure().handle(() => fetch('https://example.com/avatar.png'));
}

JSON Responses yield both content[0].text (stringified) and structuredContent (parsed). Plain object returns also produce structuredContent from object directly — return whichever more natural.

Per-call MCP overrides — divergent HTTP and MCP responses

MCP formatter shallow-merges anything set under vovk.meta({ mcpOutput }) over auto-generated output (mcpOutputMeta spreads last → its keys win). Whatever procedure returns still becomes HTTP response unchanged — override only affects MCP-formatted payload. One procedure can serve HTTP callers and MCP/LLM callers with completely different shapes, sharing only operation logic.

@operation.tool({ name: 'list_tasks' })
@get()
static listTasks = procedure({
  query: z.object({ status: z.enum(['open', 'done']).optional() }),
  output: z.array(z.object({ id: z.string(), title: z.string(), status: z.string() })),
}).handle(async ({ vovk }) => {
  const tasks = await TaskService.list();

  // MCP caller gets a markdown digest + annotations; HTTP caller gets the typed array.
  vovk.meta({
    mcpOutput: {
      content: [{
        type: 'text',
        text: `You have ${tasks.length} tasks. Open: ${tasks.filter(t => t.status === 'open').length}.\n\n` +
              tasks.map(t => `- **${t.title}** (${t.status})`).join('\n'),
      }],
      structuredContent: { count: tasks.length, tasks }, // optional machine-readable companion
      annotations: { audience: ['user'], priority: 5, lastModified: new Date().toISOString() },
    },
  });

  return tasks; // HTTP response
});

Useful patterns built on this seam:

  • JSON for HTTP, prose for LLM — REST/UI clients get structured data; model gets digest it can reason about without drowning in JSON.
  • Render server-side, ship image — return JSON to HTTP, override content with MCP image block → LLM sees chart instead of numbers.
  • Soft-fail surfacing — return normal payload (200 OK), set isError: true in mcpOutput → MCP client treats as error.
  • Audience routingannotations.audience: ['user'] exposes result in MCP client UI; ['assistant'] keeps model-only.

Override keys (all optional, all merged shallowly):

KeyEffect
content: [...]Wholesale-replaces auto-generated content array.
structuredContentWholesale-replaces parsed structured payload.
isError: trueMarks result as error to MCP client.
annotations.audience('user' | 'assistant')[] — who should see result.
annotations.priorityNumber, relative importance hint.
annotations.lastModifiedISO timestamp.

Wire into LLM SDKs

Vercel AI SDK (canonical pattern)

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/user-controller';

@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();
  }
}

Key adapter is jsonSchema(parameters) — Vercel's tool() wants own typed schema, our parameters already JSON Schema → wrap.

OpenAI / Anthropic function calling

parameters already JSON Schema → pass directly:

const { tools, toolsByName } = deriveTools({ modules: { TaskController } });

const resp = await client.chat.completions.create({
  model: 'gpt-5',
  messages,
  tools: tools.map(t => ({
    type: 'function',
    function: { name: t.name, description: t.description, parameters: t.parameters },
  })),
});

for (const call of resp.choices[0].message.tool_calls ?? []) {
  const tool = toolsByName[call.function.name];
  if (!tool) throw new Error(`Unknown tool: ${call.function.name}`);
  const result = await tool.execute(JSON.parse(call.function.arguments));
  // append result as a tool_result message and continue the loop
}

For Anthropic: input_schema = t.parameters, otherwise identical.

MCP servers via mcp-handler (recommended for Next.js)

Pattern works for procedure-backed controllers (each handler produced by procedure({...}).handle(...) carries .definition field). Does not work for mixin-backed RPC modules — mixins go through createRPC and don't carry definitioninputSchemas come back empty {} → LLM has no input shape. For mixins, hand-write createTool({...}) wrappers (snippet in "Mixins → MCP" below).

Each Vovk tool's inputSchemas field (per-key Standard Schemas for body / query / params) plugs directly into server.registerTool's inputSchema arg:

import { createMcpHandler } from 'mcp-handler';
import { deriveTools, ToModelOutput } from 'vovk';
import type z from 'zod';
import TaskController from '@/modules/task/task-controller';
import UserController from '@/modules/user/user-controller';

const { tools } = deriveTools({
  modules: { UserController, TaskController },
  toModelOutput: ToModelOutput.MCP,
  onExecute: (result, { name }) => console.log(`${name} executed`, result),
  onError: (e, { name }) => console.error(`Error in ${name}`, e),
});

const handler = createMcpHandler(
  (server) => {
    tools.forEach(({ title, name, execute, description, inputSchemas }) => {
      server.registerTool(
        name,
        {
          title,
          description,
          inputSchema: inputSchemas as Partial<Record<'body' | 'query' | 'params', z.ZodTypeAny>>,
        },
        execute,
      );
    });
  },
  {},
  { basePath: '/api' }, // server lives at /api/mcp
);

export { handler as GET, handler as POST };

Demo-grade gating with query-string key. MCP clients like Claude Desktop connect to remote MCP server by URL but don't run full OAuth flow out of box. For getting a Vovk-backed MCP server in front of real client quickly — local testing, throwaway demos, sharing personal endpoint with one collaborator — wrap with shared-secret check:

const authorizedHandler = (req: Request) => {
  const { MCP_ACCESS_KEY } = process.env;
  const accessKey = new URL(req.url).searchParams.get('mcp_access_key');
  if (MCP_ACCESS_KEY && accessKey !== MCP_ACCESS_KEY) {
    return new Response('Invalid mcp_access_key', { status: 401 });
  }
  return handler(req);
};
export { authorizedHandler as GET, authorizedHandler as POST };

Not production auth. Query-string secret leaks into browser history, server logs, proxy logs, bookmarks; identical for every caller. For anything user-facing or multi-tenant, use real OAuth / session cookies / header-based middleware in front of MCP route. Pattern above exists because shortest path to "Claude can talk to my server right now."

Test locally with official MCP Inspector: npx @modelcontextprotocol/inspector. To mix derived + standalone MCP tools, set toModelOutput: ToModelOutput.MCP on createTool too and register inside same createMcpHandler callback. For non-Next.js MCP runtimes, same tools + toolsByName pair drops into any SDK that accepts ListTools / CallTool handlers.

Mixins → MCP — wrap with createTool

Wrap required only for MCP, not general LLM tool exposure. deriveTools reads each handler's .definition field for inputSchemas (Standard Schemas), which only exists on procedure-backed controllers (set by procedure({...}).handle(...) in with-validation-library.ts). Mixin RPC modules come from createRPC and don't carry that — passing directly to deriveTools yields tools with empty inputSchemas, unusable for mcp-handler. Function-calling paths (OpenAI / Anthropic / Vercel) work fine — they read parameters (JSON Schema), populated from mixin's schema.validation. For MCP, wrap each mixin call in createTool instead:

import { createTool, ToModelOutput } from 'vovk';
import { z } from 'zod';
import { PetstoreAPI } from 'vovk-client';

const PetstoreAPIWithAuth = PetstoreAPI.withDefaults({
  init: { headers: { Authorization: `Bearer ${process.env.PETSTORE_TOKEN}` } },
});

export const findPetsByStatusTool = createTool({
  name: 'petstore_find_pets_by_status',
  title: 'Find Petstore pets by status',
  description: 'Look up pets by lifecycle status: available, pending, or sold.',
  inputSchema: z.object({
    status: z.enum(['available', 'pending', 'sold']),
  }),
  toModelOutput: ToModelOutput.MCP,
  execute: async ({ status }) => {
    return await PetstoreAPIWithAuth.findPetsByStatus({ query: { status } });
  },
});

You write input schema by hand (Zod / Valibot / ArkType) — cost of going through createTool instead of deriveTools. Mixin still gives typed call shape and OpenAPI-derived response types via VovkOutput<typeof PetstoreAPI.findPetsByStatus>, so input is only manual piece.

Gotchas

  • No @operation → no tool. deriveTools filters on handler?.schema?.operationObject && !handler?.schema?.operationObject?.['x-tool']?.hidden. Procedures without @operation skipped silently. Always annotate.
  • Default tool name ModuleName_handlerName. Verbose but unique. Use @operation.tool({ name: 'get_user_by_id' }) for cleaner LLM-facing name.
  • parameters nested, not flat. tool.execute({ body, query, params }), not tool.execute({ ...flat }). LLM sees nested JSON Schema, constructs arguments accordingly.
  • createTool errors don't throw; land as { error } (DEFAULT) or { content, isError: true } (MCP). By design — LLM can recover. Use onError for side-channel logs.
  • createTool with no inputSchema calls execute(null), not execute(undefined). Don't destructure.
  • Output must be JSON-serializable — except ToModelOutput.MCP understands Response with binary Content-Type (image/audio) and emits correct MCP block.
  • Schema quality drives tool quality. Use .describe() on every Zod field. Tight enums beat free-form strings. Applies equally to procedure() schemas and createTool.inputSchema.
  • Tool name collisions clobber toolsByName. Two tools with same name produce map with only last one. Override with @operation.tool({ name }) if module-prefixed defaults collide.
  • Local (.fn()) tools skip HTTP. Auth decorators reading HTTP headers won't fire — use shared service layer for auth logic running both via HTTP and via local tool calls.
  • Mixing toModelOutput settings rarely useful. All tools in one LLM turn should use same formatter, else call site must branch on tool identity.
  • hidden: true is canonical exclusion knob — filtered at derive time. pick/omit for coarser per-call selection, not substitute.
  • Mixins + deriveTools + mcp-handler: inputSchemas come back empty for mixin-backed modulesmcp-handler needs Standard Schemas, mixins only carry JSON Schema in parameters. For MCP servers exposing mixin endpoints, wrap calls in createTool (see "Mixins → MCP — wrap with createTool" above). Function-calling paths (OpenAI / Anthropic / Vercel) work fine — they read parameters, not inputSchemas.

Signals

GitHub stars
52
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
tools-finom
Source
github.com/finom/vovk