- name
- workflows-custom-steps
- description
- Register and implement custom workflow steps from an external Kibana plugin using `@kbn/workflows-extensions`. Use when adding or modifying a step type with `registerStepDefinition`, designing input/output/config Zod schemas, implementing `createServerStepDefinition` / `createPublicStepDefinition`, choosing `StepCategory`, building `editorHandlers` (selection / dynamicSchema), wiring `callKibanaApi` / `onCancel`, deciding sync vs async loader registration, managing per-step approval files under `approved_step_definitions/`, or reviewing PRs that touch any of these.
# Workflows — Custom Step Registration
> Custom steps run inside the Kibana workflow engine and become part of the YAML the user writes. A misconfigured step can break workflows on every restart, pull heavy modules into the main bundle, leak resources on cancellation, or silently fail the approval gate. The defaults are not always right — verify each field below explicitly.
## Canonical guide — read this for implementation steps
Follow the end-to-end steps, code templates, naming conventions, and approval workflow in:
**[dev_docs/STEPS.md](../../dev_docs/STEPS.md)**
Sections to use while implementing:
| Task | STEPS.md section |
|---|---|
| Internal vs external boundary | [Important: Internal vs External Steps](../../dev_docs/STEPS.md#important-internal-vs-external-steps) |
| Define common step (`id`, schemas, `label`, `category`, `documentation`) | [Step 1](../../dev_docs/STEPS.md#step-1-define-common-step-definition) |
| Server handler (`createServerStepDefinition`) | [Step 2](../../dev_docs/STEPS.md#step-2-implement-server-side-handler) |
| Public definition (icon, editor handlers) | [Step 3](../../dev_docs/STEPS.md#step-3-implement-public-side-definition) |
| Custom property selection / dynamic schema | [Custom Property Selection](../../dev_docs/STEPS.md#custom-property-selection) |
| Plugin setup registration (sync vs async loader) | [Step 4](../../dev_docs/STEPS.md#step-4-register-in-plugin-setup) |
| YAML naming conventions (type / config / input) | [Workflow YAML Naming Conventions](../../dev_docs/STEPS.md#workflow-yaml-naming-conventions) |
| `config` vs `input` mental model | [Config vs Inputs: Mental Model](../../dev_docs/STEPS.md#config-vs-inputs-mental-model) |
| Error handling and `ExecutionError` | [Error Handling](../../dev_docs/STEPS.md#error-handling) |
| `callKibanaApi` usage | [Calling Kibana APIs](../../dev_docs/STEPS.md#calling-kibana-apis-callkibanaapi) |
| Cancellation cleanup | [Cancellation Cleanup (`onCancel`)](../../dev_docs/STEPS.md#cancellation-cleanup-oncancel) |
| Approval gate (per-step file workflow) | [Step Definition Approval Process](../../dev_docs/STEPS.md#step-definition-approval-process) |
## Overview
A custom workflow step is owned and registered by a **plugin other than `workflows_extensions`**. The workflows-team plugin only hosts internal steps; everything external must live in the owning plugin.
A step lives in three layers:
- **Common** — `id`, `label`, `description`, `category`, `inputSchema`, `outputSchema`, optional `configSchema` / `documentation` / `stability` / `deprecation`. Imported by both server and public to keep them in sync.
- **Server** — wraps the common definition with a `handler` (and optional `onCancel`) via `createServerStepDefinition`.
- **Public** — wraps the common definition with an `icon` and optional `editorHandlers` via `createPublicStepDefinition`.
Both sides register through the `workflowsExtensions` setup contract: `registerStepDefinition(definition | () => Promise<definition | undefined>)`.
**Additional references:**
- Extended code templates (full scaffold, selection handlers, dynamic schema): [reference.md](reference.md)
- Worked example: `examples/workflows_extensions_example/`
- Common base type: `src/platform/packages/shared/kbn-workflows/spec/step_definition_types.ts` (`BaseStepDefinition`, `StepCategory`, `StabilityLevel`, `StepDocumentation`)
- Editor handler types: `src/platform/packages/shared/kbn-workflows/types/v1.ts` (`EditorHandlers`, `StepPropertyHandler`, `PropertySelectionHandler`, `ConnectorIdSelectionHandler`, `DynamicSchema`)
- Reusable index selection handler: `src/platform/packages/shared/kbn-workflows-ui/src/lib/steps/editor_handlers/index_selection_handler.ts`
- Server step types: `src/platform/plugins/shared/workflows_extensions/server/step_registry/types.ts` (`ServerStepDefinition`, `StepHandler`, `StepHandlerContext`, `ContextManager`, `CallKibanaApiParams`, `OnCancelHandler`)
- Public step types: `src/platform/plugins/shared/workflows_extensions/public/step_registry/types.ts` (`PublicStepDefinition`)
- `ExecutionError`: `src/platform/packages/shared/kbn-workflows/server/errors/execution_error.ts`
- Approval fixtures (one file per step): `src/platform/plugins/shared/workflows_extensions/test/scout/api/fixtures/approved_step_definitions/`
## 0. Locate the owning plugin (do this first)
**Before creating or editing any files**, ask the user for their **plugin id** (`plugin.id` from `kibana.jsonc`, camelCase — e.g. `cases`, `agentBuilder`, `workflowsExtensionsExample`). Do not guess or assume a plugin.
If the user already named their plugin in the request, confirm it matches `plugin.id` before proceeding.
### Resolve the plugin root
```bash
rg '"id": "<pluginId>"' --glob '**/kibana.jsonc'
```
The directory containing that `kibana.jsonc` is the **plugin root**. Read it to confirm `plugin.server` / `plugin.browser` and whether `workflowsExtensions` is already in `requiredPlugins`.
### Choose file locations inside the plugin
Inspect the plugin root for existing step or workflow extension layout. **Follow conventions already used in that plugin** rather than inventing new paths.
| If the plugin already has… | Add files there |
|---|---|
| `common/workflows/steps/` (e.g. `cases`) | `common/workflows/steps/<step_name>.ts` |
| `common/step_types/` (e.g. example plugin) | `common/step_types/<step_name>.ts` |
| No step files yet | Use `common/step_types/<step_name>.ts` and mirror under `server/step_types/` and `public/step_types/` |
Also check for existing registration hooks:
- `server/**/step_types/index.ts` or `server/plugin.ts` — server registration
- `public/**/step_types/` or `public/plugin.ts` — public registration
Wire new steps into those existing index/setup files when present; only create new index files when the plugin has no step layout yet.
### Derive the step namespace
Step ids use `<namespace>.<action>` (kebab-case namespace, camelCase action). Prefer the namespace already used by that plugin's steps. If none exist, derive kebab-case from `plugin.id` (e.g. `agentBuilder` → `agent-builder`) and confirm with the user if ambiguous. Reserved internal prefixes (`ai`, `data`, `flowControl`, `external`, `elasticsearch`, `kibana`, `kibana.cases`) must not be used outside `workflows_extensions`.
## File layout
Mirror the layout used by `examples/workflows_extensions_example/` so reviewers and the workflows team can find things:
```
your-plugin/
├── common/step_types/<step_name>.ts # common definition (id + schemas + label + category)
├── server/step_types/<step_name>.ts # createServerStepDefinition + handler
├── server/step_types/index.ts # registerStepDefinitions(setup)
├── public/step_types/<step_name>.ts # createPublicStepDefinition + icon + editorHandlers
└── public/step_types/index.ts # registerStepDefinitions(setup, deps)
```
Keep `id`, `inputSchema`, `outputSchema`, `configSchema` in the **common** file only. Re-importing them on both sides is how server/public stay locked together.
## Agent-specific rules (beyond STEPS.md)
These are easy to miss during implementation or review — they are not always spelled out in the contributing doc:
| Concern | Rule |
|---|---|
| `id` reserved prefixes | `ai`, `data`, `flowControl`, `external`, `elasticsearch`, `kibana`, `kibana.cases` are reserved for internal/categorized steps. The `elasticsearch.` prefix is also special-cased by the auto-generated step path. Use a fresh kebab-case namespace per plugin |
| `category` field name | The live enum is `StepCategory` from `@kbn/workflows` (values: `Elasticsearch`, `External`, `Ai`, `Kibana`, `KibanaCases`, `Data`, `FlowControl`). Some older docs still say `actionsMenuCatalog` / `StepMenuCatalog` — those names are stale |
| i18n template syntax | Strings in `documentation.details` / `documentation.examples[]` that contain `{{ ... }}` MUST be passed through i18n `values:` so the i18n linter does not interpret them as variables |
| Reserved config keys | `if`, `foreach`, `on-failure`, `timeout` are reserved by the engine — never redeclare them in `configSchema` |
| Real plugin clients beat `callKibanaApi` | When the target plugin exposes a request-scoped client (`alerting.getRulesClientWithRequest`, `cases.getCasesClientWithRequest`, etc.), pass `context.contextManager.getFakeRequest()` to it. `callKibanaApi` is the fallback when no client exists |
| `callKibanaApi` hard limits | No multipart / `form_data`, no streaming/SSE, no custom TLS or fetcher options. Caller-supplied `Authorization`, `Content-Type`, `kbn-xsrf`, `x-elastic-internal-origin`, and event-chain headers are dropped (engine owns them). Non-2xx (except 304) throws `Error('HTTP <status>: <body>')`. For unsupported transports use the `kibana.request` YAML step |
| `onCancel` semantics | Invoked **after** `abortSignal` fires AND `run()` resolves — never in parallel. Steps that complete normally skip it. MUST be idempotent; thrown errors are logged but never disrupt cancellation. An empty `onCancel` "just to be safe" is an anti-pattern |
| `ExecutionError` type discipline | Pick **specific** `type` values (`ValidationError`, `PermissionError`, `NetworkError`) — never `'Error'`. Plain `throw new Error(...)` is auto-converted; only reach for `ExecutionError` when you need a custom `type` or structured `details` |
| Public icon | **Must be a React component** via `React.lazy` from `@elastic/eui/es/components/icon/assets/*`. EUI icon name strings (`'star'`) are not supported — the build will not fail, the icon will simply be missing |
| `connectorIdSelection` placement | Only recognised on **`config['connector-id']`** (exact key, under `config`). Renaming to `connectorId`, `connector_id`, or `my-connector-id`, or moving under `input`, silently disables the picker. Verified in `workflows_management/public/shared/lib/connectors_utils.ts` |
| `getIndexSelectionHandler` wiring | Requires `dataViews` (`DataViewsContract`) and `application` (`ApplicationStart`) services — must be injected via a public-side factory. Attaches in the **`selection`** slot of any field whose value is an index pattern (not field-name restricted) |
| `selection.dependsOnValues` | List every sibling field (`config.foo` / `input.x` dot path) your `search` / `resolve` / `getDetails` read from `context.values`. Missing entries cause stale cache hits when the user edits the sibling |
| `selection.getDetails` | Avoid network calls when `option` is present — use `option.label` / `option.value` / `context.values`. Only fetch when `option === null`. The combined `resolve` + `getDetails` outcome is cached for ~30s per logical field |
| Dynamic output schema | Express via `editorHandlers.dynamicSchema.getOutputSchema({ input, config })` for autocomplete; **server still validates against the static `outputSchema`** in the common definition, so keep that schema as the union of all possible shapes |
| Public async loader | Prefer async import to keep zod + step module out of the plugin's main bundle. Loaders that reject (or throw inside the registry) are caught and logged; one broken loader does NOT prevent other steps from registering — verify the log when a step is silently missing |
| Conditional registration | Loaders returning `undefined` are skipped silently (unlike triggers, which do not support this). Use for feature flags |
| Registration timing | All `registerStepDefinition` calls happen in `setup()`, never `start()`. Engine and UI both `await workflowsExtensions.isReady()` before reading the registry |
For the YAML naming conventions (the single most common mistake — only the step type action is camelCase, config/input keys we own never are), follow [STEPS.md → Workflow YAML Naming Conventions](../../dev_docs/STEPS.md#workflow-yaml-naming-conventions).
## Quick rule reference
| Concern | Rule | Default if omitted | When wrong |
|---|---|---|---|
| `id` | `<kebab-namespace>.<camelAction>`; stable for the life of the workflow | n/a | Renames break user YAML; reserved prefixes collide with internals |
| Common file | Holds `id`, schemas, `label`, `description`, `category`, `documentation` | n/a | Drift between server and public side |
| `category` | Pick the `StepCategory` matching the actions-menu group users expect | n/a | Step hides in the wrong section |
| `configSchema` vs `inputSchema` | Config controls behavior; input carries payload | n/a | Awkward YAML; bad selection UX |
| Key casing (keys we own) | Config: kebab-case; input (`with:`): kebab-case or snake_case — never camelCase | n/a | camelCase args drift from the conventions; inherited OpenAPI/connector shapes excepted |
| `createServerStepDefinition` | Use it (don't hand-annotate types) | n/a | Loss of input/output type inference, drift |
| `abortSignal` | Pass to ES, HTTP, loops | n/a | Step runs past cancellation; blocks shutdown |
| `onCancel` | Implement only when the step holds resources beyond the signal | none | Leaks; or empty stub gives false confidence |
| `ExecutionError` | Use when you need a custom `type` or `details` | auto-conversion of raw errors | Useless `'Error'` type; lost debugging context |
| `callKibanaApi` | Use only when no request-scoped client exists | n/a | Re-invents auth; brittle on transport change |
| Icon | `React.lazy` from `@elastic/eui/es/components/icon/assets/*` | none | Missing icon in editor; bundles all EUI icons |
| Connector picker | `connectorIdSelection` on `config['connector-id']` | none | Hand-rolled selection misses "create connector" link + type filtering |
| Index picker | `getIndexSelectionHandler({ dataViews, application }, options)` in `selection` slot | none | Hand-rolled handler misses wildcard/alias/data-stream semantics |
| `selection.dependsOnValues` | List every sibling field your handlers read | `{ config: {}, input: {} }` | Stale cache hits on sibling edits |
| `selection.getDetails` | Avoid network calls when `option` is present | n/a | Slow hovers; redundant fetches |
| Public registration | Async loader to keep modules out of main bundle | sync inline | Step module + zod inflate plugin bundle |
| Conditional registration | Loader returns `undefined` to skip | always registers | Cannot feature-flag |
| Approval gate | Create/update `approved_step_definitions/<step.id>.txt` with the new `definitionHash` (one file per step) | test fails | CI blocks merge until updated |
## Author checklist
When adding a new step:
1. **Plugin location**
- [ ] User's `plugin.id` confirmed; plugin root resolved from `kibana.jsonc`
- [ ] File paths follow the plugin's existing step/workflow layout
- [ ] `workflowsExtensions` is in `requiredPlugins` in `kibana.jsonc`
Auf GitHub ansehen