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.
Minimum plugin
Section titled “Minimum plugin”import type { Plugin } from '@ouncepage/core';
export function hello(): Plugin { return { name: 'hello', title: 'Hello', };}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 |
Settings form
Section titled “Settings form”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.
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.
Factory arguments
Section titled “Factory arguments”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 || '' };}File layout
Section titled “File layout”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.
Naming
Section titled “Naming”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:
/// <reference types="@ouncepage/core/env" />
declare namespace Cloudflare { interface Env { FATHOM_API_TOKEN?: string; FATHOM_SITE_ID?: string; }}/// <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.
Rendering into the admin
Section titled “Rendering into the admin”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.
Limits
Section titled “Limits”- 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 inpages.extras. - Replace authentication, media storage or the page model.
- Be loaded at runtime. Composition is build-time, always.