--- title: "Bidirectionele async-comms MCP-agent ↔ user" status: active audience: [ai-agent, contributor] language: nl last_updated: 2026-05-03 when_to_read: "When implementing or extending the claude-question channel for agent-user async communication." --- # Patroon: Bidirectionele async-comms tussen MCP-agent en interactieve user Het M11 vraag-antwoord-kanaal is herbruikbaar voor elke feature waarbij een **autonome agent** (Claude Code via MCP, een Vercel-job, etc.) iets wil ophelderen bij de **actieve gebruiker** zonder zelf te blokkeren of te raden. > "Agent stelt vraag → vraag wacht persistent → user beantwoordt op een > moment dat het hem uitkomt → agent leest het antwoord en gaat door." Voorbeelden waar dit zou kunnen passen: - AI-codereview: Claude vraagt om bevestiging op een controversiële refactor - Background-import: queue-worker vraagt om een keuze (overschrijven/skippen) per ambigue rij - Multi-step migratie: scripted task pauseert op een handmatige bevestiging - Approval-flow voor LLM-actions: user keurt een tool-call goed vóór 't echt gebeurt --- ## Vier eindpunten | Endpoint | Auth | Doel | |---|---|---| | MCP `ask_*` (write) | API-token + demo-blok | maakt rij in `*_questions`-tabel; optioneel `wait_seconds` voor sync polling | | MCP `get_*_answer` (read) | API-token | leest huidige status + antwoord (voor latere session-pickup) | | `POST /actions/answer` (Server Action) | iron-session + product-membership | atomic `updateMany WHERE status='open'` | | `GET /api/realtime/` (SSE) | iron-session | user-scoped stream, filter op `entity` + product-access | Plus: - **Postgres-trigger** op de tabel die `pg_notify` doet op een gedeeld channel met een `{op, entity, id, ...}`-payload - **Cron-endpoint** dat verlopen rijen markeert (status='expired') zodat achterstallige queries niet blijven groeien --- ## Vier security-uitgangspunten 1. **Atomic state-transities.** Antwoord-actie doet één UPDATE met alle invarianten in de WHERE (status, expiry, owner-check). Concurrent dubbele submit: PostgreSQL row-locking laat één caller count=1 zien, de rest 0. 2. **Demo-blok op writes.** Agent-side via `requireWriteAccess` (PERMISSION_ DENIED voor demo-tokens), user-side via early-return op `session.isDemo` en disabled-submit-knop met tooltip in de UI. 3. **Access-isolation in SSE-filter.** Bij connect: query alle accessible product-IDs voor deze user → Set. In notification-handler: drop alle payloads waarvan `product_id ∉ accessibleSet`. Dit is naast de DB-query- filter; redundant maar voorkomt lekkage als de DB-laag iets doorlaat dat niet zou moeten. 4. **Geen gevoelige data in logs.** Payload bevat alleen IDs en status — de tekst van vraag en antwoord komt via een aparte authenticated query. `console.log` alleen `question_id`, nooit content. --- ## Channel-strategie: hergebruik vs. eigen kanaal Twee opties bij meerdere realtime-features: | Optie | Voordeel | Nadeel | |---|---|---| | **Eigen channel per feature** (M10 `scrum4me_pairing`) | Geen filter-leakage tussen features | 1 LISTEN-connectie per feature; meer DB-resources; meer routes | | **Gedeeld channel met `entity`-key** (M11 `scrum4me_changes`) | 1 LISTEN per route; nieuwe entity = filter-aanpassing | Vergeten te filteren = leak | Voor M11 is gekozen voor **hergebruik**: één channel scaalt beter naar v2 (comments, mentions, status-updates allemaal op zelfde stream) en de filter- discipline is enforceable in code-review. Mitigatie voor leak-risico: expliciet `if (payload.entity === 'X') return false` in elke route die feature X niet hoort te zien — zoals `app/api/realtime/solo/route.ts` die `entity:'question'` weert. --- ## TTL-richtlijn - **Question lifetime**: 24 u — kort genoeg dat verlaten queries niet ophopen, lang genoeg dat een gebruiker die afwezig is een nacht heeft om te antwoorden - **MCP-tool wait_seconds**: max 600 s — Claude wacht maximaal 10 min op een antwoord; daarna `status: 'pending'` zodat hij later kan terugkomen via `get_question_answer` - **Cron schedule**: `0 4 * * *` — daily op een rustig tijdstip (Vercel Hobby staat alleen daily crons toe; Pro ondersteunt fijnmaziger). 24 u TTL + daily cleanup houdt de tabel klein zonder cron-budget te belasten --- ## Sjabloon-bestanden Specifiek voor M11. Kopieer en pas aan: ### Database - `prisma/schema.prisma`: model met `id`, `status`, `expires_at`, denormalized `product_id` voor SSE-filter, asker/answerer-FKs, json `options?`-veld voor multiple-choice - `prisma/migrations//migration.sql`: tabel-DDL + `notify_*_change()`- functie + `AFTER INSERT/UPDATE`-trigger op gedeeld channel ### Server (Scrum4Me) - `actions/questions.ts`: Server Action met getSession + Zod + demo-blok + productAccessFilter + atomic updateMany - `app/api/realtime/notifications/route.ts`: user-scoped SSE met initial-state-event ná LISTEN actief (race-fix conform M10 ST-1004) - `app/api/cron/expire-questions/route.ts`: Bearer-auth via CRON_SECRET + updateMany WHERE expires_at