​Automate ​Manual ​Approval ​Processes

Modern automation requires human oversight for critical decisions. Kestra enables integration of manual approval steps within workflows while maintaining audit trails and process consistency.

What is Human-in-the-Loop Automation?

Human-in-the-loop (HITL) automation combines automated tasks with human decision points. Kestra implements this through:

  • Pause/Resume – Pause workflows for manual inspection before resuming
  • Dynamic Inputs – Collect user decisions during execution
  • Approval Chains – Route decisions to specific users or teams
  • Audit Logs – Track who approved/rejected each request and why.

Why Use Kestra for Human-in-the-Loop Workflows?

  1. Flexible Integration – Add approval steps to existing workflows in a few lines of YAML
  2. Enterprise Security – Manage permissions via namespace-level RBAC
  3. Cross-Platform Notifications – Send approval requests to Slack, Teams, or Email
  4. Input Validation – Enforce structured responses (Numeric, Boolean, Dates, Dropdowns)
  5. Bulk Actions – Bulk-resume multiple paused workflows when needed.
  6. Audit Trails – Track approvals, rejections, and reasons for each decision.

Example: Vacation Approval Workflow

This workflow demonstrates a complete approval process with Slack notifications and audit logging:

yaml
id: vacation_approval
namespace: hr.operations

inputs:
  - id: employee
    type: STRING
  - id: start_date
    type: DATE
  - id: end_date
    type: DATE

tasks:
  - id: notify_manager
    type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook
    url: "{{ secret('SLACK_HR_WEBHOOK') }}"
    payload: |
      {
        "channel": "#vacation-approvals",
        "text": "Review request from {{ inputs.employee }}\n*Dates*: {{ inputs.start_date }} → {{ inputs.end_date }}\nApprove: {{ appLink('appId') }}"
      }

  - id: await_decision
    type: io.kestra.plugin.core.flow.Pause
    onResume:
      - id: approved
        type: BOOLEAN
        description: Approve this request?
      - id: reason
        type: STRING
        description: Decision notes

  - id: update_hr_system
    type: io.kestra.plugin.core.http.Request
    uri: "{{ kv('HR_API_ENDPOINT') }}/approvals"
    method: POST
    contentType: multipart/form-data
    formData:
      employee: "{{ inputs.employee }}"
      status: "{{ outputs.await_decision.onResume.approved ? 'APPROVED' : 'REJECTED' }}"
      notes: "{{ outputs.await_decision.onResume.reason }}"

  - id: log_result
    type: io.kestra.plugin.core.log.Log
    message: |
      Decision: {{ outputs.await_decision.onResume }}

Kestra Features for Human-in-the-Loop Automation

Structured Inputs for Human Decisions

Add approval steps with structured inputs to any workflow:

yaml
  - id: await_decision
    type: io.kestra.plugin.core.flow.Pause
    onResume:
      - id: approved
        type: BOOLEAN
        displayName: Approve this request?
      - id: reason
        type: STRING
        displayName: Decision notes
      - id: team
        type: SELECT
        displayName: Team to review
        values:
          - HR
          - Finance
          - IT

Bulk Actions

Approve multiple paused workflows simultaneously: Bulk Resume

Audit Trails

Audit Logs capture who approved or rejected each request, and the Pause task's outputs contain the user's decision:

json
{
  "approved": true,
  "reason": "Within policy limits"
}

Conditional Branching

Route next automated tasks based on human decisions:

yaml
  - id: handle_rejection
    type: io.kestra.plugin.core.flow.If
    condition: "{{ outputs.await_decision.onResume.approved is false }}"
    then:
      - id: notify_employee
        type: io.kestra.plugin.notifications.mail.MailSend
        to: "{{ inputs.employee_email }}"
        subject: "Request Denied"
        htmlTextContent: "Reason: {{ outputs.await_decision.onResume.reason }}"

Best practices for long-running workflows

Long approvals can take days or weeks. Kestra persists execution state (including PAUSED state) in the database, so a paused execution survives server restarts and stays PAUSED until you manually resume it via the UI or API.

Keep downstream logic in the same flow (simplest and most common pattern)

The simplest pattern is to keep the entire downstream logic in the same flow after the Pause task:

yaml
id: pause_demo
namespace: demo

tasks:
  - id: initial_logic
    type: io.kestra.plugin.core.log.Log
    message: placeholder for tasks with initial logic before the pause

  - id: wait_for_manual_resume
    type: io.kestra.plugin.core.flow.Pause
    onResume:
      - id: status
        type: STRING

  - id: entire_downstream_logic
    type: io.kestra.plugin.core.log.Log
    message: can have multiple tasks defined here after the pause task

Use this when it’s easier to continue in the same execution after the Pause; for larger systems, consider calling a subflow containing the downstream logic for modularity:

yaml
id: pause_demo_with_subflow
namespace: demo

tasks:
  - id: initial_logic
    type: io.kestra.plugin.core.log.Log
    message: placeholder for tasks with initial logic before the pause

  - id: wait_for_manual_resume
    type: io.kestra.plugin.core.flow.Pause
    onResume:
      - id: status
        type: STRING

  - id: entire_downstream_logic
    type: io.kestra.plugin.core.flow.Subflow
    namespace: demo
    flowId: downstream_logic_flow

Pause + Manual resume + Flow trigger pattern

  1. Flow that can stay paused as long as needed:
yaml
id: pause_demo
namespace: demo

tasks:
  - id: initial_logic
    type: io.kestra.plugin.core.log.Log
    message: placeholder for tasks with initial logic before the pause

  - id: wait_for_manual_resume
    type: io.kestra.plugin.core.flow.Pause
    onResume:
      - id: status
        type: STRING
  1. Resume the paused execution via API when ready:
bash
curl -X POST "http://localhost:28080/api/v1/demo/executions/23F2KgSYm3uCfHJ00DvNxY/resume" \
  -H 'accept: application/json' \
  -F 'status=OK' -H "Authorization: Bearer your_service_account_api_token"
  1. React to the resumed flow’s completion using a Flow trigger (fires on SUCCESS of pause_demo):
yaml
id: resume_demo
namespace: demo

tasks:
  - id: entire_downstream_logic
    type: io.kestra.plugin.core.log.Log
    message: can have multiple tasks defined here that should run after the first flow completes

triggers:
  - id: flow
    type: io.kestra.plugin.core.trigger.Flow
    preconditions:
      id: flow1
      flows:
        - flowId: pause_demo
          namespace: demo
          states:
            - SUCCESS

Why this is robust:

  • The Pause task can keep the execution in PAUSED for weeks; the state is persisted in the DB and survives server restarts.
  • You resume explicitly from the UI or via API when a decision is made.
  • Downstream automation is cleanly decoupled and reacts only after the first flow completes successfully.

Getting Started with Human-in-the-Loop Automation

  1. Install Kestra – Follow the quick start guide or the full installation instructions for production environments.
  2. Write Your Workflows – Configure your flow in YAML. Each automated task can invoke an API, run scripts, or call any existing service. Then, add Pause tasks for manual approvals:
    yaml
    - id: approval_gate
      type: io.kestra.plugin.core.flow.Pause
      onResume:
        - id: signoff
          type: BOOLEAN
          required: true
    
  3. Configure Notifications – Use Slack, Teams, or Email plugins to notify users about pending approvals:
    yaml
    - id: alert
      type: io.kestra.plugin.notifications.teams.TeamsIncomingWebhook
      url: "{{ secret('TEAMS_WEBHOOK') }}"
      payload: |
        {
          "text": "The process {{ flow.id }} is pending approval {{ appLink() }}"
        }
    
  4. Add Triggers – Use scheduled or event-based triggers to launch workflows.
  5. Observe and Manage – Use Kestra’s UI to monitor states, logs, outputs, and metrics. Correct and replay failed workflow executions or roll back to a previous revision when needed.

Next Steps

Was this page helpful?