LiveChat
0.8.0 — 2026-08-31
Section titled “0.8.0 — 2026-08-31”- The operator chooses the console’s front door.
Auth__PasswordLoginEnabled=false(valid only alongside a configuredOidcsection — a deployment needs at least one door, and a doorless configuration refuses the boot) turns the console SSO-only: the login page renders the Sign in with SSO button alone, andPOST v1/agent/auth/loginitself answers 422 rather than merely hiding the form.GET v1/agent/auth/ssonow carriespasswordLoginEnabledso the login page knows which doors to draw; the password form also no longer flashes briefly on SSO-only deployments while that answer is on the wire.
0.7.2 — 2026-08-31
Section titled “0.7.2 — 2026-08-31”- The SSO callback completes now: it posted the validated ID token through the same session-requiring API helper v0.7.1 fixed for the button’s config fetch, and the callback page is likewise a page with no session yet — obtaining one is what the call is for. The request is anonymous now, matching the endpoint it talks to.
0.7.1 — 2026-08-31
Section titled “0.7.1 — 2026-08-31”- The Sign in with SSO button actually appears now. The login page asked for the SSO configuration through the console’s authenticated API helper, which refuses to run without a stored session — and a login page is precisely the screen that has none, so the button never rendered on any deployment. The page now asks anonymously, as the endpoint always allowed.
0.7.0 — 2026-08-31
Section titled “0.7.0 — 2026-08-31”- Console single sign-on (OIDC), off by default. With
Oidc__AuthorityandOidc__ClientIdboth set, the login page offers Sign in with SSO next to the password form: an authorization-code + PKCE flow runs in the browser against the configured provider (Keycloak, in FlowDesk deployments), andPOST v1/agent/auth/ssovalidates the resulting ID token against the authority’s published keys and signs in the existing active agent whose e-mail it certifies — 404 when no such agent exists (SSO never provisions), 401 for any token the validation refuses, 422 on a deployment without SSO configured.GET v1/agent/auth/ssotells the login page whether to render the button. The provider client must be public (PKCE) with redirect URIhttps://<host>/console/oidc. Half a configuration refuses the boot. Seedocs/CONSOLE.md §3b.
0.6.1 — 2026-08-31
Section titled “0.6.1 — 2026-08-31”- The console’s alert tone actually plays now. Browsers keep an
AudioContextsuspended until the page has seen a user gesture, and a console restored from a stored session may never have seen one — the tone was played into a suspended context and lost. The context is now unlocked by the agent’s first click or keypress, and a suspended tone waits for the resume instead of racing it. - Inside the FlowDesk dashboard the embedded console now hands its pending count (queued visitors + unread messages) to the host page, which shows it in the dashboard’s own tab title — the iframe’s title is visible nowhere, and browsers refuse desktop-notification permission to a cross-origin frame, so this was the one signal that could still reach an agent reading another tab. Requires flowdesk-web v0.6.5.
0.6.0 — 2026-08-31
Section titled “0.6.0 — 2026-08-31”- Sneak peek: agents now see the visitor’s message as it is being written — a dimmed draft
bubble in the console transcript, updated while the visitor types. One-directional by design
(the widget is never shown an agent’s draft), ephemeral (relayed over the socket, capped at 500
characters, never stored), and cleared the moment the message arrives or the visitor goes idle.
The visitor hub gained a second client-to-server method,
Preview(conversationId, draft), spending the same per-visitor typing budget asTyping. - Leave a message before an unanswered goodbye: a visitor who presses End chat while still queued — no agent has joined — is offered a short form first (e-mail, pre-filled when known, and a final message posted into the transcript before the close), so the team can answer out of band from the History screen. Declining still closes the chat.
data-pre-chat="on"/"off"on the embed script tag lets the integrating page force the pre-chat form on or off, overriding the operator’s widget setting; any other value defers to it.
Changed
Section titled “Changed”- A page-declared identity now skips the pre-chat form: when the embedding site has declared a
name and an e-mail (
data-user-*attributes,flowdesk-user-*meta tags, or anidentify()call), the form’s purpose is already served and the visitor goes straight to the composer.
0.5.3 — 2026-08-30
Section titled “0.5.3 — 2026-08-30”- The admin console has an API keys page (
/console/admin/api-keys). Service keys for the FlowDesk integration (scopeagent:sso) can now be created, listed and revoked in the browser rather than only overv1/admin/api-keyswith curl; the rawlck_…token is shown exactly once after creation, with a copy button, and each row shows the key’s prefix, scopes, creation time and last use.
0.5.2 — 2026-08-30
Section titled “0.5.2 — 2026-08-30”- Without an
Embedding:AllowedFrameAncestorsallowlist,/console/ssonow answers404instead of serving the console shell. The landing page’s login-CSRF guard requires being framed, and the allowlist is what limits who may frame — on a deployment that never configured one, a page the attacker controls could have hosted the frame. No allowlist, no page: enabling the embed now forces the one setting that makes it safe.
0.5.1 — 2026-08-30
Section titled “0.5.1 — 2026-08-30”- The
/console/ssolanding page adopts a session only when it is running inside a frame. A crafted top-level link carrying an attacker’s own valid token pair could otherwise sign the victim into the attacker’s account (login CSRF); the legitimate flow only ever runs framed by the hosting product. Deployments that mintagent:ssokeys should also setEmbedding:AllowedFrameAncestors, which closes the remaining delivery path (a frame on a page the attacker controls) at the browser.
0.5.0 — 2026-08-30
Section titled “0.5.0 — 2026-08-30”- Service keys — the third credential, for SYSTEMS.
POST/GET/DELETE v1/admin/api-keys(administrator-only): hashed, scoped, revocable keys with the raw token returned exactly once at creation (lck_prefix; only the SHA-256 is stored; deletion is the revocation). One scope exists today:agent:sso. New table, so a migration runs on both providers. - The integration surface (
v1/integration, X-Api-Key auth — FlowDesk’s connected-apps entry is the caller):GET /ping, the connectivity probe FlowDesk’s Test button calls, andPOST /agent-sessions, the SSO exchange — a trusted server presents its signed-in user’s email and receives the same token pair a password login issues, for the active agent with that address (404with one message for “no such agent” and “deactivated”). Sessions are issued alongside existing ones, never replacing them. - The embedded console.
/console/sso#at=…&rt=…&exp=…&embed=1adopts a token pair from the URL fragment (scrubbed from the address bar before anything else runs), signs the agent in with no login screen, and — whenembed=1— drops the chrome the hosting product already provides (Administration, Account, Sign out) for the life of the tab. Chats, History and the duty toggle stay. Embedding:AllowedFrameAncestors(Embedding__AllowedFrameAncestors__0=…): when set, every/consoleresponse carriesContent-Security-Policy: frame-ancestors 'self' <origins>— the FlowDesk dashboard that frames the console is allowed, every other site is refused by the browser. Unset, no header is sent (the pre-integration status quo).
0.4.0 — 2026-08-30
Section titled “0.4.0 — 2026-08-30”Changed
Section titled “Changed”- The console’s shell is the classic helpdesk two-tone now. The brand-navy rail (the family palette’s own primary-700) runs the full height of the screen and owns the brand and both work lists — white-chip Take buttons, light-on-dark rows, live section counts — while everything to its right is light content under a quiet white toolbar. Colours are the FlowDesk palette throughout; nothing off-brand was introduced.
- Inter actually loads now. The font stack always named Inter first, but nothing ever shipped it, so most machines silently fell back to the system font. The variable font is bundled with the console (fontsource), no network fetch involved.
- Primary buttons carry the palette’s own blue-800→blue-900 gradient with a soft hover lift; admin/history headers are light with blue accent pills, matching the new shell.
0.3.2 — 2026-08-30
Section titled “0.3.2 — 2026-08-30”Changed
Section titled “Changed”- The console’s chrome went dark. Every screen’s top bar — the working screen, History, and the whole admin section — now sits on the deep brand navy with the logo in a white chip, light nav pills and inverted ghost buttons, anchoring the app the way the industry’s consoles are anchored: dark chrome, light content.
- Empty space now says something or is not there at all. The centre pane’s “pick a conversation” is a real empty state (icon in a soft circle, centred); the rail’s empty sections are quiet dashed cards instead of stray sentences; the rail’s section headings carry live counts (My chats, Queue). When no conversation is on screen the right-hand visitor column is not reserved at all, and History’s table takes the full width until a transcript is opened beside it.
0.3.1 — 2026-08-30
Section titled “0.3.1 — 2026-08-30”- Console polish, all of it visual: the composer’s raw native file input is now a proper “Attach a file” button (the input stays, visually hidden) sharing one toolbar row with the canned-response picker; the transfer dialog is a framed card with styled controls instead of bare browser widgets; an agent’s transcript bubbles are the flat brand blue rather than a gradient that read as near-black; admin form labels top-align with their fields (a label beside a textarea no longer floats at mid-height), checkboxes line up with their labels, and a form’s Cancel no longer dresses like its Save.
0.3.0 — 2026-08-30
Section titled “0.3.0 — 2026-08-30”- Signed-in user handoff: the customer’s page can now tell the console who is chatting. Three
declarative channels, all applied exactly as a
FlowDeskChat.identify()call (same merge semantics, same display-only trust — never a lookup key):- attributes on the embed tag:
data-user-id,data-user-name,data-user-email; - meta tags for server-rendered pages:
<meta name="flowdesk-user-id" content="...">(+-name/-email), read once when the widget mounts; - the
X-FlowDesk-User-Idrequest header onPOST /v1/widget/{key}/visitors, sent by the widget when a user id is declared, so the visitor row is created already labelled. The header is normalized server-side (trimmed; over 200 characters it is dropped, never truncated) and a malformed value can only ever cost the label, not the mint.docs/WIDGET.md §3documents all three, including why an empty declared value never clears a stored trait.
- attributes on the embed tag:
GET /v1/agent/conversations/{id}now carriesvisitorDisplayName,visitorEmailandvisitorExternalId— an additive response-shape change. The console’s visitor panel shows them (avatar, name, email, and the customer’s own id rendered as selectable code), and it now makes the detail read on every deployment rather than only FlowDesk-connected ones.- The widget greets an empty panel (“Merhaba! Size nasil yardimci olabiliriz?” / “Hi there! How can we help?”) — painted client-side, never stored, gone the moment a real message exists.
- The console transcript draws date separators between calendar days, in the working screen and everywhere else the transcript renders.
0.2.0 — 2026-08-30
Section titled “0.2.0 — 2026-08-30”GET /v1/admin/reports/summary— the manager’s numbers, administrator-only like the rest of the admin surface. One read answers: totals for the range (chats started; chats closed, broken down by who ended them — agent, visitor, or the inactivity timeout), a live snapshot (waiting and active right now, deliberately not range-bound), message counts by sender, per-day started/closed series with every calendar day present (zeroes included), per-department started counts, and a per-agent table (served, closed, average first-response seconds). Averages arenull, not zero, when the range holds no served conversation.from/toare the same half-open[from, to)convention as the conversation search; both optional (default: the last seven days, server-side); an inverted range answers400, and so does one wider than 92 days.- Console: a Reports page under Administration — range filter, stat cards, a per-day started/closed chart, and the department and agent breakdowns, all from the endpoint above.
- Console: a History page (
/console/history, linked from the header) — the “all chats in one list” surface overGET /v1/agent/conversations, with status and started-date filters, paging, and a read-only transcript pane for any row, closed conversations included. Scoped exactly as the server scopes it: an agent sees their departments’ conversations, an administrator sees all. GET /now answers302to/console/— the bare origin serves nothing of its own, so the one human who types it (an agent) lands on the console instead of a 404.
Changed
Section titled “Changed”- The widget wears a current-generation design. A circular launcher with the chat glyph
(entrance pop, hover lift, unread badge ring), a larger panel (372×600 on desktop, near-full-width
on small screens) with an open animation from the launcher corner, a gradient header carrying a
live presence dot, message bubbles with entrance motion and asymmetric corners, a three-dot
typing indicator in place of the italic sentence (the sentence still ships for screen readers),
a pill-shaped composer with icon attach and send buttons, and derived shades so the whole surface
restyles from the widget’s one configurable
primaryColor.prefers-reduced-motiondisables every animation. The embed contract is unchanged: same script tag, samedata-widget-key, same DOM hooks (data-flowdesk-*), so nothing an integrator wrote needs to change. - The console wears the FlowDesk design language — the family’s tokens (the deep fintech blue, the warm near-black ink, Inter, soft hairline borders and card shadows), the FlowDesk logo and favicon, a branded login screen, avatar rows and unread badges on the rail, gradient message bubbles in the transcript, and one shared control style across the working screen, the account page and the whole admin section. Behaviour is untouched; every route and string is where it was.
0.1.0 — 2026-08-29
Section titled “0.1.0 — 2026-08-29”The first tagged release — everything since the product’s first commit.
-
A waiting room, and a ceiling for it to defend.
Chat:MaxConcurrentConversationsbounds how many chats one instance carries, queued and active together. It is unset by default and every existing deployment behaves exactly as it did; set, it is the product’s first answer to arriving at more visitors than one pod can serve.Past the ceiling a visitor is held at the door rather than admitted or refused: no conversation row, no message, no socket — a dictionary entry and a place in line.
POST v1/widget/conversationsanswers503with the codeFD:3001, the widget shows their number and pollsGET v1/widget/conversations/waiting-roomevery 30 seconds, and the message they had already typed is sent for them the moment they are let in. The line is fair rather than random: each held visitor takes a ticket on their first poll and keeps it, so polling repeatedly never sends anybody to the back. A visitor who already holds a conversation is never held, however full the deployment gets.Separately and always on, a visitor queued for an agent now sees where they stand:
queuePositionon the transcript read (GET v1/widget/conversations/{id}/messages), 1-based, null the moment somebody takes the chat, and computed only while the conversation is still queued so an active chat pays nothing for it. The widget renders it as one line above the composer, which stays usable.The Helm chart exposes the ceiling as
chat.maxConcurrentConversations(empty = off) besideresources, because they are one decision: memory tracks concurrent sockets, so a ceiling above what the memory limit can hold never fires.docs/CAPACITY.md §2band §6 have the pairs;docs/WIDGET.md §5has what the visitor sees.wwwroot/widget.jsis rebuilt in this commit. -
The console tells an agent about work they cannot see. Until now the queue, the rail and the unread badges were all on screen — visible only to somebody already looking — so a console in a background tab, or a browser behind another window, showed a waiting visitor nothing at all.
Three signals now. The tab title carries the pending count (
(3) FlowDesk LiveChat, waiting conversations plus unread messages) and needs no permission of any kind. A short tone plays when a conversation joins the queue, or when an unread message arrives while the tab is hidden — synthesised in the browser, so no audio file is served and no CSP needs widening. And a desktop notification fires at the same two moments if the agent has granted permission: the header shows an Enable notifications control only while the browser has not been asked, and nothing requests permission on its own.A message the agent is watching land does not alert, and a queue that loses one row and gains another — the same length, a different visitor — does.
docs/CONSOLE.md §1has both rules and the reason there is no per-agent preference for any of it. -
The widget now tells a visitor when nobody is available.
GET v1/widget/{key}/configcarries a newonlinefield, and the panel renders a line above the composer when it is false: “Nobody is available right now. Leave your message and we will get back to you.”It is not a refusal. The panel stays open, the composer stays enabled, the chat is created and queued as it always was, and the first agent on duty is handed it by the router. What changed is that the product stopped being silent: a visitor writing at three in the morning got exactly the interface of one writing at midday, and on a stock deployment nothing else covered it either —
FlowDesk:Enabledis false by default, so the automatic escalation that would otherwise turn an unanswered chat into a ticket does not run.onlineis true when an agent is available (connected and on duty) for at least one active department and the instant is inside business hours — the same pair the automatic escalation triggers on, so the two cannot disagree. Business hours stay optional and unconfigured by default, in which case every instant is inside them; they are set underFlowDesk:BusinessHours, a section name inherited from the escalation work that first read it and not FlowDesk-specific.docs/WIDGET.md §5documents both, including its ceiling: the flag is read when the panel opens, so an agent going offline mid-chat does not raise the notice.Operators need change nothing.
wwwroot/widget.jsis rebuilt in this commit, as it must be. -
Passwords can be changed and reset. Two new routes and two new pieces of console, replacing a recovery path this product documented in its own source: delete the account and create it again.
POST /v1/agent/auth/change-password— an agent’s own, from the new Account screen in the console header. It takes the current password as well as the new one (an access token proves a session, not the person at an unattended console), and it answers with a fresh token pair: the change ends every session that agent has, and the pair is what keeps the browser that made the change signed in while the others are signed out. Their access tokens still work for up toJwt:AccessTokenExpireMinutes, the same fifteen-minute window that bounds every other revocation here.POST /v1/admin/agents/{id}/password— an administrator setting somebody else’s, from a control in the agents table. Same effect on that agent’s sessions. There is still no self-service reset, and there cannot be one until this product can send mail: an agent who has forgotten their password asks an administrator, and the sign-in screen offers no “forgot my password” link rather than a dead one. Resetting a password is also not the way to cut somebody off immediately — deactivating the account is, and it takes effect within one request.The password policy is the vendored family policy, unchanged, and is now written once (
PasswordRules.ValidPassword) for all three routes that set a credential rather than spelled out on the create-agent DTO alone. -
docs/CAPACITY.md— where this product stops scaling, and why. One instance is a hard ceiling rather than a tuning parameter (replicasis fixed at 1 in the chart), and the document names the four pieces of in-process state that make it so, what one connected visitor and one message actually cost, the four limits that bite before the code does — an unsetnetwork.trustedProxiesfirst among them — and starting resource sizes to load-test from. It is explicit that no load test has ever been run against this repository and that none of its numbers may be quoted to a customer as a benchmark. The chart’sresources:block now says in place that it is a demo profile and points at that document. -
This product can now be released. Publishing a GitHub Release builds the repository’s
Dockerfileand pushes the image toghcr.io/shftco/flowdesk-livechat, tagged with the release tag (v0.1.0), the bare semver (0.1.0) andlatest— the tag an operator pointsimage.repository/image.tagat in the Helm chart. Nothing has been tagged or published yet; what changed is that there is now a path from a commit to a deployable image, where before the image existed only on the machine of whoever built it by hand.latestmoves on a published release only: the workflow can also be run manually to publish a one-off tag, and a manual run deliberately leaveslatestwhere it was rather than repointing every deployment that pulls it.The shipped image is also built on every push and pull request now, by a new
docker-buildCI job that builds it and does not push it. Until now nothing in the repository ever ran theDockerfile: CI compiled the API and generated the console separately, and the file that composes them — including theCOPYthat puts the console bundle inwwwroot/console— ran for the first time when somebody cut a release. A broken image was a release-day discovery. -
An agent can now send a file from the console. The composer has an Attach a file control: picking a file uploads it immediately to
POST /v1/agent/conversations/{id}/attachments— the endpoint has existed since the attachment work landed and nothing in the console called it — and the stored attachment then rides on the next message the agent sends. Sending a file with no covering note is allowed, because the server already allows it: the Send button is enabled with an empty box as soon as a file is attached. An upload in progress shows a progress bar and a Cancel button, and a file that has finished uploading can be dropped before it is sent (the unreferenced upload is then reclaimed by the orphan sweep afterAttachment:OrphanGracePeriod, as it always was).The console does not check the file itself. The size limit (
Attachment:MaxSizeMb) and the allowed extensions are enforced by the API and always were; the agent surface exposes neither of them to a client, so a refused file shows the API’s own sentence — “The file must not exceed 10 MB”, “This file type is not supported. Allowed types: …” — in the language the console asked for. Operators changingAttachment:MaxSizeMbtherefore need no console change and get no stale second opinion from it. -
CI runs the agent console, and checks that its generated API client is not stale. Both are contributor-facing rather than operator-facing, and both close gaps that looked like coverage. A new
agent-consolejob runs the console’s typecheck, its unit tests and its production build; until now.github/workflows/test.ymlmentionedapps/agentnowhere, so all three ran only when a human remembered. The typecheck is the one that had to be automated: it is the only thing that proves the console’sen-USandtr-TRpacks carry the same keys, because the backend’smake localization-check-strictreadsResources/Localization/*.jsonand cannot see TypeScript. Removing a key fromtr-TR.tsleaves that check reporting zero issues.Separately,
embed-smokegained a fourth step that regeneratesapps/agent/api/livechat.d.tsfrom the running API’s OpenAPI document and fails if the committed file moved — the samegit status --porcelainshape already used forwwwroot/widget.js, so it catches both a file that drifted and one that was never committed. It lives in that job because generating the client needs a booted API in Development, which that job already has. A contract change that adds an optional field used to pass every check in the repository. -
The console build now fails if the bundle stops carrying its
/console/base path.npm run build --workspace apps/agentisnuxt generatefollowed by a check that every root-relative asset reference in the emittedindex.htmlsits under/console/. It runs everywhere that build runs — theagent-consoleCI job, theDockerfile’s console stage and a developer’s own build — so a bundle whose assets would 404 the moment the API image served it can no longer be produced. Changingapp.baseURLused to pass the typecheck, the unit tests andnuxt generatealike; the repository’s only assertion about the base path lived in an integration test branch that runs on a developer machine and never on CI.Four console behaviours also gained the tests they were missing: the admin Save button (the shared
updatewrite behind departments, widgets and canned responses), the queue’s manual Take control including its 409 and 410 outcomes, and the sign-in screen — the login route and which credential goes in which field. No product behaviour changed. -
The console now has an administration section, behind the role claim. An administrator signing in gets an Administration link in the console header; an ordinary agent does not. It opens five pages at
/console/admin— the licence, agents, departments, widgets and canned responses. Every administrator-only operation an operator would otherwise need an HTTP client for is there: list, create, edit and delete each of the four resources, set an agent’s department memberships, and read the licence status. (The four single-item reads are the one thing the section does not call: the list already carries every field its edit form offers, so fetching a row again to edit it would be a second request for an answer already on screen.)Hiding the link is a convenience and is not the boundary. Every administrator-only route refuses a non-administrator on the server, and it always did; the console adds nothing to that. An agent who types
/console/admin/agentsgets the page, the call goes out, the server answers403, and the console renders the server’s own sentence in the language the request asked for rather than a message of its own. The same is true of every other refusal in the section: a department that cannot be deleted because a widget still routes into it, a widget origin that is not a bare scheme and host, an email address already in use — the words on screen are the API’s.The licence page is the section’s landing page on purpose. It is the one administrator route that keeps answering while a licence is blocked, so it is where an operator can find out why. It states two things rather than leaving them to be discovered: conversations are counted and never limited (only the agent seat limit refuses anything), and a blocked licence leaves the entire visitor surface working — customers can still chat, but nobody can answer them.
Both interface languages ship with it.
-
The console can now change what happens to a conversation, and it draws the files inside one. The panel beside an open chat carries four actions, and each of them renders whatever the server answers rather than a sentence the console invented:
Transfer hands the chat to another agent or to another department. The picker lists every active department except the one the chat is already in, and every active agent except the caller, with each agent marked online, away or offline — the transfer is not blocked on any of those, because handing a chat to a specialist outside your own queue is the point, but an agent choosing where a visitor waits should be able to see whether anybody is there.
Escalate asks for the chat to become a FlowDesk ticket. It is not shown at all on a deployment with no FlowDesk connection, which is the default: with the integration off, that request can only ever be refused, and a control whose only outcome is an error is a support ticket of its own. Where it is shown, the console says “escalation requested” once the request lands, and shows the ticket number when one exists.
Block stops the visitor’s session working across the whole product. It does not end the chat, and the console says so — ending a chat and blocking a visitor are two separate acts.
Close ends the chat.
Attachments now render. Until now a message carrying a file showed as an empty bubble: the download route needs an authorization header, which no image tag can send, so the console fetches the bytes itself and draws them. Images render inline; anything else becomes a download link with the sender’s own file name on it. That route is the one staff route with a request budget, so a transcript full of screenshots that runs into it says “too many files at once, wait a moment and try again” with a button that does exactly that, instead of a broken-image box.
Canned replies are offered under the reply box, grouped into the ones every department shares and the ones belonging to the agent’s own, with the agent’s language first. Choosing one adds it to whatever is already typed rather than replacing it. Agents read this list; only an administrator can change it.
-
An agent can go Away, and the console says exactly what that means. The control in the header shows the current state and writes the other one. Away does two things: visitors stop being told somebody is here, and no new chat is routed to that agent. It hands nothing over. Every chat the agent already holds stays theirs, unanswered, until they come back — which is what the control’s tooltip says, in both languages, because an agent who reads “Away” as “my chats will be covered” and walks away is the failure this wording exists to prevent.
-
Signing out while holding chats now warns first. The console names how many are still open and what becomes of them — they return to their queues a short time after the console disconnects, not to another agent immediately — and then signs out or stays, as the agent chooses. Closing the tab still warns about nothing, deliberately: a browser gives no reliable way to say anything on the way out, and the server already returns a disconnected agent’s chats on its own.
-
The console has a working screen, and it holds several conversations at once. Signing in lands on it and an agent stays there all day. A rail down the left carries two lists: the chats they hold, and the queue of chats still waiting in their departments. Each held chat shows a count of messages the agent has not read; the chat on screen never carries one, because it is being read as it arrives. That is the whole point of the layout — an agent who was handed three chats did not choose to open any of them, so the count is the only thing telling them which to read first.
The queue is the secondary list and sits below. Under automatic assignment it answers “work nobody eligible could take” and is usually empty; a chat can still be taken from it by hand, and the console says either “someone else took this chat” or “the visitor already left” when it is too late, because those two leave an agent in different places.
The middle pane is the transcript of the focused chat plus a reply box. System lines — an agent joined, the chat was transferred, the chat was closed, the chat became a support ticket — are rendered by the console from its own words. The server stores the event and never the sentence, so a Turkish visitor and an English agent read the same row in their own language. A chat closed by the inactivity sweep says it was closed for inactivity; it never names a person.
The right rail is what is known about the visitor: the name their page reported if it reported one, the page they are on, and when the chat started. The page address is shown as text and never as a link — it is a string the customer’s site supplied.
Every chat has its own address.
/console/conversations/<id>opens the console with that chat in the middle, so a chat can be linked to a colleague and a browser reload comes back to it. -
The console speaks English and Turkish, and picks one on its own. It follows the language stored in the browser, falling back to the browser’s own language on first use — Turkish for a Turkish browser, English for everything else. The choice also travels on every request the console makes, so refusals the server writes come back in the same language as the interface around them. German is a language the API accepts but the console does not offer.
-
The console now follows a conversation live, and recovers the messages it missed. It holds one connection to
/hubs/agentand keeps two lists behind it: the chats the signed-in agent holds and the department queue they can take from. A conversation nobody has claimed stays in the queue — the same events reach a console for a claimed chat and for an unclaimed one, and the console files each by who the chat is assigned to, never by the fact that it arrived, so an agent never sees a colleague’s chat listed as their own. A chat transferred away disappears from the rail it left without a reload, and a closed one disappears from both.Each held chat carries a count of messages the agent has not read yet. That count lives in the browser, so it does not follow the agent to a second device or survive clearing the browser’s data — on a fresh device every open chat simply reads as unread, and one click clears it.
The connection drops about every fifteen minutes and this is by design — the API closes an agent’s socket when the token it handshook with expires. The console re-handshakes with a fresh token and, on every single reconnect, re-reads the queue and each open transcript from where it had got to, because nothing is replayed to a client that was away. A message sent while an agent’s connection was down appears when it comes back. If that re-read itself fails — the API restarting is the usual reason — it is retried rather than left until the next reconnect.
Typing is sent once per burst and switched off after three seconds of silence, not once per keystroke: the server’s typing budget is 60 a minute and it discards the rest without telling anyone, so a per-keystroke indicator would stop working mid-sentence and look like nothing at all.
The console also handles a sign-out instruction from the server, which nothing sends yet. Until something does, deactivating an agent still leaves their open console working for up to the remaining life of their access token, at most fifteen minutes.
-
Agents sign in to the console, and a session now survives being open in several tabs.
/console/loginasks for an email address and a password and nothing else — there is no registration, no password reset and no “forgot my password” link, because the API has no endpoint behind any of them. A wrong address, a wrong password and a deactivated account all answer the same message, deliberately: telling them apart would tell an attacker which addresses are registered.One browser, one session, shared by every tab. The tokens live in
localStorageunder a single key rather than per tab, because the API keeps exactly ONE refresh token per agent and deletes it whenever one is used — so a second tab with its own credentials would revoke the first tab’s the moment it signed in. Tabs coordinate their refreshes over a Web Lock: when several cross the token boundary together, one of them refreshes and the others read the result, instead of one winning and the rest being signed out mid-chat. Signing out in one tab signs out the others.This needs a secure context —
https://, orlocalhost/127.0.0.1in development. The chart does not guarantee it:ingress.privilegedTls.secretNameis empty by default and emits notls:block, so serve the console over HTTPS. A browser without the Web Locks API — which is what an insecure origin gets — still works but loses the coordination, and can still show the occasional spurious sign-out it shows today.The console refreshes two minutes before the access token expires and again when a slept laptop’s tab is brought back to the front, rather than waiting for a request to fail. That is what keeps the agent’s realtime connection alive: the API closes an agent’s socket the moment the token it handshook with expires. Signing out clears the browser’s session even if the sign-out call itself fails — a dead network must not leave an agent signed in.
Error messages arrive in the language of the console, not the server’s default: every request carries
Accept-Language. Nothing about the deployment changes — the console still calls whichever host served it, on root-relative paths, withCors:AllowedOriginsempty. -
The API image now serves an agent console at
/console/. A Nuxt single-page application (apps/agent, a second npm workspace next towidget/) built into the image and served fromwwwroot/console/, with a fallback route so a reload on a deep console path such as/console/conversations/42returns the app instead of a404. Nothing else about the image changes:/v1/*,/hubs/*and/widget.jsanswer exactly as before, and an unknown API route still answers its JSON404rather than an HTML page. This release scaffolds and serves the console; it does not yet contain the agent workflow.The console’s build output is NOT committed, and this deliberately differs from
wwwroot/widget.js, which is.widget.jsis a single file that CI can byte-compare against a fresh build, and theDockerfilenever ran npm, so the committed bytes were the shipped bytes. A Nuxt bundle is a directory of content-hashed chunks whose names move on any dependency bump, so committing it would produce an unreadable diff and a byte-comparison that fails for unrelated reasons. TheDockerfilegains a Node stage instead, which runsnpm ciand builds the console beforedotnet publish; expect roughly a minute more image build time. Nobody should “fix” either one to match the other.The console talks to the API on whichever host the browser loaded it from — every request is a root-relative path, there is no API base URL to configure, and
Cors:AllowedOriginsstays empty. For a split-ingress deployment,/console/*belongs on the privileged ingress alongsidev1/agent/*,v1/admin/*and/hubs/agent. -
Waiting conversations are now assigned automatically, and come back when an agent goes. A new background service looks every two seconds and hands each waiting conversation to the least-loaded agent who staffs its department, is signed in with a live console, and is marked on duty; ties go to the lowest agent id. The visitor’s chat starts within about two seconds of somebody being free, and nobody has to click anything.
Configured by a new
Routingsection, all three values optional and all three validated at boot — a bad value refuses the boot with a message naming it rather than leaving a queue that never drains:"Routing": {"MaxConcurrentChats": 3, // how many chats the router may give one agent"DisconnectGracePeriod": "00:01:30","Interval": "00:00:02"}MaxConcurrentChats(default 3) caps only what the ROUTER hands out. An agent may still take a fourth by hand, and a colleague may still transfer one to them — both are deliberate. Raise it for a short-answer desk, lower it for one doing technical diagnosis; over-assigning is the worse mistake, because a visitor ignored inside somebody’s rail is invisible while a visitor in a queue is on every console in the department.DisconnectGracePeriod(default 90 seconds) is how long an agent may have no console before the conversations they were holding go back to their own department’s queue and are given to somebody who is actually there. Ninety seconds and not instantly, because a tab reload, a train tunnel and the fifteen-minute token cycle all look identical from the server — this is the number that stops a reload costing an agent their chats. A visitor whose agent really has vanished waits at most the grace period plus oneIntervalbefore somebody else has them.A returned conversation keeps its original place in the queue rather than going to the back, writes nothing to the transcript, and reaches both clients as the
conversationStatusandqueueChangedframes they already handle. What the visitor sees is the chat going back to waiting and then a new agent joining — the same experience a department transfer already produces.POST v1/agent/conversations/{id}/claimis unchanged and still available, and it matters more than before, not less: it is how an agent takes a conversation the router left queued because everyone was at capacity or off duty, how a returning agent takes their own chat back, and how anybody overrides a routing decision. Its409— somebody was faster — is now routine rather than rare, because the router is one of the racers.docs/CONSOLE.mdis the operator’s page for all of this: what eachRoutingvalue does, what going Away does and does not do, and the one-machine-at-a-time limitation an agent will otherwise meet as a mid-conversation sign-out.No migration, no new endpoint, no change to any response shape, and nothing to configure on an upgrade: the defaults are the shipped behaviour.
One deployment note.
GET /v1/agent/queueno longer means “work available to you”. On a healthy deployment it is usually empty, because the router has already handed the work out; what it lists now is work nobody eligible could take. An agent’s own conversations are the screen that matters. -
escalationRequestedandflowDeskTicketIdonGET /v1/agent/conversations/{id}. Both were already columns on the conversation; until now the only trace an escalation left an agent was theTicketOpenedsystem row in the transcript, so a console could not show the state of an escalation it had just requested.flowDeskTicketIdstays null until the escalation processor has created and resolved a FlowDesk ticket — a non-null id is the only thing that means “this chat is a ticket now”. Neither field appears on the conversation list or on a hub frame. Additive: no migration, and every existing field is unchanged. -
GET /v1/agent/me— an agent’s own identity, the departments they staff, their availability and what this deployment can do. The department ids decide which realtime queue a console is looking at, and until now they were only readable through the administrator-onlyGET /v1/admin/agents/{id}, so an ordinary agent’s console had no way to learn them. -
PUT /v1/agent/me/availability— on duty or away, written to the agent’s row as well as the in-process presence cache, so it survives the fifteen-minute socket cycle. Going away releases nothing: an agent keeps every conversation they hold and simply stops being given new ones. A consequence worth knowing:availableandonlinein the transfer-target list can now differ, which they never could before. -
Agent.IsAvailable— an agent’s on-duty intent, now a column instead of a dictionary entry. A migration on both providers adds it tolivechat.Agents, defaulting totrue, so every agent an existing deployment already has comes out of the migration on duty and no operator has to go and switch their team back on.PUT v1/agent/me/availabilityis what writes it (see above);GET v1/agent/meand the agent hub read it, the hub seeding presence from it on every connect so an agent’s Away survives the fifteen-minute socket cycle. -
A functional/E2E test layer —
.hurl/, six scenarios, and afunctional-testsCI job that runs them against a deployed instance. This is the dimension nothing else in the repository had: the artefact we ship answers correctly with a real RSA keypair read off disk, a real connection string and a real process, none of whichWebApplicationFactorycan prove because it boots the app’s own assemblies inside the test process.What it covers, and what it deliberately does not. The suite is aimed at the agent and admin surface — five agent controllers and five admin controllers, including every destructive operation this product has, which had no end-to-end coverage of any kind.
01-agent-auth(login, refresh, rotation, logout revocation),02-agent-queue,03-agent-conversation(claim → message → transfer → close),04-admin-crud(agents, departments, widgets, canned responses, each with its 403 path for a non-admin agent),05-widget-canaryand06-ingress-split.It does not chase every route+verb the way
flowdesk-api’s suite does. The visitor surface keepstests/e2e/embed-smoke.mjs— a real Chromium from a real second origin, asserting on real WebSocket frames and proving reconnect/catch-up recovery, none of which Hurl can express — and is represented here by exactly one liveness canary,GET v1/widget/{key}/config. Two suites asserting the same behaviour drift, and the one nobody runs locally is the one that rots.06-ingress-split.hurlconverts the Helm chart’s public/privileged split from a convention into a check, and it is the only executable proof that split will have. Against the public host,v1/admin/**,v1/agent/**(including the login route) and/hubs/agentare not routed — 404 from the routing layer, never reaching the pod; against the privileged host the same paths answer 401, reached and refused on their own terms. The four public paths are asserted positively too, each landing on a different layer of the app:/widget.js→ 200 (static files, which run before authentication),/hubs/visitor→ 401 (authorization reached),/c/{key}→ 200. It also pinspathType: Exacton/widget.js(/widget.js/probe→ 404, which aPrefixrule would route) and element-wise prefix matching (/v1/widgets→ 404).deploy/helm/livechat/verify-split.shnow has a gate. It shipped with the chart and nothing ran it;functional-testsruns it before the API even starts.Operator/contributor notes.
hurl8.0.1,helmandnodeare needed for a full local run (./.hurl/scripts/run-local.sh);helmandnodeare installed in CI by pinned actions rather than assumed present on the runner image, andhurlfrom its release tarball. Copy.hurl/secrets/local.secrets.exampleto.hurl/secrets/local.secretsfirst (*.secretsis gitignored). Ifhelmis missing,run-local.shremoves06from the run and says so rather than executing it against a configuration where both hosts are the same address — a skipped check is honest, a green one that never ran is not.There is still no coverage gate, by decision, and the reason is now written down in
rules/17-testing.md §6instead of reading as an accidental omission. -
The hosted chat page’s Content-Security-Policy is now asserted to admit the WebSocket the page opens, not merely to contain the string
connect-src 'self'. A policy that narrowedconnect-srcto explicithttps://sources, or that addedupgrade-insecure-requests(which rewritesws://towss://), would send SignalR to long polling on a plain-HTTP instance: chat keeps working, the WebSocket is dead, and every other test stays green./c/{widgetKey}integrators who front the app with TLS termination and an HTTP upstream are the ones this protects. -
A Helm chart, at
deploy/helm/livechat— the first deployment artefact this repository has. OneDeployment, oneService, onePersistentVolumeClaim, oneSecret, oneConfigMapand twoIngressobjects.deploy/helm/livechat/README.mdis the operator’s document; the headlines are below.Five values are REQUIRED and have no default —
helm installfails without each.network.trustedProxies— the CIDR(s) your ingress sends traffic from, e.g.{10.42.0.0/16}. This is the one that breaks silently if you get it wrong. The app installsUseForwardedHeadersonly when the list is non-empty, because the middleware performs no proxy check at all when its known-networks list is empty — so an unset value means every visitor of every customer lands in one rate-limit partition (the ingress address, so one abusive visitor spends the mint and login budgets for everyone behind it) andX-Forwarded-Protois ignored, leavingRequest.Schemeathttpbehind a TLS ingress. There is deliberately no default: a pod network is RFC1918, so a “sensible”10.0.0.0/8would hand every neighbouring pod the ability to pick its own partition while looking configured.ingress.publicHost— the host visitors’ browsers reach.ingress.privilegedHost— the internal host agents and admins reach. It must differ frompublicHost, and the chart refuses to render if it does not: one host for both objects means the privileged/catch-all answers wherever visitors do.ingress.publicClassName— theIngressClassof your internet-facing controller.ingress.privilegedClassName— anIngressClassthat is not reachable from the internet, and a different one frompublicClassName. An empty class name is not “no class”: the object carries noingressClassNameat all and the cluster’s defaultIngressClassclaims it, so two empty ones put the privileged catch-all —v1/agent/**,v1/admin/**,/hubs/agent,/health— on the internet-facing load balancer, under a hostAllowedHostsallows. There is no safe default here for the same reason astrustedProxies: a wrong one would look configured.
The public/privileged split. The public ingress is a positive allow-list of exactly four paths —
/c(the hosted chat page),/widget.js(Exact, not Prefix),/v1/widgetand/hubs/visitor. Everything else —v1/agent/**,v1/admin/**,/hubs/agent,/health,/health/live, the dev-only OpenAPI document — is reachable only through the privileged ingress, which routes/and must be attached to a class that is not reachable from the internet. The chart cannot verify that it is: the control lives in a manifest you can edit, and a wildcard host pointed at the sameService, or a controller that ignores unknown paths, defeats it.deploy/helm/livechat/verify-split.shasserts the manifest half without needing a cluster: the rendered(path, pathType)set must equal those four rules — an allow-list, so a rule added under a prefix nobody has thought of yet is a failure by construction — one host and one ingress class per object, and helm refusing every configuration that collapses the split.AllowedHostsis derived from the two ingress hosts, so the allowed-host list and the ingress hosts are one fact and cannot drift. Consequence for anything else that calls theServicedirectly: a request whoseHostheader matches neither gets a bare400from Kestrel before the application runs. The chart’s liveness and readiness probes therefore carry an explicitHostheader — a kubelet probe otherwise sendsHost: <podIP>:8080and the pod would CrashLoopBackOff on a400that looks like a broken application. No chart value widensAllowedHosts— it is those two hosts and nothing else, on purpose — so a mesh gateway or smoke-test job on a third hostname must send one of the two in itsHostheader, exactly as the probes do.The replica count is fixed at 1 and is not a value, with
strategy.type: Recreate(so an upgrade is a brief outage rather than two pods overlapping).PresenceTracker, the JWT logout blacklist and the agent-revocation set are all in-process, and startup migrations run unconditionally: two pods means a visitor told “nobody is available” while an agent is connected to the other pod, a logout that does not take, a deactivated agent still being served, and two processes racing the same migration — none of which produce an error anywhere. It becomes a knob when presence and the blacklist have a shared store.One volume, mounted at
/var/lib/flowdesk-livechat, covering bothStorage:RootPath(attachments/) andLicense:StateDirectory(license/) — the two defaults sit under one parent precisely so this is one PVC. The licence state must be persistent: on ephemeral storage every restart is a new installation to the licence server.podSecurityContext.fsGroupis1654to match the image’sAPP_UID;readOnlyRootFilesystemis on, with anemptyDirat/tmp.Secrets:
ConnectionStrings__Default,TokenEncryption__Key,Seed__AdminPassword(first boot only), the RS256private.pem/public.pempair (mounted as files at/app/Keys, never as environment variables),License__Key,License__ResponseSigningKeys__<keyId>, andFlowDesk__ApiTokenwhen FlowDesk is enabled. Pointsecrets.existingSecretat a Secret you manage yourself and the chart creates none. The signing keys need their key IDs in values even then: the chart builds onesecretKeyRefenv var per key of thelicense.responseSigningKeysmap (it reads no Secret withenvFrom, deliberately —private.pemis not a legal env-var name and the kubelet drops it silently), so a Secret entry whose ID is not listed there reaches the pod as nothing at all. WithexistingSecretset, list the IDs and leave the values empty.Retention, restated where an operator will read it:
chat.retentionWindowmoves expired conversations, messages, attachments and visitors into archive tables in the same database. It is not deletion — the personal data does not leave the database, and this product cannot claim deletion. On SQL Server the freed space returns to the data file and is reused. On PostgreSQL the tables do not shrink until autovacuum reclaims the dead tuples, and returning space to the operating system needs aVACUUM FULLunder anACCESS EXCLUSIVElock, in a maintenance window.Operator responsibilities the chart does not cover, all named in its README: TLS termination and HSTS (no
app.UseHsts()was added — behind an ingress withNetwork:TrustedProxiesunsetRequest.Schemeishttp, so it would be a no-op that looks like a control; on ingress-nginx HSTS is already on in the controller’s own ConfigMap), PostgreSQL vacuuming, backing up the PVC, and verifying that the privileged ingress class is genuinely internal. The image must also keep shipping tzdata — an Alpine or chiseled rebuild must add it explicitly or the first business-hours evaluation throws.Nothing has been published yet, so the chart carries no
appVersionandimage.tagis required. -
AllowedHostsinappsettings.json, shipping as"*". No behaviour change on a stock deployment —*is what ASP.NET Core falls back to when the key is absent — and noapp.UseHostFiltering()call was added, becauseWebApplication.CreateBuilderalready registersHostFilteringMiddlewareand reads this key itself. The key is there so it is discoverable; the narrowing happens in the chart. -
A licence check, and a
Licenseconfiguration section. It ships unconfigured, and an unconfigured deployment is fully functional and unrestricted — exactly what every deployment made before this does today.License:Key(License__Key) is empty by default; with either the key or the server address blank the status isPending, nothing is refused, no seat limit is enforced, and the process logs one warning at boot saying so — a key set withLicense:ServerUrlcleared is just as unlicensed, and warns the same way. The other three values areLicense:ServerUrl(https://license-api.flowdesk.com.tr),License:StateDirectory(/var/lib/flowdesk-livechat/license) andLicense:ResponseSigningKeys(empty).License:StateDirectorymust be on persistent storage. It holds the installation id, the last signed answer and a clock high-water mark. On ephemeral storage every restart looks like a brand new installation to the licence server and the deployment loses the cached answer that carries it through a licence-server outage. It sits under the same parent asStorage:RootPath, so one volume covers both.License:ResponseSigningKeysships empty, and with it empty no answer can verify — the deployment staysPending, i.e. unrestricted. Which public keys this product trusts is not settled yet; until it is, configuring a key alone changes nothing. -
GET v1/admin/license— the licence page. Administrator only,Result<T>envelope, and it reports status, installation id, customer, plan, expiry, the limits this licence carries and the deployment’s own current usage against them (active agents, conversations started this UTC calendar month). It is deliberately reachable while the licence is blocked, so an operator can see why. -
A blocked licence refuses the agent and admin surfaces, and nothing else. When the licence has expired past its grace, everything under
v1/agent/*andv1/admin/*and the/hubs/agentrealtime endpoint answer 503 withFD:0011and a localized message — an envelope, never a bare status.What keeps working while blocked, deliberately: the entire visitor surface —
v1/widget/*,/hubs/visitor, the hosted chat page/c/{key}andwidget.js— plus agent sign-in (v1/agent/auth), the licence page (v1/admin/license) and both health probes (/health,/health/live). An expired licence is a billing matter between us and the operator; the visitor is a member of the public on the customer’s own website. Conversations already in flight can be answered by nobody but can still be read and closed politely, andChat:InactivityTimeoutcloses what is left. The health probes stay green so an orchestrator does not restart-loop a deployment that is running correctly. -
Agent seats are enforced; conversations are counted but not. A licence that names an agent limit refuses the create and the reactivation that would exceed it, with 422 and
FD:0010— it never deactivates or deletes an existing agent, so an installation that came back over-seat from a restore keeps working and is refused only its next activation. A limit of zero means unlimited. Conversations per calendar month are counted and reported on the licence page and never enforced: the only place they could be refused is the anonymous visitor bootstrap, and refusing there would take a customer’s chat window away from members of the public over the operator’s bill. Being over the monthly figure is an invoice conversation, not an outage — and note that this figure tracks the customer’s own site traffic rather than their staffing, so it moves with their marketing rather than with anything they schedule. -
Chat:RetentionWindow(Chat__RetentionWindow) — a duration, unset by default, and unset means retention is off. Set it and one new unit on the existing sweep tick starts moving everything older than that window out of the live tables and into the archive tables below: conversations, then their messages, then their attachment rows, all in one transaction per batch of 200, and afterwards visitors left with no remaining conversation. Rows keep their original ids and gain anArchivedAtstamp. The documented configuration is 24 months — spell it730.00:00:00, not730:00:00, because .NET rejects an hours component above 23 inhh:mm:ssand silently re-reads the string asd:hh:mm. A value below 90 days is refused at startup; leaving the key out logs one line at boot saying retention is off and nothing moves.What is selected is
CreatedAtand never a last-activity column, so an actively chatting visitor of long standing is never moved out from under their agent, and a visitor is only moved once no live conversation of theirs remains. An escalated conversation gets no special case: a FlowDesk ticket id changes nothing about the schedule.The uploaded FILES are deleted — that is the one thing this window destroys, and it happens before the rows move, because once an attachment row leaves the live table nothing in this product could ever find its file again. The attachment’s metadata (
ObjectKey,OriginalFileName,ContentType,FileSize) is kept inChatAttachmentsArchive.Archiving moves rows inside the same database. It is not deletion of personal data. An archived transcript still holds the visitor’s name, e-mail address, the page they were on and every word either side typed, in the same schema, inside the same backup, reachable with the same connection string and the same credentials. What this buys is small live tables, fast hot queries and one backup that still covers everything; it buys nothing at all against “delete my data”. On PostgreSQL the space is not returned to the filesystem by the delete — expect autovacuum activity after a first large run, and schedule a
VACUUM FULLin a maintenance window if reclaiming disk is the goal. A chat that became a FlowDesk ticket also lives in FlowDesk, under FlowDesk’s own retention policy, which this product cannot change.A file that cannot be deleted holds back its own conversation and nothing else. If the storage volume refuses one attachment (a bad key, a permissions change, a full disk) that conversation stays in the live tables with its files intact and every other conversation in the batch moves; the log names the conversation id and the next tick retries it. Earlier the whole tick gave up, which meant the files it had already deleted were gone while their rows stayed visible, and retention never moved anything again.
A batch that races a write rolls back and is retried on the next tick. An agent posting to a conversation that retention is moving at that instant, a file uploaded to one, a conversation started by a visitor being archived — each is detected and each costs one wasted batch rather than a row. Nothing is lost and nothing is duplicated.
What the operator actually sees for that race is an
Errorline, not a quieter tick. An earlier draft of this entry said a race cost “oneInformationline fewer that tick, not an error”; that was wrong, and a probe proved it. A raced batch takes the same failure path as a genuinely stuck one, so it logs the full error — the batch’s id range, how many consecutive ticks have failed, the conflicting ids, and the sentence “retention … will not resume on its own if the cause persists”. For a race the cause does not persist: the next tick re-selects the same conversations and moves them, and the consecutive-failure count resets to zero. Read a single such line with a count of 1 as noise; read a rising count as a real stall. The usual real cause is an id already present inConversationsArchivefrom a half-finished manual restore, which the line names so it can be cleared.One stall that counter cannot see, stated so nobody waits for an alert that will not come. If every conversation in a tick fails its blob deletion, nothing is movable, so the batch is never attempted and the consecutive-failure counter is neither incremented nor reset. Retention is fully stalled and the only evidence is one
Warningper conversation — noError, and no rising count. Alert on the warning as well as the error. -
Four archive tables —
livechat.VisitorsArchive,ConversationsArchive,ChatMessagesArchiveandChatAttachmentsArchive— and three new indexes onVisitors.CreatedAt,Conversations.CreatedAtandChatMessages.CreatedAt. A migration has to run, on both SQL Server and PostgreSQL. On a large existing database the three index builds are the part that takes time; the four tables are created empty.They are the destination
Chat:RetentionWindowabove moves rows into, and they stay empty on a deployment that never sets it. The honest word for what the mover does is moved, not deleted: the personal data does not leave the database, so this product cannot claim deletion. -
A visitor whose chat is escalated is now told about it. Once FlowDesk hands back a real ticket id, a
TicketOpenedsystem row is appended to the conversation and pushed over the visitor’s socket, and the widget renders it in both locales (“Talebiniz destek ekibimize iletildi, en kisa surede donus yapilacak.” / “Your request has been passed to our support team and someone will get back to you.”). Escalation was the one transition that changed who was answering and produced no line at all — the visitor watched the chat go quiet. The line does not carry the ticket number: it is an internal identifier the visitor cannot look up or quote to anyone. Nothing is said when an agent merely requests escalation: a request with no FlowDesk pool on the department, no email for the visitor, or an unreachable FlowDesk stays pending indefinitely, and announcing it would promise a ticket that does not exist. A failure while announcing is now retried: the ticket id and the row are recorded in one transaction, so an escalation that fails mid-announce stays pending and the next 15-second tick tells the visitor, instead of recording the id and losing the announcement for good. One conversation’s failure no longer abandons the rest of that tick either — each pending escalation is advanced in a unit of work of its own, so a failure cannot reach the conversations behind it in the batch, and it is logged against its conversation id. -
docs/WIDGET.md— the integrating developer’s guide to embedding the widget: the embed snippet, thedata-widget-key/data-modeattributes, why the API origin is not configurable, the allowed-origins and CSP setup,window.FlowDeskChat.identify(), what each widget setting changes for the visitor, the hosted chat page, and the browser-support floor (Chrome/Edge 90, Firefox 101, Safari 16.4 — set by constructed stylesheets andaddEventListener(..., { signal })). -
GET /c/{widgetKey}serves a full-page chat for an email signature, a QR code or an iframe; it is public, anonymous and not indexable. -
Widget: files sent in a chat now render in the widget. Images appear inline (capped at 200 px tall); all other file types appear as a download button showing the file name and human-readable size. The download route (
GET v1/widget/attachments/{id}) requiresAuthorization: Visitor, which a plain<img src>or<a href>cannot send, so the bytes are fetched through the Visitor-authenticatedfetchpath and served from a blob URL. Object URLs are revoked when the visitor session is torn down so file bytes do not pin memory for the page’s lifetime. Failed fetches show the file name with a retry button rather than a broken image icon. -
Widget: visitors can now send file attachments when
visitorUploadsEnabledis true on the widget row. An attach button appears in the composer; picking a file checks its size and extension in the browser before upload (a courtesy check — the server enforces the same limits and is the actual gate). The upload runs overPOST v1/widget/conversations/{id}/attachmentswith a progress bar. A 429 from thevisitor-uploadrate-limit policy is shown as a notice rather than silently dropped. The size cap and the extension allowlist come fromattachmentMaxSizeBytesandattachmentAllowedExtensionsin the widget configuration. A file with no caption is a valid message; a caption with no file is also valid. -
window.FlowDeskChat.identify(traits)now reaches the server: once a visitor token exists, callingidentify({ displayName, email, externalId })sendsPUT v1/widget/visitors/mewith the visitor’s own credential. Calls made before the widget opens are stored in memory (and inlocalStoragefor cross-page-view persistence) and flushed on the next session open. Failures are swallowed — a visitor whose name would not save still gets a working chat. -
Pre-chat form (
preChatFormEnabled: trueon the widget row): visitors see a name + email form before the composer appears. Submitting identifies the visitor and, when the widget’s department list is non-empty, routes the conversation to the chosen department viadepartmentIdinPOST v1/widget/conversations. The form is bypassed for returning visitors who already have a stored conversation. Per spec ruling R5-2, both fields are required; the operator’s lever is turning the form off, not making its fields optional. -
Widget: typing indicators in both directions and a read cursor. The widget shows “An agent is typing…” above the composer when the hub’s
typingevent arrives withisTyping: truefrom an agent; the indicator self-clears after 8 s in case the sending socket drops before it can send the stop event. The visitor’s own typing is reported to the hub viaTyping(conversationId, isTyping)– debounced to one invoke per burst, with an automaticfalseafter 3 s of idle and an immediatefalsewhen the visitor sends. Read progress is reported viaPUT v1/widget/conversations/{id}/read– debounced to avoid spending thevisitor-writerate-limit budget (shared with message sends) on cursor updates, and only when the panel is open and the tab is visible. A message that arrives while the tab is hidden is reported as soon as the visitor returns to it, so the agent console’s read receipt does not wait for the next message. -
Widget: an unread badge appears on the launcher button when messages arrive while the panel is closed. The count is capped at “9+” and resets to zero when the panel opens. System rows that render to nothing (kinds this bundle has no words for) do not increment the count. The visitor’s own messages never increment it. The uncapped count is also folded into the launcher button’s accessible name, so a screen reader announces “Chat (12)” rather than just “Chat”.
-
Widget: visitors can end their own chat via an “End chat” / “Sohbeti bitir” button in the panel header, next to the existing close-the-panel button. The action posts to
POST v1/widget/conversations/{id}/close; the server broadcasts theClosedsystem row back through the hub so the “you ended the chat” bubble arrives in the normal message flow. The transcript stays on screen after a successful close — the visitor’s own close now looks exactly like an agent’s — and the next message the visitor sends is filed by the server under a new conversation, which the widget adopts. A close that comes back 404 (the chat was already over, e.g. a double click or an agent closing first) is treated as success. The button is hidden until a conversation exists, and a transient failure shows an end-the-chat-specific notice. -
PUT v1/widget/visitors/me— a visitor-authenticated endpoint that lets a page callidentify({ displayName, email, externalId })and persist those traits on the caller’s own Visitor row. The caller’s identity comes from their authenticated token, never from the request body; there is no parameter that accepts a visitor id, and there is no read side (the stored values are used by the agent console and the auto-escalation path, not returned to the visitor). Null means “leave the field alone”; an explicit empty string clears it. The route carries thevisitor-writerate-limit policy. -
Initial service skeleton: boots against either SQL Server (the default provider) or PostgreSQL, applies pending migrations automatically on startup, and answers
GET /health(dependency checks) andGET /health/live(liveness only, no dependency checks). -
Agent, Department, and Widget records, with a first-boot seeder: an administrator agent (address and password come from
Seed:AdminEmail/Seed:AdminPassword; the boot fails if neither is configured and no agent exists yet), a default department (“Genel”), and a default widget (keydefault). Re-running the seeder on every subsequent boot is safe — each piece is created only if it doesn’t already exist. -
Agent sign-in:
POST v1/agent/auth/login,POST v1/agent/auth/refresh,POST v1/agent/auth/logout. RS256-signed JWT access tokens (15-minute lifetime) with rotating, database-backed refresh tokens — issuing a new pair immediately invalidates the previous refresh token. Logging out revokes the refresh token and the still-live access token together, so a token used again after logout is rejected immediately rather than waiting out its remaining lifetime. A deactivated agent can neither log in nor refresh an existing session. There is no account lockout by design; login attempts are rate-limited instead (see below). -
Agent presence tracking: each agent’s connections are tracked with a manual availability toggle, so “is anyone from this department online” can be answered without a database round trip. Reconnecting from a second browser tab keeps the agent marked present until every connection closes.
-
ConversationandChatMessagetables, with a gap-free, per-conversation message sequence number assigned by a single atomic database statement on both providers. A sequence number handed out by a request that then fails is returned rather than burnt, so the numbering has no holes for a client’s resume cursor to fall into. -
Visitor conversations and messages — the first chat write path in this product. Both endpoints authenticate with
Authorization: Visitor <token>and count against the visitor-write rate-limit budget (RateLimit:VisitorWritePerMinute).POST v1/widget/conversations— body{ widgetKey, departmentId?, pageUrl?, referrer? }, answers{ conversationId, status, departmentId }. The widget key travels in the body rather than the path: this resource is keyed by the visitor’s own token. A visitor who already has an open conversation on that widget gets that one back instead of a second one, so a page reload or a second browser tab resumes the same thread rather than splitting it across two agents — including when both tabs start at the same instant, where the losing request is answered with the winning conversation instead of an error. An unknown or disabled widget, and a department that is not active, both answer404.POST v1/widget/conversations/{id}/messages— body{ content, clientMessageId? }, answers201 Createdwith{ id, conversationId, seq, sender, content, sentAt, replayed }.seqis the gap-free per-conversation sequence number a client resumes from, andconversationIdnames where the message was actually stored — which is not always the id it was posted to, see the closed-conversation behaviour below. Reposting the sameclientMessageIdreturns the original message with200 OKandreplayed: trueinstead of storing a second copy, so retrying after a lost response on a flaky mobile connection is safe. Two retries racing each other are safe too: the one that loses is answered with the stored message rather than an error.- A conversation belonging to a different visitor answers
404, never403— a caller who does not own an id is never told that it exists. - Closed is terminal: a message sent to a closed conversation starts a new conversation instead of
reopening the finished one, and the response carries the new conversation’s
seq 1. - Message content is capped at 8000 characters and is rejected with
400above it. This is now enforced identically on both database providers; previously the cap only existed as a column width, which PostgreSQL enforced and SQL Server did not. GET v1/widget/conversations/{id}/messages?afterSeq=0— a visitor’s transcript read, answering{ messages[], lastSeq }. Messages are ordered by their sequence number rather than by send time, since two messages can share a timestamp but never a sequence number, and only messages withseq > afterSeqare returned, capped at 200 per page.lastSeqis the conversation’s current sequence number, not the last one actually returned: when it is still ahead of the last message in the page, the caller is more than one page behind and calls again withafterSeqset to that page’s own lastseqto keep fetching. As with the write endpoints, a conversation belonging to a different visitor answers404, never403.
-
File attachments on a conversation, in both directions. One file per message, capped by
Attachment:MaxSizeMb(default 10) and restricted to an allowlist of image and PDF types. The allowlist is deliberately fixed in the product rather than configurable, because the same list decides both what may be stored and what a browser is allowed to render inline, and this origin also serves the embeddable widget. Files are stored on disk (or a mounted volume), not in the database.Storage:RootPath(Storage__RootPath) is where those files are kept, defaulting to/var/lib/flowdesk-livechat/attachments— mount the volume there, and back it up alongside the database, since a record without its file is as broken as a file without its record.Attachment:MaxSizeMbmust be between 1 and 25 and the boot fails outside that range: above it the web server’s own 30 MB request-body limit would refuse the upload first, with a bare413and none of this product’s messages, so the configured cap would quietly not be the cap.POST v1/widget/conversations/{id}/attachments— a visitor uploads one file to their own conversation asmultipart/form-data, answering201 Created. Uploads can be turned off per widget (VisitorUploadsEnabled), and a conversation that has been closed no longer accepts them. A conversation belonging to a different visitor answers404here too, so an upload endpoint cannot be used to discover which conversation ids exist. If storing the record fails after the file has been written, the file is removed rather than left behind.POST v1/agent/conversations/{id}/attachments— the other direction of the same upload, on the agent JWT, so an agent can send a file back into a conversation. Sending a file is writing into the chat, so it takes the same ownership rule as a reply rather than a looser one: only the agent the conversation is assigned to may upload into it, a colleague who can merely see it in their department’s queue is answered403, an agent who cannot see it at all is answered404— the id is never confirmed to them — and a closed conversation answers422. The cap and the allowlist apply to staff exactly as they do to visitors, because the file is served back from this origin to a browser either way. The per-widgetVisitorUploadsEnabledswitch is one-directional and does not apply here; it turns off visitor uploads only. Neither an anonymous caller nor a visitor token can reach this route.GET v1/widget/attachments/{id}andGET v1/agent/attachments/{id}— downloading a stored file, one route per audience. Neither route carries a conversation id: the file is fetched by its own id and the conversation it belongs to is read off the stored record, so a caller cannot pair an attachment with a conversation of their choosing. Whether the caller may then read it is decided by the same rule that already decides whether they may read the conversation itself — a visitor may read the files on their own conversations (including ones that have since been closed, so ending a chat does not take a visitor’s own files away), and an agent may read the files on any conversation they can see, which is their departments’ plus anything assigned to them, plus everything for anAdmin. Holding the conversation is not required to read a file, only to send one.- Every refusal on those two routes answers
404with one identical message: a file that does not exist, a file on somebody else’s conversation, a file in a department the agent does not staff, and a record whose stored file has gone missing are indistinguishable from outside. An attachment id is not an existence oracle, exactly as a conversation id is not. - Downloads are served with
X-Content-Type-Options: nosniffand with the stored content type, which was decided from the allowlist at upload time rather than taken from the uploading client. Images are servedinlineso a chat client can render them; everything else, PDFs included, is served as an attachment so the browser saves it rather than opening it as a document on this origin — the same origin that serves the embeddable widget. Non-ASCII file names are encoded per RFC 6266 and survive the round trip intact. - Downloads carry their own rate-limit budget,
RateLimit:AttachmentDownloadPerMinute(default 240 per minute per caller). This is the only read endpoint in the product with a budget, and it has one because it is the only one that can moveAttachment:MaxSizeMbper request. The default is set well above a real client reopening a conversation full of images with a cold cache; lower it if a deployment needs to. - Attaching a stored file to a message. Both message endpoints accept an optional
attachmentId, and every message shape a client reads — thePOSTresponse and both transcript reads — now carriesattachmentwith{ id, fileName, contentType, fileSize }, ornullwhen the message has no file. The metadata travels with the message, so rendering a page of history needs no extra call per file; the bytes are still fetched from the caller’s own download route. - A message may now be a file with no text at all — sending a photo without typing a caption is a
real message, and a client no longer has to invent one. A message with neither text nor a file
is still refused with
400, and whitespace-only text still counts as no text. - A file can be attached to exactly one message. Referencing one that is already spent, one on
another conversation, one belonging to another visitor, or one that never existed all answer the
same
404with the same message as the download routes — an attachment id is not an existence oracle on the write path either. - A retried
POSTcarrying the sameclientMessageIdreplays the original message together with its file, rather than reporting that the file has already been used. The reply describes the file the stored message actually has, not the one the retry asked for. - When a visitor’s message is redirected into a new conversation because the old one closed
underneath them, a file they had already uploaded follows the message instead of being lost. The
move is confined to the visitor’s own unspent file leaving the exact conversation that send was
redirected out of, so it can never pull in a file uploaded by someone else — an agent’s own
pending upload included — or a file from an unrelated conversation. Anything outside that answers
404and moves nothing. This keeps the rule the download routes depend on intact: a file always belongs to the conversation of the message that carries it, which is what lets the agent who ends up with the new conversation open it. - The widget configuration response now carries
attachmentMaxSizeBytesandattachmentAllowedExtensions, so a client can reject a file before spending an upload on it and show the operator’s real limit instead of a hardcoded guess. Uploads count against their own budget,RateLimit:VisitorUploadPerMinute(default 10 per minute), separate from the visitor-write budget. - An upload that no message ever referenced — someone picked a file and then closed the tab — is
removed on its own once it is older than
Attachment:OrphanGracePeriod, the record and the stored file together. A file that any message references is never removed, however old it or its conversation is; being unreferenced is the rule, and the grace period only decides how long an unreferenced file is kept before it counts. The window is generous by default (one day) so that uploading, walking away and coming back later still finds the file there. Write the duration as1.00:00:00for a day:.NETreads24:00:00as twenty-four days, and the setting is validated at startup only for being positive, so the unit is worth getting right. Environment variable form:Attachment__OrphanGracePeriod. - The cleanup runs inside the conversation inactivity sweep rather than as a second background
job, so it shares one cadence (
Chat:SweepInterval), one registration and one place to look when cleanup stops happening. The two are independent within a tick: if closing conversations starts failing, unreferenced uploads are still collected, and the log names which part failed instead of reporting “the sweep” and leaving an operator to guess.
-
The agent queue and race-safe claiming — the first agent-facing operational endpoints. All three authenticate with the agent JWT (
Authorization: Bearer <accessToken>); an anonymous caller or a visitor token is rejected with401.GET v1/agent/queue— the conversations still waiting for an agent, answering an array of{ conversationId, departmentId, startedAt, lastMessageAt, pageUrl }. Scoped to the departments the calling agent belongs to; anAdminsees every department, and an agent who belongs to no department sees an empty queue rather than everything. Ordered oldest-waiting first, so a conversation transferred back into a department keeps its original place — the visitor already waited once. Capped at 200 conversations per call; there is no cursor, and the cap drops the newest arrivals rather than the longest-waiting ones.POST v1/agent/conversations/{id}/claim— assigns the conversation to the calling agent and moves it toActive. An agent may claim a conversation they can already see — one in a department they staff, or one already assigned to them — and anAdminmay claim any. Answers200on success,409when another agent claimed it first,410when the conversation is already closed, and404both when there is no such conversation and when there is one the calling agent may not see, never403.409and410are deliberately different answers: “someone beat you to it” and “this chat is over” call for different handling in a console, and the second can happen to a conversation nobody ever claimed — the inactivity sweep closes chats that are still waiting. Two agents claiming in the same instant produce exactly one winner; the loser is told so rather than silently sharing the conversation. Claiming a conversation you already hold answers200again rather than409, and writes no second System message — a double-click, or a retry after a lost response, is absorbed instead of reporting that a stranger took your chat.- A successful claim appends a
Systemmessage to the conversation, visible to both sides, with the sequence number a client resumes from. It carriessystemKind: "Claimed"andsystemParams: "{\"agentName\":\"...\"}"rather than pre-rendered text, so the widget and the console each render the event in their own language. POST v1/agent/conversations/{id}/close— ends the conversation, recording that an agent closed it. Only the agent currently holding the conversation may close it; anAdminmay close any, which is also the only way to end a conversation still waiting in the queue that nobody claimed. Answers200, or404for every reason it did not apply — no such conversation, already closed, or held by another agent — never403, so an agent is not told that a conversation they cannot act on exists. Closing twice does not rewrite who ended the conversation or when.GET v1/widget/conversations/{id}/messagesnow returnssystemKindandsystemParamson every message. Both arenullon visitor and agent messages, so a client can branch on their presence. This is an additive response-shape change for anyone already consuming the visitor transcript.
-
Agent replies, ending a conversation from either side, and the visitor’s read cursor. The agent endpoints authenticate with the agent JWT; the widget endpoints with
Authorization: Visitor <token>and against the visitor-write rate-limit budget.POST v1/agent/conversations/{id}/messages— body{ content, clientMessageId? }, answers201 Createdwith{ id, conversationId, seq, sender, agentId, content, systemKind, systemParams, sentAt, replayed }.seqcomes from the same per-conversation counter the visitor’s messages use, so a transcript is one sequence rather than two interleaved ones. Reposting the sameclientMessageIdreturns the stored message with200 OKandreplayed: true. Only the agent the conversation is assigned to may reply: a colleague who can see it in their department’s queue is answered403, because being able to see a chat is not the same as holding it. A closed conversation answers422— and unlike a visitor’s message, an agent’s is never redirected into a new conversation. The first agent message also marks the conversation as answered, which is what will keep a chat nobody ever replied to out of the coming inactivity sweep.GET v1/agent/conversations/{id}/messages?afterSeq=0— the agent’s transcript, same paging and samelastSeqsemantics as the visitor’s, plusagentIdon each message so a conversation that changed hands shows which agent said what.GET v1/agent/conversations/{id}— the conversation detail:{ conversationId, status, departmentId, assignedAgentId, visitorId, lastSeq, visitorLastReadSeq, hasAgentReplied, startedAt, lastMessageAt, closedAt, closedBy, pageUrl }. Scoped like the queue: an agent sees a conversation in a department they staff or one assigned to them, anAdminsees any, and anything else answers404rather than403.POST v1/widget/conversations/{id}/close— the visitor ends their own chat, whichever agent is holding it. Answers200, or404for a conversation that is not theirs or is already over.PUT v1/widget/conversations/{id}/read— body{ upToSeq }, records how far the visitor has read for the agent console to display. The cursor only ever moves forward: an out-of-order request from a background tab is accepted with200and changes nothing, and a position past the conversation’s last message is ignored rather than parking the cursor beyond the end. Read receipts are one-directional by design — the widget is shown none of its own.- Closing a conversation now appends a
Systemmessage withsystemKind: "Closed"andsystemParams: "{\"closedBy\":\"Agent\"}"(or"Visitor"), from both the agent route and the widget route. Previously a close was silent in the transcript, so a widget whose page stayed open had no way to tell the chat had ended — its next message would quietly start a new conversation and come back under an id it never asked for. Clients already renderingsystemKindneed no change; clients that ignore unknown kinds will now see one more message in a closed conversation.
-
The public widget surface, the first anonymous, internet-facing endpoints in this product:
GET v1/widget/{widgetKey}/config— returns the widget’s display configuration and, per department, whether an agent is currently available (department-scoped, so a visitor headed for Sales is never told someone is available because a Support agent happens to be connected).POST v1/widget/{widgetKey}/visitors— mints a new visitor and its bearer token. The token is returned in plaintext exactly once, in this response; only its SHA-256 digest is ever stored, logged, or queried afterward. A visitor authenticates subsequent requests withAuthorization: Visitor <token>.- Anonymous traffic on this surface is rate-limited: visitor minting and agent login are limited
per client address, and authenticated visitor writes are limited per visitor (falling back to
address if unauthenticated). Budgets are configurable
(
RateLimit:VisitorMintPerMinute/RateLimit:VisitorWritePerMinute/RateLimit:AgentLoginPerMinute, defaults 10/60/20 per minute). A throttled request receives429with aRetry-Afterheader and the same response envelope as every other error on this API, rather than a bare rejection.
-
Conversations that have gone quiet are now closed automatically by a background sweep, running inside the API process — no scheduler, no extra deployment.
- The timeout only ever applies to a conversation an agent has actually replied in. A chat still waiting in the queue that nobody answered is never swept, however long it waits: closing it would silently discard a visitor’s overnight message before any agent saw it.
- A swept conversation is closed exactly as the agent and widget close endpoints close one — its
status becomes closed,
closedByreadsTimeout, and the samesystemKind: "Closed"message is appended to the transcript, withsystemParams: "{\"closedBy\":\"Timeout\"}". A client already rendering the Closed system message needs no change beyond expecting the thirdclosedByvalue; a visitor whose widget is still open learns the chat has ended instead of having their next message quietly start a new conversation. - Configurable through the new
Chatsection:Chat:InactivityTimeout(default00:30:00) is how long an answered conversation may sit idle, andChat:SweepInterval(default00:01:00) is how often the sweep looks — so a conversation closes between the timeout and the timeout plus one interval after its last message. Both are durations (hh:mm:ss), both must be positive, and a non-positive value fails the boot with a message naming the key rather than producing a service that silently never runs. Environment-variable form:Chat__InactivityTimeout,Chat__SweepInterval. - A tick that fails is logged and abandoned; the next one retries. A sweep that cannot reach the database never takes the chat surface down with it.
-
Realtime delivery over SignalR — two hub endpoints,
/hubs/visitorand/hubs/agent. Until now every write was announced into a placeholder that discarded it, so a widget or a console could only learn about new activity by polling; both now receive it as it happens.- Both hubs authenticate with the credential the matching REST surface uses, but passed as
?access_token=<token>on the connection URL rather than in a header, because a browser cannot set headers on a hub handshake. Query-string credentials are accepted only on these two paths. Deployments must redact query strings in reverse-proxy access logs. - The agent hub’s connection is closed when its access token expires rather than outliving it, so a console that has not refreshed stops receiving conversation traffic instead of continuing on a dead token. Logging out revokes the token immediately, as before.
- Server-to-client events, all with camelCase payloads:
message({ conversationId, seq, sender, content, systemKind, systemParams, sentAt }— the same shape the REST transcript answers,systemKindnull for an ordinary message),conversationStatus({ conversationId, status, departmentId, assignedAgentId, closedAt, closedBy }),queueChanged({ conversationId, departmentId, status }, agent hub only),readUpTo({ conversationId, upToSeq }, to the assigned agent only — the widget shows no read receipts), andtyping({ conversationId, from, isTyping }). - One client-to-server method, on both hubs:
Typing(conversationId, isTyping). Everything durable is still written over REST, where validation, rate limiting and the localized error contract live. ATypingcall naming a conversation the caller may not touch is dropped silently rather than answered with an error — an error would confirm which conversation ids exist. Typingis rate-limited per authenticated principal (per visitor on the widget hub, per agent on the console hub — never per address, since a whole office floor shares one). The budget is configurable:RateLimit:TypingPerMinute(RateLimit__TypingPerMinute), default 60 per minute. A call over budget is dropped silently, exactly like one naming a conversation the caller may not touch — the connection stays open and no error is returned.- Events are addressed to a conversation’s visitor and to the agent holding it; while nobody holds it, the agent side goes to everyone watching that department’s queue. An agent’s department subscriptions are re-derived from the database on every connect, so a reconnect restores them, and an administrator receives every department’s queue exactly as the REST queue endpoint answers it.
- Nothing is replayed on reconnect: a client that was offline catches up by reading
?afterSeq=on the transcript and re-reading the queue, which is what those endpoints are for.
- Both hubs authenticate with the credential the matching REST surface uses, but passed as
-
Claiming a conversation you already hold now re-announces the assignment. The second claim still writes nothing — no duplicate “an agent joined” line in the transcript — but the console whose first response was lost is told it owns the chat instead of being left silent.
-
The embeddable chat widget. One tag on the customer’s page —
<script async src="https://<this-deployment>/widget.js" data-widget-key="default"></script>— mounts a launcher, a panel and a live conversation, with no configuration object and no origin repeated anywhere: the API origin is taken from the script’s ownsrc. The widget renders inside a Shadow DOM and styles itself through a constructed stylesheet, so it is isolated from the host page’s CSS in both directions and works on a site whose Content-Security-Policy has no'unsafe-inline'. It is lazy — no visitor is minted and no socket is opened until the visitor opens the panel, or until a conversation from a previous page view is already known — and it never throws into the host page.window.FlowDeskChat.identify(traits)records visitor traits for the agent console (seePUT v1/widget/visitors/mebelow for the server side). -
GET /widget.js— the widget bundle, served by the API fromwwwroot. Anonymous, and served before localization, correlation ids, request logging and authentication. -
CORS for the embed surface (
v1/widget/*and the visitor hub’s negotiate). Allowed origins come from the widget rows in the database rather than from configuration, so a customer’s site is onboarded by editing their widget, not by redeploying.Cors:AllowedOriginsis unchanged and still governs first-party callers only. An origin no enabled widget lists is answered normally with noAccess-Control-Allow-Originheader — never an error, so non-browser callers are unaffected — and the answer varies byOriginso an intermediary cache cannot serve one site’s answer to another. Note that the allowed-origin set is deployment-wide (the union of every enabled widget’s list) rather than per widget key: three of the five routes it protects carry the widget key in a request body, which a CORS preflight never sends. -
An
Origincheck on the visitor hub’s handshake, which CORS structurally cannot cover: a WebSocket upgrade is not a CORS request, so without it any page on the internet could hold a live hub connection. A browser on an origin no enabled widget lists is refused403. A request with noOriginheader at all is allowed on purpose — the header is client-asserted, and WebViews on custom schemes legitimately omit it. -
Widget:DevAllowedOrigins(Widget__DevAllowedOrigins__0=…) — a development-only list of extra allowed origins for the embed surface, read only whenApplication:EnvironmentisDev. It ships empty. On any other environment the entries are ignored and the boot logs a warning naming the fact; customer origins belong on the widget row, not in configuration. -
Admin department management under
v1/admin/departments, requiring the Admin role throughout. An ordinary agent attempting any of these routes receives403with this product’s error envelope and a localized message; the role check runs before any department id is resolved, so a non-admin cannot use the endpoints to discover which department ids exist.GET v1/admin/departments— lists departments by name (?query=), paged and ordered alphabetically with a stable tie-break, so a page cursor never skips or repeats a row. Page size is clamped to a safe ceiling.GET v1/admin/departments/{id}— returns a single department, including one whoseIsActiveisfalse. Deactivation is therefore not a one-way door: a department can be inspected and reactivated after the fact.POST v1/admin/departments— creates a department.PUT v1/admin/departments/{id}— updates a department’s name orIsActiveflag. SettingIsActivetofalseis the non-destructive way to stop new conversations routing into a department.DELETE v1/admin/departments/{id}— soft-deletes a department, but is refused with409if any of three conditions hold: a widget still lists the department as its routing target, at least one agent is still assigned to it, or at least one conversation inside it is still open. Each refusal carries a distinct message identifying which condition blocked the delete.
-
Admin widget management under
v1/admin/widgets, requiring the Admin role throughout — list (?query=, paged), read (including a disabled widget), create, update and delete. Each widget’s routing target (defaultDepartmentId) must name a department that currently exists and is active, or the write is refused; a department deactivated or removed after the fact does not silently orphan the widget. A widget key that differs from an existing one only in case is refused with409on create and on rename — the same case-folding the embed surface already applies, so two spellings of a key can never become two widgets — and a malformed allowed-origin (anything that is not a barescheme://host[:port]) is refused with400. -
Admin agent management under
v1/admin/agents, requiring the Admin role throughout — list, read, create, update and delete an agent account, plusPUT v1/admin/agents/{id}/departmentsto set the departments an agent staffs (the request replaces the whole set). Agent addresses are stored case-folded and a duplicate answers409; a password is accepted on write and never echoed back. The last administrator on an installation cannot be demoted, deactivated or deleted — each is refused with422, because there would be no way back in. Create or promote a second administrator first. (A narrow race is accepted for v1: two administrators demoting each other in the very same instant can both pass the check and leave the installation with none; recovery is a database edit, and hardening this is carried to a later plan.) -
Admin canned-reply management under
v1/admin/canned-responses, requiring the Admin role throughout — list, read, create, update and delete a reusable reply, optionally scoped to one department. Reply titles are not required to be unique. -
Canned replies for agents at
GET v1/agent/canned-responses: an agent sees every global reply (one scoped to no department) plus the replies of the departments they staff, an administrator sees all, and an agent who staffs nothing still sees the globals. This is a scoped read, not a guarded one — there is no403. -
Conversation transfer. The agent holding a conversation can hand it to a colleague (
POST v1/agent/conversations/{id}/transferwithtoAgentId) or send it back to a department’s queue (toDepartmentId), and aSystemmessage records the handover in the transcript. A conversation returned to a queue keeps its original waiting position — the visitor already waited once. Only the agent who holds the conversation may transfer it; a colleague who can merely see it answers403, and an agent who cannot see it at all answers404.GET v1/agent/conversations/transfer-targetslists the departments and agents a transfer may name, each agent carried with their current presence and availability so the transferring agent can avoid handing a live chat to someone who has stepped away — a transfer to an absent agent is allowed, not blocked, because the server cannot know who is at lunch. The list carries no email address and does not disclose which agent staffs which department. -
Blocking a visitor, anchored on the conversation they are in:
POST/DELETE v1/agent/conversations/{id}/block-visitor. A blocked visitor’s token stops working on every widget route (within the token’s own lifetime — see below). Only the agent holding the conversation may block, except that an administrator may block without holding it. A closed conversation refuses the block for everyone, administrators included: the exemption covers ownership only. There is deliberately no route that blocks a visitor by a raw visitor id — the id is one nothing ever hands an agent — so a block cannot be aimed at a guessed id. -
Conversation search at
GET v1/agent/conversations: free text (?query=) over message content and the visitor’s name or email, plus filters for status, department, assigned agent and start date, with paging. Results — including the total count — are limited to the conversations the caller may already open: their own departments and the ones assigned to them, with an administrator seeing all. A conversation an agent is refused by id does not appear in their search, and its existence is not leaked through the count. Sorting is bylastMessageAt(the default) orstartedAtonly; any other sort value answers400. -
FlowDesk escalation — turn a live chat into a FlowDesk ticket. Optional, off by default, and a LiveChat deployment stays fully usable without it. A new
FlowDeskconnection setting (FlowDesk:Enabled,FlowDesk:BaseUrl,FlowDesk:ApiToken, andFlowDesk:BusinessHours) points this product at a FlowDesk deployment’s Integration API. The API token is a secret — read from configuration or the environment only, never logged and never returned by any endpoint; when escalation is enabled the boot fails fast if the base URL or token is missing, but the boot does not call FlowDesk. Seerules/16-configuration-and-environment.md. -
Agent-initiated escalation:
POST v1/agent/conversations/{id}/escalateflags a conversation for escalation into the department’s FlowDesk pool. A double-click is idempotent. The request is refused 422 with a distinguishing message when the department has no FlowDesk pool (NoPool), when the visitor has no email address to raise a ticket against (NoContact), or when FlowDesk is not configured (NotConfigured); the usual404/403apply to a conversation the caller may not see or act on. -
Automatic escalation: a queued, unassigned conversation whose visitor has provided an email and whose department has a FlowDesk pool is escalated automatically when no agent is online, or the message arrives outside configured business hours — so a visitor nobody can serve still reaches a ticket instead of an empty queue. It is not escalated when an agent is online inside business hours. Escalation requires a visitor email; a visitor who has not left one is never auto-escalated. This never blocks or fails the visitor’s message send — the message is accepted first, the escalation is attempted around it.
-
One-way batched message mirror: once a conversation has a FlowDesk ticket, its later messages are pushed onto that ticket in the background, in order, on a configurable interval (
Chat:MirrorInterval, default 15 seconds). The cursor advances only after a batch is accepted, so an interrupted push resumes from where it left off. Stated ceilings, by design:- One-way. The mirror pushes LiveChat → FlowDesk only. Replies or changes made on the FlowDesk ticket are not reflected back to the visitor in the chat.
- Latency, not a live feed. Because the mirror is batched and retrying, a message reaches the ticket after a short delay (about one interval plus a round trip, longer while FlowDesk is unreachable and the batch is retrying) — it lags a live feed rather than matching it.
- At-least-once. A batch that partly succeeds and is then retried may re-send a message, so the ticket can show a duplicate. A per-message client id lets the FlowDesk side collapse the duplicate, but the send guarantee is at-least-once, not exactly-once.
- Message bodies only, not attachments. Message text is mirrored; message attachments are not mirrored yet — a file a visitor or agent sent in the chat does not appear on the FlowDesk ticket in this version.
-
A rate-limit budget for the two anonymous bootstrap reads,
GET /v1/widget/{widgetKey}/configand the hosted pageGET /c/{widgetKey}. Both previously had none at all, while each did one to three database round trips per request — enough for an unauthenticated caller to exhaust the process-wide EF connection pool and take the agent console, both SignalR hubs and the readiness probe down with it. The budget isRateLimit:WidgetBootstrapPerMinute(default 300, per client address) and the two routes share it, so a visitor opening the hosted page spends two of it. Exceeding it answers429with aRetry-Afterheader and the usual error envelope. -
A
Content-Security-PolicyonGET /c/{widgetKey}—default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; img-src blob:; base-uri 'none'. This is stricter than the policydocs/WIDGET.mdasks integrators to write, on purpose:script-src,connect-srcandimg-src blob:are the three the widget actually needs, with'self'where an integrator writes their instance’s origin, whilebase-uri 'none'andstyle-src 'self'are properties of this page rather than requirements of the widget. Nothing here changes what an integrator has to write.frame-ancestorsis deliberately omitted: this page is meant to be embedded in an iframe, anddefault-srcdoes not fall back to it.
Changed
Section titled “Changed”-
Documentation only — six claims in
rules/andCLAUDE.mdthat were false at HEAD, plus what the last three plans falsified on the way past.rules/is the declared first read for every implementer on this project, so each of these misled on first read:rules/00-overview.md— “the escalation flow itself is not built yet” is wrong; it shipped in Plan 4 and the visitor is now told when their chat becomes a ticket. The section now also says it is inert on a stock deployment (FlowDeskOptions.Enableddefaults false, noFlowDesksection inappsettings.json, so escalate answers 422FlowDesk:Escalate:NotConfigured).rules/00-overview.md— the “does NOT have” table listed admin CRUD endpoints and a Hurl functional layer as absent. Both exist; both rows are gone.rules/01-solution-and-folder-structure.md— “Controllers/V1/Admin/does not exist yet” is wrong; it holds five controllers, now named.rules/14-http-clients.md— advised adding Refit “at that point” for a client that was already built without it. Two typedHttpClients exist (FlowDeskIntegrationClient,LicenseChecker) and neither uses Refit. The rewrite records that no rationale for rejecting Refit is written down anywhere in this repository rather than inventing one, and names the shape to copy instead.CLAUDE.md— theadminaudience was described as “planned, no controller yet”.rules/10-api-conventions.md— said Seven named rate-limit policies; there are eight.CLAUDE.mdhad already been corrected to eight, so the miscount had moved rather than closed. All eight are now listed in a table, so the next miscount is a diff against a list.- Also corrected, found by the same sweep:
rules/03andrules/06still called theAdminaudience planned;rules/11said no endpoint required the Visitor auth scheme yet (four do);rules/12said there was one hosted service and no background jobs (there are three, on three cadences);rules/18said there was no attachment storage and no HTTP client usage.
-
CLAUDE.mdandrules/00-overview.mdnow state what this release built — that retention archives and does not delete, keys onCreatedAtand is off by default; that a blocked licence stops the agent and admin surfaces and leaves the visitor surface working; that the Helm chart requires five values with no default and pinsreplicasat 1; and that the.hurl/suite is gated by afunctional-testsCI job which also runs the chart’sverify-split.sh. -
Integrators with a strict Content-Security-Policy now need
img-src blob:alongsidescript-srcandconnect-src, anddocs/WIDGET.mdwas missing it. Received attachments render fromURL.createObjectURLand ablob:URL never matches'self', so a site that wrote exactly the policy that document prescribed had every received image blocked by its own policy — a broken-image box,naturalWidth: 0on the element, and no error the visitor could act on. The section now derives every directive the widget actually needs (script-src,connect-src,img-src blob:), states whystyle-src,font-src,frame-src,media-src,object-srcandform-actionneed nothing added, and records that this product’s own hosted chat page ships a stricter policy than that minimum — so nobody reads the two documents as disagreeing and adds a directive their site does not need. -
The embeddable widget now honours three fields from the operator’s widget configuration that were previously settable in the admin panel but had no visible effect:
- Name — the text shown in the chat panel’s header. Previously always “Canli Destek”; now reflects the operator-supplied widget name, with the Turkish default as a fallback for blank names.
- Position — where the widget is anchored in the browser viewport.
bottom-leftmoves the launcher and panel to the bottom-left corner;bottom-right(the default) keeps the current bottom-right position. - DefaultLocale — the language used for all visitor-facing widget text (launcher button, panel
header, input placeholder, system messages).
tr-TRrenders Turkish (the previous hard-coded behaviour),en-USrenders English. Any other value, or a missing value, falls back to Turkish. The widget switches language after the config fetch completes: the panel is interactive before the request returns, starting on the built-intr-TRpack, so anen-USwidget on a slow config fetch can show its Turkish text first and then flip.
-
data-widget-keyis now case-insensitive, and a rename is a breaking operational change. Widget keys are stored in lower case and resolved regardless of how they are typed, sodata-widget-key="MyWidget"anddata-widget-key="mywidget"reach the same widget. Renaming a widget’s key throughPUT v1/admin/widgets/{id}therefore requires everydata-widget-keyon the customer’s site to be updated to the new casing before the embed will resolve — the same string, compared case-insensitively, must still match. Two keys that differ only in case can no longer both exist; creating or renaming one onto another answers409. Existing installations need no action — the only key shipped isdefault. -
Migration required. This release adds one table (canned replies); run the deployment’s usual migration step. It applies automatically on startup as every prior migration has.
-
A department can only be deleted once nothing points at it: no widget routes new conversations into it, no agent is assigned to it, and no conversation in it is still open (see the
v1/admin/departmentsdelete above). Deactivating a department remains the way to stop using one without deleting it. -
Deactivating an agent does not sever their live connections instantly. A deactivated agent can no longer sign in or refresh, and their existing socket and API access stop working within 15 minutes — the access-token lifetime — rather than the moment they are deactivated. An operator acting on an incident should treat deactivation as taking effect soon, not now; tightening this is carried to a later plan.
-
The hosted chat page’s
<noscript>line is now written in the widget’s ownDefaultLocaleinstead of always in English, matching the page’s<html lang>.
-
The conversation ceiling now actually holds under a burst. A load test found it advisory: the ceiling is compared against a database count, so when a crowd arrives at once every request in flight reads that count before any of the others has committed. Measured — 500 arrivals at concurrency 100 against a ceiling with 50 free places admitted 70, and the overshoot was the number of requests in flight.
A place is now reserved from the moment a visitor is told “come in” until their conversation row exists, so the next decision sees it as taken. The same burst shape (1,000 arrivals, 100 concurrent, 300 free places) now admits 301 — the one-over is the single-request race no lock-free door avoids. A first attempt at this held each admission for a fixed two seconds instead, and that was worse in the other direction: it double-counts once the row appears, and a sustained arrival rate collapsed the same 300 places to 163 admissions.
Nothing changes for a deployment with no ceiling configured, which is still the default.
-
An agent can be signed in on more than one machine. Signing in on a laptop used to invalidate the desktop’s session: the API kept exactly one refresh token per agent and issuing a new one deleted whatever that agent had. The losing machine noticed nothing at first — its access token was still valid — and then took a
401on its next renewal and landed on the sign-in screen up to thirteen minutes later, mid-conversation, losing whatever the agent had half-typed.There is now one refresh-token row per signed-in client, and refreshing replaces the row whose token was presented rather than every row the agent has. Five clients at once, after which the sixth sign-in evicts the least recently issued one — a bound, because a row lives 30 days and an uncapped table would grow one row per sign-in for a month. Nothing changed for an agent who works from one machine, signing out still ends every session on every machine, and no migration is needed: the
AgentIdindex was never unique.docs/CONSOLE.md §5has the whole of it. -
Signing out no longer leaves the previous agent behind. Signing out and signing in again in the same tab without reloading kept the first agent’s identity in memory, and the console then asked the server for that agent’s conversations and listed them as the new one’s own. Everything held about the signed-out agent is now dropped with their credentials.
-
Every integer in the OpenAPI document was missing its
type, and any client generated from it saw ids, sequence numbers and counts as untyped. All 291 of them — the document described them with a format and a pattern but no type, because .NET exports an integer as “integer or string” and OpenAPI 3.0 cannot write that. Anyone generating a client fromv1/dashboard/openapi/v1.jsonshould regenerate it: numeric fields now arrive typed, and nullable ones stay nullable. The document is a development-only endpoint, so nothing about a deployed API’s behaviour changes. -
An agent who marked themselves Away was back on duty within fifteen minutes, and the console’s own auth design was what did it. Availability lived only in the in-process presence dictionary, which deletes an agent’s entry when their last socket closes — and the agent hub is the one hub with
CloseOnAuthenticationExpiration, so every agent socket closes when its handshake token expires, at most a quarter of an hour after it opened. The console reconnected immediately and the reconnect seeded availability from a hard-coded “online”. A visitor was then told a human was waiting for them who had gone home. The hub now seeds fromAgent.IsAvailable, so the intent survives the socket close that caused it. -
A restarted process no longer puts a deactivated agent back on the queue. The set of agents an administrator has switched off is in-process and is emptied by a restart, and until now nothing on the connect path re-read the agent’s row — so for the remaining life of their access token, a deactivated agent who reconnected after a restart was served live customer chats again. The row read the hub now makes for availability answers this too, and re-marks the agent so the rest of the process re-learns the fact. The REST half of that window is unchanged and still open: no
v1/agent/*orv1/admin/*request re-reads the row either, so inside the same fifteen minutes a deactivated administrator can still reach the admin surface — and what they do there does not expire when the token does. Seerules/11-identity-and-auth.md §5.1. -
GET /v1/admin/licenseis administrator-only, and now has a test that says so. It was the one admin route missing from the hand-maintained list that checks the guard on every admin route, so deleting its guard left every suite green while an ordinary agent could read the installation id, the plan and the current usage. The list now carries a counter that fails when a controller gains an action nobody added to it. -
A blocked licence now also stops an agent’s typing indicator reaching a visitor.
AgentHub.Typingis the hub’s one client-to-server method, and an already-open WebSocket never re-enters the HTTP middleware that refuses everything else — so an agent connected before the block could still put “Support is typing…” on a live customer’s screen. -
Network:TrustedProxiesnow refuses a value that would trust everyone.0.0.0.0/0and::/0were syntactically valid and silently accepted, which let any client choose its ownX-Forwarded-Forand therefore its own partition in every rate limit in the product. Blank and malformed entries are refused too, at startup, with a message that names the setting and the value — previously a stray blank entry crashlooped the pod on aFormatExceptionnaming neither. -
The boot warning about an unlicensed deployment is no longer silenced by the action that does not fix it. It asked whether a key and a server URL were set; it now asks whether the deployment can actually enforce, which additionally needs
License:ResponseSigningKeys. With a key and no signing keys every answer fails verification and nothing is enforced — and setting the key used to turn the warning off. The chart’s install notes said the same false thing and now describe all three states. -
Renaming the seeded “Genel” department to a different case no longer makes the next boot create a second one. The seeder’s “does the default department already exist” lookup was a case-sensitive string comparison, which SQL Server’s default collation hid and PostgreSQL’s did not; the comparison now folds case. Department names stay free-form and deliberately non-unique — nothing about what an operator may call a department changed, and no migration is involved.
-
An administrator’s change now takes effect on the next request, not up to 30 seconds later. The visitor widget config (
GET /v1/widget/{widgetKey}/configandGET /c/{widgetKey}) and the embed origin allowlist that governs CORS onv1/widget/*and the visitor hub handshake are both memoised for 30 seconds, and until now every admin write waited that window out. Every write throughv1/admin/*that changes what those answers contain drops the memos on the way out: creating, updating or deleting a widget (disabled, deleted, renamed, re-keyed, recoloured, repositioned, relocalized, pre-chat form and uploads toggled — a deleted widget no longer keeps answering200, and an allowed origin added or removed is honoured on the next preflight); creating, renaming, deactivating or deleting a department, which is whatdepartments[]on the widget config lists; and assigning an agent to departments, deactivating, reactivating or deleting them, which is whatdepartments[].anyAgentAvailableis computed from. What the 30 seconds still bound is staleness this instance was never told about: a row edited directly in the database, and a write that landed on another instance, since the cache is per process. Agent presence is not memoised at all — an agent going online or offline is reflected immediately. -
Deactivating or deleting an agent now cuts their live chat delivery immediately instead of leaving it running for up to 15 minutes. The moment
PUT v1/admin/agents/{id}setsisActiveto false — orDELETE v1/admin/agents/{id}removes the account — that agent’s open console connections are removed from every department group, conversation events addressed to them personally (new messages on a chat they hold, read receipts, transfers away from them) stop being sent, and the console is pushed arevokedframe so it can sign itself out rather than sitting on a socket that has quietly stopped carrying chats. A deactivated agent who reloads the page cannot rejoin — the hub refuses the connection. Reactivating the agent restores delivery once they reconnect. The severance is per process: on a deployment running more than one instance (which this product does not support — seerules/16 §5), only the instance that served the deactivation cuts its own sockets. -
A removed agent’s access token stops working straight away, instead of lasting another 15 minutes. Severing live delivery stopped a deactivated or deleted agent receiving chats; it did not stop them acting. Their already-issued access token kept answering, so for the rest of its 15-minute lifetime they could still claim queued conversations, post replies into a customer’s transcript and read messages that arrived after they were removed — a deleted agent as much as a deactivated one. Every request now checks whether an administrator has removed that agent, so
PUT v1/admin/agents/{id}withisActive: falseandDELETE v1/admin/agents/{id}take effect on the caller’s very next request:v1/agent/*andv1/admin/*answer 401 with the usual error body, and the console’s socket handshake is refused. Reactivating them makes the same unchanged token work again — they do not have to log in. A console socket that was already open when the write landed is covered too: it cannot send a typing indicator any more, so a removed agent can no longer put “Support is typing…” in front of a customer. One thing is unaffected on purpose: the anonymous visitor surface (v1/widget/*,/c/{widgetKey}), which uses a different credential entirely. Restarting the API reopens the window for up to 15 minutes: the record of who was removed is held in process, so a restart forgets it and nothing re-reads the agent row until they next log in — an operator acting on an incident should not treat a restart as reinforcing a removal. -
Widget: an agent’s reply that arrived while the widget was already fetching the transcript could be dropped and never shown. The catch-up now records a message that arrives mid-fetch and re-reads the transcript for it.
-
Widget: a visitor who dropped their connection (a phone changing cells, an ingress rolling) lost every message sent while the socket was down. The reconnect catch-up is now proven end to end by the embed smoke test, which closes a real WebSocket.
-
Widget: cancelling a file upload during the first two round trips (visitor mint and conversation start) did nothing — the file uploaded anyway and was attached to the visitor’s next message.
-
Widget: when the server moved a visitor into a new conversation because the old one had been closed, the old transcript stayed on screen and collided with the new conversation’s message numbering. It is now cleared, matching what a page reload shows.
-
Widget:
TransferredToAgentandTransferredToDepartmentsystem rows rendered as blank bubbles. A visitor whose chat is transferred is now told so, in bothtr-TRanden-US. -
Widget: ending a chat while the visitor’s token had expired reported “could not end the chat, please try again” instead of “your session has ended, a new chat has been started”.
-
Widget: the End chat button stayed on screen after a chat was ended, where every further click did nothing at all.
-
Widget: the unread badge could keep a count for a transcript that had just been discarded.
-
Widget: a file attached while a message send was in flight could start a second conversation.
-
docs/WIDGET.md:identify()was documented as replacing the previously stored traits, and contradicted itself four lines earlier. Calls merge: a key left off the object is not sent and the server leaves that field alone, an explicit''is what clears one, and only the most recent call is buffered while no visitor token exists yet. -
docs/WIDGET.md: three further corrections.DefaultLocaledoes not land on the right language “without a flash” — the bundle starts on its built-intr-TRpack and switches when the config response arrives. Only constructed stylesheets are a hard rendering floor; a browser that ignoresaddEventListener(..., { signal })still renders and chats. And the troubleshooting table’s “stays connecting” row named a state the widget does not have — it is now the notice the widget really shows, plus a new row for images blocked by a missingimg-src blob:. -
CHANGELOG.md:[Unreleased]carried two### Added, two### Changedand three### Fixedheadings. Consolidated to one of each, in Keep a Changelog order, with every entry preserved. -
Realtime: a file sent in a chat now appears live for the other side instead of only after a page reload. The
messageframe on both hubs (/hubs/visitor,/hubs/agent) now carries the sameattachmentobject (id,fileName,contentType,fileSize) that the REST transcript and the message POST response already answered; it previously carried none, so an agent’s file — for which the visitor has no POST response of their own — was announced as a message with nothing attached. -
Widget: cancelling an in-progress upload no longer shows the visitor an error notice. The pending state is cleared silently, consistent with a deliberate cancel rather than a failure.
-
Widget: when the operator sets an attachment size cap below 1 MB (e.g. 400 KB), the “file too large” notice now shows the cap in KB rather than “0 MB”. Caps of 1 MB or more continue to display in MB.
-
Widget: a 429 from the upload rate-limit policy now shows its own notice (“too many files uploaded, wait a moment”) instead of appending a raw status code to the generic failure string.
-
Widget: a transient
identifyfailure (500, dropped connection) no longer permanently suppresses the visitor’s traits for the remainder of the page view; the next flush attempt retries. -
PUT v1/widget/visitors/me: passing""or a whitespace-only string as theemailfield previously returned 400 (FluentValidation’sEmailAddress()rule rejects the empty string). The clear branch documented in the endpoint description — null = leave alone,""= clear — is now reachable for all three fields (displayName,email,externalId). -
The inactivity sweep no longer closes a conversation that is waiting in a queue for its next agent. A conversation that had been answered and was then transferred back to a department’s queue is once again waiting for someone to pick it up — the product is the one keeping it waiting — but the sweep judged it by whether an agent had ever replied and closed it as idle while it sat in the queue, dropping the visitor for waiting. The sweep now closes only conversations that are actually assigned and idle, so a requeued chat survives past the timeout and a genuinely idle answered one is still closed as before.
-
A deployment whose attachment storage root cannot be written now fails to start, instead of starting green and refusing the first upload.
Storage:RootPath(Storage__RootPath, default/var/lib/flowdesk-livechat/attachments) is proven writable while the process is still starting, by creating the directory and then writing and deleting a file inside it. A directory that merely exists is not enough: a volume mounted read-only, or one owned by a uid this process is not, is the usual way this is misconfigured, and it passes an existence check. Previously the root was only touched when the first file was stored, so a host that could not write it booted, answeredGET /healthwithHealthy, served the widget and accepted conversations, and then answered the firstPOST v1/widget/conversations/{id}/attachmentswith a500— with the health check green throughout, so the first report came from a customer. The startup failure names the setting, both spellings of it, the value that was configured and the absolute path it resolved to, since a relative root resolves against the process’s working directory and is rarely the path that actually failed. -
The initial administrator can now sign in on PostgreSQL when
Seed:AdminEmailwas configured with capital letters or stray whitespace. The seeder stored that address exactly as configured while sign-in looks it up trimmed and lower-cased, and PostgreSQL compares text case-sensitively — unlike SQL Server’s default collation, which had been hiding it. A deployment set up withSeed__AdminEmail=Admin@Company.comtherefore came up with an administrator nobody could sign in as, and since there is no agent management endpoint yet and the seeder skips any database that already holds an agent, restarting could not undo it and editing the row by hand was the only way in. An agent’s address is now normalized wherever it is written rather than by each writer in turn. A database seeded before this fix keeps the address it already stored — on PostgreSQL, lower-case that one row to recover it. -
data-widget-keyis now case-insensitive. An embed snippet writtendata-widget-key="Default"loads the widget whose key isdefault, and every widget endpoint that takes a key — in the path or in the request body — resolves it the same way. Previously the key was compared exactly as stored, so the same snippet against the same data loaded on SQL Server, whose default collation ignores case, and answered404on PostgreSQL, where text comparison is case-sensitive. The failure was silent on the customer’s own page: the widget simply never appeared, with nothing in the snippet to suggest why. Keys are now trimmed and lower-cased wherever they are written, so every stored key is already in its canonical form and two spellings of one key can no longer become two widgets that one route cannot tell apart. Existing deployments need no action — the only key that has ever existed is the seededdefault, which is already lower-case — but a key entered with capitals from now on is stored, listed and returned in lower case. -
Claiming a conversation is now scoped to what the agent may actually see. An agent who staffs the conversation’s department, or who already holds it, can claim it, and an
Admincan claim any. Every other agent is answered404. Previously any signed-in agent could claim any queued conversation by id, including one in a department they neither staff nor can read — and because claiming makes that agent the conversation’s assignee, taking it granted them exactly the access the department rule had just refused. The refusal carries the same body a conversation id that does not exist returns, so the endpoint cannot be used to discover which conversations are waiting in departments the agent cannot see. -
A realtime announcement is no longer cancelled by the disconnect of whoever triggered it. A broadcast was sent with the originating request’s cancellation token, which SignalR applies to every recipient’s delivery — so a visitor closing their tab as their message was stored could cancel that message’s delivery to the agent as well. The message was written, the console was never told, and nothing prompted a
?afterSeq=catch-up, because the console’s own connection was healthy. Announcements are now made independently of the caller’s request lifetime. -
Realtime announcements are now addressed using the conversation as it was committed rather than as it was read at the start of the request. A visitor’s message sent while an agent was claiming the same conversation could be addressed to the department queue instead of to the agent who had just taken it, and a read receipt in that window could reach nobody at all.
-
A conversation’s last-message timestamp can no longer move backwards. The sequence allocator wrote it unconditionally on every message, so two messages racing on the same conversation — or a host clock stepped backwards by NTP — could replace a later timestamp with an earlier one. Because the inactivity sweep closes a conversation whose last message is older than
Chat:InactivityTimeout, a rewound timestamp made a live conversation look stale and could end it mid-sentence; the shorter that timeout is set, the more room the skew had. The column now only ever moves forward, on both database providers, while the message’s sequence number is still allocated as before. -
The per-conversation message sequence allocator now rejects a timestamp whose time zone is unspecified instead of reinterpreting it as the server’s local time. On a host that is not set to UTC, that reinterpretation shifted a conversation’s last-message timestamp by the host’s offset, which would have mis-ordered the inactivity sweep and the agent’s queue view.
-
The chat widget now renders the system lines an agent’s actions write, instead of leaving a blank gap in the transcript. A system row is stored with an empty body on purpose — the server keeps a kind and its parameters so the same row can be read in Turkish by the visitor and in another language by the agent — and the widget detected the row and then rendered its empty body. A visitor watching an agent take and then end their chat saw two small blank spaces and nothing else, which is the exact outcome the “chat ended” line is written to prevent: their next message silently starts a new conversation, and until now nothing told them why. Both lines the product writes today are rendered, the one that ends the chat distinguishing between the agent ending it, the visitor ending it, and the inactivity timeout closing it. A line this version of the widget has no wording for is left out of the transcript rather than shown as an empty bubble.
-
A visitor whose stored credential has died can chat again instead of being permanently stuck. The visitor token has an absolute 90-day lifetime and is also refused for a visitor an operator has blocked, but the widget minted a token only when it had none and never inspected a response status — so a visitor returning in April to the browser they chatted from in January got a panel that failed every call, reported “message could not be sent”, and retried the same dead token for ever. Clearing site data was the only way out, and nothing said so. The widget now discards a credential the server has refused, mints a new one and retries the message once, carrying the same idempotency key so a first attempt that did reach the server is not stored twice. The stored conversation id is treated the same way: an id the current token cannot resolve is abandoned and the next message starts a fresh conversation, instead of being retried against it for ever.
-
The visitor write surface is now bounded per client address as well as per visitor. Every budget on that surface was per-visitor, and a visitor token is something one address can mint: minting is limited to 10 a minute per address but nothing limited how many an address could accumulate, so an address that minted quietly for an hour held hundreds of tokens and could then spend all of their message and upload budgets at once — every individual limit respected, and no limit at all on the total. A new ceiling,
RateLimit:VisitorAddressPerMinute(default 600 a minute), applies to the message and upload routes together, whatever token each request carries. Reads are untouched, minting keeps its own separate address budget, and an operator whose customers sit behind a very large shared address can raise the ceiling without a redeploy. -
X-Forwarded-Forwas honoured from any client on the shipped default configuration, letting a caller choose its own rate-limit partition and defeat every IP-keyed budget in the product — includingagent-login, which is the only brake on password guessing against accounts that have no lockout. Forwarded headers are now honoured only whenNetwork:TrustedProxiesis set; with it empty they are ignored outright and a startup warning says so. Action required for any deployment behind a reverse proxy or ingress: setNetwork:TrustedProxies(for exampleNetwork__TrustedProxies__0=10.0.0.0/8). Without it, every client behind the proxy shares one rate-limit bucket andRequest.Schemestayshttpbehind a TLS ingress. -
A malformed e-mail address sent to
PUT /v1/widget/visitors/meis rejected with400. The rule existed but nothing tested it, so nothing would have caught a future edit that removed it.
See GitHub Releases for older versions.