Customer Support Inbox: Sentiment Triage & Routing

Classify every inbound support message with SENTIMENT() and auto-route it to the right team, owner, and priority — no external NLP service.

All recipes· agents· 10 minutesintermediate✓ Certified v1.14.0-ceen
Instance: localhost:8080

Opens your running SynapCores (Customer Support Inbox: Sentiment Triage & Routing will be staged for a preview — nothing runs until you click Run). No instance yet? Install free in ~30s.

Share

Objective

A support inbox never arrives sorted. A furious billing dispute from an enterprise account sits in the same queue as a thank-you note and a routine "where's my order?" — and a human has to read every one to decide how urgent it is and who should own it. That triage is the slowest, most error-prone step in support, and it happens before anyone has even started helping.

This recipe does the triage inside the database. Every message is scored with SENTIMENT() — a SQL function that returns positive, negative, or neutral using the engine's built-in model, no Hugging Face endpoint and no Python service. Then a plain-SQL routing rule turns sentiment + customer tier + a couple of keywords into a concrete assignment: team, owner, and priority. The final step hands the hardest message to an in-database agent (AGENT_RUN) that writes the routing rationale and a suggested first reply. One table, one connection, one auth token.

Step 1: Create the inbox and the routing directory

recipe_support_inbox holds raw messages exactly as they land from email/chat. recipe_support_teams is a tiny directory of who handles what, so routing decisions point at a real owner.

CREATE TABLE IF NOT EXISTS recipe_support_inbox (
  message_id    INTEGER PRIMARY KEY,
  received_at   TIMESTAMP,
  channel       TEXT,          -- email | chat | twitter
  customer_name TEXT,
  customer_tier TEXT,          -- free | pro | enterprise
  subject       TEXT,
  body          TEXT
);

CREATE TABLE IF NOT EXISTS recipe_support_teams (
  team        TEXT PRIMARY KEY,
  owner       TEXT,
  handles     TEXT
);

Step 2: Seed a realistic inbox

Ten messages that look like a real Monday morning: praise, a churn-risk enterprise complaint, a double-charge, an outage, and routine questions.

INSERT INTO recipe_support_inbox (message_id, received_at, channel, customer_name, customer_tier, subject, body) VALUES
  (1, '2026-08-03 08:02:00', 'email', 'Dana Whitfield',  'enterprise', 'Incredible onboarding',            'Your onboarding team was amazing this week - we were live in two days. Thank you!'),
  (2, '2026-08-03 08:07:00', 'email', 'Marcus Reyes',     'enterprise', 'Charged twice, no refund',         'I have been charged twice this month and three emails to billing have gone unanswered. If this is not fixed today we are cancelling our contract.'),
  (3, '2026-08-03 08:11:00', 'chat',  'Priya Nair',       'pro',        'Order status?',                    'Hi, can you tell me the status of order #4471? It has been a few days.'),
  (4, '2026-08-03 08:15:00', 'email', 'Tom Bexley',       'pro',        'URGENT: production is down',       'Our production integration is down and your API has been returning 500 errors for the last hour. This is business critical.'),
  (5, '2026-08-03 08:19:00', 'twitter','Sofia Almeida',   'free',       'love it',                          'just wanted to say the new dashboard is gorgeous, great work team!'),
  (6, '2026-08-03 08:24:00', 'email', 'Henry Osei',       'enterprise', 'Considering alternatives',         'We are frustrated with the slow response times and are evaluating competitors. We need a call this week.'),
  (7, '2026-08-03 08:30:00', 'chat',  'Aiko Tanaka',      'free',       'How do I reset my password?',      'Where is the password reset link? I cannot find it in settings.'),
  (8, '2026-08-03 08:36:00', 'email', 'Gabriel Santos',   'pro',        'Refund not received',              'The refund you promised last week still has not arrived and I am starting to lose patience.'),
  (9, '2026-08-03 08:41:00', 'email', 'Lena Vogt',        'enterprise', 'Renewal + expansion',             'We are thrilled with the platform and want to talk about expanding to two more teams next quarter.'),
  (10,'2026-08-03 08:47:00', 'chat',  'Omar Haddad',      'free',       'Feature question',                 'Does the export support CSV as well as JSON? Just checking before I upgrade.');

INSERT INTO recipe_support_teams (team, owner, handles) VALUES
  ('Escalations',      'Sarah Chen (Sr. CSM)',      'Angry or churn-risk enterprise accounts'),
  ('Billing',          'Ravi Kapoor',                'Charges, refunds, invoices'),
  ('On-call SRE',      'Ops pager rotation',         'Outages, 5xx errors, downtime'),
  ('Tier-1 Support',   'Support queue',              'General questions, how-tos'),
  ('Advocacy',         'Mia Torres (Community)',     'Happy customers, testimonials, expansion');

Step 3: Ground truth — read the inbox by eye

Before any AI, look at what you would route manually. This is the check you compare the machine against.

SELECT message_id, customer_tier, channel, subject
FROM recipe_support_inbox
ORDER BY message_id;

Expected: 10 rows. By eye, the obvious escalations are #2 (double charge + cancel threat, enterprise), #4 (production outage), and #6 (churn-risk enterprise). #1, #5, #9 are clearly happy. The rest are routine.

Step 4: Classify sentiment with one SQL function

SENTIMENT(text) returns a single word — positive, negative, or neutral. No model to download, no endpoint to call.

SELECT message_id, customer_name, subject, SENTIMENT(body) AS sentiment
FROM recipe_support_inbox
ORDER BY message_id;

Expected: praise (#1, #5, #9) → positive; the billing disputes, the churn-risk note, and the outage (#2, #4, #6, #8) → negative; routine questions (#3, #10) → neutral. Borderline messages tip on tone — the terse password-reset ask (#7) comes back negative because the model reads its mild frustration. These labels are the model's judgment, not a lookup table, so treat them as signal, not gospel.

Persist the label so downstream routing and reporting don't re-run the model:

ALTER TABLE recipe_support_inbox ADD COLUMN sentiment TEXT;
UPDATE recipe_support_inbox SET sentiment = SENTIMENT(body);

Step 5: Triage — turn sentiment into a team, owner, and priority

This is the payoff: a deterministic routing rule that combines the sentiment label with the customer tier and a few keyword signals. It answers who gets this message and how fast.

SELECT
  m.message_id,
  m.customer_name,
  m.customer_tier,
  m.sentiment,
  CASE
    WHEN LOWER(m.body) LIKE '%down%' OR LOWER(m.body) LIKE '%500%' OR LOWER(m.body) LIKE '%outage%'
      THEN 'On-call SRE'
    WHEN LOWER(m.body) LIKE '%charge%' OR LOWER(m.body) LIKE '%refund%' OR LOWER(m.body) LIKE '%invoice%'
      THEN 'Billing'
    WHEN m.sentiment = 'negative' AND m.customer_tier = 'enterprise'
      THEN 'Escalations'
    WHEN m.sentiment = 'positive'
      THEN 'Advocacy'
    ELSE 'Tier-1 Support'
  END AS assigned_team,
  CASE
    WHEN LOWER(m.body) LIKE '%down%' OR LOWER(m.body) LIKE '%500%' OR LOWER(m.body) LIKE '%outage%' THEN 'P1'
    WHEN m.sentiment = 'negative' AND m.customer_tier = 'enterprise' THEN 'P1'
    WHEN m.sentiment = 'negative' THEN 'P2'
    WHEN m.sentiment = 'neutral' THEN 'P3'
    ELSE 'P4'
  END AS priority
FROM recipe_support_inbox m
ORDER BY priority, m.message_id;

Expected routing: #4 → On-call SRE / P1 (outage keywords), #2 → Billing / P1 (charge keyword routes it; negative + enterprise sets P1), #8 → Billing / P2 (refund keyword), #6 → Escalations / P1 (negative enterprise, no keyword), #1/#5/#9 → Advocacy / P4 (positive), #3/#10 → Tier-1 / P3 (neutral). Notice the design: the keyword rule deliberately sends billing pain to Billing even for enterprise accounts, while the priority still escalates on account tier — so an enterprise complaint gets the right owner and the right urgency.

Join to the directory to hand each message a named owner:

SELECT r.message_id, r.assigned_team, t.owner, r.priority, r.subject
FROM (
  SELECT
    m.message_id, m.subject, m.sentiment,
    CASE
      WHEN LOWER(m.body) LIKE '%down%' OR LOWER(m.body) LIKE '%500%' OR LOWER(m.body) LIKE '%outage%' THEN 'On-call SRE'
      WHEN LOWER(m.body) LIKE '%charge%' OR LOWER(m.body) LIKE '%refund%' OR LOWER(m.body) LIKE '%invoice%' THEN 'Billing'
      WHEN m.sentiment = 'negative' AND m.customer_tier = 'enterprise' THEN 'Escalations'
      WHEN m.sentiment = 'positive' THEN 'Advocacy'
      ELSE 'Tier-1 Support'
    END AS assigned_team,
    CASE
      WHEN LOWER(m.body) LIKE '%down%' OR LOWER(m.body) LIKE '%500%' OR LOWER(m.body) LIKE '%outage%' THEN 'P1'
      WHEN m.sentiment = 'negative' AND m.customer_tier = 'enterprise' THEN 'P1'
      WHEN m.sentiment = 'negative' THEN 'P2'
      WHEN m.sentiment = 'neutral' THEN 'P3'
      ELSE 'P4'
    END AS priority
  FROM recipe_support_inbox m
) r
JOIN recipe_support_teams t ON t.team = r.assigned_team
ORDER BY r.priority, r.message_id;

Step 6: Level up — let an in-DB agent write the rationale and first reply

Deterministic rules route the obvious cases. For the highest-priority message, have the in-database agent read it, confirm the routing, and draft a response — so the assigned owner starts with a first draft, not a blank page.

SELECT AGENT_RUN(
  'aidb-assistant',
  'A support message from an enterprise customer reads: "I have been charged ' ||
  'twice this month and three emails to billing have gone unanswered. If this ' ||
  'is not fixed today we are cancelling our contract." Its sentiment is ' ||
  'negative. In 3 short numbered lines output: (1) which team should own it and ' ||
  'why, (2) the priority P1-P4, (3) a two-sentence first reply the owner can send.'
) AS triage_brief;

Expected: a short brief that routes the message to Billing, marks it P1, and drafts an apologetic reply acknowledging the double charge and committing to a same-day fix. (The message text is inlined here so the step is self-contained; in a real app you'd pass the row's body in as a bind parameter — an AGENT_RUN argument can't itself contain a (SELECT …) subquery.)

Cleanup (Optional)

DROP TABLE IF EXISTS recipe_support_inbox;
DROP TABLE IF EXISTS recipe_support_teams;

Use it from your app or agent

  • REST/SDK: run Step 4 on INSERT (or on a schedule) to stamp sentiment on every new row, then Step 5 as a view your ticketing UI reads. Your app never calls a separate sentiment API — it's one client.sql(...).
  • Durable agent: wrap Step 6 in CREATE AGENT ... ON INSERT INTO recipe_support_inbox so every new message is auto-triaged the moment it lands, with the rationale written to an audit table.
  • MCP: an LLM client (Claude Code, Cursor) can call the same SQL through the query tool — "score and route the last 20 support messages" becomes one tool call.
  • Why in-DB: the message, its sentiment, the routing rule, the team directory, and the agent's reasoning all live in the same store under one tenant scope. No message text is shipped to a third-party NLP service, and the triage is auditable because it's just SQL.

Key Concepts Learned

  • SENTIMENT(text) is a first-class SQL function returning positive/negative/neutral — classification without a separate ML stack.
  • Triage is a policy, and policy is just CASE logic over the AI label plus business signals (tier, keywords) you already store.
  • Escalate from deterministic rules to AGENT_RUN only for the cases that need judgment — cheap where you can, smart where you must.
  • Everything — raw text, AI labels, routing, and rationale — stays in one engine, one query surface, one auth boundary.

Tags

sentimentsentiment-analysisnlpaisupporttriageroutingagent-run

Run this on your own machine

Install SynapCores Community Edition free, paste the SQL or Cypher above into the bundled web UI, and watch it run.

Download Free CE