@openhands/extensions 0.24.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.
Files changed (133) hide show
  1. package/.claude-plugin/marketplace.json +30 -0
  2. package/.codex-plugin/marketplace.json +30 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +30 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +10 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/bundle-index.js +18 -14
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/automations/catalog-index.js +2 -0
  28. package/index.d.ts +2 -0
  29. package/index.js +1 -0
  30. package/marketplaces/openhands-extensions.json +30 -0
  31. package/package.json +9 -1
  32. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  33. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  34. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  35. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  36. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  37. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  38. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  39. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +183 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +183 -15
  72. package/skills/github-pr-review/SKILL.md +12 -2
  73. package/skills/github-pr-reviewer/README.md +36 -0
  74. package/skills/github-pr-reviewer/SKILL.md +147 -4
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
  77. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  78. package/skills/github-pr-reviewer/scripts/worker.py +842 -57
  79. package/skills/github-repo-monitor/scripts/github_client.py +183 -15
  80. package/skills/github-repo-monitor/scripts/main.py +32 -1
  81. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  82. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  83. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  84. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  85. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  86. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  87. package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
  88. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
  89. package/skills/gitlab-issue-to-mr/README.md +12 -0
  90. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  91. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  92. package/skills/incident-retrospective/SKILL.md +5 -5
  93. package/skills/index.js +34 -16
  94. package/skills/jira-issue-to-pr/README.md +12 -4
  95. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  96. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  97. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  98. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  99. package/skills/linear-triage/SKILL.md +5 -5
  100. package/skills/news-digest/README.md +6 -0
  101. package/skills/news-digest/SKILL.md +19 -9
  102. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  103. package/skills/news-digest/scripts/main.py +133 -15
  104. package/skills/openhands-api/README.md +1 -1
  105. package/skills/openhands-api/SKILL.md +3 -3
  106. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  107. package/skills/openhands-automation/SKILL.md +66 -4
  108. package/skills/openhands-automation/references/custom-automation.md +21 -7
  109. package/skills/openhands-sdk/SKILL.md +1 -0
  110. package/skills/research-brief/SKILL.md +5 -5
  111. package/skills/slack-standup-digest/SKILL.md +5 -5
  112. package/skills/ticket-to-code-change/SKILL.md +6 -3
  113. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  114. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  115. package/skills/use-jev/.plugin/plugin.json +8 -0
  116. package/skills/use-jev/README.md +66 -0
  117. package/skills/use-jev/SKILL.md +66 -0
  118. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  119. package/tests/test_agent_conversation_dispatch.py +212 -0
  120. package/tests/test_apps_catalog.py +26 -0
  121. package/tests/test_automation_setup.py +44 -0
  122. package/tests/test_check_pr_description.py +89 -6
  123. package/tests/test_github_automation_foundation.py +158 -0
  124. package/tests/test_github_repo_monitor.py +107 -0
  125. package/tests/test_github_reviewer_delivery.py +2247 -6
  126. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  127. package/tests/test_jira_issue_to_pr.py +312 -0
  128. package/tests/test_jira_setup_webhook.py +309 -0
  129. package/tests/test_openhands_api_python.py +37 -0
  130. package/tests/test_skill_plugin_loading.py +25 -15
  131. package/tests/test_sync_extensions.py +102 -0
  132. package/tests/test_vscode_app.py +33 -0
  133. package/tests/vscode-app.test.mjs +43 -0
@@ -84,7 +84,7 @@
84
84
  }
85
85
  },
86
86
  "bundle": {
87
- "version": "1.0.0",
87
+ "version": "1.1.0",
88
88
  "entrypoint": "python3 main.py",
89
89
  "timeout": 900,
90
90
  "files": {
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "id": "github-pr-reviewer",
3
- "version": "1.5.0",
3
+ "version": "1.8.1",
4
4
  "name": "GitHub code review",
5
5
  "category": "Code review",
6
- "description": "Review requested pull-request heads, record exact-head decisions, stop early on out-of-scope changes, and optionally request a code-aware human maintainer after approval or a maintainer-decision scope stop.",
6
+ "description": "Review requested pull-request heads, resume an outstanding reviewer request on a schedule once its checks are green, classify all open non-draft pull requests that nobody requested and review eligible green heads, drain the backlog oldest-first under a small per-scan new-conversation bound, record exact-head decisions, stop early on out-of-scope changes, and optionally request a code-aware human maintainer after approval or a maintainer-decision scope stop.",
7
7
  "requires": {
8
8
  "integrations": {},
9
9
  "features": [
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "popularityRank": 100,
15
15
  "estimatedSetupMinutes": 4,
16
- "exampleImplementation": "A scheduled label scan or GitHub reviewer-request event selects a PR head and submits an idempotent subject turn. The profile-backed agent clones that exact head in its runtime, applies an early repository-ownership and product/architecture scope gate, then reuses the established review workflow, runs appropriate tests, and publishes a readable native review. A native approval, or a maintainer-decision scope stop, can then be handed to a code-aware human maintainer.",
16
+ "exampleImplementation": "A scheduled scan of the trigger label, of open PRs (including drafts) holding an outstanding reviewer request, and of every open non-draft PR nobody requested whose current head may be green and unreviewed, or a GitHub reviewer-request event, selects a PR head and submits an idempotent subject turn. Each scheduled scan classifies the full backlog, while the global per-run quota limits only newly created review conversations; blocked and pending heads consume no launch slot, and explicit requests retain priority. The scheduled scan orders eligible heads with explicit requests first and then oldest-first, and starts at most a small configurable number of new conversations per scan across every repository. The profile-backed agent clones that exact head in its runtime, applies an early repository-ownership and product/architecture scope gate, then reuses the established review workflow, runs appropriate tests, and publishes a readable native review. A native approval, or a maintainer-decision scope stop, can then be handed to a code-aware human maintainer.",
17
17
  "impact": {
18
18
  "basis": "completed-runs",
19
19
  "one": "1 PR review sweep completed",
@@ -28,8 +28,8 @@
28
28
  "schedule": {
29
29
  "type": "cron",
30
30
  "label": "Check frequency",
31
- "help": "How often to look for newly labelled pull requests.",
32
- "default": "*/15 * * * *",
31
+ "help": "How often to look for newly labelled pull requests, outstanding reviewer requests whose checks have not yet finished, and open non-draft pull requests nobody requested that still need a review.",
32
+ "default": "*/5 * * * *",
33
33
  "required": true
34
34
  },
35
35
  "timezone": {
@@ -68,7 +68,7 @@
68
68
  "triggerLabel": {
69
69
  "type": "text",
70
70
  "label": "Trigger label",
71
- "help": "Only pull requests carrying this label are reviewed.",
71
+ "help": "Pull requests carrying this label are reviewed. An open, non-draft pull request that nobody requested is also reviewed once its current head is green and carries no review yet, with the whole backlog classified each scan and only newly launched reviews counting against the configured quota.",
72
72
  "default": "openhands-review",
73
73
  "required": true,
74
74
  "constraints": {
@@ -87,6 +87,16 @@
87
87
  "maxLength": 39
88
88
  }
89
89
  },
90
+ "maxNewPerRun": {
91
+ "type": "number",
92
+ "label": "New reviews per scan",
93
+ "help": "Most new review conversations one scheduled scan may start, counted across every repository. Keeps a first scan over a large backlog from starting an agent for every eligible pull request at once. Reviews that are already running are resumed without counting against it.",
94
+ "default": 2,
95
+ "required": true,
96
+ "constraints": {
97
+ "min": 1
98
+ }
99
+ },
90
100
  "reviewTone": {
91
101
  "type": "select",
92
102
  "label": "Review tone",
@@ -125,7 +135,7 @@
125
135
  }
126
136
  },
127
137
  "bundle": {
128
- "version": "1.5.0",
138
+ "version": "1.8.1",
129
139
  "entrypoint": "python3 worker.py",
130
140
  "timeout": 300,
131
141
  "files": {
@@ -139,12 +149,13 @@
139
149
  "repos": "{{form.repositories}}",
140
150
  "trigger_label": "{{form.triggerLabel}}",
141
151
  "trigger_reviewer": "{{form.triggerReviewer}}",
152
+ "max_new_per_run": "{{form.maxNewPerRun}}",
142
153
  "review_tone": "{{form.reviewTone}}",
143
154
  "maintainers": "{{form.maintainers}}",
144
155
  "github_token_secret": "{{form.githubTokenSecret}}"
145
156
  }
146
157
  },
147
158
  "filter": "contains({{form.repositories}}, repository.full_name) && contains(keys(@), 'pull_request') && ((action == 'review_requested' && requested_reviewer.login == '{{form.triggerReviewer}}') || (action == 'submitted' && contains(keys(@), 'review') && review.user.login == '{{form.triggerReviewer}}'))",
148
- "message": "Configure the repositories, trigger, review tone, optional maintainer roster, credential name, and agent profile."
159
+ "message": "Configure the repositories, trigger, review tone, optional maintainer roster, credential name, per-scan new-review bound, and agent profile."
149
160
  }
150
161
  }
@@ -0,0 +1,71 @@
1
+ {
2
+ "id": "github-stale-ci-pr-closer",
3
+ "version": "1.0.1",
4
+ "name": "GitHub stale CI cleanup",
5
+ "category": "Code review",
6
+ "description": "Warn and close abandoned pull requests whose required CI remains failing.",
7
+ "requires": {
8
+ "integrations": {},
9
+ "features": [
10
+ "customTarball"
11
+ ]
12
+ },
13
+ "estimatedSetupMinutes": 3,
14
+ "exampleImplementation": "A deterministic scheduled scan reads the required checks that apply to each pull request's base branch. It warns seven days after the newest required-check failure and closes seven days later only when the author has neither pushed nor commented.",
15
+ "setup": {
16
+ "version": "1.0",
17
+ "mode": "direct",
18
+ "form": {
19
+ "triggers": {
20
+ "cron": {
21
+ "schedule": {
22
+ "type": "cron",
23
+ "label": "Check frequency",
24
+ "help": "How often to reconcile failing pull requests.",
25
+ "default": "0 * * * *",
26
+ "required": true
27
+ },
28
+ "timezone": {
29
+ "type": "timezone",
30
+ "label": "Timezone",
31
+ "help": "Timezone for the schedule.",
32
+ "default": "UTC",
33
+ "required": true
34
+ }
35
+ }
36
+ },
37
+ "args": {
38
+ "repositories": {
39
+ "type": "repo-picker",
40
+ "provider": "github",
41
+ "multiple": true,
42
+ "label": "Repositories",
43
+ "help": "Repositories whose open pull requests should be maintained.",
44
+ "required": true
45
+ },
46
+ "githubTokenSecret": {
47
+ "type": "text",
48
+ "label": "GitHub token secret",
49
+ "help": "Name of a saved GitHub token with rules and CI read access plus issue and pull-request write access.",
50
+ "default": "GITHUB_TOKEN_REVIEW",
51
+ "required": true
52
+ }
53
+ }
54
+ },
55
+ "bundle": {
56
+ "version": "1.0.1",
57
+ "entrypoint": "python3 worker.py",
58
+ "timeout": 300,
59
+ "files": {
60
+ "worker.py": "skills/github-stale-ci-pr-closer/scripts/worker.py",
61
+ "github_client.py": "skills/github/scripts/github_client.py"
62
+ },
63
+ "config": {
64
+ "repos": "{{form.repositories}}",
65
+ "github_token_secret": "{{form.githubTokenSecret}}"
66
+ }
67
+ },
68
+ "message": "Configure the repositories, credential name, and schedule."
69
+ },
70
+ "popularityRank": 77
71
+ }
@@ -105,7 +105,7 @@
105
105
  }
106
106
  },
107
107
  "bundle": {
108
- "version": "1.0.0",
108
+ "version": "1.1.0",
109
109
  "entrypoint": "python3 main.py",
110
110
  "timeout": 900,
111
111
  "files": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "popularityRank": 78,
14
14
  "estimatedSetupMinutes": 2,
15
- "exampleImplementation": "Trigger: cron, daily by default (0 8 * * *)\nRequired secret: none. The feeds are public URLs and the conversation is started with an empty secret allow-list and no MCP servers.\n\n1. Read the feed list, the topics, and the schedule from setup.\n2. Key one unit of work to the UTC date, so a cron that fires more often, a retried run, or a restarted service cannot write the same digest twice.\n3. Fetch every feed over plain HTTPS and parse RSS 2.0, RSS 1.0/RDF and Atom by local element name. A feed that is down, moved, or no longer a feed is reported and skipped; the run fails only when every feed fails.\n4. Drop stories already covered by an earlier digest and stories older than the lookback window. Do not judge what a story is about: that has no right answer and is the agent's call.\n5. Start no conversation at all when nothing new was published, and leave the day open for a later run. A quiet day costs no tokens.\n6. Otherwise start an OpenHands conversation with the newest stories and the topics in the prompt, and let it decide which are relevant, group them, merge duplicate coverage, and write the digest.\n7. Deliver it three ways that need no credentials: it stays in the conversation, it is printed into the run log, and its opening is kept in state.\n8. Remember the reported stories only once a digest exists, so a failed run is recovered by the next one rather than lost.",
15
+ "exampleImplementation": "Trigger: cron, daily by default (0 8 * * *)\nRequired secret: none. The feeds are public URLs and, on a local Agent Canvas, the conversation is started with an empty secret allow-list and no MCP servers. On OpenHands Cloud and Enterprise it is started with the user's own settings.\n\n1. Read the feed list, the topics, and the schedule from setup.\n2. Key one unit of work to the UTC date, so a cron that fires more often, a retried run, or a restarted service cannot write the same digest twice.\n3. Fetch every feed over plain HTTPS and parse RSS 2.0, RSS 1.0/RDF and Atom by local element name. A feed that is down, moved, or no longer a feed is reported and skipped; the run fails only when every feed fails.\n4. Drop stories already covered by an earlier digest and stories older than the lookback window. Do not judge what a story is about: that has no right answer and is the agent's call.\n5. Start no conversation at all when nothing new was published, and leave the day open for a later run. A quiet day costs no tokens.\n6. Otherwise start an OpenHands conversation with the newest stories and the topics in the prompt, and let it decide which are relevant, group them, merge duplicate coverage, and write the digest.\n7. Deliver it three ways that need no credentials: it stays in the conversation, it is printed into the run log, and its opening is kept in state.\n8. Remember the reported stories only once a digest exists, so a failed run is recovered by the next one rather than lost.",
16
16
  "impact": {
17
17
  "basis": "completed-runs",
18
18
  "one": "1 feed check completed",
@@ -65,7 +65,7 @@
65
65
  }
66
66
  },
67
67
  "bundle": {
68
- "version": "1.0.0",
68
+ "version": "1.1.0",
69
69
  "entrypoint": "python3 main.py",
70
70
  "timeout": 900,
71
71
  "files": {
@@ -24,6 +24,7 @@ import entry18 from "./catalog/github-issue-triage/manifest.json" with { type: "
24
24
  import entry19 from "./catalog/upstream-fork-sync/manifest.json" with { type: "json" };
25
25
  import entry20 from "./catalog/incident-retrospective-drafter/manifest.json" with { type: "json" };
26
26
  import entry21 from "./catalog/news-digest/manifest.json" with { type: "json" };
27
+ import entry22 from "./catalog/github-stale-ci-pr-closer/manifest.json" with { type: "json" };
27
28
 
28
29
  export const AUTOMATION_CATALOG_ENTRIES = [
29
30
  entry0,
@@ -48,4 +49,5 @@ export const AUTOMATION_CATALOG_ENTRIES = [
48
49
  entry19,
49
50
  entry20,
50
51
  entry21,
52
+ entry22,
51
53
  ];
package/index.d.ts CHANGED
@@ -44,3 +44,5 @@ export type {
44
44
  } from "./automations/index.js";
45
45
  export { SKILLS_CATALOG, DEFAULT_ENABLED_SKILL_NAMES } from "./skills/index.js";
46
46
  export type { SkillCatalogEntry } from "./skills/index.js";
47
+ export { APPS_CATALOG } from "./apps/index.js";
48
+ export type { AppCatalogEntry } from "./apps/index.js";
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { INTEGRATION_CATALOG } from "./integrations/index.js";
2
2
  export { AUTOMATION_CATALOG, AUTOMATION_INTERFACE } from "./automations/index.js";
3
3
  export { SKILLS_CATALOG, DEFAULT_ENABLED_SKILL_NAMES } from "./skills/index.js";
4
+ export { APPS_CATALOG } from "./apps/index.js";
@@ -541,6 +541,20 @@
541
541
  "semver"
542
542
  ]
543
543
  },
544
+ {
545
+ "name": "runtime-api-configs",
546
+ "source": "./plugins/runtime-api-configs",
547
+ "description": "Manage warm sandbox pools on OpenHands Enterprise from a Python CLI: list, template, save, and delete warm runtime configurations against the runtime-api, and bootstrap credentials from Kubernetes secrets. Standard-library only, runs anywhere python3 is available.",
548
+ "category": "environment",
549
+ "keywords": [
550
+ "openhands-enterprise",
551
+ "runtime-api",
552
+ "warm-runtime-config",
553
+ "warm-pool",
554
+ "custom-sandbox-image",
555
+ "admin"
556
+ ]
557
+ },
544
558
  {
545
559
  "name": "security",
546
560
  "source": "./skills/security",
@@ -890,6 +904,22 @@
890
904
  "source": "./skills/github-delivery-watchdog",
891
905
  "description": "Periodically check pull requests and merge only current heads with independent review, tests, and passing CI.",
892
906
  "category": "automations"
907
+ },
908
+ {
909
+ "name": "github-stale-ci-pr-closer",
910
+ "source": "./skills/github-stale-ci-pr-closer",
911
+ "description": "Warn and close abandoned pull requests whose required CI remains failing.",
912
+ "category": "automations"
913
+ },
914
+ {
915
+ "name": "use-jev",
916
+ "source": "./skills/use-jev",
917
+ "description": "Use Jev, a general classifier, for typed choices, scores, and yes/no probabilities in software.",
918
+ "category": "integrations",
919
+ "keywords": [
920
+ "jev",
921
+ "classification"
922
+ ]
893
923
  }
894
924
  ]
895
925
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openhands/extensions",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,8 @@
12
12
  "build:skills": "node scripts/build-skills-catalog.mjs",
13
13
  "build:integrations": "node scripts/build-integration-catalog.mjs",
14
14
  "build:automations": "node scripts/build-automation-catalog.mjs",
15
+ "build:vscode-app": "node apps/vscode/scripts/package.mjs",
16
+ "test:vscode-app": "node tests/vscode-app.test.mjs",
15
17
  "build": "npm run build:skills && npm run build:integrations && npm run build:automations"
16
18
  },
17
19
  "engines": {
@@ -51,6 +53,12 @@
51
53
  "import": "./skills/index.js",
52
54
  "default": "./skills/index.js"
53
55
  },
56
+ "./apps": {
57
+ "types": "./apps/index.d.ts",
58
+ "import": "./apps/index.js",
59
+ "default": "./apps/index.js"
60
+ },
61
+ "./apps/*": "./apps/*",
54
62
  "./skills/*": "./skills/*",
55
63
  "./plugins/*": "./plugins/*",
56
64
  "./marketplaces/*.json": "./marketplaces/*.json",
@@ -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
 
@@ -177,7 +177,7 @@ When you start a delegated Cloud conversation:
177
177
  3. Start the new conversation with `POST /api/v1/app-conversations`.
178
178
  4. Poll the start-task until `status` is `READY` and you have an `app_conversation_id`.
179
179
  5. Monitor the delegated conversation via `GET /api/v1/app-conversations?ids=...`.
180
- 6. Share or store the Cloud URL: `https://app.all-hands.dev/conversations/<app_conversation_id>`.
180
+ 6. Share or store the conversation's `conversation_ui_url` from that response. It opens the conversation in Agent Canvas. If the server does not return it, use `<base_url>/canvas/conversations/<app_conversation_id>`, where `<base_url>` is the server you called the API on. Never share `conversation_url`; it is the sandbox's agent-server API, not a page users can open.
181
181
 
182
182
  ### Minimal cURL flow
183
183
 
@@ -200,7 +200,7 @@ curl -s "https://app.all-hands.dev/api/v1/app-conversations/start-tasks?ids=${ST
200
200
  -H "Authorization: Bearer ${OPENHANDS_CLOUD_API_KEY}"
201
201
  ```
202
202
 
203
- Then check execution status:
203
+ Then check execution status and get the link to share (`conversation_ui_url`):
204
204
 
205
205
  ```bash
206
206
  curl -s "https://app.all-hands.dev/api/v1/app-conversations?ids=${APP_CONVERSATION_ID}" \
@@ -229,7 +229,7 @@ if not ready.get("app_conversation_id"):
229
229
  ready = api.poll_start_task_until_ready(start["id"])
230
230
 
231
231
  conversation_id = ready["app_conversation_id"]
232
- print(f"Delegated conversation: {api.base_url}/conversations/{conversation_id}")
232
+ print(f"Delegated conversation: {api.app_conversation_ui_url(conversation_id)}")
233
233
 
234
234
  status = api.app_conversation_get(conversation_id)
235
235
  print(status.get("sandbox_status"), status.get("execution_status"))
@@ -129,6 +129,19 @@ class OpenHandsAPI:
129
129
  items = self.app_conversations_get_batch(ids=[conversation_id])
130
130
  return items[0] if items else None
131
131
 
132
+ def app_conversation_ui_url(self, conversation_id: str) -> str:
133
+ """Browser link that opens the conversation in Agent Canvas.
134
+
135
+ Share this link with users. `conversation_url` on the conversation record is
136
+ the sandbox's agent-server API, not a page a user can open.
137
+ """
138
+ conversation = self.app_conversation_get(conversation_id) or {}
139
+ # Servers that predate `conversation_ui_url` serve Agent Canvas at /canvas.
140
+ return (
141
+ conversation.get("conversation_ui_url")
142
+ or f"{self.base_url}/canvas/conversations/{conversation_id}"
143
+ )
144
+
132
145
  def sandboxes_search(self, *, limit: int = 20) -> dict[str, Any]:
133
146
  limit = max(1, int(limit))
134
147
  r = self._client.get(f"{self.api_v1_url}/sandboxes/search", params={"limit": limit})
@@ -47,7 +47,7 @@ The agent server typically runs inside a **sandbox** (a Docker or Kubernetes con
47
47
  | Variable | Availability | Description |
48
48
  |---|---|---|
49
49
  | `RUNTIME_URL` | Ambient in cloud environments | Public-facing URL of the **agent server** sandbox. Use this to determine whether external webhook delivery is possible — if unset or local, webhooks cannot be received. The automation service may run at a separate URL (see Determining the API Host). |
50
- | `AGENT_SERVER_URL` | Injected into scripts at run time only | Internal URL of the agent server. Available inside script execution context; **not** an ambient environment variable outside of a running script. |
50
+ | `AGENT_SERVER_URL` | Injected into scripts at run time, local Agent Canvas only | Internal URL of the agent server. Available inside script execution context; **not** an ambient environment variable outside of a running script, and not set on OpenHands Cloud or Enterprise. |
51
51
  | `OPENHANDS_HOST` | Shell convention only — set manually | Base URL for the automation service API. **Not a real environment variable.** Set it from an explicit host, a detected local Agent Canvas server, or the cloud default. Used in all `curl` examples throughout this skill. |
52
52
 
53
53
  > **⚠️ CRITICAL — Agent behavior rules:**
@@ -77,17 +77,22 @@ The agent server typically runs inside a **sandbox** (a Docker or Kubernetes con
77
77
 
78
78
  ### No-LLM Script Helpers
79
79
 
80
- When building a deterministic custom script, these two stdlib-only functions are required. Copy them verbatim — they use `AGENT_SERVER_URL` and `SESSION_API_KEY` injected by the automation service.
80
+ When building a deterministic custom script, these two stdlib-only functions are required. Copy them verbatim - they use variables injected by the automation service: `SESSION_API_KEY`, plus `AGENT_SERVER_URL` on a local Agent Canvas or `OPENHANDS_CLOUD_API_URL` and `SANDBOX_ID` on OpenHands Cloud and Enterprise.
81
81
 
82
82
  ```python
83
83
  import json, os, urllib.request
84
84
 
85
85
  def get_secret(name):
86
- """Fetch a named secret stored in the agent server."""
86
+ """Fetch a named secret: from the agent server when AGENT_SERVER_URL is set, else from the OpenHands API."""
87
87
  url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
88
88
  key = os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0", "")
89
+ if url: # local Agent Canvas
90
+ url = f"{url}/api/settings/secrets/{name}"
91
+ else: # OpenHands Cloud / Enterprise
92
+ api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
93
+ url = f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
89
94
  with urllib.request.urlopen(urllib.request.Request(
90
- f"{url}/api/settings/secrets/{name}", headers={"X-Session-API-Key": key}
95
+ url, headers={"X-Session-API-Key": key}
91
96
  )) as r:
92
97
  return r.read().decode().strip()
93
98
 
@@ -498,6 +503,7 @@ For services other than GitHub (Linear, Stripe, Slack, etc.), register a custom
498
503
 
499
504
  > **Agent behavior:**
500
505
  > - **Always provide the curl request** to the user — do not attempt to register webhooks yourself.
506
+ > The exception is a service skill that ships a setup script for its webhook, such as `jira-issue-to-pr`: the script registers both sides and keeps the signing secret out of the conversation, so run it as that skill describes.
501
507
  > - **Ask the user:** "Do you have a webhook signing secret from [service], or should the system generate one?"
502
508
  > - If they have one → include `webhook_secret` in the request
503
509
  > - If not → omit it; the response will contain a generated secret they must configure in their service
@@ -619,11 +625,67 @@ curl -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
619
625
  }'
620
626
  ```
621
627
 
628
+ ### Custom Webhook Example: Jira Cloud
629
+
630
+ Jira Cloud sends webhooks with:
631
+ - Signature header: `X-Hub-Signature` (`sha256=` followed by the hex HMAC-SHA256 of the body), sent only when the webhook has a secret
632
+ - Event type in payload: `webhookEvent` field (e.g., `jira:issue_created`, `jira:issue_updated`)
633
+ - Labels as plain strings: `issue.fields.labels` is `["create-pr"]`, not a list of objects, so `issue.fields.labels[].name` matches nothing
634
+ - On an update, a `changelog.items` list naming each changed `field`
635
+
636
+ Only a webhook a Jira admin registers (Jira settings → System → WebHooks, or the REST API) can carry a secret. The "Send web request" action of a Jira Automation rule cannot sign its request, so it cannot deliver to a custom webhook.
637
+
638
+ When the user can supply the API token of a Jira administrator, `scripts/setup_webhook.py` in the `jira-issue-to-pr` skill registers the webhook on both sides for them, replacing step 1 and the Jira configuration below. Done by hand:
639
+
640
+ ```bash
641
+ # 1. Register the Jira webhook
642
+ # - Use "X-Hub-Signature" as the signature header
643
+ # - Use "webhookEvent" to extract the event type from the payload
644
+ # - Omit webhook_secret to have one generated, or pass the one Jira generated
645
+ curl -X POST "${OPENHANDS_HOST}/api/automation/v1/webhooks" \
646
+ -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \
647
+ -H "Content-Type: application/json" \
648
+ -d '{
649
+ "name": "Jira Cloud",
650
+ "source": "jira",
651
+ "event_key_expr": "webhookEvent",
652
+ "signature_header": "X-Hub-Signature"
653
+ }'
654
+
655
+ # Response includes webhook_url and the generated webhook_secret - configure both in Jira:
656
+ # Jira settings → System → WebHooks → Create a WebHook → paste the webhook_url and the
657
+ # secret, then select the issue events. A JQL filter such as `labels = create-pr` keeps
658
+ # unrelated issues from being sent at all.
659
+
660
+ # 2. Create an automation for issues that receive a label
661
+ # The filter matches an issue created with the label and an update that changes the
662
+ # labels of an issue carrying it. Without the changelog check, every later edit of a
663
+ # labelled issue would start another run. It still fires when another label is added
664
+ # to or removed from an issue that carries the label, so have the prompt skip an
665
+ # issue that already has a pull request.
666
+ curl -X POST "${OPENHANDS_HOST}/api/automation/v1/preset/prompt" \
667
+ -H "Authorization: Bearer ${OPENHANDS_API_KEY}" \
668
+ -H "Content-Type: application/json" \
669
+ -d '{
670
+ "name": "Jira Issues Labelled create-pr",
671
+ "prompt": "A Jira issue received the create-pr label. Read the issue from the event payload and open a pull request for it.",
672
+ "trigger": {
673
+ "type": "event",
674
+ "source": "jira",
675
+ "on": ["jira:issue_created", "jira:issue_updated"],
676
+ "filter": "contains(issue.fields.labels, '\''create-pr'\'') && (webhookEvent == '\''jira:issue_created'\'' || length(changelog.items[?field == '\''labels'\''] || `[]`) > `0`)"
677
+ }
678
+ }'
679
+ ```
680
+
681
+ An unsigned request to the webhook URL answers `401 Missing signature header: X-Hub-Signature`; that is the webhook working, not a fault. `404 Unknown webhook source` means no webhook with that source is registered for the organization in the URL.
682
+
622
683
  ### Common Signature Headers by Service
623
684
 
624
685
  | Service | Signature Header | Event Key Expression |
625
686
  |---------|-----------------|---------------------|
626
687
  | Linear | `Linear-Signature` | `type` |
688
+ | Jira Cloud | `X-Hub-Signature` | `webhookEvent` |
627
689
  | Stripe | `Stripe-Signature` | `type` |
628
690
  | Slack | `X-Slack-Signature` | `type` |
629
691
  | Twilio | `X-Twilio-Signature` | `type` |
@@ -223,7 +223,7 @@ Key points:
223
223
  - **Your script and its conversations share the same agent server.** There is no network hop to a remote service.
224
224
  - **Conversations are asynchronous.** You can fire one and continue, fire several concurrently, or start none at all (e.g. if your script fetches external data and decides no action is needed).
225
225
  - **The completion callback** is sent by `OpenHandsCloudWorkspace.__exit__` when the `with` block exits, telling the automation service the run is done. For async patterns, defer exiting until the conversation is in the desired state.
226
- - **Secrets** stored in the agent server are accessed via its REST API: `GET {AGENT_SERVER_URL}/api/settings/secrets/{name}` with `X-Session-API-Key: {SESSION_API_KEY}`. SDK scripts can also call `workspace.get_llm()` to get the configured LLM.
226
+ - **Secrets** are fetched over REST with `X-Session-API-Key: {SESSION_API_KEY}`. When `AGENT_SERVER_URL` is set (local Agent Canvas): `GET {AGENT_SERVER_URL}/api/settings/secrets/{name}`. Otherwise (OpenHands Cloud and Enterprise): `GET {OPENHANDS_CLOUD_API_URL}/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets/{name}`. SDK scripts can also call `workspace.get_llm()` to get the configured LLM.
227
227
 
228
228
  **SDK Documentation:** https://docs.openhands.dev/sdk
229
229
 
@@ -421,7 +421,7 @@ with OpenHandsCloudWorkspace(local_agent_server_mode=True, cloud_api_url=api_url
421
421
 
422
422
  For tasks that don't need AI reasoning — sending a Slack message, calling an API, rotating from a fixed list — skip the SDK entirely. Use pure Python stdlib with `python3 main.py` as the entrypoint and no `setup.sh`.
423
423
 
424
- **Accessing secrets** — custom secrets are not injected into the subprocess environment automatically. Fetch them via the agent server's REST API:
424
+ **Accessing secrets** — custom secrets are not injected into the subprocess environment automatically. Fetch them over REST (from the agent server when `AGENT_SERVER_URL` is set, otherwise from the OpenHands API):
425
425
 
426
426
  ```python
427
427
  import os, urllib.request
@@ -429,8 +429,15 @@ import os, urllib.request
429
429
  def get_secret(name: str) -> str:
430
430
  url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
431
431
  key = os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0", "")
432
+ if url:
433
+ # Local Agent Canvas: secrets live in the agent server.
434
+ url = f"{url}/api/settings/secrets/{name}"
435
+ else:
436
+ # OpenHands Cloud / Enterprise: secrets live in the OpenHands API.
437
+ api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
438
+ url = f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
432
439
  req = urllib.request.Request(
433
- f"{url}/api/settings/secrets/{name}",
440
+ url,
434
441
  headers={"X-Session-API-Key": key},
435
442
  )
436
443
  with urllib.request.urlopen(req) as r:
@@ -627,11 +634,13 @@ def save_state(state: dict) -> None:
627
634
 
628
635
  ## Environment Variables
629
636
 
630
- The automation service injects these environment variables into every run:
637
+ The automation service injects these environment variables into every run, except where a row says otherwise:
631
638
 
632
639
  | Variable | Alt name | Description |
633
640
  |----------|----------|-------------|
634
- | `AGENT_SERVER_URL` | — | Agent server URL. Used as `cloud_api_url` for `OpenHandsCloudWorkspace`, and as the base URL for secret lookups |
641
+ | `AGENT_SERVER_URL` | — | Agent server URL. Local Agent Canvas only; not set on OpenHands Cloud or Enterprise. Used as `cloud_api_url` for `OpenHandsCloudWorkspace`, and as the base URL for secret lookups |
642
+ | `OPENHANDS_CLOUD_API_URL` | — | OpenHands API base URL. OpenHands Cloud and Enterprise only. Base URL for secret lookups |
643
+ | `SANDBOX_ID` | — | ID of the sandbox the run executes in. OpenHands Cloud and Enterprise only. Part of the secret lookup URL |
635
644
  | `OH_SESSION_API_KEYS_0` | `SESSION_API_KEY` | Session API key. Used as `cloud_api_key` for `OpenHandsCloudWorkspace`, and as `X-Session-API-Key` for REST API calls |
636
645
  | `AUTOMATION_CALLBACK_URL` | — | POST here to mark the run complete (done automatically by `OpenHandsCloudWorkspace.__exit__`, or manually in no-LLM scripts) |
637
646
  | `AUTOMATION_CALLBACK_API_KEY` | — | Bearer token for the completion callback POST |
@@ -673,7 +682,12 @@ CHANNEL = "C12345678"
673
682
  def get_secret(name):
674
683
  url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
675
684
  key = os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0", "")
676
- req = urllib.request.Request(f"{url}/api/settings/secrets/{name}",
685
+ if url: # local Agent Canvas
686
+ url = f"{url}/api/settings/secrets/{name}"
687
+ else: # OpenHands Cloud / Enterprise
688
+ api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
689
+ url = f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
690
+ req = urllib.request.Request(url,
677
691
  headers={"X-Session-API-Key": key})
678
692
  with urllib.request.urlopen(req) as r:
679
693
  return r.read().decode().strip()
@@ -823,7 +837,7 @@ The upload limit is 1MB. Reduce your tarball size by:
823
837
  ### Run fails instantly with `error_detail: null`
824
838
  The script sent `fire_callback("FAILED")` immediately — before doing meaningful work. Common causes:
825
839
  - A required secret was empty: the `get_secret()` call failed or returned nothing
826
- - A missing/wrong `AGENT_SERVER_URL` or `SESSION_API_KEY`
840
+ - A missing/wrong `SESSION_API_KEY`, or a missing `OPENHANDS_CLOUD_API_URL` / `SANDBOX_ID` (`AGENT_SERVER_URL` on a local Agent Canvas)
827
841
  - An import error in the entrypoint
828
842
 
829
843
  Add `"error": str(exc)` to your `fire_callback("FAILED", ...)` call so `error_detail` is populated.
@@ -201,6 +201,7 @@ Source: [`examples/`](https://github.com/OpenHands/software-agent-sdk/tree/main/
201
201
  - [`56_structured_output.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/56_structured_output.py)
202
202
  - [`57_prompt_hooks`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/57_prompt_hooks)
203
203
  - [`58_ask_oracle_tool`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/58_ask_oracle_tool)
204
+ - [`59_route_task_to_model.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/59_route_task_to_model.py)
204
205
 
205
206
  ### [`02_remote_agent_server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server)
206
207
 
@@ -57,12 +57,22 @@ gh api -X POST repos/{owner}/{repo}/pulls/{pr_number}/reviews --input /tmp/revie
57
57
  | Parameter | Description |
58
58
  |-----------|-------------|
59
59
  | `commit_id` | Commit SHA to comment on (use `git rev-parse HEAD`) |
60
- | `event` | `COMMENT`, `APPROVE`, or `REQUEST_CHANGES` |
60
+ | `event` | `APPROVE`, `COMMENT`, or `REQUEST_CHANGES` (see below) |
61
61
  | `path` | File path as shown in the diff |
62
62
  | `line` | Line number in the NEW version (right side of diff) |
63
63
  | `side` | `RIGHT` for new/added lines, `LEFT` for deleted lines |
64
64
  | `body` | Comment text with priority label |
65
65
 
66
+ ### Choosing the `event` Value
67
+
68
+ | Event | When to use |
69
+ |-------|-------------|
70
+ | `APPROVE` | No issues found, or only 🟡 suggestions that are non-blocking |
71
+ | `COMMENT` | You have 🟠 Important feedback but it's not a hard blocker |
72
+ | `REQUEST_CHANGES` | There are 🔴 Critical issues that **must** be fixed before merge |
73
+
74
+ **Default to `APPROVE`** when the code is correct and merge-ready. Use `COMMENT` or `REQUEST_CHANGES` only when there are actionable issues that warrant it.
75
+
66
76
  ### Multi-Line Comments
67
77
 
68
78
  For comments spanning multiple lines, add `start_line` to specify the range:
@@ -184,4 +194,4 @@ curl -X POST \
184
194
  5. Do NOT post comments for code that is acceptable — only comment when action is needed
185
195
  6. Use suggestion syntax for concrete code changes, but only after verifying the resulting code matches your description (see "How Suggestions Actually Work")
186
196
  7. Keep the review body brief (details go in inline comments)
187
- 8. If no issues: post a short approval message with no inline comments
197
+ 8. If no issues: use `"event": "APPROVE"` with a short approval message and no inline comments
@@ -57,12 +57,22 @@ gh api -X POST repos/{owner}/{repo}/pulls/{pr_number}/reviews --input /tmp/revie
57
57
  | Parameter | Description |
58
58
  |-----------|-------------|
59
59
  | `commit_id` | Commit SHA to comment on (use `git rev-parse HEAD`) |
60
- | `event` | `COMMENT`, `APPROVE`, or `REQUEST_CHANGES` |
60
+ | `event` | `APPROVE`, `COMMENT`, or `REQUEST_CHANGES` (see below) |
61
61
  | `path` | File path as shown in the diff |
62
62
  | `line` | Line number in the NEW version (right side of diff) |
63
63
  | `side` | `RIGHT` for new/added lines, `LEFT` for deleted lines |
64
64
  | `body` | Comment text with priority label |
65
65
 
66
+ ### Choosing the `event` Value
67
+
68
+ | Event | When to use |
69
+ |-------|-------------|
70
+ | `APPROVE` | No issues found, or only 🟡 suggestions that are non-blocking |
71
+ | `COMMENT` | You have 🟠 Important feedback but it's not a hard blocker |
72
+ | `REQUEST_CHANGES` | There are 🔴 Critical issues that **must** be fixed before merge |
73
+
74
+ **Default to `APPROVE`** when the code is correct and merge-ready. Use `COMMENT` or `REQUEST_CHANGES` only when there are actionable issues that warrant it.
75
+
66
76
  ### Multi-Line Comments
67
77
 
68
78
  For comments spanning multiple lines, add `start_line` to specify the range:
@@ -184,4 +194,4 @@ curl -X POST \
184
194
  5. Do NOT post comments for code that is acceptable — only comment when action is needed
185
195
  6. Use suggestion syntax for concrete code changes, but only after verifying the resulting code matches your description (see "How Suggestions Actually Work")
186
196
  7. Keep the review body brief (details go in inline comments)
187
- 8. If no issues: post a short approval message with no inline comments
197
+ 8. If no issues: use `"event": "APPROVE"` with a short approval message and no inline comments