WorkflowsMCP

Customer feedback triage

Classify Intercom conversations, route them to #feedback, file the real bugs in Linear.

@workflowsmcpMar 4, 2026Verified Aug 2026supporttriageai-classificationcustomer-feedbackautomation

Support inboxes hide product signal inside individual conversations. This workflow classifies every inbound Intercom conversation into bug / feature request / pricing / churn risk, tags it back in Intercom so support sees the same label, posts the interesting ones to #feedback, and only creates a Linear bug when the classifier is confident and the conversation is not a duplicate of one filed this week.

How it flows

  1. 01

    New Intercom conversation

    Webhook delivers conversation.user.created; the endpoint 200s immediately and queues the work.

  2. 02

    Classified into four buckets

    bug / feature / pricing / churn-risk, with a confidence score and a one-line summary.

  3. 03

    Tag written back to the conversation

    Support sees the same label product does — one classification, not two.

  4. 04

    Duplicate check against open issues

    Same fingerprint this week means comment-and-count, not a new ticket.

  5. 05

    Posted to #feedback

    Class, confidence, account plan and MRR, with a link straight into the conversation.

  6. 06

    Confident bugs filed in Linear

    Labelled customer-reported and linked back to Intercom.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

Intercom

Source of conversations, and where the label goes back

  1. 01

    Create an app and access token

    Intercom → Settings → Integrations → Developer Hub → New app → Configure → Authentication → generate an access token. Scopes: "Read conversations", "Write tags", "Read and list users and companies". Pin the API version in the header, or a future version bump will silently reshape your payloads.

    Required headers
    Authorization: Bearer $INTERCOM_ACCESS_TOKEN
    Intercom-Version: 2.16
    Accept: application/json
  2. 02

    Subscribe to conversation webhooks

    Developer Hub → your app → Webhooks → set the endpoint, then subscribe to conversation.user.created and conversation.user.replied. Reply to the delivery with a 200 inside 5 seconds and do the classification asynchronously — Intercom retries on timeout and you will classify twice.

    conversation.user.created (trimmed)
    {
      "type": "notification_event",
      "topic": "conversation.user.created",
      "data": {
        "item": {
          "type": "conversation",
          "id": "97812334",
          "created_at": 1772283901,
          "source": {
            "type": "conversation",
            "body": "<p>Export to CSV times out on anything over ~20k rows.</p>",
            "author": { "type": "user", "email": "lee@northwind.co" }
          },
          "contacts": { "contacts": [{ "id": "6620a1f3c9d2", "external_id": "org_4471" }] }
        }
      }
    }
  3. 03

    Create the tag taxonomy once

    Four tags, no more: triage/bug, triage/feature, triage/pricing, triage/churn-risk. Applying the tag back to the conversation is what keeps support and product looking at the same classification.

    Create a tag and apply it
    curl -X POST https://api.intercom.io/tags \
      -H "Authorization: Bearer $INTERCOM_ACCESS_TOKEN" \
      -H "Intercom-Version: 2.16" \
      -H "Content-Type: application/json" \
      -d '{"name":"triage/bug"}'
    
    curl -X POST https://api.intercom.io/conversations/97812334/tags \
      -H "Authorization: Bearer $INTERCOM_ACCESS_TOKEN" \
      -H "Intercom-Version: 2.16" \
      -d '{"id":"8812004","admin_id":"5140922"}'

Slack

Shared feed the product team reads daily

  1. 01

    Create #feedback and set the rule of thumb

    Post everything classified bug or churn-risk, plus feature requests from accounts over your revenue threshold. Everything else stays in Intercom — a channel that posts every conversation gets muted in a week.

  2. 02

    Give the app a token that can post

    api.slack.com/apps → your app → OAuth & Permissions → Bot Token Scopes → chat:write (that is the whole requirement here; add chat:write.public if you want it to post without being invited). Install to the workspace, then invite the bot to #feedback. Everything below is one chat.postMessage call with that bot token.

    chat.postMessage
    curl -X POST https://slack.com/api/chat.postMessage \
      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
      -H "Content-Type: application/json; charset=utf-8" \
      -d '{"channel":"C05FEEDBACK","text":"bug · 0.91 · Northwind","blocks":[]}'
  3. 03

    Show the classification and its confidence

    Displaying confidence is what lets the team calibrate the classifier instead of trusting it blindly. Anything under 0.75 is posted as "needs a human".

    Classifier output → Slack fields
    {
      "conversation_id": "97812334",
      "class": "bug",
      "confidence": 0.91,
      "severity_guess": "medium",
      "account": { "name": "Northwind", "plan": "growth", "mrr": 890 },
      "summary": "CSV export times out above ~20k rows",
      "duplicate_of": null
    }

Linear

Only the confident, non-duplicate bugs

  1. 01

    Set the filing rule

    Create the issue only when class is "bug" AND confidence is at least 0.75 AND no open issue already carries the same customer-report fingerprint. Everything else waits for a human to press the button in Slack.

    Filing predicate
    file_issue =
          class == "bug"
      and confidence >= 0.75
      and duplicate_of is null
      and account.plan != "free"     # free-tier reports go to the weekly digest instead
  2. 02

    Stamp the source on the issue

    File it with the issueCreate mutation, authenticated by a personal API key from Linear → Settings → Security & access → Personal API keys, sent as the raw Authorization value with no "Bearer " prefix. Put the Intercom conversation id and the reporter in the description, and add the "customer-reported" label. Intercom publishes no documented URL format for a conversation, so the id — which you already have from the webhook — is the durable handle, and it is what support searches on. Six weeks later, it is the only way to answer "who asked for this?".

    Look up the ids once, then issueCreate
    # Once, into config: the team you file under and the id of the label.
    # Both connections page at 50 by default, more than any real workspace needs here.
    curl -s -X POST https://api.linear.app/graphql \
      -H "Authorization: $LINEAR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"query":"{ teams { nodes { id key name } } issueLabels { nodes { id name } } }"}'
    
    # Per confident bug. The single quotes are load-bearing: $input is a GraphQL
    # variable, not an env var — unquoted, the shell expands it to empty and Linear 400s.
    curl -s -X POST https://api.linear.app/graphql \
      -H "Authorization: $LINEAR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "query": "mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier url } } }",
        "variables": {
          "input": {
            "teamId": "3f0c9a51-7d2e-4b86-9a04-c1e5b83d7f62",
            "title": "CSV export times out above ~20k rows",
            "description": "Intercom conversation 97812334, reported by lee@northwind.co — Northwind (growth, $890 MRR) — classified bug at 0.91 confidence, severity medium",
            "labelIds": ["b48e2d7c-6a19-4f53-8e21-9d0a4c7b3e15"],
            "priority": 3
          }
        }
      }'
    
    # => {"data":{"issueCreate":{"success":true,"issue":{
    #      "id":"c9f1...","identifier":"SUP-412","url":"https://linear.app/acme/issue/SUP-412"}}}}
    # Keep issue.identifier next to the conversation — it is what the de-dup check matches on.
  3. 03

    De-duplicate before you create

    Search open issues for the same fingerprint first; on a hit, comment on the existing issue with the new conversation link and increment a "reports" count instead of creating a second ticket.

    Duplicate check before issueCreate
    # The fingerprint is a distinctive phrase plus a week, not the whole summary:
    # the classifier rewrites its one-liner for every conversation, so an exact
    # title match never hits and you file the same bug again every few days.
    # The floor below is the webhook's created_at (1772283901) minus seven days.
    curl -s -X POST https://api.linear.app/graphql \
      -H "Authorization: $LINEAR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "query": "query Duplicates($filter: IssueFilter!) { issues(filter: $filter) { nodes { id identifier title url createdAt } } }",
        "variables": {
          "filter": {
            "title": { "containsIgnoreCase": "CSV export" },
            "createdAt": { "gte": "2026-02-21T13:05:01.000Z" }
          }
        }
      }'
    
    # => {"data":{"issues":{"nodes":[]}}}
    # Empty is where the classifier's "duplicate_of": null comes from, and the only
    # state in which the issueCreate above may run. A non-empty array: comment the
    # conversation id on nodes[0].identifier, bump its report count, create nothing.
    # No open/closed filter on purpose — a report closed on Tuesday is still the
    # same report on Friday, and reopening one issue beats two nobody links up.