-
Notifications
You must be signed in to change notification settings - Fork 15
Web Chat Developer Guide
The engineering detail behind the Web chat channel: the tables, the public endpoints, the security model in code, and how it reuses the WhatsApp channel spine. Office hours, AI answers and escalation are now wired; the one remaining piece — sending the offline reply out from the company mailbox — has its own resume plan at the bottom.
Mental model. A web chat widget is the self-hosted twin of a WhatsApp number. It drives one
messaging_channelsrow (channel_type = 'webchat',provider = 'freeitsm'). There is no external provider and no credentials; "sending" a reply just persists a row that the visitor's browser polls for. Everything downstream — ticket creation, theemailsthread, the reading-pane composer, multi-tenancy routing — is reused unchanged. If you're touching this, the first instinct should always be "how does WhatsApp already do this?"
Four tables. webchat_widgets and webchat_conversations are the spine; webchat_messages is the pre-ticket transcript for AI (deflect) conversations and the mirror target for analyst replies shown in an AI widget.
One row per widget, joined 1:1 to its messaging_channels row (channel_id, unique). Company routing and the active flag live on the channel row; this table holds only what the browser needs plus the behaviour toggles.
| Column | Purpose |
|---|---|
channel_id |
FK → messaging_channels (unique, ON DELETE CASCADE). |
widget_key |
Public key in the embed snippet (unique). Not a secret. |
allowed_origins |
Newline/comma list of permitted site origins. Empty = any (dev only). |
greeting, accent_colour, launcher_text, offline_message
|
Look & copy. |
require_email |
Pre-chat name+email gate (default 1). |
business_calendar_id |
FK → sla_calendars (ON DELETE SET NULL). NULL = always open. Evaluated by webchatIsOpenNow(). |
email_when_away |
Email a reply to the visitor if they've left. (offline-email delivery still pending — see below) |
ai_enabled, ai_mode (assist|deflect), ai_offer_agent, ai_offer_email
|
AI answers + escalation. Live. |
| Column | Purpose |
|---|---|
channel_id |
FK → messaging_channels (ON DELETE CASCADE). |
token |
The per-conversation capability (unique), held in the visitor's browser. |
ticket_id |
Set lazily on the first message (AI-off) or on escalation (deflect). One conversation → one ticket. |
visitor_name, visitor_email
|
From the pre-chat form. |
visitor_ip |
Rate limiting only. |
created_datetime, last_activity_datetime
|
Timestamps (written with UTC_TIMESTAMP()). |
The chat held before (and if) a conversation becomes a ticket — used in AI deflect mode, where the AI can answer without ever raising a ticket. sender ∈ visitor|ai|agent|system. source_email_id is the dedup key when an analyst reply (in emails) is mirrored in so the widget can show it. Not written for plain AI-off widgets (those go straight to the ticket's emails thread).
-
messaging_channels— the channel spine (channel_type='webchat',provider='freeitsm'). -
emails— inbound/outbound messages,channel='webchat',channel_idset. The analyst's canonical thread. -
ticket_origins— a seeded "Web chat" origin. -
sla_calendars(+_hours,_holidays) — office hours. -
knowledge_articles— the AI answer source.
-
FreeitsmProvider(includes/messaging/FreeitsmProvider.php, registered inmessagingProvider()): satisfies the sharedMessagingProvidercontract.sendMessage()does no network I/O — it just mints a synthetic id; the outboundemailsrow is what the visitor polls. This is why the existingapi/messaging/send_message.phpreply path works for webchat with no special-casing. -
webchatIngestMessage()(includes/webchat/webchat.php): the webchat twin of the WhatsApp ingest. Creates/append the ticket, stores the message inemailswithchannel='webchat', seeds the requester from the real visitor email. Threading is by the conversation'sticket_id, not byfrom_address(unlike WhatsApp, whosenormaliseChannelIdentifieris phone-shaped). -
24-hour window bypass: webchat has no provider service window, so it's exempt in both
send_message.phpandget_ticket_thread.php($ticketChannel === 'webchat'→ always "open"). -
"Web chat" ticket origin + subject label:
getChannelOriginId()andbuildChannelSubject()special-casewebchat→'Web chat'.
All under api/webchat/, unauthenticated (a website visitor hits them), and all routed through the guard in includes/webchat/public.php.
| Endpoint | Method | Does |
|---|---|---|
config.php |
GET | Returns the widget's public look-and-feel for widget.js. |
start.php |
POST | Creates a conversation, returns the token. No ticket yet. |
send.php |
POST | Handles a visitor message: plain widgets ingest → ticket; AI widgets branch open/closed then assist/deflect (see below). |
poll.php |
GET | Returns messages since after id — the delivery mechanism. For AI widgets, mirrors analyst replies then reads webchat_messages. |
escalate.php |
POST | "Talk to a person" / "email me back" — promotes a deflect chat to a ticket via webchatPromoteToTicket(). |
widget.js is a static, dependency-free file served from the same directory; it reads its own key + API base from its <script> tag and renders the launcher + panel in a Shadow DOM.
includes/webchat/public.php is where the safety lives:
-
webchatPublicResolve($conn, $key)— loads the widget by key, checks it's active, enforces the origin allowlist, sets scoped CORS headers, or exits with JSON. Treat its return as authorised. -
webchatRequestOrigin()—Originheader, falling back to theRefererorigin when absent (same-origin embeds send noOrigin; this bit us in the local demo where the site and FreeITSM were bothhttp://localhost). The allowlist is a deterrent, not crypto. -
webchatLoadConversation($conn, $token, $channelId)— a token is only valid for its own widget's channel; a token from widget A can't be replayed on widget B. -
webchatRateLimitStart()/webchatRateLimitSend()— per-IP new conversations and per-conversation messages, per minute.
The rule to remember: the widget key only lets you start a conversation from an allowed origin. Reading or posting an existing conversation needs its token. So the key leaking (it always does — it's in page source) never exposes anyone's chat.
widget.js polls poll.php?after=<lastId> every ~3s — this is how the visitor receives the other side's replies (analyst or AI) without a websocket or a page refresh. poll.php returns messages since after (both directions, so a reload rebuilds the whole transcript) plus a closed flag from the ticket status.
The subtlety: because the poller rebuilds the transcript from the server, there are two independent things drawing bubbles — the poller, and the optimistic echo (the widget drawing the visitor's own message the instant they hit Send, so it doesn't lag behind the ~2s AI round trip). Left unreconciled they collide: the poller re-fetches the message the echo already drew and shows it twice. Three pieces keep them in step (all in widget.js):
-
lastIdcursor — every rendered message advanceslastId, and the poll only asks for ids after it.send.phpreturnsmsg_id: the stored id of the just-sent message, in whatever table this widget polls —webchat_messagesfor an AI widget,emailsfor a plain one. The widget advanceslastIdpast it so the poller never re-fetches the echoed message. (This is whysend.phpbothers to return an id at all.) -
sendInFlight—send.phpstores the visitor message near the start of the request but only returnsmsg_idafter the AI call. An interval poll firing in that ~2s window would re-fetch the already-stored message before the cursor advances.sendInFlightsuppresses polling from the echo until the cursor has moved. -
pollInFlight— stops two overlappingpoll()calls (the 3s interval tick and the explicit poll fired right after a send/escalate) both appending the same batch.
A typing indicator (three blinking dots, agent side) fills the gap between the echo and the reply. It's shown only when a reply is genuinely expected (ai_enabled and within hours) and removed the moment the reply lands — or after a 30s safety timeout, so it can never spin forever.
SSE is the obvious later upgrade; the widget funnels all incoming rendering through one poll(), so it's a contained change — but the echo/cursor reconciliation above still applies (an optimistic echo always has to be deduped against whatever channel delivers the server's copy).
-
Retrieval —
kbRetrieveArticles()+kbBuildContext()(shared, inkb_ai.php): embedding search over publishedknowledge_articles(falls back to all articles if no OpenAI key/embeddings). (Knowledge isn't multi-tenant yet, so no company filter — add one here when it is.) -
Answer —
webchatAiReply($conn, $question, $history)(ai.php): builds a strictly-grounded, KB-only system prompt and callsaiProviderChat()with the sharedknowledge_aiprovider/key. Returns{ok, answer, articles}. -
The
send.phpdecision — for anai_enabledwidget: record the visitor message inwebchat_messages, then-
lands on a ticket now? — assist always; deflect only once escalated (
ticket_idset) or when closed (capture to answer later). -
AI answers? —
$isOpen && ($mode === 'assist' || !$hasTicket). So: assist while open; deflect while open and still bot-handled; never after a hand-off to a human, and never out of hours.
-
lands on a ticket now? — assist always; deflect only once escalated (
-
Assist vs deflect —
-
Assist: ticket created on the first message; the AI's reply is also posted onto the ticket as an outbound webchat email (
from_name = "<widget> (AI)", linked bysource_email_idso the poll mirror doesn't echo it back). The analyst sees the whole exchange. -
Deflect: no ticket until escalation. Visitor + AI messages live only in
webchat_messages. The widget shows the escalation buttons.
-
Assist: ticket created on the first message; the AI's reply is also posted onto the ticket as an outbound webchat email (
-
Escalation (
escalate.php→webchatPromoteToTicket()) —- agent: transcript becomes the ticket's opening (inbound) message; thereafter it's a normal webchat ticket (further sends ingest, AI stops).
-
email: opening body is
webchatAiSummarise()output (falls back to the raw transcript), with the full chat log attached aschat-transcript.txtviasaveChannelMediaAttachment(). - Both write a persistent
systemnote into the transcript so the visitor sees the outcome after a reload.
-
Transcript unification — when
ai_enabled, the widget readswebchat_messages(poll'saftercursor is awebchat_messages.id, not anemails.id). Analyst replies (stored inemailsas Outbound) are mirrored in bywebchatMirrorAgentReplies()at poll time, deduped bysource_email_id.
webchatIsOpenNow($conn, $calendarId) reuses sla_load_calendar() from includes/sla.php and evaluates the calendar's weekday hours + holidays in the calendar's own timezone. NULL calendar → always open; it also fails open on any misconfiguration (missing calendar, bad timezone, no hours rows) so availability never hard-blocks contact. config.php exposes the resulting is_open so the widget can present "leave a message"; when closed, send.php still takes the enquiry as a ticket and returns the offline message as a notice.
The one genuinely mailbox-dependent piece. Direction matters: outbound goes from the company's own service-desk mailbox (the same authenticated Microsoft/Google/SMTP mailbox as email tickets) to the visitor — it never sends as the visitor's address. Because a webchat ticket has no source mailbox (it didn't arrive via one), the sender is the company's configured mailbox; if the company has none, the toggle can't act, and Settings should say so. Reuse the senders in api/tickets/send_email.php (sendEmailViaGraph() / the SMTP path) — but note getMailboxForTicket() there assumes an email ticket, so mailbox selection for a webchat ticket needs its own small resolver (company → mailbox). Cannot be verified locally without sending real mail, so build it carefully and test against a real mailbox.
includes/webchat/webchat.php core helpers: key gen, embed snippet, origin parse/allow,
load-by-key/channel, get-or-create user, webchatIngestMessage
includes/webchat/public.php the public guard: resolve, origin (+Referer), token load, rate limits
includes/webchat/ai.php webchatAiReply() + webchatAiSummarise()
includes/knowledge/kb_ai.php shared KB retrieval (embedding search + context)
includes/messaging/FreeitsmProvider.php no-op provider for the shared reply path
api/webchat/config|start|send|poll|escalate.php public endpoints
api/webchat/widget.js the embeddable widget (Shadow DOM)
api/webchat/get_widgets|save_widget|delete_widget.php authed settings API (Cap::TICKETS_WEBCHAT)
tickets/settings/index.php the Web chat settings tab + modal
database/freeitsm.sql · api/system/db_verify.php schema (4 tables) + Web chat origin seed
Steps 1–6 (runtime helpers, config/send/poll wiring, escalate.php, widget.js) shipped in the part-3 commit and were driven end-to-end against the local Dream Holidays widget (OpenRouter, ~20 embedded articles): closed→ticket, open-deflect→AI reply with no ticket, agent-escalate→ticket + post-escalation sends bypass the AI, analyst-reply mirror with dedup, email-escalate→ticket with AI-summary body + chat-transcript.txt.
What remains — sending the reply out by email. Today an "email me back" escalation opens the ticket but the reply is still made by an analyst in the inbox; the email_when_away toggle has no delivery behind it. To finish:
- Direction matters: outbound goes from the company's own service-desk mailbox (the same authenticated Microsoft/Google/SMTP mailbox as email tickets) to the visitor — it never sends as the visitor's address. Because a webchat ticket has no source mailbox (it didn't arrive via one), the sender is the company's configured mailbox; if the company has none, the toggle can't act, and Settings should say so.
- Reuse the senders in
api/tickets/send_email.php(sendEmailViaGraph()/ the SMTP path) — but notegetMailboxForTicket()there assumes an email ticket, so a webchat ticket needs its own small company → mailbox resolver. - Cannot be verified locally without sending real mail, so build it carefully and test against a real mailbox.
The
webchat_messages.source_email_idcolumn is created by Database Verify on existing installs (it's in the$schemaandfreeitsm.sql); a live table that predates part 3 won't have it until Verify runs.
-
Same-origin sends no
Originheader → usewebchatRequestOrigin()(Referer fallback), never$_SERVER['HTTP_ORIGIN']directly. - A PHP fatal is served as HTTP 200 — always read the response body when testing, not just the status.
-
Testing POSTs with curl: use
--data-raw; a bare-dmangled a JSON payload during development. -
The panel is fixed-height — the message list must be a flex column with
min-height:0or the composer gets pushed off the bottom (already fixed; don't regress). -
The poller double-draws an optimistically-echoed message unless you advance the cursor past it (
msg_idfromsend.php) and suppress polling mid-send (sendInFlight). This bit us twice during the part-3 build — see Delivery = polling. Any new "draw locally, then confirm from the server" flow has the same trap. -
webchatAddMessage()readslastInsertId()before its follow-up UPDATE — a later statement on the same connection can zero it, so grab the id immediately after the INSERT. - After any schema change here, update both
freeitsm.sqlanddb_verify.php, then run Database Verify.
- Web chat channel (feature overview) · WhatsApp channel (the sibling it reuses)
- Multi-Tenancy: Developer Guide · AI Providers · SLA Management · Mailbox Authentication
FreeITSM — an open-source IT Service Management platform · github.com/edmozley/freeitsm · MIT licence
- Installation
- ⏰ Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- ⌨️ Command palette (⌘K)
- 🔍 Searching inside tickets
- 📄 Attached documents
- Mobile‑Friendly
-
Security
- Layer 1 — which modules you can enter
- ↳ 🧩 Module Access Control
- ↳ 🛠️ Module Access — Developer Guide
- Layer 2 — what you can administer
- ↳ 🎭 Roles & Permissions
- ↳ 🛠️ Roles — Developer Guide
- ↳ 🔤 Why capabilities are constants
- Layer 3 — the System module
- ↳ 🔑 Admin Access Control
- Hardening
- ↳ 📄 Security review response 2026-08
- ↳ 🛡️ Security hardening 2026-08
- ↳ 🛠️ Security hardening 2026-08 — Developer Guide
- ↳ 🛡️ Round three — plain English
- ↳ 🛠️ Round three — Developer Guide
- Single Sign-On (SSO)
- 🗂️ LDAP & Active Directory
- Browser Extension
- API Reference
-
🔌 REST API — how it works
- ↳ 🎫 REST API: Tickets
- ↳ 💻 REST API: Assets
- ↳ 🔴 REST API: Problems
- ↳ 🟠 REST API: Changes
- ↳ 📚 REST API: Knowledge
- ↳ ✅ REST API: Tasks
- ↳ 🗄️ REST API: CMDB
- ↳ 📜 REST API: Contracts
- ↳ 🗓️ REST API: Calendar
- ↳ 💿 REST API: Software
- ↳ 🚦 REST API: Service Status
- ↳ ☀️ REST API: Morning Checks
- ↳ 📝 REST API: Forms
- ↳ ⚙️ REST API: Workflow
- ↳ 🗺️ REST API: Network Mapper
- ↳ 🧭 Using the API docs page
- ↳ 📐 OpenAPI specification
- ↳ ✅ OpenAPI: kept correct
- ↳ 🛠️ Maintaining the catalogue
- Watchtower
-
Tickets
- ↳ Mailbox Authentication
- ↳ 📤 Email send log
- ↳ Basic IMAP mailboxes
- ↳ Email rendering & images
- ↳ SLA Management
- ↳ WhatsApp channel
- ↳ 💬 Web chat channel
- ↳ 🟣 Slack channel
- ↳ 🔗 Linking tickets
- ↳ 🗒️ Canned responses
- ↳ ✉️ Limiting replies to particular senders
- ↳ ✍️ Email signatures
- ↳ 🌐 The public web address
- ↳ 🙋 Raising a ticket for someone else
- ↳ 🔀 Merging tickets
- ↳ ⑂ Splitting tickets
- ↳ ✅ Selecting several tickets
- ↳ 🛠️ Snoozing tickets — Developer Guide
- ↳ 👥 Collision detection
- ↳ ⏱️ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- 🔔 Notifications
- 🚨 War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- ↳ 📊 Progress tracker
- ↳ Concepts & vocabulary
- ↳ Email routing & mailboxes
- ↳ Settings: global vs per-company
- ↳ Users & self-service
- ↳ Staff cross-company access
- ↳ Worked examples
- ↳ Pitfalls & gotchas
- ↳ Scope: what it's for
- ↳ 🛠️ Developer Guide (make a module multi-company)
- ↳ 🗄️ Case study: CMDB (a linked graph)
- ↳ 🧪 Test harness (prove it's isolated)