Website widget¶
A school can put the assistant on its own website as a chat bubble by pasting one script tag. This document says how that script becomes a working chat, why the visitor inside it is not a Django session, what stops any other site from embedding it, and what an admin configures. It builds directly on public-access.md: the widget answers the same questions from the same published knowledge, with the same agent and the same limits. The only thing that is new is the delivery.
What the school pastes, and what it does¶
The snippet is one line, which the Website Widget settings tab shows ready to copy:
/widget/v1.js is campuscore_app/campus_core/widget_loader.js, plain browser JavaScript with no build step.
It creates one fixed-position iframe pointing at https://<deployment>/widget/frame, and then does almost nothing: it listens for messages from that frame and resizes and positions the iframe to match.
Everything a visitor sees - the launcher pill, the panel, the transcript, the composer - is rendered by the page inside the frame, on the deployment's own origin.
Nothing of CampusCore's CSS or JavaScript reaches the school's page, and nothing of the school's page reaches the conversation.
The loader refuses a second install, so a snippet rendered twice by a template partial or fired again by a tag manager does not stack two launchers.
It also exposes window.CampusCoreWidget.open() and .close(), so a school can wire its own "Chat with us" button to the same widget.
Until the frame reports itself the iframe is invisible and takes no clicks, and it is removed if it says nothing within a few seconds of loading.
Both halves are the same promise: a page that embeds the widget it is not allowed to embed, or a deployment that answers the frame with an error or the unconfigured 204, gets nothing on screen rather than the browser's "refused to connect" placeholder or a transparent box swallowing clicks.
The iframe also starts at a real size rather than at nothing, because a zero-sized iframe is never laid out and the launcher inside it would measure zero and report a box too small to show itself.
It is made invisible with opacity, never visibility: hidden, because Chrome stops laying out a cross-origin frame whose box is hidden that way, and an unlaid-out launcher can never measure itself.
The removal timer counts only visible time: a browser also stops laying out the frame while its tab is in the background, so a visitor who opens the school's page behind another tab must still find the launcher when they switch over.
The frame is the only side that knows whether the panel is open and how wide the launcher measured, so it is the side that reports the size.
The message it posts is { type: "campuscore-widget", state, width, height, margin, position }, sent with postMessage(..., "*") because the frame cannot know which of the school's origins embedded it.
That is safe because the payload is a state name, three numbers and a corner - nothing private - and because the loader accepts a message only when it came from this frame's window on the deployment's origin.
Commands travel the other way as { type: "campuscore-widget:command", command }, and the frame accepts one only from window.parent.
The margin is the room inside the reported box, on every side, that the frame keeps for the shadow the panel and the pill cast.
The loader subtracts it from the 20px edge gap, so the visible thing still sits 20px from the page's corner while the frame itself reaches past it and the shadow has somewhere to draw; a frame that reports no margin at all is placed as if it were zero, which is where such a frame sat before the field existed.
Who may embed it¶
Framing is the one protection the widget has to relax, and it is relaxed precisely, on both sides of the wire.
The server side is the door.
/widget/frame renders only for a request whose Referer names this deployment or an origin the admin stored, and answers every other page with a plain-text 403 naming the origin it saw, so a browser on an unlisted site never receives the frame document.
The Referer is the door's key because the loader pins referrerpolicy="strict-origin-when-cross-origin" on the iframe element, which wins over whatever the host page declares: a cross-site frame sends its parent's origin and never the page URL, and the deployment's own preview sends its settings-page URL, which reduces to the same origin.
That is a statement the browser makes, not one the page supplies, which is what makes it worth checking; a client that is not a browser can send any Referer and get the markup, which buys it nothing beyond the API exposure described below.
A refusal is one info line in the django.request log naming the origin, in place of the warning Django writes for a 4xx, because a stale snippet on a rogue site refuses once per page view and that is traffic rather than trouble.
The 403 is no-store, so the tab a developer is watching loads the frame the moment an admin lists the site.
The browser side is the frame's own Content-Security-Policy, whose frame-ancestors is 'self' plus those same origins, and it carries no X-Frame-Options header at all.
Dropping that header is deliberate: it cannot express a list, and in older browsers it would override the policy that can.
'self' is always included, which is what lets the Settings tab show a live preview of the real widget without an admin having to allow their own deployment.
The list is required: the settings API refuses to save an empty one, with a message the tab shows as is, and the tab says so when a stored list is empty. An empty list would mean "nowhere but the preview", which is never what an admin saving the form means.
Origins are stored normalised - scheme://host[:port], lowercased, no path - because that is the exact form a browser compares frame-ancestors against.
The settings API is where that normalisation happens, and an address it cannot normalise is refused with a message naming the line at fault rather than dropped, so an admin who pasted a full page URL is told, not left wondering why their site is still blocked.
The host is held to a hostname rather than to whatever urlsplit was willing to return: what that parser calls a host can carry a space or a semicolon, and a semicolon inside this value would start a directive of the sender's choosing in the header it lands in.
AppConfig.is_valid_origin is that one definition, and the read boundary applies it too, so a row someone edited by hand in Django admin cannot put a newline into a header every visitor loads.
The frame's response is no-store.
A school that turns the widget off must stop being embedded on the next page load, not when a cache expires.
One honest limit of this posture: the allow-list governs framing, not the API.
Someone determined can call /api/widget/ from their own page and build their own chat box against it, which is the same exposure public access already carries - the answer is still only published knowledge, the same 10-turns-a-minute limit applies per client, and turning either flag off closes it immediately.
The widget session¶
The widget's page runs in a cross-site iframe, where the browser withholds this deployment's Lax session and CSRF cookies.
A Django session therefore cannot be the visitor's identity, and CSRF cannot be the protection.
Instead WidgetSessionToken (apps/main_app/services/widget_session.py) mints a token: a fresh random key signed by a TimestampSigner with its own salt and a 14-day maximum age.
The frame asks for one on first load, keeps it in its own sessionStorage, and sends it in the X-Widget-Session header on every call.
The key inside the token is what Conversation.session_key stores, so a widget thread is scoped exactly the way a guest thread is.
A widget key starts with w and Django's session keys are hex, and that prefix is what keeps the two spaces apart: no session key this deployment mints can name a widget thread, and no widget key can name a guest one.
Nothing is written when a token is minted, so a frame a visitor never speaks to costs no row. The salt is the widget's own, so a leaked Django session key is not a widget credential and a leaked widget token is not a Django session.
The /api/widget/ views are therefore CSRF-exempt, declared as cookieless=True on the endpoint decorator beside auth and rate_limit.
Exempting them is safe precisely because they read no cookie: there is nothing for a cross-site page to ride on, and a browser never attaches the header on its own.
campus_core/api/tests/test_anonymous_endpoint_set.py pins the exemption to exactly those four views, by reading the attribute CsrfViewMiddleware itself consults.
The endpoints¶
All four sit behind auth="website_widget", which answers 404 unless both cc_public_access and cc_website_widget evaluate true.
Two flags, because a school that turned public access off has turned the widget off with it, and one that never wanted an embed keeps the widget off while guest chat works.
| Endpoint | What it does |
|---|---|
GET /api/widget/config |
Branding, presentation and sample questions the frame paints itself with |
POST /api/widget/session |
Mints a widget session token |
POST /api/widget/chat/stream |
One turn, the same SSE stream the guest page gets |
GET /api/widget/conversations/<id>/messages |
The stored transcript for a thread this token owns |
The stream is limited to 10 requests a minute per client and the reads to 60, matching the guest surface.
widget_chat_stream is an ordinary function returning the generator rather than a generator function: a generator's body does not run until the response is already on the wire, and a refused token has to be a real 401 status line, not an error frame on a 200.
The transcript shape and its reader live in apps/main_app/apis/guest_transcript.py, which the guest page and the widget both import.
They differ only in how the caller proves which session_key is theirs.
What an admin configures¶
Settings -> Website Widget (web/src/components/settings/WebsiteWidgetSettings.tsx) has four panels, in the order an admin works through them.
The switch is the cc_website_widget feature flag, toggled in place through the same write Feature Management uses.
The tab reads that switch, and the public access flag beneath it, from the feature list query Feature Management reads and writes, not from its own settings GET.
That is what makes a flip on either tab show on the other at once: there is one cache for the flags, so there is nothing to invalidate and no reload to wait for.
When public access is off the panel says the widget will answer nobody, rather than letting an admin flip a switch that changes nothing.
The appearance form holds the bubble text, the bubble style, the accent colour, the first message, the position, and the list of websites allowed to show the widget.
All six are widget_* columns on AppConfig, so they ride the same process-local cache with cross-process invalidation that every widget load already reads through.
The first message field holds the sentence visitors actually see, not the raw column.
When nothing is stored the server builds one from the assistant and institution names - widget_greeting in widget_api.py is its one home, and the frame's config endpoint and this settings GET both call it - so the field and the preview bubble show the same words a visitor would read.
Clearing the field and saving stores blank, and the response carries the built sentence back, which is how an admin returns to it.
Saving the built sentence unchanged also stores blank, so an admin who edits the colour and leaves the sentence alone does not freeze it against a later rename of the assistant or the institution, and the audit row records no greeting change.
The names those two sentences are built from have one home each as well, display_assistant_name and display_institution_name on AppConfig, which the bootstrap payload, the widget config, the template context and the greeting all read.
The bubble style is opt-in: the default is a pill carrying the bubble text, and a school that chooses the logo bubble gets a round launcher carrying the logo from General & Branding, or the institution's initials until one is uploaded.
The bubble text is kept either way, because the logo bubble paints no words and needs the text as its accessible name and hover title.
widget_position and widget_launcher_style carry check constraints on their WidgetPosition and WidgetLauncherStyle values; widget_allowed_origins is JSON, which the database cannot constrain the same way, so AppConfig.widget_origins() is the single place that parses it into the list its readers take, dropping anything the settings door would have refused.
A save writes a freshly read row inside its transaction rather than the cached instance this process is serving from, so a save that fails leaves the running policy alone.
The install panel shows the snippet, built from request.build_absolute_uri, so an admin on a custom domain copies a snippet naming that domain.
The preview panel installs the real loader on the settings page.
What the admin sees is what their visitors will see, because it is the same script and the same frame - which works only because frame-ancestors always allows 'self'.
The loader and the frame are Django's routes, so the Vite dev server proxies /widget/v1.js and /widget/frame to Django (web/vite.config.ts); without that the browser is handed the SPA's HTML in their place and refuses to run it, and the preview shows nothing.
The frame the preview embeds runs the built widget bundle, not Vite's live source, so a change to web/src/widget/ shows in the preview only after scripts/build_spa_local.sh rebuilds it.
The preview reflects the draft as the admin types, without a save.
The settings page pushes the appearance fields into the mounted frame over the loader's preview() method, and the frame merges them over the config it fetched, so the bubble text, the first message, the colour, the style and the position update at once.
While the first message field is cleared the page pushes the greeting it last loaded rather than an empty string, because the frame is promised a greeting that is never blank and an empty one paints an empty bubble.
The frame accepts the push only from a same-origin parent, which the settings page is and a school's real embed is not, so no school page can restyle the widget through this channel.
The frame stays mounted across saves and posts a ready report on load, which the settings page answers with the current draft so an edit made while it was still loading is reflected.
Save stays disabled until one of the appearance fields or the origins list differs from the saved row, with the origins compared as the list the request would send, so blank lines and stray spaces do not count as a change.
After a save the form shows the row the server stored, so the button goes back to disabled.
An untouched form follows a refetched row, such as another admin's save arriving when the tab regains focus, while a form with edits keeps them.
Every save writes a widget_settings_updated audit row naming which fields changed.
The accent colour, and the one branding exception¶
The launcher pill and the panel header take the school's widget_accent_color.
That is a deliberate exception to the rule in branding-and-theming.md that only the login hero and the sidebar initials square carry school colour: those two surfaces sit on the school's own website, where a CampusCore-blue bubble would look like somebody else's software bolted on.
Everything inside the panel - the composer, the chips, the focus rings - keeps the CampusCore brand token, so contrast and behaviour stay CampusCore's to guarantee.
On a touch screen the panel's controls answer taps across 44px without growing (see the design system's tap area), and every such area stays inside the panel, whose rounded corners clip what reaches past them.
Because a school is as likely to choose a pale gold as a deep navy, the text on the accent follows its luminance rather than being fixed at white (web/src/widget/accent.ts).
The frame's page¶
web/widget.html is a second Vite entry beside the SPA shell, built into templates/spa/widget.html by the same Dockerfile and scripts/build_spa_local.sh steps that place the shell.
It shares the chat components and the Tailwind tokens with the SPA and nothing else: no router, no query client, no session handling, no theme resolution.
It pins data-theme="light" and runs no theme script, because a visitor's dark-mode preference is about the school's site rather than ours.
The greeting renders as a bubble the panel owns rather than a stored message, so re-opening the panel never replays it as part of the conversation.
The current thread id lives in sessionStorage next to the token, and "Start over" clears the id while keeping the token.
An unconfigured workspace answers /widget/frame with an empty 204: the setup holding page inside a launcher-sized iframe would be a broken-looking box on somebody's home page.
The panel reads its stored transcript when it opens, when the thread changes, when the session is renewed, and when a turn ends - cleanly through onDone, and through onReplace when the next question replaces a turn that was stopped or failed.
It never reads it at a Stop, because that read races the server's last write of the partial answer and the record on screen is the fuller copy until the next question is asked.
It never reads it while an answer is arriving either, because the server names a brand-new thread mid-answer, and a read then would hold the visitor's question but not yet the answer, so the panel would show the question twice.
A read that fails on open is answered by what failed.
A thread the server no longer has (WidgetUnavailable) is not there to come back to, so the panel starts a fresh one rather than offering to read a thread that is gone.
A rejected session (WidgetSessionRejected) mints a new token and reads again with it, and the visitor sees nothing.
Anything else - and a renewal that cannot mint - leaves the notice under the header with "Try again", keeping the rows already on screen while a retry is out.
The transcript follows an answer as it is written through useTranscriptScroll, the same hook the chat and guest pages use, so the panel stays at the bottom of a growing answer until the visitor scrolls up and then leaves them where they are.
Where to look¶
| Question | File |
|---|---|
| The loader the school includes | campuscore_app/campus_core/widget_loader.js |
| The frame's routes and its CSP | campuscore_app/campus_core/widget.py |
| The visitor's endpoints | campuscore_app/apps/main_app/apis/widget_api.py |
| The session token | campuscore_app/apps/main_app/services/widget_session.py |
| The guard mode | campus_core/api/auth.py (require_website_widget, widget_session_key) |
| The admin's endpoints | campuscore_app/apps/main_app/apis/workspace_widget_api.py |
| The frame's UI | campuscore_app/web/src/widget/ |
| The settings tab | campuscore_app/web/src/components/settings/WebsiteWidgetSettings.tsx |
| End-to-end contract tests | apps/main_app/tests/test_widget_api.py, campus_core/tests/test_widget_views.py, apps/main_app/tests/test_workspace_widget_api.py |