ai, @ai-sdk/openai, @ai-sdk/anthropic). There is no shared AI service. Each feature has a small factory that takes { apiKey, model } and is built in the router that needs it.
Two providers are in use, and the split is by feature, not by preference.
When
ANTHROPIC_WORKSPACE_ID is set it is sent as the anthropic-workspace-id header. That is needed when the key is organization wide and not scoped to a workspace.
Shared conventions
Every AI factory in the codebase follows the same shape. Follow it for new ones.- No key means disabled, not broken. With an empty key the factory returns
nullor an object withconfigured: false. The server still boots. The feature either falls back to a deterministic path or answers a clear error. - A test seam. Factories accept an optional
generatefunction with the same signature asgenerateObjectorgenerateText. Tests pass a fake and never call a provider. - Structured output. Features that need data use
generateObjectwith a Zod schema. The model’s output is parsed before any code reads it. - A timeout. Calls pass
abortSignal: AbortSignal.timeout(ms). - Never trust the model with numbers that matter. Totals are computed by code. Matching to library items is checked by code. Writes are executed by code after a human confirms.
- Failure is contained. A summarizer that fails returns
nulland the request succeeds without it. Only features whose whole purpose is the model call surface an error.
Meal analysis
Analyzes a meal photo, a text description or both, for the trainee app.
Flow in
analyzeMeal:
- The studio’s food catalog is sent in the system prompt as a numbered list. The model returns one entry per food with
name,grams,calories,protein,carbs,fatand alibraryIndex, plus a dishname, anoteand arejectionfield. rejectionofalcoholorcannabisthrowsBAD_REQUESTwithdetails.easterEgg.toItemsrounds values and validateslibraryIndexagainst the catalog size. An item with a valid index has sourcelibrary, otherwiseestimate.applyLibrarySearchruns a deterministic name search against the catalog for items the model did not match.- For items still marked
estimate,withWebValuesasks the lookup model for values per 100 g using OpenAI’sweb_searchtool, with a 15 second timeout. Found values replace the estimate and the source becomesonline. A lookup failure is reported throughonWebLookupErrorand the estimates are kept. toAnalysiscomputes the totals in code.
source: library, online or estimate.
Errors: no key gives INTERNAL with nutrition AI is not configured. No text and no image gives BAD_REQUEST. A provider failure gives INTERNAL with failed to analyze meal, logged with the model name.
pnpm --filter @perform/core-api smoke:nutrition runs scripts/smoke-nutrition-analyzer.ts against the real provider.
Form summaries and nutrition insight
createFormSummarizer turns a submitted form into at most 4 Hebrew bullets for the coach, each a concrete action or concern. Input is capped at 6,000 characters. It returns null when there is no key or there are no answers.
createNutritionInsightGenerator produces a short insight with a 12 second timeout.
Recipe generation and link reading
A coach can generate a recipe from a prompt or from a link. For a link the server fetches the page itself through
safe-fetch.ts. When a site blocks the server, recipe-link-reader.ts reads the recipe through the provider’s web search instead, with a 30 second timeout. A link that cannot be read at all throws BAD_REQUEST with details.reason of LINK_UNREADABLE, the host, and optionally status and cause.
safe-fetch
Every feature that fetches a coach supplied URL goes through this one hardened path:
httpandhttpsonly.- The host is resolved and must be a public IP. This is checked again on every redirect hop.
- Redirects are followed manually, at most 3 (
MAX_REDIRECTS). - One overall timeout of 12 seconds (
FETCH_TIMEOUT_MS). - HTML is stripped to readable text and cut to 14,000 characters (
MAX_SOURCE_CHARS).
fetch on one directly.
Program generation
createProgramGenerator is passed to the program templates service. The automation API builds the same service with the generator left undefined, so generation is not reachable from that lane.
Web assistant
A chat assistant for coaches inside the web app.
The tools run in process against Prisma. The caller is an authenticated web user, so their coach row is the authority and every tool is filtered through one client scope derived from it.
Writes never happen inside the model loop. A
propose_* tool stores a payload in Redis for 15 minutes (perform:assistant-proposal:) and the chat shows a confirm button. POST /confirm executes the stored payload. The system prompt tells the model it must never claim it made a change.
Other state in Redis: thread and history keys with a 48 hour TTL, and a daily cap of 300 messages per user (perform:assistant-cap:<userId>:<date>).
WhatsApp agent
The same idea over WhatsApp, for coaches.
One dedicated WhatsApp number serves every coach. A SmartSend flow posts each inbound message to the webhook. The flow:
- The webhook verifies the token and calls
handleInbound. The message is stored as pending in Redis and a job is added toagent-replywith an 8 second delay (DEBOUNCE_MS), so a burst of messages gets one reply. - The worker calls
replyToPending. The coach is resolved from the sender’s phone.AGENT_PHONE_COACHESpins a number to a coach row when one phone appears on several rows.AGENT_PILOT_PHONES, when set, restricts who is served. - The model runs with the MCP tool set. Each tool call is a loopback HTTP call to
/v1/automationwith a studio API key the service mints itself and never exposes. That gives the agent the same validation and readable errors the public API has. - The reply goes back into the same conversation through SmartSend, split into chunks of at most 3,500 characters.
stepCountIs(8)), 200 replies a day (DAILY_CAP), history of 24 messages kept for 48 hours.
The minted key is a normal studio API key with a fixed Hebrew name. A coach who deletes it in settings has switched the agent off for their studio. The service does not silently mint a new one. The coach has to send the reconnect command.
AGENT_SMARTSEND_API_KEY empty disables the whole agent.
Plan import
Turns a document (text, photos or PDFs of a nutrition or training plan) into a reviewable draft.analyze answers 202 and runs in process. Stages:
- OCR. One vision call per uploaded file, transcription only. The prompt forbids interpreting, summarizing or correcting.
- Structuring. When a structurer is configured it is the primary path. The model identifies what the document is and extracts only what is written. Every value is a string, and an empty string means “not written in the document”, which makes the no invention rule enforceable. Code, not the model, normalizes quantities, units and times.
- Matching. Deterministic matching of foods and exercises against the library. The model never touches matching.
null and the service falls back to the deterministic parsers. With no Anthropic key, text only imports still work end to end and file uploads answer a Hebrew error.
commit-items creates the approved new foods and exercises as studio rows in one transaction. A row saved to the library gets source ai. A row kept for this plan only gets ai-private and is hidden from every list by listedLibraryRows(). rollback-items deletes only studio owned rows that carry one of those two sources.
The request body limit for analyze is 48mb.
Forms AI
Creates a draft form from a link, a file or pasted text.
Sources in priority order:
- The first
http(s)URL in the text. A Google Forms page is parsed deterministically from its embedded data. Any other page is fetched withsafe-fetch, stripped to text and structured by the model. - Uploaded files, transcribed by the plan import OCR.
- Pasted text, parsed as is. If the deterministic parse finds fewer than 2 fields, the model structures it.
FormTemplate with status DRAFT and active: false and returns { formId, name, fieldCount, pageCount }.
Check-in insights
A hybrid engine. Every metric, threshold and flag is deterministic code, documented where it runs. The model writes exactly two things: the wording of the overall verdict and a suggested feedback message. Both have deterministic fallback templates, so the endpoint never fails and never changes a number because of the model.
Results are cached in Redis: insights and flags for 6 hours, the suggested message for 24 hours.
AutoFit import passes
The import worker formats imported recipe text and labels unknown form columns. Because it runs over a whole studio’s data, the recipe formatter has protections the interactive features do not need:
- A Redis cache keyed by content (
perform:autofit:recipe-fmt:v1:), kept 90 days, so a re-run does not pay again. - Concurrency of 4 and a 30 second timeout per call.
- A circuit breaker. After 5 consecutive failures it answers
nullfor 5 minutes without calling the model. An outage costs a minute of the import, not a timeout per recipe.
null and the import uses its non AI fallbacks.
Cost and abuse controls
Meal analysis has no per trainee cap beyond the IP rate limit.
Adding an AI feature
1
Pick the provider that the neighbouring feature uses
Coach facing agents and document understanding are on Anthropic. Trainee facing estimation and short summaries are on OpenAI. Reuse an existing model variable unless there is a reason for a new one.
2
Write a factory with a seam
createThing({ apiKey, model, generate? }) returning null or configured: false without a key.3
Define the output schema
Use
generateObject with a Zod schema. Make “not present” expressible, an empty string or null, so the model has somewhere to put it other than a guess.4
Keep decisions in code
Compute totals, match ids and apply thresholds in code. Put a human confirmation in front of any write.
5
Bound it
Add a timeout, cap the input size, and decide the fallback. If the feature loops over many items, add a cache and a breaker.
6
Wire it in the router
Read the key and model from
ctx.env in the router factory and pass the built object into the service.