This is a build guide. Follow it in order in a development or sandbox copy of your course, then move it to the live section. Every step gives you the Canvas UI path and, where the UI cannot do the job or does it wrong, the API call that can.
You do not need to write code to follow this guide. You do need to be willing to paste a
curl command into a terminal for roughly six of the steps, because Canvas's own interface
either hides the setting (assignment post policy), silently ignores it (same), or would take
an hour of clicking to do what one loop does (posting 24 quiz questions across four surveys).
Throughout, these placeholders stand in for your own values:
| Placeholder | Meaning | Where to find it |
|---|---|---|
$CANVAS |
your institution's Canvas host, e.g. https://canvas.nau.edu |
your browser's address bar |
$TOKEN |
a personal API access token | Account → Settings → + New Access Token |
$COURSE |
numeric course id | the /courses/<id> part of any course URL |
$GROUP_SET |
a group category id | python3 scripts/discover.py --groups |
$PARTICIPATION_GROUP |
assignment group the surveys live in | /api/v1/courses/$COURSE/assignment_groups |
$DELIVERABLE |
a group assignment id | python3 scripts/discover.py --assignments |
$ADJUSTMENT |
a 0-point adjustment assignment id | python3 scripts/discover.py --assignments |
Put these in your shell once per session. Do not type the token on the command line —
export TOKEN=7~abc... is appended verbatim to ~/.zsh_history and stays there:
export CANVAS=https://canvas.your-university.edu
export COURSE=000000 # your numeric course id
read -rs TOKEN && export TOKEN # paste at the prompt; nothing is echoed or savedBetter still on macOS, store it once in the keychain — encrypted at rest, unlocked by your login, never in plaintext on disk — and pull it per session:
security add-generic-password -U -s canvas-api-token -a "$USER" -w
export TOKEN=$(security find-generic-password -s canvas-api-token -w)The scripts in scripts/ read that same keychain item automatically, so once it is stored
you do not need $TOKEN set at all for them.
The scripts read the host and course id from scripts/course.yml, and the token from the
keychain or CANVAS_TOKEN (see scripts/ags/canvas.py, which scrubs the token out of any error it
prints).
Your course must be published before you can test negative scores. Canvas returns 403 on any attempt to grade a submission in an unpublished course (gotcha 9). The whole mechanism turns on posting a negative number to a 0-point assignment, and you cannot smoke-test that in a draft shell. Plan to publish a sandbox course, or to run the smoke test in the live course during the first week with your own test-student enrollment and the assignment unpublished until after you have checked it.
The shipped code assumes NAU-shaped email addresses. Three places hard-code the pattern
"letters, then digits, then @nau.edu":
scripts/ags/peerreview.py, line ~13:NAU_EMAIL = re.compile(r"^[a-z]+[0-9]+@nau\.edu$")allocator/index.html:var NAU=/^[a-z]+[0-9]+@nau\.edu$/i- the example addresses inside
canvas/quiz-peer-evaluation-questions.jsonandcanvas/quiz-peer-evaluation-description.html
Edit all of them to match your institution before you deploy anything, or the allocator will reject your students' own addresses and the parser will treat every ballot line as an unknown address. This is the single most common way an adoption fails on day one.
The adjustment assignment must live in the same assignment group as the deliverables it modifies. That is not a stylistic preference; it is how the delta reaches the grade. A weighted Canvas gradebook computes each group's percentage independently, so a delta parked in a different group is multiplied by that group's weight instead of the project's, and if that group has a small denominator the delta is wildly amplified.
UI: Assignments → three-dot menu → Assignment Groups Weight → check Weight final grade based on assignment groups.
canvas/examples/assignment-group-weights.json is the origin course's real weighting, as a
worked reference: three project groups (15 / 15 / 25), Critical Writing 15, Quizzes 15,
Participation 10, Portfolio 5, Bonus 2, and two zero-weight groups used for organizing
non-graded material.
Decide now, and write it down:
- which assignment group holds each project arc's deliverables
- which low-stakes group will hold the peer-evaluation surveys themselves (the origin course
uses Participation and Attendance, 10%;
scripts/course.ymlrecords it aseval_quiz.assignment_group_id)
API: GET $CANVAS/api/v1/courses/$COURSE/assignment_groups?per_page=100 prints ids,
names and weights. Keep that output; you will need the group ids in steps 3 and 4.
Create one group set per project arc, not one for the whole term. Doerry & Palmer's
central design point is that evaluation attaches to specific deliverables so that one bad week
does not taint a semester; re-forming teams per arc is the structural half of that, and it also
lets peer_report.py read a round's teams from exactly one group category. The origin course's
scripts/course.yml shows the shape:
group_category_sets:
tangible: 100001 # "Tangible Board Game" <- your group_category_id
construct: 100002 # "Construct Game" <- your group_category_idUI: People → + Group Set. Name it after the arc. Check Allow self sign-up, set Group Structure to the number of groups you want to exist up front, and set Limit groups to __ members to your target team size. Leave Require group members to be in the same section off unless your section structure demands it.
API:
curl -X POST "$CANVAS/api/v1/courses/$COURSE/group_categories" \
-H "Authorization: Bearer $TOKEN" \
-d "name=Tangible Board Game" \
-d "self_signup=enabled" \
-d "group_limit=3" \
-d "create_group_count=10"Two honest limitations. group_limit enforces the maximum only — self-signup will happily
leave you a team of one and a team of two, and nothing in this toolkit fixes that
automatically. peer_report.py reports undersized teams; you resolve them by hand, in People,
before the round opens. And teams of two are a genuinely degraded case: there is no averaging,
and each partner can derive the other's rating from their own posted result. scoring.py
handles this with a pair_policy (default clamp, which narrows the factor range to
0.80–1.10; neutralize forces 1.00; full applies the normal clamp) and always attaches a
team_of_2 flag saying the ratings are not anonymous. Choosing among those three is your
judgement call, not the tool's.
Record each group set's id — it appears in the URL when you open the set, and
GET $CANVAS/api/v1/courses/$COURSE/group_categories lists them.
Every deliverable in the arc keeps one shared grade for the whole team. This is what lets the syllabus say, truthfully, that all teammates receive the same grade on group work. All individual differentiation happens in step 4.
UI: open each deliverable → Edit → under Group Assignment: check This is a Group Assignment, pick the arc's group set, and leave Assign Grades to Each Student Individually unchecked. Set Allowed Attempts to unlimited (leave the box unchecked). Any one member submits on behalf of the team; Canvas does this by default once the assignment is a group assignment.
API:
curl -X PUT "$CANVAS/api/v1/courses/$COURSE/assignments/$DELIVERABLE" \
-H "Authorization: Bearer $TOKEN" \
-d "assignment[group_category_id]=$GROUP_SET" \
-d "assignment[grade_group_students_individually]=false" \
-d "assignment[allowed_attempts]=-1"Set every group deliverable and every adjustment assignment to a manual post policy. Two reasons, both practical. First, the adjustment is computed from a team grade that must already be final — if a deliverable grade changes after you have run the round, every factor built on it is stale. Second, a negative number appearing in a student's grades with no explanatory comment attached yet is the single worst way for this system to introduce itself. Manual posting lets you post score plus comment together, for the whole round at once.
UI: Gradebook → the assignment's column menu → Grade Posting Policy → Manually.
API — and here is gotcha 7. assignment[post_manually] over REST returns HTTP 200 and
changes nothing. It is silently ignored. The setting is only reachable through GraphQL:
curl -X POST "$CANVAS/api/graphql" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"mutation { setAssignmentPostPolicy(input: {assignmentId: \"$ADJUSTMENT\", postManually: true}) { postPolicy { postManually } errors { message } } }"}'Read postPolicy.postManually back out of the response. If you used REST and believed it, you
will discover the truth when the first adjustment posts itself to a student unannounced.
One adjustment assignment per round — that is, per set of deliverables you evaluate
together. The origin course runs four: draft and final for each of two arcs. The rounds: table in
scripts/course.yml maps each one to its survey, its adjustment assignment, its group set,
and the deliverable ids it covers.
Settings that matter:
| Setting | Value | Why |
|---|---|---|
| Points possible | 0 | see below — this is the load-bearing choice |
| Assignment group | the same one as the covered deliverables | so the delta is weighted like the project |
| Display grade as | Points | the gradebook shows a signed number |
| Submission type | No submission | students never submit here |
| Omit from final grade | off | omitting it removes the entire mechanism |
| Post policy | Manually | as in step 3 |
UI: Assignments → + Assignment. Name it so a student can tell what it covers, e.g.
"Tangible Board Game — Peer Review Grade Adjustment (Parts 1–3)". Points 0, Display Grade
as Points, Submission Type No Submission, and the arc's assignment group. Paste
canvas/assignment-0pt-adjustment.html into the description via the HTML editor (the </>
button); it is a real, student-tested description with the arithmetic, a five-row worked
example at one team grade, and the floor explained.
API:
curl -X POST "$CANVAS/api/v1/courses/$COURSE/assignments" \
-H "Authorization: Bearer $TOKEN" \
-d "assignment[name]=Tangible Board Game — Peer Review Grade Adjustment (Parts 1–3)" \
-d "assignment[points_possible]=0" \
-d "assignment[grading_type]=points" \
-d "assignment[submission_types][]=none" \
-d "assignment[assignment_group_id]=270111" \
-d "assignment[published]=true" \
--data-urlencode "assignment[description]@canvas/assignment-0pt-adjustment.html"Then apply the manual post policy from step 3 to this assignment too.
Canvas computes an assignment group's percentage as sum(scores) / sum(points_possible) over
graded submissions, then multiplies by the group weight. If the adjustment assignment carries
the deliverable's own point value, those points join the denominator, and the mechanism
breaks in both directions at once. Measured on the origin course:
| Adjustment assignment worth | What happens to a team at 85% with factor 1.00 | Cost of a 0.60 factor |
|---|---|---|
| the deliverables' own points | group falls 85.0% → 54.24% (denominator 67 → 105) | only 1.85 percentage points of final grade |
| 0 points | unchanged at 85.0% | the full designed consequence |
Read the first row carefully: a student who did precisely their share, adjustment exactly 0,
loses thirty points of group percentage purely as an artifact of the denominator. With
points_possible = 0 the delta lands in the numerator only and the denominator is untouched.
This is the adopter's most consequential judgement call, and it costs nothing extra to get right. With group work at 40% of the final grade and a team at 85%:
| Coverage | Loss for a persistent 0.60 student |
|---|---|
| Draft and Final deliverables only (78 of 153 group points) | 6.76 percentage points |
| the whole arc (all 153 points) | 13.41 percentage points |
Full-arc coverage roughly doubles the consequence at an identical number of assignment objects. If the mechanism is meant to be consequential — and the paper's finding is that a non-consequential version is not worth running — cover the whole arc.
Build it as a Canvas graded survey, not a quiz and not an ungraded survey. Graded gets you a gradebook column for completion credit and a due date; ungraded gets you neither.
UI: Quizzes → + Quiz → Quiz Type Graded Survey. Then:
| Setting | Value | Why |
|---|---|---|
| Quiz type | Graded Survey | completion credit + due date |
| Keep submissions anonymous | unchecked | see the warning below |
| Allow multiple attempts | on, 2 attempts | lets a student fix a bad total |
| Assignment group | your low-stakes completion group | the accountability lives in step 4, not here |
| Shuffle answers / time limit | off | free-text questions |
Do not check "Keep submissions anonymous." The evaluation must be anonymous to peers, and it is:
peerreview.py's report renderer strips rater names from every comment before it reaches a student. Canvas's anonymous-survey option hides the respondent from you, which makes it impossible to know whose ballot is whose — and the entire round becomes unscorable.
API:
curl -X POST "$CANVAS/api/v1/courses/$COURSE/quizzes" \
-H "Authorization: Bearer $TOKEN" \
-d "quiz[title]=Peer Evaluation: Tangible Board Game (Parts 1–3)" \
-d "quiz[quiz_type]=graded_survey" \
-d "quiz[allowed_attempts]=2" \
-d "quiz[anonymous_submissions]=false" \
-d "quiz[assignment_group_id]=$PARTICIPATION_GROUP" \
-d "quiz[due_at]=2026-10-08T23:59:00Z" \
-d "quiz[published]=false" \
--data-urlencode "quiz[description]@canvas/quiz-peer-evaluation-description.html"canvas/quiz-peer-evaluation-description.html is the shipped description: the rules, the
N × 100 budget, the justification threshold, the email-format warning, and the anonymity
statement including the honest note that a two-person team can derive each other's ratings.
canvas/quiz-peer-evaluation-questions.json holds them exactly as authored, each worth 0.5
points (3 points total as shipped — completion credit, nothing more):
| Name | Type | Asks for |
|---|---|---|
Q1 |
essay | everything fed back to students. The allocator iframe, and the combined POINTS / STRENGTHS / IMPROVEMENTS block the student pastes out of it |
Q2 |
essay | instructor only. Justification for any rating below 80 or above 120 |
Q3 |
essay | instructor only. Anything else the instructor should know |
The audience split is enforced, not just documented: the comment posted to a student is
rendered with anonymous=True, which emits nothing from an instructor-only question.
Gotcha 5, and it is the one that will silently ruin a round. Canvas does not persist
positionon quiz questions created or updated through the API — it reads back asNone.peer_report.pytherefore keys every answer offquestion_name, matching^Q(\d+)$. The three questions must be named exactlyQ1,Q2,Q3, in that meaning. Rename or reorder them and ballots will misparse with no error at all. The layout lives inscripts/ags/survey.py, which bothprovision.pyandpeer_report.pyimport, so the builder and the reader cannot drift apart;provision.py verifyre-checks the live quiz against it.
Posting them by hand through the UI works but is tedious and easy to get wrong; the question
text for Q1 is several thousand characters of tables. The shipped client makes it a loop —
run this from the repository root once scripts/course.yml is filled in:
import json, pathlib, sys
sys.path.insert(0, "scripts")
from ags.canvas import Canvas
COURSE, QUIZ = 000000, 000000 # your course id, your quiz id
c = Canvas()
for q in json.loads(pathlib.Path("canvas/quiz-peer-evaluation-questions.json").read_text()):
payload = {"question_name": q["name"], "question_type": q["type"],
"question_text": q["text"], "points_possible": q["points_possible"]}
if q["answers"]:
payload["answers"] = q["answers"]
c.request("POST", f"courses/{COURSE}/quizzes/{QUIZ}/questions",
json={"question": payload})
print("posted", q["name"])Then read every question back and check the text and the answers survived.
Canvas cannot validate one question against another. Neither Classic nor New Quizzes can do
it, and the "Formula" question type is not the escape hatch it looks like: its variables are
Canvas-generated random numbers (fields name/min/max/scale) and cannot reference a
student's answer to another question (gotcha 3, verified by building one). So the survey
itself cannot check that the numbers in Q1 add up.
An earlier version of this toolkit worked around that with a numerical "total" question accepting 200/300/400 (gotcha 4). That is gone. It could not catch a legal total that was wrong for the student's own team size — a three-person team typing 400 passed it — and it added a field students could fail after the allocator had already told them they were correct.
The allocator now does the enforcement, in front of the student: it refuses to produce output
until the points total their team size x 100, and it writes the strengths and improvements
into the same block. peer_report.py then re-checks both the arithmetic and the coverage
when it reads the ballot, so an allocation that omits a teammate is held rather than scored.
The shipped answers array is three exact answers:
{"weight": 100, "numerical_answer_type": "exact_answer", "exact": 200.0}Gotcha 6. The file above records the shape Canvas reads back. On the write side the field names differ: send
numerical_answer_type: "exact_answer"withanswer_exact(andanswer_error_margin: 0). If you ever switch to a tolerance range, the fields areanswer_range_startandanswer_range_end— writingstart/endis silently accepted and stores 0/0, producing a question that accepts only zero. Always read the question back and confirm the three accepted values before you publish.
Gotcha 10. Partial question updates can silently revert
question_text. When you edit a question through the API, always send the full payload — name, type, text, points, and answers — asembed_allocator.pydoes.
Gotcha 8. A quiz's cached
points_possibledoes not recompute when you add or edit questions through the API. It will read 0 while the questions clearly total 3. Unpublish and republish the quiz to force the recount, and check the gradebook column afterwards.
The allocator (allocator/index.html) is a single dependency-free HTML file: the student picks
a team size, optionally starts from a pattern, enters one row per teammate including
themselves, and the Copy button stays disabled until the points total exactly team size × 100
and every address is valid and unique. It has no backend, stores nothing, and transmits
nothing.
Gotcha 1, the reason this step exists at all. Canvas serves uploaded HTML files with
Content-Security-Policy: sandbox; script-src 'none'; object-src 'none'andContent-Disposition: attachment. JavaScript in a Canvas-hosted file never executes. Canvas Files cannot host an interactive tool, full stop. External hosting plus an iframe is not a preference; it is the only route.
Gotcha 2, the reason the iframe works. Canvas strips inline
<script>, external<script src>,onclick,<input>, and<form>from page bodies and quiz text. It permits<iframe>, inline styles,data-*attributes, and<details>/<summary>. Iframes survive in page bodies, in quiz descriptions, and in quiz question text — which is why the allocator can sit directly above the question it feeds.
Hosting on GitHub Pages:
- Put the repository on GitHub (a fork or a fresh repo containing
allocator/index.html). - Edit the NAU email regex and the example addresses in
allocator/index.htmlfor your institution, and adjust thePATpattern table if your teams are a different size. - Repo Settings → Pages → Build and deployment → Source Deploy from a branch, branch
main, folder/ (root)if the file is at the root, or move/copy it to/docsand select that. - Wait for the Pages build to finish (Actions tab), then confirm the host is not stripping scripts:
curl -sI https://USERNAME.github.io/peer-evaluation-toolkit/ | grep -i -e content-security -e content-dispositionAny other static host works identically — the tool needs no server. What it must not do is
serve with script-src 'none' or as an attachment.
Embedding. Paste this anywhere Canvas keeps iframes — the top of Q1 is where it earns its place:
<iframe src="https://USERNAME.github.io/peer-evaluation-toolkit/"
width="100%" height="1020"
style="border:1px solid #c7cdd1;border-radius:6px;"
title="Peer Evaluation Point Allocator" loading="lazy"></iframe>scripts/embed_allocator.py does this for all four surveys plus the guide page in one command,
and it preflights the URL before touching Canvas: it refuses to embed unless the URL
returns HTTP 200, carries a real <script> tag, is not served as an attachment, and is not
under a script-src 'none' policy. --remove takes the iframes back out.
python3 scripts/embed_allocator.py https://USERNAME.github.io/peer-evaluation-toolkit/embed_allocator.py takes the course and the survey ids from scripts/course.yml, the page
slug from the constant at the top of the file. A round whose survey_quiz_id is not filled
in yet is skipped with a note, and the guide page still gets the iframe.
Also publish canvas/page-distribution-guide.html as a Canvas page ("Point Distribution
Guide"). It is the no-JavaScript fallback: pre-computed distribution tables for teams of 2, 3,
and 4 where every row already sums correctly. Students on a phone with a blocked iframe, or
students who simply prefer a table, need this. The same tables are also inlined in Q1 behind
<details>-style panels, so the fallback survives even if the page does not.
Put the round's items in one module, in the order a student meets them:
- Point Distribution Guide (page) — requirement view
- Peer Evaluation survey — requirement submit
- Peer Review Grade Adjustment (the 0-point assignment) — no requirement, or mark as done
UI: Modules → + → pick the item type → then the module's Edit dialog → Add requirement.
API:
curl -X POST "$CANVAS/api/v1/courses/$COURSE/modules/$MODULE/items" \
-H "Authorization: Bearer $TOKEN" \
-d "module_item[type]=Page" \
-d "module_item[page_url]=point-distribution-guide" \
-d "module_item[completion_requirement][type]=must_view"Gotcha 12.
must_mark_doneis the only requirement type that can complete anot_gradedassignment. If you make the adjustment assignment ungraded — don't; you need to post scores to it — or if you have any other not-graded placeholder in the module,must_submitmakes the item permanently incomplete and quietly blocks every student from finishing the module. The adjustment assignment as built in step 4 is graded with 0 points and has no submission, somust_submitis wrong for it too: there is nothing to submit. Leave it with no requirement, or usemust_mark_done.
Gotcha 11. Removing a completion requirement is not obvious.
type: ""returns HTTP 400. JSONnullreturns success and does nothing. The form that actually works is an empty object:
curl -X PUT "$CANVAS/api/v1/courses/$COURSE/modules/$MODULE/items/$ITEM" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"module_item":{"completion_requirement":{}}}'This is the step people skip and then regret. It requires a published course (gotcha 9: Canvas refuses to grade submissions in an unpublished course with a 403), and it takes five minutes.
- Note a test student's current percentage in the project assignment group, and the group's denominator.
- Enter -1.50 by hand in the adjustment assignment's gradebook cell.
- Confirm: the denominator did not change; the group percentage moved down by roughly 1.50 divided by that denominator, times the group weight; the cell displays a negative number rather than refusing it.
- Enter +1.50 and confirm it moves the other way.
- Clear the cell.
If the denominator changed, points_possible is not 0. If the percentage barely moved, the
assignment is in the wrong assignment group, or omit from final grade is on.
Institution and code:
- the NAU email regex is replaced in
scripts/ags/peerreview.py,allocator/index.html, and both Canvas HTML files - example addresses in Q1, Q2, Q3 use your institution's form
- the test suite still passes after your edits — from
scripts/, runpython3 -m unittest discover -s . -p 'test_*.py'(83 tests: the scoring rules and the ballot parser, readable as a specification of the grade math) -
scripts/.envhasCANVAS_BASE_URLandCANVAS_TOKEN, and is not committed
Structure:
- assignment groups are weighted, and you have written down which group holds each arc
- one group set per arc, self-signup on,
group_limit= target team size - every team has at least 3 members, or you have consciously chosen a
pair_policy - every group deliverable: group assignment on, grade individually off, attempts unlimited
- every group deliverable and every adjustment assignment: post policy manual, verified by
reading
postPolicy.postManuallyback from GraphQL, not by trusting REST (7)
The adjustment assignments:
-
points_possibleis exactly 0 on every one - each sits in the same assignment group as the deliverables it covers
- omit from final grade is off; display grade as Points
- coverage decision made deliberately — whole arc, or a documented subset
- description pasted from
canvas/assignment-0pt-adjustment.html, with the arc's real deliverable names and points substituted
The surveys:
- quiz type is
graded_survey - "Keep submissions anonymous" is unchecked
- 2 attempts allowed
- questions named exactly
Q1,Q2,Q3— Q1 student-facing, Q2 and Q3 instructor-only (5) -
python3 scripts/provision.py verifypasses for every round - every question's
question_textread back intact after any edit (10) - unpublished and republished once, and the gradebook column shows the expected points (8)
- due dates set, and each round's due date is after the covered deliverables are graded
The allocator:
- hosted somewhere that serves real JavaScript;
curl -Ishows noscript-src 'none'and noattachment(1) - embedded in each survey's Q1 (and the guide page), verified by loading the quiz as a student (2)
- Point Distribution Guide page published as the no-JavaScript fallback
Modules and the live check:
- module items in order: guide page, survey, adjustment assignment
- no
must_submitrequirement on any not-graded or no-submission item (12) - smoke test done in a published course: ±1.50 moves the grade the expected amount and leaves the denominator alone (9)
- one full dry run of the round script against real submissions before anything is posted
(
peer_report.pyis dry-run by default and writes a report file; it writes to Canvas only with--post)
- No cross-question validation in Canvas. The allocator enforces the total before the
student copies anything out, and
peer_report.pyre-checks it. Canvas itself catches wrong for the student's team size, and not a ballot that hits the target while omitting the student themselves. Both of those are caught after the fact, bycheck_budgetinpeerreview.py, and land in your report as problems to resolve. - No automatic team repair. Self-signup produces undersized teams. You fix them by hand.
- No anonymity on a team of two. Arithmetic, not policy. Say so out loud, in the
assignment description, as
canvas/assignment-0pt-adjustment.htmldoes. - Nothing in
scripts/is course-specific. Every id, threshold and round lives inscripts/course.yml, which you create by copyingcourse.example.yml. It is gitignored, and it is the one file you fill in. The scripts are identical for every adopter, so you can pull an update without re-applying your edits. - Your token is not in that file. It is read from the macOS keychain, or from
CANVAS_TOKEN. So a config file is safe to hand a colleague as a starting point, and a leaked config file is not a leaked credential. - Grade every covered deliverable before you run a round. The adjustment is
team_earned x factor - team_earned; an ungraded deliverable silently shrinksteam_earnedand every adjustment with it. That is not something to discover at 11pm on a grading night.