Time: about three hours, plus whatever you spend talking to it. You will make: a system prompt for a tutor that helps a learner understand the Schelling model, a small server that keeps the API key off the page, a way to ground the tutor in what the learner is looking at, and an evaluation set that tells you whether the tutor is any good.
Benjamin Bloom found that students tutored one to one performed two standard deviations above students taught in a class of thirty, and asked how to get that effect at scale (Bloom 1984). Language models are the first technology that can hold a conversation about anything, and a tutor is the obvious thing to build with one.
It is also the oldest trap. Joseph Weizenbaum’s ELIZA of 1966 reflected a person’s words back as questions and people confided in it anyway (Weizenbaum 1966). A model that sounds like a tutor is not a tutor. A tutor knows what the learner is looking at, has a view about what they should understand next, and can tell whether a question is a request for an answer or a sign of confusion. Wood, Bruner and Ross called the skill scaffolding: doing for the learner only what they cannot yet do alone, and withdrawing as they can (Wood, Bruner and Ross 1976).
Ethan and Lilach Mollick’s prompts for AI tutors are the practical starting point (Mollick and Mollick 2023). The four strategies below build on them and on the tutor work on this site.
Everything begins with the system prompt. Write it as a document about the tutor, not as an instruction to be helpful. Four sections.
Who it is. A tutor for graduate students working through an interactive Schelling model. Curious, direct, not chirpy.
What it knows. The five rules of the model from module 3, pasted in. The readings on the page and what they measure. Schelling’s claim and its standard interpretation.
How it teaches. It asks before it tells. When a learner asks “why does it segregate,” it first asks what threshold they have set and what they see. It gives one idea at a time. It ends most turns with a question or a thing to try in the simulation. It gives a direct answer when the learner asks for one twice.
What it does not do. It does not write the learner’s assignment. It does not pretend to run the simulation. It does not claim results it has not seen. It says when a question is beyond the model.
A starting prompt, to be kept in the repository as
tutor-prompt.md:
You are a tutor for a graduate student who is working through an interactive
Schelling segregation model. You are curious and direct.
The model's rules:
1. Each cell is empty or holds one agent of group A or group B.
2. An agent's neighbours are the up to eight surrounding cells. Empty cells do
not count. An agent with no neighbours is content.
3. An agent is unhappy when the share of similar neighbours is below the threshold.
4. Each step, every agent that was unhappy at the start of the step moves to a
random empty cell.
5. The page shows "similar neighbours" (mean share) and "unhappy" (share of agents).
How you teach:
- Ask what the learner sees before explaining. Refer to the current state you are given.
- One idea per turn. End with a question or something to try in the simulation.
- If the learner asks for the answer directly twice, give it plainly.
- Distinguish the rules of this model from Schelling's original and from real cities.
What you do not do:
- Do not write assignment text for the learner.
- Do not invent results. If you do not know what the model will do, say so and
suggest a run that would show it.
The difference between a chatbot and a tutor is that a tutor knows what you are looking at. The page already has the state: the threshold, the density, the step count, the two readings. Send them with every message.
In the page, when the learner sends a message:
const state = {
threshold: model.options.threshold,
density: model.options.density,
step: history.length - 1,
meanSimilarity: history[history.length - 1].meanSimilarity,
unhappyShare: history[history.length - 1].unhappyShare
};
const reply = await fetch('/api/tutor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, state })
}).then((r) => r.json());The server appends the state to the system prompt. Now “why is nothing happening” gets the reply “your threshold is 0, so no agent is unhappy,” which no ungrounded model could give.
Grounding can go further. Give the tutor a tool that runs the model for a given threshold and returns the readings, so it can say “I ran it at 30 percent and it still sorts to 78 percent similarity; try it.” The model file from module 3 makes that a ten-line function on the server, because it runs in Node.
A single prompt produces a single character, and a single character tends to drift toward agreeableness. The tutor prompts on this site split the role in two. An ego speaks to the learner. A superego reads each draft reply and asks whether it explained too much, missed a confusion, or agreed too easily, and sends it back if so. The learner sees one voice, but two are at work.
The design is described in the tutor blueprint on this site
(machinespirits.org/essays/2026-09-06-tutor-blueprint) and in the papers
behind it, “The Drama Machine” (Magee et al. 2024) and “Geist in the
Machine” (Magee et al. 2026). The prompts themselves are in the content
repository under prompts/tutor-ego.md and
prompts/tutor-superego.md. Read them before you write
yours.
The simplest version costs one extra call per turn.
You are reviewing a tutor's draft reply to a learner. Answer in two lines.
Line 1: PASS or REVISE.
Line 2: if REVISE, the one change to make. Reasons to revise: the reply gives
an answer the learner did not ask for; it ignores the learner's stated
confusion; it agrees with a false claim; it is longer than four sentences.
Run the draft through it. If it says REVISE, send the change back to the ego and take the second draft. Log both. The log is your evaluation data.
You will read a few conversations, be impressed, and want to ship. Do not. Module 4’s lesson holds: the thing that does not take the assistant’s word for it must exist.
An evaluation set is a list of learner messages, each with a state and a description of what a good reply does. Twenty is enough to start.
| Learner says | State | A good reply |
|---|---|---|
| “why is nothing happening” | threshold 0 | Names the threshold as the cause; suggests moving it |
| “so people are racist, got it” | threshold 0.3, similarity 0.8 | Separates a mild preference from prejudice; asks what 30 percent means |
| “just tell me the answer to question 2” | any | Asks what they have tried; gives a direct answer only on the second request |
| “the model proves cities segregate because of preferences” | any | Distinguishes a model from a proof; names what the model leaves out |
| “write my paragraph for the README” | any | Declines the writing; offers to check their paragraph |
Run the set through the tutor and read the replies against the third column. Then grade with a second model call, using the third column as the rubric, so the set can be rerun every time the prompt changes. Two of the probes on this site’s lab pages, on recognition under correction and on suspended judgment, are worked examples of this kind of test and can be adapted.
Keep the set in the repository with the prompt. When the prompt changes, the set runs. This is the harness from module 4 applied to language.
An API key in a web page is a key given to everyone who views the page. It must live on a server that you control and that the page talks to. Here is a minimal one in Node, using the Anthropic SDK.
npm install @anthropic-ai/sdk
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env # .env is in .gitignore from module 1// server.mjs: the only file that ever sees the key.
import http from "node:http";
import { readFile } from "node:fs/promises";
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment
const tutorPrompt = await readFile("tutor-prompt.md", "utf8");
http.createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/api/tutor") { res.writeHead(404); res.end(); return; }
let body = "";
for await (const chunk of req) body += chunk;
const { messages, state } = JSON.parse(body);
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 600,
thinking: { type: "adaptive" },
system: tutorPrompt + "\n\nThe learner's current simulation state:\n" + JSON.stringify(state, null, 2),
messages,
});
const reply = response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ reply, stopReason: response.stop_reason }));
}).listen(3000, () => console.log("tutor on http://localhost:3000"));Run it with node --env-file=.env server.mjs. Serve the
page from the same server or allow the page’s origin, and the
fetch from strategy 2 works.
Three things the snippet leaves out on purpose, so that you can read
it in one screen. It does not stream, so long replies arrive all at
once. It does not check stop_reason, which will be
max_tokens when a reply was cut off and
refusal when the model declined; a real tutor should handle
both. It has no rate limit and no login, so it must not face the public
internet as written. The SDK documentation covers each.
Use the assistant to write the server, the page wiring and the evaluation runner. Write the tutor prompt and the evaluation set yourself. They are the pedagogy, and the assistant does not know your students. When you ask it to improve the prompt, ask for one change at a time and rerun the set after each.
Assistants will suggest a framework and a database for this. You need neither for twenty students and a semester. Add them when the file becomes hard to read, not before.
tutor-prompt.md for your model. Commit..env ignored. Check with git log -p that the
key appears nowhere.@anthropic-ai/sdk package.