PrairieLearn is an educational learning platform with a focus on automated assessments.
This is a monorepo that contains both applications (in apps/*) and libraries (in packages/*).
Frontend: TypeScript / React / Bootstrap / Tanstack Backend: TypeScript / Express / Python / PostgreSQL
-
apps/prairielearn: The main PrairieLearn web application. Key files:apps/prairielearn/src/server.ts: Entry point for the PrairieLearn web application. Initializes the Express server and maps URLs to pages.apps/prairielearn/src/pages/: Individual pages of the PrairieLearn application. You would add a new page here.
-
apps/grader-host: The application that runs external grading jobs. -
apps/workspace-host: The application that runs workspace containers.
Libraries live in packages/. If you update a public package (one without "private": true in its package.json), you MUST add a changeset. Create a markdown file in .changeset/ with a name like fix-my-bug.md containing:
---
'@prairielearn/package-name': patch
---
Description of the changeUse patch for bug fixes, minor for new features, and major for breaking changes.
Frequently used packages:
@prairielearn/ui: UI components for the PrairieLearn web application.
- NEVER amend commits or force push unless specifically requested.
- NEVER rebase unless specifically requested, always use merge commits.
- ALWAYS create pull requests as drafts unless specifically requested.
- When creating pull requests, follow the PR template in
.github/PULL_REQUEST_TEMPLATE.md. - In PR descriptions, keep the Testing section high signal. Do not list routine lint/typecheck/test commands just because they were run locally, and do not mention that CI will run. Mention only manual verification, docs rendering, screenshots, special test coverage, or unusual validation that helps reviewers understand the change.
- In Claude Code remote sessions, if the target branch is not
master, commit and push directly to the parent/target branch instead of creating a separate feature branch.
When working on a task, you should typecheck / lint / format individual files as you go. When you are done, you should typecheck / lint / format all changed files.
Run make format-changed from the root directory to format all files changed on the current branch compared to the default branch, including committed, staged, unstaged, and untracked changes.
Typechecking:
- Individual files:
./scripts/typecheck-file.sh path/to/file.ts [path/to/file2.ts] ... - All files:
make build. You will need to do this after making changes to a package.
Linting:
- Individual files:
yarn eslint --fix path/to/file.ts. Prefer using a skill / LSP / MCP for this to improve performance. - All files:
make lint-js - Check for dead code with
make lint-dependencies.
Formatting:
- Individual files:
yarn prettier --write path/to/file.ts - All files:
make format-js
Typechecking:
- Individual files:
yarn pyright path/to/file.py. Prefer using a skill / LSP / MCP for this to improve performance. - All files:
make typecheck-python
Linting:
- Individual files:
uv run ruff check --fix path/to/file.py - All files:
make lint-python
Formatting:
- Individual files:
uv run ruff format path/to/file.py - All files:
make format-python
SQL, shell, markdown, and JSON files should also be formatted with yarn prettier --write path/to/file.{sql,sh,md,json}.
Reference the Makefile for commands to format/lint/typecheck other tools / languages.
All applications share a single Postgres database. See database/ for descriptions of the database tables and enums. All tables have corresponding Zod types in apps/prairielearn/src/lib/db-types.ts.
Migrations are stored in apps/prairielearn/src/migrations. When working with migrations, ALWAYS refer to the migration README.md for details on how to create, run, and sequence migrations. Migrations are often a multi-step process that should be broken into multiple PRs.
If a migration was created on the current feature branch (i.e., it has not been merged to master), modify it directly instead of creating a new migration.
If you make a change to the database, make sure to update the database schema description in database/ and the Zod types/table list in apps/prairielearn/src/lib/db-types.ts.
Dropping a sproc (stored procedure) only requires removing the file from apps/prairielearn/src/sprocs and updating apps/prairielearn/src/sprocs/index.ts. Do not author a migration that uses DROP FUNCTION.
Always prefer existing model functions over one-off raw SQL queries. Check apps/prairielearn/src/models/ for existing functions before writing any database queries. Model functions provide type safety, consistent patterns, and proper abstractions. Only write raw queries when no suitable model function exists.
When inserting audit events (insertAuditEvent), always do so inside the same transaction as the action being audited. Use runInTransactionAsync to wrap the original database mutation and its corresponding audit log insertion together. This ensures that if either the action or the audit event fails, both are rolled back.
When inserting audit events (insertAuditEvent), always do so inside the same transaction as the action being audited. Use runInTransactionAsync to wrap the original database mutation and its corresponding audit log insertion together. This ensures that if either the action or the audit event fails, both are rolled back.
Course content repositories use JSON files like infoCourse.json, infoCourseInstance.json, and infoAssessment.json to configure different parts of the course. The schemas for these files are stored as Zod schemas in schemas/. If you make a change to a schema file in schemas/, make sure to update the JSON schema with make update-jsonschema.
- Use
to_jsonb(table.*)if you need to select all columns from a table as JSON. This is preferred over explicitjsonb_build_objectcalls because it automatically includes all columns and stays in sync with schema changes.
When working with assessment "groups" / "teams", see the groups-and-teams skill.
- Use
to_jsonb(table.*)if you need to select all columns from a table as JSON. This is preferred over explicitjsonb_build_objectcalls because it automatically includes all columns and stays in sync with schema changes. - When writing SQL, get table and column names from
database/tables/(the source of truth) or from nearby existing queries in the same feature area. Do NOT rely on names found in old migrations, as tables and columns may have been renamed since those migrations were written. - Never inline SQL strings in TypeScript code. Place SQL queries in a
.sqlfile alongside the TypeScript file using-- BLOCK query_namedelimiters, load them withsqldb.loadSqlEquiv(import.meta.url), and reference them assql.query_name.
- Use
tRPC + @trpc/tanstack-react-queryfor new client/server communication. When interacting with existing REST APIs, use@tanstack/react-query. See thetrpcskill for conventions on authorization scopes, file structure, and client-side patterns. - Use
react-hook-formfor form handling. - Prefer
extractPageContext(res.locals, ...)over accessingres.localsproperties directly in route handlers. This provides better type safety and ensures consistent access patterns. - Use
nuqsfor URL query state in hydrated components. UseNuqsAdapterfrom@prairielearn/uiand pass the search string from the router. Seepages/home/for an example.
- Information about the current user, course instance, course, etc. is stored in
res.localsin route handlers. Types forres.localsare defined inapps/prairielearn/src/lib/res-locals.ts. - NEVER use
as anycasts in TypeScript code to avoid type errors. - Don't add extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths).
- Don't add extra comments that a human wouldn't add or that are inconsistent with the rest of the file. Comments should explain why, not what — if a comment just restates the code, remove it.
- Always check for existing model functions in
apps/prairielearn/src/models/or lib functions before writing one-off database queries. - Express request handlers must always either send a response (either by calling
res.send/etc. or throwing an error) or explicitly pass control by callingnext(...). - DO NOT re-export functions or types from other modules for convenience or backward compatibility within applications (e.g.
export { bar } from 'foo'inapps/*). When moving a function to a new module, update all callers to import from the new location directly. Package-level barrel exports inpackages/*/src/index.tsare expected and should be used to provide a clean public API. - When importing library code, prefer top-level imports instead of using dynamic
import()statements inside functions. Notable exceptions are oureecode, and module registration patterns. - When formatting dates and intervals, use the functions from
@prairielearn/formatterto ensure consistent formatting across the application. The timezone should be retrieved from the course instance, the course, or the institution, in this order of preference, using the values fromres.localswhere available.
- Look for existing shared UI components in
apps/prairielearn/src/components/or@prairielearn/uibefore building new ones. When the same UI pattern appears across multiple pages, extract it into a shared component rather than duplicating code. - For basic UI elements that have a dedicated Bootstrap component, use
react-bootstrapcomponents. For more complex / interactive UI elements, usereact-aria. - Titles and buttons should use sentence case ("Save course", "Discard these changes").
- Form inputs with validation errors should include
aria-invalidandaria-errormessageattributes pointing to the error message element'sid. - Prefer using Bootstrap Icons for icons in new code.
Integration and unit tests are written with Vitest. End-to-end tests are written with Playwright. Unit tests are located next to the code they test in files with a .test.ts suffix. Integration tests are located in dedicated tests directories, e.g. apps/prairielearn/src/tests. End-to-end tests are located in apps/prairielearn/src/tests/e2e.
Individual tests:
- For integration and unit tests, use
yarn test path/to/file.test.tsfrom the root directory. - For end-to-end tests, use
yarn test:e2e path/to/integration.spec.tsfrom the root directory.
Avoid running the entire test suite unless necessary, as it can be time-consuming. However, if you must:
- To run all TypeScript tests, use
yarn testfrom the root directory
Tests expect Postgres, Redis, and an S3-compatible store to be running, and usually they already are. If you suspect that they're not, run make start-support from the root directory.
To test UI code looks correct, you should try to connect to the development server and screenshot the page with playwright. The dev server runs on the port specified by the CONDUCTOR_PORT environment variable (if set) or 3000. If you can't determine the port, ask the user.
When writing tests:
- Don't add assertion messages unless they provide information that isn't obvious from reading the assertion itself (e.g.,
assert.isNull(linkRecord)is clear without a message). - Don't use defensive checks in tests -- tests should fail fast if unexpected data exists.
- In e2e tests, don't use CSS class selectors (e.g.
page.locator('.my-class')). Prefer Playwright's recommended locators:getByRole,getByText,getByTestId,getByLabel. Adddata-testidattributes oraria-labelto page components when needed. - Don't add comments that narrate what the code already says (e.g.,
// Click the buttonbefore a.click()call). Only add comments when the intent isn't obvious from reading the code. - Prefer using the existing test course and its course instances for testing. Don't create new courses or course instances just to get a clean slate; instead, use transaction rollbacks or wipe the state between tests.
- To enable a feature flag for a test you can use
withConfig({ features: { 'feature-name': true } }, async () => { ... }).
The PrairieLearn web application renders HTML in one of two ways:
- Static HTML is rendered with an
htmltagged-template literal from the@prairielearn/htmlpackage. Seepackages/html/README.mdfor details. - Interactive components are built and rendered with React and hydrated with utilities from the
@prairielearn/reactpackage. Seepackages/react/README.mdfor details.
Inline PageLayout directly in the Express route handler rather than creating wrapper components. See pages/publicQuestions/publicQuestions.tsx for an example.
- A file at
./foo.tsxshould be imported as./foo.jsfrom other files. - Use
clsxin React components. - Define component props directly in the function signature (e.g.,
function Foo({ a, b }: { a: string; b: number })) instead of declaring a separate named interface. Exception: if the props type is used by multiple components or exported, a named interface is fine. - Pass
res.localstogetPageContextto get information about the course instance / authentication state. - If you hydrate a component with
Hydrate, you must register the component withregisterHydratedComponentin a file inapps/prairielearn/assets/scripts/esm-bundles/hydrated-components. - Don't use
useMemofor cheap computations. Userunfrom@prairielearn/runinstead (an IIFE helper that executes a function immediately). - Don't use
useEffectto sync internal state to a parent via a callback on every change — instead, let the child own its state and notify the parent imperatively when a user action requires it (e.g., clicking "Save"). - Avoid unnecessary
useEffectwhen usingreact-hook-form. Thewatch()function returns reactive values that trigger re-renders automatically, so derived state can be computed directly withoutuseEffect. - When a
useEffectis necessary, add a comment explaining what it does — the intent of effects is often non-obvious. - In hydrated components using
react-hook-form, always adddefaultValue(text inputs, textareas, selects) ordefaultChecked(checkboxes) alongside{...register(...)}. Without these, values aren't populated until client hydration, causing a flash of empty fields.
Elements (similar to React components, used to build interactive questions) are written in Python and are located in apps/prairielearn/elements/.
When changing element properties or options, you MUST update the corresponding documentation in docs/elements/<element-name>.md to match.
When modifying or reviewing element controllers — especially adding fields to data["params"] or data["correct_answers"] — see the element-backwards-compat skill for the rules that protect existing variants from breaking.
When changing attributes on an element exposed to AI question generation (any element in SUPPORTED_ELEMENTS in apps/prairielearn/src/ee/lib/validateHTML.ts), see the ai-html-validator skill for the validator and documentation files that must be kept in sync.
- For Python tests, use
uv run pytest path/to/testfile.pyfrom the root directory. - To run all Python tests, use
make test-pythonfrom the root directory.
When you get corrected or discover a codebase convention through trial and error, consider whether adding a rule to this file would prevent the same mistake in future sessions. Only propose an addition if:
- The mistake stems from something non-obvious about this codebase (not general best practices).
- It's likely to recur — another agent reading the current instructions would plausibly make the same error.
- It can be stated as a direct rule ("Use X", "Don't do Y"), not a narrative about what happened.
When proposing, suggest the specific text and which section it belongs in. Don't add it without user approval.