Noon Docs · Getting Started · Concepts · Guides · Reference

A Noon plugin is a package built against @nooncms/plugin-sdk. It can add database tables, a query collection agents can read through the query tool, MCP tools agents can call, and hooks that run when content is saved. This guide walks through the manifest shape, the hook contract, and a full minimal example.

A plugin is not a runtime extension. It is reviewed source code, built into the install, and turned on with an environment variable and a restart. There is no dynamic loading and no marketplace.

What a plugin can add

  • Tables: your own database tables, one set per dialect (SQLite, MySQL, Postgres). Every physical table name must start with your plugin's id, so it can never collide with a core table or another plugin's table.
  • Collections: read access to your tables through the existing query and describe_schema tools. You do not write a new tool just to make data readable.
  • MCP tools: agent-invocable actions, for example a tool that creates a row in one of your tables. Optional. A plugin with nothing for an agent to invoke omits this.
  • Hooks: handlers that run when a page or post is saved, published, discarded, or unpublished, or when site settings change. Optional.

The manifest

Your package exports one object satisfying the NoonPlugin interface from @nooncms/plugin-sdk:

FieldRequiredWhat it does
idyesYour plugin's id. Lowercase letters, digits, and underscores, starting with a letter. Maximum 42 characters.
tablesyesThree maps (sqlite, mysql, postgres), each keyed by a camelCase schema name pointing at your Drizzle table objects. All three maps must declare the same keys.
collectionsyesAn array of collection definitions to register. Pass [] if your plugin has nothing to expose to query.
hooksyesAn array of hook registrations. Pass [] if your plugin registers none.
mcpToolsnoAn array of MCP tool definitions. Omit it, or pass [], if your plugin has no agent-invocable actions.
bundleWritersnoAn object naming which bundle sections your plugin can write at import time. Today the only member is theme. Omit it unless your plugin owns a bundle section the way the theme plugin does.
migrationsyes{ dir, journalTable }. dir is the absolute path to your plugin's drizzle/ migrations folder, computed from import.meta.url so it resolves correctly whether your code runs from source or from a built dist/ folder. journalTable must be exactly __drizzle_migrations_<id>.

The hook contract

A hook fires inside the same database transaction as the content mutation that triggered it. If your handler throws, the whole mutation rolls back.

HookFires onEntity kindsCan it transform or veto
before_post_savecreate, update, rollbackpost onlyTransform: may return a replacement draft, which is re-validated before it is persisted
before_publishpublishpage, postVeto only: may throw to block the publish
after_post_savecreate, update, rollback, publishpost onlyObserve only
after_layout_compilecreate, update, rollback (draft phase), publish (publish phase)page onlyObserve only
after_draft_discarddiscarding a draftpage, postObserve only. The firing point where a plugin learns its draft-phase rows are dead
after_unpublishtaking a published entity off the sitepage, postObserve only. Fires only on a real published-to-unpublished transition
after_site_settings_writecreating or updating the site settings documentsite settings onlyObserve only
after_permalink_changea slug or URL pattern change on an entity that has ever been publishedpage, postObserve only

Only before_post_save may transform. Every other hook is observe-only: it runs after the change is already persisted, and any value it returns is ignored.

Two rules apply to every hook handler:

  • Do not perform network calls inside a hook. A hook runs inside a database transaction; anything that needs the network belongs in a tool handler, before the mutation starts.
  • Write only your own <id>_-prefixed tables through the hook's transaction handle. Reading a core table is fine; writing one is not.

The collection definition

FieldRequiredWhat it does
nameyesThe collection name agents pass to query.
tableyesA function from dialect to your Drizzle table object. Must return one of your own declared table fragments, by identity.
directFieldsyesA map of field name to its definition: physical column, type, which operators it accepts, and whether it is sortable.
jsonPredicatesyesA map of field name to a JSON-path predicate definition. Pass {} if you have none.
projectableJsonColumnsyesA set of JSON column names that may be selected whole. Pass new Set() if you have none.
readScopesyes for a plugin collectionThe scopes that may read this collection. Must be explicit and non-empty; there is no silent default.
defaultOrdernoThe sort order used when a caller supplies none. Omit it and the collection falls back to createdAt desc, which only works if your table actually has a createdAt column. If it does not, declare your own default order or every query that omits orderBy fails.
uniqueKeynoThe tuple of fields that uniquely identifies a row. Omit it and the collection falls back to ["id"]. Declare it only if a different combination of fields is what is actually unique, so pagination never silently drops rows.

Every collection needs a sortable id field: it is the tiebreak keyset pagination relies on.

The MCP tool definition

FieldRequiredWhat it does
nameyesThe tool name an agent calls. Lowercase letters, digits, and underscores. Unprefixed (create_product, not shop_create_product). A name collision with a core tool or another plugin's tool fails at startup.
titlenoA human-facing title.
descriptionyesWhat the tool does, at most 300 characters.
inputSchemayesA raw Zod shape (a plain object of field name to Zod type), passed straight through to the MCP tool registration. Keep content-bearing fields as z.unknown().optional() so a bad value reaches your own validator and comes back as a structured, correctable error, instead of being rejected by the transport before your code runs. Type only genuinely structural fields, like an id you address by or a CAS token, as their real type.
requiredScopesyesThe scopes a caller must hold, all of them, to invoke this tool. Must be non-empty.
handleryesAn async function that receives (ctx, args) and does the work. ctx.db is a full, unrestricted database handle: it is not limited to the scopes you declared. Auditing your own writes is your responsibility.

A complete minimal example

This mirrors the real word_count plugin: one table, a hook that transforms a draft, a hook that stores derived stats, and a query collection. No tools.

Table fragment (repeat the same shape for mysql and postgres):

// src/tables/sqlite.ts
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
import { generateId } from "@nooncms/schema";

export const wordCountStats = sqliteTable(
  "word_count_stats",
  {
    id: text().primaryKey().$defaultFn(generateId),
    entityType: text().notNull(),
    entityId: text().notNull(),
    wordCount: integer().notNull(),
    readingTimeMin: integer().notNull(),
    computedAt: integer({ mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
  },
  (table) => [uniqueIndex("word_count_stats_entity_idx").on(table.entityType, table.entityId)],
);

The collection:

// src/collection.ts
export const wordCountStatsCollection: CollectionDef = {
  name: "word_count_stats",
  table: (dialect) => {
    switch (dialect) {
      case "sqlite": return sqliteWordCountStats;
      case "mysql": return mysqlWordCountStats;
      case "postgres": return postgresWordCountStats;
    }
  },
  directFields: {
    id: { name: "id", column: "id", type: "string", allowedOperators: ["eq", "ne", "in"], sortable: true },
    wordCount: { name: "wordCount", column: "wordCount", type: "number", allowedOperators: ["eq", "ne", "gt", "gte", "lt", "lte"], sortable: true },
    computedAt: { name: "computedAt", column: "computedAt", type: "timestamp", allowedOperators: ["eq", "gt", "gte", "lt", "lte"], sortable: true },
    // entityType, entityId follow the same shape as id
  },
  jsonPredicates: {},
  projectableJsonColumns: new Set(),
  readScopes: ["query:read", "content:read"],
  // this table has no createdAt column, so a default order must be declared
  defaultOrder: [{ field: "computedAt", dir: "desc" }],
};

The hooks. before_post_save returns a replacement draft; after_post_save and after_layout_compile derive and store stats; before_publish vetoes an empty post body:

// src/hooks.ts
const beforePostSave: HookRegistration = {
  hook: "before_post_save",
  handler: async (ctx) => {
    const draft = (ctx as BeforePostSaveCtx).draft;
    const body = typeof draft.body === "string" ? draft.body : "";
    const readingTime = readingTimeMin(countWords(body));
    return {
      draft: {
        ...draft,
        frontmatter: { ...(draft.frontmatter as Record<string, unknown> ?? {}), readingTime },
      },
    };
  },
};

const afterPostSave: HookRegistration = {
  hook: "after_post_save",
  handler: async (ctx) => {
    const c = ctx as AfterPostSaveCtx;
    const body = typeof c.content.body === "string" ? c.content.body : "";
    await upsertStats(c.tx, "post", c.entityId, countWords(body));
  },
};

const beforePublish: HookRegistration = {
  hook: "before_publish",
  handler: async (ctx) => {
    const c = ctx as BeforePublishCtx;
    if (c.entityKind !== "post") return;
    const body = typeof c.draft.body === "string" ? c.draft.body : "";
    if (body.trim() === "") {
      throw new ContentValidationError([
        { path: "body", code: "empty_body", message: "cannot publish a post with an empty body", received: "" },
      ]);
    }
  },
};

export const wordCountHooks: HookRegistration[] = [beforePostSave, afterPostSave, beforePublish];

The manifest that ties it together:

// src/index.ts
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { NoonPlugin } from "@nooncms/plugin-sdk";

export const wordCountPlugin: NoonPlugin = {
  id: "word_count",
  tables: {
    sqlite: { wordCountStats: sqliteWordCountStats },
    mysql: { wordCountStats: mysqlWordCountStats },
    postgres: { wordCountStats: postgresWordCountStats },
  },
  collections: [wordCountStatsCollection],
  hooks: wordCountHooks,
  migrations: {
    dir: join(dirname(fileURLToPath(import.meta.url)), "..", "drizzle"),
    journalTable: "__drizzle_migrations_word_count",
  },
};

Registering your plugin

  1. Add your package as a dependency of packages/plugins.
  2. In packages/plugins/src/noon.plugins.ts, add one static import and one entry to the EXTERNAL_PLUGINS record:
import { myPlugin } from "@yourscope/noon-plugin-my-thing";

export const EXTERNAL_PLUGINS: Readonly<Record<string, NoonPlugin>> = Object.freeze({
  [myPlugin.id]: myPlugin,
});
  1. Run pnpm install && turbo build.
  2. Run pnpm db:migrate to create your plugin's tables.
  3. Add your plugin's id to NOON_PLUGINS and restart every Noon process.

Presence in noon.plugins.ts makes a plugin available. NOON_PLUGINS selects which available plugins are actually enabled.

A warning worth repeating

A plugin runs with full database access inside the install. The scopes you declare on a tool are enforced against the caller, not against what your own handler code can reach: your handler can read or write any table, core tables included, regardless of what scopes it declared. Only install plugin code you have reviewed and trust.