Chat widget
Add an AI-powered chat widget. Enable in init, control with flat methods that are safe to call before the widget loads.
Add an AI-powered chat widget to your site. Enable it in init and control it with flat methods like vt.openChat() and vt.sendChatMessage(). All methods are safe to call immediately after init() — the SDK queues calls internally until the feature is ready.
#Enable
vt.init('YOUR_PROJECT_TOKEN', {
api_host: 'https://www.vtilt.com',
chat: { enabled: true },
})The widget script lazy-loads the first time it's needed when you use the default array.js snippet. Until then, the bubble is server-styled by the SDK config; the heavyweight UI only ships when the user opens the panel.
#Open, close & toggle
// Open the chat widget (lazy-loads the full widget if needed)
vt.openChat()
// Close the chat widget
vt.closeChat()
// Toggle between open and closed
vt.toggleChat()#Show & hide bubble
// Show the chat bubble (does NOT open the widget)
vt.showChat()
// Hide the chat bubble entirely
vt.hideChat()#Send messages
One method handles sending, routing, and opening the panel (open defaults to true):
// Active conversation — opens the panel, then sends (plain text).
// If no conversation is active (first visit, empty inbox, or the user
// is on the channel list), a new conversation is created automatically.
vt.sendChatMessage('Follow-up question')
// Markdown (same styling as AI replies in the widget and inbox)
vt.sendChatMessage({ markdown: '**Billing** question about invoice `#1042`' })
// Or plain string + format option
vt.sendChatMessage('**Billing** question', { format: 'markdown' })
// Limited HTML (sanitized on display)
vt.sendChatMessage({
html: '<p>See our <a href="/pricing">pricing</a> page.</p>',
})
// Send without opening the panel
vt.sendChatMessage('Follow-up question', { open: false })
// Force a brand-new conversation even when one is already active
vt.sendChatMessage('I need help with billing', { channel: 'new' })
// Message in a specific existing channel
vt.sendChatMessage('Ping', { channel: 'CHANNEL_UUID' })
// Form / CTA injection — AI knows the visitor did not type this manually
vt.sendChatMessage('Interested in the enterprise plan', {
source: 'landing_form',
channel: 'new',
})| Option | Default | Description |
|---|---|---|
channel | — | 'new' always creates a channel; a UUID selects one. Omit to use the active conversation, or create one automatically if there is none. |
open | true | Open the widget panel before sending. Pass open: false to send in the background. |
format | text | When the first argument is a string: text, markdown, or html. |
source | — | Slug key (e.g. landing_form) marking the message as automatically sent from your site — form submit, CTA, etc. Stored as message_source on the message so the AI can respond differently than for typed chat. Letters, numbers, _, -, max 64 characters. |
Note: Pair source with instructions in your project AI system prompt (Chat settings in the dashboard). For example: When the visitor's message has source landing_form, thank them for the demo request and ask one qualifying question.
The first argument can be a string (plain by default), { markdown: '...' }, { html: '...' }, or { text: '...' } for explicit plain text.
#Delivery reliability
vt.sendChatMessage() returns a Promise<void> that resolves when your message row is saved on the server (not when the AI finishes replying). On poor networks the widget keeps the bubble visible, retries automatically, and queues unsent messages in sessionStorage so a refresh in the same tab can finish delivery.
Important: If you send from a form and immediately redirect, await the promise before navigating so the browser can finish the request:
await vt.sendChatMessage('Enterprise plan inquiry', {
source: 'landing_form',
channel: 'new',
})
window.location.href = '/thank-you'If you redirect without awaiting, the outbox still retries on the next page load in the same tab, but awaiting is the reliable pattern for form submissions.
Optional callback when the user row is persisted (before the AI stream ends):
vt.init('YOUR_PROJECT_TOKEN', {
chat: {
enabled: true,
onMessageDelivered: ({ channelId, messageId }) => {
console.log('Saved', channelId, messageId)
},
},
})#API reference
#Widget chrome
| Method | Description |
|---|---|
vt.openChat() | Open the widget panel. Lazy-loads the full chat script on first call. |
vt.closeChat() | Close the widget panel. |
vt.toggleChat() | Toggle the widget. If not yet loaded, behaves like openChat(). |
vt.showChat() | Show the chat bubble without opening the widget. |
vt.hideChat() | Hide the chat bubble entirely. |
#Messaging
| Method | Description |
|---|---|
vt.sendChatMessage(content, options?) | Send a message; returns a Promise that resolves when the user message is persisted. Queued if the widget is not loaded yet. |
#Bubble position and offset
Chat options (position, bubble.offset, theme, and the rest) can be changed at runtime with the same vt.updateConfig() used for all SDK settings — pass a partial patch; top-level keys shallow-merge, and nested chat deep-merges so you do not wipe other chat fields from init. See Install — Update config at runtime.
The bubble sits in the bottom-right corner by default (position: 'bottom-right'). Switch to the left with position: 'bottom-left'.
To clear host floating buttons, cookie banners, or mobile tab bars, set a pixel inset with bubble.offset instead of custom CSS — offsets are applied inside the widget and survive UI updates.
vt.init('YOUR_PROJECT_TOKEN', {
api_host: 'https://www.vtilt.com',
chat: {
enabled: true,
position: 'bottom-right',
bubble: {
offset: { bottom: 88, right: 24 },
},
},
})| Field | Default | Description |
|---|---|---|
bottom | 20 | Distance from the bottom of the viewport (px). |
right | 20 | Used when position is bottom-right (px). |
left | 20 | Used when position is bottom-left (px). |
Omit a field to keep the default for that edge. Pass bubble: { offset: null } inside vt.updateConfig({ chat: { bubble: { offset: null } } }) to reset all edges to 20px.
#Change bubble position on a specific route
Typical case: A fixed cart, help, or cookie-control button sits in the bottom-right on /checkout or /pricing. The chat bubble uses the same corner by default and overlaps it. Raise the bubble on those routes only — without overriding vTilt styles in your own CSS (those overrides often break when the widget UI updates).
When the route changes, patch only the chat slice of SDK config:
vt.updateConfig({
chat: { bubble: { offset: { bottom: 88, right: 24 } } },
})The chat object deep-merges with options from init, so you only send what changed. Other top-level SDK keys (autocapture, capture_pageview, …) are untouched. Both the lightweight pre-load bubble and the full widget apply the new inset immediately (unless bubble.draggable is enabled — see above).
- Set your default offset in
init(optional — defaults are 20px on each edge). - On each navigation, call
vt.updateConfig({ chat: { bubble: { offset } } })with route-specific values. - When leaving a conflicting route, pass the default offset again (or
offset: nullto reset to 20px).
// Shared helper — import from a small module in your app
const DEFAULT_BUBBLE_OFFSET = { bottom: 20, right: 20, left: 20 }
/** Routes where a host FAB occupies the bottom-right corner. */
const ROUTES_WITH_FAB = ['/checkout', '/pricing']
const RAISED_BUBBLE_OFFSET = { bottom: 88, right: 24, left: 20 }
export function syncChatBubbleForPath(pathname: string): void {
const needsLift = ROUTES_WITH_FAB.some(route => pathname.startsWith(route))
vt.updateConfig({
chat: {
bubble: {
offset: needsLift ? RAISED_BUBBLE_OFFSET : DEFAULT_BUBBLE_OFFSET,
},
},
})
}Wire the helper to your router so it runs once per navigation (in useEffect with [pathname], or router.afterEach in Vue/Angular). Do not call vt.updateConfig() on every render.
Add a client component next to your existing VTiltProvider (same layout that calls vt.init):
// app/providers/vtilt-chat-bubble.tsx
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { syncChatBubbleForPath } from '@/lib/vtilt-chat-bubble'
export function VTiltChatBubbleLayout() {
const pathname = usePathname()
useEffect(() => {
syncChatBubbleForPath(pathname)
}, [pathname])
return null
}// app/layout.tsx
import { VTiltProvider } from './providers/vtilt-provider'
import { VTiltChatBubbleLayout } from './providers/vtilt-chat-bubble'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<VTiltProvider />
<VTiltChatBubbleLayout />
{children}
</body>
</html>
)
}// lib/vtilt-chat-bubble.ts — same helper as above (DEFAULT_BUBBLE_OFFSET, syncChatBubbleForPath)Tip: If the overlap is severe or the launcher should live inside your nav, hide the default bubble (bubble: { visible: false }) and open chat from your own button with vt.openChat() — see Hidden bubble with programmatic show.
#Hidden bubble with programmatic show
Start with the bubble hidden and show it when ready — for example after onboarding or on a specific page.
vt.init('YOUR_PROJECT_TOKEN', {
api_host: 'https://www.vtilt.com',
chat: {
enabled: true,
bubble: { visible: false },
},
})
// Show the bubble programmatically (e.g. after onboarding)
vt.showChat()#Event callbacks
React to widget lifecycle events with callbacks in the chat config.
vt.init('YOUR_PROJECT_TOKEN', {
api_host: 'https://www.vtilt.com',
chat: {
enabled: true,
onWidgetOpen: () => {
console.log('Chat opened')
},
onWidgetClose: ({ timeOpenSeconds, messagesSent }) => {
console.log(
`Chat closed after ${timeOpenSeconds}s, ${messagesSent} messages`,
)
},
onConversationStart: ({ channelId, aiMode }) => {
console.log(`New conversation ${channelId}, AI: ${aiMode}`)
},
},
})| Callback | Payload | Description |
|---|---|---|
onWidgetOpen | — | Fired when the widget panel opens. |
onWidgetClose | { timeOpenSeconds, messagesSent } | Fired when the widget panel closes, with session stats. |
onConversationStart | { channelId, aiMode } | Fired when a new conversation begins. |
onMessageSent | { channelId, messageId } | Fired when the user sends a message. |
onMessageReceived | { channelId, messageId, senderType } | Fired when a message is received from AI or an agent. |