Tutorial

How to Build a Slack Bot That Routes Jira Tickets with AI

April 2026 · 8 min read

Most Slack-to-Jira integrations stop at "create a ticket." You type /jira create, fill a form, the ticket lands in a backlog — and someone still has to manually route it to the right team. The value is in the routing, not the creation. This post covers the full stack: Slack modal → AI classification → Jira ticket creation → correct queue assignment → Slack confirmation.

This is running in production across three workspaces. All code is Python + Flask + Slack Bolt.

Prerequisites

  • A Slack app with chat:write, commands, and views:open scopes
  • A Jira Service Management project (or classic Jira with issue types)
  • Any LLM API — we use OpenAI GPT-4o-mini for classification ($0.003 per 1K tokens)
  • Python 3.11+, slack-bolt, jira, openai packages

Step 1: The Slack Modal

Don't use a slash command that accepts free text. Use a modal — it forces structure and gives you the summary + description fields you need for accurate classification.

@app.command("/support")
def open_support_modal(ack, body, client):
    ack()
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "support_ticket",
            "title": {"type": "plain_text", "text": "IT Support"},
            "submit": {"type": "plain_text", "text": "Submit"},
            "blocks": [
                {
                    "type": "input",
                    "block_id": "summary",
                    "element": {
                        "type": "plain_text_input",
                        "action_id": "summary_input",
                        "placeholder": {"type": "plain_text", "text": "One sentence: what do you need?"}
                    },
                    "label": {"type": "plain_text", "text": "Summary"}
                },
                {
                    "type": "input",
                    "block_id": "description",
                    "optional": True,
                    "element": {
                        "type": "plain_text_input",
                        "action_id": "description_input",
                        "multiline": True,
                        "placeholder": {"type": "plain_text", "text": "More context (optional)"}
                    },
                    "label": {"type": "plain_text", "text": "Details"}
                }
            ]
        }
    )

The callback_id is what ties the submission to your handler. Keep the form short — two fields is enough for classification. Every extra required field reduces submission rate.

Step 2: AI Classification

The classifier runs on modal submission, before the Jira ticket is created. You have about 3 seconds before Slack shows an error, so keep the LLM call fast — use a small model.

CATEGORIES = {
    "IT_HARDWARE": {
        "jira_project": "IT",
        "issue_type": "Hardware Issue",
        "assignee_group": "it-hardware",
        "priority": "Medium"
    },
    "SOFTWARE_ACCESS": {
        "jira_project": "IT",
        "issue_type": "Access Request",
        "assignee_group": "it-software",
        "priority": "Low"
    },
    "DEV_TOOLS": {
        "jira_project": "DEVOPS",
        "issue_type": "Task",
        "assignee_group": "engineering-ops",
        "priority": "Medium"
    },
    "HR_SYSTEMS": {
        "jira_project": "HR",
        "issue_type": "Service Request",
        "assignee_group": "hr-team",
        "priority": "Low"
    }
}

def classify(summary: str, description: str) -> str:
    response = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    "Classify IT support tickets. "
                    "Reply with exactly one of: IT_HARDWARE, SOFTWARE_ACCESS, DEV_TOOLS, HR_SYSTEMS, UNKNOWN. "
                    "No explanation."
                )
            },
            {
                "role": "user",
                "content": f"Summary: {summary}\nDetails: {description or 'none'}"
            }
        ],
        max_tokens=10,
        temperature=0
    )
    result = response.choices[0].message.content.strip()
    return result if result in CATEGORIES else "UNKNOWN"

Two things worth noting in this implementation:

  1. System prompt is a constraint, not a description. "Reply with exactly one of: X, Y, Z" produces cleaner output than "You are an expert classifier that..."
  2. max_tokens=10 caps cost and latency. Category names are short — there's no reason to let the model ramble.

Step 3: Create the Jira Ticket

def create_jira_ticket(summary: str, description: str, category: str, reporter_email: str):
    config = CATEGORIES.get(category, CATEGORIES["IT_HARDWARE"])

    issue = jira_client.create_issue(
        project=config["jira_project"],
        summary=summary,
        description=description or "No additional details provided.",
        issuetype={"name": config["issue_type"]},
        priority={"name": config["priority"]},
        labels=[f"slack-bot", f"category:{category.lower()}"],
        customfield_10050=reporter_email  # reporter field (your field ID will differ)
    )

    # Assign to group via Jira automation rule (preferred) or directly
    # Direct assignment requires knowing a specific user — use Jira automation
    # rule "When: issue created AND label = category:it_hardware → Assign to group"

    return issue.key, issue.permalink()

A note on assignment: don't assign to a specific user in code. Teams change. Use a Jira automation rule triggered by the label — this keeps the routing logic in Jira where non-engineers can update it, not buried in Python.

Step 4: Wire It Together and Confirm in Slack

@app.view("support_ticket")
def handle_submission(ack, body, view, client):
    ack()

    summary = view["state"]["values"]["summary"]["summary_input"]["value"]
    description = view["state"]["values"]["description"]["description_input"].get("value", "")
    user_id = body["user"]["id"]

    # Get user email for Jira reporter field
    user_info = client.users_info(user=user_id)
    email = user_info["user"]["profile"].get("email", "[email protected]")

    # Classify
    category = classify(summary, description)

    # Create ticket
    ticket_key, ticket_url = create_jira_ticket(summary, description, category, email)

    # Confirm in Slack (DM the submitter)
    client.chat_postMessage(
        channel=user_id,
        text=f":white_check_mark: Ticket created: *{ticket_key}*\n"
             f"*Category:* {category.replace('_', ' ').title()}\n"
             f"*Summary:* {summary}\n"
             f"<{ticket_url}|View in Jira>"
    )

The DM confirmation closes the loop that kills informal Slack follow-up. Users know their ticket landed, know what it was classified as, and have a link. If the classification looks wrong, they can comment on the Jira ticket or re-submit with a correction note.

Handling UNKNOWN Classifications

Don't silently create an unrouted ticket. When the classifier returns UNKNOWN, route to a triage channel and notify an admin:

if category == "UNKNOWN":
    ticket_key, ticket_url = create_jira_ticket(
        summary, description, "IT_HARDWARE", email  # safe default project
    )
    # Alert the triage channel
    client.chat_postMessage(
        channel="#it-triage",
        text=(
            f":question: Unclassified ticket from <@{user_id}>: *{ticket_key}*\n"
            f"*Summary:* {summary}\n"
            f"Manual routing needed. <{ticket_url}|View in Jira>"
        )
    )

In production, UNKNOWN hits about 4% of tickets. The triage channel means nothing falls through — it just needs a human for those 4 cases per 100.

Deployment

This runs on a DigitalOcean App Platform instance (512MB RAM, €5/month). The full app is about 200 lines of Python. Flask handles the Slack event URL; Slack Bolt handles the routing.

One gotcha: Slack requires your event URL to respond within 3 seconds. If your LLM call + Jira API call exceed that, you'll get timeout errors. The fix is to acknowledge immediately and process in a background thread:

@app.view("support_ticket")
def handle_submission(ack, body, view, client):
    ack()  # acknowledge immediately — Slack's 3s clock stops here
    # process in background
    threading.Thread(
        target=_process_ticket,
        args=(body, view, client)
    ).start()
~200
Lines of Python total
96%
Classification accuracy
€5/mo
Hosting cost

What to Build Next

Once this is running, two extensions add significant value:

  • SLA breach alerts. Query Jira every 15 minutes for tickets approaching their SLA deadline and post to the assignee's Slack DM. See the SLA automation post for the full implementation.
  • Status updates back to Slack. Use a Jira webhook to DM the original requester when their ticket status changes. Closes the loop entirely — no more "what's happening with my request?" messages.

Get the Full Source Code

The complete Slack-to-Jira bot (~200 lines): Flask app, classifier, Jira integration, and the DigitalOcean deploy config.

Related Service

Custom Slack Bots for Engineering Teams

I build production Slack bots with AI classification, Jira integration, and multi-workspace support. Fixed price, full code ownership, deployed in under 3 weeks.

Learn more →

Related Posts

Jira SLA Automation: 65% Faster Response Time

The SLA breach alert system that pairs with this bot.

Multi-Tenant Slack Bot: One App, Three Workspaces

Scale this architecture to multiple Slack workspaces.

Evgeny Goncharov - Founder of TechConcepts, ex-Yandex, ex-EY, Darden MBA

Evgeny Goncharov

Founder, TechConcepts

I build automation tools and custom software for businesses. Previously at Yandex (Search) and EY (Advisory). Darden MBA. Based in Madrid.

About me LinkedIn GitHub
← All blog posts

Want a Slack bot that actually routes tickets?

15 minutes. I'll tell you if I can build it for your stack and give you a rough scope.

Book a Free Call