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.

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.

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.

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.

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:

LanguageInstallGenerate
TypeScript (this guide)npm i @metaobjectsdev/climeta gen
C# / .NETdotnet add package MetaObjectsdotnet meta gen
Java / Kotlincom.metaobjects (Maven Central)mvn metaobjects:generate
Pythonpip install metaobjectsmetaobjects 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.

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 }

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.

$ 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):

// 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(),
});

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

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.

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.

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.