Plugin authoring¶
The classes and hooks a plugin author uses. See the plugin development guide for the how-to; this page is the generated reference.
Plugin framework¶
sparkth.lib.plugins ¶
Public API for the plugin framework.
All plugins and external modules import the plugin-authoring surface from here.
Nothing outside sparkth/lib/plugins should import from sparkth.core.plugins,
sparkth.core.plugins.base, sparkth.core.plugins.config_base,
sparkth.core.plugins.middleware or sparkth.core.plugins.service directly.
ConfigWidget ¶
Bases: StrEnum
A form control the settings UI knows how to render.
Values are the names the frontend config modal looks a control up by: the controls a plugin passes into its own config modal, then core's built-in controls. A field with no hint is rendered from its JSON-schema type alone, which covers plain strings, numbers and booleans; declare a widget only when the type does not carry enough meaning -- a multi-line message, a secret, or a value that must be picked from data the frontend fetches.
PluginAccessMiddleware ¶
PluginAccessMiddleware(
app: Any, exclude_paths: list[str] | None = None
)
Bases: BaseHTTPMiddleware
Reject requests to plugins the caller has turned off, before they reach the route.
Runs ahead of routing, so it resolves both facts it needs itself: which plugin owns the
requested URL (from the name register_router stamps on plugin endpoints) and who is
asking (from the request's bearer token, via the helpers in sparkth.lib.auth that
get_current_user is built from).
Anonymous requests fail open. Plugin routers carry unauthenticated endpoints — Slack's OAuth callback is called by Slack itself, with no token — and a per-user preference is meaningless without a user; endpoints that do require a caller are still rejected by their own auth dependency.
Routes registered platform_wide pass like core routes.
PluginConfig ¶
Bases: BaseModel
Base class for all plugin configs
PluginConfigInvalidError ¶
PluginConfigInvalidError(display_name: str)
Bases: PluginError
Raised when the calling user's saved settings for a plugin no longer validate.
The message is shown to the chat model, so it names the plugin and tells the user where to update its settings.
PluginNotConfiguredError ¶
PluginNotConfiguredError(display_name: str)
Bases: PluginError
Raised when the calling user has saved no settings for a plugin.
The message is shown to the chat model, so it names the plugin and tells the user where to add its settings.
PluginService ¶
Business logic related to Plugin persistence and state.
apply_postprocess
async
staticmethod
¶
apply_postprocess(
plugin_name: str,
session: AsyncSession,
user_id: int,
stored_config: dict[str, Any],
) -> dict[str, Any]
Run the plugin's postprocess adapter if one is registered.
apply_preprocess
async
staticmethod
¶
apply_preprocess(
plugin_name: str,
session: AsyncSession,
user_id: int,
incoming_config: dict[str, Any],
) -> dict[str, Any]
Run the plugin's preprocess adapter if one is registered.
config_with_schema_keys
staticmethod
¶
config_with_schema_keys(
schema: dict[str, Any], config: dict[str, Any] | None
) -> dict[str, Any]
Stored config, with every schema key it lacks present as None.
The settings UI renders one field per key it receives, so a plugin whose config was never saved -- an empty dict, as created by enabling it -- must still report its declared fields.
get_all
async
¶
get_all(
session: AsyncSession,
include_disabled: bool = True,
include_deleted: bool = False,
) -> Sequence[Plugin]
Get all plugins.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
AsyncSession
|
Database session |
required |
include_disabled
|
bool
|
Whether to include disabled plugins |
True
|
include_deleted
|
bool
|
Whether to include soft-deleted plugins |
False
|
Returns:
| Type | Description |
|---|---|
Sequence[Plugin]
|
list of Plugin objects |
get_or_create_all
async
¶
get_or_create_all() -> None
Ensure every loaded plugin has a row in the database.
Called once at startup. Fetches existing plugins in a single query and
upserts all loaded plugins in one transaction. Only config_schema is
refreshed on existing rows — enabled is left untouched so a plugin a
user disabled stays disabled across restarts.
validate_user_config
staticmethod
¶
validate_user_config(
plugin: Plugin, user_config: dict[str, Any]
) -> dict[str, Any]
Validate and normalize user configuration against plugin's Pydantic config model.
Uses the plugin.config_schema directly instead of dynamically loading config.py.
Raises:
| Type | Description |
|---|---|
ConfigValidationError
|
if config_schema is not a subclass of PluginConfig or if validation fails |
SparkthPlugin ¶
SparkthPlugin(name: str)
Base class for Sparkth plugins.
All plugins should inherit from this class. Each plugin declares its own
name explicitly by passing it positionally to super().__init__(); the
loader constructs every plugin as plugin_class() and reads the declared
name; nothing is ever derived from the class name. Register routes, tools,
the config schema, and frontend metadata from within __init__.
Example:
from sparkth.lib.mcp.hooks import MCP_TOOLS, Tool
class MyAppPlugin(SparkthPlugin):
def __init__(self) -> None:
super().__init__("my-app")
MCP_TOOLS.add_item(self, Tool(self.my_tool, category="utilities"))
async def my_tool(self, payload: MyPayload) -> dict:
"""Describe what the tool does (becomes the MCP tool description)."""
...
Initialize the plugin with its declared name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Unique identifier for the plugin (e.g., "canvas"). Must be a kebab-case slug (lowercase letters, digits, single hyphens). |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the name is not a valid kebab-case slug. |
UserPluginResponse ¶
Bases: BaseModel
Response model for user plugin information.
display, sidebar, and has_frontend carry the read-only
frontend-facing metadata the plugin declared through the
sparkth.lib.frontend hooks; build responses with :meth:for_plugin
so they are always populated.
config_schema is the plugin's declared JSON schema, which the settings
UI renders its form from -- field types, labels, defaults, and the widget
hints a plugin sets through json_schema_extra. It is empty for a plugin
that registered no config class.
config never carries a field marked secret(); secrets_set names the ones stored.
for_plugin
classmethod
¶
for_plugin(
plugin_name: str,
enabled: bool,
config: dict[str, Any],
is_core: bool,
config_schema: dict[str, Any] | None = None,
) -> UserPluginResponse
Build a response carrying the frontend metadata declared for plugin_name.
The declared display and sidebar strings are gettext_noop-marked
source messages; this is their rendering boundary, so they are
translated into the request locale here.
disabled_plugin_names
async
¶
disabled_plugin_names(
user_id: int | None, session: AsyncSession
) -> set[str]
Names of the plugins switched off for this user, by either switch that can turn one off.
A plugin is disabled when an administrator turned it off system-wide
(Plugin.enabled) or the user turned it off for themselves (UserPlugin.enabled).
The join is a LEFT OUTER one keyed on the user as well as the plugin, so a plugin the
user has no preference on stays in play and another user's preference never answers
for this one. With no user, the join matches no preference and only the system-wide
switch applies.
Answers "which plugins are off?" rather than "which are on?" on purpose. A plugin with
no registry row (one that has never reached get_or_create_all), or with no
preference row for this user, is absent from the result and so stays reachable.
Soft-deleted plugin and preference rows are ignored.
One query serves a whole request, so a caller checking many plugins does not issue a query per plugin.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
int | None
|
The user to answer for, or None to apply only the system-wide switch. |
required |
session
|
AsyncSession
|
The async database session to read through. |
required |
Returns:
| Type | Description |
|---|---|
set[str]
|
The set of disabled plugin names, empty when every plugin is on for this user. |
get_disabled_plugins
async
¶
get_disabled_plugins(
request: Request,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
) -> set[str]
The plugins switched off for the requesting user, as a route dependency.
Reuses the set PluginAccessMiddleware left on request.state when it has already
read the switches for this request's caller, rather than running the same query again.
The gate writes it beside request.state.user, from this request's own token, so the
answer is the one this dependency would have computed. A request the gate never
identified falls through to disabled_plugin_names.
get_plugin_loader ¶
get_plugin_loader() -> PluginLoader
Get the singleton PluginLoader instance.
This function is safe and efficient to call multiple times.
Returns:
| Name | Type | Description |
|---|---|---|
PluginLoader |
PluginLoader
|
The global plugin loader instance |
load_user_plugin_config
async
¶
load_user_plugin_config(
plugin_name: str, config_class: type[ConfigT]
) -> ConfigT
Load the calling user's stored settings for a plugin as config_class.
The caller is the user bound to this request (current_user_id), never an argument,
so a tool cannot be steered into reading someone else's settings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
plugin_name
|
str
|
The registered name of the plugin whose settings to read. |
required |
config_class
|
type[ConfigT]
|
The plugin's config model the stored settings are validated into. |
required |
Returns:
| Type | Description |
|---|---|
ConfigT
|
The validated config. |
Raises:
| Type | Description |
|---|---|
NoAuthenticatedUser
|
no user is bound, checked before any database work. |
PluginNotConfiguredError
|
the user has no row or an empty config. |
PluginConfigInvalidError
|
the saved config fails validation. Only the failing field names are logged, never their values. |
secret ¶
secret(
control: ConfigWidget = ConfigWidget.PASSWORD,
) -> dict[str, Any]
json_schema_extra marking a config field as a secret the API never returns.
api_key: str = Field(..., json_schema_extra=secret())
private_key: str = Field(..., json_schema_extra=secret(ConfigWidget.TEXTAREA))
widget ¶
widget(control: ConfigWidget) -> dict[str, Any]
json_schema_extra marking a config field as rendered by control.
lms_password: str = Field(..., json_schema_extra=widget(ConfigWidget.PASSWORD))
Route registration¶
sparkth.lib.routes ¶
platform_wide ¶
platform_wide(endpoint: F) -> F
Mark a plugin endpoint as serving platform-wide data.
The plugin access gate lets a marked endpoint through whatever the plugin switches say; its own dependencies (e.g. a permission) still apply. Apply it below the route decorator.
register_router ¶
register_router(
plugin: SparkthPlugin, router: APIRouter
) -> None
Register a router associated to a plugin.
The plugin routes will automatically be prefixed with "/api/v1/
MCP tools¶
sparkth.lib.mcp.hooks ¶
Tool
dataclass
¶
Tool(
handler: Callable[..., Any], category: str | None = None
)
An MCP tool a plugin contributes to the :data:MCP_TOOLS hook.
The plugin registers it from its __init__ with
MCP_TOOLS.add_item(self, Tool(self.my_method, category="...")). The tool's
name and description are derived from the bound handler (its name and
docstring); the input schema is auto-generated from the handler signature.
The handler is wrapped for audit at construction
(:func:sparkth.lib.audit.audited_tool), so every execution path
that consumes the hook (the FastMCP server and the chat tool registry)
records the tool call without instrumenting each consumer. Wrapping
preserves the handler's name, docstring, and generated input schema.
generate_input_schema ¶
generate_input_schema(
func: Callable[..., Any],
) -> dict[str, Any]
Auto-generate a JSON Schema from a function signature using type hints.
resolve_schema_refs ¶
resolve_schema_refs(
schema: Any, defs: dict[str, Any]
) -> Any
Recursively resolve all $ref references inline within a JSON schema.
type_to_json_schema ¶
type_to_json_schema(py_type: type[Any]) -> dict[str, Any]
Convert a Python type to a JSON Schema type definition.
Config hooks¶
sparkth.lib.config ¶
get_plugin_adapter ¶
get_plugin_adapter(
plugin_name: str,
) -> LLMConfigAdapter | None
Return the config adapter a plugin contributed, looked up by plugin name.
get_plugin_config_schema ¶
get_plugin_config_schema(
plugin_name: str,
) -> type[PluginConfig] | None
Return the config class a plugin contributed, looked up by plugin name.
iter_plugin_adapters ¶
iter_plugin_adapters() -> Iterator[
tuple[str, LLMConfigAdapter]
]
Yield (plugin_name, adapter) for every plugin that contributed a config adapter.
Like CONFIG_SCHEMAS, CONFIG_ADAPTERS is populated when plugins are instantiated at the process entrypoint; this iterator assumes that has already happened.
iter_plugin_config_schemas ¶
iter_plugin_config_schemas() -> Iterator[
tuple[str, type[PluginConfig]]
]
Yield (plugin_name, config_class) for every plugin that contributed a config.
Plugins are instantiated once per process at the entrypoint (the FastAPI lifespan, the standalone MCP server, or the migration runner), which is what populates CONFIG_SCHEMAS; this iterator assumes that has already happened.
Frontend metadata hooks¶
sparkth.lib.frontend ¶
Lookup helpers over the frontend-facing plugin declaration hooks.
Like sparkth.lib.config, the hooks are populated when plugins are
instantiated at the process entrypoint; these helpers assume that has already
happened and resolve declarations by plugin name.
get_plugin_display_info ¶
get_plugin_display_info(
plugin_name: str,
) -> DisplayInfo | None
Return the display info a plugin declared, looked up by plugin name.
get_plugin_sidebar_entry ¶
get_plugin_sidebar_entry(
plugin_name: str,
) -> SidebarEntry | None
Return the sidebar entry a plugin declared, looked up by plugin name.
plugin_has_frontend ¶
plugin_has_frontend(plugin_name: str) -> bool
Return whether a plugin declared that it ships a frontend page.
sparkth.lib.frontend.hooks ¶
Hooks for the frontend-facing metadata a plugin declares.
The backend is the single source of truth for what the frontend shows about a
plugin. Each concern is its own hook, mirroring CONFIG_SCHEMAS and
MCP_TOOLS, rather than one monolithic manifest object:
- :data:
DISPLAY_INFO: the human-facing identity (display name, description, optional icon) shown in settings and catalogs. Every plugin should register one; without it the plugin appears with only its slug name. - :data:
SIDEBAR_ENTRIES: the dashboard sidebar navigation entry (label, optional icon, sort order). Register it only if the plugin should appear in the sidebar. - :data:
FRONTEND_APPS: marks the plugin as shipping a frontend page at/dashboard/<plugin-name>. Backend-only plugins skip it.
A plugin registers from its __init__, passing human-facing names
positionally::
DISPLAY_INFO.add_item(self, DisplayInfo("Create Course", "Build courses with AI", icon="plus"))
SIDEBAR_ENTRIES.add_item(self, SidebarEntry("Create Course", icon="plus", order=1))
FRONTEND_APPS.add_item(self, FrontendApp())
Icons cross the wire as lucide <https://lucide.dev/icons/>_ icon names, never
as components; the frontend resolves names to components.
The declarations are exposed read-only on the user-plugins API (display,
sidebar, and has_frontend on UserPluginResponse), so the frontend
renders what the backend declares instead of keeping its own copy.
DisplayInfo
dataclass
¶
DisplayInfo(
display_name: str,
description: str,
icon: str | None = None,
)
The human-facing identity of a plugin, shown in settings and catalogs.
FrontendApp
dataclass
¶
FrontendApp()
Marks the plugin as shipping a frontend page (/dashboard/<plugin-name>).
Registering it is the declaration. The marker carries no data yet; fields describing the frontend counterpart (entry route, bundle info) belong here as the frontend plugin system grows.
SidebarEntry
dataclass
¶
SidebarEntry(
label: str, icon: str | None = None, order: int = 100
)
A dashboard sidebar navigation entry pointing at the plugin's frontend page.
order sorts entries ascending; entries that keep the default sort last.
Content contributor hooks¶
sparkth.lib.content.hooks ¶
Hook through which a plugin contributes content another plugin publishes to an LMS.
A content contributor is a named producer of one LMS content block. A plugin that owns content registers one; a plugin that publishes to an LMS resolves it by name and awaits the builder registered under the publishing plugin's own name.
A contributor may also offer options, the choices an author picks between, such as which activity to place. The publisher lists them and hands the chosen option's id back to the builder.
The hook is keyed by plugin, so a plugin switched off for a user stops contributing: a
publishing plugin resolves contributors through :func:enabled_content_contributors, which
leaves out those of a disabled plugin. A plugin registers one contributor from its
SparkthPlugin.__init__::
register_content_contributor(
self, ContentContributor("pxc", "...", {"open-edx": build_pxc_block}, list_pxc_options)
)
ContentBlock
dataclass
¶
ContentBlock(title: str, kind: str, attributes: Any = None)
One content block to create in an LMS course.
kind and attributes are opaque here; the publishing plugin defines what each means
for its own LMS.
ContentContributor
dataclass
¶
ContentContributor(
name: str,
description: str,
builders: Mapping[
str,
Callable[
[str, str | None], Awaitable[ContentBlock]
],
],
list_options: Callable[
[], Awaitable[list[ContentOption]]
]
| None = None,
callback: Callable[[str | None], Awaitable[None]]
| None = None,
)
A named producer of one :class:ContentBlock per LMS it targets.
description is human-facing: it is what a publishing tool lists
back to an agent choosing a contributor.
builders is keyed by the publishing plugin's (LMS's) own name, so its keys are the LMSes this
contributor targets. A builder reports a failure by raising
:class:~sparkth.lib.content.exceptions.ContentBuildError.
list_options, when set, returns the options the authenticated caller may choose from,
and a publishing tool requires one of them: nothing is built by default. Leave it None
for a contributor that builds one kind of block; a publishing tool then refuses any option id.
callback, when set, is awaited by a publishing tool with the option id the builder was
given. The tool awaits it once it has created the block in the course and before it writes
the block's attributes. A block whose attribute write then fails still counts as placed,
since the caller may write its attributes again with another tool.
callback reports a failure by raising
:class:~sparkth.lib.content.exceptions.ContentPlacementError.
list_options, callback and the builders must be module-level functions. Each
construction of a plugin registers its contributor again, which is accepted only if the
copies compare equal, and a closure or bound method would not.
ContentOption
dataclass
¶
ContentOption(id: str, label: str)
One choice a contributor offers for the block it builds.
id is what a publishing tool hands back to the builder as option_id; label is
what an agent shows the author.
enabled_content_contributor
async
¶
enabled_content_contributor(
name: str,
) -> ContentContributor
The contributor registered as name, if its plugin is switched on for the current caller.
The switches are read only once the name is found. A database error propagates.
Raises:
| Type | Description |
|---|---|
UnknownContentContributorError
|
No contributor is registered as |
ContentContributorUnavailableError
|
Its plugin is switched off for the caller. |
enabled_content_contributors
async
¶
enabled_content_contributors() -> dict[
str, ContentContributor
]
The registered contributors the current caller may use, by name.
Leaves out every contributor whose owning plugin is switched off for the caller, system-wide or by their own preference. A database error propagates.
register_content_contributor ¶
register_content_contributor(
plugin: SparkthPlugin, contributor: ContentContributor
) -> None
Register plugin's contributor on the LMS_CONTENT_CONTRIBUTORS hook.
Call this from a plugin's __init__, passing self. The hook records the plugin, so
that plugin's switches decide whether the contributor is offered. A plugin has one
contributor: registering one under another name from the same instance replaces the first.
A plugin is constructed more than once in a single process — the loader builds it, and its
own tests build their own instances — so each construction registers the same contributor
again. An equal contributor from a plugin of the same name is accepted, and the copies fold
into one when read. A contributor claiming a registered name with a different value, or
from a different plugin, raises
:class:~sparkth.lib.content.exceptions.DuplicateContentContributorError.
Equality is what separates the two cases, so a contributor must stay a value with
field-wise equality — that is why ContentContributor is a frozen dataclass rather
than a class with identity semantics.
Content contributor exceptions¶
sparkth.lib.content.exceptions ¶
Domain exceptions raised when plugins contribute LMS content.
ContentBuildError ¶
Bases: Exception
Raised by a :class:ContentContributor's build when it cannot produce its block.
A publishing tool resolves the failure through its own error contract (for example,
Open edX's openedx_add_plugin_content returns an {"error": {...}} dict) instead of
letting the exception propagate as a raw, unhandled failure.
ContentContributorUnavailableError ¶
ContentContributorUnavailableError(name: str)
Bases: Exception
Raised when the requested contributor's plugin is switched off for the caller.
The contributor is registered, so the caller is told it may not be used rather than that it does not exist.
ContentPlacementError ¶
Bases: Exception
Raised by a :class:ContentContributor's callback when it cannot record a placement.
The block already exists in the course by then. A publishing tool reports the failure with the block's locator through its own error contract, and leaves the block's attributes unwritten, so the block does not reference content whose placement went unrecorded.
DuplicateContentContributorError ¶
DuplicateContentContributorError(name: str)
Bases: Exception
Raised when a contributor claims a name that a different value or plugin already holds.
Fires only for a different contributor, or one from a different plugin, claiming the name, which is a programming error: a publishing tool resolves by name, so two contributors under one name would leave it unclear which to build and whose switch applies. An equal contributor from another construction of the same plugin is accepted, so a plugin constructed more than once does not trip this.
Raising this from a plugin's __init__ does not stop the process. The plugin loader
logs a failed construction and carries on, so the colliding plugin is absent from the
running application — no routes, no tools, no contributor — while everything else
starts normally. The log entry is the only signal.
UnknownContentContributorError ¶
UnknownContentContributorError(name: str)
Bases: Exception
Raised when no content contributor is registered under the requested name.
Chat capability hooks¶
sparkth.lib.chat.hooks ¶
Hook through which a plugin gives the chat assistant a capability.
A capability is one kind of work the chat assistant does, such as designing a course. On every request chat reads the capabilities of the plugins that are switched on for the requesting user. Its scope classifier refuses a message that belongs to none of their scopes, and the turn's system prompt is chat's own skeleton with every capability's prompt section under its title. Every turn gets the tools the request asks for, except those of plugins switched off for the user.
The hook lives in sparkth.lib so a plugin can contribute a capability without importing chat.
It is keyed by plugin, so a capability is offered only while its plugin is switched on for the
user, by both switches. A plugin registers one capability from its __init__::
register_chat_capability(self, ChatCapability(TITLE, SCOPE, PROMPT))
ChatCapability
dataclass
¶
ChatCapability(title: str, scope: str, prompt: str)
One kind of work the chat assistant does for a plugin.
title is the heading the capability is listed under, in chat's system prompt and in the
scope classifier's, so it is unique among capabilities. scope is the text the classifier
reads to decide whether a message belongs to the capability. prompt is the capability's
section of the system prompt: plain text inserted as written, never formatted, holding only
what is specific to the capability. Chat's skeleton carries the date, identity, output
language, refusal and offering-choices rules.
A frozen dataclass, so the copies registered by repeated plugin constructions compare equal.
enabled_capabilities ¶
enabled_capabilities(
disabled_plugins: set[str],
) -> list[ChatCapability]
Return the capabilities chat offers, in plugin-name order.
Leaves out the capability of every plugin named in disabled_plugins, the plugins switched
off for the requesting user by either switch. Equal copies from repeated constructions of one
plugin fold into one.
register_chat_capability ¶
register_chat_capability(
plugin: SparkthPlugin, capability: ChatCapability
) -> None
Register plugin's capability on CHAT_CAPABILITIES.
Call this from a plugin's __init__. A plugin is constructed more than once in one process,
by the loader and by its own tests, so an equal capability arriving again is accepted and
:func:enabled_capabilities folds the copies into one. Only a different capability claiming
a registered title raises :class:~sparkth.lib.chat.exceptions.DuplicateChatCapabilityError.
Chat capability exceptions¶
sparkth.lib.chat.exceptions ¶
Domain exceptions raised when plugins contribute chat capabilities.
DuplicateChatCapabilityError ¶
DuplicateChatCapabilityError(title: str)
Bases: Exception
Raised when a capability claims a title an unequal capability already holds.
Fires only for a different capability claiming the title, which is a programming error: chat's system prompt and the scope classifier's list each capability under its title, so two unequal capabilities under one title would give the model two sections under one heading. An equal capability arriving again is accepted, so a plugin constructed more than once does not trip this.
Raising this from a plugin's __init__ does not stop the process. The plugin loader logs a
failed construction and carries on, so the colliding plugin is absent from the running
application — no routes, no tools, no capability — while everything else starts normally.
The log entry is the only signal.