Building a Module

This guide walks through the complete process of building a new module (plugin) for the Knowledge Platform. By the end you will have a fully functional, self-contained module that can be enabled through modules.yaml without changing host code.

The worked example creates a Kanban module — a task board with Card nodes and Column nodes linked by InColumn edges. The same steps apply to any module.


Overview

A module consists of four components:

graph LR
    GT[GraphType Domain Schema]
    SVC[Module Service Domain Operations]
    PROJ[Projection Graph to View Model]
    MOD[ModuleDescriptor Entry Point]
    UI[Widget and ViewModel]

    GT -->|defines schema for| SVC
    SVC -->|uses| PROJ
    MOD -->|provides| GT
    MOD -->|creates| UI
    UI -->|calls| SVC
Component Location Purpose
graph_type.py modules/<name>/graph_type.py Defines node/edge schemas
service.py modules/<name>/service.py High-level domain operations
projection.py modules/<name>/projection.py Converts Graph → view-friendly structure
module.py modules/<name>/module.py ModuleDescriptor entry-point, widget factory, document hooks
view.py ui/<name>/view.py Qt widget (view layer)
view_model.py ui/<name>/view_model.py Qt ViewModel (state + commands)

Step 1: Create the Package Structure

# Under src/knowledge_platform/
mkdir -p src/knowledge_platform/modules/kanban
touch src/knowledge_platform/modules/kanban/__init__.py
touch src/knowledge_platform/modules/kanban/graph_type.py
touch src/knowledge_platform/modules/kanban/service.py
touch src/knowledge_platform/modules/kanban/projection.py
touch src/knowledge_platform/modules/kanban/module.py

# UI package
mkdir -p src/knowledge_platform/ui/kanban
touch src/knowledge_platform/ui/kanban/__init__.py
touch src/knowledge_platform/ui/kanban/view.py
touch src/knowledge_platform/ui/kanban/view_model.py

Step 2: Define the Graph Type

graph_type.py declares what node and edge types exist and what attributes they carry.

# src/knowledge_platform/modules/kanban/graph_type.py
from __future__ import annotations

from knowledge_platform.domain.graph_type import (
    AttributeField, EdgeSchema, GraphType, NodeSchema,
)

# ── Node schemas ──────────────────────────────────────────────────────────────

COLUMN_SCHEMA = NodeSchema(
    type_name="Column",
    fields=(
        AttributeField("title",    str, required=True,  description="Column heading, e.g. 'To Do'"),
        AttributeField("position", int, required=False, default=0,
                       description="Left-to-right column order"),
    ),
    description="A lane/column on the Kanban board.",
)

CARD_SCHEMA = NodeSchema(
    type_name="Card",
    fields=(
        AttributeField("title",       str,  required=True,  description="Card headline"),
        AttributeField("description", str,  required=False, default=""),
        AttributeField("priority",    int,  required=False, default=0,
                       description="0=low, 1=medium, 2=high"),
        AttributeField("position",    int,  required=False, default=0,
                       description="Vertical order within the column"),
    ),
    description="A single task card.",
)

# ── Edge schemas ──────────────────────────────────────────────────────────────

IN_COLUMN_SCHEMA = EdgeSchema(
    type_name="InColumn",
    allowed_source_types=frozenset({"Card"}),
    allowed_target_types=frozenset({"Column"}),
    description="Card belongs to a Column.",
)

BLOCKS_SCHEMA = EdgeSchema(
    type_name="Blocks",
    allowed_source_types=frozenset({"Card"}),
    allowed_target_types=frozenset({"Card"}),
    description="Card A is blocked by Card B (B must be done before A).",
)

# ── Graph type ────────────────────────────────────────────────────────────────

class KanbanGraphType(GraphType):
    """Graph type for Kanban boards."""

    type_name = "kanban"
    node_schemas = {
        "Column": COLUMN_SCHEMA,
        "Card": CARD_SCHEMA,
    }
    edge_schemas = {
        "InColumn": IN_COLUMN_SCHEMA,
        "Blocks": BLOCKS_SCHEMA,
    }

Rules for defining schemas:

  • type_name on GraphType must be unique across all registered types.
  • required=True fields must be present in attributes when the node/edge is created.
  • allowed_source_types / allowed_target_types: leave empty (frozenset()) to allow any type.

Step 3: Define a Projection

A projection converts a flat Graph into a view-friendly data structure. This is a pure function — no I/O, no Qt.

# src/knowledge_platform/modules/kanban/projection.py
from __future__ import annotations

from dataclasses import dataclass, field
from knowledge_platform.core.graph import Graph
from knowledge_platform.core.identifiers import NodeId
from knowledge_platform.core.node import Node


@dataclass
class KanbanCard:
    """View object for a single card."""
    node: Node
    children: list["KanbanCard"] = field(default_factory=list)

    @property
    def node_id(self) -> NodeId:
        return self.node.id

    @property
    def title(self) -> str:
        return str(self.node.attributes.get("title", ""))

    @property
    def priority(self) -> int:
        return int(self.node.attributes.get("priority", 0))

    @property
    def position(self) -> int:
        return int(self.node.attributes.get("position", 0))


@dataclass
class KanbanColumn:
    """View object for a column and its cards."""
    node: Node
    cards: list[KanbanCard] = field(default_factory=list)

    @property
    def node_id(self) -> NodeId:
        return self.node.id

    @property
    def title(self) -> str:
        return str(self.node.attributes.get("title", ""))

    @property
    def position(self) -> int:
        return int(self.node.attributes.get("position", 0))


class KanbanProjection:
    """Projects a kanban Graph into an ordered list of KanbanColumn objects."""

    def project(self, graph: Graph) -> list[KanbanColumn]:
        columns = {
            n.id: KanbanColumn(node=n)
            for n in graph.nodes(type_name="Column")
        }
        cards = {
            n.id: KanbanCard(node=n)
            for n in graph.nodes(type_name="Card")
        }

        for edge in graph.edges(type_name="InColumn"):
            col = columns.get(edge.target_id)
            card = cards.get(edge.source_id)
            if col and card:
                col.cards.append(card)

        for col in columns.values():
            col.cards.sort(key=lambda c: c.position)

        return sorted(columns.values(), key=lambda c: c.position)

Step 4: Define the Module Service

The service layer wraps IGraphService with kanban-specific helpers.

# src/knowledge_platform/modules/kanban/service.py
from __future__ import annotations

from knowledge_platform.core.graph import Graph
from knowledge_platform.core.identifiers import GraphId, NodeId, WorkspaceId
from knowledge_platform.core.node import Node
from knowledge_platform.modules.kanban.projection import KanbanColumn, KanbanProjection
from knowledge_platform.services.interfaces import IGraphService


class KanbanService:
    """High-level operations for Kanban boards."""

    GRAPH_TYPE = "kanban"
    COLUMN_TYPE = "Column"
    CARD_TYPE = "Card"
    IN_COLUMN_EDGE = "InColumn"

    def __init__(self, graph_service: IGraphService) -> None:
        self._gs = graph_service
        self._projection = KanbanProjection()

    def create_board(self, workspace_id: WorkspaceId, name: str) -> Graph:
        """Create a new Kanban board with default columns."""
        graph = self._gs.create_graph(workspace_id, self.GRAPH_TYPE, name)
        for pos, title in enumerate(["To Do", "In Progress", "Done"]):
            self._gs.add_node(graph.id, self.COLUMN_TYPE, {"title": title, "position": pos})
        return self._gs.get_graph(graph.id)

    def add_card(
        self,
        graph_id: GraphId,
        column_id: NodeId,
        title: str,
        description: str = "",
        priority: int = 0,
        position: int = 0,
    ) -> Node:
        """Add a card to a column."""
        card = self._gs.add_node(
            graph_id, self.CARD_TYPE,
            {"title": title, "description": description,
             "priority": priority, "position": position},
        )
        self._gs.add_edge(graph_id, card.id, column_id, self.IN_COLUMN_EDGE, {})
        return card

    def move_card(self, graph_id: GraphId, card_id: NodeId, new_column_id: NodeId) -> None:
        """Move a card to a different column."""
        graph = self._gs.get_graph(graph_id)
        for edge in list(graph.outgoing_edges(card_id, type_name=self.IN_COLUMN_EDGE)):
            self._gs.remove_edge(graph_id, edge.id)
        self._gs.add_edge(graph_id, card_id, new_column_id, self.IN_COLUMN_EDGE, {})

    def get_board(self, graph_id: GraphId) -> list[KanbanColumn]:
        """Return the board as a list of KanbanColumn view objects."""
        graph = self._gs.get_graph(graph_id)
        return self._projection.project(graph)

Step 5: Create the Module Entry-Point

# src/knowledge_platform/modules/kanban/module.py
from __future__ import annotations

from typing import TYPE_CHECKING

from knowledge_platform.modules.kanban.graph_type import KanbanGraphType
from knowledge_platform.modules.kanban.service import KanbanService
from knowledge_platform.services.interfaces import IGraphService

if TYPE_CHECKING:
    from PySide6.QtWidgets import QWidget


class KanbanModule:
    """ModuleDescriptor entry-point for the Kanban plugin."""

    module_id = "kanban"
    display_name = "Kanban"
    document_label_singular = "Board"
    document_label_plural = "Boards"
    graph_types = (KanbanGraphType(),)
    primary_graph_type = "kanban"

    def configure(self, settings) -> None:
        self._settings = dict(settings)

    def supports_graph_type(self, type_name: str) -> bool:
        return type_name == "kanban"

    def create_widget(
        self,
        graph_service: IGraphService,
        parent: "QWidget | None" = None,
    ) -> "QWidget":
        # Lazy import keeps PySide6 out of headless test runs
        from knowledge_platform.ui.kanban.view import KanbanView  # type: ignore[import]
        return KanbanView(graph_service=graph_service, parent=parent)

    def create_document(self, graph_service: IGraphService, workspace_id, name):
        return KanbanService(graph_service).create_board(workspace_id, name)

    def list_documents(self, graph_service: IGraphService, workspace_id):
        return [
            graph
            for graph in graph_service.list_graphs(workspace_id)
            if graph.type_name == "kanban"
        ]

Lazy import pattern

Always put the from PySide6... import inside create_widget(). This prevents ImportError in headless test environments where Qt is not installed.


Step 6: Create the ViewModel

The ViewModel exposes Qt signals for the view to connect to and calls the service for mutations.

# src/knowledge_platform/ui/kanban/view_model.py
from __future__ import annotations

from PySide6.QtCore import QObject, Signal

from knowledge_platform.core.identifiers import GraphId, NodeId
from knowledge_platform.modules.kanban.projection import KanbanColumn
from knowledge_platform.modules.kanban.service import KanbanService
from knowledge_platform.services.interfaces import IGraphService


class KanbanViewModel(QObject):
    """ViewModel for the Kanban board view."""

    board_refreshed = Signal(list)   # list[KanbanColumn]
    error_raised = Signal(str)

    def __init__(self, graph_service: IGraphService, parent: QObject | None = None) -> None:
        super().__init__(parent)
        self._service = KanbanService(graph_service)
        self._current_graph_id: GraphId | None = None

    def load_board(self, graph_id: GraphId) -> None:
        self._current_graph_id = graph_id
        self.refresh()

    def refresh(self) -> None:
        if self._current_graph_id is None:
            return
        try:
            columns = self._service.get_board(self._current_graph_id)
            self.board_refreshed.emit(columns)
        except Exception as e:
            self.error_raised.emit(str(e))

    def move_card(self, card_id: NodeId, new_column_id: NodeId) -> None:
        if self._current_graph_id is None:
            return
        try:
            self._service.move_card(self._current_graph_id, card_id, new_column_id)
            self.refresh()
        except Exception as e:
            self.error_raised.emit(str(e))

Step 7: Create the View

The view contains only presentation logic — no domain imports. All operations go through the ViewModel.

# src/knowledge_platform/ui/kanban/view.py
from __future__ import annotations

from PySide6.QtWidgets import QHBoxLayout, QLabel, QWidget

from knowledge_platform.modules.kanban.projection import KanbanColumn
from knowledge_platform.services.interfaces import IGraphService
from knowledge_platform.ui.kanban.view_model import KanbanViewModel


class KanbanView(QWidget):
    """Main Kanban board widget."""

    def __init__(self, graph_service: IGraphService, parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self._vm = KanbanViewModel(graph_service, parent=self)
        self._vm.board_refreshed.connect(self._on_board_refreshed)
        self._vm.error_raised.connect(self._on_error)

        self._layout = QHBoxLayout(self)
        self.setLayout(self._layout)

    def _on_board_refreshed(self, columns: list[KanbanColumn]) -> None:
        # Clear and re-render columns
        while self._layout.count():
            item = self._layout.takeAt(0)
            if item.widget():
                item.widget().deleteLater()
        for col in columns:
            label = QLabel(f"{col.title}\n({len(col.cards)} cards)", self)
            self._layout.addWidget(label)

    def _on_error(self, message: str) -> None:
        print(f"Kanban error: {message}")

Step 8: Register the Module for Runtime Loading

Add the module to your package metadata so the host can discover it as an entry point:

[project.entry-points."knowledge_platform.modules"]
kanban = "knowledge_platform.modules.kanban.module:KanbanModule"

Then enable it in modules.yaml:

version: 1
strict: true
modules:
  - id: outline
    enabled: true
    source: entry_point
  - id: kanban
    enabled: true
    source: entry_point

For local development before reinstalling package metadata, an explicit import path is also valid:

modules:
  - id: kanban
    enabled: true
    source: import
    factory: knowledge_platform.modules.kanban.module:KanbanModule

At startup, ModuleLoader reads this config, loads the descriptor, registers its graph types into TypeRegistry, and the MainWindow creates the tab automatically.


Step 9: Write Tests

Create a test file at tests/unit/modules/test_kanban.py:

# tests/unit/modules/test_kanban.py
from __future__ import annotations

import pytest

from knowledge_platform.core.engine import GraphEngine
from knowledge_platform.core.identifiers import new_workspace_id
from knowledge_platform.domain.registry import TypeRegistry
from knowledge_platform.modules.kanban.graph_type import KanbanGraphType
from knowledge_platform.modules.kanban.module import KanbanModule
from knowledge_platform.modules.kanban.service import KanbanService
from knowledge_platform.persistence.store import SqliteGraphRepository
from knowledge_platform.services.graph_service import GraphService


@pytest.fixture()
def kanban_service() -> KanbanService:
    repo = SqliteGraphRepository(":memory:")
    engine = GraphEngine(repo)
    type_registry = TypeRegistry()
    type_registry.register(KanbanGraphType())
    graph_service = GraphService(engine, type_registry)
    return KanbanService(graph_service)


def test_create_board_has_three_columns(kanban_service: KanbanService) -> None:
    ws_id = new_workspace_id()
    board = kanban_service.create_board(ws_id, "Sprint 1")
    columns = kanban_service.get_board(board.id)
    assert len(columns) == 3
    assert [c.title for c in columns] == ["To Do", "In Progress", "Done"]


def test_add_card_appears_in_column(kanban_service: KanbanService) -> None:
    ws_id = new_workspace_id()
    board = kanban_service.create_board(ws_id, "Board")
    col_id = kanban_service.get_board(board.id)[0].node_id
    kanban_service.add_card(board.id, col_id, "Write docs")
    columns = kanban_service.get_board(board.id)
    assert len(columns[0].cards) == 1
    assert columns[0].cards[0].title == "Write docs"


def test_kanban_module_implements_descriptor() -> None:
    from knowledge_platform.modules.base import ModuleDescriptor
    module = KanbanModule()
    assert isinstance(module, ModuleDescriptor)
    assert module.module_id == "kanban"

Checklist

Before submitting a new module, verify:

  • [ ] module_id is unique (snake_case, no spaces)
  • [ ] GraphType.type_name is unique in the TypeRegistry
  • [ ] All required AttributeFields have clear descriptions
  • [ ] create_widget() uses a lazy import for PySide6
  • [ ] The module service has no imports from ui.*
  • [ ] Views have no imports from core.*, domain.*, or persistence.*
  • [ ] Tests use SqliteGraphRepository(":memory:") — never a real file
  • [ ] Tests cover: create, add, update, remove, and projection
  • [ ] Test coverage for the module is ≥ 85%

Module Registration Sequence

sequenceDiagram
    participant App as create_application()
    participant ML as ModuleLoader
    participant TR as TypeRegistry
    participant MR as ModuleRegistry
    participant MW as MainWindow

    App->>ML: load modules.yaml
    ML->>TR: register(Kanban graph types)
    ML->>MR: register(KanbanModule())
    App->>MW: load_modules()
    MW->>MR: all_modules()
    MR-->>MW: [OutlineModule, KanbanModule]
    MW->>MW: create tab "Outline"
    MW->>MW: create tab "Kanban"
    MW->>KanbanModule: create_widget(graph_service)
    KanbanModule-->>MW: KanbanView