aspi6246/canvas-lms-api
> Use this skill whenever working with the Canvas LMS REST API — including creating or updating modules, pages, and files for a course, building sync scripts, managing course structure programmatically, or uploading files and linking them in pages. Trigger this skill any time the user mentions Canvas, LMS, course sync, module pages, or file uploads to Canvas. Also use when writing or debugging any Python script that calls the Canvas API.
npx skills add https://github.com/aspi6246/Claude-Code-Skills-for-Academics --skill canvas-lms-api
A comprehensive reference for building Python scripts that interact with the Canvas LMS REST API, specifically for course content management: modules, pages, and file uploads.
This skill supports building a course content sync system for Ethics and Governance at UTS. The system reads HTML content files and a course structure config from a local folder, then syncs everything to Canvas via the API.
Local project folder: ~/path/to/CanvasDev-EthicsGov/
> ⚠️ Replace with the actual folder location on your machine.
Key files:
config.py — stores credentials and course IDs (NEVER commit this to GitHub)course_structure.json — master blueprint of all modules/pages/filessync.py — main sync scriptmodule-XX-name/ — one folder per module containing HTML page files and a files/ subfolderCANVAS_DOMAIN = "canvas.uts.edu.au"
COURSE_ID = "XXXXX" # sandpit — replace with your sandpit course ID
# COURSE_ID = "YYYYY" # live course (comment out sandpit when ready)
ACCESS_TOKEN = "your_token_here"
> ⚠️ Replace XXXXX with your sandpit course ID, YYYYY with your live Ethics and Governance
> course ID, and paste your API token in place of "your_token_here".
import requests
from config import CANVAS_DOMAIN, COURSE_ID, ACCESS_TOKEN
BASE_URL = f"https://{CANVAS_DOMAIN}/api/v1"
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
Canvas → Profile picture → Settings → Approved Integrations → + New Access Token
Before creating anything, check if it already exists. This prevents duplicates on re-runs.
def get_or_create_module(name, position):
"""Find an existing module by name, or create it."""
response = requests.get(f"{BASE_URL}/courses/{COURSE_ID}/modules", headers=HEADERS)
modules = response.json()
for m in modules:
if m["name"] == name:
print(f" Found existing module: {name}")
return m["id"]
# Create new
response = requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/modules",
headers=HEADERS,
data={"module[name]": name, "module[position]": position, "module[published]": False}
)
module = response.json()
print(f" Created module: {name}")
return module["id"]
Canvas paginates responses — always handle it:
def get_all_pages(url):
results = []
while url:
response = requests.get(url, headers=HEADERS)
results.extend(response.json())
url = response.links.get("next", {}).get("url")
return results
all_modules = get_all_pages(f"{BASE_URL}/courses/{COURSE_ID}/modules")
response = requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/modules",
headers=HEADERS,
data={
"module[name]": "Module 1: Introduction to Ethics and Governance",
"module[position]": 1,
"module[published]": False
}
)
module_id = response.json()["id"]
requests.put(
f"{BASE_URL}/courses/{COURSE_ID}/modules/{module_id}",
headers=HEADERS,
data={"module[position]": 1}
)
requests.delete(f"{BASE_URL}/courses/{COURSE_ID}/modules/{module_id}", headers=HEADERS)
Module item types: File, Page, Discussion, Assignment, Quiz, SubHeader, ExternalUrl, ExternalTool
CRITICAL: For Page type, use page_url (the slug), NOT content_id. Using content_id for pages will silently fail.
requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/modules/{module_id}/items",
headers=HEADERS,
data={
"module_item[title]": "Topic 1 - Foundations of Ethics",
"module_item[type]": "SubHeader",
"module_item[indent]": 0
}
)
# page_url is the slug — Canvas generates this from the title
# e.g. "Lecture - Foundations of Ethics" → "lecture-foundations-of-ethics"
requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/modules/{module_id}/items",
headers=HEADERS,
data={
"module_item[title]": "Lecture - Foundations of Ethics",
"module_item[type]": "Page",
"module_item[page_url]": "lecture-foundations-of-ethics",
"module_item[indent]": 1
}
)
requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/modules/{module_id}/items",
headers=HEADERS,
data={
"module_item[title]": "Lecture Slides",
"module_item[type]": "File",
"module_item[content_id]": file_id, # File ID from upload response
"module_item[indent]": 1
}
)
Items are ordered by position (1-based). Set position explicitly when creating items, or update after:
requests.put(
f"{BASE_URL}/courses/{COURSE_ID}/modules/{module_id}/items/{item_id}",
headers=HEADERS,
data={"module_item[position]": 2}
)
response = requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/pages",
headers=HEADERS,
data={
"wiki_page[title]": "Lecture - Foundations of Ethics",
"wiki_page[body]": html_content, # full HTML string
"wiki_page[published]": False
}
)
page = response.json()
page_slug = page["url"] # e.g. "lecture-foundations-of-ethics"
page_id = page["page_id"] # numeric ID (less commonly needed)
requests.put(
f"{BASE_URL}/courses/{COURSE_ID}/pages/{page_slug}",
headers=HEADERS,
data={"wiki_page[body]": updated_html_content}
)
response = requests.get(
f"{BASE_URL}/courses/{COURSE_ID}/pages/{page_slug}",
headers=HEADERS
)
def get_or_create_page(title, html_body):
slug = title.lower().replace(" ", "-").replace("–", "-")
import re
slug = re.sub(r"[^a-z0-9-]", "", slug)
response = requests.get(f"{BASE_URL}/courses/{COURSE_ID}/pages/{slug}", headers=HEADERS)
if response.status_code == 200:
# Update existing
requests.put(
f"{BASE_URL}/courses/{COURSE_ID}/pages/{slug}",
headers=HEADERS,
data={"wiki_page[body]": html_body}
)
print(f" Updated page: {title}")
return slug
else:
# Create new
response = requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/pages",
headers=HEADERS,
data={"wiki_page[title]": title, "wiki_page[body]": html_body, "wiki_page[published]": False}
)
print(f" Created page: {title}")
return response.json()["url"]
IMPORTANT: Canvas file upload is a 3-step process. It is NOT a simple single POST.
import os
import requests
def upload_file(local_path, canvas_folder_name):
"""Upload a file to Canvas, return (file_id, file_url). Overwrites if name exists."""
file_name = os.path.basename(local_path)
file_size = os.path.getsize(local_path)
# Step 1: Notify Canvas
step1 = requests.post(
f"{BASE_URL}/courses/{COURSE_ID}/files",
headers=HEADERS,
data={
"name": file_name,
"size": file_size,
"parent_folder_path": canvas_folder_name,
"on_duplicate": "overwrite"
}
)
if step1.status_code != 200:
raise Exception(f"Step 1 failed: {step1.status_code} {step1.text}")
info = step1.json()
upload_url = info["upload_url"]
upload_params = info["upload_params"]
# Step 2: Upload (NO auth header here — this goes to Canvas's file storage)
with open(local_path, "rb") as f:
form_data = {k: (None, v) for k, v in upload_params.items()}
form_data["file"] = (file_name, f)
step2 = requests.post(upload_url, files=form_data, allow_redirects=False)
# Step 3: Confirm
location = step2.headers.get("Location")
if not location:
raise Exception(f"Step 2 failed: {step2.status_code} {step2.text}")
step3 = requests.get(location, headers=HEADERS)
file_data = step3.json()
print(f" Uploaded: {file_name} → ID {file_data['id']}")
return file_data["id"], file_data["url"]
After uploading a file, embed a download link in your page HTML:
file_id, file_url = upload_file("module-01-foundations/files/lecture-slides.pptx", "Module 1")
html = f"""
<p>Download the slides:
<a href="{file_url}" target="_blank">Lecture Slides (PPTX)</a>
</p>
"""
Important: Use on_duplicate: overwrite when uploading. This preserves the file's URL even when the content is replaced — so links in pages remain valid after file updates.
The master blueprint that drives the sync script. Here is an example for Ethics and Governance:
[
{
"module_name": "Module 1: Foundations of Ethics",
"position": 1,
"items": [
{
"type": "subheader",
"title": "Topic 1 - Ethical Theories and Frameworks",
"indent": 0
},
{
"type": "page",
"title": "Lecture - Ethical Theories and Frameworks",
"file": "module-01-foundations/lecture.html",
"indent": 1,
"upload_files": ["module-01-foundations/files/lecture-slides.pptx"]
},
{
"type": "page",
"title": "Tutorial - Ethical Dilemmas in Practice",
"file": "module-01-foundations/tutorial.html",
"indent": 1,
"upload_files": [
"module-01-foundations/files/case-study.pdf",
"module-01-foundations/files/tutorial-questions.docx"
]
}
]
}
]
Pages use a consistent design system. Adjust the colours below to match your preferred branding.
<div style="font-family: Calibri, sans-serif; max-width: 900px; margin: 0 auto;">
<!-- Header Banner -->
<div style="background-color: #0a1628; padding: 30px; border-radius: 8px; margin-bottom: 30px;">
<h1 style="color: #2ec4b6; font-family: Georgia, serif; margin: 0 0 8px 0;">PAGE TITLE</h1>
<p style="color: #ffffff; margin: 0; font-size: 16px;">Ethics and Governance</p>
</div>
<!-- Section with accent bar -->
<div style="border-left: 4px solid #2ec4b6; padding-left: 20px; margin-bottom: 30px;">
<h2 style="font-family: Georgia, serif; color: #0a1628;">Section Title</h2>
<p>Content here.</p>
</div>
<!-- Grey content block -->
<div style="background-color: #f4f6f9; padding: 24px; border-radius: 8px; margin-bottom: 24px;">
<h2 style="font-family: Georgia, serif; color: #0a1628;">Section Title</h2>
<p>Content here.</p>
</div>
<!-- Key Takeaway Box -->
<div style="background-color: #0a1628; border-left: 6px solid #2ec4b6; padding: 20px; border-radius: 4px; margin-bottom: 30px;">
<h3 style="color: #2ec4b6; font-family: Georgia, serif; margin-top: 0;">Key Takeaway</h3>
<p style="color: #ffffff; margin: 0;">Takeaway text here.</p>
</div>
<!-- File download links -->
<div style="border-left: 4px solid #2ec4b6; padding-left: 20px; margin-bottom: 30px;">
<h2 style="font-family: Georgia, serif; color: #0a1628;">Downloads</h2>
<p><a href="FILE_URL_HERE" target="_blank">Lecture Slides (PPTX)</a></p>
<p><a href="FILE_URL_HERE" target="_blank">Case Study (PDF)</a></p>
</div>
</div>
In your HTML files, use a placeholder token:
<a href="{{files/lecture-slides.pptx}}" target="_blank">Lecture Slides</a>
The sync script replaces {{files/lecture-slides.pptx}} with the real Canvas URL after uploading.
The main sync.py follows this sequence:
course_structure.jsonlocal_path → {file_id, file_url}subheader: create SubHeader module itempage: read the HTML file, inject file URLs into placeholders, find-or-create the page, add to module| Error | Cause | Fix |
|-------|-------|-----|
| ImportError: cannot import CANVAS_DOMAIN | Typo in config.py | Check exact variable names in config.py |
| Page module item not appearing | Used content_id instead of page_url | Always use page_url (the slug) for Page type |
| 404 on page slug | Slug doesn't match actual page URL | Get actual slug from response.json()["url"] after creating page |
| File upload step 2 fails | Sending auth header to S3/upload URL | Do NOT send Authorization header in step 2 |
| Duplicate modules on re-run | No find-or-create logic | Always check existing modules before creating |
| File link breaks after update | Created new file instead of overwriting | Use on_duplicate: overwrite in step 1 |
| 400 on module item | Missing required field | Check type — SubHeader needs no content_id; Page needs page_url; File needs content_id |
| Pagination cut-off | Only reading first page of results | Use pagination loop with response.links["next"] |
COURSE_ID before switching to the live course.config.py, comment out the one not in use.published: False. Publish manually after review.ACCESS_TOKEN stays in config.py only, never hardcoded elsewhere.config.py is never committed.Take aspi6246/canvas-lms-api from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.