How It Works
v0.29.34You write one class. SMRT's Vite plugin scans it at build time, builds a manifest, and generates four call surfaces from that single definition: a REST API, a CLI, an MCP tool set, and the SQL schema. This page traces that pipeline end to end β what runs, what gets written, and where to find it.
The pipeline
The whole thing is driven by smrtPlugin(), registered in your vite.config.ts alongside SvelteKit. There is no separate build step or codegen CLI
to run β the plugin participates in Vite's own lifecycle, so generation happens on dev startup and on build.
@smrt() class becomes four call surfaces. Only the REST routes are written to
disk as files; the CLI, MCP, and SQL surfaces are served as Vite virtual modules built from
the same manifest.1. You write the class
A SMRT object is a plain TypeScript class extending SmrtObject, annotated with @smrt(). The decorator config declares which surfaces to generate β here, four REST
actions plus the CLI and MCP tool sets.
// src/lib/models/product.ts
import { SmrtObject, smrt } from '@happyvertical/smrt-core';
@smrt({
api: { include: ['list', 'get', 'create', 'update'] },
cli: true,
mcp: true
})
export class Product extends SmrtObject {
collection = 'products';
name: string = '';
price: number = 0.0;
}2. The plugin scans it
Register smrtPlugin() in vite.config.ts. During configResolved (and buildStart), it calls its scanner over your source
tree.
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { smrtPlugin } from '@happyvertical/smrt-core';
export default {
plugins: [
smrtPlugin(), // scans, builds the manifest, generates routes
sveltekit()
]
};Scanning is handled by @happyvertical/smrt-scanner's OxcScanner, a
Rust-based oxc-parser AST pass. It walks your .ts files (it explicitly
excludes .svelte files, since OXC chokes on <style> tags),
reads each @smrt() class, and resolves inheritance. A scan error fails the build
rather than silently emitting an empty manifest.
3. It builds the manifest
The scan produces a manifest: a description of every object β its fields, methods,
decorator config, and collection name. The plugin writes it to .smrt/manifest.json so the CLI and other tools can discover your objects without
re-scanning.
4. It generates the surfaces
With the manifest in hand, generateSvelteKitRoutes() writes the REST routes to
disk, and the plugin registers virtual modules that materialize the other three surfaces on
import.
Where the generated REST files land
Routes are written under your routes directory, keyed by each object's collection name:
| Surface | Generated location | Handlers |
|---|---|---|
| Collection | src/routes/products/+server.ts | GET (list), POST (create) |
| Item | src/routes/products/[id]/+server.ts | GET (get), PUT (update), DELETE (delete) |
| Custom (item) | src/routes/products/[id]/restock/+server.ts | your method, see below |
| Custom (collection) | src/routes/products/featured/+server.ts | your static method, see below |
Every generated file begins with // Auto-generated by @smrt/core vite plugin and a DO NOT EDIT banner. On the next run the plugin deletes any +server.ts that starts with that header and regenerates it, so hand-edits are
overwritten. The generated routes are added to .gitignore.
What a generated route looks like
Here is the collection route for Product. Note the auth guard at the top of every
handler, and that responses are serialized with toPublicJSON(), never raw toJSON().
// src/routes/products/+server.ts
// Auto-generated by @smrt/core vite plugin
// DO NOT EDIT - changes will be overwritten
import { error, json } from '@sveltejs/kit';
import { getCollection } from '$lib/server/smrt';
import type { RequestHandler } from './$types';
// Fail-closed authorization (#1540): generated routes require an authenticated
// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`.
const PUBLIC_ACCESS: boolean | 'read' = false;
function requireRouteAuth(locals: unknown, mutating: boolean): void {
if (PUBLIC_ACCESS === true) return;
if (PUBLIC_ACCESS === 'read' && !mutating) return;
if (!hasAuthenticatedPrincipal(locals)) {
throw error(401, 'Authentication required');
}
}
export const GET: RequestHandler = async ({ locals, url }) => {
requireRouteAuth(locals, false);
const limit = Number(url.searchParams.get('limit')) || 50;
const offset = Number(url.searchParams.get('offset')) || 0;
const collection = await getCollection('Product');
const items = await collection.list({ limit, offset });
const count = await collection.count();
const items_public = items.map((item) => item.toPublicJSON());
return json({ items: items_public, count, limit, offset });
};
export const POST: RequestHandler = async ({ locals, request }) => {
requireRouteAuth(locals, true);
const data = applyWritablePolicy(await request.json());
const collection = await getCollection('Product');
const item = await collection.create(data);
await item.save();
return json(item.toPublicJSON(), { status: 201 });
};The fail-closed auth guard
Every generated CRUD and action handler calls requireRouteAuth(locals, mutating) before doing any work. It throws 401 unless the route is explicitly opted public,
or locals carries a resolved, object-shaped principal
(locals.user, locals.session, or the explicit locals.smrtAuth === true marker). Mutating verbs require auth even when reads are
opted public. The full security model β what counts as a principal, how @field visibility works, and where your responsibilities begin β is on the Security page.
Custom-method routing
Any public method that is not one of the five CRUD actions (list, get, create, update, delete) becomes its
own route. The api.routes[name] entry controls its HTTP method, URL path, and scope:
- Item scope (the default for instance methods) β routed under the
[id]segment, e.g.POST /products/[id]/restock. The handler loads the instance withcollection.get(params.id)before invoking your method. - Collection scope β routed at the collection root, e.g.
GET /products/featured. Collection-scoped routes require a static method; a non-static method declared withscope: 'collection'is skipped with a warning.
@smrt({
api: {
include: ['get'],
routes: {
// item-scoped (instance method) β /products/[id]/restock
restock: { method: 'POST' },
// collection-scoped (static method) β /products/featured
featured: { scope: 'collection', method: 'GET' }
}
}
})
export class Product extends SmrtObject {
collection = 'products';
stock: number = 0;
// Instance method: routed under the [id] segment.
async restock(amount: number) {
this.stock += amount;
await this.save();
return { stock: this.stock };
}
// Static method: routed at the collection root.
static async featured() {
/* ... */
}
}The default URL segment is the method name (kebab-cased when the kebabRoutes option is on). An explicit api.routes[name].path always wins. Custom-action results are returned as { action, result }, with the result recursively passed through toPublicJSON() so any returned objects still strip their sensitive fields.
The four surfaces, side by side
From that one Product class you can now call into the same logic four ways:
# REST (src/routes/products/+server.ts, products/[id]/+server.ts)
GET /products
POST /products
GET /products/:id
# CLI (virtual module smrt:cli)
smrt product:list
smrt product:create --name "Widget" --price 9.99
# MCP (virtual module smrt:mcp)
product_list # tool exposed to AI agents
product_create
# SQL (virtual module smrt:schema)
CREATE TABLE products (
id TEXT PRIMARY KEY,
name TEXT,
price DECIMAL,
...
);The CLI, MCP, and SQL surfaces are exposed as Vite virtual modules β smrt:cli, smrt:mcp, and smrt:schema respectively β
generated on import from the same manifest. (smrt:routes, smrt:client, smrt:types, and smrt:manifest are also
available.) Only the REST routes are emitted as files on disk.
The CLI β API coherence check
The generated CLI does not call your methods in-process β it invokes them over
HTTP, through the generated API. So a CLI command for a method that has no API route
would be dead on arrival. To catch that at build time, validateCliIncludeAgainstApi() runs during the build and throws if any class lists a command in cli.include that is not reachable via api.include.
End to end
- You author a
@smrt()class with acollectionname and fields. smrtPlugin()runs theOxcScannerover your.tsfiles on dev startup and build.- The scan produces a manifest, written to
.smrt/manifest.json. generateSvelteKitRoutes()writes+server.tsfiles undersrc/routes/<collection>/, each guarded byrequireRouteAuth.- The CLI, MCP, and SQL surfaces are served from
smrt:cli,smrt:mcp, andsmrt:schemavirtual modules, all built from the same manifest. - The build-time coherence check fails fast if a CLI command can't reach an API route.
That is the entire codegen path. To go deeper on each object's fields, decorators, and lifecycle, see Objects; for the security defaults on the generated surface, see Security.
Grounded in packages/core/src/vite-plugin/sveltekit-generator.ts and packages/core/src/vite-plugin/index.ts in the SMRT framework source.