Skip to content

Client Generation

Ontogen generates TypeScript client code that mirrors your server API surface. Three client generators cover different deployment scenarios: a pure HTTP client, a split transport that switches between HTTP and Tauri IPC at runtime, and an admin registry that provides per-entity metadata for admin UIs.

Client generators are configured through the client_generators field on ServersConfig:

use ontogen::servers::ClientGenerator;
client_generators: vec![
ClientGenerator::HttpTs {
output: "../src-nuxt/app/types/httpCommands.ts".into(),
bindings_path: "../src-nuxt/app/types/bindings.ts".into(),
},
ClientGenerator::HttpTauriIpcSplit {
output: "../src-nuxt/app/transport/generated.ts".into(),
bindings_path: "../src-nuxt/app/types/bindings.ts".into(),
},
ClientGenerator::AdminRegistry {
output: "../src-nuxt/layers/admin/generated/admin-registry.ts".into(),
},
],

ClientGenerator lives at ontogen::servers::ClientGenerator (re-exported at the module level). The internal path ontogen::servers::config::ClientGenerator is pub(crate) and no longer accessible to downstream crates.

The HttpTs generator produces a typed, fetch-based HTTP client. For each API function, it generates a TypeScript function that makes the appropriate HTTP request.

// Auto-generated HTTP client. DO NOT EDIT.
import type {
Task,
CreateTaskInput,
UpdateTaskInput,
} from './bindings';
const BASE = '/api';
async function httpGet<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function httpPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// ... httpPut, httpDelete helpers
export async function taskList(): Promise<Task[]> {
return httpGet('/tasks');
}
export async function taskGetById(id: string): Promise<Task> {
return httpGet(`/tasks/${id}`);
}
export async function taskCreate(input: CreateTaskInput): Promise<Task> {
return httpPost('/tasks', input);
}
export async function taskUpdate(id: string, input: UpdateTaskInput): Promise<Task> {
return httpPut(`/tasks/${id}`, input);
}
export async function taskDelete(id: string): Promise<null> {
return httpDelete(`/tasks/${id}`);
}

Function names follow the same entity-first convention as the server transports: taskList, taskGetById, taskCreate. The naming is camelCase for TypeScript conventions.

Custom endpoints discovered through API scanning get client functions too:

export async function taskGetOverdue(): Promise<Task[]> {
return httpGet('/tasks/overdue');
}

The bindings_path points to a TypeScript file (typically generated by ts-rs or specta) that exports your Rust types as TypeScript interfaces. The generator reads this file to determine which types are available for import.

Types that exist in bindings get proper imports. Types that are missing get a Record<string, unknown> placeholder with a TODO comment, so you can see exactly what needs exporting:

// TODO: Type 'CustomQueryResult' not yet exported from bindings.ts -- using placeholder
type CustomQueryResult = Record<string, unknown>;

The HttpTauriIpcSplit generator is for applications that run as both a web app and a Tauri desktop app. It generates a Transport interface with two implementations:

interface Transport {
taskList(): Promise<Task[]>;
taskGetById(id: string): Promise<Task>;
taskCreate(input: CreateTaskInput): Promise<Task>;
// ... all operations
}
function createHttpTransport(): Transport {
return {
taskList: () => httpGet('/tasks'),
taskGetById: (id) => httpGet(`/tasks/${id}`),
taskCreate: (input) => httpPost('/tasks', input),
// ...
};
}
function createIpcTransport(): Transport {
return {
taskList: () => invoke('task_list'),
taskGetById: (id) => invoke('task_get_by_id', { id }),
taskCreate: (input) => invoke('task_create', { input }),
// ...
};
}

At runtime, you detect the environment and pick the right transport:

const transport = window.__TAURI__
? createIpcTransport()
: createHttpTransport();

Both implementations have identical signatures. Code that uses the transport doesn’t know or care whether it’s talking to a local server over HTTP or to the Tauri backend over IPC.

When route_prefix is configured, the generated client functions accept an optional project ID parameter:

export async function taskList(projectId?: string): Promise<Task[]> {
const prefix = projectId ? `/projects/${projectId}` : '';
return httpGet(`${prefix}/tasks`);
}

For the IPC transport, the project ID is passed as an additional argument to invoke:

taskList: (projectId) => invoke('task_list', {
projectId: projectId ?? null
}),

The AdminRegistry generator produces a TypeScript file with per-entity metadata for admin UIs. This is designed for generic admin interfaces that can render CRUD forms and tables without hand-written configuration.

// Auto-generated admin registry. DO NOT EDIT.
import type { AdminFieldDef, AdminEntityConfig } from '@ontogen/admin-types'
export const adminEntities: AdminEntityConfig[] = [
{
key: 'task',
plural: 'tasks',
label: 'Task',
pluralLabel: 'Tasks',
idType: 'string',
listMethod: 'taskList',
getMethod: 'taskGetById',
createMethod: 'taskCreate',
updateMethod: 'taskUpdate',
deleteMethod: 'taskDelete',
returnType: 'Task',
createInputType: 'CreateTaskInput',
updateInputType: 'UpdateTaskInput',
paginated: false,
fields: [
{ key: 'name', label: 'Name', type: 'string', role: 'plain', required: true },
{ key: 'status', label: 'Status', type: 'string', role: 'enum', required: false },
{ key: 'assignee_id', label: 'Assignee', type: 'string', role: 'relation',
relation: { kind: 'belongs_to', target: 'agent' }, required: false },
],
},
// ... more entities
];

The registry only includes modules that have all five CRUD functions (list, get_by_id, create, update, delete). Partial or custom-only modules are excluded.

When schema_entities is populated in the server config, the admin generator emits detailed field information:

  • type — the TypeScript type (string, number, boolean, arrays)
  • role — the field’s purpose (id, plain, enum, relation, body)
  • relation metadata — for relation fields, the kind (belongs_to, has_many, many_to_many) and target entity
  • required — whether the field is required vs optional

This lets admin UI frameworks render appropriate input components (text fields, dropdowns, relation pickers) without per-entity configuration.

When pagination is configured, each entity config includes pagination details:

{
paginated: true,
defaultLimit: 50,
maxLimit: 200,
// ...
}

Admin UIs can use this to render pagination controls with sensible defaults.

Some IPC commands shouldn’t appear in the TypeScript client — perhaps they’re internal or handled through a different mechanism. The ts_skip_commands list excludes specific commands by their canonical name:

ts_skip_commands: vec![
"system_health_check".to_string(),
"internal_reindex".to_string(),
],

Skipped commands are omitted from both the HTTP client and the split transport. They still get server-side handlers.

How client functions mirror the server API

Section titled “How client functions mirror the server API”

The client generators discover functions through the same ApiModule list that the server generators use. For each function, the generator:

  1. Determines the HTTP method and path from OpKind.
  2. Maps Rust parameter types to TypeScript types using the rust_type_to_ts function.
  3. Checks which TypeScript types are available in bindings.ts.
  4. Generates a function with matching name, parameters, and return type.

This means custom API endpoints you add through scan_dirs automatically get client functions too. You write the Rust function, rebuild, and the TypeScript client has a new typed function ready to call.