Plugin System¶
AXIS uses a lightweight plugin system to organize feature modules as self-contained packages. Each plugin owns its routers, services, models, and config — the framework discovers and registers them automatically at startup. Removing a plugin is as simple as deleting its directory.
Architecture¶
graph TB
subgraph Core ["Core (app/)"]
MAIN["main.py"]
CONFIG["config.py<br/>(Settings)"]
LOADER["plugins/__init__.py<br/>(discover + register)"]
TYPES["plugins/types.py<br/>(PluginMeta)"]
CONFIG_ROUTER["routers/config.py<br/>GET /api/config/plugins"]
end
subgraph PluginDir ["plugins/memory/"]
ENTRY["__init__.py<br/>PLUGIN_META + register()"]
P_CONFIG["config.py<br/>(MemoryConfig)"]
P_ROUTERS["routers/<br/>memory.py, graph.py"]
P_SERVICES["services/<br/>memory_service.py, graph_service.py"]
P_MODELS["models/<br/>memory_schemas.py, graph_schemas.py"]
end
subgraph Frontend ["Frontend"]
SIDEBAR["sidebar.tsx<br/>(dynamic nav)"]
HOOK["usePluginNav.ts<br/>GET /api/config/plugins"]
PAGES["app/memory/page.tsx"]
COMPONENTS["components/memory/"]
end
MAIN -->|"startup"| LOADER
LOADER -->|"pkgutil scan"| ENTRY
LOADER -->|"validates"| TYPES
ENTRY -->|"register(app)"| P_ROUTERS
P_ROUTERS --> P_SERVICES --> P_MODELS
P_ROUTERS --> P_CONFIG
P_CONFIG -->|"imports settings"| CONFIG
CONFIG_ROUTER -->|"calls"| LOADER
HOOK -->|"fetches"| CONFIG_ROUTER
SIDEBAR -->|"reads"| HOOK
PAGES --> COMPONENTS
Plugin Contract¶
Every plugin is a Python package inside backend/app/plugins/. The loader expects two things in the package's __init__.py:
| Export | Type | Purpose |
|---|---|---|
PLUGIN_META |
PluginMeta |
Static metadata: name, version, nav items, OpenAPI tags |
register(app) |
Callable[[FastAPI], None] |
Registers routers on the FastAPI app instance |
The module body should contain only these two items. All other imports happen inside register() to keep discovery cheap and side-effect-free.
from fastapi import FastAPI
from app.plugins.types import PluginMeta, PluginNavItem, PluginTagMeta
PLUGIN_META = PluginMeta(
name="memory",
version="0.1.0",
description="Decision memory rules, hard stops, and batch analysis",
nav=[
PluginNavItem(name="Memory", href="/memory", icon="Brain", section="main", order=50),
],
tags_metadata=[
PluginTagMeta(name="memory", description="Decision memory rules and batch analysis"),
PluginTagMeta(name="graph", description="Knowledge graph queries (FalkorDB)"),
],
)
def register(app: FastAPI) -> None:
from .routers.graph import router as graph_router
from .routers.memory import router as memory_router
app.include_router(memory_router, prefix="/api/memory", tags=["memory"])
app.include_router(graph_router, prefix="/api/memory/graph", tags=["graph"])
Agent Replay Plugin¶
The Agent Replay plugin shows a more complex pattern — it integrates with an external service (Langfuse) and an optional search database:
from fastapi import FastAPI
from app.plugins.types import PluginMeta, PluginNavItem, PluginTagMeta
PLUGIN_META = PluginMeta(
name="agent_replay",
version="0.1.0",
description="Step through AI agent workflows from Langfuse",
nav=[
PluginNavItem(
name="Agent Replay",
href="/agent-replay",
icon="PlayCircle",
section="main",
order=45,
),
],
tags_metadata=[
PluginTagMeta(
name="agent-replay",
description="Agent workflow replay from Langfuse traces",
),
],
)
def register(app: FastAPI) -> None:
from .routers.replay import router as replay_router
app.include_router(replay_router, prefix="/api/agent-replay", tags=["agent-replay"])
backend/app/plugins/
└── agent_replay/
├── __init__.py # PLUGIN_META + register()
├── config.py # ReplayConfig, ReplayDBConfig, LangfuseAgentCreds
├── routers/
│ └── replay.py # /api/agent-replay/* endpoints
├── services/
│ ├── replay_service.py # Langfuse trace fetching, tree building
│ └── search_db.py # PostgreSQL business-field → trace-ID lookup
└── models/
└── replay_schemas.py # Pydantic request/response models
Key differences from the Memory plugin:
- External service dependency — Langfuse credentials are discovered from
LANGFUSE_*env vars at config load time, not from YAML - Optional database — The search DB (
agent_replay_db.yaml) is optional; trace ID search works without it - Per-agent overrides — Both Langfuse credentials and DB table/column mappings can vary per agent
Metadata Models¶
Plugin metadata is defined as typed Pydantic models in plugins/types.py:
class PluginNavItem(BaseModel):
name: str # Display name in sidebar
href: str # Next.js route path
icon: str # Lucide icon name (e.g. "Brain")
section: str # "main" or "tools"
order: int = 50 # Sort key (lower = higher in list)
class PluginTagMeta(BaseModel):
name: str # OpenAPI tag name
description: str = ""
class PluginMeta(BaseModel):
name: str
api_version: int = 1
version: str = "0.1.0"
description: str = ""
nav: list[PluginNavItem] = Field(default_factory=list)
tags_metadata: list[PluginTagMeta] = Field(default_factory=list)
api_version allows the contract to evolve without silent breakage. nav and tags_metadata use Field(default_factory=list) to avoid the mutable default footgun.
Discovery and Registration¶
The plugin loader in plugins/__init__.py uses pkgutil.iter_modules to scan subdirectories. Discovery is deterministic — module names are sorted alphabetically before importing.
sequenceDiagram
participant Main as main.py
participant Loader as plugins/__init__
participant Plugin as plugins/memory
Main->>Loader: register_all(app)
Loader->>Loader: discover_plugins() [cached]
loop Each subdirectory (sorted)
Loader->>Plugin: importlib.import_module()
Plugin-->>Loader: PLUGIN_META + register
Loader->>Loader: Validate PluginMeta, check callable(register)
end
loop Each enabled plugin
Loader->>Plugin: register(app)
Plugin->>Plugin: include_router(...)
end
Loader->>Loader: Cache result (atomic assign)
Failure Isolation¶
Each plugin's discovery and register() call is individually wrapped in try/except. A broken plugin logs logger.error(...) and is skipped — the app still starts. The error surfaces in the /api/config/plugins endpoint.
Conflict Policy¶
- OpenAPI tags: Deduplicated by
name, first wins (core tags before plugin tags, plugins in alphabetical order). Sorted by name for stable output. - Nav items: Deduplicated by
href, first wins. Sorted by(order, name).
Enable / Disable¶
The AXIS_PLUGINS_ENABLED setting in app/config/env.py controls which plugins are active:
| Value | Effect |
|---|---|
"*" (default) |
All discovered plugins are enabled |
"memory,other" |
Only named plugins are enabled |
"" (empty) |
All plugins disabled |
Disabled plugins are still listed in /api/config/plugins with enabled: false but their register() is never called.
Set it via environment variable or in the Settings class:
Plugin Directory Structure¶
backend/app/plugins/
├── __init__.py # Plugin loader (discover, register, tags, nav)
├── types.py # PluginMeta, PluginNavItem, PluginTagMeta
└── memory/
├── __init__.py # PLUGIN_META + register()
├── config.py # MemoryConfig, MemoryFieldRoles, load_memory_config()
├── routers/
│ ├── __init__.py
│ ├── memory.py # /api/memory/* endpoints
│ └── graph.py # /api/memory/graph/* endpoints
├── services/
│ ├── __init__.py
│ ├── memory_service.py # Rule parsing, CSV loading, CRUD
│ └── graph_service.py # FalkorDB graph queries
└── models/
├── __init__.py
├── memory_schemas.py # Memory Pydantic schemas
└── graph_schemas.py # Graph Pydantic schemas
API Endpoint¶
GET /api/config/plugins returns all discovered plugins (including disabled ones):
{
"plugins": [
{
"name": "memory",
"version": "0.1.0",
"api_version": 1,
"description": "Decision memory rules, hard stops, and batch analysis",
"nav": [
{
"name": "Memory",
"href": "/memory",
"icon": "Brain",
"section": "main",
"order": 50
}
],
"enabled": true,
"error": null
}
]
}
The error field is null for healthy plugins or a string describing the failure (e.g., "no PLUGIN_META", "register failed").
Frontend Integration¶
Dynamic Sidebar¶
The sidebar fetches plugin data via the usePluginNav hook and merges plugin nav items into the navigation:
const { data: pluginData } = usePluginNav();
const pluginMainNav = (pluginData?.plugins ?? [])
.filter((p) => p.enabled)
.flatMap((p) => p.nav)
.filter((n) => n.section === 'main')
.map((n) => ({ name: n.name, href: n.href, icon: resolveIcon(n.icon) }));
const mainNav = [...coreMainNav, ...pluginMainNav];
Icon Resolution¶
A Record<string, LucideIcon> maps icon names from the plugin API to Lucide components. Unknown names fall back to LayoutGrid and log a warning in development:
const iconMap: Record<string, LucideIcon> = { Brain };
const resolveIcon = (name: string): LucideIcon => {
const icon = iconMap[name];
if (!icon && process.env.NODE_ENV === 'development') {
console.warn(`[plugins] Unknown icon "${name}", using fallback`);
}
return icon ?? LayoutGrid;
};
Add new icon mappings to iconMap as plugins are created.
Hydration Safety¶
The plugin query is prefetched on QueryClient creation in providers.tsx with staleTime: Infinity. This means the sidebar usually has plugin data on first render. If the API is slow, core nav renders immediately and plugin items appear when ready.
Creating a New Plugin¶
-
Create a package directory under
backend/app/plugins/: -
Define
PLUGIN_METAandregister()in__init__.py:from fastapi import FastAPI from app.plugins.types import PluginMeta, PluginNavItem, PluginTagMeta PLUGIN_META = PluginMeta( name="my_feature", version="0.1.0", description="What this plugin does", nav=[PluginNavItem(name="My Feature", href="/my-feature", icon="Sparkles", section="tools", order=60)], tags_metadata=[PluginTagMeta(name="my-feature", description="My feature endpoints")], ) def register(app: FastAPI) -> None: from .routers.my_router import router app.include_router(router, prefix="/api/my-feature", tags=["my-feature"]) -
Add the icon name to
iconMapinsidebar.tsx: -
Create the Next.js page at
frontend/src/app/my-feature/page.tsx. -
Restart the backend — the plugin is automatically discovered and registered.
Key Design Decisions¶
Why Not Entry Points / setuptools?¶
Entry points (pkg_resources / importlib.metadata) are designed for installed third-party packages. AXIS plugins are first-party code in the same repo — pkgutil.iter_modules is simpler, faster, and requires no packaging machinery.
Why Module-Level Cache?¶
discover_plugins() caches its result after first call. Plugin discovery involves importing every plugin module and validating metadata, which should only happen once. The cache is assigned atomically (single = statement) to avoid partial population under concurrency.
Why Deprecation Proxy Instead of Redirect?¶
When the /api/config/memory endpoint moved to /api/memory/config, a deprecation proxy was added instead of a 307 redirect. Some HTTP clients and scripts don't follow redirects by default. The proxy returns the same JSON payload plus deprecation headers (Deprecation: true, Sunset, Link) and an extra _deprecated field in the body.