Turning Slack Conversations into Linear Tickets with Claude
Slack is where decisions happen. But Slack is also where decisions get lost.
A feature request comes in on #product-feedback. Someone replies "good idea, we should do that." Three weeks later, no one remembers it existed. The requester follows up, annoyed. The team scrambles to recreate context.
I built slack-linear-sync to fix this. It's an agent that monitors Slack channels, uses Claude to identify actionable items, and proposes Linear tickets for human approval. The goal: turn async conversations into tracked work without manual triage.
The Problem with Manual Ticket Creation
The traditional flow looks like this:
- Discussion happens in Slack
- Someone (maybe) creates a ticket
- They copy-paste relevant messages
- Context gets lost in translation
- The ticket sits in a backlog, disconnected from the original conversation
The failure modes are predictable:
- Forgotten entirely: No one creates the ticket
- Poor context: Ticket says "fix the thing" with no background
- Duplicate work: Multiple people create tickets for the same request
- Wrong team: Ticket lands in the wrong Linear team or project
What if an AI could watch the conversations and propose tickets automatically?
How slack-linear-sync Works
The Polling Loop
The agent runs continuously, polling configured channels on a schedule:
async def polling_loop(agent, slack, dedup, channel_configs, approval_channel, interval_hours):
while True:
logger.info("Starting sync cycle...")
for channel_id, config in channel_configs.items():
messages = await slack.get_channel_messages(channel_id, hours_back=8)
if not messages:
continue
# Claude analyzes messages against channel rules
proposals = await agent.analyze_messages(messages, config)
# Post proposals to approval channel
for proposal in proposals:
proposal_id = dedup.save_proposal(proposal)
blocks = format_proposal_blocks(proposal, config, source_msg)
await slack.post_message(approval_channel, blocks=blocks)
await asyncio.sleep(interval_hours * 60 * 60)
Every 6 hours (configurable), it:
- Fetches recent messages from each monitored channel
- Runs them through Claude with channel-specific rules
- Posts proposals to an approval channel
- Waits for human review
Channel Configuration
Each channel has rules defined in markdown:
## #product-feedback
Channel ID: C0123456789
Linear Team: Product
Default Labels: feedback, triage
**What to look for:**
- Feature requests from users or team members
- Bug reports with reproduction steps
- Complaints about existing functionality
- Requests for documentation or guides
**What to ignore:**
- Thank-you messages and praise
- General discussion without actionable items
- Questions that were already answered
- Duplicate requests (check before proposing)
**Ticket format:**
- Title should be actionable: "Add X" or "Fix Y", not "User wants..."
- Include the original requester's name
- Link back to the Slack thread
- Default priority P3 unless urgency is mentioned
These rules get injected into Claude's prompt. The result is channel-specific behavior without changing code.
The Analysis Prompt
Claude receives:
- Recent messages from the channel (with thread context)
- The channel's configuration and rules
- Examples of good vs bad ticket proposals
- Instructions to output structured JSON
The response looks like:
{
"proposals": [
{
"action": "create_ticket",
"title": "Add dark mode support to dashboard",
"description": "User @sarah requested dark mode for the analytics dashboard. She mentioned eye strain during late-night work sessions.\n\nOriginal thread: [link]",
"labels": ["feature-request", "dashboard"],
"priority": "P3",
"source_message_id": "1234567890.123456",
"confidence": "high",
"reasoning": "Clear feature request with use case explanation"
}
],
"skipped": [
{
"message_id": "1234567890.789012",
"reason": "General discussion, no actionable item"
}
]
}
The confidence and reasoning fields help me understand Claude's thinking when reviewing proposals.
The Approval Flow
Proposals appear in a dedicated approval channel:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📝 New Linear Ticket Proposed
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Channel: #product-feedback
From: @sarah (2 hours ago)
Original message:
"Would love to have dark mode for the dashboard.
I often work late and the bright white is rough on my eyes."
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Proposed ticket:
Title: Add dark mode support to dashboard
Team: Product
Labels: feature-request, dashboard
Priority: P3
Confidence: High
Reasoning: Clear feature request with use case
[✓ Approve] [✏️ Edit] [✗ Dismiss]
Three options:
- Approve: Creates the ticket in Linear immediately
- Edit: Opens a modal to modify title, description, labels, or priority before creating
- Dismiss: Marks as reviewed, won't propose again
The webhook server handles button clicks:
@app.post("/slack/interactions")
async def handle_interaction(request: Request):
payload = await verify_and_parse(request)
action = payload["actions"][0]
proposal_id = action["value"]
if action["action_id"] == "approve_ticket":
proposal = dedup.get_proposal(proposal_id)
issue = await linear.create_issue(
team_id=proposal.team_id,
title=proposal.title,
description=proposal.description,
labels=proposal.labels,
priority=proposal.priority,
)
dedup.mark_executed(proposal_id, issue.id)
await slack.update_message(payload, f"✅ Created: {issue.url}")
elif action["action_id"] == "dismiss_ticket":
dedup.mark_dismissed(proposal_id)
await slack.update_message(payload, "❌ Dismissed")
Deduplication
The dedup service prevents the same message from generating multiple tickets:
class DedupService:
def __init__(self, db_path: str = "proposals.db"):
self.conn = sqlite3.connect(db_path)
self._init_schema()
def is_duplicate(self, message_id: str, channel_id: str) -> bool:
"""Check if we've already processed this message."""
cursor = self.conn.execute(
"SELECT 1 FROM proposals WHERE slack_message_id = ? AND channel_id = ?",
(message_id, channel_id)
)
return cursor.fetchone() is not None
def save_proposal(self, proposal: Proposal) -> int:
"""Save a new proposal, return its ID."""
cursor = self.conn.execute(
"""INSERT INTO proposals
(slack_message_id, channel_id, title, description, status, created_at)
VALUES (?, ?, ?, ?, 'pending', datetime('now'))""",
(proposal.slack_message_id, proposal.channel_id,
proposal.title, proposal.description)
)
self.conn.commit()
return cursor.lastrowid
Before analyzing messages, the agent filters out any that have already been processed. This prevents:
- Re-proposing dismissed items
- Duplicate tickets from overlapping time windows
- Noise from the same conversation being analyzed multiple times
Lessons Learned
1. Channel rules are essential
Without per-channel configuration, Claude either over-proposes (every message becomes a ticket) or under-proposes (misses legitimate requests). The rules file lets me encode institutional knowledge about what matters in each channel.
2. Confidence scores help calibration
Adding confidence to the output let me see where Claude was uncertain. Low-confidence proposals often needed better rules or examples. High-confidence dismissals validated that the filtering was working.
3. Thread context matters
Initially I only passed individual messages. Results improved dramatically when I included thread replies. A top-level message might be vague, but the thread often contains clarification, reproduction steps, or agreement that this should be tracked.
4. The approval step catches errors
About 10% of proposals get dismissed or edited. Common reasons:
- Already tracked elsewhere
- Not actually our team's responsibility
- Needs more discussion before becoming a ticket
- Claude misunderstood the context
That 10% is why full automation would be risky. The approval step is cheap (a few seconds) and catches real mistakes.
5. Linking back to Slack is crucial
Every ticket includes a link to the original Slack thread. This means:
- Full context is one click away
- The requester can be notified when the ticket is created
- Future discussion can happen in either place
- Nothing gets lost in translation
The Broader Pattern
This project reinforced a pattern I keep coming back to:
Monitor → Analyze → Propose → Approve → Execute
- Monitor: Watch a data source (Slack, email, meetings, whatever)
- Analyze: Use Claude to extract structured information
- Propose: Present findings in a human-reviewable format
- Approve: Get explicit human sign-off
- Execute: Take action via API
The proposal step is key. It's where AI capability meets human judgment. Claude does the tedious work of reading everything and identifying patterns. I make the final call.
Running It
The agent runs as a single Python process:
# Set environment variables
export SLACK_BOT_TOKEN=xoxb-...
export SLACK_SIGNING_SECRET=...
export LINEAR_API_KEY=lin_api_...
export SLACK_APPROVAL_CHANNEL_ID=C0123456789
# Run the agent
python -m src.run_agent
It starts a webhook server (for Slack interactions) and a polling loop (for channel monitoring) concurrently. Deployed on Fly.io with a single fly deploy.
What's Next
A few improvements I'm considering:
- Semantic dedup: Currently dedup is message-ID based. Semantic similarity would catch "the same request phrased differently"
- Auto-link related tickets: If a proposal matches an existing Linear issue, suggest linking instead of creating new
- Feedback loop: Track which proposals get approved vs dismissed to improve Claude's calibration over time
If you're building something similar, reach out. I'd love to compare notes.
Built with Python, Claude (Opus 4.5), Slack Bolt, and Linear GraphQL. Deployed on Fly.io.