njin

New Built for AI agents

One model.
Everything else,
generated.

Define your content once, in TypeScript with Zod. njin generates the REST API, an admin panel and server-rendered pages, then opens it all to AI agents over MCP.

$ bunx @njinlabs/njin create my-web

How it works

One model in. Four things out.

Define a model once. njin turns it into a REST API, an admin panel your team can use, server-rendered pages and tools your AI agents can call, each with validation, drafts, languages and access control already wired up.

Read the docs
Model src/models/post.ts
1export default makeModel("post", {
2 name: "Post",
3 draftable: true,
4 i18n: true,
5 searchFields: ["title", "author.name"],
6 schema: z.object({
7 title: text({ label: "Title", localized: true }),
8 slug: text({ label: "Slug", unique: true }),
9 body: richtext({ label: "Body", localized: true }),
10 cover: file({ label: "Cover" }),
11 author: relation({ label: "Author" }, author),
12 }),
13});

Bright lines are what the selected output is built from.

1GET /api/post?locale=en&search=about
2
3200 OK
4{
5 "data": [{
6 "id": "8x2k",
7 "title": "About",
8 "slug": "about",
9 "author": { "id": "1", "name": "Ayu" }
10 }],
11 "meta": { "total": 1, "page": 1 }
12}
13
14// same slug again
15POST /api/post -> 409 { "field": "slug" }

Edit post

draft Publish
ID EN

Title

About

Slug

about unique

Body

We build calm software for small teams.

Cover

Drop a file

Author

Ayu
1@let(item = await post.published.show(params.id, { locale: 'en' }))
2@if(!item)
3 {{ abort(404) }}
4@end
5
6<article class="mx-auto max-w-narrow">
7 <h1>{{ item.title }}</h1>
8 <img src="{{ imgOptimize(item.cover.url, { w: 1200 }) }}" />
9 {{{ item.body }}}
10</article>
Change the English title of the About page to "About us".
read_records post · search "about"
update_record post/8x2k · title.en

Done. Only the English title changed; the Indonesian copy is untouched.

post:read post:update no delete

No controller to write. Validation and unique checks included. Forms, language switch and publish, from the same model. File-based routing. Reads only the live copy. Scoped, revocable, never more than the user can do.

AI agents

Predictable by design.
Easy for agents.

Agents do their best work where there is one obvious way to do things. njin removes the guesswork, so what they build is what you meant.

Convention

Every kind of change has one home, so an agent always knows which file to touch.

Deterministic

One definition feeds the API, the admin and the schema, so they cannot drift apart. Same field, same shape, everywhere.

Features

Then make it
yours.

The model is only the start. Settings, template helpers, events, MCP tools and plugins are plain TypeScript files you own, all registered in one config.ts. No framework source to fork.

  • Settings your team can edit
  • Helpers and events in plain TypeScript
  • Plugins that bundle models, routes and admin pages
  • Custom tools your AI agents can call
Read the docs
src/vars/general.ts
export default makeVars("general", {
  name: "General",
  schema: z.object({
    title: text({ label: "Title" }, (z) => z.default("njin")),
    banner: text({ label: "Banner" }, (z) => z.default("")),
  }),
});

// in any template:
// @let(site = await general.get())
// <title>{{ site.title }}</title>

What you get

GET/PUT /api/vars/generalSettings form in adminDefaults always completeDrafts and languages optional
src/helpers/format_date.ts
import { defineHelper } from "@njinlabs/njin";

export default defineHelper("formatDate", (date: string) => {
  return new Date(date).toLocaleDateString("en-GB", {
    day: "numeric",
    month: "short",
    year: "numeric",
  });
});

// then, in any template:
// {{ formatDate(post.createdAt) }}

What you get

Global in every templateSync or asyncNo endpoint, no databasePlain TypeScript
src/events/order_paid.ts
// src/events/order_paid.ts
const orderPaid = makeEvent<{ orderId: string }>();

// src/events/listeners/send_receipt.ts
orderPaid.listen(async ({ orderId }) => {
  await sendReceipt(orderId); // errors are logged, never thrown back
});

// when an order is created
afterCreate(order, (record) => {
  orderPaid.dispatch({ orderId: record.id });
});

What you get

Fire-and-forget fan-outListener errors are loggedType-safe payloadsPairs with model hooks
src/mcp/stale_drafts.ts
export default defineTool({
  name: "stale_drafts",
  description: "List drafts not edited for N days.",
  input: { days: z.number().describe("Minimum age in days") },
  handler: async ({ days }, { can }) => {
    if (!can("post", "read")) throw new Error("Not allowed");
    const { data } = await post.read({
      filters: { status: "draft" },
    });
    const cutoff = Date.now() - days * 86_400_000;
    return data.filter((p) => +new Date(p.updatedAt) < cutoff);
  },
});

// config.ts
//   mcp: { tools: [() => import("./src/mcp/stale_drafts")] }

What you get

Offered next to the built-in toolsArguments validated with Zodcan() checks who is callingErrors go back to the agent
config.ts
import { defineConfig } from "@njinlabs/njin/config";
import seo from "my-seo-plugin";

export default defineConfig({
  plugins: [seo({ apiKey: process.env.SEO_KEY! })],
});

// A plugin can bundle models, vars, hooks, events,
// routes and its own admin pages. Or scaffold one
// inside your project:
//   bunx njin plugin:create analytics

What you get

Models, vars, hooks, routesAdmin pages via the SDKIts own permissionsFrom npm or src/plugins
tests/post.test.ts
const app = await createTestApp(config);

it("lets editors read posts", async () => {
  const g = await app.createGroup({
    name: "Editors",
    grants: ["post:*:own"],
  });
  const user = await app.createUser({ groups: [g.id] });
  const res = await app.get("/api/post", { as: user });
  expect(res.status).toBe(200);
});

What you get

Your real app, in memoryNo port openedPermissions are testablebun test --isolate

Get started

Start with one model.

Three commands to a running API, admin panel and website. Open the admin, create your first user, and describe your content.

$ bunx @njinlabs/njin create my-web

$ cd my-web

$ bunx njin dev

# then open localhost:3000/_admin