diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx
index 2e7b3fe5..e2c7d7f7 100644
--- a/sdk/guides/plugins.mdx
+++ b/sdk/guides/plugins.mdx
@@ -196,28 +196,48 @@ Brief explanation on how to use a plugin with an agent.
## Ready-to-run Example
+The example below demonstrates plugin loading via Conversation and plugin management utilities (install, list, update, uninstall).
+
This example is available on GitHub: [examples/05_skills_and_plugins/02_loading_plugins/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/02_loading_plugins/main.py)
```python icon="python" expandable examples/05_skills_and_plugins/02_loading_plugins/main.py
-"""Example: Loading Plugins via Conversation
+"""Example: Loading and Managing Plugins
+
+This example demonstrates plugin loading and management in the SDK:
+
+1. Loading plugins via Conversation (PluginSource)
+2. Installing plugins to persistent storage
+3. Listing, updating, and uninstalling plugins
-Demonstrates the recommended way to load plugins using the `plugins` parameter
-on Conversation. Plugins bundle skills, hooks, and MCP config together.
+Plugins bundle skills, hooks, and MCP config together.
+
+Supported plugin sources:
+- Local path: /path/to/plugin
+- GitHub shorthand: github:owner/repo
+- Git URL: https://github.com/owner/repo.git
+- With ref: branch, tag, or commit SHA
+- With repo_path: subdirectory for monorepos
For full documentation, see: https://docs.all-hands.dev/sdk/guides/plugins
"""
import os
-import sys
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk import LLM, Agent, Conversation
-from openhands.sdk.plugin import PluginSource
+from openhands.sdk.plugin import (
+ PluginFetchError,
+ PluginSource,
+ install_plugin,
+ list_installed_plugins,
+ load_installed_plugins,
+ uninstall_plugin,
+)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool
@@ -225,64 +245,194 @@ from openhands.tools.terminal import TerminalTool
# Locate example plugin directory
script_dir = Path(__file__).parent
-plugin_path = script_dir / "example_plugins" / "code-quality"
-
-# Define plugins to load
-# Supported sources: local path, "github:owner/repo", or git URL
-# Optional: ref (branch/tag/commit), repo_path (for monorepos)
-plugins = [
- PluginSource(source=str(plugin_path)),
- # PluginSource(source="github:org/security-plugin", ref="v2.0.0"),
- # PluginSource(source="github:org/monorepo", repo_path="plugins/logging"),
-]
-
-# Check for API key
-api_key = os.getenv("LLM_API_KEY")
-if not api_key:
- print("Set LLM_API_KEY to run this example")
- print("EXAMPLE_COST: 0")
- sys.exit(0)
-
-# Configure LLM and Agent
-model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
-llm = LLM(
- usage_id="plugin-demo",
- model=model,
- api_key=SecretStr(api_key),
- base_url=os.getenv("LLM_BASE_URL"),
-)
-agent = Agent(
- llm=llm, tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)]
-)
+local_plugin_path = script_dir / "example_plugins" / "code-quality"
-# Create conversation with plugins - skills, MCP config, and hooks are merged
-# Note: Plugins are loaded lazily on first send_message() or run() call
-with tempfile.TemporaryDirectory() as tmpdir:
- conversation = Conversation(
- agent=agent,
- workspace=tmpdir,
- plugins=plugins,
- )
- # Test: The "lint" keyword triggers the python-linting skill
- # This first send_message() call triggers lazy plugin loading
- conversation.send_message("How do I lint Python code? Brief answer please.")
+def demo_conversation_with_plugins(llm: LLM) -> None:
+ """Demo 1: Load plugins via Conversation's plugins parameter.
+
+ This is the recommended way to use plugins - they are loaded lazily
+ when the conversation starts.
+ """
+ print("\n" + "=" * 60)
+ print("DEMO 1: Loading plugins via Conversation")
+ print("=" * 60)
+
+ # Define plugins to load
+ plugins = [
+ PluginSource(source=str(local_plugin_path)),
+ # Examples of other sources:
+ # PluginSource(source="github:owner/repo", ref="v1.0.0"),
+ # PluginSource(source="github:owner/monorepo", repo_path="plugins/my-plugin"),
+ ]
- # Verify skills were loaded from the plugin (after lazy loading)
- skills = (
- conversation.agent.agent_context.skills
- if conversation.agent.agent_context
- else []
+ agent = Agent(
+ llm=llm,
+ tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)],
)
- print(f"Loaded {len(skills)} skill(s) from plugins")
- conversation.run()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ conversation = Conversation(
+ agent=agent,
+ workspace=tmpdir,
+ plugins=plugins,
+ )
+
+ # The "lint" keyword triggers the python-linting skill
+ conversation.send_message("How do I lint Python code? Brief answer please.")
+
+ # Verify skills were loaded
+ skills = (
+ conversation.agent.agent_context.skills
+ if conversation.agent.agent_context
+ else []
+ )
+ print(f"✓ Loaded {len(skills)} skill(s) from plugins")
+
+ conversation.run()
+
+
+def demo_install_local_plugin(installed_dir: Path) -> None:
+ """Demo 2: Install a plugin from a local path.
- print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
+ Useful for development or local-only plugins.
+ """
+ print("\n" + "=" * 60)
+ print("DEMO 2: Installing plugin from local path")
+ print("=" * 60)
+
+ info = install_plugin(source=str(local_plugin_path), installed_dir=installed_dir)
+ print(f"✓ Installed: {info.name} v{info.version}")
+ print(f" Source: {info.source}")
+ print(f" Path: {info.install_path}")
+
+
+def demo_install_github_plugin(installed_dir: Path) -> None:
+ """Demo 3: Install a plugin from GitHub.
+
+ Demonstrates the github:owner/repo shorthand with repo_path for monorepos.
+ """
+ print("\n" + "=" * 60)
+ print("DEMO 3: Installing plugin from GitHub")
+ print("=" * 60)
+
+ try:
+ # Install from anthropics/skills repository
+ info = install_plugin(
+ source="github:anthropics/skills",
+ repo_path="skills/pptx",
+ ref="main",
+ installed_dir=installed_dir,
+ )
+ print(f"✓ Installed: {info.name} v{info.version}")
+ print(f" Source: {info.source}")
+ print(f" Resolved ref: {info.resolved_ref}")
+
+ except PluginFetchError as e:
+ print(f"⚠ Could not fetch from GitHub: {e}")
+ print(" (Network or rate limiting issue)")
+
+
+def demo_list_and_load_plugins(installed_dir: Path) -> None:
+ """Demo 4: List and load installed plugins."""
+ print("\n" + "=" * 60)
+ print("DEMO 4: List and load installed plugins")
+ print("=" * 60)
+
+ # List installed plugins
+ print("Installed plugins:")
+ for info in list_installed_plugins(installed_dir=installed_dir):
+ print(f" - {info.name} v{info.version} ({info.source})")
+
+ # Load plugins as Plugin objects
+ plugins = load_installed_plugins(installed_dir=installed_dir)
+ print(f"\nLoaded {len(plugins)} plugin(s):")
+ for plugin in plugins:
+ skills = plugin.get_all_skills()
+ print(f" - {plugin.name}: {len(skills)} skill(s)")
+
+
+def demo_uninstall_plugins(installed_dir: Path) -> None:
+ """Demo 5: Uninstall plugins."""
+ print("\n" + "=" * 60)
+ print("DEMO 5: Uninstalling plugins")
+ print("=" * 60)
+
+ for info in list_installed_plugins(installed_dir=installed_dir):
+ uninstall_plugin(info.name, installed_dir=installed_dir)
+ print(f"✓ Uninstalled: {info.name}")
+
+ remaining = list_installed_plugins(installed_dir=installed_dir)
+ print(f"\nRemaining plugins: {len(remaining)}")
+
+
+# Main execution
+if __name__ == "__main__":
+ api_key = os.getenv("LLM_API_KEY")
+ if not api_key:
+ print("Set LLM_API_KEY to run the full example")
+ print("Running install/uninstall demos only...")
+ llm = None
+ else:
+ model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
+ llm = LLM(
+ usage_id="plugin-demo",
+ model=model,
+ api_key=SecretStr(api_key),
+ base_url=os.getenv("LLM_BASE_URL"),
+ )
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ installed_dir = Path(tmpdir) / "installed"
+ installed_dir.mkdir()
+
+ # Demo 1: Conversation with plugins (requires LLM)
+ if llm:
+ demo_conversation_with_plugins(llm)
+
+ # Demo 2-5: Plugin management (no LLM required)
+ demo_install_local_plugin(installed_dir)
+ demo_install_github_plugin(installed_dir)
+ demo_list_and_load_plugins(installed_dir)
+ demo_uninstall_plugins(installed_dir)
+
+ print("\n" + "=" * 60)
+ print("EXAMPLE COMPLETED SUCCESSFULLY")
+ print("=" * 60)
+
+ if llm:
+ print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
+ else:
+ print("EXAMPLE_COST: 0")
```
+## Installing Plugins to Persistent Storage
+
+The SDK provides utilities to install plugins to a local directory (`~/.openhands/plugins/installed/` by default). Installed plugins are tracked in `.installed.json`.
+
+```python icon="python"
+from openhands.sdk.plugin import (
+ install_plugin,
+ list_installed_plugins,
+ load_installed_plugins,
+ uninstall_plugin,
+)
+
+# Install from local path or GitHub
+install_plugin(source="/path/to/plugin")
+install_plugin(source="github:owner/repo", ref="v1.0.0")
+
+# List and load installed plugins
+for info in list_installed_plugins():
+ print(f"{info.name} v{info.version}")
+
+plugins = load_installed_plugins()
+
+# Uninstall
+uninstall_plugin("plugin-name")
+```
## Next Steps