This page walks through adding a new domain module to the coach web lane, then covers the shorter path for adding one endpoint to an existing module.

Before you start

Decide three things first:
  1. The caller. That decides the lane. See the table at the end of Route lanes.
  2. The tenant key. Every domain row belongs to a studio, directly through studioId or through a parent that has one.
  3. Who may call it. Any coach, only OWNER and HEAD_COACH, or coaches restricted to their assigned trainees. See Roles and permissions.

Add a new module

1

Change the data model in both schemas

The repo has two Prisma schemas and both must change.
  • packages/db/prisma/schema.prisma owns migrations. Add the model, then add a migration folder under packages/db/prisma/migrations/ with a migration.sql.
  • packages/database/prisma/schema.prisma generates the client the API compiles against. Mirror the same model there and add @@schema("public").
Then rebuild the generated client so core-api sees the new types:
See Database packages for why there are two.
pnpm db:migrate runs prisma migrate deploy against whatever DATABASE_URL or DIRECT_URL points at. Check which database that is before you run it.
2

Write the schema file

Create modules/<name>/<name>.schema.ts with one Zod schema per input: create<Entity>Body, update<Entity>Body, <entity>ListQuery, idParam. Export the inferred type beside each one. See Validation.
3

Write the repository

create<Name>Repository(prisma: PrismaClient) returns the data access functions. Every read filters by studioId. Use findFirst({ where: { id, studioId } }) for single rows.
4

Write the service

create<Name>Service(repo, ...deps). Take studioId as the first argument of every function. Throw AppError(ErrorCode.X, message) for rule violations. Before an update or delete by id, load the row through a studio scoped get so a foreign id answers NOT_FOUND.
5

Write the controller

create<Name>Controller(service). Parse req.query, req.params and req.body with the schemas. Read the studio with a local studioIdOf(req). Respond with ok, created or noContent from http/respond.ts.
6

Write the routes file

<name>Router(ctx: AppContext): Router. Build repository, service and controller from the context, then declare the routes. Put literal paths before /:id. Add requireRole(...) per route where needed.
7

Mount it

In modules/index.ts, import the router and mount it with the lane’s guard array:
Place the line with the other /web mounts, above router.use(replay).
8

Test it

Add apps/core-api/tests/<name>.test.ts. Construct the service with a fake repository (a plain object with the same functions) and assert each branch that throws. See Testing.
9

Run the gates

pnpm run ci at the repo root runs format check, lint, the em dash check, typecheck, tests and build for the whole workspace.

Add an endpoint to an existing module

  1. Add the input schema to <name>.schema.ts.
  2. Add the repository function if new data access is needed.
  3. Add the service function with the rule.
  4. Add the controller handler and return it from the factory.
  5. Add the route line. Check its position relative to /:id routes.
If the endpoint needs a dependency the service does not have yet, add it as a constructor argument and pass it in every place the service is constructed. Search for create<Name>Service( first. Services such as createFormsService and createClientSubscriptionsService are built in several routers and in workers.

Add a trainee endpoint

Trainee routes live in modules/trainee/ or in a trainee<Thing>Router exported from the owning module.
  • Use authenticateTrainee(ctx.env.BETTER_AUTH_SECRET, ctx.prisma).
  • Add requireTraineeAppAccess(repository.accessState) unless the route must work for a frozen trainee.
  • Read the principal from (req as TraineeRequest).trainee. Scope queries by clientId and studioId from it.
  • Remember that a preview token is refused on any method other than GET. A new read that has side effects (a “seen” stamp, an activity touch) must check principal.preview itself, as me does.
  • Use the trainee’s time zone for day boundaries. See Time zones and day keys.
Older app builds stay installed for a long time. Do not remove or narrow a field an existing build sends or reads. The health endpoints keep HEART_RATE in an enum and return explicit null heart rate fields for that reason.

Add an automation or MCP capability

Add the endpoint to modules/automation-api/. Use sendOk(res, data, message) and write error messages as full sentences that say what to fix, because Make shows them to a coach and an AI model reads them to recover. To expose it as an MCP tool, add a ToolDefinition to packages/mcp-tools/src/tools.ts. Both the stdio binary and the hosted /mcp endpoint pick it up. Read the scope rules on MCP first.

Add a background job

See “Adding a queue” on Queues and jobs.

Checklist

  • Both Prisma schemas changed, migration written, @repo/database rebuilt.
  • Every query scoped by studioId from the principal.
  • Inputs parsed with Zod. No field read from req.body unparsed.
  • Role guard on writes that only admins may do.
  • Plan limits respected if the endpoint creates a trainee or a team member (billingFor(ctx).limits).
  • Notifications go through createTraineeNotifier, never straight to Expo.
  • No secrets or tokens in logs.
  • Lint, typecheck and tests green.