How It Works

v0.29.34

You 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.

Pipeline: your class is scanned by the OXC scanner into a manifest, which the SvelteKit generator turns into REST routes, CLI, MCP, and SQL schema.Your class@smrt() + fieldsOXC scannersmrt-scanner Β· ASTManifest.smrt/manifest.jsonSvelteKit generatorgenerateSvelteKitRoutes()Build-time CLI ↔ API coherence checkvalidateCliIncludeAgainstApi() β€” throws if a CLI command has no API routeREST+server.tsroutes on diskCLIsmrt:clivirtual moduleMCPsmrt:mcpvirtual moduleSQLsmrt:schemavirtual module
One @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
typescript
// 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
typescript
// 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:

SurfaceGenerated locationHandlers
Collectionsrc/routes/products/+server.tsGET (list), POST (create)
Itemsrc/routes/products/[id]/+server.tsGET (get), PUT (update), DELETE (delete)
Custom (item)src/routes/products/[id]/restock/+server.tsyour method, see below
Custom (collection)src/routes/products/featured/+server.tsyour 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
typescript
// 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 with collection.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 with scope: 'collection' is skipped with a warning.
src/lib/models/product.ts
typescript
@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:

text
# 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

  1. You author a @smrt() class with a collection name and fields.
  2. smrtPlugin() runs the OxcScanner over your .ts files on dev startup and build.
  3. The scan produces a manifest, written to .smrt/manifest.json.
  4. generateSvelteKitRoutes() writes +server.ts files under src/routes/<collection>/, each guarded by requireRouteAuth.
  5. The CLI, MCP, and SQL surfaces are served from smrt:cli, smrt:mcp, and smrt:schema virtual modules, all built from the same manifest.
  6. 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.