The tool targets any LTI 1.3 platform; Canvas is the primary target but nothing outside the Canvas-specific custom variable substitutions depends on it. User-facing strings and the README now say "LMS" except where a mechanism genuinely is Canvas's (the $Canvas.* / com.instructure.* substitutions and their handling), and the README states plainly that the tool has so far been exercised only against the saltire emulator, not yet a production LMS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 KiB
coldcall-lti
An LTI 1.3 external tool for managing cold calls in case-based
classes: it selects students to call using weighted randomness, records
what happened with each call, lets students report planned absences, and
reports participation data back to both instructor and students. It
replaces a manual workflow built on exported rosters, Google Forms, and
local scripts. It should work with any LMS that implements LTI 1.3
(LTI Advantage); Canvas is the primary target and the platform the
Canvas-specific conveniences below are written for. So far it has been
exercised only against the saltire LTI emulator (see
docs/SALTIRE.md) — not yet against any production LMS.
How it works
The tool is a Flask application that the LMS launches over LTI 1.3. A single URL serves everyone: the LMS identifies the person and course on each launch, so instructors get the call-list and reporting views while students get the absence form and their own history. The roster comes from the LMS through the Names and Role Provisioning Service, which means adds and drops are picked up automatically rather than reconciled by hand.
Selection uses the same weighting as the manual system it replaces: each answered call divides a student's weight by the course's weight factor (default 2), so students who have answered more questions become progressively less likely to be called. Each course can instead use "cycle" mode, a rolling rotation through the roster in random order: everyone is called once before anyone repeats, batches take the next students in the current pass (continuing across lists and class days), and skipped calls don't count as a turn. These, along with whether students can see their own assessments, are per-course settings.
Daily use
The instructor page works against a selectable date (defaulting to today), so printing tomorrow's list a day in advance just means switching the working date. There are two ways to run a class, per day and freely mixed:
- Live mode: a "call next student" button shows who's up (photo, name, pronouns) with one-tap outcomes — the assessment levels, missing (absent without an opt-out), or skip (as if the call never happened).
- Printed list: generate a numbered list of any length, print it, and mark it up on paper; after class, enter the outcomes in the day editor. Generating is safe by design: when an unused list already exists the button becomes an explicit "regenerate", and a warning appears only if the day already has recorded outcomes (which are always preserved).
The day editor allows full correction of the record: change any call's status, assessment, or note; delete lines entirely (effectively excusing the student); and add calls after the fact for anyone on the roster.
Layout
coldcall_lti/models.py— SQLAlchemy models: courses (with their settings), students, enrollments, opt-outs, and calls, plus the query helpers that feed selection.coldcall_lti/selection.py— the weighted and cycle selection logic. Kept free of database and web dependencies so it can be tested and reasoned about on its own.coldcall_lti/__init__.py— the Flask application factory.migrations/— alembic migrations. The schema avoids database-specific types so the same migrations run on SQLite (the default) and MariaDB/MySQL; switching is a matter of changingCOLDCALL_DATABASE_URL.tests/— pytest suite covering selection behavior and the model helpers.
Setup
The app needs Python ≥ 3.11 with Flask, SQLAlchemy, alembic, and
numpy, plus one library that only exists on PyPI: pylti1p3next, the
maintained fork of PyLTI1p3. There are two equivalent ways to install,
depending on whether you prefer distribution packages or pip.
Pure pip/venv (any distribution; everything from PyPI):
python3 -m venv .venv
.venv/bin/pip install -e '.[test]'
Debian/Ubuntu system packages (only pylti1p3next comes from
PyPI; the venv shares the system's packages):
sudo apt install python3-venv python3-flask python3-sqlalchemy \
python3-alembic python3-numpy python3-pytest
python3 -m venv --system-site-packages .venv
.venv/bin/pip install pylti1p3next
.venv/bin/pip install -e .
Either way, then create the database and run the tests:
.venv/bin/python -m alembic upgrade head
.venv/bin/python -m pytest tests/
Configuration is by environment variable: COLDCALL_DATABASE_URL (any
SQLAlchemy URL; defaults to an SQLite file under instance/),
COLDCALL_SECRET_KEY for Flask sessions, COLDCALL_LTI_CONFIG (path
to the LTI platform configuration), and COLDCALL_DEV_MODE=1 to enable
the fake-launch pages.
Connecting to an LMS
Any LTI 1.3 platform can launch the tool; registration means telling
the LMS about three endpoints here: /lti/login (OIDC initiation),
/lti/launch (the launch target), and /lti/jwks (this tool's public
keys). In Canvas this takes a Developer Key created by an account
admin; other platforms have their own registration screens for the
same information. The platform side is described in a JSON file — copy
lti_config.example.json to instance/lti_config.json and fill in
the client id and deployment id from the registration. Generate the
tool's keypair alongside it:
openssl genrsa -out instance/private.key 4096
openssl rsa -in instance/private.key -pubout -out instance/public.key
On each instructor launch the tool refreshes the course roster from the LMS through the Names and Role Provisioning Service, so enrollment changes appear without any manual step. There is also a "Sync roster now" button on the instructor page, and a management command suitable for an hourly cron job on the server, which keeps rosters current even when nobody has launched the tool (worth having during the add/drop churn at the start of a term):
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
Courses whose end date has passed are skipped automatically.
Student names come from the LMS's display names, which already reflect preferred names. Canvas users can get more through four custom parameters on the Developer Key (these are Canvas-specific variable substitutions; other platforms may offer equivalents under different names, and the tool works fine without them):
pronouns=$com.instructure.Person.pronouns
course_start=$Canvas.course.startAt
course_end=$Canvas.course.endAt
grading_scheme=$com.instructure.Course.gradingScheme
Pronouns then arrive in launches and in the roster data (the tool requests memberships scoped to the resource link, which is what makes Canvas attach per-member custom fields) and appear on the live call card, printed lists, and each student's own page. The course dates bound the schedule and date pickers, and the grading scheme becomes importable into the grade display scale with one click in settings. All four degrade gracefully: a course, account, or platform without them simply does without.
Opt-outs
Students remove themselves from a day's cold-call list by picking the date on their page; nothing else is asked. There is deliberately no enforced submission deadline: an instructor running live calls in class gets up-to-the-second opt-outs automatically, while one who prints a call list beforehand should just tell students how much lead time they need (for example, "an hour or two before class"), since opt-outs after printing won't be on the paper.
Withdrawing an opt-out is bounded, because un-opting-out after the fact would rewrite a student's availability record (and with it their participation grade): withdrawal closes when class begins, using the start time recorded on the schedule page, or at the start of the class day when no time is recorded. Withdrawals are also recorded rather than deleted — the opt-out export includes a withdrawn_at column, so the full history of changes survives.
Grading
Final participation grades use the timing-neutral foregone- participation scheme (a port of the earlier participation_grades.R): a student's grade is the quality of their answers minus a deduction for participation they missed by being unavailable, estimated by Monte Carlo simulation of the actual weighted draw and averaged over when the absences fall, plus a small incentive penalty for being drawn while absent with no opt-out filed. The reason for an absence never matters, and luck of the draw never moves a grade. Parameters (allowance, passing line, penalties, simulation size) are per-course settings; grades are computed on demand, reviewed on the instructor's grades page, and shown to students only when the instructor publishes reports. With gradebook passback enabled, a review-then-push page sends the reviewed scores to the LMS gradebook via the Assignment and Grade Services; nothing is ever sent without explicit confirmation.
Grades are computed in points out of 100 and displayed through a per-course scale: the built-in linear UW 4.0 map, a threshold table (letter grades, importable in one click from the course's own LMS grading scheme), or raw points.
The port was verified against the R engine's rendered reports from a
real course: answer quality and availability match exactly; the
simulated penalty agrees within Monte Carlo noise. flask --app coldcall_lti import-legacy <dir> imports a manual-era class directory
for this kind of testing.
Developing without an LMS
Because a Developer Key takes institutional approval to get, the app has a fake-launch mode for local development:
COLDCALL_DEV_MODE=1 .venv/bin/flask --app coldcall_lti run --debug
Then open http://localhost:5000/dev and launch as the fake instructor or any of the fake students. This sets up exactly the session state a real launch would, and the fake roster flows through the same sync code as real NRPS data, so everything past the launch behaves identically. Dev mode also relaxes the cookie settings that LMS iframe embedding requires in production (SameSite=None; Secure), which would otherwise break plain-http localhost use.
The one thing dev mode cannot exercise is the real OIDC/JWT handshake
itself. For that, the app is tested against the saltire LTI platform
emulator — a hosted fake LMS that performs genuine LTI 1.3 launches
and answers NRPS requests. The full setup and re-run instructions are
in docs/SALTIRE.md.
License
Copyright © 2026 Benjamin Mako Hill. This program is free software, released under the GNU Affero General Public License, version 3 of the License or (at your option) any later version. See the LICENSE file for the full text.