Teachers write to parents in Librus Synergia. To read a message, you open the app, log in and dig through the inbox. In our house that meant messages were read late, or by one parent only. So I wrote a small bot that forwards every new Librus message to a private #librus channel in our family Slack.
It took one day, from the first commit on 27 October to a working timer on the server. Most of that day went into the Slack layout, not into Librus.
How it works
flowchart LR
T["systemd timer<br/>every 15 min"] --> B[slack-librus]
B --> L["Librus Synergia<br/>(librus-apix)"]
B --> S["state/seen.json"]
B --> C["#librus channel<br/>title, sender, date"]
C --> R["Thread<br/>full text"]
The bot is a Python script built with Poetry. It logs in with the unofficial librus-apix library, reads the inbox and compares it with a list of messages it has already seen. New ones go to Slack, and the list is saved again.
The first run only saves the current inbox and posts nothing. Without that, the channel would get a year of old messages in one go.
librus-apix is not an official API. It reads the Librus website, so the bot can break any day Librus changes its pages.
The layout took three tries
The first version posted the whole message through an incoming webhook. It worked, but a long message from a teacher filled the whole screen, and the channel was hard to scan.
The second version used Block Kit: title, sender and date in the channel, with the full text as a reply in the thread. That needs a bot token with chat:write instead of a webhook. The third fix removed the text from the channel post, because it showed up twice.
Now the channel is a list of short posts, and you open a thread only when the title matters to you:
✉️ Re: Nieobecność, prośba o informację
👤 Nauczyciel · 2026-09-16 10:05
🧵 Treść w wątku
The webhook is still there as a fallback. If the bot token is not set, the whole message goes out as one post.
Small things that cost time
LIBRUS_LOGIN is the Synergia login. The email you use on portal.librus.pl does not work here.
For a private channel, the API wants the channel ID (C...), not #librus. The bot also has to be invited with /invite.
When the bot opens a message, Librus marks it as read. Unread counters in the app stop meaning anything.
If Slack rejects one message, the bot skips it. A bad token or a network error stops the run, and the timer tries again 15 minutes later.
Deploy
The bot runs on a small DigitalOcean droplet that we use for home tools. It has its own user, a systemd timer, and secrets in a .env file with chmod 600. The server has no GitHub access, so make deploy copies the committed code over and installs the dependencies.
Add make dry-run from day one. It prints new messages without posting them, so you can test against the real inbox without spamming the family channel.
What it took
One day of work, and most of it was layout. The Librus part was a library call. What made the bot useful was the choice to keep the channel short and put the long text in a thread. Next time I would start with the thread layout and skip the webhook version.

Related notes
Learning Embedded Rust: A C/Python Developer's Rosetta Stone
A practical guide for C and Python developers transitioning to embedded Rust. Side-by-side comparisons of ownership, error handling, pattern matching, and more — mapped to concepts you already know.
Codemagic MCP Server: Trigger Mobile CI Builds from Claude Code
I open-sourced mcp-codemagic, an MCP server that brings Codemagic CI/CD into Claude Code. List apps, trigger builds, check status, and cancel runs without leaving your terminal.



