AI templates

Full design ยท AI

AI assistant

Harper: a branded AI front desk for Hartley Street Physiotherapy, a small Australian clinic.

Live preview. Click around, it works.

About this template

Harper: a branded AI front desk for Hartley Street Physiotherapy, a small Australian clinic. Patients ask about first appointments, Medicare and private health rebates, the conditions the clinic treats, the cancellation policy, parking and general home care, and Harper answers only from the clinic's own documents, streamed word by word in demo mode, every claim carrying a citation chip that opens the exact passage it came from. Patients can copy or retry any answer, rate it up or down with a reason, search, rename and delete their conversation history with undo, browse the source documents themselves in a searchable help library, and hand over to a person through a callback form when Harper has nothing to go on. Staff sign in as Admin to add a knowledge source by pasting text and watch it split into passages live, toggle a source off and see answers stop citing it, review flagged and low rated answers, export conversations to a CSV they can preview before saving, and change the assistant's name, its tone and the hours a callback can be answered, each of which visibly changes what Harper says next.

What the agents are asked

The first message sent when you use this template. You can edit the plan before anything is built.

Adapt this AI assistant to my business. Keep the customer side (Chat with streamed demo replies, citations that open the source passage, suggested questions, copy and retry, thumbs up or down with a reason, and a handover to a person when nothing matches; Conversation history with search, rename and delete with undo; Help library, the source documents themselves, searchable) and the admin side (Knowledge sources where a document is added by pasting text and split into passages, and a source can be switched off; Conversations review with flagged answers, ratings and CSV export; Settings for the assistant's name, its tone and the handover hours). Change the brand, the assistant's name and the sample documents to my own business and my own real policies, keeping every answer built only from documents I actually provide, honestly saying so when nothing matches rather than guessing, and keeping the demo label until I connect a real AI model and a real document store as the README describes. Where a form writes to the database, keep it going through a database function rather than the table directly, and keep every key on the server.

A branded AI front desk for Hartley Street Physiotherapy, a small Australian clinic. Patients ask about first appointments, Medicare and private health rebates, the conditions the clinic treats, the cancellation policy, and parking, and Harper answers only from the clinic's own documents, streamed word by word in demo mode, every claim carrying a citation that opens the exact passage it came from. Nothing is ever invented: when no document matches, Harper says so honestly and offers a callback.

"Harper" and "Hartley Street Physiotherapy" are fictional; its principal physiotherapist, Kavya Menon, and the sample patients Tamsin Reyhani and Callan Bartosz are fictional too, and email addresses use .example domains. Not modelled on any real clinic, product or person.

Plain HTML, CSS and one ES module; no dependencies, no build step. Light and dark follow the device. Bottom tab bar under 860px, top navigation above. Dates in en-AU, sample data dated relative to today.

Health information: read this before you adapt it

  • General information, not medical advice, on every clinical answer and every clinical document in the Help library. Two of the six sample documents (Conditions we treat, Home exercise and self care) are marked topic = 'clinical'; the composer appends the line automatically whenever a clinical document is cited. Keep this rule when you add your own clinical content.
  • The engine never paraphrases a clinical claim. Every sentence Harper shows in quotes is copied character for character from a stored passage. If you want it to say something different, edit the document, not the code.
  • Adapting this to a business outside health? Delete the topic = 'clinical' documents and the advice note in script.js (doc.topic === 'clinical') if it does not apply to you.

Art direction

A physio clinic's own quiet, exact confidence: parchment, pine, one warm terracotta accent.

  • Palette, tokens at the top of styles.css: parchment #EDEAE2 (ground), deep pine ink #1B2521 (text, buttons, the assistant's mark), terracotta #C15B3C (the one accent: the send button, active states, the citation number). Dark mode inverts to a near black pine ground with a lifted terracotta. No gradients, no glow.
  • Type. Familjen Grotesk for headings and the assistant's own words in the welcome screen, tight tracking; Golos Text for reading, line height 1.6, chat bubbles capped near 68 characters at desktop width.
  • Photography. Three images from media.json, one shoot: directional window light, real shadows, natural skin, a warm neutral and deep desaturated grade. One subject each, once per screen: the empty reception room is the Chat welcome hero; Kavya Menon's portrait introduces the Help library; a physiotherapist coaching a patient through a resistance band exercise illustrates "how Harper answers" on the Chat welcome screen. The coaching photo has a wall chart with unreadable placeholder lettering at its left edge (the image provider would not generate a hands on treatment shot, L28); every use of it is cropped tight with object-position: 75% 40% in a frame narrow enough that the chart never enters view (L29). Check this again if the photo is regenerated at a different crop.
  • Layout. Chat is an asymmetric, image led welcome (a widescreen hero over a narrow reading column) that becomes an ordinary two column message list once a conversation starts. History and the Help library are single column and dense. Admin gets a two column "sources and the form that adds one" layout on Knowledge sources, and plain tables elsewhere.
  • Motion, each with one job, all off under prefers-reduced-motion or a hidden tab: a staggered entrance on first load; document.startViewTransition between screens; a clip-path: inset() reveal on the welcome photo, once; 150 to 200 ms hovers. The orchestrated moment, "the exhale": sending a question, the composer's border contracts as if holding a breath, Harper's three typing dots settle into the first streamed word, and each citation chip drops into place with a soft one degree tilt, as if set down on a desk. Replays once per message the first time it is shown in this session; a reopened conversation renders settled.

Photos (/template-media/ai-chat/): reception 1600 x 893, physio-portrait 900 x 1350, hands-on-treatment 1200 x 908.

Roles

The ribbon at the top of the page reads Viewing as: Customer / Admin (a demo control; the real app reads the role from ai_chat_staff through public.is_admin()).

CustomerAdmin
Chat, History, Help libraryFull useCan preview the same screens ("Chat as a patient" in the sidebar)
Knowledge sourcesNo accessAdd, split, switch on or off, delete
Conversations review, callback requestsNo accessRead every one, rate visible, export CSV
Settings (name, tone, hours)No accessChange; takes effect immediately

Screens

  • Chat (#/chat or #/chat/<conversation id>). A welcome screen (reception photo, the demo label, the "how Harper answers" card, suggested questions) when no conversation is active, otherwise the message list and composer. Every reply streams in word by word, cites the passages it used with chips that open the exact passage (#/help/<doc>/<passage>), and offers copy, retry (rephrases the same cited facts in a different opening, never a different fact), and thumbs up or down. Thumbs down opens a reason picker; that answer, and any answer that found no match at all, is flagged for Conversations review. "Talk to a person" opens a callback form pre filled with the unanswered question.
  • Conversation history (#/history). Search across titles and message text, rename in place, delete with a 6 second Undo toast before the row is actually removed.
  • Help library (#/help, #/help/<doc>). Every enabled document, searchable, each opening to its own passages; a citation from Chat lands on and highlights the exact passage.
  • Admin, Knowledge sources (#/admin/sources). Paste a title, a topic and a document's text; a live preview shows exactly how it will split into passages before you add it. Every source has a switch: turning one off removes its passages from Harper's matching pool immediately (ask Harper something it used to answer, from Chat, and watch it fall back honestly).
  • Admin, Conversations review (#/admin/conversations). Flagged answers (rated down, or found no match), all rateable answers, and callback requests, each exportable to CSV: the CSV is built as text and shown in a preview panel first, with a Copy button and a real download link, never a silent file save.
  • Admin, Settings (#/admin/settings). The assistant's name; three tone presets (Warm and reassuring, Brisk and clinical, Friendly and casual) with a live preview of the same sample question in the tone you are looking at; handover hours per day, which change what a callback request tells the patient to expect ("today", "tomorrow" or a named day, worked out from the current time).

What is demo or simulated

PartStatus
The answer engineReal, deterministic keyword matching against the passages you can see in Knowledge sources. Every quoted sentence is copied verbatim from a passage; nothing is generated. Labelled "Demo answers. Connect an AI model for real ones."
StreamingA visual effect (streamReveal in script.js) over an answer computed instantly; a real integration keeps the same reveal over tokens as they arrive
Passage splittingReal: splitIntoPassages in script.js is exactly what the Knowledge sources preview runs
Callback requestsSaved and reviewable in Admin; no call is actually placed
Sample dataMarked Sample, dated relative to today. SAMPLE_VERSION in script.js: when it changes, returning visitors get the new samples and keep their own rows
SavinglocalStorage (hartley.aichat.store.v1, hartley.aichat.prefs.v1), every access guarded. Routing uses location.hash only (the gallery sandbox blocks the History API)

Data model

schema.sql holds the tables, row level security, the two SECURITY DEFINER functions a visitor may call, and the same seed rows as demo mode. store.state in the browser has the same tables.

TableHoldsAccess
ai_chat_settingsassistant name, business name, phone, tone, handover_hours (jsonb)anyone reads; staff updates
ai_chat_documentstitle, topic, body, enabled, is_sampleanyone reads an enabled one; staff reads and writes every one
ai_chat_passagesdocument_id, passage_order, bodyfollows its document's enabled flag; staff writes
ai_chat_conversationstitle, is_sample, deleted_atstaff only; a visitor never reads or writes this table directly
ai_chat_messagesconversation_id, role, body, citations (jsonb), matched, rating, rating_reason, flaggedstaff only; written through submit_conversation_message() and rate_message()
ai_chat_handover_requestsname, contact, preferred_time, message, statusstaff reads; written through submit_handover_request(), no table grant at all
ai_chat_staffwhich signed in users are admin: name, email, user_id (null until claimed), role, status, is_samplea user reads their own row; claim_invite() and start_as_admin() are the only way the row is ever written

public.is_admin() is the one check every staff only policy shares. The file ends with a VERIFY block to rehearse as a stranger and as staff. It has not been run yet: builders have no database access by design (docs/ops/LESSONS.md L23); run tools/rehearse-template-schema.mjs before publishing.

The first admin, on a real backend

schema.sql starts with zero rows in ai_chat_staff, so nobody, the founder included, can just be typed into it by hand (docs/ops/LESSONS.md L60: a members table nobody can join). Apply schema.sql to a Supabase project, then, once Confirm email is turned on under Authentication settings (never leave it off, since without it anyone could sign up claiming an address they do not own and claim someone else's invite), the first person to sign up and call select public.start_as_admin('Their Name'); becomes the assistant's admin. public.nova_confirmed_email() is what checks the email really belongs to them. To add a second admin, insert a row with just name and email set (insert into public.ai_chat_staff (name, email) values ('A Colleague', 'colleague@example.com');) and have that person sign in and call select public.claim_invite();. Note: these functions exist in schema.sql today, but script.js does not yet call them, since this template is still demo only (localStorage); wiring up a real Supabase mode (CONFIG.backend, sign in, the claim step) is separate work for whoever connects a real backend, described in "Moving from localStorage to Supabase" below.

Moving from localStorage to Supabase

Screens read store.all() and store.get(), and write only through store.rpc.* (for a visitor) or store.addDocument / toggleDocument / deleteDocument / updateSettings (for staff, once real authentication exists). Replace the store object in script.js:

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); // public key; RLS protects every row
store.load = async function () {
  const [{ data: documents }, { data: passages }, { data: settings }] = await Promise.all([
    supabase.from('ai_chat_documents').select('*'),
    supabase.from('ai_chat_passages').select('*'),
    supabase.from('ai_chat_settings').select('*').single(),
  ]);
  this.state = { documents, passages, settings, conversations: [], messages: [], handover_requests: [] };
};
store.rpc.submit_conversation_message = (...args) =>
  supabase.rpc('submit_conversation_message', {
    _conversation_id: args[0], _title: args[1], _role: args[2], _body: args[3], _citations: args[4], _matched: args[5],
  }).then((r) => r.data[0]);
store.rpc.submit_handover_request = (...args) =>
  supabase.rpc('submit_handover_request', { _name: args[0], _contact: args[1], _preferred_time: args[2], _message: args[3], _conversation_id: args[4] });

A signed in admin's store.addDocument etc. become ordinary supabase.from('ai_chat_documents').insert(...) calls; RLS (public.is_admin()) is what actually stops a non staff user, not the UI hiding the button.

Where the AI calls go

The matching and composition in matchPassages() and composeAnswer() in script.js is where a real integration replaces keyword overlap with retrieval and a language model, as one server function:

  • `answer-question`: takes the question and the conversation id, retrieves the enabled passages for this business (the same ai_chat_passages rows, likely through a vector index once there are many), asks a language model to answer only from what was retrieved, keep the "general information, not medical advice" rule for clinical topics, and return the answer with the passage ids it actually used as citations. Refuses to answer from anything not retrieved. Calls submit_conversation_message() itself so the citations it returns are exactly what gets stored.
  • Passage splitting can stay client side (it already is, and staff sees the exact result before saving) or move server side if you later chunk by embedding similarity instead of paragraphs.
  • Keys never reach the browser; the function runs with a service key or an RLS respecting user token, never both loosely.

Checks

node tools/check-template.mjs templates/ai-chat prints ok. schema.sql has not been rehearsed; run node tools/rehearse-template-schema.mjs templates/ai-chat/schema.sql before publishing.