08 — Architectuur: "het huis praat" in de bestaande app
Onderzoeksagent 8 van 8 · 17 september 2026 · ontwerp, geen code gewijzigd
Samenvatting
- Er hoeft niets herschreven te worden. Eén nieuwe route (
POST /api/gesprek, gestreamd) en één paneelregister volstaan; de zeventien bestaande schermen worden panelen zoals ze zijn. - Dertien van de zeventien schermen halen hun eigen data op en hebben géén props — alleen
FanDetailenWijkkennenparams. Een scherm in een paneelslot monteren werkt dus meteen. - De beurtvorm is: tekst (gestreamd) plus nul of meer paneel-opdrachten
{ paneel, props }. De paneelnaam komt uit een witte lijst in code; een onbekende naam levert geen paneel op, nooit een fout. - Het model krijgt géén schrijfgereedschap. Muteren gaat via
stel_…_voor-tools die alleen een bevestigingspaneel tekenen; de mens tikt de bestaande knop aan, die de bestaande route aanroept met de bestaande 401/403-controles. - Anonimiteit is de vórm van het toolresultaat (initiaal, matchpercentage, sinds — precies de projectie in
routes/huizen.mjs:126), niet een zin in de prompt. Het model ziet nooit een fannaam. - Drie lagen zekerheid: een gescript pad voor de vijf pitch-flows (deterministisch, echte API-aanroepen), het model daarbuiten, en een keyword-terugval bij traagheid of uitval. De gebruiker ziet nooit een foutmelding.
- Modelkeuze: Claude Sonnet 5 voor de gesprekslaag, Haiku 4.5 als goedkopere terugval. Met prompt-caching ≈ $0,039 per gesprek, ≈ $39 per 1.000 gesprekken (Sonnet 5) of ≈ $19 (Haiku 4.5).
- De sleutel woont in
/srv/micasa-demo/data/.env— hetzelfde patroon alsEP_ONLINE_API_KEY(docker-compose.ymlenv_file), nooit in het image, nooit in git, nooit in de client. - Opslag: één migratie
0002-gesprekken.sqlmetgesprekken,beurtenenbeslislog(één mensenzin per actie). Aangesloten opDELETE /api/ikenPOST /api/reset. - De tabnavigatie blijft. Zij is het vangnet én het eerlijke antwoord op "en als het gesprek hapert": de app is compleet zonder gesprek.
1. Het gespreksmodel
Eén route, op dezelfde Hono-server, met dezelfde streamSSE-helper die /api/events al gebruikt (server/server.mjs:105).
POST /api/gesprek Authorization: Bearer <token>
{ gesprekId?, tekst, context: { scherm: "/mijn-huis", huisId: "h1", taal: "nl" } }
→ text/event-stream
event: beurt-start data: { beurtId, bron: "gescript" | "model" | "terugval" }
event: tekst data: { delta: "Er is er vanavond één bij gekomen." }
event: paneel data: { paneel: "fans", props: { huisId: "h1" } }
event: beurt-klaar data: { beurtId, ms, bron, bronnen: [...] }
Sessie en rol komen nooit uit de body. De middleware in server.mjs:75-82 zet c.get('persoon') op elk /api/*-verzoek; rol (eigenaar/zoeker/gast/beheer) en huisId komen daaruit. context kleurt alleen de prompt ("hij kijkt naar Mijn huis"), en is geen autorisatiebron.
Schermen worden panelen zonder herschrijving. Een nieuw bestand web/src/lib/panelen.js is een registry naam → { component, propsSchema }, hergebruik van precies de tabel die router.js:32-66 al bijhoudt. Paneel.svelte rendert <svelte:component> in de gespreksstroom, net zoals App.svelte:180 dat voor routes doet. Dat kan omdat de schermen zichzelf vullen: grep "export let" over web/src/schermen/*.svelte geeft precies twee treffers (FanDetail.svelte:35, Wijk.svelte:29). De componenten met props (FotoKader, Teller, PremiumMuur, Meldknop, FanSignaal, AdresBevestiging) worden geparametriseerde panelen; hun props staan al in de bestanden.
SSE lokt een beurt van het huis uit. lib/sse.mjs zendt vandaag naar álle verbindingen; events.js:56 filtert pas in de browser op sessie.huisId. Voor een beurt mét inhoud is dat te laat: abonneer(schrijver, persoonId) en filteren vóór het schrijven. Daarna kan fan-nieuw een gescripte huisbeurt uitlokken ("Er is er vanavond één bij gekomen. Dat zijn er nu twaalf.") — geen model, dus onfeilbaar en zonder kans op een naam. Idem voor notificatie en sollicitatie.
2. De gereedschappen
Twee soorten, geen derde.
Lezen — roepen de bestaande routehandlers aan binnen dezelfde context, zodat de 401/403's blijven gelden: huis_lezen, dossier_lezen (taken + documenten), fans_lezen, wijk_zoeken, mijn_lijst_lezen, verificatie_status. fans_lezen geeft terug wat GET /api/huizen/:id/fans teruggeeft: { initiaal, matchpercentage, sinds }. De namen staan niet in het antwoord, dus ze kunnen niet lekken — dat is een eigenschap van de route, niet van de prompt.
Voorstellen — stel_zichtbaarheid_voor, stel_notificatie_voor, stel_taak_voor, stel_sollicitatie_voor, stel_bod_voor, stel_wis_alles_voor. Deze muteren niets. Ze geven een paneel-opdracht terug; het paneel bevat de bestaande knop die de bestaande route aanroept. Er bestaat geen schrijf-tool. Dat is de hele grens, in één zin, afdwingbaar met een test.
3. Drie lagen zekerheid
gebruiker ─▶ [a] gescript pad ──trefr.──▶ beurt (echte API-aanroepen)
│ geen match
▼
[b] model (Sonnet 5, stream, tools)
│ >1,2 s geen token → "…" │ >6 s of fout
▼ ▼
beurt [c] keyword-terugval
(a) Gescript. server/lib/gesprek/scripts.mjs: per pitch-flow een lijst { herken, beurt }, met beurt = { nl, en, panelen[], acties[] }. De acties zijn échte aanroepen (POST /api/huizen/:id/notificeer, POST /api/huizen/:id/fan). Dit is genui-tel's stub-modus, strakker: die valt terug op keywordRoute (lib/conversation/stub-conversation-service.ts:60) zodra /api/brain geen sleutel heeft.
(b) Model. Alles daarbuiten. Timeouts: 1,2 s tot eerste token (daarna een rustige denkpauze in beeld, geen spinner), 6 s harde stop met AbortController.
(c) Terugval. Dezelfde keywordRoute(tekst, context) als pure functie — zelfde retourvorm, dus de UI merkt het verschil niet. Ook het pad zonder API-sleutel, precies zoals app/api/brain/route.ts:296 doet.
Eerlijkheid (D51). In de pitch één keer hardop, vóór scène 1: "De vijf scènes lopen langs een vaste route, zoals een demo hoort. Alles wat u ziet gebeuren gaat door dezelfde server en dezelfde database als bij een echte gebruiker. Vraag straks iets wat niet in het script staat." En laat E dat dan ook doen. Dat is letterlijk waar en het is het sterkste moment van de avond.
4. Systeemprompt-architectuur
tools ── deterministisch, gesorteerd, nooit per verzoek gefilterd ┐
system[0] ── karakter · grenzen · panelencatalogus (~5.000 tokens) ├ cache_control
─────────────────────────────────────────────────────────────────────────┘
system[1] ── huisfeiten mét bron+peildatum · rol · taal · scherm (~800)
messages ── de beurten
Twee vallen uit genui-tel, beide te vermijden: de tool-enum wordt daar per verzoek uit instellingen opgebouwd (respondTool(acties), route.ts:171) en de beslislog wordt in de systeemprompt geïnjecteerd (bouwSysteem, route.ts:228). Beide zitten vóór het cachepunt en wissen de cache elk verzoek. Zet de veranderlijke feiten ná de breakpoint; controleer met usage.cache_read_input_tokens.
Harde uitvoercontroles, in code, na het model:
geenWaardeschatting(tekst)— elk €-bedrag of "waard/waarde/getaxeerd" wordt geweigerd, behalve een bedrag dat letterlijk uit een toolresultaat kwam (eigenwozWaarde, een bod dat de eigenaar al heeft). Anders: zin vervangen door de vaste regel.geenFanNaam(tekst)— de server heeft de namenlijst; komt er één in voor, dan wordt de beurt verworpen en uit sjabloon hertekend. (Het model heeft ze nooit gezien; dit is de gordel naast de bretels.)bronBijElkGetal— elk getal moet uit een toolresultaat metbron+peildatumkomen; de renderer zet de bestaande i18n-sleutelalgemeen.broneronder. In demo-modus verbergtlib/demo.jsdie regel — de controle blijft (D51b).
5. Modelkeuze, latency en kosten
| Laag | Model | Waarom |
|---|---|---|
| Gesprek | claude-sonnet-5 |
Beste verhouding oordeel/prijs/snelheid; min. cacheprefix 1.024 tokens |
| Goedkope terugval / hoge drukte | claude-haiku-4-5 |
Helft van de prijs; let op: min. cacheprefix 4.096 tokens |
| Beslislog-samenvatting (batch, 's nachts) | claude-haiku-4-5 + Batch API |
50 % korting, geen latency-eis |
Gebruik claude-haiku-4-5 (de kale id), niet de gedateerde variant. Zet op Sonnet 5 thinking: {type:"disabled"} (geaccepteerd) of output_config.effort: "low" — een gesprek met je huis moet niet nadenken, het moet antwoorden.
Rekenvoorbeeld. Vast prefix 5.000 tokens (karakter + grenzen + panelen + tools + tool-overhead 354 tokens voor Sonnet 5), variabel deel 800 tokens per beurt, zes beurten per gesprek, ~150 tokens geschiedenis per beurt, ~120 uitvoertokens per beurt.
Sonnet 5 ($2 / $10 per MTok; 5m-cachewrite $2,50; cacheread $0,20):
| Post | Tokens | Kosten |
|---|---|---|
| Cacheschrijf (1×) | 5.000 | $0,0125 |
| Cachelezingen (5×) | 25.000 | $0,0050 |
| Verse invoer | 7.050 | $0,0141 |
| Uitvoer | 720 | $0,0072 |
| Per gesprek | ≈ $0,039 | |
| Per 1.000 gesprekken | ≈ $39 |
Zonder caching: ≈ $0,081 per gesprek (≈ $81 / 1.000). Haiku 4.5: ≈ $0,019 (≈ $19 / 1.000). Opus 5: ≈ $0,097 (≈ $97 / 1.000). Voor de pitchavond zelf, met vijf gescripte flows (nul modelkosten) en pakweg vijftig vrije beurten: onder de $1.
Latency. Reken met een streamende eerste tekstblok vóór de tool-aanroep: dan begint het huis te praten en gaat het paneel een tel later open — precies de bedoelde beleving. Forceer dus géén tool_choice: {type:"tool"} zoals genui-tel doet (route.ts:334), want dan komt er pas iets in beeld als het JSON compleet is. Zet de echte tijd-tot-eerste-token in stap 5 zelf op de meter en leg 'm vast in PITCH-FEITEN.md met die meting als bron; de budgetten hierboven (1,2 s / 6 s) zijn ontwerpgrenzen, geen meetwaarden.
Sleutel en misbruik. ANTHROPIC_API_KEY in /srv/micasa-demo/data/.env, chmod 600, via env_file in docker-compose.yml:66 — dezelfde weg als EP_ONLINE_API_KEY, dus geen nieuw patroon. Op een publieke demo verder: max 800 tekens per bericht, max 20 beurten per gesprek, max 5 gesprekken per IP per uur, een dagplafond in dollars dat bij overschrijding de laag naar keyword-terugval zet, en een beheerschakelaar instellingen.gesprek (zelfde plek als instellingen.demo) als noodrem.
6. Opslag
Migratie 0002-gesprekken.sql (nooit 0001 aanraken — server/README.md §Opslag):
gesprekken— id, persoon_id, rol, huis_id, taal, laag, begonnen, laatst.beurten— gesprek_id, volgorde, rol, tekst, panelen (JSON), bron (gescript|model|terugval), model, tokens_in / tokens_cache_read / tokens_uit, ms_eerste_token.beslislog— één mensenzin per actie: "Sanne zette haar huis op 'overweegt te verkopen'." Dit is genui-tel'slistEvents-gedachte, maar als audit voor de mens, niet als promptvoer.
Niet bewaren: ruwe locatie (D42 blijft: alleen match ja/nee + tijdstip), fannamen, en de vrije tekst van een fan zolang hij niet gesolliciteerd heeft. Wis alles: DELETE /api/ik (routes/auth.mjs:70) verwijdert ook gesprekken, beurten en beslislog van die persoon; DELETE /api/mijn-data idem voor de gesprekshistorie. POST /api/reset leegt de drie tabellen mee — het zijn demotabellen, net als de rest.
7. Stem als optionele laag
Twee naden, allebei buiten de kern. Vóór de route: spraak → tekst in de client, POST /api/gesprek verandert niet. Ná de route: de gestreamde tekstdelta's zin voor zin naar TTS, terwijl het paneel opengaat. Eén vlag stem: true op de sessie bepaalt alleen hoe de client rendert. De schrijfregister ervoor bestaat al als skill (zilch-conversational). Tekst eerst; stem pas als de vijf flows twee keer foutloos gelopen hebben.
8. Bouwvolgorde in vijf stappen
| # | Wat | Wat E ziet | Werk | Modelkosten |
|---|---|---|---|---|
| 1 | Casco: route + migratie 0002 + gescript pad flow 3 + keyword-terugval | "hoeveel fans heb ik?" → het fanspaneel opent, op demo.micasa.bijt.me | 2-3 d | $0 |
| 2 | Paneelregister + Paneel.svelte; alle schermen als paneel; tabbalk blijft |
de hele app bereikbaar vanuit het gesprek | 2 d | $0 |
| 3 | Model erbij: Sonnet 5, leestools, caching, uitvoercontroles, timeouts | een vraag die niet in het script staat, krijgt antwoord | 2 d | < $5 |
| 4 | Voorstelpanelen + huisbeurt via SSE + NL/EN | "zet mijn huis op overweegt te verkopen" → bevestigen → Toms scherm beweegt | 2 d | < $5 |
| 5 | Repetitie en dichtzetten: limieten, plafond, noodrem, twee volledige doorlopen, latency meten | de hele pitch, twee keer, op de klok | 3 d | < $10 |
Risico's.
| Risico | Tegenmaatregel |
|---|---|
| Model traag of onbereikbaar op 1 nov | Laag (a) heeft het model niet nodig; laag (c) vangt de rest; noodrem in Beheer |
| Model noemt een bedrag of een naam | Controles in code ná het model, plus een tooloppervlak waarin namen niet bestaan |
| Cache slaat niet aan (Haiku 4.5: 4.096 tokens) | usage.cache_read_input_tokens loggen per beurt; bij nul: prefix meten en verlengen of Sonnet 5 |
| Paneel opent verkeerd | Witte lijst; onbekende naam = geen paneel, nooit een fout (genui-tel route.ts:346) |
| Twee agents in dezelfde repo | Alleen eigen paden stagen; gesprekscode leeft in server/lib/gesprek/ en web/src/lib/panelen.js |
| SSE lekt naar de verkeerde sessie | abonneer(schrijver, persoonId) + filteren op de server, vóór stap 4 |
9. Wat dit doet met het bestaande werk
Storyboard (27 momenten). storyboard/model.mjs blijft de ene waarheid. Voeg per moment één veld toe: gesprek: { nl, en, panelen: [...] }. Datzelfde model voedt dan drie dingen in plaats van twee: de storyboardpagina, de Like Water-kaarten én server/lib/gesprek/scripts.mjs. Daarmee kán het gescripte pad niet uit de pas lopen met het storyboard — dat is de grootste winst van dit hele voorstel.
Like Water-kaarten. De schermkaart blijft kloppen, want de panelen zíjn de schermen. Eén derde kaart erbij ("wat het huis zegt, en wat er dan opengaat") uit hetzelfde model.
Tabnavigatie. Blijft, ongewijzigd, tot na 1 november. Niet als compromis maar als ontwerp: wie niet wil praten, klikt.
iOS/Capacitor. fetch + EventSource verhuizen ongewijzigd. Alleen de stemlaag vraagt later native toestemmingen.
Bevindingen (met bewijs)
- Eén Node-proces, Hono,
streamSSEal in gebruik — een tweede stream kost geen nieuwe infrastructuur:~/Developer/mi-casa-presentatie/server/server.mjs:105-122. - Rol en persoon staan al op elk API-verzoek:
server/server.mjs:75-82; sessies in het geheugen, 30 dagen:server/lib/auth.mjs:10-21. - SSE is vandaag een broadcast zonder persoonsfilter:
server/lib/sse.mjs:8-27; het filteren gebeurt in de browser:web/src/lib/events.js:56-62. - Anonimiteit is server-side afgedwongen door de projectie van de fanlijst:
server/routes/huizen.mjs:126-136. - Schermen zijn zelfvoorzienend: alleen
web/src/schermen/FanDetail.svelte:35enweb/src/schermen/Wijk.svelte:29hebbenexport let params. - Demo verbergt presentatie, nooit gedrag, met een expliciete lijst:
web/src/lib/demo.js:1-30. - Bronregel bestaat al als i18n-sleutel:
web/src/i18n/nl.json→algemeen.bron. - Sleutels komen per laag uit
.envin het datavolume, niet uit het image:docker-compose.yml:58-68; laagkeuze en poorten:deploy.sh:26-48. - genui-tel: tool-use met één
respond-tool en geforceerdetool_choice, plus terugval zonder sleutel:z:~/Developer/genui-tel/app/api/brain/route.ts:171, 296, 334, 346; keyword-router als deterministisch vangnet:z:~/Developer/genui-tel/lib/conversation/stub-conversation-service.ts:60-120. - Prijzen en cachemultipliers geverifieerd op https://platform.claude.com/docs/en/about-claude/pricing; minimale cacheprefix per model (Haiku 4.5 = 4.096) op https://platform.claude.com/docs/en/build-with-claude/prompt-caching; streamvorm op https://platform.claude.com/docs/en/build-with-claude/streaming.
Wat dit betekent voor Mi casa
Het gesprek is geen tweede app maar een tweede ingang tot dezelfde app. Alles wat de afgelopen weken gebouwd is — de schermen, de kamer, het passe-partout, de SSE-lus, de woonverificatie — blijft staan en wordt zichtbaar op een nieuwe manier. De zeven harde grenzen worden er niet zwakker van, want ze staan waar ze stonden: in de routes en in de vorm van de data, niet in een prompt die een model kan negeren. En de demo wordt veiliger dan nu: een gescript pad dat echte aanroepen doet is deterministischer dan een mens die op de goede knop moet klikken.
Aanbevelingen
- Bouw laag (a) en (c) eerst, zonder model. Als 1 november morgen was, kon de pitch al.
- Voeg
gesprektoe aanstoryboard/model.mjsen genereerscripts.mjsdaaruit. Eén bron, geen divergentie. - Geen schrijf-tool. Alleen lezen en
stel_…_voor; laat een test falen zodra er een muterende tool bijkomt. - Zet de tool-definities vast en gesorteerd, de huisfeiten ná het cachepunt, en log
cache_read_input_tokensper beurt. - Sonnet 5 met thinking uit; Haiku 4.5 alleen als bewuste terugval, mét controle dat de prefix boven 4.096 tokens komt.
- Filter SSE server-side op persoon vóórdat de huisbeurt inhoud gaat dragen.
- Laat de tabbalk staan tot na de pitch en zeg in de pitch hardop wat gescript is.
Open vragen
- Praat het huis in de eerste persoon ("ik sta hier sinds 1931") of praat de app óver het huis? Dat is een merkbesluit voor E, geen architectuurbesluit — beide passen in deze vorm.
- Krijgt de gast (zonder account) een gesprek? Nu zou ik zeggen: ja, lezen mag, maar geen ♥ en geen voorstelpaneel — gelijk aan de bestaande gastregels.
- Wordt het gesprek de landing (vervangt
/welkom) of een laag erbovenop? Dit rapport gaat uit van "erbovenop"; de landing is rapport 1-7. - Mag de gesprekshistorie de pitch overleven, of wist
POST /api/resethem altijd mee? Ik stel het laatste voor. - Wie betaalt de API-sleutel en op welk account staat hij (Zilch of Mi casa)?
Bronnen
~/Developer/mi-casa-presentatie/:server/server.mjs,server/README.md,server/routes/*.mjs,server/lib/{sse,auth,store}.mjs,server/db/migraties/0001-basis.sql,web/src/App.svelte,web/src/lib/{router,pad,events,api,stores,demo,i18n}.js,web/src/schermen/*.svelte,web/src/components/*.svelte,design-system/MASTER.md,Dockerfile,docker-compose.yml,deploy.sh~/Documents/mi-casa-presentatie/storyboard/:model.mjs,OVERZICHT.md~/Documents/mi-casa-su-casa/DECISIONS.md(D42, D51, D51b, D53, D55)- Mac z (alleen gelezen):
~/Developer/genui-tel/app/api/brain/route.ts,~/Developer/genui-tel/lib/conversation/stub-conversation-service.ts - https://platform.claude.com/docs/en/about-claude/pricing
- https://platform.claude.com/docs/en/build-with-claude/prompt-caching
- https://platform.claude.com/docs/en/build-with-claude/streaming