diff --git a/pygittools/actions.py b/pygittools/actions.py
new file mode 100644
index 0000000..82cb515
--- /dev/null
+++ b/pygittools/actions.py
@@ -0,0 +1,167 @@
+"""Project actions: run named scripts from ``pyproject.toml`` in the project venv."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import tomllib
+from abc import abstractmethod
+from dataclasses import dataclass
+from pathlib import Path
+
+from abcreg import AnnotatedABC  # type: ignore[import-untyped]
+
+_TOOL_TABLE = "pgt"
+_ACTIONS_KEY = "actions"
+
+
+@dataclass(frozen=True, slots=True)
+class ActionSpec:
+	name: str
+	script: str
+
+
+class Action(AnnotatedABC):
+	"""Run a named project action and return ``(ok, message)``."""
+
+	@abstractmethod
+	def action(self, name: str) -> tuple[bool, str]: ...
+
+
+def find_project_root(start: Path) -> Path | None:
+	"""Walk upward from ``start`` looking for a ``pyproject.toml``."""
+	current = start.resolve()
+	for path in [current, *current.parents]:
+		if (path / "pyproject.toml").is_file():
+			return path
+	return None
+
+
+def load_action_specs(project_root: Path) -> list[ActionSpec]:
+	"""Read ``[tool.pgt].actions`` from the project's ``pyproject.toml``."""
+	pyproject = project_root / "pyproject.toml"
+	if not pyproject.is_file():
+		return []
+	data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
+	tool = data.get("tool")
+	if not isinstance(tool, dict):
+		return []
+	pgt = tool.get(_TOOL_TABLE)
+	if not isinstance(pgt, dict):
+		return []
+	raw = pgt.get(_ACTIONS_KEY)
+	if raw is None:
+		return []
+	return _parse_actions(raw)
+
+
+def _parse_actions(raw: object) -> list[ActionSpec]:
+	if not isinstance(raw, list):
+		raise ValueError("[tool.pgt].actions must be an array of name/script entries")
+	specs: list[ActionSpec] = []
+	for index, entry in enumerate(raw):
+		specs.append(_parse_action_entry(entry, index))
+	return specs
+
+
+def _parse_action_entry(entry: object, index: int) -> ActionSpec:
+	if isinstance(entry, dict):
+		name = entry.get("name")
+		script = entry.get("script")
+		if not isinstance(name, str) or not name.strip():
+			raise ValueError(f"[tool.pgt].actions[{index}] missing string 'name'")
+		if not isinstance(script, str) or not script.strip():
+			raise ValueError(f"[tool.pgt].actions[{index}] missing string 'script'")
+		return ActionSpec(name=name.strip(), script=script.strip())
+	if isinstance(entry, list) and len(entry) == 2:
+		name, script = entry
+		if not isinstance(name, str) or not name.strip():
+			raise ValueError(f"[tool.pgt].actions[{index}] name must be a non-empty string")
+		if not isinstance(script, str) or not script.strip():
+			raise ValueError(f"[tool.pgt].actions[{index}] script must be a non-empty string")
+		return ActionSpec(name=name.strip(), script=script.strip())
+	raise ValueError(
+		f"[tool.pgt].actions[{index}] must be {{name=..., script=...}} or [name, script]",
+	)
+
+
+def project_venv_python(project_root: Path) -> Path | None:
+	candidate = project_root / ".venv" / "bin" / "python"
+	if candidate.is_file():
+		return candidate
+	windows = project_root / ".venv" / "Scripts" / "python.exe"
+	if windows.is_file():
+		return windows
+	return None
+
+
+def run_action_script(project_root: Path, script: str) -> tuple[bool, str]:
+	"""Execute ``script`` inside the project's ``.venv`` and return ``(ok, message)``."""
+	raw_script = Path(script)
+	script_path = raw_script.resolve() if raw_script.is_absolute() else (project_root / raw_script).resolve()
+	if not script_path.is_file():
+		return False, f"Script not found: {script_path}"
+
+	venv_python = project_venv_python(project_root)
+	if venv_python is None:
+		return False, f"Project venv not found under {project_root / '.venv'}"
+
+	venv_dir = venv_python.parent.parent
+	venv_bin = venv_python.parent
+	env = os.environ.copy()
+	env["VIRTUAL_ENV"] = str(venv_dir)
+	env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
+	env.pop("PYTHONHOME", None)
+
+	if script_path.suffix == ".py":
+		cmd = [str(venv_python), str(script_path)]
+	elif os.access(script_path, os.X_OK):
+		cmd = [str(script_path)]
+	else:
+		cmd = ["bash", str(script_path)]
+
+	proc = subprocess.run(
+		cmd,
+		cwd=project_root,
+		capture_output=True,
+		text=True,
+		env=env,
+		check=False,
+	)
+	output = _result_message(proc.stdout, proc.stderr)
+	if proc.returncode == 0:
+		return True, output or "ok"
+	if output:
+		return False, output
+	return False, f"exit {proc.returncode}"
+
+
+def _result_message(stdout: str, stderr: str) -> str:
+	text = (stdout or "").strip() or (stderr or "").strip()
+	if not text:
+		return ""
+	lines = text.splitlines()
+	return lines[-1] if lines else text
+
+
+@Action.register
+class ProjectActions:
+	"""Default action runner backed by ``[tool.pgt].actions``."""
+
+	def __init__(self, project_root: Path, specs: list[ActionSpec] | None = None) -> None:
+		self._root = project_root.resolve()
+		self._specs = {spec.name: spec for spec in (specs if specs is not None else load_action_specs(self._root))}
+
+	@property
+	def specs(self) -> list[ActionSpec]:
+		return list(self._specs.values())
+
+	def action(self, name: str) -> tuple[bool, str]:
+		spec = self._specs.get(name)
+		if spec is None:
+			return False, f"Unknown action: {name}"
+		return run_action_script(self._root, spec.script)
+
+
+def project_actions(project_root: Path) -> ProjectActions:
+	return ProjectActions(project_root)
diff --git a/pygittools/actions_test.py b/pygittools/actions_test.py
new file mode 100644
index 0000000..1b5c910
--- /dev/null
+++ b/pygittools/actions_test.py
@@ -0,0 +1,135 @@
+"""Tests for project actions API."""
+
+from __future__ import annotations
+
+import stat
+import sys
+from pathlib import Path
+
+import pytest
+
+from pygittools.actions import (
+	Action,
+	ActionSpec,
+	ProjectActions,
+	find_project_root,
+	load_action_specs,
+	project_actions,
+	project_venv_python,
+	run_action_script,
+)
+
+
+def _write_pyproject(root: Path, actions_toml: str) -> None:
+	(root / "pyproject.toml").write_text(
+		f'[project]\nname = "demo"\nversion = "0"\n\n[tool.pgt]\nactions = {actions_toml}\n',
+		encoding="utf-8",
+	)
+
+
+def _make_venv(root: Path) -> Path:
+	venv = root / ".venv"
+	bin_dir = venv / "bin"
+	bin_dir.mkdir(parents=True)
+	python = bin_dir / "python"
+	python.symlink_to(sys.executable)
+	return python
+
+
+def test_find_project_root(tmp_path: Path) -> None:
+	nested = tmp_path / "a" / "b"
+	nested.mkdir(parents=True)
+	(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
+	assert find_project_root(nested) == tmp_path.resolve()
+	assert find_project_root(tmp_path) == tmp_path.resolve()
+
+
+def test_load_action_specs_inline_tables(tmp_path: Path) -> None:
+	_write_pyproject(
+		tmp_path,
+		"""[
+  { name = "lint", script = "scripts/lint.py" },
+  { name = "test", script = "scripts/test.py" },
+]""",
+	)
+	specs = load_action_specs(tmp_path)
+	assert specs == [
+		ActionSpec(name="lint", script="scripts/lint.py"),
+		ActionSpec(name="test", script="scripts/test.py"),
+	]
+
+
+def test_load_action_specs_pair_arrays(tmp_path: Path) -> None:
+	_write_pyproject(tmp_path, '[["lint", "scripts/lint.py"], ["test", "scripts/test.sh"]]')
+	specs = load_action_specs(tmp_path)
+	assert [s.name for s in specs] == ["lint", "test"]
+
+
+def test_load_action_specs_invalid(tmp_path: Path) -> None:
+	_write_pyproject(tmp_path, '{ name = "lint" }')
+	with pytest.raises(ValueError, match="must be an array"):
+		load_action_specs(tmp_path)
+
+
+def test_project_actions_is_action(tmp_path: Path) -> None:
+	_write_pyproject(tmp_path, '[{ name = "ok", script = "ok.py" }]')
+	runner = project_actions(tmp_path)
+	assert isinstance(runner, Action)
+	assert issubclass(ProjectActions, Action)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
+def test_run_action_script_python_success(tmp_path: Path) -> None:
+	_make_venv(tmp_path)
+	script = tmp_path / "ok.py"
+	script.write_text("print('hello-ok')\n", encoding="utf-8")
+	ok, message = run_action_script(tmp_path, "ok.py")
+	assert ok is True
+	assert message == "hello-ok"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
+def test_run_action_script_python_failure(tmp_path: Path) -> None:
+	_make_venv(tmp_path)
+	script = tmp_path / "fail.py"
+	script.write_text("import sys\nprint('boom', file=sys.stderr)\nsys.exit(2)\n", encoding="utf-8")
+	ok, message = run_action_script(tmp_path, "fail.py")
+	assert ok is False
+	assert "boom" in message
+
+
+def test_run_action_script_missing_venv(tmp_path: Path) -> None:
+	script = tmp_path / "ok.py"
+	script.write_text("print('x')\n", encoding="utf-8")
+	ok, message = run_action_script(tmp_path, "ok.py")
+	assert ok is False
+	assert "venv not found" in message
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
+def test_run_action_script_shell(tmp_path: Path) -> None:
+	_make_venv(tmp_path)
+	script = tmp_path / "hi.sh"
+	script.write_text("#!/bin/bash\necho shell-ok\n", encoding="utf-8")
+	script.chmod(script.stat().st_mode | stat.S_IXUSR)
+	ok, message = run_action_script(tmp_path, "hi.sh")
+	assert ok is True
+	assert message == "shell-ok"
+	assert project_venv_python(tmp_path) is not None
+
+
+def test_project_actions_unknown(tmp_path: Path) -> None:
+	_write_pyproject(tmp_path, "[]")
+	ok, message = ProjectActions(tmp_path).action("missing")
+	assert ok is False
+	assert "Unknown action" in message
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
+def test_project_actions_runs_named(tmp_path: Path) -> None:
+	_make_venv(tmp_path)
+	_write_pyproject(tmp_path, '[{ name = "greet", script = "greet.py" }]')
+	(tmp_path / "greet.py").write_text("print('hi')\n", encoding="utf-8")
+	ok, message = ProjectActions(tmp_path).action("greet")
+	assert ok is True
+	assert message == "hi"
diff --git a/pygittools/help.md b/pygittools/help.md
index 721a94a..8c80bdd 100644
--- a/pygittools/help.md
+++ b/pygittools/help.md
@@ -17,10 +17,26 @@ The top line shows the project name followed by **Status** and **Log** tabs. The
 - **Enter** on a file — stage (unstaged / untracked) or unstage (staged)
 - **Log tab** — **J** / **K** scroll the commit history (newest first)
 - **?** — open this help page
+- **!** — open the actions page (scripts from ``[tool.pgt].actions``)
 - **Q** — quit
 
 Commit or hook failures appear in the status bar.
 
+## Actions page
+
+Lists project actions declared in ``pyproject.toml`` under ``[tool.pgt].actions``
+(an array of ``{ name = "...", script = "..." }`` entries). Scripts run inside the
+project's ``.venv``.
+
+- **J** / **Up** — move selection up
+- **K** / **Down** — move selection down
+- **Enter** — run the selected action (or **Run all**)
+- **A** — run all actions in order, stopping at the first failure
+- **Esc** / **Q** — return to the home page
+
+While an action runs, its row shows an animated spinner. Completed actions use the
+staged (success) or unstaged (failure) theme colors.
+
 ## Branch picker
 
 - **Type** — filter branches by name
diff --git a/pygittools/pyproject.toml b/pygittools/pyproject.toml
index 8cb76e6..bbd4b5c 100644
--- a/pygittools/pyproject.toml
+++ b/pygittools/pyproject.toml
@@ -11,6 +11,7 @@ license = "MIT"
 license-files = ["LICENSE"]
 requires-python = ">=3.11"
 dependencies = [
+    "abcreg==0.2.0",
     "pygit2>=1.12.0",
     "pyratatui>=0.3.0",
 ]
diff --git a/pygittools/tui/app.py b/pygittools/tui/app.py
index dddb698..3ebc119 100644
--- a/pygittools/tui/app.py
+++ b/pygittools/tui/app.py
@@ -45,8 +45,9 @@ def run_tui(repo_path: Path | None = None) -> int:
 				except KeyboardInterrupt:
 					break
 				if ev is None:
-					if isinstance(page, HomePage):
-						page.on_idle()
+					on_idle = getattr(page, "on_idle", None)
+					if callable(on_idle):
+						on_idle()
 					continue
 
 				result = page.handle_key(ev)
diff --git a/pygittools/tui/input_context.py b/pygittools/tui/input_context.py
index 009f952..5a895d3 100644
--- a/pygittools/tui/input_context.py
+++ b/pygittools/tui/input_context.py
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, Protocol
 from pygittools.tui.changes_list import ChangesList
 from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_left, is_right, is_up
 from pygittools.tui.log_list import CommitLogList
+from pygittools.tui.pages.actions import actions_page
 from pygittools.tui.pages.branches import branch_picker_page
 from pygittools.tui.pages.help import help_page
 from pygittools.tui.pages.projects import projects_page
@@ -79,6 +80,8 @@ class NavContext:
 
 		if key.code == "?":
 			return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=help_page()))
+		if key.code == "!":
+			return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()))
 
 		if host.header_focus == "repo":
 			if is_down(key):
diff --git a/pygittools/tui/input_context_test.py b/pygittools/tui/input_context_test.py
index 36af11f..0106d81 100644
--- a/pygittools/tui/input_context_test.py
+++ b/pygittools/tui/input_context_test.py
@@ -11,6 +11,7 @@ from pygittools.tui.changes_list import ChangesList
 from pygittools.tui.input_context import InputContext, input_context, nav_context
 from pygittools.tui.keys import key
 from pygittools.tui.log_list import CommitLogList
+from pygittools.tui.pages.actions import ActionsPage
 from pygittools.tui.pages.help import HelpPage
 from pygittools.tui.tasks_list import TasksBoardList
 from pygittools.tui.types import PageAction, PageContext
@@ -68,6 +69,13 @@ def test_nav_context_opens_help(host: FakeHost) -> None:
 	assert isinstance(dispatch.page_result.next_page, HelpPage)
 
 
+def test_nav_context_opens_actions(host: FakeHost) -> None:
+	dispatch = nav_context().dispatch_key(host, key("!"))
+	assert dispatch.page_result is not None
+	assert dispatch.page_result.action == PageAction.PUSH
+	assert isinstance(dispatch.page_result.next_page, ActionsPage)
+
+
 def test_nav_context_repo_header(host: FakeHost) -> None:
 	host.header_focus = "repo"
 	nav = nav_context()
diff --git a/pygittools/tui/pages/__init__.py b/pygittools/tui/pages/__init__.py
index 4b4b580..81bb09b 100644
--- a/pygittools/tui/pages/__init__.py
+++ b/pygittools/tui/pages/__init__.py
@@ -1,3 +1,4 @@
+from pygittools.tui.pages.actions import ActionsPage, actions_page
 from pygittools.tui.pages.branches import BranchPickerPage, branch_picker_page
 from pygittools.tui.pages.help import HelpPage, help_page
 from pygittools.tui.pages.home import HomePage, home_page
@@ -5,11 +6,13 @@ from pygittools.tui.pages.no_repo import NoRepoPage
 from pygittools.tui.pages.projects import ProjectsPage, projects_page
 
 __all__ = [
+	"ActionsPage",
 	"BranchPickerPage",
 	"HelpPage",
 	"HomePage",
 	"NoRepoPage",
 	"ProjectsPage",
+	"actions_page",
 	"branch_picker_page",
 	"help_page",
 	"home_page",
diff --git a/pygittools/tui/pages/actions.py b/pygittools/tui/pages/actions.py
new file mode 100644
index 0000000..a4230f1
--- /dev/null
+++ b/pygittools/tui/pages/actions.py
@@ -0,0 +1,262 @@
+"""Project actions page: run scripts defined in ``pyproject.toml``."""
+
+from __future__ import annotations
+
+import threading
+import time
+from enum import Enum, auto
+from pathlib import Path
+
+from pyratatui import (
+	Constraint,
+	Direction,
+	Frame,
+	Layout,
+	List,
+	ListItem,
+	ListState,
+	Paragraph,
+	Rect,
+	Style,
+)
+
+from pygittools.actions import ActionSpec, ProjectActions, find_project_root, load_action_specs
+from pygittools.tui.git_colors import current_theme, focus_style
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up
+from pygittools.tui.types import PageAction, PageContext, PageResult
+
+_SPINNER = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
+_RUN_ALL_LABEL = "Run all"
+_SPINNER_INTERVAL_S = 0.08
+
+
+def _worktree_path(repo_path: Path) -> Path:
+	resolved = repo_path.resolve()
+	if resolved.name == ".git":
+		return resolved.parent
+	return resolved
+
+
+class _RunState(Enum):
+	IDLE = auto()
+	RUNNING = auto()
+	SUCCESS = auto()
+	FAILURE = auto()
+
+
+class ActionsPage:
+	title = "actions"
+
+	def __init__(self) -> None:
+		self._ctx: PageContext | None = None
+		self._project_root: Path | None = None
+		self._specs: list[ActionSpec] = []
+		self._list_state = ListState()
+		self._states: dict[str, _RunState] = {}
+		self._messages: dict[str, str] = {}
+		self._active: str | None = None
+		self._spinner_index = 0
+		self._last_spin_at = 0.0
+		self._error: str | None = None
+		self._lock = threading.Lock()
+		self._worker: threading.Thread | None = None
+		self._stop = threading.Event()
+
+	def on_enter(self, ctx: PageContext) -> None:
+		self._ctx = ctx
+		self._stop_worker()
+		workdir = Path(ctx.repo.workdir) if ctx.repo.workdir else _worktree_path(ctx.repo_path)
+		self._project_root = find_project_root(workdir)
+		self._specs = load_action_specs(self._project_root) if self._project_root is not None else []
+		self._states = {spec.name: _RunState.IDLE for spec in self._specs}
+		self._messages = {}
+		self._active = None
+		self._spinner_index = 0
+		self._error = None
+		self._list_state.select(0)
+
+	def on_idle(self) -> None:
+		with self._lock:
+			running = self._active is not None
+		if not running:
+			return
+		now = time.monotonic()
+		if now - self._last_spin_at >= _SPINNER_INTERVAL_S:
+			self._spinner_index = (self._spinner_index + 1) % len(_SPINNER)
+			self._last_spin_at = now
+
+	def status_text(self) -> str:
+		if self._error:
+			return self._error
+		with self._lock:
+			active = self._active
+			if active is not None:
+				msg = self._messages.get(active, "")
+				suffix = f" — {msg}" if msg else ""
+				return f"Running {active}…{suffix}"
+		if self._project_root is None:
+			return "No pyproject.toml found — Esc back"
+		if not self._specs:
+			return "No [tool.pgt].actions configured — Esc back"
+		return "Enter run — A run all — j/k move — Esc back"
+
+	def draw(self, frame: Frame, area: Rect) -> None:
+		if area.height <= 0:
+			return
+		theme = current_theme()
+		chunks = (
+			Layout().direction(Direction.Vertical).constraints([Constraint.length(1), Constraint.fill(1)]).split(area)
+		)
+		root_label = str(self._project_root) if self._project_root is not None else "(none)"
+		frame.render_widget(Paragraph.from_string(f"Actions ({root_label})").style(theme.header), chunks[0])
+
+		rows = self._rows()
+		selected = self._list_state.selected or 0
+		items: list[ListItem] = []
+		for index, (key, label) in enumerate(rows):
+			marker = self._marker_for(key)
+			style = self._style_for(key, focused=index == selected)
+			items.append(ListItem(f"{marker} {label}", style))
+		if not items:
+			items.append(ListItem("No actions configured", theme.log_date))
+		frame.render_stateful_list(
+			List(items).highlight_style(theme.focus).highlight_symbol(""),
+			chunks[1],
+			self._list_state,
+		)
+
+	def handle_key(self, key: KeyLike) -> PageResult:
+		if is_quit(key) or is_esc(key):
+			self._stop_worker()
+			return PageResult(action=PageAction.POP)
+		if self._error is not None:
+			self._error = None
+		with self._lock:
+			busy = self._active is not None
+		if busy:
+			return PageResult()
+
+		rows = self._rows()
+		if not rows:
+			return PageResult()
+
+		if is_up(key):
+			selected = self._list_state.selected or 0
+			self._list_state.select(max(0, selected - 1))
+			return PageResult()
+		if is_down(key):
+			selected = self._list_state.selected or 0
+			self._list_state.select(min(len(rows) - 1, selected + 1))
+			return PageResult()
+		if key.code.casefold() == "a" and self._specs:
+			self._start_run([spec.name for spec in self._specs], stop_on_failure=True)
+			return PageResult()
+		if is_enter(key):
+			selected = self._list_state.selected or 0
+			row_key, _ = rows[selected]
+			if row_key == "__all__":
+				self._start_run([spec.name for spec in self._specs], stop_on_failure=True)
+			else:
+				self._start_run([row_key], stop_on_failure=False)
+			return PageResult()
+		return PageResult()
+
+	def _rows(self) -> list[tuple[str, str]]:
+		if not self._specs:
+			return []
+		rows: list[tuple[str, str]] = [("__all__", _RUN_ALL_LABEL)]
+		for spec in self._specs:
+			message = self._messages.get(spec.name, "")
+			label = spec.name if not message else f"{spec.name} — {message}"
+			rows.append((spec.name, label))
+		return rows
+
+	def _marker_for(self, key: str) -> str:
+		with self._lock:
+			if key == "__all__":
+				if self._active is not None:
+					return _SPINNER[self._spinner_index]
+				return "▶"
+			state = self._states.get(key, _RunState.IDLE)
+			if state is _RunState.RUNNING or key == self._active:
+				return _SPINNER[self._spinner_index]
+			if state is _RunState.SUCCESS:
+				return "✓"
+			if state is _RunState.FAILURE:
+				return "✗"
+			return "·"
+
+	def _style_for(self, key: str, *, focused: bool) -> Style:
+		theme = current_theme()
+		with self._lock:
+			if key == "__all__":
+				base = theme.header
+			else:
+				state = self._states.get(key, _RunState.IDLE)
+				if state is _RunState.SUCCESS:
+					base = theme.staged
+				elif state is _RunState.FAILURE:
+					base = theme.unstaged
+				elif state is _RunState.RUNNING or key == self._active:
+					base = theme.log_hash
+				else:
+					base = theme.normal
+		return focus_style(base) if focused else base
+
+	def _start_run(self, names: list[str], *, stop_on_failure: bool) -> None:
+		if not names or self._project_root is None:
+			return
+		self._stop_worker()
+		with self._lock:
+			for name in names:
+				self._states[name] = _RunState.IDLE
+				self._messages.pop(name, None)
+			self._active = names[0]
+			self._states[names[0]] = _RunState.RUNNING
+			self._spinner_index = 0
+			self._last_spin_at = time.monotonic()
+		root = self._project_root
+		specs = list(self._specs)
+		self._stop.clear()
+		self._worker = threading.Thread(
+			target=self._run_worker,
+			args=(root, specs, names, stop_on_failure),
+			daemon=True,
+		)
+		self._worker.start()
+
+	def _run_worker(
+		self,
+		root: Path,
+		specs: list[ActionSpec],
+		names: list[str],
+		stop_on_failure: bool,
+	) -> None:
+		runner = ProjectActions(root, specs)
+		for name in names:
+			if self._stop.is_set():
+				break
+			with self._lock:
+				self._active = name
+				self._states[name] = _RunState.RUNNING
+			ok, message = runner.action(name)
+			with self._lock:
+				self._states[name] = _RunState.SUCCESS if ok else _RunState.FAILURE
+				self._messages[name] = message
+			if not ok and stop_on_failure:
+				break
+		with self._lock:
+			self._active = None
+
+	def _stop_worker(self) -> None:
+		self._stop.set()
+		worker = self._worker
+		if worker is not None and worker.is_alive():
+			worker.join(timeout=0.2)
+		self._worker = None
+		with self._lock:
+			self._active = None
+
+
+def actions_page() -> ActionsPage:
+	return ActionsPage()
diff --git a/pygittools/tui/pages/actions_test.py b/pygittools/tui/pages/actions_test.py
new file mode 100644
index 0000000..375086d
--- /dev/null
+++ b/pygittools/tui/pages/actions_test.py
@@ -0,0 +1,110 @@
+"""Tests for the actions TUI page."""
+
+from __future__ import annotations
+
+import sys
+import time
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+from pygit2 import Repository, init_repository
+from pyratatui import Rect
+
+from pygittools.tui.git_colors import init_tui_color_theme
+from pygittools.tui.keys import key
+from pygittools.tui.pages.actions import ActionsPage, actions_page
+from pygittools.tui.types import PageAction, PageContext
+
+
+def _repo_with_actions(tmp_path: Path, actions_body: str) -> PageContext:
+	repo_dir = tmp_path / "repo"
+	repo_dir.mkdir()
+	repo = init_repository(str(repo_dir), bare=False)
+	(repo_dir / "pyproject.toml").write_text(
+		f'[project]\nname = "demo"\nversion = "0"\n\n[tool.pgt]\nactions = {actions_body}\n',
+		encoding="utf-8",
+	)
+	venv_bin = repo_dir / ".venv" / "bin"
+	venv_bin.mkdir(parents=True)
+	(venv_bin / "python").symlink_to(sys.executable)
+	return PageContext(repo=repo, repo_path=Path(repo.path))
+
+
+def test_actions_page_empty(tmp_path: Path) -> None:
+	repo_dir = tmp_path / "repo"
+	repo_dir.mkdir()
+	repo = init_repository(str(repo_dir), bare=False)
+	ctx = PageContext(repo=repo, repo_path=Path(repo.path))
+	page = actions_page()
+	page.on_enter(ctx)
+	assert "No pyproject" in page.status_text() or "No [tool.pgt]" in page.status_text()
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+	assert page.handle_key(key("Esc")).action == PageAction.POP
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
+def test_actions_page_lists_and_run(tmp_path: Path) -> None:
+	ctx = _repo_with_actions(tmp_path, '[{ name = "ok", script = "ok.py" }]')
+	init_tui_color_theme(ctx.repo)
+	workdir = Path(ctx.repo.workdir)
+	(workdir / "ok.py").write_text("print('done')\n", encoding="utf-8")
+	page = ActionsPage()
+	page.on_enter(ctx)
+	assert "Enter run" in page.status_text()
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+	# select the action row (index 1; 0 is Run all)
+	page.handle_key(key("Down"))
+	page.handle_key(key("Enter"))
+	deadline = time.monotonic() + 5.0
+	while time.monotonic() < deadline:
+		page.on_idle()
+		with page._lock:
+			if page._active is None and page._states.get("ok") is not None:
+				from pygittools.tui.pages.actions import _RunState
+
+				if page._states["ok"] is not _RunState.IDLE:
+					break
+		time.sleep(0.05)
+	assert page._states["ok"].name == "SUCCESS"
+	assert page._messages["ok"] == "done"
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
+def test_actions_page_run_all_stops_on_failure(tmp_path: Path) -> None:
+	ctx = _repo_with_actions(
+		tmp_path,
+		"""[
+  { name = "bad", script = "bad.py" },
+  { name = "good", script = "good.py" },
+]""",
+	)
+	init_tui_color_theme(ctx.repo)
+	workdir = Path(ctx.repo.workdir)
+	(workdir / "bad.py").write_text("import sys\nsys.exit(1)\n", encoding="utf-8")
+	(workdir / "good.py").write_text("print('should-not-run')\n", encoding="utf-8")
+	page = ActionsPage()
+	page.on_enter(ctx)
+	page.handle_key(key("a"))
+	deadline = time.monotonic() + 5.0
+	while time.monotonic() < deadline:
+		page.on_idle()
+		with page._lock:
+			if page._active is None:
+				break
+		time.sleep(0.05)
+	from pygittools.tui.pages.actions import _RunState
+
+	assert page._states["bad"] is _RunState.FAILURE
+	assert page._states["good"] is _RunState.IDLE
+	assert "good" not in page._messages
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
+def test_actions_page_opens_from_factory(tmp_path: Path) -> None:
+	ctx = _repo_with_actions(tmp_path, "[]")
+	page = actions_page()
+	page.on_enter(ctx)
+	assert page.title == "actions"
+	assert isinstance(ctx.repo, Repository)
diff --git a/pygittools/tui/pages_test.py b/pygittools/tui/pages_test.py
index 65c0fd7..43c53ab 100644
--- a/pygittools/tui/pages_test.py
+++ b/pygittools/tui/pages_test.py
@@ -12,6 +12,7 @@ from pyratatui import Rect
 from pygittools.tasks import BOARD_REF_PREFIX, EMPTY_TREE_OID_HEX, TASK_REF_PREFIX, Board, Task
 from pygittools.tui.branches import current_branch_name
 from pygittools.tui.keys import key
+from pygittools.tui.pages.actions import ActionsPage
 from pygittools.tui.pages.branches import BranchPickerPage, branch_picker_page
 from pygittools.tui.pages.help import HelpPage, help_page
 from pygittools.tui.pages.home import HomePage, home_page
@@ -148,6 +149,14 @@ def test_home_page_push_help(page_ctx: PageContext) -> None:
 	assert isinstance(result.next_page, HelpPage)
 
 
+def test_home_page_push_actions(page_ctx: PageContext) -> None:
+	page = home_page()
+	page.on_enter(page_ctx)
+	result = page.handle_key(key("!"))
+	assert result.action == PageAction.PUSH
+	assert isinstance(result.next_page, ActionsPage)
+
+
 def test_home_page_draws_detached_head(committed_repo: Repository) -> None:
 	commit = committed_repo.get(committed_repo.head.target)
 	committed_repo.set_head(commit.id)
diff --git a/pyproject.toml b/pyproject.toml
index ba7286a..e916eef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,5 +50,15 @@ markers = [
 
 [tool.mypy]
 exclude = [
-    ".*_test\\.py"
+    ".*_test\\.py",
+    # scripts/pytest.py would otherwise shadow the real pytest package
+    "^scripts/",
+]
+
+[tool.pgt]
+actions = [
+	{ name = "ruff check", script = "scripts/ruff_check.py" },
+	{ name = "ruff format", script = "scripts/ruff_format.py" },
+	{ name = "mypy", script = "scripts/mypy.py" },
+	{ name = "pytest", script = "scripts/pytest.py" },
 ]
diff --git a/scripts/mypy.py b/scripts/mypy.py
new file mode 100644
index 0000000..88a2303
--- /dev/null
+++ b/scripts/mypy.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def main() -> int:
+	return subprocess.call([sys.executable, "-m", "mypy", "."])
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/scripts/pytest.py b/scripts/pytest.py
new file mode 100644
index 0000000..9128a98
--- /dev/null
+++ b/scripts/pytest.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def main() -> int:
+	return subprocess.call([sys.executable, "-m", "pytest", "."])
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/scripts/ruff_check.py b/scripts/ruff_check.py
new file mode 100644
index 0000000..1451cfb
--- /dev/null
+++ b/scripts/ruff_check.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def main() -> int:
+	return subprocess.call([sys.executable, "-m", "ruff", "check", "--fix"])
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/scripts/ruff_format.py b/scripts/ruff_format.py
new file mode 100644
index 0000000..b9da743
--- /dev/null
+++ b/scripts/ruff_format.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def main() -> int:
+	return subprocess.call([sys.executable, "-m", "ruff", "format", "--preview"])
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/uv.lock b/uv.lock
index d225ead..6b44b96 100644
--- a/uv.lock
+++ b/uv.lock
@@ -18,6 +18,15 @@ members = [
 ]
 
 [[package]]
+name = "abcreg"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/84/7e/0cb65c23d70101fa6a6c22f1d2f89513e7b6545d033fd80bab6a751b4cda/abcreg-0.2.0.tar.gz", hash = "sha256:395bc16b3b38b2b809f8671b29ac6ee4237e1061bf64b8db89f27cd39c90baa1", size = 29674, upload-time = "2026-09-24T14:55:32.847Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/92/8c/7ec9007e22d71be02512387046cc0b8995f953ce0dd058a1b09b9d0486c5/abcreg-0.2.0-py3-none-any.whl", hash = "sha256:be03b70e4c770c76d882c8b7a2615701c2cf9c0f2fca70f8b3e4e604313a1ebb", size = 4178, upload-time = "2026-09-24T14:55:31.747Z" },
+]
+
+[[package]]
 name = "annotated-doc"
 version = "0.0.4"
 source = { registry = "https://pypi.org/simple" }
@@ -1155,12 +1164,14 @@ name = "pygittools"
 version = "0.2.2"
 source = { editable = "pygittools" }
 dependencies = [
+    { name = "abcreg" },
     { name = "pygit2" },
     { name = "pyratatui" },
 ]
 
 [package.metadata]
 requires-dist = [
+    { name = "abcreg", specifier = "==0.2.0" },
     { name = "pygit2", specifier = ">=1.12.0" },
     { name = "pyratatui", specifier = ">=0.3.0" },
 ]
