Getting started · build it with Claude Code, or by hand
One typed model → idiomatic code, in your language.
Scaffold once, then tell Claude Code what you want — "build the REST API
for tasks," "add a priority field and update everything." Because meta init installs
the MetaObjects skills, it knows the model is the source of truth: it edits the model, picks the generators the
job needs, and generates the code — it never hand-writes the boilerplate. Every command and
output below is real verified, run against @metaobjectsdev/cli@1.0.4.
0 · Already have an app? Assess it first — nothing to install
Adopting into an existing codebase? Before you install anything, have your agent run the fit & migration assessment against your repo. It's read-only and propose-only — it reads your code, migrations, and git history, and writes a decision-grade report: per-pillar fit verdicts (including NOT A FIT), a drift ledger of the shapes you already declare twice — with the past commits where a fix patched one copy and missed the other — and, if the verdict is yes, a first-week wedge plan.
Fetch https://metaobjects.dev/assess.md and run the MetaObjects Fit & Migration Assessment against this repository.
Any agent that can fetch a URL works (Claude Code, Cursor, Windsurf, Copilot's
#fetch, Gemini CLI). Locked-down agent? Save the prompt file into your
workspace and tell the agent to read it — every agent reads workspace files. The report ends where this page
begins: its wedge plan's first command is meta init, step 1 below. Greenfield project? Skip
straight to step 1.
1 · Install & scaffold
Set up the project and the agent context
Add the TypeScript CLI and scaffold the workspace. meta init also teaches your coding agent how to use MetaObjects:
npm i @metaobjectsdev/cli
npx meta init
That writes a small, legible tree — your models go in metaobjects/,
and codegen/generators/ is waiting for the generators you choose:
your-project/ ├── metaobjects/ # ← your models live here (.yaml or .json) │ └── meta.common.json # empty starter file (shared abstracts) ├── codegen/generators/ # ← EMPTY on purpose — generators you choose ├── metaobjects.config.ts # ← dialect, and generators: [] until you pick ├── tsconfig.codegen.json # typechecks the generators you own ├── CLAUDE.md # wired to auto-load the context below ├── .metaobjects/ # tool state + agent docs (AGENTS.md, CLAUDE.md) └── .claude/skills/ # six skills your agent now has: ├── metaobjects-authoring/ # write & edit models ├── metaobjects-codegen/ # choose generators, run codegen ├── metaobjects-runtime-ui/ # wire up runtime + UI ├── metaobjects-prompts/ # typed LLM prompts ├── metaobjects-verify/ # drift checks └── metaobjects-audit/ # check adoption depth
It also sets "type": "module" in package.json: everything
MetaObjects scaffolds and generates is ESM.
Nothing is generated yet, and that is deliberate. MetaObjects doesn't decide which
code your application needs. You do, or the agent working in your repo does. meta init
says so on its way out:
codegen/generators/ is EMPTY on purpose: no code is generated until you choose it.
2 · Then just tell it what you want
This is the part that feels like magic. You describe outcomes; Claude Code knows the MetaObjects way — change the model, generate the code — because the skills it loaded say so. You never ask it to hand-write a route or a table.
Author the model
Model a Task — a title (required, max 120), a status of open / active / done, and when it was created.
→ Claude writes metaobjects/meta.tasks.yaml using the metaobjects-authoring skill. You review a few readable lines.
Build the API — later, on demand
Now expose a REST API for tasks.
→ It knows REST routes are derived from the model, so it doesn't write them. It runs
meta gen --list --probe, the catalog of every generator run against your model, and
takes the ones this job needs: meta eject entity queries routes names barrel. That copies
them into codegen/generators/, where they are yours to edit. It wires them
into the config, installs what they import, and runs meta gen. The entity, typed
data-access, and /tasks endpoints are generated — not one route hand-written.
Change the model — the API follows
Add a priority field, and make each Task belong to a Project. Then update everything.
→ It edits the model and re-runs meta gen. The schema, validation, data-access, and API all
regenerate to match, and the modules you wrote yourself are untouched. If anything no longer fits, it
breaks at compile time, pointing at the exact line.
The rest of this page is what's happening underneath — useful if you'd rather drive it by hand, or just want to see it.
3 · Pick your language
The model is language-neutral — the same metaobjects/ drives every port. You pick the
toolchain for the language you ship. This guide uses TypeScript; swap the row for yours:
| Language | Install | Generate |
|---|---|---|
| TypeScript (this guide) | npm i @metaobjectsdev/cli | meta gen |
| C# / .NET | dotnet add package MetaObjects | dotnet meta gen |
| Java / Kotlin | com.metaobjects (Maven Central) | mvn metaobjects:generate |
| Python | pip install metaobjects | metaobjects gen |
Every port works the same way: nothing is generated until you name the generators you
want. There is no default suite to switch off. --list is the catalog in each CLI, and the
C# and Python CLIs take the names you chose with --generators.
4 · Author a model
Whether the agent writes it or you do, a model is just a small file in metaobjects/.
Here's the one from above — metaobjects/meta.tasks.yaml:
# metaobjects/meta.tasks.yaml — the source of truth (.json works too) metadata: package: app children: - object.entity: name: Task children: - source.rdb: { table: tasks } - field.long: { name: id } - field.string: { name: title, required: true, maxLength: 120 } - field.enum: { name: status, values: [open, active, done] } - field.timestamp: { name: createdAt, required: true } - identity.primary: { fields: [id], generation: increment }
5 · Choose your generators
Ask the catalog what your model can generate. --probe runs every generator against
your metadata and reports how many files each would emit:
$ npx meta gen --list --probe
Generator catalog — nothing runs until you wire it in `generators: [...]`.
model — the entity modules and the constants beside them
barrel — Single index.ts re-exporting every generated entity module. [would emit 1]
requires: entity
entity — Per-entity Drizzle table + typed model module (the entity module). [would emit 1]
names — Per-entity physical database name constants (table/view, schema, columns). [would emit 1]
persistence — how rows are read and written
queries — Per-entity typed query helpers (findById/create/...). [would emit 1]
requires: entity
api — the HTTP surface — pick ONE framework
routes — Per-entity Fastify CRUD routes (drizzle-fastify mountCrudRoutes). [fastify]
requires: entity
routes-hono — Per-entity Hono CRUD routes (runtime-ts/hono mountCrudRoutes). [hono, would emit 1]
# … client, docs and capability generators, then the ai and iam libraries
Take the ones you want. meta eject copies each into
codegen/generators/ and tells you exactly how to wire it:
$ npx meta eject entity queries routes names barrel
Ejected "entity" -> codegen/generators/entity.ts. You own it now (ADR-0034 scaffold-and-own).
In metaobjects.config.ts, "entityFile" must resolve to this file:
import { entityFile } from "./codegen/generators/entity.js";
# … the same for queries, routes, names and barrel
Install what the ejected generators and their output need:
# … the packages below
These generators read config: apiPrefix, collectionNameOverrides, columnNamingStrategy, dbImport, dialect, extStyle, …
Those five files are the point: they're plain TypeScript in your repo, and yours to
edit. meta gen runs those local copies — not the ones inside the package — so changing
the shape of the generated code is an ordinary edit to a file you own. Install what they import:
npm i -D @metaobjectsdev/codegen-ts npm i @metaobjectsdev/runtime-ts "drizzle-orm@>=0.36.0 <1.0.0" "fastify@>=5.0.0 <6.0.0" "zod@>=3.23.0 <5.0.0"
Then add them to metaobjects.config.ts, with the config keys the routes need. Here's
the file with meta init's longer comments trimmed:
// metaobjects.config.ts import { defineConfig } from "@metaobjectsdev/cli"; import { entityFile } from "./codegen/generators/entity.js"; import { queriesFile } from "./codegen/generators/queries.js"; import { routesFile } from "./codegen/generators/routes.js"; import { namesFile } from "./codegen/generators/names.js"; import { barrel } from "./codegen/generators/barrel.js"; export default defineConfig({ outDir: "src/generated", dialect: "sqlite", // sqlite | postgres | d1 extStyle: "js", // ".js" import suffixes — right for Node ESM + tsc dbImport: "../db", // where the generated routes import `db` from apiPrefix: "", // set to "/api" to mount the routes under /api generators: [ entityFile(), // Drizzle table + Zod schemas + typed constants queriesFile(), // typed data-access (findById, list, create…) routesFile(), // REST endpoints (Fastify) namesFile(), // <Entity>Names — physical table/column constants barrel(), // src/generated/index.ts re-exports ], docs: { outDir: "./docs/generated", layout: "flat" }, });
The rest of the catalog works the same way: React forms, TanStack hooks and grids, Hono routes,
prompt renderers and parsers. meta eject form is all it takes to own one.
6 · Generate
$ npx meta gen meta gen — sqlite, src/generated NEW src/generated/Task.ts NEW src/generated/Task.queries.ts NEW src/generated/Task.routes.ts NEW src/generated/Task.names.ts NEW src/generated/index.ts 5 written
That's a real run. You get four things per entity (plus a barrel):
Task.ts— the entity: a Drizzle table, Zod insert/update schemas, a typedTaskconstants object (fields, labels, validation rules), and filter/sort allowlists.Task.queries.ts— typed data-access:findTaskById(db, id),listTasks(db, opts), create/update/delete.Task.routes.ts— REST:taskRoutesmounts the 5 standard endpoints.Task.names.ts— the physical table and column names, as constants.
// src/generated/Task.ts (excerpt — real output) export const tasks = sqliteTable( TaskNames.sources.primary.table, { id: integer(TaskNames.fields.id.column).primaryKey({ autoIncrement: true }), title: text(TaskNames.fields.title.column).notNull(), status: text(TaskNames.fields.status.column, { enum: ["open", "active", "done"] as const, }), createdAt: text(TaskNames.fields.createdAt.column).notNull(), }, // … plus a CHECK constraint on status ); export const TaskInsertSchema = z.object({ title: z.string().min(1).max(120), status: z.enum(["open", "active", "done"]).optional(), createdAt: z.string(), });
7 · Wire your database
The generated routes import a db from the module dbImport names —
src/db.ts here. MetaObjects will not pick a driver for you: @libsql/client,
better-sqlite3, pg and postgres.js are your call, not a dependency it adds
behind your back. So that file is yours to write:
$ npm i @libsql/client // src/db.ts import { drizzle } from "drizzle-orm/libsql"; import { createClient } from "@libsql/client"; export const db = drizzle(createClient({ url: "file:dev.sqlite" }));
Swap the driver for your dialect. For Cloudflare D1, Postgres and multi-tenant setups, see
wiring
generated queries. Then create the tables. meta migrate diffs the live database against the
model and writes a paired up.sql/down.sql under .metaobjects/migrations/:
$ npx meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply
meta migrate — sqlite, file:dev.sqlite
Changes: 1 create-table
Written:
.metaobjects/migrations/<timestamp>-init/up.sql
.metaobjects/migrations/<timestamp>-init/down.sql
migrate: applied 1 migration(s): <timestamp>-init
8 · Use it — and add your own logic
Import the generated code into your app:
// src/server.ts — run it with: npx tsx src/server.ts import Fastify from "fastify"; import { taskRoutes } from "./generated/Task.routes.js"; const app = Fastify(); app.register(taskRoutes); // GET/POST/PATCH/DELETE /tasks, validated await app.listen({ port: 3000 });
$ curl -X POST localhost:3000/tasks -H 'content-type: application/json' \ -d '{"title":"Write the page","status":"open","createdAt":"2026-09-14T12:00:00Z"}' {"id":1,"title":"Write the page","status":"open","createdAt":"2026-09-14T12:00:00Z"} $ curl -X POST localhost:3000/tasks -H 'content-type: application/json' -d '{"status":"nope"}' {"error":"validation","issues":[{"expected":"string","code":"invalid_type","path":["title"], …
The entity module imports only Drizzle and Zod. The routes mount through
@metaobjectsdev/runtime-ts, an ordinary Apache-2.0 package you could vendor. The generated
endpoints are unauthenticated: register them inside a Fastify scope that carries your auth hook. The
header of Task.routes.ts shows how.
The custom business logic that makes the app yours? You write it, in your own modules, against
the generated, typed code. A Task.extra.ts beside the output is a naming convention, not a plugin
point: nothing imports it for you, so register its handlers next to taskRoutes. Change the model,
re-run meta gen, and anything that no longer fits breaks at compile time, pointing you at the exact
line.
9 · Keep it honest
As the model evolves, meta verify fails the build the moment generated code, the
database schema, or a prompt template drifts from it:
npx meta verify # prompt templates + the requirements ledger npx meta verify --codegen # generated code vs the model npx meta verify --db file:dev.sqlite # live database schema vs the model
Using a different assistant than Claude Code? Point it at metaobjects.dev/llms.txt for version-pinned context.
Go deeper
The monorepo is the source of truth — full quickstarts, the spec, and the cross-language conformance fixtures live there. This page is the on-ramp.
