Tendkit

Search documentation

Find a docs page

Get Started

Insights

A dashboard next to the inbox: response times, volume, backlog, topics, AI drafts and spikes.

SupportInsights is a dashboard that lives next to the inbox. It answers two questions: how is support going, and what do customers struggle with. Every number sits next to the same number for the period before, and links to the tickets behind it where the inbox can show them. Try it in the demo.

What it shows

  • Speed. Median and 90th-percentile time to first reply and time to solve, the tickets that missed your response target, and the longest waits among them.
  • Volume. Tickets opened and solved per day and the backlog at the end of each day, for every channel or one. From 60 days up, the charts show a week per bar.
  • Team. What each agent holds open and waiting right now, and what they solved.
  • Customers and quality. The customers who wrote most, tickets reopened after being solved, replies per solved ticket, and the share solved in one reply.

The period is the last 7, 30 or 90 days in the viewer's time zone, today included.

Set it up

Apply 0005_insights.sql (three indexes, no new tables), expose support.stats and the new list filters, and add stats to your client:

app/api/support/route.ts
// In the API you already expose to the screens:
case "stats":
  return Response.json(await s.stats(input)); // { from, to, responseTargetMinutes }
case "listTickets":
  return Response.json(
    await s.listTickets(
      {
        status: input.status, // left out when a link asks for every status
        assigneeId: input.assignee === "me" ? agent.id : input.assignee,
        search: input.search,
        // What Insights links to:
        topic: input.topic,
        channelId: input.channel,
        customerId: input.customerId,
        createdFrom: input.createdFrom && new Date(input.createdFrom),
        createdTo: input.createdTo && new Date(input.createdTo),
        sort: input.status === "open" ? "waiting" : "recent",
        limit: input.pageSize,
        offset: (input.page - 1) * input.pageSize,
      },
      agent.id
    )
  );

// With AI:
case "ai.insights":
  return Response.json(await s.ai.insights(input)); // { from, to }
case "ai.listSpikes":
  return Response.json(await s.ai.listSpikes());
case "ai.acknowledgeSpike":
  return Response.json(await s.ai.acknowledgeSpike({ id: input.id, actorId: agent.id }));
app/admin/support/client.ts
export const supportClient: SupportClient = {
  // …
  stats: (input) => call("stats", input),
  ai: {
    // …
    insights: (input) => call("ai.insights", input),
    listSpikes: () => call("ai.listSpikes"),
    acknowledgeSpike: (input) => call("ai.acknowledgeSpike", input),
  },
};
app/admin/support/insights/page.tsx
// app/admin/support/insights/page.tsx
import { SupportInsights } from "@/components/support/support-insights";

export default function Page() {
  return <SupportInsights />;
}

The screen uses the provider you already have. responseTargetMinutes is the target breaches are counted against, the same one that turns a wait red in the inbox. Two things are new on the provider and the client:

NameTypeDescription
aiSettingsHrefstringSupportProvider. Where SupportAiSettings is mounted, so a gap on the dashboard links to the list it is in.
stats(input) => Promise<SupportStats>SupportClient. Calls support.stats({ from, to, responseTargetMinutes }). Without it, SupportInsights renders nothing.
ListQuerytopic, channel, customerId, createdFrom, createdToSupportClient.listTickets. The filters a link from Insights adds to the inbox URL. status is left out when the link asks for every status but spam.

How each number is defined

  • Time to first reply runs from the customer's first message to the first reply that reached them, from an agent or Autopilot. Acknowledgements and failed sends don't count, and tickets your team started have none. It is filed under the period the ticket was opened in.
  • Time to solve runs from when a ticket was opened to when it was last solved, for tickets that are still solved. It is filed under the period it was solved in.
  • Median and 90th percentile interpolate between neighbours, as spreadsheets do: the median of 10, 20, 30 and 40 minutes is 25.
  • A missed target is a ticket opened in the period whose first reply took longer than responseTargetMinutes, or that is open and still waiting past it. Time is wall-clock: there are no business hours.
  • Backlog is rebuilt from when tickets were opened and last solved, so a ticket that was solved and later reopened reads as open in between.
  • The period before has the same length and ends where the chosen one starts. Spam is left out of everything.

With AI

When AI is configured, apply 0006_insights_ai.sql and add insights to client.ai. The dashboard gains:

  • Topics. Tickets per topic with their trend and change, the topics growing fastest (at least 5 tickets and a fifth more than before), the urgency mix, and the share of frustrated or angry customers per day. Each topic also shows how many of its drafts went out as written or lightly edited: where that is low, AI isn't carrying its weight on the topic yet.
  • What became of drafts. Sent by Autopilot, sent as drafted, lightly edited, rewritten or discarded, counted in the period they were decided in; Autopilot replies flagged as wrong; and the median time to first reply on tickets that had a draft ready next to those that didn't.
  • Gaps. The open gaps most tickets ran into, from Memory, linking to AI settings.
  • Questions answered before a ticket. When the portal's Ask box is on: the conversations customers started in it, how many got an answer, how many ended without a ticket, and how many were handed to your team. A conversation counts as answered before a ticket when the customer gave a thumbs-up, or gave no rating, asked nothing more for 24 hours and opened no ticket since it began. It needs 0007_proactive.sql; see Deflection.

Spikes

On every runScheduled, Tendkit looks for a topic that many different customers raised in a short window: by default 5 customers within 2 hours, and at least 3 times what the topic usually gets in those same hours over the last 14 days. A match is recorded as a spike and shown as a likely incident at the top of the inbox and the dashboard, with its tickets one click away, until an agent acknowledges it.

lib/support.ts
createSupport({
  // …
  notify: async (n) => {
    if (n.type === "insights.spike") {
      await postToTeamChannel(`Likely incident: ${n.spike.customers} customers wrote about ${n.spike.topic}`);
    }
  },
  ai: supportAi({
    // …
    spikes: { minCustomers: 5, windowMinutes: 120, factor: 3, baselineDays: 14 }, // the defaults
  }),
});
  • Colleagues at one customer count once, and so does one person writing twice. People not linked to a customer count by contact. The catch-all topic "other" never spikes.
  • notify gets insights.spike once per spike, however many schedulers run. While it lasts, its ticket and customer counts are kept up to date.
  • A spike ends when a whole window has passed without the detector seeing it. An ended spike still shows until someone acknowledges it: an incident overnight is news in the morning. The next burst on the topic is a new spike.
  • Add listSpikes and acknowledgeSpike to client.ai for the banner and its button. Acknowledging records who and when; the first agent stands.
  • Once a spike is acknowledged, "Message customers" writes to every customer who wrote in about it: one ticket each, on one approval, and never twice for the same spike. Add previewBroadcast and sendBroadcast to client.ai to show it. Each spike then carries messaged, the number of customers written to. See Incident broadcast.
  • spikes: false switches detection off.

A spike needs topics

Detection reads the topic AI gave each ticket, so it starts working once you have set your topics in AI settings.

What it costs to load

Nothing is tracked or rolled up: the numbers are computed from tickets, messages and events when the page loads, so they are right for any period and need no backfill. Each query reads one period's tickets through an index on when they were opened or solved, and percentiles are ranked in the database, which returns four rows however many tickets there are. The same queries run on Postgres and on SQLite or Cloudflare D1, one after another, and the screen keeps a result for a minute.