-
-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture the kernel and plugins
Radd's server is a kernel plus a set of plugins. This page covers the split between them, the plugin contract, and the path one request takes through both.
The kernel provides generic machinery every feature builds on. A plugin provides one concrete feature and decides how that machinery behaves for it.
Concretely: the kernel turns a plugin's declarations into a table, a router, and event types. It never decides who the caller is, whether the caller may act, which rows the caller may see, or where an event should go. Those are policy questions, and only an installed plugin answers them.
The kernel must not import any radd.modules.* package — not even from
inside a function body. kernel/specs.py states the rule directly:
"""Contribution specs — the typed declarations a plugin puts on its manifest.
Each spec is a frozen dataclass the loader aggregates into a kernel registry
(`radd.kernel.registry`). The kernel iterates the registries blind — no consumer
names a plugin. These are the vocabulary of the plugin platform (docs/plugin-platform.md §4).
Kept deliberately pure: this module imports nothing from `radd.modules.*`, so the
kernel never depends on a plugin. Specs carry data + light callables only.
"""— server/src/radd/kernel/specs.py
A test enforces this for every file under kernel/, not only specs.py. It
walks the syntax tree of each file, including imports deferred inside a
function body, and fails if any of them import a module:
def test_the_kernel_imports_no_plugin():
"""The kernel is mechanism; a plugin is policy. Nothing under `kernel/` may
import `radd.modules.*` — including from inside a handler, which is how
`kernel/entities.py` reached auth/projects/events for years while its own
docstring claimed purity (RADD-892). Policies arrive through
`kernel.hosts.EntityHost` and the contribution registries."""
found = _kernel_module_imports()
assert not found, "the kernel imports plugins:\n " + "\n ".join(found)— server/tests/test_module_contracts.py
The comment names a real defect. Before RADD-892, kernel/entities.py reached
into auth, projects, and events through deferred imports, while its own
docstring claimed the kernel was pure. The fix moved four decisions — identity,
the permission gate, row visibility, and where an event goes — behind one
protocol, kernel.hosts.EntityHost, resolved once per request. The last
section on this page traces a request through that protocol.
53 directories exist under server/src/radd/modules/. Each one defines a
module-level plugin: RaddPlugin. 52 of them load by default, listed in
Settings.modules (the RADD_MODULES boot set); one, milestones, is an
installable plugin a person turns on through the plugin manager instead.
# Ordered module assembly — the plugin system's root configuration.
modules: tuple[str, ...] = (
"radd.modules.events",
"radd.modules.projects",
"radd.modules.auth",
"radd.modules.capabilities",
"radd.modules.pluginmgr",
"radd.modules.settings",
...
"radd.modules.jiraimport",
"radd.modules.monitoring",
"radd.modules.leave",
)
# Non-core plugins are INSTALLED + runtime-ENABLED via the plugin manager
# (docs/plugin-platform.md §10), not the always-on bootstrap set above. These
# ship in the repo and are discoverable by the manager; `create_app` also loads
# whichever are in state ENABLED in the `installed_plugins` table.
installable_plugins: tuple[str, ...] = ("radd.modules.milestones",)— server/src/radd/config.py
Every one of the 53 builds a RaddPlugin directly — there is no separate
RaddModule type any more. The minimal constructor still works:
plugin = RaddPlugin(
on_startup=(_startup,),
on_shutdown=(_shutdown,),
name="events",
depends_on=(),
weak_depends=("auth",),
description="Transactional outbox: append-only event log, the spine every consumer reads.",
routers=(router,),
)— server/src/radd/modules/events/init.py
RaddPlugin(name=..., description=..., routers=...) alone still constructs a
valid plugin: core defaults to True, id defaults to name, and every
contribution field defaults to an empty tuple.
RaddPlugin (kernel/plugin.py) is a frozen dataclass. Its fields split into
identity/lifecycle and contributions.
Identity and lifecycle
| Field | What it does |
|---|---|
name |
The stable enable key. depends_on names other plugins by this. |
id |
Defaults to name. A namespaced id (radd.milestones) for an external plugin. |
version |
The plugin's own semver. |
api_version |
The kernel SDK version this plugin targets ("1.0" today). The loader refuses a major-version mismatch. |
core |
True = built in, cannot be disabled. False = the plugin manager can install, enable, and disable it. |
depends_on |
Other plugin names that must load first. The loader enforces the order. |
weak_depends |
Modules this plugin imports but must NOT be ordered by — a deferred reverse reach or a feature-detected optional seam. Declares the import without ordering by it. |
python_deps / js_deps
|
The Python distributions and npm packages this plugin needs. |
Contributions. Each field below feeds exactly one kernel registry. Declaring one is the whole cost of the feature — no other module needs an edit.
| Field | Contributing it gets you |
|---|---|
routers |
Endpoints, mounted under the API prefix. |
entities |
A table, a CRUD router, RBAC atoms, and created/updated/deleted event types — see EntitySpec auto-wiring, below. |
event_types |
A row in the event-type catalog. trigger=True (the default) makes it an automation trigger with no other catalog edit. |
entity_refs |
How this plugin's entity types are described inside another event's payload (see Events and consumers). |
consumers |
A name and a description for an offset-tracked stream consumer. Currently descriptive only — see the note below. |
automation_actions / automation_conditions
|
Declared on the manifest, but not wired into a registry. Contribute an automation action through automation_nodes instead — see the milestones example. |
tasks |
A periodic tick or an enqueue-only job, run by the active TaskBackend. |
settings_keys / settings_sections
|
Scalar cascade settings this plugin owns, and the settings page section they appear on. |
permissions |
RBAC atoms — appear in the roles matrix and GET /permissions. |
crud_resources |
The create/update/delete atom family plus its umbrella verb, for a resource that is not a declared EntitySpec. |
relations / relation_domains
|
What a qualifier like @own or @team means for this resource's rows. |
access_resources |
A grantable resource on the access-grant framework — gets the generic /grants API and the shared grants editor. |
nav_facts |
One area-visibility answer for GET /auth/me — is this area worth offering this user. |
grant_scopes |
A kind of thing a role grant can be bound to. |
project_relations |
Why an actor can see a project without a grant on it. |
project_purges |
Tables that must be deleted when a project is deleted, in dependency order. |
capabilities |
A status descriptor for GET /capabilities. |
slq_fields |
A custom SLQ query field. |
view_types / widget_types
|
A saved-view type or dashboard widget type, rendered by this plugin's own frontend slot. |
mcp_tools |
An MCP tool — filtered by caller permission and enforced by the dispatcher (see the milestones example). |
automation_nodes |
A node the automation graph can hold: a filter, a gate, or an action. |
page_extensions |
A fenced block a wiki page can embed, written as ```radd:<name>. |
cascades |
Cleanup for rows that must die with a parent the database cannot cascade from — a polymorphic parent with no foreign key. |
integrations |
An implementation of a named socket another plugin consumes: a storage backend, a notifier, a task backend, and so on. |
ui |
Nav items, and the URL of this plugin's federated frontend bundle. |
on_startup / on_shutdown
|
Hooks the app lifespan runs at boot and at shutdown, in plugin load order (reverse order on shutdown). |
exception_handlers / openapi_augmentors
|
A FastAPI exception handler, or a function that edits the generated OpenAPI schema. |
Every contribution field is a tuple. The dataclass refuses a bare value in its place:
def _reject_unwrapped_contributions(self) -> None:
"""Refuse a single contribution passed where a tuple is declared.
Every contribution field on this manifest is a `tuple[Spec, ...]`, and
`on_startup=_startup` instead of `on_startup=(_startup,)` is a defect
Python will not catch: the dataclass stores whatever it is handed, and
the failure surfaces much later, wherever the field is finally iterated.
RADD-745's cascade refactor shipped exactly that and produced an image
that could not complete `lifespan` — a one-character typo that reached a
published container with 1391 green tests behind it.
Rejecting rather than NORMALISING is the deliberate choice. Quietly
wrapping a bare value into a 1-tuple would make two shapes valid for one
field, and the second one is how the next module learns the wrong
convention. This raises at import — before an image is built, let alone
deployed.
"""— server/src/radd/kernel/plugin.py
Write on_startup=(_startup,), with the trailing comma. A missing comma
raises TypeError at import time, before an image builds.
KernelRegistries (kernel/registry.py) holds one dictionary per contribution
kind — entities, event_types, permissions, crud_resources,
capabilities, nav_facts, slq_fields, mcp_tools, automation_nodes, and
the rest of the table above. The loader calls register_plugin once per
plugin and fans its manifest fields into these dictionaries.
Exactly one generic consumer reads each registry, and that consumer never names a plugin:
"""The kernel contribution registries — one dict per contribution kind, populated
by the loader and read by exactly one generic consumer. No consumer names a plugin
(docs/plugin-platform.md §4). This is the `access.registry` pattern, generalized.
"""— server/src/radd/kernel/registry.py
For example, triggers() filters event_types down to the ones an automation
may trigger on. The automations catalog derives its whole trigger list from
this call, not from a hardcoded import of every producing module:
def triggers(self) -> dict[str, EventTypeSpec]:
"""Event types that are automation triggers — chokepoint-1 inversion."""
return {k: v for k, v in self.event_types.items() if v.trigger}— server/src/radd/kernel/registry.py
This design is deliberate, not incidental. A consumer that named plugins
(if plugin_id == "milestones": ...) would need an edit every time a new
plugin arrived — the exact backwards dependency the kernel exists to invert.
A registry the consumer reads blind means a plugin's contribution is live the
moment the plugin loads, whether it ships with Radd or arrives as an external
install.
Two fields on the manifest run ahead of a consumer today. The loader
aggregates consumers into registries.consumers, but no built-in plugin
populates it and nothing reads the dictionary back — it records a name and a
description with no behaviour behind it. A real consumer today is a plugin's
own on_startup hook starting a hand-written loop; see
Events and consumers. automation_actions and automation_conditions
are declared on RaddPlugin but register_plugin does not iterate them at
all — no registry receives them. Contribute an automation action through
automation_nodes, the mechanism the milestones plugin uses.
load_plugins(paths) (kernel/loader.py) imports each configured plugin path
in order, in one pass:
- Import the package and read its
plugin: RaddPluginexport. - Check
api_versionagainst the kernel's own. A major-version mismatch refuses to load:
def _api_compatible(plugin_api: str, kernel_api: str) -> bool:
"""Semver major-compat gate (§9): a plugin loads iff its api_version's major
matches the kernel's. Minors/patches are backward-compatible by contract."""
try:
return plugin_api.split(".")[0] == kernel_api.split(".")[0]
except (AttributeError, IndexError):
return False— server/src/radd/kernel/loader.py
- Check every name in
depends_onhas already loaded, or raise:
missing = [dep for dep in plugin.depends_on if dep not in seen]
if missing:
raise PluginLoadError(
f"plugin {plugin.id!r} depends on {missing} — order/enable them first"
)— server/src/radd/kernel/loader.py
- Register the plugin's contributions into
registries, and auto-wire any declaredentities.
weak_depends plays no part in this ordering. It exists only so a deferred,
function-body import is a declared dependency instead of a silent one —
tests/test_module_contracts.py::test_every_import_is_declared requires every
radd.modules.X import, deferred or not, to appear in depends_on or
weak_depends.
After every plugin has loaded, one more pass checks that every event subject,
and every action node's subject, names a real registered entity. This is what
the milestones plugin's auto-wired subjects=("milestone",) resolves
against, checked at boot rather than discovered later:
def _check_subjects(plugins: list[RaddPlugin]) -> None:
"""Every declared event subject must have a registered `EntityRefSpec`
(RADD-923), and every contributed action node's subject too.
Checked after the whole set has loaded, not per plugin: the ref may be
contributed by a plugin listed later, and ordering is `depends_on`'s job, not
this check's.
Boot is the cheapest place to find this. An event promising a subject nothing
can resolve produces an automation that saves cleanly, enables cleanly and
then does nothing at 3am — the failure mode this whole seam exists to remove,
so it must not be reintroduced by the seam itself.
"""— server/src/radd/kernel/loader.py
import_models(paths) runs separately, before load_plugins, and imports
each plugin's <path>.models submodule so every table lands in
Base.metadata for Alembic. A plugin with no models.py is skipped, not an
error.
Startup and shutdown. create_app's lifespan hook runs every plugin's
on_startup hooks in load order, then starts the kernel-registered
TaskSpecs — deliberately after every plugin's own startup, because a task's
run function may touch a table a plugin's startup just created:
for plugin in plugins:
for hook in plugin.on_startup:
await hook()
# RADD-872: kernel-registered TaskSpecs start after every plugin's own
# startup (their run functions may touch tables plugins just ensured).
task_backend = socket_provider(Socket.TASK_BACKEND, settings.task_backend)
task_loops = _schedule_registered_tasks(task_backend) if task_backend else []
for loop in task_loops:
await loop.start()
yield
for loop in reversed(task_loops):
await loop.stop()
for plugin in reversed(plugins):
for hook in plugin.on_shutdown:
await hook()— server/src/radd/app.py
Shutdown reverses the order. Task loops stop first, then each plugin's
on_shutdown hooks run, last-loaded plugin first.
Hot mount and unmount. The plugin manager (modules/pluginmgr/) can
enable or disable a core=False plugin in the running process, with no
restart. Mounting adds the plugin's routers ahead of the frontend catch-all
route, which must stay last or it shadows them:
def mount_plugin(app: FastAPI, plugin: RaddPlugin, path: str) -> None:
registries.register_plugin(plugin)
# Record the plugin's UI bundle dir (`<plugin dir>/ui/dist`) for /plugins/<name>/* serving.
registries.register_plugin_ui_dir(plugin, importlib.import_module(path))
routers = _routers_for(plugin, path)
catchall = [r for r in app.router.routes if getattr(r, "path", "") == _CATCHALL]
for r in catchall:
app.router.routes.remove(r)
for router in routers:
app.include_router(router, prefix=settings.api_prefix)
for r in catchall: # keep the SPA fallback last
app.router.routes.append(r)
app.openapi_schema = None— server/src/radd/modules/pluginmgr/runtime.py
Unmounting matches routes by identity for the plugin's own routers and by
prefix for its generated entity-CRUD routers, guards against a FastAPI
internal that broke this once, then drops the plugin's registry contributions
so /capabilities, the roles matrix, and the nav stop advertising it in the
same step:
# FastAPI's include_router no longer flattens into per-endpoint APIRoutes —
# it appends a lazy `_IncludedRouter` wrapper whose `path` is None. The old
# path-prefix filter therefore both MISSED every mounted router and crashed
# on `None.startswith` (swallowed upstream), which is how a "disabled" AI
# plugin kept serving every endpoint. Match the wrapper's `original_router`
# instead: by identity for the plugin's own routers (module singletons at
# boot and hot-mount alike), by prefix for entity CRUD routers (created
# fresh per mount, but always prefixed `/{plural}`). Plain path-bearing
# routes keep the prefix match as a fallback.— server/src/radd/modules/pluginmgr/runtime.py
milestones is the plugin platform's north star: the smallest complete
feature, built from one EntitySpec. Its whole schema is five fields:
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
Declaring it, and listing it on the plugin's entities=(SPEC,), is the entire
feature. kernel.entities.register_entity, run by the loader, gives it — with
no other plugin code:
-
A table.
build_modelmaps the field list onto a SQLAlchemyTableand an imperatively mapped class, added toBase.metadata. -
CRUD.
crud_router(spec)returns a full create/read/update/delete router at/milestones, permission-guarded by generated atoms. -
RBAC atoms.
milestone.create,milestone.update,milestone.delete, and the umbrellamilestone.manage— derived from the entity's ownproject_scopedflag. -
Events.
milestone.created,milestone.updated,milestone.deleted, each declaringsubjects=("milestone",)— so an automation trigger and a webhook event exist the moment the entity does. -
A payload ref. A canonical
{id, entity_type, title, project}shape, built from whichever of the entity's own fields is namedtitle,name, orlabel. -
Project-delete cleanup.
register_entityalso registers themilestonestable as a project purge, so a project cannot be deleted while its milestones still exist.
The plugin's own file adds a nav item and the second half of the north star — an automation action that reacts to the very events the entity auto-wired:
plugin = RaddPlugin(
name="milestones",
id="radd.milestones",
version="1.0.0",
core=False,
description="Project milestones — the plugin-platform north-star (entity + nav, all auto-wired).",
depends_on=("projects", "auth", "events"),
entities=(SPEC,),
mcp_tools=(LIST_MILESTONES,),
# RADD-923: the plugin contributes the ACTION that responds to its own
# auto-wired events — the other half of the north-star. No edits to
# `automations`, the kernel or the SPA.
automation_nodes=(SET_STATUS_NODE,), # RADD-640: agents see milestones too, kernel-filtered
ui=PluginUiManifest(
nav=(
NavItemSpec(
key="milestones",
label="Milestones",
path="/milestones",
icon="flag",
section="main",
requires=("item.read",),
order=55,
),
),
# The Milestones CRUD page ships as this plugin's federated remote (web/remotes/milestones),
# mounted by the host at /milestones via the route.page slot (spec 94).
remote="/plugins/milestones/remoteEntry.js",
ui_api_version="1.0.0",
),
)— server/src/radd/modules/milestones/init.py
SET_STATUS_NODE declares subject="milestone", so the executor hands its
plan/apply pair milestone ids, resolved through the same ref the entity
auto-wired. No line in automations, the kernel, or the frontend shell names
milestones. The plugin acts on its own entity, and nothing else has to know
it exists.
Two rules in tests/test_module_contracts.py are worth restating here.
CLAUDE.md calls them the spine-table exception, and both run on every test
pass, not only at review time.
A module's models.py is private, except for six spine modules. Any
other module may import auth (User), projects (Project), items
(WorkItem), workflow (State), teams (Team), and fields
(FieldDefinition) directly, for reads — joins and foreign keys. Writes still
go through the owning module's service.py. Every other module's models stay
off-limits:
SPINE = {"auth", "projects", "items", "workflow", "teams", "fields"}
#: (importing module, imported module) pairs the audit found reaching a
#: non-spine models.py — frozen so the class cannot GROW. Shrunk by the
#: RADD-887/888 seam work and EMPTIED by RADD-892, which inverted auth's reach
#: into `pages.models` into a registered GrantScopeSpec. An addition needs a
#: reason reviewed here; the burn-down is finished.
MODEL_IMPORT_ALLOWLIST: set[tuple[str, str]] = set()— server/tests/test_module_contracts.py
MODEL_IMPORT_ALLOWLIST is a burn-down list of an audit's remaining
findings, not a place to add a new exception. It is currently empty, and a
second test fails if an entry in it stops matching a real import — the list
may only shrink.
Every cross-module import must be declared. A module that imports
radd.modules.X anywhere, deferred or not, must name X in its own
depends_on or weak_depends. No allowlist covers this rule:
def test_every_import_is_declared():
...
missing = []
for module, deps in declared.items():
imported = {target for target, _, _ in _module_edges(module) if target != module}
for target in sorted(imported - deps):
missing.append(f"{module} imports {target} but declares no dependency on it")
assert not missing, "undeclared module dependencies:\n " + "\n ".join(missing)— server/tests/test_module_contracts.py
A request that mutates data through an auto-wired entity crosses five steps:
request, auth resolution, authz, router, service, event. Trace them through
the generated POST /milestones handler.
Auth resolution. FastAPI resolves the _acting_user dependency, which
calls the installed host rather than importing auth directly:
async def _acting_user(
request: Request, session: Annotated[AsyncSession, Depends(get_session)]
):
"""The caller, resolved through the host at REQUEST time.
A kernel-owned dependency with a fixed signature is what lets the generated
routers be built while plugins are still loading: FastAPI needs a callable at
decoration time, and `auth.deps.CurrentUser` would have to be imported then —
the very dependency this file is here to shed.
"""
return await entity_host().current_user(request, session)— server/src/radd/kernel/entities.py
The auth plugin installs the host at import time. Its current_user reads
the session cookie or a Bearer radd_pat_… header and raises
UnauthorizedError — a 401 — when neither resolves to a user:
class AuthEntityHost:
async def current_user(self, request: Any, session: AsyncSession) -> Any:
user = await optional_user(request, session)
if user is None:
raise UnauthorizedError()
return user— server/src/radd/modules/auth/entityhost.py
Authz. The handler calls _require, which resolves the target project
when the entity is project-scoped and asks the host to enforce the atom:
async def require(
self, session: AsyncSession, user: Any, atom: str, *, project_id: Any | None
) -> None:
if project_id is None:
await authz.require(session, user, atom)
return
from radd.modules.projects import service as projects_service
project = await projects_service.get_project(session, project_id)
await authz.require(session, user, atom, project=project)— server/src/radd/modules/auth/entityhost.py
authz.require raises ForbiddenError — a 403 — when the user does not hold
the atom on that project.
Router and service. For a generated entity router, router and service are the same function. The handler builds the model instance and flushes it directly — there is no plugin-authored service layer to call through:
@router.post("", response_model=Read, status_code=201)
async def create(data: Create, session: Session, user: User): # type: ignore[valid-type]
# exclude_unset so omitted fields fall to the column default (e.g. status).
payload = data.model_dump(exclude_unset=True)
await _require(session, user, payload.get("project_id"), f"{key}.create")
obj = model(**payload)
session.add(obj)
await session.flush()
await _emit(session, "created", obj, user.id)
return Read.model_validate(obj)— server/src/radd/kernel/entities.py
A plugin with its own router calls its own service.py at this point
instead; the shape of the step is the same either way.
Event. The handler emits through the host, which calls the real
events.service.emit:
async def emit(
self,
session: AsyncSession,
*,
event_type: str,
entity_type: str,
entity_id: Any,
actor_id: Any,
payload: dict[str, Any] | None = None,
subjects: dict[str, Any] | None = None,
) -> None:
from radd.modules.events import service as events
await events.emit(
session,
event_type=event_type,
entity_type=entity_type,
entity_id=entity_id,
actor_id=actor_id,
payload=payload,
subjects=subjects,
)— server/src/radd/modules/auth/entityhost.py
The handler adds the event row to the same session as the row it describes.
get_session commits both together when the handler returns without raising:
async def get_session() -> AsyncIterator[AsyncSession]:
"""Request-scoped session: commits on success, rolls back on error."""
async with SessionLocal() as session:
try:
yield session
await session.commit()
except BaseException:
await session.rollback()
raise— server/src/radd/db.py
One more step sits outside this list. CommitBeforeSendMiddleware holds the
response until that commit finishes. Without it, a client that reads its
response and immediately issues a follow-up request could race the write:
"""ASGI middleware that holds a response until the request's transaction has committed.
FastAPI runs dependency-with-yield teardown (where `get_session` commits) AFTER the
response bytes are sent. A client that reads its response and immediately issues a
dependent request can therefore miss the write (found in the wild by the Jira importer).
Buffering the send until the inner app — including that teardown — finishes closes the
race. Non-http scopes (websockets, lifespan) pass through untouched.
"""— server/src/radd/middleware.py
See Events and consumers for what happens to that event row next.
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