# Plugins (/docs/guides/plugins)

Content has an input for every field type, but not for your brand palette, a map pin or a product from your PIM. A **plugin** is a web page you build and host that Content loads in a frame: as the input of a field, or as a page of its own in the dashboard. The value it sets is the field's, checked like any other, so sites and the API see nothing different.

## A plugin is a manifest [#a-plugin-is-a-manifest]

A plugin describes itself in a JSON manifest at a URL of yours:

```json title="https://plugins.example.com/brand-colour/manifest.json"
{
	"id": "brand-colour",
	"name": "Brand colour",
	"url": "https://plugins.example.com/brand-colour/",
	"locations": [
		{ "type": "field", "fieldTypes": ["text", "json"] },
		{ "type": "page", "title": "Palette" }
	],
	"permissions": ["user", "token"],
	"config": {
		"type": "object",
		"properties": {
			"palette": {
				"type": "string",
				"title": "Palette",
				"enum": ["web", "print"]
			}
		},
		"required": ["palette"]
	}
}
```

| Key           | What it is                                                                                                                                                                                              |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | A lowercase slug, unique in your Organization. Fields name it.                                                                                                                                          |
| `url`         | The page Content frames: `https`, never on obelism.studio.                                                                                                                                              |
| `locations`   | `field`: the input of fields of the `fieldTypes` it lists (any type but `blocks`). `page`: a page in the dashboard, under **Plugins**. At most one of each.                                             |
| `permissions` | What it gets beyond its own field's value: `entry:read` (the entry it is in, as it is edited), `user` (the editor's id, name and role), `token` (a [context token](#context-tokens)). Empty by default. |
| `config`      | The settings each environment gives it: string, number and boolean settings with `title`, `description`, `enum` and `required`. Never secrets: they are handed to the frame.                            |

The entry's sidebar isn't extendible: it holds what Content manages about the entry.

## Registering and turning it on [#registering-and-turning-it-on]

1. An **admin** opens **Plugins**, clicks **Register plugin**, pastes the manifest URL and clicks **Check manifest**. Content fetches the manifest (https only, to a public hostname, no redirects, 5 seconds, 64 KB) and shows what it adds, what it can read and change, and the host it loads from. **Register** registers exactly that; it is on nowhere yet.
2. A **developer** turns it on per environment under **Settings → Preview and site → Plugins**, with that environment's settings. Turn it on in develop first. An environment may load another URL of the same plugin on the registered origin (a version or branch), or `http://localhost:…` in develop, to build against real content.
3. A developer picks it as a field's **Input** in the content model. The field stores `editor: { "plugin": "brand-colour", "config": { … } }`, with up to 8 KB of settings for that field.

In a plugin's ⋯ menu, **Check for updates** fetches the manifest again and lists what changed (origin, locations, permissions, settings) before **Update** saves it. **Remove…** turns it off in every Space; fields that used it get their own input back, with their values.

`editor` is part of the model: in model files, promotions and the agent API. Whether a plugin is on isn't promoted, like tokens and webhooks. A plan for an environment where the plugin is off says so as a **warning** and applies anyway; the field has its own input there until the plugin is turned on.

## In the dashboard [#in-the-dashboard]

- A field whose plugin is on here gets the plugin's frame instead of its own input, wherever the entry form is: the entry page, the canvas's panel and [editing on your site](/docs/guides/site-editing).
- A frame loads when it comes into view (block cards start closed). After five plugin frames in one form, the next ones show a **Load** button with the plugin's name, and load when clicked.
- A plugin that is off, removed, or doesn't answer within 10 seconds leaves the field's own input, with a line saying so. The value stays as it is.
- Agents and [MCP](/docs/getting-started/agents) write the field's value directly. `GET …/plugins` (`listPlugins`) lists the plugins on in an environment.
- The [activity log](/docs/guides/activity) records plugins registered, updated, removed, turned on and off, and their settings changed.

## Building one [#building-one]

The frame is sandboxed (`allow-scripts allow-same-origin allow-forms allow-popups`) on your own origin, so it never sees Content's cookies or page. Your page must allow being framed by Content, and, for editing on your site, by your site too, since browsers check every ancestor:

```http
Content-Security-Policy: frame-ancestors https://content.obelism.studio https://www.example.com
```

[`@obelism/content-plugin`](https://github.com/Obelism/sdk/tree/main/packages/content-plugin) does the protocol:

```ts
import { connect } from '@obelism/content-plugin'

const plugin = await connect()
// plugin.context: the field, its config, the value, locale, Space, environment
// and theme; the entry and user when it may have them.
input.value = String(plugin.context.value ?? '')
input.oninput = () => plugin.setValue(input.value)
plugin.onValue((value) => (input.value = String(value ?? '')))
```

Under the hood, Content posts `{ type: 'obelism:init', protocol: 1 }` to your page's origin once, with a `MessagePort`; everything after goes over that port. The plugin sends `ready`, then `setValue` (a field's value, JSON, up to 1 MB), `height`, `openLink` and `toast`; Content sends `context`, `value` when the form changes it, and `entry` updates with `entry:read`. A plugin never saves or publishes: what it sets is an unsaved change the editor reviews and saves.

## Context tokens [#context-tokens]

With the `token` permission, `await plugin.token()` gives a five-minute JWT (ES256, `typ: obelism-plugin+jwt`) for your backend: `aud` is the plugin id, `sub` the user, with `org`, `space`, `env` and `role`, and `entry: { id, contentType }` with `entry:read`. Verify it against `https://content.obelism.studio/.well-known/plugin-jwks.json`:

```ts
import { verifyContextToken } from '@obelism/content-plugin/server'

const claims = await verifyContextToken(token, { pluginId: 'brand-colour' })
```

The token also reads the [delivery API](/docs/api/delivery) of its Space environment, drafts included, as a bearer token: while the person may read there and the plugin is still on. Turning the plugin off ends every token it was given.
