@kerabie/mcp 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1054 @@
1
+ // src/tools.ts
2
+ import { z } from "zod";
3
+ var page = { page: z.number().int().min(1).optional().describe("Page number, from 1"), limit: z.number().int().min(1).max(100).optional().describe("Results per page (default 20, at most 100)") };
4
+ var id = (what) => ({ id: z.number().int().positive().describe(`The ${what} id`) });
5
+ var days = { days: z.number().int().min(1).max(365).optional().describe("Window in days (default 30)") };
6
+ var STATUS = z.enum(["WAITING", "ACTIVE", "RESOLVED", "CLOSED"]);
7
+ var PRIORITY = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
8
+ var CONTACT_TYPE = z.enum(["LEAD", "CUSTOMER", "VIP"]);
9
+ var contactFields = {
10
+ name: z.string().optional(),
11
+ email: z.string().email().optional(),
12
+ phone: z.string().optional(),
13
+ company: z.string().optional(),
14
+ notes: z.string().optional(),
15
+ contactType: CONTACT_TYPE.optional()
16
+ };
17
+ var articleFields = {
18
+ title: z.string().min(1),
19
+ content: z.string().min(1),
20
+ category: z.string().optional(),
21
+ type: z.enum(["faq", "article", "news", "information"]).optional(),
22
+ status: z.enum(["draft", "published", "archived"]).optional()
23
+ };
24
+ var campaignFields = {
25
+ name: z.string().min(1).max(80),
26
+ message: z.string().min(1).max(300).describe("What the visitor sees"),
27
+ ctaLabel: z.string().max(40).optional().describe("Button text; no button when empty"),
28
+ ctaAction: z.enum(["open_chat", "open_kb", "open_link"]).optional(),
29
+ ctaUrl: z.string().url().optional().describe("Only for open_link"),
30
+ trigger: z.enum(["page_load", "time_on_page", "scroll_depth", "exit_intent"]).optional(),
31
+ triggerValue: z.union([z.string(), z.number()]).optional().describe("Seconds for time_on_page, percent for scroll_depth"),
32
+ pages: z.string().optional().describe("Comma-separated URL patterns with * wildcards; empty = every page"),
33
+ audience: z.enum(["all", "new", "returning", "not_chatted", "country"]).optional(),
34
+ audienceCountry: z.string().length(2).optional().describe("ISO country code, required when audience is country"),
35
+ frequency: z.enum(["once_per_visitor", "once_per_day", "every_session"]).optional()
36
+ };
37
+ var toolParam = z.object({
38
+ name: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
39
+ type: z.enum(["string", "number", "boolean"]),
40
+ description: z.string().max(300).optional(),
41
+ required: z.boolean().optional()
42
+ });
43
+ var aiToolFields = {
44
+ name: z.string().regex(/^[a-z][a-z0-9_]*$/).describe("snake_case function name the AI calls"),
45
+ displayName: z.string().min(2).max(80),
46
+ description: z.string().min(10).max(500).describe('Say when the AI should use the tool ("Use this when a customer asks\u2026")'),
47
+ endpoint: z.string().url().describe("A public https address; private and local addresses are rejected"),
48
+ method: z.enum(["GET", "POST", "PUT", "PATCH"]).optional(),
49
+ parameters: z.array(toolParam).max(20).optional(),
50
+ authType: z.enum(["none", "bearer", "api_key", "basic"]).optional(),
51
+ authConfig: z.record(z.string()).optional().describe("bearer: {token} \xB7 api_key: {header, value} \xB7 basic: {username, password}. Write-only."),
52
+ responsePath: z.string().optional().describe("Dot-notation path into the JSON reply, e.g. data.message"),
53
+ enabled: z.boolean().optional()
54
+ };
55
+ var TOOLS = [
56
+ // ── Conversations ─────────────────────────────────────────────────────────
57
+ {
58
+ name: "list_conversations",
59
+ title: "List conversations",
60
+ description: "List conversations in the inbox, newest first. Filter by status, channel, assigned agent or text.",
61
+ input: { status: STATUS.optional(), channel: z.enum(["WEBSITE", "WHATSAPP", "INSTAGRAM", "EMAIL", "API"]).optional(), search: z.string().optional(), assignedTo: z.number().int().optional(), ...page },
62
+ run: (c, a) => c.conversations.list(a)
63
+ },
64
+ {
65
+ name: "get_conversation",
66
+ title: "Get a conversation",
67
+ description: "One conversation with its customer, assigned agent, tags and whether the AI is handling it.",
68
+ input: id("conversation"),
69
+ run: (c, a) => c.conversations.get(a.id)
70
+ },
71
+ {
72
+ name: "get_customer_history",
73
+ title: "Get a customer's previous conversations",
74
+ description: "The same customer's other conversations, newest first.",
75
+ input: { ...id("conversation"), limit: z.number().int().min(1).max(20).optional() },
76
+ run: (c, a) => c.conversations.getHistory(a.id, a.limit)
77
+ },
78
+ {
79
+ name: "take_over_conversation",
80
+ title: "Take over from the AI",
81
+ description: "Stop the AI bot on a conversation and assign it to a team member if nobody holds it yet.",
82
+ write: true,
83
+ input: { ...id("conversation"), agentId: z.number().int().positive().describe("Organisation team-member id") },
84
+ run: (c, a) => c.conversations.takeOver(a.id, { agentId: a.agentId })
85
+ },
86
+ // ── Tickets ───────────────────────────────────────────────────────────────
87
+ { name: "list_tickets", title: "List tickets", description: "List support tickets, newest first.", input: { status: STATUS.optional(), priority: PRIORITY.optional(), ...page }, run: (c, a) => c.tickets.list(a) },
88
+ { name: "get_ticket_stats", title: "Ticket overview", description: "Open, unassigned and SLA-breaching ticket counts and average resolution time.", input: {}, run: (c) => c.tickets.getStats() },
89
+ { name: "get_ticket", title: "Get a ticket", description: "One ticket with its messages.", input: id("ticket"), run: (c, a) => c.tickets.get(a.id) },
90
+ {
91
+ name: "create_ticket",
92
+ title: "Create a ticket",
93
+ description: "Create a ticket for an end user. Repeated calls with the same externalRef land on the same ticket.",
94
+ write: true,
95
+ input: { externalRef: z.string().min(1).describe("A stable identifier for the end user"), message: z.string().min(1), subject: z.string().optional(), priority: PRIORITY.optional(), name: z.string().optional(), email: z.string().email().optional() },
96
+ run: (c, a) => c.tickets.create(a)
97
+ },
98
+ {
99
+ name: "update_ticket",
100
+ title: "Update a ticket",
101
+ description: "Change a ticket's status or priority.",
102
+ write: true,
103
+ input: { ...id("ticket"), status: STATUS.optional(), priority: PRIORITY.optional() },
104
+ run: (c, a) => c.tickets.update(a.id, { status: a.status, priority: a.priority })
105
+ },
106
+ {
107
+ name: "reply_to_ticket",
108
+ title: "Reply to a ticket",
109
+ description: "Send a reply on a ticket. If the requester has an email, they receive it.",
110
+ write: true,
111
+ input: { ...id("ticket"), message: z.string().min(1) },
112
+ run: (c, a) => c.tickets.reply(a.id, a.message)
113
+ },
114
+ // ── Contacts ──────────────────────────────────────────────────────────────
115
+ {
116
+ name: "list_contacts",
117
+ title: "List contacts",
118
+ description: "Search contacts by name, email, phone, company or tag.",
119
+ input: { search: z.string().optional(), type: CONTACT_TYPE.optional(), sort: z.enum(["recent", "newest", "name"]).optional(), ...page },
120
+ run: (c, a) => c.contacts.list(a)
121
+ },
122
+ { name: "get_contact_stats", title: "Contacts overview", description: "Totals by type and new contacts this week.", input: {}, run: (c) => c.contacts.getStats() },
123
+ { name: "get_contact", title: "Get a contact", description: "One contact with their type, company, owner, notes, tags and last contact time.", input: id("contact"), run: (c, a) => c.contacts.get(a.id) },
124
+ { name: "create_contact", title: "Create a contact", description: "Add a contact. Give at least a name or an email.", write: true, input: contactFields, run: (c, a) => c.contacts.create(a) },
125
+ {
126
+ name: "update_contact",
127
+ title: "Update a contact",
128
+ description: "Change fields on a contact. Send only what changes.",
129
+ write: true,
130
+ input: { ...id("contact"), ...contactFields },
131
+ run: (c, a) => {
132
+ const { id: contactId, ...rest } = a;
133
+ return c.contacts.update(contactId, rest);
134
+ }
135
+ },
136
+ // ── Knowledge base ────────────────────────────────────────────────────────
137
+ {
138
+ name: "search_knowledge_base",
139
+ title: "Search the Knowledge Base",
140
+ description: "Find articles by text. These are the articles the AI chatbot answers from.",
141
+ input: { query: z.string().min(1), ...page },
142
+ run: (c, a) => c.kb.list({ search: a.query, page: a.page, limit: a.limit })
143
+ },
144
+ { name: "get_kb_stats", title: "Knowledge Base overview", description: "Article totals (published, drafts), total views, the most viewed article and the categories in use.", input: {}, run: (c) => c.kb.getStats() },
145
+ { name: "get_kb_insights", title: "What to write or fix next", description: "Most viewed articles, customer questions the AI could not answer in the last 30 days (content gaps), lowest-rated articles and published articles not updated in 90 days.", input: {}, run: (c) => c.kb.getInsights() },
146
+ { name: "get_article", title: "Get an article", description: "One Knowledge Base article.", input: id("article"), run: (c, a) => c.kb.get(a.id) },
147
+ { name: "create_article", title: "Create an article", description: "Add a Knowledge Base article. Published articles are used by the AI chatbot straight away.", write: true, input: articleFields, run: (c, a) => c.kb.createArticle(a) },
148
+ {
149
+ name: "update_article",
150
+ title: "Update an article",
151
+ description: "Edit an article. Send only what changes.",
152
+ write: true,
153
+ input: { ...id("article"), title: articleFields.title.optional(), content: articleFields.content.optional(), category: articleFields.category, type: articleFields.type, status: articleFields.status },
154
+ run: (c, a) => {
155
+ const { id: articleId, ...rest } = a;
156
+ return c.kb.updateArticle(articleId, rest);
157
+ }
158
+ },
159
+ { name: "delete_article", title: "Delete an article", description: "Permanently delete a Knowledge Base article.", write: true, destructive: true, input: id("article"), run: async (c, a) => {
160
+ await c.kb.deleteArticle(a.id);
161
+ return { success: true };
162
+ } },
163
+ // ── Campaigns ─────────────────────────────────────────────────────────────
164
+ {
165
+ name: "list_campaigns",
166
+ title: "List campaigns",
167
+ description: "Proactive messages shown on the chat widget, with how many times each was shown, opened, clicked and replied to.",
168
+ input: { status: z.enum(["active", "paused", "draft"]).optional(), search: z.string().optional(), ...page },
169
+ run: (c, a) => c.campaigns.list(a)
170
+ },
171
+ { name: "get_campaign_stats", title: "Campaigns overview", description: "Totals, funnel numbers and how many campaigns the plan lets you run at once (limit, 0 = unlimited).", input: {}, run: (c) => c.campaigns.getStats() },
172
+ { name: "get_campaign", title: "Get a campaign", description: "One campaign with its message, rules (trigger, pages, audience, frequency) and results.", input: id("campaign"), run: (c, a) => c.campaigns.get(a.id) },
173
+ {
174
+ name: "create_campaign",
175
+ title: "Create a campaign",
176
+ description: "Save a campaign as a draft, or launch it with isActive (counts against the plan's live-campaign limit).",
177
+ write: true,
178
+ input: { ...campaignFields, isActive: z.boolean().optional() },
179
+ run: (c, a) => c.campaigns.create(a)
180
+ },
181
+ {
182
+ name: "update_campaign",
183
+ title: "Update a campaign",
184
+ description: "Edit a campaign and/or turn it on or off with isActive. Send only what changes.",
185
+ write: true,
186
+ input: { ...id("campaign"), ...Object.fromEntries(Object.entries(campaignFields).map(([k, v]) => [k, v.optional()])), isActive: z.boolean().optional() },
187
+ run: (c, a) => {
188
+ const { id: campaignId, ...rest } = a;
189
+ return c.campaigns.update(campaignId, rest);
190
+ }
191
+ },
192
+ { name: "duplicate_campaign", title: "Duplicate a campaign", description: "A copy saved as a draft with fresh results.", write: true, input: id("campaign"), run: (c, a) => c.campaigns.duplicate(a.id) },
193
+ { name: "delete_campaign", title: "Delete a campaign", description: "Permanently delete a campaign and its results.", write: true, destructive: true, input: id("campaign"), run: (c, a) => c.campaigns.delete(a.id) },
194
+ // ── Analytics ─────────────────────────────────────────────────────────────
195
+ { name: "get_analytics_summary", title: "Analytics summary", description: "Conversations, resolution rate, messages, AI chats, satisfaction and changes against the previous period.", input: days, run: (c, a) => c.analytics.getSummary(a) },
196
+ { name: "get_analytics_insights", title: "Analytics insights", description: "Response times, busiest times, top questions, questions the AI could not answer, channels, countries, new vs returning customers and pages that start conversations.", input: days, run: (c, a) => c.analytics.getInsights(a) },
197
+ { name: "get_agent_stats", title: "Agent performance", description: "Per-agent conversations, resolution rate, response time and rating.", input: days, run: (c, a) => c.analytics.getAgentStats(a) },
198
+ { name: "get_ticket_sla", title: "Ticket SLA", description: "Tickets within SLA and the oldest waiting ones by priority.", input: {}, run: (c) => c.analytics.getTicketSla() },
199
+ { name: "get_setup_status", title: "Setup status", description: "Whether a channel, the AI and an automation are set up.", input: {}, run: (c) => c.analytics.getSetupStatus() },
200
+ // ── Visitors and store ────────────────────────────────────────────────────
201
+ {
202
+ name: "list_visitors",
203
+ title: "List visitors",
204
+ description: "Recent and live website visitors.",
205
+ input: { status: z.enum(["all", "online", "offline", "blocked", "allowed"]).optional(), search: z.string().optional(), country: z.string().length(2).optional(), days: z.number().int().min(1).max(30).optional(), ...page },
206
+ run: (c, a) => c.visitors.list(a)
207
+ },
208
+ { name: "get_visitors_overview", title: "Visitors overview", description: "Online now, countries, returning visitors, blocked IPs and top countries.", input: {}, run: (c) => c.visitors.getOverview() },
209
+ { name: "list_store_products", title: "List store products", description: "Products in the Kerabie store.", input: page, run: (c, a) => c.store.list(a) },
210
+ // ── AI tools ──────────────────────────────────────────────────────────────
211
+ { name: "list_ai_tools", title: "List AI tools", description: "Custom API actions the chatbot can call, with 30-day usage and the plan's tool limit (-1 = not on the plan, 0 = unlimited).", input: {}, run: (c) => c.aiTools.list() },
212
+ { name: "get_ai_tool", title: "Get an AI tool", description: "One AI tool. Secrets are never returned.", input: id("tool"), run: (c, a) => c.aiTools.get(a.id) },
213
+ { name: "create_ai_tool", title: "Create an AI tool", description: "Add an API the chatbot can call. Endpoints must be public addresses; the plan limits how many tools you can have.", write: true, input: aiToolFields, run: (c, a) => c.aiTools.create(a) },
214
+ {
215
+ name: "update_ai_tool",
216
+ title: "Update an AI tool",
217
+ description: "Edit a tool and/or switch it on or off with enabled. Send only what changes; leave a secret out to keep the stored one.",
218
+ write: true,
219
+ input: { ...id("tool"), ...Object.fromEntries(Object.entries(aiToolFields).map(([k, v]) => [k, v.optional()])) },
220
+ run: (c, a) => {
221
+ const { id: toolId, ...rest } = a;
222
+ return c.aiTools.update(toolId, rest);
223
+ }
224
+ },
225
+ { name: "duplicate_ai_tool", title: "Duplicate an AI tool", description: "A copy that starts switched off.", write: true, input: id("tool"), run: (c, a) => c.aiTools.duplicate(a.id) },
226
+ { name: "delete_ai_tool", title: "Delete an AI tool", description: "Permanently delete an AI tool.", write: true, destructive: true, input: id("tool"), run: (c, a) => c.aiTools.delete(a.id) },
227
+ {
228
+ name: "test_ai_tool",
229
+ title: "Test an AI tool",
230
+ description: "Calls the tool's real endpoint once with the given arguments and returns what the AI would see. Limited to 40 a hour.",
231
+ write: true,
232
+ input: { ...id("tool"), args: z.record(z.unknown()).optional().describe("Values for the tool's parameters") },
233
+ run: (c, a) => c.aiTools.test(a.id, a.args ?? {})
234
+ },
235
+ // ── MCP servers as tool sources ───────────────────────────────────────────
236
+ { name: "list_mcp_servers", title: "List connected MCP servers", description: "Remote MCP servers whose tools the AI chatbot can call: the tools each offers, which are switched on, and call counts.", input: {}, run: (c) => c.mcpServers.list() },
237
+ {
238
+ name: "connect_mcp_server",
239
+ title: "Connect an MCP server",
240
+ description: "Connect a remote MCP server (public Streamable HTTP address) to the chatbot. No tool is switched on until you enable it with update_mcp_server.",
241
+ write: true,
242
+ input: { name: z.string().min(2).max(60), description: z.string().min(10).max(300).describe("What this server is for and when the chatbot should use it. The chatbot reads it to choose between servers."), url: z.string().url(), authType: z.enum(["none", "bearer", "api_key"]).optional(), authConfig: z.record(z.string()).optional().describe("bearer: {token} \xB7 api_key: {header, value}. Write-only.") },
243
+ run: (c, a) => c.mcpServers.create(a)
244
+ },
245
+ { name: "sync_mcp_server", title: "Sync an MCP server", description: "Read the server\u2019s tools again. Switched-on tools it no longer offers are dropped.", write: true, input: id("MCP server"), run: (c, a) => c.mcpServers.sync(a.id) },
246
+ {
247
+ name: "update_mcp_server",
248
+ title: "Update an MCP server",
249
+ description: "Rename it, switch the whole server on or off, or set which of its tools (enabledTools) the chatbot may use. Switched-on tools count against the plan\u2019s AI tool limit.",
250
+ write: true,
251
+ input: { ...id("MCP server"), name: z.string().min(2).max(60).optional(), description: z.string().min(10).max(300).optional(), enabled: z.boolean().optional(), enabledTools: z.array(z.string()).max(100).optional(), authType: z.enum(["none", "bearer", "api_key"]).optional(), authConfig: z.record(z.string()).optional() },
252
+ run: (c, a) => {
253
+ const { id: serverId, ...rest } = a;
254
+ return c.mcpServers.update(serverId, rest);
255
+ }
256
+ },
257
+ {
258
+ name: "test_mcp_server_tool",
259
+ title: "Test an MCP server tool",
260
+ description: "Runs one of the connected server\u2019s tools once and returns what the AI would see. Limited to 40 a hour.",
261
+ write: true,
262
+ input: { ...id("MCP server"), tool: z.string().min(1).describe("The tool\u2019s name on that server"), args: z.record(z.unknown()).optional() },
263
+ run: (c, a) => c.mcpServers.test(a.id, a.tool, a.args ?? {})
264
+ },
265
+ { name: "disconnect_mcp_server", title: "Disconnect an MCP server", description: "Remove a connected MCP server from the chatbot.", write: true, destructive: true, input: id("MCP server"), run: (c, a) => c.mcpServers.delete(a.id) }
266
+ ];
267
+
268
+ // src/generated/knowledge.json
269
+ var knowledge_default = { builtAt: "2026-10-29T23:19:52.441Z", docs: [{ id: "agents/availability", url: "https://kerabie.com/docs/agents/availability", title: "Agent Availability", description: "Set your status and manage online presence.", section: "agents", body: "Agent availability controls whether you receive new conversations and appear as online to visitors.\n\n## Status options\n\n| Status | Meaning |\n|---|---|\n| **Available** | You can receive new conversations and are shown as online |\n| **Busy** | You are logged in but not taking new conversations |\n| **Away** | You are temporarily offline; conversations go to the queue |\n| **Offline** | Fully signed out |\n\n## Changing your status\n\nClick your **avatar** in the bottom-left of the sidebar, then choose a status from the dropdown. The change is instant.\n\nWhen you set yourself to **Busy** or **Away**, new conversations are not auto-assigned to you. They remain in the unassigned queue or are routed to other available agents.\n\n## Automatic away\n\nIf you close the browser tab or have no activity for a configurable period, Kerabie can automatically set you to Away.\n\nAdmins configure this timeout under **Settings \u2192 General \u2192 Auto-away after**.\n\n## Online indicator in the widget\n\nIf at least one agent in your workspace is **Available**, the widget shows a green indicator to visitors. When no agents are available, the indicator turns grey and the widget falls back to offline mode (collecting an email for async replies).\n\nConfigure offline mode messaging under **Settings \u2192 Chat Widget \u2192 Offline mode**.\n\n## Availability and round-robin\n\nWhen a conversation is assigned to a team using round-robin, only agents who are **Available** are eligible to receive it. Agents set to Busy or Away are skipped until they return to Available.\n\n## Agent schedule *(Pro)*\n\nOn Pro and above, admins can set working hours per agent under **Settings \u2192 Agents \u2192 [agent] \u2192 Schedule**. Outside those hours the agent is automatically set to Away even if they're logged in.", headings: [{ level: 2, text: "Status options" }, { level: 2, text: "Changing your status" }, { level: 2, text: "Automatic away" }, { level: 2, text: "Online indicator in the widget" }, { level: 2, text: "Availability and round-robin" }, { level: 2, text: "Agent schedule *(Pro)*" }] }, { id: "agents/inviting", url: "https://kerabie.com/docs/agents/inviting", title: "Inviting Agents", description: "Add team members to your Kerabie workspace.", section: "agents", body: "Any admin can invite new agents to the workspace. Invited agents get an email with a link to set their password and join.\n\n## Sending an invitation\n\n1. Go to **Settings \u2192 Agents \u2192 Invite agent**\n2. Enter the agent's **email address**\n3. Choose their **role**: Agent or Admin (see [Roles & Permissions](/docs/agents/roles))\n4. Optionally assign them to one or more **teams**\n5. Click **Send invite**\n\nThe invitation is valid for **7 days**. If it expires, send a new one from the same screen.\n\n## What the invited agent sees\n\nThe agent receives an email from `noreply@kerabie.com` with a **Join workspace** button. Clicking it opens a page where they set their name and password. After that they land directly in the dashboard.\n\n## Resending or cancelling an invitation\n\nIn **Settings \u2192 Agents**, pending invitations appear with a **Pending** badge. Click the kebab menu (\u22EF) next to a pending invite to:\n\n- **Resend**: sends the email again with a fresh 7-day link\n- **Cancel**: invalidates the link immediately\n\n## Agent limits by plan\n\n| Plan | Max agents |\n|---|---|\n| Free | 2 |\n| Starter | 5 |\n| Pro | 20 |\n| Business | Unlimited |\n\nIf you've reached your plan's limit, the invite button is disabled. Upgrade under **Settings \u2192 Billing** to add more seats.\n\n## Removing an agent\n\n1. Go to **Settings \u2192 Agents**\n2. Click the agent's name\n3. Click **Remove from workspace**\n\nRemoved agents lose access immediately. Their past conversations remain in the system and can be reassigned.", headings: [{ level: 2, text: "Sending an invitation" }, { level: 2, text: "What the invited agent sees" }, { level: 2, text: "Resending or cancelling an invitation" }, { level: 2, text: "Agent limits by plan" }, { level: 2, text: "Removing an agent" }] }, { id: "agents/roles", url: "https://kerabie.com/docs/agents/roles", title: "Roles & Permissions", description: "Control what agents can see and do.", section: "agents", body: "Kerabie has two built-in roles: **Agent** and **Admin**.\n\n## Role comparison\n\n| Permission | Agent | Admin |\n|---|---|---|\n| View and reply to assigned conversations | \u2705 | \u2705 |\n| View all conversations (unassigned + team) | \u2705 | \u2705 |\n| Use quick responses | \u2705 | \u2705 |\n| Add tags and notes | \u2705 | \u2705 |\n| Assign / reassign conversations | \u2705 | \u2705 |\n| Create and edit quick responses | \u274C | \u2705 |\n| Invite and remove agents | \u274C | \u2705 |\n| Create and manage teams | \u274C | \u2705 |\n| Configure automations | \u274C | \u2705 |\n| Manage knowledge base | \u274C | \u2705 |\n| Access billing and subscription | \u274C | \u2705 |\n| Generate and revoke API keys | \u274C | \u2705 |\n| View analytics | \u274C | \u2705 |\n| Change widget settings | \u274C | \u2705 |\n\n## Changing a role\n\n1. Go to **Settings \u2192 Agents**\n2. Click the agent's name\n3. Change the **Role** dropdown to Agent or Admin\n4. Save\n\nThe change takes effect on the agent's next page load.\n\n## Owner\n\nThe workspace **owner** is the user who created the organisation. The owner cannot be demoted to Agent and cannot be removed by other admins. Only the owner can delete the workspace entirely.\n\nTo transfer ownership, contact support.\n\n## Multiple admins\n\nYou can have as many admins as needed; there is no limit on admin seats. It's good practice to have at least two admins so the workspace is never locked if one person leaves.", headings: [{ level: 2, text: "Role comparison" }, { level: 2, text: "Changing a role" }, { level: 2, text: "Owner" }, { level: 2, text: "Multiple admins" }] }, { id: "agents/teams", url: "https://kerabie.com/docs/agents/teams", title: "Teams", description: "Group agents into teams for conversation routing.", section: "agents", body: "Teams let you group agents so conversations can be routed to the right group rather than a specific individual. This makes routing more resilient; if one agent is unavailable, another team member picks it up.\n\n## Creating a team\n\n1. Go to **Settings \u2192 Teams \u2192 New team**\n2. Enter a **team name** (e.g. Sales, Support, Billing)\n3. Add agents to the team\n4. Save\n\n## Assigning conversations to a team\n\n**Manually**: in the conversation panel, click **Assign \u2192 Team** and choose a team.\n\n**Automatically**: use an automation:\n- Trigger: `Conversation created`\n- Action: `Assign team \u2192 [team name]`\n\nSee [Automations](/docs/automations/overview) for the full setup.\n\n## Round-robin distribution\n\nWhen a conversation is assigned to a team, Kerabie uses round-robin to give it to the next **Available** agent on the team. The cycle resets when all agents have been assigned once.\n\nIf no team member is Available, the conversation stays in the team queue until someone comes online.\n\n## Team inbox\n\nAgents see conversations assigned to their teams in the **Team** section of the conversations inbox, alongside their personally assigned conversations.\n\n## Multiple teams\n\nAn agent can belong to multiple teams. They receive conversations from all teams they're on, in the same shared inbox.\n\n## Removing agents from a team\n\n1. Go to **Settings \u2192 Teams \u2192 [team name]**\n2. Click the **\xD7** next to an agent's name\n3. Save\n\nAny conversations currently assigned to that team remain assigned to the team; they are not unassigned from existing members.", headings: [{ level: 2, text: "Creating a team" }, { level: 2, text: "Assigning conversations to a team" }, { level: 2, text: "Round-robin distribution" }, { level: 2, text: "Team inbox" }, { level: 2, text: "Multiple teams" }, { level: 2, text: "Removing agents from a team" }] }, { id: "agents/virtual-assistants", url: "https://kerabie.com/docs/agents/virtual-assistants", title: "Virtual Assistants", description: "Hire verified support agents from the Kerabie marketplace.", section: "agents", body: "The Kerabie Marketplace connects you with verified virtual assistants (VAs), trained support agents who handle conversations on your behalf.\n\n<Note>The VA Marketplace is available on **Pro** and **Business** plans.</Note>\n\n## What is a virtual assistant?\n\nA virtual assistant is a real human agent who joins your Kerabie workspace and handles conversations as if they were a member of your team. They are:\n\n- **Vetted**: background-checked and tested on support quality\n- **Trained on your docs**: they read your knowledge base and playbook before going live\n- **Managed through Kerabie**: conversations, notes, and handoffs all happen in your existing inbox\n\n## Hiring a VA\n\n1. Go to **Marketplace** in the sidebar (or [kerabie.com/app/marketplace](https://kerabie.com/app/marketplace))\n2. Browse available VAs: filter by language, availability, rating, and speciality\n3. Click a profile to read their bio, experience, and reviews\n4. Click **Hire** and select a plan (per-hour or monthly retainer)\n5. The VA is added as an agent to your workspace within 24 hours\n\n## Onboarding your VA\n\nAfter hiring, share your:\n- Knowledge base: the VA reads all published articles\n- Brand tone guide: set this under **Settings \u2192 Widget Settings \u2192 Bot personality**\n- Any special instructions: add them to the **VA Playbook** in Marketplace settings\n\n## Monitoring VA performance\n\nTrack your VA's activity in **Analytics \u2192 Agents**:\n- Response time\n- CSAT scores\n- Conversations handled per day\n- Resolution rate\n\n## Pausing or ending a contract\n\n1. Go to **Marketplace \u2192 My VAs**\n2. Click the VA's name\n3. Choose **Pause** (temporary) or **End contract** (permanent)\n\nThe VA's access to your workspace is removed within 1 hour of ending the contract. All conversation history is retained.\n\n## VA availability\n\nVAs set their own availability hours. Check a VA's timezone and hours on their profile page before hiring. If you need 24/7 coverage, hire multiple VAs in different time zones.\n\n## Job board\n\nPrefer to let VAs come to you? Post a job instead of searching.\n\n1. Go to **Marketplace \u2192 Job board** and choose **Post a job**.\n2. Add a title, a description, the pay type (hourly or fixed price), your budget, how long the work runs, and the skills you need.\n3. VAs send you proposals with their rate and a cover letter. Open **Marketplace \u2192 Activity \u2192 Posted jobs** to read them.\n4. **Accept** a proposal to send that VA a hire request at their proposed rate, or **Decline** it. **Close job** when you have found someone.\n\nHow many jobs you can have open at once depends on your plan. Closed jobs never count toward the limit.\n\n**Applying as a VA:** open the job board, choose **Apply now**, and send your hourly rate with a short cover letter. You need a VA profile first. Track your proposals under **Activity \u2192 Applications**, and withdraw one at any time before it is answered. You cannot apply to a job posted by your own organisation.\n\n## Job board\n\nInstead of searching for a VA yourself, post a job and let VAs come to you.\n\n1. Go to **Marketplace \u2192 Job board** and choose **Post a job**.\n2. Add a title, a description, the pay type (hourly or fixed price), your budget, how long the work lasts and the skills you need.\n3. VAs send you a proposal with the hourly rate they would charge and a short cover letter.\n4. Open **Marketplace \u2192 Activity \u2192 Posted jobs** to read the proposals. **Accept** sends that VA a normal hire request at their proposed rate. **Decline** closes the proposal.\n5. **Close job** takes the job off the board.\n\nHow many jobs you can keep open at once depends on your plan. Closed jobs never count towards it.\n\nIf you are a VA, browse the board, choose **Apply now**, and track what you sent under **Activity \u2192 Applications**. You can withdraw a proposal until it is answered. You need a VA profile to apply, and you cannot apply to a job posted by your own organisation.", headings: [{ level: 2, text: "What is a virtual assistant?" }, { level: 2, text: "Hiring a VA" }, { level: 2, text: "Onboarding your VA" }, { level: 2, text: "Monitoring VA performance" }, { level: 2, text: "Pausing or ending a contract" }, { level: 2, text: "VA availability" }, { level: 2, text: "Job board" }, { level: 2, text: "Job board" }] }, { id: "ai/escalation", url: "https://kerabie.com/docs/ai/escalation", title: "Escalation to Human Agent", description: "How the AI hands off conversations to your team.", section: "ai", body: `When the AI can't confidently answer a question, it escalates the conversation to a human agent rather than guessing.
270
+
271
+ ## When escalation happens
272
+
273
+ - AI confidence falls below your threshold (default: 70%)
274
+ - Visitor explicitly asks for a human ("speak to a person", "talk to someone")
275
+ - Visitor is frustrated (negative sentiment detected)
276
+ - Conversation matches an escalation trigger you've set up in Automations
277
+
278
+ ## What happens during escalation
279
+
280
+ 1. The AI sends a handoff message: *"Let me connect you with a team member."*
281
+ 2. The conversation is flagged as **needs agent**
282
+ 3. Available agents are notified (push, email, or both, based on their preferences)
283
+ 4. The conversation appears in the agents' queue with a summary of what was discussed
284
+
285
+ ## Escalation message
286
+
287
+ Customise the message visitors see when the AI escalates:
288
+
289
+ In **Settings \u2192 AI \u2192 Escalation message**:
290
+ \`\`\`
291
+ I wasn't able to fully answer your question. I'm connecting you with a team member now. They'll be with you shortly.
292
+ \`\`\`
293
+
294
+ ## Outside business hours
295
+
296
+ If no agents are online when escalation happens:
297
+
298
+ 1. The AI sends your **offline message** ("Our team is offline, but we'll reply within a few hours")
299
+ 2. The visitor can leave their email
300
+ 3. The org owner receives an email notification immediately
301
+
302
+ Configure offline behavior in [Settings \u2192 Business Hours](/docs/widget/offline-mode).
303
+
304
+ ## Escalation analytics
305
+
306
+ Track escalation rates in **Analytics \u2192 AI** to see:
307
+
308
+ - Which questions the AI struggles with most (add these to your knowledge base)
309
+ - Average time from escalation to first human response
310
+ - Escalation rate trend over time`, headings: [{ level: 2, text: "When escalation happens" }, { level: 2, text: "What happens during escalation" }, { level: 2, text: "Escalation message" }, { level: 2, text: "Outside business hours" }, { level: 2, text: "Escalation analytics" }] }, { id: "ai/flows", url: "https://kerabie.com/docs/ai/flows", title: "Chat flows", description: "Greet visitors with a menu of buttons, ask follow-up questions, and hand over to your team or the AI.", section: "ai", body: "A chat flow is a short menu a visitor sees as soon as they open the chat, before they type anything. Each button leads to another question, saves an answer, or hands the conversation to your team or the AI.\n\n<Note>Chat flows are included on **Basic** (up to 3 flows) and **Pro** and above (unlimited). They work with or without the AI assistant.</Note>\n\n## What a visitor sees\n\n1. They open the chat and see your first question with buttons, for example *What would you like help with?*\n2. They tap a button. Their tap appears as their message, and the conversation starts.\n3. The next question appears with its own buttons, until a button hands over.\n4. At any point they can type instead of tapping. The flow steps aside and the AI or your team answers that message.\n\nOpening the chat does not start a conversation, so it does not count against your monthly conversation limit. The conversation begins when the visitor taps a button.\n\n## The three kinds of step\n\n| Step | What it does |\n|---|---|\n| **Buttons** | Shows a message and up to 8 buttons. Each button goes to another step, or ends the flow. |\n| **Typed answer** | Asks a question and saves what the visitor types on their contact (for example `email` or `order_number`), then goes on to another step or ends. |\n| **Ending** | Shows a closing message, then hands over. |\n\nA button or ending can hand the conversation to:\n\n- **Your team.** The conversation is flagged as needing an agent and your agents are notified. Outside your office hours the visitor is told when you will reply.\n- **The AI.** The visitor is invited to ask their question and the AI answers from your knowledge base.\n- **Nobody.** The flow just ends.\n\nThe first step must have buttons, because a visitor joins the flow by tapping one.\n\n## Build a flow\n\n1. Go to **Automations \u2192 Chat flows**.\n2. Choose **Templates** for a ready-made menu, or **New flow**.\n3. Write each step's message and add its buttons. For each button choose where it leads: another step, your team, the AI, or the end.\n4. Use the preview on the right to click through the flow as a visitor would.\n5. Save. The flow is on straight away; switch it off from the list at any time.\n\nThe editor lists anything that stops a flow from running (a button that leads nowhere, an empty message) and will not save until it is fixed.\n\n## When a flow shows\n\nEvery flow has a **When to show** setting in the editor:\n\n- **Greet visitors** (the default). The flow opens the chat with its question and buttons. Name the pages it should appear on, one per line: `/pricing` for one page, or `/docs/*` for a whole section. Leave the pages empty to show it on every page. A flow named for the page the visitor is on is used instead of the general one.\n- **Start on a keyword.** The flow does not greet. It starts when a visitor's message contains one of its words or phrases (for example `refund` or `money back`), as whole words, ignoring case. It starts at most once per conversation. You can still limit it to some pages.\n\nKeywords match whole words, so `car` does not match `cart`.\n\n## Limits\n\n- Up to 30 steps per flow, 8 buttons per step, 40 characters per button and 500 characters per message.\n- Basic allows 3 flows, Pro and above unlimited. If you downgrade, your flows are kept but stop showing until you upgrade again.\n- When more than one greeting flow matches a page, visitors are greeted with the oldest. Up to 10 pages and 10 keywords per flow.\n\n## Where answers go\n\nA typed answer is saved on the contact as a custom attribute with the name you chose, so your agents see it in the contact's details and it is available to automations.\n\n## Chat flows and the AI\n\nA visitor who taps a button that hands over to the AI, or who types instead of tapping, is answered by the AI as usual, including its [escalation rules](/docs/ai/escalation). Once a conversation has been handed to your team, the flow and the AI both stay quiet so they do not talk over an agent.", headings: [{ level: 2, text: "What a visitor sees" }, { level: 2, text: "The three kinds of step" }, { level: 2, text: "Build a flow" }, { level: 2, text: "When a flow shows" }, { level: 2, text: "Limits" }, { level: 2, text: "Where answers go" }, { level: 2, text: "Chat flows and the AI" }] }, { id: "ai/knowledge-base", url: "https://kerabie.com/docs/ai/knowledge-base", title: "AI & Knowledge Base", description: "Connect your knowledge base to power accurate AI responses.", section: "ai", body: `The Kerabie AI draws all its answers from your published knowledge base articles. There is no separate AI training step. Publish an article and it's immediately available to the AI.
311
+
312
+ ## How the AI uses articles
313
+
314
+ When a visitor sends a message, the AI:
315
+
316
+ 1. **Searches** your knowledge base for articles relevant to the question
317
+ 2. **Reads** the matched articles in full
318
+ 3. **Generates** a response grounded in that content
319
+ 4. **Cites** the article title so the visitor can read the full page
320
+
321
+ If no relevant article is found, the AI says it doesn't know and offers to connect with a human agent.
322
+
323
+ ## Writing articles the AI understands well
324
+
325
+ The AI works best with articles that are:
326
+
327
+ - **Specific**: one topic per article, not a wall of text covering everything
328
+ - **Direct**: answer the question in the first paragraph, then add detail
329
+ - **Keyword-rich**: include the exact phrases visitors use (e.g. "cancel subscription", "forgot password")
330
+ - **Up to date**: outdated answers are worse than no answer; archive articles that no longer apply
331
+
332
+ Avoid:
333
+ - Marketing language and filler ("We're excited to offer...")
334
+ - Ambiguous pronouns without clear referents
335
+ - Answers that depend on context the AI can't know (e.g. "call us at...")
336
+
337
+ ## Controlling AI access per article
338
+
339
+ By default the AI can use all published articles. To exclude a specific article:
340
+
341
+ 1. Open the article in the knowledge base editor
342
+ 2. Toggle **Exclude from AI** in the sidebar
343
+ 3. Save
344
+
345
+ The article still appears in the FAQ widget and search; it's only hidden from AI responses.
346
+
347
+ ## Checking what the AI knows
348
+
349
+ Under **Settings \u2192 AI Assistant \u2192 Knowledge**, you can see which articles the AI currently has access to and test specific questions to see which article it would use.
350
+
351
+ ## Syncing after changes
352
+
353
+ The AI index updates within **2 minutes** of publishing, editing, or archiving an article. There is no manual sync required.`, headings: [{ level: 2, text: "How the AI uses articles" }, { level: 2, text: "Writing articles the AI understands well" }, { level: 2, text: "Controlling AI access per article" }, { level: 2, text: "Checking what the AI knows" }, { level: 2, text: "Syncing after changes" }] }, { id: "ai/mcp-servers", url: "https://kerabie.com/docs/ai/mcp-servers", title: "Connecting MCP servers", description: "Give your AI chatbot the tools of any remote MCP server.", section: "ai", body: "An [MCP](https://modelcontextprotocol.io) server publishes a set of tools in a standard way. Connect one and your AI chatbot can use its tools in a conversation, next to the [HTTP tools](/docs/api-reference/ai-tools) you define yourself. Many products already offer an MCP server (order systems, calendars, CRMs), so you can often add actions without writing any API glue.\n\nLooking for the other direction, letting Claude or Cursor manage your Kerabie account? See the [Kerabie MCP server](/docs/sdks/mcp).\n\n## Connect a server\n\n1. Open **AI Tools** in the dashboard and choose **Add MCP server**.\n2. Enter a name, a **description**, and the server's URL (its *Streamable HTTP* endpoint), plus any authentication it needs: a bearer token, or an API key sent in a header you choose.\n3. Kerabie connects, reads the tools the server offers, and lists them. **None are switched on yet.**\n4. Switch on the tools you want the chatbot to use, and use **Test** to run one before the AI does.\n\nWhen the server adds, renames or removes tools, press **Sync**. Tools you had switched on that the server no longer offers are dropped.\n\n## Write a good description\n\nThe description is how the chatbot tells your servers apart. It is shown to the AI with every tool the server offers, so say **what the server is for** and **when to use it**:\n\n> Order and stock lookups for our online shop. Use when a customer asks where their order is or whether an item is available.\n\nIt must be 10 to 300 characters. If you connect several servers, make each description say clearly what the others don't cover, so the AI doesn't pick the wrong one.\n\n## How the chatbot uses them\n\n- The chatbot sees each switched-on tool's name and description, led by your server's name and description, and decides when to call it, exactly as with your own tools.\n- Inputs that are objects or arrays are asked for as JSON and converted back before the call.\n- A tool's text answer is shown to the AI (cut at 8,000 characters). If the tool reports an error or the server is slow to answer (10 seconds), the AI is told the request failed.\n- Every call is made fresh: Kerabie connects, calls the tool and disconnects.\n\n## Limits and safety\n\n- **Plan limit.** Every switched-on MCP tool counts towards your plan's AI tool limit, together with your own tools. If you downgrade, the chatbot keeps using your own tools first, then MCP tools, up to the new limit.\n- **Up to 5 servers** per organisation, and up to 100 tools read from each.\n- **Public addresses only.** Servers on `localhost`, private networks or cloud metadata addresses are rejected, and redirects are not followed.\n- **Secrets are write-only.** Tokens and API-key values are never shown again after you save them. Leave the field blank when editing to keep the stored one.\n- **Trust.** The chatbot will call whatever you switch on, with details a customer gives it. Only connect servers you trust, and switch on the tools you actually want.\n\n## From code\n\n```typescript\nconst { server } = await kerabie.mcpServers.create({\n name: 'Shop tools',\n description: 'Order and stock lookups for our shop. Use when a customer asks about an order or an item.',\n url: 'https://mcp.yourstore.com/mcp',\n authType: 'bearer',\n authConfig: { token: process.env.SHOP_MCP_TOKEN },\n});\n\nawait kerabie.mcpServers.update(server.id, { enabledTools: ['check_stock'] });\nawait kerabie.mcpServers.test(server.id, 'check_stock', { sku: 'A-100' });\n```\n\n| Method | Route | Description |\n|---|---|---|\n| `list()` | `GET /v1/mcp-servers` | Servers, their tools and which are switched on. |\n| `create(params)` | `POST /v1/mcp-servers` | Connect a server. `400` if it can't be reached, `403` if your plan has no AI tools, `409` if already connected. |\n| `sync(id)` | `POST /v1/mcp-servers/:id/sync` | Read the tools again. |\n| `update(id, params)` | `PATCH /v1/mcp-servers/:id` | `name`, `description`, `enabled`, `enabledTools`, `authType`, `authConfig`. `403` if switching tools on would exceed your plan. |\n| `delete(id)` | `DELETE /v1/mcp-servers/:id` | Disconnect and remove. |\n| `test(id, tool, args)` | `POST /v1/mcp-servers/:id/test` | Run one tool once. Limited to 40 a hour (`429`). |", headings: [{ level: 2, text: "Connect a server" }, { level: 2, text: "Write a good description" }, { level: 2, text: "How the chatbot uses them" }, { level: 2, text: "Limits and safety" }, { level: 2, text: "From code" }] }, { id: "ai/overview", url: "https://kerabie.com/docs/ai/overview", title: "AI Features", description: "Let AI handle common questions automatically using your knowledge base.", section: "ai", body: `Kerabie's AI chatbot answers visitor questions instantly using your knowledge base as its source of truth. When it can't answer confidently, it escalates to a human agent.
354
+
355
+ ## How it works
356
+
357
+ 1. A visitor sends a message
358
+ 2. The AI searches your knowledge base for relevant articles
359
+ 3. If confidence is high enough, it replies immediately
360
+ 4. If not, it hands off to an available agent with context on what was asked
361
+
362
+ The AI never makes up answers; it only responds based on content you've added.
363
+
364
+ ## What the AI can do
365
+
366
+ - Answer FAQs instantly, 24/7
367
+ - Handle multiple conversations simultaneously
368
+ - Collect visitor details before escalating (name, email, order number)
369
+ - Suggest reply drafts to agents while they type
370
+ - Summarise long conversations for agents joining mid-thread
371
+
372
+ ## Setup overview
373
+
374
+ <Step title="Add knowledge base articles">
375
+ Go to **Knowledge Base \u2192 Add article** or import from a URL. See [Knowledge Base](/docs/knowledge-base/overview).
376
+ </Step>
377
+ <Step title="Enable AI in widget settings">
378
+ Go to **Settings \u2192 AI \u2192 Enable AI chatbot** and toggle it on for your widget.
379
+ </Step>
380
+ <Step title="Set escalation threshold">
381
+ Choose how confident the AI needs to be before it replies (default: 70%). Below this, it escalates to a human.
382
+ </Step>
383
+ <Step title="Test it">
384
+ Open your widget and ask a question that's covered in your knowledge base. The AI should reply immediately.
385
+ </Step>
386
+
387
+ ## AI usage limits
388
+
389
+ | Plan | AI chats/month |
390
+ |---|---|
391
+ | Free | No |
392
+ | Starter | 200 |
393
+ | Pro | 2,000 |
394
+ | Business | 10,000 |
395
+
396
+ An "AI chat" counts each time the AI sends a reply. Escalations don't count once a human takes over.`, headings: [{ level: 2, text: "How it works" }, { level: 2, text: "What the AI can do" }, { level: 2, text: "Setup overview" }, { level: 2, text: "AI usage limits" }] }, { id: "ai/setup", url: "https://kerabie.com/docs/ai/setup", title: "AI Setup", description: "Enable and configure the AI chatbot for your widget.", section: "ai", body: `Kerabie's AI assistant can answer visitor questions automatically, suggest replies to agents, and escalate to a human when it can't help. It's powered by your knowledge base, so the quality of your articles directly affects AI accuracy.
397
+
398
+ ## Enabling the AI assistant
399
+
400
+ 1. Go to **Settings \u2192 AI Assistant**
401
+ 2. Toggle **Enable AI** on
402
+ 3. Choose a mode:
403
+ - **Auto-reply**: AI responds to visitors automatically before any agent joins
404
+ - **Suggestions only**: AI drafts replies but an agent must approve and send them
405
+ 4. Save
406
+
407
+ ## Connecting the knowledge base
408
+
409
+ The AI reads all **published** articles in your knowledge base to answer questions. No additional configuration is needed; it updates automatically when you publish or edit articles.
410
+
411
+ For best results:
412
+ - Publish articles for your most common questions first
413
+ - Keep articles concise and factual
414
+ - Use plain language: avoid jargon the AI might misinterpret
415
+
416
+ See [AI & Knowledge Base](/docs/ai/knowledge-base) for tips on writing AI-friendly content.
417
+
418
+ ## Configuring the AI persona
419
+
420
+ 1. Go to **Settings \u2192 AI Assistant \u2192 Persona**
421
+ 2. Set a **bot name** (shown to visitors, e.g. "Aria" or "Support Bot")
422
+ 3. Write a **personality prompt**: describe the tone and style (e.g. "Friendly and concise. Always offer to connect with a human if unsure.")
423
+ 4. Save
424
+
425
+ ## Escalation to human
426
+
427
+ The AI automatically offers to connect the visitor to a live agent when:
428
+ - It cannot find a relevant answer in the knowledge base
429
+ - The visitor asks to speak with a human
430
+ - Confidence in the answer is below the threshold you set
431
+
432
+ Set the escalation threshold under **Settings \u2192 AI Assistant \u2192 Escalation**.
433
+
434
+ ## Testing the AI
435
+
436
+ Use the **Preview** button in AI settings to open a test widget and try questions before going live. Responses are generated in real time using your current knowledge base.
437
+
438
+ ## Usage and limits
439
+
440
+ AI responses count against your **AI message quota**, shown in **Settings \u2192 Usage**. Quotas reset monthly. Upgrade your plan to increase the limit.`, headings: [{ level: 2, text: "Enabling the AI assistant" }, { level: 2, text: "Connecting the knowledge base" }, { level: 2, text: "Configuring the AI persona" }, { level: 2, text: "Escalation to human" }, { level: 2, text: "Testing the AI" }, { level: 2, text: "Usage and limits" }] }, { id: "ai/suggestions", url: "https://kerabie.com/docs/ai/suggestions", title: "AI Reply Suggestions", description: "Let AI draft replies as agents type, for faster and more consistent responses.", section: "ai", body: "AI Reply Suggestions surface a ready-to-send response at the top of the agent's reply box whenever a visitor message comes in. The agent can accept it as-is, edit it, or ignore it.\n\n## Enabling suggestions\n\n1. Go to **Settings \u2192 AI Assistant**\n2. Set mode to **Suggestions only** (or **Auto-reply**; suggestions appear in both modes)\n3. Save\n\nSuggestions appear automatically in every conversation once enabled; no per-conversation setup needed.\n\n## How it works for agents\n\nWhen a visitor sends a message, a **suggested reply** card appears above the reply input:\n\n- Click **Use** to copy it into the reply box (you can still edit before sending)\n- Click **Dismiss** to hide the suggestion for that message\n- Ignore it and type your own reply; the suggestion disappears when you start typing\n\n## Suggestion quality\n\nSuggestions are generated from your knowledge base. The more thorough your articles, the better the suggestions. If the AI can't find relevant content, no suggestion is shown and agents won't see a low-confidence guess.\n\n## Tracking suggestion usage\n\nUnder **Analytics \u2192 AI**, see:\n- **Suggestion rate**: what % of messages received a suggestion\n- **Acceptance rate**: what % of suggestions were used (accepted) by agents\n- **Edit rate**: what % of accepted suggestions were edited before sending\n\nHigh edit rates on a particular topic mean the suggestion quality is low there; consider improving the knowledge base articles for that topic.\n\n## Disabling per conversation\n\nAgents can turn off suggestions for a specific conversation by clicking the **AI** toggle in the conversation toolbar. This is useful for sensitive or complex cases where human judgement is needed throughout.", headings: [{ level: 2, text: "Enabling suggestions" }, { level: 2, text: "How it works for agents" }, { level: 2, text: "Suggestion quality" }, { level: 2, text: "Tracking suggestion usage" }, { level: 2, text: "Disabling per conversation" }] }, { id: "ai/voice-agent", url: "https://kerabie.com/docs/ai/voice-agent", title: "AI voice agent", description: "An AI that answers calls from your knowledge base when nobody on your team is free.", section: "ai", body: `When a customer calls and nobody is free to answer, the AI voice agent can pick up. It tells the caller it is an AI, listens, looks answers up in your [Knowledge Base](/docs/ai/knowledge-base) and speaks them back. Customers get an answer instead of a queue.
441
+
442
+ ## Turn it on
443
+
444
+ 1. Go to **Settings \u2192 Calls** and make sure **Allow calls** is on.
445
+ 2. Switch on **AI voice agent**. It is available on plans that include AI call minutes.
446
+ 3. Optionally write **What the AI says first**. Leave it empty to use the default: "Hi, you've reached your organisation name. I'm an AI assistant. How can I help?"
447
+ 4. Optionally paste a **Voice** id to change how it sounds. Leave it empty for the default voice.
448
+
449
+ Your team is always rung first. The AI only answers when no agent is free, or when you have no calls with a person on your plan.
450
+
451
+ ## What it knows
452
+
453
+ - **Your Knowledge Base.** It searches your published articles whose visibility is **Public** or **AI only**. Articles marked **Team** and drafts are never used.
454
+ - **Your AI Tools.** It can use the same AI Tools and MCP servers the chat AI uses, within your plan's tool limit. Before it does anything that creates or changes something, it repeats the details and waits for a clear yes.
455
+ - If your Knowledge Base has nothing on a question, it says it does not have that information. It does not guess or make up prices, dates or policies.
456
+
457
+ Write the names customers say out loud into your article titles. The AI is told to expect the capitalised names in your titles, such as product and company names, so they are heard correctly.
458
+
459
+ ## What it will not do
460
+
461
+ - It cannot transfer a call, take payments, or take a message for your team. A caller who wants a person is asked to type their message in the chat on the page, where your team replies.
462
+ - It speaks English unless the caller clearly speaks another language.
463
+
464
+ ## Minutes
465
+
466
+ Calls the AI answers are counted against your plan's **AI call minutes**, separately from calls with a person, one started minute at a time. When the AI minutes run out, the AI stops answering and calls behave as if the AI voice agent were off.
467
+
468
+ ## Good to know
469
+
470
+ - The AI ends the call when the caller says goodbye or says they need nothing else.
471
+ - Answers take a moment, because the AI first looks things up.
472
+ - If you use several workspaces, each one has its own AI voice agent settings, Knowledge Base and tools.`, headings: [{ level: 2, text: "Turn it on" }, { level: 2, text: "What it knows" }, { level: 2, text: "What it will not do" }, { level: 2, text: "Minutes" }, { level: 2, text: "Good to know" }] }, { id: "analytics/agents", url: "https://kerabie.com/docs/analytics/agents", title: "Agent Performance", description: "See how each agent is performing across response time, volume, and satisfaction.", section: "analytics", body: "The Agent Performance dashboard lets admins compare output and quality across individual agents and teams.\n\n## Accessing agent analytics\n\nGo to **Analytics \u2192 Agents** in the sidebar.\n\n## Metrics per agent\n\n| Metric | Description |\n|---|---|\n| **Conversations handled** | Total conversations the agent replied to in the period |\n| **First response time** | Average time from conversation assignment to first reply |\n| **Resolution rate** | % of their conversations that were resolved |\n| **Avg handle time** | Average duration from first reply to resolution |\n| **CSAT score** | Average customer satisfaction rating received |\n| **Online time** | Hours the agent was set to Available |\n\n## Leaderboard view\n\nSwitch to **Leaderboard** to rank all agents by any metric. Useful for performance reviews or spotting who needs support.\n\n## Team rollup\n\nSelect a **team** from the filter to see aggregated metrics for that team alongside individual agent breakdowns within it.\n\n## Tracking over time\n\nThe **Trend** chart for each agent shows their first response time and CSAT score over the selected period. A rising first response time or falling CSAT score is an early warning sign.\n\n## Using data in 1-on-1s\n\nClick any agent's name to open their detail view showing:\n- Daily conversation volume\n- Response time distribution (fastest / slowest / median)\n- CSAT comments from that period\n- Conversations flagged or escalated\n\nThis view is designed to be used directly in agent performance conversations.\n\n## Date range and filters\n\nAll metrics respect the **date range** selector at the top. You can compare any two periods using the **Compare** toggle.", headings: [{ level: 2, text: "Accessing agent analytics" }, { level: 2, text: "Metrics per agent" }, { level: 2, text: "Leaderboard view" }, { level: 2, text: "Team rollup" }, { level: 2, text: "Tracking over time" }, { level: 2, text: "Using data in 1-on-1s" }, { level: 2, text: "Date range and filters" }] }, { id: "analytics/conversations", url: "https://kerabie.com/docs/analytics/conversations", title: "Conversation Analytics", description: "Track volume, response times, and resolution rates.", section: "analytics", body: "The Conversations analytics dashboard gives you a high-level view of how your support operation is performing over any time range.\n\n## Accessing conversation analytics\n\nGo to **Analytics \u2192 Conversations** in the sidebar.\n\n## Metrics explained\n\n### Volume\n\n| Metric | Description |\n|---|---|\n| **Total conversations** | All conversations started in the period |\n| **New conversations** | Conversations with no prior history from that visitor |\n| **Reopened** | Resolved conversations that received a new message |\n\n### Response time\n\n| Metric | Description |\n|---|---|\n| **First response time** | Time from visitor's first message to agent's first reply |\n| **Average handle time** | Total time a conversation was open (first message to resolution) |\n| **Median response time** | Middle value: less skewed by outliers than average |\n\n### Resolution\n\n| Metric | Description |\n|---|---|\n| **Resolution rate** | % of conversations marked resolved |\n| **Resolved this period** | Count of conversations resolved |\n| **Unresolved** | Still open at end of period |\n\n## Charts\n\nThe **Volume over time** chart shows daily conversation counts. Toggle between:\n- Total conversations\n- Resolved conversations\n- Unresolved conversations\n\nThe **Response time trend** chart shows how first response time has changed day by day, useful for spotting regressions after team changes.\n\n## Filtering\n\nFilter all metrics by:\n- **Date range**: today, last 7/30/90 days, or custom\n- **Team**: see metrics for a specific team only\n- **Agent**: drill down to an individual's numbers\n- **Channel**: widget, API, or specific integration\n\n## Exporting\n\nClick **Export \u2192 CSV** to download a row-per-conversation report. See [History & Export](/docs/conversations/history-export) for the full field reference.", headings: [{ level: 2, text: "Accessing conversation analytics" }, { level: 2, text: "Metrics explained" }, { level: 3, text: "Volume" }, { level: 3, text: "Response time" }, { level: 3, text: "Resolution" }, { level: 2, text: "Charts" }, { level: 2, text: "Filtering" }, { level: 2, text: "Exporting" }] }, { id: "analytics/partner-program", url: "https://kerabie.com/docs/analytics/partner-program", title: "Partner Program", description: "Earn commission by referring businesses to Kerabie.", section: "analytics", body: "The Kerabie Partner Program lets bloggers, agencies, and influencers earn commission by referring new businesses to Kerabie.\n\n## Commission structure\n\n- **20%** recurring commission for every paying customer you refer\n- Commission is paid monthly after the referred customer's subscription is confirmed\n- Minimum payout: \u20A65,000\n- Payouts via bank transfer (Nigeria) or wire transfer (international)\n\n## Getting started\n\n1. Go to **Partner Dashboard** in your Kerabie account (or via the sidebar)\n2. Click **Apply to Partner Program**\n3. Enter your brand name, website, and how you plan to promote Kerabie\n4. Choose a custom referral code (or auto-generate one)\n5. Submit for review: approval takes 1\u20132 business days\n\n## Sharing your link\n\nOnce approved, your referral link is:\n```\nhttps://kerabie.com/?ref=YOURCODE\n```\n\nShare it in:\n- Blog posts and reviews\n- YouTube video descriptions\n- Email newsletters\n- Social media bios\n\nAnyone who clicks your link and signs up is tagged to your account. If they subscribe to a paid plan, you earn commission.\n\n## Partner dashboard\n\nYour dashboard shows:\n- **Total clicks**: how many people clicked your referral link\n- **Signups**: how many signed up using your link\n- **Conversion rate**: signups \xF7 clicks\n- **Earnings**: per-referral breakdown (pending, approved, paid)\n- **Payouts**: your withdrawal history\n\n## Requesting a payout\n\n1. Go to **Partner Dashboard \u2192 Payout**\n2. Add your bank details (one-time setup)\n3. Enter the amount to withdraw\n4. Submit: processed within 2\u20133 business days\n\n## API access\n\nSee the [Partner API reference](/docs/api-reference/growth) to programmatically access your referral stats, earnings, and payout history.", headings: [{ level: 2, text: "Commission structure" }, { level: 2, text: "Getting started" }, { level: 2, text: "Sharing your link" }, { level: 2, text: "Partner dashboard" }, { level: 2, text: "Requesting a payout" }, { level: 2, text: "API access" }] }, { id: "analytics/satisfaction", url: "https://kerabie.com/docs/analytics/satisfaction", title: "Satisfaction Scores", description: "Collect and analyse CSAT ratings from visitors after conversations.", section: "analytics", body: 'Kerabie sends an automatic satisfaction survey after a conversation is resolved. The results feed into the **Satisfaction** analytics dashboard.\n\n## Enabling CSAT surveys\n\n1. Go to **Settings \u2192 Widget Settings \u2192 Satisfaction**\n2. Toggle **Send CSAT survey after resolution** on\n3. Customise the survey message (optional)\n4. Save\n\nThe survey appears in the widget a few minutes after an agent resolves the conversation.\n\n## Survey format\n\nThe default survey asks:\n\n> "How would you rate your support experience?"\n> \u2B50 \u2B50 \u2B50 \u2B50 \u2B50 (1\u20135 stars)\n\nAn optional follow-up text field collects written comments. Both are shown in the satisfaction dashboard.\n\n## Metrics\n\n### Overall CSAT\n\n| Metric | Description |\n|---|---|\n| **CSAT score** | Average rating across all responses in the period (out of 5) |\n| **Response rate** | % of resolved conversations where the visitor submitted a rating |\n| **Total responses** | Raw count of ratings received |\n\n### Distribution\n\nThe **Rating breakdown** chart shows the count of 1-star through 5-star ratings. A large number of 1\u20132 star ratings with comments is the most actionable signal.\n\n## Per-agent CSAT\n\nRatings are linked to the agent who resolved the conversation, so you can compare CSAT scores per agent in **Analytics \u2192 Agents**.\n\n## Comments\n\nAll written comments are listed in the **Comments** tab, newest first. Filter by:\n- Rating (e.g. only 1-star comments)\n- Agent\n- Date range\n\nUse low-rating comments directly in agent coaching sessions.\n\n## Improving your score\n\nCommon causes of low CSAT:\n- Slow first response time: address with automations and staffing\n- Unresolved issues: check resolution rate alongside CSAT\n- Tone: review low-rated transcripts to spot patterns in how agents communicate', headings: [{ level: 2, text: "Enabling CSAT surveys" }, { level: 2, text: "Survey format" }, { level: 2, text: "Metrics" }, { level: 3, text: "Overall CSAT" }, { level: 3, text: "Distribution" }, { level: 2, text: "Per-agent CSAT" }, { level: 2, text: "Comments" }, { level: 2, text: "Improving your score" }] }, { id: "api-reference/agents", url: "https://kerabie.com/docs/api-reference/agents", title: "Agents", description: "List and retrieve agents in your organisation.", section: "api-reference", body: '## List agents\n\n```http\nGET /agents\n```\n\nReturns all agents who have access to your organisation.\n\n**Response**\n\n```json\n{\n "data": [\n {\n "id": 7,\n "name": "Sarah",\n "email": "sarah@yourcompany.com",\n "availability": "online",\n "role": "agent",\n "avatar": "https://assets.kerabie.com/avatars/7.png"\n }\n ]\n}\n```\n\n---\n\n## Get an agent\n\n```http\nGET /agents/:id\n```\n\n---\n\n## Agent availability\n\nAgents set their own availability from the dashboard or mobile app. The `availability` field is one of:\n\n| Value | Meaning |\n|---|---|\n| `online` | Available to receive conversations |\n| `away` | Temporarily unavailable |\n| `busy` | In a conversation, prefer not to be assigned |\n| `offline` | Not logged in or signed out |', headings: [{ level: 2, text: "List agents" }, { level: 2, text: "Get an agent" }, { level: 2, text: "Agent availability" }] }, { id: "api-reference/ai-tools", url: "https://kerabie.com/docs/api-reference/ai-tools", title: "AI Tools API", description: "Let your AI chatbot call your own APIs, and manage those tools from code.", section: "api-reference", body: 'These endpoints use your [API key](/docs/api-reference/authentication) and manage the same tools as the **AI Tools** page in the dashboard.\n\n```typescript\n\nconst kerabie = new KerAbieClient({ apiKey: process.env.KERABIE_API_KEY });\n```\n\nA **tool** is an API of yours that the chatbot may call mid-conversation: check stock, validate an address, look up an order, book a slot. The AI reads the tool\'s **description** to decide when to call it, collects the **parameters** from the customer, calls your endpoint, and uses the answer in its reply.\n\nThree built-in tools are always available and need no setup: `save_memory`, `get_memory` and `escalate_to_agent`.\n\n## How a call works\n\n| Method | What is sent |\n|---|---|\n| `GET` | The parameters in the query string. |\n| `POST`, `PUT`, `PATCH` | The parameters as a JSON body. |\n\nYour endpoint has 8 seconds to answer. If you set a `responsePath` such as `data.message`, only that part of a JSON reply is shown to the AI; otherwise it sees the whole reply (cut at 8,000 characters). A non-2xx answer, a timeout or a network error is reported to the AI as a failure, and it will say it couldn\'t complete the request.\n\n## Rules\n\n- **Public addresses only.** Endpoints on `localhost`, private networks or cloud metadata addresses are rejected, and redirects are not followed. Use the final URL.\n- **Secrets are write-only.** `authConfig` values are stored when you send them and never returned. On `update`, leave a secret out (or blank) to keep the stored one.\n- **Plan limit.** Your plan sets how many tools you can have. `stats.limit` is `-1` when AI tools aren\'t on your plan and `0` when unlimited. `create` and `duplicate` return **403** when it is full. If you downgrade, the chatbot keeps using your oldest enabled tools up to the new limit.\n- **Function names** are snake_case and unique in your organisation (**409** if taken).\n- Up to **20 parameters** and **10 custom headers** per tool.\n\n---\n\n## List tools\n\n```http\nGET /v1/ai-tools\n```\n\n```typescript\nconst { tools, stats } = await kerabie.aiTools.list();\n```\n\n```json\n{\n "tools": [\n {\n "id": 1,\n "name": "check_availability",\n "displayName": "Check availability",\n "description": "Use this when a customer asks if a specific item is in stock.",\n "parameters": [\n { "name": "sku", "type": "string", "description": "Product SKU the customer mentioned", "required": true }\n ],\n "endpoint": "https://api.yourstore.com/inventory/check",\n "method": "GET",\n "authType": "bearer",\n "responsePath": "data.message",\n "enabled": true,\n "createdAt": "2026-10-01T09:00:00.000Z",\n "updatedAt": "2026-10-01T09:00:00.000Z",\n "usage": { "calls": 142, "failures": 6, "lastUsedAt": "2026-10-06T12:30:00.000Z" }\n }\n ],\n "stats": { "total": 1, "active": 1, "calls": 142, "successRate": 96, "limit": 5 }\n}\n```\n\n`usage` and `stats.calls` cover the last 30 days. `successRate` is `null` when there were no calls.\n\n## Get a tool\n\n```http\nGET /v1/ai-tools/:id\n```\n\nReturns the tool with `authConfig` (names only, secrets come back empty), `headers` and `hasSecret`.\n\n## Create a tool\n\n```http\nPOST /v1/ai-tools\n```\n\n```typescript\nconst { tool } = await kerabie.aiTools.create({\n name: \'check_availability\',\n displayName: \'Check availability\',\n description: \'Use this when a customer asks if a specific item is in stock.\',\n endpoint: \'https://api.yourstore.com/inventory/check\',\n method: \'GET\',\n authType: \'bearer\',\n authConfig: { token: process.env.STORE_API_TOKEN },\n responsePath: \'data.message\',\n parameters: [{ name: \'sku\', type: \'string\', description: \'Product SKU the customer mentioned\' }],\n});\n```\n\n| Field | Description |\n|---|---|\n| `name` | Required. snake_case, 2-60 characters. What the AI calls. |\n| `displayName` | Required. 2-80 characters. |\n| `description` | Required. 10-500 characters. Say **when** the AI should use the tool ("Use this when a customer asks\u2026"). |\n| `endpoint` | Required. A public `https://` (or `http://`) address. |\n| `method` | `GET`, `POST` (default), `PUT` or `PATCH`. |\n| `parameters` | List of `{ name, type, description?, required? }`. `type` is `string`, `number` or `boolean`; `required` defaults to `true`. |\n| `authType` | `none` (default), `bearer`, `api_key` or `basic`. |\n| `authConfig` | `bearer`: `{ token }` \xB7 `api_key`: `{ header, value }` \xB7 `basic`: `{ username, password }`. |\n| `headers` | Extra static headers sent on every call. |\n| `responsePath` | Dot-notation path into the JSON reply, e.g. `data.message`. |\n| `enabled` | Default `true`. |\n\nReturns `{ "tool": { "id": 12 } }`.\n\n## Update a tool\n\n```http\nPATCH /v1/ai-tools/:id\n```\n\n```typescript\nawait kerabie.aiTools.update(12, { enabled: false });\n```\n\nSend only what you want to change. Changing `authType` needs the new secrets in `authConfig`.\n\n## Duplicate a tool\n\n```http\nPOST /v1/ai-tools/:id/duplicate\n```\n\nCreates a copy named `<name>_copy` that starts **switched off**, so you can edit it before the AI can use it.\n\n## Delete a tool\n\n```http\nDELETE /v1/ai-tools/:id\n```\n\n## Test a tool\n\n```http\nPOST /v1/ai-tools/:id/test\n```\n\n```typescript\nconst result = await kerabie.aiTools.test(12, { sku: \'A-100\' });\n// { success: true, result: \'In stock: 14 units\', statusCode: 200, durationMs: 184 }\n```\n\nCalls your real endpoint once with `args`. It is not counted in `usage`, and is limited to 40 tests per hour per organisation (**429**).\n\n## Errors\n\n| Status | When |\n|---|---|\n| `400` | Invalid input, or an endpoint on a private or local address. |\n| `403` | AI tools aren\'t on your plan, or your plan\'s tool limit is reached. |\n| `404` | No such tool in your organisation. |\n| `409` | A tool with that function name already exists. |\n| `429` | Test limit reached. |', headings: [{ level: 2, text: "How a call works" }, { level: 2, text: "Rules" }, { level: 2, text: "List tools" }, { level: 2, text: "Get a tool" }, { level: 2, text: "Create a tool" }, { level: 2, text: "Update a tool" }, { level: 2, text: "Duplicate a tool" }, { level: 2, text: "Delete a tool" }, { level: 2, text: "Test a tool" }, { level: 2, text: "Errors" }] }, { id: "api-reference/analytics", url: "https://kerabie.com/docs/api-reference/analytics", title: "Analytics API", description: "Conversation, AI, ticket and team numbers, the same data that powers your dashboard.", section: "api-reference", body: 'All analytics endpoints use your [API key](/docs/api-reference/authentication) and return the same data, from the same cache, as the Kerabie dashboard. Results are cached briefly (summary and agents: 5 minutes; tickets and setup: 1 minute), so a number can lag live activity slightly.\n\n```typescript\n\nconst kerabie = new KerAbieClient({ apiKey: process.env.KERABIE_API_KEY });\n```\n\n## Summary\n\n```http\nGET /v1/analytics/summary\n```\n\n| Query param | Description |\n|---|---|\n| `days` | Window length in days. Default `30`, maximum `365`. |\n| `period` | Alternative to `days`, e.g. `7d` or `30d`. Wins if both are sent. |\n\n```typescript\nconst summary = await kerabie.analytics.getSummary({ days: 7 });\n```\n\n**Response**\n\n```json\n{\n "period": { "days": 7, "since": "2026-09-28T00:00:00.000Z" },\n "openConversations": 14,\n "resolvedToday": 9,\n "avgResponseTime": 144,\n "satisfactionScore": 96,\n "activeVisitors": 23,\n "totals": { "conversations": 186, "resolved": 120, "closed": 20, "messages": 900, "visitors": 40, "aiChats": 310, "tickets": 22, "contacts": 35, "siteVisitors": 1290 },\n "rates": { "resolution": 75 },\n "satisfaction": { "avgRating": 4.8, "totalRatings": 30 },\n "deltas": { "conversations": 12, "resolved": 9, "responseTime": -8, "satisfaction": 4, "aiChats": 31 },\n "ai": { "resolvedAlone": 130, "resolvedAlonePct": 70, "handedOffPct": 18, "agentHoursSaved": 41 },\n "channels": [{ "channel": "WHATSAPP", "count": 54 }, { "channel": "WEBSITE", "count": 38 }],\n "aiEscalations": [{ "reason": "kb_gap", "count": 33 }],\n "trend": [\n { "date": "2026-10-04", "count": 28, "ai": 17, "human": 11 },\n { "date": "2026-10-05", "count": 24, "ai": 15, "human": 9 }\n ]\n}\n```\n\n**Field notes**\n\n- `avgResponseTime` is the average first-response time in **seconds** (`0` when there is no data yet).\n- `satisfactionScore` is the average rating \xD7 20, so `0\u2013100`.\n- `deltas` are the **percentage change versus the previous period of the same length**. A value is `null` when there is nothing to compare against. For `responseTime`, a **negative** number is an improvement.\n- `ai.resolvedAlone` counts conversations the AI handled without a human ever replying. `ai.handedOffPct` is the share handed off to a person.\n- `ai.agentHoursSaved` is an **estimate**: AI-only conversations \xD7 the average time your agents spent on the human-handled ones in the same window (each clamped to 1\u2013120 minutes; 8 minutes is assumed when there are no human-handled conversations yet).\n- `trend` has one entry per day, oldest first, capped at the last 30 days, with empty days included. `ai + human = count`.\n\n---\n\n## Insights\n\n```http\nGET /v1/analytics/insights\n```\n\nTakes the same `days` / `period` parameters as the summary. This is what powers the **Agents**, **Channels**, **AI and questions**, **Customers** and **Pages** tabs of the dashboard\'s Analytics page.\n\n```typescript\nconst insights = await kerabie.analytics.getInsights({ days: 30 });\n```\n\n```json\n{\n "period": { "days": 30, "since": "2026-09-06T00:00:00.000Z" },\n "firstResponse": { "medianSeconds": 144, "deltaPct": -8, "sample": 240 },\n "agents": [\n { "agentId": 1, "name": "Amara Osei", "availability": "online", "messages": 900, "assigned": 142, "resolved": 134, "resolutionRate": 94, "avgRating": 4.8, "firstResponseSeconds": 126 }\n ],\n "channels": [\n { "channel": "WHATSAPP", "count": 108, "pct": 38, "firstResponseSeconds": 132, "avgRating": 4.7 }\n ],\n "ai": {\n "csatOnAiChats": { "avg": 4.4, "count": 61 },\n "questions": [\n { "question": "Where is my order?", "asked": 128, "aiResolvedPct": 74, "avgRating": 4.1, "page": "/orders", "trendPct": 6 }\n ],\n "unanswered": [{ "question": "Do you support SSO with Okta?", "count": 23 }],\n "outcomes": { "resolvedByAi": 198, "handedOff": 51, "other": 35 }\n },\n "customers": {\n "active": 1230, "new": 412, "returning": 818, "perCustomer": 2.3,\n "deltas": { "active": 23, "new": 14, "returning": 27 },\n "csat": [{ "stars": 5, "count": 111, "pct": 68 }, { "stars": 4, "count": 33, "pct": 20 }],\n "csatResponses": 163,\n "countries": [{ "country": "NG", "count": 412 }],\n "newPct": 34, "returningPct": 66,\n "top": [{ "id": 1, "name": "Chidi Okafor", "company": "Palmex", "conversations": 14, "lastSeenAt": "2026-10-06T10:30:00.000Z", "avgRating": 4.2 }],\n "heatmap": { "days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "cells": [[0, 1, 0, 2, 5, 9, 14, 12, 8, 4, 2, 0]], "timezone": "Africa/Lagos" }\n },\n "pages": [\n { "page": "/pricing", "views": 4210, "questions": 412, "askRate": 9.8, "topQuestion": "Can I get a refund after 30 days?" }\n ]\n}\n```\n\n**Field notes**\n\n- `firstResponse.medianSeconds` is the **median** time from a customer\'s first message to a person\'s first reply (AI replies don\'t count). `deltaPct` compares with the previous period; negative is faster.\n- Questions are grouped by their **first customer message**, ignoring case and punctuation. Greetings ("hi", "thanks") are left out. Only the top 20 are returned, most asked first.\n- `aiResolvedPct` is the share of those conversations where no person ever replied and the AI didn\'t hand off. `avgRating` is the customers\' own 1\u20135 rating of those conversations.\n- `unanswered` are the questions behind AI handoffs with the reason *no matching knowledge base article*: good candidates for new articles.\n- `heatmap.cells` has one row per weekday (Monday first) and 12 columns, each a two-hour block starting at 0, 2, \u2026 22, counted in the organisation\'s own timezone.\n- `pages`: `views` are the widget page views we recorded (kept for 90 days) and `questions` the conversations that started on that page. `askRate` = questions \xF7 views, and is `null` while a page has no recorded views. A conversation\'s page is the one the visitor was on when they wrote in; conversations before this was tracked have none.\n- Computed from up to the **20,000 most recent conversations** in the window and cached for up to 5 minutes.\n\n---\n\n## Agent performance\n\n```http\nGET /v1/analytics/agents\n```\n\nAccepts the same `days` / `period` parameters as the summary.\n\n```typescript\nconst { agents } = await kerabie.analytics.getAgentStats({ period: \'7d\' });\n```\n\n```json\n{\n "agents": [\n {\n "agentId": 7,\n "name": "Amara Osei",\n "avatar": "https://cdn.kerabie.com/avatars/7.jpg",\n "availability": "online",\n "messages": 200,\n "assigned": 12,\n "resolved": 10,\n "resolutionRate": 83,\n "avgRating": 4.9\n }\n ]\n}\n```\n\n`name` falls back to the part of the email before the `@` for agents who haven\'t set a name. Agents are sorted by `resolved`, highest first.\n\n---\n\n## Open tickets and SLA\n\n```http\nGET /v1/analytics/tickets-sla\n```\n\n```typescript\nconst sla = await kerabie.analytics.getTicketSla();\n```\n\n```json\n{\n "total": 32,\n "withinSlaPct": 94,\n "byPriority": [\n { "priority": "URGENT", "count": 3, "oldestMinutes": 14 },\n { "priority": "HIGH", "count": 8, "oldestMinutes": 60 },\n { "priority": "NORMAL", "count": 21, "oldestMinutes": 180 },\n { "priority": "LOW", "count": 0, "oldestMinutes": null }\n ]\n}\n```\n\nOnly open tickets (new or active) are counted. `withinSlaPct` is the share that has **not** passed its SLA due time, and is `100` when there are no open tickets. `oldestMinutes` is the age of the oldest ticket at that priority, or `null` if there are none.\n\n---\n\n## Setup status\n\n```http\nGET /v1/analytics/setup-status\n```\n\n```typescript\nconst status = await kerabie.analytics.getSetupStatus();\n// { channel: true, ai: false, automation: false }\n```\n\n| Field | `true` when |\n|---|---|\n| `channel` | WhatsApp or Instagram is connected and active, or an email mailbox is connected. |\n| `ai` | At least one published knowledge-base item exists. |\n| `automation` | At least one automation exists. |', headings: [{ level: 2, text: "Summary" }, { level: 2, text: "Insights" }, { level: 2, text: "Agent performance" }, { level: 2, text: "Open tickets and SLA" }, { level: 2, text: "Setup status" }] }, { id: "api-reference/authentication", url: "https://kerabie.com/docs/api-reference/authentication", title: "Authentication", description: "How to authenticate requests to the Kerabie API.", section: "api-reference", body: "## API Keys\n\nAPI keys are the primary way to authenticate with the Kerabie API. They're scoped per organisation and never expire unless you revoke them.\n\n### Create a key\n\n1. Go to **Settings \u2192 API \u2192 New API Key**\n2. Give it a name (e.g. \"CRM integration\")\n3. Copy the key; it's shown only once\n\n### Use it in requests\n\n```http\nAuthorization: Bearer ker_live_abc123...\nX-Kerabie-Org: 42\nContent-Type: application/json\n```\n\nOr with the SDK:\n\n```typescript\nconst kerabie = new KerAbieClient({\n apiKey: 'ker_live_abc123...',\n orgId: 42,\n});\n```\n\n## Key prefixes\n\n| Prefix | Environment |\n|---|---|\n| `ker_live_` | Production |\n| `ker_test_` | Test (sandbox data, no real conversations) |\n\n## Revoking keys\n\nGo to **Settings \u2192 API** and click the trash icon next to any key. Revocation is immediate.\n\n## Keeping keys secure\n\n- Store keys in environment variables, never in source code\n- Use separate keys per integration so you can revoke one without affecting others\n- Rotate keys periodically for sensitive integrations\n\n Never commit API keys to version control. If a key is exposed, revoke it immediately and create a new one.", headings: [{ level: 2, text: "API Keys" }, { level: 3, text: "Create a key" }, { level: 3, text: "Use it in requests" }, { level: 2, text: "Key prefixes" }, { level: 2, text: "Revoking keys" }, { level: 2, text: "Keeping keys secure" }] }, { id: "api-reference/campaigns", url: "https://kerabie.com/docs/api-reference/campaigns", title: "Campaigns API", description: "Create, launch and measure proactive messages on your chat widget.", section: "api-reference", body: 'These endpoints use your [API key](/docs/api-reference/authentication) and manage the same campaigns as the **Campaigns** page in the dashboard.\n\n```typescript\n\nconst kerabie = new KerAbieClient({ apiKey: process.env.KERABIE_API_KEY });\n```\n\nA **campaign** is a short message (with an optional button) that pops up next to your chat bubble when a visitor meets its rules: a trigger, the pages it applies to, who it is for, and how often.\n\n## Status\n\n| Status | Meaning |\n|---|---|\n| `draft` | Saved but never launched. Visitors don\'t see it. |\n| `active` | Live on your widget. |\n| `paused` | Launched before, switched off now. |\n\nDrafts and paused campaigns are unlimited. Your plan limits how many can be **active** at once: `GET /v1/campaigns/stats` returns it as `limit` (`0` means unlimited).\n\n---\n\n## List campaigns\n\n```http\nGET /v1/campaigns\n```\n\n```typescript\nconst { campaigns, meta } = await kerabie.campaigns.list({ status: \'active\', limit: 20 });\n```\n\n| Query param | Description |\n|---|---|\n| `status` | `active`, `paused` or `draft`. |\n| `search` | Case-insensitive match on name or message. |\n| `page`, `limit` | Pagination. `limit` defaults to 20, maximum 100. |\n\n```json\n{\n "campaigns": [\n {\n "id": 1,\n "name": "Pricing page nudge",\n "message": "Questions about plans? I can compare them for you in two minutes.",\n "ctaLabel": "Compare plans",\n "ctaAction": "open_chat",\n "ctaUrl": null,\n "trigger": "time_on_page",\n "triggerValue": "20",\n "pages": "/pricing",\n "audience": "all",\n "audienceCountry": null,\n "frequency": "once_per_visitor",\n "isActive": true,\n "status": "active",\n "launchedAt": "2026-10-01T09:00:00.000Z",\n "sentCount": 1240,\n "openedCount": 612,\n "clickCount": 188,\n "repliedCount": 74,\n "createdAt": "2026-09-30T14:20:00.000Z"\n }\n ],\n "meta": { "total": 1, "page": 1, "limit": 20, "pages": 1 }\n}\n```\n\nThe four counters are the campaign\'s funnel: **shown** (`sentCount`), **opened** the chat from it (`openedCount`), **pressed its button** (`clickCount`) and **wrote back** (`repliedCount`).\n\n---\n\n## Overview numbers\n\n```http\nGET /v1/campaigns/stats\n```\n\n```json\n{ "total": 4, "active": 2, "paused": 1, "draft": 1, "sent": 2650, "opened": 1323, "clicked": 426, "replied": 153, "limit": 5 }\n```\n\nCached for up to a minute; changes to campaigns show immediately.\n\n---\n\n## Get a campaign\n\n```http\nGET /v1/campaigns/:id\n```\n\n`404` if it isn\'t in your organisation.\n\n---\n\n## Create a campaign\n\n```http\nPOST /v1/campaigns\n```\n\n```typescript\nconst { campaign } = await kerabie.campaigns.create({\n name: \'Checkout rescue\',\n message: \'Need a hand finishing your order? We are online right now.\',\n ctaLabel: \'Chat with us\',\n trigger: \'exit_intent\',\n pages: \'/checkout\',\n audience: \'returning\',\n frequency: \'once_per_day\',\n isActive: true,\n});\n```\n\n| Body field | Notes |\n|---|---|\n| `name` | **Required.** Up to 80 characters. |\n| `message` | **Required.** Up to 500 characters. |\n| `imageUrl` | Read-only cover image shown full-width on top of the popup (set it in the dashboard or the app). Without one, visitors see your logo or initial. |\n| `ctaLabel` | Button text, up to 30 characters. Leave empty for no button. |\n| `ctaAction` | `open_chat` (default), `open_kb` (opens the Help screen) or `open_link`. |\n| `ctaUrl` | Required for `open_link`: a full `http://` or `https://` link. |\n| `trigger` | `page_load` (default), `time_on_page`, `scroll_depth` or `exit_intent`. Exit intent works with a mouse, so it never fires on phones. |\n| `triggerValue` | Whole number. Seconds for `time_on_page` (1\u2013600, default 10); percent for `scroll_depth` (1\u2013100, default 50). Ignored for the other triggers. |\n| `pages` | Comma-separated URL patterns, `*` matches anything: `/pricing`, `/docs*`, `https://shop.com/checkout`. Up to 20. Empty = every page. |\n| `audience` | `all` (default), `new` (first visit), `returning`, `not_chatted` (hasn\'t started a conversation), or `country` (needs `audienceCountry`, e.g. `NG`; matched from the visitor\'s IP address, so visitors whose location can\'t be determined don\'t see it). |\n| `audienceCountry` | Two-letter country code. Required when `audience` is `country`, ignored otherwise. |\n| `frequency` | `once_per_visitor` (default), `once_per_day`, or `every_session` (once per page load). |\n| `isActive` | `true` launches it now. Default: saved as a draft. |\n\n| Status | When |\n|---|---|\n| `400` | A field is missing or invalid; the message says which. |\n| `403` | Launching would exceed your plan\'s live-campaign limit. |\n\n---\n\n## Update a campaign\n\n```http\nPATCH /v1/campaigns/:id\n```\n\nSend only what you want to change. `isActive: true` launches or resumes it (same `403` rule), `isActive: false` pauses it. Pausing is always allowed. Changing `trigger` resets `triggerValue` unless you send a new one.\n\n---\n\n## Duplicate a campaign\n\n```http\nPOST /v1/campaigns/:id/duplicate\n```\n\nReturns a copy named \u201C\u2026 (copy)\u201D saved as a draft, with its results reset to zero.\n\n---\n\n## Delete a campaign\n\n```http\nDELETE /v1/campaigns/:id\n```\n\nThe campaign stops showing and its results are removed.\n\n---\n\n## How visitors see it\n\nThe chat widget loads your live campaigns once per page view and checks each one\'s rules in the visitor\'s browser:\n\n1. The **page** matches one of `pages`.\n2. The **audience** fits: a visitor who was on your site before counts as returning.\n3. The **frequency** allows it, based on when this visitor last saw that campaign.\n4. The **trigger** fires: after N seconds, at N% scroll, when the mouse leaves through the top of the window, or shortly after the page loads.\n\nThe widget reports each step (shown, opened, clicked, replied) to your results. Each event is counted at most once per appearance, and the endpoint is rate limited per IP so counts can\'t be inflated.', headings: [{ level: 2, text: "Status" }, { level: 2, text: "List campaigns" }, { level: 2, text: "Overview numbers" }, { level: 2, text: "Get a campaign" }, { level: 2, text: "Create a campaign" }, { level: 2, text: "Update a campaign" }, { level: 2, text: "Duplicate a campaign" }, { level: 2, text: "Delete a campaign" }, { level: 2, text: "How visitors see it" }] }, { id: "api-reference/contacts", url: "https://kerabie.com/docs/api-reference/contacts", title: "Contacts API", description: "List, add, update and import the people you talk to.", section: "api-reference", body: 'These endpoints use your [API key](/docs/api-reference/authentication) and return the same data as the **Contacts** page in the dashboard.\n\n```typescript\n\nconst kerabie = new KerAbieClient({ apiKey: process.env.KERABIE_API_KEY });\n```\n\nA **contact** is anyone with at least a name, email or phone number. Website visitors who only browsed are not contacts until they message you or you save them.\n\n## List contacts\n\n```http\nGET /v1/contacts\n```\n\n```typescript\nconst { contacts, meta } = await kerabie.contacts.list({ type: \'VIP\', sort: \'recent\' });\n```\n\n| Query param | Description |\n|---|---|\n| `search` | Case-insensitive match on name, email, phone, company or a tag name. |\n| `type` | `LEAD`, `CUSTOMER` or `VIP`. |\n| `tagId` | Only contacts with this tag. |\n| `ownerId` | Only contacts owned by this team member. |\n| `sort` | `recent` (default: last contact first, contacts who have never messaged last), `newest` or `name`. |\n| `page`, `limit` | Pagination. `limit` defaults to 20, maximum 100. |\n\n```json\n{\n "contacts": [\n {\n "id": 24,\n "name": "Chidi Okafor",\n "email": "chidi@palmex.com",\n "phone": "+234 803 555 0142",\n "company": "Palmex",\n "notes": "Prefers WhatsApp.",\n "contactType": "VIP",\n "ownerId": 7,\n "owner": { "id": 7, "user": { "fname": "Amara", "lname": "Osei", "email": "amara@kerabie.com", "avatar": null } },\n "tagLinks": [{ "tag": { "id": 3, "name": "Enterprise", "color": null } }],\n "visitorCountry": "NG",\n "externalSource": null,\n "lastContactAt": "2026-10-05T10:52:00.000Z",\n "createdAt": "2026-09-28T09:10:00.000Z",\n "_count": { "conversations": 3 }\n }\n ],\n "meta": { "total": 9, "page": 1, "limit": 20, "pages": 1 }\n}\n```\n\n`lastContactAt` is the last message in either direction (replies from the AI assistant don\'t count). For contacts created before this was tracked it falls back to their latest conversation.\n\n---\n\n## Overview numbers\n\n```http\nGET /v1/contacts/stats\n```\n\n```json\n{ "total": 9, "newThisWeek": 2, "leads": 2, "vip": 2 }\n```\n\n`newThisWeek` counts contacts added in the last 7 days. Results are cached for up to a minute and refresh immediately when contacts change.\n\n---\n\n## Get a contact\n\n```http\nGET /v1/contacts/:id\n```\n\nReturns the contact with its last 10 conversations. `404` if it isn\'t in your organisation.\n\n---\n\n## Add a contact\n\n```http\nPOST /v1/contacts\n```\n\n```typescript\nconst { contact } = await kerabie.contacts.create({ name: \'Chidi Okafor\', email: \'chidi@palmex.com\', contactType: \'CUSTOMER\' });\n```\n\n| Body field | Notes |\n|---|---|\n| `name`, `email`, `phone` | **At least one is required.** |\n| `company`, `notes` | Optional. Notes up to 5,000 characters. |\n| `contactType` | `LEAD` (default), `CUSTOMER` or `VIP`. |\n| `ownerId` | A team-member id in your organisation, or `null`. |\n\n| Status | When |\n|---|---|\n| `400` | Nothing identifying was given, the email is malformed, the type is unknown, or the owner isn\'t on your team. |\n| `409` | A contact with this email (any casing) already exists. |\n\n---\n\n## Update a contact\n\n```http\nPATCH /v1/contacts/:id\n```\n\nSend only the fields you want to change. A blank text field clears it, and `ownerId: null` removes the owner. The same validation and `409` rule as adding applies: you can\'t give a contact an email another contact already has.\n\n---\n\n## Import contacts\n\n```http\nPOST /v1/contacts/import\n```\n\n```typescript\nconst result = await kerabie.contacts.import([\n { name: \'Sam Adeyemi\', email: \'sam@tenda.ng\', company: \'Tenda\', type: \'lead\' },\n { email: \'not-an-email\' },\n]);\n```\n\n```json\n{ "created": 1, "duplicates": 0, "invalid": [{ "row": 2, "error": "\\"not-an-email\\" is not a valid email" }], "total": 2 }\n```\n\n- Up to **1,000 rows** per call.\n- Each row needs a name or an email. `type` is `lead`, `customer` or `vip` (any case) and defaults to lead.\n- Rows whose email already belongs to a contact are skipped and counted in `duplicates`. The same email twice in one request is reported as invalid on the second row.\n- One bad row never fails the others; invalid rows are listed with their number (starting at 1).', headings: [{ level: 2, text: "List contacts" }, { level: 2, text: "Overview numbers" }, { level: 2, text: "Get a contact" }, { level: 2, text: "Add a contact" }, { level: 2, text: "Update a contact" }, { level: 2, text: "Import contacts" }] }, { id: "api-reference/conversations", url: "https://kerabie.com/docs/api-reference/conversations", title: "Conversations", description: "List, retrieve, and manage conversations via the API.", section: "api-reference", body: '## List conversations\n\n```http\nGET /v1/conversations\n```\n\n```typescript\nconst { data, meta } = await kerabie.conversations.list({ status: \'WAITING\', limit: 20 });\n```\n\n**Query params**\n\n| Param | Type | Description |\n|---|---|---|\n| `status` | `WAITING \\| ACTIVE \\| RESOLVED \\| CLOSED` | Filter by status (`WAITING` is shown as **New** in the dashboard) |\n| `channel` | `string` | Filter by channel, e.g. `WHATSAPP` |\n| `assignedTo` | `number` | Filter by the **user id** of the assigned agent |\n| `search` | `string` | Case-insensitive match on customer name, email, subject or message text (max 200 characters) |\n| `page` | `number` | Page number (default 1) |\n| `limit` | `number` | Results per page (default 20, maximum 100) |\n\n**Response**\n\n```json\n{\n "data": [\n {\n "id": 101,\n "status": "ACTIVE",\n "channel": "WHATSAPP",\n "subject": null,\n "customer": { "id": 5, "name": "Chidi Okafor", "email": "chidi@palmex.com", "sessionId": "s_8f2c" },\n "assignedAgent": { "id": 7, "userId": 12, "user": { "fname": "Amara", "lname": "Osei", "email": "amara@kerabie.com", "avatar": null } },\n "lastMessage": "Please hurry.",\n "lastMessageAt": "2026-10-05T10:52:00.000Z",\n "tags": [{ "tag": "refund" }, { "tag": "vip" }],\n "aiHandling": false,\n "_count": { "messages": 1 },\n "createdAt": "2026-10-05T09:10:00.000Z",\n "updatedAt": "2026-10-05T10:52:00.000Z"\n }\n ],\n "meta": {\n "total": 43,\n "page": 1,\n "limit": 20,\n "pages": 3,\n "counts": { "all": 57, "WAITING": 4, "ACTIVE": 21, "RESOLVED": 12, "CLOSED": 20 }\n }\n}\n```\n\n**Field notes**\n\n- `tags` holds the first three tags only; [get a conversation](#get-a-conversation) returns all of them.\n- `_count.messages` is the number of **unread** messages.\n- `aiHandling` is `true` while the AI assistant is handling the conversation on its own: it is still open, the AI has replied, no human has, and it has not handed off.\n- `meta.counts` are organisation-wide totals per status and **ignore** the filters above, so they work as inbox tab counts.\n\n---\n\n## Get a conversation\n\n```http\nGET /v1/conversations/:id\n```\n\n```typescript\nconst conversation = await kerabie.conversations.get(101);\n```\n\nReturns the conversation with its customer, assigned agent, **all** tags and the `aiHandling` flag. Responds `404` if the conversation does not exist in your organisation.\n\n---\n\n## Previous conversations\n\n```http\nGET /v1/conversations/:id/history\n```\n\n```typescript\nconst { data } = await kerabie.conversations.getHistory(101, 5);\n```\n\nThe same customer\'s **other** conversations in your organisation, newest first. `limit` defaults to 5 and is capped at 20. The conversation you ask about is never included. A conversation with no linked customer returns an empty list.\n\n```json\n{\n "data": [\n {\n "id": 88,\n "status": "RESOLVED",\n "channel": "WHATSAPP",\n "subject": "Refund not received",\n "isTicket": false,\n "createdAt": "2026-09-28T09:00:00.000Z",\n "lastMessageAt": "2026-09-28T09:40:00.000Z",\n "handoffSummary": null\n }\n ]\n}\n```\n\n---\n\n## Take over from the AI\n\n```http\nPOST /v1/conversations/:id/take-over\n```\n\n```typescript\nawait kerabie.conversations.takeOver(101, { agentId: 7 });\n```\n\nHands a conversation from the AI assistant to a person. It **stops the AI** from replying, sets the status to `ACTIVE`, and assigns the conversation to `agentId` if nobody holds it yet (an existing assignee is kept).\n\n**Body**\n\n| Field | Type | Description |\n|---|---|---|\n| `agentId` | `number` | **Required.** The organisation team-member id to assign. |\n\n**Errors**\n\n| Status | When |\n|---|---|\n| `400` | `agentId` is missing or invalid, the team member is not in your organisation, or the AI is no longer handling the conversation (for example, someone already took over or it was resolved). |\n| `404` | The conversation does not exist in your organisation. |\n\nTaking over is safe to race: only one caller succeeds, and the rest receive `400`.\n\n---\n\n## Resolve a conversation\n\n```http\nPOST /conversations/:id/resolve\n```\n\n---\n\n## Close a conversation\n\n```http\nPOST /conversations/:id/close\n```\n\n---\n\n## Assign to an agent\n\n```http\nPATCH /conversations/:id\n```\n\n```json\n{ "agentId": 7 }\n```\n\n---\n\n## Send a message\n\n```http\nPOST /conversations/:id/messages\n```\n\n```json\n{\n "body": "Your order has shipped! Tracking: ABC123",\n "senderType": "bot"\n}\n```\n\n`senderType` can be `"agent"` or `"bot"`.', headings: [{ level: 2, text: "List conversations" }, { level: 2, text: "Get a conversation" }, { level: 2, text: "Previous conversations" }, { level: 2, text: "Take over from the AI" }, { level: 2, text: "Resolve a conversation" }, { level: 2, text: "Close a conversation" }, { level: 2, text: "Assign to an agent" }, { level: 2, text: "Send a message" }] }, { id: "api-reference/customers", url: "https://kerabie.com/docs/api-reference/customers", title: "Customers", description: "Look up and manage customers (visitors who have started conversations).", section: "api-reference", body: '## List customers\n\n```http\nGET /customers\n```\n\n**Query params:** `page`, `limit`, `search` (email or name), `from`, `to`\n\n---\n\n## Get a customer\n\n```http\nGET /customers/:id\n```\n\n---\n\n## Get by email\n\n```http\nGET /customers/by-email/:email\n```\n\n---\n\n## Update a customer\n\n```http\nPATCH /customers/:id\n```\n\n```json\n{\n "name": "Jane Smith",\n "email": "jane@example.com",\n "attributes": {\n "plan": "pro",\n "company": "Acme Corp",\n "userId": "user_123"\n }\n}\n```\n\n---\n\n## Delete a customer (GDPR)\n\n```http\nDELETE /customers/:id\n```\n\nPermanently removes all PII associated with this customer. Conversation content is anonymised but preserved for analytics. This action is irreversible.', headings: [{ level: 2, text: "List customers" }, { level: 2, text: "Get a customer" }, { level: 2, text: "Get by email" }, { level: 2, text: "Update a customer" }, { level: 2, text: "Delete a customer (GDPR)" }] }, { id: "api-reference/growth", url: "https://kerabie.com/docs/api-reference/growth", title: "Growth & Partner API", description: "UTM attribution tracking, traffic analytics, and partner/affiliate management.", section: "api-reference", body: 'The Growth API covers two systems:\n1. **UTM attribution**: track where visitors came from (Google Ads, Instagram, partner blogs, etc.)\n2. **Partner program**: manage affiliate partners, referrals, and commissions\n\n## UTM Attribution\n\n### Record a visit\n\nCall this on page load to record a UTM-tagged visit. The frontend SDK does this automatically via `useUtmCapture`.\n\n```http\nPOST /growth/utm/visit\nContent-Type: application/json\n```\n\n```json\n{\n "sessionId": "ks_abc123",\n "utmSource": "google",\n "utmMedium": "cpc",\n "utmCampaign": "summer_launch",\n "referralCode": "EMMAB2C",\n "landingPage": "https://kerabie.com/?utm_source=google&utm_medium=cpc"\n}\n```\n\nWhen a user signs up, pass the same UTM fields in the registration payload. The backend links the visit and credits the partner automatically.\n\n### UTM analytics summary\n\n```http\nGET /growth/utm/analytics?days=30\nAuthorization: Bearer YOUR_API_KEY\n```\n\n```json\n{\n "period": "30d",\n "visits": 1240,\n "conversions": 87,\n "conversionRate": 7.0,\n "topSources": [\n { "source": "google", "count": 542 },\n { "source": "instagram", "count": 311 }\n ],\n "topCampaigns": [\n { "campaign": "summer_launch", "count": 398 }\n ]\n}\n```\n\n### UTM timeline\n\n```http\nGET /growth/utm/timeline?days=30\nAuthorization: Bearer YOUR_API_KEY\n```\n\nReturns daily visit and conversion counts for charting.\n\n---\n\n## Partner Program\n\n### Apply as a partner\n\n```http\nPOST /partner/apply\nAuthorization: Bearer YOUR_API_KEY\n```\n\n```json\n{\n "displayName": "TechBlog Nigeria",\n "website": "https://techblog.ng",\n "description": "I write about SaaS tools for African businesses",\n "referralCode": "TECHBLOG"\n}\n```\n\nPartners start with `pending_review` status. Once approved, they get a referral link:\n`https://kerabie.com/?ref=TECHBLOG`\n\n### Partner dashboard\n\n```http\nGET /partner/me\nAuthorization: Bearer YOUR_API_KEY\n```\n\nReturns the partner profile, recent referrals, earnings, and payouts.\n\n### Earnings breakdown\n\n```http\nGET /partner/earnings?days=30\nAuthorization: Bearer YOUR_API_KEY\n```\n\n```json\n{\n "summary": {\n "totalEarned": 25000,\n "totalPaid": 10000,\n "pendingBalance": 15000,\n "totalClicks": 342,\n "totalSignups": 18\n },\n "earnings": [...],\n "timeline": [\n { "date": "2026-06-01", "earned": 5000, "pending": 5000, "paid": 0 }\n ]\n}\n```\n\n### Verify a referral code\n\n```http\nGET /partner/verify/{code}\n```\n\n```json\n{\n "valid": true,\n "partner": {\n "referralCode": "TECHBLOG",\n "displayName": "TechBlog Nigeria"\n }\n}\n```\n\n### Request payout\n\n```http\nPOST /partner/payout/request\nAuthorization: Bearer YOUR_API_KEY\n```\n\n```json\n{ "amount": 10000 }\n```\n\nPayouts are processed within 2\u20133 business days to the partner\'s registered bank account.\n\n---\n\n## How UTM flows to Partner commission\n\n1. Visitor lands on `kerabie.com/?ref=TECHBLOG`\n2. Frontend calls `POST /growth/utm/visit` with `referralCode: "TECHBLOG"`\n3. Partner\'s `totalClicks` increments\n4. Visitor signs up: registration payload includes `referralCode`, `utmSessionId`\n5. Backend links the `UtmVisit` \u2192 creates `PartnerReferral` \u2192 increments `totalSignups`\n6. When visitor subscribes to a paid plan \u2192 `PartnerEarning` is created (20% commission)', headings: [{ level: 2, text: "UTM Attribution" }, { level: 3, text: "Record a visit" }, { level: 3, text: "UTM analytics summary" }, { level: 3, text: "UTM timeline" }, { level: 2, text: "Partner Program" }, { level: 3, text: "Apply as a partner" }, { level: 3, text: "Partner dashboard" }, { level: 3, text: "Earnings breakdown" }, { level: 3, text: "Verify a referral code" }, { level: 3, text: "Request payout" }, { level: 2, text: "How UTM flows to Partner commission" }] }, { id: "api-reference/knowledge-base", url: "https://kerabie.com/docs/api-reference/knowledge-base", title: "Knowledge Base", description: "Manage articles via the authenticated API, and read published articles from the public widget-facing endpoints.", section: "api-reference", body: 'Kerabie exposes two separate knowledge base surfaces:\n\n- **`/v1/kb/*`**: authenticated CRUD for your own articles, using an API key (see [Authentication](/docs/api-reference/authentication)). Available on every plan.\n- **`/public/kb/*`**: read-only, published-only articles for embeddable consumers (the chat widget, the React Native helpdesk package). No API key; scoped by the widget key your embed already uses.\n\nEvery published article is also fed automatically into the AI chatbot as answer context, so anything you publish here can be used to answer visitor questions without extra setup.\n\n## Authenticated API (`/v1/kb`)\n\n### List articles\n\n```http\nGET /v1/kb/articles\nAuthorization: Bearer ker_live_abc123...\n```\n\n**Query params**\n\n| Param | Type | Description |\n|---|---|---|\n| `search` | `string` | Full-text search across title and content |\n| `category` | `string` | Filter by category |\n| `page` | `number` | Page number (default: 1) |\n| `limit` | `number` | Results per page (default 20) |\n\n**Response**\n\n```json\n{\n "data": [\n {\n "id": 12,\n "title": "How to reset your password",\n "content": "<p>Go to the login page and click Forgot Password...</p>",\n "type": "faq",\n "category": "Account",\n "status": "published",\n "imageUrl": "https://cdn.kerabie.com/knowledge-base/42/12-1234.jpg",\n "views": 245,\n "helpfulPct": 92,\n "votes": 30,\n "aiCitations": 88,\n "createdAt": "2026-03-15T10:00:00Z",\n "updatedAt": "2026-03-15T10:00:00Z"\n }\n ],\n "meta": { "total": 43, "page": 1, "limit": 20, "hasMore": true }\n}\n```\n\n`content` is HTML: the dashboard editor, the chat widget and the apps all store and show it that way. Plain text sent through the API or a CSV import is shown as-is, so send HTML (`<p>\u2026</p>`) for paragraphs and formatting.\n\n`helpfulPct` is the share of widget visitors who answered "Was this helpful?" with yes, and stays `null` until at least 3 have voted. `aiCitations` counts the AI chatbot answers that cited the article.\n\n---\n\n### Stats\n\n```http\nGET /v1/kb/stats\n```\n\n```json\n{ "total": 43, "published": 38, "drafts": 5, "views": 12840, "top": { "title": "How to reset your password", "views": 1240 }, "categories": ["Account", "Billing"] }\n```\n\n### Insights: what to write or fix next\n\n```http\nGET /v1/kb/insights\n```\n\n```json\n{\n "mostViewed": [{ "id": 12, "title": "How to reset your password", "views": 1240, "pct": 100 }],\n "gaps": [{ "question": "okta sso", "count": 23 }],\n "lowestRated": [{ "id": 7, "title": "Do you offer refunds?", "helpfulPct": 74, "votes": 12 }],\n "stale": [{ "id": 9, "title": "Connect WhatsApp", "age": "4 months" }]\n}\n```\n\n`gaps` are customer questions in the last 30 days that the AI had no article to answer from. `lowestRated` needs at least 3 votes per article. `stale` lists published articles not updated in 90 days. Cached for a few minutes.\n\n---\n\n### Get an article\n\n```http\nGET /v1/kb/articles/:id\n```\n\n---\n\n### Create an article\n\n```http\nPOST /v1/kb/articles\n```\n\n```json\n{\n "title": "How to reset your password",\n "content": "<p>Go to the login page...</p>",\n "category": "Account",\n "type": "faq",\n "status": "published"\n}\n```\n\n`type` is one of `faq | article | news | information` (default `article`). `status` is one of `draft | published | archived` (default `published`).\n\n---\n\n### Update an article\n\n```http\nPATCH /v1/kb/articles/:id\n```\n\nAccepts the same fields as create; only send what\'s changing.\n\n---\n\n### Delete an article\n\n```http\nDELETE /v1/kb/articles/:id\n```\n\n---\n\n## Public, widget-facing API (`/public/kb`)\n\nNo API key. Authenticate with your widget key, either as an `x-widget-key` header or a `widgetKey` query param, the same key your chat widget embed already uses.\n\n### List published articles\n\n```http\nGET /public/kb/articles?widgetKey=wk_yourorg_abc123&search=refund\n```\n\n**Query params**\n\n| Param | Type | Description |\n|---|---|---|\n| `search` | `string` | Full-text search |\n| `category` | `string` | Filter by category |\n| `type` | `string` | Filter by `faq \\| article \\| news \\| information` |\n| `page` / `limit` | `number` | Pagination |\n\nReturns only `status: "published"` articles, the same `{ data, meta }` shape as the authenticated list.\n\n### Get a published article\n\n```http\nGET /public/kb/articles/:id?widgetKey=wk_yourorg_abc123\n```\n\nIncrements the article\'s view count.\n\n### Rate an article\n\n```http\nPOST /public/kb/articles/:id/feedback?widgetKey=wk_yourorg_abc123\n{ "helpful": true }\n```\n\nThe "Was this helpful?" vote from the widget and the React Native package. A visitor can vote up to 3 times per article per day (`429` after that); `404` for a draft or unknown article. Votes feed the helpfulness shown in the dashboard.\n\n The chat widget\'s built-in search screen calls a simpler variant of this, `GET /public/knowledge-base?widgetKey=...&q=...`, returning `{ items: [...] }`; that\'s what actually powers the widget UI. The `/public/kb/*` endpoints above exist for consumers that need pagination or a single-article lookup, like the React Native helpdesk package.\n\n## Importing content\n\n Import, crawl, and refine are dashboard actions, authenticated with your logged-in session (not an API key); they live under `/knowledge-base/*`, not `/v1/kb/*`. They\'re documented here because they\'re part of the same feature, not because they\'re callable with a `ker_live_` key.\n\nThree ways to get content into the knowledge base beyond typing it manually; all AI-assisted and gated by plan (see below).\n\n### Upload a document\n\n```http\nPOST /knowledge-base/import/document\nContent-Type: multipart/form-data\n```\n\nUpload a file (max 5MB). Everything becomes drafts for you to review.\n\n- **`.csv`**: one article per row, no AI step and no AI quota used. Use a header row with `title` (or `question`) and `content` (or `answer`); `category` and `type` are optional. Up to 500 rows; rows that can\'t be used are returned in `skipped` with their row number.\n- **`.docx`, `.md`, `.txt`, `.pdf`, `.json`**: AI structures the text into one or more draft articles.\n\n### Crawl a website\n\nA two-step flow: you pick which pages get imported before anything runs:\n\n```http\nPOST /knowledge-base/crawl/discover\n{ "url": "https://docs.yoursite.com" }\n```\n\nReturns candidate pages found on that domain (from on-page links and, when present, `sitemap.xml`):\n\n```json\n{ "pages": [{ "url": "https://docs.yoursite.com/faq", "title": "FAQ" }] }\n```\n\n```http\nPOST /knowledge-base/crawl/import\n{ "urls": ["https://docs.yoursite.com/faq"] }\n```\n\nRuns as a background job (crawling and AI structuring can take a while for several pages) and responds immediately with a job ID:\n\n```json\n{ "jobId": "42" }\n```\n\nPoll for completion:\n\n```http\nGET /knowledge-base/jobs/:jobId\n```\n\n```json\n{ "status": "succeeded", "result": { "imported": 4, "items": [...] } }\n```\n\n### Refine an article with AI\n\n```http\nPOST /knowledge-base/:id/refine\n```\n\nStreams the refined content back as Server-Sent Events (`data: { "delta": "..." }` chunks, ending with `data: { "done": true }`) and saves the result once the stream completes. AI-generated text never uses em dashes.\n\n## Plan limits\n\nImport, crawl, and refine share one monthly AI-operations budget per organisation, plus crawl has its own per-run page cap:\n\n| Plan | AI ops / month | Pages per crawl |\n|---|---|---|\n| Free | Not available | Not available |\n| Basic | 10 | 20 |\n| Pro | 50 | 100 |\n| Enterprise | Unlimited | Unlimited |\n\nExceeding either limit returns a `403` with a message naming the limit.', headings: [{ level: 2, text: "Authenticated API (`/v1/kb`)" }, { level: 3, text: "List articles" }, { level: 3, text: "Stats" }, { level: 3, text: "Insights: what to write or fix next" }, { level: 3, text: "Get an article" }, { level: 3, text: "Create an article" }, { level: 3, text: "Update an article" }, { level: 3, text: "Delete an article" }, { level: 2, text: "Public, widget-facing API (`/public/kb`)" }, { level: 3, text: "List published articles" }, { level: 3, text: "Get a published article" }, { level: 3, text: "Rate an article" }, { level: 2, text: "Importing content" }, { level: 3, text: "Upload a document" }, { level: 3, text: "Crawl a website" }, { level: 3, text: "Refine an article with AI" }, { level: 2, text: "Plan limits" }] }, { id: "api-reference/overview", url: "https://kerabie.com/docs/api-reference/overview", title: "API Overview", description: "REST API for the Kerabie customer support platform.", section: "api-reference", body: 'The Kerabie REST API lets you build custom integrations, automate workflows, and access your support data programmatically.\n\n## Base URL\n\n```\nhttps://api.kerabie.com\n```\n\n## Authentication\n\nAll API requests require a Bearer token and your org ID:\n\n```http\nAuthorization: Bearer YOUR_API_KEY\nX-Kerabie-Org: YOUR_ORG_ID\nContent-Type: application/json\n```\n\nGenerate an API key in **Settings \u2192 API \u2192 New API Key**. Keys are scoped to your organisation.\n\n## Interactive docs\n\nTry every endpoint directly in the browser; no separate tool needed:\n\n**[Open API Playground \u2192](/docs/api-reference/playground)**\n\nThe playground is powered by Swagger UI and includes every endpoint with request/response schemas. Authorize with your API key once and test live requests against the real API.\n\n## Rate limits\n\n| Plan | Requests per minute |\n|---|---|\n| Free | 30 |\n| Starter | 120 |\n| Pro | 600 |\n| Business | 3,000 |\n\nRate limit headers are returned on every response:\n\n```http\nX-RateLimit-Limit: 120\nX-RateLimit-Remaining: 87\nX-RateLimit-Reset: 1716734040\n```\n\n## Errors\n\nAll errors follow a consistent format:\n\n```json\n{\n "statusCode": 404,\n "message": "Conversation not found",\n "error": "Not Found"\n}\n```\n\n| Status | Meaning |\n|---|---|\n| `400` | Bad request: missing or invalid parameters |\n| `401` | Unauthorised: missing or invalid API key |\n| `403` | Forbidden; you don\'t have access to this resource |\n| `404` | Not found |\n| `429` | Rate limit exceeded |\n| `500` | Server error |\n\n## Pagination\n\nList endpoints return paginated results:\n\n```json\n{\n "data": [...],\n "total": 243,\n "page": 1,\n "limit": 20\n}\n```\n\nPass `?page=2&limit=50` to navigate pages.\n\n## Use with an AI assistant\n\nConnect the [Kerabie MCP server](/docs/sdks/mcp) to Claude, Cursor or another MCP client and it can search these docs, read the API and TypeScript SDK types and follow the integration guide while it writes code for you. No API key is needed for that.', headings: [{ level: 2, text: "Base URL" }, { level: 2, text: "Authentication" }, { level: 2, text: "Interactive docs" }, { level: 2, text: "Rate limits" }, { level: 2, text: "Errors" }, { level: 2, text: "Pagination" }, { level: 2, text: "Use with an AI assistant" }] }, { id: "api-reference/store", url: "https://kerabie.com/docs/api-reference/store", title: "Store & Products API", description: "Manage your Kerabie store, products, orders, and product analytics.", section: "api-reference", body: 'The Store API lets you manage products, process orders, and track per-product analytics (views, inquiries, sales).\n\n## Products\n\n### List products\n\n```http\nGET /store/products\nAuthorization: Bearer YOUR_API_KEY\n```\n\n```json\n{\n "products": [\n {\n "id": 1,\n "name": "Blue Sneakers",\n "price": 15000,\n "currency": "NGN",\n "imageUrl": "https://...",\n "category": "Footwear",\n "stock": 24,\n "views": 312,\n "inquiries": 18,\n "salesCount": 9,\n "salesAmount": 135000\n }\n ]\n}\n```\n\n### Create a product\n\n```http\nPOST /store/products\nAuthorization: Bearer YOUR_API_KEY\n```\n\n```json\n{\n "name": "Blue Sneakers",\n "description": "Comfortable everyday sneakers",\n "price": 15000,\n "currency": "NGN",\n "category": "Footwear",\n "imageUrl": "https://...",\n "stock": 50,\n "sku": "SNKR-BLU-001"\n}\n```\n\n### Product analytics (per-product stats)\n\n```http\nGET /store/products/stats\nAuthorization: Bearer YOUR_API_KEY\n```\n\nReturns products sorted by views (most viewed first), with view count, inquiry count, sales count, and revenue.\n\n```json\n{\n "products": [\n {\n "id": 1,\n "name": "Blue Sneakers",\n "views": 312,\n "inquiries": 18,\n "salesCount": 9,\n "salesAmount": 135000\n }\n ]\n}\n```\n\n## Public Storefront\n\nThese endpoints are unauthenticated and called by your public storefront.\n\n### Track product view\n\nCalled automatically by the Kerabie storefront when a visitor adds a product to cart.\n\n```http\nPOST /public/store/{slug}/view/{productId}\nContent-Type: application/json\n```\n\n```json\n{ "sessionId": "ks_abc123" }\n```\n\n### Track product inquiry\n\nCall this when a visitor opens chat asking about a specific product.\n\n```http\nPOST /public/store/{slug}/inquiry/{productId}\n```\n\n## Orders\n\n### List orders\n\n```http\nGET /store/orders?status=pending\nAuthorization: Bearer YOUR_API_KEY\n```\n\n### Update order status\n\n```http\nPATCH /store/orders/{id}\nAuthorization: Bearer YOUR_API_KEY\n```\n\n```json\n{ "status": "fulfilled" }\n```\n\nStatus values: `pending` | `paid` | `fulfilled` | `cancelled`', headings: [{ level: 2, text: "Products" }, { level: 3, text: "List products" }, { level: 3, text: "Create a product" }, { level: 3, text: "Product analytics (per-product stats)" }, { level: 2, text: "Public Storefront" }, { level: 3, text: "Track product view" }, { level: 3, text: "Track product inquiry" }, { level: 2, text: "Orders" }, { level: 3, text: "List orders" }, { level: 3, text: "Update order status" }] }, { id: "api-reference/tickets", url: "https://kerabie.com/docs/api-reference/tickets", title: "Tickets API", description: "Overview numbers and CSV export for tickets.", section: "api-reference", body: 'Tickets are conversations created through a form, email or the API, so they share the conversation model. These endpoints use your [API key](/docs/api-reference/authentication).\n\n```typescript\n\nconst kerabie = new KerAbieClient({ apiKey: process.env.KERABIE_API_KEY });\n```\n\n## Overview numbers\n\n```http\nGET /v1/tickets/stats\n```\n\n```typescript\nconst stats = await kerabie.tickets.getStats();\n```\n\n```json\n{\n "open": 4,\n "unassigned": 2,\n "breachingSla": 1,\n "avgResolutionMinutes": 192,\n "resolutionDeltaPct": -18\n}\n```\n\n| Field | Meaning |\n|---|---|\n| `open` | Tickets that are new or active. |\n| `unassigned` | Open tickets nobody owns. |\n| `breachingSla` | Open tickets past their SLA due time. |\n| `avgResolutionMinutes` | Average time from creation to resolution for tickets resolved or closed in the last 7 days. `null` when none were. |\n| `resolutionDeltaPct` | % change versus the 7 days before. **Negative is faster.** `null` when there is nothing to compare against. |\n\nResults are cached for up to a minute and refresh immediately when a ticket\'s status, priority or assignee changes.\n\n---\n\n## Export to CSV\n\n```http\nGET /v1/tickets/export\n```\n\n```typescript\nconst { filename, csv, count, truncated } = await kerabie.tickets.export({ status: \'ACTIVE\' });\n```\n\n| Query param | Description |\n|---|---|\n| `status` | `WAITING`, `ACTIVE`, `RESOLVED` or `CLOSED`. |\n| `priority` | `LOW`, `NORMAL`, `HIGH` or `URGENT`. |\n| `search` | Case-insensitive match on requester name or email, or subject. A ticket reference such as `KB-2041` (or just `2041`) matches by id. |\n| `assignedTo` | User id of the assigned agent. |\n\n```json\n{\n "filename": "tickets-2026-10-05.csv",\n "csv": "Ticket,Subject,Status,Priority,Channel,Requester,Requester email,Assignee,Created,Last activity,SLA due,SLA breached\nKB-2041,Refund not received,ACTIVE,URGENT,WHATSAPP,Chidi Okafor,chidi@palmex.com,Amara Osei,2026-10-05T09:10:00.000Z,2026-10-05T10:52:00.000Z,2026-10-05T11:00:00.000Z,yes\n",\n "count": 1,\n "truncated": false\n}\n```\n\n- At most **5,000** tickets are returned. If more matched, `truncated` is `true`; narrow the filters and export again.\n- `SLA breached` is `yes` or `no` for open tickets with an SLA, and empty otherwise.\n- **Plan requirement:** exporting needs a plan with data exports (Pro and above). Other plans receive `403 Forbidden`.\n- Cells beginning with `=`, `+`, `-` or `@` are prefixed with a single quote so spreadsheet apps show them as text rather than evaluating them as formulas.', headings: [{ level: 2, text: "Overview numbers" }, { level: 2, text: "Export to CSV" }] }, { id: "api-reference/visitors", url: "https://kerabie.com/docs/api-reference/visitors", title: "Visitors API", description: "Who is on your site, who has been, and who is blocked or allowed.", section: "api-reference", body: 'These endpoints use your [API key](/docs/api-reference/authentication) and return the same data as the **Visitors** page in the dashboard.\n\n```typescript\n\nconst kerabie = new KerAbieClient({ apiKey: process.env.KERABIE_API_KEY });\n```\n\nVisitor history is recorded when someone opens your chat widget and is kept for **30 days**. A visitor who never sends a message is still recorded; they just have no contact or conversation.\n\n## List visitors\n\n```http\nGET /v1/visitors\n```\n\n```typescript\nconst { data, meta } = await kerabie.visitors.list({ status: \'online\' });\n```\n\n| Query param | Description |\n|---|---|\n| `status` | `all` (default), `online`, `offline`, `blocked` or `allowed`. |\n| `search` | Case-insensitive match on name, email, IP, city or page. |\n| `country` | Two-letter country code, e.g. `NG`. |\n| `days` | How far back to look. Default `7`, maximum `30`. |\n| `page`, `limit` | Pagination. `limit` defaults to 20, maximum 100. |\n\n```json\n{\n "data": [\n {\n "sessionId": "sess_8f2c1d",\n "name": "Chidi Okafor",\n "email": "chidi@palmex.com",\n "phone": "+234 803 555 0142",\n "country": "NG",\n "city": "Lagos",\n "ip": "102.89.34.12",\n "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \u2026",\n "currentPage": "/pricing",\n "visitCount": 14,\n "firstSeenAt": "2026-09-28T09:10:00.000Z",\n "lastSeenAt": "2026-10-05T10:52:00.000Z",\n "conversationId": 101,\n "status": "online"\n }\n ],\n "meta": { "total": 43, "page": 1, "limit": 20, "pages": 3, "truncated": false }\n}\n```\n\n**Field notes**\n\n- **`status`** is derived from your IP rules and **wins over presence**: a visitor on the allowlist reads `allowed` even while online, and a blocked IP reads `blocked`. A blocklist match wins over an allowlist match, as it does when the visitor connects.\n- **`status=blocked`** and **`status=allowed`** match IP *ranges* (for example `203.0.113.0/24`) as well as exact IPs. They are checked against the 1,000 most recent matching visitors, and `meta.truncated` is `true` when that limit was reached.\n- **`ip`** is `null` when your organisation hides visitor IPs in its privacy settings. Blocking and allowing still apply to the real address.\n- **`visitCount`** goes up by one when a visitor comes back after being away for 30 minutes or more. Reloading a page or coming back quickly does not count.\n- **`name` and `email`** come from the contact record if the visitor has chatted, otherwise from what they told the widget.\n- **`currentPage`** is the page they are on right now for online visitors, otherwise the last page they were on.\n\n---\n\n## Overview numbers\n\n```http\nGET /v1/visitors/overview\n```\n\n```typescript\nconst overview = await kerabie.visitors.getOverview();\n```\n\n```json\n{\n "online": 3,\n "countries": 9,\n "returning": 7,\n "blockedIps": 1,\n "topCountries": [{ "country": "NG", "count": 5 }, { "country": "GB", "count": 4 }]\n}\n```\n\n| Field | Meaning |\n|---|---|\n| `online` | Visitors on the site right now (always live). |\n| `countries` | Distinct countries among visitors in the last 24 hours. |\n| `returning` | Visitors in the last 24 hours who have visited more than once. |\n| `blockedIps` | Active block rules. |\n| `topCountries` | Up to five countries by visitor count in the last 24 hours, most first. |\n\nThe last-24-hours numbers are cached for up to 30 seconds.\n\n---\n\n## Export to CSV\n\n```http\nGET /v1/visitors/export\n```\n\n```typescript\nconst { filename, csv, count, truncated } = await kerabie.visitors.export({ status: \'blocked\' });\n```\n\nAccepts `status`, `search`, `country` and `days` as above.\n\n- At most **5,000** visitors are returned; `truncated` is `true` if more matched.\n- The IP column is empty when your organisation hides visitor IPs.\n- **Plan requirement:** exporting needs a plan with data exports (Pro and above). Other plans receive `403 Forbidden`.\n- Cells beginning with `=`, `+`, `-` or `@` are prefixed with a single quote so spreadsheet apps show them as text instead of evaluating them as formulas. This matters here because visitor names and pages are typed by the public.', headings: [{ level: 2, text: "List visitors" }, { level: 2, text: "Overview numbers" }, { level: 2, text: "Export to CSV" }] }, { id: "api-reference/webhooks", url: "https://kerabie.com/docs/api-reference/webhooks", title: "Webhooks API", description: "Manage webhook endpoints via the API.", section: "api-reference", body: '## List webhooks\n\n```http\nGET /webhooks\n```\n\n---\n\n## Create a webhook\n\n```http\nPOST /webhooks\n```\n\n```json\n{\n "url": "https://yourserver.com/webhooks/kerabie",\n "events": ["conversation.created", "message.sent.agent"],\n "description": "CRM sync"\n}\n```\n\n**Response** includes the generated `secret`; store it securely. It\'s shown only once.\n\n---\n\n## Update a webhook\n\n```http\nPATCH /webhooks/:id\n```\n\n---\n\n## Delete a webhook\n\n```http\nDELETE /webhooks/:id\n```\n\n---\n\n## List delivery logs\n\n```http\nGET /webhooks/:id/deliveries\n```\n\nReturns recent delivery attempts with status, response code, and error message.\n\n---\n\n## Retry a delivery\n\n```http\nPOST /webhooks/:id/deliveries/:deliveryId/retry\n```\n\nManually retries a failed delivery.\n\n---\n\nFor details on events, payloads, and signature verification see the [Webhooks guide](/integrations/webhooks).', headings: [{ level: 2, text: "List webhooks" }, { level: 2, text: "Create a webhook" }, { level: 2, text: "Update a webhook" }, { level: 2, text: "Delete a webhook" }, { level: 2, text: "List delivery logs" }, { level: 2, text: "Retry a delivery" }] }, { id: "automations/actions", url: "https://kerabie.com/docs/automations/actions", title: "Actions", description: "What an automation can do when triggered.", section: "automations", body: "Actions are the steps an automation performs after its trigger fires and conditions are met. An automation can have **multiple actions**; they run in sequence.\n\n## Assignment actions\n\n| Action | Description |\n|---|---|\n| `Assign agent` | Assigns the conversation to a specific agent |\n| `Assign team` | Assigns to a team using round-robin across available agents |\n| `Unassign` | Removes the current assignee |\n\n## Messaging actions\n\n| Action | Description |\n|---|---|\n| `Send message` | Sends an automated message to the visitor |\n| `Send internal note` | Posts a note visible only to agents (not the visitor) |\n\nWhen using `Send message`, you can use template variables:\n\n| Variable | Value |\n|---|---|\n| `{{visitor_name}}` | Visitor's name if known |\n| `{{agent_name}}` | Assigned agent's name |\n| `{{org_name}}` | Your organisation name |\n\n## Status actions\n\n| Action | Description |\n|---|---|\n| `Resolve conversation` | Marks the conversation resolved |\n| `Reopen conversation` | Reopens a resolved conversation |\n| `Add tag` | Adds a tag to the conversation |\n| `Remove tag` | Removes a specific tag |\n\n## Notification actions\n\n| Action | Description |\n|---|---|\n| `Notify agent` | Sends a push and/or email alert to a specific agent |\n| `Notify team` | Alerts all members of a team |\n\n## Action limits\n\n- Maximum **10 actions** per automation\n- `Send message` actions include a short delay (1\u20133 seconds) to avoid instant bot responses that feel robotic\n- Actions run in the order listed: drag to reorder\n\n## Example: full automation\n\n**Goal**: Route VIP customers to the senior support team and notify the manager.\n\n1. **Trigger**: Conversation created\n2. **Condition**: Visitor attribute `plan` = `enterprise`\n3. **Actions**:\n - Assign team \u2192 Senior Support\n - Add tag \u2192 `vip`\n - Notify agent \u2192 Manager (Alice)\n - Send message \u2192 \"Hi `{{visitor_name}}`, you're connected with our senior support team. We'll be right with you.\"", headings: [{ level: 2, text: "Assignment actions" }, { level: 2, text: "Messaging actions" }, { level: 2, text: "Status actions" }, { level: 2, text: "Notification actions" }, { level: 2, text: "Action limits" }, { level: 2, text: "Example: full automation" }] }, { id: "automations/flows", url: "https://kerabie.com/docs/automations/flows", title: "Message Flows", description: "Build branching conversation flows with decision trees.", section: "automations", body: 'Message Flows let you build interactive bot conversations with branching paths, like a decision tree. Visitors choose from options and the flow guides them to the right answer or agent.\n\n<Note>Message Flows are available on **Pro** and above.</Note>\n\n## How flows work\n\nA flow is a sequence of **steps**. Each step is one of:\n\n| Step type | Description |\n|---|---|\n| `Message` | Send a text message to the visitor |\n| `Question` | Ask a question with multiple-choice options |\n| `Condition` | Branch based on a visitor attribute or previous answer |\n| `Assign` | Assign the conversation to an agent or team |\n| `End` | Close the flow (visitor can still send messages) |\n\nWhen a visitor opens the widget, the flow starts automatically (if configured) or is triggered by an automation.\n\n## Creating a flow\n\n1. Go to **Settings \u2192 Automations \u2192 Flows \u2192 New flow**\n2. Give the flow a name\n3. Add steps using the visual canvas:\n - Drag steps from the left panel\n - Connect them by drawing lines between step outputs\n4. Set the **entry point**; the first step that runs\n5. Click **Activate** when ready\n\n## Example: booking triage flow\n\n```\n[Message] "Hi! What can I help you with today?"\n |\n[Question] \n \u2192 "Book a call" \u2192 [Assign] Sales team \u2192 [End]\n \u2192 "Technical issue" \u2192 [Assign] Support team \u2192 [End]\n \u2192 "Billing query" \u2192 [Message] "Please email billing@example.com" \u2192 [End]\n```\n\n## Using variables in flow messages\n\nUse the same template variables as automations:\n\n```\nHi {{visitor_name}}! I\'m here to help. What brings you here today?\n```\n\n## Triggering a flow\n\nFlows can be triggered:\n\n- **Automatically on widget open**: set in the flow settings as the default entry flow\n- **Via an automation action**: add a `Start flow` action to any automation\n\n## Fallback to live agent\n\nAlways include a path that connects to a live agent. Visitors who get stuck in a flow can feel abandoned; a clear "Talk to a human" option prevents drop-off.\n\n## Analytics\n\nTrack flow performance in **Analytics \u2192 Flows**:\n- Completion rate per step\n- Drop-off points\n- Which paths visitors take most', headings: [{ level: 2, text: "How flows work" }, { level: 2, text: "Creating a flow" }, { level: 2, text: "Example: booking triage flow" }, { level: 2, text: "Using variables in flow messages" }, { level: 2, text: "Triggering a flow" }, { level: 2, text: "Fallback to live agent" }, { level: 2, text: "Analytics" }] }, { id: "automations/overview", url: "https://kerabie.com/docs/automations/overview", title: "Automations", description: "Automatically route conversations, send messages, and trigger actions based on rules.", section: "automations", body: 'Automations let you define rules that run whenever a trigger event occurs. Use them to route conversations, greet visitors, assign agents, and more, without any manual work.\n\n## Anatomy of an automation\n\nEvery automation has three parts:\n\n- **Trigger**: what event starts the rule (e.g. "conversation created")\n- **Conditions**: optional filters (e.g. "page URL contains /pricing")\n- **Actions**: what to do (e.g. "assign to Sales team")\n\n## Common use cases\n\n| Goal | Trigger | Action |\n|---|---|---|\n| Route pricing questions to Sales | Conversation created + URL contains `/pricing` | Assign to Sales team |\n| Greet returning visitors | Visitor started + has previous conversations | Send message: "Welcome back!" |\n| Auto-close idle conversations | No activity for 24 hours | Resolve conversation |\n| Tag bug reports | Message contains "bug" or "broken" | Add tag: `bug-report` |\n| Notify on VIP customer | Conversation created + attribute `plan = enterprise` | Notify specific agent |\n\n## Creating an automation\n\n1. Go to **Settings \u2192 Automations \u2192 New automation**\n2. Give it a name\n3. Choose a **trigger** event\n4. Add **conditions** (optional: leave empty to match all)\n5. Add one or more **actions**\n6. Toggle it **Active** and save\n\n## Triggers\n\n| Trigger | When it fires |\n|---|---|\n| `Conversation created` | A new conversation starts |\n| `Message received` | A visitor sends a message |\n| `Visitor page view` | A visitor views a page *(Pro)* |\n| `Conversation resolved` | A conversation is marked resolved |\n| `No activity` | No messages for a set time |\n| `Visitor attribute` | A visitor attribute matches a value |\n\n## Actions\n\n| Action | What it does |\n|---|---|\n| `Send message` | Bot sends a message to the visitor |\n| `Assign agent` | Assigns the conversation to a specific agent |\n| `Assign team` | Assigns to a team (round-robin) |\n| `Add tag` | Tags the conversation |\n| `Resolve` | Marks conversation as resolved |\n| `Notify agent` | Sends a push/email alert to an agent |', headings: [{ level: 2, text: "Anatomy of an automation" }, { level: 2, text: "Common use cases" }, { level: 2, text: "Creating an automation" }, { level: 2, text: "Triggers" }, { level: 2, text: "Actions" }] }, { id: "automations/triggers", url: "https://kerabie.com/docs/automations/triggers", title: "Triggers", description: "Events that start an automation rule.", section: "automations", body: "A trigger is the event that causes an automation to fire. Every automation has exactly one trigger.\n\n## Available triggers\n\n### Conversation triggers\n\n| Trigger | When it fires |\n|---|---|\n| `Conversation created` | A new conversation starts (visitor sends first message) |\n| `Conversation resolved` | A conversation is marked resolved |\n| `Conversation reopened` | A resolved conversation receives a new message |\n| `No activity` | No messages received for a configured duration |\n\n### Message triggers\n\n| Trigger | When it fires |\n|---|---|\n| `Message received` | A visitor sends any message |\n| `Message contains` | A visitor message matches a keyword or phrase |\n\n### Visitor triggers\n\n| Trigger | When it fires |\n|---|---|\n| `Visitor started session` | A visitor opens the widget for the first time in a session |\n| `Visitor page view` *(Pro)* | A visitor views a specific page |\n| `Visitor attribute` | A visitor attribute matches a value (e.g. `plan = enterprise`) |\n\n## Combining trigger with conditions\n\nA trigger fires the rule; **conditions** then narrow down whether the actions run. For example:\n\n- **Trigger**: `Conversation created`\n- **Condition**: Page URL contains `/pricing`\n- **Action**: Assign to Sales team\n\nWithout the condition, every new conversation would be assigned to Sales. The condition restricts it to conversations started on the pricing page.\n\nSee [Actions](/docs/automations/actions) for what an automation can do once triggered.\n\n## Trigger order\n\nIf multiple automations share the same trigger and their conditions all match, they run **in the order they appear** in the automation list. Drag automations to reorder them.\n\n## Testing a trigger\n\nUse the **Test** button when editing an automation. It runs the automation against the most recent matching conversation so you can verify conditions and actions work as expected before going live.", headings: [{ level: 2, text: "Available triggers" }, { level: 3, text: "Conversation triggers" }, { level: 3, text: "Message triggers" }, { level: 3, text: "Visitor triggers" }, { level: 2, text: "Combining trigger with conditions" }, { level: 2, text: "Trigger order" }, { level: 2, text: "Testing a trigger" }] }, { id: "billing/invoices", url: "https://kerabie.com/docs/billing/invoices", title: "Invoices & Receipts", description: "Download and manage your billing history.", section: "billing", body: "## Download invoices\n\nAll invoices are available in **Settings \u2192 Billing \u2192 Invoices**. Each invoice is a PDF you can download and share with your finance team.\n\n## Invoice details\n\nEach invoice includes:\n- Invoice number and date\n- Your organisation name and address\n- Itemised charges (plan, seat count, billing period)\n- Payment method (last 4 digits)\n- Amount paid\n\n## Adding a billing email\n\nBy default invoices are sent to the org owner's email. To send them to a separate billing address (e.g. `finance@yourcompany.com`):\n\nGo to **Settings \u2192 Billing \u2192 Billing email** and enter the address.\n\n## Tax / VAT\n\nTo add a tax ID or company details to your invoices, go to **Settings \u2192 Billing \u2192 Tax information**.\n\n## Failed payments\n\nIf a payment fails, Kerabie retries automatically over several days:\n\n| Attempt | Timing |\n|---|---|\n| Initial charge | Billing date |\n| 1st retry | +2 days |\n| 2nd retry | +4 days |\n| 3rd retry | +7 days |\n\nAfter 3 failures, your subscription is downgraded to Free and you'll receive an email.\n\nTo fix a payment failure, go to **Settings \u2192 Billing \u2192 Update payment method**.", headings: [{ level: 2, text: "Download invoices" }, { level: 2, text: "Invoice details" }, { level: 2, text: "Adding a billing email" }, { level: 2, text: "Tax / VAT" }, { level: 2, text: "Failed payments" }] }, { id: "billing/plans", url: "https://kerabie.com/docs/billing/plans", title: "Plans & Pricing", description: "Compare Kerabie plans and understand what's included.", section: "billing", body: `## Plan comparison
473
+
474
+ | Feature | Free | Starter | Pro | Business |
475
+ |---|---|---|---|---|
476
+ | Agents | 1 | 3 | 10 | Unlimited |
477
+ | Conversations/month | Unlimited | Unlimited | Unlimited | Unlimited |
478
+ | AI chats/month | None | 200 | 2,000 | 10,000 |
479
+ | Chat widgets | 1 | 2 | 5 | Unlimited |
480
+ | Visitor tracking | No | No | \u2713 | \u2713 |
481
+ | Knowledge base articles | 10 | 50 | Unlimited | Unlimited |
482
+ | Remove Kerabie branding | No | No | \u2713 | \u2713 |
483
+ | Virtual assistants | No | \u2713 | \u2713 | \u2713 |
484
+ | Custom automations | No | 5 | Unlimited | Unlimited |
485
+ | Campaigns | No | No | \u2713 | \u2713 |
486
+ | Analytics export | No | No | \u2713 | \u2713 |
487
+ | Priority support | No | No | No | \u2713 |
488
+ | SLA | No | No | No | \u2713 |
489
+
490
+ All paid plans include a **14-day free trial**. No credit card required to start.
491
+
492
+ ## Billing cycle
493
+
494
+ Plans are billed monthly or yearly. Yearly plans get 2 months free.
495
+
496
+ ## What counts as an "AI chat"
497
+
498
+ Each time the AI sends a reply to a visitor counts as one AI chat. If the AI escalates to a human agent, subsequent messages from the agent don't count. One conversation may involve multiple AI chats if the visitor sends several questions.
499
+
500
+ ## Overage
501
+
502
+ If you exceed your AI chat limit, the AI stops responding for the rest of the month and conversations go straight to human agents. Upgrade your plan to restore AI; no automatic overage charges.`, headings: [{ level: 2, text: "Plan comparison" }, { level: 2, text: "Billing cycle" }, { level: 2, text: 'What counts as an "AI chat"' }, { level: 2, text: "Overage" }] }, { id: "billing/upgrading", url: "https://kerabie.com/docs/billing/upgrading", title: "Upgrading & Downgrading", description: "How to change your plan.", section: "billing", body: "## Upgrade\n\n1. Go to **Settings \u2192 Billing \u2192 Plans**\n2. Click **Upgrade** on the plan you want\n3. Enter payment details (card via Paystack)\n4. Confirm: your new plan is active immediately\n\nWhen you upgrade mid-cycle, you're charged a pro-rated amount for the remaining days.\n\n## Downgrade\n\n1. Go to **Settings \u2192 Billing \u2192 Plans**\n2. Click **Downgrade**\n3. Confirm\n\nDowngrades take effect at the end of your current billing period. You keep your current plan's features until then.\n\n Downgrading may reduce your agent seats, AI chat quota, or widget count. Features above the new plan's limit will be deactivated at the end of the billing period.\n\n## Cancel\n\nGo to **Settings \u2192 Billing \u2192 Cancel subscription**. Your plan continues until the end of the billing period, then switches to Free.\n\nYour data is preserved on the Free plan; you don't lose conversation history when you cancel.\n\n## Reactivate\n\nIf you cancelled and want to reactivate, just go to **Settings \u2192 Billing \u2192 Plans** and choose a plan. Your previous data is intact.", headings: [{ level: 2, text: "Upgrade" }, { level: 2, text: "Downgrade" }, { level: 2, text: "Cancel" }, { level: 2, text: "Reactivate" }] }, { id: "campaigns/announcements", url: "https://kerabie.com/docs/campaigns/announcements", title: "Announcement Campaigns", description: "Send a one-time message to all visitors currently on your site.", section: "campaigns", body: `An announcement is a broadcast message sent simultaneously to every visitor who currently has the Kerabie widget open on your site. It's a real-time megaphone, useful for time-sensitive updates.
503
+
504
+ <Warning>Announcements go to all active visitors at once. Use them for important, broadly relevant messages, not personalised outreach.</Warning>
505
+
506
+ ## Creating an announcement
507
+
508
+ 1. Go to **Campaigns \u2192 New campaign \u2192 Announcement**
509
+ 2. Write your **message**: keep it short (1\u20132 sentences)
510
+ 3. Optionally add a **call to action** button (label + URL)
511
+ 4. Preview the widget appearance
512
+ 5. Click **Send now** or **Schedule**
513
+
514
+ ## Scheduling
515
+
516
+ Announcements can be sent immediately or scheduled for a future date and time. Scheduled announcements trigger for visitors who are active at that exact moment, not visitors who come later.
517
+
518
+ ## Examples
519
+
520
+ **New feature launch:**
521
+ > "\u{1F389} Our new AI assistant is live! Ask it anything; it's trained on our full help centre."
522
+
523
+ **Scheduled maintenance:**
524
+ > "We'll be down for maintenance on Saturday 10\u201311 AM UTC. Chat support will be limited during this time."
525
+
526
+ **Limited-time offer:**
527
+ > "Flash sale: 30% off all plans today only. Use code FLASH30 at checkout."
528
+
529
+ ## Delivery
530
+
531
+ The announcement appears as a message in the widget chat, the same as a message from an agent, but sent by the system. Visitors can reply to it, which starts a live conversation.
532
+
533
+ If a visitor has the widget open but minimised, the widget badge updates to show the new message.
534
+
535
+ ## Reach
536
+
537
+ The announcement reaches only visitors **currently on site** with the widget loaded; it is not an email blast or a push notification. Visitors who arrive after the announcement is sent do not see it.
538
+
539
+ ## Analytics
540
+
541
+ After sending, view **Campaigns \u2192 [campaign name]** to see:
542
+ - Total delivered
543
+ - Opened (visitor saw it)
544
+ - Replied (visitor sent a message back)`, headings: [{ level: 2, text: "Creating an announcement" }, { level: 2, text: "Scheduling" }, { level: 2, text: "Examples" }, { level: 2, text: "Delivery" }, { level: 2, text: "Reach" }, { level: 2, text: "Analytics" }] }, { id: "campaigns/billing/invoices", url: "https://kerabie.com/docs/campaigns/billing/invoices", title: "Invoices & Receipts", description: "Download and manage your billing history.", section: "campaigns", body: "## Download invoices\n\nAll invoices are available in **Settings \u2192 Billing \u2192 Invoices**. Each invoice is a PDF you can download and share with your finance team.\n\n## Invoice details\n\nEach invoice includes:\n- Invoice number and date\n- Your organisation name and address\n- Itemised charges (plan, seat count, billing period)\n- Payment method (last 4 digits)\n- Amount paid\n\n## Adding a billing email\n\nBy default invoices are sent to the org owner's email. To send them to a separate billing address (e.g. `finance@yourcompany.com`):\n\nGo to **Settings \u2192 Billing \u2192 Billing email** and enter the address.\n\n## Tax / VAT\n\nTo add a tax ID or company details to your invoices, go to **Settings \u2192 Billing \u2192 Tax information**.\n\n## Failed payments\n\nIf a payment fails, Kerabie retries automatically over several days:\n\n| Attempt | Timing |\n|---|---|\n| Initial charge | Billing date |\n| 1st retry | +2 days |\n| 2nd retry | +4 days |\n| 3rd retry | +7 days |\n\nAfter 3 failures, your subscription is downgraded to Free and you'll receive an email.\n\nTo fix a payment failure, go to **Settings \u2192 Billing \u2192 Update payment method**.", headings: [{ level: 2, text: "Download invoices" }, { level: 2, text: "Invoice details" }, { level: 2, text: "Adding a billing email" }, { level: 2, text: "Tax / VAT" }, { level: 2, text: "Failed payments" }] }, { id: "campaigns/billing/plans", url: "https://kerabie.com/docs/campaigns/billing/plans", title: "Plans & Pricing", description: "Compare Kerabie plans and understand what's included.", section: "campaigns", body: `## Plan comparison
545
+
546
+ | Feature | Free | Starter | Pro | Business |
547
+ |---|---|---|---|---|
548
+ | Agents | 1 | 3 | 10 | Unlimited |
549
+ | Conversations/month | Unlimited | Unlimited | Unlimited | Unlimited |
550
+ | AI chats/month | None | 200 | 2,000 | 10,000 |
551
+ | Chat widgets | 1 | 2 | 5 | Unlimited |
552
+ | Visitor tracking | No | No | \u2713 | \u2713 |
553
+ | Knowledge base articles | 10 | 50 | Unlimited | Unlimited |
554
+ | Remove Kerabie branding | No | No | \u2713 | \u2713 |
555
+ | Virtual assistants | No | \u2713 | \u2713 | \u2713 |
556
+ | Custom automations | No | 5 | Unlimited | Unlimited |
557
+ | Campaigns | No | No | \u2713 | \u2713 |
558
+ | Analytics export | No | No | \u2713 | \u2713 |
559
+ | Priority support | No | No | No | \u2713 |
560
+ | SLA | No | No | No | \u2713 |
561
+
562
+ All paid plans include a **14-day free trial**. No credit card required to start.
563
+
564
+ ## Billing cycle
565
+
566
+ Plans are billed monthly or yearly. Yearly plans get 2 months free.
567
+
568
+ ## What counts as an "AI chat"
569
+
570
+ Each time the AI sends a reply to a visitor counts as one AI chat. If the AI escalates to a human agent, subsequent messages from the agent don't count. One conversation may involve multiple AI chats if the visitor sends several questions.
571
+
572
+ ## Overage
573
+
574
+ If you exceed your AI chat limit, the AI stops responding for the rest of the month and conversations go straight to human agents. Upgrade your plan to restore AI; no automatic overage charges.`, headings: [{ level: 2, text: "Plan comparison" }, { level: 2, text: "Billing cycle" }, { level: 2, text: 'What counts as an "AI chat"' }, { level: 2, text: "Overage" }] }, { id: "campaigns/billing/upgrading", url: "https://kerabie.com/docs/campaigns/billing/upgrading", title: "Upgrading & Downgrading", description: "How to change your plan.", section: "campaigns", body: "## Upgrade\n\n1. Go to **Settings \u2192 Billing \u2192 Plans**\n2. Click **Upgrade** on the plan you want\n3. Enter payment details (card via Paystack)\n4. Confirm: your new plan is active immediately\n\nWhen you upgrade mid-cycle, you're charged a pro-rated amount for the remaining days.\n\n## Downgrade\n\n1. Go to **Settings \u2192 Billing \u2192 Plans**\n2. Click **Downgrade**\n3. Confirm\n\nDowngrades take effect at the end of your current billing period. You keep your current plan's features until then.\n\n Downgrading may reduce your agent seats, AI chat quota, or widget count. Features above the new plan's limit will be deactivated at the end of the billing period.\n\n## Cancel\n\nGo to **Settings \u2192 Billing \u2192 Cancel subscription**. Your plan continues until the end of the billing period, then switches to Free.\n\nYour data is preserved on the Free plan; you don't lose conversation history when you cancel.\n\n## Reactivate\n\nIf you cancelled and want to reactivate, just go to **Settings \u2192 Billing \u2192 Plans** and choose a plan. Your previous data is intact.", headings: [{ level: 2, text: "Upgrade" }, { level: 2, text: "Downgrade" }, { level: 2, text: "Cancel" }, { level: 2, text: "Reactivate" }] }, { id: "campaigns/overview", url: "https://kerabie.com/docs/campaigns/overview", title: "Campaigns", description: "Proactively reach visitors with targeted messages.", section: "campaigns", body: "Campaigns let you send messages to visitors proactively, before they ask for help. Unlike reactive support, campaigns start the conversation on your terms.\n\n<Note>Campaigns are available on **Starter** and above.</Note>\n\n## Campaign types\n\n| Type | What it does |\n|---|---|\n| **Proactive message** | Triggers a message when a visitor spends time on a specific page |\n| **Announcement** | Sends a one-time broadcast to all active visitors at once |\n\n## When to use each type\n\n**Proactive messages** are ideal for:\n- Offering help on a pricing or checkout page before visitors leave\n- Guiding new users through onboarding steps\n- Flagging a known issue on a page affected by downtime\n\n**Announcements** are ideal for:\n- Notifying visitors of a new feature or product launch\n- Sharing a limited-time offer with everyone currently on site\n- Alerting users to scheduled maintenance\n\n## Campaign vs automation\n\nBoth campaigns and automations can trigger messages; the difference is scope:\n\n- **Automations** react to specific conversation events (conversation created, message received, etc.)\n- **Campaigns** target visitors by page, session behaviour, or a one-time broadcast, even before a conversation starts\n\n## Getting started\n\n- [Proactive messages](/docs/campaigns/proactive): set up page-specific triggers\n- [Announcements](/docs/campaigns/announcements): send a broadcast to all active visitors\n\n## Analytics\n\nView campaign performance under **Analytics \u2192 Campaigns**:\n- Messages sent\n- Open rate (visitor saw the message)\n- Reply rate (visitor replied, starting a conversation)\n- Conversion rate (conversation was resolved)", headings: [{ level: 2, text: "Campaign types" }, { level: 2, text: "When to use each type" }, { level: 2, text: "Campaign vs automation" }, { level: 2, text: "Getting started" }, { level: 2, text: "Analytics" }] }, { id: "campaigns/proactive", url: "https://kerabie.com/docs/campaigns/proactive", title: "Proactive Messages", description: "Trigger a message when a visitor spends time on a specific page.", section: "campaigns", body: 'A proactive message is an automated chat message that appears to a visitor based on the page they\'re on and how long they\'ve been there. It starts the conversation before they have to ask.\n\n## Creating a proactive message\n\n1. Go to **Campaigns \u2192 New campaign \u2192 Proactive message**\n2. Set the **trigger conditions**:\n - **Page URL**: exact match, contains, or starts with\n - **Time on page**: e.g. after 30 seconds\n - **Visit count**: e.g. only on first visit, or after 3+ visits\n3. Write the **message** (supports template variables)\n4. Set **frequency**: show once per session, once per day, or always\n5. Choose whether to show in **widget** or as a **full banner**\n6. Click **Activate**\n\n## Example campaigns\n\n**Help on the pricing page:**\n- Trigger: URL contains `/pricing`, time on page \u2265 20 seconds\n- Message: "Hi! Got questions about our plans? I\'m happy to help."\n- Frequency: Once per session\n\n**Onboarding nudge:**\n- Trigger: URL contains `/app/onboarding`, visit count = 1\n- Message: "Welcome! Need a hand getting set up? It takes about 5 minutes."\n- Frequency: Once per visitor\n\n**Checkout recovery:**\n- Trigger: URL contains `/checkout`, time on page \u2265 60 seconds\n- Message: "Still deciding? We offer a 14-day money-back guarantee, happy to answer questions."\n- Frequency: Once per day\n\n## Template variables\n\n| Variable | Value |\n|---|---|\n| `{{visitor_name}}` | Visitor\'s name if known |\n| `{{page_title}}` | Current page title |\n\n## Frequency options\n\n| Option | Behaviour |\n|---|---|\n| Once per session | Shown once while the visitor is on the site; resets when they close the tab |\n| Once per visitor | Shown only once, ever; stored in a cookie |\n| Once per day | Resets at midnight |\n| Always | Shown every time conditions are met |\n\n## Pausing and stopping\n\nToggle a campaign off from the Campaigns list at any time. Pausing stops new messages immediately; it doesn\'t affect conversations already started.', headings: [{ level: 2, text: "Creating a proactive message" }, { level: 2, text: "Example campaigns" }, { level: 2, text: "Template variables" }, { level: 2, text: "Frequency options" }, { level: 2, text: "Pausing and stopping" }] }, { id: "changelog", url: "https://kerabie.com/docs/changelog", title: "Changelog", description: "What's new in Kerabie.", section: "changelog", body: "## October 2026\n\n### Live calling and the AI voice agent\nCustomers can now call your team from the website widget and from your mobile apps.\n\n- **Calls:** a phone button (and a camera button when video is on) in the chat header. Free agents are rung together in the dashboard and the Kerabie mobile app, the first to answer takes the call, and callers see their place in line when everyone is busy. Calls are on by default for new accounts, and the 14-day Pro trial includes call minutes. Turn them on or off under **Settings \u2192 Calls**. Video calls show each side's camera, with your own as a small picture in the corner.\n- **AI voice agent:** when nobody is free, an AI answers from your Knowledge Base, can use your AI Tools, and always says it is an AI. See [AI voice agent](/docs/ai/voice-agent).\n- **Away means away:** if your widget is set to away, or nobody is available, a caller is told right away to send a message instead of waiting in a queue that would end in a missed call.\n- **Details first:** if you ask customers for their details before a chat, a call asks the same, once. Customers your site or app has already identified are not asked.\n- **SDKs:** React Native `react-native-kerabie-helpdesk` 2.4.0 and Flutter `kerabie_helpdesk_flutter` 0.4.0 and `kerabie_helpdesk_flutter_calls` 0.2.0 put the call buttons in the chat header. See [Calls](/docs/widget/calls).\n\n### Team handbook and goal boards\nTwo new things for your own team, in the web dashboard and the mobile app.\n\n- **Team handbook:** private pages for your agents (company info, response guidelines, processes) with sections and search. Mark a page as required reading and it appears in each new agent's **Start here** checklist; editing a required page asks everyone to read it again. Managers on Enterprise get a report of who has read what. The handbook is never shown to visitors or used by the AI. See [Team handbook](/docs/team/handbook).\n- **Goals:** kanban boards with response-goals and onboarding templates, assignees, due dates and a done column. Drag cards on the web; move them from the card sheet in the app. Boards per plan: Basic 1, Pro 3, Enterprise unlimited. See [Goals boards](/docs/team/goals).\n\n### Redesigned dashboard\nThe dashboard and Conversations page have a new look, with \u2318K search across conversations and contacts.\n\n- **Dashboard:** period-over-period changes on each number, an AI-vs-human conversation chart (7 or 30 days), the live queue, open tickets with SLA, team presence, and a setup checklist that now reflects what you've actually connected.\n- **AI card:** share of conversations Kerabie AI resolved without an agent, share handed off, and an estimate of agent-hours saved.\n- **Conversations:** status tabs with counts, tags on each row, an internal-note tab, an on-demand AI suggested reply and AI summary, previous conversations for the same customer, and **Take over** when Kerabie AI is handling a chat.\n- **Search:** conversation search is now case-insensitive.\n- **Tickets:** a new Tickets page with an overview strip, List and Board views, bulk actions, CSV export (Pro and above), agent-created tickets, and tags you can create on the spot. A ticket with no customer email now says so, with a quick way to add one.\n- **Analytics:** the Analytics page is redesigned with six tabs: Overview, Agents, Channels, AI and questions, Customers and Pages. New: median first-response time with change vs the previous period, per-agent and per-channel response times and CSAT, the questions customers ask most (with how often the AI answered alone and the customer's rating), questions the AI couldn't answer, new vs returning customers, top countries and customers, a busiest-times map in your timezone, and which pages start the most conversations. Export the report as CSV on Pro and up. Available in the API and SDK as `analytics.getInsights()`.\n- **Campaigns:** a redesigned Campaigns page with draft, active and paused states, a funnel per campaign (shown, opened, clicked, replied), a button with an action (open chat, open the Knowledge Base or a link), and rules for pages, audience (new, returning, hasn't chatted, or visitors from a chosen country) and frequency. New triggers: scroll depth and page load, alongside time on page and exit intent. Duplicate a campaign as a draft. How many can be live at once depends on your plan. Campaigns also appear in React Native apps (page-load and time-in-app triggers) and can be managed from the mobile app. Available in the API and SDK as `campaigns` (`list`, `getStats`, `get`, `create`, `update`, `duplicate`, `delete`).\n- **Contacts:** type (lead, customer, VIP), company, owner, notes and tags on every contact, a last-contact time, overview numbers, search across name, email, company and tags, add by hand, and CSV import (bad rows are reported by number, existing emails skipped). Contact tags use the same tags as conversations and tickets, and can be created on the spot.\n- **Visitors:** the Visitors page now shows recent visitors as well as live ones, with visit counts, returning visitors, top countries, block and allow status, and CSV export (Pro and above). Visitor history is kept for 30 days. *Save as contact* now works for visitors who never sent a message.\n\n- **AI Assistant:** a redesigned AI Assistant page with Setup, Guardrails and handoff, and Test chat tabs. Set the reply language and length, choose guardrails (only use Knowledge Base facts, never ask for sensitive details, refunds need a person, allow discounts), block topics, and set a minimum Knowledge Base match so the AI hands off questions your articles don't cover. Choose which team gets AI handoffs, what the AI says outside business hours, and whether the agent gets an AI summary. The AI can now answer on WhatsApp, Instagram and email (off by default, per channel), and agent replies on WhatsApp and Instagram are now delivered to the customer. Try changes in Test chat before saving. Also in the mobile app.\n- **AI Tools:** a redesigned AI Tools page with usage and success rate per tool, templates, a quality hint for descriptions, and a real test that shows what the AI would see. Duplicate a tool (the copy starts switched off). How many tools you can have depends on your plan. Endpoints must be public addresses, and secrets are never shown again after saving. Available in the API and SDK as `aiTools` (`list`, `get`, `create`, `update`, `duplicate`, `delete`, `test`) and in the mobile app. See the [AI Tools API](/docs/api-reference/ai-tools).\n\n- **Knowledge Base:** a redesigned Knowledge Base page with search, filters (type, status, category), sorting and pagination, grid and list views, a preview of how customers see an article, and an Analytics tab: most viewed articles, customer questions the AI couldn't answer (what to write next), lowest rated articles and articles that need a refresh. Each article shows its views, how helpful visitors found it and how often the AI cited it. The chat widget and the React Native package now ask \"Was this helpful?\" under articles. Import accepts CSV (one article per row, no AI needed), Word and Markdown files, and reports the rows it skipped. Available in the API and SDK as `kb.getStats()` and `kb.getInsights()`, and in the mobile app.\n- **MCP:** two ways to use the Model Context Protocol. **Kerabie MCP server**: connect Claude, Cursor or any MCP client to Kerabie. Without an API key it helps you build: search the docs, list API endpoints, read the TypeScript and React Native SDK types and follow integration guides. With a key it works with your account (conversations, tickets, contacts, campaigns, the Knowledge Base, analytics and AI tools), with a read-only mode. **MCP servers as tool sources:** on the AI Tools page (web and mobile), connect a remote MCP server and switch on the tools your chatbot may use; each counts towards your plan's AI tool limit. Available in the API and SDK as `mcpServers`. See [MCP server](/docs/sdks/mcp) and [Connecting MCP servers](/docs/ai/mcp-servers).\n\n### API and SDK\nNew in `@kerabie/sdk` and the `/v1` API: `analytics.getTicketSla()`, `analytics.getSetupStatus()`, `conversations.getHistory()`, `conversations.takeOver()`, `tickets.getStats()`, `tickets.export()`, `visitors.list()`, `visitors.getOverview()`, `visitors.export()` and a new `contacts` resource (`list`, `getStats`, `get`, `create`, `update`, `import`). `analytics.getSummary()` now returns `deltas`, `ai` and a daily AI/human `trend`; `conversations.list()` returns tags, `aiHandling` and per-status `meta.counts`. See the [Analytics](/docs/api-reference/analytics) and [Conversations](/docs/api-reference/conversations) references.\n\nThe analytics response types in earlier SDK builds did not match the API, and the `/v1/analytics` and `/v1/conversations` routes they called were not available; both now match.\n\n---\n\n## May 2026\n\n### Admin Portal\nBuilt the internal admin portal for Kerabie staff. Includes:\n- User and org management\n- Billing dashboard (MRR, churn, failed payments)\n- Platform analytics\n- VA marketplace moderation\n- System health monitoring\n\n### Help Center\nLaunched the public Help Center at [kerabie.com/help-center](https://kerabie.com/help-center). 56 articles across 9 categories: Getting Started, Conversations, Agents & Teams, Visitors, AI & Chatbot, Billing & Plans, Widget, Security, and Notifications.\n\n### Offline Email Notifications\nWhen a visitor starts a conversation and no agents are online, the org owner now receives an immediate email notification.\n\n### Trial Warning Emails\nOrganisations on a trial now receive a 7-day advance warning email before their trial expires.\n\n### Analytics: Live Visitor Count\nThe analytics dashboard now shows a real-time active visitor count (previously always showed 0).\n\n---\n\n## April 2026\n\n### AI Chatbot\nReleased the AI chatbot powered by your knowledge base. Handles common questions automatically with configurable confidence threshold and human escalation.\n\n### Knowledge Base\nAdded the Knowledge Base feature: create, organise, and publish articles. Articles feed the AI and appear in the widget's FAQ tab.\n\n### Webhooks\nLaunched the webhooks system. Receive real-time events (`conversation.created`, `message.sent.*`, `visitor.*`) with HMAC signature verification and automatic retry.\n\n### Virtual Assistant Marketplace\nLaunched the VA marketplace. Businesses can browse, hire, and manage verified virtual assistants directly from the dashboard.\n\n---\n\n## March 2026\n\n### Chat Widget\nLaunched the embeddable chat widget: install via script tag, WordPress plugin, or React Native SDK.\n\n### Real-time Conversations\nWebSocket-powered real-time messaging between visitors and agents.\n\n### Push Notifications\nMobile push notifications via Expo Push for iOS and Android agents.\n\n### Multi-language Support\nWidget UI available in 40+ languages. Auto-detected from browser locale.", headings: [{ level: 2, text: "October 2026" }, { level: 3, text: "Live calling and the AI voice agent" }, { level: 3, text: "Team handbook and goal boards" }, { level: 3, text: "Redesigned dashboard" }, { level: 3, text: "API and SDK" }, { level: 2, text: "May 2026" }, { level: 3, text: "Admin Portal" }, { level: 3, text: "Help Center" }, { level: 3, text: "Offline Email Notifications" }, { level: 3, text: "Trial Warning Emails" }, { level: 3, text: "Analytics: Live Visitor Count" }, { level: 2, text: "April 2026" }, { level: 3, text: "AI Chatbot" }, { level: 3, text: "Knowledge Base" }, { level: 3, text: "Webhooks" }, { level: 3, text: "Virtual Assistant Marketplace" }, { level: 2, text: "March 2026" }, { level: 3, text: "Chat Widget" }, { level: 3, text: "Real-time Conversations" }, { level: 3, text: "Push Notifications" }, { level: 3, text: "Multi-language Support" }] }, { id: "conversations/assigning", url: "https://kerabie.com/docs/conversations/assigning", title: "Assigning Conversations", description: "Manually or automatically assign conversations to agents or teams.", section: "conversations", body: "Conversations can be assigned to a specific agent or team so the right person handles each request.\n\n## Manual assignment\n\n1. Open a conversation\n2. In the right-hand panel, find **Assigned to**\n3. Click **Assign** and pick an agent or team from the list\n4. The agent receives a notification and the conversation appears in their queue\n\n## Automatic assignment via Automations\n\nUse automations to route conversations without manual work:\n\n1. Go to **Settings \u2192 Automations \u2192 New automation**\n2. Set trigger: `Conversation created`\n3. Add condition (optional): e.g. page URL, visitor attribute, or message keyword\n4. Set action: `Assign agent` or `Assign team`\n\nExample: route pricing enquiries to Sales:\n- **Trigger**: Conversation created\n- **Condition**: Page URL contains `/pricing`\n- **Action**: Assign to team \u2192 Sales\n\nSee [Automations](/docs/automations/overview) for the full reference.\n\n## Round-robin team assignment\n\nWhen a conversation is assigned to a **team**, Kerabie distributes it to the next available agent using round-robin. Agents must be set as **Available** to receive new conversations.\n\n## Unassigned conversations\n\nConversations with no assignee appear in the **Unassigned** filter in the inbox. Supervisors can monitor this view and redistribute work.\n\n## Reassigning\n\nClick the current assignee in the right panel and choose a different agent. The conversation keeps its current status.", headings: [{ level: 2, text: "Manual assignment" }, { level: 2, text: "Automatic assignment via Automations" }, { level: 2, text: "Round-robin team assignment" }, { level: 2, text: "Unassigned conversations" }, { level: 2, text: "Reassigning" }] }, { id: "conversations/history-export", url: "https://kerabie.com/docs/conversations/history-export", title: "History & Export", description: "Search, filter, and export conversation history.", section: "conversations", body: "All conversations, including resolved and closed ones, are stored and searchable. Export them for reporting, audits, or CRM imports.\n\n## Viewing history\n\nIn the **Conversations** inbox, switch to the **History** tab to see resolved and closed conversations. Filter by:\n\n- Visitor name or email\n- Message content keyword\n- Date range\n- Assigned agent or team\n- Tags\n\n## Exporting to CSV\n\n<Note>CSV export is available on **Pro** and above.</Note>\n\n1. Go to **Analytics \u2192 Conversations**\n2. Set the date range and filters\n3. Click **Export \u2192 CSV**\n\n### Export fields\n\n| Field | Description |\n|---|---|\n| `id` | Unique conversation ID |\n| `visitor_name` | Visitor's name if collected |\n| `visitor_email` | Visitor's email if collected |\n| `status` | Final status at export time |\n| `assigned_to` | Agent email |\n| `created_at` | First message timestamp |\n| `resolved_at` | Resolution timestamp |\n| `message_count` | Total messages |\n| `tags` | Comma-separated tags |\n| `rating` | CSAT rating if collected |\n\n## Retention\n\nConversations are retained indefinitely on all paid plans. Free plan conversations older than 90 days are archived and not directly searchable.\n\n## Data deletion (GDPR)\n\nTo erase a visitor's data:\n\n1. Find the visitor in **Contacts**\n2. Click **Delete contact**\n3. Their profile is removed and conversation messages are anonymised", headings: [{ level: 2, text: "Viewing history" }, { level: 2, text: "Exporting to CSV" }, { level: 3, text: "Export fields" }, { level: 2, text: "Retention" }, { level: 2, text: "Data deletion (GDPR)" }] }, { id: "conversations/overview", url: "https://kerabie.com/docs/conversations/overview", title: "Conversations", description: "How conversations work in Kerabie.", section: "conversations", body: "A conversation starts when a visitor sends their first message through the widget. Every conversation has a status, an optional assigned agent, and a full message history.\n\n## Conversation lifecycle\n\n```\nVisitor opens widget \u2192 sends message\n \u2193\nConversation created \u2192 status: WAITING\n \u2193\nAgent picks up \u2192 status: ACTIVE (or AI handles it)\n \u2193\nIssue resolved \u2192 status: RESOLVED\n \u2193\nAfter 24h of no activity \u2192 status: CLOSED\n```\n\n## Statuses\n\n| Status | Meaning |\n|---|---|\n| `WAITING` | New; no agent has responded yet |\n| `ACTIVE` | Agent or AI is handling it |\n| `RESOLVED` | Marked resolved by an agent |\n| `CLOSED` | Automatically closed after inactivity |\n\n## The inbox\n\nThe **Conversations** page in the dashboard is your inbox. It shows:\n\n- All open conversations, sorted by last message\n- Unread count badge per conversation\n- Filter by status, agent, tag, or date\n- Search across message content and customer details\n\n## Assigning conversations\n\nConversations can be assigned to specific agents or teams:\n\n- **Manual**: Click **Assign** in any open conversation\n- **Automatic**: Use [Automations](/automations/overview) to route based on rules (page URL, visitor country, keyword, etc.)\n- **Round-robin**: Enable in **Settings \u2192 Routing** to distribute conversations equally across online agents\n\n## Tags\n\nAdd tags to conversations for organisation and reporting. Tags can be added manually or automatically via automations. Examples: `billing`, `bug-report`, `feature-request`, `vip`.", headings: [{ level: 2, text: "Conversation lifecycle" }, { level: 2, text: "Statuses" }, { level: 2, text: "The inbox" }, { level: 2, text: "Assigning conversations" }, { level: 2, text: "Tags" }] }, { id: "conversations/quick-responses", url: "https://kerabie.com/docs/conversations/quick-responses", title: "Quick Responses", description: "Save and reuse templated replies for common questions.", section: "conversations", body: "Quick responses (canned replies) are pre-written messages agents can insert with a keystroke, eliminating repetitive typing for common questions.\n\n## Creating a quick response\n\n1. Go to **Settings \u2192 Widget Settings \u2192 Quick Responses**\n2. Click **New response**\n3. Enter a **shortcut** (e.g. `greet`) and the **message body**\n4. Save\n\n## Using a quick response\n\nIn the reply box, type `/` followed by the shortcut (e.g. `/greet`). A dropdown shows matching responses. Click to insert, then edit before sending if needed.\n\nYou can also click the **\u26A1 lightning bolt** icon in the reply toolbar to browse all responses.\n\n## Template variables\n\nThese placeholders are replaced automatically when the response is inserted:\n\n| Variable | Replaced with |\n|---|---|\n| `{{visitor_name}}` | The visitor's name (if known) |\n| `{{agent_name}}` | The logged-in agent's display name |\n| `{{org_name}}` | Your organisation name |\n\nExample:\n```\nHi {{visitor_name}}, I'm {{agent_name}} from {{org_name}}. How can I help?\n```\n\n## Tips\n\n- Keep shortcuts short: `greet`, `sorry`, `refund`, `close`, `hours`\n- Write in a neutral tone agents can personalise before sending\n- Only admins can create or delete responses; all agents can use them", headings: [{ level: 2, text: "Creating a quick response" }, { level: 2, text: "Using a quick response" }, { level: 2, text: "Template variables" }, { level: 2, text: "Tips" }] }, { id: "conversations/statuses", url: "https://kerabie.com/docs/conversations/statuses", title: "Conversation Statuses", description: "The four statuses a conversation moves through: WAITING, ACTIVE, RESOLVED, and CLOSED.", section: "conversations", body: 'Every conversation in Kerabie has a status that reflects where it is in its lifecycle.\n\n## The four statuses\n\n| Status | Meaning |\n|---|---|\n| **Waiting** | A visitor sent a message and no agent has replied yet |\n| **Active** | An agent is engaged; the conversation is open and in progress |\n| **Resolved** | The issue was handled; the conversation is closed but retrievable |\n| **Closed** | Permanently archived: visible in history but removed from the active queue |\n\n## Status transitions\n\n```\nVisitor sends message\n \u2193\n WAITING \u2500\u2500\u2500\u2500 agent replies \u2500\u2500\u2500\u2500\u2192 ACTIVE\n \u2193 \u2193\n (auto-resolve agent resolves\n after timeout) \u2193\n RESOLVED\n \u2193\n (manual close\n or auto-close)\n \u2193\n CLOSED\n```\n\n## Changing status\n\nIn the conversation panel, use the status dropdown in the top-right corner:\n\n- **Resolve**: marks the conversation resolved and removes it from the main queue\n- **Close**: archives it permanently\n- **Reopen**: moves a resolved conversation back to Active\n\n## Auto-resolve\n\nYou can configure Kerabie to automatically resolve conversations after a period of inactivity:\n\n1. Go to **Settings \u2192 Widget Settings \u2192 Auto-resolve**\n2. Set the inactivity threshold (e.g. 24 hours)\n3. Save\n\nConversations that are auto-resolved appear with a "System" label in the activity log.\n\n## Filtering by status\n\nIn the **Conversations** inbox, use the status filter buttons at the top to view:\n\n- All conversations\n- Waiting only\n- Active only\n- Resolved\n\nClosed conversations are available under **History**.', headings: [{ level: 2, text: "The four statuses" }, { level: 2, text: "Status transitions" }, { level: 2, text: "Changing status" }, { level: 2, text: "Auto-resolve" }, { level: 2, text: "Filtering by status" }] }, { id: "getting-started/add-to-website", url: "https://kerabie.com/docs/getting-started/add-to-website", title: "Add to Your Website", description: "Install the Kerabie widget on any website.", section: "getting-started", body: "See the [Quickstart](/docs/quickstart) for the fastest path, or choose your platform:\n\n- [HTML script tag](/docs/widget/install-html): any site, paste two lines of code\n- [WordPress](/docs/widget/install-wordpress): install the plugin, no code required\n- [Webflow / Squarespace / Wix](/docs/widget/install-webflow): no-code builders\n- [Shopify](/docs/widget/install-shopify): e-commerce stores\n- [React Native](/docs/sdks/react-native): iOS and Android apps", headings: [] }, { id: "getting-started/first-conversation", url: "https://kerabie.com/docs/getting-started/first-conversation", title: "Your First Conversation", description: "Test that everything is working end to end.", section: "getting-started", body: "After installing the widget:\n\n1. Open your site in a browser\n2. Click the chat bubble in the corner\n3. Type a test message and hit send\n\nNow open the Kerabie dashboard \u2192 **Conversations**. Your test message appears in real time.\n\nClick **Reply**, type a response, and send; it appears in the widget immediately.\n\n If you see the exchange in both places, everything is working correctly.\n\n## Troubleshooting\n\n**Widget doesn't appear**\n- Check the widget key in your script matches the one in Settings \u2192 Widget\n- Clear your browser cache and hard-reload (`Ctrl+Shift+R`)\n- Check for JavaScript errors in the browser console\n\n**Message not showing in dashboard**\n- Make sure you're logged in to the right Kerabie account\n- Check you're viewing the correct organisation if you have multiple\n\n**Still stuck?** Open a conversation via the widget on [kerabie.com](https://kerabie.com) and we'll help.", headings: [{ level: 2, text: "Troubleshooting" }] }, { id: "getting-started/invite-agents", url: "https://kerabie.com/docs/getting-started/invite-agents", title: "Invite Your First Agent", description: "Add team members to your Kerabie workspace.", section: "getting-started", body: "## Invite an agent\n\n1. Go to **Settings \u2192 Agents \u2192 Invite agent**\n2. Enter their email address\n3. Choose their role: **Agent** or **Admin**\n4. Click **Send invite**\n\nThey'll receive an email with a link to join your workspace. If they don't have a Kerabie account yet, they'll be prompted to create one.\n\n## Roles\n\n| Role | Can do |\n|---|---|\n| **Agent** | View and reply to conversations, manage their own settings |\n| **Admin** | Everything agents can do + invite agents, change widget settings, view billing |\n\n## Alternative: Hire a virtual assistant\n\nDon't have in-house support staff? Browse verified virtual assistants on the [VA Marketplace](/agents/virtual-assistants) and hire one directly from the dashboard.", headings: [{ level: 2, text: "Invite an agent" }, { level: 2, text: "Roles" }, { level: 2, text: "Alternative: Hire a virtual assistant" }] }, { id: "index", url: "https://kerabie.com/docs/", title: "What is Kerabie?", description: "Live chat, AI support, and virtual assistants; all in one platform.", section: "index", body: 'Kerabie is a customer support platform that lets you add live chat to any website in under 5 minutes. It combines real-time messaging, an AI chatbot powered by your knowledge base, and an on-demand marketplace of verified virtual assistants.\n\n## What you can do\n\n <Card title="Live Chat" icon="message" href="/widget/overview">\n Add a chat widget to your site. Visitors chat and agents reply in real time.\n </Card>\n <Card title="AI Chatbot" icon="robot" href="/ai/overview">\n Connect your knowledge base to an AI that handles common questions automatically.\n </Card>\n <Card title="Virtual Assistants" icon="users" href="/agents/virtual-assistants">\n Hire verified support agents from the Kerabie marketplace when you need extra help.\n </Card>\n <Card title="Automations" icon="bolt" href="/automations/overview">\n Set rules to route conversations, trigger messages, and assign agents automatically.\n </Card>\n\n## How it works\n\n1. **Install the widget**: paste one script tag into your site\'s `<head>`. No framework required.\n2. **Customize**: set your brand colors, welcome message, and business hours.\n3. **Connect AI**: add your FAQ articles or import your existing docs. The AI answers immediately.\n4. **Invite agents**: add your team or hire a virtual assistant from the marketplace.\n5. **Track everything**: conversations, response times, satisfaction scores, and visitor behavior in one dashboard.\n\n## Plans\n\n| Feature | Free | Starter | Pro | Business |\n|---|---|---|---|---|\n| Agents | 1 | 3 | 10 | Unlimited |\n| AI chats/month | None | 200 | 2,000 | 10,000 |\n| Visitor tracking | No | No | \u2713 | \u2713 |\n| Custom branding removal | No | No | \u2713 | \u2713 |\n| Virtual assistants | No | \u2713 | \u2713 | \u2713 |\n\n All plans include unlimited conversations. Limits apply to agents and AI usage.\n\n## Next steps\n\n <Card title="5-minute quickstart" icon="rocket" href="/quickstart">\n Get your first conversation in under 5 minutes.\n </Card>\n <Card title="API Reference" icon="code" href="/api-reference/overview">\n Build custom integrations with the REST API.\n </Card>', headings: [{ level: 2, text: "What you can do" }, { level: 2, text: "How it works" }, { level: 2, text: "Plans" }, { level: 2, text: "Next steps" }] }, { id: "integrations/overview", url: "https://kerabie.com/docs/integrations/overview", title: "Integrations", description: "Connect Kerabie to your other tools and platforms.", section: "integrations", body: 'Kerabie integrates with your website, mobile apps, and third-party services so you can manage all customer conversations in one place.\n\n## Available integrations\n\n <Card title="WordPress" href="/docs/integrations/wordpress">\n Install the official plugin with one click; no code required.\n </Card>\n <Card title="React Native" href="/docs/integrations/react-native">\n Embed the chat widget in your iOS or Android app.\n </Card>\n <Card title="Webhooks" href="/docs/integrations/webhooks">\n Receive real-time HTTP POST events for conversations, messages, and visitors.\n </Card>\n <Card title="REST API" href="/docs/api-reference/overview">\n Build custom integrations using the Kerabie REST API.\n </Card>\n\n## Coming soon\n\n| Integration | Status |\n|---|---|\n| WhatsApp Business | In development |\n| Instagram DM | In development |\n| Facebook Messenger | Planned |\n| Slack | Planned |\n| Zapier | Planned |\n\nVote on upcoming integrations or request a new one via the [Feature Request](/feature-request) page.\n\n## Custom integrations\n\nUse the [REST API](/docs/api-reference/overview) and [Webhooks](/docs/integrations/webhooks) to build any integration not listed above. The API gives you full read/write access to conversations, visitors, agents, and knowledge base content.\n\n## Managing integrations\n\nAll active integrations are visible under **Settings \u2192 Integrations** in your dashboard. From there you can:\n\n- Generate and revoke API keys\n- Create and manage webhook endpoints\n- Configure allowed widget domains', headings: [{ level: 2, text: "Available integrations" }, { level: 2, text: "Coming soon" }, { level: 2, text: "Custom integrations" }, { level: 2, text: "Managing integrations" }] }, { id: "integrations/react-native", url: "https://kerabie.com/docs/integrations/react-native", title: "React Native SDK", description: "Add Kerabie live chat to your iOS or Android app.", section: "integrations", body: 'The Kerabie React Native SDK lets you embed a fully functional chat widget inside your iOS or Android app with a few lines of code.\n\n## Installation\n\n```bash\nnpm install react-native-kerabie-helpdesk\n```\n\nFor Expo managed workflow, also run:\n\n```bash\nnpx expo install react-native-kerabie-helpdesk\n```\n\n## Basic setup\n\nWrap your app (or just the screen where you want chat) with the provider:\n\n```tsx\n\nexport default function App() {\n return (\n <KerAbieProvider widgetKey="wk_yourkey_here">\n {/* your app */}\n </KerAbieProvider>\n );\n}\n```\n\n## Opening the chat widget\n\n```tsx\n\nfunction SupportButton() {\n const { openChat } = useKerabie();\n return <Button title="Chat with us" onPress={openChat} />;\n}\n```\n\n## Identifying the user\n\nPass the logged-in user\'s details so agents know who they\'re talking to:\n\n```tsx\n\n```\n\n## Configuration options\n\n| Prop | Type | Description |\n|---|---|---|\n| `widgetKey` | `string` | Required; your Widget Key |\n| `user` | `object` | Optional: `name`, `email`, `userId` |\n| `primaryColor` | `string` | Override the widget accent colour |\n| `locale` | `string` | Language code (e.g. `en`, `fr`, `es`) |\n\n## Getting your Widget Key\n\n1. Go to [kerabie.com/app](https://kerabie.com/app)\n2. **Settings \u2192 Chat Widget**\n3. Copy the **Widget Key** (it starts with `wk_`)\n\n## Full SDK reference\n\nFor the complete API including events, methods, and advanced configuration, see the [TypeScript SDK docs](/docs/sdks/typescript).', headings: [{ level: 2, text: "Installation" }, { level: 2, text: "Basic setup" }, { level: 2, text: "Opening the chat widget" }, { level: 2, text: "Identifying the user" }, { level: 2, text: "Configuration options" }, { level: 2, text: "Getting your Widget Key" }, { level: 2, text: "Full SDK reference" }] }, { id: "integrations/webhooks", url: "https://kerabie.com/docs/integrations/webhooks", title: "Webhooks", description: "Receive real-time event notifications from Kerabie to your server.", section: "integrations", body: 'Webhooks let Kerabie POST to your server whenever something happens: a new conversation, a message, a resolved ticket. Use them to sync with your CRM, trigger Slack alerts, or run any custom workflow.\n\n<Info>Available on Starter, Pro, and Business plans.</Info>\n\n## Setup\n\n1. Go to **Settings \u2192 API & Webhooks \u2192 Webhooks**\n2. Click **Add Endpoint**\n3. Enter your HTTPS URL (must be publicly reachable)\n4. Select which events to receive\n5. Save: copy the **Webhook Secret** shown once\n\n## Events\n\n| Event | When it fires |\n|---|---|\n| `conversation.created` | A visitor opens a new conversation |\n| `conversation.resolved` | A conversation is marked resolved |\n| `conversation.closed` | A conversation is closed |\n| `message.sent.agent` | An agent sends a message |\n| `message.sent.customer` | A visitor sends a message |\n| `visitor.started` | A new visitor session begins |\n| `visitor.page_view` | A visitor views a page *(Pro)* |\n\n## Payload format\n\n```json\n{\n "event": "message.sent.agent",\n "orgId": 42,\n "timestamp": "2026-05-26T14:32:00.000Z",\n "data": {\n "conversationId": 101,\n "messageId": 5231,\n "body": "Hi there! How can I help?",\n "agentId": 7,\n "agentName": "Sarah"\n }\n}\n```\n\n## Verifying the signature\n\nEvery request includes headers to confirm it came from Kerabie:\n\n```http\nX-Kerabie-Event: message.sent.agent\nX-Kerabie-Signature: sha256=abc123...\nX-Kerabie-Timestamp: 1716733920\n```\n\n <Tab title="Node.js (manual)">\n ```javascript\n const crypto = require(\'crypto\');\n\n function verifyWebhook(rawBody, signature, secret) {\n const expected = \'sha256=\' + crypto\n .createHmac(\'sha256\', secret)\n .update(rawBody)\n .digest(\'hex\');\n return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));\n }\n\n app.post(\'/webhooks/kerabie\', express.raw({ type: \'application/json\' }), (req, res) => {\n const valid = verifyWebhook(\n req.body,\n req.headers[\'x-kerabie-signature\'],\n process.env.KERABIE_WEBHOOK_SECRET,\n );\n if (!valid) return res.status(401).send(\'Invalid signature\');\n\n const event = JSON.parse(req.body);\n // handle event.event, event.data ...\n res.json({ received: true });\n });\n ```\n </Tab>\n <Tab title="Node.js (SDK)">\n ```typescript\n import { KerAbieClient } from \'@kerabie/sdk\';\n\n app.post(\'/webhooks/kerabie\', express.raw({ type: \'application/json\' }), (req, res) => {\n const isValid = KerAbieClient.webhooks.verify(\n req.body,\n req.headers[\'x-kerabie-signature\'] as string,\n process.env.KERABIE_WEBHOOK_SECRET!,\n );\n if (!isValid) return res.status(401).send(\'Invalid signature\');\n\n const event = KerAbieClient.webhooks.parse(req.body);\n console.log(event.event, event.data);\n res.json({ received: true });\n });\n ```\n </Tab>\n\n## Retry policy\n\nIf your endpoint returns non-2xx or times out (> 30 s), Kerabie retries automatically:\n\n| Attempt | Delay |\n|---|---|\n| 1st retry | 5 seconds |\n| 2nd retry | 30 seconds |\n| 3rd retry | 2 minutes |\n| 4th retry | 10 minutes |\n| 5th retry | 30 minutes |\n\nAfter 5 failures the delivery is marked failed. View logs in **Settings \u2192 Webhooks \u2192 Delivery Logs**.\n\n## Best practices\n\n- Respond `200 OK` immediately, then process async (push to a queue).\n- Make your handler **idempotent**; the same event may arrive more than once.\n- Reject events whose `timestamp` is more than 5 minutes old to block replay attacks.', headings: [{ level: 2, text: "Setup" }, { level: 2, text: "Events" }, { level: 2, text: "Payload format" }, { level: 2, text: "Verifying the signature" }, { level: 2, text: "Retry policy" }, { level: 2, text: "Best practices" }] }, { id: "integrations/wordpress", url: "https://kerabie.com/docs/integrations/wordpress", title: "WordPress", description: "Install Kerabie on WordPress with the official plugin.", section: "integrations", body: "The Kerabie WordPress plugin adds the live chat widget to your site in minutes; no code required.\n\n## Installation\n\n### Option A: from the WordPress plugin directory\n\n1. In WP Admin, go to **Plugins \u2192 Add New**\n2. Search for **Kerabie Live Chat**\n3. Click **Install Now**, then **Activate**\n\n### Option B: manual upload\n\n1. Go to **Settings \u2192 Integrations \u2192 Channels** in your Kerabie dashboard\n2. Click **WordPress Plugin \u2192 View Setup** to find the download link\n3. In WP Admin, go to **Plugins \u2192 Add New \u2192 Upload Plugin**\n4. Upload the ZIP and activate\n\n## Connecting your account\n\nAfter activation:\n\n1. In WP Admin, go to **Kerabie \u2192 Settings**\n2. Enter your **Widget Key** (find it in Kerabie under **Settings \u2192 Chat Widget**)\n3. Save settings\n\nThe widget appears on all pages immediately.\n\n## Configuration options\n\n| Option | Description |\n|---|---|\n| **Widget Key** | Required: links the plugin to your Kerabie account |\n| **Show on pages** | All pages, or specific page IDs |\n| **Hide for logged-out users** | Optional: only show to authenticated WP users |\n| **Pass user data** | If enabled, passes the logged-in WP user's name and email to Kerabie automatically |\n\n## Passing user identity (optional)\n\nEnable **Pass user data** in the plugin settings to automatically identify logged-in WordPress users. Kerabie will show their name and email in the conversation, so agents know who they're talking to without asking.\n\n## Troubleshooting\n\n**Widget not appearing:**\n- Check that your Widget Key is correct (no extra spaces)\n- Ensure another plugin isn't blocking script loading (e.g. cookie consent or caching plugins; add `cdn.kerabie.com` to the allowlist)\n- Try disabling other plugins temporarily to check for conflicts\n\n**Caching plugins:**\nAfter installing, clear your cache in WP Rocket, W3 Total Cache, or your hosting provider's cache dashboard.", headings: [{ level: 2, text: "Installation" }, { level: 3, text: "Option A: from the WordPress plugin directory" }, { level: 3, text: "Option B: manual upload" }, { level: 2, text: "Connecting your account" }, { level: 2, text: "Configuration options" }, { level: 2, text: "Passing user identity (optional)" }, { level: 2, text: "Troubleshooting" }] }, { id: "knowledge-base/analytics", url: "https://kerabie.com/docs/knowledge-base/analytics", title: "Knowledge Base analytics", description: "See what customers read, what they can't find, and which articles to fix.", section: "knowledge-base", body: "The **Analytics** tab on the Knowledge Base page turns your articles and your customers' conversations into a to-do list.\n\n## Most viewed\n\nThe five published articles customers open most, with a bar relative to the top one. These are the ones worth keeping accurate.\n\n## Questions with no answer\n\nCustomer questions from the last 30 days that Kerabie AI had no article to answer from, with how often each was asked. Choose **Write** to start a new article with the question as its title. This is the quickest way to improve how many conversations the AI resolves on its own.\n\n## Helpfulness\n\nUnder each article in the chat widget and the mobile SDK, customers can answer **Was this helpful?** Articles with at least 3 votes are listed lowest first, so you can see which answers confuse people. Fixing them improves the AI's answers too, because it reads the same text.\n\nA customer can vote up to three times per article per day.\n\n## Needs a refresh\n\nPublished articles that haven't been edited for 90 days, oldest first, with how long it has been. Open one to check it is still true.\n\n## From code\n\nThe same numbers are available as `kerabie.kb.getStats()` and `kerabie.kb.getInsights()`. See the [Knowledge Base API](/docs/api-reference/knowledge-base).", headings: [{ level: 2, text: "Most viewed" }, { level: 2, text: "Questions with no answer" }, { level: 2, text: "Helpfulness" }, { level: 2, text: "Needs a refresh" }, { level: 2, text: "From code" }] }, { id: "knowledge-base/articles", url: "https://kerabie.com/docs/knowledge-base/articles", title: "Writing Articles", description: "Create, edit, publish and organise Knowledge Base articles.", section: "knowledge-base", body: `Your Knowledge Base holds the FAQs, articles and news that customers read in the chat widget and that Kerabie AI answers from. Publish an article and the AI can use it straight away; there is no separate training step.
575
+
576
+ ## Create an article
577
+
578
+ 1. Open **Knowledge Base** and choose **New article**.
579
+ 2. Enter a **title**, for example *How to reset your password*.
580
+ 3. Choose the **type** (Article, FAQ or News), the **category** and the **status**.
581
+ 4. Write the answer in the editor. Short paragraphs work best, and the counter under the editor tells you if it's too short for the AI to cite well or long enough to split.
582
+ 5. Choose **Publish** to make it live, or **Save draft** to keep working. Drafts and archived articles are never shown to customers or used by the AI.
583
+
584
+ The editor supports headings, bold, italic, underline, bullet and numbered lists, links and code blocks. You can also add a **cover photo** (cropped to 16:9) once the article has been saved, and use **Refine with AI** to tidy the wording. Refining uses part of your monthly AI budget.
585
+
586
+ ## Find your articles
587
+
588
+ Search matches the title, text and category. **Filters** narrow by type, status and category, and you can sort by most viewed, newest or A to Z. Switch between grid and list views; Kerabie remembers your choice. The preview button shows an article the way customers see it in the widget.
589
+
590
+ ## What each card shows
591
+
592
+ | | |
593
+ |---|---|
594
+ | **Views** | How many times customers opened it. |
595
+ | **Helpful** | The share of customers who answered "Was this helpful?" with yes. Shown once at least 3 have voted. |
596
+ | **AI cited** | How many AI answers used the article. |
597
+
598
+ ## Archive or delete
599
+
600
+ - **Archive** hides an article from the widget and the AI but keeps it in your account.
601
+ - **Delete** is permanent and removes the article from the widget straight away.
602
+
603
+ Use archive for seasonal content you may bring back, and delete for content that is wrong.`, headings: [{ level: 2, text: "Create an article" }, { level: 2, text: "Find your articles" }, { level: 2, text: "What each card shows" }, { level: 2, text: "Archive or delete" }] }, { id: "knowledge-base/faq-widget", url: "https://kerabie.com/docs/knowledge-base/faq-widget", title: "FAQ Widget", description: "Show knowledge base articles inside the chat widget.", section: "knowledge-base", body: 'The FAQ widget displays your published knowledge base articles directly inside the Kerabie chat bubble, so visitors can find answers before starting a conversation.\n\n## Enabling the FAQ tab\n\n1. Go to **Settings \u2192 Chat Widget \u2192 Features**\n2. Toggle **Show FAQ tab** on\n3. Save\n\nA **FAQ** tab appears next to the **Chat** tab in the widget. Visitors can search and browse articles without leaving your page.\n\n## How articles appear\n\n- Articles are grouped by category\n- The search bar at the top filters articles by title and content\n- Clicking an article opens it in a full-screen view within the widget\n- A **Start chat** button at the bottom lets visitors escalate to a live agent\n\n## Controlling which articles show\n\nOnly **published** articles appear in the FAQ widget. Draft and archived articles are hidden.\n\nTo reorder articles within a category, drag them in the **Knowledge Base** sidebar.\n\n## Customising the FAQ tab label\n\n1. Go to **Settings \u2192 Chat Widget \u2192 Text overrides**\n2. Find **FAQ tab label** and change it (e.g. "Help" or "Articles")\n3. Save\n\n## Search behaviour\n\nThe FAQ search uses keyword matching across:\n- Article title\n- Article body text\n- Category name\n\nIt is not AI-powered. For AI-based article suggestions in chat, see the [AI Assistant](/docs/ai/setup) docs.\n\n## Analytics\n\nView which articles are being read and searched in **Analytics \u2192 Knowledge Base**. Use this data to identify gaps; if visitors search for a term with no results, consider writing that article.', headings: [{ level: 2, text: "Enabling the FAQ tab" }, { level: 2, text: "How articles appear" }, { level: 2, text: "Controlling which articles show" }, { level: 2, text: "Customising the FAQ tab label" }, { level: 2, text: "Search behaviour" }, { level: 2, text: "Analytics" }] }, { id: "knowledge-base/import-url", url: "https://kerabie.com/docs/knowledge-base/import-url", title: "Import from URL", description: "Crawl your existing docs or website into the knowledge base, choosing exactly which pages to bring in.", section: "knowledge-base", body: "If you already have a help centre, documentation site, or blog, you can crawl that content into Kerabie's knowledge base instead of rewriting everything from scratch.\n\n## How it works\n\n1. Go to **Knowledge Base \u2192 Crawl a website**\n2. Enter the URL of your existing site\n3. Kerabie discovers pages on that domain (from on-page links, and from `sitemap.xml` when one exists) and shows you the list\n4. Pick exactly which pages you want imported; nothing is crawled until you choose\n5. AI extracts and structures each selected page's content into draft articles\n6. Review and publish the ones you want\n\nDiscovery is instant and free. The actual crawl runs in the background (it can take a little while for several pages plus AI processing), so you'll see a progress state while it finishes.\n\n## What gets imported\n\n| Content | Imported? |\n|---|---|\n| Page title | Yes \u2192 article title |\n| Body text and headings | Yes \u2192 article content |\n| Images | No: images aren't imported; add a cover photo to an article afterward if you want one |\n| Navigation, footer, header | No: stripped automatically before AI sees the page |\n\n## Page limits\n\nThe number of pages you can select in one crawl run depends on your plan:\n\n| Plan | Pages per crawl |\n|---|---|\n| Free | Not available |\n| Basic | 20 |\n| Pro | 100 |\n| Enterprise | Unlimited |\n\nThis is a per-run cap; a page you don't select in one run can still be picked up in a later one. Crawling also counts against your plan's shared monthly AI-operations budget (import, crawl, and AI refine draw from the same pool).\n\n## After importing\n\nAll imported articles start as **drafts**. Review each one before publishing:\n\n- Remove any leftover navigation text the AI didn't catch\n- Update the category\n- Add a cover photo if you want one; crawled pages never bring their own images\n- Optionally run **Refine with AI** on an article to tighten the writing further\n\n## Re-crawling\n\nRunning a crawl again on pages you've already imported creates new draft articles rather than updating the existing ones; archive or delete the old version first if you want a clean replacement.", headings: [{ level: 2, text: "How it works" }, { level: 2, text: "What gets imported" }, { level: 2, text: "Page limits" }, { level: 2, text: "After importing" }, { level: 2, text: "Re-crawling" }] }, { id: "knowledge-base/import", url: "https://kerabie.com/docs/knowledge-base/import", title: "Import and crawl", description: "Bring existing content into the Knowledge Base from files or from your website.", section: "knowledge-base", body: "Imported content always arrives as **drafts**, so nothing reaches customers or the AI until you review and publish it.\n\n## Import a file\n\nChoose **Import** on the Knowledge Base page and pick a file (up to 5 MB).\n\n| File | What happens |\n|---|---|\n| **CSV** | One draft per row. No AI is used, so it doesn't use your AI budget. |\n| **Word (.docx), Markdown, text, PDF, JSON** | AI reads the file and splits it into articles. Headings and lists in a Word or Markdown file are kept. |\n\n### CSV format\n\nUse a header row. Only a title and content are required.\n\n| Column | Notes |\n|---|---|\n| `title` (or `question`) | Required. |\n| `content` (or `answer`) | Required. Plain text becomes paragraphs. |\n| `category` | Optional. Defaults to *Imported*. |\n| `type` | Optional: `article`, `faq` or `news`. Rows with a `question` column default to FAQ. |\n\nUp to 500 rows per file. Rows that can't be used (no title, or no content) are skipped and listed by their row number, so you can fix them and import again.\n\n## Crawl a website\n\nChoose **Crawl URL**, enter the address of your help site, and **Find pages**. Select the pages you want (the first ten are ticked for you) and start the crawl. Kerabie reads each page and creates a draft article from it. Crawling runs in the background and uses your monthly AI budget and the page limit of your plan.\n\n## How imported articles are written\n\nImported and crawled text is rewritten into articles that are easy to read: a short answer first, then a heading for each section, steps as numbered lists, options as bullet lists and key terms in bold. Facts, numbers, names, prices and links are kept exactly as they are in the source, and nothing is invented. A long page becomes several focused articles instead of one wall of text.\n\nEach page you select in a crawl is read in full on its own. If an article from an earlier import reads like plain text, open it and choose **Refine with AI** to restructure it the same way.", headings: [{ level: 2, text: "Import a file" }, { level: 3, text: "CSV format" }, { level: 2, text: "Crawl a website" }, { level: 2, text: "How imported articles are written" }] }, { id: "knowledge-base/overview", url: "https://kerabie.com/docs/knowledge-base/overview", title: "Knowledge Base", description: "Create and manage articles that power the AI assistant and the FAQ widget.", section: "knowledge-base", body: 'The Kerabie Knowledge Base is a collection of articles your team writes. It serves two purposes:\n\n1. **AI training**: the AI assistant reads your articles to answer visitor questions automatically\n2. **FAQ widget**: articles appear inside the chat widget so visitors can self-serve before starting a conversation\n\n## Getting started\n\n1. Go to **Knowledge Base** in the sidebar\n2. Click **New article**\n3. Write your article using the rich-text editor\n4. Click **Publish**\n\nThe article is immediately available to the AI and in the FAQ widget.\n\n## How it connects to AI\n\nWhen a visitor asks a question, the AI searches your published articles for relevant content before generating a response. The better your articles, the more accurate the AI answers.\n\nTips for AI-friendly articles:\n- Use plain, clear language, avoid jargon\n- One topic per article\n- Include the exact phrases visitors might use (e.g. "cancel my subscription", "reset password")\n- Keep articles up to date; the AI uses whatever is published\n\n## Structure\n\nArticles are organised into **categories**. Create categories to group related articles (e.g. "Billing", "Getting Started", "Technical").\n\nTo create a category:\n1. Click **New category** in the sidebar\n2. Give it a name and optional description\n3. Articles can be moved between categories at any time\n\n## Article visibility\n\n| Status | Visible in FAQ widget | Used by AI |\n|---|---|---|\n| Draft | No | No |\n| Published | Yes | Yes |\n| Archived | No | No |\n\n## Next steps\n\n- [Writing articles](/docs/knowledge-base/articles): editor, formatting, and SEO tips\n- [FAQ widget](/docs/knowledge-base/faq-widget): show articles inside the chat widget\n- [Import from URL](/docs/knowledge-base/import-url): scrape your existing help docs', headings: [{ level: 2, text: "Getting started" }, { level: 2, text: "How it connects to AI" }, { level: 2, text: "Structure" }, { level: 2, text: "Article visibility" }, { level: 2, text: "Next steps" }] }, { id: "notifications/email", url: "https://kerabie.com/docs/notifications/email", title: "Email Notifications", description: "Manage when and how Kerabie sends email alerts to agents.", section: "notifications", body: "Kerabie can send email notifications to agents for important events so nothing slips through even when they're offline.\n\n## Configuring email notifications\n\n1. Go to **Settings \u2192 Notifications \u2192 Email**\n2. Toggle each event on or off\n3. Changes take effect immediately; no save button needed\n\n## Available email events\n\n| Event | Description |\n|---|---|\n| New conversation assigned | You are assigned a new conversation |\n| New message (assigned) | A visitor replies in a conversation assigned to you |\n| New unassigned conversation | A conversation arrives with no assignee |\n| Conversation reopened | A resolved conversation is reopened |\n| Mentioned in note | An agent @mentions you in an internal note |\n| Daily summary | A digest of open/unresolved conversations (sent at 8 AM) |\n\n## Notification email address\n\nNotification emails are sent from `notifications@kerabie.com`. Add this to your contacts or allowlist if they land in spam.\n\n## Per-agent preferences\n\nEach agent controls their own notification preferences. Admins cannot override individual agent settings, but they can set **organisation-level defaults** that apply to new agents when they join.\n\nSet defaults under **Settings \u2192 Notifications \u2192 Default preferences**.\n\n## Unsubscribing\n\nEvery notification email includes an **Unsubscribe** link at the bottom. Clicking it turns off that specific event for your account. You can re-enable it from Settings at any time.", headings: [{ level: 2, text: "Configuring email notifications" }, { level: 2, text: "Available email events" }, { level: 2, text: "Notification email address" }, { level: 2, text: "Per-agent preferences" }, { level: 2, text: "Unsubscribing" }] }, { id: "notifications/mobile", url: "https://kerabie.com/docs/notifications/mobile", title: "Mobile Notifications", description: "Push notifications for agents on iOS and Android.", section: "notifications", body: "If you use the Kerabie mobile app (via the React Native SDK or the native app), you can receive push notifications directly to your phone.\n\n## Setting up mobile notifications\n\n1. Install the Kerabie app on your device\n2. Sign in with your agent account\n3. When prompted, allow push notification permissions\n4. Go to **Settings \u2192 Notifications \u2192 Mobile** to configure which events trigger a notification\n\n## Notification events\n\n| Event | Default |\n|---|---|\n| New conversation assigned to you | On |\n| New message in your conversations | On |\n| New unassigned conversation | Off |\n| Mention in internal note | On |\n| Conversation reopened | On |\n\n## Sounds and badges\n\nThe app badge (conversation count) updates in real time. Notification sounds use your device's default alert sound. You can silence Kerabie notifications without disabling them by using your device's **Focus** (iOS) or **Do Not Disturb** (Android) mode.\n\n## Troubleshooting\n\n**Not receiving notifications on iOS:**\n- Go to **Settings \u2192 Notifications \u2192 Kerabie** and ensure notifications are set to **Allow**\n- Make sure Background App Refresh is enabled for Kerabie\n\n**Not receiving notifications on Android:**\n- Go to **Settings \u2192 Apps \u2192 Kerabie \u2192 Notifications** and enable all channels\n- Check that battery optimisation is not restricting Kerabie in the background\n\n**Token expired:**\nLog out of the mobile app and log back in to refresh the push notification token.", headings: [{ level: 2, text: "Setting up mobile notifications" }, { level: 2, text: "Notification events" }, { level: 2, text: "Sounds and badges" }, { level: 2, text: "Troubleshooting" }] }, { id: "notifications/push", url: "https://kerabie.com/docs/notifications/push", title: "Push Notifications", description: "Configure browser push notifications for agents.", section: "notifications", body: "Browser push notifications alert agents to new conversations and messages even when they're not looking at the Kerabie tab.\n\n## Enabling push notifications\n\n1. Log in to [kerabie.com/app](https://kerabie.com/app)\n2. Go to **Settings \u2192 Notifications \u2192 Push**\n3. Click **Enable browser notifications**\n4. Allow the browser permission prompt\n\nYou'll start receiving notifications immediately for any of the events you have toggled on.\n\n## What triggers a push notification\n\n| Event | Default |\n|---|---|\n| New conversation assigned to you | On |\n| New message in your assigned conversation | On |\n| New unassigned conversation | Off |\n| Conversation reopened | On |\n| Mention in internal note | On |\n\nToggle each event individually under **Settings \u2192 Notifications**.\n\n## Browser support\n\nPush notifications work in Chrome, Edge, Firefox, and Safari 16.1+. They require HTTPS and a granted permission; browsers won't prompt twice if you denied the first time. To re-enable:\n\n- **Chrome**: Click the lock icon in the address bar \u2192 Notifications \u2192 Allow\n- **Firefox**: Preferences \u2192 Privacy & Security \u2192 Notifications \u2192 Allow for kerabie.com\n- **Safari**: System Preferences \u2192 Notifications \u2192 Safari \u2192 Allow\n\n## Not receiving notifications?\n\n- Check your OS-level notification settings (macOS / Windows); the browser must be allowed there too\n- Make sure your status is set to **Available** in the agent status indicator\n- Ensure the Kerabie tab has been open at least once in the current browser session to register the service worker", headings: [{ level: 2, text: "Enabling push notifications" }, { level: 2, text: "What triggers a push notification" }, { level: 2, text: "Browser support" }, { level: 2, text: "Not receiving notifications?" }] }, { id: "quickstart", url: "https://kerabie.com/docs/quickstart", title: "Quickstart", description: "Add Kerabie to your website and start your first conversation in under 5 minutes.", section: "quickstart", body: '## Step 1: Create your account\n\nGo to [kerabie.com/app/register](https://kerabie.com/app/register) and sign up. You\'ll be on a free trial automatically.\n\n## Step 2: Copy your widget script\n\nAfter signing up, go to **Settings \u2192 Widget**. Copy the embed script:\n\n```html\n<script>\n window.KERConfig = { widgetKey: "wk_yourkey_here" };\n</script>\n<script async src="https://cdn.kerabie.com/widget.js"></script>\n```\n\n## Step 3: Paste it into your site\n\nAdd both script tags before the closing `</body>` tag of every page you want the widget on.\n\n <Tab title="HTML">\n ```html\n <!DOCTYPE html>\n <html>\n <body>\n <!-- your content -->\n\n <script>\n window.KERConfig = { widgetKey: "wk_yourkey_here" };\n </script>\n <script async src="https://cdn.kerabie.com/widget.js"></script>\n </body>\n </html>\n ```\n </Tab>\n <Tab title="Next.js">\n ```tsx\n // app/layout.tsx\n import Script from \'next/script\';\n\n export default function RootLayout({ children }) {\n return (\n <html>\n <body>\n {children}\n <Script id="kerabie-settings" strategy="beforeInteractive">\n {`window.KERConfig = { widgetKey: "wk_yourkey_here" };`}\n </Script>\n <Script src="https://cdn.kerabie.com/widget.js" strategy="afterInteractive" />\n </body>\n </html>\n );\n }\n ```\n </Tab>\n <Tab title="WordPress">\n Install the [Kerabie WordPress plugin](/integrations/wordpress); no code needed.\n </Tab>\n\n## Step 4: Test it\n\nOpen your site. The chat bubble appears in the bottom-right corner. Click it and send a message.\n\nIn the Kerabie dashboard, go to **Conversations**; you\'ll see your test message arrive in real time.\n\n## Step 5: Invite your team (optional)\n\nGo to **Settings \u2192 Agents \u2192 Invite agent**. Enter their email; they\'ll get a link to join your workspace.\n\n---\n\n You\'re live. Visitors can now start conversations and your agents can reply from the dashboard or mobile app.\n\n## What\'s next?\n\n <Card title="Customize the widget" icon="palette" href="/widget/customization">\n Change colors, position, welcome message, and logo.\n </Card>\n <Card title="Connect AI" icon="robot" href="/ai/setup">\n Let AI handle common questions automatically.\n </Card>\n <Card title="Set business hours" icon="clock" href="/widget/offline-mode">\n Show an offline form when no agents are available.\n </Card>\n <Card title="Install on WordPress" icon="wordpress" href="/integrations/wordpress">\n One-click plugin install, no code required.\n </Card>', headings: [{ level: 2, text: "Step 1: Create your account" }, { level: 2, text: "Step 2: Copy your widget script" }, { level: 2, text: "Step 3: Paste it into your site" }, { level: 2, text: "Step 4: Test it" }, { level: 2, text: "Step 5: Invite your team (optional)" }, { level: 2, text: "What's next?" }] }, { id: "sdks/flutter", url: "https://kerabie.com/docs/sdks/flutter", title: "Flutter SDK", description: "Add Kerabie live chat to your iOS or Android app built with Flutter.", section: "sdks", body: "The `kerabie_helpdesk_flutter` package adds live chat to a Flutter app, with photos and files, a help centre, tickets, a store and proactive campaigns. Messages arrive in your Kerabie inbox.\n\n## Install\n\n```bash\nflutter pub add kerabie_helpdesk_flutter\n```\n\nIt needs Flutter 3.27 or later and Dart 3.6 or later. Get your widget key from **Settings, Widget** in the dashboard.\n\nOn iOS, add the reason for the photo library to `ios/Runner/Info.plist`, so customers can attach photos:\n\n```xml\n<key>NSPhotoLibraryUsageDescription</key>\n<string>Attach a photo to your message to support.</string>\n```\n\n## Quick start\n\nPut `KerabieProvider` above your `MaterialApp`, and add the launcher over your screens.\n\n```dart\n\nfinal navigatorKey = GlobalKey<NavigatorState>();\n\nvoid main() {\n runApp(\n KerabieProvider(\n navigatorKey: navigatorKey,\n config: const KerabieConfig(\n widgetKey: 'wk_yourorg_123',\n appName: 'My App',\n appVersion: '1.0.0',\n user: KerabieUser(name: 'Jane', email: 'jane@example.com'),\n ),\n child: MaterialApp(\n navigatorKey: navigatorKey,\n navigatorObservers: [KerabieNavigatorObserver()],\n home: const Scaffold(\n body: Stack(children: [HomeScreen(), KerabieLauncher()]),\n ),\n ),\n ),\n );\n}\n```\n\n`KerabieLauncher` shows the floating button or the search bar, whichever the dashboard picked. To open the chat from your own button, use `KerabieChat.show(context)`.\n\n## Configuration\n\n| Field | Required | Description |\n|---|---|---|\n| `widgetKey` | Yes | Your widget key from Settings, Widget |\n| `appName`, `appVersion` | Yes | Your app's name and version, sent when the chat connects |\n| `appBuild` | No | Build number, sent with them |\n| `user` | No | `KerabieUser` with `id`, `name`, `email`, `phone`, `role` and `customAttributes` |\n| `theme` | No | `KerabieTheme` overrides: `primaryColor`, `secondaryColor`, `gradient`, `cornerRadius`, `mode` (`light`, `dark` or `auto`), `backgroundColor`, `textColor`, `agentBubbleColor`, `userBubbleColor`, `bubbleColor`, `bubbleIcon`, `bubbleSize`, `bubbleIconSize`, `fontFamily`, `chatbotAvatarUrl` |\n| `chatSettings` | No | `KerabieChatSettings` overrides for the dashboard: `name`, `welcomeMessage`, `offlineMessage`, `showBranding`, `typingIndicator`, `enableFileAttachments`, `chatbotEnabled`, `layout`, `showMenu`, `launcherType`, `searchbarPlaceholder`, `customerEnrichmentEnabled`, `customerEnrichmentForm` |\n| `openMode` | No | `modal` (a full-screen page, the default), `sheet` (a bottom sheet) or `push` (your own navigator opens it) |\n| `position` | No | Where the launcher sits: `bottomRight` (default), `bottomLeft`, `topRight`, `topLeft` |\n| `defaultOpen` | No | Open the chat as soon as the app has built |\n| `hideFloatingButton` | No | Make `KerabieLauncher` draw nothing; open the chat from your own button |\n| `deepLinkScheme` | No | Your app's URL scheme, so only `myapp://conversation/123` links open the chat |\n\nColours, the widget name, the welcome message, the offline message and branding come from **Settings, Widget** in the dashboard. Anything you set in `theme` or `chatSettings` wins over the dashboard. Light or dark comes from the dashboard's Theme setting: `auto` follows the device, and the chat is light when nothing is set. Text on a coloured background is dark or white depending on the colour's luminance, so it stays readable on any brand colour. The welcome message can use `{{name}}`, which becomes the signed-in user's first name.\n\n`KerabieProvider` also takes `onOpenChat`, `onCloseChat`, `onNavigate` and `onOpenKnowledgeBase`.\n\n### Open modes\n\nWith `openMode: KerabieOpenMode.push` the SDK does not touch your navigator. It calls `onNavigate` and your app shows a `KerabieChatScreen` in its own route:\n\n```dart\nKerabieProvider(\n config: const KerabieConfig(widgetKey: '...', appName: '...', appVersion: '...', openMode: KerabieOpenMode.push),\n onNavigate: (route) => router.push(route.type == KerabieRouteType.conversation ? '/chat/${route.conversationId}' : '/chat'),\n child: ...,\n)\n\n// In your /chat route:\nKerabieChatScreen(onClose: () => router.pop())\n```\n\nWithout `onNavigate` the chat opens as a full-screen page.\n\n## Reading the chat state\n\n```dart\nfinal kerabie = KerabieProvider.of(context); // rebuilds when the chat changes\n\nBadge(label: Text('${kerabie.unreadCount}'), child: const Icon(Icons.support_agent));\n```\n\n| Member | Description |\n|---|---|\n| `messages` | The conversation, oldest first |\n| `unreadCount` | Replies that arrived while the chat was closed |\n| `isConnected` | Whether the live connection is up |\n| `isTyping`, `isAiTyping` | An agent, or the AI assistant, is writing |\n| `agentsOnline` | How many agents are online, once the server has said |\n| `settings`, `chat` | What the dashboard saved, and the chat settings in force |\n| `sendMessage(text)`, `retry(message)` | Sends a message, and sends a failed one again |\n| `showChat()`, `dismissChat()`, `toggle()` | Open and close the chat |\n| `setUser(user)`, `clearUser()`, `updateAttribute(key, value)` | Who the customer is, see below |\n| `setAppVersion(version)` | The app's version changed; it is sent with the next connection |\n| `openWithContext(context)` | Opens the chat with context, see below |\n\n## Who the customer is\n\nCall `setUser` after login so agents see who they are talking to, `updateAttribute` to add a detail, and `clearUser` after logout. `clearUser` empties the chat on screen and the next visitor starts a fresh anonymous session.\n\n```dart\nfinal kerabie = KerabieProvider.of(context, listen: false);\n\nawait kerabie.setUser(const KerabieUser(id: 'u_123', name: 'Ada Obi', email: 'ada@example.com'));\nawait kerabie.updateAttribute('plan', 'pro');\nawait kerabie.clearUser();\n```\n\n## Opening the chat with context\n\nTell the agent what the customer was looking at, so they do not have to explain it:\n\n```dart\nkerabie.openWithContext(const KerabieChatContext(\n type: 'order', // order, product, transaction, ride, errand, booking or custom\n id: 'ORD-2024-001',\n title: 'Order #2024-001',\n data: {'status': 'shipped', 'total': '45.50'},\n));\n```\n\nPass `openChat: false` to only record it and open the chat later.\n\n## Screens\n\n`KerabieNavigatorObserver` tells Kerabie which screen the customer is on, from your routes' names. Campaigns can be limited to certain screens (\"On pages\" in the dashboard). Dialogs, sheets and routes without a name are ignored. With a router that has no route names, such as `go_router`, report screens yourself with `kerabie.trackScreen('/checkout')`.\n\n## Help centre, store and tickets\n\nReady-made screens you can push anywhere in your app. Each follows your theme and the dashboard settings.\n\n```dart\nNavigator.of(context).push(MaterialPageRoute(builder: (_) => const KerabieKnowledgeBaseScreen()));\nNavigator.of(context).push(MaterialPageRoute(builder: (_) => const KerabieStoreScreen()));\nNavigator.of(context).push(MaterialPageRoute(builder: (_) => const KerabieTicketsScreen()));\n```\n\n`kerabie.settings.ticketsEnabled` says whether tickets are on in the dashboard, so you can show an entry point only when they are. For your own design use the data classes behind the screens: `KerabieKnowledgeBase`, `KerabieStore` and `KerabieTickets`.\n\n## Campaigns\n\nProactive messages you create in the dashboard (**Campaigns**) also appear in your app, above the launcher, with their button. They load once per app launch and need the same `navigatorKey` on `KerabieProvider` and `MaterialApp`.\n\n| Dashboard setting | In the app |\n|---|---|\n| Trigger **Page load** | Shown about 1.5 seconds after a screen appears |\n| Trigger **Time on page** | Shown after N seconds in the app |\n| Trigger **Scroll depth**, **Exit intent** | Website only; not shown in apps |\n| **On pages** | Matched against the current screen. Without a screen, only campaigns for every screen show |\n| **Audience** | New and returning mean the first and a later launch on this device; \"hasn't chatted\" checks this device's conversations; country is decided by the server |\n| **Frequency** | Remembered on the device: once per visitor, once per day, or once per app launch |\n| Button **Open chat** / **Open a link** | Opens the chat / the link in the browser |\n| Button **Open Knowledge Base** | Calls your `onOpenKnowledgeBase`; opens the chat if you do not pass it |\n\nResults (shown, opened, clicked, replied) are counted in the dashboard like they are for the website widget. Nothing pops up while the chat is open.\n\n## Calls\n\nCustomers can call your team from the app: voice, and video if you turn it on. Calls are a separate, optional package, so apps that only want chat do not carry the call code.\n\n```bash\nflutter pub add kerabie_helpdesk_flutter_calls\n```\n\nTurn calls on in the dashboard under **Settings, Calls**. Your plan decides how many call minutes you get each month, and calls the AI voice agent answers are counted separately.\n\nThe app needs permission to use the microphone, and the camera for video. On iOS add `NSMicrophoneUsageDescription` and `NSCameraUsageDescription` to `Info.plist`. On Android add the `CAMERA`, `RECORD_AUDIO`, `ACCESS_NETWORK_STATE`, `CHANGE_NETWORK_STATE` and `MODIFY_AUDIO_SETTINGS` permissions to the manifest, plus `BLUETOOTH` and `BLUETOOTH_ADMIN` limited to `maxSdkVersion=\"30\"`. The package README has the exact lines.\n\n```dart\n\n// Anywhere under KerabieProvider. It shows nothing when calls are off or the plan has no minutes left.\nconst KerabieCallButton(label: 'Call us'),\nconst KerabieCallButton(label: 'Video call', video: true),\n```\n\nTo put call buttons in the chat header itself, pass the icon variant to the provider. It is a widget you pass in, so the main package never carries the call code:\n\n```dart\nKerabieProvider(config: ..., callActions: const KerabieCallButton.icon(), child: ...)\n```\n\nThe button opens a call screen with the queue position, ringing, the call (mute, camera, end) and how it ended. For your own design, create a `KerabieCallController` and read its `phase`.\n\n| `phase` | Meaning |\n|---|---|\n| `starting` | Asking Kerabie to start the call |\n| `waiting` | Every agent is busy; `queuePosition` is the place in line |\n| `ringing` | Free agents are being rung |\n| `connected` | Answered by a person or the AI (`handler`) |\n| `ended` | Over; `endReason` says why |\n\nIf you ask customers for their details before a chat (the pre-chat form in the dashboard), the call asks the same questions first, once. Pass `user` in `KerabieConfig`, or call `setUser` after login, and a customer you have identified is never asked. On a video call the customer sees the agent, with their own camera as a small picture in the corner.\n\nA call goes to every free agent at once and the first to answer wins. When nobody is free, the AI voice agent answers if you turned it on. Otherwise the customer waits in the queue, and the call is marked missed after two minutes.\n\n## Push notifications\n\nKerabie keeps one push token per customer, so use one push service in your app. The SDK does not depend on a push package. You get the token from your service and pass it in, with the provider that issued it.\n\n```dart\nfinal kerabie = KerabieProvider.of(context, listen: false);\nawait kerabie.registerPushToken(token, provider: KerabiePushProvider.fcm);\n```\n\nThe providers are `fcm` and `expo`, the two values the Kerabie API accepts. `fcm` takes the token from `FirebaseMessaging.getToken()`, and it also covers iOS because Firebase delivers through Apple's service for you.\n\n Using Firebase (`fcm`)? Connect your Firebase project once in the dashboard under **Settings, Integrations, App push**, so Kerabie can send to your app's customers. Without it, replies are not pushed to your app.\n\n### Opening the chat when a notification is tapped\n\nKerabie notifications carry `conversationId` and `orgId` in their data. Pass the data to the SDK when the user taps. With Firebase Cloud Messaging, handle both cases FCM documents: the tap that launches the app (`getInitialMessage`) and the tap from the background (`onMessageOpenedApp`).\n\n```dart\nfinal messaging = FirebaseMessaging.instance;\n\nfinal initial = await messaging.getInitialMessage();\nif (initial != null) kerabie.handleNotificationData(initial.data);\n\nFirebaseMessaging.onMessageOpenedApp.listen((m) => kerabie.handleNotificationData(m.data));\nmessaging.onTokenRefresh.listen((t) => kerabie.registerPushToken(t, provider: KerabiePushProvider.fcm));\n```\n\n The chat can only open from a tap if you gave `KerabieProvider` the same `navigatorKey` as your `MaterialApp`. With `go_router`, pass the same key as `GoRouter(navigatorKey: navigatorKey)`.\n\n## Opening the chat from a link\n\nLinks use the same format in every Kerabie SDK: `<your-scheme>://conversation/<id>`.\n\nFlutter handles incoming deep links itself, so the SDK does not listen for them. Hand the link to the SDK where your app receives it, for example in `onGenerateRoute` or your router's redirect.\n\n```dart\nif (kerabie.handleDeepLink(Uri.parse('myapp://conversation/123'))) return;\n```\n\n`KerabieDeepLink.conversationUri(123, 'myapp')` builds a link you can put in a notification.\n\n## Use with an AI assistant\n\nThe [Kerabie MCP server](/docs/sdks/mcp) can read this SDK's documentation, so a coding assistant can add it to your app.", headings: [{ level: 2, text: "Install" }, { level: 2, text: "Quick start" }, { level: 2, text: "Configuration" }, { level: 3, text: "Open modes" }, { level: 2, text: "Reading the chat state" }, { level: 2, text: "Who the customer is" }, { level: 2, text: "Opening the chat with context" }, { level: 2, text: "Screens" }, { level: 2, text: "Help centre, store and tickets" }, { level: 2, text: "Campaigns" }, { level: 2, text: "Calls" }, { level: 2, text: "Push notifications" }, { level: 3, text: "Opening the chat when a notification is tapped" }, { level: 2, text: "Opening the chat from a link" }, { level: 2, text: "Use with an AI assistant" }] }, { id: "sdks/mcp", url: "https://kerabie.com/docs/sdks/mcp", title: "MCP server", description: "Let Claude, Cursor and other AI assistants build on Kerabie and work with your account.", section: "sdks", body: 'Kerabie has a [Model Context Protocol](https://modelcontextprotocol.io) server. It does two jobs.\n\n- **Build with Kerabie.** Your coding assistant can search the docs, list API endpoints, read the TypeScript, React Native and Flutter SDK types, and follow integration guides, so it writes correct code for the [API](/docs/api-reference/overview), the [TypeScript SDK](/docs/sdks/typescript), the [React Native SDK](/docs/sdks/react-native), the [Flutter SDK](/docs/sdks/flutter), the web widget and WordPress. This works **without an API key**.\n- **Work with your account.** With an API key, the same assistant can use your live data. Connect it to Claude Desktop, Claude Code, Cursor or any MCP client and you can ask things like *"which tickets are about to breach SLA?"*, *"draft a Knowledge Base article from this conversation"* or *"pause the pricing-page campaign"*.\n\nThe account tools are a thin layer over the API and `@kerabie/sdk`, so they do exactly what your API key is allowed to do, and nothing else. The developer tools answer from documentation bundled with the package, so they match the SDK version you install.\n\n## Hosted server (no install)\n\nPoint your client at the hosted endpoint and send your API key as a Bearer token. Nothing runs on your machine.\n\n```\nhttps://mcp.kerabie.com/mcp\nAuthorization: Bearer ker_live_...\n```\n\n**Claude Code:**\n\n```bash\nclaude mcp add --transport http kerabie https://mcp.kerabie.com/mcp --header "Authorization: Bearer ker_live_..."\n```\n\n**Cursor** (`.cursor/mcp.json`):\n\n```json\n{\n "mcpServers": {\n "kerabie": {\n "url": "https://mcp.kerabie.com/mcp",\n "headers": { "Authorization": "Bearer ker_live_..." }\n }\n }\n}\n```\n\nAdd `?readOnly=true` to the URL to leave out every tool that changes data. Without a key you get the developer tools only. Your key is used for every call and is never stored, so the server can do exactly what your key allows.\n\n## Get an API key\n\nThe developer tools work without a key. To let your assistant work with your account, create an API key under **Settings \u2192 Integrations \u2192 API Keys** (API access is included on every plan) and send it as the Bearer token shown above.\n\n> **Careful:** an MCP client can use every tool the key allows, including ones that reply to customers or delete things. Start with `?readOnly=true` on the URL, and keep the client\'s approval prompts on for anything that changes data.\n\n## Developer tools\n\nAvailable with or without an API key. None of them change anything.\n\n| Tool | What it does |\n|---|---|\n| `search_docs` | Search the docs, SDK readmes and API reference. |\n| `get_doc` | Read a docs page, or one section of it. |\n| `list_docs` | Every page with its description, by section. |\n| `list_api_endpoints` | Every REST endpoint with its docs page and the equivalent TypeScript SDK call. |\n| `get_sdk_types` | The TypeScript, React Native or Flutter SDK type declarations: list them, or fetch the ones you name. |\n| `get_integration_guide` | The pages to follow for `typescript`, `react-native`, `flutter`, `wordpress`, `web-widget`, `rest-api` or `mcp`. |\n\nThe server also offers ready-made **prompts** (`integrate_react_native`, `integrate_flutter`, `integrate_typescript_sdk`, `integrate_rest_api`, `install_web_widget`) and every docs page as a **resource** (`kerabie-docs://sdks/react-native`) for clients that support them.\n\n## Account tools\n\nThese need `KERABIE_API_KEY`.\n\nTools that change something are marked **write**; those that delete are also marked **destructive**, so clients can ask before running them.\n\n| Area | Tools |\n|---|---|\n| **Conversations** | `list_conversations`, `get_conversation`, `get_customer_history`, `take_over_conversation` *(write)* |\n| **Tickets** | `list_tickets`, `get_ticket_stats`, `get_ticket`, `create_ticket` *(write)*, `update_ticket` *(write)*, `reply_to_ticket` *(write)* |\n| **Contacts** | `list_contacts`, `get_contact_stats`, `get_contact`, `create_contact` *(write)*, `update_contact` *(write)* |\n| **Knowledge Base** | `search_knowledge_base`, `get_kb_stats`, `get_kb_insights`, `get_article`, `create_article` *(write)*, `update_article` *(write)*, `delete_article` *(destructive)* |\n| **Campaigns** | `list_campaigns`, `get_campaign_stats`, `get_campaign`, `create_campaign`, `update_campaign`, `duplicate_campaign` *(write)*, `delete_campaign` *(destructive)* |\n| **Analytics** | `get_analytics_summary`, `get_analytics_insights`, `get_agent_stats`, `get_ticket_sla`, `get_setup_status` |\n| **Visitors and store** | `list_visitors`, `get_visitors_overview`, `list_store_products` |\n| **AI tools** | `list_ai_tools`, `get_ai_tool`, `create_ai_tool`, `update_ai_tool`, `duplicate_ai_tool` *(write)*, `delete_ai_tool` *(destructive)*, `test_ai_tool` *(write: calls your endpoint)* |\n| **MCP servers** | `list_mcp_servers`, `connect_mcp_server` *(write)*, `sync_mcp_server` *(write)*, `update_mcp_server` *(write)*, `test_mcp_server_tool` *(write)*, `disconnect_mcp_server` *(destructive)* |\n\nLong results are cut at 60,000 characters with a note to use `page`, `limit` or filters. API errors come back as readable messages, for example `Kerabie API error 403: Your plan includes 1 AI agent tool. Upgrade to add more.`\n\n## Use MCP servers from your chatbot\n\nThe other direction: your **AI chatbot** can use tools from any remote MCP server. See [Connecting MCP servers](/docs/ai/mcp-servers).', headings: [{ level: 2, text: "Hosted server (no install)" }, { level: 2, text: "Get an API key" }, { level: 2, text: "Developer tools" }, { level: 2, text: "Account tools" }, { level: 2, text: "Use MCP servers from your chatbot" }] }, { id: "sdks/react-native", url: "https://kerabie.com/docs/sdks/react-native", title: "React Native SDK", description: "Add Kerabie live chat to your iOS or Android app.", section: "sdks", body: "The `react-native-kerabie-helpdesk` package adds a fully customizable chat UI to any React Native app (Expo managed or bare workflow).\n\n## Install\n\n```bash\n# Expo\nnpx expo install react-native-kerabie-helpdesk socket.io-client @react-native-async-storage/async-storage\n\n# Bare React Native\nnpm install react-native-kerabie-helpdesk socket.io-client @react-native-async-storage/async-storage\nnpx pod-install\n```\n\n## Quick start\n\nWrap your app in `KerAbieProvider`. Place it below your gesture/safe-area providers:\n\n```tsx\n\nexport default function App() {\n return (\n <GestureHandlerRootView style={{ flex: 1 }}>\n <SafeAreaProvider>\n <KerAbieProvider\n widgetKey=\"wk_yourorg_123\"\n appName=\"My App\"\n appVersion=\"1.0.0\"\n user={{ name: 'Jane', email: 'jane@example.com' }}\n theme={{ primaryColor: '#6d28d9' }}\n >\n <YourNavigator />\n <KerAbieFloatingButton />\n </KerAbieProvider>\n </SafeAreaProvider>\n </GestureHandlerRootView>\n );\n}\n```\n\n## Provider props\n\n| Prop | Type | Required | Description |\n|---|---|---|---|\n| `widgetKey` | `string` | \u2713 | Your widget key from Settings |\n| `appName` | `string` | \u2713 | Human-readable app name shown in dashboard |\n| `appVersion` | `string` | \u2713 | Your app version |\n| `user` | `KerUser` | No | Pre-identified user (name, email, userId) |\n| `theme` | `KerTheme` | No | Colors, font family, bubble size |\n| `openMode` | `'modal' \\| 'screen'` | No | How chat opens (default: `modal`) |\n\n `appName` and `appVersion` are shown to agents in the conversation, so they immediately know which app and version the user is on. Critical for bug reports and version-specific support.\n\n## Hooks\n\n```typescript\n\nconst { open, close, toggle, sendMessage, unreadCount, isConnected } = useKerAbieContext();\n\n// Open chat\n\n <Text>Chat with us</Text>\n {unreadCount > 0 && <Badge count={unreadCount} />}\n\n```\n\n## Campaigns\n\nProactive messages you create in the dashboard (**Campaigns**) also appear in your app, above the launcher, with their button. The provider loads your live campaigns once per app launch; there is nothing to add to your code beyond the optional props below.\n\n| Dashboard setting | In the app |\n| ---- | ---- |\n| Trigger **Page load** | Shown about 1.5 seconds after a screen appears (each time the `screen` prop changes). |\n| Trigger **Time on page** | Shown after N seconds in the app. |\n| Trigger **Scroll depth**, **Exit intent** | Website-only. These campaigns are not shown in apps. |\n| **On pages** | Matched against the `screen` prop (for example Expo Router's `usePathname()`). Without the prop, only campaigns for every screen show. |\n| **Audience** | New / returning means the first / a later app launch on this device; \"hasn't chatted\" checks this device's conversations; country is decided by the server from the device's IP. |\n| **Frequency** | Remembered on the device: once per visitor, once per day, or once per app launch. |\n| Button **Open chat** / **Open a link** | Opens the chat / the link in the browser. |\n| Button **Open Knowledge Base** | Calls your `onOpenKnowledgeBase` (the chat has no built-in Knowledge Base screen); opens the chat if you don't pass it. |\n\n```tsx\n\n<KerAbieProvider\n widgetKey=\"wk_yourorg_xxx\"\n screen={usePathname()}\n onOpenKnowledgeBase={() => router.push('/help')}\n>\n```\n\nCampaign results (shown, opened, clicked, replied) are counted in the dashboard like they are for the website widget. Nothing pops up while the chat is open.\n\n## Calls\n\nCustomers can call your team from the app: voice, and video if you turn it on. This part is optional. **Chat, tickets and notifications work without it, including in Expo Go.**\n\n Calls need a **development build**, because they use native WebRTC code. They do not run in Expo Go. If you do not install the call packages, nothing changes in your app.\n\nTurn calls on in the dashboard under **Settings, Calls**. Your plan decides how many call minutes you get each month. Calls the AI voice agent answers are counted separately.\n\n### Install\n\n```bash\nnpx expo install livekit-client @livekit/react-native @livekit/react-native-expo-plugin @livekit/react-native-webrtc @config-plugins/react-native-webrtc\n```\n\nAdd the plugins to `app.json`, then make a new development build:\n\n```json\n{\n \"expo\": {\n \"plugins\": [\"@livekit/react-native-expo-plugin\", \"@config-plugins/react-native-webrtc\"]\n }\n}\n```\n\nThe app must be allowed to use the microphone, and the camera if you use video: `RECORD_AUDIO` and `CAMERA` on Android, and a reason for `NSMicrophoneUsageDescription` and `NSCameraUsageDescription` on iOS.\n\n### Add the button\n\nCalls come from a separate entry point, so the main package never loads the call libraries.\n\n```tsx\n\n```\n\nTo put call buttons in the chat header itself (a phone, and a camera when video is on), pass the icon variant to the provider. It is a prop, so the main package still never loads the call libraries:\n\n```tsx\n<KerAbieProvider widgetKey=\"...\" callAction={<KerAbieCallButton variant=\"icon\" />}>\n```\n\nThe button renders nothing when calls are off or the plan has no minutes left. It opens a call screen with the queue position, ringing, the call (mute, camera, end) and how it ended.\n\nFor your own design, use `useKerAbieCall()` for the state and `KerAbieCallModal` for the screen:\n\n```tsx\n\nconst call = useKerAbieCall()\n// call.available, call.state.phase, call.start(), call.hangUp(), call.dismiss()\n\n```\n\n| `state.phase` | Meaning |\n|---|---|\n| `starting` | Asking Kerabie to start the call |\n| `waiting` | Every agent is busy; `state.queuePosition` is the place in line |\n| `ringing` | Free agents are being rung |\n| `connected` | Answered by a person or the AI (`state.handler`) |\n| `ended` | Over; `state.endReason` says why |\n\nIf you ask customers for their details before a chat (the pre-chat form in the dashboard), the call asks the same questions first, once. Pass `user` to `KerAbieProvider`, or call `setUser` after login, and a customer you have identified is never asked. On a video call the customer sees the agent, with their own camera as a small picture in the corner.\n\nA call goes to every free agent at once and the first to answer wins. When nobody is free, the AI voice agent answers if you turned it on. Otherwise the customer waits in the queue, and the call is marked missed after two minutes.\n\n## Push notifications\n\nKerabie keeps one push token per visitor session, so your app uses **one push service**. Expo is built in. For Firebase or another service, pass an adapter in `notifications.adapter` that can ask for permission, return the token with its provider (`expo` or `fcm`), and report taps. Without one the SDK uses Expo.\n\n Using Firebase (`fcm`)? Connect your Firebase project once in the dashboard under **Settings, Integrations, App push**, so Kerabie can send to your app's customers. Expo push needs nothing there.\n\n### Android: register notification channels first\n\nOn **Android 8+ (API 26+)**, push notifications require a registered channel. Without one, notifications are silently dropped. Call `NotificationService.setup()` **before** the provider renders:\n\n```tsx\n\n// In App.tsx / _layout.tsx, before KerAbieProvider:\nuseEffect(() => {\n NotificationService.setup({\n projectId: 'your-expo-project-id', // from app.json / expo.dev\n onTokenReceived: (token) => console.log('Push token:', token),\n });\n}, []);\n```\n\nThis single call:\n1. Registers Android notification channels (`kerabie_messages`, `kerabie_visitor`, `kerabie_system`)\n2. Requests permission from the user\n3. Gets the Expo push token\n4. Registers the token with the Kerabie API automatically\n\n### Custom channels\n\nOverride the default channels to match your app's branding:\n\n```tsx\nawait NotificationService.setup({\n channels: [\n {\n channelId: 'my_app_chat',\n name: 'Support Chat',\n importance: 'high',\n sound: 'default',\n enableVibrate: true,\n },\n ],\n});\n```\n\n### app.json: required Android config\n\nMake sure your `app.json` includes:\n\n```json\n{\n \"expo\": {\n \"android\": {\n \"permissions\": [\"NOTIFICATIONS\"],\n \"googleServicesFile\": \"./google-services.json\"\n },\n \"plugins\": [\"expo-notifications\"]\n }\n}\n```\n\n### Foreground notifications\n\n```tsx\n\nfunction AppInner() {\n const { requestNotificationPermission } = useKerAbieNotifications();\n\n useEffect(() => {\n requestNotificationPermission();\n }, []);\n\n return <YourApp />;\n}\n```\n\n## Open modes\n\n| Mode | Behavior |\n|---|---|\n| `modal` | A React Native `Modal` over your app. The default, works with any navigation setup |\n| `sheet` | A bottom sheet. Needs `@gorhom/bottom-sheet`, `react-native-reanimated` and `react-native-gesture-handler` |\n| `push` | Your app's own navigator shows the chat. The SDK calls `onNavigate` and you open the screen |\n\n```tsx\n\n```\n\n### Using your own navigator (`push`)\n\nThe SDK never touches your navigator, so one hook works with Expo Router and React Navigation. From Expo SDK 56, Expo Router no longer allows `@react-navigation/*` imports in app code, which is why the SDK asks you to navigate instead of taking a navigation ref.\n\n```tsx\n\n router.push(route.type === 'conversation' ? `/chat/${route.conversationId}` : '/chat')\n }\n>\n```\n\nYou define the `/chat` route and render `KerAbieChatScreen` in it. Without `onNavigate`, the chat opens inline.\n\n## Contextual chat: open with context\n\nUse `openWithContext` to open the chat widget with a pre-loaded context payload. The agent sees the context (order details, product info, transaction data) immediately when they open the conversation.\n\nThis is the recommended pattern for any app where users need help about a specific item.\n\n### Open when a product is clicked\n\n```tsx\n\nfunction ProductCard({ product }) {\n const { openWithContext } = useKerAbieContext();\n\n return (\n <Pressable onPress={() => openWithContext({\n type: 'product',\n id: String(product.id),\n title: product.name,\n data: { price: product.price, category: product.category },\n })}>\n <Text>{product.name}</Text>\n </Pressable>\n );\n}\n```\n\n### Open for an order inquiry\n\n```tsx\nconst { openWithContext } = useKerAbieContext();\n\n// In an order detail screen:\n<Button onPress={() => openWithContext({\n type: 'order',\n id: order.reference,\n title: `Order ${order.reference}`,\n data: {\n status: order.status,\n total: order.amount,\n placedAt: order.createdAt,\n },\n})}>\n Get help with this order\n\n```\n\n### Open for a fintech transaction\n\n```tsx\n<Button onPress={() => openWithContext({\n type: 'transaction',\n id: txn.id,\n title: `\u20A6${txn.amount} Transfer`,\n data: { status: txn.status, recipient: txn.recipientName, narration: txn.narration },\n})}>\n Report this transaction\n\n```\n\n### Open for a ride\n\n```tsx\n<Button onPress={() => openWithContext({\n type: 'ride',\n id: ride.id,\n title: `Trip to ${ride.destination}`,\n data: { driver: ride.driverName, fare: ride.fare, status: ride.status },\n})}>\n Get help with this trip\n\n```\n\n### Context type reference\n\n| `type` | Use case |\n|---|---|\n| `product` | E-commerce: user asking about an item |\n| `order` | Order inquiry or return |\n| `transaction` | Fintech: failed/disputed payment |\n| `ride` | Ride-sharing: trip issue |\n| `errand` | Delivery/logistics |\n| `booking` | Appointment or reservation |\n| `custom` | Any other context (pass a descriptive `title`) |\n\n---\n\n## Theming\n\n```tsx\n\n```\n\n## Use with an AI assistant\n\nConnect the [Kerabie MCP server](/docs/sdks/mcp) to Claude, Cursor or another MCP client and it can search these docs, read the React Native SDK types and follow the integration guide while it writes code for you. No API key is needed for that.", headings: [{ level: 2, text: "Install" }, { level: 2, text: "Quick start" }, { level: 2, text: "Provider props" }, { level: 2, text: "Hooks" }, { level: 2, text: "Campaigns" }, { level: 2, text: "Calls" }, { level: 3, text: "Install" }, { level: 3, text: "Add the button" }, { level: 2, text: "Push notifications" }, { level: 3, text: "Android: register notification channels first" }, { level: 3, text: "Custom channels" }, { level: 3, text: "app.json: required Android config" }, { level: 3, text: "Foreground notifications" }, { level: 2, text: "Open modes" }, { level: 3, text: "Using your own navigator (`push`)" }, { level: 2, text: "Contextual chat: open with context" }, { level: 3, text: "Open when a product is clicked" }, { level: 3, text: "Open for an order inquiry" }, { level: 3, text: "Open for a fintech transaction" }, { level: 3, text: "Open for a ride" }, { level: 3, text: "Context type reference" }, { level: 2, text: "Theming" }, { level: 2, text: "Use with an AI assistant" }] }, { id: "sdks/typescript", url: "https://kerabie.com/docs/sdks/typescript", title: "TypeScript / Node.js SDK", description: "Build server-side integrations with the Kerabie TypeScript SDK.", section: "sdks", body: "The `@kerabie/sdk` package lets you integrate Kerabie into your backend, server-side scripts, or custom tooling.\n\n## Install\n\n```bash\nnpm install @kerabie/sdk\n```\n\n## Initialize\n\n```typescript\n\nconst kerabie = new KerAbieClient({\n apiKey: process.env.KERABIE_API_KEY!, // Settings \u2192 API \u2192 New API Key\n orgId: 42, // Your org ID\n // baseUrl: 'https://api.kerabie.com', // default\n});\n```\n\n## Conversations\n\n```typescript\n// List open conversations\nconst { conversations, total } = await kerabie.conversations.list({\n status: 'open',\n page: 1,\n limit: 20,\n});\n\n// Get a conversation\nconst conv = await kerabie.conversations.get(conversationId);\n\n// Resolve\nawait kerabie.conversations.resolve(conversationId);\n\n// Assign to agent\nawait kerabie.conversations.assign(conversationId, { agentId: 7 });\n\n// Send a message as a bot/agent\nawait kerabie.conversations.sendMessage(conversationId, {\n body: 'Your order has been shipped!',\n senderType: 'bot',\n});\n```\n\n## Customers\n\n```typescript\n// Look up by email\nconst customer = await kerabie.customers.getByEmail('jane@example.com');\n\n// List all customers\nconst { customers } = await kerabie.customers.list({ page: 1 });\n\n// Update attributes\nawait kerabie.customers.update(customerId, {\n name: 'Jane Smith',\n attributes: { plan: 'pro', company: 'Acme' },\n});\n\n// Delete (GDPR)\nawait kerabie.customers.delete(customerId);\n```\n\n## Knowledge Base\n\n```typescript\n// Search articles\nconst results = await kerabie.kb.search('refund policy');\n\n// List all articles\nconst { articles } = await kerabie.kb.list({ category: 'billing' });\n\n// Get one article\nconst article = await kerabie.kb.get('how-to-cancel');\n```\n\n## Analytics\n\n```typescript\n// Summary for a date range\nconst summary = await kerabie.analytics.summary({\n from: '2026-05-01',\n to: '2026-05-26',\n});\n// \u2192 { conversations, avgResponseTime, resolvedRate, satisfactionScore }\n\n// Agent performance\nconst agentStats = await kerabie.analytics.agents({ from: '2026-05-01' });\n```\n\n## Webhooks\n\n```typescript\n// Verify an incoming webhook (use with express.raw middleware)\nconst isValid = KerAbieClient.webhooks.verify(\n req.body, // raw Buffer\n req.headers['x-kerabie-signature'],\n process.env.KERABIE_WEBHOOK_SECRET!,\n);\n\n// Parse the event\nconst event = KerAbieClient.webhooks.parse(req.body);\n// \u2192 { event: 'conversation.created', orgId: 42, data: { ... } }\n```\n\n## Resources reference\n\n| Resource | Methods |\n|---|---|\n| `conversations` | `list`, `get`, `resolve`, `close`, `assign`, `sendMessage` |\n| `customers` | `list`, `get`, `getByEmail`, `update`, `delete` |\n| `visitors` | `list`, `get`, `block` |\n| `agents` | `list`, `get` |\n| `kb` | `list`, `search`, `get` |\n| `analytics` | `summary`, `agents` |\n| `webhooks` | `verify` (static), `parse` (static) |\n\n## Error handling\n\nAll methods throw `KerAbieError` on failure:\n\n```typescript\n\ntry {\n await kerabie.conversations.get(99999);\n} catch (err) {\n if (err instanceof KerAbieError) {\n console.log(err.status); // 404\n console.log(err.message); // \"Conversation not found\"\n }\n}\n```\n\n## Use with an AI assistant\n\nConnect the [Kerabie MCP server](/docs/sdks/mcp) to Claude, Cursor or another MCP client and it can search these docs, read the TypeScript SDK types and follow the integration guide while it writes code for you. No API key is needed for that.", headings: [{ level: 2, text: "Install" }, { level: 2, text: "Initialize" }, { level: 2, text: "Conversations" }, { level: 2, text: "Customers" }, { level: 2, text: "Knowledge Base" }, { level: 2, text: "Analytics" }, { level: 2, text: "Webhooks" }, { level: 2, text: "Resources reference" }, { level: 2, text: "Error handling" }, { level: 2, text: "Use with an AI assistant" }] }, { id: "sdks/wordpress", url: "https://kerabie.com/docs/sdks/wordpress", title: "WordPress Plugin", description: "Full reference for the Kerabie WordPress plugin.", section: "sdks", body: "The Kerabie WordPress plugin is the easiest way to add live chat to a WordPress site. It installs the widget automatically on every page without touching any code.\n\nFor the basic installation walkthrough, see [Integrations \u2192 WordPress](/docs/integrations/wordpress).\n\n## Plugin settings reference\n\nAfter activation, go to **WP Admin \u2192 Kerabie \u2192 Settings** to configure:\n\n### General\n\n| Setting | Description |\n|---|---|\n| **Widget Key** | Your Kerabie widget key (`wk_...`), required |\n| **Enable on all pages** | Toggle the widget on every page of your site |\n| **Enable on specific pages** | Enter comma-separated page IDs to restrict to those pages |\n| **Hide for logged-out users** | Only show the widget to logged-in WordPress users |\n| **Hide for admins** | Suppress the widget in WP Admin screens |\n\n### User identity\n\n| Setting | Description |\n|---|---|\n| **Pass WordPress user data** | When on, logged-in users' name and email are sent to Kerabie automatically |\n| **Pass user ID** | Also sends the WordPress user ID as `userId` |\n| **Custom user meta** | Pass additional user meta fields as custom attributes (comma-separated meta keys) |\n\n### Widget appearance\n\nThe widget's colours, greeting, and position are controlled from your Kerabie dashboard under **Settings \u2192 Chat Widget**, not from the WordPress plugin. Changes there reflect immediately on your site without a plugin update.\n\n## Hooks and filters\n\nThe plugin provides WordPress hooks for developers who need custom control:\n\n### Filter: `kerabie_widget_settings`\n\nModify the `window.KERConfig` object before it's output:\n\n```php\nadd_filter('kerabie_widget_settings', function($settings) {\n if (is_user_logged_in()) {\n $user = wp_get_current_user();\n $settings['user']['plan'] = get_user_meta($user->ID, 'subscription_plan', true);\n }\n return $settings;\n});\n```\n\n### Action: `kerabie_before_widget_script`\n\nRun code before the widget script tag:\n\n```php\nadd_action('kerabie_before_widget_script', function() {\n // e.g. output a nonce or inline config\n});\n```\n\n## WooCommerce integration\n\nIf WooCommerce is active, the plugin automatically passes the customer's order count and total spend as custom attributes:\n\n```js\nuser: {\n orders_count: 5,\n total_spend: \"349.00\"\n}\n```\n\nThese appear in the conversation sidebar and can be used in automation conditions.\n\n## Updating the plugin\n\nUpdates are delivered through the standard WordPress plugin updater. You'll see an update notification in **WP Admin \u2192 Plugins** when a new version is available. Always update, older versions may not receive new widget features.\n\n## Compatibility\n\n- WordPress 5.8+\n- PHP 7.4+\n- Compatible with all major page builders (Elementor, Divi, Oxygen, Bricks)\n- Compatible with WP Rocket, W3 Total Cache, LiteSpeed Cache, add `cdn.kerabie.com` to each cache plugin's script exclusion list", headings: [{ level: 2, text: "Plugin settings reference" }, { level: 3, text: "General" }, { level: 3, text: "User identity" }, { level: 3, text: "Widget appearance" }, { level: 2, text: "Hooks and filters" }, { level: 3, text: "Filter: `kerabie_widget_settings`" }, { level: 3, text: "Action: `kerabie_before_widget_script`" }, { level: 2, text: "WooCommerce integration" }, { level: 2, text: "Updating the plugin" }, { level: 2, text: "Compatibility" }] }, { id: "team/goals", url: "https://kerabie.com/docs/team/goals", title: "Goals boards", description: "Kanban boards for your team's goals, response targets and onboarding.", section: "team", body: `Goals are kanban boards your whole team can see. Use them to track response-time targets, plan a quarter, or walk a new agent through their first three weeks.
604
+
605
+ ## Start from a template
606
+
607
+ Click **New board** and choose a starting point:
608
+
609
+ | Template | What you get |
610
+ |---|---|
611
+ | **Response goals** | Ideas, Targets, In progress and Achieved, with example targets such as "First reply within 2 minutes" |
612
+ | **New agent onboarding** | Week 1, Week 2, Week 3 and Done, with a starter checklist for each week |
613
+ | **Blank board** | To do, In progress and Done |
614
+
615
+ You can rename the board and change its columns at any time.
616
+
617
+ ## Cards
618
+
619
+ Each card has a title, optional details, an assignee and a due date. Everyone on the team can add, edit and move cards.
620
+
621
+ - **Web:** drag a card to another column, or to a spot within a column.
622
+ - **Mobile app:** open the card and pick a column under **Move to**.
623
+ - Due dates turn amber when they are two days away and red once overdue. Finished cards never alarm.
624
+ - You can delete a card you created. Managers can delete any card.
625
+
626
+ ## Columns
627
+
628
+ Managers can add, rename, reorder and delete columns (up to 12 per board). Mark one column as **Cards here count as done**: a card moved into it is marked complete, and the board's progress bar counts it. Deleting a column deletes its cards.
629
+
630
+ ## Who can do what
631
+
632
+ | | Open and work cards | Create, rename or delete boards and columns |
633
+ |---|---|---|
634
+ | Owner | Yes | Yes |
635
+ | Agents who manage the Knowledge Base | Yes | Yes |
636
+ | Other agents | Yes | No |
637
+
638
+ ## Plan limits
639
+
640
+ | Plan | Boards |
641
+ |---|---|
642
+ | Free | None |
643
+ | Basic | 1 |
644
+ | Pro | 3 |
645
+ | Enterprise | Unlimited |
646
+
647
+ On a plan without boards, or at your limit, you will see an upgrade prompt. Existing boards stay open.`, headings: [{ level: 2, text: "Start from a template" }, { level: 2, text: "Cards" }, { level: 2, text: "Columns" }, { level: 2, text: "Who can do what" }, { level: 2, text: "Plan limits" }] }, { id: "team/handbook", url: "https://kerabie.com/docs/team/handbook", title: "Team handbook", description: "Company info and response guidelines for your agents, with required reading for new hires.", section: "team", body: "The team handbook is a private set of pages for your own agents: how you handle refunds, your tone of voice, when to escalate, who to ask. New agents read the same answers every other agent uses.\n\n<Note>The handbook is **never shown to visitors and never used by the AI**. It is stored separately from the Knowledge Base, so nothing you write here can appear in the widget or in an AI reply.</Note>\n\n## Who can do what\n\n| | Read pages | Confirm required pages | Write, edit, delete |\n|---|---|---|---|\n| Owner | Yes | Not needed | Yes |\n| Agents who manage the Knowledge Base (Full access, Support lead, Knowledge) | Yes | Yes | Yes |\n| Other agents | Yes | Yes | No |\n\n## Write a page\n\n1. Open **Handbook** in the sidebar (or **More > Team handbook** in the mobile app).\n2. Click **New page**.\n3. Give it a **title**, pick a **section** (Company, Response guidelines and Processes are suggested, or type your own), and write the page.\n4. Turn on **Required reading** if every new agent must read it.\n5. Click **Add page**.\n\nUse **Preview** in the editor to see how it will look.\n\n### Formatting\n\nPages are plain text with a few simple marks, so they look the same on the web and in the app:\n\n| You type | You get |\n|---|---|\n| `# Heading`, `## Heading`, `### Heading` | Headings |\n| `- item` or `* item` | A bullet list |\n| `1. item` | A numbered list |\n| `**bold**` | Bold text |\n| `[link text](https://example.com)` | A link (web addresses only) |\n\nLeave a blank line between paragraphs.\n\n## Required reading and Start here\n\nPages marked as required reading appear in a **Start here** checklist on each new agent's dashboard, on the web and in the mobile app. The agent opens each page and taps **I have read this**. The checklist disappears once everything is read. The owner never sees it.\n\nIf you change the title or text of a required page, everyone is asked to read it again. Moving a page to another section, or changing only its position, does not.\n\n## See who has read what\n\nOn the **Team progress** tab, managers can see how many agents have confirmed each required page and where each agent stands, with the least complete at the top.\n\n<Note>The read-receipt report is available on **Enterprise**.</Note>\n\n## Plan limits\n\n| Plan | Handbook pages | Read-receipt report |\n|---|---|---|\n| Free | 5 | No |\n| Basic | 25 | No |\n| Pro | Unlimited | No |\n| Enterprise | Unlimited | Yes |\n\nWhen you reach your limit, existing pages stay readable and you can still edit them. Delete one or upgrade to add another.", headings: [{ level: 2, text: "Who can do what" }, { level: 2, text: "Write a page" }, { level: 3, text: "Formatting" }, { level: 2, text: "Required reading and Start here" }, { level: 2, text: "See who has read what" }, { level: 2, text: "Plan limits" }] }, { id: "test", url: "https://kerabie.com/docs/test", title: "Components", description: "Components", section: "test", body: '## Code Block\n\n```js\nconsole.log(\'Hello World\');\n```\n\n## Cards\n\n <Card title="Learn more about Next.js" href="https://nextjs.org/docs" />\n <Card title="Learn more about Fumadocs" href="https://fumadocs.dev" />', headings: [{ level: 2, text: "Code Block" }, { level: 2, text: "Cards" }] }, { id: "visitors/blocking", url: "https://kerabie.com/docs/visitors/blocking", title: "Blocking Visitors", description: "Block abusive or spam visitors from using your chat widget.", section: "visitors", body: "If a visitor is sending spam, abusive messages, or repeatedly opening conversations to harass your team, you can block them. Blocked visitors still see the widget but cannot send messages.\n\n## Blocking a visitor\n\n**From a conversation:**\n\n1. Open the conversation with the visitor\n2. Click the **\u22EF more options** menu in the top-right\n3. Click **Block visitor**\n4. Confirm\n\n**From the Visitors list:**\n\n1. Click the visitor in the **Visitors** list\n2. Click **Block**\n\nThe block takes effect immediately.\n\n## What a blocked visitor experiences\n\nThe widget loads normally but when they try to send a message, they see:\n\n> \"You are unable to start a conversation at this time.\"\n\nThere is no indication that they've been specifically blocked.\n\n## Blocking by IP address\n\nKerabie blocks at the **visitor ID** level (session cookie) by default. For persistent spam from a known IP address:\n\n1. Go to **Settings \u2192 Chat Widget \u2192 Blocked IPs**\n2. Enter the IP address or CIDR range (e.g. `192.168.1.0/24`)\n3. Save\n\nIP blocks apply across all visitors from that address.\n\n## Viewing and unblocking\n\nGo to **Settings \u2192 Chat Widget \u2192 Blocked visitors** to see the full list. Click **Unblock** next to any entry to restore access.\n\n## Limitations\n\n- Blocking by visitor ID only works if the visitor uses the same browser, clearing cookies lets them start fresh with a new session ID\n- IP blocking is more persistent but affects all visitors from that IP (e.g. office networks or shared IPs)\n- Blocking does not delete past conversations; those remain in history\n\n## Reporting\n\nIf a visitor sends threatening or illegal content, block them and document the conversation before it's needed for reporting. Use **Export \u2192 CSV** on the conversation to save a record.", headings: [{ level: 2, text: "Blocking a visitor" }, { level: 2, text: "What a blocked visitor experiences" }, { level: 2, text: "Blocking by IP address" }, { level: 2, text: "Viewing and unblocking" }, { level: 2, text: "Limitations" }, { level: 2, text: "Reporting" }] }, { id: "visitors/overview", url: "https://kerabie.com/docs/visitors/overview", title: "Visitors", description: "See who is on your site in real time and start conversations proactively.", section: "visitors", body: `The **Visitors** view shows every person currently on your site with the Kerabie widget loaded, in real time. You can see what page they're on, how long they've been there, and start a conversation with them before they ask.
648
+
649
+ ## Accessing the Visitors view
650
+
651
+ Click **Visitors** in the sidebar. The list updates live, new visitors appear as they land and drop off as they leave.
652
+
653
+ ## What you can see per visitor
654
+
655
+ | Field | Description |
656
+ |---|---|
657
+ | **Page** | The current page URL the visitor is on |
658
+ | **Time on site** | How long they've been on your site this session |
659
+ | **Location** | Country and city (from IP geolocation) |
660
+ | **Device** | Browser and operating system |
661
+ | **Visit count** | Total number of times they've visited |
662
+ | **Last seen** | When they were last on your site |
663
+ | **Name / Email** | If they've identified themselves or started a conversation before |
664
+
665
+ ## Starting a conversation
666
+
667
+ Click any visitor in the list, then click **Start conversation**. A message appears in the widget on their screen, exactly like a proactive message. If they reply, it becomes a live conversation in your inbox.
668
+
669
+ ## Identified vs anonymous visitors
670
+
671
+ - **Anonymous**: visitor has not provided their name or email yet; shown as "Visitor #1234"
672
+ - **Identified**: visitor has sent a message, filled in the pre-chat form, or been identified via the widget SDK
673
+
674
+ Once a visitor is identified, all their sessions are linked and shown in their **Contact** profile.
675
+
676
+ ## Filtering the list
677
+
678
+ Filter visitors by:
679
+ - Current page URL
680
+ - Country
681
+ - Device type
682
+ - Visit count (e.g. returning visitors only)
683
+ - Has/has not started a conversation
684
+
685
+ ## Related
686
+
687
+ - [Visitor Tracking](/docs/visitors/tracking): what data is collected and how
688
+ - [Blocking Visitors](/docs/visitors/blocking): block spam or abusive visitors
689
+ - [Proactive Messages](/docs/campaigns/proactive): automatically message visitors based on page and time`, headings: [{ level: 2, text: "Accessing the Visitors view" }, { level: 2, text: "What you can see per visitor" }, { level: 2, text: "Starting a conversation" }, { level: 2, text: "Identified vs anonymous visitors" }, { level: 2, text: "Filtering the list" }, { level: 2, text: "Related" }] }, { id: "visitors/tracking", url: "https://kerabie.com/docs/visitors/tracking", title: "Visitor Tracking", description: "What data Kerabie collects about visitors and how to use it.", section: "visitors", body: 'Kerabie collects behavioural data about each visitor session to help your team provide faster, more contextual support. No third-party tracking pixels are used; all data flows directly to Kerabie.\n\n## What is collected automatically\n\n| Data point | How it\'s collected |\n|---|---|\n| Page URL and title | Captured on each page load |\n| Time on page / site | Tracked via JavaScript timer |\n| Referrer | HTTP referrer header from the first page load |\n| Country, city | Derived from IP address (IP is not stored) |\n| Browser and OS | User-Agent header |\n| Device type | Desktop / tablet / mobile |\n| Session ID | Anonymous cookie set in the visitor\'s browser |\n| Visit count | Incremented per session using the session cookie |\n\n## What is NOT collected by default\n\n- Name and email: only captured if the visitor starts a conversation or fills a pre-chat form\n- Any data from other tabs or websites\n- Keystrokes outside the chat widget\n\n## Identifying visitors\n\nPass known user data from your app to Kerabie using the widget SDK:\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: "wk_yourkey_here",\n user: {\n name: "Jane Smith",\n email: "jane@example.com",\n userId: "user_123",\n plan: "pro" // custom attribute\n }\n };\n</script>\n```\n\nAny fields you pass under `user` are stored as visitor attributes and visible in the conversation sidebar and Visitors list.\n\n## Custom attributes\n\nYou can pass any key/value pair under `user`, not just name and email. Custom attributes appear in:\n- The visitor profile panel during conversations\n- The Visitors list (filterable)\n- Automation conditions (e.g. `plan = enterprise`)\n\n## Data retention\n\nVisitor session data is retained for **12 months** on all paid plans, **30 days** on Free.\n\n## GDPR considerations\n\n- Kerabie processes visitor data as a **data processor** on your behalf; you are the data controller\n- The session cookie (`kw_session`) is strictly necessary for the widget to function; it does not require explicit consent under ePrivacy rules in most jurisdictions, but consult your legal team\n- Visitors can request deletion via your [Contacts](/docs/conversations/history-export) panel or by submitting a GDPR request to you directly', headings: [{ level: 2, text: "What is collected automatically" }, { level: 2, text: "What is NOT collected by default" }, { level: 2, text: "Identifying visitors" }, { level: 2, text: "Custom attributes" }, { level: 2, text: "Data retention" }, { level: 2, text: "GDPR considerations" }] }, { id: "widget/calls", url: "https://kerabie.com/docs/widget/calls", title: "Calls", description: "Let visitors call your team by voice or video from the website widget, in your mobile app, and let an AI answer when nobody is free.", section: "widget", body: "Visitors can call your team from the chat. Calls start from the phone button in the chat header (and a camera button when video is on), and they ring in the Kerabie dashboard and the Kerabie mobile app.\n\n## Turn it on\n\n1. Go to **Settings \u2192 Calls** and switch on **Allow calls**. New accounts start with it on.\n2. Optionally allow **Video**, and the [AI voice agent](/docs/ai/voice-agent), which answers when nobody is free.\n\nCalls need call minutes on your plan, and the 14-day Pro trial includes them. The buttons only appear when calls are on and the plan still has minutes, so there is nothing to change on your site.\n\n## How a call goes\n\n- Every free agent is rung at once, in the dashboard and in the mobile app. The first to answer takes the call.\n- When nobody is free, the [AI voice agent](/docs/ai/voice-agent) answers if you turned it on. Otherwise the visitor waits in a queue and sees their place in line, and the call is marked missed after two minutes.\n- If nobody is available at all (your widget is set to away, it is outside your office hours, or no agent is signed in) and the AI voice agent is off, the visitor is told straight away to send a message instead of waiting.\n- The visitor's browser asks for microphone (and camera) permission the first time. A visitor who reloads the page during a call is put back into it.\n- On a video call each side sees the other, with their own camera as a small picture in the corner.\n- One visitor can start up to 5 calls an hour.\n\n## Details before the call\n\nIf you ask customers for their details before a chat (**Messages \u2192 Customer enrichment**), a call asks the same questions first, once. A customer your site or app has already identified, for example with `user` in the widget settings or `setUser` in the SDKs, is not asked.\n\n## Good to know\n\n- The call library loads when the first call starts, from `cdn.jsdelivr.net`. If your site has a Content Security Policy, allow that host in `script-src`, and your LiveKit address (`wss://*.livekit.cloud`) in `connect-src`.\n- Calls need HTTPS, because browsers only give the microphone to secure pages. On a page without HTTPS the visitor is told so instead of the call starting.\n- Calls with a person and calls the AI answers are counted separately against your plan, one started minute at a time.\n- For apps, see the [React Native](/docs/sdks/react-native) and [Flutter](/docs/sdks/flutter) SDK pages.", headings: [{ level: 2, text: "Turn it on" }, { level: 2, text: "How a call goes" }, { level: 2, text: "Details before the call" }, { level: 2, text: "Good to know" }] }, { id: "widget/customization", url: "https://kerabie.com/docs/widget/customization", title: "Widget Customization", description: "Change colors, logo, layout, messages, and behavior to match your brand.", section: "widget", body: 'All widget settings live in **Settings \u2192 Widget Settings** in the dashboard. Click **Save Changes** and they apply to your live widget and the React Native SDK; no re-deploy needed.\n\nClick **Preview** at the top of that page to open your saved widget on a sample web page in a new tab.\n\n## Style\n\n- **Colors**: by default the widget uses a slate \u2192 teal gradient (`#334155` \u2192 `#0f766e`). Pick your own brand color from the presets or the color picker, and switch between **Solid** and **Gradient**; the launcher button, header, and visitor messages use it. **Reset** puts the colors back to the default.\n- **Logo**: upload an image (PNG, JPG, GIF, or WebP, up to 5 MB); free on every plan. It appears in the widget header and welcome bubble.\n- **Position**: bottom right, bottom left, top right, or top left.\n- **Theme**: light, dark, or auto (follows the visitor\'s OS preference).\n- **Border radius**: none, small, medium, or large corner rounding for the chat window and the launcher button.\n- **"Powered by Kerabie"**: a small credit under the message box. Hiding it is included on every paid plan, or available as an add-on on Free.\n\n## Launcher and layout\n\n- **Launcher**, a floating **chat bubble**, or a wide **search bar** ("Ask anything\u2026") docked to the top or bottom of the page. Typing in the bar and pressing Enter opens the chat and sends what was typed. The search bar has no attachment button, attach files from the chat itself once it opens.\n- **Bubble icon**: upload your own icon for the bubble (Pro and Enterprise), and set the **icon size** (16\u201348 px) and **label font size** (10\u201320 px).\n- **Chat layout**: **Full widget** (Home, conversations, help and tickets tabs) or **Single chat**: just the chat, with all of a visitor\'s messages shown as one continuous conversation. In Single chat you can optionally turn on the **bottom navigation** and a header **menu** (change name, sounds, download or email transcript).\n\nYour settings are remembered in the visitor\'s browser, so the widget shows the right look immediately on every page load and updates quietly if you have changed anything since.\n\n## AI page awareness\n\nOn every paid plan, turn on **AI page awareness** (Settings > Chat > AI). The AI assistant can then answer questions about the page the visitor is currently on, "what does this page say about pricing?", and can look at screenshots they attach or paste into the chat. While it works, visitors see an animated "thinking" orb.\n\n## Messages\n\n- **Welcome message**: the greeting shown before a visitor starts chatting. Add `{{name}}` to greet the visitor by first name (it is dropped when their name is unknown).\n- **When the chat bubble / greeting is clicked**: choose what opens: the home screen, a new conversation, help articles, or tickets (when tickets are on).\n- **Tickets**: the Tickets tab is off by default; turn it on under Behaviour.\n- **Offline message**: shown when your team is offline. See [Offline Mode](/docs/widget/offline-mode).\n- **Quick replies**: suggestion chips shown before the visitor\'s first message.\n\n## Behavior\n\n- **Show typing indicator**: visitors see when an agent is typing.\n- **Sound notifications**: play a sound in the widget when a new agent or bot reply arrives.\n- **Save chat history**: when off, visitors don\'t see a list of their past conversations; they can still start a new chat.\n- **Auto-open**: expand the widget automatically after a delay, once per visitor session.\n- **Auto-close**: close inactive conversations and tickets after a set time.\n\n## AI\n\nWith the AI chatbot enabled, you can upload a **bot avatar** that appears next to AI replies instead of the default bot icon.\n\n## Overriding from code\n\nThe dashboard is the source of truth. For per-page tweaks, a few values can be overridden in the install snippet:\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: "wk_yourkey_here",\n theme: {\n primaryColor: "#6d28d9",\n secondaryColor: "#8b5cf6",\n bubbleIcon: "https://yoursite.com/logo.png" // overrides the logo\n },\n chatSettings: {\n welcomeMessage: "Hi there \u{1F44B} How can we help you today?",\n offlineMessage: "We\'re away, leave a message and we\'ll reply soon."\n }\n };\n</script>\n```\n\nAny value set here wins over the dashboard setting on that page.\n\nTo open the chat from your own button, hide the launcher bubble, or move the widget at runtime, see the [JavaScript API](/docs/widget/javascript-api).', headings: [{ level: 2, text: "Style" }, { level: 2, text: "Launcher and layout" }, { level: 2, text: "AI page awareness" }, { level: 2, text: "Messages" }, { level: 2, text: "Behavior" }, { level: 2, text: "AI" }, { level: 2, text: "Overriding from code" }] }, { id: "widget/domain-whitelist", url: "https://kerabie.com/docs/widget/domain-whitelist", title: "Domain Whitelist", description: "Restrict your widget to only load on approved domains.", section: "widget", body: "By default a widget key works on any domain. Enable domain whitelisting to prevent your widget from being embedded on other sites.\n\n## Enable whitelisting\n\n1. Go to **Settings \u2192 Widget \u2192 Security**\n2. Toggle on **Domain whitelist**\n3. Add your allowed domains (one per line):\n ```\n yoursite.com\n www.yoursite.com\n staging.yoursite.com\n localhost\n ```\n4. Save\n\n Add `localhost` and any staging domains before enabling, or your widget will stop loading in development.\n\n## How it works\n\nThe Kerabie CDN checks the `Referer` and `Origin` headers on every widget load. If the domain isn't in your whitelist, the widget script returns an empty response.\n\nThis doesn't prevent someone from making API calls directly (use API key scoping for that), but it stops your chat bubble from appearing on unauthorized sites.", headings: [{ level: 2, text: "Enable whitelisting" }, { level: 2, text: "How it works" }] }, { id: "widget/install-html", url: "https://kerabie.com/docs/widget/install-html", title: "Install via HTML", description: "Add Kerabie to any website with two script tags.", section: "widget", body: "## Get your widget key\n\n1. Log in to [kerabie.com/app](https://kerabie.com/app)\n2. Go to **Settings \u2192 Widget**\n3. Copy your **Widget Key** (starts with `wk_`)\n\n## Add the script\n\nPaste both tags before the closing `</body>` on every page:\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: \"wk_yourkey_here\"\n };\n</script>\n<script async src=\"https://cdn.kerabie.com/widget.js\"></script>\n```\n\nThat's it: the chat bubble appears immediately.\n\n## Passing user identity\n\nIf your site has logged-in users, identify them so agents know who they're talking to:\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: \"wk_yourkey_here\",\n user: {\n name: \"Jane Smith\",\n email: \"jane@example.com\",\n userId: \"user_123\"\n }\n };\n</script>\n```\n\n## Load only on specific pages\n\nTo show the widget on certain pages only, wrap the second script in a condition:\n\n```html\n<script>\n window.KERConfig = { widgetKey: \"wk_yourkey_here\" };\n</script>\n<script>\n if (window.location.pathname.startsWith('/support')) {\n var s = document.createElement('script');\n s.src = 'https://cdn.kerabie.com/widget.js';\n s.async = true;\n document.body.appendChild(s);\n }\n</script>\n```\n\n## Content Security Policy\n\nIf your site uses CSP headers, add these directives:\n\n```\nscript-src 'self' https://cdn.kerabie.com;\nconnect-src 'self' https://api.kerabie.com wss://ws.kerabie.com;\nimg-src 'self' https://assets.kerabie.com data:;\nframe-src https://cdn.kerabie.com;\n```", headings: [{ level: 2, text: "Get your widget key" }, { level: 2, text: "Add the script" }, { level: 2, text: "Passing user identity" }, { level: 2, text: "Load only on specific pages" }, { level: 2, text: "Content Security Policy" }] }, { id: "widget/install-shopify", url: "https://kerabie.com/docs/widget/install-shopify", title: "Install on Shopify", description: "Add Kerabie live chat to your Shopify store; no theme code editing required.", section: "widget", body: '## Connect your store\n\nIn the Kerabie dashboard, go to **Channels \u2192 Shopify**, enter your `your-store.myshopify.com` domain, and approve the install on Shopify\'s consent screen. You\'re sent back to Kerabie with the store connected; there\'s no API key to copy.\n\n## Add the widget to your storefront\n\nEvery Online Store 2.0 theme (the default for new stores) includes a **Custom Liquid** block, so you never have to open `theme.liquid`.\n\n <Step title="Copy your install code">\n In Kerabie, go to **Settings \u2192 Widget Settings** and click **Get Install Code**, then open the **Shopify** tab and copy the snippet.\n </Step>\n <Step title="Open the theme editor">\n In Shopify admin go to **Online Store \u2192 Themes** and click **Customize** on your live theme.\n </Step>\n <Step title="Add a Custom Liquid block to the footer">\n Switch to the **Footer** group so the widget loads on every page, click **Add section** (or **Add block**), and choose **Custom Liquid**.\n </Step>\n <Step title="Paste and save">\n Paste the snippet into the Liquid box and click **Save**. The chat bubble appears across your storefront.\n </Step>\n\nThe snippet looks like this:\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: "wk_yourkey_here",\n };\n</script>\n<script async src="https://cdn.kerabie.com/widget.js"></script>\n```\n\n Using an older theme without Custom Liquid? Send the snippet to whoever manages your theme code and have them paste it before the closing `</body>` tag in `Layout \u2192 theme.liquid`.\n\n## Identify logged-in customers\n\nBecause the snippet lives in a Liquid block, you can pass the signed-in customer\'s name and email so agents know who they\'re talking to:\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: "wk_yourkey_here",\n {% if customer %}\n user: {\n name: "{{ customer.name | escape }}",\n email: "{{ customer.email | escape }}"\n }\n {% endif %}\n };\n</script>\n<script async src="https://cdn.kerabie.com/widget.js"></script>\n```\n\n## Show on specific pages only\n\nWrap the snippet in a Liquid condition, for example, cart and checkout only:\n\n```html\n{% if template contains \'cart\' or template contains \'checkout\' %}\n<script>\n window.KERConfig = { widgetKey: "wk_yourkey_here" };\n</script>\n<script async src="https://cdn.kerabie.com/widget.js"></script>\n{% endif %}\n```', headings: [{ level: 2, text: "Connect your store" }, { level: 2, text: "Add the widget to your storefront" }, { level: 2, text: "Identify logged-in customers" }, { level: 2, text: "Show on specific pages only" }] }, { id: "widget/install-webflow", url: "https://kerabie.com/docs/widget/install-webflow", title: "Install on Webflow / Squarespace / Wix", description: "Add Kerabie to no-code website builders.", section: "widget", body: '<Tab title="Webflow">\n 1. Open your Webflow project and go to **Project Settings \u2192 Custom Code**\n 2. In the **Footer Code** section, paste:\n ```html\n <script>\n window.KERConfig = { widgetKey: "wk_yourkey_here" };\n </script>\n <script async src="https://cdn.kerabie.com/widget.js"></script>\n ```\n 3. Click **Save** and publish your site\n </Tab>\n <Tab title="Squarespace">\n 1. Go to **Settings \u2192 Advanced \u2192 Code Injection**\n 2. In the **Footer** section, paste the two script tags\n 3. Click **Save** and preview your site\n </Tab>\n <Tab title="Wix">\n 1. Open the Wix Editor and click **Add \u2192 Embed \u2192 Custom Embed**\n 2. Click **Enter Code** and paste both script tags\n 3. Position the embed element anywhere on the page (it renders invisibly)\n 4. Publish your site\n\n <Note>Wix requires the Wix Premium plan to add custom code.</Note>\n </Tab>\n <Tab title="Framer">\n 1. Open your Framer project\n 2. Go to **Site Settings \u2192 General \u2192 Custom Code**\n 3. Paste both script tags in the **End of `<body>` tag** section\n 4. Publish\n </Tab>', headings: [] }, { id: "widget/install-wordpress", url: "https://kerabie.com/docs/widget/install-wordpress", title: "Install on WordPress", description: "Add Kerabie to WordPress in under 2 minutes; no code required.", section: "widget", body: `## Install the plugin
690
+
691
+ <Step title="Download or search">
692
+ In WP Admin go to **Plugins \u2192 Add New** and search **"Kerabie Live Chat"**, or download the ZIP from [kerabie.com/integrations/wordpress](https://kerabie.com/integrations/wordpress).
693
+ </Step>
694
+ <Step title="Activate">
695
+ Click **Install Now** then **Activate**.
696
+ </Step>
697
+ <Step title="Enter your Widget Key">
698
+ Go to **WP Admin \u2192 Kerabie \u2192 Settings**. Paste your Widget Key (from Kerabie dashboard \u2192 Settings \u2192 Widget).
699
+ </Step>
700
+ <Step title="Save">
701
+ Click **Save Settings**. The chat bubble appears on your site immediately.
702
+ </Step>
703
+
704
+ ## Features
705
+
706
+ - **Automatic user identity**: when a WordPress user is logged in, their name and email are passed to the widget automatically. Agents see who they're talking to without asking.
707
+ - **Page targeting**: choose which pages show the widget (all pages, specific pages, or post types).
708
+ - **Color and position**: configure widget appearance directly in WP Admin.
709
+ - **Branding toggle**: hide "Powered by Kerabie" on Pro plan.
710
+
711
+ ## Passing custom data
712
+
713
+ For WooCommerce or membership sites, you can extend the plugin to pass extra data:
714
+
715
+ \`\`\`php
716
+ // functions.php
717
+ add_filter('kerabie_user_data', function($data) {
718
+ if (is_user_logged_in()) {
719
+ $user = wp_get_current_user();
720
+ $data['plan'] = get_user_meta($user->ID, 'membership_level', true);
721
+ $data['orders'] = wc_get_customer_order_count($user->ID);
722
+ }
723
+ return $data;
724
+ });
725
+ \`\`\`
726
+
727
+ ## Troubleshooting
728
+
729
+ <Accordion title="Widget not appearing">
730
+ Check that the Widget Key is saved correctly. Make sure no caching plugin is serving a stale version of your pages, clear your cache after installing.
731
+ </Accordion>
732
+ <Accordion title="Widget appears on pages I don't want">
733
+ Use the **Page Targeting** setting in WP Admin \u2192 Kerabie \u2192 Settings to restrict which pages show the widget.
734
+ </Accordion>
735
+ <Accordion title="Conflict with another chat plugin">
736
+ Deactivate any other live chat plugins. Two chat widgets on the same page will conflict.
737
+ </Accordion>`, headings: [{ level: 2, text: "Install the plugin" }, { level: 2, text: "Features" }, { level: 2, text: "Passing custom data" }, { level: 2, text: "Troubleshooting" }] }, { id: "widget/javascript-api", url: "https://kerabie.com/docs/widget/javascript-api", title: "JavaScript API", description: "Open and close the chat, hide the launcher bubble, and move the widget from your own code.", section: "widget", body: 'Once the widget has loaded, it exposes `window.Kerabie`, so you can control it from your own buttons, links and scripts.\n\n## Wait for the widget to load\n\nThe widget script loads `async`, so `window.Kerabie` doesn\'t exist yet when your own page scripts first run. Use either of these to run code once it\'s ready.\n\n**`onReady` in your install snippet:**\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: "wk_yourkey_here",\n onReady: function (kerabie) {\n // kerabie === window.Kerabie\n kerabie.setPosition("bottom-left");\n }\n };\n</script>\n<script async src="https://cdn.kerabie.com/widget.js"></script>\n```\n\n**The `kerabie:ready` event**, from anywhere on the page:\n\n```js\nwindow.addEventListener("kerabie:ready", function () {\n window.Kerabie.open();\n});\n```\n\nFor code that runs on a user action (like a click), it\'s usually loaded already. Guard the call in case it isn\'t:\n\n```js\nwindow.Kerabie?.open();\n```\n\n## Methods\n\n| Method | What it does |\n|---|---|\n| `Kerabie.open()` | Opens the chat window. |\n| `Kerabie.close()` | Closes the chat window. |\n| `Kerabie.toggle()` | Opens it if closed, closes it if open. |\n| `Kerabie.isOpen()` | Returns `true` while the chat window is open. |\n| `Kerabie.hideLauncher()` | Hides the launcher bubble. The chat can still be opened with `open()`. |\n| `Kerabie.showLauncher()` | Shows the launcher bubble again. |\n| `Kerabie.setPosition(position)` | Moves the widget: `"bottom-right"`, `"bottom-left"`, `"top-right"` or `"top-left"`. |\n| `Kerabie.setConsent(granted)` | Tells the widget whether the visitor has consented to non-essential local storage. Only does anything if **Cookie Consent** is on under **Settings \u2192 Org Settings \u2192 Security and Privacy**, otherwise it\'s a no-op. |\n\n`setPosition` and `hideLauncher` apply to the current page only. Your dashboard setting under **Settings \u2192 Widget Settings \u2192 Style** stays the default everywhere else.\n\n## Cookie Consent mode\n\nThe widget doesn\'t use cookies, visitor/session data (a session id, conversation id, name/email if given, and appearance) is kept in `localStorage`. When your org enables **Cookie Consent** in Settings, the widget still works normally, but stops **writing** any of that to `localStorage` until you call `setConsent(true)`, so nothing persists across page loads for a visitor who hasn\'t consented. Call it from your own cookie bar\'s callbacks:\n\n```js\n// wherever your cookie/consent bar calls back on accept/decline/withdraw\nwindow.Kerabie?.setConsent(true) // start persisting the visitor\'s session locally\nwindow.Kerabie?.setConsent(false) // stop, and clear whatever was already stored\n```\n\n Reads are never gated; if a visitor already consented on a previous visit, they\'re still recognized on this one. Only writes wait for `setConsent(true)`.\n\n## Install snippet options\n\nThese set the same things before the widget first appears, so there\'s no flicker:\n\n| Option | Type | What it does |\n|---|---|---|\n| `position` | string | Same values as `setPosition`. Overrides the dashboard position on this page. |\n| `hideFloatingButton` | boolean | Start with the launcher bubble hidden. |\n| `defaultOpen` | boolean | Start with the chat window open on every page load. |\n| `onReady` | function | Called with `window.Kerabie` once it\'s ready. |\n\n## Examples\n\n### Open the chat from your own button\n\nHide the bubble and use a button that fits your design instead:\n\n```html\n<button id="support-btn" type="button">Chat with support</button>\n\n<script>\n window.KERConfig = {\n widgetKey: "wk_yourkey_here",\n hideFloatingButton: true\n };\n document.getElementById("support-btn").addEventListener("click", function () {\n window.Kerabie?.open();\n });\n</script>\n<script async src="https://cdn.kerabie.com/widget.js"></script>\n```\n\n### Open the chat from a link\n\n```html\n<a href="#" onclick="window.Kerabie?.open(); return false;">Talk to us</a>\n```\n\n### Move the widget on one page\n\nFor example, out of the way of a "Buy" bar on product pages:\n\n```js\nwindow.addEventListener("kerabie:ready", function () {\n if (location.pathname.startsWith("/products/")) {\n window.Kerabie.setPosition("bottom-left");\n }\n});\n```\n\n Building a React Native app? The React Native SDK has the same controls: `open`, `close` and `toggle` from `useKerAbieContext()`, plus the `position` and `hideFloatingButton` props on `KerAbieProvider`. See [React Native SDK](/docs/sdks/react-native).', headings: [{ level: 2, text: "Wait for the widget to load" }, { level: 2, text: "Methods" }, { level: 2, text: "Cookie Consent mode" }, { level: 2, text: "Install snippet options" }, { level: 2, text: "Examples" }, { level: 3, text: "Open the chat from your own button" }, { level: 3, text: "Open the chat from a link" }, { level: 3, text: "Move the widget on one page" }] }, { id: "widget/offline-mode", url: "https://kerabie.com/docs/widget/offline-mode", title: "Offline Mode", description: "What visitors see when your team isn't available.", section: "widget", body: "The widget shows your team as **offline** when either:\n\n- no agent is signed in to Kerabie (on the web dashboard or mobile app), or\n- you've set the availability switcher at the top of **Settings \u2192 Widget Settings** to **Away**.\n\n**Away** is a manual override, use it to go offline for holidays or after hours even while agents are still signed in. It takes effect for visitors right away, no save needed. The switcher shows **No agents online** when it's set to Available but nobody is signed in.\n\n## What visitors see\n\nWhile you're offline, the chat window shows your **offline message** (set under **Settings \u2192 Widget Settings \u2192 Messages**). Visitors can still send messages; they land in your inbox for when you're back.\n\n If the AI chatbot is enabled, the offline message isn't shown; the bot keeps answering whether or not an agent is available.\n\n## Email follow-up\n\nIf a visitor who has shared their email messages you while you're offline, Kerabie emails them to confirm their message was received, so they know to watch their inbox for your reply.", headings: [{ level: 2, text: "What visitors see" }, { level: 2, text: "Email follow-up" }] }, { id: "widget/overview", url: "https://kerabie.com/docs/widget/overview", title: "Chat Widget", description: "The Kerabie chat widget runs on any website and connects visitors to your team in real time.", section: "widget", body: 'The widget is a lightweight JavaScript snippet (~8 KB gzipped). It renders a chat bubble on your site, opens a conversation panel, and connects via WebSocket for real-time messaging.\n\n## Installation methods\n\n| Method | Best for |\n|---|---|\n| [HTML script tag](/docs/widget/install-html) | Any static site, custom builds |\n| [WordPress plugin](/docs/widget/install-wordpress) | WordPress sites |\n| [Webflow / Squarespace / Wix](/docs/widget/install-webflow) | No-code builders |\n| [Shopify](/docs/widget/install-shopify) | E-commerce |\n| [React Native SDK](/docs/sdks/react-native) | Mobile apps |\n\n## Identifying visitors\n\nPass user data so agents see who they\'re talking to:\n\n```html\n<script>\n window.KERConfig = {\n widgetKey: "wk_yourkey_here",\n user: {\n name: "Jane Smith",\n email: "jane@example.com",\n userId: "user_123", // your internal ID\n plan: "pro", // any custom attribute\n }\n };\n</script>\n<script async src="https://cdn.kerabie.com/widget.js"></script>\n```\n\n## JavaScript API\n\nControl the widget programmatically:\n\n```javascript\n// Open / close\nwindow.Kerabie.open();\nwindow.Kerabie.close();\nwindow.Kerabie.toggle();\n\n// Unread count\nwindow.Kerabie.on(\'unreadCount\', (count) => {\n document.title = count > 0 ? `(${count}) My App` : \'My App\';\n});\n\n// Pre-fill a message\nwindow.Kerabie.openWithMessage(\'I need help with my order\');\n```\n\n## Available plans\n\n| Feature | Free | Starter | Pro | Business |\n|---|---|---|---|---|\n| Widget on unlimited pages | \u2713 | \u2713 | \u2713 | \u2713 |\n| Remove "Powered by Kerabie" | No | No | \u2713 | \u2713 |\n| Custom pages in widget | No | No | \u2713 | \u2713 |\n| Domain whitelist | \u2713 | \u2713 | \u2713 | \u2713 |', headings: [{ level: 2, text: "Installation methods" }, { level: 2, text: "Identifying visitors" }, { level: 2, text: "JavaScript API" }, { level: 2, text: "Available plans" }] }, { id: "readme/typescript-sdk", url: "", title: "TypeScript SDK README", description: "The TypeScript SDK package readme, straight from its repository.", section: "sdks", body: "# @kerabie/sdk\n\nOfficial TypeScript SDK for the [Kerabie](https://kerabie.com) customer support platform.\n\n## Installation\n\n```bash\nnpm install @kerabie/sdk\n```\n\n## Quick Start\n\n```typescript\n\nconst kerabie = new KerAbieClient({\n apiKey: process.env.KERABIE_API_KEY,\n orgId: 'your-org-id', // Optional; set per request or in client\n baseUrl: 'https://api.kerabie.com', // Default\n});\n```\n\n## Conversations\n\n```typescript\n// List conversations\nconst { data, meta } = await kerabie.conversations.list({ status: 'OPEN', limit: 20 });\n\n// Get a single conversation\nconst conv = await kerabie.conversations.get(123);\n\n// Send a message\nconst msg = await kerabie.conversations.sendMessage(123, { body: 'Hello!' });\n\n// Resolve / assign\nawait kerabie.conversations.resolve(123);\nawait kerabie.conversations.assign(123, agentId);\n```\n\n## Customers\n\n```typescript\n// Upsert a customer (create or update)\nconst customer = await kerabie.customers.upsert({\n email: 'user@example.com',\n name: 'John Doe',\n externalRef: 'app_user_id_123',\n customAttributes: { plan: 'pro' },\n});\n\n// Get conversation history\nconst { data: convs } = await kerabie.customers.getConversations(customer.id);\n```\n\n## Campaigns\n\n```typescript\n// Proactive messages on your chat widget\nconst { campaigns, meta } = await kerabie.campaigns.list({ status: 'active' });\n\n// Saved as a draft; add isActive: true to launch it straight away\nconst { campaign } = await kerabie.campaigns.create({\n name: 'Pricing page nudge',\n message: 'Questions about plans? I can compare them for you in two minutes.',\n ctaLabel: 'Compare plans',\n trigger: 'time_on_page',\n triggerValue: 20, // seconds\n pages: '/pricing',\n audience: 'all',\n frequency: 'once_per_visitor',\n});\n\nawait kerabie.campaigns.update(campaign.id, { isActive: true }); // launch (403 if your plan's live limit is reached)\nawait kerabie.campaigns.duplicate(campaign.id); // copy as a draft\nconst stats = await kerabie.campaigns.getStats(); // { total, active, sent, opened, clicked, replied, limit, \u2026 }\nawait kerabie.campaigns.delete(campaign.id);\n```\n\n`trigger` is `page_load`, `time_on_page` (seconds), `scroll_depth` (percent) or `exit_intent`. `pages` takes comma-separated URL patterns with `*` wildcards; leave it empty for every page. How many campaigns can be live at once depends on your plan (`getStats().limit`, 0 = unlimited).\n\n## AI tools\n\n```typescript\n// Custom API actions your chatbot can call (check stock, validate an address, book a slot\u2026)\nconst { tools, stats } = await kerabie.aiTools.list(); // stats.limit: -1 = not on your plan, 0 = unlimited\n\nconst { tool } = await kerabie.aiTools.create({\n name: 'check_availability', // snake_case; this is what the AI calls\n displayName: 'Check availability',\n description: 'Use this when a customer asks if a specific item is in stock.',\n endpoint: 'https://api.yourstore.com/inventory/check', // must be a public address\n method: 'GET',\n authType: 'bearer',\n authConfig: { token: process.env.STORE_API_TOKEN }, // write-only, never returned\n responsePath: 'data.message',\n parameters: [\n { name: 'sku', type: 'string', description: 'Product SKU the customer mentioned' },\n { name: 'location', type: 'string', description: 'Store, if mentioned', required: false },\n ],\n});\n\nconst result = await kerabie.aiTools.test(tool.id, { sku: 'A-100' }); // calls your real endpoint once\nawait kerabie.aiTools.update(tool.id, { enabled: false }); // switch it off\nawait kerabie.aiTools.duplicate(tool.id); // a copy that starts switched off\nawait kerabie.aiTools.delete(tool.id);\n```\n\nHow many tools you can have depends on your plan; `create` and `duplicate` throw a 403 when it is full. Endpoints that resolve to a private or local address are rejected, and redirects are not followed.\n\n### MCP servers as tool sources\n\n```typescript\n// Let your chatbot use the tools of a remote MCP server\nconst { server } = await kerabie.mcpServers.create({\n name: 'Shop tools',\n description: 'Order and stock lookups for our shop. Use when a customer asks about an order or an item.',\n url: 'https://mcp.yourstore.com/mcp',\n authType: 'bearer',\n authConfig: { token: process.env.SHOP_MCP_TOKEN },\n});\nconsole.log(server.tools.map((t) => t.name)); // what the server offers\n\nawait kerabie.mcpServers.update(server.id, { enabledTools: ['check_stock'] }); // switch tools on\nawait kerabie.mcpServers.test(server.id, 'check_stock', { sku: 'A-100' });\nawait kerabie.mcpServers.sync(server.id); // read the tools again\nawait kerabie.mcpServers.delete(server.id);\n```\n\nEach switched-on MCP tool counts towards your plan's AI tool limit, the same as your own tools.\n\n## Contacts\n\n```typescript\n// People you have talked to (or added), last-contacted first\nconst { contacts, meta } = await kerabie.contacts.list({ type: 'VIP', search: 'palmex', limit: 20 });\n\nconst stats = await kerabie.contacts.getStats(); // { total, newThisWeek, leads, vip }\n\n// Add one by hand \u2014 throws an HttpError with status 409 if the email is already saved\nconst { contact } = await kerabie.contacts.create({ name: 'Chidi Okafor', email: 'chidi@palmex.com', company: 'Palmex', contactType: 'CUSTOMER' });\n\nawait kerabie.contacts.update(contact.id, { contactType: 'VIP', notes: 'Prefers WhatsApp', ownerId: 7 });\n\n// Bulk import (max 1,000 per call). Existing emails are skipped, bad rows are reported by number.\nconst result = await kerabie.contacts.import([{ name: 'Sam Adeyemi', email: 'sam@tenda.ng', type: 'lead' }]);\nresult; // { created, duplicates, invalid: [{ row, error }], total }\n```\n\n`type` is `LEAD`, `CUSTOMER` or `VIP`. `ownerId` is a team-member id and must belong to your organisation. Emails are stored lower-cased and must be unique per organisation.\n\n## Visitors\n\n```typescript\n// Who has been on the site recently (online and offline), newest first\nconst { data, meta } = await kerabie.visitors.list({ status: 'all', country: 'NG', limit: 20 });\ndata[0].visitCount; // 14 \u2192 coming back after 30+ minutes away counts as a new visit\ndata[0].status; // 'online' | 'offline' | 'blocked' | 'allowed'\n\n// On the site right now\nconst { data: online } = await kerabie.visitors.getActive();\n\n// Online now, countries and returning visitors (24h), blocked IPs, top countries\nconst overview = await kerabie.visitors.getOverview();\n\n// CSV of the visitors matching the filters (max 5,000 rows) \u2014 Pro plan and above\nconst { csv, filename, count, truncated } = await kerabie.visitors.export({ status: 'blocked' });\n```\n\n`status` comes from your IP rules and wins over presence: an allowlisted IP reads `allowed` even while the visitor is online. `ip` is `null` when your organisation hides visitor IPs in its privacy settings. Visitor history is kept for 30 days. `visitors.export()` needs a plan with data exports; on other plans it throws an `HttpError` with status `403`.\n\n## Knowledge Base\n\n```typescript\nconst { data: articles } = await kerabie.kb.search('refund policy');\n\nconst stats = await kerabie.kb.getStats(); // { total, published, drafts, views, top, categories }\nconst insights = await kerabie.kb.getInsights(); // { mostViewed, gaps, lowestRated, stale }: what to write or fix next\n\nconst article = await kerabie.kb.createArticle({\n title: 'How to reset your password',\n body: '...markdown content...',\n category: 'account',\n tags: ['password', 'security'],\n});\n```\n\n## Tickets\n\nTickets are async, form-submitted support requests \u2014 the same underlying conversation model as live chat, just created through a dedicated flow instead of a real-time widget session. Use `externalRef` to identify your own end users; repeated calls with the same `externalRef` land on the same open ticket instead of creating duplicates.\n\n```typescript\n// Create a ticket on behalf of one of your users\nconst { ticketId } = await kerabie.tickets.create({\n externalRef: 'user_42', // your own stable user id\n subject: 'Refund request',\n message: 'I was charged twice for order #1029.',\n priority: 'HIGH', // LOW | NORMAL | HIGH | URGENT\n name: 'Jane Doe',\n email: 'jane@example.com', // optional \u2014 enables email continuation: replies to the notification land back on this ticket\n});\n\n// List / filter\nconst { data: tickets } = await kerabie.tickets.list({ status: 'WAITING', priority: 'HIGH' });\n\n// Fetch a ticket with its full message thread\nconst ticket = await kerabie.tickets.get(ticketId);\n\n// Reply as the end user (e.g. relaying a message from your own app)\nawait kerabie.tickets.reply(ticketId, 'Any update on this?');\n\n// Update status / priority, or use the shortcuts\nawait kerabie.tickets.update(ticketId, { priority: 'URGENT' });\nawait kerabie.tickets.resolve(ticketId);\nawait kerabie.tickets.close(ticketId);\n\n// Overview numbers: open, unassigned, breaching SLA, and 7-day average resolution time\nconst stats = await kerabie.tickets.getStats();\nstats.resolutionDeltaPct; // -18 \u2192 18% faster than the 7 days before (null if nothing to compare)\n\n// CSV export of the tickets matching the filters (max 5,000 rows)\nconst { csv, filename, count, truncated } = await kerabie.tickets.export({ status: 'ACTIVE', priority: 'URGENT' });\n```\n\n`tickets.export()` needs a plan with data exports (Pro and above); on other plans it throws an `HttpError` with status `403`. Every cell is escaped, and values that start with `=`, `+`, `-` or `@` are prefixed with a single quote so spreadsheets show them as text instead of running them as formulas.\n\nTickets are subject to the same monthly conversation limit as live chat on your plan (a ticket **is** a conversation) \u2014 there's no separate ticket-specific cap or plan gate on the API itself.\n\n## Webhooks\n\n```typescript\n// Register a webhook\nconst webhook = await kerabie.webhooks.create({\n url: 'https://your-server.com/hooks/kerabie',\n events: ['message.sent.customer', 'conversation.created'],\n secret: 'your-signing-secret',\n});\n\n// Verify incoming webhooks (Express example)\n\nconst app = express();\napp.use(express.raw({ type: 'application/json' }));\n\napp.post('/hooks/kerabie', (req, res) => {\n const sig = req.headers['x-kerabie-signature'] as string;\n const rawBody = req.body.toString();\n\n if (!WebhooksResource.verify(rawBody, sig, process.env.KERABIE_WEBHOOK_SECRET!)) {\n return res.status(401).end();\n }\n\n const payload = WebhooksResource.parse(rawBody);\n console.log('Event:', payload.event, payload.data);\n res.status(200).end();\n});\n```\n\n## Analytics\n\nThe same numbers as your Kerabie dashboard (cached for up to 5 minutes; tickets and setup for 1 minute).\n\n```typescript\n// Conversation, response-time, satisfaction and AI numbers (days: default 30, max 365)\nconst summary = await kerabie.analytics.getSummary({ days: 7 });\nsummary.totals.conversations; // 186\nsummary.deltas.conversations; // 12 \u2192 +12% vs the previous 7 days (null if nothing to compare)\nsummary.ai.resolvedAlonePct; // 70 \u2192 % the AI handled without a human\nsummary.ai.agentHoursSaved; // 41 \u2192 an estimate\nsummary.trend; // [{ date, count, ai, human }, \u2026] oldest first\n\n// Team\nconst { agents } = await kerabie.analytics.getAgentStats({ period: '7d' });\n\n// The deeper numbers behind the dashboard's Analytics page\nconst insights = await kerabie.analytics.getInsights({ days: 30 });\ninsights.firstResponse.medianSeconds; // 144\ninsights.ai.questions[0]; // { question, asked, aiResolvedPct, avgRating, page, trendPct }\ninsights.customers.heatmap.cells; // 7 weekdays \xD7 12 two-hour blocks of customer messages\ninsights.pages[0]; // { page, views, questions, askRate, topQuestion }\n\n// Open tickets by priority and the % still within SLA\nconst sla = await kerabie.analytics.getTicketSla();\nsla.withinSlaPct; // 94\nsla.byPriority; // [{ priority: 'URGENT', count: 3, oldestMinutes: 14 }, \u2026]\n\n// First-run checklist: connect a channel, train the AI, create an automation\nconst setup = await kerabie.analytics.getSetupStatus(); // { channel, ai, automation }\n```\n\n`deltas.responseTime` is a percentage where a **negative** number is an improvement. `avgResponseTime` is in seconds.\n\n## AI take-over and customer history\n\n```typescript\n// List with inbox tab counts, tags and an `aiHandling` flag\nconst { data, meta } = await kerabie.conversations.list({ status: 'WAITING' });\nmeta.counts; // { all, WAITING, ACTIVE, RESOLVED, CLOSED } \u2014 ignores filters\ndata.filter((c) => c.aiHandling); // conversations the AI is handling alone\n\n// A customer's earlier conversations (newest first, default 5, max 20)\nconst { data: earlier } = await kerabie.conversations.getHistory(101, 5);\n\n// Stop the AI and hand the conversation to a team member\nawait kerabie.conversations.takeOver(101, { agentId: 7 });\n```\n\n`takeOver` throws an `HttpError` with status `400` if the AI is no longer handling the conversation (for example someone already took over), so it is safe to call from several places at once.\n\n## Error handling\n\n```typescript\n\ntry {\n const conv = await kerabie.conversations.get(9999);\n} catch (err) {\n if (err instanceof HttpError) {\n console.log(err.status); // 404\n console.log(err.message); // \"Conversation not found\"\n }\n}\n```\n\n## TypeScript\n\nAll types are exported:\n\n```typescript\n\n```\n\n## License\n\nMIT\n\n## Building with an AI assistant\n\nThe [`@kerabie/mcp`](https://kerabie.com/docs/sdks/mcp) server gives Claude, Cursor and other MCP clients the API reference, this SDK's types and integration guides, so generated code matches the real API. It needs no API key to help you build, and with one it can work with your account:\n\n```json\n{ \"mcpServers\": { \"kerabie\": { \"command\": \"npx\", \"args\": [\"-y\", \"@kerabie/mcp\"], \"env\": { \"KERABIE_API_KEY\": \"ker_live_...\" } } } }\n```", headings: [{ level: 2, text: "Installation" }, { level: 2, text: "Quick Start" }, { level: 2, text: "Conversations" }, { level: 2, text: "Customers" }, { level: 2, text: "Campaigns" }, { level: 2, text: "AI tools" }, { level: 3, text: "MCP servers as tool sources" }, { level: 2, text: "Contacts" }, { level: 2, text: "Visitors" }, { level: 2, text: "Knowledge Base" }, { level: 2, text: "Tickets" }, { level: 2, text: "Webhooks" }, { level: 2, text: "Analytics" }, { level: 2, text: "AI take-over and customer history" }, { level: 2, text: "Error handling" }, { level: 2, text: "TypeScript" }, { level: 2, text: "License" }, { level: 2, text: "Building with an AI assistant" }] }, { id: "readme/react-native-sdk", url: "", title: "React Native SDK README", description: "The React Native SDK package readme, straight from its repository.", section: "sdks", body: '# react-native-kerabie-helpdesk\n\nEmbed the [Kerabie](https://kerabie.com) help desk into any React Native or Expo app.\nFull TypeScript support, real-time chat, push notifications, contextual help, and multi-app analytics.\n\n## Contents\n\n- [Installation](#installation)\n- [Provider setup](#provider-setup)\n- [KerAbieProvider props](#kerAbieProvider-props)\n- [Hooks](#hooks)\n - [useKerAbieChat](#usekerAbieChat)\n - [useKerAbieUser](#usekerAbieUser)\n - [useKerAbieApp](#usekerAbieApp)\n - [useKerAbieNotifications](#usekerAbieNotifications)\n- [Components](#components)\n - [KerAbieFloatingButton](#kerAbieFloatingButton)\n - [KerAbieWidget](#kerAbieWidget)\n - [KerAbieChatScreen](#kerAbieChatScreen)\n- [The widget](#the-widget)\n- [Types](#types)\n- [Open modes in depth](#open-modes-in-depth)\n- [Campaigns](#campaigns)\n- [Calls (optional)](#calls-optional)\n- [Notification setup](#notification-setup)\n - [Android \u2014 register channels first](#android--register-channels-first)\n - [One-call setup](#one-call-setup)\n- [Contextual chat](#contextual-chat)\n - [Product click](#product-click)\n - [Order inquiry](#order-inquiry)\n - [Fintech transaction](#fintech-transaction)\n - [Ride sharing](#ride-sharing)\n- [User identity](#user-identity)\n\n---\n\n## Installation\n\n```bash\n# Expo managed workflow\nnpx expo install react-native-kerabie-helpdesk @react-native-async-storage/async-storage\n\n# Bare React Native\nnpm install react-native-kerabie-helpdesk @react-native-async-storage/async-storage\nnpx pod-install\n```\n\nOptional peer dependencies (only install what you use):\n\n```bash\n# Required for openMode="sheet"\nnpx expo install @gorhom/bottom-sheet react-native-reanimated react-native-gesture-handler\n\n# Required for push notifications\nnpx expo install expo-notifications\n```\n\n---\n\n## Provider setup\n\n`KerAbieProvider` must be an **ancestor** of every component that calls a `useKerAbie*` hook.\nPlace it below your gesture/safe-area wrappers but above your navigation stack.\n\n### Expo Router (`app/_layout.tsx`)\n\n```tsx\n\nexport default function RootLayout() {\n return (\n <GestureHandlerRootView style={{ flex: 1 }}>\n <SafeAreaProvider>\n <KerAbieProvider {/* \u2190 below gesture/safe-area */}\n widgetKey="wk_yourorg_123"\n appName="My App"\n appVersion={Constants.expoConfig?.version ?? \'1.0.0\'}\n openMode="modal"\n >\n <Stack /> {/* \u2190 above navigation */}\n </KerAbieProvider>\n </SafeAreaProvider>\n </GestureHandlerRootView>\n )\n}\n```\n\n### React Navigation (`App.tsx`)\n\nFor `openMode="push"`, `KerAbieProvider` must be **inside** `NavigationContainer`.\nFor `openMode="modal"` it can go either side, but inside is safer.\n\n```tsx\n\nexport default function App() {\n return (\n <GestureHandlerRootView style={{ flex: 1 }}>\n <NavigationContainer>\n <KerAbieProvider widgetKey="wk_yourorg_123" appName="My App" appVersion="1.0.0">\n <MyRootNavigator />\n </KerAbieProvider>\n </NavigationContainer>\n </GestureHandlerRootView>\n )\n}\n```\n\n---\n\n## KerAbieProvider props\n\n### Required\n\n| Prop | Type | Description |\n| ---- | ---- | ----------- |\n| `widgetKey` | `string` | Your widget key from Kerabie dashboard \u2192 Settings \u2192 Chat Widget. Format: `wk_yourorg_xxx`. |\n| `children` | `ReactNode` | Your app tree. |\n\n### App identity\n\n| Prop | Type | Default | Description |\n| ---- | ---- | ------- | ----------- |\n| `appName` | `string` | device package name | Human-readable app name shown in the agent sidebar and analytics filters (e.g. `"Delivery App"`). |\n| `appVersion` | `string` | `"0.0.0"` | Semver string. Use `Constants.expoConfig?.version` to set automatically. |\n| `appBuild` | `string` | `""` | iOS build number or Android `versionCode`. |\n\n### Open mode\n\n| Prop | Type | Default | Description |\n| ---- | ---- | ------- | ----------- |\n| `openMode` | `"modal" \\| "sheet" \\| "push"` | `"modal"` | How the chat screen opens. See [Open modes in depth](#open-modes-in-depth). |\n| `onNavigate` | `(route: KerNavigationRoute) => void` | \u2014 | Used when `openMode="push"`. The SDK calls it, and your app opens the chat in its own navigator. |\n| `onOpenChat` | `() => void` | \u2014 | Called when chat opens. Use with `openMode="custom"` to navigate yourself. |\n| `onCloseChat` | `() => void` | \u2014 | Called when chat closes. |\n\n### Floating button\n\n| Prop | Type | Default | Description |\n| ---- | ---- | ------- | ----------- |\n| `position` | `"bottom-right" \\| "bottom-left" \\| "top-right" \\| "top-left"` | `"bottom-right"` | Position of the auto-rendered floating button. |\n| `defaultOpen` | `boolean` | `false` | Open the chat immediately on mount. |\n| `hideFloatingButton` | `boolean` | `false` | Hide the auto-rendered button. Use your own trigger with `useKerAbieChat().open()`. |\n\n### User identity\n\n| Prop | Type | Description |\n| ---- | ---- | ----------- |\n| `user` | `KerUser` | Initial visitor identity. Can also be set at runtime with `useKerAbieUser().setUser()`. |\n\n### Appearance\n\nAll fields inside the `theme` object are optional.\n\n| Field | Type | Default | Description |\n| ----- | ---- | ------- | ----------- |\n| `theme.primaryColor` | `string` | `"rgb(78 121 105)"` | Accent colour for buttons, links, and agent bubbles. |\n| `theme.backgroundColor` | `string` | `"#ffffff"` | Chat screen background. |\n| `theme.textColor` | `string` | `"#0f172a"` | Body text colour. |\n| `theme.bubbleColor` | `string` | same as `primaryColor` | Floating button background colour. |\n| `theme.bubbleIcon` | `ImageSourcePropType` | chat icon | Custom floating button icon. |\n| `theme.bubbleSize` | `number` | `56` | Floating button diameter in points. |\n| `theme.fontFamily` | `string` | system default | Font family applied to all widget text. |\n| `theme.agentBubbleColor` | `string` | `"#f1f5f9"` | Background colour of agent message bubbles. |\n| `theme.userBubbleColor` | `string` | same as `primaryColor` | Background colour of visitor message bubbles. |\n\n### Chat settings (override dashboard)\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| `chatSettings.welcomeMessage` | `string` | Text shown before the first message. `{{name}}` is replaced by the identified user\'s first name (dropped when unknown). |\n| `chatSettings.offlineMessage` | `string` | Placeholder shown in the input when disconnected. |\n| `chatSettings.showBranding` | `boolean` | Show "Powered by Kerabie" footer (Pro feature: set to `false` to hide). |\n| `chatSettings.enableFileAttachments` | `boolean` | Allow visitors to send file attachments. |\n| `chatSettings.enableVoiceMessages` | `boolean` | Allow visitors to record voice messages. |\n| `chatSettings.layout` | `"full" \\| "single"` | `full` has the Home / Chat / Help tabs and a list of conversations; `single` opens straight on one continuous chat. Set in the dashboard. |\n| `chatSettings.showNavigation` | `boolean` | Show the tab bar in the `single` layout too. |\n| `chatSettings.chatHistoryEnabled` | `boolean` | Off hides past conversations: the Chat tab then only offers a new chat. |\n| `chatSettings.logoUrl` | `string` | Logo in the Home header. |\n| `chatSettings.showGreeting` / `greetingText` | `boolean` / `string` | The Home header greeting. `{{name}}` is replaced by the visitor\'s first name. |\n| `chatSettings.showStatusChip` / `statusChipText` | `boolean` / `string` | The "We reply in minutes" chip in the Home header. |\n\n### Notifications\n\nKerabie keeps one push token per visitor session, so the app uses **one push service**. Expo is built in. For Firebase or anything else, pass your own adapter.\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| `notifications.adapter` | `KerNotificationAdapter` | The push service. Defaults to `expoNotificationAdapter()`. Create it once, outside your component. |\n| `notifications.onTokenRegister` | `(token: string, provider: KerPushProvider) => void` | Called after the token is registered with Kerabie. |\n\nAn adapter has three methods:\n\n```ts\ninterface KerNotificationAdapter {\n requestPermission: () => Promise<boolean>;\n getToken: () => Promise<{ token: string; provider: \'expo\' | \'fcm\' } | null>;\n onTap: (handler: (data: Record<string, unknown>) => void) => Promise<() => void> | (() => void);\n}\n```\n\n`onTap` calls the handler with the notification\'s `data` when the user taps it, including the tap that launched the app. Kerabie notifications carry `conversationId` and `orgId` in `data`, and the SDK opens that conversation.\n\n```tsx\n\nconst adapter = expoNotificationAdapter({ projectId: \'your-expo-project-id\' });\n\n```\n\n---\n\n## Hooks\n\nAll hooks must be called inside a component that is a descendant of `KerAbieProvider`.\n\n### useKerAbieChat\n\nPrimary hook for controlling the chat and reading its state.\n\n```tsx\nconst {\n isOpen, // boolean \u2014 is the chat screen currently visible?\n open, // () => void \u2014 open the chat\n close, // () => void \u2014 close the chat\n toggle, // () => void \u2014 toggle open/close\n unreadCount, // number \u2014 messages received while chat was closed\n conversations, // KerConversationSummary[] \u2014 visitor\'s conversation list (newest first)\n conversationsLoaded, // boolean \u2014 the list has been loaded at least once\n startNewConversation, // () => void \u2014 switch to an empty draft chat\n openConversation, // (id: number) => Promise<void> \u2014 switch to a listed chat and load its messages\n refreshConversations, // () => Promise<void> \u2014 reload the list\n markConversationRead, // (id: number) => void \u2014 the visitor is viewing it: earlier replies stop counting as unread\n currentConversation, // KerConversation | null\n messages, // KerMessage[] \u2014 messages in the active conversation\n sendMessage, // (body: string, attachments?: KerAttachment[]) => Promise<void>\n isTyping, // boolean \u2014 an agent is currently typing\n onlineAgents, // number \u2014 agents currently online\n isConnected, // boolean \u2014 socket connected to Kerabie\n} = useKerAbieChat()\n```\n\n**Example \u2014 custom help button with unread badge:**\n\n```tsx\nfunction HelpButton() {\n const { open, unreadCount } = useKerAbieChat()\n return (\n <Pressable onPress={open}>\n <Text>Help</Text>\n {unreadCount > 0 && <Text>{unreadCount}</Text>}\n </Pressable>\n )\n}\n```\n\n---\n\n### useKerAbieUser\n\nManage visitor identity. Call `setUser` after login and `clearUser` after logout.\n\n```tsx\nconst {\n currentUser, // KerUser | null \u2014 currently identified visitor\n setUser, // (user: KerUser) => void\n clearUser, // () => void \u2014 resets to anonymous session\n updateAttribute, // (key: string, value: string) => void\n} = useKerAbieUser()\n```\n\n`KerUser` fields:\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| `id` | `string` | Your internal user ID. |\n| `name` | `string` | Visitor name shown to agents. |\n| `email` | `string` | Visitor email. |\n| `phone` | `string` | Visitor phone number. |\n| `role` | `string` | e.g. `"customer"`, `"driver"`, `"admin"`. |\n| `customAttributes` | `Record<string, string>` | Any extra key-value pairs shown in the agent sidebar. |\n\n**Example:**\n\n```tsx\nconst { setUser, clearUser, updateAttribute } = useKerAbieUser()\n\n// On login\nsetUser({ id: \'u_123\', name: \'Ada Obi\', email: \'ada@example.com\', role: \'customer\' })\n\n// Attach live context \u2014 appears in agent sidebar immediately\nupdateAttribute(\'plan\', \'pro\')\nupdateAttribute(\'last_order\', \'#ORD-4521\')\n\n// On logout\nclearUser()\n```\n\n---\n\n### useKerAbieApp\n\nRead or update app identity at runtime (e.g. after an OTA update changes the version).\n\n```tsx\nconst {\n appName, // string\n appVersion, // string\n appBuild, // string\n setAppVersion, // (v: string) => void\n} = useKerAbieApp()\n```\n\n---\n\n### useKerAbieNotifications\n\nManage Expo push notification permissions and token registration.\n\n```tsx\nconst {\n hasPermission, // boolean \u2014 user has granted push permission\n requestPermission,// () => Promise<boolean> \u2014 request OS permission\n registerToken, // (token: string) => void \u2014 send token to Kerabie backend\n} = useKerAbieNotifications()\n```\n\n**Example \u2014 full push setup alongside your existing notification config:**\n\n```tsx\n// Must be called inside a component that is inside KerAbieProvider\nfunction NotificationSetup() {\n const { requestPermission, registerToken } = useKerAbieNotifications()\n\n useEffect(() => {\n requestPermission().then(async (granted) => {\n if (!granted) return\n const { data: token } = await Notifications.getExpoPushTokenAsync()\n registerToken(token)\n })\n }, [])\n\n return null\n}\n\n// In your layout, render it as a sibling of your navigation stack:\n\n <NotificationSetup />\n <Stack />\n\n```\n\n> `NotificationSetup` must be **inside** `KerAbieProvider` so `useKerAbieNotifications()` can access the context.\n\n---\n\n## Components\n\n### KerAbieFloatingButton\n\nThe floating chat bubble. Rendered automatically by the provider unless `hideFloatingButton` is set. Use this component directly if you need to render it in a specific place (e.g. inside a tab bar).\n\n```tsx\n\n```\n\n| Prop | Type | Default | Description |\n| ---- | ---- | ------- | ----------- |\n| `size` | `number` | `56` | Button diameter in points. |\n| `badgeColor` | `string` | `"#ef4444"` | Unread count badge background colour. |\n| `style` | `ViewStyle` | \u2014 | Extra style applied to the wrapper `View`. |\n\n---\n\n### KerAbieWidget\n\nThe whole widget: Home, Chat, Help and Tickets behind a bottom tab bar (see [The widget](#the-widget)). Used automatically by the provider for `openMode="modal"` and `"sheet"`. Use it directly when `openMode="push"` and you render it in your own navigator screen.\n\n```tsx\n\n<KerAbieWidget onClose={() => router.back()} theme={{ primaryColor: \'#6d28d9\' }} />\n```\n\n| Prop | Type | Required | Description |\n| ---- | ---- | -------- | ----------- |\n| `onClose` | `() => void` | Yes | Called when the user taps the close button. |\n| `theme` | `KerTheme` | No | Theme override (merged with provider theme). |\n| `chatSettings` | `KerChatSettings` | No | Override welcome/offline messages and the other chat settings. |\n\n---\n\n### KerAbieChatScreen\n\nJust the chat screen, without the tabs. Use it when you want your own navigation around the chat. Used automatically by the provider for `openMode="modal"`. Use it directly when `openMode="push"` and you render it in your own navigator screen.\n\n```tsx\n\n<KerAbieChatScreen\n onClose={() => navigation.goBack()}\n theme={{ primaryColor: \'#6d28d9\' }}\n/>\n```\n\n| Prop | Type | Required | Description |\n| ---- | ---- | -------- | ----------- |\n| `onClose` | `() => void` | Yes | Called when the user taps the close/back button. |\n| `theme` | `KerTheme` | No | Theme override (merged with provider theme). |\n| `chatSettings` | `KerChatSettings` | No | Override welcome/offline messages. |\n\n---\n\n## The widget\n\nOpening the chat (the floating button, `open()`, a campaign or a notification tap) shows the same widget as the Kerabie web chat, in the org\'s colours, logo and wording from the dashboard:\n\n| Tab | What it has |\n| --- | ----------- |\n| **Home** | Header with logo, status chip and greeting, search, featured products (tap one for its sheet and a link to the store), suggested articles and an "Ask our AI / Chat with us" card. |\n| **Chat** | The `full` layout lists the visitor\'s conversations with a **New chat** button; the `single` layout opens one continuous chat with the recent history. |\n| **Help** | Download or email a transcript, download or delete the visitor\'s data, and the most useful articles as FAQs. |\n| **Tickets** | Only when tickets are on for the widget. |\n\nA tapped push notification or `<your-scheme>://conversation/:id` link opens the widget on that conversation. With `layout: "single"` the tab bar is hidden unless `showNavigation` is on.\n\nThe widget\'s icons are drawn by the package itself, so there is no icon or SVG dependency to install.\n\nArticles open in a bottom sheet and are drawn by the package too (headings, bold, italic, underline, links, lists, quotes, code, images and tables, with entities such as `&nbsp;` decoded), so there is no HTML renderer to install. When an AI reply cites an article it shows as a tappable card with the article\'s picture and the start of its text.\n\n---\n\n## Types\n\n```tsx\n\n KerAbieProviderProps,\n KerUser,\n KerTheme,\n KerChatSettings,\n KerNotificationConfig,\n KerMessage,\n KerAttachment,\n KerConversation,\n KerConversationSummary,\n KerNotification,\n OpenMode, // "modal" | "sheet" | "push"\n BubblePosition, // "bottom-right" | "bottom-left" | "top-right" | "top-left"\n KerNavigationRoute, // passed to onNavigate\n} from \'react-native-kerabie-helpdesk\'\n```\n\n#### `KerMessage`\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| `id` | `number \\| string` | Message ID. |\n| `body` | `string` | Message text. |\n| `createdAt` | `string` | ISO 8601 timestamp. |\n| `senderType` | `"visitor" \\| "agent" \\| "bot"` | Who sent the message. |\n| `senderName` | `string` | Display name (agents/bot only). |\n| `senderAvatar` | `string` | Avatar URL. |\n| `attachments` | `KerAttachment[]` | File/image attachments. |\n| `status` | `"sent" \\| "delivered" \\| "read" \\| "failed"` | Delivery status (visitor messages). |\n\n#### `KerConversationSummary`\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| `id` | `number` | Conversation ID. |\n| `name` | `string` | Who the visitor talked to. |\n| `avatar` | `string \\| null` | Avatar URL. |\n| `lastMessage` | `string` | Preview of the latest message. |\n| `lastMessageTime` | `string` | ISO timestamp. |\n| `unreadCount` | `number` | Unread messages. |\n| `isOnline` | `boolean` | The agent is online now. |\n\n#### `KerConversation`\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| `id` | `number` | Conversation ID. |\n| `status` | `"open" \\| "resolved" \\| "closed"` | Current status. |\n| `assignedAgent` | `{ id, name, avatar? }` | Assigned agent, if any. |\n| `lastMessage` | `KerMessage` | Most recent message. |\n| `unreadCount` | `number` | Unread message count. |\n| `createdAt` | `string` | ISO timestamp. |\n| `updatedAt` | `string` | ISO timestamp. |\n\n#### `KerAttachment`\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| `id` | `string` | Attachment ID. |\n| `url` | `string` | Download/display URL. |\n| `type` | `"image" \\| "file" \\| "audio" \\| "video"` | Media type. |\n| `name` | `string` | File name. |\n| `size` | `number` | File size in bytes. |\n\n---\n\n## Open modes in depth\n\n### `openMode="modal"` \u2014 recommended default\n\nRenders a React Native `<Modal>` over your app. Works with **any** navigation setup, no extra configuration.\n\n```tsx\n\n <YourApp />\n\n```\n\n### `openMode="sheet"` \u2014 bottom sheet\n\nRequires `@gorhom/bottom-sheet`. Chat slides up from the bottom; the content behind stays visible.\n\n```bash\nnpx expo install @gorhom/bottom-sheet react-native-reanimated react-native-gesture-handler\n```\n\n```tsx\n\n <YourApp />\n\n```\n\n### `openMode="push"` \u2014 navigation stack\n\nThe SDK does not touch your navigator. With `openMode="push"` it calls `onNavigate`, and your app opens the chat screen itself. One hook works with every navigator, including Expo Router, which from SDK 56 no longer allows importing `@react-navigation/*` in app code.\n\n**Expo Router:**\n```tsx\n\n router.push(route.type === \'conversation\' ? `/chat/${route.conversationId}` : \'/chat\')\n }\n>\n <Stack />\n\n```\n\n**React Navigation:**\n```tsx\n<KerAbieProvider\n widgetKey="wk_xxx"\n openMode="push"\n onNavigate={(route) => navigationRef.current?.navigate(\'KerAbieChat\', route)}\n>\n <MyNavigator />\n\n```\n\nYou define the `/chat` route or the `KerAbieChat` screen yourself and render `KerAbieWidget` in it (or `KerAbieChatScreen` for the bare chat). Without `onNavigate`, the chat opens inline.\n\n**Custom (any open mode) \u2014 control it yourself:**\n```tsx\n<KerAbieProvider\n widgetKey="wk_xxx"\n openMode="modal"\n hideFloatingButton\n onOpenChat={() => console.log(\'opened\')}\n onCloseChat={() => console.log(\'closed\')}\n>\n <YourApp />\n\n// Then from any component:\nconst { open } = useKerAbieChat()\n\n```\n\n---\n\n## Campaigns\n\nProactive messages you create in the dashboard (**Campaigns**) also appear in your app, above the launcher, with their button. The provider loads your live campaigns once per app launch; there is nothing to add to your code beyond the optional props below.\n\n| Dashboard setting | In the app |\n| ---- | ---- |\n| Trigger **Page load** | Shown about 1.5 seconds after a screen appears (each time the `screen` prop changes). |\n| Trigger **Time on page** | Shown after N seconds in the app. |\n| Trigger **Scroll depth**, **Exit intent** | Website-only. These campaigns are not shown in apps. |\n| **On pages** | Matched against the `screen` prop (for example Expo Router\'s `usePathname()`). Without the prop, only campaigns for every screen show. |\n| **Audience** | New / returning means the first / a later app launch on this device; "hasn\'t chatted" checks this device\'s conversations; country is decided by the server from the device\'s IP. |\n| **Frequency** | Remembered on the device: once per visitor, once per day, or once per app launch. |\n| Button **Open chat** / **Open a link** | Opens the chat / the link in the browser. |\n| Button **Open Knowledge Base** | Calls your `onOpenKnowledgeBase`; without it the widget opens on Home, where the articles are. |\n\n```tsx\n\n<KerAbieProvider\n widgetKey="wk_yourorg_xxx"\n screen={usePathname()}\n onOpenKnowledgeBase={() => router.push(\'/help\')}\n>\n```\n\nThe `screen` prop is also sent to Kerabie, so agents see the screen the customer is on and the last few they visited, next to the app name, version, build and whether it is iOS or Android.\n\nCampaign results (shown, opened, clicked, replied) are counted in the dashboard like they are for the website widget. Nothing pops up while the chat is open.\n\n## Calls (optional)\n\nCustomers can call your team from the app: voice, and video if you turn it on. Everything below is optional. **Chat, tickets and notifications work without any of it, including in Expo Go.**\n\n### What you need\n\n- Calls turned on in the dashboard (**Settings, Calls**) and call minutes on your plan.\n- A **development build**. Calls use native WebRTC code, so they do not run in Expo Go. When the packages are missing, or you are in Expo Go, you simply do not import the calls entry point and the app is unchanged.\n\n### Install\n\n```bash\nnpx expo install livekit-client @livekit/react-native @livekit/react-native-expo-plugin @livekit/react-native-webrtc @config-plugins/react-native-webrtc\n```\n\nAdd the two plugins to `app.json`, then make a new development build:\n\n```json\n{\n "expo": {\n "plugins": ["@livekit/react-native-expo-plugin", "@config-plugins/react-native-webrtc"]\n }\n}\n```\n\nOn bare React Native, follow the [LiveKit React Native setup](https://github.com/livekit/client-sdk-react-native) for the Android and iOS native files instead.\n\nThe app must be allowed to use the microphone, and the camera if you use video: `RECORD_AUDIO` and `CAMERA` on Android, and a reason for `NSMicrophoneUsageDescription` and `NSCameraUsageDescription` on iOS. The phone asks the customer the first time.\n\n### Use it\n\nCalls come from a separate entry point, so the main package never loads the call libraries:\n\n```tsx\n\n// Anywhere inside <KerAbieProvider>. It renders nothing when calls are off or the plan has no minutes left.\n\n```\n\nTo put call buttons in the chat header itself (a phone, and a camera when video is on), pass the icon variant to the provider. It is a prop so the main package still never loads the call libraries:\n\n```tsx\n\n<KerAbieProvider widgetKey="..." callAction={<KerAbieCallButton variant="icon" />}>\n```\n\nThe button opens a call screen that shows the queue position, ringing, the call itself (mute, camera, end) and how it ended.\n\nFor your own design, use the hook and the screen separately:\n\n```tsx\n\nfunction Support() {\n const call = useKerAbieCall()\n return (\n <>\n {call.available && <Button title="Call support" onPress={() => call.start()} />}\n <KerAbieCallModal call={call} />\n </>\n )\n}\n```\n\n| Member | Description |\n| ---- | ---- |\n| `available` | Calls are on in the dashboard and the plan has minutes. |\n| `videoAvailable` | Video is on too. |\n| `state.phase` | `idle`, `starting`, `waiting` (in the queue), `ringing`, `connected` or `ended`. |\n| `state.queuePosition` | Your place in the queue while `waiting`. |\n| `state.handler` | `HUMAN` or `AI`, once answered. |\n| `state.endReason` | Why it ended: `hangup`, `cancelled`, `missed`, `limit` or `connection_lost`. |\n| `start(video?)` | Starts a call. A customer who already has one is put back into it. |\n| `hangUp()` | Ends the call, or cancels it before it is answered. |\n| `dismiss()` | Closes the call screen after it ended. |\n\nIf you ask customers for their details before a chat (the pre-chat form in the dashboard), the call asks the same questions first, once. Pass `user` to `KerAbieProvider`, or call `setUser` after login, and a customer you have identified is never asked. On a video call the customer sees the agent, with their own camera as a small picture in the corner.\n\nA call goes to every free agent at once; the first to answer wins. When nobody is free, the AI voice agent answers if you turned it on, and otherwise the customer waits in the queue and the call is marked missed after two minutes. A call you reopen the app into is picked up again.\n\n## Notification setup\n\nKerabie push notifications deep-link directly to the conversation that triggered them.\n\n### Android \u2014 register channels first\n\n> **Android 8+ (API 26+) requirement:** Without a registered notification channel, push notifications are **silently dropped**. You must call `NotificationService.setup()` before the provider renders.\n\n```tsx\n// app/_layout.tsx or App.tsx \u2014 BEFORE KerAbieProvider renders\n\nuseEffect(() => {\n NotificationService.setup({\n projectId: \'your-expo-project-id\', // from app.json expo.extra.eas.projectId\n })\n}, [])\n```\n\nThis single call does everything:\n1. Registers Android notification channels (`kerabie_messages`, `kerabie_visitor`, `kerabie_system`)\n2. Requests OS permission from the user\n3. Gets the Expo push token\n4. Registers the token with the Kerabie API\n\n### Custom Android channels\n\nOverride the default channels to match your app branding:\n\n```tsx\n\nconst MY_CHANNELS: KerAndroidChannel[] = [\n {\n channelId: \'support_chat\',\n name: \'Support Messages\',\n importance: \'high\',\n sound: \'default\',\n enableVibrate: true,\n },\n]\n\nNotificationService.setup({ channels: MY_CHANNELS })\n```\n\n### app.json \u2014 required config\n\n```json\n{\n "expo": {\n "android": {\n "permissions": ["NOTIFICATIONS"],\n "googleServicesFile": "./google-services.json"\n },\n "plugins": [\n ["expo-notifications", {\n "icon": "./assets/notification-icon.png",\n "color": "#4e7969",\n "defaultChannel": "kerabie_messages"\n }]\n ]\n }\n}\n```\n\n### One-call setup\n\n```tsx\n\n// In your root layout, before the provider:\nuseEffect(() => {\n NotificationService.setup({\n projectId: \'your-expo-project-id\',\n onTokenReceived: (token) => console.log(\'Registered:\', token),\n })\n}, [])\n```\n\n### Manual setup (fine-grained control)\n\n```tsx\n\n// Set your global handler (module level, outside any component)\nNotifications.setNotificationHandler({\n handleNotification: async () => ({\n shouldShowAlert: true,\n shouldPlaySound: true,\n shouldSetBadge: true,\n }),\n})\n\n// Inside a component that is inside KerAbieProvider:\nfunction NotificationSetup() {\n const { requestPermission, registerToken } = useKerAbieNotifications()\n\n useEffect(() => {\n requestPermission().then(async (granted) => {\n if (!granted) return\n const { data: token } = await Notifications.getExpoPushTokenAsync()\n registerToken(token)\n })\n }, [])\n\n return null\n}\n```\n\n---\n\n## Contextual chat\n\nOpen the chat widget with a pre-loaded context so agents immediately see what the user needs help with \u2014 without the user having to explain it.\n\nUse `openWithContext` from `useKerAbieContext()` (or `useKerAbieChat()`). The context is sent to the Kerabie backend and shown to the agent in the conversation.\n\n### Product click\n\n```tsx\n\nfunction ProductCard({ product }) {\n const { openWithContext } = useKerAbieContext()\n\n return (\n <Pressable onPress={() => openWithContext({\n type: \'product\',\n id: String(product.id),\n title: product.name,\n data: {\n price: `\u20A6${product.price}`,\n category: product.category,\n sku: product.sku,\n },\n })}>\n <Text>Ask about this product</Text>\n </Pressable>\n )\n}\n```\n\nAgent sees: **"Product \u2014 Blue Sneakers"** with price, category, SKU.\n\n### Order inquiry\n\n```tsx\nconst { openWithContext } = useKerAbieContext()\n\n<Button onPress={() => openWithContext({\n type: \'order\',\n id: order.reference,\n title: `Order ${order.reference}`,\n data: {\n status: order.status,\n total: `\u20A6${order.total}`,\n placedAt: order.createdAt,\n items: order.itemCount,\n },\n})}>\n Get help with this order\n\n```\n\n### Fintech transaction\n\n```tsx\nopenWithContext({\n type: \'transaction\',\n id: txn.id,\n title: `\u20A6${txn.amount} ${txn.type}`,\n data: {\n status: txn.status, // "failed", "pending", "success"\n recipient: txn.recipientName,\n narration: txn.narration,\n reference: txn.reference,\n },\n})\n```\n\n### Ride sharing\n\n```tsx\nopenWithContext({\n type: \'ride\',\n id: ride.id,\n title: `Trip to ${ride.destination}`,\n data: {\n driver: ride.driverName,\n fare: `\u20A6${ride.fare}`,\n status: ride.status,\n startedAt: ride.startedAt,\n },\n})\n```\n\n### Context types\n\n| `type` | Best for |\n|---|---|\n| `product` | E-commerce \u2014 user asking about an item |\n| `order` | Order status, returns, refunds |\n| `transaction` | Fintech \u2014 failed or disputed payment |\n| `ride` | Ride-sharing \u2014 trip issue or complaint |\n| `errand` | Delivery or logistics issue |\n| `booking` | Appointment or reservation |\n| `custom` | Anything else \u2014 set a descriptive `title` |\n\n### Open without showing the chat\n\nPass `openChat: false` if you only want to set context (e.g. pre-load on screen mount) without immediately opening the widget:\n\n```tsx\nopenWithContext({\n type: \'order\',\n id: order.id,\n title: `Order ${order.reference}`,\n openChat: false, // context is stored; chat opens later when user taps the button\n})\n```\n\n---\n\n## User identity\n\nWhen a user logs in, call `setUser` so agents see who they\'re talking to:\n\n```tsx\nconst { setUser, clearUser, updateAttribute } = useKerAbieUser()\n\n// On login\nsetUser({\n id: currentUser.id, // your internal ID\n name: currentUser.name,\n email: currentUser.email,\n role: \'customer\',\n customAttributes: {\n plan: currentUser.plan,\n signedUpAt: currentUser.createdAt,\n },\n})\n\n// Add live context at any point (shows in agent sidebar immediately)\nupdateAttribute(\'cart_value\', \'\u20A612,500\')\nupdateAttribute(\'last_error\', \'CheckoutScreen: undefined is not an object\')\n\n// On logout \u2014 starts a fresh anonymous session\nclearUser()\n```\n\n---\n\n## Notification taps \u2014 opening the right conversation\n\nWhen a user taps a Kerabie push notification, the SDK opens the chat on that conversation. **No app.json deep link or URL scheme setup is needed**: the notification\'s data already carries the `conversationId` (and the `orgId`), and `KerAbieProvider` listens for the tap, including the tap that launches the app from a closed state.\n\n```tsx\n// Once, at your app entry: ask for permission, get the Expo push token and register it with Kerabie\nawait NotificationService.setup({ projectId: \'your-expo-project-id\' });\n```\n\nThat is all. Notifications tapped while the app is in the foreground, in the background or closed all land on the right conversation.\n\n### Links with your own scheme (optional)\n\nIf you also want a link such as `myapp://conversation/123` to open a conversation (from an email or another screen), use **your own app\'s scheme**, never `kerabie`. Register it the way your app already registers its scheme (in Expo, `"scheme": "myapp"` in `app.json`), then build links with:\n\n```ts\nKerAbieDeepLinkService.conversationUrl(123, \'myapp\') // "myapp://conversation/123"\n```\n\nThe provider accepts a conversation link with any scheme by default. To accept only yours, initialise the service with it:\n\n```ts\nKerAbieDeepLinkService.init({ scheme: \'myapp\', onConversation: (id) => { /* open the chat */ } })\n```\n\n### What the push contains\n\nKerabie sends Expo pushes like this. Read `conversationId` and `orgId` from `data`; ignore the `url` field, it is only used by Kerabie\'s own app.\n\n```json\n{\n "to": "ExponentPushToken[...]",\n "title": "New message",\n "body": "You have a new message",\n "data": { "conversationId": 123, "orgId": 45 }\n}\n```\n\n---\n\n## License\n\nMIT\n\n## Building with an AI assistant\n\nThe [`@kerabie/mcp`](https://kerabie.com/docs/sdks/mcp) server gives Claude, Cursor and other MCP clients this package\'s docs and types, so they use the provider, hooks and components correctly instead of guessing. It needs no API key:\n\n```json\n{ "mcpServers": { "kerabie": { "command": "npx", "args": ["-y", "@kerabie/mcp"] } } }\n```\n\nAsk the assistant to use the `integrate_react_native` prompt, or the `get_integration_guide` and `get_sdk_types` tools.', headings: [{ level: 2, text: "Contents" }, { level: 2, text: "Installation" }, { level: 2, text: "Provider setup" }, { level: 3, text: "Expo Router (`app/_layout.tsx`)" }, { level: 3, text: "React Navigation (`App.tsx`)" }, { level: 2, text: "KerAbieProvider props" }, { level: 3, text: "Required" }, { level: 3, text: "App identity" }, { level: 3, text: "Open mode" }, { level: 3, text: "Floating button" }, { level: 3, text: "User identity" }, { level: 3, text: "Appearance" }, { level: 3, text: "Chat settings (override dashboard)" }, { level: 3, text: "Notifications" }, { level: 2, text: "Hooks" }, { level: 3, text: "useKerAbieChat" }, { level: 3, text: "useKerAbieUser" }, { level: 3, text: "useKerAbieApp" }, { level: 3, text: "useKerAbieNotifications" }, { level: 2, text: "Components" }, { level: 3, text: "KerAbieFloatingButton" }, { level: 3, text: "KerAbieWidget" }, { level: 3, text: "KerAbieChatScreen" }, { level: 2, text: "The widget" }, { level: 2, text: "Types" }, { level: 2, text: "Open modes in depth" }, { level: 3, text: '`openMode="modal"` \u2014 recommended default' }, { level: 3, text: '`openMode="sheet"` \u2014 bottom sheet' }, { level: 3, text: '`openMode="push"` \u2014 navigation stack' }, { level: 2, text: "Campaigns" }, { level: 2, text: "Calls (optional)" }, { level: 3, text: "What you need" }, { level: 3, text: "Install" }, { level: 3, text: "Use it" }, { level: 2, text: "Notification setup" }, { level: 3, text: "Android \u2014 register channels first" }, { level: 3, text: "Custom Android channels" }, { level: 3, text: "app.json \u2014 required config" }, { level: 3, text: "One-call setup" }, { level: 3, text: "Manual setup (fine-grained control)" }, { level: 2, text: "Contextual chat" }, { level: 3, text: "Product click" }, { level: 3, text: "Order inquiry" }, { level: 3, text: "Fintech transaction" }, { level: 3, text: "Ride sharing" }, { level: 3, text: "Context types" }, { level: 3, text: "Open without showing the chat" }, { level: 2, text: "User identity" }, { level: 2, text: "Notification taps \u2014 opening the right conversation" }, { level: 3, text: "Links with your own scheme (optional)" }, { level: 3, text: "What the push contains" }, { level: 2, text: "License" }, { level: 2, text: "Building with an AI assistant" }] }, { id: "readme/flutter-sdk", url: "", title: "Flutter SDK README", description: "The Flutter SDK package readme, straight from its repository.", section: "sdks", body: "# kerabie_helpdesk_flutter\n\nEmbed the [Kerabie](https://kerabie.com) help desk in a Flutter app: live chat, photos and files, a help centre, tickets, a store, proactive campaigns and a launcher, all following the settings you save in the Kerabie dashboard.\n\n- Real-time chat that lands in your Kerabie inbox, with history, typing indicators, read ticks, unread count and AI replies that stream in word by word\n- Photos and files (25 MB), an emoji picker, link previews, and links to the knowledge base articles an AI reply used\n- The same tabbed widget as the web chat: Home with articles and products, a list of conversations, Help with FAQs, and Tickets\n- A floating button or a search bar, a Help tab (email or share the transcript, download or erase the customer's data) and a pre-chat form for contact details\n- A help centre (searchable knowledge base with article feedback), a store and tickets\n- Campaigns: proactive messages from the dashboard, limited to screens, audiences and frequencies\n- Open the chat with context (an order, a product, a payment), set or clear the signed-in user, and tell Kerabie which screen the customer is on\n- Colours, widget name, welcome message, launcher icon, AI assistant picture and branding follow **Settings, Widget** in the dashboard; light and dark mode with text colours picked by luminance\n- Push token registration, and opening the chat from a tapped notification or a link\n\nRequires Flutter 3.27 or later and Dart 3.6 or later. Android and iOS.\n\n## Install\n\n```bash\nflutter pub add kerabie_helpdesk_flutter\n```\n\nGet your widget key (it starts with `wk_`) from **Settings, Widget** in the Kerabie dashboard.\n\n### Platform setup\n\nOn iOS, add the reason for the photo library to `ios/Runner/Info.plist`, so customers can attach photos:\n\n```xml\n<key>NSPhotoLibraryUsageDescription</key>\n<string>Attach a photo to your message to support.</string>\n```\n\nAndroid needs nothing extra.\n\n## Quick start\n\nPut `KerabieProvider` above your `MaterialApp`, give both the same navigator key, and add the launcher.\n\n```dart\n\nfinal navigatorKey = GlobalKey<NavigatorState>();\n\nvoid main() {\n runApp(\n KerabieProvider(\n navigatorKey: navigatorKey,\n config: const KerabieConfig(\n widgetKey: 'wk_yourorg_123',\n appName: 'My App',\n appVersion: '1.0.0',\n user: KerabieUser(name: 'Jane', email: 'jane@example.com'),\n ),\n child: MaterialApp(\n navigatorKey: navigatorKey,\n navigatorObservers: [KerabieNavigatorObserver()],\n home: const Scaffold(\n body: Stack(children: [HomeScreen(), KerabieLauncher()]),\n ),\n ),\n ),\n );\n}\n```\n\n`KerabieLauncher` shows the floating button or the search bar, whichever the dashboard picked. To open the chat from your own button:\n\n```dart\nElevatedButton(onPressed: () => KerabieChat.show(context), child: const Text('Chat with us'));\n```\n\n## Configuration\n\n`KerabieConfig`:\n\n| Field | Required | Description |\n|---|---|---|\n| `widgetKey` | Yes | Your widget key |\n| `appName`, `appVersion` | Yes | Your app's name and version, sent when the chat connects |\n| `appBuild` | No | Build number, sent with them |\n| `user` | No | A `KerabieUser`: `id`, `name`, `email`, `phone`, `role`, `customAttributes` |\n| `theme` | No | A `KerabieTheme`: `primaryColor`, `secondaryColor`, `gradient`, `cornerRadius`, `mode` (`light`, `dark` or `auto`), `backgroundColor`, `textColor`, `agentBubbleColor`, `userBubbleColor`, `bubbleColor`, `bubbleIcon`, `bubbleSize`, `bubbleIconSize`, `fontFamily`, `chatbotAvatarUrl`. Overrides the dashboard |\n| `chatSettings` | No | A `KerabieChatSettings` that overrides the dashboard: `name`, `welcomeMessage`, `offlineMessage`, `showBranding`, `typingIndicator`, `enableFileAttachments`, `chatbotEnabled`, `layout`, `showMenu`, `launcherType`, `searchbarPlaceholder`, `customerEnrichmentEnabled`, `customerEnrichmentForm` |\n| `openMode` | No | `modal` (a full-screen page, the default), `sheet` (a bottom sheet) or `push` (your own navigator opens it, see below) |\n| `position` | No | Where the launcher sits: `bottomRight` (default), `bottomLeft`, `topRight`, `topLeft` |\n| `defaultOpen` | No | Open the chat as soon as the app has built |\n| `hideFloatingButton` | No | Make `KerabieLauncher` draw nothing; open the chat from your own button |\n| `deepLinkScheme` | No | Your app's URL scheme. When set, only `myapp://conversation/123` links open the chat |\n\nThe welcome message can use `{{name}}`, which becomes the signed-in user's first name (and disappears, with the space before it, when the name is not known).\n\n`KerabieProvider` also takes `onOpenChat`, `onCloseChat`, `onNavigate` (for `push`) and `onOpenKnowledgeBase`.\n\n### Open modes\n\nWith `openMode: KerabieOpenMode.push` the SDK does not touch your navigator. It calls `onNavigate`, and your app shows a `KerabieWidget` (the whole widget) or a `KerabieChatScreen` (just the conversation):\n\n```dart\nKerabieProvider(\n config: const KerabieConfig(widgetKey: '...', appName: '...', appVersion: '...', openMode: KerabieOpenMode.push),\n onNavigate: (route) => router.push(route.type == KerabieRouteType.conversation ? '/chat/${route.conversationId}' : '/chat'),\n child: ...,\n)\n\n// In your /chat route:\nKerabieWidget(onClose: () => router.pop())\n```\n\nWithout `onNavigate` the chat opens as a full-screen page.\n\n## Chat state and actions\n\n```dart\nfinal kerabie = KerabieProvider.of(context); // rebuilds when the chat changes\n```\n\n| Member | Description |\n|---|---|\n| `messages` | The conversation, oldest first |\n| `unreadCount` | Replies that arrived while the chat was closed |\n| `isOpen`, `isConnected` | Whether the chat is on screen, and whether the live connection is up |\n| `isTyping`, `isAiTyping` | An agent, or the AI assistant, is writing |\n| `agentsOnline` | How many agents are online, once the server has said |\n| `settings`, `chat` | What the dashboard saved, and the chat settings in force (the dashboard's with yours on top) |\n| `sendMessage(text)`, `retry(message)` | Send, and resend a failed message |\n| `showChat()`, `dismissChat()`, `toggle()` | Open and close the chat |\n| `currentUser`, `visitorName` | Who the customer is, as far as you have said |\n| `setUser(user)`, `clearUser()`, `updateAttribute(key, value)` | See [Who the customer is](#who-the-customer-is) |\n| `setAppVersion(version)` | Tell Kerabie the app's version changed; sent with the next connection |\n| `openWithContext(context)` | See [Opening the chat with context](#opening-the-chat-with-context) |\n| `refreshConversations()`, `conversations` | The customer's conversations |\n\n## Who the customer is\n\nCall `setUser` after login so agents see who they are talking to, `updateAttribute` to add a detail, and `clearUser` after logout. `clearUser` empties the chat on screen and the next visitor starts a fresh anonymous session.\n\n```dart\nfinal kerabie = KerabieProvider.of(context, listen: false);\n\nawait kerabie.setUser(const KerabieUser(id: 'u_123', name: 'Ada Obi', email: 'ada@example.com'));\nawait kerabie.updateAttribute('plan', 'pro');\nawait kerabie.clearUser();\n```\n\n## Opening the chat with context\n\nTell the agent what the customer was looking at, so they do not have to explain it:\n\n```dart\nkerabie.openWithContext(const KerabieChatContext(\n type: 'order', // order, product, transaction, ride, errand, booking or custom\n id: 'ORD-2024-001',\n title: 'Order #2024-001',\n data: {'status': 'shipped', 'total': '45.50'},\n));\n```\n\nPass `openChat: false` to only record it and open the chat later.\n\n## Screens\n\n`KerabieNavigatorObserver` tells Kerabie which screen the customer is on, from your routes' names. Agents see the page the customer is on, and campaigns can be limited to certain screens (\"On pages\" in the dashboard). Dialogs, sheets and routes without a name are ignored. With a router that has no route names, such as `go_router`, report screens yourself:\n\n```dart\nkerabie.trackScreen('/checkout');\n```\n\n## Launcher\n\n`KerabieLauncher` draws the floating button or the search bar. Use the pieces yourself if you want to place them: `KerabieLauncherButton` and `KerabieSearchBar`. The search bar sends what was typed (and a file picked with its paperclip) to the chat when the customer presses send.\n\n## Help centre, store and tickets\n\nReady-made screens you can push anywhere in your app. Each one follows your theme and the dashboard settings, and each has a Close button that leaves the route.\n\n```dart\nNavigator.of(context).push(MaterialPageRoute(builder: (_) => const KerabieKnowledgeBaseScreen()));\nNavigator.of(context).push(MaterialPageRoute(builder: (_) => const KerabieStoreScreen()));\nNavigator.of(context).push(MaterialPageRoute(builder: (_) => const KerabieTicketsScreen()));\n```\n\n`kerabie.settings.ticketsEnabled` says whether tickets are on in the dashboard, so you can show an entry point only when they are. For your own design use the data classes behind the screens: `KerabieKnowledgeBase`, `KerabieStore` and `KerabieTickets`.\n\n## Campaigns\n\nProactive messages you create in the dashboard (**Campaigns**) also appear in your app, above the launcher, with their button. They load once per app launch, and need the same `navigatorKey` on `KerabieProvider` and `MaterialApp`.\n\n| Dashboard setting | In the app |\n|---|---|\n| Trigger **Page load** | Shown about 1.5 seconds after a screen appears (each time the screen changes) |\n| Trigger **Time on page** | Shown after N seconds in the app |\n| Trigger **Scroll depth**, **Exit intent** | Website only; not shown in apps |\n| **On pages** | Matched against the current screen (see [Screens](#screens)). Without a screen, only campaigns for every screen show |\n| **Audience** | New and returning mean the first and a later launch on this device; \"hasn't chatted\" checks this device's conversations; country is decided by the server |\n| **Frequency** | Remembered on the device: once per visitor, once per day, or once per app launch |\n| Button **Open chat** / **Open a link** | Opens the chat / the link in the browser |\n| Button **Open Knowledge Base** | Calls your `onOpenKnowledgeBase`; opens the chat if you do not pass it |\n\nCampaign results (shown, opened, clicked, replied) are counted in the dashboard like they are for the website widget. Nothing pops up while the chat is open.\n\n## Push notifications\n\nKerabie keeps one push token per customer, so use one push service in your app. This package does not depend on a push package: you get the token from your service and pass it in, with the provider that issued it. The providers are `fcm` (a Firebase Cloud Messaging token, which also covers iOS through Firebase) and `expo`. For `fcm`, connect your Firebase project once in the dashboard under Settings, Integrations, App push, so Kerabie can send to your app's customers.\n\n```dart\nfinal kerabie = KerabieProvider.of(context, listen: false);\nawait kerabie.registerPushToken(token, provider: KerabiePushProvider.fcm);\n```\n\nKerabie notifications carry `conversationId` and `orgId` in their data. When the user taps one, pass the data in. With Firebase Cloud Messaging, handle both cases FCM documents: the tap that launches the app, and the tap from the background.\n\n```dart\nfinal messaging = FirebaseMessaging.instance;\n\nfinal initial = await messaging.getInitialMessage();\nif (initial != null) kerabie.handleNotificationData(initial.data);\n\nFirebaseMessaging.onMessageOpenedApp.listen((m) => kerabie.handleNotificationData(m.data));\nmessaging.onTokenRefresh.listen((t) => kerabie.registerPushToken(t, provider: KerabiePushProvider.fcm));\n```\n\nThe chat can only open from a tap when `KerabieProvider` has the same `navigatorKey` as your `MaterialApp`. With `go_router`, pass the same key as `GoRouter(navigatorKey: navigatorKey)`.\n\n## Opening the chat from a link\n\nLinks use the same format in every Kerabie SDK: `<your-scheme>://conversation/<id>`.\n\nFlutter handles incoming deep links itself, so this package does not listen for them. Hand the link over where your app receives it, for example in `onGenerateRoute` or your router's redirect:\n\n```dart\nif (kerabie.handleDeepLink(Uri.parse('myapp://conversation/123'))) return;\n```\n\n`KerabieDeepLink.conversationUri(123, 'myapp')` builds a link to put in a notification.\n\n## Voice and video calls\n\nPass `callActions: const KerabieCallButton.icon()` to `KerabieProvider` to put call buttons in the chat header. Calls are an optional package, so apps that only want chat do not carry the call code: [`kerabie_helpdesk_flutter_calls`](https://pub.dev/packages/kerabie_helpdesk_flutter_calls).\n\n## Links\n\n- Docs: https://kerabie.com/docs/sdks/flutter\n- Kerabie: https://kerabie.com\n- MCP server, so an AI assistant can add this SDK for you: https://kerabie.com/sdks/mcp\n\nMIT licensed.", headings: [{ level: 2, text: "Install" }, { level: 3, text: "Platform setup" }, { level: 2, text: "Quick start" }, { level: 2, text: "Configuration" }, { level: 3, text: "Open modes" }, { level: 2, text: "Chat state and actions" }, { level: 2, text: "Who the customer is" }, { level: 2, text: "Opening the chat with context" }, { level: 2, text: "Screens" }, { level: 2, text: "Launcher" }, { level: 2, text: "Help centre, store and tickets" }, { level: 2, text: "Campaigns" }, { level: 2, text: "Push notifications" }, { level: 2, text: "Opening the chat from a link" }, { level: 2, text: "Voice and video calls" }, { level: 2, text: "Links" }] }, { id: "readme/flutter-calls", url: "", title: "Flutter calls package README", description: "The Flutter calls package package readme, straight from its repository.", section: "sdks", body: '# kerabie_helpdesk_flutter_calls\n\nVoice and video calls for the [Kerabie](https://kerabie.com) help desk in a Flutter app. A call button and a call screen that sit on top of [`kerabie_helpdesk_flutter`](https://pub.dev/packages/kerabie_helpdesk_flutter).\n\nCalls are a separate package so apps that only want chat do not carry the WebRTC code.\n\n- Free agents are rung together and the first to answer wins\n- The AI voice agent answers when nobody is free, if you turn it on\n- A waiting screen with the customer\'s place in the queue\n- Mute, camera and hang up; a call you reopen the app into is picked up again\n\n## Before you start\n\n- Chat set up with `kerabie_helpdesk_flutter` (a `KerabieProvider` above your app).\n- Calls turned on in the dashboard under Settings, Calls, and call minutes on your plan.\n\n## Install\n\n```bash\nflutter pub add kerabie_helpdesk_flutter_calls\n```\n\nIt needs Flutter 3.27 or later and Dart 3.6 or later.\n\n### iOS\n\nAdd a reason for the microphone, and the camera if you use video, to `ios/Runner/Info.plist`:\n\n```xml\n<key>NSMicrophoneUsageDescription</key>\n<string>Talk to our support team</string>\n<key>NSCameraUsageDescription</key>\n<string>Show our support team what you see</string>\n```\n\n### Android\n\nAdd to `android/app/src/main/AndroidManifest.xml`:\n\n```xml\n<uses-feature android:name="android.hardware.camera" />\n<uses-feature android:name="android.hardware.camera.autofocus" />\n<uses-permission android:name="android.permission.CAMERA" />\n<uses-permission android:name="android.permission.RECORD_AUDIO" />\n<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />\n<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />\n<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />\n<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />\n<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />\n```\n\n## Add the button\n\n```dart\n\n// Anywhere under KerabieProvider. It shows nothing when calls are off or the plan has no minutes left.\nconst KerabieCallButton(label: \'Call us\'),\nconst KerabieCallButton(label: \'Video call\', video: true),\n```\n\nTo put call buttons in the chat header itself, pass the icon variant to the provider. It is a widget you pass in, so the main package never carries the call code:\n\n```dart\nKerabieProvider(\n config: ...,\n callActions: const KerabieCallButton.icon(),\n child: ...,\n)\n```\n\nThe button opens a call screen with the queue position, ringing, the call itself and how it ended.\n\n## Your own button\n\n```dart\nfinal call = KerabieCallController(KerabieProvider.of(context, listen: false));\n\nif (call.available) {\n call.start(); // then show KerabieCallScreen(call: call, chat: chat), or your own screen\n}\n```\n\n| Member | Description |\n|---|---|\n| `available`, `videoAvailable` | Calls, and video, are on in the dashboard |\n| `phase` | `idle`, `starting`, `waiting`, `ringing`, `connected` or `ended` |\n| `queuePosition` | Your place in line while `waiting` |\n| `handler` | `HUMAN` or `AI`, once answered |\n| `endReason` | `hangup`, `cancelled`, `missed`, `declined`, `limit` or `connection_lost` |\n| `start({video})` | Starts a call; a customer who already has one is put back into it |\n| `resume()` | Picks up a call that was going when the app was closed |\n| `hangUp()` | Ends the call, or cancels it before it is answered |\n| `toggleMicrophone()`, `toggleCamera()` | Mute and camera |\n| `dismiss()` | Closes the call screen after it ended |\n\nCall `dispose()` on a controller you create yourself.\n\n## How a call is handled\n\nIf you ask customers for their details before a chat (the pre-chat form in the dashboard), the call asks the same questions first, once. Pass `user` in `KerabieConfig`, or call `setUser` after login, and a customer you have identified is never asked. On a video call the customer sees the agent, with their own camera as a small picture in the corner.\n\nA call goes to every free agent at once. When nobody is free the AI voice agent answers if you turned it on; otherwise the customer waits in the queue, and the call is marked missed after two minutes. Calls with a person and calls the AI answers are limited separately by your plan.\n\nMIT licensed.', headings: [{ level: 2, text: "Before you start" }, { level: 2, text: "Install" }, { level: 3, text: "iOS" }, { level: 3, text: "Android" }, { level: 2, text: "Add the button" }, { level: 2, text: "Your own button" }, { level: 2, text: "How a call is handled" }] }, { id: "readme/chat-widget", url: "", title: "Chat widget README", description: "The Chat widget package readme, straight from its repository.", section: "widget", body: '# Vue 3 + Vite\n\nThis template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.\n\nLearn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).\n"# kerabie-widget"', headings: [] }], endpoints: [{ method: "GET", path: "/agents", title: "List agents", doc: "api-reference/agents", sdk: "" }, { method: "GET", path: "/agents/:id", title: "Get an agent", doc: "api-reference/agents", sdk: "" }, { method: "GET", path: "/v1/ai-tools", title: "List tools", doc: "api-reference/ai-tools", sdk: "kerabie.aiTools.list(...)" }, { method: "GET", path: "/v1/ai-tools/:id", title: "Get a tool", doc: "api-reference/ai-tools", sdk: "" }, { method: "POST", path: "/v1/ai-tools", title: "Create a tool", doc: "api-reference/ai-tools", sdk: "kerabie.aiTools.create(...)" }, { method: "PATCH", path: "/v1/ai-tools/:id", title: "Update a tool", doc: "api-reference/ai-tools", sdk: "kerabie.aiTools.update(...)" }, { method: "POST", path: "/v1/ai-tools/:id/duplicate", title: "Duplicate a tool", doc: "api-reference/ai-tools", sdk: "" }, { method: "DELETE", path: "/v1/ai-tools/:id", title: "Delete a tool", doc: "api-reference/ai-tools", sdk: "" }, { method: "POST", path: "/v1/ai-tools/:id/test", title: "Test a tool", doc: "api-reference/ai-tools", sdk: "kerabie.aiTools.test(...)" }, { method: "GET", path: "/v1/analytics/summary", title: "Summary", doc: "api-reference/analytics", sdk: "kerabie.analytics.getSummary(...)" }, { method: "GET", path: "/v1/analytics/insights", title: "Insights", doc: "api-reference/analytics", sdk: "kerabie.analytics.getInsights(...)" }, { method: "GET", path: "/v1/analytics/agents", title: "Agent performance", doc: "api-reference/analytics", sdk: "kerabie.analytics.getAgentStats(...)" }, { method: "GET", path: "/v1/analytics/tickets-sla", title: "Open tickets and SLA", doc: "api-reference/analytics", sdk: "kerabie.analytics.getTicketSla(...)" }, { method: "GET", path: "/v1/analytics/setup-status", title: "Setup status", doc: "api-reference/analytics", sdk: "kerabie.analytics.getSetupStatus(...)" }, { method: "GET", path: "/v1/campaigns", title: "List campaigns", doc: "api-reference/campaigns", sdk: "kerabie.campaigns.list(...)" }, { method: "GET", path: "/v1/campaigns/stats", title: "Overview numbers", doc: "api-reference/campaigns", sdk: "" }, { method: "GET", path: "/v1/campaigns/:id", title: "Get a campaign", doc: "api-reference/campaigns", sdk: "" }, { method: "POST", path: "/v1/campaigns", title: "Create a campaign", doc: "api-reference/campaigns", sdk: "kerabie.campaigns.create(...)" }, { method: "PATCH", path: "/v1/campaigns/:id", title: "Update a campaign", doc: "api-reference/campaigns", sdk: "" }, { method: "POST", path: "/v1/campaigns/:id/duplicate", title: "Duplicate a campaign", doc: "api-reference/campaigns", sdk: "" }, { method: "DELETE", path: "/v1/campaigns/:id", title: "Delete a campaign", doc: "api-reference/campaigns", sdk: "" }, { method: "GET", path: "/v1/contacts", title: "List contacts", doc: "api-reference/contacts", sdk: "kerabie.contacts.list(...)" }, { method: "GET", path: "/v1/contacts/stats", title: "Overview numbers", doc: "api-reference/contacts", sdk: "" }, { method: "GET", path: "/v1/contacts/:id", title: "Get a contact", doc: "api-reference/contacts", sdk: "" }, { method: "POST", path: "/v1/contacts", title: "Add a contact", doc: "api-reference/contacts", sdk: "kerabie.contacts.create(...)" }, { method: "PATCH", path: "/v1/contacts/:id", title: "Update a contact", doc: "api-reference/contacts", sdk: "" }, { method: "POST", path: "/v1/contacts/import", title: "Import contacts", doc: "api-reference/contacts", sdk: "kerabie.contacts.import(...)" }, { method: "GET", path: "/v1/conversations", title: "List conversations", doc: "api-reference/conversations", sdk: "kerabie.conversations.list(...)" }, { method: "GET", path: "/v1/conversations/:id", title: "Get a conversation", doc: "api-reference/conversations", sdk: "kerabie.conversations.get(...)" }, { method: "GET", path: "/v1/conversations/:id/history", title: "Previous conversations", doc: "api-reference/conversations", sdk: "kerabie.conversations.getHistory(...)" }, { method: "POST", path: "/v1/conversations/:id/take-over", title: "Take over from the AI", doc: "api-reference/conversations", sdk: "kerabie.conversations.takeOver(...)" }, { method: "POST", path: "/conversations/:id/resolve", title: "Resolve a conversation", doc: "api-reference/conversations", sdk: "" }, { method: "POST", path: "/conversations/:id/close", title: "Close a conversation", doc: "api-reference/conversations", sdk: "" }, { method: "PATCH", path: "/conversations/:id", title: "Assign to an agent", doc: "api-reference/conversations", sdk: "" }, { method: "POST", path: "/conversations/:id/messages", title: "Send a message", doc: "api-reference/conversations", sdk: "" }, { method: "GET", path: "/customers", title: "List customers", doc: "api-reference/customers", sdk: "" }, { method: "GET", path: "/customers/:id", title: "Get a customer", doc: "api-reference/customers", sdk: "" }, { method: "GET", path: "/customers/by-email/:email", title: "Get by email", doc: "api-reference/customers", sdk: "" }, { method: "PATCH", path: "/customers/:id", title: "Update a customer", doc: "api-reference/customers", sdk: "" }, { method: "DELETE", path: "/customers/:id", title: "Delete a customer (GDPR)", doc: "api-reference/customers", sdk: "" }, { method: "POST", path: "/growth/utm/visit", title: "UTM Attribution", doc: "api-reference/growth", sdk: "" }, { method: "GET", path: "/growth/utm/analytics?days=30", title: "UTM Attribution", doc: "api-reference/growth", sdk: "" }, { method: "GET", path: "/growth/utm/timeline?days=30", title: "UTM Attribution", doc: "api-reference/growth", sdk: "" }, { method: "POST", path: "/partner/apply", title: "Partner Program", doc: "api-reference/growth", sdk: "" }, { method: "GET", path: "/partner/me", title: "Partner Program", doc: "api-reference/growth", sdk: "" }, { method: "GET", path: "/partner/earnings?days=30", title: "Partner Program", doc: "api-reference/growth", sdk: "" }, { method: "GET", path: "/partner/verify/{code}", title: "Partner Program", doc: "api-reference/growth", sdk: "" }, { method: "POST", path: "/partner/payout/request", title: "Partner Program", doc: "api-reference/growth", sdk: "" }, { method: "GET", path: "/v1/kb/articles", title: "Authenticated API (`/v1/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "GET", path: "/v1/kb/stats", title: "Authenticated API (`/v1/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "GET", path: "/v1/kb/insights", title: "Authenticated API (`/v1/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "GET", path: "/v1/kb/articles/:id", title: "Authenticated API (`/v1/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "POST", path: "/v1/kb/articles", title: "Authenticated API (`/v1/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "PATCH", path: "/v1/kb/articles/:id", title: "Authenticated API (`/v1/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "DELETE", path: "/v1/kb/articles/:id", title: "Authenticated API (`/v1/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "GET", path: "/public/kb/articles?widgetKey=wk_yourorg_abc123&search=refund", title: "Public, widget-facing API (`/public/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "GET", path: "/public/kb/articles/:id?widgetKey=wk_yourorg_abc123", title: "Public, widget-facing API (`/public/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "POST", path: "/public/kb/articles/:id/feedback?widgetKey=wk_yourorg_abc123", title: "Public, widget-facing API (`/public/kb`)", doc: "api-reference/knowledge-base", sdk: "" }, { method: "POST", path: "/knowledge-base/import/document", title: "Importing content", doc: "api-reference/knowledge-base", sdk: "" }, { method: "POST", path: "/knowledge-base/crawl/discover", title: "Importing content", doc: "api-reference/knowledge-base", sdk: "" }, { method: "POST", path: "/knowledge-base/crawl/import", title: "Importing content", doc: "api-reference/knowledge-base", sdk: "" }, { method: "GET", path: "/knowledge-base/jobs/:jobId", title: "Importing content", doc: "api-reference/knowledge-base", sdk: "" }, { method: "POST", path: "/knowledge-base/:id/refine", title: "Importing content", doc: "api-reference/knowledge-base", sdk: "" }, { method: "GET", path: "/store/products", title: "Products", doc: "api-reference/store", sdk: "" }, { method: "POST", path: "/store/products", title: "Products", doc: "api-reference/store", sdk: "" }, { method: "GET", path: "/store/products/stats", title: "Products", doc: "api-reference/store", sdk: "" }, { method: "POST", path: "/public/store/{slug}/view/{productId}", title: "Public Storefront", doc: "api-reference/store", sdk: "" }, { method: "POST", path: "/public/store/{slug}/inquiry/{productId}", title: "Public Storefront", doc: "api-reference/store", sdk: "" }, { method: "GET", path: "/store/orders?status=pending", title: "Orders", doc: "api-reference/store", sdk: "" }, { method: "PATCH", path: "/store/orders/{id}", title: "Orders", doc: "api-reference/store", sdk: "" }, { method: "GET", path: "/v1/tickets/stats", title: "Overview numbers", doc: "api-reference/tickets", sdk: "kerabie.tickets.getStats(...)" }, { method: "GET", path: "/v1/tickets/export", title: "Export to CSV", doc: "api-reference/tickets", sdk: "kerabie.tickets.export(...)" }, { method: "GET", path: "/v1/visitors", title: "List visitors", doc: "api-reference/visitors", sdk: "kerabie.visitors.list(...)" }, { method: "GET", path: "/v1/visitors/overview", title: "Overview numbers", doc: "api-reference/visitors", sdk: "kerabie.visitors.getOverview(...)" }, { method: "GET", path: "/v1/visitors/export", title: "Export to CSV", doc: "api-reference/visitors", sdk: "kerabie.visitors.export(...)" }, { method: "GET", path: "/webhooks", title: "List webhooks", doc: "api-reference/webhooks", sdk: "" }, { method: "POST", path: "/webhooks", title: "Create a webhook", doc: "api-reference/webhooks", sdk: "" }, { method: "PATCH", path: "/webhooks/:id", title: "Update a webhook", doc: "api-reference/webhooks", sdk: "" }, { method: "DELETE", path: "/webhooks/:id", title: "Delete a webhook", doc: "api-reference/webhooks", sdk: "" }, { method: "GET", path: "/webhooks/:id/deliveries", title: "List delivery logs", doc: "api-reference/webhooks", sdk: "" }, { method: "POST", path: "/webhooks/:id/deliveries/:deliveryId/retry", title: "Retry a delivery", doc: "api-reference/webhooks", sdk: "" }], types: { typescript: "interface KerAbieClientConfig {\n apiKey: string;\n orgId?: string;\n baseUrl?: string;\n timeout?: number;\n}\ninterface PaginationMeta {\n total: number;\n page: number;\n limit: number;\n hasMore?: boolean;\n /** Total pages (conversation lists). */\n pages?: number;\n /** Organisation-wide totals per status \u2014 ignores the list filters (conversation lists). */\n counts?: ConversationCounts;\n}\ntype ConversationStatus = 'WAITING' | 'ACTIVE' | 'RESOLVED' | 'CLOSED';\ntype ConversationChannel = 'WEBSITE' | 'WHATSAPP' | 'INSTAGRAM' | 'EMAIL' | 'API';\ninterface Conversation {\n id: number;\n status: ConversationStatus;\n channel: ConversationChannel;\n subject?: string | null;\n customerId?: number | null;\n customer?: {\n id: number;\n name: string | null;\n email: string | null;\n sessionId: string;\n };\n /** Team-member id of the assignee, if any. */\n assignedTeamMemberId?: number | null;\n assignedAgent?: {\n id: number;\n userId: number;\n user: {\n fname: string | null;\n lname: string | null;\n email: string;\n avatar?: string | null;\n };\n } | null;\n rating?: number | null;\n ratingComment?: string | null;\n /** Text of the most recent message (list responses). */\n lastMessage?: string | null;\n lastMessageAt?: string | null;\n /** First few tags (list responses); `get` returns all of them. */\n tags?: {\n tag: string;\n }[];\n /** True while the AI bot is handling the conversation alone: still open, no human reply, not handed off. */\n aiHandling?: boolean;\n /** AI recap written when the bot hands off to a human. */\n handoffSummary?: string | null;\n /** Unread message count, under `_count.messages` (list responses). */\n _count?: {\n messages: number;\n };\n createdAt: string;\n updatedAt: string;\n}\n/** Organisation-wide conversation totals per status. */\ninterface ConversationCounts {\n all: number;\n WAITING: number;\n ACTIVE: number;\n RESOLVED: number;\n CLOSED: number;\n}\n/** An earlier conversation with the same customer. */\ninterface PreviousConversation {\n id: number;\n status: ConversationStatus;\n channel: ConversationChannel;\n subject: string | null;\n isTicket: boolean;\n createdAt: string;\n lastMessageAt: string | null;\n handoffSummary: string | null;\n}\ninterface TakeOverParams {\n /** Organisation team-member id the conversation is assigned to if nobody holds it yet. */\n agentId: number;\n}\ninterface ListConversationsParams {\n status?: ConversationStatus;\n channel?: ConversationChannel;\n page?: number;\n limit?: number;\n search?: string;\n assignedTo?: number;\n}\ninterface CreateConversationParams {\n customerId?: number;\n customerEmail?: string;\n subject?: string;\n channel?: ConversationChannel;\n metadata?: Record<string, any>;\n}\ninterface UpdateConversationParams {\n status?: ConversationStatus;\n assignedTeamMemberId?: number;\n rating?: number;\n ratingComment?: string;\n}\ntype MessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system';\ntype SenderType = 'agent' | 'visitor' | 'bot';\ninterface Message {\n id: number;\n conversationId: number;\n body: string;\n type: MessageType;\n senderType: SenderType;\n senderName?: string;\n agentId?: number;\n metadata?: Record<string, any>;\n attachments?: Attachment[];\n createdAt: string;\n}\ninterface Attachment {\n id: string;\n url: string;\n type: 'image' | 'file' | 'audio' | 'video';\n name?: string;\n size?: number;\n mimeType?: string;\n}\ninterface SendMessageParams {\n body: string;\n type?: MessageType;\n metadata?: Record<string, any>;\n}\ninterface ListMessagesParams {\n limit?: number;\n before?: number;\n}\ninterface Customer {\n id: number;\n name?: string;\n email?: string;\n phone?: string;\n avatar?: string;\n externalSource?: string;\n externalRef?: string;\n customAttributes?: Record<string, string>;\n conversationCount?: number;\n createdAt: string;\n updatedAt: string;\n}\ninterface UpsertCustomerParams {\n email?: string;\n name?: string;\n phone?: string;\n externalSource?: string;\n externalRef?: string;\n customAttributes?: Record<string, string>;\n}\ntype VisitorStatus = 'online' | 'offline' | 'blocked' | 'allowed';\n/** A visitor from the last few days \u2014 on the site now or recently gone. */\ninterface Visitor {\n /** The widget's anonymous session id. */\n sessionId: string;\n /** From the contact record if they have chatted, otherwise as identified to the widget. */\n name: string | null;\n email: string | null;\n phone: string | null;\n /** ISO 3166-1 alpha-2 code, e.g. \"NG\". */\n country: string | null;\n city: string | null;\n /** null when the organisation hides visitor IPs in its privacy settings. */\n ip: string | null;\n userAgent: string | null;\n /** The page they are on now (online) or were last on. */\n currentPage: string | null;\n /** Visits so far. Coming back after 30+ minutes away counts as a new visit. */\n visitCount: number;\n firstSeenAt: string;\n lastSeenAt: string;\n /** Their most recent conversation, if they have chatted. */\n conversationId: number | null;\n /** Blocked / allowed come from your IP rules and win over online / offline. */\n status: VisitorStatus;\n}\ninterface ListVisitorsParams {\n status?: 'all' | VisitorStatus;\n /** Matches name, email, IP, city or page, case-insensitively. */\n search?: string;\n /** Two-letter country code. */\n country?: string;\n /** How far back to look: default 7, max 30 (visitor history is kept for 30 days). */\n days?: number;\n page?: number;\n limit?: number;\n}\ninterface VisitorsOverview {\n /** On the site right now. */\n online: number;\n /** Distinct countries in the last 24 hours. */\n countries: number;\n /** Visitors in the last 24 hours who have visited more than once. */\n returning: number;\n /** Active IP block rules. */\n blockedIps: number;\n topCountries: {\n country: string;\n count: number;\n }[];\n}\ninterface VisitorsExport {\n /** e.g. visitors-2026-10-05.csv */\n filename: string;\n csv: string;\n count: number;\n /** True when more than 5,000 matched and the file was cut at 5,000. */\n truncated: boolean;\n}\ninterface BlockVisitorParams {\n reason?: string;\n blockIp?: boolean;\n}\ntype CampaignStatus = 'active' | 'paused' | 'draft';\ntype CampaignTrigger = 'page_load' | 'time_on_page' | 'scroll_depth' | 'exit_intent';\ntype CampaignAction = 'open_chat' | 'open_kb' | 'open_link';\ntype CampaignAudience = 'all' | 'new' | 'returning' | 'not_chatted' | 'country';\ntype CampaignFrequency = 'once_per_visitor' | 'once_per_day' | 'every_session';\ninterface Campaign {\n id: number;\n name: string;\n /** What the visitor sees. */\n message: string;\n /** Button text; no button when null. */\n ctaLabel: string | null;\n ctaAction: CampaignAction;\n /** Only for `open_link`. */\n ctaUrl: string | null;\n trigger: CampaignTrigger;\n /** Seconds for `time_on_page`, percent for `scroll_depth`, otherwise null. */\n triggerValue: string | null;\n /** Comma-separated URL patterns (`*` wildcards); null = every page. */\n pages: string | null;\n audience: CampaignAudience;\n /** ISO country code (e.g. `NG`), only for audience `country`. */\n audienceCountry: string | null;\n frequency: CampaignFrequency;\n isActive: boolean;\n /** `draft` = never launched, `paused` = launched before but off. */\n status: CampaignStatus;\n launchedAt: string | null;\n /** Times it was shown to a visitor. */\n sentCount: number;\n /** Visitors who opened the chat from it. */\n openedCount: number;\n /** Visitors who pressed its button. */\n clickCount: number;\n /** Visitors who wrote back after opening it. */\n repliedCount: number;\n createdAt: string;\n}\ninterface ListCampaignsParams {\n status?: CampaignStatus;\n /** Matches name or message, case-insensitively. */\n search?: string;\n page?: number;\n limit?: number;\n}\ninterface CreateCampaignParams {\n name: string;\n message: string;\n ctaLabel?: string;\n ctaAction?: CampaignAction;\n ctaUrl?: string;\n trigger?: CampaignTrigger;\n triggerValue?: string | number;\n pages?: string;\n audience?: CampaignAudience;\n /** Required when audience is `country`. */\n audienceCountry?: string;\n frequency?: CampaignFrequency;\n /** `true` launches it straight away (counts against your plan's live-campaign limit). Default: saved as a draft. */\n isActive?: boolean;\n}\ntype UpdateCampaignParams = Partial<CreateCampaignParams>;\ninterface CampaignStats {\n total: number;\n active: number;\n paused: number;\n draft: number;\n sent: number;\n opened: number;\n clicked: number;\n replied: number;\n /** Most campaigns your plan lets you run at once; 0 = unlimited. */\n limit: number;\n}\ntype ContactType = 'LEAD' | 'CUSTOMER' | 'VIP';\ninterface Contact {\n id: number;\n name: string | null;\n email: string | null;\n phone: string | null;\n company: string | null;\n notes: string | null;\n contactType: ContactType;\n /** Team-member id of the owner, if any. */\n ownerId: number | null;\n owner: {\n id: number;\n user: {\n fname: string | null;\n lname: string | null;\n email: string;\n avatar?: string | null;\n };\n } | null;\n tagLinks: {\n tag: {\n id: number;\n name: string;\n color: string | null;\n };\n }[];\n /** Two-letter country code, from the visitor's IP when they chatted in. */\n visitorCountry: string | null;\n /** 'manual', 'import', or null for contacts who wrote in themselves. */\n externalSource: string | null;\n /** Last message in either direction; falls back to the latest conversation for older contacts. */\n lastContactAt: string | null;\n createdAt: string;\n _count: {\n conversations: number;\n };\n}\ninterface ListContactsParams {\n /** Matches name, email, phone, company or a tag name, case-insensitively. */\n search?: string;\n type?: ContactType;\n tagId?: number;\n /** Team-member id. */\n ownerId?: number;\n /** 'recent' (default) = last contact first, contacts never messaged last. */\n sort?: 'recent' | 'newest' | 'name';\n page?: number;\n limit?: number;\n}\ninterface CreateContactParams {\n name?: string;\n email?: string;\n phone?: string;\n company?: string;\n notes?: string;\n contactType?: ContactType;\n ownerId?: number | null;\n}\ntype UpdateContactParams = CreateContactParams;\ninterface ContactStats {\n total: number;\n /** Added in the last 7 days. */\n newThisWeek: number;\n leads: number;\n vip: number;\n}\ninterface ImportContactRow {\n name?: string;\n email?: string;\n phone?: string;\n company?: string;\n /** lead, customer or vip (any case). Defaults to lead. */\n type?: string;\n}\ninterface ImportContactsResult {\n created: number;\n /** Rows skipped because the email already belongs to a contact. */\n duplicates: number;\n /** Rows that could not be imported. `row` is 1-based, not counting the header. */\n invalid: {\n row: number;\n error: string;\n }[];\n total: number;\n}\ninterface Agent {\n id: number;\n fname: string;\n lname: string;\n email: string;\n avatar?: string;\n availability?: 'online' | 'away' | 'offline';\n role?: string;\n}\ninterface InviteAgentParams {\n email: string;\n accessLevel?: 'ADMIN' | 'CHAT_ONLY' | 'READ_ONLY';\n teamId?: number;\n}\ntype KbArticleType = 'faq' | 'article' | 'news' | 'information';\ntype KbArticleStatus = 'draft' | 'published' | 'archived';\ninterface KbArticle {\n id: number;\n title: string;\n content: string;\n category: string;\n type: KbArticleType;\n status: KbArticleStatus;\n imageUrl?: string;\n views: number;\n /** Share of widget visitors who found it helpful (0-100); null until at least 3 have voted. */\n helpfulPct: number | null;\n /** How many visitors have voted. */\n votes: number;\n /** Times the AI chatbot cited it in an answer. */\n aiCitations: number;\n createdAt: string;\n updatedAt: string;\n}\ninterface KbStats {\n total: number;\n published: number;\n drafts: number;\n views: number;\n /** The most viewed published article; null until something has been viewed. */\n top: {\n title: string;\n views: number;\n } | null;\n /** Categories in use. */\n categories: string[];\n}\ninterface KbInsights {\n mostViewed: {\n id: number;\n title: string;\n views: number;\n pct: number;\n }[];\n /** Customer questions the AI had no article to answer from in the last 30 days: what to write next. */\n gaps: {\n question: string;\n count: number;\n }[];\n /** Published articles with the lowest helpfulness, lowest first (needs 3 votes). */\n lowestRated: {\n id: number;\n title: string;\n helpfulPct: number;\n votes: number;\n }[];\n /** Published articles not updated in 90 days. */\n stale: {\n id: number;\n title: string;\n age: string;\n }[];\n}\ninterface CreateArticleParams {\n title: string;\n content: string;\n category?: string;\n type?: KbArticleType;\n status?: KbArticleStatus;\n}\ntype TicketPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';\ninterface Ticket {\n id: number;\n status: ConversationStatus;\n priority: TicketPriority;\n subject?: string;\n slaDueAt?: string;\n customerId?: number;\n createdAt: string;\n updatedAt: string;\n}\n/** KPI numbers for the tickets overview. */\ninterface TicketStats {\n /** Tickets that are new or active. */\n open: number;\n /** Open tickets nobody owns. */\n unassigned: number;\n /** Open tickets past their SLA due time. */\n breachingSla: number;\n /** Average open\u2192resolved time in minutes over the last 7 days; null when none were resolved. */\n avgResolutionMinutes: number | null;\n /** % change vs the 7 days before; negative = faster. Null when there is nothing to compare. */\n resolutionDeltaPct: number | null;\n}\ninterface ExportTicketsParams {\n status?: ConversationStatus;\n priority?: TicketPriority;\n /** Matches requester name or email, subject, or a ticket reference such as KB-2041. */\n search?: string;\n /** User id of the assigned agent. */\n assignedTo?: number;\n}\ninterface TicketExport {\n /** e.g. tickets-2026-10-05.csv */\n filename: string;\n csv: string;\n count: number;\n /** True when more than 5,000 tickets matched and the file was cut at 5,000. */\n truncated: boolean;\n}\ninterface CreateTicketParams {\n /** A stable identifier for this end user in your own system \u2014 repeated calls with the same externalRef land on the same ticket. */\n externalRef: string;\n message: string;\n subject?: string;\n priority?: TicketPriority;\n name?: string;\n email?: string;\n}\ninterface UpdateTicketParams {\n status?: ConversationStatus;\n priority?: TicketPriority;\n}\ninterface TicketMessage {\n id: number;\n body: string;\n senderRole: 'agent' | 'customer';\n senderName?: string;\n createdAt: string;\n}\ninterface StoreProduct {\n id: number;\n name: string;\n description?: string;\n price: number;\n currency: string;\n imageUrl?: string;\n category?: string;\n stock?: number;\n sku?: string;\n isActive: boolean;\n createdAt: string;\n updatedAt: string;\n}\ntype WebhookEvent = 'message.sent.customer' | 'message.sent.agent' | 'conversation.created' | 'conversation.resolved' | 'conversation.assigned' | 'visitor.new' | 'rating.submitted';\ninterface Webhook {\n id: number;\n url: string;\n events: WebhookEvent[];\n isActive: boolean;\n secret?: string;\n createdAt: string;\n}\ninterface CreateWebhookParams {\n url: string;\n events: WebhookEvent[];\n secret?: string;\n}\ninterface AnalyticsSummary {\n period: {\n days: number;\n since: string;\n };\n /** Currently open (new + active) conversations. */\n openConversations: number;\n /** Conversations resolved or closed since midnight. */\n resolvedToday: number;\n /** Average first-response time in seconds (0 when there is no data). */\n avgResponseTime: number;\n /** Customer satisfaction 0\u2013100 (average rating \xD7 20). */\n satisfactionScore: number;\n /** Visitors on the website right now. */\n activeVisitors: number;\n totals: {\n conversations: number;\n resolved: number;\n closed: number;\n messages: number;\n visitors: number;\n aiChats: number;\n /** Tickets created in the period. */\n tickets: number;\n /** Contacts added (someone with a name, email or phone). */\n contacts: number;\n /** Website visitor sessions that started in the period (history is kept 30 days, so longer windows count that far back). */\n siteVisitors: number;\n };\n rates: {\n resolution: number;\n };\n satisfaction: {\n avgRating: number | null;\n totalRatings: number;\n };\n /** % change vs the previous period of the same length; null = nothing to compare. For responseTime, negative is an improvement. */\n deltas: {\n conversations: number | null;\n resolved: number | null;\n responseTime: number | null;\n satisfaction: number | null;\n aiChats: number | null;\n };\n /** The AI assistant's share of the work. */\n ai: {\n /** Conversations the AI handled alone (no human ever replied). */\n resolvedAlone: number;\n resolvedAlonePct: number;\n /** Share of conversations handed off to a human. */\n handedOffPct: number;\n /** Estimate: AI-only conversations \xD7 the average time agents spent on human-handled ones. */\n agentHoursSaved: number;\n };\n channels: {\n channel: string;\n count: number;\n }[];\n aiEscalations: {\n reason: string;\n count: number;\n }[];\n /** Daily buckets (up to 30), oldest first. `ai` + `human` = `count`. */\n trend: {\n date: string;\n count: number;\n ai: number;\n human: number;\n }[];\n}\ninterface AnalyticsParams {\n /** Window in days (default 30, max 365). */\n days?: number;\n /** Alternative to `days`, e.g. '7d' or '30d'. */\n period?: string;\n}\ninterface AgentStat {\n agentId: number;\n /** Full name, falling back to the email's local part for agents who haven't set one. */\n name: string;\n avatar?: string | null;\n availability: 'online' | 'away' | 'busy' | 'offline' | string;\n messages: number;\n assigned: number;\n resolved: number;\n resolutionRate: number;\n avgRating: number | null;\n}\n/** An agent in {@link AnalyticsInsights}: the usual stats plus first-response time. */\ninterface AgentInsight extends AgentStat {\n /** Average time to this agent's first reply on conversations they answered first; null when there are none. */\n firstResponseSeconds: number | null;\n}\ninterface QuestionInsight {\n /** The wording customers used most. */\n question: string;\n asked: number;\n /** Share of these conversations the AI handled without a person stepping in. */\n aiResolvedPct: number;\n /** Average customer rating (1-5) of these conversations; null when none were rated. */\n avgRating: number | null;\n /** The page most of them started on. */\n page: string | null;\n /** % change in how often it was asked vs the previous period; null when it is new. */\n trendPct: number | null;\n}\n/** The deeper numbers behind the dashboard's Analytics page. */\ninterface AnalyticsInsights {\n period: {\n days: number;\n since: string;\n };\n /** Median time from a customer's first message to a person's first reply. `deltaPct` is vs the previous period (negative = faster). */\n firstResponse: {\n medianSeconds: number | null;\n deltaPct: number | null;\n sample: number;\n };\n agents: AgentInsight[];\n channels: {\n channel: string;\n count: number;\n pct: number;\n firstResponseSeconds: number | null;\n avgRating: number | null;\n }[];\n ai: {\n csatOnAiChats: {\n avg: number | null;\n count: number;\n };\n /** The most frequently asked questions (top 20), grouped by wording. */\n questions: QuestionInsight[];\n /** Questions handed to a person because nothing in the knowledge base matched. */\n unanswered: {\n question: string;\n count: number;\n }[];\n outcomes: {\n resolvedByAi: number;\n handedOff: number;\n other: number;\n };\n };\n customers: {\n active: number;\n new: number;\n returning: number;\n perCustomer: number | null;\n deltas: {\n active: number | null;\n new: number | null;\n returning: number | null;\n };\n /** One row per star, 5 first. */\n csat: {\n stars: number;\n count: number;\n pct: number;\n }[];\n csatResponses: number;\n countries: {\n country: string;\n count: number;\n }[];\n newPct: number;\n returningPct: number;\n top: {\n id: number;\n name: string;\n company: string | null;\n conversations: number;\n lastSeenAt: string | null;\n avgRating: number | null;\n }[];\n /** Customer messages by weekday (Monday first) and two-hour block (0, 2, \u2026 22), in the organisation's timezone. */\n heatmap: {\n days: string[];\n cells: number[][];\n timezone: string;\n };\n };\n /** Pages that start the most conversations. `askRate` is null until the page has recorded views. */\n pages: {\n page: string;\n views: number;\n questions: number;\n askRate: number | null;\n topQuestion: string | null;\n }[];\n}\n/** Open tickets by priority and how many are still inside their SLA. */\ninterface TicketSla {\n total: number;\n withinSlaPct: number;\n byPriority: {\n priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';\n count: number;\n oldestMinutes: number | null;\n }[];\n}\n/** Which first-run steps are done. */\ninterface SetupStatus {\n channel: boolean;\n ai: boolean;\n automation: boolean;\n}\ninterface WebhookPayload<T = any> {\n event: WebhookEvent;\n orgId: number;\n timestamp: string;\n data: T;\n}\ntype AiToolMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';\ntype AiToolAuthType = 'none' | 'bearer' | 'api_key' | 'basic';\ninterface AiToolParameter {\n /** Letters, numbers and underscores. */\n name: string;\n type: 'string' | 'number' | 'boolean';\n /** What the AI should put here, e.g. \"Product SKU the customer mentioned\". */\n description?: string;\n /** Default `true`: the AI collects it before calling your API. */\n required?: boolean;\n}\ninterface AiToolUsage {\n /** Calls in the last 30 days. */\n calls: number;\n /** Calls that failed (error or HTTP 4xx/5xx) in the last 30 days. */\n failures: number;\n lastUsedAt: string | null;\n}\ninterface AiTool {\n id: number;\n /** The snake_case function name the AI calls. */\n name: string;\n displayName: string;\n /** The AI reads this to decide when to call the tool. */\n description: string;\n parameters: Required<AiToolParameter>[];\n endpoint: string;\n method: AiToolMethod;\n authType: AiToolAuthType;\n /** Dot-notation path into the JSON response; null = the whole response. */\n responsePath: string | null;\n enabled: boolean;\n createdAt: string;\n updatedAt: string;\n usage: AiToolUsage;\n}\n/** One tool as returned by `get`: secrets are never returned, `hasSecret` says whether one is stored. */\ninterface AiToolDetail extends Omit<AiTool, 'usage'> {\n /** Names and headers only; secret values come back empty. */\n authConfig: Record<string, string> | null;\n headers: Record<string, string> | null;\n hasSecret: boolean;\n}\ninterface AiToolStats {\n total: number;\n active: number;\n /** Calls in the last 30 days. */\n calls: number;\n /** Percent of calls that succeeded; null when there were none. */\n successRate: number | null;\n /** How many tools your plan allows: -1 = not on your plan, 0 = unlimited. */\n limit: number;\n}\ninterface CreateAiToolParams {\n /** snake_case, unique in your organisation. */\n name: string;\n displayName: string;\n /** 10-500 characters. Say when the AI should use the tool. */\n description: string;\n parameters?: AiToolParameter[];\n /** A public https:// or http:// address. Private and local addresses are rejected. */\n endpoint: string;\n /** Default `POST`. GET sends parameters in the query string, the others as a JSON body. */\n method?: AiToolMethod;\n /** Default `none`. */\n authType?: AiToolAuthType;\n /** bearer: `{ token }` | api_key: `{ header, value }` | basic: `{ username, password }`. Write-only. */\n authConfig?: Record<string, string>;\n /** Extra static headers sent on every call (10 at most). */\n headers?: Record<string, string>;\n responsePath?: string;\n /** Default `true`. */\n enabled?: boolean;\n}\n/** Send only what you want to change. Leave a secret out of `authConfig` to keep the stored one. */\ntype UpdateAiToolParams = Partial<CreateAiToolParams>;\ninterface AiToolTestResult {\n success: boolean;\n /** What the AI would see, when the call succeeded. */\n result?: string;\n error?: string;\n statusCode?: number;\n durationMs: number;\n}\ninterface McpServerTool {\n name: string;\n description: string;\n /** The tool's input as JSON Schema. */\n inputSchema: Record<string, unknown>;\n}\ninterface McpServer {\n id: number;\n name: string;\n /** What the server is for and when the chatbot should use it. */\n description: string;\n url: string;\n authType: 'none' | 'bearer' | 'api_key';\n /** Header names and the like; secret values come back empty. */\n authConfig: Record<string, string> | null;\n /** Whether a secret is stored. */\n hasSecret: boolean;\n /** `false` disconnects the whole server from the chatbot. */\n enabled: boolean;\n /** What the server offered at the last sync. */\n tools: McpServerTool[];\n /** Names of the tools the chatbot may use. */\n enabledTools: string[];\n lastSyncedAt: string | null;\n /** Why the last sync failed, if it did. */\n lastError: string | null;\n /** Calls the chatbot made to this server, and how many failed. */\n callCount: number;\n failureCount: number;\n createdAt: string;\n}\ninterface CreateMcpServerParams {\n name: string;\n /**\n * 10-300 characters: what this server is for and when the chatbot should use it, e.g.\n * \"Order and stock lookups for our shop. Use when a customer asks about an order or an item.\"\n * The chatbot reads it with each of the server's tools to choose the right one.\n */\n description: string;\n /** The server's Streamable HTTP endpoint, a public address. */\n url: string;\n authType?: 'none' | 'bearer' | 'api_key';\n /** bearer: `{ token }` | api_key: `{ header, value }`. Write-only. */\n authConfig?: Record<string, string>;\n}\ninterface UpdateMcpServerParams {\n name?: string;\n description?: string;\n enabled?: boolean;\n /** Tool names to switch on (the rest are switched off). Counts against your plan's AI tool limit. */\n enabledTools?: string[];\n authType?: 'none' | 'bearer' | 'api_key';\n /** Leave a secret out to keep the stored one. */\n authConfig?: Record<string, string>;\n}\n\ndeclare class HttpError extends Error {\n readonly status: number;\n readonly body?: unknown | undefined;\n constructor(status: number, message: string, body?: unknown | undefined);\n}\ninterface RequestOptions {\n method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';\n path: string;\n params?: Record<string, string | number | undefined>;\n body?: unknown;\n}\ndeclare class HttpClient {\n private baseUrl;\n private apiKey;\n private orgId?;\n private timeout;\n constructor(baseUrl: string, apiKey: string, orgId?: string, timeout?: number);\n request<T>(opts: RequestOptions): Promise<T>;\n get<T>(path: string, params?: RequestOptions['params']): Promise<T>;\n post<T>(path: string, body?: unknown): Promise<T>;\n patch<T>(path: string, body?: unknown): Promise<T>;\n delete<T>(path: string): Promise<T>;\n}\n\ndeclare class ConversationsResource {\n private readonly http;\n constructor(http: HttpClient);\n list(params?: ListConversationsParams): Promise<{\n data: Conversation[];\n meta: PaginationMeta;\n }>;\n get(id: number): Promise<Conversation>;\n create(params: CreateConversationParams): Promise<Conversation>;\n update(id: number, params: UpdateConversationParams): Promise<Conversation>;\n resolve(id: number): Promise<Conversation>;\n close(id: number): Promise<Conversation>;\n assign(id: number, agentId: number): Promise<Conversation>;\n /** The same customer's earlier conversations, newest first (default 5, max 20). */\n getHistory(id: number, limit?: number): Promise<{\n data: PreviousConversation[];\n }>;\n /**\n * Hand a conversation from the AI assistant to a human: stops the bot and\n * assigns `agentId` (a team-member id) if nobody holds it yet. Fails with\n * 400 if the AI is no longer handling it.\n */\n takeOver(id: number, params: TakeOverParams): Promise<{\n success: boolean;\n conversationId: number;\n }>;\n getMessages(id: number, params?: ListMessagesParams): Promise<{\n data: Message[];\n meta: {\n hasMore: boolean;\n nextCursor?: string;\n };\n }>;\n sendMessage(id: number, params: SendMessageParams): Promise<Message>;\n delete(id: number): Promise<void>;\n}\n\ndeclare class CustomersResource {\n private readonly http;\n constructor(http: HttpClient);\n list(params?: {\n search?: string;\n page?: number;\n limit?: number;\n }): Promise<{\n data: Customer[];\n meta: PaginationMeta;\n }>;\n get(id: number): Promise<Customer>;\n upsert(params: UpsertCustomerParams): Promise<Customer>;\n update(id: number, params: Partial<UpsertCustomerParams>): Promise<Customer>;\n getConversations(id: number): Promise<{\n data: Conversation[];\n }>;\n delete(id: number): Promise<void>;\n}\n\ndeclare class VisitorsResource {\n private readonly http;\n constructor(http: HttpClient);\n /** Visitors from the last few days (online and offline) with visit counts and block/allow status. */\n list(params?: ListVisitorsParams): Promise<{\n data: Visitor[];\n meta: PaginationMeta & {\n truncated: boolean;\n };\n }>;\n /** Visitors on the site right now. */\n getActive(): Promise<{\n data: Visitor[];\n }>;\n /** Online now, countries and returning visitors (24h), active IP blocks, top countries. */\n getOverview(): Promise<VisitorsOverview>;\n /**\n * CSV of the visitors matching the filters (max 5,000). Requires a plan with\n * data exports (Pro and above) \u2014 otherwise throws an HttpError with status 403.\n */\n export(params?: Omit<ListVisitorsParams, 'page' | 'limit'>): Promise<VisitorsExport>;\n getPages(sessionId: string): Promise<{\n data: {\n url: string;\n title: string;\n visitedAt: string;\n }[];\n }>;\n block(sessionId: string, params?: BlockVisitorParams): Promise<{\n success: boolean;\n }>;\n}\n\ndeclare class AgentsResource {\n private readonly http;\n constructor(http: HttpClient);\n list(): Promise<{\n data: Agent[];\n }>;\n getOnline(): Promise<{\n data: {\n agentId: number;\n name: string;\n status: string;\n }[];\n }>;\n invite(params: InviteAgentParams): Promise<{\n success: boolean;\n message: string;\n }>;\n}\n\ndeclare class KnowledgeBaseResource {\n private readonly http;\n constructor(http: HttpClient);\n list(params?: {\n search?: string;\n category?: string;\n page?: number;\n limit?: number;\n }): Promise<{\n data: KbArticle[];\n meta: PaginationMeta;\n }>;\n /** Totals and the categories in use. */\n getStats(): Promise<KbStats>;\n /** What to write or fix next: most viewed, unanswered customer questions, lowest rated, and stale articles. */\n getInsights(): Promise<KbInsights>;\n get(id: number): Promise<KbArticle>;\n search(query: string): Promise<{\n data: KbArticle[];\n }>;\n createArticle(params: CreateArticleParams): Promise<KbArticle>;\n updateArticle(id: number, params: Partial<CreateArticleParams>): Promise<KbArticle>;\n deleteArticle(id: number): Promise<void>;\n}\n\ndeclare class WebhooksResource {\n private readonly http;\n constructor(http: HttpClient);\n list(): Promise<{\n data: Webhook[];\n }>;\n get(id: number): Promise<Webhook>;\n create(params: CreateWebhookParams): Promise<Webhook>;\n update(id: number, params: Partial<CreateWebhookParams & {\n isActive: boolean;\n }>): Promise<Webhook>;\n delete(id: number): Promise<void>;\n /**\n * Verify an incoming webhook signature.\n * The signature header is `X-Kerabie-Signature`.\n */\n static verify(rawBody: string, signature: string, secret: string): boolean;\n /**\n * Parse a verified webhook payload with full TypeScript types.\n */\n static parse<T = any>(rawBody: string): WebhookPayload<T>;\n}\n\ndeclare class AnalyticsResource {\n private readonly http;\n constructor(http: HttpClient);\n /** Conversation, response-time, satisfaction and AI numbers for the last `days` days (default 30), with deltas vs the previous period. */\n getSummary(params?: AnalyticsParams): Promise<AnalyticsSummary>;\n getAgentStats(params?: AnalyticsParams): Promise<{\n agents: AgentStat[];\n }>;\n /**\n * The deeper numbers: first-response times, per-agent and per-channel results, the questions customers ask,\n * who they are, when they write in, and which pages start conversations.\n */\n getInsights(params?: AnalyticsParams): Promise<AnalyticsInsights>;\n /** Open tickets by priority, and the percentage still within their SLA. */\n getTicketSla(): Promise<TicketSla>;\n /** Which of the first-run steps (connect a channel, train the AI, create an automation) are done. */\n getSetupStatus(): Promise<SetupStatus>;\n}\n\ndeclare class StoreResource {\n private readonly http;\n constructor(http: HttpClient);\n list(params?: {\n page?: number;\n limit?: number;\n }): Promise<{\n data: StoreProduct[];\n meta: PaginationMeta;\n }>;\n get(id: number): Promise<StoreProduct>;\n}\n\ndeclare class TicketsResource {\n private readonly http;\n constructor(http: HttpClient);\n list(params?: {\n status?: string;\n priority?: string;\n page?: number;\n limit?: number;\n }): Promise<{\n data: Ticket[];\n meta: PaginationMeta;\n }>;\n /** Open, unassigned and SLA-breaching counts plus the 7-day average resolution time and its change. */\n getStats(): Promise<TicketStats>;\n /**\n * CSV of tickets matching the filters (max 5,000). Requires a plan with\n * data exports (Pro and above) \u2014 otherwise throws an HttpError with status 403.\n */\n export(params?: ExportTicketsParams): Promise<TicketExport>;\n get(id: number): Promise<Ticket & {\n messages: TicketMessage[];\n }>;\n create(params: CreateTicketParams): Promise<{\n ticketId: number;\n message: TicketMessage;\n }>;\n update(id: number, params: UpdateTicketParams): Promise<Ticket>;\n resolve(id: number): Promise<Ticket>;\n close(id: number): Promise<Ticket>;\n reply(id: number, message: string): Promise<TicketMessage>;\n}\n\ndeclare class ContactsResource {\n private readonly http;\n constructor(http: HttpClient);\n /** People with a name, email or phone number, last-contacted first. */\n list(params?: ListContactsParams): Promise<{\n contacts: Contact[];\n meta: PaginationMeta;\n }>;\n /** Totals for the overview: everyone, new this week, leads, VIPs. */\n getStats(): Promise<ContactStats>;\n get(id: number): Promise<Contact>;\n /** Adds a contact. Needs a name, email or phone. Throws an HttpError with status 409 if the email is already saved. */\n create(params: CreateContactParams): Promise<{\n contact: Contact;\n }>;\n update(id: number, params: UpdateContactParams): Promise<{\n success: boolean;\n }>;\n /**\n * Imports up to 1,000 contacts per call. Rows whose email is already saved are skipped;\n * invalid rows are reported with their number instead of failing the whole import.\n */\n import(rows: ImportContactRow[]): Promise<ImportContactsResult>;\n}\n\ndeclare class CampaignsResource {\n private readonly http;\n constructor(http: HttpClient);\n /** Proactive messages shown on your chat widget, newest first. */\n list(params?: ListCampaignsParams): Promise<{\n campaigns: Campaign[];\n meta: PaginationMeta;\n }>;\n /** Totals and funnel numbers, plus how many campaigns your plan lets you run at once. */\n getStats(): Promise<CampaignStats>;\n get(id: number): Promise<Campaign>;\n /**\n * Saves a draft, or launches it with `isActive: true`.\n * Throws an HttpError with status 403 when launching would exceed your plan's live-campaign limit.\n */\n create(params: CreateCampaignParams): Promise<{\n campaign: Campaign;\n }>;\n /** Edits fields and/or turns it on or off with `isActive`. Send only what you want to change. */\n update(id: number, params: UpdateCampaignParams): Promise<{\n success: boolean;\n }>;\n /** A copy saved as a draft, with fresh results. */\n duplicate(id: number): Promise<{\n campaign: Campaign;\n }>;\n delete(id: number): Promise<{\n success: boolean;\n }>;\n}\n\ndeclare class AiToolsResource {\n private readonly http;\n constructor(http: HttpClient);\n /** Your AI agent tools (custom API actions the chatbot can call), with 30-day usage and your plan's limit. */\n list(): Promise<{\n tools: AiTool[];\n stats: AiToolStats;\n }>;\n /** One tool. Secrets are never returned. */\n get(id: number): Promise<AiToolDetail>;\n /**\n * Adds a tool. Throws an HttpError with status 403 when your plan has no room for another,\n * 409 when the function name is taken, and 400 for a private or local endpoint.\n */\n create(params: CreateAiToolParams): Promise<{\n tool: {\n id: number;\n };\n }>;\n /** Edits fields and/or switches the tool on or off with `enabled`. Send only what you want to change. */\n update(id: number, params: UpdateAiToolParams): Promise<{\n tool: {\n id: number;\n };\n }>;\n /** A copy that starts switched off. Counts against your plan's tool limit. */\n duplicate(id: number): Promise<{\n tool: {\n id: number;\n };\n }>;\n delete(id: number): Promise<{\n success: boolean;\n }>;\n /** Calls the tool's real endpoint once with `args`, the way the AI would. Not counted as usage. Limited to 40 a hour. */\n test(id: number, args?: Record<string, unknown>): Promise<AiToolTestResult>;\n}\n\n/** Remote MCP servers whose tools your AI chatbot can call, next to your own AI tools. */\ndeclare class McpServersResource {\n private readonly http;\n constructor(http: HttpClient);\n /** Connected servers, with the tools each offers and which are switched on. */\n list(): Promise<{\n servers: McpServer[];\n limit: number;\n }>;\n /**\n * Connects a server and reads its tools. No tool is switched on yet: use `update` with `enabledTools`.\n * Throws an HttpError with status 400 when the server can't be reached, 403 when your plan has no AI tools,\n * and 409 when it is already connected.\n */\n create(params: CreateMcpServerParams): Promise<{\n server: McpServer;\n }>;\n /** Reads the server's tools again. Switched-on tools the server no longer offers are dropped. */\n sync(id: number): Promise<{\n server: McpServer;\n }>;\n /** Renames, switches the server on or off, changes which tools the chatbot may use, or updates credentials. */\n update(id: number, params: UpdateMcpServerParams): Promise<{\n server: McpServer;\n }>;\n delete(id: number): Promise<{\n success: boolean;\n }>;\n /** Runs one of the server's tools once with `args`, the way the chatbot would. Limited to 40 a hour. */\n test(id: number, tool: string, args?: Record<string, unknown>): Promise<AiToolTestResult>;\n}\n\ndeclare class KerAbieClient {\n readonly conversations: ConversationsResource;\n readonly customers: CustomersResource;\n readonly visitors: VisitorsResource;\n readonly agents: AgentsResource;\n readonly kb: KnowledgeBaseResource;\n readonly webhooks: WebhooksResource;\n readonly analytics: AnalyticsResource;\n readonly store: StoreResource;\n readonly tickets: TicketsResource;\n readonly contacts: ContactsResource;\n readonly campaigns: CampaignsResource;\n readonly aiTools: AiToolsResource;\n readonly mcpServers: McpServersResource;\n private readonly http;\n constructor(config: KerAbieClientConfig);\n}\n\nexport { type Agent, type AgentInsight, type AgentStat, type AiTool, type AiToolAuthType, type AiToolDetail, type AiToolMethod, type AiToolParameter, type AiToolStats, type AiToolTestResult, type AiToolUsage, type AnalyticsInsights, type AnalyticsParams, type AnalyticsSummary, type Attachment, type BlockVisitorParams, type Campaign, type CampaignAction, type CampaignAudience, type CampaignFrequency, type CampaignStats, type CampaignStatus, type CampaignTrigger, type Contact, type ContactStats, type ContactType, type Conversation, type ConversationChannel, type ConversationCounts, type ConversationStatus, type CreateAiToolParams, type CreateArticleParams, type CreateCampaignParams, type CreateContactParams, type CreateConversationParams, type CreateMcpServerParams, type CreateTicketParams, type CreateWebhookParams, type Customer, type ExportTicketsParams, HttpError, type ImportContactRow, type ImportContactsResult, type InviteAgentParams, type KbArticle, type KbInsights, type KbStats, KerAbieClient, type KerAbieClientConfig, type ListCampaignsParams, type ListContactsParams, type ListConversationsParams, type ListMessagesParams, type ListVisitorsParams, type McpServer, type McpServerTool, type Message, type MessageType, type PaginationMeta, type PreviousConversation, type QuestionInsight, type SendMessageParams, type SenderType, type SetupStatus, type StoreProduct, StoreResource, type TakeOverParams, type Ticket, type TicketExport, type TicketMessage, type TicketPriority, type TicketSla, type TicketStats, type UpdateAiToolParams, type UpdateCampaignParams, type UpdateContactParams, type UpdateConversationParams, type UpdateMcpServerParams, type UpdateTicketParams, type UpsertCustomerParams, type Visitor, type VisitorStatus, type VisitorsExport, type VisitorsOverview, type Webhook, type WebhookEvent, type WebhookPayload, WebhooksResource };\n", "react-native": "import { ImageSourcePropType } from 'react-native';\n\nexport interface KerKbArticle {\n id: number;\n title: string;\n content: string;\n type: string;\n category: string;\n imageUrl?: string | null;\n views: number;\n updatedAt: string;\n}\n\nexport interface KerStoreProduct {\n id: number;\n name: string;\n description?: string | null;\n price: number;\n currency: string;\n imageUrl?: string | null;\n category?: string | null;\n stock?: number | null;\n}\n\nexport interface KerUser {\n id?: string;\n name?: string;\n email?: string;\n phone?: string;\n role?: string;\n customAttributes?: Record<string, string>;\n}\n\nexport interface KerTheme {\n primaryColor?: string;\n /** Gradient end color \u2014 only applied when colorType is 'gradient'. Mirrors the web dashboard's ChatWidget.secondaryColor. */\n secondaryColor?: string;\n colorType?: 'gradient' | 'singleColor';\n borderRadius?: 'none' | 'small' | 'medium' | 'large';\n /** Light/dark palette \u2014 mirrors the dashboard's ChatWidget.theme; 'auto' follows the device's color scheme. */\n mode?: 'light' | 'dark' | 'auto';\n /** Overrides the palette's background \u2014 leave unset to follow `mode`. */\n backgroundColor?: string;\n textColor?: string;\n bubbleColor?: string;\n bubbleIcon?: ImageSourcePropType;\n bubbleSize?: number;\n /** Size of the icon inside the floating button \u2014 mirrors the dashboard's ChatWidget.launcherIconSize (16\u201348). */\n bubbleIconSize?: number;\n fontFamily?: string;\n agentBubbleColor?: string;\n userBubbleColor?: string;\n /** Custom mascot/icon shown next to AI replies (ChatWidget.chatbotAvatarUrl) \u2014 MessageBubble falls back to a generic \u{1F916} when unset. */\n chatbotAvatarUrl?: string;\n}\n\nexport interface KerNotification {\n id: string;\n type: 'new_message' | 'conversation_assigned' | 'system';\n title: string;\n body: string;\n conversationId?: number;\n timestamp: string;\n}\n\n/** One button of a guided flow (a menu the organisation built in the dashboard). */\nexport interface KerGuidedChoice { id: string; label: string }\n\n/** The opening question of the organisation's guided flow, with its buttons, shown before the visitor types. */\nexport interface KerGuidedGreeting { flowId: string; message: string; choices: KerGuidedChoice[] }\n\nexport interface KerMessage {\n id: number | string;\n /** Set on messages from the server; absent on local optimistic bubbles. */\n conversationId?: number;\n body: string;\n createdAt: string;\n senderType: 'visitor' | 'agent' | 'bot';\n senderName?: string;\n senderAvatar?: string;\n /** Local-only, pre-send scaffolding for ctx.sendMessage(body, attachments) \u2014\n * the actual wire format (and what MessageBubble renders) is `type` +\n * `metadata`, matching the server's message shape 1:1 (see chat-cache.service.ts\n * CachedMessage). sendMessage() derives `type`/`metadata` from the first\n * entry here for its own optimistic bubble, same as the chat widget does. */\n attachments?: KerAttachment[];\n type?: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system';\n /** `articles` is the KB citation(s) an AI reply matched (chatbot.service.ts buildContext). */\n metadata?: { url?: string; fileName?: string; mimeType?: string; articles?: Array<{ id: number; title: string }>; [key: string]: any };\n status?: 'pending' | 'sent' | 'delivered' | 'read' | 'failed';\n /** Set async, after the message arrives, via the 'chat:message:linkpreview' socket event. */\n linkPreview?: KerLinkPreview;\n /** True while an AI reply is still streaming in (chat:message:start..chat:message:chunk) \u2014 MessageBubble shows a blinking cursor instead of the sent/read ticks. */\n streaming?: boolean;\n}\n\nexport interface KerAttachment {\n id: string;\n /** For local optimistic display only (a local file:// URI or similar) \u2014\n * attachments are private objects; the real, fetchable URL only exists\n * server-side, resolved fresh per read from `key` (see chat.service.ts\n * resolveAttachmentUrls). Never assume this is fetchable once the\n * message has round-tripped through the server. */\n url: string;\n /** S3 object key \u2014 what actually gets sent to the server so it can\n * resolve a signed URL. Absent for a purely local/optimistic attachment\n * that hasn't finished uploading yet. */\n key?: string;\n type: 'image' | 'file' | 'audio' | 'video';\n name?: string;\n size?: number;\n}\n\nexport interface KerLinkPreview {\n url: string;\n title: string;\n description?: string;\n image?: string;\n favicon?: string;\n siteName?: string;\n}\n\nexport interface KerConversation {\n id: number;\n status: 'open' | 'resolved' | 'closed';\n assignedAgent?: { id: number; name: string; avatar?: string };\n lastMessage?: KerMessage;\n unreadCount: number;\n createdAt: string;\n updatedAt: string;\n}\n\n/** One row of the visitor's conversation list (the Chat tab), as the server summarises it. */\nexport interface KerConversationSummary {\n id: number;\n name: string;\n avatar?: string | null;\n lastMessage: string;\n lastMessageTime: string;\n unreadCount: number;\n isOnline?: boolean;\n}\n\nexport type KerTicketPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';\nexport type KerTicketStatus = 'WAITING' | 'ACTIVE' | 'RESOLVED' | 'CLOSED';\n\nexport interface KerTicket {\n id: number;\n subject?: string | null;\n status: KerTicketStatus;\n priority: KerTicketPriority;\n slaDueAt?: string | null;\n createdAt: string;\n}\n\nexport interface KerTicketMessage {\n id: number | string;\n body: string;\n senderRole: 'agent' | 'customer';\n senderName?: string;\n createdAt: string;\n}\n\nexport type OpenMode = 'modal' | 'sheet' | 'push';\nexport type BubblePosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';\n\nexport interface KerNavigationRoute {\n /** The screen to navigate to */\n type: 'conversation' | 'inbox';\n /** Present when type === 'conversation' */\n conversationId?: number;\n}\n\n/** The push service a token came from. These are the values the Kerabie API accepts. */\nexport type KerPushProvider = 'expo' | 'fcm';\n\n/**\n * The one push service your app uses. Kerabie keeps a single token per visitor session, so pass one adapter.\n * Expo is built in (`expoNotificationAdapter`); for Firebase or anything else, supply an object with these three methods.\n */\nexport interface KerNotificationAdapter {\n /** Asks the user for permission to show notifications. Resolves true when granted. */\n requestPermission: () => Promise<boolean>;\n /** This device's token for the service the adapter wraps, and which service that is. Null when there is none. */\n getToken: () => Promise<{ token: string; provider: KerPushProvider } | null>;\n /**\n * Calls `handler` with a notification's `data` when the user taps it, including the tap that launched the app.\n * Returns a function that stops listening. Kerabie notifications carry `conversationId` and `orgId` in `data`.\n */\n onTap: (handler: (data: Record<string, unknown>) => void) => Promise<() => void> | (() => void);\n}\n\nexport interface KerNotificationConfig {\n /** The push service to use. Defaults to Expo. Create it once, outside your component. */\n adapter?: KerNotificationAdapter;\n /** Called after a token has been registered with Kerabie. */\n onTokenRegister?: (token: string, provider: KerPushProvider) => void;\n}\n\n/** One question on the pre-chat form (dashboard: Messages > Pre-chat form). name/email/phone are the contact's own fields; other keys become custom attributes. */\nexport interface KerEnrichmentField {\n key: string;\n label: string;\n type: 'text' | 'email' | 'phone' | 'number' | 'textarea' | 'select';\n required: boolean;\n placeholder?: string;\n options?: string[];\n}\n\nexport interface KerChatSettings {\n /** Widget title shown in ChatScreen's own header \u2014 falls back to the org's dashboard-configured Widget Name (ChatWidget.name) when unset. */\n name?: string;\n welcomeMessage?: string;\n offlineMessage?: string;\n showBranding?: boolean;\n /** Show the \"agent is typing\" indicator \u2014 mirrors the dashboard's ChatWidget.typingIndicator. */\n typingIndicator?: boolean;\n enableFileAttachments?: boolean;\n enableVoiceMessages?: boolean;\n /** Whether the AI chatbot can reply when no agent is online \u2014 mirrors ChatWidget.chatbotEnabled. Gates the offline pre-chat capture form in ChatScreen. */\n chatbotEnabled?: boolean;\n /** Off hides past conversations (the Chat tab then offers only a new chat). Mirrors ChatWidget.chatHistoryEnabled. */\n chatHistoryEnabled?: boolean;\n /** 'single' = one continuous chat; the header menu (settings) only shows when showMenu is on. Mirrors ChatWidget.layout/showMenu. */\n layout?: 'full' | 'single';\n showMenu?: boolean;\n /** The org's logo, shown in the Home header. */\n logoUrl?: string | null;\n /** Show the Home / Chat / Help tab bar even in the 'single' layout. Mirrors ChatWidget.showNavigation. */\n showNavigation?: boolean;\n /** Home header greeting. `greetingText` falls back to the default; `{{name}}` is the visitor's first name. */\n showGreeting?: boolean;\n greetingText?: string | null;\n /** The \"We reply in minutes\" chip in the Home header. */\n showStatusChip?: boolean;\n statusChipText?: string | null;\n /** 'searchbar' = render <KerAbieSearchBar /> instead of the floating bubble. Mirrors ChatWidget.launcherType. */\n launcherType?: 'bubble' | 'searchbar';\n searchbarPlaceholder?: string;\n /** Ask visitors the pre-chat form's questions before their first message. Mirrors ChatWidget.customerEnrichmentEnabled/Form. */\n customerEnrichmentEnabled?: boolean;\n customerEnrichmentForm?: { fields: KerEnrichmentField[] } | null;\n /** The org's guided flow opens the chat with this question and its buttons. Mirrors the widget's guidedGreeting. */\n guidedGreeting?: KerGuidedGreeting | null;\n}\n\nexport interface KerAbieProviderProps {\n widgetKey: string;\n appName?: string;\n appVersion?: string;\n appBuild?: string;\n openMode?: OpenMode;\n user?: KerUser;\n theme?: KerTheme;\n position?: BubblePosition;\n defaultOpen?: boolean;\n hideFloatingButton?: boolean;\n /**\n * Called when the SDK needs to open a screen in your app's navigator. This is the only navigation hook: call\n * `router.push(...)` (Expo Router) or `navigation.navigate(...)` (React Navigation) from it.\n * Only called when openMode='push'; without it the chat opens inline.\n */\n onNavigate?: (route: KerNavigationRoute) => void;\n onOpenChat?: () => void;\n onCloseChat?: () => void;\n /**\n * The app's current screen path (for example Expo Router's `usePathname()`).\n * Campaigns can be limited to certain screens (\"On pages\" in the dashboard); without this,\n * only campaigns for every screen are shown.\n */\n screen?: string;\n /**\n * A campaign button can open your Knowledge Base. Chat has no built-in screen for it, so the\n * app decides where it goes (e.g. `router.push('/help')`); without this the chat opens instead.\n */\n onOpenKnowledgeBase?: () => void;\n /**\n * Shown in the chat header, next to Settings. Pass `<KerAbieCallButton variant=\"icon\" />` from\n * 'react-native-kerabie-helpdesk/calls' to put call buttons in the chat. It is a prop, not an import, so the main\n * package never loads the call libraries.\n */\n callAction?: React.ReactNode;\n notifications?: KerNotificationConfig;\n chatSettings?: KerChatSettings;\n children: React.ReactNode;\n}\n\nexport interface KerAbieContextValue {\n // Chat state\n isOpen: boolean;\n open: () => void;\n close: () => void;\n toggle: () => void;\n unreadCount: number;\n /** The visitor's past and open conversations, newest first. Empty when the org turned chat history off. */\n conversations: KerConversationSummary[];\n /** False until the first answer (or remembered list) is in, so screens show a skeleton rather than \"no chats\". */\n conversationsLoaded: boolean;\n currentConversation: KerConversation | null;\n /** A conversation a push tap or deep link asked for; the widget opens on it, then clears this. */\n chatTarget: number | null;\n clearChatTarget: () => void;\n /** Switches the chat to an empty draft; the conversation is only created once the first message is sent. */\n startNewConversation: () => void;\n /** Switches the chat to one of `conversations` and loads its messages. */\n openConversation: (id: number) => Promise<void>;\n /** Pulls the conversation list again. */\n refreshConversations: () => Promise<void>;\n /** The visitor is looking at this conversation: what the team said so far no longer counts as unread. */\n markConversationRead: (id: number) => void;\n /** The identified user's name, used to fill {{name}} in the welcome message. */\n visitorName: string;\n messages: KerMessage[];\n /** `choice` marks a tap on a guided flow's button; `body` is then the button's label. */\n sendMessage: (body: string, attachments?: KerAttachment[], choice?: { flowId: string; choiceId: string }) => Promise<void>;\n retryMessage: (msg: KerMessage) => Promise<void>;\n /** Asks again for the guided flow's opening question for the screen the visitor is on now (it can differ by page). */\n refreshGreeting: () => Promise<void>;\n isTyping: boolean;\n /** True while the typing indicator is the AI chatbot (not a human agent) \u2014 ChatScreen shows the thinking orb for it. */\n isAiTyping: boolean;\n onlineAgents: number;\n isConnected: boolean;\n\n // User identity\n currentUser: KerUser | null;\n setUser: (user: KerUser) => void;\n clearUser: () => void;\n updateAttribute: (key: string, value: string) => void;\n\n // App identity\n appName: string;\n appVersion: string;\n appBuild: string;\n setAppVersion: (v: string) => void;\n\n // Notifications\n hasNotificationPermission: boolean;\n requestNotificationPermission: () => Promise<boolean>;\n registerNotificationToken: (token: string, provider?: KerPushProvider) => void;\n\n /** Text handed to ChatScreen when the search-bar launcher opens the chat (any staged attachment goes with it). */\n chatDraft: { text: string } | null;\n openWithDraft: (draft: { text: string }) => void;\n clearChatDraft: () => void;\n\n // Contextual chat\n openWithContext: (context: KerChatContext) => void;\n\n // Config\n callAction: React.ReactNode;\n config: Required<Pick<KerAbieProviderProps, 'openMode' | 'position' | 'hideFloatingButton'>> & {\n theme: KerTheme;\n widgetKey: string;\n /** From /public/widget/init \u2014 gates whether the host app should show a ticket entry point at all. */\n ticketsEnabled: boolean;\n /** From /public/widget/init \u2014 the dashboard has calls on and the plan still has minutes. Used by the optional calls entry point. */\n callsEnabled: boolean;\n videoCallsEnabled: boolean;\n /** The pre-chat form the org set up (Messages > Pre-chat form), asked before a chat and before a call. */\n customerEnrichmentEnabled: boolean;\n customerEnrichmentForm: { fields: KerEnrichmentField[] } | null;\n /** /public/widget/init has answered \u2014 launchers render nothing before this. */\n configReady: boolean;\n launcherType: 'bubble' | 'searchbar';\n searchbarPlaceholder: string;\n };\n}\n\n/**\n * Contextual payload for opening chat with pre-loaded context.\n * Shown to the agent as context alongside the conversation.\n *\n * @example \u2013 product click\n * openWithContext({ type: 'product', id: 'sku_123', title: 'Blue Sneakers', data: { price: 4500 } })\n *\n * @example \u2013 order inquiry\n * openWithContext({ type: 'order', id: 'ORD-2024-001', title: 'Order #2024-001', data: { status: 'shipped' } })\n *\n * @example \u2013 fintech transaction\n * openWithContext({ type: 'transaction', id: 'TXN-987', title: '\u20A65,000 Transfer', data: { status: 'failed' } })\n *\n * @example \u2013 ride sharing\n * openWithContext({ type: 'ride', id: 'RIDE-456', title: 'Trip to Airport', data: { driver: 'John' } })\n */\nexport interface KerChatContext {\n type: 'order' | 'product' | 'transaction' | 'ride' | 'errand' | 'booking' | 'custom';\n id?: string; // external reference ID\n title?: string; // human-readable label shown to the agent\n data?: Record<string, any>; // arbitrary key-value payload\n openChat?: boolean; // auto-open the chat UI (default: true)\n}\n", flutter: "import 'package:flutter/foundation.dart';\n\n/// Who sent a message.\nenum KerabieSender { visitor, agent, bot }\n\n/// Delivery state of a message the visitor sent from this device.\nenum KerabieMessageStatus { pending, sent, delivered, read, failed }\n\n/// The person using your app. Pass it in [KerabieConfig.user] so agents see who they are talking to.\n@immutable\nclass KerabieUser {\n const KerabieUser({this.id, this.name, this.email, this.phone, this.role, this.customAttributes = const {}});\n\n /// Your own id for this user.\n final String? id;\n final String? name;\n final String? email;\n final String? phone;\n\n /// For example `customer`, `driver` or `admin`.\n final String? role;\n\n /// Extra details shown to agents, such as a plan or an account id.\n final Map<String, String> customAttributes;\n\n Map<String, dynamic> toJson() => {\n if (id != null) 'id': id,\n if (name != null) 'name': name,\n if (email != null) 'email': email,\n if (phone != null) 'phone': phone,\n if (role != null) 'role': role,\n if (customAttributes.isNotEmpty) 'customAttributes': customAttributes,\n };\n\n KerabieUser copyWith({String? id, String? name, String? email, String? phone, String? role, Map<String, String>? customAttributes}) =>\n KerabieUser(\n id: id ?? this.id,\n name: name ?? this.name,\n email: email ?? this.email,\n phone: phone ?? this.phone,\n role: role ?? this.role,\n customAttributes: customAttributes ?? this.customAttributes,\n );\n}\n\n/// A file picked to send. Upload it with [KerabieController.stageAttachment]; it goes out with the next message.\n@immutable\nclass KerabiePickedFile {\n const KerabiePickedFile({required this.path, required this.name, required this.mimeType, this.size});\n\n /// Where the file is on the device.\n final String path;\n final String name;\n final String mimeType;\n final int? size;\n\n bool get isImage => mimeType.startsWith('image/');\n}\n\n/// A file attached to a message being sent. [url] is the file on the device, shown while the message is sent;\n/// [key] is what the upload returned and what the server needs.\n@immutable\nclass KerabieAttachment {\n const KerabieAttachment({required this.url, required this.key, required this.type, this.name});\n\n final String url;\n final String key;\n\n /// `image` or `file`.\n final String type;\n final String? name;\n}\n\n/// A preview of a link in a message, fetched by the server after the message arrives.\n@immutable\nclass KerabieLinkPreview {\n const KerabieLinkPreview({required this.url, required this.title, this.description, this.image, this.favicon, this.siteName});\n\n final String url;\n final String title;\n final String? description;\n final String? image;\n final String? favicon;\n final String? siteName;\n\n static KerabieLinkPreview? fromServer(Object? raw) {\n if (raw is! Map || raw['url'] is! String) return null;\n return KerabieLinkPreview(\n url: raw['url'] as String,\n title: '${raw['title'] ?? ''}',\n description: raw['description'] as String?,\n image: raw['image'] as String?,\n favicon: raw['favicon'] as String?,\n siteName: raw['siteName'] as String?,\n );\n }\n}\n\n/// A knowledge base article an AI reply was based on.\n@immutable\nclass KerabieCitation {\n const KerabieCitation({required this.id, required this.title});\n\n final int id;\n final String title;\n}\n\n/// One row of the visitor's conversation list, as the server describes it.\n@immutable\nclass KerabieConversation {\n const KerabieConversation({\n required this.id,\n required this.name,\n this.avatar,\n this.lastMessage = '',\n this.lastMessageTime,\n this.unreadCount = 0,\n this.isOnline = false,\n this.status = 'open',\n });\n\n final int id;\n\n /// The assigned agent, or the widget's agent name.\n final String name;\n final String? avatar;\n final String lastMessage;\n final DateTime? lastMessageTime;\n final int unreadCount;\n final bool isOnline;\n\n /// `open`, `resolved` or `closed`.\n final String status;\n\n static KerabieConversation? fromServer(Object? raw) {\n if (raw is! Map || raw['id'] is! num) return null;\n return KerabieConversation(\n id: (raw['id'] as num).toInt(),\n name: '${raw['name'] ?? 'Support'}',\n avatar: raw['avatar'] as String?,\n lastMessage: '${raw['lastMessage'] ?? ''}',\n lastMessageTime: DateTime.tryParse('${raw['lastMessageTime']}'),\n unreadCount: (raw['unreadCount'] as num?)?.toInt() ?? 0,\n isOnline: raw['isOnline'] == true,\n status: '${raw['status'] ?? 'open'}'.toLowerCase(),\n );\n }\n}\n\n/// What the customer was looking at when they asked for help, so agents do not have to ask.\n@immutable\nclass KerabieChatContext {\n const KerabieChatContext({required this.type, this.id, this.title, this.data, this.openChat = true});\n\n /// `order`, `product`, `transaction`, `ride`, `errand`, `booking` or `custom`.\n final String type;\n\n /// Your own reference, for example an order number.\n final String? id;\n\n /// A short label the agent sees, for example `Order #2024-001`.\n final String? title;\n\n /// Any extra details, such as a price or a status.\n final Map<String, dynamic>? data;\n\n /// Set false to only record the context and open the chat later.\n final bool openChat;\n}\n\n/// A call, and the pass that lets the app join its LiveKit room. The optional `kerabie_helpdesk_flutter_calls`\n/// package turns this into audio and video; this package only carries it.\n@immutable\nclass KerabieCallJoin {\n const KerabieCallJoin({\n required this.callId,\n required this.status,\n required this.video,\n required this.livekitUrl,\n required this.livekitToken,\n this.handler,\n this.queuePosition,\n });\n\n final int callId;\n\n /// `QUEUED`, `RINGING` or `ACTIVE`.\n final String status;\n final bool video;\n\n /// `HUMAN` or `AI`, once somebody has answered.\n final String? handler;\n\n /// Place in the queue (1 = next), only while the call is waiting.\n final int? queuePosition;\n final String livekitUrl;\n final String livekitToken;\n\n factory KerabieCallJoin.fromServer(Map<String, dynamic> json) {\n final livekit = json['livekit'] is Map ? Map<String, dynamic>.from(json['livekit'] as Map) : const <String, dynamic>{};\n return KerabieCallJoin(\n callId: (json['callId'] as num).toInt(),\n status: '${json['status'] ?? 'QUEUED'}',\n video: json['video'] == true,\n handler: json['handler'] as String?,\n queuePosition: (json['queuePosition'] as num?)?.toInt(),\n livekitUrl: '${livekit['url'] ?? ''}',\n livekitToken: '${livekit['token'] ?? ''}',\n );\n }\n}\n\n/// Where a call stands, pushed by the server: `ringing`, `queued`, `accepted` or `ended`.\n@immutable\nclass KerabieCallEvent {\n const KerabieCallEvent(this.name, this.data);\n\n final String name;\n final Map<String, dynamic> data;\n\n int? get callId => (data['callId'] as num?)?.toInt();\n}\n\n/// One message in the conversation.\n@immutable\nclass KerabieMessage {\n const KerabieMessage({\n required this.id,\n required this.body,\n required this.createdAt,\n required this.sender,\n this.conversationId,\n this.senderName,\n this.senderAvatar,\n this.status = KerabieMessageStatus.sent,\n this.streaming = false,\n this.streamId,\n this.type = 'text',\n this.metadata = const {},\n this.linkPreview,\n this.attachment,\n });\n\n /// Server ids are numbers; local, not-yet-sent messages use `local_<n>`.\n final String id;\n final int? conversationId;\n final String body;\n final DateTime createdAt;\n final KerabieSender sender;\n final String? senderName;\n final String? senderAvatar;\n final KerabieMessageStatus status;\n\n /// True while an AI reply is still arriving word by word.\n final bool streaming;\n final String? streamId;\n\n /// `text`, `image`, `file`, `audio`, `video` or `system`.\n final String type;\n final Map<String, dynamic> metadata;\n\n /// Arrives after the message, once the server has fetched the first link in it.\n final KerabieLinkPreview? linkPreview;\n\n /// Set on a message this device is sending, so a failed one can be sent again.\n final KerabieAttachment? attachment;\n\n /// Where an attached image or file can be opened from. On a message still being sent this is the file on the device.\n String? get attachmentUrl => metadata['url'] is String ? metadata['url'] as String : null;\n String? get attachmentName => metadata['fileName'] is String ? metadata['fileName'] as String : null;\n\n /// The knowledge base articles an AI reply was based on.\n List<KerabieCitation> get citations {\n final articles = metadata['articles'];\n if (articles is! List) return const [];\n return [\n for (final a in articles)\n if (a is Map && a['id'] is num) KerabieCitation(id: (a['id'] as num).toInt(), title: '${a['title'] ?? ''}'),\n ];\n }\n\n bool get isFromVisitor => sender == KerabieSender.visitor;\n\n /// Builds a message from the server's wire format. The server says `senderRole: customer | agent`;\n /// an AI reply (or a guided flow's question) is an agent message whose `metadata.source` is `ai` (or `guided`).\n factory KerabieMessage.fromServer(Map<String, dynamic> raw) {\n final metadata = raw['metadata'] is Map ? Map<String, dynamic>.from(raw['metadata'] as Map) : <String, dynamic>{};\n final sender = raw['senderRole'] == 'customer'\n ? KerabieSender.visitor\n : metadata['source'] == 'ai' || metadata['source'] == 'guided'\n ? KerabieSender.bot\n : KerabieSender.agent;\n return KerabieMessage(\n id: '${raw['id']}',\n conversationId: (raw['conversationId'] as num?)?.toInt(),\n body: (raw['body'] ?? '') as String,\n createdAt: DateTime.tryParse('${raw['createdAt']}') ?? DateTime.now(),\n sender: sender,\n senderName: raw['senderName'] as String?,\n senderAvatar: raw['senderAvatar'] as String?,\n streamId: raw['streamId']?.toString(),\n type: (raw['type'] ?? 'text') as String,\n metadata: metadata,\n linkPreview: KerabieLinkPreview.fromServer(raw['linkPreview']),\n status: switch (raw['status']) {\n 'delivered' => KerabieMessageStatus.delivered,\n 'read' => KerabieMessageStatus.read,\n _ => KerabieMessageStatus.sent,\n },\n );\n }\n\n KerabieMessage copyWith({\n String? id,\n String? body,\n KerabieMessageStatus? status,\n bool? streaming,\n int? conversationId,\n KerabieLinkPreview? linkPreview,\n }) {\n return KerabieMessage(\n id: id ?? this.id,\n conversationId: conversationId ?? this.conversationId,\n body: body ?? this.body,\n createdAt: createdAt,\n sender: sender,\n senderName: senderName,\n senderAvatar: senderAvatar,\n status: status ?? this.status,\n streaming: streaming ?? this.streaming,\n streamId: streamId,\n type: type,\n metadata: metadata,\n linkPreview: linkPreview ?? this.linkPreview,\n attachment: attachment,\n );\n }\n}\n\n\nimport 'package:flutter/material.dart';\n\nimport 'guided.dart';\nimport 'models.dart';\n\n/// Where the SDK talks to.\nconst String kerabieDefaultBaseUrl = 'https://api.kerabie.com';\n\n/// How the chat opens. [modal] is a full-screen page, [sheet] a bottom sheet over your app, and [push] hands the\n/// opening to your own navigator through [KerabieProvider.onNavigate] (without that callback it opens as [modal]).\nenum KerabieOpenMode { modal, sheet, push }\n\n/// Where the launcher sits.\nenum KerabiePosition { bottomRight, bottomLeft, topRight, topLeft }\n\n/// `full` has the settings menu; `single` is one continuous chat (the menu shows only when [KerabieChatSettings.showMenu]).\nenum KerabieLayout { full, single }\n\n/// The floating button, or a search bar that starts a chat from the text typed into it.\nenum KerabieLauncherType { bubble, searchbar }\n\n/// Where [KerabieProvider.onNavigate] should take the app.\nenum KerabieRouteType { conversation, inbox }\n\n@immutable\nclass KerabieRoute {\n const KerabieRoute(this.type, {this.conversationId});\n\n final KerabieRouteType type;\n\n /// Set when [type] is [KerabieRouteType.conversation].\n final int? conversationId;\n}\n\n/// Everything the SDK needs to start.\n@immutable\nclass KerabieConfig {\n const KerabieConfig({\n required this.widgetKey,\n required this.appName,\n required this.appVersion,\n this.appBuild = '',\n this.user,\n this.theme = const KerabieTheme(),\n this.deepLinkScheme,\n this.openMode = KerabieOpenMode.modal,\n this.position = KerabiePosition.bottomRight,\n this.defaultOpen = false,\n this.hideFloatingButton = false,\n this.chatSettings = const KerabieChatSettings(),\n });\n\n /// From the Kerabie dashboard: Settings, Widget. Starts with `wk_`.\n final String widgetKey;\n\n /// The app's name, version and build number, sent with the connection.\n final String appName;\n final String appVersion;\n final String appBuild;\n final KerabieUser? user;\n\n /// Overrides for the colours saved in the dashboard. Anything left unset follows the dashboard.\n final KerabieTheme theme;\n\n /// Your app's URL scheme, for example `myapp`. When set, only `myapp://conversation/123` links open the chat;\n /// when null a conversation link with any scheme opens it.\n final String? deepLinkScheme;\n\n final KerabieOpenMode openMode;\n\n /// Where [KerabieLauncher] sits unless you give it an alignment.\n final KerabiePosition position;\n\n /// Open the chat as soon as the app has built its first frame.\n final bool defaultOpen;\n\n /// Makes [KerabieLauncher] draw nothing. Open the chat from your own button with [KerabieChat.show].\n final bool hideFloatingButton;\n\n /// Overrides for what the dashboard saved. Anything left unset follows the dashboard.\n final KerabieChatSettings chatSettings;\n}\n\n/// Light or dark chat. [auto] follows the device. The dashboard's Theme setting uses the same three values.\nenum KerabieThemeMode { light, dark, auto }\n\n/// Local overrides for the look of the chat. Anything left unset follows the dashboard.\n@immutable\nclass KerabieTheme {\n const KerabieTheme({\n this.primaryColor,\n this.secondaryColor,\n this.gradient,\n this.cornerRadius,\n this.mode,\n this.backgroundColor,\n this.textColor,\n this.agentBubbleColor,\n this.userBubbleColor,\n this.bubbleColor,\n this.bubbleIcon,\n this.bubbleSize,\n this.bubbleIconSize,\n this.fontFamily,\n this.chatbotAvatarUrl,\n });\n\n final Color? primaryColor;\n final Color? secondaryColor;\n\n /// Use a primary to secondary gradient on the header and launcher.\n final bool? gradient;\n final double? cornerRadius;\n\n /// Light, dark or follow the device. When unset the dashboard's Theme setting decides, and light if that is unset too.\n final KerabieThemeMode? mode;\n\n /// Replace the chat background and text colour of the light or dark scheme.\n final Color? backgroundColor;\n final Color? textColor;\n final Color? agentBubbleColor;\n\n /// The visitor's own bubbles. Setting it turns off the gradient on them.\n final Color? userBubbleColor;\n\n /// The launcher button. Setting it turns off the gradient on it.\n final Color? bubbleColor;\n\n /// Your own picture for the launcher button. Wins over the icon saved in the dashboard.\n final ImageProvider? bubbleIcon;\n\n /// Diameter of the launcher button.\n final double? bubbleSize;\n\n /// Size of the icon inside the launcher button.\n final double? bubbleIconSize;\n\n /// Font used by all text in the chat. The app's own font when unset.\n final String? fontFamily;\n\n /// The picture beside the AI assistant's replies. Wins over the one saved in the dashboard.\n final String? chatbotAvatarUrl;\n}\n\n/// One question on the pre-chat form (dashboard: Messages, Pre-chat form). `name`, `email` and `phone` are the\n/// contact's own fields; every other key becomes a custom attribute.\n@immutable\nclass KerabieEnrichmentField {\n const KerabieEnrichmentField({\n required this.key,\n required this.label,\n this.type = 'text',\n this.required = false,\n this.placeholder,\n this.options = const [],\n });\n\n final String key;\n final String label;\n\n /// `text`, `email`, `phone`, `number`, `textarea` or `select`.\n final String type;\n final bool required;\n final String? placeholder;\n final List<String> options;\n\n static KerabieEnrichmentField? fromServer(Object? raw) {\n if (raw is! Map || raw['key'] is! String || raw['label'] is! String) return null;\n return KerabieEnrichmentField(\n key: raw['key'] as String,\n label: raw['label'] as String,\n type: raw['type'] is String ? raw['type'] as String : 'text',\n required: raw['required'] == true,\n placeholder: raw['placeholder'] as String?,\n options: raw['options'] is List ? (raw['options'] as List).whereType<String>().toList() : const [],\n );\n }\n}\n\n/// Chat behaviour. Each field you leave null follows the dashboard (Settings, Widget); each one you set wins.\n@immutable\nclass KerabieChatSettings {\n const KerabieChatSettings({\n this.name,\n this.welcomeMessage,\n this.offlineMessage,\n this.showBranding,\n this.typingIndicator,\n this.enableFileAttachments,\n this.chatbotEnabled,\n this.layout,\n this.showMenu,\n this.launcherType,\n this.searchbarPlaceholder,\n this.customerEnrichmentEnabled,\n this.customerEnrichmentForm,\n this.guidedGreeting,\n this.logoUrl,\n this.showNavigation,\n this.showGreeting,\n this.greetingText,\n this.showStatusChip,\n this.statusChipText,\n this.chatHistoryEnabled,\n });\n\n /// The title in the chat header.\n final String? name;\n\n /// Shown before the first message. `{{name}}` becomes the identified user's first name (dropped when unknown).\n final String? welcomeMessage;\n final String? offlineMessage;\n final bool? showBranding;\n\n /// Show the \"agent is typing\" indicator.\n final bool? typingIndicator;\n\n /// Whether visitors can attach a photo or a file. On unless you turn it off.\n final bool? enableFileAttachments;\n\n /// Whether the AI assistant can reply when no agent is online. Without it, the offline form asks for contact details.\n final bool? chatbotEnabled;\n final KerabieLayout? layout;\n final bool? showMenu;\n final KerabieLauncherType? launcherType;\n final String? searchbarPlaceholder;\n\n /// Ask visitors the pre-chat questions before their first message.\n final bool? customerEnrichmentEnabled;\n final List<KerabieEnrichmentField>? customerEnrichmentForm;\n\n /// The organisation's guided flow opens the chat with this question and its buttons (a menu built in the dashboard).\n final KerabieGuidedGreeting? guidedGreeting;\n\n /// The organisation's logo, shown in the Home header.\n final String? logoUrl;\n\n /// Show the Home / Chat / Help tab bar in the single layout too.\n final bool? showNavigation;\n\n /// The Home header greeting. `{{name}}` becomes the visitor's first name.\n final bool? showGreeting;\n final String? greetingText;\n\n /// The \"We reply in minutes\" chip in the Home header.\n final bool? showStatusChip;\n final String? statusChipText;\n\n /// Off hides past conversations: the Chat tab then only offers a new chat.\n final bool? chatHistoryEnabled;\n\n /// These settings with every non-null field of [other] on top.\n KerabieChatSettings merge(KerabieChatSettings other) => KerabieChatSettings(\n name: other.name ?? name,\n welcomeMessage: other.welcomeMessage ?? welcomeMessage,\n offlineMessage: other.offlineMessage ?? offlineMessage,\n showBranding: other.showBranding ?? showBranding,\n typingIndicator: other.typingIndicator ?? typingIndicator,\n enableFileAttachments: other.enableFileAttachments ?? enableFileAttachments,\n chatbotEnabled: other.chatbotEnabled ?? chatbotEnabled,\n layout: other.layout ?? layout,\n showMenu: other.showMenu ?? showMenu,\n launcherType: other.launcherType ?? launcherType,\n searchbarPlaceholder: other.searchbarPlaceholder ?? searchbarPlaceholder,\n customerEnrichmentEnabled: other.customerEnrichmentEnabled ?? customerEnrichmentEnabled,\n customerEnrichmentForm: other.customerEnrichmentForm ?? customerEnrichmentForm,\n guidedGreeting: other.guidedGreeting ?? guidedGreeting,\n logoUrl: other.logoUrl ?? logoUrl,\n showNavigation: other.showNavigation ?? showNavigation,\n showGreeting: other.showGreeting ?? showGreeting,\n greetingText: other.greetingText ?? greetingText,\n showStatusChip: other.showStatusChip ?? showStatusChip,\n statusChipText: other.statusChipText ?? statusChipText,\n chatHistoryEnabled: other.chatHistoryEnabled ?? chatHistoryEnabled,\n );\n}\n\n/// What the dashboard has saved for this widget (Settings, Widget), fetched when the SDK starts.\n@immutable\nclass KerabieSettings {\n const KerabieSettings({\n this.chat = const KerabieChatSettings(),\n this.primaryColor,\n this.secondaryColor,\n this.gradient = true,\n this.cornerRadius,\n this.borderRadius,\n this.mode = KerabieThemeMode.light,\n this.launcherIconUrl,\n this.launcherIconSize,\n this.chatbotAvatarUrl,\n this.ticketsEnabled = false,\n this.callsEnabled = false,\n this.videoCallsEnabled = false,\n });\n\n /// The chat behaviour saved in the dashboard. Only what the server sent is set.\n final KerabieChatSettings chat;\n final Color? primaryColor;\n final Color? secondaryColor;\n final bool gradient;\n final double? cornerRadius;\n\n /// The dashboard's own name for the corner radius: `none`, `small`, `medium` or `large`.\n final String? borderRadius;\n\n /// The dashboard's Theme setting.\n final KerabieThemeMode mode;\n\n /// A custom picture for the launcher button, and the size of the icon inside it.\n final String? launcherIconUrl;\n final double? launcherIconSize;\n\n /// The picture shown beside the AI assistant's replies.\n final String? chatbotAvatarUrl;\n\n /// Tickets are on in the dashboard, so the app can show an entry point to them.\n final bool ticketsEnabled;\n\n /// Calls are on in the dashboard and the plan still has minutes. The `kerabie_helpdesk_flutter_calls` package uses it.\n final bool callsEnabled;\n final bool videoCallsEnabled;\n\n factory KerabieSettings.fromServer(Map<String, dynamic> s) {\n String? text(Object? v) => v is String && v.isNotEmpty ? v : null;\n String? url(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;\n bool? flag(Object? v) => v is bool ? v : null;\n final form = s['customerEnrichmentForm'];\n final fields = form is Map && form['fields'] is List\n ? (form['fields'] as List).map(KerabieEnrichmentField.fromServer).whereType<KerabieEnrichmentField>().toList()\n : null;\n return KerabieSettings(\n chat: KerabieChatSettings(\n name: text(s['name']),\n welcomeMessage: text(s['welcomeMessage']),\n offlineMessage: text(s['offlineMessage']),\n showBranding: flag(s['showBranding']),\n typingIndicator: flag(s['typingIndicator']),\n chatbotEnabled: flag(s['chatbotEnabled']),\n layout: switch (s['layout']) {\n 'full' => KerabieLayout.full,\n 'single' => KerabieLayout.single,\n _ => null,\n },\n showMenu: flag(s['showMenu']),\n launcherType: switch (s['launcherType']) {\n 'bubble' => KerabieLauncherType.bubble,\n 'searchbar' => KerabieLauncherType.searchbar,\n _ => null,\n },\n searchbarPlaceholder: text(s['searchbarPlaceholder']),\n customerEnrichmentEnabled: flag(s['customerEnrichmentEnabled']),\n customerEnrichmentForm: fields,\n guidedGreeting: KerabieGuidedGreeting.fromServer(s['guidedGreeting']),\n logoUrl: url(s['logoUrl']),\n showNavigation: flag(s['showNavigation']),\n showGreeting: flag(s['showGreeting']),\n greetingText: text(s['greetingText']),\n showStatusChip: flag(s['showStatusChip']),\n statusChipText: text(s['statusChipText']),\n chatHistoryEnabled: flag(s['chatHistoryEnabled']),\n ),\n ticketsEnabled: s['ticketsEnabled'] == true,\n callsEnabled: s['callsEnabled'] == true,\n videoCallsEnabled: s['videoCallsEnabled'] == true,\n launcherIconUrl: url(s['launcherIconUrl']),\n launcherIconSize: s['launcherIconSize'] is num ? (s['launcherIconSize'] as num).toDouble() : null,\n chatbotAvatarUrl: url(s['chatbotAvatarUrl']),\n mode: switch (s['theme']) {\n 'dark' => KerabieThemeMode.dark,\n 'auto' => KerabieThemeMode.auto,\n _ => KerabieThemeMode.light,\n },\n primaryColor: parseHexColor(s['primaryColor'] as String?),\n secondaryColor: parseHexColor(s['secondaryColor'] as String?),\n gradient: s['colorType'] != 'singleColor',\n borderRadius: switch (s['borderRadius']) {\n 'none' || 'small' || 'medium' || 'large' => s['borderRadius'] as String,\n _ => null,\n },\n cornerRadius: switch (s['borderRadius']) {\n 'none' => 0,\n 'small' => 8,\n 'medium' => 18,\n 'large' => 24,\n _ => null,\n },\n );\n }\n}\n\n/// Parses `#rgb`, `#rrggbb` or `#aarrggbb`. Returns null for anything else.\nColor? parseHexColor(String? hex) {\n if (hex == null) return null;\n var h = hex.trim().replaceFirst('#', '');\n if (h.length == 3) h = h.split('').map((c) => '$c$c').join();\n if (h.length == 6) h = 'ff$h';\n if (h.length != 8) return null;\n final value = int.tryParse(h, radix: 16);\n return value == null ? null : Color(value);\n}\n\n\nimport 'dart:async';\n\nimport 'package:flutter/foundation.dart';\nimport 'package:flutter/widgets.dart' show Widget;\n\nimport 'api_client.dart';\nimport 'attachments.dart';\nimport 'config.dart';\nimport 'deep_link.dart';\nimport 'guided.dart';\nimport 'knowledge_base.dart';\nimport 'models.dart';\nimport 'realtime.dart';\nimport 'storage.dart';\n\n/// Which push service issued the token you register. These are the values the Kerabie API accepts.\n/// Use one provider for the whole app: Kerabie keeps a single token per visitor session.\nenum KerabiePushProvider { fcm, expo }\n\n/// The first message of a new conversation, still waiting for the server to say which conversation it made.\nclass _DraftConversation {\n _DraftConversation(this.tempId);\n\n final String tempId;\n final Completer<int?> id = Completer<int?>();\n}\n\n/// A screen path the server accepts: it starts with a slash.\nString kerabieScreenPath(String name) {\n final trimmed = name.trim();\n if (trimmed.isEmpty) return '/';\n return trimmed.startsWith('/') ? trimmed : '/$trimmed';\n}\n\n/// The state of the chat and the actions on it. Rebuilds listeners when anything changes.\nclass KerabieController extends ChangeNotifier {\n KerabieController({\n required this.config,\n KerabieStorage? storage,\n KerabieRealtime? realtime,\n KerabieApi Function(String sessionId)? apiFactory,\n KerabieFilePicker? filePicker,\n KerabieUploader? uploader,\n this.sendTimeout = const Duration(seconds: 8),\n }) : _storage = storage ?? KerabieStorage(),\n _realtime = realtime ?? SocketIoRealtime(),\n filePicker = filePicker ?? DeviceFilePicker(),\n staging = KerabieAttachmentStaging(uploader: uploader),\n _user = config.user,\n _appVersion = config.appVersion,\n _apiFactory = apiFactory ??\n ((sessionId) => KerabieApi(baseUrl: kerabieDefaultBaseUrl, widgetKey: config.widgetKey, sessionId: sessionId));\n\n final KerabieConfig config;\n\n /// Opens the photo library and the document picker for attachments.\n final KerabieFilePicker filePicker;\n\n /// The file the visitor picked and is about to send. Shared by the chat screen and the search bar.\n final KerabieAttachmentStaging staging;\n\n /// How long a sent message may stay unconfirmed before it is marked failed.\n final Duration sendTimeout;\n\n final KerabieStorage _storage;\n final KerabieRealtime _realtime;\n final KerabieApi Function(String sessionId) _apiFactory;\n\n KerabieApi? _api;\n final _apiReady = Completer<KerabieApi>();\n StreamSubscription<KerabieEvent>? _events;\n String? _sessionId;\n int _orgId = 0;\n bool _started = false;\n bool _disposed = false;\n bool _hasReadyBefore = false;\n\n final List<KerabieMessage> _messages = [];\n final Map<String, String> _pendingBodies = {};\n final Map<String, Timer> _failTimers = {};\n _DraftConversation? _draftConversation;\n Timer? _typingTimer;\n int _localCounter = 0;\n int? _conversationId;\n\n KerabieSettings _settings = const KerabieSettings();\n KerabieUser? _user;\n String _appVersion;\n List<KerabieConversation> _conversations = const [];\n String? _chatDraft;\n bool _isConnected = false;\n bool _isOpen = false;\n bool _isTyping = false;\n bool _isAiTyping = false;\n int _unreadCount = 0;\n int? _agentsOnline;\n String? _screen;\n DateTime? _screenSince;\n\n /// Set by [KerabieProvider]: shows the chat over the app. A link or a notification tap uses it.\n void Function(KerabieRoute route)? presenter;\n\n /// Set by whoever shows the chat, so [toggle] and [dismissChat] can take it away again.\n VoidCallback? dismisser;\n\n /// Called when the app should open the chat in its own navigator ([KerabieOpenMode.push]).\n void Function(KerabieRoute route)? onNavigate;\n VoidCallback? onOpenChat;\n VoidCallback? onCloseChat;\n\n /// A campaign button can open your knowledge base. Chat has no fixed screen for it, so the app decides where it goes.\n VoidCallback? onOpenKnowledgeBase;\n\n /// Shown in the chat header, set by [KerabieProvider.callActions].\n Widget? callActions;\n\n /// Runs when the visitor sends a message. Campaigns use it to count a reply.\n VoidCallback? onVisitorMessage;\n\n List<KerabieMessage> get messages => List.unmodifiable(_messages);\n\n /// What the dashboard has saved for this widget.\n KerabieSettings get settings => _settings;\n\n /// The chat settings in force: the dashboard's, with anything you set in [KerabieConfig.chatSettings] on top.\n KerabieChatSettings get chat => _settings.chat.merge(config.chatSettings);\n\n bool get isConnected => _isConnected;\n bool get isOpen => _isOpen;\n bool get isTyping => _isTyping;\n bool get isAiTyping => _isAiTyping;\n int get unreadCount => _unreadCount;\n\n /// Agents online right now, when the server has said. Null until it has.\n int? get agentsOnline => _agentsOnline;\n int? get conversationId => _conversationId;\n String? get sessionId => _sessionId;\n\n /// What the SDK remembers on the device.\n KerabieStorage get storage => _storage;\n\n /// The REST client, once the chat has started. The ticket, knowledge base and store screens use it.\n KerabieApi? get api => _api;\n\n /// The REST client, waiting for the chat to start if it has not yet.\n Future<KerabieApi> waitForApi() async => _api ?? await _apiReady.future;\n\n /// Who the customer is, as far as the app has said.\n KerabieUser? get currentUser => _user;\n\n /// The identified user's name, used to fill `{{name}}` in the welcome message. Empty when unknown.\n String get visitorName => _user?.name ?? '';\n\n String get appName => config.appName;\n String get appVersion => _appVersion;\n String get appBuild => config.appBuild;\n\n /// The visitor's conversations, after [refreshConversations].\n List<KerabieConversation> get conversations => _conversations;\n\n /// Text typed into the search bar, waiting for the chat screen to send it.\n String? get chatDraft => _chatDraft;\n\n /// The screen the app reported with [trackScreen], as a path such as `/checkout`.\n String? get screen => _screen;\n\n /// The visitor has written, or been written to: campaigns for people who have not chatted yet stay quiet.\n bool get hasChatted => _messages.isNotEmpty || _conversationId != null || _conversations.isNotEmpty;\n\n /// Loads the history and the dashboard settings, then connects. Safe to call more than once.\n Future<void> start() async {\n if (_started || _disposed) return;\n _started = true;\n final sessionId = await _storage.sessionId();\n if (_disposed) return;\n _sessionId = sessionId;\n _api = _apiFactory(sessionId);\n _apiReady.complete(_api);\n _events = _realtime.events.listen(_onEvent);\n\n await _loadHistory(replace: true);\n _orgId = await _loadSettings();\n if (_disposed) return;\n // One continuous chat (single layout): the whole recent history, not just the latest conversation.\n if (chat.layout == KerabieLayout.single && chat.chatHistoryEnabled != false) await _loadHistory(replace: true, all: true);\n if (_disposed) return;\n\n final user = _user;\n if (user != null) unawaited(identify(user));\n _connect();\n }\n\n void _connect() {\n _realtime.connect(\n baseUrl: kerabieDefaultBaseUrl,\n orgId: _orgId,\n sessionId: _sessionId!,\n appName: config.appName,\n appVersion: _appVersion,\n appBuild: config.appBuild,\n user: _user,\n );\n }\n\n Future<void> _loadHistory({required bool replace, int? conversationId, bool all = false}) async {\n try {\n final res = await _api!.get(\n '/public/widget/messages',\n query: all ? {'all': '1'} : conversationId == null ? null : {'conversationId': '$conversationId'},\n );\n final raw = res['messages'];\n if (raw is! List) return;\n // A conversation that is not this visitor's comes back empty; keep what is on screen.\n if (conversationId != null && res['conversationId'] is! num) return;\n final loaded = raw.whereType<Map>().map((m) => KerabieMessage.fromServer(Map<String, dynamic>.from(m))).toList();\n if (res['conversationId'] is num) _conversationId = (res['conversationId'] as num).toInt();\n if (replace) {\n _messages\n ..clear()\n ..addAll(loaded);\n } else {\n // After a reconnect, pick up what arrived while offline. The visitor's own messages are already shown.\n var fresh = 0;\n for (final m in loaded) {\n if (!m.isFromVisitor && !_messages.any((x) => x.id == m.id)) {\n _messages.add(m);\n fresh++;\n }\n }\n if (!_isOpen) _unreadCount += fresh;\n }\n _notify();\n } on KerabieException {\n // No history yet is fine; the conversation starts with the first message.\n }\n }\n\n /// Returns the organisation id the socket needs (0 when the settings could not be loaded, as in the other SDKs).\n Future<int> _loadSettings() async {\n try {\n final res = await _api!.get('/public/widget/init');\n final settings = res['settings'];\n if (settings is Map) {\n _settings = KerabieSettings.fromServer(Map<String, dynamic>.from(settings));\n _notify();\n }\n return (res['orgId'] as num?)?.toInt() ?? 0;\n } on KerabieException {\n return 0;\n }\n }\n\n /// Loads the visitor's conversations into [conversations].\n Future<void> refreshConversations() async {\n try {\n final res = await _api?.get('/public/widget/conversations');\n final raw = res?['conversations'];\n if (raw is! List) return;\n _conversations = raw.map(KerabieConversation.fromServer).whereType<KerabieConversation>().toList();\n _notify();\n } on KerabieException {\n // Keep the list we have.\n } finally {\n if (!_conversationsLoaded) {\n _conversationsLoaded = true;\n _notify();\n }\n }\n }\n\n bool _conversationsLoaded = false;\n\n /// False until the first answer for [conversations] is in, so a screen shows placeholders rather than \"no chats\".\n bool get conversationsLoaded => _conversationsLoaded;\n\n final _articles = <int, Future<KerabieArticle?>>{};\n\n /// A knowledge base article by id, fetched once per session (the same article can be cited on several messages). A\n /// failed fetch is asked for again next time.\n Future<KerabieArticle?> loadArticle(int id) {\n return _articles.putIfAbsent(id, () async {\n final article = await KerabieKnowledgeBase.fetchArticle(this, id);\n if (article == null) _articles.remove(id);\n return article;\n });\n }\n\n /// The visitor is looking at this conversation: what the team said so far no longer counts as unread.\n void markConversationRead(int id) {\n // Fire and forget: the badge is a nicety and must never get in the way of reading.\n unawaited(_api?.post('/public/widget/conversations/$id/read', const {}).then((_) {}, onError: (_) {}) ?? Future<void>.value());\n final index = _conversations.indexWhere((c) => c.id == id && c.unreadCount > 0);\n if (index != -1) {\n final c = _conversations[index];\n _conversations = [\n for (var i = 0; i < _conversations.length; i++)\n if (i == index)\n KerabieConversation(\n id: c.id,\n name: c.name,\n avatar: c.avatar,\n lastMessage: c.lastMessage,\n lastMessageTime: c.lastMessageTime,\n unreadCount: 0,\n isOnline: c.isOnline,\n status: c.status,\n )\n else\n _conversations[i],\n ];\n _notify();\n }\n }\n\n /// Switches to an empty draft chat. The conversation is only created once the first message is sent.\n void startNewConversation() {\n _draftConversation = null;\n _conversationId = null;\n _messages.clear();\n _isTyping = false;\n _isAiTyping = false;\n _notify();\n unawaited(refreshGuidedGreeting());\n }\n\n /// Switches to one of [conversations] and loads its messages.\n Future<void> openConversation(int id) async {\n _draftConversation = null;\n _conversationId = id;\n _isTyping = false;\n _isAiTyping = false;\n _notify();\n await _loadHistory(replace: true, conversationId: id);\n }\n\n // \u2500\u2500\u2500 Identity \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /// Tells Kerabie who this visitor is. Called for you when [KerabieConfig.user] is set.\n Future<void> identify(KerabieUser user) async {\n try {\n await _api?.post('/public/widget/identify', {'user': user.toJson()});\n } on KerabieException {\n // Identification is best effort; chat works without it.\n }\n }\n\n /// Call after login so agents see who they are talking to.\n Future<void> setUser(KerabieUser user) async {\n _user = user;\n _notify();\n await identify(user);\n }\n\n /// Call after logout. The conversation on screen is cleared and the next visitor starts a fresh anonymous session.\n Future<void> clearUser() async {\n _user = null;\n _messages.clear();\n _conversationId = null;\n _conversations = const [];\n _unreadCount = 0;\n _agentsOnline = null;\n _chatDraft = null;\n _clearPending();\n _clearTyping();\n staging.remove();\n _notify();\n if (!_started || _disposed) return;\n _realtime.disconnect();\n await _storage.clear();\n final sessionId = await _storage.sessionId();\n if (_disposed) return;\n _sessionId = sessionId;\n _api?.close();\n _api = _apiFactory(sessionId);\n _hasReadyBefore = false;\n _connect();\n }\n\n /// Adds one detail agents see next to the customer, for example `plan: pro`.\n Future<void> updateAttribute(String key, String value) async {\n final user = _user ?? const KerabieUser();\n final updated = user.copyWith(customAttributes: {...user.customAttributes, key: value});\n _user = updated;\n _notify();\n await identify(updated);\n }\n\n /// Tells Kerabie the app's version changed. It is sent with the next connection.\n void setAppVersion(String version) {\n _appVersion = version;\n _notify();\n }\n\n // \u2500\u2500\u2500 Push notifications \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /// Registers this device's push token so Kerabie can notify the visitor. Pass the token from the one push\n /// service your app uses, with its [provider]. Returns whether Kerabie accepted it.\n Future<bool> registerPushToken(String token, {required KerabiePushProvider provider}) async {\n try {\n final res = await _api?.post('/public/widget/push-token', {'token': token, 'provider': provider.name});\n return res?['success'] == true;\n } on KerabieException {\n return false;\n }\n }\n\n /// Call with a notification's data when the user taps it. Returns true when it pointed at a Kerabie conversation\n /// and the chat was opened.\n bool handleNotificationData(Map<String, dynamic> data) {\n final id = KerabieDeepLink.conversationIdFromNotification(data);\n if (id == null) return false;\n _openConversation(id);\n return true;\n }\n\n /// Call with an incoming link. Returns true when it was a Kerabie conversation link and the chat was opened.\n bool handleDeepLink(Uri uri) {\n final id = KerabieDeepLink.conversationId(uri, scheme: config.deepLinkScheme);\n if (id == null) return false;\n _openConversation(id);\n return true;\n }\n\n void _openConversation(int id) {\n // The single layout already holds every conversation in its one chat.\n if (id != _conversationId && _started && chat.layout != KerabieLayout.single) unawaited(_loadHistory(replace: true, conversationId: id));\n showChat(route: KerabieRoute(KerabieRouteType.conversation, conversationId: id));\n }\n\n // \u2500\u2500\u2500 Calls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Only the requests and events live here, so apps that just want chat carry no call code. The optional\n // `kerabie_helpdesk_flutter_calls` package adds the audio, video and screens.\n\n final _callEvents = StreamController<KerabieCallEvent>.broadcast();\n\n /// Where the current call stands, pushed by the server: `ringing`, `queued`, `accepted` and `ended`.\n Stream<KerabieCallEvent> get callEvents => _callEvents.stream;\n\n /// Starts a call. A visitor who already has one is put back into it. Throws [KerabieException] when calls are off,\n /// the plan has no minutes left or the visitor is starting too many.\n Future<KerabieCallJoin> startCall({bool video = false}) async {\n final api = _api;\n if (api == null) throw KerabieException('The chat has not started yet.');\n final res = await api.post('/public/widget/calls', {'video': video && _settings.videoCallsEnabled});\n return KerabieCallJoin.fromServer(res);\n }\n\n /// The call this visitor is in right now, if any, so an app that was closed can rejoin it.\n Future<KerabieCallJoin?> currentCall() async {\n final api = _api;\n if (api == null) return null;\n final res = await api.get('/public/widget/calls/current');\n final call = res['call'];\n return call is Map ? KerabieCallJoin.fromServer(Map<String, dynamic>.from(call)) : null;\n }\n\n /// Hangs up, or cancels a call nobody has answered yet.\n Future<void> endCall(int callId) async {\n await _api?.post('/public/widget/calls/$callId/end', {});\n }\n\n // \u2500\u2500\u2500 Opening and closing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /// Marks the chat as open and clears the unread count. The chat screen calls this itself.\n void open() {\n if (_isOpen && _unreadCount == 0) return;\n final opening = !_isOpen;\n _isOpen = true;\n _unreadCount = 0;\n _notify();\n if (opening) {\n onOpenChat?.call();\n if (_messages.isEmpty) refreshGuidedGreeting();\n }\n }\n\n KerabieGuidedGreeting? _greeting;\n bool _greetingAsked = false;\n\n /// The question and buttons the organisation's chat flow opens with, for the screen the visitor is on now.\n /// Starts as what the widget settings carried, and is asked for again each time an empty chat opens, because\n /// the flow can differ by screen.\n KerabieGuidedGreeting? get guidedGreeting => _greetingAsked ? _greeting : chat.guidedGreeting;\n\n /// Asks the server for the opening question of this screen. A failed request keeps what is shown.\n Future<void> refreshGuidedGreeting() async {\n try {\n final res = await _api?.get('/public/widget/greeting');\n if (res == null) return;\n _greeting = KerabieGuidedGreeting.fromServer(res['guidedGreeting']);\n _greetingAsked = true;\n _notify();\n } on KerabieException {\n // Keep what is on screen.\n }\n }\n\n void close() {\n if (!_isOpen) return;\n _isOpen = false;\n _notify();\n onCloseChat?.call();\n }\n\n /// Shows the chat screen over the app. Needs the navigator key passed to [KerabieProvider]; without one, only the\n /// state changes and your own UI shows the chat. With [KerabieOpenMode.push] your [onNavigate] is called instead.\n void showChat({KerabieRoute route = const KerabieRoute(KerabieRouteType.inbox)}) {\n final present = presenter;\n if (present != null) {\n present(route);\n } else {\n open();\n }\n }\n\n /// Takes the chat screen away again.\n void dismissChat() {\n final dismiss = dismisser;\n if (dismiss != null) {\n dismiss();\n } else {\n close();\n }\n }\n\n void toggle() => _isOpen ? dismissChat() : showChat();\n\n /// The search bar opens the chat with what was typed. The chat screen sends it.\n void openWithDraft(String text) {\n _chatDraft = text;\n _notify();\n showChat();\n }\n\n void clearChatDraft() {\n if (_chatDraft == null) return;\n _chatDraft = null;\n _notify();\n }\n\n /// Opens the chat and tells the agent what the customer was looking at: an order, a product, a payment.\n /// Set [KerabieChatContext.openChat] to false to only record it.\n void openWithContext(KerabieChatContext context) {\n final api = _api;\n if (api == null) return;\n // Fire and forget: a failed report must not stop the chat from opening.\n unawaited(api.post('/public/widget/context', {\n 'contextType': context.type,\n 'contextId': context.id,\n 'contextTitle': context.title,\n 'contextData': context.data,\n }).then((_) {}, onError: (_) {}));\n if (context.openChat) showChat();\n }\n\n // \u2500\u2500\u2500 Screens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /// Tells Kerabie which screen the customer is on, as a path such as `/checkout`. Agents see the page the customer\n /// is on, and campaigns can be limited to certain screens. [KerabieNavigatorObserver] calls this for you.\n void trackScreen(String path, {String? title}) {\n final next = kerabieScreenPath(path);\n final previous = _screen;\n if (next == previous) return;\n final now = DateTime.now();\n if (previous != null && _screenSince != null) {\n _realtime.sendPageExit(path: previous, timeSpentMs: now.difference(_screenSince!).inMilliseconds);\n }\n _screen = next;\n _screenSince = now;\n _realtime.sendPage(path: next, title: title, previousPath: previous);\n _notify();\n }\n\n // \u2500\u2500\u2500 Attachments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /// Opens the photo library and stages the picked photo for the next message.\n Future<void> pickPhoto() => _pick(filePicker.pickImage, 'Could not open photo library');\n\n /// Opens the document picker and stages the picked file for the next message.\n Future<void> pickDocument() => _pick(filePicker.pickDocument, 'Could not open document picker');\n\n Future<void> _pick(Future<KerabiePickedFile?> Function() picker, String failure) async {\n try {\n final file = await picker();\n await staging.stage(_api, file);\n } catch (e) {\n staging.showError(e is KerabieException ? e.message : failure);\n }\n }\n\n // \u2500\u2500\u2500 Sending \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /// Sends [text]. A photo or file staged with [pickPhoto] or [pickDocument] goes with it as the caption's attachment.\n /// Does nothing while that file is still uploading.\n ///\n /// A [choice] marks a tap on a guided flow's button: [text] is then the button's label.\n Future<void> sendMessage(String text, {KerabieAttachment? attachment, KerabieChoice? choice}) async {\n final body = text.trim();\n var attached = attachment;\n if (attached == null && staging.pending != null) {\n final file = staging.pending!;\n final uploaded = staging.uploaded;\n if (staging.isUploading || uploaded == null) return;\n attached = KerabieAttachment(\n url: file.path,\n key: uploaded.key,\n type: uploaded.mimeType.startsWith('image/') ? 'image' : 'file',\n name: uploaded.fileName,\n );\n staging.consume();\n }\n if (body.isEmpty && attached == null) return;\n\n onVisitorMessage?.call();\n var conversationId = _conversationId;\n // The first message of a conversation is still on its way: wait for its id, so this one joins the same conversation.\n if (conversationId == null && _draftConversation != null) conversationId = await _draftConversation!.id.future;\n\n final tempId = 'local_${++_localCounter}';\n final message = KerabieMessage(\n id: tempId,\n conversationId: conversationId,\n body: body,\n createdAt: DateTime.now(),\n sender: KerabieSender.visitor,\n status: KerabieMessageStatus.pending,\n type: attached?.type ?? 'text',\n metadata: attached == null ? const {} : {'url': attached.url, 'fileName': attached.name},\n attachment: attached,\n );\n _messages.add(message);\n\n if (!_realtime.isConnected) {\n _replace(tempId, (m) => m.copyWith(status: KerabieMessageStatus.failed));\n _notify();\n return;\n }\n\n _pendingBodies[tempId] = body;\n if (conversationId == null) _draftConversation = _DraftConversation(tempId);\n _failTimers[tempId] = Timer(sendTimeout, () => _markFailed(tempId));\n _notify();\n _realtime.sendMessage(\n conversationId: conversationId,\n body: body,\n attachmentType: attached?.type,\n attachmentKey: attached?.key,\n attachmentName: attached?.name,\n choice: choice,\n );\n }\n\n /// Sends a failed message again.\n Future<void> retry(KerabieMessage message) async {\n if (message.status != KerabieMessageStatus.failed) return;\n _messages.removeWhere((m) => m.id == message.id);\n await sendMessage(message.body, attachment: message.attachment);\n }\n\n void sendTyping(bool typing) {\n final id = _conversationId;\n if (id != null) _realtime.sendTyping(id, typing);\n }\n\n void _markFailed(String tempId) {\n _failTimers.remove(tempId)?.cancel();\n if (_draftConversation?.tempId == tempId) {\n _draftConversation!.id.complete(null);\n _draftConversation = null;\n }\n if (!_pendingBodies.containsKey(tempId)) return;\n _pendingBodies.remove(tempId);\n _replace(tempId, (m) => m.copyWith(status: KerabieMessageStatus.failed));\n _notify();\n }\n\n void _clearPending() {\n for (final timer in _failTimers.values) {\n timer.cancel();\n }\n _failTimers.clear();\n _pendingBodies.clear();\n if (_draftConversation?.id.isCompleted == false) _draftConversation!.id.complete(null);\n _draftConversation = null;\n }\n\n // \u2500\u2500\u2500 Events from the server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n void _onEvent(KerabieEvent event) {\n switch (event.name) {\n case 'connect':\n _isConnected = true;\n final screen = _screen;\n if (screen != null) _realtime.sendPage(path: screen);\n case 'disconnect':\n _isConnected = false;\n _clearTyping();\n case 'ready':\n final id = event.data['conversationId'];\n if (id is num) _conversationId ??= id.toInt();\n final online = event.data['agentsOnline'];\n // Only a boolean here; `agents` carries the real count later.\n if (online is bool) _agentsOnline = online ? ((_agentsOnline ?? 0) > 0 ? _agentsOnline : 1) : 0;\n // The socket does not replay what was sent while it was down, so ask for the history again after a reconnect.\n if (_hasReadyBefore) unawaited(_loadHistory(replace: false));\n _hasReadyBefore = true;\n case 'agents':\n final count = event.data['count'];\n if (count is num) _agentsOnline = count.toInt();\n case 'message':\n _onMessage(event.data);\n case 'linkpreview':\n final preview = KerabieLinkPreview.fromServer(event.data['linkPreview']);\n if (preview != null) _replace('${event.data['messageId']}', (m) => m.copyWith(linkPreview: preview));\n case 'start':\n _onStreamStart(event.data);\n case 'chunk':\n _onStreamChunk(event.data);\n case 'typing':\n _onTyping(event.data);\n case 'call_ringing' || 'call_queued' || 'call_accepted' || 'call_ended':\n _callEvents.add(KerabieCallEvent(event.name.substring('call_'.length), event.data));\n return; // no chat state changed\n default:\n return;\n }\n _notify();\n }\n\n void _onMessage(Map<String, dynamic> raw) {\n final message = KerabieMessage.fromServer(raw);\n\n // The echo of a new conversation's first message is the earliest the server's conversation id is known.\n final draft = _draftConversation;\n if (draft != null && message.conversationId != null && _pendingBodies[draft.tempId] == message.body) {\n _draftConversation = null;\n draft.id.complete(message.conversationId);\n }\n if (message.conversationId != null) _conversationId ??= message.conversationId;\n\n // The final text of an AI reply that was streaming in.\n final streamId = message.streamId;\n if (streamId != null) {\n final i = _messages.indexWhere((m) => m.id == 'stream_$streamId');\n if (i >= 0) {\n _messages[i] = message;\n _afterIncoming(message);\n return;\n }\n }\n\n // The server's copy of a message this device sent: replace the local one.\n if (message.isFromVisitor) {\n final tempId = _pendingBodies.entries.where((e) => e.value == message.body).map((e) => e.key).firstOrNull;\n if (tempId != null) {\n _pendingBodies.remove(tempId);\n _failTimers.remove(tempId)?.cancel();\n _replace(tempId, (_) => message.copyWith(status: KerabieMessageStatus.sent));\n return;\n }\n }\n\n if (_messages.any((m) => m.id == message.id)) return;\n _messages.add(message);\n _afterIncoming(message);\n }\n\n void _afterIncoming(KerabieMessage message) {\n if (message.isFromVisitor) return;\n _clearTyping();\n if (!_isOpen) _unreadCount++;\n }\n\n void _onStreamStart(Map<String, dynamic> data) {\n final streamId = data['streamId']?.toString();\n if (streamId == null) return;\n final id = 'stream_$streamId';\n if (_messages.any((m) => m.id == id)) return;\n _messages.add(KerabieMessage(\n id: id,\n body: '',\n createdAt: DateTime.now(),\n sender: KerabieSender.bot,\n senderName: (data['senderName'] as String?) ?? 'AI Assistant',\n streaming: true,\n streamId: streamId,\n ));\n _isAiTyping = false;\n _isTyping = false;\n }\n\n void _onStreamChunk(Map<String, dynamic> data) {\n final id = 'stream_${data['streamId']}';\n final delta = data['delta'];\n if (delta is! String) return;\n _replace(id, (m) => m.copyWith(body: m.body + delta));\n }\n\n void _onTyping(Map<String, dynamic> data) {\n _typingTimer?.cancel();\n _typingTimer = null;\n final typing = data['typing'] != false;\n // User id 0 is the AI assistant's typing identity.\n final fromAi = data['userId'] == 0;\n if (!typing) {\n _isTyping = false;\n _isAiTyping = false;\n return;\n }\n _isTyping = true;\n _isAiTyping = fromAi;\n // Safety net in case the matching \"stopped typing\" never arrives.\n _typingTimer = Timer(fromAi ? const Duration(seconds: 30) : const Duration(seconds: 3), () {\n _isTyping = false;\n _isAiTyping = false;\n _notify();\n });\n }\n\n void _clearTyping() {\n _typingTimer?.cancel();\n _typingTimer = null;\n _isTyping = false;\n _isAiTyping = false;\n }\n\n void _replace(String id, KerabieMessage Function(KerabieMessage) change) {\n final i = _messages.indexWhere((m) => m.id == id);\n if (i >= 0) _messages[i] = change(_messages[i]);\n }\n\n void _notify() {\n if (!_disposed) notifyListeners();\n }\n\n @override\n void dispose() {\n _disposed = true;\n _typingTimer?.cancel();\n _clearPending();\n _events?.cancel();\n _callEvents.close();\n staging.dispose();\n _realtime.dispose();\n _api?.close();\n super.dispose();\n }\n}\n\n\n/// Opening the chat from outside the app: a link or a tapped notification.\n///\n/// The link format is the same in every Kerabie SDK: `<your-scheme>://conversation/<id>`.\n/// Notifications from Kerabie carry `conversationId` (and `orgId`) in their data, so a tap needs no URL at all.\nclass KerabieDeepLink {\n const KerabieDeepLink._();\n\n /// The conversation id in a `<scheme>://conversation/<id>` link, or null for any other link.\n /// With [scheme] set, links using a different scheme are ignored.\n static int? conversationId(Uri uri, {String? scheme}) {\n if (scheme != null && uri.scheme != scheme) return null;\n if (uri.host != 'conversation' || uri.pathSegments.isEmpty) return null;\n return int.tryParse(uri.pathSegments.first);\n }\n\n /// `myapp://conversation/123`, for example to put in a notification's `data.url`.\n static Uri conversationUri(int conversationId, String scheme) {\n return Uri(scheme: scheme, host: 'conversation', path: '/$conversationId');\n }\n\n /// The conversation a notification points at, from its `data` map. Values may arrive as numbers or strings,\n /// because push services deliver data as strings.\n static int? conversationIdFromNotification(Map<String, dynamic> data) {\n final raw = data['conversationId'];\n if (raw is num) return raw.toInt();\n if (raw is String) return int.tryParse(raw);\n return null;\n }\n}\n\n\nimport 'dart:async';\n\nimport 'package:file_picker/file_picker.dart';\nimport 'package:flutter/foundation.dart';\nimport 'package:image_picker/image_picker.dart';\nimport 'package:mime/mime.dart';\n\nimport 'api_client.dart';\nimport 'models.dart';\n\n/// Mirrors the server's list of allowed attachment types.\nconst _allowedMime = {\n 'image/jpeg',\n 'image/png',\n 'image/gif',\n 'image/webp',\n 'application/pdf',\n 'text/plain',\n 'text/csv',\n 'application/msword',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.ms-excel',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.ms-powerpoint',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n 'application/zip',\n};\n\n/// 25 MB, the same limit as the server.\nconst kerabieMaxAttachmentBytes = 25 * 1024 * 1024;\n\n/// Opens the photo library or the document picker. Tests replace it; apps normally use [DeviceFilePicker].\nabstract class KerabieFilePicker {\n /// Null when the visitor cancels.\n Future<KerabiePickedFile?> pickImage();\n Future<KerabiePickedFile?> pickDocument();\n}\n\nclass DeviceFilePicker implements KerabieFilePicker {\n @override\n Future<KerabiePickedFile?> pickImage() async {\n final file = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 80);\n if (file == null) return null;\n final name = file.name.isNotEmpty ? file.name : 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg';\n return KerabiePickedFile(\n path: file.path,\n name: name,\n mimeType: file.mimeType ?? lookupMimeType(name) ?? lookupMimeType(file.path) ?? 'image/jpeg',\n size: await file.length(),\n );\n }\n\n @override\n Future<KerabiePickedFile?> pickDocument() async {\n final file = await FilePicker.pickFile();\n if (file == null) return null;\n final path = file.path ?? file.xFile.path;\n return KerabiePickedFile(\n path: path,\n name: file.name,\n mimeType: lookupMimeType(file.name) ?? lookupMimeType(path) ?? 'application/octet-stream',\n size: await file.xFile.length(),\n );\n }\n}\n\n/// What an upload leaves on the server. The attachment is private: [key] is all the server needs to find it again.\n@immutable\nclass KerabieUploadedAttachment {\n const KerabieUploadedAttachment({required this.key, required this.fileName, required this.mimeType});\n\n final String key;\n final String fileName;\n final String mimeType;\n}\n\n/// Checks a file, uploads it and waits until the server has stored it. Throws [KerabieException] with a message\n/// fit to show the visitor.\nFuture<KerabieUploadedAttachment> uploadAttachment(\n KerabieApi api,\n KerabiePickedFile file, {\n Duration pollInterval = const Duration(milliseconds: 700),\n Duration timeout = const Duration(seconds: 30),\n}) async {\n final size = file.size;\n if (size != null && size > kerabieMaxAttachmentBytes) {\n throw KerabieException('File is too large (${(size / 1024 / 1024).toStringAsFixed(1)} MB). Maximum is 25 MB.');\n }\n if (!_allowedMime.contains(file.mimeType)) {\n throw KerabieException('File type \"${file.mimeType}\" is not allowed');\n }\n\n final started = await api.postFile('/public/widget/attachment',\n field: 'file', filePath: file.path, fileName: file.name, mimeType: file.mimeType);\n final jobId = started['jobId'];\n if (jobId == null) throw KerabieException('Upload failed');\n\n final deadline = DateTime.now().add(timeout);\n while (DateTime.now().isBefore(deadline)) {\n final job = await api.get('/public/widget/attachment/$jobId');\n final result = job['result'];\n if (job['status'] == 'succeeded' && result is Map) {\n return KerabieUploadedAttachment(\n key: '${result['key']}',\n fileName: '${result['fileName'] ?? file.name}',\n mimeType: '${result['mimeType'] ?? file.mimeType}',\n );\n }\n if (job['status'] == 'failed') {\n throw KerabieException(job['error'] is String && (job['error'] as String).isNotEmpty ? job['error'] as String : 'Upload processing failed');\n }\n await Future<void>.delayed(pollInterval);\n }\n throw KerabieException('Upload timed out');\n}\n\n/// Uploads one picked file. [uploadAttachment] is the real one; tests hand in a stand-in.\ntypedef KerabieUploader = Future<KerabieUploadedAttachment> Function(KerabieApi api, KerabiePickedFile file);\n\n/// A picked file uploads in the background and waits for Send, so a caption can go with it. The search bar and the\n/// chat screen share one staging area: a file picked in the bar is still there, already uploaded, when the chat opens.\nclass KerabieAttachmentStaging extends ChangeNotifier {\n KerabieAttachmentStaging({this.errorDuration = const Duration(seconds: 5), KerabieUploader? uploader})\n : _uploader = uploader ?? ((api, file) => uploadAttachment(api, file));\n\n final Duration errorDuration;\n final KerabieUploader _uploader;\n\n KerabiePickedFile? _pending;\n KerabieUploadedAttachment? _uploaded;\n bool _isUploading = false;\n String? _error;\n Timer? _errorTimer;\n bool _disposed = false;\n\n /// The file the visitor picked, while it uploads and until it is sent or removed.\n KerabiePickedFile? get pending => _pending;\n\n /// Set once the upload finished: this is what [KerabieController.sendMessage] sends.\n KerabieUploadedAttachment? get uploaded => _uploaded;\n bool get isUploading => _isUploading;\n String? get error => _error;\n\n Future<void> stage(KerabieApi? api, KerabiePickedFile? file) async {\n if (file == null) return;\n _uploaded = null;\n _pending = file;\n _isUploading = true;\n _notify();\n try {\n if (api == null) throw KerabieException('The chat has not started yet.');\n final uploaded = await _uploader(api, file);\n // The visitor may have removed it, or picked another, while it uploaded.\n if (identical(_pending, file)) _uploaded = uploaded;\n } on KerabieException catch (e) {\n showError(e.message);\n if (identical(_pending, file)) _pending = null;\n } catch (_) {\n showError('Could not upload attachment');\n if (identical(_pending, file)) _pending = null;\n } finally {\n if (identical(_pending, file) || _pending == null) _isUploading = false;\n _notify();\n }\n }\n\n void remove() {\n _pending = null;\n _uploaded = null;\n _isUploading = false;\n _notify();\n }\n\n /// Clears the staging area once its attachment has been handed to a message.\n void consume() => remove();\n\n void showError(String message) {\n _error = message;\n _errorTimer?.cancel();\n _errorTimer = Timer(errorDuration, dismissError);\n _notify();\n }\n\n void dismissError() {\n _errorTimer?.cancel();\n _error = null;\n _notify();\n }\n\n void _notify() {\n if (!_disposed) notifyListeners();\n }\n\n @override\n void dispose() {\n _disposed = true;\n _errorTimer?.cancel();\n super.dispose();\n }\n}\n\n\nimport 'dart:async';\n\nimport 'package:flutter/foundation.dart';\n\nimport 'api_client.dart';\nimport 'controller.dart';\n\nconst _day = Duration(hours: 24);\n\n/// A proactive message made in the dashboard (Campaigns).\n@immutable\nclass KerabieCampaign {\n const KerabieCampaign({\n required this.id,\n required this.name,\n required this.message,\n this.imageUrl,\n this.ctaLabel,\n this.ctaAction = 'open_chat',\n this.ctaUrl,\n this.trigger = 'page_load',\n this.triggerValue,\n this.pages,\n this.audience = 'all',\n this.frequency = 'once_per_visitor',\n });\n\n final int id;\n final String name;\n final String message;\n\n /// A cover shown full width on top of the popup.\n final String? imageUrl;\n\n /// The button's label. No button when empty.\n final String? ctaLabel;\n\n /// `open_chat`, `open_kb` or `open_link`.\n final String ctaAction;\n final String? ctaUrl;\n\n /// `page_load`, `time_on_page`, `scroll_depth` or `exit_intent`. The last two only exist on websites; the app skips them.\n final String trigger;\n\n /// Seconds in the app, for `time_on_page`.\n final String? triggerValue;\n\n /// Comma separated screen patterns with `*` wildcards. Empty means every screen.\n final String? pages;\n\n /// `all`, `new`, `returning`, `not_chatted` or `country` (the server already decided the country from the IP).\n final String audience;\n\n /// `once_per_visitor`, `once_per_day` or `every_session`.\n final String frequency;\n\n static KerabieCampaign? fromServer(Object? raw) {\n if (raw is! Map || raw['id'] is! num) return null;\n String? text(Object? v) => v is String && v.isNotEmpty ? v : null;\n return KerabieCampaign(\n id: (raw['id'] as num).toInt(),\n name: '${raw['name'] ?? ''}',\n message: '${raw['message'] ?? ''}',\n imageUrl: text(raw['imageUrl']),\n ctaLabel: text(raw['ctaLabel']),\n ctaAction: text(raw['ctaAction']) ?? 'open_chat',\n ctaUrl: text(raw['ctaUrl']),\n trigger: text(raw['trigger']) ?? 'page_load',\n triggerValue: raw['triggerValue']?.toString(),\n pages: text(raw['pages']),\n audience: text(raw['audience']) ?? 'all',\n frequency: text(raw['frequency']) ?? 'once_per_visitor',\n );\n }\n}\n\n/// Triggers that make sense inside an app.\nconst _nativeTriggers = ['page_load', 'time_on_page'];\n\nString _normalizePath(String s) {\n final withSlash = s.startsWith('/') ? s : '/$s';\n return (withSlash.length > 1 ? withSlash.replaceAll(RegExp(r'/+$'), '') : withSlash).toLowerCase();\n}\n\n/// Glob style match (`*` is anything) of the current screen path against a campaign's patterns.\nbool kerabieMatchesScreen(String? pages, String? screen) {\n final patterns = (pages ?? '').split(',').map((p) => p.trim()).where((p) => p.isNotEmpty).toList();\n if (patterns.isEmpty) return true;\n // The app did not say where the user is, so a campaign for certain screens cannot be placed.\n if (screen == null) return false;\n final path = _normalizePath(screen.split('?').first);\n return patterns.any((p) {\n final escaped = RegExp.escape(_normalizePath(p)).replaceAll(r'\\*', '.*');\n return RegExp('^$escaped\\$', caseSensitive: false).hasMatch(path);\n });\n}\n\n/// What the rules need to know about the visitor and the moment.\n@immutable\nclass KerabieEligibility {\n const KerabieEligibility({this.screen, required this.now, required this.returning, required this.hasChatted, this.lastShownAt, required this.shownThisLaunch});\n\n final String? screen;\n final DateTime now;\n\n /// The user had opened the app before this launch.\n final bool returning;\n final bool hasChatted;\n final DateTime? lastShownAt;\n\n /// Already shown since this app launch.\n final bool shownThisLaunch;\n}\n\nbool kerabieIsEligible(KerabieCampaign c, KerabieEligibility ctx) {\n if (!_nativeTriggers.contains(c.trigger)) return false;\n if (!kerabieMatchesScreen(c.pages, ctx.screen)) return false;\n\n if (c.audience == 'new' && ctx.returning) return false;\n if (c.audience == 'returning' && !ctx.returning) return false;\n if (c.audience == 'not_chatted' && ctx.hasChatted) return false;\n\n switch (c.frequency) {\n case 'once_per_visitor':\n return ctx.lastShownAt == null;\n case 'once_per_day':\n return ctx.lastShownAt == null || ctx.now.difference(ctx.lastShownAt!) >= _day;\n default:\n return !ctx.shownThisLaunch;\n }\n}\n\n/// Proactive campaigns inside the app. Queues each live campaign when its trigger fires, either a screen appearing\n/// (`page_load`) or N seconds in the app (`time_on_page`), and reports what the visitor does with it. Nothing pops up\n/// while the chat itself is open.\nclass KerabieCampaigns extends ChangeNotifier {\n KerabieCampaigns(\n this.controller, {\n this.pageLoadDelay = const Duration(milliseconds: 1500),\n this.autoHide = const Duration(seconds: 12),\n DateTime Function()? now,\n }) : _now = now ?? DateTime.now {\n controller.addListener(_onController);\n controller.onVisitorMessage = noteReply;\n _load();\n }\n\n final KerabieController controller;\n\n /// Lets the chat history load so \"has not chatted yet\" is accurate.\n final Duration pageLoadDelay;\n final Duration autoHide;\n final DateTime Function() _now;\n\n static const _seenKey = 'campaign_seen';\n static const _visitedKey = 'has_visited';\n\n List<KerabieCampaign> _campaigns = const [];\n Map<String, int> _seen = {};\n bool _returning = false;\n bool _ready = false;\n bool _disposed = false;\n final _queued = <int>{}; // once per app launch\n final _reported = <int>{};\n final _queue = <KerabieCampaign>[];\n final _timers = <Timer>[];\n Timer? _pageLoadTimer;\n Timer? _hideTimer;\n int? _showingId;\n int? _engagedId;\n String? _lastScreen;\n bool _lastOpen = false;\n final DateTime _startedAt = DateTime.now();\n\n /// The campaign on screen, or null.\n KerabieCampaign? get current {\n if (_queue.isEmpty || controller.isOpen) return null;\n return _queue.first;\n }\n\n Future<void> _load() async {\n final api = await controller.waitForApi();\n final list = await _fetch(api);\n final seen = await controller.storage.readForever<Map<String, dynamic>>(_seenKey) ?? {};\n final before = await controller.storage.readForever<Object>(_visitedKey);\n if (before == null) await controller.storage.writeForever(_visitedKey, 1);\n if (_disposed) return;\n _campaigns = list;\n _seen = {for (final e in seen.entries) e.key: (e.value as num).toInt()};\n _returning = before != null;\n _ready = true;\n _lastScreen = controller.screen;\n\n // Time in app: one timer per campaign, measured from when the provider mounted.\n for (final c in _campaigns.where((c) => c.trigger == 'time_on_page')) {\n final seconds = int.tryParse(c.triggerValue ?? '') ?? 10;\n final wait = Duration(seconds: seconds) - DateTime.now().difference(_startedAt);\n _timers.add(Timer(wait.isNegative ? Duration.zero : wait, () => _fire(c)));\n }\n _schedulePageLoad();\n }\n\n Future<List<KerabieCampaign>> _fetch(KerabieApi api) async {\n try {\n final res = await api.get('/public/campaigns', query: {'widgetKey': controller.config.widgetKey});\n final raw = res['campaigns'];\n return raw is List ? raw.map(KerabieCampaign.fromServer).whereType<KerabieCampaign>().toList() : const [];\n } on KerabieException {\n return const [];\n }\n }\n\n void _onController() {\n if (!_ready) return;\n if (controller.screen != _lastScreen) {\n _lastScreen = controller.screen;\n _schedulePageLoad();\n }\n if (controller.isOpen != _lastOpen) {\n _lastOpen = controller.isOpen;\n _syncShowing();\n }\n }\n\n /// A screen appearing: each time the screen changes, campaigns for it get their chance.\n void _schedulePageLoad() {\n _pageLoadTimer?.cancel();\n _pageLoadTimer = Timer(pageLoadDelay, () {\n for (final c in _campaigns.where((c) => c.trigger == 'page_load')) {\n _fire(c);\n }\n });\n }\n\n void _fire(KerabieCampaign c) {\n if (_disposed || _queued.contains(c.id) || controller.isOpen) return;\n final ok = kerabieIsEligible(\n c,\n KerabieEligibility(\n screen: controller.screen,\n now: _now(),\n returning: _returning,\n hasChatted: controller.hasChatted,\n lastShownAt: _seen['${c.id}'] == null ? null : DateTime.fromMillisecondsSinceEpoch(_seen['${c.id}']!),\n shownThisLaunch: _queued.contains(c.id),\n ),\n );\n if (!ok) return;\n _queued.add(c.id);\n _queue.add(c);\n _syncShowing();\n notifyListeners();\n }\n\n /// Reports and arms the auto hide for the head of the queue once it is on screen.\n void _syncShowing() {\n final c = current;\n if (c == null) {\n _hideTimer?.cancel();\n _showingId = null;\n notifyListeners();\n return;\n }\n if (_showingId == c.id) return;\n _showingId = c.id;\n if (_reported.add(c.id)) {\n _report(c.id, 'shown');\n _seen = {..._seen, '${c.id}': _now().millisecondsSinceEpoch};\n controller.storage.writeForever(_seenKey, _seen);\n }\n _hideTimer?.cancel();\n _hideTimer = Timer(autoHide, () => _remove(c.id));\n notifyListeners();\n }\n\n void _remove(int id) {\n _queue.removeWhere((c) => c.id == id);\n if (_showingId == id) _showingId = null;\n _syncShowing();\n notifyListeners();\n }\n\n /// Fire and forget: a failed report must never get in the user's way.\n void _report(int id, String event) {\n controller.waitForApi().then((api) => api.post('/public/campaigns/$id/events', {'widgetKey': controller.config.widgetKey, 'event': event})).then((_) {}, onError: (_) {});\n }\n\n void dismiss() {\n final c = current;\n if (c != null) _remove(c.id);\n }\n\n /// The visitor tapped the message or its button: that is \"opened\", and a reply if they write back.\n void engage({required bool withClick}) {\n final c = current;\n if (c == null) return;\n _report(c.id, 'opened');\n if (withClick) _report(c.id, 'clicked');\n _engagedId = c.id;\n dismiss();\n }\n\n /// The first message after engaging with a campaign counts as its reply.\n void noteReply() {\n final id = _engagedId;\n if (id == null) return;\n _engagedId = null;\n _report(id, 'replied');\n }\n\n @override\n void dispose() {\n _disposed = true;\n controller.removeListener(_onController);\n if (controller.onVisitorMessage == noteReply) controller.onVisitorMessage = null;\n for (final t in _timers) {\n t.cancel();\n }\n _pageLoadTimer?.cancel();\n _hideTimer?.cancel();\n super.dispose();\n }\n}\n\n\nimport 'dart:async';\n\nimport 'package:flutter/foundation.dart';\n\nimport 'api_client.dart';\nimport 'controller.dart';\n\n/// One published article of the organisation's knowledge base.\n@immutable\nclass KerabieArticle {\n const KerabieArticle({required this.id, required this.title, this.content = '', this.imageUrl});\n\n final int id;\n final String title;\n\n /// HTML.\n final String content;\n final String? imageUrl;\n\n static KerabieArticle? fromServer(Object? raw) {\n if (raw is! Map || raw['id'] is! num) return null;\n return KerabieArticle(\n id: (raw['id'] as num).toInt(),\n title: '${raw['title'] ?? ''}',\n content: '${raw['content'] ?? ''}',\n imageUrl: raw['imageUrl'] is String && (raw['imageUrl'] as String).isNotEmpty ? raw['imageUrl'] as String : null,\n );\n }\n}\n\n/// The start of an article's text for a card: tags dropped, entities decoded, whitespace collapsed.\nString kerabieArticleExcerpt(String html, {int max = 140}) {\n var text = html.replaceAll(RegExp(r'<(script|style)[^>]*>[\\s\\S]*?</\\1>', caseSensitive: false), ' ').replaceAll(RegExp(r'<[^>]+>'), ' ');\n // Twice: crawled and pasted content is often encoded twice (&amp;nbsp;).\n for (var i = 0; i < 2; i++) {\n text = text\n .replaceAll(RegExp(r'&nbsp;', caseSensitive: false), ' ')\n .replaceAll(RegExp(r'&lt;', caseSensitive: false), '<')\n .replaceAll(RegExp(r'&gt;', caseSensitive: false), '>')\n .replaceAll(RegExp(r'&quot;', caseSensitive: false), '\"')\n .replaceAll(RegExp(r'&(#39|apos);', caseSensitive: false), \"'\")\n .replaceAll(RegExp(r'&amp;', caseSensitive: false), '&');\n }\n text = text.replaceAll(RegExp(r'[\\s\\u00A0]+'), ' ').trim();\n return text.length > max ? '${text.substring(0, max).trimRight()}...' : text;\n}\n\n/// The searchable knowledge base: the published articles of this widget's organisation, with a debounced search.\n/// Articles are remembered on the device, so the list shows at once next time and refreshes behind it.\nclass KerabieKnowledgeBase extends ChangeNotifier {\n KerabieKnowledgeBase(this._controller, {this.searchDelay = const Duration(milliseconds: 400)}) {\n _start();\n }\n\n final KerabieController _controller;\n final Duration searchDelay;\n\n List<KerabieArticle> _articles = const [];\n // True until the first answer (or remembered content) is in, so the screen never flashes \"No articles yet.\"\n bool _loading = true;\n bool _gotFresh = false;\n bool _disposed = false;\n Timer? _timer;\n\n List<KerabieArticle> get articles => _articles;\n bool get loading => _loading;\n\n String get _cacheKey => 'cache:kb:${_controller.config.widgetKey}';\n\n Future<void> _start() async {\n // Remembered articles paint at once; the request below refreshes them.\n final remembered = await _controller.storage.read<List<dynamic>>(_cacheKey);\n if (remembered != null && !_gotFresh && !_disposed) {\n _articles = remembered.map(KerabieArticle.fromServer).whereType<KerabieArticle>().toList();\n _loading = false;\n _notify();\n }\n await refetch();\n }\n\n Future<void> refetch([String? search]) async {\n final query = search?.trim() ?? '';\n // A search replaces the list so it shows the skeleton; refreshing the default list behind remembered articles does not.\n if (query.isNotEmpty || _articles.isEmpty) {\n _loading = true;\n _notify();\n }\n try {\n final api = await _controller.waitForApi();\n final res = await api.get('/public/kb/articles', query: query.isEmpty ? null : {'search': query});\n final raw = res['data'] is List ? res['data'] as List : const [];\n _articles = raw.map(KerabieArticle.fromServer).whereType<KerabieArticle>().toList();\n if (query.isEmpty) {\n _gotFresh = true;\n unawaited(_controller.storage.write(_cacheKey, raw));\n }\n } on KerabieException {\n // Keep the articles we have.\n } finally {\n _loading = false;\n _notify();\n }\n }\n\n /// Searches after the visitor stops typing.\n void search(String query) {\n _timer?.cancel();\n _timer = Timer(searchDelay, () => refetch(query));\n }\n\n Future<KerabieArticle?> getArticle(int id) => fetchArticle(_controller, id);\n\n /// One article with its full body, or null when it cannot be loaded.\n static Future<KerabieArticle?> fetchArticle(KerabieController controller, int id) async {\n try {\n final api = await controller.waitForApi();\n final res = await api.get('/public/kb/articles/$id');\n return KerabieArticle.fromServer(res['article']);\n } on KerabieException {\n return null;\n }\n }\n\n void _notify() {\n if (!_disposed) notifyListeners();\n }\n\n @override\n void dispose() {\n _disposed = true;\n _timer?.cancel();\n super.dispose();\n }\n}\n\n/// Records \"was this helpful?\" for an article. Asked once per article, remembered on the device.\nclass KerabieArticleFeedback {\n KerabieArticleFeedback(this._controller);\n\n final KerabieController _controller;\n\n String _key(int id) => 'kb-vote:$id';\n\n /// The earlier vote, or null if the visitor has not voted.\n Future<bool?> previousVote(int articleId) => _controller.storage.read<bool>(_key(articleId));\n\n /// Sends a vote. Returns true when it counts as done: sent, or the server says this visitor voted plenty already.\n Future<bool> vote(int articleId, {required bool helpful}) async {\n try {\n final api = await _controller.waitForApi();\n await api.post('/public/kb/articles/$articleId/feedback', {'helpful': helpful});\n await _controller.storage.write(_key(articleId), helpful);\n return true;\n } on KerabieException catch (e) {\n // Too many votes today (429) counts as done. Anything else lets the visitor try again.\n return e.status == 429 || e.message.toLowerCase().contains('already');\n }\n }\n}\n\n\nimport 'package:flutter/foundation.dart';\n\nimport 'api_client.dart';\nimport 'controller.dart';\n\nenum KerabieTicketPriority { low, normal, high, urgent }\n\n/// `WAITING`, `ACTIVE`, `RESOLVED` or `CLOSED`.\n@immutable\nclass KerabieTicket {\n const KerabieTicket({required this.id, this.subject, required this.status, required this.priority, this.slaDueAt, required this.createdAt});\n\n final int id;\n final String? subject;\n final String status;\n final KerabieTicketPriority priority;\n final DateTime? slaDueAt;\n final DateTime createdAt;\n\n bool get isClosed => status == 'CLOSED';\n\n static KerabieTicket? fromServer(Object? raw) {\n if (raw is! Map || raw['id'] is! num) return null;\n return KerabieTicket(\n id: (raw['id'] as num).toInt(),\n subject: raw['subject'] as String?,\n status: '${raw['status'] ?? 'WAITING'}',\n priority: switch ('${raw['priority']}'.toUpperCase()) {\n 'LOW' => KerabieTicketPriority.low,\n 'HIGH' => KerabieTicketPriority.high,\n 'URGENT' => KerabieTicketPriority.urgent,\n _ => KerabieTicketPriority.normal,\n },\n slaDueAt: DateTime.tryParse('${raw['slaDueAt']}'),\n createdAt: DateTime.tryParse('${raw['createdAt']}') ?? DateTime.now(),\n );\n }\n}\n\n@immutable\nclass KerabieTicketMessage {\n const KerabieTicketMessage({required this.id, required this.body, required this.fromCustomer, this.senderName, required this.createdAt});\n\n final String id;\n final String body;\n final bool fromCustomer;\n final String? senderName;\n final DateTime createdAt;\n\n static KerabieTicketMessage? fromServer(Object? raw) {\n if (raw is! Map) return null;\n return KerabieTicketMessage(\n id: '${raw['id']}',\n body: '${raw['body'] ?? ''}',\n fromCustomer: raw['senderRole'] == 'customer',\n senderName: raw['senderName'] as String?,\n createdAt: DateTime.tryParse('${raw['createdAt']}') ?? DateTime.now(),\n );\n }\n}\n\n/// A ticket and its messages.\n@immutable\nclass KerabieTicketThread {\n const KerabieTicketThread(this.ticket, this.messages);\n\n final KerabieTicket ticket;\n final List<KerabieTicketMessage> messages;\n}\n\n/// The visitor's tickets: list, open one, create, reply. Which tickets belong to this device rides on the session id\n/// every request already carries.\nclass KerabieTickets extends ChangeNotifier {\n KerabieTickets(this._controller) {\n fetch();\n }\n\n final KerabieController _controller;\n\n List<KerabieTicket> _tickets = const [];\n // True until the first answer, so \"No tickets yet.\" never flashes before the skeleton.\n bool _loading = true;\n bool _disposed = false;\n\n List<KerabieTicket> get tickets => _tickets;\n bool get loading => _loading;\n\n Future<void> fetch() async {\n _loading = true;\n _notify();\n try {\n final api = await _controller.waitForApi();\n final res = await api.get('/public/tickets');\n final raw = res['data'] is List ? res['data'] as List : const [];\n _tickets = raw.map(KerabieTicket.fromServer).whereType<KerabieTicket>().toList();\n } on KerabieException {\n // Keep the list we have.\n } finally {\n _loading = false;\n _notify();\n }\n }\n\n Future<KerabieTicketThread?> getTicket(int id) async {\n try {\n final api = await _controller.waitForApi();\n final res = await api.get('/public/tickets/$id');\n final ticket = KerabieTicket.fromServer(res['ticket']);\n if (ticket == null) return null;\n final raw = res['messages'] is List ? res['messages'] as List : const [];\n return KerabieTicketThread(ticket, raw.map(KerabieTicketMessage.fromServer).whereType<KerabieTicketMessage>().toList());\n } on KerabieException {\n return null;\n }\n }\n\n /// Returns the new ticket's id, or null when it could not be created.\n Future<int?> createTicket({\n required String subject,\n required String message,\n KerabieTicketPriority priority = KerabieTicketPriority.normal,\n String? name,\n String? email,\n }) async {\n try {\n final api = await _controller.waitForApi();\n final res = await api.post('/public/tickets', {\n 'subject': subject,\n 'message': message,\n 'priority': priority.name.toUpperCase(),\n if (name != null) 'name': name,\n if (email != null) 'email': email,\n });\n fetch();\n return (res['ticketId'] as num?)?.toInt();\n } on KerabieException {\n return null;\n }\n }\n\n Future<bool> reply(int id, String body) async {\n try {\n final api = await _controller.waitForApi();\n await api.post('/public/tickets/$id/reply', {'body': body});\n return true;\n } on KerabieException {\n return false;\n }\n }\n\n void _notify() {\n if (!_disposed) notifyListeners();\n }\n\n @override\n void dispose() {\n _disposed = true;\n super.dispose();\n }\n}\n\n\nimport 'package:flutter/foundation.dart';\nimport 'package:intl/intl.dart';\n\nimport 'api_client.dart';\nimport 'controller.dart';\n\n@immutable\nclass KerabieProduct {\n const KerabieProduct({required this.id, required this.name, required this.price, required this.currency, this.imageUrl, this.description});\n\n final int id;\n final String name;\n final double price;\n final String currency;\n final String? imageUrl;\n final String? description;\n\n static KerabieProduct? fromServer(Object? raw) {\n if (raw is! Map || raw['id'] is! num) return null;\n return KerabieProduct(\n id: (raw['id'] as num).toInt(),\n name: '${raw['name'] ?? ''}',\n price: (raw['price'] as num?)?.toDouble() ?? 0,\n currency: '${raw['currency'] ?? ''}',\n imageUrl: raw['imageUrl'] is String && (raw['imageUrl'] as String).isNotEmpty ? raw['imageUrl'] as String : null,\n description: raw['description'] is String && (raw['description'] as String).trim().isNotEmpty ? raw['description'] as String : null,\n );\n }\n\n /// The price in the visitor's number format, for example `$12.50`.\n String get formattedPrice {\n try {\n return NumberFormat.simpleCurrency(name: currency).format(price);\n } catch (_) {\n return '$currency $price';\n }\n }\n}\n\n/// The organisation's storefront, read only: products and where to buy them.\nclass KerabieStore extends ChangeNotifier {\n KerabieStore(this._controller) {\n refetch();\n }\n\n final KerabieController _controller;\n\n List<KerabieProduct> _products = const [];\n String? _name;\n String? _url;\n bool _loading = true;\n bool _disposed = false;\n\n List<KerabieProduct> get products => _products;\n String? get storeName => _name;\n\n /// Public storefronts live at the organisation's subdomain: `https://<slug>.kerabie.com`.\n String? get storeUrl => _url;\n bool get loading => _loading;\n\n Future<void> refetch() async {\n _loading = true;\n _notify();\n try {\n final api = await _controller.waitForApi();\n final res = await api.get('/public/store/by-widget');\n final raw = res['products'] is List ? res['products'] as List : const [];\n _products = raw.map(KerabieProduct.fromServer).whereType<KerabieProduct>().toList();\n final store = res['store'];\n _name = store is Map ? store['name'] as String? : null;\n final slug = store is Map ? store['slug'] : null;\n _url = slug is String && slug.isNotEmpty ? 'https://$slug.kerabie.com' : null;\n } on KerabieException {\n // Keep the products we have.\n } finally {\n _loading = false;\n _notify();\n }\n }\n\n void _notify() {\n if (!_disposed) notifyListeners();\n }\n\n @override\n void dispose() {\n _disposed = true;\n super.dispose();\n }\n}\n\n\nimport 'config.dart';\nimport 'controller.dart';\nimport 'models.dart';\n\n/// The built-in name and email form, used when the organisation has not set its own questions.\nconst kerabieDefaultPreChatFields = [\n KerabieEnrichmentField(key: 'name', label: 'Your name', placeholder: 'Your name'),\n KerabieEnrichmentField(key: 'email', label: 'Email address', type: 'email', placeholder: 'Email address'),\n];\n\n/// The pre-chat questions and what is known about the visitor. The chat and a call ask the same form, so a visitor\n/// cannot skip it by calling instead.\nextension KerabiePreChat on KerabieController {\n /// The organisation's own questions when it set up a pre-chat form, otherwise the built-in name and email.\n List<KerabieEnrichmentField> get preChatFields {\n final custom = chat.customerEnrichmentEnabled == true ? chat.customerEnrichmentForm ?? const <KerabieEnrichmentField>[] : const <KerabieEnrichmentField>[];\n return custom.isNotEmpty ? custom : kerabieDefaultPreChatFields;\n }\n\n bool get hasAnsweredPreChat {\n final user = currentUser;\n return (user?.name?.isNotEmpty ?? false) || (user?.email?.isNotEmpty ?? false) || (user?.customAttributes.isNotEmpty ?? false);\n }\n\n /// The organisation asks for details before a chat, and this visitor has not given any yet.\n bool get needsPreChatDetails => chat.customerEnrichmentEnabled == true && !hasAnsweredPreChat;\n\n void submitPreChat(Map<String, String> answers) {\n // name, email and phone are the contact's own fields; every other answer is a custom attribute.\n var user = currentUser ?? const KerabieUser();\n final custom = {...user.customAttributes};\n for (final f in preChatFields) {\n final v = (answers[f.key] ?? '').trim();\n if (v.isEmpty) continue;\n switch (f.key) {\n case 'name':\n user = user.copyWith(name: v);\n case 'email':\n user = user.copyWith(email: v);\n case 'phone':\n user = user.copyWith(phone: v);\n default:\n custom[f.key] = v;\n }\n }\n setUser(user.copyWith(customAttributes: custom));\n }\n}\n" } };
738
+
739
+ // src/knowledge.ts
740
+ var KNOWLEDGE = knowledge_default;
741
+ var DOCS = KNOWLEDGE.docs;
742
+ var ENDPOINTS = KNOWLEDGE.endpoints;
743
+ var TYPE_SOURCES = KNOWLEDGE.types;
744
+ var tokens = (s) => s.toLowerCase().split(/[^a-z0-9_]+/).filter((t) => t.length > 1);
745
+ function searchDocs(query, opts = {}, docs = DOCS) {
746
+ const words = [...new Set(tokens(query))];
747
+ if (!words.length) return [];
748
+ const hits = [];
749
+ for (const d of docs) {
750
+ if (opts.section && d.section !== opts.section) continue;
751
+ const title = `${d.title} ${d.id}`.toLowerCase();
752
+ const heads = d.headings.map((h) => h.text).join(" ").toLowerCase();
753
+ const body = d.body.toLowerCase();
754
+ let score = 0;
755
+ for (const w of words) {
756
+ if (title.includes(w)) score += 10;
757
+ if (heads.includes(w)) score += 4;
758
+ const n = body.split(w).length - 1;
759
+ if (n) score += Math.min(n, 5);
760
+ }
761
+ if (!score) continue;
762
+ hits.push({ id: d.id, title: d.title, section: d.section, url: d.url, snippet: snippetOf(d.body, words), score });
763
+ }
764
+ return hits.sort((a, b) => b.score - a.score).slice(0, Math.min(opts.limit ?? 5, 10));
765
+ }
766
+ function snippetOf(body, words) {
767
+ const lower = body.toLowerCase();
768
+ const at = words.map((w) => lower.indexOf(w)).filter((i) => i >= 0).sort((a, b) => a - b)[0] ?? 0;
769
+ const start = Math.max(0, at - 80);
770
+ return (start > 0 ? "\u2026" : "") + body.slice(start, start + 260).replace(/\s+/g, " ").trim() + (start + 260 < body.length ? "\u2026" : "");
771
+ }
772
+ function getDocText(doc, heading) {
773
+ if (!heading) return doc.body;
774
+ const lines = doc.body.split("\n");
775
+ const want = heading.trim().toLowerCase();
776
+ let fence = false;
777
+ let start = -1;
778
+ let level = 0;
779
+ for (let i = 0; i < lines.length; i++) {
780
+ if (lines[i].startsWith("```")) fence = !fence;
781
+ const m = !fence && lines[i].match(/^(#{2,3})\s+(.+)$/);
782
+ if (!m) continue;
783
+ if (start < 0 && m[2].trim().toLowerCase() === want) {
784
+ start = i;
785
+ level = m[1].length;
786
+ continue;
787
+ }
788
+ if (start >= 0 && m[1].length <= level) return lines.slice(start, i).join("\n").trim();
789
+ }
790
+ return start >= 0 ? lines.slice(start).join("\n").trim() : null;
791
+ }
792
+ function findDoc(id2, docs = DOCS) {
793
+ const clean = id2.trim().replace(/^https?:\/\/[^/]+\/docs\/?/, "").replace(/^\/+|\/+$/g, "").replace(/\.mdx?$/, "") || "index";
794
+ return docs.find((d) => d.id === clean);
795
+ }
796
+ function searchEndpoints(query, endpoints = ENDPOINTS) {
797
+ const words = tokens(query ?? "");
798
+ if (!words.length) return endpoints;
799
+ return endpoints.filter((e) => {
800
+ const hay = `${e.method} ${e.path} ${e.title} ${e.doc} ${e.sdk}`.toLowerCase();
801
+ return words.every((w) => hay.includes(w));
802
+ });
803
+ }
804
+ var DECL = /(?:export\s+)?(?:declare\s+)?(?:interface|type|class|enum|const|function)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
805
+ function listDeclarations(source) {
806
+ const names = /* @__PURE__ */ new Set();
807
+ for (const m of source.matchAll(DECL)) names.add(m[1]);
808
+ return [...names].sort();
809
+ }
810
+ function extractDeclaration(source, name) {
811
+ const re = new RegExp(`(?:export\\s+)?(?:declare\\s+)?(?:interface|type|class|enum)\\s+${name}\\b`);
812
+ const m = re.exec(source);
813
+ if (!m) return null;
814
+ let start = m.index;
815
+ const before = source.slice(0, start).trimEnd();
816
+ if (before.endsWith("*/")) {
817
+ const c = before.lastIndexOf("/**");
818
+ if (c >= 0) start = c;
819
+ }
820
+ let depth = 0;
821
+ let i = m.index;
822
+ let seenBrace = false;
823
+ for (; i < source.length; i++) {
824
+ const ch = source[i];
825
+ if (ch === "{") {
826
+ depth++;
827
+ seenBrace = true;
828
+ } else if (ch === "}") {
829
+ depth--;
830
+ if (seenBrace && depth === 0) {
831
+ i++;
832
+ if (source[i] === ";") i++;
833
+ break;
834
+ }
835
+ } else if (ch === ";" && depth === 0) {
836
+ i++;
837
+ break;
838
+ } else if (ch === "\n" && depth === 0 && seenBrace === false && /^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type|class|enum|const|function)\s/.test(source.slice(i + 1, i + 60)) && i > m.index) break;
839
+ }
840
+ return source.slice(start, i).trim();
841
+ }
842
+ var SDK_GUIDES = {
843
+ typescript: ["sdks/typescript", "readme/typescript-sdk", "api-reference/authentication"],
844
+ "react-native": ["sdks/react-native", "readme/react-native-sdk"],
845
+ flutter: ["sdks/flutter", "readme/flutter-sdk"],
846
+ wordpress: ["sdks/wordpress"],
847
+ "web-widget": ["widget/overview", "widget/install-html", "widget/javascript-api", "widget/customization", "readme/chat-widget"],
848
+ "rest-api": ["api-reference/overview", "api-reference/authentication"],
849
+ mcp: ["sdks/mcp", "ai/mcp-servers"]
850
+ };
851
+
852
+ // src/dev-tools.ts
853
+ import { z as z2 } from "zod";
854
+ var MAX_DOC_CHARS = 4e4;
855
+ var cut = (text) => text.length > MAX_DOC_CHARS ? `${text.slice(0, MAX_DOC_CHARS)}
856
+
857
+ \u2026 (cut off. Ask for one section with the heading option.)` : text;
858
+ var DEV_TOOLS = [
859
+ {
860
+ name: "search_docs",
861
+ title: "Search the Kerabie docs",
862
+ local: true,
863
+ description: "Search the Kerabie documentation, SDK readmes and API reference. Use this first when asked how to do something with Kerabie. Returns the best matching pages with a snippet; read one with get_doc.",
864
+ input: { query: z2.string().min(2).describe('What you are looking for, e.g. "react native push notifications"'), section: z2.string().optional().describe("Limit to one section, e.g. api-reference, sdks, widget, ai"), limit: z2.number().int().min(1).max(10).optional() },
865
+ run: async (_c, a) => {
866
+ const hits = searchDocs(a.query, { section: a.section, limit: a.limit });
867
+ return hits.length ? hits.map(({ score: _s, ...h }) => h) : { results: [], hint: "Nothing matched. Try fewer or different words, or list_docs." };
868
+ }
869
+ },
870
+ {
871
+ name: "get_doc",
872
+ title: "Read a docs page",
873
+ local: true,
874
+ description: 'The full text of one docs page (or one section of it). Ids come from search_docs and list_docs, e.g. "api-reference/campaigns", "sdks/react-native" or "readme/react-native-sdk".',
875
+ input: { id: z2.string().min(1).describe("Page id or docs URL"), heading: z2.string().optional().describe("Return only the section under this heading") },
876
+ run: async (_c, a) => {
877
+ const d = findDoc(a.id);
878
+ if (!d) throw new Error(`No docs page "${a.id}". Use search_docs or list_docs to find the id.`);
879
+ const text = getDocText(d, a.heading);
880
+ if (text == null) throw new Error(`"${d.id}" has no section "${a.heading}". Sections: ${d.headings.map((h) => h.text).join(" | ")}`);
881
+ return cut(`# ${d.title}
882
+ ${d.url ? `Source: ${d.url}
883
+ ` : ""}
884
+ ${text}`);
885
+ }
886
+ },
887
+ {
888
+ name: "list_docs",
889
+ title: "List the docs pages",
890
+ local: true,
891
+ description: "Every docs page with its id and description, optionally for one section (api-reference, sdks, widget, ai, conversations, \u2026).",
892
+ input: { section: z2.string().optional() },
893
+ run: async (_c, a) => DOCS.filter((d) => !a.section || d.section === a.section).map((d) => ({ id: d.id, title: d.title, description: d.description }))
894
+ },
895
+ {
896
+ name: "list_api_endpoints",
897
+ title: "List API endpoints",
898
+ local: true,
899
+ description: 'The public REST API endpoints (method, path, what it does, the docs page, and the equivalent TypeScript SDK call). Filter with words, e.g. "campaigns" or "POST tickets". Auth is a Bearer API key; see get_integration_guide rest-api.',
900
+ input: { query: z2.string().optional() },
901
+ run: async (_c, a) => searchEndpoints(a.query)
902
+ },
903
+ {
904
+ name: "get_sdk_types",
905
+ title: "Get SDK type definitions",
906
+ local: true,
907
+ description: 'Type declarations from the Kerabie SDKs: TypeScript for typescript and react-native, Dart classes for flutter. Without names, lists what is declared. With names (e.g. ["Campaign", "CreateCampaignParams"] or, for flutter, ["KerabieConfig", "KerabieController"]), returns those declarations. Use before writing code against an SDK.',
908
+ input: { sdk: z2.enum(["typescript", "react-native", "flutter"]), names: z2.array(z2.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)).max(20).optional() },
909
+ run: async (_c, a) => {
910
+ const src = TYPE_SOURCES[a.sdk];
911
+ if (!src) throw new Error(`No type definitions bundled for ${a.sdk}.`);
912
+ if (!a.names?.length) return { sdk: a.sdk, declarations: listDeclarations(src) };
913
+ const found = [];
914
+ const missing = [];
915
+ for (const n of a.names) {
916
+ const d = extractDeclaration(src, n);
917
+ (d ? found : missing).push(d ?? n);
918
+ }
919
+ return cut(found.join("\n\n") + (missing.length ? `
920
+
921
+ // Not found: ${missing.join(", ")}. Call without names to list declarations.` : ""));
922
+ }
923
+ },
924
+ {
925
+ name: "get_integration_guide",
926
+ title: "Get an integration guide",
927
+ local: true,
928
+ description: "The pages to follow to integrate Kerabie on one platform: install, configure and use. platform is typescript (the @kerabie/sdk package), react-native (live chat in a React Native or Expo app), flutter (live chat in a Flutter app, the kerabie_helpdesk_flutter package), wordpress, web-widget (the website chat widget), rest-api (the HTTP API) or mcp.",
929
+ input: { platform: z2.enum(["typescript", "react-native", "flutter", "wordpress", "web-widget", "rest-api", "mcp"]) },
930
+ run: async (_c, a) => {
931
+ const ids = SDK_GUIDES[a.platform] ?? [];
932
+ const pages = ids.map((id2) => findDoc(id2)).filter((d) => !!d);
933
+ if (!pages.length) throw new Error(`No guide for ${a.platform}.`);
934
+ return cut(pages.map((d) => `# ${d.title}
935
+ ${d.url ? `Source: ${d.url}
936
+ ` : ""}
937
+ ${d.body}`).join("\n\n---\n\n"));
938
+ }
939
+ }
940
+ ];
941
+
942
+ // src/server.ts
943
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
944
+ import { HttpError } from "@kerabie/sdk";
945
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
946
+ var MAX_RESULT_CHARS = 6e4;
947
+ var ALL_TOOLS = [...DEV_TOOLS, ...TOOLS];
948
+ var toolsFor = (readOnly, hasClient = true) => ALL_TOOLS.filter((t) => (hasClient || t.local) && !(readOnly && t.write));
949
+ function formatResult(value) {
950
+ const text = typeof value === "string" ? value : JSON.stringify(value ?? { success: true }, null, 2);
951
+ return text.length > MAX_RESULT_CHARS ? `${text.slice(0, MAX_RESULT_CHARS)}
952
+ \u2026 (cut off: ${text.length.toLocaleString("en-US")} characters. Use page/limit or filters to narrow the result.)` : text;
953
+ }
954
+ function formatError(e) {
955
+ if (e instanceof HttpError) {
956
+ const hint = e.status === 401 ? " Check your API key (KERABIE_API_KEY, or the Authorization header)." : e.status === 403 ? " The API key or your plan does not allow this." : e.status === 429 ? " Slow down and try again later." : "";
957
+ return `Kerabie API error ${e.status}: ${e.message}.${hint}`;
958
+ }
959
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
960
+ }
961
+ function createServer(client, opts = {}) {
962
+ const server = new McpServer({ name: "kerabie", version: opts.version ?? "1.0.0" });
963
+ for (const t of toolsFor(opts.readOnly ?? false, !!client)) {
964
+ server.registerTool(
965
+ t.name,
966
+ {
967
+ title: t.title,
968
+ description: t.description,
969
+ inputSchema: t.input,
970
+ annotations: { readOnlyHint: !t.write, destructiveHint: !!t.destructive, openWorldHint: t.name === "test_ai_tool" }
971
+ },
972
+ async (args) => {
973
+ try {
974
+ return { content: [{ type: "text", text: formatResult(await t.run(client, args)) }] };
975
+ } catch (e) {
976
+ return { isError: true, content: [{ type: "text", text: formatError(e) }] };
977
+ }
978
+ }
979
+ );
980
+ }
981
+ registerDocs(server);
982
+ return server;
983
+ }
984
+ var GUIDE_PROMPTS = [
985
+ {
986
+ name: "integrate_react_native",
987
+ title: "Add Kerabie chat to a React Native app",
988
+ description: "Walks an assistant through adding live chat to a React Native or Expo app.",
989
+ platform: "react-native",
990
+ task: 'Add Kerabie live chat to this React Native app. Call get_integration_guide with platform "react-native", then get_sdk_types for the types you use. Install the package and its peer dependencies, wrap the app in the provider, add the chat entry point, and show me what you changed. Do not invent props: use only what the guide and types define.'
991
+ },
992
+ {
993
+ name: "integrate_flutter",
994
+ title: "Add Kerabie chat to a Flutter app",
995
+ description: "Walks an assistant through adding live chat to a Flutter app.",
996
+ platform: "flutter",
997
+ task: 'Add Kerabie live chat to this Flutter app. Call get_integration_guide with platform "flutter", then get_sdk_types with sdk "flutter" for the classes you use. Run flutter pub add kerabie_helpdesk_flutter, wrap the app in KerabieProvider above MaterialApp with the same navigator key, add KerabieLauncher, and show me what you changed. Do not invent members: use only what the guide and types define. Ask me for the widget key; do not guess it. If the app already uses a push service, register its token with registerPushToken and pass tapped notification data to handleNotificationData; use one push provider only.'
998
+ },
999
+ {
1000
+ name: "integrate_typescript_sdk",
1001
+ title: "Use @kerabie/sdk in this project",
1002
+ description: "Walks an assistant through calling the Kerabie API from TypeScript.",
1003
+ platform: "typescript",
1004
+ task: 'Integrate the Kerabie TypeScript SDK in this project. Call get_integration_guide with platform "typescript", then list_api_endpoints and get_sdk_types for what you need. Keep the API key in an environment variable, never in code. Handle HttpError by status (401, 403 plan limit, 404, 429).'
1005
+ },
1006
+ {
1007
+ name: "integrate_rest_api",
1008
+ title: "Call the Kerabie REST API",
1009
+ description: "Walks an assistant through using the HTTP API directly.",
1010
+ platform: "rest-api",
1011
+ task: 'Call the Kerabie REST API from this project without the SDK. Call get_integration_guide with platform "rest-api", then list_api_endpoints for the routes you need and get_doc for their request and response shapes. Send the API key as a Bearer token from an environment variable.'
1012
+ },
1013
+ {
1014
+ name: "install_web_widget",
1015
+ title: "Install the Kerabie chat widget on a website",
1016
+ description: "Walks an assistant through adding the chat widget to a website.",
1017
+ platform: "web-widget",
1018
+ task: 'Install the Kerabie chat widget on this website. Call get_integration_guide with platform "web-widget" and follow the install page that matches this site\u2019s stack. Do not guess the widget key: ask me for it.'
1019
+ }
1020
+ ];
1021
+ function registerDocs(server) {
1022
+ for (const p of GUIDE_PROMPTS) {
1023
+ server.registerPrompt(p.name, { title: p.title, description: p.description }, () => ({ messages: [{ role: "user", content: { type: "text", text: p.task } }] }));
1024
+ }
1025
+ server.registerResource(
1026
+ "kerabie-docs",
1027
+ new ResourceTemplate("kerabie-docs://{+id}", { list: async () => ({ resources: DOCS.map((d) => ({ uri: `kerabie-docs://${d.id}`, name: d.title, description: d.description, mimeType: "text/markdown" })) }) }),
1028
+ { title: "Kerabie documentation", description: "Every docs page and SDK readme as Markdown.", mimeType: "text/markdown" },
1029
+ async (uri, variables) => {
1030
+ const d = findDoc(String(variables.id));
1031
+ if (!d) throw new Error(`No docs page "${variables.id}"`);
1032
+ return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: `# ${d.title}
1033
+
1034
+ ${d.body}` }] };
1035
+ }
1036
+ );
1037
+ }
1038
+
1039
+ export {
1040
+ TOOLS,
1041
+ searchDocs,
1042
+ getDocText,
1043
+ findDoc,
1044
+ searchEndpoints,
1045
+ listDeclarations,
1046
+ extractDeclaration,
1047
+ DEV_TOOLS,
1048
+ MAX_RESULT_CHARS,
1049
+ ALL_TOOLS,
1050
+ toolsFor,
1051
+ formatResult,
1052
+ formatError,
1053
+ createServer
1054
+ };