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?
- Flexible Integration – Add approval steps to existing workflows in a few lines of YAML
- Enterprise Security – Manage permissions via namespace-level RBAC
- Cross-Platform Notifications – Send approval requests to Slack, Teams, or Email
- Input Validation – Enforce structured responses (Numeric, Boolean, Dates, Dropdowns)
- Bulk Actions – Bulk-resume multiple paused workflows when needed.
- 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:
id: vacation_approvalnamespace: 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:
- 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:
Audit Trails
Audit Logs capture who approved or rejected each request, and the Pause task’s outputs contain the user’s decision:
{ "approved": true, "reason": "Within policy limits"}
Conditional Branching
Route next automated tasks based on human decisions:
- 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:
id: pause_demonamespace: 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:
id: pause_demo_with_subflownamespace: 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
- Flow that can stay paused as long as needed:
id: pause_demonamespace: 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
- Resume the paused execution via API when ready:
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"
- React to the resumed flow’s completion using a Flow trigger (fires on SUCCESS of
pause_demo
):
id: resume_demonamespace: 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 inPAUSED
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.
Note that the Flow trigger cannot react to a RESUMED
state as this state is not observable. While you could react to the RESTARTED
state, this state is used not only for resumed executions after paused but also for executions restarted after a failure, which may not be what you want. Therefore, we recommend that you design your Flow trigger to react directly to the SUCCESS
state as shown above.
Getting Started with Human-in-the-Loop Automation
- Install Kestra – Follow the quick start guide or the full installation instructions for production environments.
- 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:- id: approval_gatetype: io.kestra.plugin.core.flow.PauseonResume:- id: signofftype: BOOLEANrequired: true - Configure Notifications – Use Slack, Teams, or Email plugins to notify users about pending approvals:
- id: alerttype: io.kestra.plugin.notifications.teams.TeamsIncomingWebhookurl: "{{ secret('TEAMS_WEBHOOK') }}"payload: |{"text": "The process {{ flow.id }} is pending approval {{ appLink() }}"}
- Add Triggers – Use scheduled or event-based triggers to launch workflows.
- 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
- Explore notification plugins for Slack, Teams, Email and more
- Check How-to Guides for detailed examples of approval workflows
- Explore video tutorials on our YouTube channel
- Join Slack to ask questions, contribute code, or share feature requests
- Book a demo to discuss how Kestra can help automate your approval processes.