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 was run and checked against @metaobjectsdev/cli@1.1.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. A quick pass (5–30 minutes of agent time) reads your code, migrations and git history and answers in the conversation: how much of MetaObjects this project should adopt — not worth it, a contract spine between your apps, one subsystem, or all of it — and the seams where it would pay off. Ask for the full assessment and it also writes an evidence-cited report, outside your repo, with a drift ledger of the shapes you already declare twice and a first-week 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 init -y
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
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 a created-at time the server sets itself.
→ 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 tool install --global MetaObjects.Cli, then dotnet meta init | dotnet meta gen ./metaobjects --out ./Generated --namespace App |
| Java / Kotlin | com.metaobjects:metaobjects-maven-plugin (Maven Central) | mvn metaobjects:generate |
| Python (3.11+) | 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 the TypeScript and C# CLIs. Each port keeps the
list you chose in a file you own: generators in metaobjects.config.ts (TypeScript),
codegen/Generators.cs (C#, which dotnet meta init writes empty), metaobjects.config.yaml
(Python), and the plugin's <generators> in your pom.xml (Java and Kotlin, set at plugin level
so the bare goal finds it). --generators only narrows a run to names that list already holds.
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, autoSet: onCreate } - identity.primary: { fields: [id], generation: increment }
autoSet: onCreate makes createdAt the server's job: the generated
insert schema stamps it with the current time, so a client never sends it, and a value a client does send is
ignored.
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: [...]`.
Each generator is a reference helper, not a guarantee: `meta eject <name>` copies it into
your repo and the copy is yours to change. What MetaObjects guarantees (migrate, verify,
render) is not a generator and is not listed here.
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, would emit 1 once dbImport is set]
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
That is the layout you see in a terminal. When stdout is not a terminal (piped, or run by an
agent), meta gen --list prints a structured format instead; pass --format text to get
the layout above, or --format json for JSON. meta eject behaves the same way.
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; for routes also:
The code this generator emits imports its HTTP adapter from codegen/runtime/, not from @metaobjectsdev/runtime-ts: the adapter, filter parser, error envelopes and pagination are yours too.
Install what the ejected generators and their output need:
# … the packages below
These generators read config: apiPrefix, collectionNameOverrides, dbImport, dialect, extStyle, outputLayout, …
Those five files are 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. Ejecting routes also copies
the HTTP adapter the routes call (mount helpers, filter parser, error envelopes, pagination) into
codegen/runtime/, so that code is yours too. Install what they all import:
npm i -D @metaobjectsdev/codegen-ts @types/qs typescript npm i @metaobjectsdev/metadata qs "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 ], });
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() .$defaultFn(() => new Date().toISOString()), }, // … 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() .optional() .transform(() => new Date().toISOString()), });
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
Now that src/db.ts exists, typecheck everything: create a tsconfig.json once and run the compiler.
meta init only scaffolds tsconfig.codegen.json, which covers the generators you own,
not src/:
npx tsc --init npx tsc --noEmit
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 (npx fetches tsx on first use) 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"}' {"id":1,"title":"Write the page","status":"open","createdAt":"2026-09-26T14:08:05.053Z"} $ 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 the
HTTP adapter eject copied into codegen/runtime/: plain TypeScript you own and can change. The generated
endpoints are unauthenticated: register them inside a Fastify scope that carries your auth hook. The
header of Task.routes.ts shows how.
Lists filter as ?filter[status][eq]=open, and only fields you mark
filterable: true in the model accept a filter; filtering on any other field answers 400
invalid_filter_field. That keeps an unindexed column from becoming a query anyone can run.
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 in sync
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 # the prompt-template gate (the default when no gate is named) 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.
