Skip to content
Acecore
Table of Contents
Technical Design for Adding an AI Contact Chat to an Astro Site

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.

LayerResponsibility
Chat widgetUI, input, current locale, minimal history, and Markdown rendering
/api/ai-contactValidation, Origin checks, rate limiting, prompt construction, AI calls
OpenAI Responses APIGenerate 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.

RouteRole
FAQResolve common questions on the page
AI chatHelp visitors choose services, contact routes, and related pages
LINEShort questions, school-related topics, and lightweight checks
FormEstimates, production inquiries, partnerships, and recruiting
Direct contactFollow-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.

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 needed
  • tel:05088902788 when 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-contact accepts POST requests
  • OPENAI_API_KEY and OPENAI_MODEL are 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.

Responsibilities to Separate

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
Design Checklist for Other Sites
  • 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
Frequently Asked Questions
Do I need RAG or a vector database to build an AI contact chat?
For a small corporate site, structured public site context in the prompt is often enough. Search indexes or vector databases can be added later when page count or update frequency grows.
Is the OpenAI API key exposed to the browser?
No. The browser only sends the question to /api/ai-contact. The Cloudflare Pages Function calls the OpenAI Responses API and manages the API key.
Can the AI output any link it wants?
No. Links are restricted to internal paths, the current origin, acecore.net, the official LINE URL, and specific mailto or tel links when needed. Markdown URLs are trimmed before safety checks.

Comments

Loading comments...

Links, email addresses, and promotional text cannot be posted.

G

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.

Business problem framingTechnology selectionSystem designC#/.NETDatabase/infrastructure designGitHub development operationsGenerative AIAI workflow designQuality designOn-site integration

Want to learn more about our services?

We provide comprehensive support including system development, web design, graphic design, and IT education.

Related Posts

Search articles