Skip to content

Web Chat Developer Guide

Ed Mozley edited this page Jul 15, 2026 · 3 revisions

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_channels row (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, the emails thread, 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?"


Data model

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.

webchat_widgets β€” the embed config (1:1 with a channel)

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.

webchat_conversations β€” one chat = one browser token

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

webchat_messages β€” pre-ticket transcript (AI runtime only)

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

Reused, not new

  • messaging_channels β€” the channel spine (channel_type='webchat', provider='freeitsm').
  • emails β€” inbound/outbound messages, channel='webchat', channel_id set. The analyst's canonical thread.
  • ticket_origins β€” a seeded "Web chat" origin.
  • sla_calendars (+ _hours, _holidays) β€” office hours.
  • knowledge_articles β€” the AI answer source.

The channel reuse

  • FreeitsmProvider (includes/messaging/FreeitsmProvider.php, registered in messagingProvider()): satisfies the shared MessagingProvider contract. sendMessage() does no network I/O β€” it just mints a synthetic id; the outbound emails row is what the visitor polls. This is why the existing api/messaging/send_message.php reply 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 in emails with channel='webchat', seeds the requester from the real visitor email. Threading is by the conversation's ticket_id, not by from_address (unlike WhatsApp, whose normaliseChannelIdentifier is phone-shaped).
  • 24-hour window bypass: webchat has no provider service window, so it's exempt in both send_message.php and get_ticket_thread.php ($ticketChannel === 'webchat' β†’ always "open").
  • "Web chat" ticket origin + subject label: getChannelOriginId() and buildChannelSubject() special-case webchat β†’ 'Web chat'.

Public endpoints

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.

The trust model in code

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() β€” Origin header, falling back to the Referer origin when absent (same-origin embeds send no Origin; this bit us in the local demo where the site and FreeITSM were both http://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.

Delivery = polling

widget.js polls poll.php?after=<lastId> every ~3s. poll.php returns both directions (so a reload rebuilds the transcript), plus a closed flag from the ticket's status so the widget can show a "conversation closed" state. SSE is the obvious later upgrade; the widget already funnels all rendering through one poll(), so it's a contained change.

AI answers β€” how it works (live)

  • Retrieval β€” kbRetrieveArticles() + kbBuildContext() (shared, in kb_ai.php): embedding search over published knowledge_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 calls aiProviderChat() with the shared knowledge_ai provider/key. Returns {ok, answer, articles}.
  • The send.php decision β€” for an ai_enabled widget: record the visitor message in webchat_messages, then
    • lands on a ticket now? β€” assist always; deflect only once escalated (ticket_id set) 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.
  • 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 by source_email_id so 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.
  • 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 as chat-transcript.txt via saveChannelMediaAttachment().
    • Both write a persistent system note into the transcript so the visitor sees the outcome after a reload.
  • Transcript unification β€” when ai_enabled, the widget reads webchat_messages (poll's after cursor is a webchat_messages.id, not an emails.id). Analyst replies (stored in emails as Outbound) are mirrored in by webchatMirrorAgentReplies() at poll time, deduped by source_email_id.

Office hours (live)

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.

Offline email (runtime pending β€” the one remaining piece)

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.

File map

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

Resume plan (offline email β€” the one piece left)

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 note getMailboxForTicket() 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_id column is created by Database Verify on existing installs (it's in the $schema and freeitsm.sql); a live table that predates part 3 won't have it until Verify runs.

Gotchas

  • Same-origin sends no Origin header β†’ use webchatRequestOrigin() (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 -d mangled a JSON payload during development.
  • The panel is fixed-height β€” the message list must be a flex column with min-height:0 or the composer gets pushed off the bottom (already fixed; don't regress).
  • After any schema change here, update both freeitsm.sql and db_verify.php, then run Database Verify.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally