Loading

Human-in-the-loop workflows

Not every decision should be fully automated. Human-in-the-loop (HITL) is the pattern where a workflow pauses at a critical decision point, presents structured findings to a responder, waits for their input, and then resumes based on that input. It lets you combine the reach of automation with human judgment where judgment matters most.

  • Remediation with potential impact. Isolating a host, blocking a user, or deleting data. Pause for analyst approval before the destructive action.
  • Ambiguous classifications. When an AI or rule is uncertain, ask a human before proceeding.
  • Escalation gates. Page an on-call, wait for acknowledgement and decision, then route accordingly.
  • Approval for automation. A new workflow in test mode can pause and ask for approval on each action for the first few runs, then switch to full automation once trusted.

HITL is built on wait steps that pause execution until a human responds:

Step Use when Response shape
waitForInput You need a custom form (notes, severity, multi-field decisions) Your JSON Schema payload (see output shape)
waitForApproval The decision is approve or reject approved: true or false under output.response

When the workflow reaches either step, execution pauses in the WAITING_FOR_INPUT state. The responder sees the message and form (or approve/reject controls). When they respond, the workflow resumes with their input available as steps.<step_name>.output.

While a HITL step is waiting, the execution status is WAITING_FOR_INPUT and the run appears in the execution history with a resume action.

When Inbox is enabled, the waiting action also appears there.

Timeout behavior depends on the Elastic Stack version that you're using:

Wait steps time out by default: waitForInput after 72h and waitForApproval after 24h. These defaults apply whether you configure an external channel. Override them with a top-level timeout on the step. If no one responds before the timeout, the step fails.

waitForInput has no default timeout and waits until someone responds. To limit the wait, set a workflow-level settings.timeout. If that timeout elapses before anyone responds, the execution is cancelled.

The three ingredients: a preceding step that gathers context, a wait step that presents it, and subsequent steps that branch on the responder's decision.

name: isolate-host-with-approval
enabled: true

triggers:
  - type: alert

steps:
  - name: open_case
    type: cases.createCase
    with:
      title: "Potential compromise: {{ event.alerts[0].host.name }}"
      description: "Potential host compromise detected by alert workflow"
      owner: securitySolution
      severity: high
      tags: ["auto-triage"]

  - name: investigate
    type: elasticsearch.search
    with:
      index: "logs-*"
      query:
        term:
          "host.name": "{{ event.alerts[0].host.name }}"

  - name: classify
    type: ai.classify
    connector-id: "my-openai"
    with:
      input: "${{ steps.investigate.output.hits.hits }}"
      categories: ["confirmed_compromise", "likely_benign", "needs_review"]
      includeRationale: true

  - name: review
    type: waitForInput
    timeout: 72h
    with:
      message: |
        ## Alert on `{{ event.alerts[0].host.name }}`

        **AI classification:** {{ steps.classify.output.category }}

        **Rationale:** {{ steps.classify.output.rationale }}

        Isolate this host?
      schema:
        type: object
        properties:
          approved:
            type: boolean
            title: "Isolate the host"
          notes:
            type: string
            title: "Analyst notes"
        required: ["approved"]

  - name: isolate
    type: http
    if: "steps.review.output.response.approved : true"
    connector-id: "edr-connector"
    with:
      method: "POST"
      url: "https://edr.example.com/isolate"
      body:
        host: "{{ event.alerts[0].host.name }}"
        reason: "{{ steps.review.output.response.notes }}"

  - name: record_decision
    type: cases.addComment
    with:
      case_id: "{{ steps.open_case.output.case.id }}"
      comment: |
        **Decision:** {% if steps.review.output.response.approved %}isolated{% else %}no action{% endif %}
        **Notes:** {{ steps.review.output.response.notes }}
        **Responded by:** {{ steps.review.output.respondedBy }}
		

Execution pauses at review. Until someone responds, the execution state is WAITING_FOR_INPUT. When they respond, execution resumes at isolate, which is gated by an if guard on the approval decision.

The review step above is a simple yes/no decision, so you can replace it with waitForApproval, which renders approve/reject controls without a schema:

- name: review
  type: waitForApproval
  with:
    message: |
      ## Alert on `{{ event.alerts[0].host.name }}`

      **AI classification:** {{ steps.classify.output.category }}

      Isolate this host?
		

Downstream steps read the decision from {{ steps.review.output.response.approved }} — the same path as a waitForInput schema that defines an approved boolean.

Resume a paused workflow using any of the following methods. The step resumes on the first response it receives. If the action reaches more than one responder (for example, in a shared Inbox or a Slack channel), only the first response is accepted; the resume token is single-use, so later responses are rejected.

Open the workflow execution view in Kibana. The paused step renders a form generated from the schema (or approve/reject controls for waitForApproval). Fill it in, submit, and the workflow resumes.

The Inbox app is a separate Kibana app that lists HITL actions that need a response across all workflows, so responders don't have to open each execution individually. Enable it with xpack.inbox.enabled: true in kibana.yml (disabled by default). When enabled, open it from the Kibana navigation menu.

Inbox splits into:

  • Awaiting response — Pending waitForInput / waitForApproval steps. Open the Respond flyout to fill in the form (or approve/reject). If the preceding step included a reasoning summary, Inbox shows it to help the responder decide.
  • History — A record of actions that were responded to, timed out, or whose workflow was deleted. Each entry includes who responded, their response, the channel, and when it happened.

Send a POST request to the resume endpoint with the responder's input wrapped in an input object:

POST /api/workflows/executions/{executionId}/resume
Content-Type: application/json

{
  "input": {
    "approved": true,
    "notes": "Confirmed malicious. Proceeding with isolation."
  }
}
		

The input fields become part of the step output. See Output shape for how to reference fields by Stack version.

Configure with.channels on waitForInput or waitForApproval to notify responders in Slack. Slack is the only supported built-in channel. For the channel configuration, refer to waitForApproval external channels or waitForInput external channels.

How responders act:

  • waitForApproval — Responders receive approve and reject links (and Slack API buttons) that allow them to resume the step. They can act without signing in to Kibana.
  • waitForInput — Responders receive a link to a hosted form where they submit the schema fields. The Slack notification can also include a query link, which responders complete by appending the schema field values.

External links work without a Kibana session. Each link includes a short-lived, single-use token that Kibana invalidates after use, timeout, or workflow cancellation.

Warning

External channels send public, short-lived resume links. Don't use them for destructive, production-impacting, or hard-to-reverse workflows.

To disable external resume links so that workflows can only be resumed from Kibana (the execution view, the Inbox app, or the resume API), set external resume to false in kibana.yml:

workflowsManagement.hitlExternalResume.enabled: false
workflowsExecutionEngine.hitlExternalResume.enabled: false
		

Both settings default to true. When set to false, Slack and other external resume links stop working.

How you read the resume payload depends on the Elastic Stack version:

  • Fields are under output.response, and the responder is output.respondedBy. For example: {{ steps.review.output.response.approved }}, {{ steps.review.output.response.notes }}, and {{ steps.review.output.respondedBy }}.
  • Fields are on the step output directly. For example: {{ steps.review.output.approved }} and {{ steps.review.output.notes }}.

A HITL message is read by a human mid-incident. Design for speed:

  • Lead with the decision. The first line should say what the responder needs to decide.
  • Include the evidence. Relevant context (alert details, enrichment results, AI rationale) belongs in the message so the responder doesn't have to dig.
  • Keep the schema small. Three fields is a lot. One boolean plus an optional notes field is often enough. Prefer waitForApproval when you only need yes/no.
  • Use Markdown. The message supports Markdown, so use headings, bold text, and bullets to make it scannable.