Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ Official skills and plugins for OpenHands — the open-source AI software engine
| github-stale-ci-pr-closer | skill | Warn and close abandoned pull requests whose required CI remains failing. | `/github-stale-ci-pr-closer` |
| gitlab | skill | Interact with GitLab repositories, merge requests, and APIs using the GITLAB_TOKEN environment variable. Use when wor... | — |
| gitlab-issue-to-mr | skill | Create an automation that implements GitLab issues when a configurable trigger label is applied. Polls the projects d... | `/issue-to-mr:setup` |
| hello-world-automation | plugin | Example automation plugin that runs a plugin command through the stock conversation runner. | — |
| hello-world-automation | plugin | Example automation plugin that runs a deterministic script entrypoint with no required agent profile. | — |
| incident-retrospective | skill | Create an automation that drafts incident retrospectives by gathering incident-channel messages from Slack, collectin... | `/incident-retro:setup` |
| iterate | skill | Iterate on a GitHub pull request — drive it through CI, code review, and QA until merge-ready. Monitors state, fixes ... | `/iterate`, `/verify`, `/babysit` |
| jira-issue-to-pr | skill | Deploy a cron-based OpenHands automation that opens a GitHub pull request, a GitLab merge request or a Bitbucket pull... | — |
Expand Down
2 changes: 1 addition & 1 deletion marketplaces/openhands-extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
{
"name": "hello-world-automation",
"source": "./plugins/hello-world-automation",
"description": "Example automation plugin that runs a plugin command through the stock conversation runner.",
"description": "Example automation plugin that runs a deterministic script entrypoint with no required agent profile.",
"category": "automations",
"keywords": [
"automation",
Expand Down
13 changes: 3 additions & 10 deletions plugins/hello-world-automation/.plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hello-world-automation",
"version": "0.1.0",
"description": "Example automation plugin that runs a plugin command through the stock conversation runner.",
"description": "Example automation plugin that runs a deterministic script entrypoint with no required agent profile.",
"author": {
"name": "OpenHands",
"email": "contact@all-hands.dev"
Expand All @@ -17,17 +17,10 @@
"automation": {
"schema_version": "openhands.automation.plugin.v1",
"entrypoint": {
"runner": "conversation",
"command": "hello"
"runner": "script",
"command": "python3 scripts/main.py"
},
"runtime": {
"agent_profile": {
"required": true,
"default_name": "hello-world-agent",
"allowed_tools": [
"terminal"
]
},
"plugins": []
},
"config_schema": {
Expand Down
33 changes: 27 additions & 6 deletions plugins/hello-world-automation/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
# Hello World Automation Plugin

This is a minimal plugin-backed automation that uses the automation service stock runner.
This is a minimal plugin-backed **executable** automation. It uses the `script`
runner: the plugin ships a small Python entrypoint (`scripts/main.py`) that the
automation service packages and runs inside the sandbox on every trigger. The
entrypoint reads the resolved plugin config, prints a greeting, and fires the
completion callback.

The automation metadata is embedded in `.plugin/plugin.json` under the `automation` key. It points at a plugin command instead of a custom Python entrypoint:
The automation metadata is embedded in `.plugin/plugin.json` under the
`automation` key:

```json
"automation": {
"schema_version": "openhands.automation.plugin.v1",
"entrypoint": {
"runner": "conversation",
"command": "hello"
"runner": "script",
"command": "python3 scripts/main.py"
},
"suggested_trigger": {
"type": "cron",
Expand All @@ -19,6 +24,22 @@ The automation metadata is embedded in `.plugin/plugin.json` under the `automati
}
```

At run time, the automation service reads the embedded automation metadata and packages the stock SDK runner. The runner loads this same plugin as a conversation plugin and sends `/hello-world-automation:hello` as the prompt. The command prompt lives in `commands/hello.md`.
At run time, the automation service reads the embedded automation metadata,
packages the script-runner tarball (config, execution metadata, plugin list),
and the agent server runs `scripts/main.py` with `AUTOMATION_PLUGIN_ROOT`
pointing at the fetched plugin root.

The manifest also demonstrates `runtime.agent_profile.allowed_tools`: the automation service rejects an agent profile unless it explicitly enables exactly the tools the plugin allows. The same allowlist model applies to MCP server refs and secret refs when a plugin declares them.
### Why the `script` runner?

A `conversation` runner entrypoint requires a pre-provisioned
`runtime.agent_profile` (a named agent profile that must already exist in the
caller's org, and — if `allowed_tools` is set — must enable exactly those
tools). That makes a conversation-runner example impossible to create on a
fresh org without first provisioning the named profile, so a `curl` against
`POST /api/automation/v1/plugin-spec` fails with `422 Agent profile
'hello-world-agent' not found`.

The `script` runner needs no agent profile at all — it runs a deterministic
entrypoint with no conversation and no LLM. That keeps the example
self-contained: the plugin-spec `curl` succeeds and a dispatched run produces
output out of the box. See `scripts/main.py` for the entrypoint.
107 changes: 107 additions & 0 deletions plugins/hello-world-automation/scripts/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Deterministic hello-world entrypoint for the hello-world-automation plugin.

This script is the entrypoint for a plugin-backed executable automation that
uses the ``script`` runner (``entrypoint.runner == "script"``). It reads the
plugin config, prints a greeting, and fires the automation completion
callback. Because it does not start an agent conversation, it needs no
agent/LLM profile to be pre-provisioned — the automation is created and
dispatched without any ``runtime.agent_profile`` requirement.

Environment variables consumed:
AUTOMATION_CONFIG_PATH - path to the resolved plugin config JSON
AUTOMATION_CALLBACK_URL - completion callback endpoint (optional)
AUTOMATION_RUN_ID - run id for the callback payload (optional)
AUTOMATION_EVENT_PAYLOAD - JSON event payload, if event-triggered (optional)
AUTOMATION_AGENT_PROFILE_NAME / AUTOMATION_LLM_PROFILE_NAME - injected when
present; echoed for observability.
"""

from __future__ import annotations

import json
import os
import sys
import urllib.error
import urllib.request


def load_config() -> dict:
path = os.environ.get("AUTOMATION_CONFIG_PATH")
if not path or not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError) as exc:
print(f"WARNING: could not read config at {path}: {exc}", file=sys.stderr)
return {}


def fire_callback(status: str, error: str | None = None) -> None:
url = os.environ.get("AUTOMATION_CALLBACK_URL", "")
if not url:
return
body: dict[str, object] = {
"status": status,
"run_id": os.environ.get("AUTOMATION_RUN_ID", ""),
}
if error:
body["error"] = error
token = (
os.environ.get("AUTOMATION_CALLBACK_API_KEY")
or os.environ.get("OPENHANDS_API_KEY")
or ""
)
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
req = urllib.request.Request(
url, data=json.dumps(body).encode("utf-8"), headers=headers
)
urllib.request.urlopen(req, timeout=10)
except (urllib.error.URLError, OSError) as exc:
print(f"WARNING: callback failed: {exc}", file=sys.stderr)


def main() -> int:
config = load_config()
message = config.get("message") or "Hello from the automation plugin"

print("=== HELLO WORLD AUTOMATION ===")
print(f" message: {message}")
print(
" agent_profile_name: "
f"{os.environ.get('AUTOMATION_AGENT_PROFILE_NAME') or '(none)'}"
)
print(
" llm_profile_name: "
f"{os.environ.get('AUTOMATION_LLM_PROFILE_NAME') or '(none)'}"
)

event_payload = os.environ.get("AUTOMATION_EVENT_PAYLOAD", "")
if event_payload:
try:
event = json.loads(event_payload)
event_type = event.get("type") or event.get("event_type") or "(unknown)"
print(f" event_context: {event_type}")
print(" (responding with the standard greeting plus event notice)")
except json.JSONDecodeError:
print(" event_context: (unparseable)")
else:
print(" event_context: (none)")

print(f"\n{message} \U0001f44b")
return 0


if __name__ == "__main__":
try:
code = main()
except Exception as exc: # pragma: no cover - defensive top-level guard
fire_callback("FAILED", str(exc))
raise
fire_callback("COMPLETED" if code == 0 else "FAILED")
sys.exit(code)
Loading