# Configuration

URL: https://better-helpdesk.com/docs/reference/configuration/

Every field of HelpdeskConfig, the object buildHelpdesk takes, with its type and default.



`HelpdeskConfig` is the one object that `buildHelpdesk(config)` from `better-helpdesk` takes. The fields below are grouped by what they serve; the order inside the type is different. Inboxes have [a page of their own](/docs/inbox/inboxes), and the adapter interfaces are on [Adapters](/docs/reference/adapters).

```ts title="lib/helpdesk.ts"
import { buildHelpdesk, postgresAdapter } from 'better-helpdesk';
import { pool } from './db'; // a pg.Pool

export const helpdesk = buildHelpdesk({
  db: postgresAdapter({ pool }),
  referencePrefix: 'ACME',
  adminUrl: 'https://app.example.com/helpdesk/',
  inboxes: { support: {} },
  identify: async () => null,
});
```

## Required [#required]

<TypeTable
  type="{
  db: {
    type: 'DatabaseAdapter',
    required: true,
    description: <>Where the helpdesk keeps its data. <code>postgresAdapter({'{ pool }'})</code> builds it over a <code>pg.Pool</code>; <code>mysqlAdapter</code>, <code>mssqlAdapter</code> and <code>sqliteAdapter</code> over the other databases, as <a href=&#x22;/docs/database/connect&#x22;>Choose and connect a database</a> shows, and <code>createAdapter</code> over <a href=&#x22;/docs/database/own-adapter&#x22;>an adapter of your own</a>.</>,
  },
  referencePrefix: {
    type: 'string',
    required: true,
    description: <>Prefix of the human-readable reference, <code>PREFIX-number</code>. <code>ACME</code> gives <code>ACME-1042</code>. Numbers come from the <a href=&#x22;/docs/database/tables#counter&#x22;><code>counter</code></a> table and start at 1000.</>,
  },
  adminUrl: {
    type: 'string',
    required: true,
    description: <>Absolute URL of the agent UI. Agent emails link to <code>{'{adminUrl}'}/conversations/{'{id}'}/</code>. Its origin is the only one, besides an inbox's <code>allowedOrigins</code> on widget routes, that mutations are accepted from. Rating links are built against it.</>,
  },
  inboxes: {
    type: 'Record<string, InboxConfig>',
    required: true,
    description: <>The inboxes by key. See <a href=&#x22;/docs/inbox/inboxes&#x22;>Inboxes</a>.</>,
  },
  identify: {
    type: '(request: Request) => Promise<Identity | null>',
    required: true,
    description: <>Turns a request into the host's signed-in user, or <code>null</code> for an anonymous visitor. Called by every route that needs the caller, which is every <code>widget/*</code> route but <code>widget/help</code>, and every <code>agent/*</code> route. On agent routes it is the only source of identity, and <code>isAgent: true</code> is required. See <a href=&#x22;#identity&#x22;>Identity</a>.</>,
  },
}"
/>

## Routing and identity [#routing-and-identity]

<TypeTable
  type="{
  basePath: {
    type: 'string',
    default: &#x22;'/api/helpdesk'&#x22;,
    description: 'Mount path of the route handler. A trailing slash is removed.',
  },
  identityTokenSecret: {
    type: 'string',
    description: <>Secret for the HS256 JWTs a host on another origin passes in <code>x-helpdesk-identity</code>. Consulted only when <code>identify</code> returns <code>null</code>. Shorter than 32 bytes, <code>buildHelpdesk</code> throws. A token never grants the agent UI.</>,
  },
  clientIp: {
    type: '(request: Request) => string | null',
    description: <>The caller's address as the host's proxy reports it, for rate limits. Unset, the last <code>X-Forwarded-For</code> hop is used, then <code>X-Real-IP</code>. <code>null</code> or no address counts as <code>unknown</code>. IPv6 addresses count per <code>/64</code>.</>,
  },
}"
/>

## Names shown to customers [#names-shown-to-customers]

<TypeTable
  type="{
  teamName: {
    type: 'Partial<Record<Locale, string>>',
    description: <>Who answers when more than one person does, per locale, for example <code>{&#x22;{ en: 'The Acme team' }&#x22;}</code>. The widget names the one person otherwise.</>,
  },
  agentTitles: {
    type: 'Record<string, Partial<Record<Locale, string>>>',
    description: <>Role shown under an agent's name where they sign an email, keyed by the agent's email address. Sent as <code>responderTitle</code> in <a href=&#x22;/docs/email/messages#customer-receipt&#x22;><code>customer-receipt</code></a>.</>,
  },
}"
/>

## Conversations [#conversations]

<TypeTable
  type="{
  types: {
    type: 'string[]',
    default: &#x22;['question', 'bug', 'feature', 'lead']&#x22;,
    description: 'Conversation types. A conversation opened by inbound email takes the first one.',
  },
  priorities: {
    type: 'string[]',
    default: &#x22;['low', 'normal', 'high', 'urgent']&#x22;,
    description: <>The priorities, lowest first; what agents pick from, what the SLA policy and <code>priorityLabels</code> are keyed by, and what AI triage chooses between. The two highest are shown in colour and make the inbox's High and urgent switch. A new conversation starts at <code>normal</code> when the list has it, else at the lowest, unless its inbox sets <code>defaultPriority</code>. Every priority named elsewhere in the config must be in the list, or <code>buildHelpdesk</code> throws. Changing the list on an existing install leaves old conversations with their old words, shown as they are and sorted below the configured ones.</>,
  },
  retentionDays: {
    type: 'number',
    description: <>Resolved conversations are deleted, with their attachments, this many days after <code>resolved_at</code>, by <code>runJobs</code>. Unset, nothing is deleted.</>,
  },
  onEvent: {
    type: '(event: HelpdeskEvent) => Promise<void>',
    description: <>Told about new conversations, new messages and conversation changes once they are stored. Awaited inside the request; what it throws is logged and never returned. See <a href=&#x22;/docs/app/events&#x22;>Events</a>.</>,
  },
  realtime: {
    type: 'RealtimeAdapter',
    description: <>Pushes changes to open widgets and agent UIs over <code>widget/events</code> and <code>agent/events</code>, so a reply or a typing agent shows without waiting for the next poll. <code>memoryRealtime()</code> serves one process, <code>postgresRealtime({'{ pool }'})</code> every process on the database. Unset, both routes answer 404 and the clients poll alone. See <a href=&#x22;/docs/app/realtime&#x22;>Real-time updates</a>.</>,
  },
  jobsSecret: {
    type: 'string',
    description: <>Bearer secret for <code>POST {'{basePath}'}/jobs</code>. Unset, that route answers 401.</>,
  },
}"
/>

## Service levels and tiers [#service-levels-and-tiers]

<TypeTable
  type="{
  tiers: {
    type: 'string[]',
    default: 'the keys of sla.tiers',
    description: <>The support tiers a conversation can carry, such as <code>gold</code> and <code>platinum</code>. Agents set one in the properties card, <code>resolveTier</code> sets it when a conversation opens, and the tier picks the SLA policy. A tier not in the list is refused with 400.</>,
  },
  resolveTier: {
    type: '(input: TierInput) => string | null | Promise<string | null>',
    description: <>The tier a new conversation gets, from what your app knows about the customer: their user, the organization they filed it under, their address, the inbox and the captured context. Called when a conversation opens from the widget, by email or from host code. A value not in <code>tiers</code>, or a throw, leaves the tier unset. See <a href=&#x22;#tierinput&#x22;>TierInput</a>.</>,
  },
  sla: {
    type: 'SlaConfig',
    description: <>Response and resolution targets per tier and priority. With it set, every conversation that falls under a target carries deadlines, the jobs run sends a warning and a breach notice per target, the inbox sorts by the nearest deadline, and Statistics reports attainment. See <a href=&#x22;#slaconfig&#x22;>SlaConfig</a> and <a href=&#x22;/docs/inbox/sla&#x22;>Set service levels</a>.</>,
  },
  priorityLabels: {
    type: 'Partial<Record<Priority, Translated>>',
    description: <>What agents and customers see instead of a priority's own word, for example <code>{&#x22;{ urgent: { en: 'P1' }, high: { en: 'P2' }, normal: { en: 'P3' } }&#x22;}</code>. The stored values do not change; to change those, set <code>priorities</code>.</>,
  },
  ratingScale: {
    type: '2 | 5',
    default: '2',
    description: <>How customers rate a resolved conversation: <code>good</code> or <code>bad</code>, or a score from 1 to 5. On the five-point scale 4 and 5 count as good, 1 and 2 reopen the conversation, and the reply email carries one link per score.</>,
  },
}"
/>

## Email [#email]

<TypeTable
  type="{
  email: {
    type: '{ send(message: HelpdeskEmail): Promise<void> }',
    description: <>Outbound mail. Unset, no email is sent. See <a href=&#x22;/docs/email/messages&#x22;>Email messages</a>.</>,
  },
  replyToAddress: {
    type: '(reference: string) => string',
    description: <>The Reply-To of customer emails, for example <code>{'reference => `support+${reference}@example.com`'}</code>. Inbound mail to a plus-addressed reference threads into that conversation.</>,
  },
  inboundWebhookSecret: {
    type: 'string',
    description: <>Bearer secret for <code>POST {'{basePath}'}/inbound</code>, where a relay posts raw messages. Unset, that route answers 401.</>,
  },
  inboundInbox: {
    type: 'string',
    default: 'the first key of inboxes',
    description: 'Inbox that conversations opened by inbound email land in.',
  },
  dnsResolver: {
    type: 'DNSResolver',
    default: 'the system resolver',
    description: <>DNS lookups for DKIM keys, as <code>mailauth</code> defines the type.</>,
  },
}"
/>

## Adapters and host hooks [#adapters-and-host-hooks]

<TypeTable
  type="{
  storage: {
    type: 'StorageAdapter',
    description: <>Attachments. Unset, uploads answer 400 and attachments on inbound email are dropped. See <a href=&#x22;/docs/reference/adapters#storage&#x22;>Storage</a>.</>,
  },
  maxAttachmentBytes: {
    type: 'number',
    default: '10485760 (10 MiB)',
    description: 'Largest attachment accepted, from the widget or by email.',
  },
  ai: {
    type: 'AiAdapter',
    description: <>Triage suggestions, duplicate detection, draft replies and rewrites. See <a href=&#x22;/docs/reference/adapters#ai&#x22;>AI</a>.</>,
  },
  help: {
    type: '{ search(query: string, locale: Locale): Promise<HelpResult[]> }',
    description: <>Searches the host's documentation for the widget and for AI drafts. See <a href=&#x22;/docs/reference/adapters#help-search&#x22;>Help search</a>.</>,
  },
  resolveContext: {
    type: '(externalOrgId: string) => Promise<Record<string, string>>',
    description: 'Key-value context about a host organization, shown with its conversations and on its company page, and given to AI drafts. A throw shows nothing.',
  },
  orgNames: {
    type: '(externalOrgIds: string[]) => Promise<Record<string, string>>',
    description: <>Names for organizations <code>identify</code> returned without one, asked only when a name is shown or stored.</>,
  },
  links: {
    type: '(contact: LinkContact | null, company: LinkCompany | null) => HostLink[] | Promise<HostLink[]>',
    description: <>Links into the host's own tools, shown with the contact in a conversation and on contact and company pages. Links that are not <code>http</code> or <code>https</code> are dropped. A throw shows none and is logged.</>,
  },
}"
/>

## CRM [#crm]

<TypeTable
  type="{
  leadStages: {
    type: 'string[]',
    default: &#x22;['lead', 'qualified', 'customer', 'churned']&#x22;,
    description: 'The lead stages a contact or company can be in.',
  },
  dealStages: {
    type: 'string[]',
    default: &#x22;['new', 'qualified', 'proposal', 'won', 'lost']&#x22;,
    description: 'The stages a deal moves through. A deal created without a stage takes the first.',
  },
  customFields: {
    type: &#x22;Partial<Record<'contact' | 'company' | 'deal', CustomFieldDef[]>>&#x22;,
    description: <>Extra fields per record, stored in its <code>custom</code> column. See <a href=&#x22;#customfielddef&#x22;>CustomFieldDef</a>.</>,
  },
  features: {
    type: &#x22;Partial<Record<'deals' | 'canned' | 'overview', boolean>>&#x22;,
    default: 'every one on',
    description: <>Parts of the agent UI a team does without, each off when set to <code>false</code>: the Deals board, canned replies, or the Statistics page. The agent UI hides the section and what leads to it (the deals on a contact or company, the canned-reply menu and <code>/</code> in the composer), and the section's <code>agent/*</code> routes answer 404. A host that renders its own tabs leaves the section out itself.</>,
  },
}"
/>

## Rate limits [#rate-limits]

<TypeTable
  type="{
  anonymousRateLimit: {
    type: 'number',
    default: '20',
    description: 'Conversations, messages and uploads an anonymous caller may start per hour, per IP address.',
  },
  customerRateLimit: {
    type: 'number',
    default: '60',
    description: 'Conversations, messages and uploads a signed-in customer may start per hour.',
  },
  botProtection: {
    type: '{ minSeconds?: number } | false',
    default: '{ minSeconds: 3 }',
    description: <>The invisible check on a visitor's first message from the widget: a token signed when the form opened, at least <code>minSeconds</code> and at most a day old. <code>false</code> turns it off. See <a href=&#x22;/docs/widget/bot-protection&#x22;>Keep bots out of the widget</a>.</>,
  },
  captcha: {
    type: 'CaptchaAdapter',
    description: <>A captcha visitors solve before their first message. See <a href=&#x22;/docs/reference/adapters#captcha&#x22;>Captcha</a>.</>,
  },
}"
/>

Fixed limits that are not configurable are listed under [HTTP routes](/docs/reference/routes#limits).

## Startup checks [#startup-checks]

`buildHelpdesk` throws when:

* `identityTokenSecret` is set and shorter than 32 bytes.
* An inbox's `hours` fails validation; the message names the inbox. The rules are on [Inboxes](/docs/inbox/inboxes#businesshours).
* `sla` fails validation: a target without `respond` or `resolve`, a target that is not a positive number of hours, `warnAt` outside 0 to 1, or hours that break the rules above.
* `priorities` is empty, repeats a word, or has one longer than 50 characters; or an inbox's `defaultPriority` or `priorities.options`, `priorityLabels` or an SLA target names a priority that is not in the list.

## Identity [#identity]

What `identify` returns. `Identity`, `HelpdeskUser` and `HelpdeskOrg` are exported as types.

<TypeTable
  type="{
  user: {
    type: 'HelpdeskUser',
    required: true,
    description: 'The signed-in user.',
  },
  orgs: {
    type: 'HelpdeskOrg[]',
    required: true,
    description: 'Only the organizations the user is an active member of. A conversation can be filed under, and shared with, these.',
  },
  isAgent: {
    type: 'boolean',
    required: true,
    description: 'Grants the agent UI and every agent route.',
  },
  role: {
    type: &#x22;'agent' | 'supervisor' | 'viewer'&#x22;,
    default: &#x22;'agent'&#x22;,
    description: <>What an agent may do. An <code>agent</code> works the queue. A <code>supervisor</code> does too, receives every SLA notice, and, once any supervisor exists, alone changes Settings, removes agents and reads the audit log. A <code>viewer</code> reads everything and changes nothing but their own away time, read marks and saved views. See <a href=&#x22;/docs/inbox/team#roles&#x22;>Roles</a>.</>,
  },
}"
/>

### HelpdeskUser [#helpdeskuser]

<TypeTable
  type="{
  id: { type: 'string', required: true, description: &#x22;The host's user id.&#x22; },
  email: { type: 'string | null', description: 'Stored lower-cased on the contact.' },
  emailVerified: {
    type: 'boolean',
    description: &#x22;The host proved the address. The user's messages count as verified, and when their contact is created the address is attached to it as a verified email identity.&#x22;,
  },
  name: { type: 'string | null' },
  locale: { type: 'string | null', description: <>Anything starting with <code>de</code> is German, with <code>fr</code> French and with <code>it</code> Italian; everything else is English.</> },
  image: { type: 'string | null', description: 'Avatar URL, shown to customers when this user answers as an agent.' },
}"
/>

### HelpdeskOrg [#helpdeskorg]

<TypeTable
  type="{
  id: { type: 'string', required: true, description: &#x22;The host's organization id, stored as a company's external_org_id.&#x22; },
  name: { type: 'string', description: <>Display name. When absent, <code>orgNames</code> is asked.</> },
}"
/>

## SlaConfig [#slaconfig]

The value of `sla`. `SlaConfig`, `SlaPolicy`, `SlaTarget` and `Priority` are exported as types.

<TypeTable
  type="{
  tiers: { type: 'Record<string, SlaPolicy>', description: 'A policy per tier, keyed by the tier name.' },
  default: { type: 'SlaPolicy', description: 'The policy for a conversation whose tier has none, or that has no tier.' },
  warnAt: { type: 'number', default: '0.8', description: 'The share of a target after which the warning notice goes out; between 0 and 1.' },
}"
/>

### SlaPolicy [#slapolicy]

<TypeTable
  type="{
  targets: { type: 'Partial<Record<Priority, SlaTarget>>', required: true, description: 'A target per priority. A priority left out has no deadline under this policy.' },
  hours: { type: &#x22;BusinessHours | 'always'&#x22;, description: <>The hours every target in this policy counts, unless a target names its own. <code>'always'</code> counts every hour. Unset, the inbox's <code>hours</code> apply, and every hour when the inbox has none.</> },
}"
/>

### SlaTarget [#slatarget]

<TypeTable
  type="{
  respond: { type: &#x22;number | `${number}d`&#x22;, description: <>Hours until the first public agent reply is due, or open days as <code>'1d'</code>.</> },
  resolve: { type: &#x22;number | `${number}d`&#x22;, description: <>Hours, or open days as <code>'3d'</code>, until the conversation is due to be resolved, counted while it is <code>open</code>: the clock pauses while it is <code>pending</code> or <code>resolved</code> and resumes when the customer writes.</> },
  reply: { type: &#x22;number | `${number}d`&#x22;, description: <>Hours, or open days, to answer each customer message after the first response, counted from that message. See <a href=&#x22;/docs/inbox/sla#how-the-clock-runs&#x22;>How the clock runs</a>.</> },
  hours: { type: &#x22;BusinessHours | 'always'&#x22;, description: 'The hours this target counts; see SlaPolicy.' },
}"
/>

A target needs `respond`, `resolve` or `reply`, each a positive number of hours or a whole number of days from `'1d'` to `'999d'`. A day target is due at the same time that many open days later; [In business days](/docs/inbox/sla#in-business-days) has the rules.

```ts title="lib/helpdesk.ts"
sla: {
  tiers: {
    gold: {
      hours: 'always',
      targets: {
        urgent: { respond: 4, resolve: 24 },
        high: { respond: 8, resolve: 48 },
        normal: { respond: 16, resolve: 80, hours: office },
      },
    },
  },
  default: { targets: { normal: { respond: 24 } } },
  warnAt: 0.75,
},
```

## TierInput [#tierinput]

What `resolveTier` receives. Exported as a type.

<TypeTable
  type="{
  user: { type: 'HelpdeskUser | null', required: true, description: <>The signed-in customer, or <code>null</code> for a visitor, an email or a conversation an agent opened.</> },
  orgId: { type: 'string | null', required: true, description: 'The organization the conversation is filed under, as your app ids it.' },
  email: { type: 'string | null', required: true, description: &#x22;The customer's address, lower-cased.&#x22; },
  inbox: { type: 'string', required: true },
  context: { type: 'ConversationContext', required: true, description: <>What the widget captured, with the host context under <code>host</code>; empty for email and agent-opened conversations.</> },
}"
/>

## CustomFieldDef [#customfielddef]

<TypeTable
  type="{
  key: { type: 'string', required: true, description: <>Key in the record's <code>custom</code> object.</> },
  label: { type: 'Translated', required: true, description: <>English, plus any other language; see <a href=&#x22;#translated&#x22;>Translated</a>.</> },
  type: {
    type: &#x22;'text' | 'number' | 'date' | 'select'&#x22;,
    required: true,
    description: 'A text value is a string of up to 2,000 characters; a number is finite; a date is a string Date.parse accepts; a select value is one of options.',
  },
  options: { type: 'string[]', description: <>The choices for <code>select</code>.</> },
}"
/>

A value may also be `null`. A key that is not defined for the record is refused with 400.

## Link types [#link-types]

The arguments and result of `links`. All three are exported as types.

### LinkContact [#linkcontact]

<TypeTable
  type="{
  id: { type: 'string', required: true, description: &#x22;The contact's id in the helpdesk.&#x22; },
  name: { type: 'string | null', required: true },
  email: { type: 'string | null', required: true },
  userId: { type: 'string | null', required: true, description: <>The host's user id for a customer it signed in; <code>null</code> for one who only emailed or wrote in.</> },
}"
/>

### LinkCompany [#linkcompany]

<TypeTable
  type="{
  id: { type: 'string', required: true, description: &#x22;The company's id in the helpdesk.&#x22; },
  name: { type: 'string', required: true },
  domain: { type: 'string | null', required: true },
  orgId: { type: 'string | null', required: true, description: <>The host's organization id; <code>null</code> for a company an agent made.</> },
}"
/>

### HostLink [#hostlink]

<TypeTable
  type="{
  label: { type: 'Partial<Record<Locale, string>>', required: true, description: 'Link text per locale.' },
  url: { type: 'string', required: true },
}"
/>

## Translated [#translated]

Copy you write per language: `{ en: string } & Partial<Record<Locale, string>>`. English is required, and a language you leave out shows the English text, so adding a language to the package never breaks your build. The qualifying question's labels and [CustomFieldDef](#customfielddef) use it.

## Fixed values [#fixed-values]

These are not configurable and not exported.

| Name     | Values                            |
| -------- | --------------------------------- |
| Locales  | `en`, `de`, `fr`, `it`            |
| Statuses | `open`, `pending`, `resolved`     |
| Channels | `widget`, `email`, `agent`, `api` |
| Roles    | `agent`, `supervisor`, `viewer`   |
