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:

  1. Discussion happens in Slack
  2. Someone (maybe) creates a ticket
  3. They copy-paste relevant messages
  4. Context gets lost in translation
  5. 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:

  1. Fetches recent messages from each monitored channel
  2. Runs them through Claude with channel-specific rules
  3. Posts proposals to an approval channel
  4. 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
  1. Monitor: Watch a data source (Slack, email, meetings, whatever)
  2. Analyze: Use Claude to extract structured information
  3. Propose: Present findings in a human-reviewable format
  4. Approve: Get explicit human sign-off
  5. 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.