Skip to main content
Plugins provide a way to package and distribute multiple agent components together. A single plugin can include:
  • Skills: Specialized knowledge and workflows
  • Hooks: Event handlers for tool lifecycle
  • MCP Config: External tool server configurations
  • Agents: Specialized agent definitions
  • Commands: Slash commands
The plugin format is compatible with the Claude Code plugin structure.

Plugin Structure

See the example_plugins directory for a complete working plugin structure.
A plugin follows this directory structure:
plugin-name
.plugin
plugin.json
skills
hooks
hooks.json
agents
agent-name.md
commands
command-name.md
.mcp.json
README.md
Note that the plugin metadata, i.e., plugin-name/.plugin/plugin.json, is required.

Plugin Manifest

The manifest file plugin-name/.plugin/plugin.json defines plugin metadata:
{
  "name": "code-quality",
  "version": "1.0.0",
  "description": "Code quality tools and workflows",
  "author": "openhands",
  "license": "MIT",
  "repository": "https://github.com/example/code-quality-plugin"
}

Skills

Skills are defined in markdown files with YAML frontmatter:
---
name: python-linting
description: Instructions for linting Python code
trigger:
  type: keyword
  keywords:
    - lint
    - linting
    - code quality
---

# Python Linting Skill

Run ruff to check for issues:

\`\`\`bash
ruff check .
\`\`\`

Hooks

Hooks are defined in hooks/hooks.json:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "file_editor",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'File edited: $OPENHANDS_TOOL_NAME'",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

MCP Configuration

MCP servers are configured in .mcp.json:
{
  "mcpServers": {
    "fetch": {
      "command": "uvx",
      "args": ["mcp-server-fetch"]
    }
  }
}

Using Plugin Components

The ready-to-run example is available here!
Brief explanation on how to use a plugin with an agent.
1

Loading a Plugin

First, load the desired plugins.
from openhands.sdk.plugin import Plugin

# Load a single plugin
plugin = Plugin.load("/path/to/plugin")

# Load all plugins from a directory
plugins = Plugin.load_all("/path/to/plugins")
2

Accessing Components

You can access the different plugin components to see which ones are available.
# Skills
for skill in plugin.skills:
    print(f"Skill: {skill.name}")

# Hooks configuration
if plugin.hooks:
    print(f"Hooks configured: {plugin.hooks}")

# MCP servers
if plugin.mcp_config:
    servers = plugin.mcp_config.get("mcpServers", {})
    print(f"MCP servers: {list(servers.keys())}")
3

Using with an Agent

You can now feed your agent with your preferred plugin.
# Create agent context with plugin skills
agent_context = AgentContext(
    skills=plugin.skills,
)

# Create agent with plugin MCP config
agent = Agent(
    llm=llm,
    tools=tools,
    mcp_config=plugin.mcp_config or {},
    agent_context=agent_context,
)

# Create conversation with plugin hooks
conversation = Conversation(
    agent=agent,
    hook_config=plugin.hooks,
)

Ready-to-run Example

The example below demonstrates plugin loading via Conversation and plugin management utilities (install, list, update, uninstall).
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:

1. Loading plugins via Conversation (PluginSource)
2. Installing plugins to persistent storage
3. Listing, updating, and uninstalling plugins

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 tempfile
from pathlib import Path

from pydantic import SecretStr

from openhands.sdk import LLM, Agent, Conversation
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


# 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.

    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"),
    ]

    agent = Agent(
        llm=llm,
        tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)],
    )

    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.
    """
    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")
You can run the example code as-is.
The model name should follow the LiteLLM convention: provider/model_name (e.g., anthropic/claude-sonnet-4-5-20250929, openai/gpt-4o). The LLM_API_KEY should be the API key for your chosen provider.
ChatGPT Plus/Pro subscribers: You can use LLM.subscription_login() to authenticate with your ChatGPT account and access Codex models without consuming API credits. See the LLM Subscriptions guide for details.

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.
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

  • Skills - Learn more about skills and triggers
  • Hooks - Understand hook event types
  • MCP Integration - Configure external tool servers