Headless website chat (for developers)
Experimental — not supported. This feature can change or be removed without notice, and our support team cannot help you build on it. This page is everything there is. It is written for a programmer or a coding agent.
Headless mode lets your own website draw the chat itself: your own bubbles, your own typing indicator, your own message box. The conversation still runs through omotto’s real website chat, with the same assistant, the same visitor identity and the same limits.
Your page loads a hidden frame of the chat in headless mode. The frame draws nothing. It talks to your page with the postMessage messages below.
Know what you are granting. A site that only shows the ordinary chat cannot read the conversation. A site that uses headless mode can: it receives every message and may send as the visitor. That is why headless mode is a separate switch, off by default.
Turning it on
Section titled “Turning it on”In omotto: Settings → Chat services → Website → Headless mode (developers).
- Website chat must be enabled and working.
- Only the chat’s Allowed domains may use headless mode, and only exact HTTPS entries such as
https://www.example.com. A wildcard entry such ashttps://*.example.comnever grants headless access, and a remotehttp://entry is ignored. - For local development only,
http://localhost(for examplehttp://localhost:4321) is accepted in Allowed domains.
Loading the frame
Section titled “Loading the frame”<iframe src="https://app.omotto.ai/p/<your-org>/chat-widget?omw_mode=headless&locale=en" title="" aria-hidden="true" tabindex="-1" style="position:absolute;width:0;height:0;border:0;visibility:hidden"></iframe><your-org>is your org slug, thedata-orgvalue in your install snippet.https://app.omotto.aiis the origin of the chat in your install snippet (normallyhttps://app.omotto.ai).locale(heoren) is a request. If the chat’s language is fixed in its settings, that wins.ready.localetells you which one you got.- Send
helloas soon as the frame loads (see below). It tells the frame your origin even when your page sends no referrer. - Loading the frame creates no conversation and no message. The first
sendcreates them. - Headless mode never shows or saves the chat’s greeting unless you opt in. Add
&greeting=1to the frame URL only if your page will display the greeting:ready.greetingthen carries its text, and the greeting is saved as the assistant’s opening turn, so the assistant knows it said it. Withoutgreeting=1,readyhas nogreetingand nothing is saved, so the assistant never thinks it said something your page did not show.
Envelope
Section titled “Envelope”Every message, both directions, is a plain object:
{ v: 1, type: string, ...fields }- The frame ignores messages whose
vis not1, whoseoriginis not one of your exact Allowed domains, or that do not come from its parent window. - The frame posts only to that exact origin, never
"*". - The host must check
event.origin === <app origin>andevent.source === iframe.contentWindow, and ignore unknowntypes and unknown fields. New fields and new event types are additive withinv: 1; a breaking change becomesv: 2.
Host → frame
Section titled “Host → frame”| type | fields | meaning |
|---|---|---|
hello |
— | Tells the frame your origin. Send it on iframe load and re-send it every 250 ms until ready or unavailable arrives (a frame that is still starting may miss the first one). If the frame is already ready, it answers every hello with ready again; duplicates are harmless. |
send |
ref: string (≤ 64 chars, yours), text: string (≤ 2000 chars) — or file_ids: string[] with an optional text (a caption) |
Send plain text as the visitor, or the attached files named by file_ids (each must be ready, see attach). Answered by exactly one send_ack or send_failed carrying the same ref. A send of several files is one command and one ack: send_ack.id is the id of the first file’s bubble, and only that bubble carries ref. A file_id that is unknown, already sent or detached, or not yet ready answers send_failed reason: "invalid", as do a repeated file_id and more ids than ready.uploads.max_files. After a send_failed for a send with files, the files stay attached with any reason except error (attachment events resume): send them again or detach them. This includes a retry that fails for any reason except error. With error they stay held, because the send may have landed: use retry, and a late send_ack may still arrive. |
press |
ref: string, message_id: string, option_id: string |
Tap a quick reply. Sent as the visitor’s message (marked as a button press). Answered like send. |
attach |
ref: string, files: File[] (1–20 File objects, passed by structured clone) |
Stage files for a later send. Answered by one attachment event per file, all carrying this ref. Check ready.capabilities for "attachments" first; without it every file answers attachment state: "failed" reason: "unavailable". |
detach |
file_id: string |
Drop a staged file. Never answered; an unknown id is ignored. |
react |
ref: string, message_id: string, emoji: string (≤ 16 characters) |
Set the visitor’s reaction on an assistant or agent bubble. emoji must be one of ready.allowed_reactions; an empty string "" removes the visitor’s own reaction. Answered by react_ack or react_failed with the same ref. While the chat is not ready (paused included) it answers react_failed reason: "not_ready"; the status event is the signal for paused, and paused as a react_failed reason appears only if the chat pauses while the write is in flight. Setting what is already set, or removing nothing, is acknowledged at once without a change. A bubble you do not have, a visitor bubble, an emoji that is not allowed, or a bubble the server no longer has answers react_failed reason: "invalid". |
retry |
ref: string, message_id: string |
Resend a visitor message whose send failed (message_id is the bubble’s id). Answered like send, under the new ref: the bubble’s later events carry the new ref, and a late send_ack for the old attempt is reported under it too. A bubble that is not a failed visitor send answers send_failed reason: "invalid"; one the chat cannot take up yet answers reason: "cooling_down". |
history |
— | Ask for the transcript again. Answered by history; ignored (no reply) before ready. Returns what the frame holds and does not refresh links. |
Send only after ready. Before it, send/press/retry answer send_failed
reason: "not_ready", react answers react_failed reason: "not_ready", and
attach answers one attachment state: "failed" reason: "not_ready" per file.
Commands sent after unavailable get no reply at all.
ref must be unique per command. A command with a valid ref but a bad body
(empty text, missing ids, no files) answers invalid in its own vocabulary.
A command without a valid ref (1–64 characters) cannot be answered and is
ignored; so is a detach without a file_id.
Frame → host
Section titled “Frame → host”| type | fields | meaning |
|---|---|---|
ready |
protocol: 1, locale: "he"|"en", dir: "rtl"|"ltr", org: { name }, history: Bubble[], has_visitor_turns: boolean, history_complete: boolean, capabilities: string[], allowed_reactions: string[], uploads: { available, max_files, max_file_bytes, content_types } | null, greeting?: string |
Sent within about 3 s of the frame accepting the identity: when the first transcript load settles, or when 3 s pass without it. history is the visitor’s earlier conversation (most recent 50 bubbles, oldest first) — not pushed as message events. Show it or not; that is your choice. history_complete: false means the transcript load failed or was still running, so history may be short; a history event may follow once the transcript loads, and its rows are never re-sent as message. ready can arrive again later (the visitor’s identity was reset); treat it as a fresh start and replace your transcript with its history. Feature-detect through capabilities, never through the version: it lists "attachments" (uploads are available; uploads gives max_files, max_file_bytes and the accepted content_types), "reactions" (allowed_reactions is the set of emoji you may send) "retry" and "streaming" (the frame can emit draft events, see Live replies; drafts arrive only when the business has live replies on, so never assume they will). Unknown entries are for later; ignore them. greeting is present only when you loaded the frame with greeting=1 and the chat has one. |
unavailable |
reason: "paused"|"unavailable"|"error" |
Sent instead of ready when the boot fails, or later if access is withdrawn (headless switched off, chat paused or removed). Fall back; the frame sends nothing further, and the results of commands still in flight (a react, an upload) are dropped rather than sent after it, and every command sent after it gets no reply. Before the first ready, exactly one of ready/unavailable is sent. |
typing |
active: boolean |
The assistant started / stopped preparing a reply. Every active: true is followed by an active: false — at the latest just before the reply’s message, on a send failure, or when the server’s typing window expires. |
draft |
turn: string, text: string |
The reply being written. text is the whole text so far, never a delta, so a dropped event is harmless: replace your partial bubble with it. It uses the same light markup as message text parts. Sent roughly every 80 ms per turn, and the latest text is always flushed right before draft_end. Never empty: when the partial has no text (for example only a card so far), you get draft_end instead. Only after ready. Check ready.capabilities for "streaming". See Live replies. |
draft_end |
turn: string |
The partial reply for that turn is gone: the assistant paused (for example to look something up), the reply was cancelled or failed, or the final reply arrived. Remove your partial bubble. When the final reply arrives, draft_end normally comes right before its message (after any typing active: false). Rarely the message arrives first: if an assistant message arrives while a partial is open, treat it as the reply and drop the partial. A paused turn that resumes sends new draft events with the same turn. |
message |
bubble: Bubble |
A new bubble after ready, as rows reach the frame (place bubbles by created_at if order matters). Includes the visitor’s own sends (with ref when it came from this host’s send). |
message_updated |
bubble: Bubble |
A bubble you already have changed: a reaction was added or removed, staff edited the text, or media became available. Replace by bubble.id. |
message_removed |
id: string |
Staff deleted a bubble. Remove it. |
send_ack |
ref, id |
The server accepted your send/press/retry. The visitor bubble you got with this ref (whose id was a temporary wm_… id) is now permanently id: re-key it. Later updates use id. |
send_failed |
ref, reason: "not_ready"|"cooling_down"|"rate_limited"|"paused"|"unavailable"|"invalid"|"error" |
The send did not happen. Nothing was posted. If you already got a message for this ref, mark or remove that bubble. Show a polite fallback (e.g. a contact link). With reason error the server may still have received it: a later send_ack for the same ref means it was sent after all. |
connection |
state: "online"|"offline" |
Both live transports are down (offline) or one recovered (online). Informational. |
attachment |
ref, file_id: string, name: string, state: "preparing"|"uploading"|"checking"|"ready"|"failed", progress?: number (0–1, only while uploading), reason?: string (only when failed) |
Progress of one staged file, answering attach (ref is the attach ref). file_id is what you pass to send and detach; it is stable. A new event is sent on each state change, and progress at most every 250 ms per file. ready means the file can be sent. A file that is sent or detached leaves silently. reason is a short token: a refusal (not_ready, unavailable, paused, error, invalid), too_many_files, or an upload failure such as too_large, invalid_file, infected, scan_delayed or network_error. The list is open: show a generic message for one you do not know. A file the chat refused before staging it (and every file of an attach that was refused) gets a one-off file_id starting with refused_ and state: "failed"; it cannot be sent or detached. |
react_ack |
ref |
The react was applied (or was already in place). |
react_failed |
ref, reason: "not_ready"|"cooling_down"|"rate_limited"|"paused"|"unavailable"|"invalid"|"error" |
The react did not happen. The bubble’s reactions are unchanged. |
status |
state: "ok"|"paused"|"cooling_down"|"offline", retry_after_ms?: number (only with cooling_down) |
Whether the chat can take a send right now. Sent on every change, not on a countdown; nothing is sent while it stays ok. After every ready, the frame re-sends the current status if it is not ok, so a repeated ready never leaves you assuming ok. If more than one applies, paused wins over offline, and offline over cooling_down. cooling_down is the wait after a rate-limit or a temporary refusal, and retry_after_ms says how long. Informational: a send during cooling_down still answers send_failed reason: "cooling_down". |
history |
history: Bubble[], has_visitor_turns: boolean |
Answer to a history request, or sent unsolicited (once, when the transcript loads after a ready with history_complete: false). Hosts may receive it at any time and should treat it as “replace your transcript”. |
Live replies. draft and draft_end are optional and additive: they flow only when the business has live replies switched on, so a host that ignores them still works, because every reply still arrives as a message. Show draft.text in a temporary partial bubble and replace it on every draft; remove it on draft_end, and the final message follows. Drafts are text only (cards appear in the final message), never appear in history, and are not sent after unavailable.
Bubble
Section titled “Bubble”interface Bubble { id: string // stable public id ("msg_…"); a pending visitor // send carries its client id until acked role: 'visitor' | 'assistant' | 'agent' // agent = a human teammate created_at: string // ISO 8601 ref?: string // your send/press ref, on visitor bubbles you sent progress?: true // a short "let me check" note ahead of a slow reply edited?: true parts: Part[] // in display order; never empty buttons?: LinkButton | QuickReplies reactions?: { emoji: string; mine: boolean }[] // mine = the visitor's own}
type Part = | { type: 'text'; text: string } | { type: 'card'; title?: string; note?: string; price?: string; url?: string; image_url?: string } // a product card | { type: 'media'; kind: 'image' | 'file' | 'audio'; state: 'pending' | 'ready' | 'unavailable'; url?: string; filename?: string; width?: number; height?: number }
interface LinkButton { kind: 'link'; label: string; url: string }interface QuickReplies { kind: 'quick_replies'; options: { id: string; label: string }[] }- Every URL is absolute. Card and button URLs are
http(s)only; anything else is dropped. Media and card image URLs are signed and valid for about 10 minutes, and v1 has no refresh command. If one expires, re-render without it or reload the frame. - A media part’s
stateisready(it has aurl),pending(still being scanned; a latermessage_updatedadds theurl), orunavailable(gone for good, never arrives: show a placeholder, do not wait). - A product-card message and a composed response (text interleaved with cards)
both arrive as one bubble whose
partsare already flattened in order. - Quick replies are only ever on the newest bubble; when a newer one arrives the older bubble is re-sent via
message_updatedwithout them. Apresson any other bubble answerssend_failedreason: "invalid". - Reactions are what the visitor sees in the ordinary chat: grouped by emoji,
with no actor beyond
mine. - Only what the ordinary chat shows crosses: no internal notes, team threads, other conversations, or other visitors.
Example sequence
Section titled “Example sequence”host ⇢ iframe loads /p/your-org/chat-widget?omw_mode=headless&locale=enframe → {v:1,type:"ready",protocol:1,locale:"en",dir:"ltr",org:{name:"omotto"}, history:[],has_visitor_turns:false,history_complete:true}host → {v:1,type:"send",ref:"h1",text:"Let's book a demo meeting"}frame → {v:1,type:"message",bubble:{id:"wm_01J…",role:"visitor",ref:"h1",parts:[{type:"text",text:"Let's book a demo meeting"}],…}}frame → {v:1,type:"send_ack",ref:"h1",id:"msg_01J…"}frame → {v:1,type:"typing",active:true}frame → {v:1,type:"draft",turn:"t_01K…",text:"Happily! Which"} (only with live replies on)frame → {v:1,type:"draft",turn:"t_01K…",text:"Happily! Which works better for you?"}frame → {v:1,type:"typing",active:false}frame → {v:1,type:"draft_end",turn:"t_01K…"}frame → {v:1,type:"message",bubble:{id:"msg_01K…",role:"assistant", parts:[{type:"text",text:"Happily! Which works better for you?"}], buttons:{kind:"quick_replies",options:[{id:"msg_01K…:0",label:"Tomorrow morning"}, {id:"msg_01K…:1",label:"Thursday afternoon"}]}}}host → {v:1,type:"press",ref:"h2",message_id:"msg_01K…",option_id:"msg_01K…:1"}frame → … send_ack h2, typing true/false, message …frame → {v:1,type:"message_updated",bubble:{id:"msg_01J…",…,reactions:[{emoji:"👍",mine:false}]}}A file with a caption (ready listed "attachments"; file is a File from an <input type="file">):
host → {v:1,type:"attach",ref:"a1",files:[file]}frame → {v:1,type:"attachment",ref:"a1",file_id:"u_7",name:"menu.jpg",state:"preparing"}frame → {v:1,type:"attachment",ref:"a1",file_id:"u_7",name:"menu.jpg",state:"uploading",progress:0.4}frame → {v:1,type:"attachment",ref:"a1",file_id:"u_7",name:"menu.jpg",state:"checking"}frame → {v:1,type:"attachment",ref:"a1",file_id:"u_7",name:"menu.jpg",state:"ready"}host → {v:1,type:"send",ref:"h3",file_ids:["u_7"],text:"Is this the current menu?"}frame → {v:1,type:"message",bubble:{id:"wm_01L…",role:"visitor",ref:"h3",parts:[{type:"media",kind:"image",state:"pending"},…],…}}frame → {v:1,type:"send_ack",ref:"h3",id:"msg_01L…"}host → {v:1,type:"react",ref:"r1",message_id:"msg_01K…",emoji:"👍"}frame → {v:1,type:"react_ack",ref:"r1"}Failure and timeouts (host side)
Section titled “Failure and timeouts (host side)”- Headless mode switched off, a wrong org slug, and too many loads from one
network all return the same empty, script-less document. A page that is not in
Allowed domains is refused by the browser, which does not render the frame at
all. Either way you get
load(or not even that) and then nothing. Wait about 6 s forreadyorunavailable(the frame itself answers within about 3 s of its identity call), then fall back. Areadythat arrives after you have fallen back may be ignored, or adopted if your UI can switch back. - After
ready, asendwith nosend_ack/send_failedwithin ~20 s should be treated as failed on your side; the frame retries nothing on its own. - The visitor’s identity is kept in the frame’s own browser storage, exactly as for the ordinary chat. A browser that blocks frame storage, or a private window, starts a new visitor on each load.
- Rate limits and abuse protection are the same as for the ordinary chat.
Quick start
Section titled “Quick start”A complete minimal host script. Add your own rendering where the comments say so.
const APP = 'https://app.omotto.ai' // the origin of the chat in your install snippetconst ORG = 'your-org' // your org slug, the data-org value in your install snippetconst frame = document.createElement('iframe')frame.src = `${APP}/p/${ORG}/chat-widget?omw_mode=headless&locale=en`frame.setAttribute('aria-hidden', 'true')frame.tabIndex = -1frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'
let ready = falselet partial = null // your one live partial bubble element, if anylet seq = 0let helloTimerconst post = (msg) => frame.contentWindow.postMessage({ v: 1, ...msg }, APP)const fallback = () => { ready = false; /* show your own contact link */ }const settled = () => { clearInterval(helloTimer); clearTimeout(giveUp) }const giveUp = setTimeout(() => { if (!ready) { clearInterval(helloTimer); fallback() } }, 6000)
window.addEventListener('message', (event) => { if (event.origin !== APP || event.source !== frame.contentWindow) return const msg = event.data if (!msg || msg.v !== 1) return switch (msg.type) { case 'ready': // can repeat (repeated hello, reset visitor): replace, never append ready = true; settled(); /* replace your whole transcript with msg.history */ break case 'history': /* replace your whole transcript with msg.history */ break case 'unavailable': settled(); fallback(); break // also after ready case 'typing': /* show or hide your indicator: msg.active */ break case 'draft': // only with live replies on, and only if ready listed 'streaming' // msg.text is the WHOLE reply so far, not a delta: replace one partial bubble's text with it if (!partial) partial = createBubble('assistant') // your own bubble element partial.textContent = msg.text break case 'draft_end': // paused, cancelled, failed, or the final reply is next partial?.remove(); partial = null // if a reply follows, it arrives as 'message' break case 'message': // append msg.bubble (place by created_at if order matters) if (msg.bubble.role !== 'visitor') { partial?.remove(); partial = null } // a reply replaces any partial break case 'message_updated': /* replace the bubble with id msg.bubble.id */ break case 'message_removed': /* remove the bubble with id msg.id */ break case 'send_ack': /* the bubble you drew with ref msg.ref now has id msg.id: re-key it */ break case 'send_failed': /* the bubble with ref msg.ref was not sent; tell the visitor (msg.reason) */ break case 'attachment': /* update the chip for msg.file_id: msg.state, msg.progress, msg.reason */ break case 'react_ack': case 'react_failed': /* msg.ref is your react; failed: undo your optimistic reaction */ break case 'status': /* msg.state: disable your send box unless 'ok' (msg.retry_after_ms) */ break default: break // ignore anything you do not know }})
// A frame that is still starting may miss the first hello: repeat it until it answers.frame.addEventListener('load', () => { post({ type: 'hello' }) helloTimer = setInterval(() => post({ type: 'hello' }), 250)})document.body.appendChild(frame)
// `ref` must be unique per send; the frame answers each one with send_ack or send_failed.export function send(text) { if (!ready) return false post({ type: 'send', ref: `s${++seq}`, text }) return true}
export function press(bubble, option) { post({ type: 'press', ref: `p${++seq}`, message_id: bubble.id, option_id: option.id })}
// Attach, wait for `attachment` state 'ready' for each file_id, then send them.// Only if the ready event listed 'attachments' in its capabilities.export function attach(files) { post({ type: 'attach', ref: `a${++seq}`, files }) }export function sendFiles(fileIds, caption) { post({ type: 'send', ref: `f${++seq}`, file_ids: fileIds, text: caption })}
// emoji must be in ready.allowed_reactions; '' removes the visitor's own reaction.export function react(bubble, emoji) { post({ type: 'react', ref: `r${++seq}`, message_id: bubble.id, emoji })}