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, load, enable, disable, and uninstall).
examples/05_skills_and_plugins/02_loading_plugins/main.py
"""Example: Loading and Managing Plugins

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

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.

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 json
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,
    disable_plugin,
    enable_plugin,
    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


script_dir = Path(__file__).parent
local_plugin_path = script_dir / "example_plugins" / "code-quality"


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


def demo_conversation_with_github_plugin(llm: LLM) -> None:
    """Demo 1: Load plugin from GitHub via Conversation."""
    print("\n" + "=" * 60)
    print("DEMO 1: Loading plugin from GitHub via Conversation")
    print("=" * 60)

    plugins = [
        PluginSource(
            source="github:anthropics/skills",
            ref="main",
        ),
    ]

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

    with tempfile.TemporaryDirectory() as tmpdir:
        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)

    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}")
    return info.name


def demo_install_github_plugin(installed_dir: Path) -> None:
    """Demo 3: Install a plugin from GitHub to persistent storage."""
    print("\n" + "=" * 60)
    print("DEMO 3: Installing plugin from GitHub")
    print("=" * 60)

    try:
        info = install_plugin(
            source="github:anthropics/skills",
            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 tracked plugins and load the enabled ones."""
    print("\n" + "=" * 60)
    print("DEMO 4: Listing and loading installed plugins")
    print("=" * 60)

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

    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 6: Uninstall all tracked plugins."""
    print("\n" + "=" * 60)
    print("DEMO 6: 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)}")


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 and lifecycle 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-plugins"
        installed_dir.mkdir()

        if llm:
            demo_conversation_with_github_plugin(llm)

        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)
    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, 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 the same APIs directly when you need a narrower flow:
from openhands.sdk.plugin import (
    disable_plugin,
    enable_plugin,
    install_plugin,
    list_installed_plugins,
    load_installed_plugins,
    uninstall_plugin,
)

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)

Multiple Marketplace Registrations

For enterprise and team scenarios, you can register multiple plugin marketplaces with different loading strategies. This allows you to:
  • Register internal team marketplaces alongside the public marketplace
  • Control which plugins auto-load at conversation start vs load on-demand
  • Reference plugins from specific marketplaces using the plugin@marketplace syntax

Loading Strategies

StrategyBehavior
auto_load="all"All plugins from the marketplace load automatically when a conversation starts
auto_load=None (default)Marketplace is registered but plugins are loaded on-demand via load_plugin()

Plugin Reference Syntax

Use the plugin-name@marketplace-name format to explicitly specify which marketplace a plugin comes from. This syntax follows the same convention as Claude Code’s plugin install command.
# Load a specific plugin from a registered marketplace
conversation.load_plugin("greeter@demo")

# If only one marketplace has the plugin, the marketplace name is optional
conversation.load_plugin("greeter")

Example: Auto-load and On-demand Loading

The example below demonstrates registering two marketplaces with different loading strategies, then loading an additional plugin on-demand.
examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py
"""Example: Multiple Marketplace Registrations

Demonstrates two loading strategies for marketplace plugins:

- auto_load="all": Plugins loaded automatically at conversation start
- auto_load=None: Plugins loaded on-demand via conversation.load_plugin()

This example uses pre-created marketplaces in:
- ./auto_marketplace/ - auto-loaded at conversation start
- ./demo_marketplace/ - loaded on-demand
"""

import os
from pathlib import Path

from openhands.sdk import LLM, Agent, AgentContext, Conversation
from openhands.sdk.plugin import MarketplaceRegistration

SCRIPT_DIR = Path(__file__).parent


def main():
    llm = LLM(
        model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
        api_key=os.getenv("LLM_API_KEY"),
        base_url=os.getenv("LLM_BASE_URL"),
    )

    # Register two marketplaces with different loading strategies
    agent_context = AgentContext(
        registered_marketplaces=[
            # Auto-loaded: plugins available immediately when conversation starts
            MarketplaceRegistration(
                name="auto",
                source=str(SCRIPT_DIR / "auto_marketplace"),
                auto_load="all",
            ),
            # On-demand: registered but not loaded until explicitly requested
            MarketplaceRegistration(
                name="demo",
                source=str(SCRIPT_DIR / "demo_marketplace"),
                # auto_load=None (default) - use load_plugin() to load
            ),
        ],
    )

    agent = Agent(llm=llm, tools=[], agent_context=agent_context)
    conversation = Conversation(agent=agent, workspace=os.getcwd())

    # The "auto" marketplace plugins are already loaded
    # Now load an additional plugin on-demand from "demo" marketplace
    # Format: "plugin-name@marketplace-name" (same as Claude Code plugin syntax)
    conversation.load_plugin("greeter@demo")

    resolved = conversation.resolved_plugins
    if resolved:
        print(f"Loaded {len(resolved)} plugin(s):")
        for plugin in resolved:
            print(f"  - {plugin.source}")

    # Use skills from both plugins
    conversation.send_message("Give me a tip, then greet me!")
    conversation.run()

    print(f"\nEXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")


if __name__ == "__main__":
    if not os.getenv("LLM_API_KEY"):
        print("Set LLM_API_KEY to run this example")
        print("EXAMPLE_COST: 0")
    else:
        main()
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.

MarketplaceRegistration Fields

FieldTypeDescription
namestrIdentifier for this marketplace registration
sourcestrPlugin source: github:owner/repo, git URL, or local path
refstr | NoneOptional branch, tag, or commit for the marketplace repo
repo_pathstr | NoneSubdirectory within repo (for monorepos)
auto_load"all" | NoneLoading strategy (default: None)

Next Steps

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