@openhands/extensions 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +24 -0
- package/.codex-plugin/marketplace.json +24 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +24 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +9 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +15 -15
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +24 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/README.md +5 -4
- package/skills/github-pr-reviewer/SKILL.md +8 -17
- package/skills/github-pr-reviewer/references/state-schema.md +0 -15
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/scripts/worker.py +131 -108
- package/skills/github-repo-monitor/scripts/github_client.py +67 -15
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +23 -14
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_reviewer_delivery.py +254 -118
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- package/tests/vscode-app.test.mjs +43 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set up the Jira Cloud webhook of an event-based Jira automation.
|
|
3
|
+
|
|
4
|
+
Registers a custom webhook source in OpenHands and the matching admin webhook in
|
|
5
|
+
Jira Cloud. The two share a signing secret that is generated or rotated here and is
|
|
6
|
+
never printed, so it does not enter the conversation. A webhook that already exists
|
|
7
|
+
on both sides is updated in place and keeps its secret.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 setup_webhook.py apply --jira-base-url URL --jira-email EMAIL --openhands-host HOST
|
|
11
|
+
python3 setup_webhook.py delete --jira-base-url URL --jira-email EMAIL --openhands-host HOST
|
|
12
|
+
|
|
13
|
+
--jira-token-env NAME environment variable holding the Jira API token (default JIRA_CLOUD_KEY)
|
|
14
|
+
--source NAME OpenHands webhook source (default "jira")
|
|
15
|
+
--label LABEL Jira label the webhook is limited to (default "create-pr")
|
|
16
|
+
--jql JQL JQL filter to use instead of the label
|
|
17
|
+
--name NAME name of the webhook in Jira
|
|
18
|
+
--dry-run apply only: report what would change, change nothing
|
|
19
|
+
--rotate-secret apply only: give an existing pair a new signing secret; without
|
|
20
|
+
it a webhook that exists on both sides keeps the one it has
|
|
21
|
+
|
|
22
|
+
Environment:
|
|
23
|
+
OPENHANDS_API_KEY API key for the OpenHands deployment
|
|
24
|
+
<--jira-token-env> API token of an account with the Administer Jira permission
|
|
25
|
+
|
|
26
|
+
Exit codes: 0 done, 1 failed, 2 bad arguments or environment, 3 the Jira account is
|
|
27
|
+
not an administrator (set the webhook up by hand instead).
|
|
28
|
+
"""
|
|
29
|
+
import argparse, base64, hashlib, hmac, json, os, secrets, sys, urllib.error, urllib.parse, urllib.request
|
|
30
|
+
|
|
31
|
+
JIRA_EVENTS = ["jira:issue_created", "jira:issue_updated"]
|
|
32
|
+
# What Jira Cloud sends: the event name in `webhookEvent`, the signature in
|
|
33
|
+
# `X-Hub-Signature` as "sha256=<hex HMAC-SHA256 of the body>".
|
|
34
|
+
OPENHANDS_WEBHOOK = {
|
|
35
|
+
"event_key_expr": "webhookEvent",
|
|
36
|
+
"signature_header": "X-Hub-Signature",
|
|
37
|
+
"signature_scheme": "hmac_sha256_hex",
|
|
38
|
+
}
|
|
39
|
+
# No automation listens for this event name, so the signed check starts no run.
|
|
40
|
+
CHECK_EVENT = "openhands:setup_check"
|
|
41
|
+
|
|
42
|
+
_secret = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Failure(Exception):
|
|
46
|
+
def __init__(self, message, code=1):
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.code = code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _redact(text):
|
|
52
|
+
return text.replace(_secret, "***") if _secret else text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _call(method, url, headers, body=None):
|
|
56
|
+
"""Return (status, parsed JSON or text). An HTTP error status is returned, not raised."""
|
|
57
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
58
|
+
if data is not None:
|
|
59
|
+
headers = {**headers, "Content-Type": "application/json"}
|
|
60
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
61
|
+
try:
|
|
62
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
63
|
+
status, raw = resp.status, resp.read()
|
|
64
|
+
except urllib.error.HTTPError as exc:
|
|
65
|
+
status, raw = exc.code, exc.read()
|
|
66
|
+
except urllib.error.URLError as exc:
|
|
67
|
+
raise Failure(f"{method} {url} failed: {exc.reason}") from exc
|
|
68
|
+
text = raw.decode("utf-8", errors="replace")
|
|
69
|
+
try:
|
|
70
|
+
return status, json.loads(text) if text.strip() else {}
|
|
71
|
+
except ValueError:
|
|
72
|
+
return status, text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _expect(what, status, body, ok=(200, 201, 204)):
|
|
76
|
+
if status not in ok:
|
|
77
|
+
raise Failure(f"{what} failed: HTTP {status} {_redact(str(body))[:300]}")
|
|
78
|
+
return body
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OpenHands:
|
|
82
|
+
def __init__(self, host, api_key, source):
|
|
83
|
+
self.base = host.rstrip("/") + "/api/automation/v1/webhooks"
|
|
84
|
+
self.headers = {"Authorization": f"Bearer {api_key}"}
|
|
85
|
+
self.source = source
|
|
86
|
+
|
|
87
|
+
def find(self):
|
|
88
|
+
status, body = _call("GET", f"{self.base}?limit=100", self.headers)
|
|
89
|
+
webhooks = _expect("Listing OpenHands webhooks", status, body).get("webhooks", [])
|
|
90
|
+
return next((w for w in webhooks if w.get("source") == self.source), None)
|
|
91
|
+
|
|
92
|
+
def create(self, secret):
|
|
93
|
+
payload = {"name": "Jira Cloud", "source": self.source, "webhook_secret": secret,
|
|
94
|
+
**OPENHANDS_WEBHOOK}
|
|
95
|
+
status, body = _call("POST", self.base, self.headers, payload)
|
|
96
|
+
return _expect("Registering the OpenHands webhook", status, body)
|
|
97
|
+
|
|
98
|
+
def align(self, webhook):
|
|
99
|
+
"""Give an existing webhook the settings Jira needs; return it."""
|
|
100
|
+
wanted = {**OPENHANDS_WEBHOOK, "enabled": True}
|
|
101
|
+
if all(webhook.get(k) == v for k, v in wanted.items()):
|
|
102
|
+
return webhook
|
|
103
|
+
status, body = _call("PATCH", f"{self.base}/{webhook['id']}", self.headers, wanted)
|
|
104
|
+
return _expect("Updating the OpenHands webhook", status, body)
|
|
105
|
+
|
|
106
|
+
def rotate(self, webhook):
|
|
107
|
+
status, body = _call("POST", f"{self.base}/{webhook['id']}/rotate-secret", self.headers)
|
|
108
|
+
if status != 200 or not isinstance(body, dict) or not body.get("webhook_secret"):
|
|
109
|
+
raise Failure(f"Rotating the OpenHands webhook secret failed: HTTP {status}")
|
|
110
|
+
return body["webhook_secret"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Jira:
|
|
114
|
+
def __init__(self, base_url, email, token):
|
|
115
|
+
self.site = base_url.rstrip("/")
|
|
116
|
+
self.base = self.site + "/rest/webhooks/1.0/webhook"
|
|
117
|
+
basic = base64.b64encode(f"{email}:{token}".encode()).decode()
|
|
118
|
+
self.headers = {"Authorization": f"Basic {basic}", "Accept": "application/json"}
|
|
119
|
+
|
|
120
|
+
def require_admin(self):
|
|
121
|
+
status, body = _call(
|
|
122
|
+
"GET", f"{self.site}/rest/api/3/mypermissions?permissions=ADMINISTER", self.headers
|
|
123
|
+
)
|
|
124
|
+
if status in (401, 403):
|
|
125
|
+
raise Failure(f"Jira rejected the credentials (HTTP {status}). Check the site URL, "
|
|
126
|
+
"the account email and the API token.")
|
|
127
|
+
body = _expect("Reading Jira permissions", status, body)
|
|
128
|
+
try:
|
|
129
|
+
permission = body["permissions"]["ADMINISTER"]
|
|
130
|
+
except (KeyError, TypeError) as exc:
|
|
131
|
+
raise Failure("Reading Jira permissions returned an unexpected response.") from exc
|
|
132
|
+
if not permission.get("havePermission"):
|
|
133
|
+
raise Failure("This Jira account does not have the Administer Jira permission, "
|
|
134
|
+
"which registering a webhook requires. Ask a Jira administrator to "
|
|
135
|
+
"create the webhook by hand.", code=3)
|
|
136
|
+
|
|
137
|
+
def find(self, url):
|
|
138
|
+
"""The webhooks delivering to `url`; none when no URL is known yet."""
|
|
139
|
+
status, body = _call("GET", self.base, self.headers)
|
|
140
|
+
hooks = _expect("Listing Jira webhooks", status, body)
|
|
141
|
+
if not isinstance(hooks, list):
|
|
142
|
+
raise Failure("Listing Jira webhooks returned an unexpected response.")
|
|
143
|
+
return [h for h in hooks if url and h.get("url") == url]
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _id(hook):
|
|
147
|
+
return str(hook["self"]).rstrip("/").rsplit("/", 1)[-1]
|
|
148
|
+
|
|
149
|
+
def save(self, existing, payload):
|
|
150
|
+
if existing:
|
|
151
|
+
hook_id = self._id(existing[0])
|
|
152
|
+
status, body = _call("PUT", f"{self.base}/{hook_id}", self.headers, payload)
|
|
153
|
+
_expect("Updating the Jira webhook", status, body)
|
|
154
|
+
return hook_id, "updated"
|
|
155
|
+
status, body = _call("POST", self.base, self.headers, payload)
|
|
156
|
+
return self._id(_expect("Creating the Jira webhook", status, body)), "created"
|
|
157
|
+
|
|
158
|
+
def delete(self, hook):
|
|
159
|
+
status, body = _call("DELETE", f"{self.base}/{self._id(hook)}", self.headers)
|
|
160
|
+
_expect("Deleting the Jira webhook", status, body)
|
|
161
|
+
return self._id(hook)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _signed_check(url, secret):
|
|
165
|
+
"""Deliver one request signed the way Jira signs, to prove the pair works."""
|
|
166
|
+
body = json.dumps({"webhookEvent": CHECK_EVENT}).encode()
|
|
167
|
+
signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
168
|
+
req = urllib.request.Request(url, data=body, method="POST", headers={
|
|
169
|
+
"Content-Type": "application/json", "X-Hub-Signature": f"sha256={signature}"})
|
|
170
|
+
try:
|
|
171
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
172
|
+
return "ok" if resp.status == 200 else f"HTTP {resp.status}"
|
|
173
|
+
except urllib.error.HTTPError as exc:
|
|
174
|
+
return f"HTTP {exc.code} {_redact(exc.read().decode('utf-8', errors='replace'))[:200]}"
|
|
175
|
+
except urllib.error.URLError as exc:
|
|
176
|
+
return f"unreachable: {exc.reason}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def apply(args, openhands, jira):
|
|
180
|
+
global _secret
|
|
181
|
+
jira.require_admin()
|
|
182
|
+
jql = args.jql or 'labels = "{}"'.format(args.label.replace("\\", "\\\\").replace('"', '\\"'))
|
|
183
|
+
name = args.name or f"OpenHands: issues labelled {args.label}"
|
|
184
|
+
webhook = openhands.find()
|
|
185
|
+
# Asked before anything is written: it proves Jira serves this account the
|
|
186
|
+
# webhook API, and says whether Jira already holds this webhook's secret.
|
|
187
|
+
in_jira = jira.find(webhook["webhook_url"] if webhook else None)
|
|
188
|
+
# A pair that exists on both sides keeps its secret, so a failure half way
|
|
189
|
+
# through cannot leave Jira signing with one OpenHands no longer accepts.
|
|
190
|
+
new_secret = args.rotate_secret or not (webhook and in_jira)
|
|
191
|
+
|
|
192
|
+
if args.dry_run:
|
|
193
|
+
if not webhook:
|
|
194
|
+
openhands_plan = f"register source '{args.source}'"
|
|
195
|
+
elif new_secret:
|
|
196
|
+
openhands_plan = "rotate the secret of the existing one"
|
|
197
|
+
else:
|
|
198
|
+
openhands_plan = "keep the existing one and its secret"
|
|
199
|
+
return {
|
|
200
|
+
"dry_run": True,
|
|
201
|
+
"openhands_webhook": openhands_plan,
|
|
202
|
+
"jira_webhook": "update the existing one" if in_jira else "create",
|
|
203
|
+
"jira": {"site": jira.site, "name": name, "events": JIRA_EVENTS, "jql": jql,
|
|
204
|
+
"url": webhook["webhook_url"] if webhook else "(assigned on registration)"},
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
payload = {"name": name, "events": JIRA_EVENTS, "excludeBody": False,
|
|
208
|
+
"filters": {"issue-related-events-section": jql}}
|
|
209
|
+
if webhook:
|
|
210
|
+
webhook = openhands.align(webhook)
|
|
211
|
+
if new_secret:
|
|
212
|
+
_secret = openhands.rotate(webhook)
|
|
213
|
+
else:
|
|
214
|
+
_secret = secrets.token_urlsafe(32)
|
|
215
|
+
webhook = openhands.create(_secret)
|
|
216
|
+
url = webhook["webhook_url"]
|
|
217
|
+
if not url.startswith("https://"):
|
|
218
|
+
raise Failure(f"Jira only delivers to HTTPS URLs, and this deployment's is {url}")
|
|
219
|
+
if new_secret:
|
|
220
|
+
# Left out otherwise: Jira keeps the secret of a webhook updated without one.
|
|
221
|
+
payload["secret"] = _secret
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
hook_id, action = jira.save(in_jira, {**payload, "url": url})
|
|
225
|
+
except Failure as exc:
|
|
226
|
+
if not new_secret:
|
|
227
|
+
raise
|
|
228
|
+
raise Failure(f"{exc} OpenHands now holds a signing secret that Jira does not, so "
|
|
229
|
+
"Jira's deliveries are refused until apply succeeds; run it again once "
|
|
230
|
+
"the cause is fixed.", exc.code) from exc
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"openhands_webhook": {"id": webhook["id"], "source": webhook["source"], "url": url},
|
|
234
|
+
"jira_webhook": {"id": hook_id, "action": action, "name": name,
|
|
235
|
+
"events": JIRA_EVENTS, "jql": jql},
|
|
236
|
+
"signing_secret": "new" if new_secret else "unchanged",
|
|
237
|
+
"signed_delivery_check": _signed_check(url, _secret) if new_secret
|
|
238
|
+
else "skipped: the secret was not changed",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def delete(args, openhands, jira):
|
|
243
|
+
jira.require_admin()
|
|
244
|
+
webhook = openhands.find()
|
|
245
|
+
if not webhook:
|
|
246
|
+
return {"deleted_jira_webhooks": [],
|
|
247
|
+
"note": f"No OpenHands webhook with source '{args.source}', so no Jira webhook to match."}
|
|
248
|
+
return {"deleted_jira_webhooks": [jira.delete(h) for h in jira.find(webhook["webhook_url"])],
|
|
249
|
+
"note": "The OpenHands webhook is left in place; it receives nothing now."}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main():
|
|
253
|
+
parser = argparse.ArgumentParser(description="Set up the Jira Cloud webhook of an event-based automation.")
|
|
254
|
+
parser.add_argument("action", choices=["apply", "delete"])
|
|
255
|
+
parser.add_argument("--jira-base-url", required=True)
|
|
256
|
+
parser.add_argument("--jira-email", required=True)
|
|
257
|
+
parser.add_argument("--openhands-host", required=True)
|
|
258
|
+
parser.add_argument("--jira-token-env", default="JIRA_CLOUD_KEY")
|
|
259
|
+
parser.add_argument("--source", default="jira")
|
|
260
|
+
parser.add_argument("--label", default="create-pr")
|
|
261
|
+
parser.add_argument("--jql")
|
|
262
|
+
parser.add_argument("--name")
|
|
263
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
264
|
+
parser.add_argument("--rotate-secret", action="store_true")
|
|
265
|
+
args = parser.parse_args()
|
|
266
|
+
|
|
267
|
+
api_key = os.environ.get("OPENHANDS_API_KEY", "")
|
|
268
|
+
token = os.environ.get(args.jira_token_env, "")
|
|
269
|
+
try:
|
|
270
|
+
if not api_key:
|
|
271
|
+
raise Failure("OPENHANDS_API_KEY is not set.", code=2)
|
|
272
|
+
if not token:
|
|
273
|
+
raise Failure(f"{args.jira_token_env} is not set. Store the Jira API token as an "
|
|
274
|
+
"OpenHands secret and name it in the command.", code=2)
|
|
275
|
+
if urllib.parse.urlparse(args.jira_base_url).scheme != "https":
|
|
276
|
+
raise Failure("--jira-base-url must be an https:// URL.", code=2)
|
|
277
|
+
if urllib.parse.urlparse(args.openhands_host).scheme != "https":
|
|
278
|
+
raise Failure("--openhands-host must be an https:// URL: Jira only delivers "
|
|
279
|
+
"webhooks over HTTPS.", code=2)
|
|
280
|
+
openhands = OpenHands(args.openhands_host, api_key, args.source)
|
|
281
|
+
jira = Jira(args.jira_base_url, args.jira_email, token)
|
|
282
|
+
result = (apply if args.action == "apply" else delete)(args, openhands, jira)
|
|
283
|
+
except Failure as exc:
|
|
284
|
+
print(_redact(str(exc)), file=sys.stderr)
|
|
285
|
+
sys.exit(exc.code)
|
|
286
|
+
print(_redact(json.dumps(result, indent=2)))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
main()
|
|
@@ -64,14 +64,14 @@ Construct a prompt that includes:
|
|
|
64
64
|
|
|
65
65
|
### Step 4 — Create the automation
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
67
|
+
Set `OPENHANDS_HOST` and `AUTH_HEADER` for the request below:
|
|
68
|
+
- **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt: use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
|
|
69
|
+
- **Local Agent Canvas** - an **Automation backend** is listed in `<RUNTIME_SERVICES>`: use its `url_from_agent` as `OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
|
|
70
70
|
|
|
71
71
|
Use the **prompt preset** endpoint:
|
|
72
72
|
```bash
|
|
73
73
|
curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
|
|
74
|
-
-H "
|
|
74
|
+
-H "$AUTH_HEADER" \
|
|
75
75
|
-H "Content-Type: application/json" \
|
|
76
76
|
-d '{
|
|
77
77
|
"name": "Linear Issue Triage",
|
|
@@ -80,7 +80,7 @@ curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
|
|
|
80
80
|
}'
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
PowerShell note: use `curl.exe` for this exact flag syntax, and replace `${OPENHANDS_HOST}` / `$
|
|
83
|
+
PowerShell note: use `curl.exe` for this exact flag syntax, and replace `${OPENHANDS_HOST}` / `$AUTH_HEADER` with `$env:OPENHANDS_HOST` / `$env:AUTH_HEADER` if running it natively.
|
|
84
84
|
|
|
85
85
|
### Step 5 — Confirm
|
|
86
86
|
|
|
@@ -8,6 +8,12 @@ your topics and write a short digest.
|
|
|
8
8
|
makes it the automation to run first, before deciding what access you are
|
|
9
9
|
willing to hand over.
|
|
10
10
|
|
|
11
|
+
On a local Agent Canvas the conversation that writes the digest is also started
|
|
12
|
+
with no secrets and no MCP servers. On OpenHands Cloud and Enterprise it is
|
|
13
|
+
started through the OpenHands API with your own settings, so it holds your
|
|
14
|
+
secrets and MCP servers like any conversation you start there, while reading
|
|
15
|
+
feed text nobody vetted.
|
|
16
|
+
|
|
11
17
|
## Trigger
|
|
12
18
|
|
|
13
19
|
This skill is activated by:
|
|
@@ -4,8 +4,8 @@ description: >
|
|
|
4
4
|
Create an automation that reads a list of public RSS and Atom feeds on a
|
|
5
5
|
schedule - daily by default - keeps what is new and matches the configured
|
|
6
6
|
topics, and has an agent write a short digest of it. It needs no credentials:
|
|
7
|
-
the feeds are public URLs and
|
|
8
|
-
no MCP servers.
|
|
7
|
+
the feeds are public URLs and, on a local Agent Canvas, the conversation is
|
|
8
|
+
started with no secrets and no MCP servers.
|
|
9
9
|
triggers:
|
|
10
10
|
- /news-digest:setup
|
|
11
11
|
---
|
|
@@ -154,17 +154,24 @@ python3 -m py_compile /tmp/news-digest-build/main.py && echo "Syntax OK"
|
|
|
154
154
|
|
|
155
155
|
### Step 6 - Package and upload
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
- **
|
|
157
|
+
Set `OPENHANDS_HOST` and `AUTH_HEADER` for the commands below:
|
|
158
|
+
- **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt:
|
|
159
|
+
use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
|
|
160
|
+
- **Local Agent Canvas** - an **Automation backend** is listed in the
|
|
161
|
+
`<RUNTIME_SERVICES>` block of your system context: use its `url_from_agent` as
|
|
162
|
+
`OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
|
|
163
|
+
|
|
164
|
+
On OpenHands Cloud or Enterprise each run starts in a fresh sandbox, so the
|
|
165
|
+
automation keeps its state in the KV store: check that `kvStore` is listed under
|
|
166
|
+
`features` in `GET ${OPENHANDS_HOST}/api/automation/v1/capabilities` before
|
|
167
|
+
deploying.
|
|
161
168
|
|
|
162
169
|
```bash
|
|
163
170
|
tar -czf /tmp/news-digest.tar.gz -C /tmp/news-digest-build .
|
|
164
171
|
|
|
165
172
|
TARBALL_PATH=$(curl -s -X POST \
|
|
166
173
|
"${OPENHANDS_HOST}/api/automation/v1/uploads?name=news-digest" \
|
|
167
|
-
-H "
|
|
174
|
+
-H "$AUTH_HEADER" \
|
|
168
175
|
-H "Content-Type: application/gzip" \
|
|
169
176
|
--data-binary @/tmp/news-digest.tar.gz \
|
|
170
177
|
| python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])")
|
|
@@ -176,7 +183,7 @@ echo "Uploaded: $TARBALL_PATH"
|
|
|
176
183
|
|
|
177
184
|
```bash
|
|
178
185
|
curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
|
|
179
|
-
-H "
|
|
186
|
+
-H "$AUTH_HEADER" \
|
|
180
187
|
-H "Content-Type: application/json" \
|
|
181
188
|
-d "{
|
|
182
189
|
\"name\": \"Daily news digest\",
|
|
@@ -210,7 +217,7 @@ Then offer to dispatch it once so they can read today's digest immediately:
|
|
|
210
217
|
|
|
211
218
|
```bash
|
|
212
219
|
curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1/{id}/dispatch" \
|
|
213
|
-
-H "
|
|
220
|
+
-H "$AUTH_HEADER"
|
|
214
221
|
```
|
|
215
222
|
|
|
216
223
|
---
|
|
@@ -241,6 +248,9 @@ shipped one and then:
|
|
|
241
248
|
with the stories **and the topics** in its prompt, an empty secrets payload
|
|
242
249
|
and no MCP servers, working in `{WORKSPACE_BASE}/news-digest/{date}`. The
|
|
243
250
|
agent decides which stories are relevant before it writes anything.
|
|
251
|
+
On OpenHands Cloud and Enterprise the conversation is started through
|
|
252
|
+
the OpenHands API instead, in a sandbox of its own and with the user's own
|
|
253
|
+
settings, so there it does hold the user's secrets and MCP servers.
|
|
244
254
|
7. When the conversation reaches `idle`, `finished`, `error` or `stuck`:
|
|
245
255
|
- reads `digest.md` from the working directory, falling back to the agent's
|
|
246
256
|
final message;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
# auto-generated by sync_extensions.py
|
|
3
|
-
description: Create an automation that reads a list of public RSS and Atom feeds on a schedule - daily by default - keeps what is new and matches the configured topics, and has an agent write a short digest of it. It needs no credentials: the feeds are public URLs and the conversation is started with no secrets and no MCP servers.
|
|
3
|
+
description: Create an automation that reads a list of public RSS and Atom feeds on a schedule - daily by default - keeps what is new and matches the configured topics, and has an agent write a short digest of it. It needs no credentials: the feeds are public URLs and, on a local Agent Canvas, the conversation is started with no secrets and no MCP servers.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
|
|
@@ -8,7 +8,10 @@ This automation needs no credentials. It authenticates to nothing: the feeds are
|
|
|
8
8
|
public URLs fetched over plain HTTPS, and the conversation is started with an
|
|
9
9
|
empty secret allow-list and no MCP servers, so there is nothing for it to leak.
|
|
10
10
|
That is deliberate - it is the automation to reach for when you want to see one
|
|
11
|
-
working before you decide which tokens you are willing to hand over.
|
|
11
|
+
working before you decide which tokens you are willing to hand over. The empty
|
|
12
|
+
allow-list holds on a local Agent Canvas. On OpenHands Cloud and Enterprise the
|
|
13
|
+
OpenHands API starts the conversation in a sandbox of its own, with the user's
|
|
14
|
+
own model, secrets and MCP servers; the script itself still uses none of them.
|
|
12
15
|
|
|
13
16
|
The split of duties is the same as the other bundled automations, drawn at what
|
|
14
17
|
has a right answer. Python owns the schedule, the once-a-day claim, fetching,
|
|
@@ -37,6 +40,7 @@ import time
|
|
|
37
40
|
import urllib.error
|
|
38
41
|
import urllib.parse
|
|
39
42
|
import urllib.request
|
|
43
|
+
import uuid
|
|
40
44
|
from collections.abc import Callable
|
|
41
45
|
from datetime import datetime, timezone
|
|
42
46
|
from email.utils import parsedate_to_datetime
|
|
@@ -224,17 +228,24 @@ USER_AGENT = "OpenHands-News-Digest/1.0 (+https://github.com/OpenHands/extension
|
|
|
224
228
|
DIGEST_FILENAME = "digest.md"
|
|
225
229
|
|
|
226
230
|
|
|
231
|
+
# A local Agent Canvas run is handed the Agent Server URL. A run on OpenHands
|
|
232
|
+
# Cloud or Enterprise is handed the OpenHands API instead.
|
|
233
|
+
_CLOUD_API_URL = os.environ.get("OPENHANDS_CLOUD_API_URL", "").rstrip("/")
|
|
234
|
+
IS_CLOUD = bool(_CLOUD_API_URL) and not os.environ.get("AGENT_SERVER_URL")
|
|
235
|
+
|
|
236
|
+
|
|
227
237
|
def _get_env_key() -> str:
|
|
228
238
|
return os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0") or ""
|
|
229
239
|
|
|
230
240
|
|
|
231
241
|
def get_secret(name: str) -> str:
|
|
232
|
-
url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
233
242
|
key = _get_env_key()
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
if IS_CLOUD:
|
|
244
|
+
# The OpenHands API serves the secrets of this run's sandbox.
|
|
245
|
+
url = f"{_CLOUD_API_URL}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
|
|
246
|
+
else:
|
|
247
|
+
url = f"{os.environ.get('AGENT_SERVER_URL', '').rstrip('/')}/api/settings/secrets/{name}"
|
|
248
|
+
req = urllib.request.Request(url, headers={"X-Session-API-Key": key})
|
|
238
249
|
with urllib.request.urlopen(req) as r:
|
|
239
250
|
return r.read().decode().strip()
|
|
240
251
|
|
|
@@ -252,12 +263,14 @@ def fire_callback(
|
|
|
252
263
|
body["error"] = error
|
|
253
264
|
if conversation_id:
|
|
254
265
|
body["conversation_id"] = conversation_id
|
|
266
|
+
# A cloud run is not given a callback key; it authenticates with its API key.
|
|
267
|
+
api_key = os.environ.get("AUTOMATION_CALLBACK_API_KEY") or os.environ.get("OPENHANDS_API_KEY", "")
|
|
255
268
|
req = urllib.request.Request(
|
|
256
269
|
url,
|
|
257
270
|
data=json.dumps(body).encode(),
|
|
258
271
|
headers={
|
|
259
272
|
"Content-Type": "application/json",
|
|
260
|
-
"Authorization": f"Bearer {
|
|
273
|
+
"Authorization": f"Bearer {api_key}",
|
|
261
274
|
},
|
|
262
275
|
)
|
|
263
276
|
try:
|
|
@@ -749,12 +762,96 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
|
|
|
749
762
|
return secrets
|
|
750
763
|
|
|
751
764
|
|
|
765
|
+
def _cloud_request(method: str, path: str, body: dict | None = None):
|
|
766
|
+
"""Call the OpenHands API with the API key this cloud run was given."""
|
|
767
|
+
req = urllib.request.Request(
|
|
768
|
+
f"{_CLOUD_API_URL}{path}",
|
|
769
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
770
|
+
headers={
|
|
771
|
+
"Authorization": f"Bearer {os.environ.get('OPENHANDS_API_KEY', '')}",
|
|
772
|
+
"Content-Type": "application/json",
|
|
773
|
+
},
|
|
774
|
+
method=method,
|
|
775
|
+
)
|
|
776
|
+
try:
|
|
777
|
+
with urllib.request.urlopen(req) as r:
|
|
778
|
+
raw = r.read()
|
|
779
|
+
return json.loads(raw) if raw.strip() else {}
|
|
780
|
+
except urllib.error.HTTPError as exc:
|
|
781
|
+
body_text = exc.read().decode()
|
|
782
|
+
raise RuntimeError(f"OpenHands API {method} {path} → {exc.code}: {body_text}") from exc
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _create_cloud_conversation(initial_message: str, title: str) -> str:
|
|
786
|
+
"""Start a conversation through the OpenHands API and return its id.
|
|
787
|
+
|
|
788
|
+
OpenHands runs it in a sandbox of its own, which outlives this run's, with
|
|
789
|
+
the user's own model, MCP servers and secrets: unlike a local run, the
|
|
790
|
+
conversation is not held to the empty secrets payload below.
|
|
791
|
+
"""
|
|
792
|
+
conversation_id = uuid.uuid4().hex
|
|
793
|
+
_cloud_request(
|
|
794
|
+
"POST",
|
|
795
|
+
"/api/v1/app-conversations",
|
|
796
|
+
{
|
|
797
|
+
"conversation_id": conversation_id,
|
|
798
|
+
"title": title,
|
|
799
|
+
"initial_message": {
|
|
800
|
+
"role": "user",
|
|
801
|
+
"content": [{"type": "text", "text": initial_message}],
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
)
|
|
805
|
+
return conversation_id
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _cloud_conversation_status(conv_id: str) -> str:
|
|
809
|
+
found = _cloud_request("GET", f"/api/v1/app-conversations?ids={conv_id}")
|
|
810
|
+
conversation = found[0] if found else None
|
|
811
|
+
if conversation is None:
|
|
812
|
+
# OpenHands is still bringing the conversation up.
|
|
813
|
+
return "starting"
|
|
814
|
+
if conversation.get("execution_status"):
|
|
815
|
+
return conversation["execution_status"]
|
|
816
|
+
# The status is only reported while the conversation's sandbox is running.
|
|
817
|
+
# Once it is paused, the last status the agent reported is in its events.
|
|
818
|
+
events = _cloud_request(
|
|
819
|
+
"GET",
|
|
820
|
+
f"/api/v1/conversation/{conv_id}/events/search"
|
|
821
|
+
"?kind__eq=ConversationStateUpdateEvent&sort_order=TIMESTAMP_DESC&limit=100",
|
|
822
|
+
)
|
|
823
|
+
for event in events.get("items", []):
|
|
824
|
+
if event.get("key") == "execution_status":
|
|
825
|
+
return event.get("value") or "unknown"
|
|
826
|
+
return "unknown"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _cloud_conversation_final_response(conv_id: str) -> str:
|
|
830
|
+
"""The agent's closing message, read from the conversation's stored events."""
|
|
831
|
+
events = _cloud_request(
|
|
832
|
+
"GET",
|
|
833
|
+
f"/api/v1/conversation/{conv_id}/events/search?sort_order=TIMESTAMP_DESC&limit=100",
|
|
834
|
+
)
|
|
835
|
+
for event in events.get("items", []):
|
|
836
|
+
if event.get("source") != "agent":
|
|
837
|
+
continue
|
|
838
|
+
if event.get("kind") == "ActionEvent" and event.get("tool_name") == "finish":
|
|
839
|
+
return (event.get("action") or {}).get("message") or ""
|
|
840
|
+
if event.get("kind") == "MessageEvent":
|
|
841
|
+
content = (event.get("llm_message") or {}).get("content") or []
|
|
842
|
+
return "".join(part.get("text", "") for part in content)
|
|
843
|
+
return ""
|
|
844
|
+
|
|
845
|
+
|
|
752
846
|
def create_conversation(
|
|
753
847
|
agent_url: str,
|
|
754
848
|
api_key: str,
|
|
755
849
|
initial_message: str,
|
|
756
|
-
workspace_dir: Path,
|
|
850
|
+
workspace_dir: Path | None,
|
|
851
|
+
title: str = "",
|
|
757
852
|
) -> str:
|
|
853
|
+
if IS_CLOUD:
|
|
854
|
+
return _create_cloud_conversation(initial_message, title)
|
|
758
855
|
payload: dict = {
|
|
759
856
|
"workspace": {"working_dir": str(workspace_dir)},
|
|
760
857
|
"agent": _get_agent_dict(agent_url, api_key),
|
|
@@ -770,11 +867,15 @@ def create_conversation(
|
|
|
770
867
|
|
|
771
868
|
|
|
772
869
|
def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
870
|
+
if IS_CLOUD:
|
|
871
|
+
return _cloud_conversation_status(conv_id)
|
|
773
872
|
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
|
|
774
873
|
return result.get("execution_status", "unknown")
|
|
775
874
|
|
|
776
875
|
|
|
777
876
|
def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
877
|
+
if IS_CLOUD:
|
|
878
|
+
return _cloud_conversation_final_response(conv_id)
|
|
778
879
|
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response")
|
|
779
880
|
return result.get("response", "")
|
|
780
881
|
|
|
@@ -1048,9 +1149,14 @@ def _start_task(
|
|
|
1048
1149
|
|
|
1049
1150
|
workspace_dir = None
|
|
1050
1151
|
try:
|
|
1051
|
-
|
|
1152
|
+
# A cloud conversation runs in a sandbox of its own, so there is no
|
|
1153
|
+
# directory here for it to work in; its digest is its final message.
|
|
1154
|
+
if not IS_CLOUD:
|
|
1155
|
+
workspace_dir = _prepare_workspace(period)
|
|
1052
1156
|
prompt = _build_digest_prompt(period, TOPICS, items, feed_errors)
|
|
1053
|
-
conv_id = create_conversation(
|
|
1157
|
+
conv_id = create_conversation(
|
|
1158
|
+
agent_url, api_key, prompt, workspace_dir, title=f"News digest {period}"
|
|
1159
|
+
)
|
|
1054
1160
|
except Exception as exc:
|
|
1055
1161
|
# The claim is dropped so the next run retries today. The workspace goes
|
|
1056
1162
|
# with it rather than being left behind.
|
|
@@ -1065,7 +1171,7 @@ def _start_task(
|
|
|
1065
1171
|
{
|
|
1066
1172
|
"status": "active",
|
|
1067
1173
|
"conversation_id": conv_id,
|
|
1068
|
-
"workspace_dir": str(workspace_dir),
|
|
1174
|
+
"workspace_dir": str(workspace_dir) if workspace_dir else None,
|
|
1069
1175
|
"last_activity": time.time(),
|
|
1070
1176
|
}
|
|
1071
1177
|
)
|
|
@@ -1160,10 +1266,22 @@ def main() -> str | None:
|
|
|
1160
1266
|
if not FEEDS:
|
|
1161
1267
|
raise SystemExit("No feeds are configured; nothing to digest")
|
|
1162
1268
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1269
|
+
if IS_CLOUD and not _kv_available():
|
|
1270
|
+
# A cloud run starts in a fresh sandbox, so a state file would not
|
|
1271
|
+
# survive it and every run would write today's digest again.
|
|
1272
|
+
raise RuntimeError(
|
|
1273
|
+
"The automation KV store is required on OpenHands Cloud and Enterprise, "
|
|
1274
|
+
"but AUTOMATION_KV_TOKEN is not set for this run."
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
if IS_CLOUD:
|
|
1278
|
+
# Conversations are opened in the Canvas of the deployment itself.
|
|
1279
|
+
openhands_url = f"{_CLOUD_API_URL}/canvas"
|
|
1280
|
+
else:
|
|
1281
|
+
try:
|
|
1282
|
+
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
1283
|
+
except Exception:
|
|
1284
|
+
openhands_url = DEFAULT_OPENHANDS_URL
|
|
1167
1285
|
|
|
1168
1286
|
state = load_state()
|
|
1169
1287
|
tasks: dict = state.setdefault("tasks", {})
|
|
@@ -22,7 +22,7 @@ The Python client prefers `OPENHANDS_CLOUD_API_KEY` and falls back to `OPENHANDS
|
|
|
22
22
|
|
|
23
23
|
## Delegating work with new Cloud conversations
|
|
24
24
|
|
|
25
|
-
Use `POST /api/v1/app-conversations` to create a separate OpenHands Cloud conversation for a self-contained task, then poll `GET /api/v1/app-conversations/start-tasks?ids=...` until you have an `app_conversation_id`.
|
|
25
|
+
Use `POST /api/v1/app-conversations` to create a separate OpenHands Cloud conversation for a self-contained task, then poll `GET /api/v1/app-conversations/start-tasks?ids=...` until you have an `app_conversation_id`. Share the Agent Canvas link from `OpenHandsAPI.app_conversation_ui_url(app_conversation_id)` (the conversation's `conversation_ui_url`, falling back to `<base_url>/canvas/conversations/<id>`).
|
|
26
26
|
|
|
27
27
|
Keep delegated prompts self-contained: include the repository, branch, relevant files, constraints, and expected output. Prefer five or fewer concurrently running delegated conversations.
|
|
28
28
|
|