Skip to content

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.

Open web deployment

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:

Paste before </body>
<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.

The custom UI snippet in the Install step.

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 #

custom-chat.html
<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 #

Node.js
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:

Node.js
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_placeholder
chat.visitorId; // anonymous browser identity
chat.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 #

Node.js
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.

Node.js
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.

Node.js
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 #

Node.js
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 id
  • amomic_cid_<widget-id> — current conversation
  • amomic_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:

Node.js
try {
await chat.send("Hello");
} catch (error) {
console.log(error.code); // unauthenticated, permission_denied, rate_limited, …
console.log(error.status); // HTTP status
console.log(error.details); // structured, safe-to-display context
console.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:

  1. POST /w/{widget}/session requires a real HTTP(S) Origin that matches the deployment's website or allowed domains.
  2. The service returns a signed session valid for 20 minutes and bound to the widget id, visitor id, and exact origin.
  3. Every message, image, and lead request carries that session as a Bearer token.
  4. The service verifies the signature and scope, then rechecks the current domain policy and live deployment status.
  5. A supplied conversation id is accepted only when its tenant, bot, channel, and visitor all match.
  6. 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:

Shell
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:

Text
POST /w/{widget}/session
body: { "visitor_id": "v_..." }
browser supplies Origin
POST /w/{widget}/messages
Authorization: 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.