Agent skill

Public QA Chatbot

by swyxio in swyxio/skills

Best practices for building an unauthenticated public Q&A chatbot widget.

MITAuto-check passedBackend & APIs

Install Public QA Chatbot

skills CLI
$ npx skills add swyxio/skills --skill public-qa-chatbot -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install swyxio/skills public-qa-chatbot --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/swyxio/skills.git skills-src && mkdir -p .claude/skills && cp -r skills-src/public-qa-chatbot .claude/skills/public-qa-chatbot && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
public-qa-chatbot
GitHub stars
176
Token cost
~9.8k tokens
SKILL.md length
4,134 words
Files
14 (incl. assets)
Skills in repo
89
Repo updated
First seen
Licence
MIT

At a glance

Best practices for building an unauthenticated public Q&A chatbot widget.

  • Works in 9 steps: Rate Limiting → Security → Cost Optimization → …
  • Tasks that involve Rate limiting
  • SKILL.md covers When to use this skill, Tech stack choices, 1. Rate Limiting and 2. Security, plus 2 more sections
  • Runs TypeScript scripts from its folder; calls npm and pnpm; needs OPENAI_API_KEY and REDIS_TOKEN

What it does

Public QA Chatbot is an agent skill from swyxio/skills. Best practices for building an unauthenticated public Q&A chatbot widget. Covers rate limiting, security hardening, cost optimization, semantic caching, observability, UX patterns, chat scroll behavior, and architecture. Tech-agnostic with concrete examples from a production implementation.

Its SKILL.md is about 9.8k tokens, which your agent loads only when the skill is triggered. The skill folder holds 17 other files, including assets (for example `MINTLIFY_VIRTUAL_FILESYSTEM.md`, `agents/openai.yaml` and `assets/vite-react-tanstack-chat-demo/package-lock.json`).

It sits in Backend & APIs, covering Rate limiting, Security review and Caching. It works with TanStack. The repository describes itself as: Agent skills for Claude Code and other AI agents. The licence is MIT.

When your agent uses it

  • Tasks that involve Rate limiting
  • Tasks that involve Security review
  • Tasks that involve Caching

Example prompts

  • “/public-qa-chatbot”

Requirements

  • Node.js
  • A credential in OPENAI_API_KEY
  • A credential in REDIS_TOKEN

Workflow steps

9 steps, taken from the step headings in SKILL.md.

  1. Rate Limiting
  2. Security
  3. Cost Optimization
  4. Observability
  5. UX Patterns
  6. Architecture
  7. Knowledge Base Management
  8. Common Pitfalls
  9. Checklist

What it can do on your machine

Read from SKILL.md and the folder at commit 038ef34. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Ships script files (TypeScript), which the agent can run.

    Shell commands in SKILL.md call:

    • npm
    • pnpm

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Links to these hosts (documentation or services it may open):

    • tanstack.com
    • mintlify.com
    • github.com
    • ai.engineer
    • aistudio.google.com
    • platform.openai.com
    • agentskills.io
    • sdk.vercel.ai
    • upstash.com
    • braintrust.dev
    • langfuse.com
    • arcjet.com

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names these keys or tokens, usually read from environment variables:

    • OPENAI_API_KEY
    • REDIS_TOKEN
    • VECTOR_TOKEN
    • BRAINTRUST_API_KEY

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Public QA Chatbot loads about 9.8k tokens when it runs. Until then it costs about 77 tokens; SKILL.md has 4,134 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~77
When it runs · the whole SKILL.md, loaded when a task matches
~9.8k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from swyxio/skills at commit 038ef34, republished under its MIT licence (© swyxio). 4,134 words, ~9,767 tokens.

Download SKILL.mdSave it as .claude/skills/public-qa-chatbot/SKILL.md (or your agent's skills folder). This skill also uses 13 other files; get the full folder from GitHub.
name
public-qa-chatbot
description
Best practices for building an unauthenticated public Q&A chatbot widget. Covers rate limiting, security hardening, cost optimization, semantic caching, observability, UX patterns, chat scroll behavior, and architecture. Tech-agnostic with concrete examples from a production implementation.
license
MIT
metadata.author
aidotengineer
metadata.version
1.1
metadata.category
chatbot
metadata.compatibility
Any web framework with a server-side API route
metadata.tags
rate-limiting, security, caching, observability, LLM, chat-ui, virtualization

Public Q&A Chatbot - Best Practices

A comprehensive skill for building unauthenticated, public-facing Q&A chatbot widgets on marketing sites, conference pages, documentation portals, and similar contexts where you need to serve anonymous visitors while controlling cost and abuse.

Use this as a design and review catalog, not a requirement to add every listed service or subsystem. Select controls for the public exposure, cost, privacy, and UX risks created by the requested change. Server-side secret isolation, server-authoritative enforcement of abuse limits, and safe handling of user content are invariants where those boundaries are touched. Caching, vector search, tracing, virtualization, voice, and multi-layer infrastructure are recommendations or opportunities unless the requested scale or failure mode makes one necessary. Stop when the named widget behavior is usable and its concrete public-abuse/cost risks are bounded; report adjacent improvements as follow-ups.

Distilled from a production implementation powering the AI Engineer Europe 2026 conference chatbot, with additional chat-scroll lessons from TanStack Virtual's chat guidance and agentic retrieval lessons from Mintlify's virtual filesystem assistant. See MINTLIFY_VIRTUAL_FILESYSTEM.md for a clean markdown reference version of the Mintlify pattern.

For a runnable React/TanStack Virtual demo of long chat scroll behavior plus an expanded bottom command shelf, use assets/vite-react-tanstack-chat-demo. The demo includes hover/double-click message controls, subtle token/latency stats, tool-call and multimodal examples, assistant response variants via left/right swipe, and a Realtime voice capture strip with live transcription and an audiogram.

Image: Public Q&A chatbot demo with live voice transcription and bottom command shelf

Run the demo locally:

bash
cd assets/vite-react-tanstack-chat-demo
npm install
npm run dev -- --port 5179

To exercise the Realtime voice path, start the dev server with OPENAI_API_KEY set. Keep the standard API key on the server side only; the browser should receive an ephemeral Realtime client secret.

When to use this skill

  • Embedding a chatbot widget on a public website (no user login required)
  • Answering questions from a known FAQ / knowledge base
  • Serving anonymous visitors with LLM-powered responses
  • Needing to protect against abuse, cost overruns, and API quota exhaustion
  • Building a constrained Q&A bot (not a general-purpose assistant)
  • Reviewing a public chatbot's widget UX, streaming behavior, scroll anchoring, or history loading

Tech stack choices

This skill is written to be tech-agnostic. The reference implementation uses the stack below, but each component is swappable:

ComponentReference choiceAlternatives
LLM providerGemini 3.1 Flash-Lite (via @ai-sdk/google)OpenAI GPT-4o-mini, Anthropic Claude Haiku, Mistral, Llama via Groq/Together
AI SDKVercel AI SDK v6 (ai)LangChain, LlamaIndex, direct provider SDKs
HostingVercel (serverless functions)Cloudflare Workers, AWS Lambda, Railway, Fly.io, Render
Rate limitingUpstash Redis (@upstash/ratelimit)Cloudflare Rate Limiting, AWS WAF, Redis (self-hosted), Arcjet
Semantic cacheUpstash Vector + Gemini EmbeddingsPinecone, Weaviate, Qdrant, pgvector, Cloudflare Vectorize
Agentic docs retrievalRead-only virtual filesystem over indexed docsPlain RAG, hosted search API, real sandbox only for async/developer tools
Embedding modelGemini text-embedding-004 (128 dims)OpenAI text-embedding-3-small, Cohere Embed v3, Voyage AI
ObservabilityBraintrust (wrapAISDK)Langfuse, Helicone, LangSmith, OpenTelemetry, Datadog LLM Obs
FrontendReact (inline component)Vue, Svelte, vanilla JS, Web Components
Long chat virtualizationTanStack Virtual chat supportNative scroll for short widgets, react-virtuoso, custom virtual list only when already proven

Do not require virtualization for every public FAQ widget. A short, bounded chat can stay as a simple DOM list. Reach for a virtualized chat list when conversations can grow long, rows have dynamic heights, older history prepends, or streaming output makes scroll anchoring fragile. When using React and a virtualized list is justified, prefer TanStack Virtual's chat support over custom scroll math.

Do not require agentic document browsing for every public FAQ widget either. Plain RAG is sufficient for short, stable FAQs. Add a virtual docs filesystem when answers live across multiple pages, users ask for exact syntax, docs have a meaningful hierarchy, or top-k retrieval often misses the section an expert would grep for.


1. Rate Limiting

Multi-layer rate limits

Apply limits at multiple granularities to prevent abuse:

  • Per-turn: Cap messages per conversation (e.g. 9 turns/session)
  • Per-visitor per day: Cap sessions per IP per day (e.g. 15/day)
  • Global per day: Cap total sessions across all visitors (e.g. 3000/day)
typescript
// Example constants
const LIMITS = {
  turnsPerSession: 9,
  sessionsPerVisitorPerDay: 15,
  globalSessionsPerDay: 3000,
};
Use distributed rate limiting in production

In-memory rate limiting resets on every serverless cold start and isn't shared across instances. Use a distributed store for production:

Upstash Redis (reference):

typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = new Redis({ url: REDIS_URL, token: REDIS_TOKEN });
const limiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(15, "1 d"), // 15 per day
  prefix: "chatbot:visitor",
});
const { success } = await limiter.limit(clientIp);

Alternatives:

  • Cloudflare Rate Limiting - built into Cloudflare Workers, no external DB needed
  • Arcjet - drop-in rate limiting SDK with bot detection
  • AWS WAF - rate-based rules at the edge
  • Self-hosted Redis - ioredis + custom sliding window logic

Always keep an in-memory fallback for local development:

typescript
const useDistributed = !!redisUrl && !!redisToken;
if (!useDistributed) {
  // Fall back to in-memory Map for local dev
}
Server-authoritative counting

Never trust client-reported turn counts or session flags. The server must count turns from the messages array itself:

typescript
// Server counts turns - never trust client-reported values
const userTurnCount = messages.filter(m => m.role === "user").length;
const isNewSession = userTurnCount <= 1;
Session counting timing

Only increment the session counter after the server confirms a successful response, not when the user submits. This prevents phantom session counts from failed requests, network errors, or aborted streams:

typescript
// Client-side: count after first assistant response arrives
useEffect(() => {
  const hasAssistantMessage = messages.some(m => m.role === "assistant");
  if (hasAssistantMessage && !sessionCounted.current) {
    sessionCounted.current = true;
    incrementSessionCount();
  }
}, [messages]);
Non-new session handling

When a request is not a new session (i.e. a follow-up turn in an existing conversation), skip daily session counter increments entirely. Only the first turn of a conversation should count as a "session" for rate limiting purposes:

typescript
if (!isNewSession) {
  return { allowed: true }; // Skip session counting for follow-up turns
}
BYOK (Bring Your Own Key) fallback

When rate-limited, let users input their own API key to continue chatting. This turns abuse into the user's own cost while preserving good UX:

typescript
// Skip rate limiting when user provides their own key
if (!userApiKey) {
  const limit = await checkRateLimit(ip, turnCount, isNewSession);
  if (!limit.allowed) {
    return res.status(429).json({ error: limit.reason, rateLimited: true });
  }
}
const apiKey = userApiKey || serverKey;

Provide a direct link to obtain a key (e.g. https://aistudio.google.com/apikey for Gemini, https://platform.openai.com/api-keys for OpenAI).


2. Security

Origin validation

Check the Origin or Referer header against an allowlist. This prevents cross-site request abuse where third parties embed scripts that burn your API quota:

typescript
const origin = req.headers.origin ?? req.headers.referer ?? "";
const allowedHosts = ["localhost", "yourdomain.com", "vercel.app"];
if (origin && !allowedHosts.some(h => origin.includes(h))) {
  return res.status(403).json({ error: "Forbidden" });
}

Note: Substring matching (origin.includes(h)) is acceptable for v1 but could theoretically match crafted domains. For stricter validation, parse the URL and compare the hostname.

Input size limits

Cap both the number of messages and individual message length to prevent token-stuffing attacks that run up your LLM bill:

typescript
const MAX_MESSAGES = 10;
const MAX_MESSAGE_LENGTH = 2000;

const trimmedMessages = messages.slice(-MAX_MESSAGES).map(m => ({
  ...m,
  parts: m.parts.map(p =>
    p.type === "text" && typeof p.text === "string"
      ? { ...p, text: p.text.slice(0, MAX_MESSAGE_LENGTH) }
      : p
  ),
}));

Also limit model output: maxOutputTokens: 500 for short Q&A answers.

Validate all parameters

Never trust as casts for user-supplied values. Validate against a known set:

typescript
const VALID_PAGES = new Set(["europe", "home", "worldsfair"]);
if (!VALID_PAGES.has(page)) {
  return res.status(400).json({ error: "Invalid page parameter." });
}
Access-prune retrieval surfaces

For documentation-backed chatbots, access control must happen before retrieval, not after answer generation. If the bot exposes semantic search, exact search, or a virtual docs filesystem, apply the same visibility filter to every surface:

  • Exclude unpublished, draft, internal, customer-only, or role-gated pages before building any path tree the model can browse.
  • Apply the same filter to vector, keyword, and chunk queries. Do not rely on hiding paths in the UI while leaving chunks searchable.
  • Prefer omitting inaccessible paths entirely. A model should not be able to mention "there is an internal billing page, but you cannot access it."
  • Include isPublic, groups, tenantId, docsVersion, or equivalent metadata with indexed chunks so filters are cheap and testable.
Safe error handling
  • Never leak raw SDK error strings to the client (may contain API keys from BYOK)
  • Never log full error objects (may contain sensitive data)
  • Return generic error messages:
typescript
} catch {
  console.error("Chat API error");
  return res.status(500).json({
    error: "An error occurred processing your request. Please try again.",
  });
}
IP resolution on serverless platforms

Use the platform's trusted headers. On Vercel: x-real-ip > x-vercel-forwarded-for > x-forwarded-for. The standard x-forwarded-for is spoofable by clients.

Alternatives:

  • Cloudflare: CF-Connecting-IP
  • AWS ALB/CloudFront: X-Forwarded-For (first IP is trustworthy when set by AWS)
  • Fastly: Fastly-Client-IP
Disable non-text modalities

If you only need text responses, explicitly restrict the model:

typescript
const model = provider("gemini-3.1-flash-lite", {
  responseModalities: ["TEXT"], // Gemini-specific
  // For OpenAI: modalities: ["text"]
});

Also state "text-only assistant" in the system prompt as a defense-in-depth measure.


3. Cost Optimization

Semantic caching

Use vector similarity search to cache and reuse responses for semantically similar questions. Most effective for FAQ-style chatbots where users ask the same questions in different words.

Upstash Vector (reference):

typescript
import { Index } from "@upstash/vector";

const vectorIndex = new Index({ url: VECTOR_URL, token: VECTOR_TOKEN });

// Lookup: check cache before calling LLM
const results = await vectorIndex.query({
  vector: await getEmbedding(question),
  topK: 1,
  includeMetadata: true,
  filter: `page = '${page}'`,
});
if (results[0]?.score >= 0.92 && results[0]?.metadata?.answer) {
  return results[0].metadata.answer; // Cache hit - skip LLM call
}

// Store: cache after LLM responds (fire-and-forget)
void vectorIndex.upsert({
  id: `cache-${Date.now()}`,
  vector: embedding,
  metadata: { question, answer, page, cachedAt: Date.now() },
});

Key decisions:

  • Similarity threshold: 0.92+ to avoid returning wrong cached answers. Lower values increase hit rate but risk incorrect responses.
  • Embedding dimensions: 128 dims is sufficient for FAQ similarity and cheaper to compute/store than full 768/1536/3072.
  • Cache scope: Cache first-turn questions only (highest hit rate, simplest implementation).
  • TTL: 7 days is reasonable; stale answers are better than no cache.

Alternatives:

  • Pinecone - managed vector DB with metadata filtering
  • pgvector - if you already have PostgreSQL
  • Cloudflare Vectorize - edge-native, pairs with Workers
  • Qdrant/Weaviate - self-hosted or cloud, richer query capabilities
Cache TTL enforcement

Always store a cachedAt timestamp in cache entry metadata. On lookup, reject entries older than your TTL (e.g. 7 days). This prevents stale answers from persisting indefinitely, especially when FAQ content changes:

typescript
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
if (Date.now() - result.metadata.cachedAt > CACHE_TTL_MS) {
  // Stale - treat as cache miss
}
Stream protocol consistency for cache hits

When returning a cached response, use the same streaming protocol as live LLM responses. Don't switch to a different response format (e.g. manual Data Stream Protocol vs. UI Message Stream). Inconsistent formats cause client-side parsing errors and broken UX:

typescript
// BAD: different format for cache hits
res.write(`0:${JSON.stringify(cachedText)}\n`); // Manual Data Stream Protocol
// GOOD: same format for both paths
const stream = createUIMessageStream({ /* ... */ });
pipeUIMessageStreamToResponse(stream, res);

For docs assistants that expose grep-style tools, avoid scanning every page or chunk over the network. Use a two-stage exact-search path:

  1. Coarse filter: ask the document database for pages whose metadata or text might contain the fixed string or regex. Use datastore-native filters where available, such as $contains, full-text indexes, trigram search, or metadata filters by section/path.
  2. Bulk prefetch: fetch all candidate chunks for the matching pages in one batch, sorted by page and chunk_index.
  3. Fine filter: run exact string or regex matching in memory and return only final hit paths/snippets.
  4. Cache: store prefetched page chunks by { path, docsVersion } so repeated grep/cat workflows do not hit the database twice.

Log candidate count and final hit count. If the coarse filter returns too many pages, ask the model to narrow the query instead of silently running an expensive full-corpus scan.

FAQ list view

Offer a browsable FAQ list alongside the chat interface. This serves users who have common questions without making any LLM calls at all:

typescript
// Structured FAQ data for UI rendering
export const FAQ_QUESTIONS: Array<{
  category: string;
  question: string;
  answer: string;
}> = [
  { category: "Ticketing", question: "Can I get a refund?", answer: "Yes, per our refund policy..." },
  // ...
];

Organize by category with expandable sections. Clicking a question can either show the pre-written answer directly or send it to the chat for a more detailed LLM response.

Use the cheapest sufficient model

For a constrained Q&A chatbot, you rarely need the most powerful model:

ModelInput costOutput costBest for
Gemini 3.1 Flash-Lite$0.25/1M$1.50/1MCheapest, good for FAQ
GPT-4o-mini$0.15/1M$0.60/1MGood balance of cost/quality
Claude Haiku$0.25/1M$1.25/1MFast, good at following instructions
Llama 3.3 70B (via Groq)Free tier availableFree tier availableCost-sensitive prototypes
Short output limits

Set maxOutputTokens to the minimum needed (e.g. 500 tokens for 2-4 sentence answers). This caps cost per request and keeps responses concise.

Context caching

Pre-build and cache the system prompt context at module level. This avoids re-computing expensive string concatenations on every request:

typescript
let cachedContext: Record<string, string> | null = null;

function buildContext(): Record<string, string> {
  if (cachedContext) return cachedContext;
  // ... expensive computation ...
  cachedContext = result;
  return cachedContext;
}

4. Observability

Trace every LLM call

Instrument all LLM calls with input/output, latency, token usage, and cost. This is essential for monitoring abuse, debugging, and cost tracking.

Braintrust (reference):

typescript
import { initLogger, wrapAISDK } from "braintrust";
initLogger({ projectName: "my-chatbot", apiKey: BRAINTRUST_API_KEY });
const { streamText } = wrapAISDK(ai); // Auto-traces all calls

Alternatives:

  • Langfuse - open-source, self-hostable, supports OpenAI/Anthropic/custom
  • Helicone - proxy-based, zero-code integration
  • LangSmith - if using LangChain
  • OpenTelemetry - vendor-neutral, export to Datadog/Honeycomb/Grafana
  • Datadog LLM Observability - if already using Datadog
Log semantic cache hits

Track cache hit rates to understand cost savings and tune the similarity threshold. A cache hit is a "free" response that saved an LLM call.

Trace retrieval tool calls

Trace retrieval tools separately from LLM calls. For semantic search, exact search, and virtual filesystem tools, log:

  • Tool name, query/pattern, requested path, and docs version.
  • Latency, cache hit/miss, database round trips, chunks fetched, candidate count, and final result count.
  • Whether the model escalated from broad semantic search to exact grep/cat/ls exploration.
  • User-visible outcome signals such as cited-answer rate, "I don't know" rate, thumbs up/down, and handoff/escalation rate.

This tells you whether agentic retrieval is improving answer quality or just adding cost and latency.

Don't log sensitive data

Avoid logging full error objects, API keys, or user PII. Log just enough to debug (error type, status codes, IP hashes).


5. UX Patterns

Markdown rendering

Enable markdown in chat responses and instruct the model to use it via the system prompt:

text
You may use markdown formatting in your responses when appropriate:
- Use **bold** for emphasis on key information like dates, prices, or venue names
- Use [links](url) when referencing websites
- Use bullet points for lists of speakers, sessions, or options
- Keep formatting light and readable

React: react-markdown + remark-gfm Vue: vue-markdown-render Vanilla JS: marked or markdown-it

Draggable and resizable window

Let users reposition and resize the chat window. Persist geometry to localStorage so it survives page reloads. Clamp positions to viewport bounds:

typescript
const newX = Math.max(0, Math.min(
  e.clientX - dragOffset.x,
  window.innerWidth - geometry.width
));
Streaming responses

Always stream responses for perceived speed. Use your SDK's streaming API rather than waiting for the full response. The first token appearing quickly matters more than total latency.

For the UI, update one in-progress assistant message as tokens arrive. Do not append a new message row per token. Token-level rows are expensive, break transcript semantics, and make scroll anchoring harder.

Chat scroll and virtualization

Public Q&A widgets often start short, so a plain scroll container is fine until there is evidence it is not. Add virtualization when the widget can hold long histories, rich markdown, tool results, images, code blocks, history pagination, or token-streaming messages that grow in height.

When virtualization is warranted, recommend TanStack Virtual's chat support for React implementations, but keep it optional and swappable. The important lessons are the scroll contracts:

  • Treat chat as an end-anchored reverse feed, not a normal top-anchored list.
  • Keep message data in normal chronological order; avoid flex-direction: column-reverse, inverted transforms, and hand-maintained scrollTop += delta bookkeeping.
  • Use stable message IDs as row keys. Index keys cannot preserve position after prepending older history.
  • Loading older history should prepend messages with ordinary array updates, such as setMessages((current) => [...olderMessages, ...current]).
  • Follow appended messages only when the user was already near the latest message. If the user scrolled up to read history, incoming output must not yank them back to the bottom.
  • Use an explicit "near latest" threshold, e.g. about 80px, rather than exact-bottom checks that are brittle across browsers and dynamic heights.
  • Expose a "Latest" or "Jump to bottom" affordance when the user is away from the end.
  • Dynamic row heights are the default for real chat. Markdown, links, code, tool output, and streamed text should be measured or allowed to reflow without overlap.
  • Prefer instant/auto follow for high-frequency token streaming. Smooth scroll can look nice for discrete appends, but validate it because animation targets can fight dynamic measurement.
  • Keep pagination cursors, hasMoreHistory, loading flags, and request dedupe in app state. The virtualizer should receive the current ordered message array, not own data fetching.

TanStack Virtual maps these lessons to anchorTo: 'end', followOnAppend, scrollEndThreshold, stable getItemKey, measureElement, isAtEnd(), getDistanceFromEnd(), and scrollToEnd(). These APIs are useful defaults, not a hard dependency.

Bottom command shelf

Do not treat the composer as only a textbox. For AI applications, the bottom of the screen is valuable thumb-reachable space for the actions users need while forming a prompt: attach, tools, model, voice, send, mode, reasoning depth, runtime context, and tool launchers.

Use a progressive bottom command shelf when the app has enough controls to justify it:

  • Keep the default composer compact: input, add/attach, tools toggle, model chip, mic, and send.
  • Expand into a bottom sheet for secondary controls rather than putting all controls in the default composer.
  • Keep send one tap away in both compact and expanded states.
  • Keep the main canvas visually calm; let the bottom shelf become the command plane.
  • Show mode and execution state as compact chips, e.g. Plan / Build, effort level, device/project/branch, and budget or usage.
  • Place tool launchers in the expanded shelf when they are likely to be used mid-prompt, e.g. terminal, file search, web search, docs, or attachments.
  • Add an explicit close/collapse affordance above the expanded shelf so the user can reclaim vertical space.
  • Avoid this pattern for simple public FAQ widgets with no tools or settings. For constrained Q&A, a compact composer plus FAQ chips is often enough.
  • Reserve layout space for the compact composer, then let expanded toolbar controls move upward as an overlay. Opening tools should not resize, jump, or re-anchor the chat transcript.
  • Test keyboard open/close, safe-area insets, shelf expand/collapse, message streaming, and history reading with the shelf in both states.
Show full SKILL.md (1,575 more words)Show less
Graceful degradation

Every optional service should have a fallback:

ServiceIf unavailable...
Redis (rate limiting)Fall back to in-memory counters
Vector DB (cache)Skip semantic caching, always call LLM
Observability (tracing)Skip tracing, log locally
Server API keyPrompt user for BYOK
Virtualized chat listFall back to a bounded native scroll list with transcript limits
typescript
// Pattern: optional service with graceful fallback
const vectorIndex = vectorUrl && vectorToken
  ? new Index({ url: vectorUrl, token: vectorToken })
  : null; // null = skip caching

if (vectorIndex) { /* try cache */ }
// Always falls through to LLM call
Hover previews

Show top FAQ questions on hover over the chat bubble. This gives users an immediate sense of what the chatbot can help with and reduces "what do I ask?" friction.

Theme-aware / adaptive theming

When embedding a chatbot widget on a page that supports dark/light mode, make the chatbot colors contrast with the page background:

  • Dark page -> white/light chatbot
  • Light page -> black/dark chatbot

Accept the page's theme state (e.g. isDark prop) and derive all colors from a single theme palette function. Use useMemo to avoid recalculating on every render:

tsx
const theme = useMemo(() => getTheme(isDark), [isDark]);
// getTheme returns 40+ color tokens: bg, text, borders, buttons, surfaces, shadows

Define comprehensive color tokens so every UI element adapts. This avoids hardcoded colors scattered throughout the component and makes the entire widget respond to theme changes in one place.


6. Architecture

Pluggable component

Design the chatbot as a single component that accepts props so it can be dropped into any page with different branding/context:

tsx
<Chatbot
  page="europe"
  accentColor="#7C3AED"
  title="AI Engineer Europe Assistant"
/>
Tool calls instead of context stuffing

Instead of stuffing all data into the system prompt, expose tools that the model can call on-demand. This keeps the context window smaller and responses more accurate:

typescript
tools: {
  search_speakers: tool({
    description: "Search for speakers by name, company, or role",
    inputSchema: jsonSchema<{ search?: string }>({ ... }),
    execute: async (args) => searchSpeakers(args),
  }),
  search_sessions: tool({
    description: "Search sessions by title, speaker, day, type, or track",
    inputSchema: jsonSchema<{ search?: string; day?: string }>({ ... }),
    execute: async (args) => searchSessions(args),
  }),
}
Virtual documentation filesystem for agentic retrieval

Top-k RAG works for simple FAQ questions, but it breaks down when the answer spans several pages, the user needs exact syntax, or the correct page does not land in the nearest embedding results. For documentation-backed chatbots, consider exposing the knowledge base as a read-only virtual filesystem so the model can explore with familiar tools such as ls, cat, find, and grep.

The important idea is to give the model the filesystem workflow, not necessarily a real filesystem. Mintlify's ChromaFs pattern maps shell commands onto an existing docs index instead of booting a sandbox for every visitor. That matters for public chatbot latency and cost: their article reports p90 session creation dropping from about 46s with sandbox/repo setup to about 100ms with a virtual filesystem over Chroma.

Recommended shape:

  • Store a path tree for the docs site, e.g. page slugs and section paths, as a compact JSON artifact in the same datastore as the indexed content.
  • On session init, load the path tree into memory as Set<path> plus Map<directory, children> so ls, cd, and basic find do not need network calls.
  • Apply access control before the tree reaches the model. For public widgets this usually means pruning unpublished, private, draft, customer-only, or admin-only pages. The model should not see paths it cannot read.
  • Implement cat /path/page.mdx by fetching all chunks for that page, sorting by chunk_index, and reassembling the full page. Cache page reads during the session so repeated inspection is cheap.
  • Support lazy file pointers for large artifacts such as OpenAPI specs, generated API reference JSON, changelogs, or versioned docs. Show the file in ls, but fetch content only when the model runs cat.
  • Make the filesystem explicitly read-only. Any write-like operation should fail with an EROFS-style error so the assistant can explore freely without state cleanup or cross-user mutation risk.
  • Optimize recursive grep as a two-stage search: use the vector/document database as a coarse filter to identify candidate pages, then run exact string or regex matching in memory over the fetched candidates. This gives exact-match behavior without scanning every file over the network.

Expose the virtual filesystem as narrow tools rather than a general shell when possible:

typescript
tools: {
  list_docs: tool({
    description: "List child paths under a documentation directory.",
    inputSchema: jsonSchema<{ path: string }>({ ... }),
    execute: async ({ path }) => docsFs.ls(path),
  }),
  read_doc: tool({
    description: "Read a full documentation page by path.",
    inputSchema: jsonSchema<{ path: string }>({ ... }),
    execute: async ({ path }) => docsFs.cat(path),
  }),
  search_docs_exact: tool({
    description: "Search docs by exact string or regex and return matching paths/snippets.",
    inputSchema: jsonSchema<{ pattern: string; regex?: boolean }>({ ... }),
    execute: async ({ pattern, regex }) => docsFs.grep(pattern, { regex }),
  }),
}

Use this pattern when the chatbot needs to behave like a docs expert. Keep normal semantic search as the first-pass tool for broad questions, then let the model escalate to grep/cat/ls when it needs exact wording, syntax, cross-page synthesis, or source-grounded citations.

System prompt structure

Structure the system prompt with these sections in order:

  1. Role and constraints - "You are the conference assistant..."
  2. Formatting instructions - "Use markdown when appropriate..."
  3. Tool usage guidance - "Use tools to search speakers/sessions..."
  4. Hard constraints - "Text-only, no images/audio..."
  5. Fallback instructions - "If you don't know, suggest emailing..."
  6. Reference data - FAQ text, speaker list, session list
API route, not edge function

For chatbot endpoints that need streaming + external service calls (Redis, Vector DB, observability), use a standard API route / serverless function rather than edge functions. Edge functions have stricter size/dependency limits and cold start characteristics that can cause issues with multiple SDK imports.


7. Knowledge Base Management

Path tree index

For large documentation sites, generate a docs manifest alongside the chunk index:

typescript
type DocsPath = {
  path: string;        // "/auth/oauth.mdx"
  title: string;       // "OAuth"
  isPublic: boolean;
  groups: string[];
  updatedAt: string;
  sourceId: string;
  docsVersion: string;
};

At runtime, load the access-pruned manifest into memory:

  • Set<string> for valid file paths.
  • Map<string, string[]> for directory-to-children lookup.
  • Optional title/path aliases for forgiving find_docs behavior.

This makes list_docs, find_docs, and path validation memory-only. Rebuild or invalidate the tree when docsVersion changes.

Structured FAQ data

Maintain two representations of FAQ data:

  1. Flat text for the system prompt - a single string the model reads as context
  2. Structured objects for the UI - typed array with question, answer, category fields for rendering the FAQ list view
typescript
// System prompt context (flat text)
export const FAQ_KNOWLEDGE_BASE = `
## TICKETING & PRICING
Q: Can I get a refund?
A: Yes, per our refund policy...
`;

// UI list view (structured)
export const FAQ_QUESTIONS = [
  { category: "Ticketing", question: "Can I get a refund?", answer: "Yes..." },
];
Full-page reassembly from chunks

Chunked vector results are good for discovery, but they are often too lossy for final answers. For read_doc(path) or citation verification, fetch the whole page:

typescript
const chunks = await vectorIndex.query({
  topK: 200,
  includeMetadata: true,
  filter: `path = '${path}' AND docsVersion = '${docsVersion}'`,
});

return chunks
  .sort((a, b) => a.metadata.chunk_index - b.metadata.chunk_index)
  .map(chunk => chunk.metadata.text)
  .join("\n\n");

Cache full-page reads by { path, docsVersion }. This lets the model answer exact syntax, multi-section, and "compare these pages" questions with the same source material a human docs reader would inspect.

Include venue/logistics details

Always include practical information (venue name, address, dates, ticket URLs) directly in the context. These are the most common questions and should never require a tool call.


8. Common Pitfalls

Avoid DOM-manipulating libraries in React chat widgets

Libraries like html2canvas that clone and manipulate the DOM can interfere with React's virtual DOM reconciliation, causing page reloads, lost state, or broken event handlers. If you need page screenshots, use native browser APIs (navigator.mediaDevices.getDisplayMedia) or capture at the server level instead.

Don't make chat scroll a pile of special cases

The failure mode for long chatbot widgets is usually scattered scroll math: column-reverse, inverted transforms, manual offset deltas, unconditional scrollToBottom, and index-based keys. These hacks often pass short manual tests and then fail when older history loads, the assistant streams a long markdown answer, or the user reads history while new output arrives.

Prefer a single scroll contract:

  • Ordered messages in data.
  • Stable IDs for rows.
  • Prepend history without changing the user's visible anchor.
  • Append/follow only when already near latest.
  • Grow the active assistant row during streaming.
  • Test "reading history" and "pinned at latest" as different states.
Verify exact model identifiers before deploying

LLM model IDs change frequently and may require suffixes like -preview. A wrong model ID can return a 200 OK response with an empty or errored stream body, making it look like a frontend bug. Always verify the exact model ID against the provider's docs and test with a real API call before deploying.

Always run a local build before pushing

Never skip pnpm build / npm run build before pushing to a branch. TypeScript errors, import issues, and other compilation failures caught locally are much faster to fix than waiting for CI. This is especially important when multiple people are editing the same files.


9. Checklist

Use this checklist when building a new public Q&A chatbot:

  • Rate limiting: per-turn, per-visitor, and global limits
  • Distributed rate limiter for production (not in-memory only)
  • Session counter increments only after server confirms response
  • Non-new sessions skip daily counter increments
  • Origin/CSRF validation on the API endpoint
  • Input size limits (message count + message length)
  • Server-authoritative turn counting (don't trust the client)
  • Safe error handling (no SDK error leaks, no PII in logs)
  • Correct IP resolution for your hosting platform
  • BYOK fallback for rate-limited users
  • Semantic caching with TTL enforcement for first-turn questions
  • Cache hits use same stream protocol as live responses
  • For large docs/chatbots, virtual filesystem tools support ls/cat/grep-style exploration without per-user sandboxes
  • Docs filesystem is read-only, access-pruned, and reassembles full pages from ordered chunks
  • FAQ list view to reduce LLM calls
  • Observability/tracing on all LLM calls
  • Streaming responses for perceived speed
  • Streaming UI grows one assistant message instead of appending token rows
  • Markdown rendering in chat responses
  • Text-only modality restriction
  • Theme-aware colors that contrast with page background
  • Graceful degradation when optional services are down
  • Simple native scroll for short widgets, virtualization for long/dynamic histories
  • Composer stays usable in compact and expanded bottom-shelf states
  • Expanded bottom shelf does not hide latest messages or jump controls
  • Stable message IDs for row keys; no index keys in prependable histories
  • Older-history prepends preserve the user's visible message position
  • New messages follow only when the user is already near latest
  • "Jump to latest" affordance appears when the user is away from the end
  • Dynamic message heights remeasure without overlap, blank gaps, or scroll drift
  • Mobile keyboard open/close does not hide the composer or break latest pinning
  • System prompt with role, constraints, formatting, tools, and reference data
  • No API keys exposed to the frontend
  • Verified exact model ID against provider docs
  • Local build passes before every push

© swyxio, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

SKILL.md and 13 other files (assets) in public-qa-chatbot of swyxio/skills.

  • SKILL.md
  • MINTLIFY_VIRTUAL_FILESYSTEM.md
  • agents/openai.yaml
  • assets/chatbot-demo-voice-live.png
  • assets/vite-react-tanstack-chat-demo/.gitignore
  • assets/vite-react-tanstack-chat-demo/index.html
  • assets/vite-react-tanstack-chat-demo/package-lock.json
  • assets/vite-react-tanstack-chat-demo/package.json
  • assets/vite-react-tanstack-chat-demo/src/App.tsx
  • assets/vite-react-tanstack-chat-demo/src/main.tsx
  • assets/vite-react-tanstack-chat-demo/src/styles.css
  • assets/vite-react-tanstack-chat-demo/tsconfig.json
  • assets/vite-react-tanstack-chat-demo/tsconfig.node.json
  • assets/vite-react-tanstack-chat-demo/vite.config.ts

Open the folder on GitHubat commit 038ef34

Compare with similar skills

Public QA Chatbot next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Public QA Chatbot compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Public QA Chatbot this skillswyxio/skills176—~9.8kAutomated safety check: PassMIT
Better Auth Security Best PracticesEpicenterHQ/epicenter4.8k—~896Automated safety check: PassCustom licence
Tanstack Query Best PracticesDeckardGer/tanstack-agent-skills2221 repos~1.2kAutomated safety check: PassMIT
Trpc PatternsFranciscoMoretti/chat-js1.2k—~288Automated safety check: PassApache-2.0
Tanstack Integration Best PracticesDeckardGer/tanstack-agent-skills222—~644Automated safety check: PassMIT
API IntegrationHack23/cia239—~1.9kAutomated safety check: PassApache-2.0

Similar skills

  • Better Auth security hardening: rate limits, secrets, CSRF, trusted origins, cookies, sessions, OAuth tokens, and audit logging.

    4.8k GitHub stars~896 tokensUpdated 2 days ago
    Backend & APIsAuto-check passed
  • Tanstack Query Best Practices

    DeckardGer/tanstack-agent-skills

    TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management.

    222 GitHub starsUsed in 1 repo~1.2k tokens
    Backend & APIsAuto-check passed
  • Trpc Patterns

    FranciscoMoretti/chat-js

    Implement ChatJS tRPC routers, TanStack Query hooks, mutations, and cache invalidation.

    1.2k GitHub stars~288 tokensUpdated 2 days ago
    Backend & APIsAuto-check passed
  • Tanstack Integration Best Practices

    DeckardGer/tanstack-agent-skills

    Best practices for integrating TanStack Query with TanStack Router and TanStack Start.

    222 GitHub stars~644 tokensUpdated 1 mo ago
    Backend & APIsAuto-check passed
  • API Integration

    Hack23/cia

    External API integration patterns, retry logic, circuit breakers, caching, rate limiting for government data APIs

    239 GitHub stars~1.9k tokensUpdated yesterday
    Backend & APIsAuto-check passed
  • Frontmcp Config

    agentfront/frontmcp

    A skill your agent uses when configuring a FrontMCP server through frontmcp.config or the @FrontMcp options.

    146 GitHub stars~7k tokensUpdated yesterday
    Backend & APIsAuto-check passed

More from swyxio/skills

All 89 skills in this repo
  • Programmatic Agents

    swyxio/skills

    Run a selected coding-agent CLI programmatically, with latency, error, usage, cost, and trace logging.

    176 GitHub stars~2.2k tokensUpdated 6 days ago
    Auto-check passed
  • Design, implement, audit, or refresh protected username and handle namespaces for public products.

    176 GitHub stars~1.1k tokensUpdated 6 days ago
    Auto-check passed
  • New Mac Setup

    swyxio/skills

    Fully automated new Mac setup for fullstack web developers and AI engineers.

    176 GitHub stars~4.3k tokensUpdated 6 days ago
    Auto-check passed
  • Youtube API

    swyxio/skills

    Manage YouTube videos programmatically via the YouTube Data API v3 — upload video files, upload custom thumbnails, update video metadata (titles, descriptions, tags), and query video/channel info…

    176 GitHub stars~2.2k tokensUpdated 6 days ago
    Auto-check passed
  • Batch YouTube Studio upload workflow for videos sourced from Airtable, Google Drive, Loom, YouTube, or local files.

    176 GitHub stars~1.5k tokensUpdated 6 days ago
    Auto-check: warnings
  • Reconstruct and visually analyze paired agent, game, or policy trajectories to determine whether changed actions produced their intended effects.

    176 GitHub stars~1.8k tokensUpdated 6 days ago
    Auto-check passed

Works with

Categories

Questions about Public QA Chatbot

What does Public QA Chatbot do?

Best practices for building an unauthenticated public Q&A chatbot widget. Public QA Chatbot is an agent skill from swyxio/skills. Best practices for building an unauthenticated public Q&A chatbot widget.

When should I use Public QA Chatbot?

Public QA Chatbot fits situations like: tasks that involve Rate limiting; tasks that involve Security review; tasks that involve Caching.

How do I install Public QA Chatbot in Claude Code?

Run `npx skills add swyxio/skills --skill public-qa-chatbot -a claude-code`. Or copy the skill folder (public-qa-chatbot in swyxio/skills) into .claude/skills/public-qa-chatbot in your project. Claude Code loads it when a task matches its description.

How do I install Public QA Chatbot in Codex?

Run `npx skills add swyxio/skills --skill public-qa-chatbot -a codex`. Or copy the skill folder (public-qa-chatbot in swyxio/skills) into .agents/skills/public-qa-chatbot in your project. Codex loads it when a task matches its description.

Can I use Public QA Chatbot in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add swyxio/skills --skill public-qa-chatbot -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/public-qa-chatbot, .gemini/skills/public-qa-chatbot, .github/skills/public-qa-chatbot and .opencode/skills/public-qa-chatbot in your project.

What does Public QA Chatbot need to run?

Going by SKILL.md and its folder, Public QA Chatbot needs TypeScript for the scripts in its folder, the command-line tools its instructions call (npm and pnpm) and credentials named OPENAI_API_KEY, REDIS_TOKEN, VECTOR_TOKEN and BRAINTRUST_API_KEY. Our summary lists: Node.js; A credential in OPENAI_API_KEY; A credential in REDIS_TOKEN.

Does Public QA Chatbot access the network?

SKILL.md names 12 domains. As links in the text: tanstack.com, mintlify.com, github.com, ai.engineer, aistudio.google.com, platform.openai.com, agentskills.io, sdk.vercel.ai, upstash.com, braintrust.dev, langfuse.com and arcjet.com. This is read from the text; nothing was executed.

Is Public QA Chatbot safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Public QA Chatbot use?

Public QA Chatbot is published under the MIT licence (declared in SKILL.md). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Public QA Chatbot use?

About 9.8k tokens (SKILL.md is roughly 39k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Public QA Chatbot?

Skills that share tags, products or a category with Public QA Chatbot: Better Auth Security Best Practices (EpicenterHQ/epicenter, 4.8k stars), Tanstack Query Best Practices (DeckardGer/tanstack-agent-skills, 222 stars), Trpc Patterns (FranciscoMoretti/chat-js, 1.2k stars) and Tanstack Integration Best Practices (DeckardGer/tanstack-agent-skills, 222 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Public QA Chatbot?

swyxio (a GitHub user) maintains it in swyxio/skills, which has 176 GitHub stars. The repository holds 89 skills in this directory. The repository was last updated on October 5, 2026.

Source: swyxio/skills on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.