Inspiration

I taught for almost ten years before I became an iOS engineer, and I still teach — I'm an Apple Authorized Trainer, and I design curriculum for people learning to build software.

So I know exactly which hours teaching actually costs. Not the lesson. The stack of papers. The preparation before the class. The feedback you owe every student, and the second version of it you owe their parents. The progress reports the administration needs, about the students and about your own teaching. In a class of thirty, doing all of that properly is not possible, so every teacher I know quietly decides which part to do badly. My colleagues were making the same trade, and the bigger the class, the worse the trade got.

But the thing that actually made me build this comes from further back, when I was the student.

I rarely got feedback I could use. Not because my teachers didn't care — because they had thirty of me and one evening. So a paper would come back with a red line through the whole solution and a mark, and I'd have to guess what had gone wrong. Sometimes I'd look closely and find it: I had taken the starting value as 100 instead of 1000. Everything after that was correct. The method was correct. The reasoning was correct. I understood the topic. I had copied a number wrong.

Crossed out entirely, same as if I'd understood nothing.

And what a student concludes from that is not "I should be more careful." It's "I'm bad at this subject." I watched classmates decide they weren't mathematics people on exactly that evidence. Some of them were the best mathematicians in the room. The marking couldn't tell the difference between not knowing something and slipping while knowing it — and no teacher with thirty papers and a Tuesday night has time to make that distinction thirty times over.

That distinction is the entire reason this project exists.

What it does

A teacher types a topic. The agent breaks it into distinct, individually assessable skills; the teacher rewords, deletes, adds, approves. It writes three difficulty levels of open-response problems per skill, assigns them to a class with a deadline, and then — with nobody watching — grades what comes back, decides what needs reteaching, and drafts one note per student.

The decision it makes is the product:

  • Reteach to the whole class — most of them missed it.
  • Individual gap — these named students need support, the class moves on.
  • Mastered — leave it alone.

The part I actually care about

The grader doesn't return "right" or "wrong." It returns two separate judgments — did the student demonstrate the skill, and is the final answer correct — plus a classification of what kind of failure it was:

what it means what the teacher should do
target_skill hasn't grasped the skill under test reteach the concept
prerequisite failed on an earlier skill — rounding, fractions, units teach the earlier skill
procedural right method, slipped arithmetic accuracy practice, not reteaching

A procedural slip counts as having demonstrated the skill. It does not lower the skill's pass rate. It does not put the student in the reteach group.

That is the fifteen-year-old me with the 100 instead of 1000, and the system now says so out loud.

In a real run on atomic structure, two students got the same questions wrong. Madina was told she understands the concepts and needs to slow down and check her arithmetic. Erlan was told he's working from a belief that the first electron shell holds eighteen electrons, and offered a one-to-one session. Same wrong answers. Two completely different problems. No gradebook can see that difference; this can, because the difference is captured at the moment of grading rather than inferred from a score afterwards.

How we built it

A coordinator with three sub-agents (ADK, Python), each with a narrow tool set: skill_agent breaks down topics and handles the teacher's corrections, problem_agent writes leveled problems and assigns them to a class, diagnostic_agent aggregates results and composes student notes.

A separate grading service on Cloud Run, triggered by Pub/Sub rather than by conversation. This separation is the whole autonomy story: a submission publishes an event, the worker consumes it, Gemini grades the answer against the skill it was meant to test, and the teacher is not involved and does not need to be.

Firestore for all state, Gemini 3.5 Flash via Vertex AI on the global endpoint, all of it in europe-west1.

Two design decisions I'd defend anywhere:

The judgment lives in code, not in a prompt.

MASTERED_AT  = 0.85   # at or above: the class has it
CLASS_GAP_AT = 0.35   # at or below: reteach to everyone

Gemini turns those numbers into something a teacher can read. It does not decide them. Early on CLASS_GAP_AT was 0.50, and the system flagged five of seven skills for whole-class reteaching — technically correct and completely useless, because "reteach almost everything" is not advice. Moving it to 0.35 produced one clear reteach and named individuals for the rest. That threshold is the pedagogical judgment, so it belongs where a teacher could inspect it and tune it for her own class, not buried inside a model's reasoning.

Constraints are structural, not instructional. list_approved_skills queries with approved == True, so the problem-writing agent literally cannot see skills the teacher rejected. I learned this the hard way: I once assigned a topic without setting a class, and got a confident verdict computed on nothing. An instruction saying "always assign first" can be skipped. A function that refuses to run without a roster cannot be.

The rule I settled on for human approval: the teacher approves anything that reaches a student — the problems, and the feedback. Everything internal — skills, grading, aggregation — the agent does alone. That single line decided every remaining question about where to put a gate and where to get out of the way.

Challenges we ran into

Unreachable code cost me an evening. The Firestore write was indented inside an except block, after its return. The symptom was maddening: clean 204 No Content responses, no log output at all, nothing graded, and Pub/Sub retrying forever. Coming from Swift, where braces make this visible, Python's whitespace hid it completely. I only found it by reading the file line by line after every other theory had failed.

Local worked, deployed didn't. My .env was gitignored — correctly — so it never shipped, and Cloud Run defaulted GOOGLE_CLOUD_LOCATION to the deploy region, europe-west1, which doesn't serve Gemini 3.5 Flash. Same code, 404 in production. The fix was a deploy.sh that sets the env vars every time, so the two steps can't drift apart again.

At-least-once delivery isn't theoretical. Grading takes a few seconds per submission, ack deadlines lapsed, and Pub/Sub redelivered while the first attempt was still in flight. My logs filled with already graded, skipping. Without that idempotency check, half the class would have been graded twice — double the cost and, worse, potentially different results for the same answer. It also taught me the inverse: at-least-once only holds if the publish succeeded, and one submission vanished because I hadn't waited on the future.

Multiple choice quietly broke everything. The problem agent's first output was MCQ, which looks fine until you realise a student who picks "B" has shown no working — so there is nothing for the grader to classify as procedural versus conceptual. My entire premise depended on seeing how the student got there. Open response only, enforced in the instruction.

The model fills gaps with plausible fiction. Twice. It praised a student for demonstrating a skill he had never submitted — absence of evidence became evidence of success. I'd warn anyone building with agents about: they don't say "I don't have that." They produce something reasonable-sounding instead.

Accomplishments that we're proud of

The system can tell a slip from a gap. Two students got the same questions wrong, and one was told to slow down and check her arithmetic while the other was told he's working from a wrong belief about electron shells and offered a one-to-one. That distinction is the reason I built this, and it works. A procedural slip counts as having demonstrated the skill — it never lowers a pass rate, and it never puts a student in the reteach group.

Grading that runs with nobody watching. A submission publishes an event, a separate Cloud Run service consumes it, and grades land in Firestore while the teacher is teaching something else. No button, no waiting.

The pedagogical judgment is auditable. The reteach thresholds are two constants in Python, not a sentence in a prompt. A teacher could open the file, see 0.85 and 0.35, and change them for her own class — and get predictable behaviour. Nothing about a prompt gives you that.

Ten days, solo, alongside a full-time engineering job and teaching. The build log has every wrong turn in it.

What we learned

An agent missing a tool doesn't say so — it invents a reason the task is impossible. Mine told a teacher to go create her class in "your school's portal." There was no portal. The create_class tool existed; it just wasn't on the agent holding the conversation. When an agent starts explaining that something must be done elsewhere, check its tool list before anything else.

Models fill gaps with plausible fiction. It praised a student for demonstrating a skill he never submitted — absence of evidence became evidence of success. The fix in was to make the tools refuse to proceed on missing data rather than letting the model smooth over it.

Aggregation should preserve evidence, not just tally it. My first version returned {"madina": 2} for procedural slips. A teacher's next question is always which one, and why — so it now carries the skill name and the specific misconception all the way through to the student's note.

The teacher's view and the student's view are different shapes. The teacher needs per-skill and class-wide; the student needs one paragraph about themselves. Same graded data, two separate compositions.

At-least-once delivery is not theoretical. Grading took a few seconds, ack deadlines lapsed, and Pub/Sub redelivered while the first attempt was still running. Without the idempotency check, half the class would have been graded twice. And the inverse: at-least-once only holds if the publish succeeded — one submission vanished because I hadn't waited on the future.

Multi-agent orchestration costs something. Every handoff rewrites the system instruction and breaks the prompt cache. Separation of concerns isn't free.

What's next for Teacher's Hours

The student app. The submission and feedback surface that the seed script currently stands in for — one page per student, no ids, no jargon.

Deadline-triggered diagnosis. Cloud Scheduler computing the verdict when the deadline passes, so the teacher is notified that her results are ready rather than having to ask.

Error profiles over time. The one I most want to build. Every graded submission already records which kind of error it was, so tracking that across topics shows something a pass rate never can: a student moving from conceptual errors to procedural ones is learning, even if the score didn't move. A student producing the same error type every week is stuck. Those need opposite responses, and nothing in a school currently tells them apart.

Cross-subject prerequisites. If a student's prerequisite failures point at fractions in both chemistry and physics, that's a finding no single teacher can see alone — and the fix is one conversation between two teachers.

Minimum coverage before a class-wide verdict. With four students, one failure swings a pass rate by 25%. Thresholds shouldn't fire on thin data.

Not planned: inferring personality or learning styles from submission data. The subjects are children, homework cannot support that inference, and a wrong label follows a child for years. The system reports evidence and leaves interpretation to the person who knows them.

Built With

Share this project:

Updates

Submission history