diff --git a/sdk/guides/mixed-marketplace-skills.mdx b/sdk/guides/mixed-marketplace-skills.mdx new file mode 100644 index 00000000..fa4d6889 --- /dev/null +++ b/sdk/guides/mixed-marketplace-skills.mdx @@ -0,0 +1,283 @@ +--- +title: Mixed Marketplace Skills +description: Combine local skills with remote skills from OpenHands extensions to create custom skill configurations. +--- + +import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx"; + +This guide shows how to combine locally-defined skills with remote skills from the [OpenHands extensions repository](https://github.com/OpenHands/extensions). + +## Use Case + +Teams often need to: +- Maintain custom workflow-specific skills locally +- Use skills from OpenHands extensions +- Create curated skill sets for specific projects + +## Loading Pattern + +This guide focuses on the two loader APIs used in the example: + +| Method | Source | Use Case | +|--------|--------|----------| +| `load_skills_from_dir()` | Local directory | Project-specific skills | +| `load_public_skills()` | OpenHands/extensions | Community skills filtered by a marketplace | + +## Example repository layout + +The example repository separates local skills from the marketplace configuration that filters public skills: + +```text +43_mixed_marketplace_skills/ +├── .plugin/ +│ └── marketplace.json +├── local_skills/ +│ └── greeting-helper/ +│ └── SKILL.md +├── main.py +└── README.md +``` + +## Marketplace format note + +The `.plugin/marketplace.json` file follows the Claude Code plugin marketplace schema. In OpenHands, plugin entry names are used as a filter list for which public skills to load from OpenHands/extensions, while local skills live in `local_skills/` and are merged separately. + +The guide below starts with the simplest direct loader calls (`load_skills_from_dir()` and `load_public_skills()`) so you can see exactly what each source contributes. The example repository still includes `.plugin/marketplace.json` because that is the configuration file used for repository-managed marketplace filtering. + +Additionally, OpenHands extends the schema with an optional `skills[]` array for listing skills directly (these are treated as direct skill sources, not plugin bundles). + +## Example: Combining Local and Remote Skills + + +Full example: [examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py) + + +```python icon="python" expandable examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py +"""Example: Mixed Marketplace Skills - Combining Local and Remote Skills + +This example demonstrates how to create a marketplace that combines: +1. Local skills hosted in your project directory +2. Remote skills from the OpenHands/extensions repository + +Use Case: +--------- +Teams often need to maintain their own custom skills while also leveraging +the community skills from OpenHands. This pattern allows you to: +- Keep specialized/private skills in your repository +- Reference public skills from OpenHands/extensions +- Create a curated skill set tailored for your workflow + +Directory Structure: +------------------- +43_mixed_marketplace_skills/ +├── .plugin/ +│ └── marketplace.json # Marketplace configuration +├── local_skills/ +│ └── greeting-helper/ +│ └── SKILL.md # Local skill following AgentSkills standard +├── main.py # This example script +└── README.md + +The marketplace.json lists which remote skills to include. In OpenHands, entries +in `skills[]` or `plugins[]` should point directly to skill directories containing +`SKILL.md`; local skills live in `local_skills/` and are loaded separately. +""" + +import argparse +import os +from pathlib import Path + +from pydantic import SecretStr + +from openhands.sdk import LLM, Agent, AgentContext, Conversation +from openhands.sdk.context.skills import ( + load_public_skills, + load_skills_from_dir, +) +from openhands.sdk.tool import Tool +from openhands.tools.file_editor import FileEditorTool +from openhands.tools.terminal import TerminalTool + + +def main(): + parser = argparse.ArgumentParser(description="Mixed Marketplace Skills Example") + parser.add_argument( + "--dry-run", + action="store_true", + help="Run without LLM (just show skill loading)", + ) + args = parser.parse_args() + + # ========================================================================= + # Part 1: Loading Local Skills from Directory + # ========================================================================= + print("=" * 80) + print("Part 1: Loading Local Skills from Directory") + print("=" * 80) + + script_dir = Path(__file__).parent + local_skills_dir = script_dir / "local_skills" + + print(f"\nLoading local skills from: {local_skills_dir}") + + # Load skills from the local directory + # This loads any SKILL.md files following the AgentSkills standard + repo_skills, knowledge_skills, local_skills = load_skills_from_dir(local_skills_dir) + + print("\nLoaded local skills:") + for name, skill in local_skills.items(): + print(f" - {name}: {skill.description or 'No description'}") + if skill.trigger: + # KeywordTrigger has 'keywords', TaskTrigger has 'triggers' + trigger_values = getattr(skill.trigger, "keywords", None) or getattr( + skill.trigger, "triggers", None + ) + if trigger_values: + print(f" Triggers: {trigger_values}") + + # ========================================================================= + # Part 2: Loading Remote Skills from OpenHands/extensions + # ========================================================================= + print("\n" + "=" * 80) + print("Part 2: Loading Remote Skills from OpenHands/extensions") + print("=" * 80) + + print("\nLoading public skills from https://github.com/OpenHands/extensions...") + + # Load public skills from the OpenHands extensions repository + # This pulls from the default marketplace at OpenHands/extensions + public_skills = load_public_skills() + + print(f"\nLoaded {len(public_skills)} public skills from OpenHands/extensions:") + for skill in public_skills[:5]: # Show first 5 + desc = (skill.description or "No description")[:50] + print(f" - {skill.name}: {desc}...") + if len(public_skills) > 5: + print(f" ... and {len(public_skills) - 5} more") + + # ========================================================================= + # Part 3: Combining Local and Remote Skills + # ========================================================================= + print("\n" + "=" * 80) + print("Part 3: Combining Local and Remote Skills") + print("=" * 80) + + # Combine skills with local skills taking precedence + # This allows local skills to override public skills with the same name + combined_skills = list(local_skills.values()) + public_skills + + # Remove duplicates (keep first occurrence = local takes precedence) + seen_names: set[str] = set() + unique_skills = [] + for skill in combined_skills: + if skill.name not in seen_names: + seen_names.add(skill.name) + unique_skills.append(skill) + + print(f"\nTotal combined skills: {len(unique_skills)}") + print(f" - Local skills: {len(local_skills)}") + print(f" - Public skills: {len(public_skills)}") + + local_names = list(local_skills.keys()) + public_names = [s.name for s in public_skills[:5]] + print(f"\nSkills by source:") + print(f" Local: {local_names}") + print(f" Remote (first 5): {public_names}") + + # ========================================================================= + # Part 4: Using Skills with an Agent + # ========================================================================= + print("\n" + "=" * 80) + print("Part 4: Using Skills with an Agent") + print("=" * 80) + + api_key = os.getenv("LLM_API_KEY") + model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + + if args.dry_run or not api_key: + print("\nSkipping agent demo (LLM_API_KEY not set)") + print("To run the full demo, set the LLM_API_KEY environment variable:") + print(" export LLM_API_KEY=your-api-key") + return + + print(f"\nUsing model: {model}") + + llm = LLM( + usage_id="mixed-skills-demo", + model=model, + api_key=SecretStr(api_key), + ) + + tools = [ + Tool(name=TerminalTool.name), + Tool(name=FileEditorTool.name), + ] + + # Create agent context with combined skills + agent_context = AgentContext(skills=unique_skills) + + agent = Agent(llm=llm, tools=tools, agent_context=agent_context) + conversation = Conversation(agent=agent, workspace=str(script_dir)) + + # Test the agent with a query that should trigger both local and public skills + print("\nSending message to trigger skills...") + conversation.send_message( + "Tell me about GitHub best practices. " + "Also, can you give me a creative greeting?" + ) + conversation.run() + + print(f"\nTotal cost: ${llm.metrics.accumulated_cost:.4f}") + print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") + + +if __name__ == "__main__": + main() +``` + + + +## Creating a Local Skill + +Local skills follow the [AgentSkills standard](https://agentskills.io/specification). Create a `SKILL.md` file: + +```markdown icon="markdown" +--- +name: greeting-helper +description: A local skill that helps generate creative greetings. +triggers: + - greeting + - hello + - salutation +--- + +# Greeting Helper Skill + +When asked for a greeting, provide creative options in different styles: + +1. **Formal**: "Good day, esteemed colleague" +2. **Casual**: "Hey there!" +3. **Enthusiastic**: "Hello, wonderful human!" +``` + +## Skill Precedence + +When combining skills, local skills take precedence over public skills with the same name: + +```python icon="python" +# Local skills override public skills with matching names +combined_skills = list(local_skills.values()) + public_skills + +seen_names = set() +unique_skills = [] +for skill in combined_skills: + if skill.name not in seen_names: + seen_names.add(skill.name) + unique_skills.append(skill) +``` + +## Next Steps + +- **[Skills Overview](/overview/skills)** - Learn more about skill types +- **[Public Skills](/sdk/guides/skill#loading-public-skills)** - Load from OpenHands extensions +- **[Custom Tools](/sdk/guides/custom-tools)** - Create specialized tools