08 — Architectuur: "het huis praat" in de bestaande app

Onderzoeksagent 8 van 8 · 17 september 2026 · ontwerp, geen code gewijzigd

Samenvatting

  1. 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.
  2. Dertien van de zeventien schermen halen hun eigen data op en hebben géén props — alleen FanDetail en Wijk kennen params. Een scherm in een paneelslot monteren werkt dus meteen.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. De sleutel woont in /srv/micasa-demo/data/.env — hetzelfde patroon als EP_ONLINE_API_KEY (docker-compose.yml env_file), nooit in het image, nooit in git, nooit in de client.
  9. Opslag: één migratie 0002-gesprekken.sql met gesprekken, beurten en beslislog (één mensenzin per actie). Aangesloten op DELETE /api/ik en POST /api/reset.
  10. 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:

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):

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)

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

  1. Bouw laag (a) en (c) eerst, zonder model. Als 1 november morgen was, kon de pitch al.
  2. Voeg gesprek toe aan storyboard/model.mjs en genereer scripts.mjs daaruit. Eén bron, geen divergentie.
  3. Geen schrijf-tool. Alleen lezen en stel_…_voor; laat een test falen zodra er een muterende tool bijkomt.
  4. Zet de tool-definities vast en gesorteerd, de huisfeiten ná het cachepunt, en log cache_read_input_tokens per beurt.
  5. Sonnet 5 met thinking uit; Haiku 4.5 alleen als bewuste terugval, mét controle dat de prefix boven 4.096 tokens komt.
  6. Filter SSE server-side op persoon vóórdat de huisbeurt inhoud gaat dragen.
  7. Laat de tabbalk staan tot na de pitch en zeg in de pitch hardop wat gescript is.

Open vragen

Bronnen