Extending the platform
AfriAnnotate can load extensions: ordinary Python packages that install next to the platform and register through a single entry point. The core stays untouched, so an extended deployment upgrades like any other. A plain install has no extensions and behaves exactly as documented elsewhere.
Backend: one entry point​
Declare an entry point in the afriannotate.extensions group and point it
at a callable that returns a manifest:
# pyproject.toml of your package
[project.entry-points."afriannotate.extensions"]
billing = "acme_billing:extension"
# acme_billing/__init__.py
def extension():
return {
"name": "billing",
"apps": ["acme_billing"], # Django apps to install
"middleware": [], # dotted paths, appended
"middleware_insert": [], # (index, dotted path) pairs for early slots
"context_processors": [], # dotted paths, appended to TEMPLATES
"urls": ["acme_billing.urls"], # modules with urlpatterns
"settings": {"ACME_CURRENCY": "USD"}, # defaults, never overrides
"clusters": [("billing", "Billing")], # extra organisation-policy clusters
"policies": { # entries in the policy registry
"billing.require_card": {
"mode": "gate", "cluster": "billing", "type": "bool",
"default": False, "label": "Card on file before publishing",
"description": "Projects can only be published once the organisation has a payment method.",
}
},
"label": "Billing", # how people see it named (optional)
"about": "Cards on file and invoices.", # one line on what it gives (optional)
"feature_flags": {"acme_billing": True}, # exposed to the frontend
"frontend": { # descriptors, no code
"navigation": [{"label": "Billing", "to": "/billing", "when": "org_manager"}],
"platform_tabs": [{"key": "billing", "label": "Billing", "path": "/platform/billing"}],
},
}
Every key is optional. What happens with each:
| Key | Effect |
|---|---|
apps, middleware, context_processors | Appended to INSTALLED_APPS, MIDDLEWARE and the template context processors at settings load; migrations run with everyone else's. |
middleware_insert | (index, dotted path) pairs for middleware that must run early, for example one that rewrites the request path before URL resolution. |
urls | Included in the root URL configuration ahead of the SPA catch-all. Mount JSON endpoints under api/ext/<name>/. |
settings | Applied with setdefault, so an operator's environment still wins. |
clusters, policies | Merged into the organisation policy registry; they appear on Organization → Settings → Policies and are enforced wherever that policy key is checked. A policy's type is bool, int, choice, multichoice or text (free text of up to 4,000 characters, shown as a box; placeholder is its hint). |
feature_flags, frontend | Returned by /api/app-config/ under extensions, so the SPA can read them without a rebuild. |
Discovery runs once per process. Set AFRI_EXTENSIONS=0 to ignore every
installed extension, which is what the test suite does. A broken extension
is logged and skipped; it never stops the platform from starting.
Questions the platform asks​
Beyond the manifest, an extension can answer a few questions the platform
asks while it works. Register a function from your AppConfig.ready():
from core import extensions
extensions.on('upload_access', lambda file_upload, user: ...)
| Question | Asked when | Answer |
|---|---|---|
upload_access(file_upload, user) | a user who cannot open an uploaded file's own project requests the file | True to serve it |
import_storages(project) | a project resolves a bucket address in its task data | storages to try after the project's own |
project_deleting(project) | someone deletes a project | a reason to refuse, shown to them; nothing to allow |
project_purging(project) | a deleted project's uploaded files are about to be removed for good | ids of the uploads to keep |
self_classification_tags() | a labeling setup is read | names of tags that save their own result, so a setup made only of one counts as configured |
data_types(xml) | the task fields of a labeling setup are worked out | {field: tag type} for tags that read task data in their own way |
project_data(project) | a project is sent to the browser | a dict, merged into project.extensions; badges (a list of {label, tone, title, icon}) puts labels on the project's card in the Projects list, and their icons (lock, block, pause or warning) on its card on the Home page; lists from several extensions (badges, keywords, hidden_settings) are joined; no_media_download: true takes the browser's download and save menus off the media in the labeling screen (a deterrent: what a person can see, they can capture); watermark: true lays the viewer's name, email and the time across the project's pages, faintly, so a photograph of the screen names who took it; its hidden_settings lists settings pages ("/quality") and sections ("quality:overlap") that do not apply; its keywords lists words the Projects page's search matches besides the title |
project_policy(project) | the platform hands out or accepts work on a project | a policy object, or nothing (see core/extensions.py:project_policy for its optional methods) |
import_plan(project, file_uploads, files_as_tasks_list, saving) | uploaded files are read into tasks | a plan with order(), skips(file), read(file, tasks) and finish(); read and finish may raise ValidationError |
task_scope(queryset, project, user) | the tasks an annotator may see, or is handed next, are worked out | a narrower queryset, or nothing. Applied before a project policy's own queue, so the two combine |
dm_columns(project, result) and dm_task_fields() | the Data Manager's columns are listed, and a task is sent to it | append column definitions to result['columns']; the names of the values to send with each task (annotated on the queryset with data_manager.managers.update_annotation_map) |
reusable_storage_projects(user, organization, ids) | the saved bucket connections a user may reuse are listed, or one is used | the project ids to use instead of ids (the projects the user manages): add some, or take some out |
project_closed_to(user, project) | a user who could otherwise open a project reaches it, or it is asked whether they manage it | True to close it to them |
project_scope(queryset, user) | the projects a user may see are listed | a narrower queryset, or nothing |
project_settings(organization, attrs, instance) | a project is created or its settings are saved, after the organisation's policies | the changed attrs, or nothing; raise a ValidationError to refuse |
storage_url_ttl(project) | a signed address to a bucket file is made for a project | minutes the address may last; the shortest answer wins, never longer than the connection's own |
activity(request, action, target, details) | something the platform does not record itself happens: data.exported, data.task_viewed, storage.connected, storage.removed, storage.cors_added, project.settings_changed, project.setup_changed, data.action, data.task_deleted, data.annotation_deleted | nothing: the extension records it if it wishes. The two project events carry fields and changes ({setting: [before, after]}, told after the save succeeds and only for settings that really changed; long values by their length, secrets hidden) |
maintenance_jobs() | the daily maintenance job runs | names of management commands to run after the platform's own |
| (a response, not a hook) | an extension's middleware or view refuses a person the whole organisation | answer 403 with {"code": "access_refused", "detail": "…", "action": {"href": "…", "label": "…"}}: the app shows one screen with the reason and a button to action.href, instead of an error per request |
policies_saving(request, organization, policies) | the organisation's policies are saved | a reason to refuse the save, or nothing |
audit_actions() | the activity pages list the actions they know | (code, name) pairs for actions the extension records with AuditEvent.log |
task_data_shown(task, project, user, data) | a task is sent to the browser | the data to show in place of data, or nothing. What is stored and exported does not change |
data_kept(project) | a project's data would be deleted: the project, a task, an annotation, a delete action, a retention job | a reason to keep it, or nothing. The retention jobs skip the project |
action_refused(request, project, action_id, queryset) | a Data Manager action is about to run | a reason to refuse it, or nothing |
export_refused(user, project) | data leaves a project (export, snapshot, push to a hub) | a reason to refuse it, for platform staff too |
storage_saving(user, project, storage_type, target, source) | a bucket storage is saved on a project | a reason to refuse it, or nothing. source is the saved connection the credentials are copied from, or None when they were typed in |
import_waits(project, file_uploads) | files are uploaded for an import that is not saved yet | True to store them without reading them as tasks |
dm_annotation_filters() | a Data Manager filter names an annotation column | the column ids the extension can filter on |
health_rows(organization or None) | an owner opens Organization → Health (the organisation) or staff open Platform → Health (None) | rows {label, state, text}, state good, bad or none, shown on that page |
eaf_builder() | an .eaf file is exported | a function with build_eaf's arguments, used in its place |
notification_scopes() | a notification bell asks for its list | {app: kind prefix}: ?app=<app> shows only those kinds, ?app=label leaves them out |
notification_email(user, kind) | a notification is about to be emailed | True when the extension sends that email its own way |
verification_exempt_paths() | an unverified user requests a page | path prefixes served without the verify-email wall |
logout_redirect(request) | someone logs out | where to send them, or nothing |
robots_disallow() | /robots.txt is served | extra paths to disallow |
online_only_writes() | the desktop app decides whether a write needs the cloud | (regex, mirrored_only) pairs |
sign_in_elsewhere(email) | a person types their email on the sign-in page | {url, label, only} to send them to sign in at url (their organisation's own sign-in, which hands them back the same way as a sign_in_buttons way); only says there is no password sign-in for them here |
sign_in_buttons(request) | the sign-in and sign-up cards are shown | a list of {key, label, url, icon}: other ways to sign in, shown as buttons under the form (url is a path on this server, icon an image as a data:image/... address). On the sign-up card the page adds intent=signup, consent=1 once the consent box is ticked, and the invitation token. A way that finds no account sends the person to /auth/signup?why=no_account&email=.... A way never sets a session behind the app's back (the app keeps tokens of its own): it signs the current session out when it starts, hands a verified person back with a one-time code from users.sign_in_codes.issue(user, elsewhere=True) at /auth/continue?code=... (the app exchanges it at POST /api/auth/exchange/ for its tokens and a session, both for that person), and sends a refusal to /auth/login?refused=<words> (the card drops what the browser held and says why) |
payout_run_deleting(request, run) | a draft payout run is about to be deleted | a reason refuses it |
payout_run_marking_paid(request, run) | a payout run is about to be marked paid by hand, which issues its receipts | a reason refuses it |
member_changing(request, organization, member, change) | a member is about to be removed ({what: 'remove', disposition}) or given another role ({what: 'role', from, to}) | a reason, to refuse it |
projects_matching(word) | the Projects page's search asks for word | ids of projects to find besides those whose title has the word (by a name the extension keeps about them); the list is still narrowed to what the person can open |
annotation_template_dirs() | the template gallery is built | folders of templates laid out like annotation_templates, each with an optional groups.txt |
extension_on(organization, name) | what an organisation is offered is worked out: the policies page leaves out an extension's policies, and the browser gets the names offered as APP_SETTINGS.extensions_on (also in /api/app-config/) | False to switch extension name off for that organisation; None to say nothing. For an extension that decides what the others are available to, by licence or agreement |
extension_refusal(user, name) | a route of extension name (from its urls) is about to serve user | a reason, to refuse the call with 403 |
For something that needs the person to confirm it is really them: the app's POST /api/confirm-identity/ takes their password, or a passkey answer, and returns a proof good for five minutes; users.confirm.confirmed(request) reads it from the request (proof in the body, or the X-Identity-Proof header) and says how they confirmed, or nothing.
An answer that raises is logged and left out; it never fails the request.
Frontend: one import​
The web app imports @afriannotate/extensions at each place it offers an
extension point. At build time that name resolves to the package in
AFRI_EXTENSIONS_DIR, or to an empty stub when the variable is unset, so
the open platform always builds on its own.
AFRI_EXTENSIONS_DIR=/path/to/your-extensions/web/src/index.ts yarn build
The package exports:
| Export | Where it appears |
|---|---|
navigation | Main sidebar, after Platform, or right under Projects with after: "projects". when is signed_in, org_manager or staff. |
routes | Top-level pages, same descriptor shape as apps/labelstudio/src/pages/index.js. |
organizationTabs | Tabs of the Organization page, each with its page: { key, label, path, component, when, after, replaces }. The page is mounted at /organization<path> and renders the organisation's layout itself; when is "admin" (default) or "manager"; after names the tab it follows; replaces takes the place of a platform tab. |
earningsSections | Cards on a person's own Earnings tab, after their receipts: { key, component }. Shown to the person themself only. |
payoutsPage | Extra actions on the organisation's Payouts page: { header: [Component], run: [Component] }. header components sit with the page's own buttons; run components sit in each run's row and receive { run, onChange } (call onChange to reload the list). |
settingsTabs.project | Extra pages in a project's Settings menu (components with title and path). A page may set menuSection ("general", "labeling", "review", "governance" or "data"; default: after the last section) and appliesTo(project). |
importStep | { ImportPage }: the Import step used in place of the platform's, taking the same props. |
settingsIndex | Entries for the project settings search box: [{ label, tab, path, keywords }]. |
dmPanels | Panels beside the Data Manager's table: [{ id, event, component }], opened by the Data Manager event event. The component receives project, onLabelTask(taskId) and onClose. |
settingsTabs.organization | Extra tabs in Organization → Settings, before Danger Zone. |
settingsTabs.platform | Extra tabs and routes under Platform. |
dashboardCards | Reserved. |
analyticsPanels | Cards on a project's Analytics page: { key, section, component }, section one of overview, quality, readiness, delivery, people. The component is rendered inside that section's grid with { project, rangeParams, Panel } and returns one or more Panel cards (title, subtitle, empty, span). |
projectActions | Buttons in a project's header on the Data Manager: [{ id, component }]. The component receives project and canManage. |
importSources | Extra tabs in the Import step: [{ id, label, component, finish? }]. The component receives project, requiredKeys and labelConfig, and writes tasks itself. In the New Project wizard nothing is written before Save: the component also receives deferred, state and setState, records what to bring in, and finish({ project, state, api }) (resolving to { ok, message? }) runs when the project is saved. CreateProject accepts initialName and initialImport={{ source, state }} to open with a source already chosen. An extension page can also show the Import step itself (ImportPage with useImportPage(project)), with sources naming the tabs to offer. |
exportCards | Extra cards on a project's Export page: [{ id, component }]. The component receives project and decides whether it applies. |
configFields | Readers for tags the extension adds, so the Import step knows which task fields they use: [(element) => [{ field, kind?, list? }]]. |
configSchema | Completion for the extension's tags in the labeling setup's code editor: { tags, inView } in the shape of the platform's tags.json. |
Anything not exported is treated as empty.
Tags in the labeling editor​
An extension can add labeling tags. Beside the package entry, a file named
editor.ts exports:
| Export | Meaning |
|---|---|
resultTypes | Result types the tags save (the type of a result and the key of its value). |
containerChildren | Tag names that may appear inside <View> and <PagedView>. |
loadTags() | Called once the editor's own control tags are loaded. Import the modules that register the tags inside it, using @afriannotate/editor/... for editor internals. |
The editor resolves @afriannotate/editor-extensions to that file, and to
an empty stub when no extension is installed. Pair a tag with the backend
questions above (self_classification_tags, data_types) so the server
reads the same setup the editor does.
Deploying an extended instance​
Build the platform image as usual, then layer the extension package on top:
ARG PLATFORM_IMAGE
FROM ${PLATFORM_IMAGE}
COPY extensions /opt/ext
RUN pip install --no-cache-dir /opt/ext
Run the frontend build with AFRI_EXTENSIONS_DIR set, and keep the
extension package under its own version control and release cycle. The
platform repository never needs to know it exists.
Checklist for extension authors​
- Keep the manifest name short and stable; it becomes the
api/ext/<name>/prefix. - Write migrations for your own apps only; never alter core tables.
- Gate new endpoints with the platform's role helpers (
core.roles) rather than re-implementing access rules. - Add tests in your package; the platform's CI does not run them.
The Data Manager​
Beside the package entry, a file named dm.ts exports what an extension
adds to the Data Manager (resolved as @afriannotate/dm-extensions):
| Export | Meaning |
|---|---|
modes | [(project) => mode or null]. A mode changes how a project's work is listed and done: listsAnnotations (the table lists annotations, one row each), repeat (a task takes many annotations from one person; Submit opens a fresh form on the same task), stateEndpoint, words (button and message wording), footer(counts), toolbar ([{ id, label, event }]), Grid (grid-view card) and LabelPanel (shown above the editor). |
cells | Extra table cells by column name. |
endpoints | Endpoints added to the Data Manager's API. |
A host of its own​
An extension can serve its own app on a dedicated hostname. Its context
processor (listed in the manifest) overrides the page-shell values the
platform defaults in core/context_processors.py: host_mode (matched by
the frontend's hostModes), host_title, host_favicon_png /
host_favicon_ico, host_own_og, host_no_service_worker,
host_auth_brand_template (a template included as the sign-in brand
block), host_theme_toggle and host_settings (handed to the page as
APP_SETTINGS.host_settings). The deploy script takes EXTRA_HOSTS and
EXTRA_ENV for the hostnames and environment such a service needs.