-
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)