enterprise/doc/design-doc/plugin-launch-flow.md
This document traces the complete data flow for launching plugins in OpenHands, from the source marketplace through to agent execution. Each section shows the exact endpoints, payloads, and transformations.
Marketplace ──▶ Plugin Directory ──▶ Frontend /launch ──▶ App Server ──▶ Agent Server ──▶ SDK
(GitHub) (Index + UI) (Modal) (API) (in sandbox) (plugin loading)
| Component | Responsibility |
|---|---|
| Marketplace | Source of truth for plugin catalog (GitHub repo) |
| Plugin Directory | Index plugins from marketplace, serve browsing UI, construct launch URLs |
| Frontend | Display confirmation modal, collect parameters, call API |
| App Server | Validate request, create conversation, pass plugin specs to agent server |
| Agent Server | Run inside sandbox, delegate plugin loading to SDK |
| SDK | Fetch plugins, load contents, merge skills/hooks/MCP into agent |
Source: A GitHub repository (e.g., github.com/OpenHands/plugin-marketplace)
The marketplace is a GitHub repository containing a marketplace.json that indexes all available plugins.
{
"name": "OpenHands Plugin Marketplace",
"owner": {
"name": "OpenHands",
"email": "[email protected]"
},
"metadata": {
"description": "Official OpenHands plugin marketplace",
"pluginRoot": "plugins"
},
"plugins": [
{
"name": "city-weather",
"source": "github:jpshackelford/openhands-sample-plugins",
"ref": "main",
"repo_path": "plugins/city-weather",
"description": "Get current weather for any city",
"tags": ["weather", "utility"]
}
]
}
plugin.json)Each plugin has a plugin.json in its .claude-plugin/ directory. This file contains both official plugin manifest fields and optional directory-specific config fields:
{
"name": "city-weather",
"description": "Get current weather for any city",
"entry_command": "now",
"parameters": {
"city": {
"type": "string",
"description": "City name",
"required": true,
"default": "San Francisco"
}
},
"examples": [
{
"title": "Check Tokyo weather",
"prompt": "/city-weather:now Tokyo"
}
]
}
Output to Plugin Directory: marketplace.json + individual plugin.json files
Endpoints:
GET /api/plugins - List all pluginsGET /api/plugins/{id} - Get plugin detailsGET /api/plugins/{id}/config - Get plugin config (entry_command, parameters, examples)Fetches and transforms the marketplace catalog.
Request: None (fetches from configured MARKETPLACE_SOURCE)
Response:
{
"plugins": [
{
"id": "city-weather",
"name": "city-weather",
"description": "Get current weather for any city",
"source": {
"source": "github",
"repo": "jpshackelford/openhands-sample-plugins",
"ref": "main",
"repo_path": "plugins/city-weather"
},
"tags": ["weather", "utility"]
}
]
}
Fetches and returns the config fields from plugin.json.
Request: GET /api/plugins/city-weather/config
Response (200 OK):
{
"entry_command": "now",
"parameters": {
"city": {
"type": "string",
"description": "City name",
"required": true,
"default": "San Francisco"
}
},
"examples": [
{
"title": "Check Tokyo weather",
"prompt": "/city-weather:now Tokyo"
}
]
}
Output to Plugin Directory Client: Plugin metadata + config
When user clicks "Launch", the client constructs a launch URL using buildLaunchUrl().
From Plugin Directory Server APIs:
/api/plugins/{id}):
{
"name": "city-weather",
"source": {
"source": "github",
"repo": "jpshackelford/openhands-sample-plugins",
"ref": "main",
"repo_path": "plugins/city-weather"
}
}
/api/plugins/{id}/config):
{
"entry_command": "now",
"parameters": {
"city": { "type": "string", "required": true, "default": "San Francisco" }
}
}
Build PluginSpec from plugin source:
source: Convert to string format "github:owner/repo"ref: Extract git ref if presentrepo_path: Extract subdirectory path if presentparameters: Extract default values from parameter definitionsBuild message using buildEntrySlashCommand(pluginName, entryCommand):
"/city-weather:now"Encode and construct URL:
plugins query parammessage query paramLaunch URL:
https://app.openhands.ai/launch?plugins=BASE64&message=%2Fcity-weather%3Anow
Where plugins (base64-decoded) contains:
[{
"source": "github:jpshackelford/openhands-sample-plugins",
"ref": "main",
"repo_path": "plugins/city-weather",
"parameters": {
"city": "San Francisco"
}
}]
And message (URL-decoded) is:
/city-weather:now
Key point: The parameters in the PluginSpec contain default values for pre-filling the launch modal form. The message contains only the slash command—the Frontend passes it through unchanged, and the App Server appends the parameter values as a formatted text block.
/launch Route)Route: /launch?plugins=BASE64&message=/city-weather:now
plugins: Base64-encoded JSON array of PluginSpecmessage: Pre-filled slash command (no parameter values)Decoded:
{
"plugins": [{
"source": "github:jpshackelford/openhands-sample-plugins",
"ref": "main",
"repo_path": "plugins/city-weather",
"parameters": { "city": "San Francisco" }
}],
"message": "/city-weather:now"
}
The frontend displays a confirmation modal:
plugins[].parameters:
city, pre-filled with "San Francisco"/city-weather:nowWhen user clicks "Start Conversation":
Collect final parameter values from form inputs:
city from "San Francisco" to "Tokyo"Update PluginSpec parameters with user's values:
"parameters": { "city": "Tokyo" }
Pass message through unchanged:
/city-weather:now is NOT modified by the Frontendplugins[].parameters, not in the messagePOST /api/v1/app-conversations
Content-Type: application/json
Authorization: Bearer <user_token>
{
"plugins": [{
"source": "github:jpshackelford/openhands-sample-plugins",
"ref": "main",
"repo_path": "plugins/city-weather",
"parameters": {
"city": "Tokyo"
}
}],
"initial_message": {
"role": "user",
"content": [{"type": "text", "text": "/city-weather:now"}]
}
}
Summary of transformations:
| Field | Input (from URL) | Output (to API) |
|---|---|---|
plugins[].parameters | Default values ("San Francisco") | User's values ("Tokyo") |
initial_message.text | Slash command (/city-weather:now) | Slash command unchanged (/city-weather:now) |
Note: The Frontend does NOT append parameter values to the message. Parameters are passed as structured data in plugins[].parameters. The App Server will append them to the message text (see Step 5).
Endpoint: POST /api/v1/app-conversations
{
"plugins": [{
"source": "github:jpshackelford/openhands-sample-plugins",
"ref": "main",
"repo_path": "plugins/city-weather",
"parameters": { "city": "Tokyo" }
}],
"initial_message": {
"role": "user",
"content": [{"type": "text", "text": "/city-weather:now"}]
}
}
Note: The initial_message.text contains only the slash command—parameter values come separately in plugins[].parameters.
class PluginSpec(PluginSource):
"""Extends SDK's PluginSource with user-provided parameters."""
parameters: dict[str, Any] | None = None # User-provided values
class AppConversationStartRequest(BaseModel):
plugins: list[PluginSpec] | None = None
initial_message: SendMessageRequest | None = None
# ... other fields
Call stack in LiveStatusAppConversationService:
_construct_initial_message_with_plugin_params() - Appends parameters to message:
# Original message: "/city-weather:now"
# Parameters: {"city": "Tokyo"}
# Result: "/city-weather:now\n\nPlugin Configuration Parameters:\n- city: Tokyo"
Convert PluginSpec → SDK PluginSource (parameters are DROPPED):
sdk_plugins = [
PluginSource(
source=p.source, # "github:jpshackelford/openhands-sample-plugins"
ref=p.ref, # "main"
repo_path=p.repo_path # "plugins/city-weather"
)
# NOTE: p.parameters is NOT passed to SDK PluginSource!
for p in plugins
]
Create StartConversationRequest for agent server
StartConversationRequest(
plugins=[
PluginSource(
source="github:jpshackelford/openhands-sample-plugins",
ref="main",
repo_path="plugins/city-weather"
# NO parameters field - SDK PluginSource doesn't have it
)
],
initial_message=SendMessageRequest(
content=[
TextContent(
text="/city-weather:now\n\nPlugin Configuration Parameters:\n- city: Tokyo"
)
]
),
# ... other fields
)
⚠️ CRITICAL: Plugin parameters are passed to the agent via message text, not via the PluginSource object. The SDK's PluginSource class only has source, ref, and repo_path fields.
Note on message construction: The original slash command /city-weather:now does NOT include the parameter value "Tokyo" inline. The parameter appears only in the formatted "Plugin Configuration Parameters" block appended by the App Server.
Entry point: ConversationService.start_conversation()
StartConversationRequest)StartConversationRequest(
plugins=[
PluginSource(
source="github:jpshackelford/openhands-sample-plugins",
ref="main",
repo_path="plugins/city-weather"
)
],
initial_message=SendMessageRequest(
content=[
TextContent(
text="/city-weather:now\n\nPlugin Configuration Parameters:\n- city: Tokyo"
)
]
)
)
Call stack:
ConversationService.start_conversation(request) receives StartConversationRequestStoredConversation with plugin specs persistedLocalConversation(plugins=request.plugins, ...)run() or send_message()LocalConversation)LocalConversation(
agent=agent,
plugins=[PluginSource(...)], # Stored, not yet loaded
workspace=workspace,
# initial_message queued for processing
)
Trigger: First conversation.run() or conversation.send_message()
PluginSource list)[
PluginSource(
source="github:jpshackelford/openhands-sample-plugins",
ref="main",
repo_path="plugins/city-weather"
)
]
Call stack:
LocalConversation._ensure_plugins_loaded() triggeredPluginSource:
Plugin.fetch(source, ref, repo_path) → clones/caches git repoPlugin.load(path) → parses plugin.json, loads commands/skills/hooksplugin.add_skills_to(skill_context) → merges skills into agentplugin.add_mcp_config_to(mcp_config) → merges MCP serversPlugin object)Plugin(
name="city-weather",
path="/tmp/plugins/city-weather",
manifest=PluginManifest(
name="city-weather",
entry_command="now", # Read from plugin.json
commands={"now": Command(...)},
skills=[Skill(...)],
hooks={...},
mcp_servers={...}
)
# NOTE: No parameters field - parameters are in the message text
)
The agent now has:
/city-weather:now command as a skill)/city-weather:now
Plugin Configuration Parameters:
- city: Tokyo
When the agent processes the message:
/city-weather:now as a slash command (keyword trigger)KeywordTrigger activates the command skillcity=TokyoNote: Parameters are NOT passed as structured data to the plugin. The agent reads them from the message text in the formatted "Plugin Configuration Parameters" block appended by the App Server.
| Step | Component | Input | Output |
|---|---|---|---|
| 1 | Marketplace | - | marketplace.json + plugin.json files |
| 2 | Plugin Directory Server | Marketplace files | REST API responses with entry_command, parameters |
| 3 | Plugin Directory Client | Plugin + Config | Launch URL: plugins (with defaults) + message (slash command only) |
| 4 | OpenHands Frontend | URL query params | API call: plugins (with user values) + message (unchanged slash command) |
| 5 | App Server | API request | StartConversationRequest: PluginSource (no params) + message (params in text) |
| 6 | Agent Server | StartConversationRequest | LocalConversation with deferred plugins |
| 7 | SDK | PluginSource list | Loaded Plugin objects with skills/hooks/MCP |
| 8 | Agent | Initial message with params in text | Command execution |
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Plugin Directory │ │ OpenHands Frontend │ │ App Server │
│ │ │ │ │ │
│ plugins[].params │────▶│ plugins[].params │────▶│ Appends params to │
│ = defaults │ │ = user values │ │ message as text │
│ │ │ (from form edit) │ │ block, then DROPS │
│ │ │ │ │ from PluginSource │
│ message = │ │ message = │ │ │
│ /cmd:entry │────▶│ /cmd:entry │────▶│ Final message: │
│ (no values) │ │ (unchanged!) │ │ /cmd:entry │
│ │ │ │ │ + params block │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
Key insight: The Frontend does NOT modify the message. It passes the slash command through unchanged and sends parameters as structured data in plugins[].parameters. The App Server is responsible for formatting parameters into the message text.
Plugins load inside the sandbox because:
The entry_command field contains only the command name (e.g., "now"), not the full slash command. This separation allows:
Parameters travel through the system as structured data until the App Server, where they are converted to text:
Structured data path (PluginSpec.parameters):
PluginSource)Message path:
/city-weather:now)The SDK's PluginSource class intentionally does NOT have a parameters field. All parameter context is communicated to the agent via the initial message text, specifically in the "Plugin Configuration Parameters" block appended by the App Server.
/launch route