API reference¶
Base path: /api/ai/ on the swapps.com zone (swapps.com/api/ai/* and
www.swapps.com/api/ai/*). All POST bodies must be JSON with
Content-Type: application/json. All responses are JSON with
Cache-Control: no-store.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/ai/recommend-plan |
Recommend one Swapps path with rationale and alternatives. |
POST |
/api/ai/refine |
Refine free text into a categorized breakdown + field classification. |
GET |
/api/ai/search-test |
Direct AI Search probe. |
Method / path rules¶
POSTis required onrecommend-planandrefine;GETonsearch-test.- A wrong method on a known path returns
405with anAllowheader (POST, OPTIONSorGET, OPTIONS). - Unknown paths under
/api/ai/*return404. OPTIONSreturns204with CORS preflight headers. IfAccess-Control-Request-Methodis present and is notPOST, preflight returns405.
Error shape¶
All errors use a single envelope:
{ "error": { "code": "invalid_request", "message": "message is required and must be a string." } }
| Code | HTTP | When |
|---|---|---|
invalid_request |
400 | Bad Content-Type, malformed JSON, or a validation failure. |
method_not_allowed |
405 | Wrong method on a known path. |
not_found |
404 | Unknown path under /api/ai/*. |
ai_request_failed |
502 | The Workers AI call threw or returned an unusable shape. |
POST /api/ai/recommend-plan¶
Recommends exactly one Swapps path (Build / Launch / Scale / Custom / Technical Review), injects a controlled per-path plan info block, and explains the choice with concrete signals.
Request¶
{
"message": "I need a client portal with login, payments and an admin dashboard",
"locale": "en",
"source": "homepage-hero",
"pageUrl": "https://swapps.com/",
"fields": {
"initialChoice": "create",
"service": "build-application",
"goal": "Internal operations",
"industry": "finance",
"audience": "b2b",
"style": "modernMinimalist",
"features": ["Auth", "Payments", "Admin dashboard"]
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
message |
string | yes | 1–2000 chars after trim. |
locale |
string | no | en (default) or es; resolved via locale normalization (es-CO → es). Anything else falls back to en. ≤ 16 chars. |
source |
string | no | Free-text metadata. ≤ 64 chars. |
pageUrl |
string | no | Free-text metadata. ≤ 512 chars. |
fields |
object | no | Structured input; every sub-field is independently optional. Treated as confirmed facts by the model. |
fields sub-fields (all optional): initialChoice, service, goal, industry, audience,
style are strings (each ≤ 120 chars); features is an array of strings (each ≤ 120 chars, max
30 items). Present fields are passed to the model as a "treat as confirmed" block.
Response (200)¶
{
"summary": "Client portal with login, payments, and an admin dashboard.",
"recommendedPath": "Scale",
"plan": {
"name": "Scale",
"description": "Engineering depth for products with integrations, complex workflows, operational tooling…",
"bestFor": "Products requiring third-party integrations, complex business workflows, dashboards, automation…",
"typicalIncludes": [
"Multiple third-party integrations",
"Complex workflows and business rules",
"Operational dashboards and reporting",
"Automation and background jobs",
"Performance, observability, and scaling"
]
},
"rationale": {
"summary": "Scale fits because the request combines authentication, payments, an admin dashboard, and likely integrations.",
"signals": [
"User mentioned authentication ('login')",
"User mentioned payments",
"User mentioned an admin dashboard"
]
},
"alternatives": [
{ "path": "Launch", "whenItWouldFit": "If integrations and dashboards were dropped from scope." },
{ "path": "Custom", "whenItWouldFit": "If the program required enterprise compliance or a dedicated discovery phase." }
],
"detectedNeeds": ["Authentication", "Payments", "Admin dashboard"],
"missingInformation": ["Payment provider", "Timeline", "Audience size"],
"ctaLabel": "Request a Technical Review",
"ctaUrl": "https://swapps.com/get-swapps/",
"confidence": "medium",
"language": "en"
}
| Field | Type | Notes |
|---|---|---|
summary |
string | One short sentence restating the user's request. |
recommendedPath |
enum | One of Build / Launch / Scale / Custom / Technical Review. |
plan |
object | Server-injected from a controlled table keyed by path + language. { name, description, bestFor, typicalIncludes[] }. The model never writes this. |
rationale.summary |
string | One short paragraph (≤ 400 chars) explaining the choice for this user. |
rationale.signals |
string[] | 2–6 evidence items (each ≤ 240 chars), each traceable to the user's words. Placeholders are stripped server-side. |
alternatives |
array | Up to 2 entries { path, whenItWouldFit }. Duplicates of the primary path, unknown paths, and placeholder text are dropped server-side. |
detectedNeeds |
string[] | Short noun phrases the user expressed. |
missingInformation |
string[] | Short noun phrases of useful context that is absent. |
ctaLabel / ctaUrl |
string | CTA copy; defaults to "Request a Technical Review" / https://swapps.com/get-swapps/. |
confidence |
enum | high (explicit matching features), medium, or low (Technical Review / weak signals). |
language |
enum | Echoes the resolved locale (en or es). |
Fallback behavior
When the model output is unparseable (after trying raw, fenced, and embedded JSON), the
endpoint returns a Technical Review recommendation in the requested language with populated
plan info, empty alternatives and rationale.signals, a generic missingInformation
list, and confidence: "low" — still HTTP 200.
Errors¶
400 invalid_request for a non-object body, missing/empty/oversize message, malformed JSON,
non-application/json Content-Type, or any field exceeding its limit. 502 ai_request_failed
when the Workers AI call throws.
POST /api/ai/refine¶
Refines a user's free-text project description into a structured, categorized breakdown aligned
with the /get-swapps/ wizard. Intended for an "Improve with AI" affordance next to the
custom-text textarea in swapps-client. Output is itemized — no long paragraphs.
Request¶
{ "message": "need site, people sign up pay dashboard, company sells courses", "locale": "en" }
| Field | Type | Required | Notes |
|---|---|---|---|
message |
string | yes | 1–2000 chars after trim. |
locale |
string | no | en (default) or es; es-CO → es; otherwise falls back to en. ≤ 16 chars. |
Response (200)¶
{
"summary": "Website for selling online courses with sign-up, payments, and a dashboard.",
"sections": [
{ "key": "service", "items": ["Website"] },
{ "key": "features", "items": ["User sign-up", "Payments", "Dashboard"] },
{ "key": "context", "items": ["Company sells courses online"] }
],
"suggestions": [
"What kind of courses?",
"Preferred payment provider?",
"Do you have a budget range?"
],
"language": "en",
"fields": {
"initialChoice": "create",
"service": "build-website",
"industry": "education",
"audience": null,
"style": null
}
}
| Field | Type | Notes |
|---|---|---|
summary |
string | One short sentence describing the request. Truncated to 320 chars server-side. Only facts from the user. |
sections |
array | Ordered { key, items }. A section is present only when the user touched that dimension; empty sections are dropped. |
suggestions |
string[] | Up to 5 short hints (each ≤ 160 chars) about missing information. Empty when nothing is missing. |
language |
enum | Echoes the resolved locale (en or es). |
fields |
object | Classification into the /get-swapps/ enums. Every key is present; null when the text doesn't clearly indicate a value. |
Allowed section keys (de-duped, kept in this canonical display order; unknown keys dropped):
service, goal, industry, features, audience, style, constraints, context. Each
section's items are short noun phrases / fragments, truncated to 280 chars each and capped at 10
items per section; placeholder items (None mentioned, N/A, TBD, —, Spanish variants, etc.)
are stripped.
fields ids are restricted to the mirrored client enums; any value not in the allowed list
becomes null:
initialChoice:help|create|adviceservice:development-support,infrastructure-scale,seo-services,maintenance-plans,workflow-automation,build-website,build-application,strategic-consultation,technical-advisory,dedicated-teamindustry:technology,healthcare,finance,education,retail,manufacturing,realEstate,hospitality,nonprofit,otheraudience:b2b,b2c,youngAudience,specializedProfessionals,generalAudiencestyle:modernMinimalist,boldColorful,elegantSophisticated,funCreative,professionalCorporate
Service / choice safety net
A service id that contradicts the chosen initialChoice (e.g. help with build-website)
is dropped to null server-side.
Fallback behavior
On unparseable model output, the endpoint returns
{ summary: <original message>, sections: [], suggestions: [], fields: <all nulls> } so the
caller never sees a broken state.
Errors¶
400 invalid_request for a non-object body, missing/empty/oversize message, malformed JSON, or
non-application/json Content-Type. 502 ai_request_failed when the Workers AI call throws.
GET /api/ai/search-test¶
Direct AI Search probe — returns the top chunks indexed in swapps-ai-search. Useful to confirm
the index is populated.
Request¶
Query parameter q is required (trimmed, truncated to 500 chars):
curl "https://swapps.com/api/ai/search-test?q=plans"
Response (200)¶
{
"query": "plans",
"count": 1,
"results": [
{ "source": "https://swapps.com/plans", "text": "…", "score": 0.91 }
]
}
| Field | Type | Notes |
|---|---|---|
query |
string | The (trimmed) query that was searched. |
count |
number | Number of results returned (0 when the index is empty). |
results |
array | { source, text, score? } per chunk. text truncated to 800 chars; source resolved from chunk metadata (url → source → title → item.key → fallback). |
An empty index returns count: 0 and does not fail the request.
Errors¶
400 invalid_request when q is missing or empty.
Limits¶
| Field | Limit |
|---|---|
message |
1–2000 chars (after trim) |
source |
≤ 64 chars |
locale |
≤ 16 chars |
pageUrl |
≤ 512 chars |
fields.* strings |
≤ 120 chars each |
fields.features |
≤ 30 items, each ≤ 120 chars |
search-test q |
truncated to 500 chars |
CORS¶
Access-Control-Allow-Origin is echoed only for these origins; others are served without the
header (no wildcard):
https://swapps.comhttps://www.swapps.comhttp://localhost:5173http://localhost:3000
Calling from the frontend¶
Because the Worker is on the same zone, call the relative path with fetch — no tokens in the
browser:
export async function recommendSwappsPlan(message: string) {
const r = await fetch("/api/ai/recommend-plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!r.ok) throw new Error("Could not get AI recommendation");
return r.json();
}