Skip to content

Plugins

A plugin is a plain object. A factory function returns it, and you call that factory in ounce.config.ts. There is no runtime loading, no manifest file and no discovery step: if it is not in the plugins array, it does not exist.

src/plugins/hello/index.ts
import type { Plugin } from '@ouncepage/core';
export function hello(): Plugin {
return {
name: 'hello',
title: 'Hello',
};
}
src/site/ounce.config.ts
import { hello } from '../plugins/hello';
export default defineSite({
// ...
plugins: [hello()],
});

name and title are the only required keys. name must be unique, stable and URL-safe: it namespaces the plugin’s settings row (plugin:<name>), its admin routes (/admin/x/<name>/...) and its page tabs. Renaming it orphans all three.

Key Type Adds
name string Required. The namespace
title string Required. Shown on /admin/plugins
icon string An icon name, used where there is no domain logo
blurb string One line under the title. Say what it does and what it does not do
domain string A domain to fetch a logo for, if the site configured a logos service
config PluginConfig A settings form on /admin/plugins
secrets string[] Env var names. The admin shows whether each is set, never its value
fields FieldKind[] New field types. See Field types
pageTabs PageTab[] Fields on every page editor. See Page tabs
nav AdminNavEntry[] Sidebar entries
screens Record<string, AdminScreen> Components behind those entries. See Admin screens
overlays AdminScreen[] Components rendered on every admin page, for a drawer or a launcher
hosts CspHosts Origins, or a script hash, to add to the public policy
analytics AnalyticsProvider A dashboard source. See Analytics providers
assistant AssistantProvider An AI backend. See Assistant providers
consent ConsentProvider Gates the analytics beacons. See Consent
mcp McpProvider Serves /_ounce/mcp. See AI tools

config gives the plugin a card on /admin/plugins with a real form: the same field types, the same validation, the same sanitising, the same revision trail as site content.

src/plugins/intercom/index.ts
import { z } from 'zod';
import type { Plugin } from '@ouncepage/core';
const schema = z.object({
appId: z.string().trim().default(''),
hideOnMobile: z.boolean().default(false),
});
export function intercom(defaults: Partial<z.infer<typeof schema>> = {}): Plugin {
return {
name: 'intercom',
title: 'Intercom',
domain: 'intercom.com',
blurb: 'The messenger widget. Does not read conversations back into the CMS.',
secrets: ['INTERCOM_ACCESS_TOKEN'],
config: {
fields: [
{
name: 'appId',
label: 'App ID',
type: 'text',
help: 'Settings, Installation, Web. The value after app_id.',
},
{
name: 'hideOnMobile',
label: 'Hide on phones',
type: 'toggle',
},
],
schema,
defaults: { appId: '', hideOnMobile: false, ...defaults },
},
};
}

Reading it back at request time:

import { loadPluginStates } from '@ouncepage/core/plugin-store';
const states = await loadPluginStates([intercom()]);
const { enabled, value, ok } = states.intercom;

enabled is the toggle on /admin/plugins. Honour it: a disabled plugin should render nothing and fetch nothing.

ok is false when the stored row could not be read: it was not valid JSON, or its value did not survive the schema. Ounce then serves your declared defaults so nothing crashes, and reports it on /admin/plugins so it does not stay hidden. An unreadable row also arrives disabled, on the grounds that a record nobody can read is not consent to run a third-party script or spend a key. Saving from the admin repairs it.

The pattern above takes defaults as a factory argument, merged under the schema defaults. That lets a site pin a value in code while leaving it editable:

plugins: [intercom({ appId: 'abc123' })]

Do not read process.env in the factory. The factory runs at build time; the Worker environment does not exist yet. Read env from cloudflare:workers inside the methods that run per request.

import { env } from 'cloudflare:workers';
function settings(config: AnalyticsConfig) {
const value = config as Partial<Settings>;
return { siteId: value.siteId || env.FATHOM_SITE_ID || '' };
}
  • Directorysrc/plugins/intercom
    • index.ts the factory, and only the factory
    • api.ts network calls
    • Panel.astro an admin screen, if any
    • field.ts a field type, if any

One rule decides the boundary: a plugin imports from @ouncepage/core and from nothing else in your project. No import from src/site, no import from src/templates. If a plugin needs to know something about your content, it takes it as a factory argument.

That is what makes a plugin its own npm package rather than a folder, and it is checkable: grep the package for any relative import that climbs out of it, and expect nothing. The three plugins that ship with Ounce are separate packages for exactly this reason.

Four names, and they are not interchangeable.

Example Rule
Package @ouncepage/ga4-analytics @ouncepage/<slug>, in a repo called ounce-<slug>
Factory ga4() What a site writes in its config. The shortest thing that is still unambiguous
plugin.name 'ga4' The namespace. Lower case, hyphens, never changed
Provider const ga4Provider plugin.name in camel case, plus Provider

plugin.name is the one that cannot be renamed later. It is the settings key in D1 (plugin:ga4), the admin screen path (/admin/x/ga4/...), the page tab prefix, and the value the analytics switch puts in the query string. Change it and every site’s stored configuration for that plugin is orphaned: the row is still there, nothing reads it, and the plugin comes up with its defaults and no error. Treat it as permanent from the first release.

The package slug may carry a category the name does not, which is why @ouncepage/fathom-analytics registers 'fathom'. Say the same thing in both if you can.

A package registering two providers of different kinds names each for its kind: @ouncepage/ai-assistant exports aiAssistantProvider and aiMcpProvider.

Package What it supplies
@ouncepage/ai-assistant The assistant: a provider, an MCP server and the docked panel
@ouncepage/fathom-analytics Fathom Analytics, beacon and dashboard
@ouncepage/cloudflare-analytics Cloudflare Web Analytics over GraphQL

A plugin owns its environment variables too. Declare them in an env.d.ts in the package, and reference it from the entry file so a consumer gets it by importing the plugin:

src/env.d.ts
/// <reference types="@ouncepage/core/env" />
declare namespace Cloudflare {
interface Env {
FATHOM_API_TOKEN?: string;
FATHOM_SITE_ID?: string;
}
}
src/index.ts
/// <reference path="./env.d.ts" />

Declaration merging means every such file adds to the same Env interface, so a site that installs three plugins gets all of their bindings typed without listing any of them.

A plugin that needs a presence on every admin screen, rather than a screen of its own, declares an overlay:

import Panel from './Panel.astro';
export function queue(): Plugin {
return {
name: 'queue',
title: 'Send queue',
overlays: [Panel],
};
}

The engine renders an overlay only while the plugin is enabled, so the component does not check for itself. Two rules follow from that. An overlay must not import ounce:config: the site’s config imports the plugin, so reading the config from inside the plugin closes a cycle and the module fails to initialise. And an overlay gets no props, so anything it needs comes from Astro.locals or from its own endpoint.

Build the panel from Dock and Busy rather than styling your own. Dock is a fixed corner panel that already knows to lift itself above the save bar; Busy is the animated working indicator. @ouncepage/ai-assistant renders the whole assistant from those two plus Tailwind utilities, and ships no stylesheet of its own.

  • Register a route outside /admin/x/<name>/.
  • Run code at boot, or on a schedule.
  • Read or write the database outside its own plugin:<name> settings row and whatever the page-tab fields it declared put in pages.extras.
  • Replace authentication, media storage or the page model.
  • Be loaded at runtime. Composition is build-time, always.