Writing a plugin¶
Plugins extend AdaptiveLearner without touching the core. The plugin system uses PluginForge (a wrapper around pluggy). Each plugin is a standalone Poetry package registered via an entry point.
This tutorial walks through creating a "hello-world" plugin that adds a route and listens to one hook.
1. Scaffold the directory¶
mkdir -p plugins/adaptive-learner-plugin-hello/adaptive_learner_hello
mkdir -p plugins/adaptive-learner-plugin-hello/tests
cd plugins/adaptive-learner-plugin-hello
2. pyproject.toml¶
[tool.poetry]
name = "adaptive-learner-plugin-hello"
version = "0.1.0"
description = "Adaptive Learner: hello-world plugin"
authors = ["Your Name"]
license = "MIT"
[tool.poetry.dependencies]
python = "^3.11"
pluginforge = "^0.7.0"
fastapi = "^0.118.0"
[project.entry-points."adaptive_learner.plugins"]
hello = "adaptive_learner_hello.plugin:HelloPlugin"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
The entry point name (hello here) is what the plugin
registry uses to track the plugin. The class path
(adaptive_learner_hello.plugin:HelloPlugin) is the Python
import path to the plugin class.
3. plugin.py¶
from pluginforge import BasePlugin, hookimpl
from typing import Any
class HelloPlugin(BasePlugin):
name = "hello"
version = "0.1.0"
# pluginforge v0.7.0+ identity gating. Set this to
# "adaptive_learner" so the host's PluginManager (which
# passes ``app_id="adaptive_learner"``) recognises the
# plugin as targeted at this app. Plugins without this
# attribute will be filtered out once v0.8.0 hosts ship.
target_application = "adaptive_learner"
depends_on: list[str] = []
@hookimpl
def on_session_complete(
self, session: dict[str, Any], rating: dict[str, Any]
) -> None:
print(f"Hello! Session {session['id']} ended.")
BasePlugin is the base class from PluginForge. The
@hookimpl decorator marks a method as implementing a hook
specified in backend/app/hookspecs.py.
4. routes.py¶
from fastapi import APIRouter
router = APIRouter(prefix="/api/plugins/hello", tags=["hello"])
@router.get("/greet")
def greet() -> dict:
return {"message": "Hello from the hello plugin!"}
5. Register routes in plugin.py¶
from fastapi import FastAPI
from .routes import router
class HelloPlugin(BasePlugin):
...
def mount_routes(self, app: FastAPI) -> None:
app.include_router(router)
The plugin manager calls mount_routes() for every plugin
that defines it, after the core app is initialised.
6. Tests¶
# tests/test_plugin.py
from adaptive_learner_hello.plugin import HelloPlugin
def test_hello_plugin_has_name():
plugin = HelloPlugin()
assert plugin.name == "hello"
7. Install + activate¶
Add the plugin as a path-dep in backend/pyproject.toml:
[tool.poetry.dependencies]
...
adaptive-learner-plugin-hello = {path = "../plugins/adaptive-learner-plugin-hello", develop = true}
Then:
cd backend && poetry lock && poetry install
make dev
curl http://localhost:18001/api/plugins/hello/greet
The 8 hookspecs¶
All hook specifications live in backend/app/hookspecs.py:
get_assessment_questions(lang: str)— return question pack (list).calculate_profile(answers: list)— compute method weights (firstresult).create_session_prompt(...)— compose the system prompt (firstresult).ai_complete(messages, model, api_key, max_tokens)— call the AI (firstresult, model prefix matches).recommend_method_switch(history, profile)— return a switch recommendation or None.on_session_complete(session, rating)— side effect (e.g. write ProgressCommit).get_progress_summary(project_id, db)— return one namespace slice of the summary.get_tool_recommendations(profile, lang)— return ranked tools.
firstresult semantics¶
Hooks marked firstresult=True stop at the first plugin that
returns a non-None value. Useful for "exactly one plugin
should handle this" cases — like ai_complete, where the
matching provider's plugin returns the text and others return
None.
Hooks without firstresult=True run on every plugin (list
mode). The caller receives a list of results and decides what
to do (merge, prefer, etc.).