blendtutor

July 24, 2026 · View on GitHub

A single-binary CLI for building interactive coding lessons — in R or Python — with AI-powered feedback. Instructors author a course of exercises and grading prompts; learners practice in the terminal or in a static browser site, getting instant, personalized feedback on each submission.

Inspired by swirl and learnr, and originally designed to complement the Just Enough Software Engineering textbook.

What is blendtutor?

A course is a directory of lesson YAML files plus a blendtutor.toml manifest. Each lesson describes an exercise prompt, a code template, checks, and an LLM evaluation prompt. blendtutor handles the rest:

  • Instructors scaffold a course, add lessons, validate them, dry-run the grading against sample submissions, score the grading prompt against known cases, and build a deployable browser site.
  • Learners run a lesson, submit code, and get an AI verdict — in the terminal locally, or in a browser via webR / Pyodide with no install.

Installation

blendtutor is a Rust binary. Install it from a clone with Cargo:

git clone https://github.com/mcmullarkey/blendtutor.git
cd blendtutor
cargo install --path crates/cli

This puts a blendtutor executable on your PATH (~/.cargo/bin).

API key

AI feedback calls an LLM provider. Set one of these in your environment:

  • FIREWORKS_API_KEYFireworks AI (the default provider).
  • ANTHROPIC_API_KEYAnthropic (used by built browser sites, which are bring-your-own-key / Anthropic-only).
export FIREWORKS_API_KEY=fw_...

Authoring commands (init, new, validate, build) need no key; only run and eval call the provider.

Authoring workflow

The instructor loop is init → new → validate → run → eval → build.

blendtutor init — scaffold a course

blendtutor init my-course

Creates my-course/ with a blendtutor.toml manifest, an example lesson under lessons/, a matching eval suite, and a .gitignore. The fresh course is immediately listable and runnable.

blendtutor new — add a lesson

blendtutor new lesson --lang r greet         # add lessons/greet.yaml (R)
blendtutor new lesson --lang python tally    # add lessons/tally.yaml (Python)

Writes a lesson YAML template plus a sibling eval_<name>.yaml for grading cases. --lang selects the runtime the lesson targets (r or python).

blendtutor validate — check a lesson

blendtutor validate lessons/greet.yaml
blendtutor validate lessons/greet.yaml --format json   # machine-readable

Reports missing required fields and common authoring mistakes. Exit code is nonzero when a lesson is invalid, so it drops cleanly into CI.

blendtutor run — execute + get feedback

blendtutor run lessons/greet.yaml --code submission.R
echo 'greet <- function(x) paste("hi", x)' | blendtutor run lessons/greet.yaml

Runs a submission through the real interpreter, then asks the LLM for a verdict. --code <path> reads a file; omit it to read the submission from stdin. Add --format json for a structured report. Exit code reflects the verdict (correct / incorrect / error).

blendtutor eval — score the grading prompt

blendtutor eval lessons/greet.yaml
blendtutor eval lessons/greet.yaml --format json

Replays the lesson's eval_<name>.yaml cases through the full run pipeline and reports how often the grader's verdict matches the expected label — so you can measure (and regression-test) grading accuracy before shipping. Because evals score against whichever provider your API key selects, run them against the same provider your deployed site will use.

blendtutor build — assemble a browser site

blendtutor build my-course --target webr -o site      # R lessons → webR
blendtutor build my-course --target pyodide -o site   # Python lessons → Pyodide

Emits a static site to -o <dir>: index.html, a per-lesson JSON index, the in-browser runtime, and (if the course carries an eval-report.json) an embedded eval-results page. --target picks the WASM runtime — webr for R lessons, pyodide for Python.

Deploying to GitHub Pages

The built site/ directory is fully static, so it deploys to GitHub Pages as-is (push it to a gh-pages branch or wire it into a Pages workflow).

One caveat: webR needs SharedArrayBuffer, which browsers only enable under cross-origin isolation — i.e. COOP/COEP response headers that GitHub Pages cannot set. To work around this, the build ships a vendored coi-serviceworker shim that re-serves the page with the required COOP/COEP headers from a service worker, so the site is cross-origin isolated on Pages without any header configuration. (Pyodide-only sites boot on the main thread and do not require this, but the shim ships for both targets and is harmless when unused.)

Live example sites

Two example courses — derived from the "Write Less Code" lesson arc — are built and deployed alongside the docs on GitHub Pages:

Each site includes an eval-results page (Python) showing the grading-prompt accuracy recorded by blendtutor eval.

Quarto Extension

blendtutor also ships as a Quarto extension for authoring interactive coding exercises directly in .qmd documents. Learners get an in-browser code editor, instant check feedback, solution reveal, and AI-powered hints — all rendered as static HTML.

Requirements

  • Quarto >= 1.4 (earlier versions lack the Lua filter APIs the extension uses)

Installation

quarto add mcmullarkey/blendtutor

This installs the extension from the mcmullarkey/blendtutor GitHub repository into your project's _extensions/blendtutor/ directory.

Authoring syntax

Exercises use Quarto fenced divs with the .blendtutor class. Each exercise specifies a language attribute ("r" or "python") and contains a code template, optional checks, an optional solution, and optional hints.

R exercise:

::: {.blendtutor language="r"}
Write a function `add(a, b)` that returns the sum.

```r
add <- function(a, b) { ___ }
stopifnot(add(1, 2) == 3)

:::


**Python exercise:**

::: {.blendtutor language="python"} Write a function square(n) that returns n * n.

def square(n):
    ___
assert square(3) == 9

:::


### BYOK (Bring Your Own Key)

AI-powered feedback uses the learner's own API key — no server-side key needed.
Set one in the browser when prompted:

- `ANTHROPIC_API_KEY` — [Anthropic](https://www.anthropic.com) (browser BYOK is
  Anthropic-only).

The key is stored in the browser's `sessionStorage` and never sent to a server
other than the LLM provider.

### Cross-origin isolation (COI)

webR requires `SharedArrayBuffer`, which needs cross-origin isolation
(COOP/COEP headers). To opt in, add `coi: true` to a page's YAML header or
`coi="true"` to any div:

```yaml
---
coi: true
---

The filter injects a vendored coi-serviceworker.js shim that re-serves the page with the required headers. Pyodide-only pages do not need COI.

Demo book

A complete demo book with R and Python exercises lives in demo-book/. To render it:

cd demo-book
quarto render

This produces a multi-page HTML book in demo-book/_output/ with interactive exercises, checks, solutions, and COI configuration.

License

MIT License — see the LICENSE file for details.