Technical Design for Adding an AI Contact Chat to an Astro Site
Table of Contents
- Overall Structure
- Keep the Endpoint Contract Small
- Control Validation and Model Calls on the Server
- Make Site Information Explicit Context
- Write Rules, Not Only Tone Instructions
- Split the Contact Routes
- Preserve Locale-Aware URLs
- Add Origin Checks and Rate Limiting
- Render Markdown Links with an Allowlist
- Test Local, Preview, and Production Separately
- Watch the Right Operational Signals
- What Is Left for Another Article
- Summary
Adding an AI chat to a website is easy. Running it responsibly is where the design matters. The difficult parts are not only model quality, but deciding what the AI may answer, where it should send visitors, which URLs may be displayed, and how API cost is controlled.
Acecore added an AI contact chat to a static Astro + Cloudflare Pages site. The main implementation is in the PR that added the contact AI and CMS-scoped translation flow. We later tightened safe Markdown link rendering in a separate PR. The link-rendering details are covered separately in Safely Rendering Markdown Links in AI Chat Answers.
This article explains the design as a reusable pattern for other static sites. The same structure works beyond Astro: split the responsibilities between the client widget, server-side API boundary, prompt construction, and renderer.
Overall Structure
The architecture has three simple layers.
| Layer | Responsibility |
|---|---|
| Chat widget | UI, input, current locale, minimal history, and Markdown rendering |
/api/ai-contact | Validation, Origin checks, rate limiting, prompt construction, AI calls |
| OpenAI Responses API | Generate an answer from public site context and conversation state |
The browser should not call the OpenAI API directly. Keeping the model call behind a server-side endpoint prevents key exposure, lets you update prompts and site context without redeploying the UI, and centralizes input limits and error handling.
On Astro + Cloudflare Pages, the API boundary can be a Pages Function at /api/ai-contact. In Next.js it could be a Route Handler; in Hono or Express it can be a normal API route.
Keep the Endpoint Contract Small
The request payload should stay narrow.
type ContactAiRequest = {
message: string
locale: 'ja' | 'en' | 'zh-cn' | 'es' | 'pt' | 'fr' | 'ko' | 'de' | 'ru'
history?: Array<{
role: 'user' | 'assistant'
content: string
}>
}
type ContactAiResponse = {
answer: string
}
Names, email addresses, phone numbers, company names, and detailed form fields do not need to go through the AI chat. The chat is an entry point for helping users decide which service to read about and which contact route to use.
History should also be limited. Send only recent turns and enforce a character limit per message. This keeps prompts smaller and controls API cost.
Control Validation and Model Calls on the Server
The Pages Function owns the safety and execution boundary.
export async function onRequestPost({ request, env }: PagesFunction<Env>) {
assertSameOrigin(request)
assertRateLimit(request)
const body = await request.json()
const message = validateMessage(body.message)
const locale = validateLocale(body.locale)
const history = trimHistory(body.history)
const prompt = buildContactPrompt({
locale,
message,
history,
siteContext: buildPublicSiteContext(locale),
})
const answer = await callOpenAIResponsesApi({
apiKey: env.OPENAI_API_KEY,
model: env.OPENAI_MODEL,
prompt,
})
return Response.json({ answer })
}
The important point is to reduce and validate input before calling the AI API. Long messages, unlimited history, and uncontrolled cross-site traffic can make operations unstable before the feature itself becomes useful.
OPENAI_MODEL should be configurable through environment variables so the model can be changed in preview or production without touching the frontend. OPENAI_API_KEY must stay server-side.
See also Secure Static Site Delivery with Cloudflare Pages for the surrounding delivery and CSP setup.
Make Site Information Explicit Context
For a site of this size, you do not need to start with a vector database. A structured prompt context made from public site information is easier to operate.
Useful context includes:
- Company and service summaries
- Target users, example consultations, and related URLs for each service
- FAQ content that already answers common questions
- Rules for forms, LINE, email, and phone
- Areas the AI must not assert, such as pricing, contracts, or schedules
- Internal URLs for each locale
The goal is not to let the model answer from what it generally knows. The goal is to tell it what this site is allowed to say.
function buildPublicSiteContext(locale: Locale) {
return {
services: [
{
name: 'Web production',
summary: 'Corporate sites, recruiting sites, and landing pages',
url: localizePath('/services/web-production/', locale),
},
{
name: 'Business systems',
summary: 'Reservation, inventory, and customer management systems',
url: localizePath('/services/business-system/', locale),
},
],
contact: {
form: localizePath('/contact/', locale),
line: 'https://lin.ee/...',
emailPolicy:
'Show email only when the form cannot be used or follow-up is needed',
phonePolicy: 'Show phone only for urgent confirmation',
},
}
}
When the site grows, this layer can evolve toward Pagefind, CMS JSON, D1, Vectorize, or another retrieval mechanism.
Write Rules, Not Only Tone Instructions
The prompt should define answer boundaries and restrictions more than writing style.
You are the contact guidance AI for this website.
Answer only from public site information.
Rules:
- Do not make firm statements about pricing, contracts, schedules, or guarantees
- Send formal consultations and estimates to the contact form
- Also suggest LINE for short questions and school-related inquiries
- Show email and phone only when the user asks for direct contact
- Use URLs that match the current locale
- If unsure, do not guess; guide the user to the form
The common failure mode is that the AI tries to be helpful by over-committing. Questions about cost, delivery dates, and guarantees should lead to general guidance and then the form, because those answers require human confirmation.
Split the Contact Routes
The AI chat should not replace the contact form. The contact page works better when each route has a clear role.
| Route | Role |
|---|---|
| FAQ | Resolve common questions on the page |
| AI chat | Help visitors choose services, contact routes, and related pages |
| LINE | Short questions, school-related topics, and lightweight checks |
| Form | Estimates, production inquiries, partnerships, and recruiting |
| Direct contact | Follow-up after the form or urgent confirmation only |
The AI connects broad service content such as the service overview article with concrete routes on the contact page. This pattern works for B2B sites, agencies, schools, and SaaS support pages.
Preserve Locale-Aware URLs
On a multilingual site, the answer language is not enough. URLs also need to match the current locale.
If a user asks from an English page, the answer should be in English and service links should point to paths such as /en/services/. Japanese can use /services/.
function localizePath(path: string, locale: Locale) {
if (locale === 'ja') return path
return `/${locale}${path}`
}
This is more reliable as server-side URL generation than as a loose instruction in the prompt. For more on the translation setup, see How to Run a Multilingual Blog with Sveltia CMS.
Add Origin Checks and Rate Limiting
Because /api/ai-contact is public, it should have at least Origin checks, input length limits, history limits, and rate limiting.
function assertSameOrigin(request: Request) {
const origin = request.headers.get('Origin')
if (!origin) return
const requestUrl = new URL(request.url)
const originUrl = new URL(origin)
if (originUrl.host !== requestUrl.host) {
throw new Response('Forbidden', { status: 403 })
}
}
IP-based rate limiting is a useful first brake. In Cloudflare, you can derive identifiers from headers such as CF-Connecting-IP, X-Forwarded-For, or CF-Ray.
In-memory limits are not persistent across isolates or restarts, so they are only an initial layer. For heavier traffic, move enforcement toward Cloudflare WAF, Turnstile, KV, D1, or Durable Objects. Content-update CMS operations are covered in Sveltia CMS Setup Guide; form and comment bot protection should be treated as a separate layer.
Render Markdown Links with an Allowlist
Links make the chat useful, but Markdown should not be passed directly to HTML. The client renderer should only support the small subset you need:
- Paragraphs
- Lists
- Bold text
- Inline code
- Markdown links
Then restrict link targets:
- Internal paths such as
/services/ - The current origin
https://acecore.net- Official LINE URLs
mailto:[email protected]when neededtel:05088902788when needed
Always trim() the URL before validation. AI output can contain spaces such as [Services]( /services/ ).
function sanitizeHref(rawHref: string, currentOrigin: string) {
const href = rawHref.trim()
if (href.startsWith('/')) return href
if (href.startsWith(`${currentOrigin}/`)) return href
if (href.startsWith('https://acecore.net/')) return href
if (href.startsWith('https://lin.ee/')) return href
if (href === 'mailto:[email protected]') return href
if (href === 'tel:05088902788') return href
return null
}
A small, strict renderer is easier to reason about than a full Markdown implementation. If you allow more external links, at least use a domain allowlist and rel="noopener noreferrer".
Test Local, Preview, and Production Separately
Astro dev or preview is not identical to the Cloudflare Pages Functions environment. Without OPENAI_API_KEY, local testing should focus on UI fallback and error states.
In Pages preview or production, check:
/api/ai-contactaccepts POST requestsOPENAI_API_KEYandOPENAI_MODELare configured- Cross-origin requests are rejected
- Input length and history count are limited
- Answers match the current locale
- Internal links use locale-aware URLs
- The AI does not assert estimates or contracts
- Email and phone are not shown by default
- Markdown links are converted only when the URL is allowed
Do not finish validation after one successful question. Test long input, unexpected questions, English pages, direct-contact requests, and pricing questions separately.
Watch the Right Operational Signals
After release, watch more than page views.
- API error rate
- Rate-limit hits
- Average messages per inquiry
- Clicks to the form and LINE
- Cases where the AI could not answer and guided users to the form
- Usage by locale
If you store conversation text, define the privacy rules first. A safer first step is to store event counts and errors without message bodies.
What Is Left for Another Article
This article focuses only on the technical design of the AI contact chat. Passing service-page context into the contact form is also implemented, and that is covered in Technical design for passing context from a service CTA to the contact form.
- AI chat: organize uncertainty through conversation and guide users safely
- Service CTA: pass the service context a visitor is reading into the form
Keeping these topics separate makes both articles easier to read and easier to cross-link later.
Summary
When adding an AI contact chat to a static site, design the API boundary and answer controls before polishing the UI.
The key decisions were:
- Call OpenAI from a Cloudflare Pages Function, not the browser
- Keep endpoint input small and limit history and message length
- Build site context and locale-aware URLs on the server side
- Put clear boundaries in the prompt for what the AI may not assert
- Split the roles of forms, LINE, and direct contact
- Add Origin checks and rate limiting
- Render Markdown links through a trimmed allowlist
Static sites can support useful AI contact chats. The point is not to make the AI visible, but to help visitors choose their next action safely.
Reference Architecture
Widget
The Astro chat UI sends only the question, current locale, and minimal history.
Function
The Cloudflare Pages Function handles validation, Origin checks, rate limiting, and prompt construction.
Model
The OpenAI Responses API receives public site context and conversation state, then returns the answer.
Renderer
The client renders only allowed Markdown and sends users to internal links or approved contact channels.
When Everything Is Mixed
- Calling the AI API directly from the browser
- Mixing site context, API keys, UI display, and link rendering
- Letting the AI make firm statements about pricing, contracts, or schedules
- Rendering Markdown and URLs directly as HTML
When Responsibilities Are Split
- Keep API keys and model calls on the server side
- Manage public site information as explicit context
- Control answer scope and contact routing through prompts
- Render Markdown and URLs through allowlists
- Done: Define the chat as route guidance, not as a complete inquiry replacement
- Done: Create a server-side API boundary and never expose the API key to the browser
- Done: Restrict answers to public site information
- Done: Decide what the AI must not assert, such as pricing, contracts, schedules, and guarantees
- Done: Define when to use forms, LINE, email, and phone
- Done: Generate locale-aware URLs so multilingual navigation stays intact
- Done: Add Origin checks, input length limits, history limits, and rate limiting
- Done: Trim Markdown link URLs before allowlist validation
Do I need RAG or a vector database to build an AI contact chat?
Is the OpenAI API key exposed to the browser?
Can the AI output any link it wants?
Comments
Gui
CEO of Acecore. Leads business systems, web, databases and infrastructure, quality assurance, and AI adoption from business problem framing through design, rollout, and post-launch improvement. Builds on hands-on C#/.NET capability while also covering PHP/JavaScript, SQL Server/PostgreSQL/MySQL, and Linux/Windows Server, designing requirements, technology choices, quality standards, and GitHub-based development operations as one coherent workflow. Uses generative AI across development, verification, and information organization, treating it as practical infrastructure that helps small teams deliver faster and more reliably.
Want to learn more about our services?
We provide comprehensive support including system development, web design, graphic design, and IT education.
Related Posts
Designing an Astro + Cloudflare Website That Can Grow Feature by Feature June 7, 2026 at 07:00 PM
Build Astro Blog Comments with Cloudflare Only June 7, 2026 at 06:00 PM