Setting Up Your Development Environment
This guide covers how to set up your development environment for plugin development.
A plugin in Neleto is just a normal webserver. This means you can write plugins in any programming language.
The only 2 requirements for your webserver to work as a plugin are:
- the
plugin.jsonmanifest file. This file defines the name, version, start-command (bin), and metadata of your plugin. Here is a minimal example:
{
"name": "example-plugin",
"version": "0.1.0",
"bin": "bun run main.js"
}
- the packaged plugin needs to include everything needed to run it under an
x86 ubuntu 24.04 docker container. This includes runtimes for programming
languages like javascript, meaning you must bundle them with your plugin. In
the case of Bun you can use the
--compilewhen building your plugin to include the bun runtime.
Runtime contract
When Neleto starts your plugin it sets a few environment variables. Your server must read them rather than hard-coding values:
| Variable | Always set | What it is |
|---|---|---|
PORT | yes | The port your webserver must listen on. Bind 0.0.0.0:$PORT. |
DATABASE_URL | yes | A PostgreSQL connection string for your plugin's own database (see below). |
Listen on PORT, binding all interfaces:
const port = Number(process.env.PORT) || 3000;
Bun.serve({ port, fetch: handler });
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
Your plugin's database
Every plugin gets its own PostgreSQL database, separate from the CMS and from
other plugins. The connection string is injected as DATABASE_URL — you don't
provision anything. Create your tables by running your migrations on startup:
let db = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
sqlx::migrate!("./migrations").run(&db).await?; // ship migrations in the package
Include your migrations directory in include_files so it ships in the package.
Reading CMS content
To read or write the site's pages, posts, files and so on, call the CMS from your plugin over its REST API (or let an agent use the MCP tools). That is the supported, stable interface for content — don't reach into the CMS's own database.
Logging
Write to stdout / stderr. Neleto captures both and shows them in the plugin's Logs tab in the admin (see Managing Plugins), which is the first place to look when a plugin misbehaves.
The manifest file plugin.json
To integrate your plugin more with Neleto, you can supply more values in the
plugin.json. The plugin.json Manifest file needs to conform to this
typescript type:
export type ApiEndpoint = {
/**
* If omitted, all logged-in users can access this route. Each entry is a
* permission id like `"page.update"`; the user needs ANY of them (OR semantics).
*/
allowed_permissions?: Array<string>;
/**
* This is the route, in a format like this `/foo/:bar/:somepath*`
*/
route: string;
};
export type PluginHelper = {
/**
* The name of the helper
*/
name: string;
/**
* The route, where the helper is located, this route will get the arguments as input and should return the value
*/
route: string;
} & (
| {
type: "inline";
/**
* The argument types this helper expects (length = number of arguments).
* Accepts either `{ name, type }` objects or a bare list of types,
* e.g. `["String", "Int"]`.
*/
arguments: Array<FunctionArgument | VariableType>;
/**
* The type of the return value of this helper
*/
return_value: VariableType;
}
| {
/**
* A block helper, like the `if` helper: `{{#if <arguments>}}<body>{{/if}}`
*/
type: "block";
arguments: Array<VariableType>;
}
);
export type FunctionArgument = { name: string; type: VariableType };
export type PluginManifest = {
/**
* The name of the plugin
*/
name: PluginName;
/**
* The version of the plugin (semver)
*/
version: string;
/**
* Everything needed to run your plugin — executables, dynamically loaded
* libraries, assets, bundled runtimes — that must be included in the built
* package. Glob patterns are allowed. Required.
*/
include_files: Array<string>;
/**
* The command to start the plugin
*/
bin?: string | null;
/**
* Rewriter is a route your plugin handles. this route will be called with POST whenever a
* page is rendered, with the rendered html in the request body, and the response of this
* route will be forwarded to either the next plugin, or the render output
*/
rewriter?: string;
/**
* Here you can define routes which function as handlebars helpers when rendering
*/
helpers?: Array<PluginHelper>;
/**
* Here you can define routes which are used as pages when a user visits the website. These
* routes do not have the prefix in front of their path when called.
*/
pages?: Array<PluginPage>;
/**
* Here you can define routes which are used as authenticated api endpoints. The key is the
* route, in a format like this `/foo/:bar/:somepath*`. These routes have the prefix
* `/api/rest/plugins/<your-plugin-name>/api`, when called from the client, as they get
* proxied through the cms backend.
*/
api?: Array<ApiEndpoint>;
/**
* This is the path to where your plugin defines presets for components, this path needs to be
* included in your built plugin package Components need to be provided in folders with these
* files inside: `template.hbs`, `script.js`, `style.css`, `.meta.json` (required),
* `form.json` (required)
*/
components?: string | null;
/**
* This is the path to where your plugin defines presets for page-layouts, this path needs to
* be included in your built plugin package Layouts need to be provided in folders with these
* files inside: `template.hbs`, `script.js`, `style.css`, `.meta.json` (required)
*/
layouts?: string | null;
/**
* Routes used to render dashboard cards shown in the cms dashboard.
*/
dashboard_cards?: Array<PluginDashboardCard>;
/**
* This route will be used as the entrypoint to the ui of your plugin. These routes have the
* prefix `/api/rest/plugins/<your-plugin-name>/ui`. The access to these ui routes is only
* restricted to users who can also manage plugins.
*/
ui?: string | null;
/**
* Path (relative to the plugin root) to a hero image bundled in the package, shown as a wide
* banner on the plugin's card and detail page in the plugin store. Recommended: a ~3:1
* banner, e.g. 1600×540, PNG/JPG/WebP/SVG, ≤ 500 KB.
*/
hero_image?: string | null;
};
export type PluginName = string;
export type PluginPage = {
/**
* The name of the page
*/
name: string;
/**
* This is the route, in a format like this `/foo/:bar/:somepath*`
*/
route: string;
/**
* Allow selecting a layout for this page in the cms. When a layout is selected the page is
* rendered inside it (see First Steps -> Page-endpoint). Defaults to false.
*/
allow_select_layout?: boolean;
/**
* Generate tailwind classes for the selected layout. Has no effect if allow_select_layout is
* not enabled.
*/
enable_tailwind?: boolean;
};
export type PluginDashboardCard = {
/**
* Unique (within this plugin) id for the card. Not shown to the user; used to persist which
* cards a user chose to display, so it must never change.
*/
name: string;
/**
* Label shown when the user selects which dashboard cards to display.
*/
label: Translated;
/**
* Optional longer description shown to the user.
*/
description?: Translated | null;
/**
* The route your plugin exposes to render the entire dashboard card.
*/
route: string;
};
/** A translated string, keyed by language code (`en` and `de` required). */
export type Translated = { en: string; de: string } & Record<string, string>;
/** The type system used by helper arguments and return values. */
export type VariableType =
| "String"
| "Number"
| "Int"
| "Float"
| "Boolean"
| "Any"
| "Never"
| "Nullish"
| { Object: Record<string, VariableType> }
| { Array: VariableType }
| { Literal: string }
| { IntLiteral: number }
| { BooleanLiteral: boolean }
| { Option: VariableType }
| { Union: Array<VariableType> }
| { Record: { key: VariableType; values: VariableType } }
| { Named: string }
| { Function: { name: string; args: Array<FunctionArgument | VariableType>; return_type: VariableType } };
Schema & editor validation
The TypeScript type above is for reading. The authoritative, machine-readable schema is published as JSON Schema (Draft 2020-12) and is always generated from the backend, so it never drifts:
| Schema | URL |
|---|---|
Plugin manifest (plugin.json) | https://neleto.io/schema/plugin/v1/manifest.json |
Component form (form.json) | https://neleto.io/schema/plugin/v1/form.json |
| Helper variable types | https://neleto.io/schema/plugin/v1/variable-type.json |
| Plugin store index | https://neleto.io/schema/plugin/v1/repository.json |
Add a $schema key to your plugin.json to get autocompletion and inline
validation in editors like VS Code:
{
"$schema": "https://neleto.io/schema/plugin/v1/manifest.json",
"name": "example-plugin",
"version": "0.1.0",
"include_files": ["main"]
}
name, version and include_files are required. include_files lists
everything (executables, assets, runtimes) that must be bundled into the packaged
plugin — a manifest without it will not validate.Routes with this format /foo/:bar/:somepath* follow these rules:
| Pattern | Kind | Description |
|---|---|---|
:name | Normal | Matches a path piece, excludes / |
:name? | Optional | Matches an optional path piece, excludes / |
/:name?/ /:name? | OptionalSegment | Matches an optional path segment, excludes /, prefix or suffix should be / |
+ :name+ | OneOrMore | Matches a path piece, includes / |
* :name* | ZeroOrMore | Matches an optional path piece, includes / |
/*/ /* /:name*/ /:name* | ZeroOrMoreSegment | Matches zero or more path segments, prefix or suffix should be / |
| Case | Parameters |
|---|---|
:a:b | a b |
:a:b? | a b |
:a-:b :a.:b :a~:b | a b |
:a_a-:b_b | a_a b_b |
:a\\: :a\\_ | a |
:a\\::b :a\\_:b | a b |
:a* | a |
* | *1 |
*.* | *1 *2 |
:a+ | a |
+ | +1 |
+.+ | +1 +2 |
/*/abc/+/def/g | *1 +2 |