chatz: Een Self-Hosted AI-chat Waarin de Assistent het Verdomde Dashboard Tekent

Elke self-hosted chatinterface geeft je precies hetzelfde: een muur van markdown. Je vraagt het model om de cijfers van vorig kwartaal, je krijgt een tabel in monospace, en wil je een echte grafiek, dan plak je de cijfers ergens anders in alsof het 2011 is. De sjiekere laten het model een base64-PNG uitspugen, oftewel de foto van een grafiek die het in zijn hoofd heeft getekend, met verzonnen assen, waar je niet op kunt klikken, niet in kunt filteren en niet op kunt bouwen.
chatz doet het ding waar verder niemand zin in heeft: de assistent tekent echte, levende UI gewoon in de chat. Geen plaatje van een grafiek. Een echt gerenderde, responsive component, gebouwd door de browser.

De assistent tekent je dashboard

Hier is het mechanisme, want het is dommer en beter dan je denkt. Het antwoord van het model kan een ​```spec-blok bevatten, en daarin zitten RFC-6902 JSON-patches. De browser kijkt mee met de stream, pikt het blok er client-side uit, en zet die patches in elkaar tot een levende component uit een vaste catalogus. Het model draait geen code. Het tekent geen pixels. Het geeft een klein declaratief specje af en de frontend rendert het, net als elke andere component.
De catalogus telt 26 componenten (ik heb de bestanden geteld, het zijn er precies 26), is dicht en getypeerd, dus het model kiest uit een menu in plaats van markup te verzinnen dat misschien wel parst en misschien niet. Naast tekst, layout, status en tabellen is het precies de analytics waar je wil dat een model naar grijpt: time-series, area, sparkline, bar, donut, funnel, gauge, scatter, heatmap, histogram, box-plot, treemap, een netwerkgraaf en een grote log-viewer. Elke grafiek is native SVG, met nul charting-runtime als dependency. Geen d3, geen chart.js, geen 400kb aan bibliotheek meegestuurd om één balk te tekenen.
En het is eerlijk op het enige punt dat telt: het is een levende component, gevoed met echte waarden, niet de foto van een gehallucineerde grafiek. Komt een MCP-tool terug met cijfers, dan propt het model ze rechtstreeks in een gauge of een treemap en krijg je iets om naar te kijken, in plaats van een alinea die iets beschrijft.

Een beurt die niet liegt over wat er gebeurd is

De streaming is SSE in Anthropic-stijl, één draad van de backend naar de browser, en wat mij interesseert is dat een beurt rendert in de volgorde waarin hij binnenkomt. Tekst, redeneren en tool-calls komen precies zo door elkaar als het model ze uitstuurt, text → tool → text → tool, in plaats van aan het eind op één hoop te worden gegooid zoals de meeste interfaces doen. Het redeneren valt, bij modellen die het blootgeven, in zijn eigen inklapbare blok. Elke tool-call is een kaartje dat door CALLING → DONE/ERROR loopt, met naam, gestreamde argumenten en resultaat. Je ziet de echte vorm van de beurt ontstaan.
En het vreet je werk niet op. Je bericht wordt opgeslagen voordat de stream start, dus op stop drukken of verversen halverwege een antwoord laat niet verdampen wat je net getypt hebt. Een antwoord dat nog aan het streamen was wordt bewaard als gedeeltelijk, expliciet gemarkeerd als afgebroken. Een refresh houdt het, maar het wordt nooit stiekem meegestuurd in een latere vraag aan het model alsof het af is. Als het echte antwoord landt, vervangt het het checkpoint atomair. Geen “oeps, weg”, geen half antwoord dat zich voordoet als heel.

Eén ding om te draaien, en de database die jij kiest

Nu het stuk waar de eigen README over opschept, en terecht. Het is één ding om te draaien. De interface zit in de applicatie gebakken, dus er is geen aparte frontend om te builden, ergens te hosten en weer aan de backend te knopen. De chat en de API zijn dezelfde container. En je start het met Docker, zoals bedoeld: je trekt de gepubliceerde multi-arch image (psyb0t/chatz op Docker Hub, een vastgezette tag :v0.7.9, niet :latest) en doet docker run met SQLite op een volume, of docker compose up vanuit een checkout als je Postgres wil.
En wat er meegeleverd wordt zit meteen potdicht, niet als huiswerk achtergelaten. De gedocumenteerde docker run gaat met een read-only root filesystem, --cap-drop ALL, een non-root user, no-new-privileges, en limieten op geheugen, CPU en PIDs, en het enige waar het in mag schrijven is /data en een gemount logbestand. Het is het zeldzame soort project waarbij het deploy-commando dat je kopieert al de dichtgetimmerde versie is.
Persistentie is standaard Postgres, of een SQLite-modus met één proces: je zet CHATZ_DB_DRIVER=sqlite en klaar, het is een bestand op een volume, geen databaseserver om in leven te houden, voor als het alleen jij bent. En dat is de hele stack. Geen Temporal. Geen NATS. Geen message broker. Geen service mesh. Geen compose met vijftien containers waar je niet aan durft te komen. Het is een chat-app en het is als een chat-app gebouwd, wat in 2026 blijkbaar doorgaat voor een designstandpunt.

De saaie shit die het wél goed heeft gedaan

Een chatvenster uitbrengen kan iedereen. Het verschil zit in de stukken die mensen overslaan:

  • MCP-secrets zijn echt versleuteld. Je voegt MCP-toolservers toe (stdio of HTTP), of importeert een .mcp.json in Claude-stijl, en de secrets uit de HTTP-headers en de stdio-env worden at rest verzegeld met AES-256-GCM onder een CHATZ_SECRETS_KEY van 32 bytes. Geen base64 en een schietgebedje, echt versleuteld, en zonder key weigert hij botweg het secret op te slaan in plaats van het in platte tekst te laten staan. Opgeslagen Authorization-waarden blijven gemaskeerd in de adminschermen.
  • Niemand meldt zich hier zelf aan. Bij de eerste start staat de app in setup-toestand met nul gebruikers; /setup maakt de enige admin, en de admin maakt alle anderen. Er is geen publieke registratie die je kunt vergeten dicht te zetten. In je eentje? CHATZ_AUTH_PASSWORDLESS=true logt die ene admin automatisch in, zodat je geen wachtwoord hoeft te typen om met je eigen machine te praten.
  • De history heeft een plafond, zodat een lange chat geen rekening van vier cijfers wordt. Elke chat begrenst de history die meegaat (standaard 100.000 tokens). Het oudste systeembericht blijft plakken en de huidige beurt blijft vastgepind; oudere history wordt van nieuw naar oud toegevoegd tot de volgende hele berichteenheid over het plafond zou gaan. Alleen hele eenheden, zodat een tool-resultaat nooit losgescheurd raakt van de call die hem opleverde. En het invoervak laat je de echte selectie voor de volgende beurt zien: het plakkende systeembericht, de behouden history, het huidige concept, de verbruikte en vrije tokens, en welke complete beurten eruit gevallen zijn. Geen giswerk over waar je voor betaalt als je verstuurt.

Al je providers, in één lijst

Hier zou ik een demo mee beginnen. CHATZ_UPSTREAMS is een JSON-array, en je propt er zoveel providers in als je wil, allemaal tegelijk actief. Ollama die lokale modellen draait op je eigen bak. aigate, je eigen gateway voor wat je zelf host. Anthropic. z.ai. Elk OpenAI-compatibel endpoint dat je kunt bereiken. Chatz vraagt bij elk het modellen-endpoint op, ontdekt wat er echt geserveerd wordt, en smelt het samen tot één picker.
Dus je klapt de modellenlijst open en je lokale llama staat pal naast Claude, naast wat er achter je gateway hangt, en eentje kiezen stuurt die beurt terug naar de upstream waar hij vandaan komt. Elke upstream noemt zijn eigen driver (openai, dat ook OpenAI-compatibele gateways dekt, of anthropic) en zijn eigen key via de naam van de omgevingsvariabele, nooit inline, en elke driver is afgeschermd van de credentials van de rest, zodat de key van de ene provider niet in de calls van een andere lekt. Leesbare aliassen liggen erbovenop zonder ooit de echte model-ID te vervangen, en de redeneerinstellingen gaan uit bij modellen die niet melden dat ze het ondersteunen, zodat de picker je geen knop voorschotelt die niks doet.

Een demomodus voor als het model niet meewerkt voor de camera

Klein dingetje dat verraadt dat iemand dit echt heeft proberen op te nemen: showcase-modus. Demo’s met een live model gaan mis precies op het moment dat de camera loopt, dus je zet CHATZ_SHOWCASE_MODE=true en je start de stack. Hij houdt de echte modellenlijst, de MCP-setup en het chatgedrag, maar onderschept bepaalde prompts uit de catalogus met deterministisch redeneren, synthetische toolactiviteit en daarna de ingebedde dashboards, waarbij elke getoonde metriek verankerd is in de zichtbare synthetische resultaten, niet uit de lucht gegrepen. De antwoorden worden opgeslagen als in elke gewone chat, dus een opname kan verversen of doorlopen. Dat is het verschil tussen generatieve UI demonstreren en bidden dat het model bij de vierde take de grafiek tekent die je nodig hebt.


Het is vroeg, v0.7.9 en het beweegt snel. Maar de kernweddenschap staat er al en het is een goede: een self-hosted chat waarin de assistent echte, levende componenten rendert in plaats van ze te beschrijven, geleverd als één kleine dichtgetimmerde container (één Go-binary met de interface erin gebakken) plus de database die jij kiest en verder niets. Richt hem op je OpenAI- of Anthropic-compatibele endpoints, hang je MCP-tools eraan, en laat het model je een dashboard tekenen dat geen leugen is.
github.com/psyb0t/chatz