All AI calls go through the Vercel AI SDK (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

  1. The studio’s food catalog is sent in the prompt as a numbered index. The model returns libraryIndex for each item it recognises from the catalog, or null.
  2. apps/core-api/src/ai/nutrition-library-search.ts backs that up deterministically: searchCatalogIndex matches normalized labels, so an item the model failed to index can still land on a library food.
  3. 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.
  4. Items that match nothing go to createNutritionWebLookup (nutrition-web-lookup.ts): one generateText call with OpenAI’s web_search tool, a 15 second timeout (LOOKUP_TIMEOUT_MS), returning per-100 values that are parsed by parseNutritionLookup.
When the studio has portions switched on, trainee/analysis-mbp.ts converts the result into portion counts with the studio’s anchors.

Rejections

The model is told to set rejection 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:
  1. fetchUrlBody in ai/safe-fetch.ts downloads 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.
  2. 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 the web_search tool, with a 30 second timeout.
  3. 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.
  4. If both fail, the error is BAD_REQUEST with details.reason set to LINK_UNREADABLE and the host. linkFailureDetails(err) extracts it so the UI can tell the coach to paste the text.
Other errors: 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.ts turns a submitted form into at most 4 bullets (MAX_BULLETS). Wired in forms.wiring.ts and inbox.routes.ts.
  • ai/nutrition-insight.ts produces one sentence (insightSchema is { sentence }) with a 12 second timeout. Wired in clients.routes.ts.
Both are optional decoration. With no key the feature is simply absent.

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/analyze answers 202 at once and runs the job in-process with setImmediate. There is no BullMQ job.
  • Progress is written to one Redis key, perform:plan-import:<jobId>, with a one hour expiry. Stages are ocr, parse, match, review.
  • GET /v1/web/plan-import/analyze/:jobId returns 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: generateObject with 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-items creates approved new foods and exercises as studio-scoped rows in one transaction, with source set to ai when saved to the library or ai-private for “this plan only”. POST /rollback-items deletes only studio-owned rows carrying one of those two sources.
Imported plans carry 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:
  1. The first http or https URL 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.
  2. Uploaded files, transcribed through the plan-import OCR.
  3. Pasted text, parsed as is (fai-parser.ts). If that finds fewer than 2 fields, the model structures it.
The structurer uses 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.
  • generateText with 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 on OPENAI_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. guardFormattedRecipe rejects output that adds lines or numbers not present in the source.
  • Form field labelling (autofit-import/form-labeler.ts).
See AutoFit import.

Conventions to follow

  • Take the key and model from the factory config. Do not read process.env in a feature.
  • Use generateObject with a Zod schema when you need structure. Do not parse free text.
  • Give every call an abortSignal timeout.
  • Keep a generate test 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.