Skip to main content
skill CX Codex

rpc — Claude Code skill for Vovk.ts clients

Learn how to generate and configure typed Vovk.ts RPC clients. Useful when calling an API from client code or tracking down stale generated methods.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add finom/vovk --skill rpc -a claude-code
Open on GitHub

What rpc does

This skill covers vovk generate, which builds TypeScript client modules from segment schemas. It explains the shared client and per-segment layout, import paths, RPC call options, request body types, and apiRoot. A generated module’s name follows the key assigned to its controller in initSegment.

The guide also covers createFetcher customization and React Query integration. One important detail: vovk generate reads existing files in .vovk-schema/; it does not refresh them from controller code. After a backend change, update the schemas with vovk dev or restart npm run dev, or generated client methods can lag behind.

What it helps with

  • generate a typed client from Vovk.ts schemas
  • choose between a shared client and per-segment modules
  • customize request handling with createFetcher
  • find out why a new API method is missing from the client
  • pass typed parameters, query values, and bodies to RPC calls

Example prompts for the agent

  • Show me how to generate a typed Vovk.ts RPC client and import it.
  • Explain the shared and per-segment client layouts and their import paths.
  • A controller changed, but the client lacks its new method. Explain how to refresh the schemas and regenerate the client.

How to install rpc

1Skills CLI (Vercel)

npx skills add finom/vovk --skill rpc -a claude-code

2Manually: put the files in place

~/.claude/skills/rpc/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/rpc/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about rpc

How do I install rpc in Claude Code?

Run npx skills add finom/vovk --skill rpc -a claude-code. The skill file is placed at ~/.claude/skills/rpc/SKILL.md.

What does the rpc skill do?

It explains how to generate and configure typed Vovk.ts RPC clients, make calls, and connect them to React Query.

Why can the client still be stale after vovk generate?

That command reads schemas from .vovk-schema/ but does not rebuild them from controllers. Refresh the schemas with vovk dev or restart npm run dev, then generate the client.

Source text

SKILL.md · MIT
---
name: rpc
description: Vovk.ts RPC client — how `vovk generate` turns controllers into type-safe client modules, composed vs segmented clients, call shape (`apiRoot`, `params`, `body`, `query`, `meta`, `init`, `disableClientValidation`, `validateOnClient`, `interpretAs`, `transform`, `fetcher`), customizing generation via `outputConfig.imports.fetcher` + `createFetcher` (auth headers, retries, tracing, dynamic `onSuccess` / `onError` subscribers), configuring clients in `vovk.config.mjs` (`composedClient` / `segmentedClient`), error rethrow (`HttpException`, `HttpStatus.NULL`), `VovkInput`/`VovkOutput` against RPC modules, React Query integration (`queryKey`, `streamedQuery`), RPC method surface (`.withDefaults`, `.getURL`, `.queryKey`, `.schema`, `.isRPC`). Use whenever user asks to call API from browser / mobile / other server ("fetch users from the client", "call the API from Next.js client component", "typed API client", "why is my RPC call failing", "add auth token to every request", "custom headers on RPC calls", "retry on 401", "why is the client stale"), regenerate client (`vovk generate`), choose between composed vs segmented clients, or wire up `@/client` imports. Does NOT cover writing procedures / handlers → hand off to `procedure` skill. Does NOT cover segment registration / `initSegment` → hand off to `segment` skill. Does NOT cover JSON Lines streaming clients → hand off to `jsonlines` skill.
---

# Vovk.ts RPC client

Every controller procedure with HTTP decorator automatically gets typed client counterpart. `vovk generate` produces these client modules from segment schemas, emitting TypeScript into the project:

- **Composed client** (default): one client for all segments at `composedClient.outDir` (default `./src/client`, or `./client` when the app isn't in `src/app`). Import from `'@/client'` (standard Next.js alias; alias-less projects use a relative path).
- **Segmented client**: per-segment subdirs of `segmentedClient.outDir` (same default dir). Import from `'@/client/<segment>'`.

**Key identity**: client module name = **key** used in `initSegment`'s `controllers` map, regardless of import path.

```ts
// server
initSegment({ controllers: { UserRPC: UserController } });

// client (composed)
import { UserRPC } from '@/client';
await UserRPC.getUser({ params: { id: '42' } });
```

Not `UserControllerRPC`. Not `UserController`. Whatever the key is.

## Generating client

```bash
npx vovk generate
```

What it does:

- Reads `.vovk-schema/**/*.json` for every segment (nested segments live in subdirectories, e.g. `.vovk-schema/customer/static.json`).
- Emits generated code into configured `outDir` using selected `fromTemplates` preset (default `['ts']`).
- Composed client (default): emits `index.ts`, `schema.ts`, `openapi.ts`, `openapi.json` at `outDir` root (default `./src/client`, or `./client` when the app isn't in `src/app`). Project's own tsc handles the files; import from `'@/client'`.
- Segmented client: same four files per segment under `<outDir>/<segment>/`; import from `'@/client/<segment>'`.
- Generated dir is gitignored by `vovk init` and rebuilt by the `prebuild` script (`vovk generate`); no need to commit it.

See "Client layout and import paths" below.

**`vovk generate` does NOT rebuild schemas from controller source.** Schema emission = separate step: `vovk dev` runs alongside Next.js dev server, hits each segment's `_schema_` endpoint (only available when `NODE_ENV === 'development'`), writes JSON into `.vovk-schema/`. `vovk generate` reads whatever's there.

Consequence: if backend changed and client looks stale, running `vovk generate` alone isn't enough — `.vovk-schema/` hasn't been refreshed. One shot: `PORT=<port> npx vovk dev --next-dev --exit` starts Next.js, writes `.vovk-schema/` and the client, stops, and exits with 1 on failure. While developing, `npm run dev` keeps both fresh.

When to run:

- **Automatic**: `prebuild` script installed by `vovk init` runs `vovk generate` before `next build`. During `npm run dev`, dev watcher refreshes `.vovk-schema/` on backend changes, client regenerates.
- **Explicit**: before integration tests, after checking out branch, if client method appears missing ("compiled fine, why is `UserRPC.createUser` undefined?"). If method just written is missing, schema probably stale — run `vovk dev --next-dev --exit` (or restart `npm run dev`) rather than re-running `vovk generate`.

## Call shape

Same signature across every RPC method. Core options first, advanced below:

```ts
await ModuleRPC.methodName({
  // data
  params?: { ... },                 // path params — typed
  query?: { ... },                  // query string — typed
  body?: { ... } | FormData | ...,  // narrows by contentType — see "Body by content type"
  meta?: { [key: string]: any },    // serialized as x-meta header — see meta-isolation gotcha

  // transport
  apiRoot?: string,                 // override the generation-time default
  init?: RequestInit,               // headers, credentials, signal, next.revalidate, etc. — see "init" below

  // validation
  disableClientValidation?: boolean,
  validateOnClient?: ...,           // per-call validator override

  // advanced
  interpretAs?: string,             // force response interpretation, e.g. 'application/jsonl' when server didn't set it
  transform?: (data, response) => any, // post-process the parsed response

  // plus any custom options declared via createFetcher<TOptions>({...}) — see Fetcher section
});
```

Return type = `Promise` of whatever procedure's `output` schema resolves to, or whatever `transform` returns if present. Override per call:

```ts
const user = await UserRPC.updateUser<SomeType>({ /* ... */ });
```

### Body by content type

`body`'s accepted type narrows to procedure's `contentType`: `FormData` for `multipart/form-data`, `URLSearchParams` or `FormData` for `application/x-www-form-urlencoded`, `File` / `Blob` / `ArrayBuffer` / `Uint8Array` for binary (`image/*`, `video/*`, `*/*`). Schema-typed objects always accepted. Full matrix → **`procedure`** skill.

```ts
const form = new FormData();
form.append('file', file);
await UserRPC.uploadAvatar({ body: form }); // multipart/form-data procedure
```

Never hand-set `Content-Type` — fetcher derives it (multipart boundary included). Manual headers = usual HTTP 415 trigger.

### `apiRoot`

`apiRoot` **baked in at generation time**: defaults to `/${rootEntry}` (`rootEntry` defaults to `'api'`, so default baked-in value = `/api`). When `outputConfig.origin` is set, bake produces full URL like `http://localhost:3000/api`. Per-call `apiRoot` fully replaces baked-in value for that call — config-level changes still require `vovk generate`.

In browser, relative `/api` resolves against page origin, so same call works from `/dashboard` and `/settings`. On server (Node, integration tests) relative URLs don't resolve — pass full URL per call, bake one in via `outputConfig.origin`, or use `withDefaults({ apiRoot })`.

### `init`

`RequestInit` forwarded to `fetch` — `headers`, `credentials`, `mode`, `cache`, Next.js-specific `next: { revalidate: number }` all pass through.

**`init.signal` aborts the call.** The fetcher ties it to its own per-call `AbortController` (without `AbortSignal.any`, as in React Native and Safari before 17.4, the signal aborts that controller). A pending JSON call rejects with the signal's reason as is (an `AbortError` when `abort()` gets no reason); a stream stops with an error whose `cause` is the reason. A streaming call's async iterable also exposes `.abortController` (and `abortSilently()`) to stop that one stream.

### `transform`

Receives parsed response data and original `Response`. Return anything — including tuple if you want `Response` exposed to caller:

```ts
const [user, response] = await UserRPC.updateUser({
  /* ... */
  transform: (data, response) => [data, response] as const,
});
response satisfies Response;
```

### `interpretAs`

Forces fetcher's content-type dispatch. Useful when server returns JSON Lines but omits `content-type: application/jsonl` (common behind some proxies or when streaming through Next.js): `interpretAs: 'application/jsonl'` makes client treat response as async iterable anyway.

## Composed vs segmented client

Two top-level config keys — `composedClient` and `segmentedClient` — = **independent toggles**; enable one, the other, or both. Defaults differ because they target different workflows:

| Key               | Default `enabled` | Default `fromTemplates` | Default `outDir`                           | Default import            |
|-------------------|-------------------|--------------------------|---------------------------------------------|---------------------------|
| `composedClient`  | `true`            | `['ts']`                 | `./src/client` (or `./client` without `src/app`) | `'@/client'`              |
| `segmentedClient` | `false`           | `['ts']`                 | same dir, per-segment subdirs               | `'@/client/<segment>'`    |

Both emit into the project source tree. When both are enabled they share `outDir`: composed files at the root, segments in subdirs.

```ts
// vovk.config.mjs
const config = {
  composedClient: {
    enabled: true, outDir: './src/client', fromTemplates: ['ts'],  // all defaults
    prettifyClient: true,                     // default; formats emitted files
    includeSegments: ['public'],              // mutually exclusive with excludeSegments
  },
  segmentedClient: { enabled: true, outDir: './src/client', fromTemplates: ['ts'] },
};
```

`prettifyClient: true` uses the project-installed prettier; if it's not installed, CLI warns once per run and writes unformatted output.

Both keys can carry nested `outputConfig` overriding top-level for that specific output (e.g. segmented SDKs with different OpenAPI block).

**Composed** — one client module re-exports every RPC across every segment. Default case for most projects: `import { UserRPC, PostRPC } from '@/client'`.

**Segmented** — per-segment entries; importing `@/client/admin` keeps other segments out of bundle. Useful when segment boundaries map to independent deploys, versioning, or SDK consumers.

```
src/client/
├── index.ts, schema.ts, openapi.ts, openapi.json    (composed, at the root)
├── root/     (same four files; root segment folder is named "root")
├── admin/    ...
└── customer/
    └── static/    (nested sub-segment)
```

```ts
import { UserRPC } from '@/client/customer';
import { AdminRPC } from '@/client/admin';
```

`@/client` is the standard Next.js `@/*` alias. Custom `outDir` shifts the path; projects without the alias use a relative import. Step-by-step setup → **`init` skill**.

## Fetcher

Fetcher = client's core primitive — function (`VovkFetcher<TFetcherOptions>`) that takes request metadata (endpoint, method, schema, validator) and dispatches actual `fetch`. Default (`import { fetcher } from 'vovk/fetcher'`) already handles JSON and JSON Lines, runs client-side validation, injects `x-meta` header, rethrows `HttpException` on errors.

Response-shape dispatch by content type:

- `application/json` → parsed JSON (typed as procedure's output schema).
- `application/jsonl` / `application/jsonlines` / `application/x-ndjson` → disposable async iterable (see `jsonlines` skill).
- Other content types → raw `Response` object — caller reads text, binary, or streams it.

You **extend** default rather than rewriting via `createFetcher`:

```ts
// src/lib/fetcher.ts
import { createFetcher } from 'vovk/fetcher';

export const fetcher = createFetcher<{
  // Custom per-call options — become typed fields on every RPC method call site.
  useAuth?: boolean;
  successMessage?: string;
}>({
  prepareRequestInit: async (init, { useAuth }) => ({
    ...init,
    headers: {
      ...init.headers,
      ...(useAuth ? { Authorization: `Bearer ${await getToken()}` } : {}),
    },
  }),
  transformResponse: (data, options, info) => data,     // optional
  onSuccess: (data, { successMessage }) => {
    if (successMessage) console.log(successMessage);
  },
  onError: (error, options) => { /* Sentry, retry hooks */ },
});
```

With that fetcher installed, callers get custom options inline:

```ts
await UserRPC.updateUser({
  params: { id: '42' },
  body: { /* ... */ },
  useAuth: true,
  successMessage: 'Updated!',
});
```

### Hooks

All optional; `options` = typed `TOptions` you declared.

- `prepareRequestInit(init, options) => RequestInit | Promise<RequestInit>` — mutate `RequestInit` before `fetch` (headers, credentials, mode, cache, `next.revalidate`). Async OK — fetcher awaits return.
- `transformResponse(data, options, info)` — transform parsed response; `info = { response, init, schema }` gives access to raw `Response`, final `RequestInit`, procedure's schema (same object as `UserRPC.updateUser.schema`; useful for reading `operationObject`).
- `onSuccess(data, options)` — observe successful responses (toasts, analytics).
- `onError(error: HttpException, options)` — observe failures. Both network errors and HTTP-status errors land here; `error.statusCode === 0` (`HttpStatus.NULL`) = either transport failure or client-side validation rejection.

### Event-style subscribers (dynamic)

`onSuccess` and `onError` can also be attached **after** fetcher is created. Useful when callback depends on state not available at creation time (React context value, Zustand store reference, lazily loaded logger). Each registration returns unsubscribe function; multiple subscribers run in order on every call:

```ts
// Somewhere in an app entry point (or a React effect, etc.)
import { fetcher } from '@/lib/fetcher';

const unsubSuccess = fetcher.onSuccess((data, { successMessage }) => {
  if (successMessage) toast.success(successMessage);
});

const unsubError = fetcher.onError((error) => {
  sentryReport(error);
});

// Later
unsubSuccess();
unsubError();
```

Prefer this pattern over baking toasts / store writes into `createFetcher` when dependency graph would otherwise force you to import UI-layer modules from transport layer.

### Point config at fetcher

```ts
// vovk.config.mjs
/** @type {import('vovk').VovkConfig} */
const config = {
  outputConfig: {
    imports: {
      fetcher: './src/lib/fetcher',  // must export `fetcher` (named export)
    },
  },
};
export default config;
```

Generated client imports `fetcher` from that path. After editing config manually, run `vovk generate`.

**Per-segment fetcher**: different segments can use different fetchers (e.g., admin segment needs different auth than public one):

```ts
outputConfig: {
  imports: { fetcher: './src/lib/fetcher' },    // applied to all segments by default
  segments: {
    admin: {
      imports: { fetcher: './src/lib/adminFetcher' },  // overrides for admin
    },
  },
}
```

**Common patterns:**

- **Auth token**: inject `Authorization` header in `prepareRequestInit`, gated on `useAuth` custom option.
- **Retry on 401**: in `onError`, trigger token refresh; wrap call sites in retry helper.
- **Telemetry**: start timer in `prepareRequestInit`, report in `onSuccess` / `onError`.
- **Success toasts**: `successMessage` custom option + dynamic `fetcher.onSuccess(...)` subscriber that reads your toast library.

Keep fetcher focused on transport-layer concerns. Stateful things (request dedup, mutation queuing) belong in layer above.

## Client-side validation

Install `vovk-ajv`, set `outputConfig.imports.validateOnClient: 'vovk-ajv'`. Generated client validates `params` / `body` / `query` against procedure's schema **before** HTTP call — invalid inputs throw `HttpException(HttpStatus.NULL, ...)` locally (i.e. `statusCode === 0`, same as transport failures) instead of round-tripping.

Roll your own validator via `createValidateOnClient` from `vovk/create-validate-on-client` if Ajv isn't fit; point `imports.validateOnClient` at its module path:

```ts
// src/lib/validateOnClient.ts
import { createValidateOnClient, HttpException, HttpStatus } from 'vovk/create-validate-on-client';

export const validateOnClient = createValidateOnClient({
  validate: async (input, schema, meta) => {
    // run your validator; on failure:
    // throw new HttpException(HttpStatus.NULL, 'Validation failed', { /* optional cause */ });
    return input;
  },
});
```

Skip per call:

```ts
await UserRPC.updateUser({
  params: { id: 'malformed' },
  disableClientValidation: true,
});
```

`disableClientValidation` also useful when debugging — bypass local pass to surface server-side validation error verbatim. Override with different validator per call via `validateOnClient`. Neither should be habit in production code paths.

## Error handling

`HttpException` thrown on server rethrown on client with same `statusCode`, `message`, `cause`:

```ts
import { HttpException } from 'vovk';

try {
  await UserRPC.getUser({ params: { id: '42' } });
} catch (e) {
  if (e instanceof HttpException) {
    console.log(e.statusCode); // 404
    console.log(e.message);    // 'User not found'
    console.log(e.cause);      // any extra context passed server-side
  }
}
```

Bare `Error` on server → `HttpException` with status 500 on client. Network failures (no server, DNS error) and client-side validation failures both throw `HttpException` with status 0 (`HttpStatus.NULL`) — same `instanceof HttpException` check covers transport, validation, HTTP-status failures uniformly. Disambiguate by `e.statusCode`.

## Types against RPC modules

`VovkBody`, `VovkQuery`, `VovkParams`, `VovkInput`, `VovkOutput`, `VovkIteration`, `VovkYieldType`, `VovkReturnType` (from `vovk`) work on RPC methods as on controller methods: `type Out = VovkOutput<typeof UserRPC.updateUser>`. Details → **`procedure`** skill, Type inference.

## RPC method surface

Every generated RPC method = more than just callable. Useful properties:

- `.withDefaults(options)` — returns new RPC module with given options **deeply merged** into every call. Handy for per-environment `apiRoot`, persistent `init.headers`, scoped fetcher.
- `.getURL({ params, query, apiRoot })` — compute URL call would hit, without making request. Useful for `<a href>`, `<form action>`, or calling `fetch` directly.
- `.queryKey(key?)` — returns globally unique React Query / TanStack Query cache key. Shape: `[segmentName, controllerPrefix, rpcModuleName, decoratorPath, httpMethod, ...key]`. Optional `key` = array of **extra scalars** to disambiguate similar queries (typically same values passed in `params` / `query`).
- `.apiRoot` — baked-in `apiRoot` string.
- `.schema`, `.controllerSchema`, `.segmentSchema`, `.fullSchema` (`VovkSchema`) — raw schema objects; name a type as `typeof UserRPC.getUser.segmentSchema`. `schema.validation.body` etc. give per-input JSON Schema; `schema.operationObject` gives OpenAPI operation fragment; `fullSchema.meta.config` exposes config subset emitted into client (by default only `libs` and `rootEntry`, e.g. `fullSchema.meta.config.libs.ajv`).
- `.isRPC: true` — type guard (distinguishes RPC modules from raw controllers in tools like `deriveTools`).

```ts
const API = UserRPC.withDefaults({
  apiRoot: 'https://api.example.com/v1',
  init: { headers: { 'x-hello': 'world' } },
});
await API.getUser({ params: { id: '42' } });

const url = UserRPC.getUser.getURL({ params: { id: '42' } });
// => computed URL with params/query baked in
```

### React Query / TanStack Query

```ts
import { useQuery } from '@tanstack/react-query';

// Extra key values disambiguate per-id queries:
useQuery({
  queryKey: UserRPC.getUser.queryKey(['42']),
  queryFn: () => UserRPC.getUser({ params: { id: '42' } }),
});

// Invalidate every query for this RPC module:
queryClient.invalidateQueries({
  queryKey: UserRPC.getUser.queryKey().slice(0, 3),
});
```

JSON Lines streams pair with TanStack's `experimental_streamedQuery` → see `jsonlines` skill.

Mutations reference method directly:

```ts
import { useMutation } from '@tanstack/react-query';

useMutation({ mutationFn: UserRPC.updateUser });
```

## `openapi` and `schema` payloads from client

Composed:

```ts
import { openapi } from '@/client/openapi';   // OpenAPI 3.x, derived from procedures + @operation metadata
import { schema }  from '@/client/schema';    // Raw VovkSchema — all segments
import { schema }  from '@/client';           // Same thing, re-exported from the root
```

Segmented (one OpenAPI + schema per segment, inside each segment's subdir):

```ts
import { openapi } from '@/client/admin/openapi';
import { schema }  from '@/client/admin/schema';
import { schema }  from '@/client/admin';          // Same thing, re-exported from the segment root
```

Import path follows the client's `outDir` — if you changed `segmentedClient.outDir` to something else (e.g. `./sdk`), path = `@/sdk/admin/openapi` etc. Per-segment files exist only when segmented client is enabled.

See `openapi` skill for how spec is built.

## Flows

**"Fetch users on page load (client component)"** — ensure procedure has HTTP decorator, then:
```tsx
'use client';
import { UserRPC } from '@/client';
useEffect(() => { UserRPC.list().then(setUsers); }, []);
```

**"Add auth header to every RPC call"** — `createFetcher({ prepareRequestInit })`, point `outputConfig.imports.fetcher` at it. Don't hand-roll per-call.

**"My new procedure isn't on client"** — `PORT=<port> npx vovk dev --next-dev --exit` (or restart `npm run dev`) to re-emit `.vovk-schema/`. Still missing → check controller is registered in `initSegment({ controllers })` and method has HTTP decorator.

**"Call API from another Node process"** — pass full URL: `UserRPC.list({ apiRoot: 'https://api.example.com/api' })`, or bake via `UserRPC.withDefaults({ apiRoot })`.

**"Consume JSON Lines stream"** — `using stream = await StreamRPC.xxx(...); for await (const chunk of stream) { /* ... */ }`. Full coverage → **`jsonlines` skill**.

## Gotchas

- **Import name mismatch**: `UserRPC` (the `controllers` key), not `UserControllerRPC`. Most common "I can't import my RPC module" cause.
- **Stale client**: `vovk generate` regenerates from `.vovk-schema/`, only refreshed by running dev server. If user edited controller and ran `vovk generate` directly, output stale. Fix: `vovk dev --next-dev --exit` (above).
- **No HTTP decorator = no RPC**. Procedure without `@get`/`@post`/etc. is call-via-`.fn()` only; won't appear on client.
- **Baked-in `apiRoot` defaults to `/api`** (`rootEntry: 'api'` with no `origin`). Works in browser (resolves against page origin), fails outside (Node, integration tests). Pass full URL per call, bake one in via `outputConfig.origin`, or use `withDefaults({ apiRoot })`.
- **Fetcher path changes need regeneration**. Editing fetcher file fine; changing its path in `outputConfig.imports.fetcher` requires `vovk generate` (or dev-watcher restart).
- **`disableClientValidation` only skips *client* validation pass**. Server-side validation still runs (unless procedure disables it, which should be rare).
- **Client `meta` ≠ server `req.vovk.meta()`**. `meta` option serialized as `x-meta` header and lands on server under `xMetaHeader` key — isolated from server-set trusted state (what `authGuard` writes via `req.vovk.meta<AuthMeta>({ user })` is untouched by client). Treat client `meta` as advisory only; never use for auth or authorization decisions.

© finom · MIT · finom/vovk

Similar skills

All skills for Backend and APIs →

From the same set15

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor