6.7 KiB
| title | status | audience | language | last_updated | when_to_read | ||
|---|---|---|---|---|---|---|---|
| Bidirectionele async-comms MCP-agent ↔ user | active |
|
nl | 2026-05-03 | 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_notifydoet 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
- 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.
- Demo-blok op writes. Agent-side via
requireWriteAccess(PERMISSION_ DENIED voor demo-tokens), user-side via early-return opsession.isDemoen disabled-submit-knop met tooltip in de UI. - 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. - Geen gevoelige data in logs. Payload bevat alleen IDs en status — de
tekst van vraag en antwoord komt via een aparte authenticated query.
console.logalleenquestion_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 viaget_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 metid,status,expires_at, denormalizedproduct_idvoor SSE-filter, asker/answerer-FKs, jsonoptions?-veld voor multiple-choiceprisma/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 updateManyapp/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-countlib/realtime/use-notifications-realtime.ts: EventSource hook met state/message-handlers + reconnect-backoff + Page Visibility pausecomponents/notifications/notifications-bell.tsx+notifications-sheet.tsxanswer-modal.tsx: Bell met badge, slide-over met item-list, Dialog met free-text/options-radio
MCP-tools (scrum4me-mcp)
src/tools/ask-user-question.ts: write-tool met optionelewait_seconds- polling (intern setInterval tot status verandert of timeout)src/tools/get-question-answer.ts: read-tool voor latere session-pickupsrc/tools/list-open-questions.ts: read-tool voor session-start-checksrc/tools/cancel-question.ts: write-tool, asker-only via atomicupdateMany 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/scrum4me-architecture.md§ Vraag- antwoord-kanaal Claude ↔ user - Endpoint-contract:
docs/API.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