",
"is_typing": true
}
}
```
`page_visit` stores the visitor's current page and broadcasts recent page visits
to agents:
```json theme={null}
{
"type": "page_visit",
"data": {
"url": "https://example.com/pricing",
"title": "Pricing"
}
}
```
`ping` keeps the session active and should be sent periodically:
```json theme={null}
{
"type": "ping"
}
```
The server replies with `pong`.
### Server-to-client messages
The widget client handles these message types:
* `joined`: the socket joined the live chat inbox.
* `pong`: response to `ping`.
* `new_message`: a new chat message; `data` is a chat message payload.
* `typing`: agent typing status with `conversation_uuid` and `is_typing`.
* `conversation_update`: partial conversation update.
* `error`: socket-level error payload.
# AI
Source: https://docs.libredesk.io/configuration/ai
Connect an AI provider, build a knowledge base, and let an AI assistant answer customers
There are two things here. One is a set of helpers for your agents: copilot, drafted replies, conversation summaries, and tag suggestions. The other is AI assistants, which reply to customers on their own, search your knowledge base, call your APIs, and hand the conversation to a human when they cannot help.
Both are configured under **Admin → AI**, and neither works until you add a provider.
AI is currently in beta. Assistant replies go out to customers, so test on a low-volume inbox first.
## Providers
Libredesk talks to any OpenAI-compatible endpoint: OpenAI, Anthropic, OpenRouter, Groq, Together AI, Azure, LiteLLM, or a local Ollama. Pick a preset to pre-fill the base URL and model, then edit anything you want.
There are two providers to configure:
| Provider | Used for |
| -------------- | -------------------------------------------------------------- |
| **Completion** | Drafting replies, copilot, summaries, assistant responses |
| **Embedding** | Turning snippets into vectors so the assistant can search them |
Go to **Admin → AI → Providers**. Pick a preset, paste your API key, and save.
Fill in the embedding provider on the same page. **Dimensions** must match the model's output size (1536 for `text-embedding-3-small`, 3072 for `text-embedding-3-large`, 768 for `nomic-embed-text`).
Hit **Test connection** on each. Libredesk sends a real request and reports what the provider said, including a dimensions mismatch.
### Completion fields
| Field | Notes |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Model | Model ID as the provider names it, e.g. `gpt-5.4-mini`. |
| Base URL | Leave blank for the OpenAI default. |
| Temperature | 0 to 2. Leave blank to use the model's default. Some models reject any other value. |
| Max tokens | Cap on the reply length. |
| Reasoning effort | `none`, `low`, `medium`, `high`. Leave blank for most models. |
| Custom instructions | Added to every AI prompt. Good place for tone and format rules. |
| Model supports image input | Off by default. When on, image attachments from customers are sent to the model. When off, images are never sent. |
Changing the embedding model or dimensions re-embeds existing snippets automatically.
Model choice matters. The cheapest models ignore your instructions and guardrails and make things up. You do not need a frontier model either. A mid-tier model like Claude Haiku or GPT-5.4 Mini is the right balance for support replies. Move up a tier if you see replies drifting from your instructions.
Every AI feature calls your provider and costs money on your account. Max tokens, max turns, and max steps are the settings that cap how much a single conversation can spend.
## Snippets
Snippets are the knowledge base. The assistant and copilot search them to answer questions. One library, shared by every assistant.
Write each snippet as a self-contained answer, not a fragment. A snippet titled "Refund policy" with the full policy in the body works; a single line pulled out of a longer page does not.
You can add snippets three ways:
* **Manually** - **New snippet**, title and content.
* **Import from URL** - Libredesk fetches the page, keeps the readable content, and adds it as a snippet you can edit afterwards.
* **Suggestions** - mined from resolved conversations, see below.
### Learn from resolved conversations
Turn on **Learn from resolved conversations** under **Admin → AI → Suggestions**. Resolved conversations where a human agent gave a reusable answer are mined into FAQ suggestions.
Each suggestion shows the question, the drafted answer, and a link to the conversation it came from. Approving one adds it to snippets. Rejecting one drops it. Nothing reaches the assistant until you approve it.
## Assistants
An assistant is a persona with its own name, avatar, tone, instructions, and set of tools. Create as many as you need: one for billing, one for order status.
Assistants show up alongside your agents as assignees. **A conversation gets an AI reply only when it is assigned to an assistant**, so route work to one with an automation rule (or assign it by hand to try it out). The assistant replies on both email and live chat, and only on open conversations - never snoozed or resolved ones.
### Settings
| Field | What it does |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Name, avatar | What customers see on the reply. |
| Description | Your own note. Customers never see it. |
| Expectation message | Shown to the customer while the assistant is handling the chat, e.g. "I'm an AI assistant here to help with your order." Disappears on handoff. |
| Tone, response length | Friendly, professional, neutral, casual. Concise, balanced, detailed. |
| Max turns | Replies before it hands off. 1 to 20. |
| Languages | Languages it may reply in. It matches the customer when their language is on the list, otherwise it uses the first one. Leave empty to always match the customer. |
| Offer handoff to a human | When off, it never offers a human and says it cannot help instead. |
| Fallback team | Team the conversation lands in on handoff. |
| Instructions | How it should respond. "Always confirm the order ID before looking anything up." |
| Guardrails | Rules it must never break. "Never issue a refund without a human agent's approval." |
| Tools | Tools this assistant may call. It can only use the ones you grant. |
### Handoff
The assistant hands the conversation to a human, unassigns itself, and posts an activity message when it:
* decides it cannot help
* hits its max turns
* errors out or times out
* sees someone other than the contact reply on the thread
### Test and Performance
The assistant edit page has two extra tabs:
* **Test** - send a sample customer message and see the reply it would draft, plus which snippets it used. Nothing is saved or sent.
* **Performance** - conversations, replies, resolution rate, handoff rate, reopen rate, replies per conversation, and CSAT for that assistant.
## Tools
Tools let an assistant call your own APIs while replying, to look up an order, check a subscription, or cancel something. Create them under **Admin → AI → Tools**, then grant them per assistant.
| Field | Notes |
| ------------------------ | ------------------------------------------------------------------------------------------------------- |
| Name | The function name the model calls. Letters, numbers, hyphen, underscore, up to 64 characters. |
| Description | The model reads this to decide when to call the tool. Be precise about what it does and when to use it. |
| URL, method | `GET` or `POST`. |
| Headers | Sent with every request. Values are encrypted at rest. |
| Parameters | JSON schema for the arguments the model may pass. Leave blank for a single text input. |
| Require verified contact | On by default. See below. |
```json Parameters example theme={null}
{
"type": "object",
"properties": {
"order_id": { "type": "string", "description": "The order ID to look up" }
},
"required": ["order_id"]
}
```
On `GET` the arguments go on the query string. On `POST` they are the JSON body.
### Identity headers
Libredesk adds these headers to every tool request, server-side. The model never sees them and cannot change them.
| Header | Value |
| --------------------------------- | ---------------------------------------------------------------------------------------- |
| `X-Libredesk-Contact-Id` | Contact ID in Libredesk. |
| `X-Libredesk-Contact-Email` | Current email on the contact. |
| `X-Libredesk-Contact-External-Id` | Your own user ID, if the contact came in through a verified live chat session. |
| `X-Libredesk-Contact-Type` | `contact` or `visitor`. |
| `X-Libredesk-Contact-Verified` | `true` or `false`, see below. Always sent. Read this before returning any personal data. |
| `X-Libredesk-Conversation-UUID` | Conversation the tool call belongs to. |
| `X-Libredesk-Inbox-Id` | Inbox the conversation arrived on. |
### Require verified contact
An email address a customer types into a chat proves nothing. With **Require verified contact** on, the assistant emails a one-time code to the contact and asks them to paste it back before the tool can run. Codes expire in 10 minutes and both sends and attempts are capped.
Turn it off only if your endpoint authenticates the customer itself. Once it is off, anyone who can reach the chat widget can trigger that tool for that contact.
**Check `X-Libredesk-Contact-Verified` in your endpoint on every call. Return personal data, or act on the customer's account, only when it is `true`.** The toggle above is a gate inside Libredesk, in front of the model. It is not a guarantee your endpoint gets: with the toggle off, or on a tool where it was never turned on, requests reach you with the header set to `false`. The header is the only thing that actually proves the customer owns the email they claimed, so treat it as the source of truth and check it yourself. Do not rely on the assistant having asked for a code first.
Tool URLs are fetched by your Libredesk server. If you accept tool definitions from people you do not fully trust, turn on [SSRF protection](/configuration/ssrf-protection).
### Writing a custom tool
The tool config above is the easy half. The endpoint it calls is where the work is, and three rules cover most of it:
1. **Check your own secret first.** Configure a header like `X-Api-Key` on the tool and reject any request that does not carry it. Your endpoint is on the internet; the tool config is the only thing that proves the call came from Libredesk.
2. **Take the customer's identity from the headers, never from the arguments.** Libredesk injects the headers server-side and the model cannot see or change them. Anything in the request body was written by the model, from what the customer typed, so treat it as untrusted input. If the model asks for `account_id=99999`, look up the account that belongs to the contact in the header and ignore the number.
3. **Gate sensitive data on `X-Libredesk-Contact-Verified`.** A visitor can claim any email address in chat. That header is `true` only after the contact proved the address with a one-time code, so require it before returning anything you would not show a stranger, and before anything that changes their account: a cancellation, a refund, an address update.
```go theme={null}
const apiKey = "your-shared-secret"
func handleBalance(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != apiKey {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Header.Get("X-Libredesk-Contact-Verified") != "true" {
http.Error(w, "contact not verified", http.StatusForbidden)
return
}
// Identity comes from the header. Nothing in the body decides whose data this is.
email := r.Header.Get("X-Libredesk-Contact-Email")
account, ok := lookupByEmail(email)
if !ok {
http.Error(w, "no account for this contact", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]any{
"balance": account.Balance,
"currency": "INR",
})
}
```
Return small, flat JSON. The response goes into the model's context and long payloads are truncated, so send the three fields the assistant needs to answer rather than your whole account object.
**Use the status code to say whether the request worked, not whether it found anything.** A 2xx response reads as data, so the assistant answers from it. A non-2xx response reads as a failure, and the assistant usually tells the customer it could not help and hands the conversation to a human.
So "this customer has no orders" is a 2xx with an empty list or a short message. Keep non-2xx for requests that genuinely could not be served: a bad API key, a missing identifier, your upstream being down.
The body is handed back to the model as text either way, so error messages are worth writing properly. "No order found for that ID" lets the assistant ask the customer to check the number. A bare `500` usually ends in a handoff.
The repo ships a runnable version of this at [`scripts/tools-server`](https://github.com/abhinavxd/libredesk/tree/main/scripts/tools-server). Run it with `go run ./scripts/tools-server`, point a tool at `http://localhost:7070/account`, and it logs every header and argument it receives.
## For your agents
These need only the completion provider, and work whether or not you use assistants.
* **Copilot** - a panel in the conversation sidebar. Ask it anything about the conversation; it searches your snippets and the contact's history. Insert its answer into the reply box or add it as a private note. Rename it under **Admin → General**.
* **Drafted replies** - describe what you want to say in the reply box and let AI write the reply.
* **Rewrite prompts** - fix grammar and spelling, and the other prompts in the reply box AI menu.
* **Summarize conversation** - drops an AI summary into the conversation as a private note.
* **Tag suggestions** - suggests tags from your existing tag list.
## Limits
Assistant worker and run limits live in `config.toml`:
```toml theme={null}
[ai_agent]
# Number of workers running autonomous AI assistant responses
worker_count = 10
# Maximum number of AI assistant response jobs that can be queued
queue_size = 1000
# Maximum number of tool calls the assistant can make while working on one reply. (1-20)
max_steps = 6
# Conversation messages sent to the model as history. (5-100)
max_history_messages = 30
```
One assistant run is capped at 90 seconds on live chat and 3 minutes on email, with each provider call capped at 60 seconds. A run that hits a cap hands off to a human.
# Connecting Inboxes
Source: https://docs.libredesk.io/configuration/connecting-inboxes
Connect email inboxes using Google, Microsoft OAuth, or manual IMAP/SMTP configuration
Libredesk supports three methods for connecting email inboxes: Google OAuth, Microsoft OAuth, and manual IMAP/SMTP configuration with password authentication.
## Google setup
Connect your Gmail or Google Workspace inbox using an app password or OAuth 2.0.
### App password
An app password is the quickest way to connect Gmail. It needs no Google Cloud Console setup. Your Google account must have [2-Step Verification](https://myaccount.google.com/signinoptions/twosv) enabled for this option to be available.
Visit [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords), name the app (e.g. `Libredesk`), and copy the generated 16-character password.
Go to Admin → Inboxes → New inbox → **Other provider** and use these settings:
**IMAP**
| Field | Value |
| -------- | ---------------------------- |
| Host | `imap.gmail.com` |
| Port | `993` |
| TLS | SSL/TLS |
| Username | Your full Gmail address |
| Password | The app password from step 1 |
**SMTP**
| Field | Value |
| ----------------------- | ---------------------------- |
| Host | `smtp.gmail.com` |
| Port | `587` |
| TLS | STARTTLS |
| Authentication Protocol | PLAIN |
| Username | Your full Gmail address |
| Password | The app password from step 1 |
Click **Save**. Libredesk does not check the mailbox immediately. It checks on the **Scan interval** set in the same form, which is `5m` by default.
So: send a test email to the address, wait 5 minutes, then look in Conversations.
### OAuth
#### Prerequisites
* A Google Cloud Console project
* OAuth 2.0 credentials (Client ID & Client Secret)
#### Required OAuth scopes
Your Google OAuth app needs these scopes:
| Scope | Purpose |
| ------------------------------------------------ | ------------------------------------------------ |
| `https://mail.google.com/` | Full Gmail access for reading and sending emails |
| `https://www.googleapis.com/auth/userinfo.email` | Access to user's email address |
#### Configuration steps
In Libredesk, go to Admin → Inboxes → New inbox → Google. A dialog opens with the **Callback URL**. Copy it.
Go to the [Google Cloud Console](https://console.cloud.google.com/) and create a new project or select an existing one.
In your project, navigate to APIs & Services → Library and enable the Gmail API.
Set up the OAuth consent screen with the required scopes listed above.
Go to APIs & Services → Credentials and click Create Credentials → OAuth client ID:
* Application type: Web application
* Add the callback URL from step 1 to Authorized redirect URIs
* Note down the Client ID and Client Secret
Back in the Libredesk dialog, enter the Client ID and Client Secret, then click Authorize. You'll be redirected to Google's consent screen to grant permissions.
Click **Save**. Libredesk does not check the mailbox immediately. It checks on the **Scan interval** set in the same form, which is `5m` by default.
So: send a test email to the address, wait 5 minutes, then look in Conversations.
## Microsoft setup
Connect your Outlook or Microsoft 365 inbox using OAuth 2.0.
### Prerequisites
* An Azure Active Directory (Azure AD) app registration
* OAuth 2.0 credentials (Client ID & Client Secret)
### Required API permissions
Add the following **Microsoft Graph** delegated permissions in Azure Portal:
| Permission | Type | Description |
| --------------------- | --------- | --------------------------------------------------- |
| email | Delegated | View users' email address |
| IMAP.AccessAsUser.All | Delegated | Read and write access to mailboxes via IMAP |
| offline\_access | Delegated | Maintain access to data you have given it access to |
| openid | Delegated | Sign users in |
| SMTP.Send | Delegated | Send emails from mailboxes using SMTP AUTH |
| User.Read | Delegated | Sign in and read user profile |
### Configuration steps
In Libredesk, go to Admin → Inboxes → New inbox → Microsoft. A dialog opens with the **Callback URL**. Copy it.
Go to the [Azure Portal](https://portal.azure.com/) → Azure Active Directory → App registrations → New registration:
* Enter a name for your application
* Select the appropriate account type (single tenant or multi-tenant)
* Add the callback URL from step 1 as the Redirect URI
In your app registration, go to API permissions → Add a permission → Microsoft Graph:
* Add all the delegated permissions shown in the table above
* Click Grant admin consent for Default Directory
Go to Certificates & secrets → New client secret:
* Add a description and expiration period
* Copy the secret value immediately (it won't be shown again)
From the Overview page, copy:
* Application (client) ID
* Directory (tenant) ID (if using single-tenant)
Back in the Libredesk dialog, enter the Client ID, Client Secret, and Tenant ID (optional), then click Authorize. You'll be redirected to Microsoft's consent screen to grant permissions.
Click **Save**. Libredesk does not check the mailbox immediately. It checks on the **Scan interval** set in the same form, which is `5m` by default.
So: send a test email to the address, wait 5 minutes, then look in Conversations.
## Manual IMAP/SMTP setup
Use manual IMAP/SMTP for self-hosted mail servers and for providers that don't support OAuth. For Gmail, use the [app password](#app-password) option under Google setup instead.
### Configuration steps
Navigate to Admin → Inboxes → New inbox → Other provider, configure IMAP and SMTP settings, then click Save.
Libredesk does not check the mailbox immediately. It checks on the **Scan interval** set in the same form, which is `5m` by default.
So: send a test email to the address, wait 5 minutes, then look in Conversations.
## Live chat
For live chat widget setup, see the [Live Chat](/configuration/livechat) guide.
# Context Links
Source: https://docs.libredesk.io/configuration/context-links
Open external tools from the conversation sidebar with contact details passed automatically
Context Links are buttons in the conversation sidebar that open external URLs with contact and conversation details filled in. Agents click them to jump to CRMs, billing systems, or internal dashboards without copy-pasting identifiers.
## URL template variables
These placeholders are replaced with the current contact's details when an agent clicks the link.
| Variable | Description |
| ------------------------ | ------------------------------------------------------- |
| `{{email}}` | Contact's email |
| `{{phone}}` | Contact's phone number |
| `{{phone_country_code}}` | Contact's phone country code |
| `{{external_user_id}}` | Contact's external user ID |
| `{{contact_id}}` | Contact's internal Libredesk ID |
| `{{first_name}}` | Contact's first name |
| `{{last_name}}` | Contact's last name |
| `{{conversation_uuid}}` | UUID of the current conversation |
| `{{token}}` | Encrypted token containing all fields (requires secret) |
All values except `{{contact_id}}` and `{{conversation_uuid}}` are URL-encoded automatically.
### Examples
**Simple lookup by email:**
```
https://crm.example.com/contacts?email={{email}}
```
**Multiple parameters:**
```
https://billing.example.com/customer?ext_id={{external_user_id}}&email={{email}}
```
**Internal dashboard with conversation context:**
```
https://dashboard.internal/lookup?contact={{contact_id}}&conv={{conversation_uuid}}
```
**Encrypted token:**
```
https://api.example.com/auth/libredesk?token={{token}}
```
The receiving system decrypts the token to get all contact and agent fields in one payload, without exposing them in the URL.
## Encrypted token
When you use `{{token}}`, Libredesk generates an AES-256-GCM encrypted, time-limited token containing all the above fields plus `agent_id`, `agent_email`, `iat`, and `exp`. The receiving system decrypts it with the shared secret.
### Decrypting the token
```python theme={null}
import base64, json, time
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_context_token(token, secret):
raw = base64.b64decode(token)
nonce = raw[:12]
ciphertext = raw[12:]
aesgcm = AESGCM(secret.encode('utf-8'))
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return json.loads(plaintext)
# Usage
secret = "your-32-character-shared-secret!" # exactly 32 characters
payload = decrypt_context_token(token_from_url, secret)
if payload["exp"] < time.time():
raise ValueError("Token has expired")
print(payload["email"], payload["conversation_uuid"])
```
# Email Templates
Source: https://docs.libredesk.io/configuration/email-templates
Customize outgoing emails with dynamic template expressions
Outgoing emails can include expressions like `{{ .Recipient.FullName }}`, which pull fields from the conversation, contact, recipient, and author. Libredesk replaces them with real values when it sends the email.
## Outgoing email templates
Edit the template under **Admin → Templates → Outgoing Email Templates**. It applies to every outgoing email, including replies to conversations, notifications, and other system-generated mail.
### Available variables
#### Conversation variables
| Variable | Value |
| ------------------------------------- | ----------------------------------------------- |
| `{{ .Conversation.ReferenceNumber }}` | The unique reference number of the conversation |
| `{{ .Conversation.Subject }}` | The subject of the conversation |
| `{{ .Conversation.Priority }}` | The priority level of the conversation |
| `{{ .Conversation.UUID }}` | The unique identifier of the conversation |
#### Contact variables
| Variable | Value |
| -------------------------- | ------------------------------------- |
| `{{ .Contact.FirstName }}` | First name of the contact/customer |
| `{{ .Contact.LastName }}` | Last name of the contact/customer |
| `{{ .Contact.FullName }}` | Full name of the contact/customer |
| `{{ .Contact.Email }}` | Email address of the contact/customer |
#### Recipient variables
| Variable | Value |
| ---------------------------- | ------------------------------ |
| `{{ .Recipient.FirstName }}` | First name of the recipient |
| `{{ .Recipient.LastName }}` | Last name of the recipient |
| `{{ .Recipient.FullName }}` | Full name of the recipient |
| `{{ .Recipient.Email }}` | Email address of the recipient |
The recipient is whoever receives the email. In replies to conversations, `Recipient` and `Contact` are the same person. They differ only in [email notification templates](#email-notification-templates), where the recipient is an agent.
#### Author variables
| Variable | Value |
| ------------------------- | ----------------------------------- |
| `{{ .Author.FirstName }}` | First name of the message author |
| `{{ .Author.LastName }}` | Last name of the message author |
| `{{ .Author.FullName }}` | Full name of the message author |
| `{{ .Author.Email }}` | Email address of the message author |
The author is the sender of the message. When Libredesk itself sends the email (automations, conversation continuity), the sender is the built-in System user, so these fields stay empty - otherwise emails would render "System" as the author's name.
#### Message variables
| Variable | Value |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{{ .IsAIComposed }}` | `true` when the reply was written by an [AI assistant](/configuration/ai#assistants) rather than a person |
| `{{ .IsContinuityEmail }}` | `true` when the email is a [conversation continuity](/configuration/livechat#conversation-continuity) email, i.e. unread live chat messages emailed to a contact who left the chat |
Use them to disclose AI replies, or to skip parts of the template that don't fit an automated email:
```html theme={null}
{{ if .IsContinuityEmail }}
You have new messages:
{{ else }}
Dear {{ .Recipient.FirstName }},
{{ end }}
{{ if .IsAIComposed }}
This reply was composed by an AI assistant.
{{ end }}
```
### Template functions
These work in any template, alongside the variables above.
| Function | Value |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `{{ RootURL }}` | Root URL of the Libredesk instance, set under **Admin → General** |
| `{{ SiteName }}` | Site name, set under **Admin → General** |
| `{{ LogoURL }}` | URL of the uploaded logo |
| `{{ FaviconURL }}` | URL of the uploaded favicon |
| `{{ Date "2006-01-02" }}` | Current date and time, formatted with a [Go layout](https://pkg.go.dev/time#pkg-constants). `{{ Date "" }}` uses the ANSIC layout |
### Using variables in replies and private notes
The same expressions work when an agent types them in the reply editor or a private note, and in macros. Libredesk replaces them when the message is saved, so the agent sees the final text right away.
## Example outgoing email template
Templates support HTML formatting. Use tags like `` and `
` to control spacing and line breaks. Plain text without HTML tags renders without line breaks.
```html theme={null}
Dear {{ .Recipient.FirstName }},
{{ template "content" . }}
Best regards,
{{ .Author.FullName }}
---
Reference: {{ .Conversation.ReferenceNumber }}
{{ if .IsAIComposed }}
This reply was composed by an AI assistant.
{{ end }}
```
### How the template wraps a message
The template wraps every message an agent sends. Whatever the agent types in the editor goes where `{{ template "content" . }}` sits.
So if the template already opens with "Dear `{{ .Recipient.FirstName }}`" and closes with "Best regards, `{{ .Author.FullName }}`", agents should write neither a greeting nor a sign-off. They write only the body.
#### Correct usage
**Message written in text editor:**
```
Thank you for contacting us. I've reviewed your account and can confirm that your refund has been processed.
```
**Email received by customer:**
```
Dear John,
Thank you for contacting us. I've reviewed your account and can confirm that your refund has been processed.
Best regards,
Sarah Smith
---
Reference: TKT-2024-001
```
#### Duplicated greeting and sign-off
**Message written in text editor:**
```
Hello John,
Thank you for contacting us. I've reviewed your account and can confirm that your refund has been processed.
Best regards,
Sarah
```
**Email received by customer (with unwanted duplication):**
```
Dear John,
Hello John,
Thank you for contacting us. I've reviewed your account and can confirm that your refund has been processed.
Best regards,
Sarah
Best regards,
Sarah Smith
---
Reference: TKT-2024-001
```
### Other template shapes
A template with no greeting means agents write their own. A template that is only the content placeholder means agents write the whole email, greeting and sign-off included. Read the active template before deciding what to write.
## Email notification templates
Edit these under **Admin → Templates → Email notification templates**. The default outgoing email template wraps each one, and the subject line takes the same expressions as the body.
The first three go to agents, so `{{ .Recipient.* }}` is the agent being notified and author fields are empty. The CSAT request goes to the contact, as a normal reply in the conversation.
Each template has its own variables:
| Template | Variables |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Conversation assigned | Conversation, Contact, Recipient |
| Mentioned in conversation | Conversation, Recipient, `{{ .MentionedBy.FirstName }}`, `{{ .MentionedBy.LastName }}`, `{{ .MentionedBy.FullName }}`, `{{ .MentionedBy.Email }}`, `{{ .Message.Content }}` (the private note), `{{ .Message.UUID }}` |
| SLA breach warning, SLA breached | Conversation (`{{ .Conversation.Priority }}` is empty here), Recipient, `{{ .SLA.Metric }}` (First response, Resolution, or Next response), `{{ .SLA.DueIn }}` (time left, for the warning email), `{{ .SLA.OverdueBy }}` (time past the deadline, for the breach email) |
| CSAT request | Everything an outgoing email gets (Conversation, Contact, Recipient, Author, `{{ .IsAIComposed }}`), plus `{{ .CSATLink }}` (rating page URL, append `?rating=1` to `?rating=5` for one-click rating) and `{{ .CSATUUID }}` |
In the CSAT request, `{{ .Author.* }}` holds the person who resolved the conversation, and is empty when an automation resolved it.
# Help Center
Source: https://docs.libredesk.io/configuration/help-center
Publish a searchable knowledge base with collections, articles in multiple languages, and customize it however you want
A help center is a public site, served by Libredesk, where customers browse and search your articles. You can run more than one. Each has its own content, languages, appearance and domain. Published articles can also be used by the AI assistant to answer conversations.
## Create a help center
Go to **Admin → Help center → New help center**. Fill in the name and the slug that goes in its URL.
**Docs** gives you a documentation layout: a top navigation bar, a sidebar listing collections, and quick search on Ctrl+K. **Classic** gives you a landing page with a hero banner, a large search bar and collection cards.
## URLs
By default the help center is served from your Libredesk instance:
```
https://support.example.com/hc//
https://support.example.com/hc///collections/
https://support.example.com/hc///articles/
```
## Custom domain
A help center can be served from its own domain or subdomain, for example `https://help.example.com`, where it sits at the root and the paths above drop the `/hc/` prefix. Two help centers cannot share a domain.
Set **Custom domain** to the domain's root URL in the help center's settings, then:
Point the domain at the same server as your Libredesk instance: an `A` record to its IP, or a `CNAME` to the hostname you already serve Libredesk on.
Libredesk decides which help center to serve from the `Host` header of the request, so the proxy has to forward the original host. Save this as `/etc/nginx/sites-available/libredesk-help`:
```nginx theme={null}
server {
listen 80;
server_name help.example.com;
location / {
proxy_pass http://localhost:9000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Client-IP $remote_addr;
}
}
```
Then enable it and reload nginx:
```bash theme={null}
sudo ln -s /etc/nginx/sites-available/libredesk-help /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
`proxy_set_header Host $host;` is the line that matters. Without it nginx sends the upstream's host, no help center matches, and visitors land on the agent login page instead of your help center.
## Collections and articles
Collections group articles and can be nested up to three levels deep. To change the order readers see, drag a row by its handle in the content tree.
Only published articles appear on the public site, and **Preview** shows a draft the way its public page will look.
## Languages
Add every language you plan to write in under **Supported languages**, then pick the **Default language** that visitors land on. A language picker appears in the navigation bar once there is more than one.
A collection or article belongs to a single language, and an article has to sit in a collection of the same language. A language that still has collections or articles in it cannot be removed.
## AI answers
On any article the AI assistant may answer from, turn on **Allow AI assistants to use this article**. Nothing happens until the article is published. Enable it in one language only, not in every translation.
AI has to be configured for this to work, see [AI](/configuration/ai).
## Appearance
**Customize** covers the logo, favicon, accent colour, header and hero, announcement bar, navigation and footer links, and what the landing and article pages show.
For anything the settings do not cover, **Custom code** takes custom CSS and JS, injected into every page.
## SEO
Set the page title and meta description for the home page, and the meta fields on each article. Libredesk serves `/robots.txt` and a sitemap index at `/sitemap.xml`, plus a per-language sitemap at `/hc///sitemap.xml`. Both are scoped to the host that asked for them, so a custom domain only advertises its own help center.
## Search insights
**Search insights** lists the top searches visitors ran and the ones that came back empty, which is where you find the articles you are missing.
## Permissions
Managing help centers, collections and articles needs the **Manage help centers** permission, which you add to a role under **Admin → Roles**.
# Live Chat
Source: https://docs.libredesk.io/configuration/livechat
Embed the Libredesk chat widget on your website and verify user identity with JWTs
The Libredesk live chat widget embeds on any website as a single `
```
`baseURL` is the URL of your Libredesk instance. `inboxID` is the UUID shown on the inbox's **Installation** tab.
Anyone visiting the page can now open the widget and start a conversation. Every session is treated as a new anonymous visitor unless you add identity verification.
## Identity verification
If users are already signed into your product, pass a signed JWT so the widget loads with their existing Libredesk contact. Without verification, the widget cannot tell a returning user apart from a new visitor on a new device.
### How it works
1. Your server signs a JWT with the inbox **Secret key** (set under the **Security** tab).
2. Your page passes that JWT to the widget via `userJWT`.
3. Libredesk verifies the signature, then upserts the contact by `external_user_id`.
The signing algorithm is **HS256** (HMAC-SHA256). The secret never leaves your server.
### JWT payload
```json theme={null}
{
"external_user_id": "your_app_user_123",
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"phone_number": "9876543210",
"phone_number_country_code": "IN",
"exp": 1735689600,
"contact_custom_attributes": {
"plan": "premium",
"company": "Acme Inc"
}
}
```
| Field | Required | Description |
| --------------------------- | -------- | ----------------------------------------------------------------------- |
| `external_user_id` | Yes | Stable unique ID from your system. Used to match returning users. |
| `email` | Yes | User's email address. |
| `first_name` | Yes | User's first name. |
| `last_name` | No | User's last name. |
| `phone_number` | No | User's phone number. |
| `phone_number_country_code` | No | ISO 3166-1 alpha-2 country code for the phone number (e.g. `IN`, `US`). |
| `exp` | Yes | Unix timestamp (seconds) when the token expires. |
| `contact_custom_attributes` | No | Object of custom attributes written to the contact record on login. |
### Sign the token on your server
```python Python theme={null}
import jwt, time
payload = {
"external_user_id": "your_app_user_123",
"email": "user@example.com",
"first_name": "John",
"exp": int(time.time()) + 3600,
}
token = jwt.encode(payload, SECRET, algorithm="HS256")
```
```javascript Node.js theme={null}
const jwt = require('jsonwebtoken');
const token = jwt.sign({
external_user_id: 'your_app_user_123',
email: 'user@example.com',
first_name: 'John',
exp: Math.floor(Date.now() / 1000) + 3600,
}, SECRET, { algorithm: 'HS256' });
```
```go Go theme={null}
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
claims := jwt.MapClaims{
"external_user_id": "your_app_user_123",
"email": "user@example.com",
"first_name": "John",
"exp": time.Now().Add(time.Hour).Unix(),
}
token, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
SignedString([]byte(SECRET))
```
Never embed the inbox secret in client-side code. Sign the JWT on your server and inject it into the page at render time, or fetch it from an authenticated endpoint.
### Pass the token to the widget
```html theme={null}
```
## JavaScript API
Once the widget loads, `window.Libredesk` exposes these methods:
| Method | Description |
| ----------------------------------- | -------------------------------------------------------- |
| `Libredesk.show()` | Open the widget. |
| `Libredesk.hide()` | Close the widget. |
| `Libredesk.toggle()` | Toggle between open and closed. |
| `Libredesk.setUser(jwt)` | Log a user in after page load with a signed JWT. |
| `Libredesk.logout()` | Clear the current session and reset to anonymous. |
| `Libredesk.onShow(fn)` | Register a callback fired when the widget opens. |
| `Libredesk.onHide(fn)` | Register a callback fired when the widget closes. |
| `Libredesk.onUnreadCountChange(fn)` | Register a callback fired with the unread message count. |
Example:
```javascript theme={null}
window.Libredesk.onUnreadCountChange(function (count) {
document.title = count > 0 ? `(${count}) Inbox` : 'Inbox';
});
```
## Conversation continuity
Contacts don't have to stay in the live chat. If they leave before the conversation is finished, Libredesk can automatically email them any unread messages.
| Setting | Description |
| ------------------------ | ------------------------------------------------------------------------------------- |
| `offline_threshold` | How long agents must be offline before Libredesk sends a fallback email (e.g. `10m`). |
| `max_messages_per_email` | Maximum number of messages per email. Additional messages are sent in a new email. |
| `min_email_interval` | Minimum time between fallback emails for the same conversation. |
Replies to the email thread are added back to the same live chat conversation.
## Security
### Trusted domains
Under the **Security** tab, list the domains allowed to embed the widget. Requests from other origins are rejected. Supports wildcards:
```
example.com
*.example.com
staging.example.com
```
Leave this empty to allow all origins. Always set it in production.
### Blocked IPs
Block specific IPs or CIDR ranges from opening the widget:
```
192.168.1.0/24
10.0.0.1
2001:db8::/32
```
### Session duration
Controls how long a widget session stays authenticated before the JWT must be re-verified. Default is `10h`. Format accepts `s`, `m`, `h` (e.g. `30m`, `24h`).
# SSO Setup
Source: https://docs.libredesk.io/configuration/sso
Configure Single Sign-On with OpenID Connect providers
Agents can sign in through an external OpenID Connect provider such as Google or Keycloak.
You still create agent accounts in Libredesk yourself. Signing in with SSO does not create an account.
## Configuration
Providers differ in the details, so check your provider's documentation for anything not covered here.
In your provider's admin console, create a new OpenID Connect application/client. Retrieve:
* Client ID
* Client Secret
In Libredesk, navigate to Security → SSO and click New SSO and enter the following details:
* Provider URL (e.g., the URL of your OpenID provider)
* Client ID
* Client Secret
* A descriptive name for the connection
After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your provider's client settings.
## Provider examples
### Keycloak
In Keycloak, navigate to Clients and click Create:
* Client ID (e.g., `libredesk-app`)
* Client Protocol: `openid-connect`
* Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
* Under Authentication flow, uncheck everything except the standard flow
* Click save
Go to the credentials tab:
* Ensure client authenticator is set to `Client Id and Secret`
* Note down the generated client secret
In Libredesk, go to Admin → Security → SSO and click New SSO:
* Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
* Name (e.g., `Keycloak`)
* Client ID
* Client secret
* Click save
1. After saving, click on the three dots and choose Edit to open the new SSO entry
2. Copy the generated Callback URL from Libredesk
3. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
* e.g., `https://ticket.example.com/api/v1/oidc/1/finish`
# SSRF protection
Source: https://docs.libredesk.io/configuration/ssrf-protection
Optional guard for admin-configured outbound URLs
Libredesk lets admins configure URLs that the server fetches on their behalf. This guard blocks those outbound requests from reaching private, loopback, link-local, and cloud-metadata IP ranges (SSRF protection).
It covers every admin-configured outbound URL:
* **Webhooks** - event delivery endpoints
* **SSO** - OIDC provider discovery
* **AI** - the AI provider base URL and custom AI tool calls
## Disabled by default
The guard is **off by default**. In a self-hosted, single-tenant deployment the admin already controls the infrastructure and sets these URLs, so reaching internal hosts is not a privilege boundary. It is expected, and common setups depend on it:
* An internal webhook receiver
* An OIDC provider on a private network
* A local model endpoint, such as Ollama on `localhost` or a LAN address
Enabling the guard by default would break those legitimate cases.
## Opting in
Turn it on when the outbound URLs come from parties you do not fully trust, such as a multi-tenant or hosted deployment. Configure it in `config.toml`:
```toml theme={null}
[ssrf]
# Block outbound requests to private/loopback/link-local/cloud-metadata IPs.
enabled = true
# CIDR ranges allowed to bypass the guard when enabled, for reaching known internal hosts.
allowed_cidrs = ["10.0.0.0/8", "192.168.1.10/32"]
```
Use `allowed_cidrs` to carve out known internal hosts you still need to reach while the guard is on.
# Webhooks
Source: https://docs.libredesk.io/configuration/webhooks
Receive real-time HTTP notifications for Libredesk events
When something happens in your Libredesk instance, Libredesk sends an HTTP POST to a URL you choose. The body is JSON containing the event name, a timestamp, and the conversation or message it happened to. Use webhooks to push Libredesk activity into your own systems.
## Configuration
Go to **Admin → Integrations → Webhooks** in your Libredesk dashboard
Click **Create Webhook** and fill in:
* **Name**: a name you will recognize later
* **URL**: where Libredesk sends the payload
* **Events**: the events to subscribe to
* **Secret**: optional, used to sign the payload
* **Status**: enabled or disabled
Use the test button to send a sample payload to verify your endpoint is working
## Security
### Signature verification
When you set a secret, Libredesk signs the payload with HMAC-SHA256 and sends the result in the `X-Libredesk-Signature` header as `sha256=`.
```python Python theme={null}
import hmac
import hashlib
def verify_signature(payload, signature, secret):
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected_signature}", signature)
```
```javascript Node.js theme={null}
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return signature === `sha256=${expectedSignature}`;
}
```
```go Go theme={null}
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func verifySignature(payload []byte, signature, secret string) bool {
h := hmac.New(sha256.New, []byte(secret))
h.Write(payload)
expectedSignature := fmt.Sprintf("sha256=%s", hex.EncodeToString(h.Sum(nil)))
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
```
### Headers
Every request carries these headers:
| Header | Description |
| ----------------------- | ---------------------------------------- |
| `Content-Type` | Always `application/json` |
| `User-Agent` | `Libredesk-Webhook/` |
| `X-Libredesk-Signature` | HMAC signature (if secret is configured) |
### SSRF protection
Webhook URLs are one of several admin-configured outbound URLs covered by Libredesk's optional SSRF guard, which is disabled by default. See [SSRF protection](/configuration/ssrf-protection) for details and how to enable it.
## Events
### Conversation events
Triggered when a new conversation is created.
```json theme={null}
{
"event": "conversation.created",
"timestamp": "2025-06-15T10:30:00Z",
"payload": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"subject": "Help with account setup",
"inbox_name": "Support",
"inbox_channel": "email",
"contact": {
"id": 456,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact"
},
"custom_attributes": {},
"tags": []
}
}
```
Triggered when a conversation's status is updated.
```json theme={null}
{
"event": "conversation.status_changed",
"timestamp": "2025-06-15T10:35:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_status": "Open",
"new_status": "Resolved",
"snooze_until": "",
"actor_id": 789,
"conversation": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:35:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"closed_at": null,
"resolved_at": "2025-06-15T10:35:00Z",
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Resolved",
"status_id": 3,
"first_reply_at": "2025-06-15T10:31:00Z",
"last_reply_at": "2025-06-15T10:34:00Z",
"assigned_user_id": 789,
"assigned_team_id": null,
"waiting_since": null,
"subject": "Help with account setup",
"inbox_mail": "support@example.com",
"inbox_name": "Support",
"inbox_channel": "email",
"tags": [],
"meta": [],
"custom_attributes": {},
"last_message_at": "2025-06-15T10:34:00Z",
"last_message": "Thank you for your help!",
"last_message_sender": "contact",
"last_interaction": "Thank you for your help!",
"last_interaction_at": "2025-06-15T10:34:00Z",
"last_interaction_sender": "contact",
"contact": {
"id": 456,
"created_at": "2025-06-01T09:00:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact",
"availability_status": "offline",
"avatar_url": null,
"phone_number": null,
"phone_number_country_code": null,
"custom_attributes": {},
"enabled": true,
"last_active_at": null,
"last_login_at": null
},
"sla_policy_id": null,
"sla_policy_name": null,
"applied_sla_id": null,
"first_response_deadline_at": null,
"resolution_deadline_at": null,
"next_response_deadline_at": null,
"next_response_met_at": null,
"previous_conversations": null
}
}
}
```
Triggered when a conversation is assigned to a user.
```json theme={null}
{
"event": "conversation.assigned",
"timestamp": "2025-06-15T10:32:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"assigned_to": 789,
"actor_id": 789,
"conversation": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:32:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"closed_at": null,
"resolved_at": null,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"first_reply_at": null,
"last_reply_at": null,
"assigned_user_id": 789,
"assigned_team_id": null,
"waiting_since": "2025-06-15T10:30:00Z",
"subject": "Help with account setup",
"inbox_mail": "support@example.com",
"inbox_name": "Support",
"inbox_channel": "email",
"tags": [],
"meta": [],
"custom_attributes": {},
"last_message_at": "2025-06-15T10:30:00Z",
"last_message": "I need help setting up my account",
"last_message_sender": "contact",
"last_interaction": "I need help setting up my account",
"last_interaction_at": "2025-06-15T10:30:00Z",
"last_interaction_sender": "contact",
"contact": {
"id": 456,
"created_at": "2025-06-01T09:00:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact",
"availability_status": "offline",
"avatar_url": null,
"phone_number": null,
"phone_number_country_code": null,
"custom_attributes": {},
"enabled": true,
"last_active_at": null,
"last_login_at": null
},
"sla_policy_id": null,
"sla_policy_name": null,
"applied_sla_id": null,
"first_response_deadline_at": null,
"resolution_deadline_at": null,
"next_response_deadline_at": null,
"next_response_met_at": null,
"previous_conversations": null
}
}
}
```
Triggered when a conversation is unassigned from a user.
```json theme={null}
{
"event": "conversation.unassigned",
"timestamp": "2025-06-15T10:40:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"actor_id": 789,
"conversation": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:40:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"closed_at": null,
"resolved_at": null,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"first_reply_at": "2025-06-15T10:31:00Z",
"last_reply_at": "2025-06-15T10:35:00Z",
"assigned_user_id": null,
"assigned_team_id": null,
"waiting_since": "2025-06-15T10:35:00Z",
"subject": "Help with account setup",
"inbox_mail": "support@example.com",
"inbox_name": "Support",
"inbox_channel": "email",
"tags": [],
"meta": [],
"custom_attributes": {},
"last_message_at": "2025-06-15T10:35:00Z",
"last_message": "I still need help",
"last_message_sender": "contact",
"last_interaction": "I still need help",
"last_interaction_at": "2025-06-15T10:35:00Z",
"last_interaction_sender": "contact",
"contact": {
"id": 456,
"created_at": "2025-06-01T09:00:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact",
"availability_status": "offline",
"avatar_url": null,
"phone_number": null,
"phone_number_country_code": null,
"custom_attributes": {},
"enabled": true,
"last_active_at": null,
"last_login_at": null
},
"sla_policy_id": null,
"sla_policy_name": null,
"applied_sla_id": null,
"first_response_deadline_at": null,
"resolution_deadline_at": null,
"next_response_deadline_at": null,
"next_response_met_at": null,
"previous_conversations": null
}
}
}
```
Triggered when tags are added or removed from a conversation.
```json theme={null}
{
"event": "conversation.tags_changed",
"timestamp": "2025-06-15T10:45:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_tags": ["bug"],
"new_tags": ["bug", "priority"],
"actor_id": 789,
"conversation": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:45:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"closed_at": null,
"resolved_at": null,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"first_reply_at": "2025-06-15T10:31:00Z",
"last_reply_at": "2025-06-15T10:35:00Z",
"assigned_user_id": 789,
"assigned_team_id": null,
"waiting_since": null,
"subject": "Help with account setup",
"inbox_mail": "support@example.com",
"inbox_name": "Support",
"inbox_channel": "email",
"tags": ["bug", "priority"],
"meta": [],
"custom_attributes": {},
"last_message_at": "2025-06-15T10:35:00Z",
"last_message": "Thank you for your help!",
"last_message_sender": "contact",
"last_interaction": "Thank you for your help!",
"last_interaction_at": "2025-06-15T10:35:00Z",
"last_interaction_sender": "contact",
"contact": {
"id": 456,
"created_at": "2025-06-01T09:00:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact",
"availability_status": "offline",
"avatar_url": null,
"phone_number": null,
"phone_number_country_code": null,
"custom_attributes": {},
"enabled": true,
"last_active_at": null,
"last_login_at": null
},
"sla_policy_id": null,
"sla_policy_name": null,
"applied_sla_id": null,
"first_response_deadline_at": null,
"resolution_deadline_at": null,
"next_response_deadline_at": null,
"next_response_met_at": null,
"previous_conversations": null
}
}
}
```
### Message events
Triggered when a new message is created in a conversation.
```json theme={null}
{
"event": "message.created",
"timestamp": "2025-06-15T10:33:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:33:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "Hello! How can I help you today?
",
"text_content": "Hello! How can I help you today?",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
Triggered when an existing message is updated.
```json theme={null}
{
"event": "message.updated",
"timestamp": "2025-06-15T10:34:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:34:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "Hello! How can I help you today? (Updated)
",
"text_content": "Hello! How can I help you today? (Updated)",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
## Delivery and retries
* Deliveries run in a background worker pool.
* The request timeout is set in `config.toml`.
* Failed deliveries are not retried.
* Events can be dropped when the delivery queue is full. The queue size is set in `config.toml`.
Because there are no retries, verify the signature and reply within 5 seconds, then do the actual work after you have responded. An endpoint that blocks on slow processing will time out and lose the event.
## Testing
Both of these give you a throwaway URL that shows the payloads Libredesk sends:
Generate a temporary URL to inspect webhook payloads
Collect and inspect HTTP requests
## Example integration
An Express server that verifies the signature, replies immediately, and processes the event afterwards:
```javascript theme={null}
const express = require('express');
const crypto = require('crypto');
const app = express();
// Middleware to capture raw body for signature verification
app.use(express.raw({ type: 'application/json' }));
function verifySignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return signature === `sha256=${expectedSignature}`;
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-libredesk-signature'];
const secret = process.env.WEBHOOK_SECRET;
// Verify signature
if (secret && !verifySignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Process event asynchronously
setImmediate(() => {
processWebhookEvent(event);
});
// Respond immediately
res.status(200).send('OK');
});
function processWebhookEvent(event) {
console.log(`Received event: ${event.event}`);
switch(event.event) {
case 'conversation.created':
// Handle new conversation
break;
case 'message.created':
// Handle new message
break;
// Add more cases as needed
}
}
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});
```
# Developer Setup
Source: https://docs.libredesk.io/contributing/developer-setup
Set up your development environment for Libredesk
Libredesk is a monorepo with a Go backend and two Vue.js frontend apps: the main agent dashboard and the live chat widget. Both use Shadcn for UI components.
## Prerequisites
* Go
* Node.js and pnpm (if working on frontend)
* Redis
* PostgreSQL (≥13)
## First-time setup
```bash theme={null}
git clone https://github.com/abhinavxd/libredesk.git
cd libredesk
```
Copy `config.sample.toml` as `config.toml` and add your configuration:
```bash theme={null}
cp config.sample.toml config.toml
```
Build the libredesk binary and run the DB setup:
```bash theme={null}
make
./libredesk --install
```
Sets up the database and prompts you for the System user password.
## Running the dev environment
Run the backend and both frontend apps in development mode:
```bash theme={null}
make run-backend
```
This starts the libredesk backend dev server on `:9000`
```bash theme={null}
make run-frontend-main
```
This starts the agent dashboard in dev mode using pnpm on `:8000`
Requests are proxied to the backend running on `:9000` (check `vite.config.js` for proxy config)
```bash theme={null}
make run-frontend-widget
```
This starts the live chat widget in dev mode on `:8001`, also proxied to the backend on `:9000`
Only needed if you are working on the widget.
```bash theme={null}
make run-backend
```
Access the backend API at `http://localhost:9000`
```bash theme={null}
make run-frontend-main
```
Access the main app at `http://localhost:8000`
```bash theme={null}
make run-frontend-widget
```
Access the widget at `http://localhost:8001`
## Production build
To build a production-ready binary:
```bash theme={null}
make
```
This builds the Go binary and both frontend apps, embeds the static assets, and produces a single self-contained binary: `libredesk`.
To build just one app: `make frontend-build-main` or `make frontend-build-widget`.
# Translations
Source: https://docs.libredesk.io/contributing/translations
Help translate Libredesk into different languages
Libredesk translations are managed on Crowdin. Pick a language, translate the untranslated strings, and the translations sync back into the app.
Libredesk on Crowdin
# API Getting Started
Source: https://docs.libredesk.io/getting-started/api-getting-started
Access the Libredesk API to interact with your instance programmatically
You can access the Libredesk API to interact with your instance programmatically by generating API keys for any agent.
Some endpoints are not documented yet. Pull requests to the docs are welcome.
## Generating API keys
Go to **Admin → Teammate → Agent → Edit**
Libredesk shows an API key and an API secret for that agent
Keep both the API key and API secret secure
You can revoke or regenerate API keys at any time from the same page
## Using the API
Libredesk supports two authentication schemes:
### Basic authentication
```bash theme={null}
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: Basic "
```
### Token authentication
```bash theme={null}
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: token your_api_key:your_api_secret"
```
## API reference
Every documented endpoint is listed in the [API reference](/api-reference/introduction).
Browse endpoints with request and response examples
# Installation
Source: https://docs.libredesk.io/getting-started/installation
Deploy Libredesk using Docker, binary, or from source
Libredesk ships as a single binary and needs PostgreSQL and Redis to run. Install it with Docker, from a release binary, or from source.
## Installation methods
## Docker installation
The latest image is available on DockerHub at [libredesk/libredesk:latest](https://hub.docker.com/r/libredesk/libredesk)
### Quick start with Docker Compose
```bash theme={null}
# Download the compose file and the sample config file
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
```
```bash theme={null}
# Copy the config.sample.toml to config.toml and edit as needed
cp config.sample.toml config.toml
```
Edit `config.toml` with your database credentials and preferences.
```bash theme={null}
# Run the services in the background
docker compose up -d
```
```bash theme={null}
# Set System user password
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
Visit `http://localhost:9000` and login with:
* **Email**: `System`
* **Password**: The password you just set
## Binary installation
**Prerequisites:**
* PostgreSQL ≥13
* Redis
Download the [latest release](https://github.com/abhinavxd/libredesk/releases) for your platform and extract the libredesk binary.
```bash theme={null}
# Download the sample config file
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit as needed
cp config.sample.toml config.toml
```
Edit `config.toml` with your database credentials and preferences.
With the default `fs` upload provider, files are stored in the directory set by `upload.fs.upload_path` (`uploads`, relative to the working directory). Libredesk does not create this directory, and uploads fail if it is missing or not writable.
```bash theme={null}
mkdir uploads
chmod 755 uploads
```
If Libredesk runs as a different user than the one that created the directory, give that user ownership with `chown`.
```bash theme={null}
./libredesk --install
```
Creates the tables in PostgreSQL.
To set the System user password during installation, use the environment variable:
```bash theme={null}
LIBREDESK_SYSTEM_USER_PASSWORD=your_password ./libredesk --install
```
```bash theme={null}
# Set System user password
./libredesk --set-system-user-password
```
```bash theme={null}
./libredesk
```
Visit `http://localhost:9000` and login with:
* **Email**: `System`
* **Password**: The password you just set
## Compile from source
Build the latest unreleased version from the `main` branch.
**Prerequisites:**
* PostgreSQL ≥13
* Redis
* Go (latest version)
* Node.js (≥18)
* pnpm
```bash theme={null}
git clone git@github.com:abhinavxd/libredesk.git
cd libredesk
```
```bash theme={null}
make
```
Generates the `libredesk` binary in the current directory.
```bash theme={null}
# Download the sample config file
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit as needed
cp config.sample.toml config.toml
```
Edit `config.toml` with your database credentials and preferences.
With the default `fs` upload provider, files are stored in the directory set by `upload.fs.upload_path` (`uploads`, relative to the working directory). Libredesk does not create this directory, and uploads fail if it is missing or not writable.
```bash theme={null}
mkdir uploads
chmod 755 uploads
```
If Libredesk runs as a different user than the one that created the directory, give that user ownership with `chown`.
```bash theme={null}
./libredesk --install
```
Creates the tables in PostgreSQL.
To set the System user password during installation, use the environment variable:
```bash theme={null}
LIBREDESK_SYSTEM_USER_PASSWORD=your_password ./libredesk --install
```
```bash theme={null}
# Set System user password
./libredesk --set-system-user-password
```
```bash theme={null}
./libredesk
```
Visit `http://localhost:9000` and login with:
* **Email**: `System`
* **Password**: The password you just set
## Configuration via environment variables
Instead of using a `config.toml` file, you can configure Libredesk entirely through environment variables.
* All environment variables use the `LIBREDESK_` prefix
* Use double underscore (`__`) for nested configuration keys
* To use environment variables exclusively (without a config file), pass `--config=""`
**Example:**
| TOML Config | Environment Variable |
| ----------------------------------- | ------------------------------------------- |
| `upload.fs.upload_path = "uploads"` | `LIBREDESK_UPLOAD__FS__UPLOAD_PATH=uploads` |
| `db.host = "localhost"` | `LIBREDESK_DB__HOST=localhost` |
## Nginx reverse proxy (recommended)
We recommend running Libredesk behind Nginx as a reverse proxy.
Install nginx if you don't have it:
```bash theme={null}
# Debian / Ubuntu
sudo apt update && sudo apt install nginx
# RHEL / Fedora / CentOS
sudo dnf install nginx && sudo systemctl enable --now nginx
```
The proxy must set `X-Client-IP` to `$remote_addr`. Libredesk reads this header to identify the client for rate limiting and audit logs, and without it every request looks like it came from the proxy.
Save the following as `/etc/nginx/sites-available/libredesk`:
```nginx theme={null}
server {
listen 80;
server_name your-domain.com;
client_max_body_size 30M;
location / {
proxy_pass http://localhost:9000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Client-IP $remote_addr;
# If behind Cloudflare (or another CDN), forward the CDN's real-client header
# instead, AND firewall nginx to only accept connections from the CDN's IP range,
# otherwise clients can bypass the CDN and spoof this header directly.
# proxy_set_header X-Client-IP $http_cf_connecting_ip; # Cloudflare
proxy_cache_bypass $http_upgrade;
}
}
```
Then enable it and reload nginx:
```bash theme={null}
sudo ln -s /etc/nginx/sites-available/libredesk /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
# Upgrade Guide
Source: https://docs.libredesk.io/getting-started/upgrade
How to upgrade Libredesk to the latest version
Always take a backup of the Postgres database before upgrading Libredesk.
Dump the database to a file with `pg_dump`. This works for both Docker and non-Docker setups, since Docker exposes Postgres on `localhost:5432`. Replace the user and database name (`libredesk`) if you changed them in your config.
```bash theme={null}
pg_dump -h localhost -U libredesk libredesk > libredesk_backup.sql
```
## Upgrade methods
Stop the running libredesk binary process.
Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary to overwrite the previous version.
```bash theme={null}
./libredesk --upgrade
```
Upgrades are idempotent, so running this more than once does nothing.
```bash theme={null}
./libredesk
```
```bash theme={null}
docker compose down app
```
```bash theme={null}
docker compose pull
```
```bash theme={null}
docker compose up app -d
```
# Deploy on Railway
Source: https://docs.libredesk.io/hosting-providers/railway
One-click deploy Libredesk on Railway with managed PostgreSQL and Redis
[Railway](https://railway.com) provisions a Libredesk service alongside managed PostgreSQL and Redis from a single template. The database schema installs automatically on first deploy.
## Deploy
Sign in to Railway, then set `LIBREDESK_SYSTEM_USER_PASSWORD` in the deploy form. It must be at least 10 characters with uppercase, lowercase, numeric, and special characters (e.g. `MyPassword@123`).
Railway provisions PostgreSQL, Redis, and the Libredesk app, and assigns a `*.up.railway.app` domain to the Libredesk service. The schema installs automatically on first boot.
Visit the assigned domain and sign in with:
* **Email**: `System`
* **Password**: the password from step 1
## Configuration
To override any Libredesk setting, add an environment variable under the **Libredesk** service's **Variables** tab. All variables use the `LIBREDESK_` prefix with double underscores (`__`) for nested keys.
| Setting | Environment variable |
| -------------------------------------------- | ----------------------------------------- |
| Max attachment / request body size (bytes) | `LIBREDESK_APP__SERVER__MAX_BODY_SIZE` |
| Session lifetime | `LIBREDESK_APP__SERVER__SESSION_LIFETIME` |
| Log level (`debug`, `info`, `warn`, `error`) | `LIBREDESK_APP__LOG_LEVEL` |
See the [full configuration reference](/getting-started/installation#configuration-via-environment-variables) for the complete list.
Don't change the auto-injected database and Redis variables. Railway manages those references.
## Updating Libredesk
Redeploy the **Libredesk** service from the Railway dashboard. It pulls the latest image and runs any pending migrations on startup.
# Introduction
Source: https://docs.libredesk.io/introduction
Modern, open source, self-hosted omnichannel customer support desk. Live chat, email, and more — packed into a single binary.
Libredesk is a free and open-source omnichannel customer support desk with built-in live chat, email, and more. The backend is written in Go and the frontend in Vue JS with ShadcN for UI components.
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**live demo**](https://demo.libredesk.io/).
## Features
* **Omnichannel inbox**\
Live chat, email, and more — all in one inbox. Manage every conversation from a single, unified interface.
* **Live chat widget**\
Embed a real-time chat widget on your website. Engage visitors instantly and handle live conversations right from your support desk.
* **Help center**\
Publish a searchable knowledge base with collections, articles in multiple languages, and customize it however you want.
* **Granular permissions**\
Create custom roles with granular permissions for teams and individual agents.
* **Automations**\
Eliminate repetitive tasks with powerful automation rules. Auto-tag, assign, and route conversations based on custom conditions.
* **CSAT surveys**\
Measure customer satisfaction with automated surveys.
* **Macros**\
Save frequently sent messages as templates. With one click, send saved responses, set tags, and more.
* **Organization**\
Keep conversations organized with tags, custom statuses for conversations, and snoozing. Find any conversation instantly from the search bar.
* **Auto assignment**\
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
* **SLA management**\
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
* **Custom attributes**\
Create custom attributes for contacts or conversations such as the subscription plan or the date of their first purchase.
* **AI-assist**\
Instantly rewrite responses with AI to make them more friendly, professional, or polished.
* **Activity logs**\
Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability.
## Quick links
Get Libredesk up and running
Try Libredesk in action
View source code
Visit our website
# Roles & Permissions
Source: https://docs.libredesk.io/roles/overview
Control what agents can see and do in Libredesk with roles
A role is a named set of permissions. Every agent is assigned one or more roles, and the agent's effective access is the union of the permissions across those roles. Roles are managed under **Admin → Teams → Roles**.
## Default roles
Libredesk ships with two roles.
| Role | Description |
| ------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `Admin` | Complete access to everything, including all settings. This role cannot be edited; create a new role if you need a variation. |
| `Agent` | Limited access to conversations, messages, and views. |
The `Admin` role is locked and cannot be modified. To grant a subset of admin access, create a new role with only the permissions you need.
## Permissions
Permissions follow an `object:action` format (for example `roles:manage`). The sections below list every available permission and what it grants.
### Conversation
| Permission | What it grants |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `conversations:read` | Open and read an individual conversation, including its participants and search. Required to view any conversation. |
| `conversations:write` | Start new conversations. |
| `conversations:read_assigned` | List the conversations assigned to the agent. |
| `conversations:read_all` | List every conversation in the helpdesk, regardless of who it is assigned to. |
| `conversations:read_unassigned` | List conversations that are not yet assigned to anyone. |
| `conversations:read_team_inbox` | List the unassigned conversations in the inboxes of the agent's teams. |
| `conversations:read_team_all` | List all conversations assigned to the agent's teams. |
| `conversations:update_user_assignee` | Assign a conversation to an agent, or remove that assignment. |
| `conversations:update_team_assignee` | Assign a conversation to a team, or remove that assignment. |
| `conversations:update_priority` | Set a conversation's priority. |
| `conversations:update_status` | Change a conversation's status (for example open, resolved, or snoozed). |
| `conversations:update_tags` | Add or remove tags on a conversation. |
| `messages:read` | Read the messages in a conversation and download transcripts. |
| `messages:write` | Reply to and send messages in a conversation. |
| `messages:write_as_contact` | Post a message into a conversation on the contact's behalf. |
| `view:manage` | Create and manage the agent's own saved conversation views (filters). |
### Admin
| Permission | What it grants |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `general_settings:manage` | Edit instance-wide settings such as business name, branding, and defaults. |
| `notification_settings:manage` | Configure the email notifications that agents receive. |
| `status:manage` | Create, rename, and delete conversation statuses. |
| `oidc:manage` | Add and edit SSO (OpenID Connect) login providers. |
| `tags:manage` | Create, edit, delete, and import tags. |
| `macros:manage` | Create and edit macros (reusable sets of replies and actions). |
| `users:manage` | Create, edit, and delete agents, and assign their roles. See the warning below. |
| `teams:manage` | Create and edit teams and their membership. |
| `automations:manage` | Create and edit automation rules that act on conversations. |
| `inboxes:manage` | Create, configure, and delete inboxes (email, live chat, WhatsApp), including their channel connections and WhatsApp templates. |
| `roles:manage` | Create, edit, and delete roles and the permissions they grant. |
| `templates:manage` | Create and edit email templates. |
| `reports:manage` | View the reporting dashboards (overview, CSAT, SLA, message volume, tag distribution). |
| `business_hours:manage` | Define business-hour schedules and holidays used by SLAs. |
| `sla:manage` | Create and edit SLA policies. |
| `ai:manage` | Configure the AI provider and prompts. |
| `custom_attributes:manage` | Create and edit custom fields for conversations and contacts. |
| `activity_logs:manage` | View the activity (audit) log. |
| `webhooks:manage` | Create and edit outgoing webhooks. |
| `shared_views:manage` | Create and manage saved views shared with other agents. |
| `context_links:manage` | Configure the context links shown alongside a conversation. |
**`users:manage` is effectively full access.** It lets an agent assign any role, including `Admin`, to any agent, themselves included. So anyone with `users:manage` can grant themselves every other permission. Treat it as equivalent to admin access and give it only to people you fully trust.
### Contact
| Permission | What it grants |
| ---------------------- | --------------------------------------------------------- |
| `contacts:read_all` | List and view every contact. |
| `contacts:read` | View an individual contact's profile and search contacts. |
| `contacts:write` | Edit a contact's details. |
| `contacts:block` | Block or unblock contacts. |
| `contact_notes:read` | Read the private notes on a contact. |
| `contact_notes:write` | Add private notes to a contact. |
| `contact_notes:delete` | Delete private notes on a contact. |
## Creating a role
Go to **Admin → Teams → Roles** and click **New role**.
Give it a name and an optional description.
Check the permissions to grant. Anything left unchecked is denied.
Save the role, then assign it to agents from their user settings. An agent's access is the combined permissions of all roles assigned to them.