Skip to content

docs(sdk): document local plugin install example#367

Open
enyst wants to merge 2 commits intomainfrom
openhands/plugin-install-utils
Open

docs(sdk): document local plugin install example#367
enyst wants to merge 2 commits intomainfrom
openhands/plugin-install-utils

Conversation

@enyst
Copy link
Collaborator

@enyst enyst commented Mar 2, 2026

Documents the new example introduced in OpenHands/software-agent-sdk#2031 (examples/05_skills_and_plugins/03_local_plugin_install/main.py) by extending the Plugins guide.

This branch name intentionally matches the agent-sdk PR branch so the agent-sdk CI workflow '[Optional] Docs example' can checkout the docs changes.

Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Contributor

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taste Rating: 🟡 Acceptable - Works but violates focused documentation principles

The fundamental issue: this example is a comprehensive integration test masquerading as a user tutorial. It demonstrates install, list, load, get, update, uninstall, AND a full conversation with persistence - all in 165 lines. That's not documentation, that's a feature test.

Verdict: ✅ Worth merging with improvements - The code is correct and follows sync patterns, but needs focused examples and explanatory prose to actually help users.

Key Insight: Documentation examples should demonstrate one thing well, not everything at once.


Installed plugins are copied into an installation root (defaults to `~/.openhands/plugins/installed/`) and tracked in an `.installed.json` metadata file.

This is intended as a building block for higher-level workflows (for example, a future `/plugin install ...` CLI command).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Introduction is too sparse compared to other sections in this file.

Other examples (e.g., "Loading Plugins") include more context about when to use the feature, what problem it solves, and how it fits into the larger ecosystem.

Recommend adding 2-3 paragraphs explaining:

  • When developers should use these utilities vs higher-level APIs
  • The typical workflow (install → load → use)
  • How this relates to the Conversation plugins parameter shown earlier

Comment on lines +298 to +464
```python icon="python" expandable examples/05_skills_and_plugins/03_local_plugin_install/main.py
"""Example: Install + manage a local plugin (no external LLM required).

This example demonstrates the *installed plugins* utilities introduced in this PR.

Key ideas:
- Installed plugin packages live under `~/.openhands/plugins/installed/` by default.
- Each plugin is a self-contained directory that can include `skills/`, `agents/`,
`hooks/`, `.mcp.json`, etc. (Claude Code style).

By default this example uses a temporary directory and leaves no artifacts.

To write artifacts to disk (useful for PR review), set:

export OPENHANDS_EXAMPLE_ARTIFACT_DIR=.pr/local_plugin_install_test

Then run:

uv run python examples/05_skills_and_plugins/03_local_plugin_install/main.py

This will create (and overwrite) `plugin_src/`, `installed_root/`, and
`persistence/` under the artifact directory.
"""

from __future__ import annotations

import json
import os
import shutil
import tempfile
import uuid
from contextlib import ExitStack
from pathlib import Path

from openhands.sdk import Agent, Conversation
from openhands.sdk.llm import Message, TextContent
from openhands.sdk.plugin import (
get_installed_plugin,
install_plugin,
list_installed_plugins,
load_installed_plugins,
uninstall_plugin,
update_plugin,
)
from openhands.sdk.testing import TestLLM


def _write_example_plugin(plugin_dir: Path, *, version: str) -> None:
(plugin_dir / ".plugin").mkdir(parents=True, exist_ok=True)
(plugin_dir / ".plugin" / "plugin.json").write_text(
json.dumps(
{
"name": "local-plugin",
"version": version,
"description": "Example local plugin",
}
)
)

skill_dir = plugin_dir / "skills" / "hello"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: hello
description: Say hello
triggers:
- hello
---

Reply with a short greeting.
"""
)


artifact_dir = os.getenv("OPENHANDS_EXAMPLE_ARTIFACT_DIR")

with ExitStack() as stack:
if artifact_dir:
root = Path(artifact_dir).resolve()
root.mkdir(parents=True, exist_ok=True)

for subdir in ("plugin_src", "installed_root", "persistence"):
shutil.rmtree(root / subdir, ignore_errors=True)
else:
tmp_dir = stack.enter_context(tempfile.TemporaryDirectory())
root = Path(tmp_dir)

# Create a local plugin directory (this simulates a repo checkout).
plugin_source_dir = root / "plugin_src" / "local-plugin"
_write_example_plugin(plugin_source_dir, version="1.0.0")

# Install into a dedicated root (avoids touching real ~/.openhands/).
installed_dir = root / "installed_root" / "plugins" / "installed"

info = install_plugin(source=str(plugin_source_dir), installed_dir=installed_dir)
print(f"Installed: {info.name} v{info.version} from {info.source}")

print("\nList installed plugins:")
for item in list_installed_plugins(installed_dir=installed_dir):
print(f"- {item.name} v{item.version} ({item.source})")

print("\nLoad installed plugins:")
plugins = load_installed_plugins(installed_dir=installed_dir)
for plugin in plugins:
print(f"- {plugin.name}: {len(plugin.get_all_skills())} skill(s)")

print("\nGet installed plugin:")
print(get_installed_plugin("local-plugin", installed_dir=installed_dir))

# Smoke test: run a minimal Conversation with persistence enabled.
#
# We use TestLLM so this runs without external services. The plugin skill
# is triggered by the user message "hello".
agent = Agent(
llm=TestLLM.from_messages(
[Message(role="assistant", content=[TextContent(text="Done")])]
),
tools=[],
)

merged_context = agent.agent_context
for plugin in plugins:
merged_context = plugin.add_skills_to(merged_context)
agent = agent.model_copy(update={"agent_context": merged_context})

persistence_dir = root / "persistence"
conversation_id = (
uuid.UUID("00000000-0000-0000-0000-000000000203")
if artifact_dir
else uuid.uuid4()
)
conversation = Conversation(
agent=agent,
workspace=str(root),
persistence_dir=persistence_dir,
conversation_id=conversation_id,
visualizer=None,
)
conversation.send_message("hello")
conversation.run()

print(f"\nActivated skills: {conversation.state.activated_knowledge_skills}")
print(f"Wrote persistence to: {conversation.state.persistence_dir}")

# Don't leave transient lock files in persisted artifacts.
if conversation.state.persistence_dir:
lockfile = (
Path(conversation.state.persistence_dir) / "events" / ".eventlog.lock"
)
lockfile.unlink(missing_ok=True)

# Update: mutate the local plugin source and call update_plugin(), which
# reinstalls from the original source with ref=None (latest).
_write_example_plugin(plugin_source_dir, version="1.0.1")
updated = update_plugin("local-plugin", installed_dir=installed_dir)
assert updated is not None
print(f"\nUpdated: {updated.name} v{updated.version}")

if artifact_dir:
print("\nSkipping uninstall (artifact mode)")
else:
uninstall_plugin("local-plugin", installed_dir=installed_dir)
print("\nAfter uninstall:")
print(list_installed_plugins(installed_dir=installed_dir))

print("EXAMPLE_COST: 0")
```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: Example violates single-responsibility principle.

This 165-line example demonstrates: install, list, load, get, update, uninstall, run conversation, handle persistence, and cleanup lock files. That's at least 3-4 separate concepts crammed into one example.

Better approach:

  1. Simple example (30-40 lines): Install a plugin, list installed plugins, load and use them
  2. Optional advanced example: Update/uninstall operations
  3. Remove artifact mode complexity (lines 372-383) - that's meta-documentation about running examples, not about the feature itself
  4. Remove conversation smoke test (lines 407-441) - users already learned about Conversation in earlier sections

The "Loading Plugins" example earlier in this file is ~80 lines and does ONE thing clearly. Follow that pattern.

Comment on lines +372 to +383
artifact_dir = os.getenv("OPENHANDS_EXAMPLE_ARTIFACT_DIR")

with ExitStack() as stack:
if artifact_dir:
root = Path(artifact_dir).resolve()
root.mkdir(parents=True, exist_ok=True)

for subdir in ("plugin_src", "installed_root", "persistence"):
shutil.rmtree(root / subdir, ignore_errors=True)
else:
tmp_dir = stack.enter_context(tempfile.TemporaryDirectory())
root = Path(tmp_dir)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Artifact mode adds meta-complexity that distracts from the actual feature.

This OPENHANDS_EXAMPLE_ARTIFACT_DIR handling is about how to run the example for PR review, not about how to use plugin installation utilities. It adds 12 lines of conditional logic and cognitive overhead for users trying to learn the API.

Users don't care about your CI/review workflow - they want to learn install_plugin().

Either move this to a separate testing script or use a simpler approach that doesn't leak into the user-facing example.

Comment on lines +407 to +441
# Smoke test: run a minimal Conversation with persistence enabled.
#
# We use TestLLM so this runs without external services. The plugin skill
# is triggered by the user message "hello".
agent = Agent(
llm=TestLLM.from_messages(
[Message(role="assistant", content=[TextContent(text="Done")])]
),
tools=[],
)

merged_context = agent.agent_context
for plugin in plugins:
merged_context = plugin.add_skills_to(merged_context)
agent = agent.model_copy(update={"agent_context": merged_context})

persistence_dir = root / "persistence"
conversation_id = (
uuid.UUID("00000000-0000-0000-0000-000000000203")
if artifact_dir
else uuid.uuid4()
)
conversation = Conversation(
agent=agent,
workspace=str(root),
persistence_dir=persistence_dir,
conversation_id=conversation_id,
visualizer=None,
)
conversation.send_message("hello")
conversation.run()

print(f"\nActivated skills: {conversation.state.activated_knowledge_skills}")
print(f"Wrote persistence to: {conversation.state.persistence_dir}")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Conversation smoke test is feature creep.

Users already learned about Conversation, Agent, and persistence in earlier guides. This example should focus on the plugin management utilities (install_plugin, list_installed_plugins, etc.).

These 35 lines add:

  • Agent creation with TestLLM
  • Merging plugin context
  • Running a conversation
  • Persistence handling
  • Lock file cleanup

All of which are separate concerns from "how to install and manage plugins locally."

If you want to show loading installed plugins into a Conversation, make it 5 lines:

plugins = load_installed_plugins(installed_dir=installed_dir)
conversation = Conversation(agent=agent, workspace=tmpdir)
# Plugins are now available

- Update Ready-to-run Example with current 02_loading_plugins/main.py code
- Add succinct 'Installing Plugins to Persistent Storage' section
- Remove reference to non-existent 03_local_plugin_install example

Co-authored-by: openhands <openhands@all-hands.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants