Skip to main content

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:

KeyEffect
apps, middleware, context_processorsAppended 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.
urlsIncluded in the root URL configuration ahead of the SPA catch-all. Mount JSON endpoints under api/ext/<name>/.
settingsApplied with setdefault, so an operator's environment still wins.
clusters, policiesMerged 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, frontendReturned 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: ...)
QuestionAsked whenAnswer
upload_access(file_upload, user)a user who cannot open an uploaded file's own project requests the fileTrue to serve it
import_storages(project)a project resolves a bucket address in its task datastorages to try after the project's own
project_deleting(project)someone deletes a projecta reason to refuse, shown to them; nothing to allow
project_purging(project)a deleted project's uploaded files are about to be removed for goodids of the uploads to keep
self_classification_tags()a labeling setup is readnames 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 browsera 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 projecta 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 tasksa 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 outa 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 itappend 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 usedthe 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 itTrue to close it to them
project_scope(queryset, user)the projects a user may see are listeda narrower queryset, or nothing
project_settings(organization, attrs, instance)a project is created or its settings are saved, after the organisation's policiesthe changed attrs, or nothing; raise a ValidationError to refuse
storage_url_ttl(project)a signed address to a bucket file is made for a projectminutes 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_deletednothing: 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 runsnames 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 organisationanswer 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 saveda 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 browserthe 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 joba 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 runa 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 projecta 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 yetTrue to store them without reading them as tasks
dm_annotation_filters()a Data Manager filter names an annotation columnthe 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 exporteda 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 emailedTrue when the extension sends that email its own way
verification_exempt_paths()an unverified user requests a pagepath prefixes served without the verify-email wall
logout_redirect(request)someone logs outwhere to send them, or nothing
robots_disallow()/robots.txt is servedextra 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 showna 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 deleteda reason refuses it
payout_run_marking_paid(request, run)a payout run is about to be marked paid by hand, which issues its receiptsa 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 wordids 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 builtfolders 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 usera 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:

ExportWhere it appears
navigationMain sidebar, after Platform, or right under Projects with after: "projects". when is signed_in, org_manager or staff.
routesTop-level pages, same descriptor shape as apps/labelstudio/src/pages/index.js.
organizationTabsTabs 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.
earningsSectionsCards on a person's own Earnings tab, after their receipts: { key, component }. Shown to the person themself only.
payoutsPageExtra 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.projectExtra 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.
settingsIndexEntries for the project settings search box: [{ label, tab, path, keywords }].
dmPanelsPanels 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.organizationExtra tabs in Organization → Settings, before Danger Zone.
settingsTabs.platformExtra tabs and routes under Platform.
dashboardCardsReserved.
analyticsPanelsCards 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).
projectActionsButtons in a project's header on the Data Manager: [{ id, component }]. The component receives project and canManage.
importSourcesExtra 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.
exportCardsExtra cards on a project's Export page: [{ id, component }]. The component receives project and decides whether it applies.
configFieldsReaders for tags the extension adds, so the Import step knows which task fields they use: [(element) => [{ field, kind?, list? }]].
configSchemaCompletion 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:

ExportMeaning
resultTypesResult types the tags save (the type of a result and the key of its value).
containerChildrenTag 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):

ExportMeaning
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).
cellsExtra table cells by column name.
endpointsEndpoints 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.