Skip to content
Open
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
1 change: 1 addition & 0 deletions plugins/hello-world-automation/.claude-plugin
1 change: 1 addition & 0 deletions plugins/hello-world-automation/.codex-plugin
42 changes: 42 additions & 0 deletions plugins/hello-world-automation/.plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "hello-world-automation",
"version": "0.1.0",
"description": "Example automation plugin that runs a deterministic script entrypoint with no required agent profile.",
"author": {
"name": "OpenHands",
"email": "contact@all-hands.dev"
},
"homepage": "https://github.com/OpenHands/extensions",
"repository": "https://github.com/OpenHands/extensions",
"license": "MIT",
"keywords": [
"automation",
"example",
"hello-world"
],
"automation": {
"schema_version": "openhands.automation.plugin.v1",
"entrypoint": {
"runner": "script",
"command": "python3 scripts/main.py"
},
"runtime": {
"plugins": []
},
"config_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"message": {
"type": "string",
"default": "Hello from the automation plugin"
}
}
},
"suggested_trigger": {
"type": "cron",
"schedule": "0 9 * * *",
"timezone": "UTC"
}
}
}
45 changes: 45 additions & 0 deletions plugins/hello-world-automation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Hello World Automation Plugin

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:

```json
"automation": {
"schema_version": "openhands.automation.plugin.v1",
"entrypoint": {
"runner": "script",
"command": "python3 scripts/main.py"
},
"suggested_trigger": {
"type": "cron",
"schedule": "0 9 * * *",
"timezone": "UTC"
}
}
```

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.

### 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.
11 changes: 11 additions & 0 deletions plugins/hello-world-automation/commands/hello.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
description: Say hello from the automation stock runner
---

# Hello World Automation

Print a short hello-world response for the automation run.

If event context is present in the task, mention that the run received event context. Otherwise, respond with: Hello from the automation plugin.

$ARGUMENTS
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)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AUTOMATION_EVENT_PAYLOAD does not contain a type or event_type key, so this always resolves to "(unknown)" on an event-triggered run.

The automation service builds that payload in OpenHands/automation (openhands/automation/dispatcher.py, _build_event_payload) with exactly these top-level keys: trigger (the trigger type string, e.g. "event"), trigger_payload (the full trigger config), automation_id, automation_name, and event (the provider's raw webhook body, e.g. a GitHub payload whose keys are action, issue, comment, repository, sender). There is no type/event_type field at any level, so the branch that prints "responding with the standard greeting plus event notice" is dead code and the demo silently misreports the run.

Suggested fix, matching how the payload is actually shaped:

payload = json.loads(event_payload)
trigger = payload.get("trigger", "(unknown)")
has_event = bool(payload.get("event"))
print(f"  event_context: {trigger}{' + event payload' if has_event else ''}")

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