Hendrik stopt met liegen: lokale AI die mail en agenda doet
Het irritantste moment in dit hele project was niet een crash. Het was Hendrik die vrolijk meldde dat hij mijn mail had opgeruimd, terwijl hij niks had gedaan. Mijn reactie in de chat staat nog letterlijk in de logs: "dit is een issue dat terug komt dat hendrik zegt dat hij iets doet maar niet doet, dit is gewoon liegen."
Dat is waar de laatste weken over gingen. Hendrik is mijn lokale AI-assistent: hij draait op mijn eigen hardware, onthoudt gesprekken, en praat met me via chat. Maar een assistent die zegt dat hij je agenda checkt en het dan niet doet, is erger dan geen assistent. Dus de missie was simpel: van praatjes naar echte acties. Mail die echt verstuurd wordt. Afspraken die echt in de agenda komen. Taken die echt om acht uur 's ochtends draaien.
En dat alles zonder ook maar één byte naar een cloud-AI te sturen. Want dat is het hele punt.
Waarom soeverein? Omdat mijn lezers het moeten kunnen
Ik bouw dit niet als speeltje. Ik bouw het voor overheden, energiebedrijven en ondernemers die niet zomaar hun mailbox aan een Amerikaanse API mogen voeren. Voor hen is "lokaal en in eigen beheer" geen feature, maar een voorwaarde. Hendrik moet dus kunnen praten met een mailserver en agenda die de klant zelf draait, met zelfgetekende certificaten en al.
Dat klinkt saai tot je het probeert. Mijn mail en agenda draaien op een Stalwart-server achter een interne certificate authority. Geen Let's Encrypt, geen publiek vertrouwd certificaat, maar een eigen CA die de buitenwereld niet kent. En daar lopen de meeste libraries stuk.
De TLS-loopgraven: een eigen CA die niemand vertrouwt
De mailkant (IMAP/SMTP) was nog te doen: TLS met een meegegeven caCert, en als het echt moet een tlsRejectUnauthorized-schakelaar voor de gevallen waarin je weet wat je doet. Klaar.
De agenda was vervelender. CalDAV gaat over tsdav, en die gebruikt onder water fetch. Die negeert je caCert gewoon. Resultaat: een muur van certificaatfouten. De oplossing was een undici-dispatcher die de interne CA injecteert in elke uitgaande request. Daarbovenop nog een Node 22-eigenaardigheid in tsdav die ik moest omzeilen voordat het in de Docker-container draaide.
Niet sexy. Wel precies het soort werk dat het verschil maakt tussen een demo en iets dat bij een klant in productie kan. De brug tussen bureaucratie en frontier tech zit hem meestal in dit soort details.
Belangrijk principe dat ik er meteen inbouwde: deze tools zijn eigenaar-only. Hendrik kan via een groepschat met meerdere mensen praten, maar alleen ík kan hem mijn mailbox laten lezen. Een collega in dezelfde groep kan dat niet afdwingen. Dat onderscheid, wie de eigenaar is en wie een gast, loopt als een rode draad door alles wat ik gebouwd heb.
Een planner die snapt wat "werkdagen" betekent
Toen de mail werkte, wilde ik dat Hendrik er zelf iets mee deed. Bijvoorbeeld: elke ochtend mijn inbox samenvatten. Dus bouwde ik een scheduler. En die gaf me meteen het tweede grote irritatiemoment.
Mijn opdracht was: elke tien minuten mailtriage, maar alleen op werkdagen tussen acht en vijf. Wat er gebeurde: "ondanks mijn wens op tijdens werkdagen elke 10 minuten mailtriage blijft hij 24/7 elke 10min doen." Om vijf uur 's nachts. In het weekend. Onvermoeibaar.
Het was een tijdzone-misverstand tussen mijn lokale tijd en hoe de cron-expressie werd uitgelezen: UTC tegenover Amsterdam. Klassiek, en altijd net even zoeken. De scheduler die er nu staat, is opnieuw opgebouwd en kan een stuk meer:
- Cron én at: herhalende taken ("elke ochtend om 8 uur") en eenmalige ("over 5 minuten").
- Gemiste runs inhalen: stond de machine uit toen een taak had moeten draaien? Dan haalt hij hem in bij het opstarten, in plaats van het stilletjes over te slaan.
- Geïsoleerde turns, faal-alerts en een run-historie: elke taak draait apart, en als er iets misgaat krijg ik een melding in plaats van stilte.
- Force-run: een taak handmatig aftrappen om te testen zonder op de klok te wachten.
En een laatste, gemene bug: ik draai Hendrik in Docker, met schedule.json over een bind-mount. Pas ik dat bestand op de host aan, dan ziet de file-watcher in de container die wijziging niet. Bind-mounts geven die events nauwelijks door. De fix was onelegant maar effectief: gewoon de mtime van het bestand pollen. Soms is de saaie oplossing de juiste.
Zien wat hij doet, in plaats van het te raden
Al dat debuggen bracht een dieper probleem aan het licht: ik wist gewoon niet wat Hendrik aan het doen was. "ik mis overzicht, een log of debugging tool, ik zie niet wat ons systeem doet," schreef ik op een gegeven moment. Je kunt een agent die liegt niet betrappen als je niet kunt meekijken.
Eerst hing ik er Laminar aan, een externe tracing-tool. Maar dat botste met de hele filosofie van dit project: waarom zou een soevereine, lokale assistent zijn binnenwereld naar een externe dienst sturen? Dus heb ik Laminar eruit gesloopt en vervangen door een eigen, live trace-log: per turn, per stroom een apart bestand. Geheugen, scheduler, dromen, elke Matrix-kamer, allemaal met hun eigen log die ik live kan volgen:
tail -f apps/hendrik/workspace/.hendrik/trace/all.log
Sindsdien is "doet hij wat hij zegt?" een kwestie van meekijken in plaats van gokken. Dat ene besluit, observability in eigen huis houden, heeft me meer debug-tijd bespaard dan welke feature dan ook.
Matrix: Hendrik op je telefoon, end-to-end versleuteld
De chat-laag is Matrix (de Element-app). Zo praat ik met Hendrik vanaf mijn telefoon, en zo kunnen er straks meerdere mensen in een groep met hem werken. Maar versleutelde chat en een herstartende bot zijn geen vrienden.
Het pijnlijkste: na een herstart kon Hendrik groepsberichten niet meer ontsleutelen. De sleutels waren weg. De oplossing was om bij het opstarten de key-backup terug te zetten, zodat hij de geschiedenis weer kan lezen. Daar omheen een laag kleinere, menselijke dingen:
- Een
!restart-commando (eigenaar-only, uiteraard), met een "ik ben er weer"-melding zodra hij terug is, zodat je niet in het luchtledige zit te wachten. - Een typing-indicator terwijl hij aan een antwoord werkt. Klein detail, maar het verschil tussen "is hij bezig of gecrasht?" en gewoon weten dat hij nadenkt.
- Dedupe van events en correcte mention-gating, zodat een benoemd kanaal geen DM is en hij niet dubbel antwoordt.
Wat nog rauw is
Eerlijk blijven: niet alles is af. In apps/hendrik-voice/ zit een begin van spraakbesturing: met je stem praten, Hendrik antwoordt hardop. De techniek staat, maar met kleine, lokale modellen is de spraakkwaliteit nog niet goed genoeg. Ik vind de techniek nog te gebrekkig voor lokaal gebruik, dus het staat in de README als experiment, niet als belofte. Liever een eerlijke "dit werkt nog niet" dan weer een Hendrik die doet-alsof.
De rode draad
Als ik terugkijk op deze weken, ging het niet over AI die slimmer werd. Het ging over een assistent die betrouwbaar werd. Mail die echt verstuurd wordt, een agenda die echt klopt, taken die echt op tijd draaien, en een logvenster waarin ik dat allemaal kan controleren. Allemaal op mijn eigen hardware, zonder dat er iets naar buiten lekt.
Dat is precies de combinatie waar mijn klanten om vragen: innovatief, maar in eigen beheer en controleerbaar. Een AI-collega die je vertrouwt omdat je kunt zien wat hij doet, niet omdat hij zegt dat het goed komt.
Hendrik liegt niet meer. En dat is, geloof me, een grotere mijlpaal dan het klinkt.