Architecture¶
swapps-ai is a single Cloudflare Worker. Almost all logic lives in src/index.ts (routing,
validation, CORS, prompt building, model calls, and defensive response parsing). A small service
module, src/services/ai-search.ts, wraps the AI Search binding and formats RAG context.
src/
index.ts entry point, routing, validation, CORS, prompts, parsing
services/
ai-search.ts SWAPPS_AI_SEARCH wrapper + prompt context formatter
test/
index.test.ts vitest suite (Node, no Workers runtime)
wrangler.jsonc config + routes + bindings
Request flow¶
The Worker exports a default fetch handler that delegates to handleRequest. Routing is a plain
switch on url.pathname:
- Non-matching methods on a known path return
405with anAllowheader. - Unknown paths under
/api/ai/*return404. OPTIONSreturns204with CORS preflight headers; ifAccess-Control-Request-Methodis present and is notPOST, preflight returns405.
See the API reference for the per-endpoint contracts.
Calling Workers AI¶
All model calls go through a single helper, callModel(env, systemPrompt, userMessage, options):
const model = (env.AI_MODEL ?? DEFAULT_MODEL) as keyof AiModels;
const result = await env.AI.run(model, {
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
max_tokens: options.maxTokens ?? 512,
temperature: options.temperature ?? 0.2,
});
- The model id comes from the
AI_MODELvar, falling back to the hard-coded default@cf/meta/llama-3.2-3b-instruct. - Both
recommend-planandrefinecall the model withtemperature: 0andmaxTokens: 1024for deterministic, structured output. - The system prompt carries the instructions and schema; the user message carries the user's input
(and, for
recommend-plan, the structured-fields block and metadata).
Tolerant response extraction¶
Workers AI can return several response shapes depending on the model. extractText normalizes
them by checking, in order: a raw string, response, output_text, an output[] array of
content parts, and OpenAI-style choices[0].message.content / choices[0].text. If none match,
it logs ai_unexpected_shape and throws, which surfaces as a 502 ai_request_failed to the
caller.
RAG: retrieving context from AI Search¶
Only recommend-plan uses RAG. Before building the prompt it calls
getSwappsAiSearchContext(message, env), which delegates to searchSwappsAiSearch in
src/services/ai-search.ts:
const response = await env.SWAPPS_AI_SEARCH.search({
query: message.trim(),
ai_search_options: {
retrieval: { max_num_results: MAX_SNIPPETS }, // MAX_SNIPPETS = 5
},
});
For each returned chunk it builds a snippet { source, text, score }:
textis truncated to 800 chars (MAX_SNIPPET_LENGTH).sourceis resolved from the chunk's metadata in priority order:metadata.url→metadata.source→metadata.title→chunk.item.key→AI Search result <n>.
getSwappsAiSearchContext then concatenates the snippets into a single block
(Source 1: <source>\n<text> …) that is injected into the prompt as supporting evidence.
RAG is best-effort, never blocking
If SWAPPS_AI_SEARCH is unset, the message is empty, or search throws, the service logs
ai_search_query_failed and returns an empty list. The recommendation then runs on the
hard-coded Swapps context alone. The test suite confirms recommend-plan still returns 200
when AI Search throws.
The prompt and plan model¶
The recommendation prompt (buildPrompt) instructs the model to use two sources:
- Official Swapps context (
SWAPPS_CONTEXT, a hard-coded source-of-truth string) describing the five paths and the classification rules. - Retrieved AI Search context (the RAG block above), treated as supporting information. When
no context was found, the prompt states
No AI Search context was found for this request.
The model is asked to return a single JSON object with summary, recommendedPath, rationale,
alternatives, detectedNeeds, missingInformation, ctaLabel, ctaUrl, and confidence.
The model never writes plan descriptions
Plan info (name, description, bestFor, typicalIncludes) is server-injected from a
controlled table, PATH_PLAN_INFO, keyed by recommendedPath and language. After the model
picks a path, the Worker looks up PATH_PLAN_INFO[path][language] and attaches it, so the
model cannot invent plan copy. Switching locale localizes description, bestFor, and
typicalIncludes.
Defensive parsing and fallback¶
parseModelJsonResponse tries, in order:
- Parse the trimmed text directly.
- Strip a Markdown code fence (
```json … ```) and parse. - Extract the first balanced
{ … }object from surrounding prose and parse.
Each candidate runs through coerceRecommendation, which:
- Rejects the response unless
recommendedPathis one of the five allowed paths andconfidenceis one oflow/medium/high. - Injects the matching
planinfo. - Coerces
rationale(summary ≤ 400 chars; 2–6 signals, each ≤ 240 chars, placeholders stripped). - Coerces
alternatives(≤ 2 entries; drops duplicates of the primary path, unknown paths, empty/placeholderwhenItWouldFit). - Defaults
ctaLabel/ctaUrlwhen missing.
If every attempt fails, buildFallbackRecommendation(language) returns a Technical Review
recommendation with populated plan info, empty alternatives / signals, a generic
missing-information list, and confidence: "low".
The refine model contract¶
refine follows the same call/parse pattern with its own prompt (buildRefinePrompt) and parser
(parseRefineResponse). The prompt enforces an "absolute fidelity" rule (never invent facts), a
"completeness" rule (every concrete fact must appear), Q&A normalization, and a fields
classification against the /get-swapps/ enums. Parsing:
- Keeps only the allowed section keys, de-dupes by key, re-sorts to the canonical order
(
service,goal,industry,features,audience,style,constraints,context), and drops empty/placeholder items. - Classifies
fieldsagainst the mirrored client enums, returningnullfor any id not in the allowed list, and nulls aservicethat contradicts the choseninitialChoice(serviceMatchesChoice). - On unparseable output, falls back to
{ summary: <original message>, sections: [], suggestions: [], fields: <all nulls> }.
Locale handling (en / es)¶
Both POST endpoints accept an optional locale. normalizeLanguage lowercases it, splits on
-/_ (so es-CO → es), and resolves to en or es; anything else falls back to en.
recommend-plan: the resolved language drives the prompt's language instruction, the server-injectedplancopy, the fallback strings, and the echoedlanguagefield.refine: the model responds in the requested language forsummaryand item text, while sectionkeys, thelanguagecode, and thefieldsenum ids stay in English.
Input validation¶
Validation happens in parseInput (recommend) and parseRefineInput (refine) before any model
call. Common rules:
- The body must be a JSON object (not an array or primitive), and
Content-Typemust includeapplication/json. messageis required, must be a string, is trimmed, must be non-empty, and ≤ 2000 chars.- Optional strings are validated by
optionalString(trimmed; empty becomesundefined; over-length is rejected).
recommend-plan additionally validates optional metadata and the structured fields object via
parseIncomingFields (each string field ≤ 120 chars; features an array of strings, each
≤ 120 chars, capped at 30 items). The exact limits are listed in the
API reference.
CORS¶
corsHeaders always sets Vary: Origin, the allowed methods/headers, and a 24h max-age. The
Access-Control-Allow-Origin header is echoed only when the request Origin is in the
allow-list:
https://swapps.comhttps://www.swapps.comhttp://localhost:5173http://localhost:3000
Requests from other origins are still served, but no Allow-Origin header is returned (no
wildcard). All JSON responses also set Cache-Control: no-store.