Skip to content

feat(plugin): map client extensions under the dev.openhands namespace - #4496

Merged
enyst merged 7 commits into
mainfrom
vasco/agent-plugins-client-extensions
Sep 6, 2026
Merged

enyst merged 7 commits into
mainfrom
vasco/agent-plugins-client-extensions

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

The PR introduces dev.openhands namespace. Please let me know if you need more clarification on how it works and why we are doing that.

i found some duplicated code that we should unify in the future. I opened an issue (linked to the main issue) for that.


AGENT:

Why

Agent Plugins is an open, vendor-neutral standard. See the canonical specification and the versioned normative v1.0.0 specification. These specification links were added by an OpenHands AI agent on behalf of @enyst.

Agent Plugins deliberately keeps commands/, agents/, hooks/hooks.json and entry_command out of its portable core — they are not concepts every client shares. The standard's place for them is a client extension (spec §8): a reverse-domain namespace carrying manifest data under extensions.<ns> and files under a top-level <ns>/ directory, whose contents only the namespace owner interprets.

Until now AgentPluginsFormat read the manifest and skills and returned empty for everything else, so a ported plugin silently lost its commands, agents, hooks and entry command. This claims our namespace and reads them.

The namespace question was the stated blocker on #4452 (#4405's Open Question #2). It was settled in #4405: dev.openhands — reverse-domain form of a domain we control, with no io.openhands/com.openhands usage anywhere in the repo to conflict with.

Summary

  • Claim dev.openhands as our client-extension namespace, and read entry_command from extensions["dev.openhands"] plus commands/, agents/ and hooks/hooks.json from the top-level dev.openhands/ directory. Layout inside that directory is unchanged from the Claude Code format, so a plugin ports by moving directories rather than rewriting them.
  • Ignore namespaces we do not implement without validating their contents, as §8.1 requires — a foreign vendor's malformed extension can never fail our load.
  • Drop the plugin format's private agent-directory scanner in favour of the exported load_agents_from_dir, which it duplicated; align the commands predicate with it so the two stay symmetric.

Issue Number

Closes #4452. Sub-issue of #4405. Skills-loader duplication found on the way out: #4495.

How to Test

1. End-to-end — build a real Agent Plugins package and load it. Save as /tmp/e2e_demo.py:

import json, shutil
from pathlib import Path
from openhands.sdk.plugin.format.agent_plugins import AgentPluginsFormat

ROOT = Path("/tmp/weather-plugin"); shutil.rmtree(ROOT, ignore_errors=True); ROOT.mkdir()
(ROOT / "plugin.json").write_text(json.dumps({
    "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
    "name": "weather-plugin", "version": "1.2.0", "description": "Weather reports.",
    "extensions": {"dev.openhands": {"entry_command": "now"},
                   "com.example.client": {"anything": ["we", "never", "look"]}}}, indent=2))
(ROOT / "skills/forecast").mkdir(parents=True)
(ROOT / "skills/forecast/SKILL.md").write_text(
    "---\nname: forecast\ndescription: Multi-day forecasts.\n---\n\nForecast away.\n")

EXT = ROOT / "dev.openhands"
(EXT / "commands").mkdir(parents=True)
(EXT / "commands/now.md").write_text(
    "---\ndescription: Current conditions.\nargument-hint: <city>\n---\n\nReport now.\n")
(EXT / "agents").mkdir(parents=True)
(EXT / "agents/meteorologist.md").write_text(
    "---\nname: meteorologist\ndescription: Reads models.\n---\n\nYou read models.\n")
(EXT / "hooks").mkdir(parents=True)
(EXT / "hooks/hooks.json").write_text(json.dumps({"hooks": {"PreToolUse": [
    {"matcher": "*", "hooks": [{"type": "command", "command": "echo checking"}]}]}}))

# a foreign vendor's extension: must be ignored, contents never read
FOREIGN = ROOT / "com.example.client"
(FOREIGN / "hooks").mkdir(parents=True)
(FOREIGN / "hooks/hooks.json").write_text("{ this is not even valid json")
(FOREIGN / "agents").mkdir(parents=True)
(FOREIGN / "agents/spy.md").write_text("---\nname: spy\ndescription: x\n---\nno\n")

p = AgentPluginsFormat().load(ROOT)
print("name            :", p.manifest.name, "v" + p.manifest.version)
print("entry command   :", p.entry_slash_command)
print("skills          :", [s.name for s in p.skills])
print("agents          :", [a.name for a in p.agents])
print("commands        :", [c.name for c in p.commands])
print("hooks/PreToolUse:", len(p.hooks.pre_tool_use) if p.hooks else 0)
for s in p.get_all_skills():
    if getattr(s.trigger, "keywords", None):
        print("trigger         :", s.name, "->", s.trigger.keywords)
print("foreign agent ignored:", "spy" not in [a.name for a in p.agents])

Run uv run python /tmp/e2e_demo.py:

INFO  Loaded hooks from /tmp/weather-plugin/dev.openhands/hooks/hooks.json
name            : weather-plugin v1.2.0
entry command   : /weather-plugin:now
skills          : ['forecast']
agents          : ['meteorologist']
commands        : ['now']
hooks/PreToolUse: 1
trigger         : weather-plugin:now -> ['/weather-plugin:now']
foreign agent ignored: True

Every component resolves from the right place: forecast from the portable-core skills/, the other three from dev.openhands/, and entry_command from the manifest's extension object through to a working /weather-plugin:now trigger. The foreign com.example.client/ directory is untouched — its unparseable hooks.json never reaches a parser and its spy agent is not loaded.

2. Unit tests. uv run pytest tests/sdk/plugin/test_agent_plugins_format.py -q → 83 passed.

Full suite: uv run pytest tests/sdk -q → 5912 passed, 9 skipped, 13 xfailed.

Pre-commit on every changed file (ruff format, ruff lint, pycodestyle, pyright, import-dependency rules) → all pass.

3. The tests were mutation-checked, since a green suite proves little on its own. Each mutation was applied to the source, the suite re-run, and the source restored:

Mutation Result
Read components from the plugin root instead of dev.openhands/ 5 tests fail
Delete the non-string entry_command guard 4 tests fail
Stop mapping the namespace onto entry_command 6 tests fail
Route skills discovery through the extension dir 2 tests fail
Point the namespace at a foreign vendor's directory 2 tests fail

Video/Screenshots

Not applicable — this is a library-level loader with no UI. The terminal transcript under How to Test is the equivalent evidence.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Not registered yet, by design. AgentPluginsFormat stays out of _FORMATS, so none of this executes on a user's plugin until the mcp.json loader lands (#4451) — registering earlier would claim any directory with a root plugin.json and load it with zero MCP servers. test_not_registered_yet pins that decision.

Behaviour change to the Claude Code format. Replacing the plugin format's private agent scanner with the exported load_agents_from_dir changes agent loading slightly, in a strictly more permissive direction: Helper.MD now loads (was silently skipped by an exact .md match), and a directory named bundle.md is now skipped silently instead of emitting a misleading IsADirectoryError warning. README.md/readme.md are still skipped. I aligned the commands predicate to match so the two adjacent loaders do not diverge — that hunk is easy to drop if you would rather keep this PR strictly scoped to the Agent Plugins format.

Deliberately out of scope. Path containment for the extension directory — a symlinked dev.openhands/ pointing outside the plugin root is still followed. That is the pre-existing gap #4453 closes for every component type at once, not a regression from this change.

Open design call. ClaudeCodePluginFormat and AgentPluginsFormat now each carry three one-line forwarders that differ only in root directory. A component_root() template hook on the base would delete all six, at the cost of making load_hooks/load_agents/load_commands concrete — so a future format would silently inherit the Claude Code layout instead of being forced to declare one. I kept #4420's abstract contract, but I do not feel strongly.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:e094fe1-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-e094fe1-python \
  ghcr.io/openhands/agent-server:e094fe1-python

All tags pushed for this build

ghcr.io/openhands/agent-server:e094fe1-golang-amd64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-golang-amd64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-golang-amd64
ghcr.io/openhands/agent-server:e094fe1-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:e094fe1-golang-arm64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-golang-arm64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-golang-arm64
ghcr.io/openhands/agent-server:e094fe1-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:e094fe1-java-amd64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-java-amd64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-java-amd64
ghcr.io/openhands/agent-server:e094fe1-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:e094fe1-java-arm64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-java-arm64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-java-arm64
ghcr.io/openhands/agent-server:e094fe1-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:e094fe1-python-amd64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-python-amd64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-python-amd64
ghcr.io/openhands/agent-server:e094fe1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:e094fe1-python-arm64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-python-arm64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-python-arm64
ghcr.io/openhands/agent-server:e094fe1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:e094fe1-python-slim-amd64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-python-slim-amd64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-python-slim-amd64
ghcr.io/openhands/agent-server:e094fe1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-amd64
ghcr.io/openhands/agent-server:e094fe1-python-slim-arm64
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-python-slim-arm64
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-python-slim-arm64
ghcr.io/openhands/agent-server:e094fe1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-arm64
ghcr.io/openhands/agent-server:e094fe1-golang
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-golang
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-golang
ghcr.io/openhands/agent-server:e094fe1-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:e094fe1-java
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-java
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-java
ghcr.io/openhands/agent-server:e094fe1-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:e094fe1-python-slim
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-python-slim
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-python-slim
ghcr.io/openhands/agent-server:e094fe1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim
ghcr.io/openhands/agent-server:e094fe1-python
ghcr.io/openhands/agent-server:e094fe1a06200cc62b273dc0080d73fe7242918a-python
ghcr.io/openhands/agent-server:vasco-agent-plugins-client-extensions-python
ghcr.io/openhands/agent-server:e094fe1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., e094fe1-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., e094fe1-python-amd64) are also available if needed

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@VascoSch92
VascoSch92 marked this pull request as ready for review August 14, 2026 16:11
@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/plugin/format
   __init__.py15193%82
   base.py107793%218–219, 226–227, 247–249
TOTAL414801158172% 

Agent Plugins keeps commands, agents, hooks and entry_command out of its
portable core, so they belong in a reverse-domain client extension (spec
section 8). Claim dev.openhands and read them there: entry_command from
extensions["dev.openhands"] in the manifest, and commands/, agents/ and
hooks/hooks.json from the top-level dev.openhands/ directory. Their layout
inside that directory is unchanged, so a Claude Code plugin ports by moving
directories rather than rewriting them.

Namespaces we do not implement are ignored without validating their
contents, as the spec requires.

Extracting the shared readers surfaced that the plugin format carried a
private copy of the exported load_agents_from_dir; drop it in favour of the
canonical loader, which additionally skips directories, matches .md
case-insensitively and skips both README spellings. The commands predicate
is aligned with it so the two stay symmetric. Skills discovery has a similar
duplicate that is not substitutable today; filed as #4495.

AgentPluginsFormat stays out of _FORMATS until the mcp.json loader lands.
@VascoSch92
VascoSch92 force-pushed the vasco/agent-plugins-client-extensions branch from 996c839 to 9e269ae Compare August 14, 2026 16:16
@VascoSch92
VascoSch92 marked this pull request as draft August 14, 2026 16:16
@VascoSch92
VascoSch92 marked this pull request as ready for review August 14, 2026 16:18
@enyst

enyst commented Aug 14, 2026

Copy link
Copy Markdown
Member

@OpenHands “Agent Plugins” is a spec initiated by OpenAI recently together with some other companies. Please find it, find the spec on the internet and link it in the PR description, as well as in the linked issues, unless it’s there already, so we know what we’re looking at. This PR is part of implementing it.

Then take a deep breath, and /codereview this PR. Post your feedback as a review with gh api.

@ohe-internal-stable

Copy link
Copy Markdown

@enyst it looks like you haven't created an OpenHands account yet. Please sign up at OpenHands Cloud and try again.

@ohe-internal-unstable

Copy link
Copy Markdown

@enyst it looks like you haven't created an OpenHands account yet. Please sign up at OpenHands Cloud and try again.

@ohe-internal-beta

Copy link
Copy Markdown

@enyst it looks like you haven't created an OpenHands account yet. Please sign up at OpenHands Cloud and try again.

@openhands-ai

openhands-ai Bot commented Aug 14, 2026

Copy link
Copy Markdown

I'm on it! enyst can track my progress at all-hands.dev

@all-hands-bot

all-hands-bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@VascoSch92

Copy link
Copy Markdown
Member Author

@enyst what do you think about this? :-)

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@VascoSch92

Copy link
Copy Markdown
Member Author

Hey @jpshackelford and @enyst :-) Can I have a review on this?

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Endpoint audit

⚠️ 18 actionable Agent Server contract divergence(s) · report-only

Contract: pinned release artifact

Category Count
Actionable client-only calls 0
Actionable server-only operations 18
Documented non-divergences 13
Agent Server contract operations 129
Audited handwritten client endpoints 119

Actionable client-only calls (0)

none

Actionable server-only operations (18)

  • DELETE /api/canvas-extensions/installed/{}
  • DELETE /api/llm/provider-connections/{}
  • GET /api/canvas-extensions/installed
  • GET /api/canvas-extensions/installed/{}
  • GET /api/canvas-extensions/installed/{}/bundle
  • GET /api/conversations/{}/events
  • GET /api/file/archive
  • GET /api/git/commits
  • GET /api/git/commits/{}/changes
  • GET /api/init
  • GET /api/llm/provider-connections
  • PATCH /api/canvas-extensions/installed/{}
  • PATCH /api/llm/provider-connections/{}
  • POST /api/canvas-extensions/install
  • POST /api/conversations/{}/load_plugin
  • POST /api/file/create_directory
  • POST /api/init
  • POST /api/llm/provider-connections
Documented non-divergences (13)

Client calls intentionally absent from the filtered contract (11)

  • GET /
  • GET /alive
  • GET /health
  • GET /ready
  • GET /server_info

Reason: Operational Agent Server endpoints intentionally excluded from the filtered public release artifact.
Owner: OpenHands runtime maintainers

  • DELETE /api/meta-profiles/{}
  • GET /api/meta-profiles
  • GET /api/meta-profiles/{}
  • POST /api/meta-profiles/{}
  • POST /api/meta-profiles/{}/activate

Reason: Client-ahead API stacked on the pending Agent Server meta-profiles implementation.
Owner: OpenHands SDK maintainers
Tracking: #3744

  • POST /api/profiles/{}/validate

Reason: Client-ahead API stacked on the pending Agent Server pre-flight LLM validation endpoint.
Owner: OpenHands TypeScript client maintainers
Tracking: #4422

Server operations covered by an exposed browser URL (2)

  • GET /api/conversations/{}/workspace
  • GET /api/conversations/{}/workspace/{}

Reason: RemoteWorkspace.startWorkspaceSession exposes these authenticated URLs for browser iframe and file requests; they are not HttpClient method calls.
Owner: OpenHands TypeScript client maintainers

@VascoSch92

Copy link
Copy Markdown
Member Author

Hey @jpshackelford and @enyst a gently reminder :-)

@enyst
enyst merged commit aa84db8 into main Sep 6, 2026
52 of 53 checks passed
@enyst
enyst deleted the vasco/agent-plugins-client-extensions branch September 6, 2026 22:53
@enyst

enyst commented Sep 6, 2026

Copy link
Copy Markdown
Member

I’m so sorry about dropping the ball on this! Thank you!

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.

Agent Plugins: client extension namespace mapping

3 participants