Moving a live practice chatbot off a no-code platform without losing its behaviour
A chatbot that visitors already use is a production system, even if nobody at the practice thinks of it that way. It has a persona people recognise, a way of collecting enquiries, and habits the staff have learned to work around. Replacing it is less about building a better bot and more about not breaking the one that exists. This is how we moved an occupational-health provider off a hosted no-code chatbot platform, and what a dental practice in Vienna taught us about doing it when there is no source code at all.
Capture the running deployment first
Before writing any code, we recorded what the live assistant actually did, on the same day, from production. Memory and screenshots are not enough. You want files you can diff against later. For the occupational-health provider that meant three artefacts:
- The widget configuration. The platform served its full widget setup as JSON from a public endpoint, without authentication. We saved the whole response.
- A real conversation with timings. Four turns on the live site, captured with Playwright. The widget renders inside a shadow root, so the probes had to drive it there rather than through the page DOM.
- The website text. Twelve public pages, saved as plain text. This became the basis of the new knowledge base.
Later we also read the project from inside the platform, strictly read-only. That second pass found the most serious problems. The first pass told us what visitors saw; the second told us why.
List every gap, then find its cause
We wrote each defect down with its evidence before deciding what to do about it. The list for the occupational-health assistant:
- It invented contact details on the human-handoff path. Asked for a real person, it replied with a phone number and email address that do not exist, each marked "(Beispiel)", the German for "example". The opening hours were wrong too.
- Answers took 18 to 56 seconds. The four measured turns were 18.0, 55.7, 52.4 and 44.7 seconds, and the input locked while it streamed, so anything typed during the wait was lost.
- No domain lock. The approved-domains setting was off and the project ID was public in the page source.
- Voice input and spoken output were on, with no notice anywhere.
- The platform's own AI notice was switched off, and the header described the assistant in terms that read as a person.
- Captured leads were never delivered. The flow collected name, company, phone, email and service into variables. No function, API tool or integration ever read them. The assistant told visitors their request had been passed on. It had not.
- The knowledge base was failing silently. The website document had been in an "Auto refresh failed" error state for weeks while the bot kept answering.
Finding the cause matters more than finding the symptom. The fabricated contacts were not a random hallucination. The correct details were hardcoded into four of the flows but not into the appointment flow, which also had its knowledge base disabled. Ask for a human from inside that flow and the model had no grounded contact data, so it made some up. Contacts were duplicated per prompt instead of kept in one place. That told us exactly what the rebuild had to make impossible.
Decide what behaviour to keep
The old assistant was not all bad. Its answers about services were good, and visitors had been talking to it for months. So we kept, on purpose: the persona and its illustrated face, the formal German register, short structured lists, the lead-capture flow including the read-back block that shows visitors what will be sent, and the refusal to quote prices. We changed the machinery underneath.
When the source was never handed over
The dental practice was a harder case. Its assistant ran on a custom deployment whose source code was never handed over. We reconstructed the behaviour from the running service: the extracted system prompt, live API fixtures, the production database, and the original static frontend bundles, kept unchanged in the repository.
The widget existed only as a minified production bundle. The new disclosure texts are applied by a build script as exact string replacements. Each one must match exactly once, or the build fails, so a drifted bundle cannot ship quietly. It works, but patching an artefact you have no source for is fragile. That is why the occupational-health rebuild got its own widget from the start.
Keep the knowledge in a file, not a vector database
The whole public website of the occupational-health provider is about 28,000 characters of prose, roughly 8,000 tokens. That fits in the system prompt behind a single cache breakpoint, so it is cached from the first turn and later turns read it at cache-read rates.
This removes a subsystem. No embeddings, no vector store, no retrieval tuning, and no chance of a retriever silently missing the page that held the answer. It also makes the corpus reviewable: the client can read the exact text the assistant treats as fact.
The corpus is two files with different owners:
site.mdis regenerated from the live website by a script. The script fetches each public page, strips the markup and the header and footer that repeat on every page, and writes a review file,site.generated.md. It never overwrites the live corpus. A person reads the diff, then replacessite.mdand runs the evaluation.facts.mdis maintained by hand: contact details, locations and hard rules. The script never touches it.
The old knowledge base failed silently, so the new service refuses to. If either file is missing, or present but under 500 characters, it will not start. An assistant for a medical provider that answers without its knowledge is worse than no assistant.
Contact details get two separate guards. The model never writes a phone number or email itself: it calls an escalate_to_human tool that returns the strings from one module, verbatim. And the evaluation scans every response in the golden set for anything shaped like a phone number or email address, and fails unless it is on the allowlist or the visitor typed it earlier in the conversation.
Keep the conversation in the tab
The hosted widget persisted conversation state in localStorage, which outlives the tab and the browser session, with no consent step and no documentation. Both rebuilds use sessionStorage only. No cookies, no localStorage, no IndexedDB.
The browser keeps three things for the occupational-health widget: a conversation ID, the chosen language, and up to 40 recent messages. Nothing survives the tab, so there is nothing to ask consent for. The privacy page says exactly that.
Because the privacy notice describes this storage, the shape lives in one small tested module rather than inline in the widget. That was not theoretical. An early version wrote only the conversation ID and messages while the restore path read the language back, so a visitor who picked a language other than German was reverted mid-conversation, and the privacy notice described a stored field that was never stored. A round-trip test now pins every field.
Server-side, transcripts are deleted after 30 days. Counts and topic labels from a fixed list are kept, because they hold no free text. The purge runs nightly and on every boot, and each run is recorded so "did it fire" is a query, not a log search.
Ship text only at v1
The old assistant listened and spoke without telling anyone. The rebuild plan scoped v1 to text: no microphone, no speech synthesis. The design reason is simple. It removes the undisclosed voice capture, and it takes the synthetic-audio marking question in Article 50(2) of the AI Act out of scope, because there is no synthetic audio to mark. Voice can come back later as its own project with its own notice.
We did test voice afterwards, keeping everything on the visitor's device. Reading answers aloud with the browser's built-in speech was built, briefly shipped, then removed: the operating-system voice sounded robotic on a medical provider's site. On-device dictation crashed the browser renderer in Chromium on macOS when we tested it, so it sits behind a flag that is off. Anything that sounds better means buying a voice from a vendor, which brings a processor relationship and the marking duty back into the project. Text only is still the right default.
Measure against the old bot, then cut over in phases
A replacement has to beat the old bot on its own ground, so we built a golden set from the real site content and the captured turns: service questions, pricing, appointments, handoff, "are you a human", "delete my data", out-of-scope questions and prompt-injection attempts. Each case asserts on substance, not wording.
On the latest recorded run, 3 August 2026, the rebuild passed 49 of 49 cases with a median of 2.71 seconds to first token and a p95 of 8.08 seconds. That is time to the first word on our golden set, not a full-answer figure, measured against the 18 to 56 seconds per turn recorded on the old deployment. The handoff case returns the real phone number, email and opening hours through the tool on every run.
The cutover plan is deliberately cautious:
- Deploy the new service without embedding it anywhere.
- Run the golden set against both bots and fix whatever loses.
- Have the client and our operations lead review persona, tone and the facts file.
- Shadow on one low-traffic page while the old widget stays on the rest.
- Cut over, with the old embed kept commented out directly above the new one, so rollback is two lines in the site editor.
- After a clean week, export the old platform's transcripts, request deletion, then cancel.
The last step is easy to forget. Leaving personal data in a processor you have stopped using is its own problem, and the old transcripts are useful evaluation material anyway.