← Back to Notes

Building an AI counseling widget that matches visitors to the right counselor — without a hosted LLM

Building an AI counseling widget that matches visitors to the right counselor — without a hosted LLM

A counseling center's website usually does two things badly: it makes a visitor fill in a form, and it makes the visitor phone to ask whether anyone is free. Both are slow, and both lose people.

I wanted to find out what it would take to replace that with a single embedded chat bubble that does the triage itself. The result is the AI Counseling Widget — a Persian, right-to-left intake assistant that a clinic drops onto its site with one <script> tag.

The constraint that shaped everything

The interesting decision was not the interface. It was what runs behind it.

There is no hosted language model here. The "AI" is a local, deterministic engine: a curated clinical lexicon that maps free text to concepts, fuzzy counselor-name matching, age-bound parsing, weighted scoring over a roster, and a feedback table that re-weights future matches. A visitor's words never leave the server, there is no vendor API key, and there is no per-message cost.

That decision buys three things that matter for mental-health tooling specifically. It removes a third-party data processor from the path of sensitive disclosures. It makes the per-message cost zero, so a clinic can run this on a small budget. And it makes the behaviour auditable — every recommendation comes with a stated reason instead of a plausible-sounding sentence.

What the visitor actually gets

A floating bubble opens a 380×580 panel, which goes fullscreen under 480 pixels. The conversation is a state machine over numbered steps — text, phone, number, or choice — with optional conditional steps and two possible endings. Nothing about that flow is hardcoded.

The steps themselves are edited live from the clinic's admin panel, so the centre can change its intake questions, reorder them, add a branch, or switch the ending from "recommend a counselor" to "capture a lead" without a redeploy. A tenant may also list the domains its widget may be embedded on; the check is an exact host or subdomain match, deliberately not a substring match, so notexample.org cannot impersonate example.org.

Two validation rules are worth calling out because they are the kind of detail that decides whether a form feels hostile. A phone number must be exactly eleven digits starting with 09, and Persian numerals are converted before the check — a visitor typing ۰۹ is not rejected for typing in their own script. A suspicious-looking number is treated as a warning, not a rejection: the intake continues, because a blocked number means a person who needs help gets no help.

The screening step is optional, and it is a screen, not a diagnosis

If the tenant enables it, the widget offers the GHQ-28 wellbeing instrument: 28 questions across four subscales — somatic, anxiety, social and depression. Answers are scored server-side and interpreted into bands, with the main warning at a total of 43 or above, or a depression subscale of 15 or above, and a milder warning from a total of 22.

The distinction the code insists on is that this is a screening instrument, not a diagnosis. The widget presents it that way, and so does the matching engine, which never tells a visitor what condition they have. It extracts concepts, scores, ranks, and explains.

Ranking two counselors out of a roster

The matching engine applies hard filters first — gender, branch, age bounds, and a legal-specialty guard — and then scores what survives on topic fit, extracted clinical concepts, ability, live free-slot count, and a learned per-counselor weight. It returns the top two, each with a reason in plain language and a booking link.

The learned weight is the part that makes it a product rather than a demo. Every rating in the panel, and every successful recommendation, adjusts a per-(tenant, counselor, concept) weight within a bounded range. The clinic's own staff, by rating a recommendation, are retraining the matcher.

Eleven clinical concepts sit at the centre of this — anxiety, depression, obsessive-compulsive symptoms, relationships and interpersonal conflict, couple and marriage, parent and child, child, adolescent and young adult, personal development, legal, and medical/psychiatric. The synonym lexicon is curated and readable, and a contributor can extend it.

The lead is written before the match runs

This is a small change with a large consequence. The request is persisted before matching executes, so a visitor who needs a counselor is recorded even when the engine finds no match at all. Earlier behaviour dropped them, and a dropped lead is a person who reached out and got silence.

The clinic's side

The admin panel is the other half of the product: a dashboard, a full conversation inbox, a lead table, a conflict-resolution screen, a flow editor, a consultant roster manager, feedback, crawler control, and a super-admin area for tenant management. Leads export to a two-sheet Excel workbook — Users and Request history — with frozen headers and Persian formatting.

Availability is kept fresh by a background crawler that scrapes the centre's public team pages every two hours, with retry, backoff and a direct-connection fallback. Booking URLs are mapped per counselor, and slot counts feed straight into the ranking, so the engine prefers someone the visitor can actually book.

How it is secured

Passwords are hashed with PBKDF2-HMAC-SHA256 at 100,000 iterations with a 16-byte salt, stored as salt$hex_digest. Sessions are signed cookies with httponly and samesite=lax. Responses carry nosniff, strict-origin-when-cross-origin, and X-Frame-Options: SAMEORIGIN — except on /widget, which has to be embeddable.

Rate limiting is real: 30 requests per minute per client IP and session token on the chat API, and 10 failed panel logins per IP per five minutes. The tenant API key is the tenant identity — it scopes the flow, the roster, the learning weights and the leads, and it rotates from the super-admin panel, with the widget picking up the new key on the next page load.

The stack, and the one rule that is not negotiable

FastAPI 0.115.6 with uvicorn 0.34.0, Jinja2 for the panel, SQLite for storage, APScheduler for the crawl and the session GC, and vanilla JavaScript and CSS for the widget itself — no framework, no bundler, no npm. The widget ships as three static files.

The rule that is not negotiable: one worker. The scheduler jobs live in the process, and the rate-limit counters are in memory. Multiple workers would start multiple crawlers and split the counters. Scale vertically until the scheduler moves out of the process.

What I would build next

The honest next step is not a bigger model. It is better data: a real evaluation set of intake conversations with the recommendations a good counsellor would actually make, so the ranking can be measured instead of argued about. Everything else — a second flow branch, a richer panel — is comparatively easy.

The full technical documentation is published as a wiki: the architecture, the API reference, the matching engine, the GHQ screening, the admin panel, deployment, and troubleshooting. The source is on GitHub under the MIT license.

Related: the messenger version of the same engine — a Bale bot with the same GHQ screen and matching logic — is covered in a separate article on this site.

Contact
Next Note

Shipping a counseling bot on Bale: GHQ-28 screening, self-learning matching and 12 conversation states

→