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:

flowchart TD Req["fetch(request, env)"] --> Opt{"method === OPTIONS?"} Opt -->|yes| Pre["handlePreflight → 204 + CORS"] Opt -->|no| Path{"url.pathname"} Path -->|"/api/ai/recommend-plan"| RP["handleRecommendPlan"] Path -->|"/api/ai/refine"| RF["handleRefine"] Path -->|"/api/ai/search-test"| ST["handleSearchTest"] Path -->|other| NF["404 not_found"] RP --> Method1{"POST?"} RF --> Method2{"POST?"} ST --> Method3{"GET?"} Method1 -->|no| M405["405 + Allow header"] Method2 -->|no| M405 Method3 -->|no| M405
  • Non-matching methods on a known path return 405 with an Allow header.
  • Unknown paths under /api/ai/* return 404.
  • OPTIONS returns 204 with CORS preflight headers; if Access-Control-Request-Method is present and is not POST, preflight returns 405.

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_MODEL var, falling back to the hard-coded default @cf/meta/llama-3.2-3b-instruct.
  • Both recommend-plan and refine call the model with temperature: 0 and maxTokens: 1024 for 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.

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 }:

  • text is truncated to 800 chars (MAX_SNIPPET_LENGTH).
  • source is resolved from the chunk's metadata in priority order: metadata.urlmetadata.sourcemetadata.titlechunk.item.keyAI 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:

  1. Official Swapps context (SWAPPS_CONTEXT, a hard-coded source-of-truth string) describing the five paths and the classification rules.
  2. 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:

  1. Parse the trimmed text directly.
  2. Strip a Markdown code fence (```json … ```) and parse.
  3. Extract the first balanced { … } object from surrounding prose and parse.

Each candidate runs through coerceRecommendation, which:

  • Rejects the response unless recommendedPath is one of the five allowed paths and confidence is one of low / medium / high.
  • Injects the matching plan info.
  • 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/placeholder whenItWouldFit).
  • Defaults ctaLabel / ctaUrl when 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 fields against the mirrored client enums, returning null for any id not in the allowed list, and nulls a service that contradicts the chosen initialChoice (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-COes), 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-injected plan copy, the fallback strings, and the echoed language field.
  • refine: the model responds in the requested language for summary and item text, while section keys, the language code, and the fields enum 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-Type must include application/json.
  • message is required, must be a string, is trimmed, must be non-empty, and ≤ 2000 chars.
  • Optional strings are validated by optionalString (trimmed; empty becomes undefined; 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.com
  • https://www.swapps.com
  • http://localhost:5173
  • http://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.