diff --git a/pygitweb/api/action.py b/pygitweb/api/action.py
index de0c346..deabeaf 100644
--- a/pygitweb/api/action.py
+++ b/pygitweb/api/action.py
@@ -1,15 +1,20 @@
-from fastapi import Request
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
 
-from pygitweb.api.project import Project
+from fastapi import Request
+from starlette.responses import Response
 
 
-class Action:
+class Action(ABC):
 	"""
 	Action: A per-project action provided by a plugin.
+
+	``project`` is the project path/slug as used in URLs (under PROJECTROOT).
 	"""
 
-	def action_name(self) -> str:
-		return "base-action"
+	@abstractmethod
+	def action_name(self) -> str: ...
 
-	def action(self, project: Project, req: Request = None) -> None:
-		raise NotImplementedError(f"Action {self.action_name()} is not implemented")
+	@abstractmethod
+	def action(self, project: str, req: Request | None = None) -> Response | None: ...
diff --git a/pygitweb/api/subpage.py b/pygitweb/api/subpage.py
index 0eee388..0bef74a 100644
--- a/pygitweb/api/subpage.py
+++ b/pygitweb/api/subpage.py
@@ -1,13 +1,22 @@
-class Subpage:
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+
+class Subpage(ABC):
 	"""
 	Subpage: A per-project subpage provided by a plugin.
 	"""
 
-	def subpage_name(self) -> str:
-		raise NotImplementedError("subpage_name() must be implemented")
+	@abstractmethod
+	def subpage_name(self) -> str: ...
 
 	def link_name(self, project: str) -> str | None:
 		return None
 
 	def subpage_html(self, project: str) -> str | None:
 		return None
+
+	def summary_value_suffix_html(self, project: str, project_enc: str) -> str | None:
+		"""Optional HTML appended after the subpage \"view\" link on the project summary table."""
+		return None
diff --git a/pygitweb/main.py b/pygitweb/main.py
index cac0667..1110d29 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -72,6 +72,7 @@ from pygitweb.hooks_install import (
 from pygitweb.hooks_install import (
 	status as hook_status,
 )
+from pygitweb.plugin_loader import load_plugin_actions, load_subpages
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.settings import router as settings_router
 from pygitweb.tasks import (
@@ -85,12 +86,6 @@ from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.timeline_cache import clear_timeline_cache_async, get_all_timeline_events, warm_timeline_cache_async
 from pygitweb.validation import is_valid_pathname, is_valid_project
 
-try:
-	from pygitweb_pytesthtml.subpage_pytesthtml import SubpagePytestHtml
-except ImportError:
-	SubpagePytestHtml = None
-
-
 UPDATES_SUPPORTED_ACTIONS: frozenset[str] = frozenset({"history", "log", "shortlog", "heads", "tags"})
 
 
@@ -135,9 +130,9 @@ app = FastAPI(
 	lifespan=timeline_cache_lifespan,
 )
 
-SUBPAGES: list[Subpage] = []
-if SubpagePytestHtml is not None:
-	SUBPAGES.append(SubpagePytestHtml())
+PLUGIN_ACTIONS = load_plugin_actions()
+SUBPAGES: list[Subpage] = load_subpages()
+DISPATCH_ACTIONS: frozenset[str] = frozenset(ACTIONS) | frozenset(PLUGIN_ACTIONS.keys())
 
 # Todo handle with nginx route
 _static_dir = Path(__file__).parent / "static"
@@ -236,9 +231,12 @@ def _project_subpage_rows(project: str) -> list[list[str]]:
 		if not link_label:
 			continue
 		subpage_name = subpage.subpage_name()
+		view = f'<a href="/project/{project_enc}/subpage/{quote(subpage_name, safe="")}">view</a>'
+		suffix = subpage.summary_value_suffix_html(project, project_enc)
+		value = f"{view}{suffix}" if suffix else view
 		rows.append([
 			jinja_escape(link_label) or "",
-			f'<a href="/project/{project_enc}/subpage/{quote(subpage_name, safe="")}">view</a>',
+			value,
 		])
 	return rows
 
@@ -787,7 +785,7 @@ async def dispatch(
 			}.get(obj_type, "object")
 		else:
 			action = "summary"
-	if action not in ACTIONS:
+	if action not in DISPATCH_ACTIONS:
 		raise HTTPException(status_code=400, detail="Unknown action")
 	if action in ("opml", "project_list", "project_index"):
 		raise HTTPException(status_code=400, detail="Project not needed for this action")
@@ -798,6 +796,13 @@ async def dispatch(
 		notified = await CHANGE_QUEUE.wait_for_changes(subscribed, timeout=CHANGE_QUEUE.DEFAULT_TIMEOUT_SECONDS)
 		if notified is None:
 			return Response(status_code=304)
+	if action in PLUGIN_ACTIONS:
+		plugin = PLUGIN_ACTIONS[action]
+		result = plugin.action(project, request)
+		if result is not None:
+			return result
+		pre = PREAMBLE.render(title=f"{plugin.action_name()} - {project}", site_name=settings.SITE_NAME)
+		return HTMLResponse(f"{pre}<p>Action completed.</p>{POSTAMBLE}")
 	if action == "summary":
 		return git_summary(project, extra_rows=_project_subpage_rows(project))
 	if action == "tree":
diff --git a/pygitweb/plugin_loader.py b/pygitweb/plugin_loader.py
new file mode 100644
index 0000000..749dcbe
--- /dev/null
+++ b/pygitweb/plugin_loader.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import importlib.metadata
+import logging
+from collections.abc import Iterable
+
+from pygitweb.api.action import Action
+from pygitweb.api.subpage import Subpage
+from pygitweb.config import ACTIONS as BUILTIN_ACTIONS
+
+logger = logging.getLogger(__name__)
+
+SUBPAGE_GROUP = "pygitweb.subpages"
+ACTION_GROUP = "pygitweb.actions"
+
+
+def _entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
+	return importlib.metadata.entry_points().select(group=group)
+
+
+def load_subpages() -> list[Subpage]:
+	out: list[Subpage] = []
+	seen_names: set[str] = set()
+	for ep in _entry_points(SUBPAGE_GROUP):
+		try:
+			loaded = ep.load()
+		except Exception:
+			logger.exception("pygitweb: failed to load subpage entry point %r", ep.name)
+			continue
+		if not isinstance(loaded, type) or not issubclass(loaded, Subpage):
+			logger.error(
+				"pygitweb: entry point %r must be a Subpage subclass, got %r",
+				ep.name,
+				loaded,
+			)
+			continue
+		try:
+			instance = loaded()
+		except Exception:
+			logger.exception("pygitweb: failed to instantiate subpage %r", ep.name)
+			continue
+		key = instance.subpage_name()
+		if key in seen_names:
+			logger.warning("pygitweb: duplicate subpage %r from %r; skipping", key, ep.name)
+			continue
+		seen_names.add(key)
+		out.append(instance)
+	return out
+
+
+def load_plugin_actions() -> dict[str, Action]:
+	out: dict[str, Action] = {}
+	for ep in _entry_points(ACTION_GROUP):
+		try:
+			loaded = ep.load()
+		except Exception:
+			logger.exception("pygitweb: failed to load action entry point %r", ep.name)
+			continue
+		if not isinstance(loaded, type) or not issubclass(loaded, Action):
+			logger.error(
+				"pygitweb: entry point %r must be an Action subclass, got %r",
+				ep.name,
+				loaded,
+			)
+			continue
+		try:
+			instance = loaded()
+		except Exception:
+			logger.exception("pygitweb: failed to instantiate action %r", ep.name)
+			continue
+		name = instance.action_name()
+		if name in BUILTIN_ACTIONS:
+			logger.warning(
+				"pygitweb: plugin action %r conflicts with built-in; skipping entry %r",
+				name,
+				ep.name,
+			)
+			continue
+		if name in out:
+			logger.warning("pygitweb: duplicate plugin action %r; skipping entry %r", name, ep.name)
+			continue
+		out[name] = instance
+	return out
diff --git a/pygitweb_pytesthtml/action_pytesthtml.py b/pygitweb_pytesthtml/action_pytesthtml.py
deleted file mode 100644
index 6db7b6d..0000000
--- a/pygitweb_pytesthtml/action_pytesthtml.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import subprocess
-
-from fastapi import Request
-
-from pygitweb.api.project import Project
-
-
-def ActionPytestHtml(Action):
-	"""
-	ActionPytestHtml: Run pytest and render the results.
-	"""
-
-	def action_name(self) -> str:
-		return "pytesthtml_run"
-
-	def action(self, project: Project, req: Request = None) -> None:
-		subprocess.run(["pytest", "--html=.pygitweb/pytest_report.html", "--self-contained-html"], cwd=project.path)
diff --git a/pygitweb_pytesthtml/pygitweb_pytesthtml/__init__.py b/pygitweb_pytesthtml/pygitweb_pytesthtml/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pygitweb_pytesthtml/pygitweb_pytesthtml/__init__.py
diff --git a/pygitweb_pytesthtml/pygitweb_pytesthtml/action_pytesthtml.py b/pygitweb_pytesthtml/pygitweb_pytesthtml/action_pytesthtml.py
new file mode 100644
index 0000000..2363401
--- /dev/null
+++ b/pygitweb_pytesthtml/pygitweb_pytesthtml/action_pytesthtml.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+from fastapi import Request
+from starlette.responses import Response
+
+from pygitweb.api.action import Action
+from pygitweb.config import settings
+from pygitweb_pytesthtml.constants import PYTESTHTML_ACTION_NAME
+
+
+class ActionPytestHtml(Action):
+	"""
+	ActionPytestHtml: Run pytest and render the results.
+	"""
+
+	def action_name(self) -> str:
+		return PYTESTHTML_ACTION_NAME
+
+	def action(self, project: str, req: Request | None = None) -> Response | None:
+		cwd = Path(settings.PROJECTROOT) / project
+		subprocess.run(
+			["pytest", "--html=.pygitweb/pytest_report.html", "--self-contained-html"],
+			cwd=str(cwd),
+		)
+		return None
diff --git a/pygitweb_pytesthtml/pygitweb_pytesthtml/constants.py b/pygitweb_pytesthtml/pygitweb_pytesthtml/constants.py
new file mode 100644
index 0000000..b4b77b4
--- /dev/null
+++ b/pygitweb_pytesthtml/pygitweb_pytesthtml/constants.py
@@ -0,0 +1,3 @@
+"""Shared plugin identifiers for pytest-html subpage + action."""
+
+PYTESTHTML_ACTION_NAME = "pytesthtml_run"
diff --git a/pygitweb_pytesthtml/pygitweb_pytesthtml/subpage_pytesthtml.py b/pygitweb_pytesthtml/pygitweb_pytesthtml/subpage_pytesthtml.py
new file mode 100644
index 0000000..d609a0c
--- /dev/null
+++ b/pygitweb_pytesthtml/pygitweb_pytesthtml/subpage_pytesthtml.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from pathlib import Path
+from urllib.parse import quote
+
+from pygitweb.api.subpage import Subpage
+from pygitweb.config import settings
+from pygitweb_pytesthtml.constants import PYTESTHTML_ACTION_NAME
+
+
+class SubpagePytestHtml(Subpage):
+	"""
+	SubpagePytestHtml: Render the latest Pytest results for a project.
+	"""
+
+	def subpage_name(self) -> str:
+		return "pytesthtml"
+
+	def link_name(self, project: str) -> str | None:
+		return "Pytest Results"
+
+	def subpage_html(self, project: str) -> str | None:
+		report_path = Path(settings.PROJECTROOT) / project / ".pygitweb" / "pytest_report.html"
+		if not report_path.is_file():
+			return "<p>No pytest report found.</p>"
+		return report_path.read_text(encoding="utf-8")
+
+	def summary_value_suffix_html(self, project: str, project_enc: str) -> str | None:
+		a = quote(PYTESTHTML_ACTION_NAME, safe="")
+		return (
+			f'<a class="btn btn-sm btn-primary ms-2" role="button" href="/project/{project_enc}?a={a}">Run pytest</a>'
+		)
diff --git a/pygitweb_pytesthtml/pyproject.toml b/pygitweb_pytesthtml/pyproject.toml
index 02c00b1..d5c8de1 100644
--- a/pygitweb_pytesthtml/pyproject.toml
+++ b/pygitweb_pytesthtml/pyproject.toml
@@ -1,3 +1,7 @@
+[build-system]
+requires = ["setuptools>=61", "wheel"]
+build-backend = "setuptools.build_meta"
+
 [project]
 name = "pygitweb_pytesthtml"
 version = "0.1.0"
@@ -5,5 +9,19 @@ description = "PyGitWeb Plugin: pytest-html dashboard"
 readme = "README.md"
 requires-python = ">=3.11"
 dependencies = [
+    "pygitweb",
     "pytest-html>=4.2.0",
 ]
+
+[project.entry-points."pygitweb.subpages"]
+pytesthtml = "pygitweb_pytesthtml.subpage_pytesthtml:SubpagePytestHtml"
+
+[project.entry-points."pygitweb.actions"]
+pytesthtml_run = "pygitweb_pytesthtml.action_pytesthtml:ActionPytestHtml"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["pygitweb_pytesthtml*"]
+
+[tool.uv.sources]
+pygitweb = { workspace = true }
diff --git a/pygitweb_pytesthtml/subpage_pytesthtml.py b/pygitweb_pytesthtml/subpage_pytesthtml.py
deleted file mode 100644
index 82670c3..0000000
--- a/pygitweb_pytesthtml/subpage_pytesthtml.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from pathlib import Path
-
-from pygitweb.api.subpage import Subpage
-from pygitweb.config import settings
-
-
-class SubpagePytestHtml(Subpage):
-	"""
-	SubpagePytestHtml: Render the latest Pytest results for a project.
-	"""
-
-	def subpage_name(self) -> str:
-		return "pytesthtml"
-
-	def link_name(self, project: str) -> str | None:
-		return "Pytest Results"
-
-	def subpage_html(self, project: str) -> str | None:
-		report_path = Path(settings.PROJECTROOT) / project / ".pygitweb" / "pytest_report.html"
-		if not report_path.is_file():
-			return "<p>No pytest report found.</p>"
-		return report_path.read_text(encoding="utf-8")
diff --git a/uv.lock b/uv.lock
index db25484..b6cdf25 100644
--- a/uv.lock
+++ b/uv.lock
@@ -977,13 +977,17 @@ requires-dist = [
 [[package]]
 name = "pygitweb-pytesthtml"
 version = "0.1.0"
-source = { virtual = "pygitweb_pytesthtml" }
+source = { editable = "pygitweb_pytesthtml" }
 dependencies = [
+    { name = "pygitweb" },
     { name = "pytest-html" },
 ]
 
 [package.metadata]
-requires-dist = [{ name = "pytest-html", specifier = ">=4.2.0" }]
+requires-dist = [
+    { name = "pygitweb", editable = "pygitweb" },
+    { name = "pytest-html", specifier = ">=4.2.0" },
+]
 
 [[package]]
 name = "pygitweb-workspace"
