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", "[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()
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.