← Back to list

Automating Discord Bug Triage With AI and Kestra

Replacing weeks of manual Discord review with an Al orchestration flow that links, flags, and escalates issues to GitHub

Ayan Gupta in Kestra Engineering · 2026-06-09 18:08 · 0 claps · 10.7 min read
#built-with-kestra #editors-pick #ai #orchestration
Open on Medium ↗
Wiki topics: AI · AI · General 🔓 · Open Source

Automating Discord Bug Triage With AI and Kestra

Replacing weeks of manual Discord review with an Al orchestration flow that links, flags, and escalates issues to GitHub

I was exploring some cool AI stuff that’s when I came across Kimchi, I found it cool, explored it’s codebase a little, and joined it’s discord and that’s when I saw something in there.

Bug reports, and since I’ve come from GitHub I saw some these were already open, and people were still opening new posts. Duplicates. That’s when I came up with and Idea

Hmmmm. What if I could tell these people that there’s already an issue regarding that on GitHub. 💡 Wait. What if I could automate this somehow…

I wasn’t sure how I was gonna do it back then. but I had already completed the Kestra Fundamentals course and I knew Kestra could do it. Put my head through it.

Before we begin / An advice

I would suggest you to use KestraMCP and KestraDocsMCP attached to whatever AI Agent you’re using. They’re super super useful. They’re superpowers.

[embed]Kestra MCP: A Live Plugin and Blueprint Catalog for AI Coding Agents | Kestra The Kestra MCP server exposes the full plugin registry and Blueprint catalog as Model Context Protocol tools. Connect…kestra.io

[embed]GitHub - kestra-io/mcp-server-python: Python MCP Server for Kestra - you can use it as a tool in… Python MCP Server for Kestra - you can use it as a tool in Kestra's AI Agents - kestra-io/mcp-server-pythongithub.com

The Problem

Developer-facing products in early access tend to end up in Discord. It’s where the users already are, the feedback loop is tight, and threads are easier than a support ticket system nobody wanted to set up.

Open Source Communities like Appwrite & Kimchi run their alpha support entirely this way — a dedicated bug-reports forum in their Discord server. Users post when something breaks. At 30–40 reports a week, that’s not overwhelming on its face. But each post needs the same sequence of manual work: read it, search GitHub for a matching open issue, scan the last few weeks of threads for duplicates, decide whether it’s worth escalating, write a reply. Do that 35 times a week and it stops being a support process and starts being someone’s entire job.

The other thing: most of those posts are the same issue. Not literally, but effectively. A handful of known bugs generating five, ten, fifteen separate reports from different users, each one waiting for a human to connect it to something already tracked.

Triage Bot cuts through that. New forum post comes in, the pipeline checks GitHub and past threads, figures out what it is, and handles it — reply, react, archive, escalate. Kestra runs the whole thing.

Two layers, one clear boundary

Two layers. The Discord bot’s job is to receive events and POST to Kestra webhooks. No routing, no AI, no state. The moment it delivers the payload, it’s out of the picture.

Kestra handles everything else. New forum post: triage flow. Upvote reaction: escalation flow. Slash command: config write. Each flow is independent, reads its own KV state, makes its own Discord API calls.

We kept the bot shallow deliberately. If triage logic lived in TypeScript, every change to the AI agent’s search strategy or the escalation threshold would mean a bot redeploy. In Kestra, it’s a YAML edit. The bot doesn’t know and doesn’t care.

The entry point for each flow is a webhook trigger. The bot POSTs to it, Kestra picks it up:

triggers:
  - id: webhook
    type: io.kestra.plugin.core.trigger.Webhook
    key: YOUR_TRIAGE_WEBHOOK_KEY
    inputs:
      discord_thread_id: "{{ trigger.body.discord_thread_id }}"
      discord_guild_id: "{{ trigger.body.discord_guild_id }}"
      thread_name: "{{ trigger.body.thread_name }}"
      first_message: "{{ trigger.body.first_message }}"

Three flows, three webhook keys. The bot doesn’t know what happens after the POST.

Three flows in our workflow based on cases

Three flows in our workflow based on cases

The Triage flow

Every new post hits discord_triage.yaml . First thing it does: check a KV key called **TRIAGE_ENABLED**. If the key isn’t set, or isn’t true, the flow stops. Simple kill switch, no bot redeploy needed.

- id: gate
  type: io.kestra.plugin.core.flow.Switch
  value: "{{ kv('GUILD_TRIAGE_ENABLED') | default('true') }}"
  cases:
    "true":
      - id: triage_agent
        # ... main flow tasks
  defaults:
    - id: skip
      type: io.kestra.plugin.core.log.Log
      message: "Triage disabled via KV store"

If triage is running, an AIAgent task fires with Gemini and Coral MCP.

- id: triage_agent
  type: io.kestra.plugin.ai.agent.AIAgent
  provider:
    type: io.kestra.plugin.ai.provider.GoogleGemini
    apiKey: "{{ secret('GEMINI_API_KEY') }}"
    modelName: gemini-3.5-flash
  tools:
    - type: io.kestra.plugin.ai.tool.StdioMcpClient
      command: ["{{ vars.CORAL_PATH }}", "mcp-stdio"]

The agent runs three GitHub searches per report, each using a different keyword framing of the same problem: visible symptom, affected component, likely root cause. Users don’t describe bugs consistently. “Login crashes on startup” and “NullPointerException in AuthManager during token refresh” can be the same issue. A single-angle search only catches one of them.

That strategy is baked into the system prompt

systemMessage: |-
    STEP 1: Search GitHub issues
    Run exactly 3 queries using different keyword angles:
    - Angle 1: error message or visible symptom
    - Angle 2: component or feature name
    - Angle 3: root cause or unexpected behavior

    A MATCH means same root cause, same component, or same error pattern. Not just shared keywords.

    STEP 2: If no GitHub match, search Discord forum for duplicates
    Return the id and permalink of the OLDEST matching post.

    STEP 3: Return structured result:
      outcome: "github_match" | "discord_duplicate" | "no_match"
      github_issues: [{ number, title, url }]
      discord_duplicate_thread_id: "..."
      reply_message: "..."

All three searches use Coral’s semantic mode. Lexical search only works when the reporter uses the same words as the issue title. Semantic catches the overlap even when they don’t. I confirmed this the hard way. A single-angle lexical search was missing roughly 30% of real duplicates in our early testing because the language gap between “dashboard won’t load” and “NPE in chart renderer on init” was enough to keep them apart.

The agent outputs prose, not JSON. A JSONStructuredExtraction task sits right after and pulls out the structured fields before any branching logic runs. The extraction task keeps agent output from leaking directly into flow logic.

- id: parse_triage
  type: io.kestra.plugin.ai.completion.JSONStructuredExtraction
  schemaName: TriageResult
  jsonFields:
    - outcome
    - github_issues
    - discord_duplicate_thread_id
    - discord_duplicate_permalink
    - reply_message
  prompt: "{{ outputs.triage_agent.textOutput }}"
  provider:
    type: io.kestra.plugin.ai.provider.GoogleGemini
    apiKey: "{{ secret('GEMINI_API_KEY') }}"
    modelName: gemini-3.1-flash-lite

From there, If tasks branch on the extracted outcome:

- id: if_github_match
  type: io.kestra.plugin.core.flow.If
  condition: "{{ fromJson(outputs.parse_triage.extractedJson).outcome == 'github_match' }}"
  then:
    - id: add_reaction_tick
      type: io.kestra.plugin.core.http.Request
      uri: "https://discord.com/api/v10/channels/{{ inputs.discord_thread_id }}/messages/{{ inputs.first_message_id }}/reactions/%E2%9C%85/@me"
      method: PUT
      headers:
        Authorization: "Bot {{ secret('DISCORD_BOT_TOKEN') }}"

Three outcomes — each handled differently:

Three outcomes

Three outcomes

- id: duplicate_actions
  type: io.kestra.plugin.core.flow.Parallel
  tasks:
    - id: archive_new_thread
      type: io.kestra.plugin.core.http.Request
      uri: "https://discord.com/api/v10/channels/{{ inputs.discord_thread_id }}"
      method: PATCH
      body: '{"archived": true, "locked": true}'
      headers:
        Authorization: "Bot {{ secret('DISCORD_BOT_TOKEN') }}"
    - id: notify_old_thread
      type: io.kestra.plugin.core.http.Request
      uri: "https://discord.com/api/v10/channels/{{ fromJson(outputs.parse_triage.extractedJson).discord_duplicate_thread_id }}/messages"
      method: POST
      body: |-
        {"content": "📎 New reporter with the same issue: https://discord.com/channels/{{ inputs.discord_guild_id }}/{{ inputs.discord_thread_id }}"}
      headers:
        Authorization: "Bot {{ secret('DISCORD_BOT_TOKEN') }}"
    - id: add_reaction_duplicate
      type: io.kestra.plugin.core.http.Request
      uri: "https://discord.com/api/v10/channels/{{ inputs.discord_thread_id }}/messages/{{ inputs.first_message_id }}/reactions/%F0%9F%94%81/@me"
      method: PUT
      headers:
        Authorization: "Bot {{ secret('DISCORD_BOT_TOKEN') }}"
    - id: apply_duplicate_tag
      type: io.kestra.plugin.scripts.python.Script
      script: |
        # reads TAG_MAP KV config, applies "duplicate" forum tag to thread via Discord PATCH API

A KV reporter counter on the original thread gets bumped. At 3, a second agent fires, reads all the duplicates via Coral, and drafts a GitHub issue.

no_match: ⬆️ upvote reaction added, thread state written to KV so the escalation flow can find it:

The escalation path

Every upvote fires the bot’s reaction handler, which POSTs to triage_draft_alert.yaml. Not just when the count crosses a threshold every vote. The flow reads current state and decides whether to act.

First it reads the thread’s KV entry. Then a Python script checks two things: was this thread previously classified as no_match? Has it hit at least 3 upvotes? Both need to be true. Admins can skip this entirely with a force=true flag via slash command.

force = "{{ inputs.force }}" == "true"

if force:
    Kestra.outputs({"should_escalate": "true", "forced": "true"})
elif data and data.get("status") == "no_match" and count >= 3:
    Kestra.outputs({"should_escalate": "true", "forced": "false"})
else:
    Kestra.outputs({"should_escalate": "false"})

Before the draft agent runs, the flow writes {“status”: “escalated”} to KV. That write happens before any other work. The reason: with QUEUE concurrency, two upvotes arriving close together execute one at a time. The second run reads escalated and exits. Skip that early write and both runs pass the eligibility check and create duplicate GitHub issues.

- id: claim_escalation
  type: io.kestra.plugin.core.kv.Set
  key: "GUILD_{{ inputs.guild_id }}_THREAD_{{ inputs.channel_id }}_TRIAGE"
  value: '{"status": "escalated"}'
  overwrite: true

The draft agent gets the thread ID from the webhook payload but reads the actual messages fresh via Coral MCP — the webhook only delivers the event, not the content. JSONStructuredExtraction handles the output the same way as the triage flow.

GitHub auth uses Kestra’s AppToken task, which exchanges the GitHub App private key and installation ID for a short-lived token at runtime. No long-lived credentials in KV.

- id: get_github_token
  type: io.kestra.plugin.github.auth.AppToken
  clientId: "{{ secret('GITHUB_APP_ID') }}"
  privateKey: "{{ secret('GITHUB_APP_PRIVATE_KEY') }}"
  installationId: "{{ kv('GUILD_' ~ inputs.guild_id ~ '_GITHUB_APP_INSTALLATION_ID') }}"

The reply message is built using Kestra’s Return task with Pebble templating — no Python needed for string construction. It renders differently depending on whether an admin forced the issue or it crossed the vote threshold organically:

- id: compute_reply_message
  type: io.kestra.plugin.core.debug.Return
  format: |-
    {%- if outputs.check_eligibility.vars.forced == "true" -%}
    ✅ Admin filed on GitHub: **[{{ fromJson(outputs.parse_draft.extractedJson).title }}]({{ outputs.create_github_issue.issueUrl }})**
    {%- else -%}
    {{ outputs.check_eligibility.vars.author_id != "" ? ("<@" ~ outputs.check_eligibility.vars.author_id ~ "> ") : "" }}🎉 Confirmed by {{ inputs.reaction_count }} users — filed on GitHub: **[{{ fromJson(outputs.parse_draft.extractedJson).title }}]({{ outputs.create_github_issue.issueUrl }})**
    {%- endif -%}

Design tradeoffs and lessons

Building this pipeline revealed several design tradeoffs. I adjusted my architecture based on three key lessons.

Stateless bot vs. Kestra orchestration

In my first iteration, the Discord bot handled some of the outcome logic: deciding whether to archive a thread, post a reaction, or notify. This meant any change to the triage rules or tag mappings required a bot deployment. For an alpha product where my support workflow changed weekly, this was a significant bottleneck.

I moved all routing, logic, and API calls into Kestra YAML. The bot now acts as a thin event relay. The bot’s code has not changed in four months, while I edited the triage flow eleven times this month alone.

Concurrency queues vs. distributed locks

During early tests, I faced a race condition. When two users upvoted the same thread at the same time, both webhooks triggered flows simultaneously. Both runs checked the eligibility status, saw the thread was not yet escalated, and created duplicate GitHub issues.

Instead of deploying a distributed lock manager like Redis, I resolved this using Kestra’s built-in queue system and state management:

  • I set the flow’s concurrency behavior to QUEUE so executions run sequentially.
  • I write the escalated status to KV immediately at the start of the flow, rather than at the end. The subsequent queued execution reads the updated status and exits.

Semantic search vs. single-angle lexical search

Lexical search only matches issues containing identical terms. I found it missed roughly 30% of duplicates because users describe issues differently. A report about a startup crash might match a GitHub issue detailing an auth initialization failure, but only when searched semantically.

To catch these duplicates, I implemented a three-angle search strategy: searching by symptom, by component, and by root cause. Although this triples the number of API calls, the increase in recall was worth the trade-off.

KV store vs. database

I opted for Kestra’s native KV store instead of a relational database. Since I only track per-thread status, configuration, and a basic counter, I did not need schemas or migrations. When I needed to add fields to a thread’s state, I simply updated the JSON payload in KV.

The data layer: Coral MCP

Both agents pull live data at runtime from GitHub and Discord. They do this through **Coral, a read-only SQL layer** over external APIs. GitHub issues and Discord threads are queryable tables. The agent writes SQL, Coral calls the API, rows come back.

The triage agent’s GitHub search uses semantic mode:

SELECT title, html_url, state, number, score, label_names
FROM github.search_issues(
  q => 'repo:kimchi-dev/kimchi is:issue is:open login crash',
  mode => 'semantic'
)
WHERE is_pull_request = false
LIMIT 5

Wiring it into the AIAgent task is one entry in tools:

- type: io.kestra.plugin.ai.tool.StdioMcpClient
  command: ["{{ vars.CORAL_PATH }}", "mcp-stdio"]

The binary path comes from a namespace variable (vars.CORAL_PATH), so different deployments point to different installs without touching the flow YAML. If you want to go deeper on how Coral works, the docs are at withcoral.com.

Why Kestra was the right orchestrator

Using Kestra to orchestrate this logic offered several advantages over building a monolithic service.

Instead of writing a custom event dispatcher, I route each Discord event to its own Kestra webhook trigger. Adding a new event type means writing a new flow rather than editing the bot. Adding a new Discord server only requires setting up a few KV keys.

Because Kestra manages state natively, I did not have to spin up or maintain a database. Adding a new tracking state only requires executing Get and Set operations in the YAML flow, eliminating database migration steps.

The AI agent configuration remains declarative. Changing Gemini models or adding Coral MCP requires updating a few lines of YAML. I wrote zero code to connect to LLMs. Even conditional replies use Kestra’s Return task with Pebble templates instead of custom scripts.

By keeping the bot simple, the code remains stable. I manage retries, concurrency, and branching entirely within Kestra’s declarative workflow engine.

Explore the Implementation

The code is at github.com/xkaper001/triage.

Key files:

  • kestra/flows/discord_triage.yaml — the main triage flow, from forum post to outcome branching
  • kestra/flows/triage_draft_alert.yaml — the escalation flow, from upvote to GitHub issue
  • kestra/flows/update_config.yaml — the config write flow, triggered by slash commands
  • bot/src/ — the Discord bot (TypeScript), intentionally minimal

The bot is a thin relay. If you want to understand what actually runs, start with the three YAML files.

Config values you’ll need:

  • DISCORD_BOT_TOKEN — Discord bot token with permissions to read/write forum channels and reactions
  • GEMINI_API_KEY — Google Gemini API key (**3.5-flash for agents, `3.1-flash-lite`** for extraction)
  • CORAL_PATH — path to the Coral binary on the Kestra worker
  • GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY — GitHub App credentials; the installation ID per guild is stored in KV at
  • GUILD_{guild_id}_GITHUB_APP_INSTALLATION_ID

KV keys follow a naming scheme: GUILD_{guild_id}_{KEY} for per-guild config and GUILD_{guild_id}_THREAD_{thread_id}_TRIAGE for per-thread triage state. The update_config.yaml flow writes KV via slash command; you can also set keys directly in the Kestra UI.

Explore Kimchi

I build this workflow using Kimchi, end-to-end. Kimchi’s cool especially the /ferment. Wire up Kestra MCP & Kestra Docs MCP in It and you’re good to go. For every big feature use /ferment <feature name> and It’ll take quite some time but give you a result you’ll be happy with :) I tried and now I prefer it over Claude and Codex. Try Here

Reference Links

About the Author

Ayan Gupta is a developer, a builder, an AI enthusiast, a Cyber Security enthusiast, and basically builds and breaks what he likes. With 3 years of development experience and a track record spanning mobile, web, backend, and cybersecurity, he builds production-grade systems across platforms. A certified penetration tester (CAPT), active CTF participant, and open-source contributor (GSSoC, Hacktoberfest, FOSS), Ayan has shipped apps for real-world clients and led projects at the intersection of AI and security. Find him on GitHub · X · LinkedIn · Website

Happy Orchestrating :)


메타데이터
post_id
2142c4fe215f
slug
automating-discord-bug-triage-with-ai-and-kestra-2142c4fe215f
url
https://medium.com/kestra-engineering/automating-discord-bug-triage-with-ai-and-kestra-2142c4fe215f
canonical_url
https://medium.com/kestra-engineering/automating-discord-bug-triage-with-ai-and-kestra-2142c4fe215f
author_url
https://medium.com/@xkaper
status
ok
fetched_at
2026-06-14 11:28:49