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, andviews:openscopes - 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,openaipackages
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:
- 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."
max_tokens=10caps 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", "unknown@company.com")
# 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()
What to Build Next
If the requests already live in a Jira Service Management service space, Workforce Management may cover team and agent assignment without this bot. Rovo vs custom AI for Jira triage is where we draw that line. The bot is for the steps that leave Jira.
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.