From 0438c173c8d3d84f0ac472ddbf89cf77712025e3 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 18 Jan 2026 00:13:40 +0000 Subject: [PATCH 1/4] docs: Add plugin loading via AgentContext example Add documentation for the recommended pattern of loading plugins via AgentContext.plugin_source. This pattern: - Is consistent with the agent-server API - Automatically handles all plugin components (skills, MCP config, hooks) - Reduces boilerplate code The existing Plugin.load() pattern is now documented as 'Manual Plugin Loading' for advanced use cases requiring fine-grained control. Related to: OpenHands/software-agent-sdk#1651 Co-authored-by: openhands --- sdk/guides/plugins.mdx | 233 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 228 insertions(+), 5 deletions(-) diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx index 8f8287d5..68532fee 100644 --- a/sdk/guides/plugins.mdx +++ b/sdk/guides/plugins.mdx @@ -3,10 +3,6 @@ title: Plugins description: Plugins bundle skills, hooks, MCP servers, agents, and commands into reusable packages that extend agent capabilities. --- - -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) - - Plugins provide a way to package and distribute multiple agent components together. A single plugin can include: - **Skills**: Specialized knowledge and workflows @@ -17,7 +13,234 @@ Plugins provide a way to package and distribute multiple agent components togeth The plugin format is compatible with the [Claude Code plugin structure](https://github.com/anthropics/claude-code/tree/main/plugins). -## Loading Plugins +## Recommended: Plugin Loading via AgentContext + + +This example is available on GitHub: [examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py) + + +The recommended way to load plugins is via `AgentContext.plugin_source`. This pattern: +- Is consistent with the agent-server API +- Automatically handles all plugin components (skills, MCP config, hooks) +- Reduces boilerplate code + +```python icon="python" expandable examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py +"""Example: Plugin Loading via AgentContext + +This example demonstrates the recommended pattern for loading plugins: +pass plugin_source to AgentContext instead of explicit Plugin.load() calls. + +With this pattern: +- Skills are automatically merged into AgentContext.skills +- MCP config is automatically merged during Agent initialization +- Hooks are automatically extracted and applied to the Conversation + +This is the same pattern used by the agent-server API, ensuring consistency +between local SDK usage and remote agent-server usage. + +Usage: + export LLM_API_KEY=your-api-key # Optional, demo runs without + python main.py +""" + +import os +import sys +import tempfile +from pathlib import Path + +from pydantic import SecretStr + +from openhands.sdk import LLM, Agent, AgentContext, Conversation +from openhands.sdk.tool import Tool +from openhands.tools.terminal import TerminalTool + + +# Get path to example plugin +script_dir = Path(__file__).parent +example_plugin_path = ( + script_dir.parent / "02_loading_plugins" / "example_plugins" / "code-quality" +) + +# ============================================================================= +# Part 1: Create AgentContext with plugin_source +# ============================================================================= +print("=" * 80) +print("Part 1: Creating AgentContext with Plugin Source") +print("=" * 80) + +# The recommended pattern: pass plugin_source to AgentContext +# The plugin is automatically fetched and loaded during initialization +agent_context = AgentContext( + plugin_source=str(example_plugin_path), # Local path, or "github:owner/repo" + # plugin_ref="v1.0.0", # Optional: specific version/branch/commit + # plugin_path="plugins/sub", # Optional: subdirectory within repo +) + +print(f"\nPlugin source: {example_plugin_path}") +print(f"Skills loaded: {len(agent_context.skills)}") +for skill in agent_context.skills: + print(f" - {skill.name}") + +print(f"\nMCP config available: {agent_context.plugin_mcp_config is not None}") +if agent_context.plugin_mcp_config: + servers = agent_context.plugin_mcp_config.get("mcpServers", {}) + for server_name in servers: + print(f" - {server_name}") + +print(f"\nHooks available: {agent_context.plugin_hooks is not None}") +if agent_context.plugin_hooks: + hooks = agent_context.plugin_hooks + if hooks.pre_tool_use: + print(f" - PreToolUse: {len(hooks.pre_tool_use)} matcher(s)") + if hooks.post_tool_use: + print(f" - PostToolUse: {len(hooks.post_tool_use)} matcher(s)") + +# ============================================================================= +# Part 2: Create Agent - MCP config is automatically merged +# ============================================================================= +print("\n" + "=" * 80) +print("Part 2: Creating Agent with AgentContext") +print("=" * 80) + +# Check for API key +api_key = os.getenv("LLM_API_KEY") +if not api_key: + print("\nSkipping agent demo (LLM_API_KEY not set)") + print("\nTo run the full demo, set the LLM_API_KEY environment variable:") + print(" export LLM_API_KEY=your-api-key") + print("\nBut you can see the plugin was loaded successfully above!") + sys.exit(0) + +# Configure LLM +model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") +llm = LLM( + usage_id="plugin-demo", + model=model, + api_key=SecretStr(api_key), +) + +# Create agent with the agent_context +# MCP config from plugin is automatically merged during initialization +agent = Agent( + llm=llm, + tools=[Tool(name=TerminalTool.name)], + agent_context=agent_context, + # No need to pass mcp_config - it's merged from plugin automatically +) + +print(f"Agent created with {len(agent_context.skills)} skills from plugin") + +# ============================================================================= +# Part 3: Create Conversation - Hooks are automatically extracted +# ============================================================================= +print("\n" + "=" * 80) +print("Part 3: Creating Conversation") +print("=" * 80) + +with tempfile.TemporaryDirectory() as tmpdir: + # Create conversation - hooks from plugin are automatically applied + # because LocalConversation extracts them from agent.agent_context.plugin_hooks + conversation = Conversation( + agent=agent, + workspace=tmpdir, + # No need to pass hook_config - it's extracted from agent_context automatically + ) + + print("Conversation created!") + print(" - Skills: loaded from plugin via agent_context") + print(" - MCP config: merged during agent initialization") + print(" - Hooks: extracted from agent_context.plugin_hooks") + + # ============================================================================= + # Part 4: Use the conversation + # ============================================================================= + print("\n" + "=" * 80) + print("Part 4: Running Demo") + print("=" * 80) + + # The skill should be triggered by "lint" keyword + print("\nSending message with 'lint' keyword to trigger skill...") + conversation.send_message("How do I lint Python code? Brief explanation please.") + conversation.run() + + print(f"\nTotal cost: ${llm.metrics.accumulated_cost:.4f}") + +# ============================================================================= +# Summary +# ============================================================================= +print("\n" + "=" * 80) +print("Summary: The Recommended Pattern") +print("=" * 80) + +print(""" +The recommended way to load plugins is via AgentContext: + + # Create AgentContext with plugin source + agent_context = AgentContext( + plugin_source="github:owner/repo", # or local path + plugin_ref="v1.0.0", # optional + plugin_path="plugins/sub", # optional + ) + + # Create Agent - MCP config is merged automatically + agent = Agent( + llm=llm, + tools=[...], + agent_context=agent_context, + ) + + # Create Conversation - hooks are extracted automatically + conversation = Conversation( + agent=agent, + workspace="./workspace", + ) + +This pattern: +- Is consistent with the agent-server API +- Automatically handles all plugin components (skills, MCP, hooks) +- Reduces boilerplate code +""") +``` + +```bash Running the Example +export LLM_API_KEY="your-api-key" +cd agent-sdk +uv run python examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py +``` + +### Quick Reference + +```python highlight={2-4,10,15} +# Create AgentContext with plugin source +agent_context = AgentContext( + plugin_source="github:owner/repo", # or local path + plugin_ref="v1.0.0", # optional: branch, tag, or commit + plugin_path="plugins/sub", # optional: subdirectory +) + +# Create Agent - MCP config is merged automatically +agent = Agent( + llm=llm, + tools=[...], + agent_context=agent_context, +) + +# Create Conversation - hooks are extracted automatically +conversation = Conversation( + agent=agent, + workspace="./workspace", +) +``` + +## Manual Plugin Loading + + +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) + +For most use cases, the [recommended approach](#recommended-plugin-loading-via-agentcontext) above is simpler and more consistent with the agent-server API. + + +For advanced use cases where you need fine-grained control over plugin loading, you can use `Plugin.load()` directly: ```python icon="python" expandable examples/05_skills_and_plugins/02_loading_plugins/main.py """Example: Loading Plugins From cfd0f831cc1c5d5a0804c77b9b2bd4223451a026 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 20 Jan 2026 14:34:08 +0000 Subject: [PATCH 2/4] docs: Update plugin loading docs for Conversation-based approach Updates documentation to reflect the actual implementation: - Plugin loading via `plugins` parameter on Conversation - Using PluginSource model instead of AgentContext fields - Updated example path to 03_plugin_via_conversation Related: OpenHands/software-agent-sdk#1651 Co-authored-by: openhands --- sdk/guides/plugins.mdx | 237 ++++++++++++++++++++--------------------- 1 file changed, 115 insertions(+), 122 deletions(-) diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx index 68532fee..11bc0813 100644 --- a/sdk/guides/plugins.mdx +++ b/sdk/guides/plugins.mdx @@ -13,34 +13,31 @@ Plugins provide a way to package and distribute multiple agent components togeth The plugin format is compatible with the [Claude Code plugin structure](https://github.com/anthropics/claude-code/tree/main/plugins). -## Recommended: Plugin Loading via AgentContext +## Recommended: Plugin Loading via Conversation -This example is available on GitHub: [examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py) +This example is available on GitHub: [examples/05_skills_and_plugins/03_plugin_via_conversation/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_plugin_via_conversation/main.py) -The recommended way to load plugins is via `AgentContext.plugin_source`. This pattern: -- Is consistent with the agent-server API +The recommended way to load plugins is via the `plugins` parameter on `Conversation`. This pattern: +- Supports loading multiple plugins at once - Automatically handles all plugin components (skills, MCP config, hooks) +- Works with both local and remote (agent-server) conversations - Reduces boilerplate code -```python icon="python" expandable examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py -"""Example: Plugin Loading via AgentContext +```python icon="python" expandable examples/05_skills_and_plugins/03_plugin_via_conversation/main.py +"""Example: Loading Plugins via Conversation -This example demonstrates the recommended pattern for loading plugins: -pass plugin_source to AgentContext instead of explicit Plugin.load() calls. +This example demonstrates the recommended way to load plugins using the +`plugins` parameter on the Conversation class. This approach: -With this pattern: -- Skills are automatically merged into AgentContext.skills -- MCP config is automatically merged during Agent initialization -- Hooks are automatically extracted and applied to the Conversation +1. Automatically loads and merges multiple plugins +2. Handles skills, MCP config, and hooks automatically +3. Works with both LocalConversation and RemoteConversation +4. Supports GitHub repositories, git URLs, and local paths -This is the same pattern used by the agent-server API, ensuring consistency -between local SDK usage and remote agent-server usage. - -Usage: - export LLM_API_KEY=your-api-key # Optional, demo runs without - python main.py +This is the preferred approach over manually calling Plugin.load() and +merging components by hand. """ import os @@ -50,65 +47,76 @@ from pathlib import Path from pydantic import SecretStr -from openhands.sdk import LLM, Agent, AgentContext, Conversation +from openhands.sdk import LLM, Agent, Conversation +from openhands.sdk.plugin import PluginSource from openhands.sdk.tool import Tool +from openhands.tools.file_editor import FileEditorTool from openhands.tools.terminal import TerminalTool -# Get path to example plugin +# Get the directory containing this script script_dir = Path(__file__).parent -example_plugin_path = ( - script_dir.parent / "02_loading_plugins" / "example_plugins" / "code-quality" -) +example_plugins_dir = script_dir.parent / "02_loading_plugins" / "example_plugins" # ============================================================================= -# Part 1: Create AgentContext with plugin_source +# Part 1: Creating a Conversation with Plugins (Local Path) # ============================================================================= print("=" * 80) -print("Part 1: Creating AgentContext with Plugin Source") +print("Part 1: Loading Plugin via Conversation (Local Path)") print("=" * 80) -# The recommended pattern: pass plugin_source to AgentContext -# The plugin is automatically fetched and loaded during initialization -agent_context = AgentContext( - plugin_source=str(example_plugin_path), # Local path, or "github:owner/repo" - # plugin_ref="v1.0.0", # Optional: specific version/branch/commit - # plugin_path="plugins/sub", # Optional: subdirectory within repo -) +# The plugin source can be: +# - Local path: "/path/to/plugin" or "./relative/path" +# - GitHub shorthand: "github:owner/repo" +# - Git URL: "https://github.com/org/repo.git" +# - With ref: PluginSource(source="github:org/repo", ref="v1.0.0") +# - From monorepo: PluginSource(source="github:org/repo", repo_path="plugins/my-plugin") -print(f"\nPlugin source: {example_plugin_path}") -print(f"Skills loaded: {len(agent_context.skills)}") -for skill in agent_context.skills: - print(f" - {skill.name}") +plugin_path = example_plugins_dir / "code-quality" +print(f"Plugin source: {plugin_path}") -print(f"\nMCP config available: {agent_context.plugin_mcp_config is not None}") -if agent_context.plugin_mcp_config: - servers = agent_context.plugin_mcp_config.get("mcpServers", {}) - for server_name in servers: - print(f" - {server_name}") +# Create plugin source specification +plugin_spec = PluginSource(source=str(plugin_path)) +print(f"Created PluginSource: {plugin_spec.model_dump()}") + +# ============================================================================= +# Part 2: Multiple Plugins Example +# ============================================================================= +print("\n" + "=" * 80) +print("Part 2: Loading Multiple Plugins") +print("=" * 80) -print(f"\nHooks available: {agent_context.plugin_hooks is not None}") -if agent_context.plugin_hooks: - hooks = agent_context.plugin_hooks - if hooks.pre_tool_use: - print(f" - PreToolUse: {len(hooks.pre_tool_use)} matcher(s)") - if hooks.post_tool_use: - print(f" - PostToolUse: {len(hooks.post_tool_use)} matcher(s)") +# You can specify multiple plugins - they are loaded in order +# Skills and MCP configs: last plugin wins (override by name/key) +# Hooks: all hooks concatenate (all run) +plugins = [ + PluginSource(source=str(plugin_path)), + # Add more plugins as needed: + # PluginSource(source="github:org/security-plugin", ref="v2.0.0"), + # PluginSource(source="github:org/monorepo", repo_path="plugins/logging"), +] + +print(f"Configured {len(plugins)} plugin(s):") +for p in plugins: + print(f" - {p.source}") + if p.ref: + print(f" ref: {p.ref}") + if p.repo_path: + print(f" repo_path: {p.repo_path}") # ============================================================================= -# Part 2: Create Agent - MCP config is automatically merged +# Part 3: Using Plugins with an Agent # ============================================================================= print("\n" + "=" * 80) -print("Part 2: Creating Agent with AgentContext") +print("Part 3: Using Plugins with an Agent") print("=" * 80) # Check for API key api_key = os.getenv("LLM_API_KEY") if not api_key: - print("\nSkipping agent demo (LLM_API_KEY not set)") + print("Skipping agent demo (LLM_API_KEY not set)") print("\nTo run the full demo, set the LLM_API_KEY environment variable:") print(" export LLM_API_KEY=your-api-key") - print("\nBut you can see the plugin was loaded successfully above!") sys.exit(0) # Configure LLM @@ -119,48 +127,44 @@ llm = LLM( api_key=SecretStr(api_key), ) -# Create agent with the agent_context -# MCP config from plugin is automatically merged during initialization +# Create agent with tools (no need to manually configure MCP or skills) +tools = [ + Tool(name=TerminalTool.name), + Tool(name=FileEditorTool.name), +] agent = Agent( llm=llm, - tools=[Tool(name=TerminalTool.name)], - agent_context=agent_context, - # No need to pass mcp_config - it's merged from plugin automatically + tools=tools, + # Note: No agent_context.skills or mcp_config needed here! + # The plugins parameter on Conversation handles this automatically. ) -print(f"Agent created with {len(agent_context.skills)} skills from plugin") - -# ============================================================================= -# Part 3: Create Conversation - Hooks are automatically extracted -# ============================================================================= -print("\n" + "=" * 80) -print("Part 3: Creating Conversation") -print("=" * 80) - +# Create a temporary directory for the demo with tempfile.TemporaryDirectory() as tmpdir: - # Create conversation - hooks from plugin are automatically applied - # because LocalConversation extracts them from agent.agent_context.plugin_hooks + # Create conversation WITH plugins parameter + # This is the key difference from the manual approach! conversation = Conversation( agent=agent, workspace=tmpdir, - # No need to pass hook_config - it's extracted from agent_context automatically + plugins=plugins, # <-- Plugins are loaded and merged automatically ) - print("Conversation created!") - print(" - Skills: loaded from plugin via agent_context") - print(" - MCP config: merged during agent initialization") - print(" - Hooks: extracted from agent_context.plugin_hooks") - - # ============================================================================= - # Part 4: Use the conversation - # ============================================================================= - print("\n" + "=" * 80) - print("Part 4: Running Demo") - print("=" * 80) - - # The skill should be triggered by "lint" keyword - print("\nSending message with 'lint' keyword to trigger skill...") - conversation.send_message("How do I lint Python code? Brief explanation please.") + print("\nConversation created with plugins loaded!") + agent_context = conversation.agent.agent_context + skills = agent_context.skills if agent_context else [] + print(f"Agent skills: {len(skills)}") + + # Show loaded skills + print("\nLoaded skills from plugins:") + for skill in skills: + print(f" - {skill.name}") + + # Demo: Test the skill (triggered by "lint" keyword) + print("\n--- Demo: Skill Triggering ---") + print("Sending message with 'lint' keyword to trigger skill...") + conversation.send_message( + "How do I lint Python code? Just give a brief explanation." + ) conversation.run() print(f"\nTotal cost: ${llm.metrics.accumulated_cost:.4f}") @@ -169,66 +173,55 @@ with tempfile.TemporaryDirectory() as tmpdir: # Summary # ============================================================================= print("\n" + "=" * 80) -print("Summary: The Recommended Pattern") +print("Summary: Plugin Loading via Conversation") print("=" * 80) - print(""" -The recommended way to load plugins is via AgentContext: +The `plugins` parameter on Conversation provides: - # Create AgentContext with plugin source - agent_context = AgentContext( - plugin_source="github:owner/repo", # or local path - plugin_ref="v1.0.0", # optional - plugin_path="plugins/sub", # optional - ) - - # Create Agent - MCP config is merged automatically - agent = Agent( - llm=llm, - tools=[...], - agent_context=agent_context, - ) +1. Automatic loading: Plugins are fetched and loaded automatically +2. Automatic merging: Skills, MCP configs, and hooks are merged +3. Multi-plugin support: Load multiple plugins, last one wins for conflicts +4. Git support: Use GitHub shorthand, git URLs, or local paths +5. Version pinning: Use `ref` to pin to a specific branch/tag/commit +6. Monorepo support: Use `repo_path` for plugins in subdirectories - # Create Conversation - hooks are extracted automatically +Example: conversation = Conversation( agent=agent, workspace="./workspace", + plugins=[ + PluginSource(source="github:org/security-plugin", ref="v2.0.0"), + PluginSource(source="github:org/monorepo", repo_path="plugins/audit"), + PluginSource(source="/local/custom-plugin"), + ], ) - -This pattern: -- Is consistent with the agent-server API -- Automatically handles all plugin components (skills, MCP, hooks) -- Reduces boilerplate code """) ``` ```bash Running the Example export LLM_API_KEY="your-api-key" cd agent-sdk -uv run python examples/05_skills_and_plugins/03_plugin_via_agent_context/main.py +uv run python examples/05_skills_and_plugins/03_plugin_via_conversation/main.py ``` ### Quick Reference -```python highlight={2-4,10,15} -# Create AgentContext with plugin source -agent_context = AgentContext( - plugin_source="github:owner/repo", # or local path - plugin_ref="v1.0.0", # optional: branch, tag, or commit - plugin_path="plugins/sub", # optional: subdirectory -) +```python highlight={2-5,12-17} +from openhands.sdk.plugin import PluginSource -# Create Agent - MCP config is merged automatically -agent = Agent( - llm=llm, - tools=[...], - agent_context=agent_context, -) +# Define plugin sources +plugins = [ + PluginSource(source="github:owner/repo", ref="v1.0.0"), + PluginSource(source="github:org/monorepo", repo_path="plugins/my-plugin"), + PluginSource(source="/local/path/to/plugin"), +] -# Create Conversation - hooks are extracted automatically +# Create Conversation with plugins +# Skills, MCP config, and hooks are loaded and merged automatically conversation = Conversation( agent=agent, workspace="./workspace", + plugins=plugins, ) ``` @@ -237,7 +230,7 @@ conversation = Conversation( 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) -For most use cases, the [recommended approach](#recommended-plugin-loading-via-agentcontext) above is simpler and more consistent with the agent-server API. +For most use cases, the [recommended approach](#recommended-plugin-loading-via-conversation) above is simpler and handles merging automatically. For advanced use cases where you need fine-grained control over plugin loading, you can use `Plugin.load()` directly: From 4308f740cd1b08b0f37be85ea8bee947f670ae53 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 20 Jan 2026 17:04:30 +0000 Subject: [PATCH 3/4] docs: simplify plugins guide with concise examples - Replace verbose inline examples with concise code snippets - Add proper prose sections for plugin source formats - Add sections for multiple plugins, remote conversations - Simplify manual loading section - Remove redundant embedded full example code Co-authored-by: openhands --- sdk/guides/plugins.mdx | 479 +++++++++++------------------------------ 1 file changed, 124 insertions(+), 355 deletions(-) diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx index 11bc0813..c7c3a50d 100644 --- a/sdk/guides/plugins.mdx +++ b/sdk/guides/plugins.mdx @@ -15,421 +15,190 @@ The plugin format is compatible with the [Claude Code plugin structure](https:// ## Recommended: Plugin Loading via Conversation - -This example is available on GitHub: [examples/05_skills_and_plugins/03_plugin_via_conversation/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_plugin_via_conversation/main.py) - +The recommended way to load plugins is via the `plugins` parameter on `Conversation`. This approach: + +- **Automatic Loading**: Plugins are fetched and loaded automatically +- **Automatic Merging**: Skills, MCP configs, and hooks are merged correctly +- **Multi-Plugin Support**: Load multiple plugins with proper conflict resolution +- **Git Support**: Use GitHub shorthand, git URLs, or local paths +- **Version Pinning**: Pin to specific branches, tags, or commits +- **Monorepo Support**: Load plugins from subdirectories in monorepos -The recommended way to load plugins is via the `plugins` parameter on `Conversation`. This pattern: -- Supports loading multiple plugins at once -- Automatically handles all plugin components (skills, MCP config, hooks) -- Works with both local and remote (agent-server) conversations -- Reduces boilerplate code +### Basic Usage + +```python +from openhands.sdk import Agent, Conversation +from openhands.sdk.plugin import PluginSource -```python icon="python" expandable examples/05_skills_and_plugins/03_plugin_via_conversation/main.py -"""Example: Loading Plugins via Conversation +conversation = Conversation( + agent=agent, + workspace="./workspace", + plugins=[ + PluginSource(source="github:org/security-plugin", ref="v2.0.0"), + PluginSource(source="/local/path/to/plugin"), + ], +) +``` -This example demonstrates the recommended way to load plugins using the -`plugins` parameter on the Conversation class. This approach: +### Plugin Source Formats -1. Automatically loads and merges multiple plugins -2. Handles skills, MCP config, and hooks automatically -3. Works with both LocalConversation and RemoteConversation -4. Supports GitHub repositories, git URLs, and local paths +**GitHub Shorthand** +```python +PluginSource(source="github:owner/repo") +PluginSource(source="github:owner/repo", ref="v1.0.0") # Pin to tag +PluginSource(source="github:owner/repo", ref="main") # Pin to branch +``` -This is the preferred approach over manually calling Plugin.load() and -merging components by hand. -""" +**Git URL** (works with any git host) +```python +PluginSource(source="https://github.com/org/repo.git") +PluginSource(source="https://gitlab.com/org/repo.git", ref="develop") +PluginSource(source="git@github.com:org/repo.git") # SSH +``` -import os -import sys -import tempfile -from pathlib import Path +**Local Path** +```python +PluginSource(source="/absolute/path/to/plugin") +PluginSource(source="./relative/path/to/plugin") +``` -from pydantic import SecretStr +**Monorepo** (plugin in subdirectory) +```python +PluginSource( + source="github:org/plugins-monorepo", + repo_path="plugins/security", +) +``` -from openhands.sdk import LLM, Agent, Conversation -from openhands.sdk.plugin import PluginSource -from openhands.sdk.tool import Tool -from openhands.tools.file_editor import FileEditorTool -from openhands.tools.terminal import TerminalTool +### Multiple Plugins +When loading multiple plugins, components are merged in order: -# Get the directory containing this script -script_dir = Path(__file__).parent -example_plugins_dir = script_dir.parent / "02_loading_plugins" / "example_plugins" - -# ============================================================================= -# Part 1: Creating a Conversation with Plugins (Local Path) -# ============================================================================= -print("=" * 80) -print("Part 1: Loading Plugin via Conversation (Local Path)") -print("=" * 80) - -# The plugin source can be: -# - Local path: "/path/to/plugin" or "./relative/path" -# - GitHub shorthand: "github:owner/repo" -# - Git URL: "https://github.com/org/repo.git" -# - With ref: PluginSource(source="github:org/repo", ref="v1.0.0") -# - From monorepo: PluginSource(source="github:org/repo", repo_path="plugins/my-plugin") - -plugin_path = example_plugins_dir / "code-quality" -print(f"Plugin source: {plugin_path}") - -# Create plugin source specification -plugin_spec = PluginSource(source=str(plugin_path)) -print(f"Created PluginSource: {plugin_spec.model_dump()}") - -# ============================================================================= -# Part 2: Multiple Plugins Example -# ============================================================================= -print("\n" + "=" * 80) -print("Part 2: Loading Multiple Plugins") -print("=" * 80) - -# You can specify multiple plugins - they are loaded in order -# Skills and MCP configs: last plugin wins (override by name/key) -# Hooks: all hooks concatenate (all run) +- **Skills**: Override by name (last plugin wins) +- **MCP Config**: Override by server name (last plugin wins) +- **Hooks**: Concatenate (all hooks run) + +```python plugins = [ - PluginSource(source=str(plugin_path)), - # Add more plugins as needed: - # PluginSource(source="github:org/security-plugin", ref="v2.0.0"), - # PluginSource(source="github:org/monorepo", repo_path="plugins/logging"), + PluginSource(source="github:org/base-plugin"), + PluginSource(source="github:org/overlay-plugin"), # Overrides base ] +``` -print(f"Configured {len(plugins)} plugin(s):") -for p in plugins: - print(f" - {p.source}") - if p.ref: - print(f" ref: {p.ref}") - if p.repo_path: - print(f" repo_path: {p.repo_path}") - -# ============================================================================= -# Part 3: Using Plugins with an Agent -# ============================================================================= -print("\n" + "=" * 80) -print("Part 3: Using Plugins with an Agent") -print("=" * 80) - -# Check for API key -api_key = os.getenv("LLM_API_KEY") -if not api_key: - print("Skipping agent demo (LLM_API_KEY not set)") - print("\nTo run the full demo, set the LLM_API_KEY environment variable:") - print(" export LLM_API_KEY=your-api-key") - sys.exit(0) - -# Configure LLM -model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") -llm = LLM( - usage_id="plugin-demo", - model=model, - api_key=SecretStr(api_key), -) +### Remote Conversations -# Create agent with tools (no need to manually configure MCP or skills) -tools = [ - Tool(name=TerminalTool.name), - Tool(name=FileEditorTool.name), -] -agent = Agent( - llm=llm, - tools=tools, - # Note: No agent_context.skills or mcp_config needed here! - # The plugins parameter on Conversation handles this automatically. -) +The same `plugins` parameter works with remote agent servers: -# Create a temporary directory for the demo -with tempfile.TemporaryDirectory() as tmpdir: - # Create conversation WITH plugins parameter - # This is the key difference from the manual approach! - conversation = Conversation( - agent=agent, - workspace=tmpdir, - plugins=plugins, # <-- Plugins are loaded and merged automatically - ) - - print("\nConversation created with plugins loaded!") - agent_context = conversation.agent.agent_context - skills = agent_context.skills if agent_context else [] - print(f"Agent skills: {len(skills)}") - - # Show loaded skills - print("\nLoaded skills from plugins:") - for skill in skills: - print(f" - {skill.name}") - - # Demo: Test the skill (triggered by "lint" keyword) - print("\n--- Demo: Skill Triggering ---") - print("Sending message with 'lint' keyword to trigger skill...") - conversation.send_message( - "How do I lint Python code? Just give a brief explanation." - ) - conversation.run() - - print(f"\nTotal cost: ${llm.metrics.accumulated_cost:.4f}") - -# ============================================================================= -# Summary -# ============================================================================= -print("\n" + "=" * 80) -print("Summary: Plugin Loading via Conversation") -print("=" * 80) -print(""" -The `plugins` parameter on Conversation provides: - -1. Automatic loading: Plugins are fetched and loaded automatically -2. Automatic merging: Skills, MCP configs, and hooks are merged -3. Multi-plugin support: Load multiple plugins, last one wins for conflicts -4. Git support: Use GitHub shorthand, git URLs, or local paths -5. Version pinning: Use `ref` to pin to a specific branch/tag/commit -6. Monorepo support: Use `repo_path` for plugins in subdirectories - -Example: - conversation = Conversation( - agent=agent, - workspace="./workspace", - plugins=[ - PluginSource(source="github:org/security-plugin", ref="v2.0.0"), - PluginSource(source="github:org/monorepo", repo_path="plugins/audit"), - PluginSource(source="/local/custom-plugin"), - ], - ) -""") -``` +```python +from openhands.sdk.workspace import RemoteWorkspace -```bash Running the Example -export LLM_API_KEY="your-api-key" -cd agent-sdk -uv run python examples/05_skills_and_plugins/03_plugin_via_conversation/main.py +conversation = Conversation( + agent=agent, + workspace=RemoteWorkspace(host="http://agent-server:8000"), + plugins=[ + PluginSource(source="github:org/plugin", ref="v1.0.0"), + ], +) ``` -### Quick Reference +Plugins are sent to the server and loaded inside the sandbox. -```python highlight={2-5,12-17} +### Complete Example + + +Full example on GitHub: [examples/05_skills_and_plugins/03_plugin_via_conversation/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_plugin_via_conversation/main.py) + + +```python +from openhands.sdk import LLM, Agent, Conversation from openhands.sdk.plugin import PluginSource +from openhands.sdk.tool import Tool +from openhands.tools.terminal import TerminalTool -# Define plugin sources +# Define plugins to load plugins = [ - PluginSource(source="github:owner/repo", ref="v1.0.0"), - PluginSource(source="github:org/monorepo", repo_path="plugins/my-plugin"), - PluginSource(source="/local/path/to/plugin"), + PluginSource(source="github:org/code-quality", ref="v1.0.0"), ] -# Create Conversation with plugins -# Skills, MCP config, and hooks are loaded and merged automatically +# Configure agent +llm = LLM(usage_id="demo", model="anthropic/claude-sonnet-4-5-20250929", api_key=api_key) +agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)]) + +# Create conversation - plugins are loaded and merged automatically conversation = Conversation( agent=agent, workspace="./workspace", plugins=plugins, ) + +# Skills from plugins are now available to the agent +conversation.send_message("How do I lint Python code?") +conversation.run() +``` + +```bash Running the Example +export LLM_API_KEY="your-api-key" +cd agent-sdk +uv run python examples/05_skills_and_plugins/03_plugin_via_conversation/main.py ``` ## Manual Plugin Loading -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) - For most use cases, the [recommended approach](#recommended-plugin-loading-via-conversation) above is simpler and handles merging automatically. For advanced use cases where you need fine-grained control over plugin loading, you can use `Plugin.load()` directly: -```python icon="python" expandable examples/05_skills_and_plugins/02_loading_plugins/main.py -"""Example: Loading Plugins - -This example demonstrates how to load plugins that bundle multiple components: -- Skills (specialized knowledge and workflows) -- Hooks (event handlers for tool lifecycle) -- MCP configuration (external tool servers) -- Agents (specialized agent definitions) -- Commands (slash commands) - -Plugins follow the Claude Code plugin structure for compatibility. -See the example_plugins/ directory for a complete plugin structure. -""" - -import os -import sys -import tempfile -from pathlib import Path - -from pydantic import SecretStr - -from openhands.sdk import LLM, Agent, AgentContext, Conversation +```python from openhands.sdk.plugin import Plugin -from openhands.sdk.tool import Tool -from openhands.tools.file_editor import FileEditorTool -from openhands.tools.terminal import TerminalTool - - -# Get the directory containing this script -script_dir = Path(__file__).parent -example_plugins_dir = script_dir / "example_plugins" - -# ============================================================================= -# Part 1: Loading a Single Plugin -# ============================================================================= -print("=" * 80) -print("Part 1: Loading a Single Plugin") -print("=" * 80) - -plugin_path = example_plugins_dir / "code-quality" -print(f"Loading plugin from: {plugin_path}") - -plugin = Plugin.load(plugin_path) -print("\nPlugin loaded successfully!") -print(f" Name: {plugin.name}") -print(f" Version: {plugin.version}") -print(f" Description: {plugin.description}") +# Load a single plugin +plugin = Plugin.load("/path/to/plugin") -# Show manifest details (extra fields are accessible via model_extra) -print("\nManifest details:") -print(f" Author: {plugin.manifest.author}") -extra = plugin.manifest.model_extra or {} -print(f" License: {extra.get('license', 'N/A')}") -print(f" Repository: {extra.get('repository', 'N/A')}") +# Access plugin components +print(f"Name: {plugin.name}, Version: {plugin.version}") +print(f"Skills: {len(plugin.skills)}") +print(f"Hooks: {'Yes' if plugin.hooks else 'No'}") +print(f"MCP servers: {list(plugin.mcp_config.get('mcpServers', {}).keys())}") -# ============================================================================= -# Part 2: Exploring Plugin Components -# ============================================================================= -print("\n" + "=" * 80) -print("Part 2: Exploring Plugin Components") -print("=" * 80) +# Load all plugins from a directory +plugins = Plugin.load_all("/path/to/plugins") +``` -# Skills -print(f"\nSkills ({len(plugin.skills)}):") -for skill in plugin.skills: - desc = skill.description or "" - print(f" - {skill.name}: {desc[:60]}...") - if skill.trigger: - print(f" Triggers: {skill.trigger}") +### Using Components Manually -# Hooks -print(f"\nHooks: {'Configured' if plugin.hooks else 'None'}") -if plugin.hooks: - for event_type, matchers in plugin.hooks.hooks.items(): - print(f" - {event_type}: {len(matchers)} matcher(s)") +When loading manually, you wire each component to the agent yourself: -# MCP Config -print(f"\nMCP Config: {'Configured' if plugin.mcp_config else 'None'}") -if plugin.mcp_config is not None: - servers = plugin.mcp_config.get("mcpServers", {}) - for server_name in servers: - print(f" - {server_name}") - -# Agents -print(f"\nAgents ({len(plugin.agents)}):") -for agent_def in plugin.agents: - print(f" - {agent_def.name}: {agent_def.description[:60]}...") - -# Commands -print(f"\nCommands ({len(plugin.commands)}):") -for cmd in plugin.commands: - print(f" - /{cmd.name}: {cmd.description[:60]}...") - -# ============================================================================= -# Part 3: Loading All Plugins from a Directory -# ============================================================================= -print("\n" + "=" * 80) -print("Part 3: Loading All Plugins from a Directory") -print("=" * 80) - -plugins = Plugin.load_all(example_plugins_dir) -print(f"\nLoaded {len(plugins)} plugin(s) from {example_plugins_dir}") -for p in plugins: - print(f" - {p.name} v{p.version}") - -# ============================================================================= -# Part 4: Using Plugin Components with an Agent -# ============================================================================= -print("\n" + "=" * 80) -print("Part 4: Using Plugin Components with an Agent") -print("=" * 80) - -# Check for API key -api_key = os.getenv("LLM_API_KEY") -if not api_key: - print("Skipping agent demo (LLM_API_KEY not set)") - print("\nTo run the full demo, set the LLM_API_KEY environment variable:") - print(" export LLM_API_KEY=your-api-key") - sys.exit(0) - -# Configure LLM -model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") -llm = LLM( - usage_id="plugin-demo", - model=model, - api_key=SecretStr(api_key), -) +```python +from openhands.sdk import Agent, AgentContext, Conversation # Create agent context with plugin skills agent_context = AgentContext( skills=plugin.skills, - load_public_skills=False, # Only use plugin skills for this demo + load_public_skills=False, ) -# Create agent with tools and plugin MCP config -tools = [ - Tool(name=TerminalTool.name), - Tool(name=FileEditorTool.name), -] +# Create agent with plugin MCP config agent = Agent( llm=llm, tools=tools, agent_context=agent_context, - mcp_config=plugin.mcp_config or {}, # Use MCP servers from plugin + mcp_config=plugin.mcp_config or {}, ) -# Create a temporary directory for the demo -with tempfile.TemporaryDirectory() as tmpdir: - # Create conversation with plugin hooks - conversation = Conversation( - agent=agent, - workspace=tmpdir, - hook_config=plugin.hooks, # Use hooks from plugin - ) - - # Demo 1: Test the skill (triggered by "lint" keyword) - print("\n--- Demo 1: Skill Triggering ---") - print("Sending message with 'lint' keyword to trigger skill...") - conversation.send_message( - "How do I lint Python code? Just give a brief explanation." - ) - conversation.run() - - # Demo 2: Test hooks by using file_editor (triggers PostToolUse hook) - print("\n--- Demo 2: Hook Execution ---") - print("Creating a file to trigger PostToolUse hook on file_editor...") - conversation.send_message( - "Create a file called hello.py with a simple print statement." - ) - conversation.run() - - # Demo 3: Test MCP by using fetch tool - print("\n--- Demo 3: MCP Tool Usage ---") - print("Using fetch MCP tool to retrieve a URL...") - conversation.send_message( - "Use the fetch tool to get the content from https://httpbin.org/get " - "and tell me what the 'origin' field contains." - ) - conversation.run() - - # Verify hooks executed by checking the hook log file - print("\n--- Verifying Hook Execution ---") - hook_log_path = os.path.join(tmpdir, ".hook_log") - if os.path.exists(hook_log_path): - print("Hook log file found! Contents:") - with open(hook_log_path) as f: - for line in f: - print(f" {line.strip()}") - else: - print("No hook log file found (hooks may not have executed)") - - print(f"\nTotal cost: ${llm.metrics.accumulated_cost:.4f}") +# Create conversation with plugin hooks +conversation = Conversation( + agent=agent, + workspace="./workspace", + hook_config=plugin.hooks, +) ``` + +Full example 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) + + ```bash Running the Example export LLM_API_KEY="your-api-key" cd agent-sdk From 7253eae7a3e749429f738b4e9cdc15344bd7f407 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 21 Jan 2026 00:14:18 +0000 Subject: [PATCH 4/4] docs: clarify lazy plugin loading behavior Update documentation to reflect that plugins are loaded lazily on first send_message() or run() call, not when the Conversation is created. Related: OpenHands/software-agent-sdk#1651 Co-authored-by: openhands --- sdk/guides/plugins.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx index c7c3a50d..65f6acef 100644 --- a/sdk/guides/plugins.mdx +++ b/sdk/guides/plugins.mdx @@ -17,7 +17,7 @@ The plugin format is compatible with the [Claude Code plugin structure](https:// The recommended way to load plugins is via the `plugins` parameter on `Conversation`. This approach: -- **Automatic Loading**: Plugins are fetched and loaded automatically +- **Lazy Loading**: Plugins are fetched and loaded on first `send_message()` or `run()` call - **Automatic Merging**: Skills, MCP configs, and hooks are merged correctly - **Multi-Plugin Support**: Load multiple plugins with proper conflict resolution - **Git Support**: Use GitHub shorthand, git URLs, or local paths @@ -124,14 +124,16 @@ plugins = [ llm = LLM(usage_id="demo", model="anthropic/claude-sonnet-4-5-20250929", api_key=api_key) agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)]) -# Create conversation - plugins are loaded and merged automatically +# Create conversation with plugins +# Note: Plugins are loaded lazily on first send_message() or run() call conversation = Conversation( agent=agent, workspace="./workspace", plugins=plugins, ) -# Skills from plugins are now available to the agent +# This first send_message() triggers plugin loading +# Skills from plugins are then available to the agent conversation.send_message("How do I lint Python code?") conversation.run() ```