Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 149 additions & 86 deletions sdk/guides/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
## Plugin Structure

<Note>
See the [example_plugins directory](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/05_skills_and_plugins/02_loading_plugins/example_plugins) for a complete working plugin structure.

Check warning on line 21 in sdk/guides/plugins.mdx

View check run for this annotation

Mintlify / Mintlify Validation (allhandsai) - vale-spellcheck

sdk/guides/plugins.mdx#L21

Did you really mean 'example_plugins'?
</Note>

A plugin follows this directory structure:
Expand Down Expand Up @@ -196,7 +196,7 @@

## Ready-to-run Example

The example below demonstrates plugin loading via Conversation and plugin management utilities (install, list, update, uninstall).
The example below demonstrates plugin loading via Conversation and plugin management utilities (install, list, load, enable, disable, and uninstall).

<Note>
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)
Expand All @@ -205,11 +205,14 @@
```python icon="python" expandable examples/05_skills_and_plugins/02_loading_plugins/main.py
"""Example: Loading and Managing Plugins

This example demonstrates plugin loading and management in the SDK:
This example demonstrates plugin loading and lifecycle management in the SDK:

1. Loading plugins via Conversation (PluginSource)
2. Installing plugins to persistent storage
3. Listing, updating, and uninstalling plugins
1. Loading a plugin from GitHub via Conversation (PluginSource)
2. Installing plugins to persistent storage (local and GitHub)
3. Listing tracked plugins and loading only the enabled ones
4. Inspecting the `.installed.json` metadata file and `enabled` flag
5. Disabling and re-enabling a plugin without reinstalling it
6. Uninstalling plugins from persistent storage

Plugins bundle skills, hooks, and MCP config together.

Expand All @@ -223,6 +226,7 @@
For full documentation, see: https://docs.all-hands.dev/sdk/guides/plugins
"""

import json
import os
import tempfile
from pathlib import Path
Expand All @@ -233,6 +237,8 @@
from openhands.sdk.plugin import (
PluginFetchError,
PluginSource,
disable_plugin,
enable_plugin,
install_plugin,
list_installed_plugins,
load_installed_plugins,
Expand All @@ -243,27 +249,39 @@
from openhands.tools.terminal import TerminalTool


# Locate example plugin directory
script_dir = Path(__file__).parent
local_plugin_path = script_dir / "example_plugins" / "code-quality"


def demo_conversation_with_plugins(llm: LLM) -> None:
"""Demo 1: Load plugins via Conversation's plugins parameter.
def print_state(label: str, installed_dir: Path) -> None:
"""Print tracked, loaded, and persisted plugin state."""
print(f"\n{label}")
print("-" * len(label))

installed = list_installed_plugins(installed_dir=installed_dir)
print("Tracked plugins:")
for info in installed:
print(f" - {info.name} (enabled={info.enabled}, source={info.source})")

loaded = load_installed_plugins(installed_dir=installed_dir)
print(f"Loaded plugins: {[plugin.name for plugin in loaded]}")

metadata = json.loads((installed_dir / ".installed.json").read_text())
print("Metadata file:")
print(json.dumps(metadata, indent=2))

This is the recommended way to use plugins - they are loaded lazily
when the conversation starts.
"""

def demo_conversation_with_github_plugin(llm: LLM) -> None:
"""Demo 1: Load plugin from GitHub via Conversation."""
print("\n" + "=" * 60)
print("DEMO 1: Loading plugins via Conversation")
print("DEMO 1: Loading plugin from GitHub 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"),
PluginSource(
source="github:anthropics/skills",
ref="main",
),
]

agent = Agent(
Expand All @@ -272,31 +290,43 @@
)

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.

Useful for development or local-only plugins.
"""
try:
conversation = Conversation(
agent=agent,
workspace=tmpdir,
plugins=plugins,
)

conversation.send_message(
"What's the best way to create a PowerPoint presentation "
"programmatically? Check the skill before you answer."
)

skills = (
conversation.agent.agent_context.skills
if conversation.agent.agent_context
else []
)
print(f"✓ Loaded {len(skills)} skill(s) from GitHub plugin")
for skill in skills[:5]:
print(f" - {skill.name}")
if len(skills) > 5:
print(f" ... and {len(skills) - 5} more skills")

if conversation.resolved_plugins:
print("Resolved plugin refs:")
for resolved in conversation.resolved_plugins:
print(f" - {resolved.source} @ {resolved.resolved_ref}")

conversation.run()

except PluginFetchError as e:
print(f"⚠ Could not fetch from GitHub: {e}")
print(" Skipping this demo (network or rate limiting issue)")


def demo_install_local_plugin(installed_dir: Path) -> str:
"""Demo 2: Install a plugin from a local path."""
print("\n" + "=" * 60)
print("DEMO 2: Installing plugin from local path")
print("=" * 60)
Expand All @@ -305,57 +335,91 @@
print(f"✓ Installed: {info.name} v{info.version}")
print(f" Source: {info.source}")
print(f" Path: {info.install_path}")
return info.name


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.
"""
"""Demo 3: Install a plugin from GitHub to persistent storage."""
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}")

plugins = load_installed_plugins(installed_dir=installed_dir)
for plugin in plugins:
if plugin.name != info.name:
continue

skills = plugin.get_all_skills()
print(f" Skills: {len(skills)}")
for skill in skills[:5]:
desc = skill.description or "(no description)"
print(f" - {skill.name}: {desc[:50]}...")
if len(skills) > 5:
print(f" ... and {len(skills) - 5} more skills")

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."""
"""Demo 4: List tracked plugins and load the enabled ones."""
print("\n" + "=" * 60)
print("DEMO 4: List and load installed plugins")
print("DEMO 4: Listing and loading installed plugins")
print("=" * 60)

# List installed plugins
print("Installed plugins:")
print("Tracked plugins:")
for info in list_installed_plugins(installed_dir=installed_dir):
print(f" - {info.name} v{info.version} ({info.source})")
print(f" - {info.name} v{info.version} (enabled={info.enabled})")

# 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_enable_disable_plugin(installed_dir: Path, plugin_name: str) -> None:
"""Demo 5: Disable then re-enable a plugin without reinstalling it."""
print("\n" + "=" * 60)
print("DEMO 5: Disabling and re-enabling a plugin")
print("=" * 60)

print_state("Before disable", installed_dir)

assert disable_plugin(plugin_name, installed_dir=installed_dir) is True
print_state("After disable", installed_dir)
assert plugin_name not in [
plugin.name for plugin in load_installed_plugins(installed_dir=installed_dir)
]

metadata = json.loads((installed_dir / ".installed.json").read_text())
assert metadata["plugins"][plugin_name]["enabled"] is False

assert enable_plugin(plugin_name, installed_dir=installed_dir) is True
print_state("After re-enable", installed_dir)

metadata = json.loads((installed_dir / ".installed.json").read_text())
assert metadata["plugins"][plugin_name]["enabled"] is True
assert plugin_name in [
plugin.name for plugin in load_installed_plugins(installed_dir=installed_dir)
]


def demo_uninstall_plugins(installed_dir: Path) -> None:
"""Demo 5: Uninstall plugins."""
"""Demo 6: Uninstall all tracked plugins."""
print("\n" + "=" * 60)
print("DEMO 5: Uninstalling plugins")
print("DEMO 6: Uninstalling plugins")
print("=" * 60)

for info in list_installed_plugins(installed_dir=installed_dir):
Expand All @@ -366,12 +430,11 @@
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...")
print("Running install and lifecycle demos only...")
llm = None
else:
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
Expand All @@ -383,17 +446,16 @@
)

with tempfile.TemporaryDirectory() as tmpdir:
installed_dir = Path(tmpdir) / "installed"
installed_dir = Path(tmpdir) / "installed-plugins"
installed_dir.mkdir()

# Demo 1: Conversation with plugins (requires LLM)
if llm:
demo_conversation_with_plugins(llm)
demo_conversation_with_github_plugin(llm)

# Demo 2-5: Plugin management (no LLM required)
demo_install_local_plugin(installed_dir)
local_plugin_name = demo_install_local_plugin(installed_dir)
demo_install_github_plugin(installed_dir)
demo_list_and_load_plugins(installed_dir)
demo_enable_disable_plugin(installed_dir, local_plugin_name)
demo_uninstall_plugins(installed_dir)

print("\n" + "=" * 60)
Expand All @@ -410,9 +472,25 @@

## 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`, which stores metadata including a persistent enabled flag.
The SDK provides utilities to install plugins to a local directory
(`~/.openhands/plugins/installed/` by default). Installed plugins are tracked
in `.installed.json`, which stores metadata including a persistent enabled
flag.

Use `list_installed_plugins()` to see all tracked plugins (enabled and
disabled). Use `load_installed_plugins()` to load only enabled plugins.
`install_plugin()`, `enable_plugin()`, `disable_plugin()`, and
`uninstall_plugin()` are exposed from `openhands.sdk.plugin`, which gives the
CLI a clean SDK surface for `/plugin install`, `/plugin enable`,
`/plugin disable`, and `/plugin uninstall`.

### Installed Plugin Lifecycle

The ready-to-run example above already demonstrates the full
installed-plugin lifecycle, including toggling the persistent `enabled`
flag in `.installed.json` before uninstalling the plugin.

Use `list_installed_plugins()` to see all tracked plugins (enabled and disabled). Use `load_installed_plugins()` to load only enabled plugins. Toggle plugins on/off with `enable_plugin()` and `disable_plugin()` without uninstalling.
Use the same APIs directly when you need a narrower flow:

```python icon="python"
from openhands.sdk.plugin import (
Expand All @@ -424,27 +502,12 @@
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 installed plugins (includes enabled + disabled)
for info in list_installed_plugins():
status = "enabled" if info.enabled else "disabled"
print(f"{info.name} v{info.version} ({status})")

# Disable a plugin (won't be loaded until re-enabled)
disable_plugin("plugin-name")

# Load only enabled plugins for your agent
plugins = load_installed_plugins()

# Later: re-enable and reload
enable_plugin("plugin-name")
plugins = load_installed_plugins()

# Uninstall
uninstall_plugin("plugin-name")
info = install_plugin(source="/path/to/plugin")
tracked_plugins = list_installed_plugins()
disable_plugin(info.name)
enabled_plugins = load_installed_plugins()
enable_plugin(info.name)
uninstall_plugin(info.name)
```

## Next Steps
Expand Down
Loading
Loading