Mentoring Tomorrow's AI Developers

Too Many School Messages? I Built a Local AI Triage for Librus in One Hour

A macOS agent that polls my kids’ school inbox every hour, classifies each message with a Polish decision model running on my own GPU, and only disturbs me when something actually needs a parent.

The problem: notification fatigue, Polish-edition

Librus Synergia is the dominant electronic gradebook in Polish schools — grades, attendance, homework, and an internal messaging system (wiadomosci.librus.pl) through which teachers, school psychologists and the secretariat communicate with parents. If you have children in a Polish school, you live inside it.

The problem is volume. My inbox mixes genuinely important messages — “please make sure every student has an A3 technical drawing pad”, schedule changes, field-trip consents — with a long tail of noise: automatic absence confirmations, newsletter-style educational bulletins, post-event summaries. Every message looks identical in the list, so the only options are to read everything or miss things. I did what any developer-parent would do: built a system to read it for me.

What I built in one hour

A ~700-line Python script plus a launchd agent (the macOS-native cron) that runs every hour. It logs in through the same cookie-based OAuth flow the browser uses (client_id=46, redirect chain, session bootstrap — the old password grant is dead), polls the inbox JSON API, diffs message IDs against a local state file, and notifies me only about what’s new — via native macOS notifications, with optional ntfy.sh push, email or Telegram.

The classifier: basal 2.2, built for Polish

For the decision layer I didn’t reach for a general-purpose chat model. I used basal — a Polish typed-decision engine. You give it a “state” (here: sender, date, subject, body) plus typed questions — multiple choice, yes/no, or a score — and it returns a calibrated probability for every allowed answer in a single forward pass. No text generation, no parsing fragile JSON out of a chat response, no hallucinated options.

Each message gets three questions: kind (does it require action / is it important / informational / an auto-confirmation), urgency (0–2 score), and action type (bring something / sign / pay / confirm / prepare the child / excuse an absence / other). A decision takes about 1.7 seconds on an Apple Silicon GPU via the MLX backend.

Two things make basal the right fit here. First, it’s dedicated to Polish — the models are trained on roughly a million Polish and English typed decisions, which shows: it understands the difference between “proszę przynieść” (please bring — an action) and “do zapoznania się” (for your awareness — not). Second, version 2.2 can learn from your own data: basal-calibrate takes a few hundred labeled examples of your messages and recalibrates the decision thresholds, so the model adapts to what “important” means in your mailbox specifically.

And because no classifier is perfect, deterministic sender rules sit on top: messages from my kids’ homeroom teacher always notify; the psychologist’s newsletter never does. The model can’t override those.

The general pattern

This project is really an instance of a broadly useful design: when input volume exceeds your attention budget, put a cheap local classifier between the source and your notifications.

Confidence / classWhat happens
action, high confidencenotify immediately
importantnotify, no action required
info / autologged silently
low confidencedelegate — flag for manual review or a second look

The confidence score is the crucial part. Instead of a binary “alert / don’t alert”, you get a dial: high-confidence noise can be ignored entirely, uncertain cases can be delegated to a digest or a human, and only the genuinely important fraction reaches your attention. The same pattern works for support inboxes, monitoring alerts, RSS feeds — anywhere the signal-to-noise ratio is bad and the signal matters.

Honest caveats

  • Fetching a full message body marks it read in Librus — same as opening it yourself. The script supports a preview-only mode that leaves everything unread at the cost of truncated context.
  • Small models are still small models. It occasionally over-triggers on borderline messages — that’s what the sender rules and future calibration are for.
  • The hourly agent now runs the triage itself: every new message is fetched in full, classified, and a notification fires only when the decision says so. If the decision server is unreachable, it fails open — a false alarm beats a missed consent form.

Code is public: github.com/plusz/librus-triage (MIT).
Librus credentials stay in a local .env — the repo only ships a template.

Built with Devin’s SWE-2, Python, launchd, and basal 2.2 (basal-2.0-mini-MLX-8bit) on Apple Silicon.