Skip to content
Merged
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
63 changes: 56 additions & 7 deletions automations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ uploaded to the automations service as a `.tar.gz`, belongs in the same director
catalog/<id>/manifest.json one automation per directory - card metadata plus an optional `setup` block
catalog.schema.json the contract, JSON Schema draft 2020-12
catalog-index.js generated from catalog/ - do not edit by hand
interface.json the production Automation interface manifest - domain-level, not per-entry
interface.schema.json its contract, JSON Schema draft 2020-12
index.js the stable API for Node.js and bundlers
index.d.ts the public TypeScript shape
```
Expand Down Expand Up @@ -117,6 +119,11 @@ An assisted entry declares no triggers, because the trigger is settled during th
- **`mode: "assisted"`** declares a `message`: setup context handed to an agent conversation that finishes
the job. The command that opens that conversation comes from the skill, so it is not repeated here.

A direct entry may also declare a `message`. It is the seed for the fallback conversation the host offers
when the deployment cannot run the direct path - a deployment whose capabilities lack the entry's trigger
kind or required features. The same 2000-character cap applies, and because the fallback fires before the
form is trustworthy, a direct `message` should not reference `{{form.*}}`.

A form field is named after the property it fills. `schedule` and `timezone` under `triggers.cron` become
`trigger.schedule` and `trigger.timezone`; `on` under `triggers.event` becomes `trigger.on`; a field named
`ref` becomes `repos[].ref`. Any other field under a trigger kind, such as a phrase to match, is an input
Expand Down Expand Up @@ -162,6 +169,45 @@ a webhook variant is out of scope, while its `setup` block creates the webhook f
supports. Both statements are accurate about their own generation. Retiring the skill path for entries that
ship a `setup` block belongs to whoever promotes this to production.

## The interface manifest (`interface.json`)

The catalog states what varies per automation. `interface.json` states the domain-level facts of the
production Automation interface - the things that vary between *domains* (automations today, another
extension surface tomorrow) rather than between entries, so each is stated once rather than eight times.
Agent Canvas keeps its rendering components and reads every automation-specific datum from this file,
falling back to its built-in defaults when the manifest is absent or fails admission:

- **`routes`** - the list, setup, and detail routes. The host must have a registration serving each
declared shape, so admission verifies they match what it mounted; the manifest is the single source for
link construction.
- **`navigation`** - the sidebar entry and the command-menu entry.
- **`pages`** - page-identity copy: the list title and subtitle, the detail back label, the edit-dialog
title. Generic chrome - buttons, toasts, empty states, validation sentences - stays host copy, rendered
through the host's translations.
- **`docsUrl`** - the automations documentation link, prefix-pinned to docs.openhands.dev by schema.
- **`attributes`** - the input surface of an existing Automation: which attributes can be set after
creation, keyed by the runtime-model property the host sends (`name`, `prompt`, `model`, `timeout`,
`schedule`), with labels, help, and numeric bounds. How a client offers them - Agent Canvas renders an
edit dialog - is the client's choice, not stated here. `schedule` is a semantic type: the host owns the
frequency/weekday/time composite it renders, as it owns the setup form's `cron` type.
- **`importExport`** - the export file envelope (`kind`, `version`, filename suffix) and the two facts an
import cannot derive from the file: the provider inferred for short repository URLs, and the placeholder
event source that keeps a half-imported automation inert until its real trigger is applied.
- **`endpoints`** - service-relative paths the host calls. Relative paths only: the base path, methods,
headers, and auth remain the host's, so this cannot express a request to anywhere the host did not
choose. `{id}` marks where the host substitutes the automation id.
- **`featuredAutomationIds`** / **`responderIntegrationIds`** - the catalog entries the list page surfaces
as proven, and the integrations whose automations get the responder deployment-choice dialog.

`interface.schema.json` is the authoritative contract, under the same trust rules as the catalog: no
markup in copy, no free-form URLs, closed key sets, no credentials. This narrowly reverses the `routes`
and `submit` deletions recorded below **at the domain level only** - per-entry data still follows the
deletion table.

Changing a route or endpoint here still requires a host release that serves the declared shape, so the
Distribution open question applies unchanged. The contract fixtures pin `/v1/preset/prompt` as the create
endpoint; changing `endpoints.createPrompt` must regenerate them in the same release.

## Contract fixtures

The fixtures are independent test vectors, not part of the runtime catalog. They live in
Expand Down Expand Up @@ -201,11 +247,12 @@ Two findings came out of that check and are baked into the entries:
| Endpoint | Status |
| --- | --- |
| `POST /v1/preset/prompt` | Exists |
| `GET /v1/capabilities` | Does not exist. Defining it is OpenHands/automation#262 |
| `POST /v1/validate` | Does not exist. Defining it is OpenHands/automation#262 |
| `GET /v1/capabilities` | Exists (OpenHands/automation#270) |
| `POST /v1/validate` | Exists (OpenHands/automation#270) |

None of these appear in a manifest. They are the host's, which is why settling on different paths changes
no catalog entry at all.
None of these appear in a catalog entry. Their service-relative paths are stated once, in
`interface.json`'s `endpoints` block, which is why settling on different paths changes no catalog entry at
all.

## Deviations from the project reference document

Expand All @@ -217,7 +264,7 @@ questions to this work. What changed and why:
| A separate manifest file per automation | Merged into the catalog entry as `setup`, so there is one hand-authored record per automation and nothing to keep in sync. |
| `prompt` | Removed. It duplicated the slash command already declared in the skill's `triggers:` frontmatter. The entry names the `skill` instead, defaulting to `id`, and the command is looked up from the skills catalog. |
| `requiredIntegrationIds` | Removed. It duplicated the integration ids in `requires`, which now sits on the entry so every automation carries them once. |
| `routes` | Removed. The only route is `/automations/new/<id>`. |
| `routes` | Removed from entries. The route table is a domain-level fact, stated once in `interface.json`. |
| `capabilities` | Removed. The discovery call is the same for every automation, and the block's only entry-specific content was a feature list, now `requires.features`. |
| `capabilities.bindings` | Removed. The `cron` and `timezone` field types tell the host to resolve the deployment's limits on their own. |
| `requires.secrets` | Removed. An automation that needs GitHub declares the GitHub integration; the credential comes with the connection rather than being asked for twice. |
Expand All @@ -227,7 +274,7 @@ questions to this work. What changed and why:
| `form.fields` | Split into `form.triggers` and `form.args`, and both keyed by field name so the name is the key rather than a repeated property. |
| `validation` | Removed. The preflight call has the same shape for every automation, and the payload-path mapping is recovered by walking `payload`. |
| `review` | Removed. The confirmation screen is the declared fields and their labels. |
| `submit` | Reduced to the parts that vary: `prompt` and, for an event trigger, `filter` for direct; `message` for assisted. The action, endpoint, success navigation, and error handling are identical everywhere. |
| `submit` | Reduced to the parts that vary: `prompt` and, for an event trigger, `filter` for direct; `message` for assisted (and, on direct entries, as the fallback-conversation seed). The action, success navigation, and error handling are identical everywhere; the endpoints are domain-level and live in `interface.json`. |
| `submit.payload` | Removed. `name`, `repos` and `trigger` all restated the form, so they are rebuilt from it. What is left is the prompt and the event filter. |
| `analytics` | Removed. The same stages fire for every automation, so they belong in shared host code. |
| `workflow.steps` | Removed. It restated which keys were present, creating a second source of truth that could contradict the file. |
Expand All @@ -253,6 +300,8 @@ Recorded rather than resolved, because they need an owner outside this contract:
related fields onto one line, which reads better; if that matters, it is a host concern, not an entry's.
- **Assisted-setup completion.** The assisted flow ends at a conversation, so the host cannot emit a
completion event. Until something reports back, the ratio of direct to assisted setup is not measurable.
- **Edit and delete routes.** Only the `new` route is modelled here.
- **Delete confirmation and per-run views.** `interface.json` models the list, setup, and detail routes
and the settable attributes; deletion, run logs, and the dashboard sub-pages remain host-owned surfaces
with no manifest data of their own yet.
- **Types from the schema.** `index.d.ts` is hand-written and mirrors `catalog.schema.json`. Generating it
would remove that second source of truth.
5 changes: 2 additions & 3 deletions automations/catalog.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"$ref": "#/$defs/templateValue"
},
"message": {
"description": "assisted only. Setup context handed to the agent conversation that finishes setup. The command that opens it comes from the skill, so it is not repeated here. Capped so this never becomes a channel for runtime instructions.",
"description": "Setup context handed to the agent conversation that finishes setup. Required for assisted mode. Optional for direct mode, where it seeds the fallback conversation the host offers when the deployment cannot run the direct path. The command that opens it comes from the skill, so it is not repeated here. Capped so this never becomes a channel for runtime instructions.",
"allOf": [{ "$ref": "#/$defs/templateCopy" }, { "maxLength": 2000 }]
}
},
Expand All @@ -79,8 +79,7 @@
"required": ["mode"]
},
"then": {
"required": ["prompt"],
"not": { "required": ["message"] }
"required": ["prompt"]
}
},
{
Expand Down
3 changes: 2 additions & 1 deletion automations/catalog/github-pr-reviewer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
}
}
},
"prompt": "Review pull requests labeled '{{form.triggerLabel}}' in {{form.repository}}. Review tone: {{form.reviewTone}}."
"prompt": "Review pull requests labeled '{{form.triggerLabel}}' in {{form.repository}}. Review tone: {{form.reviewTone}}.",
"message": "This deployment cannot run the scheduled review automation directly. Set it up in this conversation instead: confirm the repository to review, the trigger label, the review tone, and the polling schedule, then create the automation."
}
}
3 changes: 2 additions & 1 deletion automations/catalog/github-repo-monitor/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
}
},
"prompt": "A comment in {{form.repository}} mentions '{{form.triggerPhrase}}'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.",
"filter": "icontains(comment.body, '{{form.triggerPhrase}}') && glob(repository.full_name, '{{form.repository}}')"
"filter": "icontains(comment.body, '{{form.triggerPhrase}}') && glob(repository.full_name, '{{form.repository}}')",
"message": "This deployment cannot run the webhook-driven monitor directly. Set it up in this conversation instead: confirm the repository to watch, the comment event, and the trigger phrase, then create the automation. If webhook delivery is unavailable here, build the polling variant this skill supports."
}
}
123 changes: 122 additions & 1 deletion automations/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,131 @@ export interface AutomationSetup {
prompt?: string;
/** direct only, event trigger only. Which delivered events belong to it. */
filter?: string;
/** assisted only. Setup context for the conversation that finishes setup. */
/**
* Setup context for the conversation that finishes setup. Required for
* assisted mode. Optional for direct mode, where it seeds the fallback
* conversation offered when the deployment cannot run the direct path.
*/
message?: string;
}

/**
* The production Automation interface manifest.
*
* Mirrors `automations/interface.schema.json`, which is authoritative. The
* catalog states what varies per automation; this states the domain-level
* facts of the interface itself: routes, navigation, page-identity copy, the
* settable attributes of an automation, the import/export envelope, the
* service-relative endpoints, and the featured and responder id lists. The
* host validates it at admission and falls back to its built-in defaults when
* it is absent or rejected.
*/

export interface AutomationInterfaceRoutes {
list: string;
/** Carries the `:automationId` segment the host substitutes. */
setup: string;
/** Carries the `:automationId` segment the host substitutes. */
detail: string;
}

export interface AutomationInterfaceNavigation {
sidebar: { label: string };
commandMenu: { title: string; description: string; keywords: string };
}

export interface AutomationInterfacePages {
list: { title: string; subtitle: string };
detail: { backLabel: string };
edit: { title: string };
}

export type AutomationAttributeType =
| "text"
| "textarea"
| "number"
| "llm-profile"
| "schedule";

/** The closed set of runtime-model properties a client may offer for setting. */
export type AutomationAttributeName =
| "name"
| "prompt"
| "model"
| "timeout"
| "schedule";

export interface AutomationAttributeConstraints {
min?: number;
max?: number;
}

/** How one settable attribute of an existing Automation is offered. */
export interface AutomationAttribute {
type: AutomationAttributeType;
label: string;
help?: string;
required: boolean;
/** Only a `number` attribute carries constraints. */
constraints?: AutomationAttributeConstraints;
}

/**
* The input surface of an existing Automation, keyed by the runtime-model
* property the host sends. How a client offers these - Agent Canvas renders
* an edit dialog - is the client's choice, not stated here.
*/
export type AutomationInterfaceAttributes = Partial<
Record<AutomationAttributeName, AutomationAttribute>
>;

export interface AutomationImportDefaults {
/** Provider inferred for short owner/repo repository URLs on import. */
repoProvider: AutomationGitProvider;
/** Event source of the placeholder trigger that keeps an import inert. */
placeholderEventSource: string;
}

export interface AutomationInterfaceImportExport {
fileKind: string;
fileVersion: 1;
filenameSuffix: string;
importDefaults: AutomationImportDefaults;
}

/**
* Service-relative paths the host calls. Relative paths only: the base path,
* methods, headers, and auth remain the host's. `{id}` marks where the host
* substitutes the automation id.
*/
export interface AutomationInterfaceEndpoints {
list: string;
detail: string;
dispatch: string;
runs: string;
tarball: string;
health: string;
capabilities: string;
validate: string;
createPrompt: string;
createPlugin: string;
}

export interface AutomationInterfaceManifest {
version: "1.0";
routes: AutomationInterfaceRoutes;
navigation: AutomationInterfaceNavigation;
pages: AutomationInterfacePages;
docsUrl: string;
attributes: AutomationInterfaceAttributes;
importExport: AutomationInterfaceImportExport;
endpoints: AutomationInterfaceEndpoints;
featuredAutomationIds: string[];
responderIntegrationIds: string[];
}

export const AUTOMATION_INTERFACE: AutomationInterfaceManifest;

export const AUTOMATION_CATALOG: RecommendedAutomation[];
/**
* Return the full automation catalog.
Expand Down
7 changes: 7 additions & 0 deletions automations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* package can statically import each JSON file without an aggregate JSON asset.
*/
import { AUTOMATION_CATALOG_ENTRIES } from "./catalog-index.js";
import interfaceManifest from "./interface.json" with { type: "json" };

const clone = (value) => JSON.parse(JSON.stringify(value));
const AUTOMATIONS = AUTOMATION_CATALOG_ENTRIES;
Expand All @@ -20,4 +21,10 @@ export const getAutomationCatalogEntry = (id) => {

export const AUTOMATION_CATALOG = clone(AUTOMATIONS);

/**
* The production Automation interface manifest: the domain-level facts of the
* interface, hand-authored in `automations/interface.json`.
*/
export const AUTOMATION_INTERFACE = clone(interfaceManifest);

export default AUTOMATION_CATALOG;
70 changes: 70 additions & 0 deletions automations/interface.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"version": "1.0",
"routes": {
"list": "/automations",
"setup": "/automations/new/:automationId",
"detail": "/automations/:automationId"
},
"navigation": {
"sidebar": { "label": "Automate" },
"commandMenu": {
"title": "Automations",
"description": "Review scheduled and webhook automations.",
"keywords": "automate cron schedule webhook jobs"
}
},
"pages": {
"list": {
"title": "Automations",
"subtitle": "View active and inactive automations, search by metadata, and inspect read-only details."
},
"detail": { "backLabel": "Back to Automations" },
"edit": { "title": "Edit automation" }
},
"docsUrl": "https://docs.openhands.dev/openhands/usage/automations/overview",
"attributes": {
"name": { "type": "text", "label": "Name", "required": true },
"prompt": {
"type": "textarea",
"label": "Prompt",
"help": "Edits apply to future runs only.",
"required": false
},
"model": { "type": "llm-profile", "label": "LLM profile", "required": false },
"timeout": {
"type": "number",
"label": "Timeout (seconds)",
"help": "Maximum time a single run may take. Leave empty for the default of 600 seconds (10 minutes); maximum 1800 seconds (30 minutes).",
"required": false,
"constraints": { "min": 1, "max": 1800 }
},
"schedule": { "type": "schedule", "label": "Frequency", "required": false }
},
"importExport": {
"fileKind": "automation",
"fileVersion": 1,
"filenameSuffix": ".automation.json",
"importDefaults": {
"repoProvider": "github",
"placeholderEventSource": "agent-canvas-import"
}
},
"endpoints": {
"list": "/v1",
"detail": "/v1/{id}",
"dispatch": "/v1/{id}/dispatch",
"runs": "/v1/{id}/runs",
"tarball": "/v1/{id}/tarball",
"health": "/health",
"capabilities": "/v1/capabilities",
"validate": "/v1/validate",
"createPrompt": "/v1/preset/prompt",
"createPlugin": "/v1/preset/plugin"
},
"featuredAutomationIds": [
"github-pr-reviewer",
"github-repo-monitor",
"slack-channel-monitor"
],
"responderIntegrationIds": ["github", "slack"]
}
Loading
Loading