---
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/<channel>` (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/<ts>/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<now

### Client (Scrum4Me)
- `stores/notifications-store.ts`: Zustand store met init/upsert/remove +
  selectors voor count en for-you-count
- `lib/realtime/use-notifications-realtime.ts`: EventSource hook met
  state/message-handlers + reconnect-backoff + Page Visibility pause
- `components/notifications/notifications-bell.tsx` + `notifications-sheet.tsx`
  + `answer-modal.tsx`: Bell met badge, slide-over met item-list, Dialog
  met free-text/options-radio

### MCP-tools (mcp)
- `src/tools/ask-user-question.ts`: write-tool met optionele `wait_seconds`-
  polling (intern setInterval tot status verandert of timeout)
- `src/tools/get-question-answer.ts`: read-tool voor latere session-pickup
- `src/tools/list-open-questions.ts`: read-tool voor session-start-check
- `src/tools/cancel-question.ts`: write-tool, asker-only via atomic
  `updateMany WHERE asked_by + status='open'`

---

## Wanneer dit patroon NIET gebruiken

- Wanneer beide kanten al synchroon kunnen werken — dan is een gewone
  fetch/Server-Action eenvoudiger
- Wanneer realtime niet kritiek is — een korte poll-loop is simpeler dan een
  SSE-stream
- Wanneer er één centrale beslisser is — gebruik dan een gewone form-flow;
  het patroon hier is voor situaties waar de agent **niet hoeft te wachten**
  op één specifieke gebruiker

---

## Referenties

- Volledige flow + threat-model: `docs/architecture.md` § Vraag-
  antwoord-kanaal Claude ↔ user
- Endpoint-contract: `docs/api/rest-contract.md` § Notifications + Cron
- LISTEN/NOTIFY-pattern: `app/api/realtime/solo/route.ts` (M8 ST-802) — zelfde
  ReadableStream + heartbeat + hard-close + abort-cleanup
- M10 vs M11 keuze tussen eigen/gedeeld kanaal: zie threat-model-tabel
