Build a human-approved AI triage workflow in Kestra
Fetch one GitHub issue, ask an LLM for a constrained suggestion, pause for a human decision, then update GitHub only after approval.
This guide is for UK SME operators and technical leads who want useful automation without letting AI make external changes on its own. Kestra orchestrates predictable steps, the model produces a recommendation and the approval boundary sits before GitHub is changed.
Outcome
What you will build
By the end, you will have a Kestra flow that:
- Reads one GitHub issue using the GitHub REST API.
- Extracts deterministic facts: issue number, title, author, current labels and body.
- Sends those facts to an OpenAI chat completion task for a structured triage suggestion.
- Pauses the execution and asks a human to approve, edit or reject the suggestion.
- If approved and not in dry-run mode, calls GitHub to add a label and create a comment.
Kestra is a workflow orchestration platform with YAML flows, a UI and plugin-based tasks. Its quickstart shows a local Docker launch and its Docker Compose guide covers a PostgreSQL-backed local setup. The core Pause task stops downstream scheduling and can collect inputs when a person resumes the flow.
Fit
When this pattern is useful
Use this pattern when the work has a repeatable shape but still needs judgement:
- customer support issue triage;
- internal IT request routing;
- operations tickets that need human sign-off before an external system changes;
- lightweight compliance checks where auditability matters more than speed.
Do not use it for fully autonomous production changes, high-volume real-time queues, or decisions where a mistaken label or comment could create legal, financial or reputational harm.
Before you start
Prerequisites
- Docker and Docker Compose installed.
- A GitHub sandbox repository where you can create a test issue.
- A GitHub token with the narrowest practical access to that repository.
- An OpenAI API key if you use the OpenAI plugin shown below.
- Basic comfort editing YAML.
Step 01
Start Kestra locally with Docker Compose
Aim
Run a local PostgreSQL-backed Kestra instance that you can inspect and discard safely.
Action
mkdir kestra-ai-triage
cd kestra-ai-triageDownload Kestra’s Docker Compose file and start it:
curl -o docker-compose.yml \
https://raw.githubusercontent.com/kestra-io/kestra/develop/docker-compose.yml
docker compose up -dOpen http://localhost:8080.
Check
The Kestra UI should load and let you create a flow.
Common mistake
Kestra’s single-container H2 quickstart is useful for a brief product tour, but use Compose for this tutorial because its PostgreSQL-backed setup is closer to a real operator environment.
Step 02
Add secrets without putting tokens in YAML
Aim
Keep GitHub and OpenAI credentials out of the flow definition.
Action
The flow uses two secrets: GITHUB_TOKEN and OPENAI_TOKEN.
Kestra exposes secrets at runtime with {{ secret('NAME') }}. In Open Source, secret() reads base64-encoded environment variables prefixed with SECRET_. Add these to the Kestra service in docker-compose.yml:
environment:
SECRET_GITHUB_TOKEN: "<base64-encoded-token>"
SECRET_OPENAI_TOKEN: "<base64-encoded-key>"Restart Kestra after changing the Compose file:
docker compose up -dCheck
The flow should resolve both secrets at runtime without exposing them in its YAML.
Least-privilege checklist
- Use a sandbox repository first.
- Grant issue read access for fetching.
- Grant issue write access only for the mutation step.
- Keep
dry_runtrue until the approval path is tested. - Avoid broad organisation tokens.
- Rotate test tokens after the exercise.
Step 03
Create a test issue
Aim
Give the workflow one controlled, non-sensitive record to process.
Action
In your sandbox repository, create an issue with a clear title and body:
Title: Checkout fails when postcode contains a space
Body: Customers report that SW1A 1AA is rejected during checkout. Expected: valid UK postcodes with spaces should be accepted.Check
Note the owner, repository name and issue number. You will pass them into the flow as inputs.
Common mistake
Do not copy a real support ticket containing customer details into the sandbox.
Step 04
Add the Kestra flow
Aim
Build an inspectable route where the human decision remains between AI output and GitHub changes.
Action
In the Kestra UI, create a new flow and paste the YAML below. Keep dry_run set to true for the first execution.
id: human_approved_github_issue_triage
namespace: company.ops
description: >
Fetch one GitHub issue, ask an LLM for a constrained triage suggestion,
pause for a human decision, then add a label and comment only if approved.
inputs:
- id: owner
type: STRING
displayName: GitHub owner or organisation
- id: repo
type: STRING
displayName: GitHub repository
- id: issue_number
type: INT
displayName: GitHub issue number
- id: dry_run
type: BOOLEAN
defaults: true
displayName: Dry run only
tasks:
- id: fetch_issue
type: io.kestra.plugin.core.http.Request
uri: "https://api.github.com/repos/{{ inputs.owner }}/{{ inputs.repo }}/issues/{{ inputs.issue_number }}"
method: GET
headers:
Accept: application/vnd.github+json
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
X-GitHub-Api-Version: "2022-11-28"
- id: deterministic_facts
type: io.kestra.plugin.core.log.Log
message: |
Issue: #{{ outputs.fetch_issue.body.number }}
Title: {{ outputs.fetch_issue.body.title }}
Author: {{ outputs.fetch_issue.body.user.login }}
State: {{ outputs.fetch_issue.body.state }}
Existing labels: {{ outputs.fetch_issue.body.labels | jq('[.[].name]') | first }}
URL: {{ outputs.fetch_issue.body.html_url }}
- id: ai_triage_suggestion
type: io.kestra.plugin.openai.ChatCompletion
apiKey: "{{ secret('OPENAI_TOKEN') }}"
model: gpt-4o
prompt: |
You are helping a UK SME operations team triage a GitHub issue.
Use only the issue facts below. Do not claim you inspected code, logs or customers.
Return only compact JSON with these keys:
- label: one of bug, enhancement, question, needs-info
- confidence: low, medium or high
- rationale: one sentence
- draft_comment: a concise, polite GitHub comment
Issue facts:
number: {{ outputs.fetch_issue.body.number }}
title: {{ outputs.fetch_issue.body.title }}
author: {{ outputs.fetch_issue.body.user.login }}
current_labels: {{ outputs.fetch_issue.body.labels | jq('[.[].name]') | first }}
body: {{ outputs.fetch_issue.body.body }}
- id: approval
type: io.kestra.plugin.core.flow.Pause
onResume:
- id: approve
type: BOOLEAN
required: true
displayName: Approve GitHub update?
defaults: false
- id: final_label
type: SELECT
required: true
displayName: Final label
values:
- bug
- enhancement
- question
- needs-info
- id: final_comment
type: STRING
required: true
displayName: Final comment to post
defaults: "Thanks for raising this. We have reviewed it and added a triage label."
- id: decision_notes
type: STRING
required: false
displayName: Internal decision notes
- id: apply_github_updates
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.approval.onResume.approve == true and inputs.dry_run == false }}"
then:
- id: add_label
type: io.kestra.plugin.core.http.Request
uri: "https://api.github.com/repos/{{ inputs.owner }}/{{ inputs.repo }}/issues/{{ inputs.issue_number }}/labels"
method: POST
contentType: application/json
headers:
Accept: application/vnd.github+json
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
X-GitHub-Api-Version: "2022-11-28"
body: |
{{ {"labels": [outputs.approval.onResume.final_label]} }}
- id: post_comment
type: io.kestra.plugin.core.http.Request
uri: "https://api.github.com/repos/{{ inputs.owner }}/{{ inputs.repo }}/issues/{{ inputs.issue_number }}/comments"
method: POST
contentType: application/json
headers:
Accept: application/vnd.github+json
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
X-GitHub-Api-Version: "2022-11-28"
body: |
{{ {
"body": outputs.approval.onResume.final_comment ~ "\n\n---\nTriage approved by " ~ outputs.approval.resumed.by ~ " on " ~ outputs.approval.resumed.on ~ "."
} }}
- id: audit_log
type: io.kestra.plugin.core.log.Log
message: |
Dry run: {{ inputs.dry_run }}
Approved: {{ outputs.approval.onResume.approve }}
Final label: {{ outputs.approval.onResume.final_label }}
Decision notes: {{ outputs.approval.onResume.decision_notes }}
AI raw suggestion: {{ outputs.ai_triage_suggestion.choices[0].message.content }}The example uses Kestra’s core HTTP Request task so the GitHub URI, method, headers and request body remain explicit. The OpenAI task receives only issue facts and returns a constrained suggestion. The Pause task then collects the final label and comment from the person who resumes the flow.
Check
Kestra should accept the flow definition and show each task in the route.
Common mistake
Do not remove either condition from the mutation guard: approval must be true and dry-run must be false.
Step 05
Run in dry-run mode
Aim
Prove fetching, suggestion and approval without changing GitHub.
Action
owner: your-org
repo: your-sandbox-repo
issue_number: 1
dry_run: trueCheck
fetch_issuereturns HTTP200.deterministic_factsshows the title, author, state and labels.ai_triage_suggestionreturns compact JSON.approvalputs the execution into a paused state.
A Pause task moves the execution to PAUSED. With no duration or timeout configured, it waits until someone resumes it through the UI or API.
Step 06
Resume with a human decision
Aim
Review and, if necessary, rewrite the prepared work before it can become an external action.
Action
Open the paused execution in Kestra, select Resume and fill in:
approve: true
final_label: bug
final_comment: Thanks for raising this. We can reproduce the postcode validation problem and have labelled it for investigation.
decision_notes: Approved after checking the issue body; no production change made by AI.Because dry_run is still true, the update branch should not call GitHub. Confirm the audit task records the decision. Then run the flow again with dry_run: false against the same sandbox issue and resume it only when you are happy with the final label and comment.
Check
The first run records the decision but makes no external change. The second run proceeds only after explicit approval.
Step 07
Review the result in GitHub
In the sandbox repository, confirm:
- the selected label was added;
- the approved comment was posted;
- the comment does not overstate what the automation did;
- the Kestra execution log shows who resumed the approval step.
The Pause output includes who resumed the execution and when, which supports an audit log.
Safe testing
How to test safely
- Run with a public or throwaway issue and
dry_run: true. - Approve the pause and inspect the audit log only.
- Run with
dry_run: falsein a sandbox repository. - Confirm the exact GitHub label and comment.
- Try a rejection and confirm no external update runs.
- Try a weak issue body and check the suggestion says
needs-inforather than inventing details. - Only then consider a private repository with a narrow token.
Exceptions
Troubleshooting
fetch_issue returns 401 or 403
Check that the GitHub secret is present, correctly base64 encoded in Open Source and limited to a repository the token can access.
The approval step never continues
Confirm someone resumed the execution. A Pause task with no duration waits until it is manually resumed or killed.
The label request returns 422
Check that the selected label exists in the target repository.
The comment request returns 403
Check token permissions and repository restrictions. Creating a comment needs issue or pull-request write permission.
The AI suggestion is not valid JSON
Keep the approval boundary. Review the raw response manually, or add deterministic schema validation before the Pause task.
Kestra cannot reach a local service
Containers do not share the host’s localhost. Depending on your setup, use host.docker.internal, a Docker network or another explicitly reviewed route.
A failed task needs to be retried
Kestra Replay can rerun from a selected task after you correct a YAML or credential configuration mistake.
Before wider use
Production hardening checklist
- Replace personal tokens with GitHub App installation tokens where possible.
- Separate read-only fetch credentials from write-capable mutation credentials.
- Add an explicit allow-list of labels the flow may apply.
- Add a maximum comment length.
- Log the original suggestion, approved text and approving user.
- Monitor executions stuck in
PAUSED. - Store the flow in Git and review changes through pull requests.
- Pin plugin and image versions rather than relying on
latest. - Consider Enterprise
HumanTaskif approval must be assigned to named users or RBAC groups.
Tool fit
When Kestra is a fit
Kestra is a good fit when:
- the work has clear steps and audit requirements;
- operators need a UI to inspect executions, logs and paused approvals;
- YAML and Git review are acceptable for workflow definitions;
- you need deterministic API calls, scripts, notifications and human decisions in one route.
When Kestra is not a fit
Kestra may not be the right first choice when:
- the process is a simple one-off script;
- the team wants only a visual canvas and no YAML ownership;
- every decision must happen in real time without human waiting time;
- the organisation is not ready to manage credentials, logs and approval ownership.
Common questions
Frequently asked questions
Is the AI allowed to update GitHub by itself?
No. A person reviews and rewrites the suggestion through the Pause resume form before label or comment calls can run.
Can we use another model provider?
Yes. Keep the same boundary: the model suggests, a person approves and only then does automation mutate an outside system.
Can this triage multiple issues at once?
Yes, but prove one issue first. Batches add pagination, duplicates, rate limits and more review work.
Does this replace a support lead?
No. It prepares repetitive work and creates a consistent review route. A person still owns the final decision and external communication.
Should we use Pause or Enterprise HumanTask?
Use Pause for a simple Open Source gate. Consider HumanTask when approval must be assigned to named users or RBAC groups.
What should we measure?
Track issue volume, approval wait time, rejections, edited suggestions and failed executions. Do not claim improvement until your own process has measured it.
References
Sources
- Kestra quickstart
- Kestra Docker Compose installation
- Kestra approval processes
- Kestra Git integration
- Kestra Replay
- Kestra Pause task
- Kestra GitHub Issues plugin
- Kestra OpenAI ChatCompletion task
- Kestra secrets
- Kestra HTTP Request task
- GitHub Issues REST API
- GitHub issue labels REST API
- GitHub issue comments REST API