-
-
Notifications
You must be signed in to change notification settings - Fork 0
Write a backend plugin
A complete tutorial: build a plugin, load it, and see it run. Every code block on this page is real code from the repository, not an invented sample.
Radd calls a feature's directory a module — a package under
server/src/radd/modules/<name>/. A module exports one object, plugin, an
instance of RaddPlugin — Radd calls this object the plugin: a manifest
of what the module contributes. This page uses "module" for the directory and
"plugin" for the manifest, because the codebase does.
The north-star plugin, milestones, is the smallest complete example. One
declaration wires a database table, a permission-guarded CRUD API, three
event types, and four RBAC atoms — with no edits to any other module.
milestones/
├── __init__.py # exports `plugin: RaddPlugin` — the manifest
├── spec.py # the EntitySpec — the whole backend feature, declared
├── models.py # builds the entity's SQLAlchemy model into Base.metadata
├── mcptool.py # a contributed MCP tool
├── automation.py # a contributed automation action node
└── ui/ # a federated frontend remote — a separate guide page
— server/src/radd/modules/milestones/
This page builds a plugin shaped like milestones, walking its real files
line by line. Where the declarative path is not enough, it shows a
hand-written alternative too — a second real plugin, fields. fields
predates EntitySpec and still writes its own router and service
functions.
By the end you will have a plugin that:
- builds a table, with no migration,
- answers a permission-guarded REST API,
- fires events an automation can react to,
- appears in the roles matrix,
- contributes an MCP tool, and
- can be installed, enabled, disabled, and uninstalled with no restart.
A module can contain any of these files. Only __init__.py is required.
| File | Holds | Required when |
|---|---|---|
__init__.py |
plugin = RaddPlugin(...) — the manifest the loader imports. |
Always |
spec.py |
An EntitySpec declaration — the declarative path. |
Your entity fits the field DSL |
models.py |
The SQLAlchemy model: entities.build_model(SPEC) for a declarative entity, or hand-written Base subclasses. |
Your plugin owns a table |
schemas.py |
Pydantic request/response shapes for a hand-written router. | You write your own router |
service.py |
The plugin's public API — every function another module is allowed to call. | Almost always |
router.py |
FastAPI endpoints, when the generated CRUD router is not enough. | You write your own router |
types.py |
StrEnum vocabulary: event types, entity-type strings, wire constants. |
Most plugins |
mcptool.py / mcptools.py
|
McpToolSpec declarations. |
You expose an MCP tool |
automation.py |
AutomationNodeSpec declarations. |
You contribute an automation node |
milestones needs only spec.py and models.py. It skips schemas.py,
service.py, router.py, and types.py entirely — the kernel generates
what those files would otherwise hold. fields needs all of them: it
validates values, and its access model is finer than "any project member"
(see Permissions and access control).
RaddPlugin is a frozen dataclass with about thirty fields, and every
contribution field defaults to an empty tuple:
@dataclass(frozen=True)
class RaddPlugin:
name: str
description: str = ""
id: str = "" # defaults to `name`
version: str = "0.0.0"
api_version: str = KERNEL_API_VERSION
core: bool = True # reclassified builtins are core; externals set False
depends_on: tuple[str, ...] = ()
...— server/src/radd/kernel/plugin.py
The name=/description= form alone builds a valid, loadable plugin — it
contributes nothing. Here is milestones/__init__.py, stripped to
that floor, to show it. (This is not a real file in the repository — the
real one, below, also declares an entity, an MCP tool, an automation node,
and nav.)
from radd.sdk import RaddPlugin
plugin = RaddPlugin(
name="milestones",
description="Project milestones — the plugin-platform north-star.",
)— skeleton, built from server/src/radd/modules/milestones/init.py
A plugin only loads if its dotted import path is listed in
server/src/radd/config.py. It goes in modules for an always-on plugin
(core=True), or in installable_plugins for one an administrator
installs and enables (core=False). milestones is core=False and
lives in installable_plugins — installing and enabling it is covered in
Install, enable, disable, uninstall
below. Every code block on this page after this one is the real file.
One EntitySpec declaration is the entire backend feature for a plugin
whose data is a plain table: standard columns, one project foreign key,
no composite validation. It gets you a table, a permission-guarded CRUD router,
three event types, and the CRUD RBAC atoms — from one declaration.
@dataclass(frozen=True)
class EntitySpec:
key: str # stable entity type, e.g. "milestone"
table: str
label: str
fields: tuple[EntityFieldSpec, ...] = ()
model: type | None = None # escape hatch: a hand-defined SQLAlchemy model
project_scoped: bool = True
searchable: bool = False
mentionable: bool = False
activity: bool = True
plural: str = ""— server/src/radd/kernel/specs.py
milestones/spec.py is the whole declaration:
from radd.sdk import EntityFieldSpec, EntitySpec
SPEC = EntitySpec(
key="milestone",
table="milestones",
label="Milestone",
plural="milestones",
project_scoped=True,
searchable=True,
mentionable=True,
fields=(
EntityFieldSpec("project_id", "uuid", nullable=False, index=True, fk="projects.id"),
EntityFieldSpec("title", "str", nullable=False),
EntityFieldSpec("description", "text", nullable=True),
EntityFieldSpec("due_on", "date", nullable=True),
EntityFieldSpec("status", "str", nullable=False, default="open"),
),
)— server/src/radd/modules/milestones/spec.py
Line by line: key is the entity type string — it becomes the event prefix
(milestone.created), the CRUD atom prefix (milestone.create), and the
table's model name. table is the literal database table name. plural
picks the router prefix (/milestones) and the CRUD-resource label; leave
it unset and the kernel appends s to key. project_scoped=True adds the
project_id foreign key's row-visibility rule and registers a project-purge
entry, so a deleted project takes its milestones with it. searchable and
mentionable opt the entity into full-text search and #-mention
resolution.
Each EntityFieldSpec becomes one column. The type string selects both the
SQLAlchemy column type and the pydantic type for the generated request/
response models:
type |
Column | Python |
|---|---|---|
str |
String(255) |
str |
text |
Text |
str |
int |
Integer |
int |
float |
Float |
float |
bool |
Boolean |
bool |
uuid |
Uuid |
uuid.UUID |
datetime |
DateTime |
datetime |
date |
Date |
date |
json |
JSON |
Any |
— server/src/radd/kernel/entities.py, _TYPES
|
status is nullable=False with a default. A field carrying either
makes it optional in the generated Create schema. The create handler
dumps the payload with exclude_unset=True, so an omitted status falls
through to the column's own default at flush time, rather than failing
validation.
models.py is one line, and it still has to exist:
from radd.sdk import entities
from .spec import SPEC
Milestone = entities.build_model(SPEC)— server/src/radd/modules/milestones/models.py
import_models() imports every enabled plugin's <name>.models at boot, so
Base.metadata is complete before Alembic or the entity table-creation step
runs. Skip this file and your table is declared but never built.
The loader registers the entity automatically, the moment it sees
entities=(SPEC,) on your manifest. That registration auto-wires four
things, with no further code from you:
def register_entity(spec: EntitySpec) -> type:
"""Auto-wire an entity: build the model + register its CRUD-resource RBAC
atoms, created/updated/deleted event types, and the payload ref those
events carry. Idempotent."""
model = build_model(spec)
registries.crud_resources.setdefault(spec.key, _crud_resource(spec))
registries.entity_refs.setdefault(
spec.key, EntityRefSpec(spec.key, _ref_builder(spec, model), label=spec.label)
)
for et in _event_types(spec):
registries.event_types.setdefault(et.event_type, et)
if spec.project_scoped:
purge = _project_purge(spec)
registries.project_purges.setdefault(purge.name, purge)
return model— server/src/radd/kernel/entities.py
That is:
- A
CrudResourceSpecwhosemanageumbrella ismilestone.manage, and whose actions arecreate/update/delete. Somilestone.create/.update/.delete/.manageall appear in the roles matrix. - Three
EventTypeSpecs:milestone.created/.updated/.deleted. Each declaressubjects=("milestone",), so an automation trigger or a webhook receiver gets a real milestone ref, not a{id, project_id}stub. - An
EntityRefSpecbuilt from your own field names. It looks for atitle/name/labelfield to use as the ref's display text. - For a project-scoped entity, a purge entry.
DELETE /projects/{id}removes your rows too, even though your table has noON DELETE CASCADE.
The generated CRUD router is permission-guarded on those same atoms. Reads
are the exception — they ride the ordinary member floor, not a
milestone.read atom of their own:
async def _require(session: AsyncSession, user, obj_or_pid, atom: str) -> None:
...
await entity_host().require(session, user, atom, project_id=pid)
@router.post("", response_model=Read, status_code=201)
async def create(data: Create, session: Session, user: User):
...
await _require(session, user, payload.get("project_id"), f"{key}.create")
@router.get("/{obj_id}", response_model=Read)
async def get_one(obj_id: uuid.UUID, session: Session, user: User):
obj = await _get(session, obj_id)
await _require(session, user, obj, authz_read_atom()) # -> "item.read", always
return Read.model_validate(obj)— server/src/radd/kernel/entities.py
authz_read_atom() returns the string "item.read" unconditionally. An
entity declaration does not create a <key>.read atom. A read is open to
any project member; value-level restriction is the access-grant/field
system's job (see Permissions and access control), not the CRUD atom's.
Only create/update/delete are your entity's own atoms.
You have written five fields in spec.py and three lines in models.py.
In return, you now have POST/GET/PATCH/DELETE /api/v1/milestones
(/api/v1 is settings.api_prefix), a table, three event types, and four
RBAC atoms. server/tests/test_north_star.py is the acceptance test for
exactly this promise — see Test your plugin.
EntitySpec covers a plain table. It does not cover per-value validation,
an access model finer than "any project member", or endpoints that are not
create/read/update/delete. When you need one of those, write service.py
and router.py yourself.
fields is a real example. A custom field's value must be validated
against its type before it is stored. Who may read or write one particular
field is also a per-record access-grant question (see
Permissions and access control), not a blanket CRUD atom. Its router
resolves the scope, then delegates:
async def _require_manage_on_scope(
session: AsyncSession, user, project_id: uuid.UUID | None, *,
permission: authz.Permission = authz.Permission.FIELD_MANAGE,
) -> None:
"""`permission` on the field's scope: its project, or globally when unscoped."""
if project_id is not None:
project = await projects_service.get_project(session, project_id)
await authz.require(session, user, permission, project=project)
else:
await authz.require(session, user, permission)— server/src/radd/modules/fields/router.py
A service function is the module's public surface — the only thing another module is allowed to call:
async def create_field(
session: AsyncSession, data: FieldDefinitionCreate, actor_id: uuid.UUID | None = None
) -> FieldDefinition:
scope_ids = list(dict.fromkeys(data.project_ids)) # dedupe, preserving order
for project_id in scope_ids:
await projects_service.get_project(session, project_id)
...— server/src/radd/modules/fields/service.py
Another module reaches your data only through your service.py
functions — never your models.py. server/tests/test_module_contracts.py
asserts this by walking the real import graph. Every radd.modules.X
import in your module must be declared, either in depends_on (ordered)
or weak_depends (a deferred reach — see
Declare your dependencies). An import of
another module's models.py at all is refused outright, unless that
module is one of six spine models (auth, projects, items,
workflow, teams, fields). Even those are READS only; a write still
goes through the owning service.
Register your router on the manifest, beside whatever else you contribute:
routers=(router,),— server/src/radd/modules/fields/init.py
Permissions and access control covers the permission model in full — this section is the manifest wiring.
Permissions. A standalone atom (one that is not part of a CRUD
create/update/delete/manage set) is a PermissionSpec:
permissions=(
PermissionSpec(
"field.manage", "project", "Manage custom-field definitions and field access rules.",
implied_by=("project.manage",),
),
),— server/src/radd/modules/fields/init.py
Events. An entity's created/updated/deleted events are auto-registered
(above). Declare an EventTypeSpec by hand for anything else:
event_types=(
EventTypeSpec(FieldEvent.CREATED, "Custom field created", "Admin"),
EventTypeSpec(FieldEvent.UPDATED, "Custom field updated", "Admin"),
),— server/src/radd/modules/fields/init.py
@dataclass(frozen=True)
class EventTypeSpec:
event_type: str
label: str
group: str = "Other"
item_scoped: bool = False # a target item resolves -> SLQ + item actions apply
has_changes: bool = False # payload carries a field diff
trigger: bool = True # appears in the automation trigger catalog
subjects: tuple[str, ...] = () # entity types this event is ABOUT— server/src/radd/kernel/specs.py
Every entry in subjects must name an entity type with a registered
EntityRefSpec. An EntitySpec registers its own automatically. If your
event names something else, register a matching EntityRefSpec too — or
the plugin refuses to load. See
Declare your dependencies.
Nav items. milestones adds one sidebar link, gated on item.read:
ui=PluginUiManifest(
nav=(
NavItemSpec(
key="milestones", label="Milestones", path="/milestones",
icon="flag", section="main", requires=("item.read",), order=55,
),
),
remote="/plugins/milestones/remoteEntry.js",
ui_api_version="1.0.0",
),— server/src/radd/modules/milestones/init.py
requires names the permission atoms that must hold before the link
appears — hiding is presentation only, never enforcement (see
Permissions and access control). This page does not cover building
the frontend remote the remote line points at.
Settings. A scalar cascade setting (instance-level, or instance and
project) is a SettingSpec:
SettingSpec(
key="timesheet_day_min_hours",
type="int",
scopes=("instance",),
label="Timesheet: minimum hours per workday",
description=(
"A working day (per the working week) with less than this logged is "
"flagged as under-logged on the timesheet's per-person view. Leave and "
"holiday days are never flagged."
),
section="timelogging",
),— server/src/radd/modules/timelogging/init.py
section places the setting on Settings → Time logging instead of the
General page. Leave it "" to land on General.
An McpToolSpec is both the tool's catalog entry and its own enforcement
declaration — there is no second place to register it:
@dataclass(frozen=True)
class McpToolSpec:
name: str
description: str
input_schema: dict[str, Any]
handler: Callable[..., Awaitable[Any]]
permission: str = "" # "" = any authenticated principal
project_scoped: bool = False # visibility: show only where the atom holds
project_param: str = "" # input property naming the project
kernel_enforced: bool = True # dispatcher requires `permission` before the handler runs— server/src/radd/kernel/specs.py
milestones/mcptool.py, in full:
async def _list(session: AsyncSession, actor: Any, args: Mapping[str, Any]) -> Any:
from radd.modules.projects import service as projects_service
key = str(args["project_key"]).upper()
project = next(
(p for p in await projects_service.list_projects(session) if p.key == key), None
)
if project is None:
raise NotFoundError("project", key)
rows = (
(await session.execute(
select(Milestone).where(Milestone.project_id == project.id)
.order_by(Milestone.due_on.asc().nulls_last(), Milestone.title.asc())
)).scalars().all()
)
return {"milestones": [...], "count": len(rows)}
LIST_MILESTONES = McpToolSpec(
name="list_milestones",
description="Milestones in a project, ordered by due date.",
input_schema={
"type": "object",
"properties": {"project_key": {"type": "string", "description": "Project key, e.g. TD."}},
"required": ["project_key"],
"additionalProperties": False,
},
handler=_list,
permission="item.read", # entity reads are member reads
project_scoped=True,
project_param="project_key",
)— server/src/radd/modules/milestones/mcptool.py
_list contains no permission check. That absence is the feature: the
dispatcher requires the declared atom before the handler runs at all —
async def _call_registry_tool(
session: AsyncSession, actor: User, spec: Any, arguments: Mapping[str, Any]
) -> Any:
if spec.kernel_enforced:
project = None
if spec.project_param and arguments.get(spec.project_param):
project = await projects_service.get_by_key(session, str(arguments[spec.project_param]))
if spec.permission:
await authz.require(session, actor, cast(Permission, spec.permission), project=project)
return await spec.handler(session, actor, arguments)— server/src/radd/modules/mcp/tools.py
— resolving project_param's value to the named project first, so a caller
scoped to one project cannot list another's milestones by naming it in
project_key. Leave kernel_enforced=True (the default) for a new tool.
False is reserved for the handful of migrated builtins whose handlers
already enforce at their own service seam.
The catalog also hides what a caller cannot run at all. It narrows
project_param's schema to an enum of the projects the caller may act in
— above mcp_project_enum_max it degrades to a plain string instead. That
is the same spec-114 filtering every tool gets by registering, with no
code of your own.
Register it:
mcp_tools=(LIST_MILESTONES,),— server/src/radd/modules/milestones/init.py
An item-rooted The query language for developers query can be extended with your module's own data
without items ever importing your module. SlqFieldSpec is the seam:
@dataclass(frozen=True)
class SlqFieldSpec:
name: str # the SLQ field keyword, e.g. "note"
label: str
# (contains, value, ctx) -> Select[work_item_id]
item_ids: Callable[[bool, str, SlqFieldContext], Any]— server/src/radd/kernel/specs.py
timelogging contributes logged_by, answering "which issues has this
person logged time on" — a question about worklogs whose answer is a set of
items:
def logged_by_item_ids(contains: bool, value: str, ctx: SlqFieldContext) -> Select:
"""Work-item ids with at least one worklog whose author matches `value`."""
stmt = select(Worklog.item_id).where(Worklog.item_id.is_not(None))
if ctx.is_me:
return stmt.where(Worklog.author_id == ctx.current_user_id)
stmt = stmt.join(User, User.id == Worklog.author_id)
if contains:
needle = f"%{value}%"
return stmt.where(or_(User.email.ilike(needle), User.name.ilike(needle)))
return stmt.where(or_(User.email.ilike(value), User.name.ilike(value)))— server/src/radd/modules/timelogging/slq/item_fields.py
contains is True for ~ (substring) and False for =. ctx.is_me is
set when the operand was the bare me literal — a grammar sentinel, not
the string "me". A person actually named "me" cannot be confused with
it. The function returns a Select of matching work-item ids; the items
query engine wraps it as work_item.id IN (…) and applies negation for
!=. Your module joins its own tables plus auth.User; it never touches
an items table.
Register it:
slq_fields=(
SlqFieldSpec(name="logged_by", label="Logged by", item_ids=logged_by_item_ids),
),— server/src/radd/modules/timelogging/init.py
logged_by = me now compiles on the item dialect. This direction — a
module reaching INTO items — is one-way by design. An item-dialect field
reaching OUT to another module's rows goes through that module's own
compile_query instead — a different module's page to document.
depends_on orders the loader: every name in it must finish loading
before yours does. milestones needs auth loaded first. Entity
registration needs the EntityHost — identity, the permission gate, row
visibility — that auth installs:
plugin = RaddPlugin(
name="milestones",
...
depends_on=("projects", "auth", "events"),
...
)— server/src/radd/modules/milestones/init.py
weak_depends declares an import without ordering by it. Use it for a
deferred reverse reach, or a feature-detected optional seam, made the
moment you need it rather than at plugin load. fields needs items for
one narrow reach — rewriting stored custom-field values when an option is
removed. But items itself depends on fields, so an ordered edge would
be a cycle:
# RADD-949: removing a select option rewrites the values items already store,
# and `work_items.custom_fields` is items'. A DEFERRED reverse reach — items
# depends on fields, so a hard edge would be a cycle — through the public
# `items.service` seam, never the table.
weak_depends=("items",),— server/src/radd/modules/fields/init.py
server/tests/test_module_contracts.py asserts every radd.modules.X
import in your module's files appears in depends_on or weak_depends.
This includes one inside a function body, guarded by a
# deferred: X loads after Y comment. There is no allowlist for this
rule. Add the name the moment you write the import, not after the test
fails.
Which migration step you need depends on how your model was built.
A declarative EntitySpec needs no migration at all. The kernel builds
your table into Base.metadata, and a startup step creates it idempotently
— once when the app boots:
async def create_app() -> FastAPI:
...
async def lifespan(app: FastAPI):
# Create any declaratively-registered plugin entity tables (idempotent) —
# the "install" step for entities without a dedicated migration.
await kentities.ensure_tables()— server/src/radd/app.py
— and again whenever a plugin with entities is hot-enabled at runtime (see Install, enable, disable, uninstall):
if plugin.entities:
await kentities.ensure_tables()— server/src/radd/modules/pluginmgr/router.py
A hand-written models.py — the code escape hatch fields, views,
and access all use — needs the real Alembic workflow:
cd server
uv run alembic revision --autogenerate -m "…" # review the generated file
uv run alembic upgrade headMigration files carry a short mnemonic revision id, not a random hash —
d113svcacct_api_token_scopes.py is spec 113's. Review the generated file
and rename it to match, before committing. Parallel agents may create
sibling heads; merge them with alembic merge.
Two ways for a plugin to be loadable at all, set in
server/src/radd/config.py:
modules: tuple[str, ...] = (
"radd.modules.events", "radd.modules.projects", "radd.modules.auth", ...
)
installable_plugins: tuple[str, ...] = ("radd.modules.milestones",)— server/src/radd/config.py
A plugin listed in modules with core=True is always on — nobody can
disable it. One listed in modules with core=False is on by default and
disableable. One listed in installable_plugins — where milestones
lives, core=False — starts DISCOVERED instead. An administrator must
install it, then enable it, before it mounts:
DISCOVERED → INSTALLED → ENABLED ⇄ DISABLED
— server/src/radd/modules/pluginmgr/service.py
Install creates the installed_plugins row (state INSTALLED). For an
in-repo plugin like milestones this is a bookkeeping step only. There is
no separate migration branch to run — the entity table-creation step above
already covers it.
Enable flips the row to ENABLED, then hot-mounts the plugin into the
running app, with no restart:
runtime.mount_plugin(request.app, plugin, path)
# An entity plugin with no migration needs its tables created on runtime enable.
if plugin.entities:
await kentities.ensure_tables()
for hook in plugin.on_startup:
await hook()— server/src/radd/modules/pluginmgr/router.py
The mount step registers the plugin's contributions into the live kernel registries — nav, permissions, event types, MCP tools, everything this page covered. It appends the plugin's routers ahead of the SPA catch-all route, which must always stay last.
Disable reverses it. The plugin's own routers are matched by identity.
Its generated CRUD router is matched by its /milestones prefix instead —
FastAPI wraps a mounted router in an object whose own path is None.
A naive path filter both misses that router and crashes on it.
registries.unregister_plugin() drops the plugin's nav, atoms, and
capabilities from every live registry, in the same call. A plugin cannot
be disabled while another plugin's depends_on still names it — disable
that dependent plugin first.
Uninstall sweeps the plugin's atoms out of every stored role and API
token scope, and deletes its access-grant rows. A role or a scoped key
never keeps vocabulary the catalog no longer knows. It then deletes the
installed_plugins row — its tables are not dropped.
milestones sets core=False specifically to prove the manager can
disable it — server/tests/test_north_star.py asserts exactly that.
Settings → Plugins is the panel that drives all of this:

Radd does not ask for a unit test per endpoint. It asks for tests on
invariants many modules depend on, and otherwise trusts the running app
(see CLAUDE.md, "Development rules"). For the auto-wiring this page
walks through, that invariant test already exists — and doubles as your
checklist:
def test_milestone_is_an_automation_trigger():
from radd.modules.automations import catalog
assert "milestone.created" in catalog.TRIGGERS
assert catalog.TRIGGERS["milestone.updated"].has_changes is True
def test_milestone_has_rbac_atoms():
atoms = all_permission_keys()
assert {"milestone.create", "milestone.update", "milestone.delete", "milestone.manage"} <= atoms
def test_milestone_has_a_nav_item():
nav = [n for n in registries.nav if n.key == "milestones"]
assert nav and nav[0].path == "/milestones" and nav[0].requires == ("item.read",)
def test_milestone_entity_and_crud_router_registered():
assert "milestone" in registries.entities
...
def test_milestone_plugin_is_disableable():
plugin = registries.plugins["radd.milestones"]
assert plugin.core is False
async def test_milestone_model_roundtrips(db):
project = await projects_service.create_project(db, ProjectCreate(key="MS1", name="Milestone Test"))
m = Milestone(project_id=project.id, title="Ship v1", status="open")
db.add(m)
await db.flush()
...— server/tests/test_north_star.py (trimmed)
Run it:
cd server
uv run pytest tests/test_north_star.py -vThe test's own fixture shows the two ways a plugin gets into the running
registries. At boot, load_plugins(settings.modules) reads the config
tuples (see Install, enable, disable, uninstall).
In a test, registries.register_plugin(...) does the same thing directly,
with no database row. That is why a test exercises a plugin's wiring
without going through install/enable at all:
@pytest.fixture(autouse=True)
def _load_milestones(_kernel_registries_loaded):
from radd.modules.milestones import plugin as milestones_plugin
registries.register_plugin(milestones_plugin)
for spec in milestones_plugin.entities:
kentities.register_entity(spec)
registries.entity_routers.append(kentities.crud_router(spec))
yield— server/tests/test_north_star.py
Two more suites fail your plugin at the door before it fails at runtime.
tests/test_module_contracts.py refuses an undeclared cross-module import
(see Declare your dependencies).
tests/test_permission_ownership.py refuses an atom your manifest
declares that belongs to a resource you do not own. It also refuses one
whose scope disagrees with where you actually check it. At boot itself,
before any test runs, the loader refuses an event or an automation-node
subject naming an entity type nothing describes:
if problems:
raise PluginLoadError(
"unresolvable event subjects — register an EntityRefSpec for each: " + "; ".join(problems)
)— server/src/radd/kernel/loader.py
An event promising a subject nothing can resolve would otherwise save cleanly and enable cleanly — then do nothing at 3am. This check exists to catch that at the cheapest possible moment.
For anything your plugin does beyond the entity/CRUD pattern — a hand-written
service function, a validation rule — there is no per-function test
requirement. Run the app (podman compose -f compose.dev.yaml up) and use
the feature through the API docs at /docs or the running UI.
Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.
-
Developer guide
- Architecture: the kernel and plugins
- Develop, test and deploy
- Events and consumers
- Permissions and access control
- The MCP server
- The query language for developers
- The REST API and authentication
- Write a backend plugin
- Write a page editor extension
- Write a plugin user interface
- Write an automation node
-
Release notes
- 0.36.4
- 0.36.3
- 0.36.2
- 0.36.1
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.0
- 0.32.0
- 0.31.1
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.1
- 0.24.0
- 0.23.1
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.1
- 0.18.0
- 0.17.2
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.1
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.6
- 0.6.5
- 0.6.4
- 0.6.3
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.0
- 0.2.0
- 0.1.0
-
User guide
- AI features
- Attachments
- Automations
- Cycles and releases
- Instance settings
- Intake forms and the portal
- Notifications and the inbox
- Personal settings
- Project settings
- Projects
- Reports and dashboards
- Search and the query language
- Start here
- The application window
- The card designer
- The roadmap
- The service desk
- The wiki
- Time logging and the timesheet
- Views
- Work items