[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"initial-data":3,"kb:search:disabled":33,"kb:sectionPath:null:null:en":36,"kb:categories:1:en":37,"kb:brandArticles:1:0:en":53,"kb:articleDetails:112:en":76,"kb:categoryDetails:3:en":109,"kb:subSections:9:en":124,"kb:sectionArticles:9:0:500:en":125},[4,14],{"theme":5,"assets":11},{"color-primary":6,"color-page-bg":7,"color-page-bg-dark":8,"color-content-bg":7,"color-content-bg-hover":9,"color-text-default":10},"#e51728","#ffffff","#121212","#f6f9fc","#0a2540",{"logo":12,"favicon":13},"\u002Fknowledge-base\u002Fpleased-logo.svg","\u002Fknowledge-base\u002Fpleased-favicon.svg",[15],{"name":16,"code":17,"url":17,"displayOrder":18,"isActive":19,"inboundEmailDomains":20,"outboundEmailDomain":20,"publishSettings":21,"id":18},"Pleased","pleased",1,true,"@pleased.com",{"KB":22,"CHATBOT":29},{"publishedLanguages":23,"defaultLanguage":28},[24],{"name":25,"nameLocal":26,"locale":27},"English*","English","en",{"name":25,"nameLocal":26,"locale":27},{"publishedLanguages":30,"defaultLanguage":32},[31],{"name":25,"nameLocal":26,"locale":27},null,{"data":34,"total":35},[],0,[],[38,43,47,51],{"id":39,"brandId":18,"position":39,"title":40,"description":41,"titleTranslated":32,"descriptionTranslated":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},3,"Getting Started","Getting Started with Pleased",false,{"id":44,"brandId":18,"position":44,"title":45,"description":46,"titleTranslated":32,"descriptionTranslated":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},5,"Ticket Management","Test 2",{"id":48,"brandId":18,"position":49,"title":50,"description":50,"titleTranslated":32,"descriptionTranslated":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},17,15,"Creating a Knowledge Base",{"id":49,"brandId":18,"position":48,"title":52,"description":52,"titleTranslated":32,"descriptionTranslated":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},"Release Notes",{"content":54,"number":35,"size":72,"totalElements":44,"pageable":73,"last":19,"totalPages":18,"sort":75,"first":19,"numberOfElements":44,"empty":42},[55,58,61,64,68],{"id":56,"brandId":18,"sectionId":32,"categoryId":39,"categoryName":32,"categoryNameTranslated":32,"title":57,"titleTranslated":32,"position":56,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},70,"Install the Pleased Chat Widget on your website",{"id":59,"brandId":18,"sectionId":44,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":60,"titleTranslated":32,"position":59,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},71,"Setting up inbound emails",{"id":62,"brandId":18,"sectionId":44,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":63,"titleTranslated":32,"position":62,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},72,"Setting up outbound emails",{"id":65,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":67,"titleTranslated":32,"position":65,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},82,9,"Inviting teammates",{"id":69,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":70,"titleTranslated":32,"position":71,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},104,"Configuring your Chatbot",93,10,{"sort":74,"offset":35,"pageNumber":35,"pageSize":72,"paged":19,"unpaged":42},{"sorted":19,"empty":42,"unsorted":42},{"sorted":19,"empty":42,"unsorted":42},{"article":77,"sections":84,"category":88,"relatedArticles":89,"relatedTagsArticles":108},{"id":78,"brandId":18,"sectionId":66,"categoryId":32,"title":79,"body":80,"keywords":32,"position":78,"isCommentDisabled":32,"isDraft":42,"isPromoted":42,"upvotes":32,"downvotes":32,"isActive":19,"language":27,"englishVersion":19,"createdAt":81,"createdBy":72,"updatedAt":82,"updatedBy":72,"tags":32,"includeForAi":19,"authorName":83},112,"Integrating the Chatbot into your Android application","\u003Cp>You can show the Pleased Chat Widget inside your Android app. Your app opens a screen with a \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebView\u003C\u002Fcode>, and the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebView\u003C\u002Fcode> loads a page that Pleased hosts for you. You do not need a website of your own.\u003C\u002Fp>\u003Cp>This article is for the Android developer adding the widget. It covers the chat itself, signing in your users, file attachments and voice calls.\u003C\u002Fp>\u003Ch2>Before you begin\u003C\u002Fh2>\u003Cul>\u003Cli>An Android project with \u003Ccode style=\"background-color: rgb(240, 240, 240);\">minSdk\u003C\u002Fcode> 21 or higher. You do not need any extra libraries or Gradle changes.\u003C\u002Fli>\u003Cli>A Chat Widget created in Pleased. See \u003Cstrong>Install the Pleased Chat Widget on your website\u003C\u002Fstrong> for how to create one.\u003C\u002Fli>\u003Cli>Your client ID and widget ID. You will find them in the Chat Widget code that Pleased generates: they are the values of \u003Ccode style=\"background-color: rgb(240, 240, 240);\">data-pls-client-id\u003C\u002Fcode> and \u003Ccode style=\"background-color: rgb(240, 240, 240);\">data-pls-widget-id\u003C\u002Fcode>.\u003C\u002Fli>\u003C\u002Ful>\u003Cblockquote>\u003Cstrong>Important:\u003C\u002Fstrong> Use the IDs from the code generated for your Chat Widget in Pleased. Do not copy the example values from this article.\u003C\u002Fblockquote>\u003Ch2>Step 1: Add the permissions\u003C\u002Fh2>\u003Cp>Add these three permissions to \u003Ccode style=\"background-color: rgb(240, 240, 240);\">AndroidManifest.xml\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">&lt;uses-permission android:name=\"android.permission.INTERNET\" \u002F&gt;\n&lt;uses-permission android:name=\"android.permission.RECORD_AUDIO\" \u002F&gt;\n&lt;uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" \u002F&gt;\n\u003C\u002Fpre>\u003Cp>\u003Ccode style=\"background-color: rgb(240, 240, 240);\">INTERNET\u003C\u002Fcode> loads the chat. \u003Ccode style=\"background-color: rgb(240, 240, 240);\">RECORD_AUDIO\u003C\u002Fcode> and \u003Ccode style=\"background-color: rgb(240, 240, 240);\">MODIFY_AUDIO_SETTINGS\u003C\u002Fcode> are for voice calls.\u003C\u002Fp>\u003Cblockquote>\u003Cstrong>Important:\u003C\u002Fstrong> Do not leave out \u003Ccode style=\"background-color: rgb(240, 240, 240);\">MODIFY_AUDIO_SETTINGS\u003C\u002Fcode>. Android grants it at install time, so it never appears in a prompt and nothing warns you when it is missing. Without it, the visitor allows the microphone and the call still fails with \u003Ccode style=\"background-color: rgb(240, 240, 240);\">NotReadableError: Could not start audio source\u003C\u002Fcode>.\u003C\u002Fblockquote>\u003Cp>If you will never use voice calls, you can leave out the two audio permissions.\u003C\u002Fp>\u003Ch2>Step 2: Add the two Kotlin files\u003C\u002Fh2>\u003Cp>Copy both files into your project as they are, in the package \u003Ccode style=\"background-color: rgb(240, 240, 240);\">com.pleased.client\u003C\u002Fcode>.\u003C\u002Fp>\u003Ch3>PleasedWebViewSetup.kt\u003C\u002Fh3>\u003Cp>Sets up the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebView\u003C\u002Fcode>, answers microphone requests and opens the system file picker for attachments.\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">package com.pleased.client\n\nimport android.Manifest\nimport android.app.Activity\nimport android.content.ActivityNotFoundException\nimport android.content.Intent\nimport android.content.pm.ApplicationInfo\nimport android.content.pm.PackageManager\nimport android.graphics.Bitmap\nimport android.net.Uri\nimport android.os.Build\nimport android.webkit.ConsoleMessage\nimport android.webkit.PermissionRequest\nimport android.webkit.ValueCallback\nimport android.webkit.WebChromeClient\nimport android.webkit.WebResourceRequest\nimport android.webkit.WebView\nimport android.webkit.WebViewClient\n\n\u002F**\n * WebView setup for the Pleased chatbot. Copy this file as-is.\n *\n * ```\n * private lateinit var pleased: PleasedWebViewSetup\n *\n * pleased = PleasedWebViewSetup(this, webView).apply { configure() }\n * webView.loadUrl(hostPageUrl)\n *\n * override fun onRequestPermissionsResult(code: Int, perms: Array&lt;out String&gt;, results: IntArray) {\n *     super.onRequestPermissionsResult(code, perms, results)\n *     pleased.onRequestPermissionsResult(code, results)\n * }\n *\n * override fun onActivityResult(code: Int, result: Int, data: Intent?) {\n *     super.onActivityResult(code, result, data)\n *     pleased.onActivityResult(code, result, data)   \u002F\u002F the chat's attach-file button\n * }\n * ```\n *\n * Add BOTH permissions to your manifest:\n * ```\n * &lt;uses-permission android:name=\"android.permission.RECORD_AUDIO\" \u002F&gt;\n * &lt;uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" \u002F&gt;\n * ```\n *\n * `MODIFY_AUDIO_SETTINGS` is not optional. It is install-time, so it never appears in a prompt,\n * and without it `getUserMedia` fails with `NotReadableError` even once RECORD_AUDIO is granted.\n *\n * One instance per WebView. Depends on framework API only.\n *\n * @param widgetOrigins origins allowed to use the microphone, besides the page you load. The\n *   chatbot asks from the widget's own iframe, so this must contain wherever the widget is\n *   served from. Override it if you self-host the loader.\n *\u002F\nclass PleasedWebViewSetup(\n    private val activity: Activity,\n    private val webView: WebView,\n    private val widgetOrigins: Set&lt;String&gt; = DEFAULT_WIDGET_ORIGINS,\n) {\n\n    \u002F**\n     * The page this WebView is for, pinned once its first load has FINISHED — so the whole\n     * redirect chain of that first navigation is allowed, and everything after is measured\n     * against where you actually ended up. Compared without the fragment, which never loads a\n     * page.\n     *\n     * The URL, not just the origin: origin-only would let a link to a neighbouring path on the\n     * same host keep the bridge and the microphone handler, which matters as soon as your host\n     * page shares an origin with anything you do not control.\n     *\u002F\n    private var pinnedUrl: String? = null\n    private var pendingRequest: PermissionRequest? = null\n    private var pendingFiles: ValueCallback&lt;Array&lt;Uri&gt;&gt;? = null\n\n    fun configure() {\n        \u002F\u002F chrome:\u002F\u002Finspect, in debug builds only — it exposes the page to anything that can\n        \u002F\u002F reach the device.\n        if ((activity.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) {\n            WebView.setWebContentsDebuggingEnabled(true)\n        }\n\n        webView.settings.apply {\n            javaScriptEnabled = true\n            domStorageEnabled = true\n            \u002F\u002F Without this the WebView can send audio but never play any back.\n            mediaPlaybackRequiresUserGesture = false\n        }\n\n        webView.webViewClient = object : WebViewClient() {\n            override fun onPageFinished(view: WebView, url: String) {\n                \u002F\u002F The first load has settled, redirects included. That is the page.\n                if (pinnedUrl == null) pinnedUrl = withoutFragment(url)\n            }\n\n            \u002F**\n             * The backstop, and not redundant with [shouldOverrideUrlLoading]: Android does not\n             * consult that method for every main-frame navigation — a POST, or some redirects,\n             * arrive here having never been offered for approval.\n             *\u002F\n            override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {\n                val pinned = pinnedUrl ?: return\n                if (withoutFragment(url) == pinned) return\n                view.stopLoading()\n                leaveWebView(url)\n            }\n\n            \u002F**\n             * Keeps this WebView on the page you loaded, because it is not an ordinary WebView:\n             * it carries a JavaScript bridge that reaches your token callback, and it answers\n             * microphone requests. Off-site navigation goes to the browser, which has neither.\n             *\u002F\n            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {\n                \u002F\u002F Sub-frames are the widget's iframe and what it loads, never a navigation of\n                \u002F\u002F this WebView.\n                if (!request.isForMainFrame) return false\n                val pinned = pinnedUrl ?: return false\n                val target = request.url?.toString() ?: return true\n                if (withoutFragment(target) == pinned) return false\n                leaveWebView(target)\n                return true\n            }\n        }\n\n        webView.webChromeClient = object : WebChromeClient() {\n            override fun onConsoleMessage(message: ConsoleMessage): Boolean = false\n\n            override fun onPermissionRequest(request: PermissionRequest) {\n                \u002F\u002F Not overriding this is not a neutral choice: the base implementation denies,\n                \u002F\u002F with no prompt and no log.\n                answerAudioPermission(request)\n            }\n\n            override fun onPermissionRequestCanceled(request: PermissionRequest) {\n                if (pendingRequest == request) pendingRequest = null\n            }\n\n            \u002F**\n             * The chat's attach-file button is an `&lt;input type=\"file\"&gt;`. A WebView has no picker\n             * of its own: the base implementation returns false and the tap does nothing — no\n             * error, no log. iOS needs no equivalent; WKWebView shows its own picker.\n             *\u002F\n            override fun onShowFileChooser(\n                view: WebView,\n                filePathCallback: ValueCallback&lt;Array&lt;Uri&gt;&gt;,\n                params: FileChooserParams,\n            ): Boolean {\n                \u002F\u002F One chooser at a time. An older callback left unanswered keeps that input\n                \u002F\u002F dead until the page reloads, so release it first.\n                pendingFiles?.onReceiveValue(null)\n                pendingFiles = filePathCallback\n                return try {\n                    activity.startActivityForResult(params.createIntent(), REQUEST_CODE_FILE_CHOOSER)\n                    true\n                } catch (e: ActivityNotFoundException) {\n                    \u002F\u002F Returning false cancels on the WebView's side; the callback must then\n                    \u002F\u002F stay unanswered.\n                    pendingFiles = null\n                    false\n                }\n            }\n        }\n    }\n\n    \u002F**\n     * Call from your Activity's `onActivityResult`. Returns true if this was the file picker\n     * this WebView opened.\n     *\u002F\n    fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {\n        if (requestCode != REQUEST_CODE_FILE_CHOOSER) return false\n        val callback = pendingFiles ?: return false\n        pendingFiles = null\n        \u002F\u002F Always answer, a cancel included (null) — see onShowFileChooser.\n        callback.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data))\n        return true\n    }\n\n    \u002F**\n     * Call from your Activity's `onRequestPermissionsResult`. Returns true if this WebView was\n     * the one waiting, so an Activity with more than one can offer the result to each.\n     *\u002F\n    fun onRequestPermissionsResult(requestCode: Int, grantResults: IntArray): Boolean {\n        if (requestCode != REQUEST_CODE_RECORD_AUDIO) return false\n        val request = pendingRequest ?: return false\n        pendingRequest = null\n\n        val granted = grantResults.isNotEmpty() &amp;&amp;\n            grantResults[0] == PackageManager.PERMISSION_GRANTED\n        if (granted) request.grant(AUDIO_ONLY) else request.deny()\n        return true\n    }\n\n    internal fun answerAudioPermission(request: PermissionRequest) {\n        \u002F\u002F This origin is the frame that asked — for the chatbot, the widget's iframe, NOT the\n        \u002F\u002F page you loaded. Comparing it to your own URL refuses every working integration.\n        \u002F\u002F\n        \u002F\u002F A request with no identifiable origin is refused outright. Letting it reach the\n        \u002F\u002F comparison would grant it whenever nothing is pinned yet, since null equals null.\n        val origin = originOf(request.origin?.toString())\n        if (origin == null || (origin != originOf(pinnedUrl) &amp;&amp; origin !in widgetOrigins)) {\n            request.deny()\n            return\n        }\n        if (!request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {\n            request.deny()\n            return\n        }\n        \u002F\u002F Runtime permissions arrived in API 23. Below that, a permission in the manifest is\n        \u002F\u002F granted at install time and there is nothing to ask for — and both checkSelfPermission\n        \u002F\u002F and requestPermissions are API 23, so reaching them on an older device throws\n        \u002F\u002F NoSuchMethodError. Without this the whole class needs minSdk 23.\n        if (Build.VERSION.SDK_INT &lt; Build.VERSION_CODES.M) {\n            request.grant(AUDIO_ONLY)\n            return\n        }\n\n        \u002F\u002F Audio only. `request.resources` is whatever the page asked for, so handing it back\n        \u002F\u002F would grant the camera off the strength of a RECORD_AUDIO check.\n        if (activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==\n            PackageManager.PERMISSION_GRANTED\n        ) {\n            request.grant(AUDIO_ONLY)\n            return\n        }\n\n        \u002F\u002F Just ask. shouldShowRequestPermissionRationale() cannot tell you whether a prompt will\n        \u002F\u002F appear — it returns false for never-asked, permanently-denied AND \"Ask every time\" —\n        \u002F\u002F so any pre-check eventually refuses someone the OS would have prompted. A permission\n        \u002F\u002F that really is permanently denied is answered at once, with no UI.\n        pendingRequest = request\n        activity.requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_CODE_RECORD_AUDIO)\n    }\n\n    \u002F** Hand a URL to the browser. Failing to launch is not a reason to load it here instead. *\u002F\n    private fun leaveWebView(url: String) {\n        runCatching { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }\n    }\n\n    companion object {\n        const val REQUEST_CODE_RECORD_AUDIO = 4371\n        const val REQUEST_CODE_FILE_CHOOSER = 4372\n\n        \u002F** Where the Pleased widget is served from — the host of your loader script. *\u002F\n        val DEFAULT_WIDGET_ORIGINS = setOf(\"https:\u002F\u002Fcdn.pleased.com\")\n\n        private val AUDIO_ONLY = arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)\n\n        \u002F** Fragments never load a page, so they must not count as navigating away. *\u002F\n        private fun withoutFragment(url: String) = url.substringBefore('#')\n\n        internal fun originOf(url: String?): String? = url\n            ?.let { runCatching { Uri.parse(it) }.getOrNull() }\n            ?.let { uri -&gt; uri.scheme?.let { scheme -&gt; uri.authority?.let { \"$scheme:\u002F\u002F$it\" } } }\n    }\n}\n\u003C\u002Fpre>\u003Ch3>PleasedBridge.kt\u003C\u002Fh3>\u003Cp>Lets your app talk to the widget. Your app uses it to hand over the visitor's token, open the chat and react to events.\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">package com.pleased.client\n\nimport android.webkit.JavascriptInterface\nimport android.webkit.WebView\nimport java.util.concurrent.ConcurrentHashMap\nimport org.json.JSONObject\nimport org.json.JSONTokener\n\n\u002F**\n * Drives the Pleased loader running inside a WebView. Copy this file as-is.\n *\n * ```\n * val bridge = PleasedBridge(webView)                              \u002F\u002F BEFORE loadUrl\n * val pleased = PleasedWebViewSetup(this, webView).apply { configure() }\n * webView.loadUrl(hostPageUrl)\n *\n * bridge.onLoaderReady = {\n *     bridge.registerCallback(\"set:callback\", \"token\") { _, respond -&gt;\n *         fetchToken { respond.resolve(JSONObject.quote(it)) }     \u002F\u002F async is fine\n *     }\n *     bridge.registerCallback(\"event:on\", \"ready\") { _, _ -&gt; bridge.invoke(\"widget:open\", \"{}\") }\n * }\n * ```\n *\n * Commands pass through as strings, so a new `set:` or event needs no change here. The list of\n * what you can send is in the integration guide, not duplicated in Kotlin.\n *\u002F\nclass PleasedBridge(private val webView: WebView) {\n\n    \u002F**\n     * Called on the main thread, so it is safe to touch UI, start an Activity or read anything\n     * with thread affinity straight from here.\n     *\n     * Answer through [respond] exactly once. Async is fine — the widget gives up after 10s.\n     * For `event:on` \u002F `event:once` there is nothing to answer and [respond] does nothing.\n     *\u002F\n    fun interface CallbackHandler {\n        fun handle(payloadJson: String?, respond: Responder)\n    }\n\n    interface Responder {\n        \u002F** [valueJson] must be valid JSON. A bare string needs `JSONObject.quote(value)`. *\u002F\n        fun resolve(valueJson: String?)\n        fun reject(error: String)\n    }\n\n    \u002F** Fires once the loader script has run. Register your callbacks here. *\u002F\n    var onLoaderReady: (() -&gt; Unit)? = null\n\n    \u002F\u002F @JavascriptInterface arrives on a background thread while registerCallback writes from\n    \u002F\u002F the UI thread; a plain HashMap loses registrations under that race.\n    private val handlers = ConcurrentHashMap&lt;String, CallbackHandler&gt;()\n\n    init {\n        \u002F\u002F Must be attached before the page loads, or every callback rejects with\n        \u002F\u002F \"no native bridge attached\".\n        webView.addJavascriptInterface(NativeInterface(), \"PleasedNative\")\n    }\n\n    \u002F**\n     * Run any loader command. [onResult] gets the JSON `{ok, result}` the page returned, or\n     * `{ok: false, error}` without reaching the page when [payloadJson] is not JSON.\n     *\u002F\n    fun invoke(command: String, payloadJson: String? = null, onResult: ((String) -&gt; Unit)? = null) {\n        \u002F\u002F Caught here rather than by JSON.parse in the page, so the error names the real cause\n        \u002F\u002F and arrives before anything is evaluated. Empty means \"no payload\", as in the page.\n        if (!payloadJson.isNullOrEmpty() &amp;&amp; runCatching { JSONTokener(payloadJson).nextValue() }.isFailure) {\n            val error = JSONObject().put(\"ok\", false).put(\"error\", \"payloadJson is not valid JSON\").toString()\n            onResult?.let { webView.post { it(error) } }\n            return\n        }\n        val js = \"window.__plInvoke(${JSONObject.quote(command)}, \" +\n            (payloadJson?.let { JSONObject.quote(it) } ?: \"null\") + \")\"\n        eval(js, onResult)\n    }\n\n    \u002F**\n     * [type] is the loader command verbatim: `set:callback` for values the widget asks for,\n     * `event:on` \u002F `event:once` to subscribe. Name an event `*` to receive all of them, as\n     * `{\"event\":..., \"payload\":...}`.\n     *\u002F\n    fun registerCallback(type: String, name: String, handler: CallbackHandler) {\n        handlers[\"$type|$name\"] = handler\n        eval(\"window.__plAddCallback(${JSONObject.quote(type)}, ${JSONObject.quote(name)})\")\n    }\n\n    fun removeCallback(type: String, name: String) {\n        handlers.remove(\"$type|$name\")\n        eval(\"window.__plRemoveCallback(${JSONObject.quote(type)}, ${JSONObject.quote(name)})\")\n    }\n\n    private fun eval(js: String, onResult: ((String) -&gt; Unit)? = null) {\n        \u002F\u002F evaluateJavascript must run on the UI thread, and @JavascriptInterface methods are\n        \u002F\u002F delivered on a background one, so this post is load-bearing rather than tidy.\n        webView.post { webView.evaluateJavascript(js) { result -&gt; onResult?.invoke(unwrap(result)) } }\n    }\n\n    \u002F**\n     * evaluateJavascript JSON-encodes what the page returned, and the shim returns a JSON\n     * string — so results arrive twice-encoded and `JSONObject(it)` throws. Undo one layer.\n     *\u002F\n    private fun unwrap(raw: String?): String {\n        val value = raw ?: return \"null\"\n        val decoded = runCatching { JSONTokener(value).nextValue() }.getOrNull()\n        return if (decoded is String) decoded else value\n    }\n\n    \u002F**\n     * Hands work to the main thread.\n     *\n     * `@JavascriptInterface` methods are delivered on a WebView background thread. Running a\n     * client's handler there would mean their token lookup, their Activity start, their view\n     * update — all off the UI thread, crashing somewhere far from the cause. Everything the\n     * page sends in crosses back here first.\n     *\u002F\n    private fun onMain(block: () -&gt; Unit) {\n        webView.post(block)\n    }\n\n    private inner class NativeInterface {\n        @JavascriptInterface\n        fun onMessage(json: String) {\n            val message = runCatching { JSONObject(json) }.getOrNull() ?: return\n            when (message.optString(\"kind\")) {\n                \"loader_ready\" -&gt; onMain { onLoaderReady?.invoke() }\n\n                \"event\" -&gt; {\n                    val name = message.optString(\"name\")\n                    val raw = message.opt(\"payload\")?.takeIf { it != JSONObject.NULL }\n\n                    \u002F\u002F One delivery per subscription, and `via` says which one — so a host\n                    \u002F\u002F registered twice for the same event (event:on and event:once, or a name\n                    \u002F\u002F and \"*\") runs each handler once rather than every handler every time.\n                    val via = message.optString(\"via\")\n                    when {\n                        \u002F\u002F A wildcard delivery names the event, not the subscription, so it is\n                        \u002F\u002F keyed by \"*\" and the name travels inside an envelope — a wildcard\n                        \u002F\u002F handler cannot tell what it received without it. `via` still says\n                        \u002F\u002F which subscription, which is what keeps event:on \"*\" and\n                        \u002F\u002F event:once \"*\" apart.\n                        message.optBoolean(\"wildcard\") -&gt; {\n                            val envelope = JSONObject()\n                                .put(\"event\", name)\n                                .put(\"payload\", raw ?: JSONObject.NULL)\n                                .toString()\n                            onMain { handlers[\"$via|*\"]?.handle(envelope, NoopResponder) }\n                        }\n\n                        via.isEmpty() -&gt; onMain {\n                            \u002F\u002F No `via`: an older host page than this bridge. Fall back to the\n                            \u002F\u002F previous behaviour rather than dropping the event.\n                            handlers[\"event:on|$name\"]?.handle(raw?.toString(), NoopResponder)\n                            handlers[\"event:once|$name\"]?.handle(raw?.toString(), NoopResponder)\n                        }\n\n                        else -&gt; onMain { handlers[\"$via|$name\"]?.handle(raw?.toString(), NoopResponder) }\n                    }\n                }\n\n                \"callback\" -&gt; {\n                    val name = message.optString(\"name\")\n                    val id = message.optString(\"id\")\n                    val type = message.optString(\"type\")\n                    val payload = message.opt(\"payload\")?.takeIf { it != JSONObject.NULL }?.toString()\n                    val handler = handlers[\"$type|$name\"]\n                    if (handler == null) {\n                        reject(id, \"no handler registered for $name\")\n                        return\n                    }\n                    onMain { handler.handle(payload, JsResponder(id)) }\n                }\n            }\n        }\n    }\n\n    private fun resolve(id: String, valueJson: String?) {\n        eval(\n            \"window.__plResolveCallback(${JSONObject.quote(id)}, \" +\n                (valueJson?.let { JSONObject.quote(it) } ?: \"null\") + \")\"\n        )\n    }\n\n    private fun reject(id: String, error: String) {\n        eval(\"window.__plRejectCallback(${JSONObject.quote(id)}, ${JSONObject.quote(error)})\")\n    }\n\n    private inner class JsResponder(private val id: String) : Responder {\n        private var settled = false\n        override fun resolve(valueJson: String?) {\n            if (settled) return\n            settled = true\n            this@PleasedBridge.resolve(id, valueJson)\n        }\n\n        override fun reject(error: String) {\n            if (settled) return\n            settled = true\n            this@PleasedBridge.reject(id, error)\n        }\n    }\n\n    private object NoopResponder : Responder {\n        override fun resolve(valueJson: String?) = Unit\n        override fun reject(error: String) = Unit\n    }\n}\n\u003C\u002Fpre>\u003Ch2>Step 3: Add the chat screen\u003C\u002Fh2>\u003Cp>Create an Activity for the chat. Replace \u003Ccode style=\"background-color: rgb(240, 240, 240);\">YOUR_CLIENT_ID\u003C\u002Fcode> and \u003Ccode style=\"background-color: rgb(240, 240, 240);\">YOUR_WIDGET_ID\u003C\u002Fcode> with the values from your generated code.\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">import android.app.Activity\nimport android.content.Intent\nimport android.net.Uri\nimport android.os.Bundle\nimport android.provider.Settings\nimport android.webkit.WebView\nimport com.pleased.client.PleasedBridge\nimport com.pleased.client.PleasedWebViewSetup\nimport org.json.JSONObject\n\nclass ChatActivity : Activity() {\n\n    private lateinit var webView: WebView\n    private lateinit var pleased: PleasedWebViewSetup\n    private lateinit var bridge: PleasedBridge\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        webView = WebView(this)\n        setContentView(webView)\n\n        \u002F\u002F Before configure() and before loadUrl: the page looks for the bridge as soon as it runs.\n        bridge = PleasedBridge(webView)\n\n        bridge.onLoaderReady = {\n            \u002F\u002F The widget asks for the signed-in visitor's token. Return \"\" for a guest.\n            \u002F\u002F fetchTokenFromYourBackend is yours: call your server, which mints the JWE\n            \u002F\u002F (see Step 4). Do the network call off the main thread and call respond\n            \u002F\u002F when it returns. Never mint the token in the app: the key must stay server-side.\n            bridge.registerCallback(\"set:callback\", \"token\") { _, respond -&gt;\n                fetchTokenFromYourBackend { token -&gt;\n                    respond.resolve(JSONObject.quote(token))      \u002F\u002F async is fine\n                }\n            }\n\n            bridge.registerCallback(\"event:on\", \"ready\") { _, _ -&gt;\n                bridge.invoke(\"widget:open\", \"{}\")\n            }\n\n            \u002F\u002F Required, not optional — see below.\n            bridge.registerCallback(\"event:on\", \"close\") { _, _ -&gt; finish() }\n\n            \u002F\u002F The way back from a blocked microphone — see \"When the microphone is blocked\".\n            bridge.registerCallback(\"event:on\", \"request_open_settings\") { _, _ -&gt;\n                startActivity(\n                    Intent(\n                        Settings.ACTION_APPLICATION_DETAILS_SETTINGS,\n                        Uri.fromParts(\"package\", packageName, null),\n                    )\n                )\n            }\n            bridge.invoke(\"set:config\", \"\"\"{\"capabilities\":{\"canOpenAppSettings\":true}}\"\"\")\n        }\n\n        pleased = PleasedWebViewSetup(this, webView).apply { configure() }\n        webView.loadUrl(\n            \"https:\u002F\u002Fcdn.pleased.com\u002Fwidget\u002Fnative\u002Findex.html\" +\n                \"?clientId=YOUR_CLIENT_ID&amp;widgetId=YOUR_WIDGET_ID&amp;platform=app\"\n        )\n    }\n\n    override fun onRequestPermissionsResult(\n        code: Int,\n        permissions: Array&lt;out String&gt;,\n        results: IntArray,\n    ) {\n        super.onRequestPermissionsResult(code, permissions, results)\n        pleased.onRequestPermissionsResult(code, results)\n    }\n\n    override fun onActivityResult(code: Int, result: Int, data: Intent?) {\n        super.onActivityResult(code, result, data)\n        pleased.onActivityResult(code, result, data)\n    }\n}\n\u003C\u002Fpre>\u003Cp>Register the Activity in \u003Ccode style=\"background-color: rgb(240, 240, 240);\">AndroidManifest.xml\u003C\u002Fcode> with a theme that has no action bar:\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">&lt;activity\n    android:name=\".ChatActivity\"\n    android:theme=\"@android:style\u002FTheme.Material.Light.NoActionBar\" \u002F&gt;\n\u003C\u002Fpre>\u003Cp>Then open it from wherever your app offers support, for example a Help button:\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">startActivity(Intent(this, ChatActivity::class.java))\n\u003C\u002Fpre>\u003Cp>A few parts of this screen are easy to get wrong:\u003C\u002Fp>\u003Cul>\u003Cli>Use a theme with no action bar. From \u003Ccode style=\"background-color: rgb(240, 240, 240);\">targetSdk\u003C\u002Fcode> 35 the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebView\u003C\u002Fcode> fills the whole screen, and an action bar would cover the widget's close button.\u003C\u002Fli>\u003Cli>Keep \u003Ccode style=\"background-color: rgb(240, 240, 240);\">platform=app\u003C\u002Fcode> in the URL. It tells the widget that your app has its own way to open the chat, so the widget does not draw its own chat bubble on top of your screens.\u003C\u002Fli>\u003Cli>Keep the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">close\u003C\u002Fcode> handler. When the visitor minimises the chat, the widget hides and sends \u003Ccode style=\"background-color: rgb(240, 240, 240);\">close\u003C\u002Fcode>. The screen above calls \u003Ccode style=\"background-color: rgb(240, 240, 240);\">finish()\u003C\u002Fcode>, which takes the visitor back to where they came from. Without it they are left looking at an empty screen.\u003C\u002Fli>\u003Cli>Forward \u003Ccode style=\"background-color: rgb(240, 240, 240);\">onRequestPermissionsResult\u003C\u002Fcode>. Without it, the visitor taps Allow on the microphone prompt and nothing happens.\u003C\u002Fli>\u003Cli>Forward \u003Ccode style=\"background-color: rgb(240, 240, 240);\">onActivityResult\u003C\u002Fcode>. Without it, the file picker opens, the visitor picks a file and nothing is attached.\u003C\u002Fli>\u003Cli>Create \u003Ccode style=\"background-color: rgb(240, 240, 240);\">PleasedBridge\u003C\u002Fcode> before you call \u003Ccode style=\"background-color: rgb(240, 240, 240);\">loadUrl\u003C\u002Fcode>. The page looks for it as soon as it loads.\u003C\u002Fli>\u003C\u002Ful>\u003Ch2>Step 4: Sign in your users\u003C\u002Fh2>\u003Cp>When a visitor is signed in to your app, the widget can know who they are. They see their name and their previous conversations, and they do not have to type their contact details.\u003C\u002Fp>\u003Cp>The widget asks for a token through the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">token\u003C\u002Fcode> callback in Step 3. Your app gets the token from your own server and passes it on. For a guest, pass an empty string.\u003C\u002Fp>\u003Ch3>Get the key\u003C\u002Fh3>\u003Col>\u003Cli>Go to \u003Cstrong>Settings &gt; Chat Widget Setup\u003C\u002Fstrong> in Pleased.\u003C\u002Fli>\u003Cli>Open \u003Cstrong>Widget Authentication\u003C\u002Fstrong>.\u003C\u002Fli>\u003Cli>Select \u003Cstrong>Reveal\u003C\u002Fstrong> to show the key.\u003C\u002Fli>\u003C\u002Fol>\u003Cblockquote>\u003Cstrong>Important:\u003C\u002Fstrong> Keep the key on your server and never put it in your app. Anyone who extracts it from the app could sign in to the chat as any of your users. If the key leaks, generate a new one on the same page. The old key stops working straight away, so update your server at the same time.\u003C\u002Fblockquote>\u003Ch3>Create the token on your server\u003C\u002Fh3>\u003Cp>Add an endpoint to your server that creates a token for the signed-in user. The token is a JWE encrypted with the key, using \u003Ccode style=\"background-color: rgb(240, 240, 240);\">RSA-OAEP-256\u003C\u002Fcode> and \u003Ccode style=\"background-color: rgb(240, 240, 240);\">A128GCM\u003C\u002Fcode>. This Java example uses the Nimbus JOSE + JWT library:\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">import java.security.KeyFactory;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.PublicKey;\nimport java.security.interfaces.RSAPublicKey;\nimport java.security.spec.InvalidKeySpecException;\nimport java.security.spec.X509EncodedKeySpec;\nimport java.util.Base64;\nimport java.util.Date;\n\nimport com.nimbusds.jwt.EncryptedJWT;\nimport com.nimbusds.jwt.JWTClaimsSet;\nimport com.nimbusds.jose.EncryptionMethod;\nimport com.nimbusds.jose.JOSEException;\nimport com.nimbusds.jose.JWEAlgorithm;\nimport com.nimbusds.jose.JWEHeader;\nimport com.nimbusds.jose.crypto.RSAEncrypter;\n\nprivate String buildJweToken(String publicKey) throws JOSEException, NoSuchAlgorithmException, InvalidKeySpecException {\n   JWTClaimsSet claims = new JWTClaimsSet.Builder()\n       \u002F\u002F Expire 2 hours from now. The widget calls the token callback again when it expires.\n       .expirationTime(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000))\n       \u002F\u002F Your signed-in user's own values. userId is how Pleased recognises them next time.\n       .claim(\"email\", \"jane@example.com\")\n       .claim(\"userId\", \"your-user-id\")\n       \u002F\u002F International format. A phone the server cannot parse makes sign-in fail.\n       .claim(\"phone\", \"+447700900123\")\n       .claim(\"name\", \"Jane Doe\")\n       \u002F\u002F All of the above claim fields are optional.\n       .build();\n\n   JWEHeader header = new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A128GCM);\n   EncryptedJWT jwt = new EncryptedJWT(header, claims);\n   RSAEncrypter encrypter = new RSAEncrypter((RSAPublicKey) loadPublicKey(publicKey));\n   jwt.encrypt(encrypter);\n   return jwt.serialize();\n}\n\nprivate PublicKey loadPublicKey(String publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException {\n    byte[] rsaKey = Base64.getDecoder().decode(publicKey);\n    X509EncodedKeySpec spec = new X509EncodedKeySpec(rsaKey);\n    KeyFactory kf = KeyFactory.getInstance(\"RSA\");\n    return kf.generatePublic(spec);\n}\n\u003C\u002Fpre>\u003Cp>Fill in your user's real details. Pleased uses \u003Ccode style=\"background-color: rgb(240, 240, 240);\">userId\u003C\u002Fcode> to recognise the same person next time. Give the phone number in international format, such as \u003Ccode style=\"background-color: rgb(240, 240, 240);\">+447700900123\u003C\u002Fcode>. If Pleased cannot read the phone number, it cannot sign in a new user.\u003C\u002Fp>\u003Ch3>Pass the token to the widget\u003C\u002Fh3>\u003Cp>In the chat screen, \u003Ccode style=\"background-color: rgb(240, 240, 240);\">fetchTokenFromYourBackend\u003C\u002Fcode> is your own function. Make the network call off the main thread, then pass the token on. For example:\u003C\u002Fp>\u003Cpre class=\"ql-syntax\" spellcheck=\"false\">import java.net.URL\nimport kotlin.concurrent.thread\n\nfun fetchTokenFromYourBackend(onToken: (String) -&gt; Unit) {\n    if (!isSignedIn()) return onToken(\"\")   \u002F\u002F a guest: no token\n    thread {\n        \u002F\u002F Your endpoint from above. Send your app's own session with the request, so your\n        \u002F\u002F server only issues a token for the user who is actually signed in.\n        val token = runCatching { URL(\"https:\u002F\u002Fapi.example.com\u002Fpleased-token\").readText() }\n            .getOrDefault(\"\")\n        onToken(token)\n    }\n}\n\u003C\u002Fpre>\u003Cp>The widget waits up to 10 seconds for an answer. If the visitor signs in or out while the chat is open, call \u003Ccode style=\"background-color: rgb(240, 240, 240);\">bridge.invoke(\"widget:re_auth\")\u003C\u002Fcode> and the widget asks for the token again.\u003C\u002Fp>\u003Ch2>File attachments\u003C\u002Fh2>\u003Cp>There is nothing more to add. When the visitor taps the attach button, \u003Ccode style=\"background-color: rgb(240, 240, 240);\">PleasedWebViewSetup\u003C\u002Fcode> opens the system file picker, as long as you forward \u003Ccode style=\"background-color: rgb(240, 240, 240);\">onActivityResult\u003C\u002Fcode> (Step 3). The picker only gives your app the file the visitor chooses, so you do not need a storage permission.\u003C\u002Fp>\u003Cblockquote>\u003Cstrong>Important:\u003C\u002Fstrong> If you set your own \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebChromeClient\u003C\u002Fcode> on the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebView\u003C\u002Fcode> after \u003Ccode style=\"background-color: rgb(240, 240, 240);\">configure()\u003C\u002Fcode>, it replaces the one that opens the picker. You then have to implement \u003Ccode style=\"background-color: rgb(240, 240, 240);\">onShowFileChooser\u003C\u002Fcode> yourself and always answer its callback, with \u003Ccode style=\"background-color: rgb(240, 240, 240);\">null\u003C\u002Fcode> if the visitor cancels. Otherwise the attach button stops working until the page reloads.\u003C\u002Fblockquote>\u003Ch2>Voice calls\u003C\u002Fh2>\u003Cp>When a call starts, \u003Ccode style=\"background-color: rgb(240, 240, 240);\">PleasedWebViewSetup\u003C\u002Fcode> asks for the microphone and the visitor sees the standard Android permission prompt.\u003C\u002Fp>\u003Cp>The call option only appears while an agent is online for it. When nobody can answer, the widget offers \u003Cstrong>Open Request\u003C\u002Fstrong> instead. Have someone signed in to Pleased when you test calls.\u003C\u002Fp>\u003Ch3>When the microphone is blocked\u003C\u002Fh3>\u003Cp>If the visitor refuses the microphone twice, Android stops asking, and only your app's settings screen can turn it back on. The \u003Ccode style=\"background-color: rgb(240, 240, 240);\">request_open_settings\u003C\u002Fcode> handler and the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">canOpenAppSettings\u003C\u002Fcode> setting in Step 3 cover this. The widget shows \"Microphone access is blocked for this app\" with an \u003Cstrong>Open settings\u003C\u002Fstrong> button. When the visitor comes back from settings, the call starts by itself.\u003C\u002Fp>\u003Cp>If you remove the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">request_open_settings\u003C\u002Fcode> handler, remove \u003Ccode style=\"background-color: rgb(240, 240, 240);\">canOpenAppSettings\u003C\u002Fcode> too. Otherwise the visitor gets a button that does nothing.\u003C\u002Fp>\u003Ch2>Verify the installation\u003C\u002Fh2>\u003Col>\u003Cli>Build and run your app, then open the chat screen.\u003C\u002Fli>\u003Cli>Check that the chat opens. Signed in, the greeting should show the user's name.\u003C\u002Fli>\u003Cli>Tap the attach button, pick a file and check that it is attached.\u003C\u002Fli>\u003Cli>With an agent online, start a call and allow the microphone.\u003C\u002Fli>\u003Cli>Minimise the chat and check that you are back on the previous screen.\u003C\u002Fli>\u003C\u002Fol>\u003Cp>In a debug build you can inspect the page from Chrome on your computer at \u003Ccode style=\"background-color: rgb(240, 240, 240);\">chrome:\u002F\u002Finspect\u003C\u002Fcode>. The widget's console messages are the quickest way to find what is wrong.\u003C\u002Fp>\u003Ch2>Troubleshooting\u003C\u002Fh2>\u003Cul>\u003Cli>\"Webpage not available\": add \u003Ccode style=\"background-color: rgb(240, 240, 240);\">INTERNET\u003C\u002Fcode> to the manifest.\u003C\u002Fli>\u003Cli>The chat never appears: check the client ID and widget ID in the URL.\u003C\u002Fli>\u003Cli>Signed-in visitors are treated as guests: check that the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">token\u003C\u002Fcode> callback is registered and your server returns a token. Also check the phone number format.\u003C\u002Fli>\u003Cli>Two chat bubbles, or a bubble over your own screens: add \u003Ccode style=\"background-color: rgb(240, 240, 240);\">platform=app\u003C\u002Fcode> to the URL.\u003C\u002Fli>\u003Cli>A blank screen after the visitor minimises the chat: handle the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">close\u003C\u002Fcode> event.\u003C\u002Fli>\u003Cli>The visitor taps Allow on the microphone prompt and nothing happens: forward \u003Ccode style=\"background-color: rgb(240, 240, 240);\">onRequestPermissionsResult\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>The attach button does nothing: check that your own \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebChromeClient\u003C\u002Fcode> has not replaced the one from \u003Ccode style=\"background-color: rgb(240, 240, 240);\">PleasedWebViewSetup\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>The file picker opens but nothing is attached: forward \u003Ccode style=\"background-color: rgb(240, 240, 240);\">onActivityResult\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>\"We couldn't get microphone access in this app\" with no button: add the \u003Ccode style=\"background-color: rgb(240, 240, 240);\">request_open_settings\u003C\u002Fcode> handler and \u003Ccode style=\"background-color: rgb(240, 240, 240);\">canOpenAppSettings\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>Calls fail with \u003Ccode style=\"background-color: rgb(240, 240, 240);\">NotReadableError\u003C\u002Fcode>: add \u003Ccode style=\"background-color: rgb(240, 240, 240);\">MODIFY_AUDIO_SETTINGS\u003C\u002Fcode> to the manifest.\u003C\u002Fli>\u003Cli>Calls fail with \u003Ccode style=\"background-color: rgb(240, 240, 240);\">NotAllowedError\u003C\u002Fcode> and no prompt appears: check that your own \u003Ccode style=\"background-color: rgb(240, 240, 240);\">WebChromeClient\u003C\u002Fcode> has not replaced the one from \u003Ccode style=\"background-color: rgb(240, 240, 240);\">PleasedWebViewSetup\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>Only Open Request is offered: no agent is online for chat or calls.\u003C\u002Fli>\u003C\u002Ful>","2026-10-01T15:33:15.000+00:00","2026-10-01T15:49:00.000+00:00","Nikita Beslik",[85],{"id":66,"brandId":18,"categoryId":39,"parentSectionId":32,"position":66,"title":86,"description":87,"titleTranslated":32,"descriptionTranslated":32,"sections":32,"articles":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},"General Guides","Quick Start Guides",{"id":39,"brandId":18,"position":39,"title":40,"description":41,"titleTranslated":32,"descriptionTranslated":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},[90,91,92,95,98,101,104,107],{"id":65,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":67,"titleTranslated":32,"position":65,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":69,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":70,"titleTranslated":32,"position":71,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":93,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":94,"titleTranslated":32,"position":93,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},78,"Going online for customers",{"id":96,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":97,"titleTranslated":32,"position":96,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},79,"Navigating Pleased",{"id":99,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":100,"titleTranslated":32,"position":99,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},80,"Understanding the dashboard",{"id":102,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":103,"titleTranslated":32,"position":102,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},81,"Using notifications",{"id":71,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":105,"titleTranslated":32,"position":106,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},"Customising your Chatbot",94,{"id":78,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":79,"titleTranslated":32,"position":78,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},[],{"category":110,"sections":111,"articles":121},{"id":39,"brandId":18,"position":39,"title":40,"description":41,"titleTranslated":32,"descriptionTranslated":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},[112,116,118,119],{"id":113,"title":114,"brandId":35,"categoryId":35,"position":35,"description":115,"isActive":19,"language":27,"englishVersion":19},-1,"Category Articles","",{"id":44,"brandId":18,"categoryId":39,"parentSectionId":32,"position":44,"title":117,"description":117,"titleTranslated":32,"descriptionTranslated":32,"sections":32,"articles":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},"Channel Setup",{"id":66,"brandId":18,"categoryId":39,"parentSectionId":32,"position":66,"title":86,"description":87,"titleTranslated":32,"descriptionTranslated":32,"sections":32,"articles":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},{"id":72,"brandId":18,"categoryId":39,"parentSectionId":32,"position":72,"title":120,"description":120,"titleTranslated":32,"descriptionTranslated":32,"sections":32,"articles":32,"isActive":19,"language":27,"englishVersion":19,"isInternal":42},"\tWorkspace Management",[122],{"id":56,"brandId":18,"sectionId":32,"categoryId":39,"categoryName":32,"categoryNameTranslated":32,"title":57,"titleTranslated":32,"position":56,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":123,"isInternal":42,"includeForAi":19,"expiryTime":32,"priority":42,"isPriority":42},"2026-08-17T08:52:13.000+00:00",[],{"content":126,"number":35,"size":135,"totalElements":136,"pageable":137,"last":19,"totalPages":18,"sort":139,"first":19,"numberOfElements":136,"empty":42},[127,128,129,130,131,132,133,134],{"id":65,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":67,"titleTranslated":32,"position":65,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":69,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":70,"titleTranslated":32,"position":71,"isDraft":42,"isPromoted":19,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":93,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":94,"titleTranslated":32,"position":93,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":96,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":97,"titleTranslated":32,"position":96,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":99,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":100,"titleTranslated":32,"position":99,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":102,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":103,"titleTranslated":32,"position":102,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":71,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":105,"titleTranslated":32,"position":106,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},{"id":78,"brandId":18,"sectionId":66,"categoryId":32,"categoryName":32,"categoryNameTranslated":32,"title":79,"titleTranslated":32,"position":78,"isDraft":42,"isPromoted":42,"isActive":19,"language":27,"englishVersion":19,"updatedAt":32,"isInternal":32,"includeForAi":32,"expiryTime":32,"priority":42,"isPriority":42},500,8,{"sort":138,"offset":35,"pageNumber":35,"pageSize":135,"paged":19,"unpaged":42},{"sorted":19,"empty":42,"unsorted":42},{"sorted":19,"empty":42,"unsorted":42}]