ntasker's core is the task store, the sidebar, the kanban and the run infrastructure. Everything a user may not want
-- a particular AI coding agent, and (in later releases) the workspace browser or task-context attachments -- is a
plugin that can be switched off individually. Built-ins live under src/ntasker/plugins/<name>/: the three agent
plugins claude, opencode and pi, plus task_context (task-context.md), workspace
(workspace.md) and the opt-in voice (voice.md).
- UI:
/settings-> Plugins tab, one card per plugin with a switch in its header. Takes effect immediately, no restart. A plugin's own settings sit inside its card and are shown only while it is on. - CLI:
ntasker config set plugins_disabled '["pi"]'-- a JSON array of plugin names to switch off. - ENV:
NTASKER_PLUGINS_DISABLED=pi,opencode(comma-separated) wins over the stored setting.
The list is a disabled list so that a plugin added in a later release is on by default and an existing DB needs no migration. The validator rejects unknown names and any list that would leave no agent plugin enabled: every task needs an agent to resolve to. (The ENV path is not validated; a value that disables every agent is ignored for the agents.)
Opt-in plugins (PluginSpec.default_on=False, e.g. voice) are off until listed in the plugins_enabled setting
(ENV NTASKER_PLUGINS_ENABLED). ntasker enable <plugin> / ntasker disable <plugin> write whichever of the two
lists applies to the plugin; the Plugins card does the same. A plugin that names an extra (PluginSpec.extra, the
ntasker[<extra>] optional dependency group) gets its missing packages installed by ntasker enable first, via
the same installer detection as self-update; the card can only switch, not install.
What "disabled" means:
| Surface | Disabled plugin |
|---|---|
Routes (ctx.add_router) |
answer 404 plugin disabled |
Template slots, scripts, window.__i18n keys |
not rendered / not merged |
Agent (ctx.add_agent) |
not listed by /api/agents, not a valid agent value, default_agent falls back |
CLI subcommand (ctx.add_cli) / ntasker agent install <key> |
refuses with exit 2 |
Tables and migrations (ctx.add_schema / add_migration) |
still run -- additive only, data survives a toggle |
Registration is static, enablement is dynamic. ntasker.plugins.load_all() runs once per process -- at import of
ntasker.app, in ntasker.cli.build_parser(), or lazily from ntasker.agents -- and wires every built-in plugin
regardless of its switch: routes, settings validators, agent specs, CLI subparsers, template slots. Whether a plugin
acts is decided per request by is_enabled(name), which reads the setting. So there is no start-up ordering
problem (routes exist before the DB is bound) and no restart to toggle.
Disabled means invisible, never destructive. See the table above.
A plugin is a package ntasker/plugins/<name>/ with two module attributes:
from ntasker.i18n import _lazy
from ntasker.plugins import PluginContext, PluginSpec
SPEC = PluginSpec(
name="example", # package name; also the settings token
label=_lazy("Example"), # shown on the Plugins card
description=_lazy("What it adds."),
kind="feature", # "feature" | "agent"
default_on=True, # False = opt-in via plugins_enabled
extra=None, # "voice" = packages of ntasker[voice]
)
def register(ctx: PluginContext) -> None:
...register() is called once at load and hands the plugin's pieces to the context:
| Method | What it contributes |
|---|---|
add_router(router) |
FastAPI APIRouter; every route carries a require_enabled dependency |
add_setting(key, validator, hint=None, label=None) |
settings key: validator, hint, label registries (see below) |
add_schema(sql) |
CREATE TABLE IF NOT EXISTS ... script, run on every init_db() |
add_migration(fn) |
idempotent fn(conn), run after the schema on init_db() |
add_agent(spec) |
an AgentSpec (see agents.md) |
add_cli(fn) |
fn(subparsers) adds subcommands to the top-level parser |
add_template_slot(slot, template) |
Jinja template rendered inside a core page slot (below) |
add_js_strings(fn) |
fn() -> {key: translated} merged into window.__i18n per request |
static_url(filename) |
URL of a file under the plugin's static/ (/static/plugins/<name>/<file>) |
A key registered with a label is rendered as a text field on the plugin's card on the Plugins tab; one without
stays CLI/API-only (or is driven by the plugin's own settings slot template, like voice_model).
Slot templates are addressed relative to plugins/, e.g. "example/templates/sidebar.html", and render with the
page's full context. Slots:
| Slot | Where |
|---|---|
head |
index.html <head>, after style.css |
sidebar |
index.html, below the tags section |
task_form |
index.html, create form, after the description |
task_edit |
index.html, edit modal, after the description |
modals |
index.html, before the toast container |
scripts |
index.html, before app.js |
settings |
settings.html, inside the plugin's card on the Plugins tab (a card-body fragment, no card of its own) |
Frontend: a plugin's scripts slot loads a script that pushes a factory onto window.ntaskerPlugins. The object it
returns (state + methods) is merged into the tracker() Alpine component; a pluginInit() method, if present, is
awaited at the end of init(). window.__plugins holds the enabled plugin names for JS-side gating.
i18n: plugin Python and templates go through the one catalog (babel.cfg covers plugins/**); run make i18n as
usual.
GET /api/plugins lists every built-in with name, label, description, kind, default_on, enabled,
fields (its labelled settings keys), icon/image and settings (whether it fills the settings slot; toggling
such a plugin reloads the page).
Toggling goes through PUT /api/settings/plugins_disabled (default-on plugins) or plugins_enabled (opt-in).
Third-party discovery via entry points is deliberately absent -- one loop in load_all() would add it, but there are
no such plugins yet and entry points bring packaging and version-skew concerns for nothing.