# Plugin development AstroBaaS has **two plugin tiers**. Pick by what you need to do: | | **Code plugins** (bundled) | **Declarative plugins** (manifest) | |---|---|---| | What it is | A TypeScript module | A JSON manifest | | Installed by | Adding an import + rebuilding | Uploading / registry, **at runtime** | | Can run logic | ✅ any filter/action | ❌ none — it is data | | Can add | anything | meta/link tags, CSS, content types, webhooks | | Privileges | full application privileges | **none** — nothing is executed | | Best for | developers, AI agents | operators who can't redeploy | Both tiers share one registry, one activation model, and one admin screen. **Code plugins** are small, trusted, in-process TypeScript modules bundled with the app. They extend behaviour through **filters** (transform a value) and **actions** (fire-and-forget side effects). Activation state is persisted in the database, so a server restart preserves what the operator turned on. > Code plugins run with full application privileges — there is no sandbox. Only > install ones you trust. This fits AstroBaaS's single-node, self-hosted model. > Declarative plugins carry no such warning precisely because they cannot execute. Jump to: [Declarative plugins](#declarative-plugins-runtime-installable). ## Anatomy of a plugin Create a folder under `src/plugins//` with an `index.ts` that default-exports a plugin via `definePlugin()`. Import everything from the stable **`astrobaas/core`** barrel — never from internal `src/lib/*` paths: ```ts import { definePlugin, PLUGIN_HOOKS } from 'astrobaas/core'; import type { Post } from 'astrobaas/core'; export default definePlugin({ id: 'my-plugin', // stable, unique, kebab-case name: 'My Plugin', version: '1.0.0', description: 'What it does.', author: 'You', filters: { [PLUGIN_HOOKS.POST_CONTENT]: (html: string, post?: Post) => html + '

Hi

', }, actions: { [PLUGIN_HOOKS.AFTER_POST_SAVE]: (post: Post) => console.log('saved', post.id), }, activate() {/* optional one-time setup */}, deactivate() {/* optional teardown */}, }); ``` Then register it in `src/plugins/index.ts` — **one import, one array entry**. Do not paste the array over: it already carries every bundled plugin (eight at the time of writing), and replacing it uninstalls the rest. ```ts import myPlugin from './my-plugin'; // add this line export const BUNDLED_PLUGINS: Plugin[] = [ readingTime, draftWatermark, productCatalog, printStyles, smtp2go, consentBanner, aiAssistant, popups, myPlugin, // ...and this one ]; ``` `definePlugin()` is an identity helper (like Astro's `defineConfig`) that gives you full hook type-checking. `astrobaas/core` is the **only** import surface covered by the project's stability guarantees — see [STABILITY.md](./STABILITY.md). That's it — it appears under **Admin → Plugins**, where it can be activated. The choice is saved to the `plugins` table and replayed on the next boot by `pluginManager.bootstrap()`. ## Hook catalog Hook names live in `PLUGIN_HOOKS` (exported from `astrobaas/core`) — always use the constants, never raw strings, so they can't drift. | Constant | Kind | Signature | Notes | | --- | --- | --- | --- | | `API_POSTS_GET` | filter | `(posts: Post[]) => Post[]` | **One page** of `GET /api/posts`, already filtered and paginated by the storage layer — visibility included. See "`api_posts_get` receives a PAGE" below. | | `POST_CONTENT` | filter | `(html: string, post: Post) => string` | Post body HTML. **Re-sanitized after filters** before render, so you cannot inject script. | | `POST_TITLE` | filter | `(title: string, post: Post) => string` | Post title (rendered as text). | | `BEFORE_POST_SAVE` | filter | `(post, ctx: { isNew: boolean }) => post` | Mutate/validate post fields just before create or update. Content is sanitized **after** this hook; `author_id` is always overridden by the session. | | `AFTER_POST_SAVE` | action | `(post: Post) => void` | Fires after a post is created **or** updated. | | `AFTER_POST_DELETE` | action | `(post: Post) => void` | Fires after a post is deleted. | | `ROBOTS_GROUPS` | filter | `(groups: RobotsGroup[], ctx: { origin: string }) => RobotsGroup[]` | Per-crawler groups for `robots.txt`. The initial value is the operator's own choices, built from the `crawler_policy` setting — **add to what you are handed**, because replacing the array discards them. `RobotsGroup` is `{ agents: string[], allow?, disallow?, crawlDelay?, source? }` (`src/lib/robots-txt.ts`). The `discourage` kill switch outranks everything, including this. | | `CRAWLER_POLICY` | filter | `(verdict: { block: boolean; reason?: string }, ctx: { userAgent, path }) => verdict` | Enforcement, which `robots.txt` only requests. Called on every **public** page request (not `/admin`, not `/api/`); the initial value is `{ block: false }` and core ships no blocklist. Returning `{ block: true }` answers **403** with `reason` as the body. A filter that throws here is swallowed — a policy plugin must never take the site down. | | `HEAD_TAGS` | filter | `(html: string, ctx: { pathname: string }) => string` | Append markup to `` on public pages. Sanitized to `meta`/`link` only — **no `