Better Helpdesk
Inbox and team

Set service levels

Give each tier of customer response and resolution targets, let the jobs run warn and record breaches, and read attainment on the Statistics page.

A service level is a promise to answer and to resolve within a time, usually different for an enterprise customer than for everyone else. Better Helpdesk keeps the promise as code in your config: tiers, a target per tier and priority, and the hours each one counts. The package starts the clocks, pauses them while the customer has the ball, warns before a deadline and records a breach after it, and reports attainment. There is no rule builder; a change to a target is a pull request. Every option is described in the configuration reference.

Name the tiers

A tier is a word your app attaches to a conversation, such as the plan a customer is on or the SLA their contract names. Decide it in resolveTier from what the helpdesk knows when a conversation opens: the customer's user, the organization they filed it under, their address, the inbox, and the context your widget captured. Most hosts look up the organization:

lib/helpdesk.ts
export const helpdesk = buildHelpdesk({
  // ...
  tiers: ['gold', 'platinum'],
  resolveTier: async ({ orgId, context }) => {
    // The cloud space the report is about decides, when the widget knows it.
    const space = context.host?.space;
    if (space) return (await db.space(space))?.slaTier ?? null;
    return orgId ? (await db.org(orgId))?.slaTier ?? null : null;
  },
});

A tier can also be set or changed by an agent in the conversation's properties card, which sets the deadlines afresh. tiers lists what agents may choose; unset, it is the keys of sla.tiers.

When a plan changes

The tier is decided when a conversation opens, so a customer who upgrades keeps their old tier on the conversations already open. Two things move them:

  • Your app calls refreshTiers when a plan changes, with the organization's id or the person's address:
app/billing/webhook.ts
await helpdesk.refreshTiers({ orgId: subscription.orgId });
  • The customer writes again. Each customer message asks resolveTier once more.

Either way, resolveTier is asked with the conversation's organization, the customer's address, its inbox and its context; user is null. A conversation whose answer differs moves to the new tier: its deadlines are set afresh from its start, the timeline records the change, and conversation.updated reports it. A tier an agent chose by hand stands, so neither changes it. Resolved and merged conversations are left as they are, and a resolveTier that throws changes nothing.

Write the policy

Targets are hours, or business days. respond is the time to the first public reply; reply the time to answer each message the customer writes after that; resolve the time to resolution. Each target counts its own hours, else the policy's, else the inbox's business hours, else every hour:

lib/helpdesk.ts
const office: BusinessHours = {
  timeZone: 'Europe/Zurich',
  weekly: Object.fromEntries(
    ['mon', 'tue', 'wed', 'thu', 'fri'].map(d => [d, [['08:00', '17:00']]])
  ),
};

export const helpdesk = buildHelpdesk({
  // ...
  sla: {
    tiers: {
      gold: {
        targets: {
          urgent: { respond: 4, resolve: 24, hours: 'always' },
          high: { respond: 8, resolve: 48, hours: 'always' },
          normal: { respond: 16, resolve: 80, hours: office },
        },
      },
      platinum: {
        targets: {
          urgent: { respond: 1, resolve: 8, hours: 'always' },
          high: { respond: 4, resolve: 24, hours: 'always' },
          normal: { respond: 8, resolve: 40, hours: office },
        },
      },
    },
    default: { targets: { normal: { respond: 24, hours: office } } },
    warnAt: 0.75,
  },
});

In business days

A contract that promises an answer "within one business day" writes it as a string of days, such as '1d':

normal: { respond: '1d', resolve: '5d', hours: office },

A day target is due at the same time of day that many open days later. A message at 14:00 on Friday is due at 14:00 on Monday; one at 20:00 on Friday counts from Monday's opening at 08:00, so it is due on Tuesday at 08:00. A day counts when the hours open on it and it is not a closed day, so weekends and public holidays are skipped. When that time falls outside a shorter day's hours, the day's opening or closing stands instead. Round the clock, with hours: 'always', a day is 24 hours.

The resolution clock still pauses while a conversation is pending, and the warning still comes at warnAt of the time: in open hours, between the start and that deadline.

A priority a policy leaves out has no deadline. The default policy is for conversations without a tier, or whose tier has no policy of its own: a best-effort tier gets a response target and no resolution target, or no entry at all.

Let customers pick the priority

Enterprise customers often raise P1, P2 and P3 themselves, while everyone else gets a priority from the team. Either name the package's four priorities with priorityLabels, or make P1 to P3 the priorities themselves with priorities: ['p3', 'p2', 'p1'], lowest first, and key the policy by those. Then on the inbox their tickets land in say which ones may be picked, what each means, and which tiers get the choice:

lib/helpdesk.ts
priorityLabels: { urgent: { en: 'P1' }, high: { en: 'P2' }, normal: { en: 'P3' } },
inboxes: {
  support: {
    priorities: {
      options: {
        urgent: { en: 'Complete loss of functionality, or a security vulnerability.' },
        high: { en: 'A key function is unavailable.' },
        normal: { en: 'Partial functionality, display or performance.' },
      },
      tiers: ['gold', 'platinum'],
    },
  },
},

The widget asks resolveTier who the signed-in customer is before it shows the form, with the organization the widget's orgId names or their first one, so a gold customer sees the choice on every ticket form of that inbox and a customer without a tier does not. Leave tiers out to offer the choice to every signed-in customer. A visitor never gets it: whoever is not signed in writes at defaultPriority, and the team sets the rest. The same rule holds on the server, so a request that names a priority its sender may not pick is refused.

How the clock runs

  • A conversation opened by a customer starts both clocks. The response clock stops at the first public agent reply and is never paused.
  • With a reply target, each customer message after the first response starts a next-reply clock, unless one already runs for an earlier message they are still waiting on. It stops at the next public agent reply; an internal note does not stop it. It also stops when the conversation leaves open, as when an agent sets it pending to wait on the customer. It has its own warning and breach, and the inbox sorts by it like the others.
  • The resolution clock runs while the status is open, the conversation being with your team. It pauses on pending, after an agent replied, and on resolved, and resumes when the customer writes again. The deadline moves forward by the time paused, in the hours the target counts.
  • A snooze does not pause it: a snoozed conversation is still with your team.
  • A conversation an agent opens starts pending and has no response target; its resolution clock starts when the customer answers.
  • A new tier or priority sets both deadlines afresh from the conversation's start. A deadline that moved into the future gets a new warning and breach.
  • A target that counts in business hours skips closed days, such as public holidays.

The widget and the receipt tell the customer when a reply is due, the next reply included. The inbox shows the time left or overdue on each row and sorts by the nearest deadline with Sort: Nearest deadline; the conversation shows its deadlines under its properties. Statistics count attainment for the response and resolution targets.

Warnings and breaches

Each runJobs looks at every open conversation with a deadline. Once warnAt of a target has passed, it queues an agent-sla email with stage: 'warning' to the assignee, or the whole team when there is none, and to every supervisor. Once the deadline has passed, it queues one with stage: 'breach'. Each goes once per target, writes an sla.warning or sla.breach row on the conversation's timeline, and raises the event of the same name.

The same warnings and breaches show on the notification bell in the agent UI, so they reach the team without an email adapter. The bell shows them to the conversation's assignee, to everyone while it has none, and to every supervisor, by who has it when the bell is opened.

To page someone, hang it on the event:

lib/helpdesk.ts
onEvent: async event => {
  if (event.kind !== 'sla.breach' || event.conversation.priority !== 'urgent') return;
  await pagerduty.trigger({
    dedup_key: helpdesk.reference(event.conversation),
    summary: `${helpdesk.reference(event.conversation)} missed its ${event.target} target`,
    links: [{ href: `${adminUrl}/conversations/${event.conversation.id}/` }],
  });
},

When the incident is acknowledged or resolved on the other side, write it back with addNote, so the thread stays the one place the story is told.

Read attainment

The Statistics page shows, per period and per inbox, agent, tier, priority and channel, the share of response and resolution targets met, the median and 90th percentile first response and resolution times, and a list of every target missed in the period with how late it was. Each figure links into the inbox. Report from your own database has the SQL for attainment per customer and month, which enterprise credits usually ask for.

On this page