Build a custom chat UI
Use Amomic-AI's browser-safe headless client with your own components while keeping the web channel's deployment, conversations, lead capture, analytics, and security boundaries.
Use your own buttons, message bubbles, layout, and state management while Amomic-AI keeps owning the agent runtime. The headless client is the custom-UI mode of the same web deployment — not another API product and not another channel.
The smallest installation is one line:
<script type="module">import { createChat } from "https://services.amomic.in/headless.js"; window.amomicChat = await createChat({ bot: "wgt_01JA7X4M9Q2T6W1Y5R8N3K0PVA" });</script>
Replace the wgt_ value with your own. The call returns a client after it has verified the current website and created a short-lived visitor session.
Where to get it: owners and admins open the agent, click Deploy → Web widget, and go to the Install step. Under Building your own chat interface? is the one-line snippet with your id already in it. The same box appears after Set live. The widget must be live and your site listed in Allowed websites — the same rules as the hosted widget, described in Install the widget.
Never put an API key in frontend code
Do not call /api/v1 from a browser and do not pass an Amomic-AI API key to the headless client. A bundled key is visible to every visitor and grants workspace-level capabilities. The headless client uses a short-lived session that can only act as one anonymous visitor on one web deployment from one allowed origin.
What you own and what stays connected #
| Your frontend owns | Amomic-AI still owns |
|---|---|
| Layout, bubbles, composer, loading states, accessibility, animation | Agent version and active web deployment |
| When to show suggested questions or lead fields | Knowledge retrieval, tools, actions, and response generation |
| How to render markdown and streaming text | Conversation creation, history, inbox, contacts, and analytics |
| Local application state around the client | Domain policy, visitor sessions, ownership checks, and rate limits |
The hosted widget and headless client use the same anonymous visitor and conversation storage keys by default. A site can replace the hosted widget with a custom interface without silently splitting a returning visitor into a second identity.
A complete minimal interface #
<div id="messages" aria-live="polite"></div><form id="composer"><input id="message" autocomplete="off" aria-label="Message" /><button>Send</button></form><script type="module">import { createChat } from "https://services.amomic.in/headless.js";const chat = await createChat({ bot: "wgt_01JA7X4M9Q2T6W1Y5R8N3K0PVA" });const messages = document.querySelector("#messages");const form = document.querySelector("#composer");const input = document.querySelector("#message");form.addEventListener("submit", async (event) => {event.preventDefault();const text = input.value.trim();if (!text) return;messages.insertAdjacentHTML("beforeend", `<p><b>You:</b> ${escapeHtml(text)}</p>`);input.value = "";const reply = document.createElement("p");reply.innerHTML = "<b>Assistant:</b> ";messages.append(reply);let answer = "";try {await chat.send(text, {onEvent(event) {if (event.type === "delta") {answer += event.text;reply.textContent = `Assistant: ${answer}`;}if (event.type === "status") {reply.dataset.status = event.status;}},});} catch (error) {reply.textContent = error.message;}});function escapeHtml(value) {const node = document.createElement("div");node.textContent = value;return node.innerHTML;}</script>
This example renders streamed text as plain text, which is the safest default. If you render markdown, sanitize the generated HTML before inserting it. Never pass model output directly to innerHTML.
Create the client #
import { createChat } from "https://services.amomic.in/headless.js";const chat = await createChat({bot: "wgt_01JA7X4M9Q2T6W1Y5R8N3K0PVA",});
createChat(options) accepts:
| Option | Required | Meaning |
|---|---|---|
bot |
Yes | The public wgt_ deployment id. It selects a bot; it is not a secret. |
baseUrl |
No | Override the service origin for a dev environment. Production normally omits it. |
visitorId |
No | Supply your own anonymous id, 8–64 characters. Do not put an email or phone number here. |
conversationId |
No | Resume a known conversation. The server verifies that it belongs to this bot and visitor. |
storage |
No | A Storage-compatible object. Defaults to localStorage; pass null for memory-only sessions. |
After creation:
chat.config; // public_id, name, company_name, welcome_message,// suggested_questions, theme, launcher, avatar_url,// image_upload, lead_capture, greeting_bubble,// allow_restart, session_continuity, phone_placeholderchat.visitorId; // anonymous browser identitychat.conversationId; // null until the first response supplies one
The client never exposes its signed visitor token as a public property.
Send and stream a message #
const result = await chat.send("Where is my order?", {signal: abortController.signal,onEvent(event) {// update your UI},});console.log(result.text, result.conversationId);
Events arrive in this order:
| Event | Important fields | UI use |
|---|---|---|
meta |
conversation_id |
The SDK stores it automatically. |
status |
status |
Show "Searching…" or "Working…" before text arrives. Zero or more. |
delta |
text |
Append text to the current assistant bubble. Zero or more. |
done |
text, metadata |
Replace the accumulated value with the authoritative final text. |
error |
message |
The promise rejects with AmomicChatError. |
Pass an AbortSignal when a route change, close button, or explicit "Stop" action should cancel the browser stream. Cancelling the stream does not delete turns the service already persisted.
Images #
Image upload uses the same plan entitlement and 5 MB limit as the hosted widget.
await chat.sendImage(file, "What is shown here?", {onEvent(event) {if (event.type === "delta") renderDelta(event.text);},});
Accepted formats are JPEG, PNG, WEBP, and GIF. Check chat.config.image_upload before showing an upload control; the server enforces the entitlement even if a custom UI ignores that flag.
Lead capture #
The deployment's lead-capture configuration is available at chat.config.lead_capture. Your UI decides when and how to render it, but the server remains authoritative about enabled and required fields.
const rules = chat.config.lead_capture;if (rules.enabled) {await chat.submitLead({name: "Asha Rao",email: "asha@example.com",phone: "+91 98765 43210",consent: true,});}
Disabled fields are discarded, required fields are checked server-side, and phone/email normalization matches the hosted widget. A successful submission is linked to a matching contact (left unchanged) or creates a new one, and stamps an existing conversation so it appears correctly in the inbox. See What happens to a submission.
For timing: "before_chat", show the form before enabling your composer. For after_first_reply, show it after the first done event. See Capture leads for the product behaviour behind the form.
Reset and continuity #
chat.reset();
reset() forgets only the current conversation. The anonymous visitor id remains, so lead identity and unique-visitor analytics stay coherent.
By default the SDK uses the same keys as the hosted widget:
amomic_vid— anonymous visitor idamomic_cid_<widget-id>— current conversationamomic_lead_<widget-id>— successful lead submission (the hosted widget also records a skip here)
Showing earlier messages #
The client has no call that returns earlier messages. conversationId survives a reload (in storage), so a visitor who comes back keeps talking in the same conversation and the agent still has its context — but your interface starts with an empty message list. If your UI needs to show the earlier messages after a reload, keep them in your own state (for example in sessionStorage). The hosted widget's Remember conversations setting applies to the hosted widget only.
Pass storage: null for a kiosk, privacy-sensitive flow, or test that must start fresh on every load. Pass a sessionStorage-like object when continuity should stop at the end of the tab.
Error handling #
Failures reject with AmomicChatError:
try {await chat.send("Hello");} catch (error) {console.log(error.code); // unauthenticated, permission_denied, rate_limited, …console.log(error.status); // HTTP statusconsole.log(error.details); // structured, safe-to-display contextconsole.log(error.requestId); // useful when contacting support}
| Code | Usually means | What your UI should do |
|---|---|---|
permission_denied |
Domain is not allowed, deployment is not live, or conversation ownership failed. | Do not retry in a loop. Fix deployment configuration. |
unauthenticated |
Visitor session expired or became invalid during key rotation. | The SDK renews once automatically. Surface the error if the retry also fails. |
rate_limited |
Visitor or network sent too many requests. | Disable send briefly and preserve the draft. |
invalid_argument |
Empty/long message, invalid lead field, or unsupported image. | Put the returned message beside the relevant control. |
provider_error |
A model or connected provider is temporarily unavailable. | Offer a retry without duplicating the user's bubble. |
When your workspace is out of allowance #
If your workspace has used up its plan allowance, a new conversation doesn't reach the agent. The stream answers with this text instead, as an ordinary reply:
Sorry — the assistant isn't available right now. Please reach out to the team directly and they'll be happy to help.
Render it like any other answer. Conversations already in progress carry on.
The stream answers with the same text on every message, including in conversations already in progress, while the workspace is suspended for an unpaid plan invoice. It stops by itself once the invoice is paid.
Security model #
The browser flow is deliberately narrower than the developer API:
POST /w/{widget}/sessionrequires a real HTTP(S)Originthat matches the deployment's website or allowed domains.- The service returns a signed session valid for 20 minutes and bound to the widget id, visitor id, and exact origin.
- Every message, image, and lead request carries that session as a Bearer token.
- The service verifies the signature and scope, then rechecks the current domain policy and live deployment status.
- A supplied conversation id is accepted only when its tenant, bot, channel, and visitor all match.
- Shared rate counters limit session creation, each visitor, and each bot/IP pair across service instances.
A session token is visible to JavaScript on the page. That is expected: it is a least-privilege browser capability, not a workspace secret. An XSS bug on your site can act as that visitor until the short expiry, but it cannot list contacts, read other conversations, manage deployments, or call the developer API. Keep your own frontend free of XSS and use a restrictive Content Security Policy.
Origin checks do not authenticate a server
Non-browser software can forge an Origin header. The allowlist prevents another website from using your deployment through a visitor's browser and catches installation mistakes; it is not a secret. Cross-instance IP and visitor limits are the abuse backstop. Use the authenticated developer API only from your own backend when you need server-to-server capabilities.
A practical CSP for the SDK needs the service in both directives:
Content-Security-Policy:script-src 'self' https://services.amomic.in;connect-src 'self' https://services.amomic.in;
Avoid 'unsafe-inline' in production. Put the import in your own module file when enforcing a strict script-src.
Direct protocol #
Use the SDK unless you have a runtime it cannot support. If you implement the protocol yourself, preserve this sequence:
POST /w/{widget}/sessionbody: { "visitor_id": "v_..." }browser supplies OriginPOST /w/{widget}/messagesAuthorization: Bearer <session token>body: { "visitor_id": "v_...", "conversation_id": null, "text": "Hello" }response: text/event-stream
Image messages use multipart POST /w/{widget}/messages/vision; lead details use JSON POST /w/{widget}/lead. The visitor_id and Origin must remain identical to the session scope. Do not persist the visitor token yourself; renew it through the session endpoint before expiry.
Next #
Use the hosted UI instead
Install the one-line widget when you do not need to own the rendering.
Configure the experience
Greeting, suggestions, appearance and image upload feed the public config available to either UI.
Capture identity
Lead capture explains timing, validation, contact resolution, and consent.
Operate it safely
Security & data covers browser keys, scopes, retention, and incident reporting.