ai), with @ai-sdk/openai or @ai-sdk/anthropic as the provider. There is no Google or Gemini code path. Each feature is built by a factory that takes its key and model from the env config, and most return a fallback or a clear error when no key is configured.
Summary
The model defaults are declared next to each variable in
apps/core-api/src/config.ts. Change a model by setting the env var, not by editing a call site.
Environment variables
Meal analysis
apps/core-api/src/ai/nutrition-analyzer.ts, wired in trainee.routes.ts.
The trainee sends a description, a photo, or both. With neither the call fails with BAD_REQUEST, “provide a description or an image”.
Output contract
Library first, then the web
- The studio’s food catalog is sent in the prompt as a numbered index. The model returns
libraryIndexfor each item it recognises from the catalog, ornull. apps/core-api/src/ai/nutrition-library-search.tsbacks that up deterministically:searchCatalogIndexmatches normalized labels, so an item the model failed to index can still land on a library food.- Items that match the library take their macros from the library row. A matched item’s quantity is still the model’s estimate in grams.
- Items that match nothing go to
createNutritionWebLookup(nutrition-web-lookup.ts): onegenerateTextcall with OpenAI’sweb_searchtool, a 15 second timeout (LOOKUP_TIMEOUT_MS), returning per-100 values that are parsed byparseNutritionLookup.
trainee/analysis-mbp.ts converts the result into portion counts with the studio’s anchors.
Rejections
The model is told to setrejection when the main subject is alcohol or cannabis. Those come back as BAD_REQUEST with “meal rejected: alcohol” or “meal rejected: cannabis” and a reason in the error details, so the app can show a specific message.
Errors
Recipe generation
apps/core-api/src/ai/recipe-generator.ts, called by POST /v1/web/content/generate-recipe.
Input is recipe text or a link. With a link:
fetchUrlBodyinai/safe-fetch.tsdownloads the page under hostile-input rules: public hosts only (isPublicIp), at most 3 redirects, a 12 second timeout, and the HTML stripped to at most 14000 characters of text.- If the site refuses the server (some sites answer 403 to data-centre addresses),
createRecipeLinkReader(recipe-link-reader.ts) asks OpenAI to read the page with theweb_searchtool, with a 30 second timeout. - The reader’s answer is accepted only when its cited sources include the requested page (
citesPage). This guard stops the model from inventing a recipe for a URL it could not open. - If both fail, the error is
BAD_REQUESTwithdetails.reasonset toLINK_UNREADABLEand the host.linkFailureDetails(err)extracts it so the UI can tell the coach to paste the text.
INTERNAL “recipe AI is not configured”, BAD_REQUEST “provide recipe text or a link”, INTERNAL “failed to generate recipe”, BAD_REQUEST “could not extract a recipe title”.
Program generation
apps/core-api/src/ai/program-generator.ts, called by POST /v1/web/program-templates/generate-ai. It takes a free-text description and returns either a training plan or a nutrition plan through generateObject, using two output schemas (trainingOutputSchema, nutritionOutputSchema). Like any other plan content, what the coach then saves goes through normalizeTrainingContent or normalizeNutritionContent in the program templates service.
Errors: INTERNAL “program AI is not configured”, BAD_REQUEST “provide a program description”, INTERNAL “failed to generate training program” or “failed to generate nutrition program”.
Form summary and nutrition insight
ai/form-summarizer.tsturns a submitted form into at most 4 bullets (MAX_BULLETS). Wired informs.wiring.tsandinbox.routes.ts.ai/nutrition-insight.tsproduces one sentence (insightSchemais{ sentence }) with a 12 second timeout. Wired inclients.routes.ts.
Plan import
apps/core-api/src/modules/plan-import. A coach uploads photos, PDFs or pasted text of an existing plan and gets a draft plan in the builder.
How it runs:
POST /v1/web/plan-import/analyzeanswers 202 at once and runs the job in-process withsetImmediate. There is no BullMQ job.- Progress is written to one Redis key,
perform:plan-import:<jobId>, with a one hour expiry. Stages areocr,parse,match,review. GET /v1/web/plan-import/analyze/:jobIdreturns the state. The stored state carries the owner’s studio and user. Any mismatch answers 404. A job id is not a capability.- OCR (
ocr.ts) is one Anthropic vision or document call per file, transcription only. - Structuring (
llm-structurer.ts) is the primary path whenever a key is configured:generateObjectwith up to 16000 output tokens. The model extracts only what is written. Code, not the model, normalizes quantities, units and times. Any failure returns null and the deterministic parsers (nutrition-parser.ts,training-parser.ts) take over. - Matching foods and exercises to the library (
matcher.ts) is deterministic. The model never touches it. POST /commit-itemscreates approved new foods and exercises as studio-scoped rows in one transaction, withsourceset toaiwhen saved to the library orai-privatefor “this plan only”.POST /rollback-itemsdeletes only studio-owned rows carrying one of those two sources.
meta.imported: true, which changes how the plan normalizers treat empty values.
Form import
apps/core-api/src/modules/forms-ai. Same job pattern: 202, in-process, Redis key perform:forms-ai:<jobId> with a one hour expiry, owner check on read. Same limits as plan import.
Sources, in priority order:
- The first
httporhttpsURL in the text. Google Forms pages are parsed deterministically from the page’s embedded data (google-forms.ts). Any other page is stripped to text and structured by the model. - Uploaded files, transcribed through the plan-import OCR.
- Pasted text, parsed as is (
fai-parser.ts). If that finds fewer than 2 fields, the model structures it.
generateObject with up to 8000 output tokens. The job creates the FormTemplate itself as a draft (status: DRAFT, active: false) and returns formId, name, fieldCount and pageCount.
Coach assistant in the web app
apps/core-api/src/modules/assistant, routes POST /chat, POST /confirm, GET /thread, DELETE /thread under /v1/web/assistant.
generateTextwith tools, at most 8 steps and 1600 output tokens.- Tools run in-process against Prisma, filtered through one client scope derived from the calling coach’s row and permissions. No studio key is involved.
- Writes never happen inside the model loop. The
propose_*tools park a payload in Redis for 15 minutes (PROPOSAL_TTL_SECONDS), and the coach executes it with an explicit confirm call. - History: the last 24 entries for 48 hours. Daily cap: 300 messages per user (
DAILY_CAP).
WhatsApp assistant
Covered in WhatsApp webhooks and the agent. It differs from the web assistant in one important way: its tools are the MCP tools, executed through a loopback call to/v1/automation with a studio API key.
AutoFit import passes
The import worker builds three OpenAI helpers, all onOPENAI_IMPORT_MODEL with OPENAI_NUTRITION_MODEL as the fallback:
- Exercise matching (
autofit-import/exercise-matching.ts). A deterministic pass narrows each AutoFit exercise to a few library candidates, then the model picks in batches of 40. A confidence of at least 0.9 (AUTO_LINK_CONFIDENCE) links automatically. Anything lower waits for the coach on the review screen. A model outage never blocks the import. - Recipe formatting (
ai/recipe-import-formatter.ts). A 30 second timeout, 2000 output tokens, at most 8000 source characters, and a circuit breaker that opens for 5 minutes after consecutive failures, so an outage costs the import a minute and not 30 seconds per recipe.guardFormattedReciperejects output that adds lines or numbers not present in the source. - Form field labelling (
autofit-import/form-labeler.ts).
Conventions to follow
- Take the key and model from the factory config. Do not read
process.envin a feature. - Use
generateObjectwith a Zod schema when you need structure. Do not parse free text. - Give every call an
abortSignaltimeout. - Keep a
generatetest seam on the factory config, as the existing factories do, so tests never call a provider. - Decide what happens with no key: a fallback, or a clear
INTERNAL“not configured” error. - Never let the model do something code can do deterministically. Matching, unit normalization and macro arithmetic are all code in the existing features.
- Fetch any user-supplied URL through
ai/safe-fetch.ts.