Build a module
Every feature in Celerp is a module, and the built-in ones (inventory, accounting, manufacturing and the rest) use the same loader your module will. A module is a Python package in a folder. Celerp finds it, runs its migrations, mounts its API and UI routes, and adds its entries to the app. You do not fork Celerp and there is no build step.
This page covers everything needed to build, test, install and list a module. You need Python 3.10 or newer and working knowledge of FastAPI and SQLAlchemy.
No code? Build one with ChatGPT or Claude.
Start from the template
Clone celerp-module-template. It holds a working "Equipment Maintenance" module (acme-maintenance) with a search provider, file uploads, two migrations and a full test suite, plus lint.py, the manifest checker described under Test it. Rename it and edit it into your own module, or start from the smaller example below.
Anatomy of a module
A module is a folder whose __init__.py defines PLUGIN_MANIFEST, with an inner Python package holding the code. The folder name is the module's name: it is what gets enabled, and it should match the manifest's name.
acme-notes/ the module; hyphens are fine here
__init__.py PLUGIN_MANIFEST and nothing else
acme_notes/ inner package; imported as acme_notes
__init__.py
models.py tables, on Celerp's shared Base
routes.py API routes; defines setup_api_routes(app)
ui_routes.py UI pages; defines setup_ui_routes(app)
migrations/
__init__.py
acme_notes_001_create_note.py
locales/
en.json translation strings
Celerp runs as two processes. The API (FastAPI) calls your setup_api_routes(app). The UI (FastHTML) calls your setup_ui_routes(app), and its pages reach the API as the signed-in user through api_request (see The module API). Each module folder is put on the Python path, so the inner package imports by its own name.
A complete example
This module adds a Notes page to the sidebar under Operations, stores notes per company, and lets anyone who can view documents read them and anyone who can edit documents add one. Every file is shown in full. Copy them into the layout above and it runs as described under Run it while you build.
acme-notes/__init__.py
PLUGIN_MANIFEST = {
"name": "acme-notes",
"version": "0.1.0",
"display_name": "Notes",
"description": "Short notes for the whole company.",
"author": "Acme",
"license": "MIT",
"min_celerp_version": "2.0.0",
"api_routes": "acme_notes.routes",
"ui_routes": "acme_notes.ui_routes",
"slots": {
"nav": [{
"key": "notes",
"label": "Notes",
"label_key": "acme_notes.nav",
"href": "/notes",
"group": "Operations",
"order": 60,
"permission": "view_documents",
}],
},
"table_prefix": "acme_",
"migrations": "acme_notes.migrations",
"company_backup": {"acme_note": "include"},
"locales": {"en": {"file": "locales/en.json", "rtl": False}},
}
acme-notes/acme_notes/__init__.py and acme-notes/acme_notes/migrations/__init__.py are empty files.
acme_notes/models.py
import uuid
from datetime import datetime, timezone
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from celerp.models.base import Base
class Note(Base):
__tablename__ = "acme_note"
id: Mapped[uuid.UUID] = mapped_column(sa.Uuid(as_uuid=True), primary_key=True,
default=uuid.uuid4)
company_id: Mapped[uuid.UUID] = mapped_column(
sa.ForeignKey("companies.id"), nullable=False, index=True)
body: Mapped[str] = mapped_column(sa.Text(), nullable=False)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc))
acme_notes/migrations/acme_notes_001_create_note.py
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
# Runs on every start, so it checks before it creates.
if "acme_note" in sa.inspect(op.get_bind()).get_table_names():
return
op.create_table(
"acme_note",
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column("company_id", sa.Uuid(as_uuid=True),
sa.ForeignKey("companies.id"), nullable=False, index=True),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
acme_notes/routes.py
Every query filters on the current company. Celerp holds many companies in one database, and a module that forgets the filter shows one company's data to another.
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from celerp.db import get_session
from celerp.services.auth import get_current_company_id, get_current_user
from celerp.services.permissions import require_permission
from acme_notes.models import Note
# Reading needs view_documents; adding a note also needs edit_documents.
router = APIRouter(prefix="/api/acme-notes", tags=["acme-notes"],
dependencies=[Depends(get_current_user),
require_permission("view_documents")])
class NoteIn(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True) # a note of only spaces is refused
body: str = Field(min_length=1, max_length=2000)
@router.get("")
async def list_notes(company_id=Depends(get_current_company_id),
session: AsyncSession = Depends(get_session)) -> dict:
rows = (await session.execute(
select(Note).where(Note.company_id == company_id)
.order_by(Note.created_at.desc()))).scalars().all()
return {"items": [{"id": str(n.id), "body": n.body,
"created_at": n.created_at.isoformat()} for n in rows]}
@router.post("", status_code=201, dependencies=[require_permission("edit_documents")])
async def add_note(payload: NoteIn, company_id=Depends(get_current_company_id),
session: AsyncSession = Depends(get_session)) -> dict:
note = Note(company_id=company_id, body=payload.body)
session.add(note)
await session.commit()
return {"id": str(note.id)}
def setup_api_routes(app) -> None:
app.include_router(router)
acme_notes/ui_routes.py
The page shows a neutral message when the API cannot be reached, says so when the user's role cannot view notes, and shows a plain error when a save is refused. It never shows made-up data. The Add form appears only to users whose role has edit_documents. The page reads the user's current role and the company's settings from /companies/me and checks them with the same helper the API uses, so a role changed since sign-in takes effect at once; when they cannot be read, the form stays hidden. The API still refuses a note from anyone else.
from fasthtml.common import Button, Div, Form, Input, Li, P, Ul, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse
from celerp.modules.api import api_request
from celerp.services.permissions import role_has_permission
from ui.components.shell import base_shell, page_header
from ui.i18n import t
API = "/api/acme-notes"
async def _call(request: Request, method: str, path: str = API, *, json=None) -> tuple[int, dict]:
"""Call the API as the signed-in user. Status 0: it could not be reached."""
try:
r = await api_request(request, method, path, json=json)
return r.status_code, r.json()
except Exception: # unreachable, or a reply that is not JSON
return 0, {}
async def _can_add(request: Request) -> bool:
"""Whether the signed-in user may add notes, by their current role and the company's settings."""
status, company = await _call(request, "GET", "/companies/me")
if status != 200:
return False
return role_has_permission(company.get("settings") or {}, company.get("current_role") or "",
"edit_documents")
def _notes(status: int, payload: dict, message: str = "") -> Div:
if status == 403:
body = P(t("acme_notes.forbidden"))
elif status != 200:
body = P(t("acme_notes.load_error"))
elif not payload.get("items"):
body = P(t("acme_notes.empty"))
else:
body = Ul(*[Li(n["body"]) for n in payload["items"]], cls="list-disc")
return Div(P(message) if message else "", body, id="acme-notes")
def setup_ui_routes(app) -> None:
@app.get("/notes")
async def notes_page(request: Request):
status, payload = await _call(request, "GET")
if status == 401:
return RedirectResponse("/login", status_code=302)
form = Form(
Input(name="body", placeholder=t("acme_notes.placeholder"), cls="form-input"),
Button(t("acme_notes.add"), type="submit", cls="btn btn--primary"),
hx_post="/notes/add", hx_target="#acme-notes", hx_swap="outerHTML",
) if await _can_add(request) else ""
return await base_shell(
page_header(t("acme_notes.title")), form, _notes(status, payload),
title=t("acme_notes.title") + " - Celerp", nav_active="notes", request=request,
)
@app.post("/notes/add")
async def notes_add(request: Request):
form = await request.form()
status, _ = await _call(request, "POST", json={"body": str(form.get("body", ""))})
message = "" if status == 201 else t("acme_notes.save_error")
return HTMLResponse(to_xml(_notes(*await _call(request, "GET"), message)))
locales/en.json
{
"acme_notes.nav": "Notes",
"acme_notes.title": "Notes",
"acme_notes.placeholder": "Write a note",
"acme_notes.add": "Add",
"acme_notes.empty": "No notes yet.",
"acme_notes.forbidden": "Your role cannot view notes.",
"acme_notes.load_error": "Notes could not be loaded. Try again in a moment.",
"acme_notes.save_error": "The note was not saved. It may be empty, or your role may not allow adding notes."
}
Manifest reference
PLUGIN_MANIFEST must be a plain literal: strings, numbers, booleans, lists and dicts only, with no variables, function calls or f-strings. Checking a module and running it are separate steps. Import Module on the Modules page, lint.py, and Celerp's own check at startup read PLUGIN_MANIFEST and the module's files as text, without running any of the module's code. Enabling a module is what runs it: Celerp then runs the module's migrations and imports its Python code inside the Celerp process, where it can do anything Celerp itself can. That happens each time Celerp starts, for every module in the load set (enabled for any company, or listed in ENABLED_MODULES), including one copied into the modules folder by hand rather than imported. From Celerp 2.5.4, the PLUGIN_MANIFEST left after the import must be exactly the literal in the file, or the module does not load. These are the keys Celerp reads. Celerp ignores any other key, so a misspelled key has no effect. lint.py reports unknown keys.
| Key | Required | Type | What it does |
|---|---|---|---|
name | Yes | string | The module's identity. Match the folder name. Letters, digits, - and _, starting with a letter or digit, at most 64 characters. Names starting with celerp- are reserved for Marketplace modules, and the upload refuses them. A module of your own needs a name without the celerp- prefix. |
version | Yes | string | Shown on the Modules page. Use major.minor.patch. |
display_name | No | string | The name shown on the Modules page. Defaults to label, then to name. |
label | No | string | Used as the display name when display_name is absent. |
description | No | string | One or two sentences, shown on the Modules page. |
author | No | string | Shown on the Modules page. |
license | No | string | An SPDX identifier such as MIT. |
min_celerp_version | No | string | The oldest Celerp release the module works on. See Versions and dependencies. |
depends_on | No | list of strings | Module names that must be enabled and loaded first. |
api_routes | No | dotted module path | A module defining setup_api_routes(app). |
ui_routes | No | dotted module path | A module defining setup_ui_routes(app). |
slots | No | dict | The places in Celerp the module extends. See Slots. |
table_prefix | When the module has tables | string | Every table the module owns starts with it. At least 3 characters and ending in _. No table Celerp keeps for itself may start with it, so label_, marketplace_ and bank_ are taken, and it may not overlap another installed module's prefix: neither prefix may be a prefix of the other. From Celerp 2.5.4 these rules apply whenever the key is present, with or without migrations, and a value of None is refused; earlier releases check it only alongside migrations. Leave the key out if the module has no tables. |
migrations | No | dotted package path | The folder of migration files run at startup on PostgreSQL. Requires table_prefix. |
company_backup | When the module has tables | dict | Each table name mapped to "include" or "exclude". See Tables, migrations and backups. |
locales | No | dict | Translation files. See Translations. |
Only name and version are required to load. lint.py also asks for display_name and license, and a listing needs them. Keys some older examples carry, such as icon, min_role, requires and soft_depends, are not read. Celerp never installs Python packages for a module, so a module can only import what the Celerp install already has.
Slots
slots maps a slot name to what the module contributes there. A slot that takes a list also accepts a single dict. Any module may fill any slot except inventory_in_production, which only Celerp's own modules fill. A slot name Celerp does not read is an error: the check before any module code runs refuses the manifest, and the module does not load. Use only the slot names in the table below; lint.py reports any other.
Any slot entry may carry permission, a key from the permission list. From Celerp 2.5.4 the loader checks every entry in every slot, and a module that breaks one of these rules does not load: each entry is a dict; permission and write_permission name keys from that list; requires_connector, when set, is a string, and an unknown connector id hides the entry; a destination Celerp links to (nav href and settings_href, bulk_action form_action, item_action href_template) is a path inside Celerp, starting with a single /, with no backslash or control character; and a handler or render names a function in the module's own files, an async def exactly where the table below shows one. Where the table says an entry is filtered, Celerp hides it from users whose role lacks the permission and from companies that have the module turned off. Hiding a button is not a gate: the route it calls must still check the permission itself.
| Slot | Keys | What it does |
|---|---|---|
nav | key, label, label_key, href, order (default 99, lower sorts first), group, permission, settings_href | A sidebar entry, under group if given. label_key is translated; label is used when label_key is not set. key is the value a page passes as nav_active to highlight it. settings_href puts a settings link on the group heading. Filtered. |
search_provider | handler, result_key, permission, all three required | Adds results to the global search. handler is "package.module:function" naming async def search(session, company_id, role, q, limit=5), which returns {result_key: [rows]}; result_key is "items" or "entries". Each row is {"id", "label", "href", "subtitle"}, with subtitle optional and href a path inside the app. Raise on failure rather than returning an empty list. One per module, and any other key stops the module from loading. A handler that is not an async def in the module's own files stops it too. Filtered by permission. |
bulk_action | form_action (required), label or label_key, permission, requires_connector, action_type | A button on the inventory list for selected items. The POST to form_action carries each selected item id as a repeated selected field. By default the request is made in place and your HTML response replaces the element with id bulk-action-result. With action_type: "navigate" the form posts to a new tab. requires_connector shows it only when that connector is set up. Filtered. |
item_action | href_template, label or label_key, permission | A link on the item detail page. {entity_id} in href_template is replaced with the item id. From Celerp 2.5.4, href_template must be a path inside Celerp, starting with a single /, with no backslash or control character, and with braces only around {entity_id}; a module that breaks this does not load. Filtered from Celerp 2.5.4; older releases show it to everyone. Either way, the target page must check permissions. |
doc_detail_actions, doc_detail_badges | render, permission | Extra buttons or badges on the document detail page. render is "package.module:function", called with the document as a dict and returning a FastHTML element or None. An error in it hides the element rather than breaking the page. Filtered from Celerp 2.5.4, the same version as item_action; older releases show them to everyone. Either way, the target page must check permissions. |
category_schema | category, fields | The default attribute fields for an item category, used until a company saves its own. |
on_company_created | handler | async def handler(session, company_id), called when a company is created. A failure is logged, its own database changes are undone, and company creation carries on. |
on_modules_ready | handler | async def handler(session), called once at API startup after every module has loaded, once data is current. A failure is logged and its own database changes are undone; startup carries on. |
pricing_action | href_template (required), label or label_key, permission, requires_connector, show_on, presentation | A link on rows of the item's Pricing tab, one per price list. href_template must be a path inside Celerp, starting with a single /, with no backslash or control character. It may use {entity_id}, {price_list} (the price list name) and {field_name} (the item field holding that price, such as retail_price); each value is URL-encoded, and any other placeholder, an unmatched brace or a key not listed here stops the module from loading. show_on is a list of row traits, and the link shows only on rows that have all of them: editable or readonly (for the current user), sell or cost, manual or derived (calculated from another list). Leave it out to show on every row. Any other value, or both traits of one pair, stops the module from loading. presentation, if given, must be "page": the link opens your page. requires_connector shows it only when that connector is set up. Needs Celerp 2.5.4 or later; set min_celerp_version to "2.5.4", since older releases ignore this slot and show nothing. Filtered. |
Celerp's own modules also use the send_to_targets, catalog_channel, projection_handler, doc_finalize_hook, on_doc_payment, inventory_in_production and item_lineage_guard slots. inventory_in_production is filled by Celerp's own modules only, and Celerp refuses any other module that fills it. The others follow the same entry rules: a projection_handler entry names the event-type prefix it handles and a plain handler function, and doc_finalize_hook and on_doc_payment handlers are async def. Each event type has one handler, so no projection prefix may start with another: not two of a module's own, not one another enabled module already handles, and not Celerp's own sys., mp. or shop.sync.; Celerp's own modules claim their prefixes first, then other modules in name order.
Permissions
Permissions are a closed list. A module cannot add its own, and it uses the same keys Celerp does, so a company's role settings govern module features too. Gate API routes with require_permission("key") from celerp.services.permissions, as the example does. Inside a handler, role_has_permission(company_settings, role, "key") from the same module returns a boolean. In a UI page, take role and the settings from GET /companies/me (its current_role and settings) rather than from the sign-in cookie, and show a control only when the check passes, as the example does.
| Area | Keys |
|---|---|
| Viewing | view_dashboards, view_documents, view_contacts, view_inventory, view_inventory_costs, view_payments, view_financial_reports, view_subscriptions |
| Documents | edit_documents, finalize_documents, fulfill_documents, delete_documents, set_sales_doc_prices, record_payments |
| Contacts | edit_contacts |
| Inventory | edit_inventory, edit_inventory_amounts, set_inventory_prices, adjust_inventory, revert_items_to_draft, manage_labels, manage_manufacturing |
| Accounting | manage_accounting |
| Data and tools | import_export_data, run_backups, use_ai_assistant |
| Administration | manage_module_settings, manage_users, manage_company_settings, manage_integrations |
| Company owner only | manage_permissions, manage_company_lifecycle, manage_billing |
For a module's own settings page, use manage_module_settings.
Tables, migrations and backups
Module tables inherit from celerp.models.base.Base and live in the same database as Celerp. Give each one a company_id column and filter every query on it.
Migrations
- At startup, before modules load, Celerp runs the files in each enabled module's
migrationsfolder, in filename order. Files whose names start with_are skipped. Name them so they sort in the order they must run, for example with a number after the module name. - Each file defines
upgrade()and usesopfromalembic(op.create_table,op.add_columnand so on). There is nodowngrade()and no revision chain. - Celerp does not record which files have run. Every file runs on every start, so each step checks before it acts, as the example does with
sa.inspect(op.get_bind()). op.create_table,drop_table,add_column,drop_column,alter_column,create_index,drop_indexandrename_tablerefuse a table outside the module'stable_prefix. This catches mistakes; it is not a sandbox. From Celerp 2.5.4, if the prefix itself breaks the rules in the manifest reference, none of the module's migrations run and the module counts as failed, including when it was copied into the modules folder by hand.- A failed migration is rolled back, the module stays unloaded for that start with the error shown on the Modules page, and the rest of Celerp starts normally. Each SQL statement, including any wait for a lock, has 30 seconds.
- Migrations run on PostgreSQL, which is what Celerp uses. On any other database they are skipped.
- After modules load, Celerp creates any model table that does not exist yet. A brand-new table therefore appears even without a migration, but only a migration can change a table that has already shipped.
Company backups
From Celerp 2.5.4, a company backup refuses to run if it finds a table with a company_id column that it cannot account for. A table with the module's prefix is always checked. A table with no matching prefix and no company_id column is left out of the backup without a message. So once a module has tables:
- Set
table_prefixand start every table name with it. From Celerp 2.5.4 a prefix that breaks the rules or overlaps another module's owns no tables, so the backup refuses on them. - List every table in
company_backupas"include"(company data that belongs in the backup) or"exclude"(caches and anything rebuilt on its own). A missing table stops the backup with an error that names it. - An included table needs a
company_idcolumn and a single UUID or text primary key, or a primary key ofcompany_idfollowed by one text column. A foreign key must point at thecompaniestable or at another included table.
Removing data
On the Modules page, the installation owner's Delete and purge data removes the module and drops every table whose name starts with its table_prefix. It refuses to run while any company has the module enabled, or until Celerp has restarted after the last company turned it off, and from Celerp 2.5.4 it also refuses, deleting nothing, when the prefix breaks the rules or clashes with Celerp's tables or another module's prefix. A module with no table_prefix has nothing to drop. Choose a prefix that only your tables use.
Translations
locales maps a language code to a file inside the module folder and whether the language is written right to left:
"locales": {
"en": {"file": "locales/en.json", "rtl": False},
"th": {"file": "locales/th.json", "rtl": False},
}
Each file is one flat JSON object of key to text. In UI code, t("key") from ui.i18n returns the text in the user's language, falls back to English, and falls back to the key itself. A nav entry's label_key is translated the same way. Celerp's own text wins for any key it already has, so start every key with your module's name. A malformed entry is logged and skipped; the module still loads.
Run it while you build
Use an install of Celerp you can stop and start, with its own company, rather than one holding real data. Celerp reads module code once at startup, so every change to a module needs a restart.
With the command-line install
- Install Celerp and pick a data folder for development:
pip install celerp export CELERP_DATA_DIR="$HOME/celerp-dev" export CELERP_CONFIG="$HOME/celerp-dev/config.toml" - Put the module inside the data folder's
modulesfolder:mkdir -p "$CELERP_DATA_DIR/modules" cp -r acme-notes "$CELERP_DATA_DIR/modules/" - Run
celerp init --embedded. It sets up a separate database and starts Celerp. Open the link it prints, create the owner account and company, then open Modules in the sidebar. Your module is listed there. Enable it.On macOS, run
pip install celerp-postgresfirst (macOS 26 or later). Or use an existing PostgreSQL: create an empty database and runcelerp init --db-url postgresql://user:password@localhost/celerp_devinstead ofcelerp init --embedded. - Stop Celerp (Ctrl+C) and run
celerp start. The sidebar now shows the module's entry.
From then on, edit the files in $CELERP_DATA_DIR/modules/acme-notes and restart. Keep both CELERP_DATA_DIR and CELERP_CONFIG set in every shell you start Celerp from. Without them Celerp uses the configuration and database of any Celerp already installed on the machine.
With the desktop app
Put the module folder in the app's modules folder, then enable it on the Modules page and restart the app:
- macOS:
~/Library/Application Support/Celerp/celerp-data/modules/ - Linux:
~/.config/Celerp/celerp-data/modules/ - Windows:
%APPDATA%\Celerp\celerp-data\modules\
That folder also holds Celerp's own modules. Leave them as they are.
When it does not load
A third-party module that fails to load does not stop Celerp. From Celerp 2.5.4, Celerp checks each enabled module by reading its files before any of its code runs, and a module that fails a check runs nothing, not even its migrations. A module that passes runs its migrations and code in the Celerp process. The check catches mistakes; it does not make untrusted code safe to run. A module whose routes fail to start is switched off in both the API and the UI, together with every module that depends on it. The Modules page lists such a module as failed with the reason: a manifest error, an unknown slot name, a missing depends_on module, an unmet min_celerp_version, a slot entry that breaks the rules above, a slot filled by Celerp's own modules only, a projection prefix that overlaps one already handled, a route module or setup function outside the module folder, a table outside the module's prefix, a route or package name that clashes with another module or with Celerp, a refused import, an import error in your code, a route that failed to start, or a failed migration. Run lint.py first to catch most of these before a restart.
Test it
The template carries two kinds of check.
lint.py checks a module folder without importing it: required manifest fields, unknown manifest keys, unknown slot names, which Celerp refuses, the characters and reserved prefix of the module name, the folder name, that api_routes and ui_routes name a file in the module that holds its own setup function, that the migrations package is inside the module, the min_celerp_version format, every slot entry against the rules Celerp enforces from 2.5.4 (it applies them whichever release you target), every top-level manifest field against the one type Celerp reads it as, the shape of table_prefix, that it claims no Celerp table, and that a module with migrations declares one, that every table is listed in company_backup, protected imports, and FastHTML elements rendered with str() instead of to_xml(). It needs only Python. It reports two kinds of finding, problems Celerp refuses and parts of the manifest Celerp ignores, and exits with 1 on either, with 0 when the folder is clean, or with 2 when it is called with the wrong arguments. Where it cannot follow a handler without running it, such as a decorated function, it refuses rather than guesses, and it cannot see other installed modules' prefixes, so it checks projection prefixes only against each other and against Celerp's own.
python lint.py path/to/acme-notes
The module test suite runs the module's own API and UI routes on test apps against SQLite, with Celerp's sign-in and company endpoints stubbed. A request to a page still goes through the module's real render, its real API call and a real database write. To run the template's suite, put a Celerp source checkout next to the template, at the commit pinned in the template's .github/workflows/ci.yml. The template's tests are written against that commit, and a different one can fail a check that has nothing to do with your module:
git clone https://github.com/celerp/celerp.git
git clone https://github.com/celerp/celerp-module-template.git
grep ref: celerp-module-template/.github/workflows/ci.yml
git -C celerp checkout COMMIT_FROM_THE_LINE_ABOVE
pip install -e ./celerp
pip install pytest pytest-xdist aiosqlite
cd celerp-module-template
python lint.py acme-maintenance
pytest tests
PYTHONPATH=../celerp:acme-maintenance pytest acme-maintenance/tests
pytest tests checks lint.py itself. The last command runs the module's own tests. To test your module the same way, copy acme-maintenance/tests/conftest.py into your module's tests folder. Its header names the three things to change: the imports at the top, MODULE_TABLE_PREFIX, and the stub company if your pages read other company fields. The template's tests show how to cover the empty state, an unreachable API, a refused permission and a second company that must not see the first one's data.
Install a finished module
The installation owner adds a module with Import Module on the Modules page, choosing a zip file or a folder. Import reads the module's files without running its code, and checks:
- The zip is at most 50 MB and unpacks to at most 200 MB. Symbolic links are refused.
- The name follows the rules in the manifest reference and is not already installed. To replace a version, remove the old one first.
- The running Celerp is at least
min_celerp_version. - A module with
migrationssetstable_prefix, and atable_prefixthat is set is valid and does not clash with Celerp's tables or another module's prefix.
Import does not turn the module on. A company's admin enables it for that company on the same page. If no company was using it yet, it starts working after the installation owner restarts Celerp.
Who can do what
One Celerp install can hold several companies, and an installed module's code runs for all of them. So the steps that change the install itself are for the installation owner only: the account that set up the install, or the user it has handed that role to on the Users page. Company admins control only their own company's use of the modules already installed, which needs the manage_company_settings permission.
| Action | Who |
|---|---|
| Import a module, or buy and install one from the marketplace | Installation owner |
| Delete a module (keeping its data), or delete it and purge its data | Installation owner |
| Restart Celerp | Installation owner |
| Enable or disable an installed module for a company | That company's admins |
Each company chooses its modules on the Modules page; until a company changes its list, it uses every module enabled on the install. Celerp loads a module when at least one company has it enabled, together with the modules it depends on, and enabling a module for a company also enables its dependencies there. A module that another module enabled in the same company needs cannot be disabled until that one is. Disabling takes effect at once, with no restart: the company no longer sees the module's pages, menu entries, buttons or search results, and the module's routes refuse that company's requests, even while another company still uses it. Users who may not take an action are not shown its control, and Celerp refuses the action if it is attempted anyway. For your own module's buttons, hiding is not enough: gate the route with require_permission.
The module API
celerp.modules.api is the public surface for module code. It has three helpers, available from Celerp 2.5.4:
await api_request(request, method, path, *, json=None, params=None)calls Celerp's own API as the user signed in onrequestand returns anhttpx.Response.pathis a path inside Celerp, such as/companies/me; Celerp supplies the address, the sign-in and a fixed timeout, and refuses any other destination. It sends a JSON body or query parameters only, so send a file as base64 inside JSON.read_resource(module_file, relative_path)returns the bytes of a file shipped with the module. Call it asread_resource(__file__, "templates/invoice.html")from the file the path is relative to; it reads only from that file's folder or below. It is read only. Data the module changes belongs in the database or in Celerp's data directory.await ai_query(query, company_id, session_token=None, db_session=None)runs an AI query through Celerp's AI service and returns a dict withanswer,model_usedandtools_called. Call it asawait ai_query(query, company_id, db_session=session)with the request's database session: the query runs only for that request's company and for a role allowed to use the AI assistant. See the module AI API.
Three rules follow for every module:
- Network. Reach Celerp through
api_request. A module that makes any other network call (its own HTTP client, a socket, another service's API) is reviewed by Celerp before the Community catalog admits it, and its listing must declare those calls. - AI. Use
ai_queryfor every AI feature. Independent AI inference is not an approvable integration: a module that runs a model or calls an AI service by any other route is not admitted. - Dependencies. Celerp installs no dependencies for a module. A module runs on the packages the Celerp installation already provides and ships no
requirements.txt;lint.pyreports one anywhere in the module folder.
Modules run inside the Celerp process and are not sandboxed, which is why these rules decide what is listed.
What module code may import
Modules run inside the Celerp process, with the same access to its data and files as Celerp itself, and may use its public pieces: celerp.db.get_session, the auth dependencies in celerp.services.auth, celerp.services.permissions, celerp.models.base.Base, and the UI helpers in ui.components and ui.i18n. A third-party module may not import celerp.session_gate, celerp.ai or anything under it, celerp.gateway or celerp.connectors; Celerp refuses to load a module it finds importing them. Use the module API instead.
List it or sell it
Say what it touches
A listing can state, in plain words, what the module touches. These are the listing's data_access and network_calls fields:
- Data access: which Celerp data it reads and writes, for example "Reads items and contacts. Writes its own notes tables."
- Network calls: every outside service it contacts and what it sends, or "None".
Each statement is at most 500 characters. Every community and verified listing must carry both. Users read them before installing, so keep them complete and current, and repeat them at the top of your README.
List it for free
Publish the module in your own public repository, then open a pull request on celerp/community-modules that adds one entry to index-v2.json. The repository's schema/index.schema.json defines the entry and rejects any other field. A community entry needs id, name, description, tier (community), author, license, repo, data_access and network_calls, and may add min_celerp_version, homepage, feedback and up to six tags. The id is the module's manifest name, written with lowercase letters, digits and hyphens only, starting with a letter or digit, as in acme-maintenance. Leave out version: Celerp installs what your repository holds, and the version is the one in your module's manifest, shown once the module is installed. The verified_commit, sha256, price_monthly, price_once and version fields are set by the directory's maintainer for verified listings. Module dependencies are not part of the listing: they stay in the manifest's depends_on. A listing is accepted when the source is readable, the license is clear, the entry declares data_access and network_calls, it uses the community tier and carries no version, the id uses only the characters above, the name does not start with celerp-, all links use https, and the module passes lint.py and the repository's scripts/validate_index.py.
Sell it
Paid modules are sold through the Celerp marketplace. Sign in with GitHub at celerp.com/authors, connect a Stripe account, and publish the module with a monthly or one-time price. Celerp keeps 20% of each sale. A paid module can be installed once it passes Celerp's security scan. If a module is later delisted, a one-time license stays valid and a subscription runs to the end of its paid period. Your module stays yours: license it open source, or keep it source-available and commercial if you sell it.
Tiers
Listings carry one of three tiers: community for modules you host and users install themselves, verified for modules sold through the marketplace, and official for modules Celerp builds and maintains.
Versions and dependencies
Set min_celerp_version to the oldest release you tested on, for example "2.5.0". Import refuses the module on an older Celerp with a message naming the version it needs. Development builds of Celerp skip this check.
Celerp installs no dependencies for a module: there is no step that installs Python packages, and a requirements.txt is never read. Use what the Celerp installation already provides.
depends_on lists other modules by name. Celerp loads them first. If one is missing, disabled or failed, your module is not loaded and the Modules page says which one it needs.
The module interface still changes between releases. Build against the current release and watch the template, which tracks it.
Getting help
Ask in GitHub Discussions, where other module authors can see the answer too. For anything that should not be public, email [email protected].