# Alert-watcher interactive reply flow — design

**Date**: 2026-05-18
**Status**: approved, pending implementation plan
**Plugin**: `minion/extensions/alert-watcher`
**Related**: [[project_alert_watcher_ledger_test]] memory file

## Problem

Alert-watcher currently fires a one-way notification when a complaint is classified `severity≥med`. Owner receives a WhatsApp alert with the customer's message and severity, but has no in-channel way to respond — they must open Telegram, find the customer's chat, and type a reply themselves. The owner is the human bottleneck and the friction kills response speed on real complaints.

## Goal

Let an alerted owner reply directly from WhatsApp, in one round-trip, and have that reply forwarded to the complainer on the original channel as if from the bot.

## User flow

1. Customer messages `@snifflebottom_bot` on Telegram: *"my product arrived broken, I demand a refund NOW"*.
2. Classifier drone flags `severity=high`, `category=product_defect`.
3. **NEW** — suggestion drone generates 3 short replies in the configured language.
4. Alert delivered to each WhatsApp destination (Owner 1, Owner 2):
   ```
   🚨 HIGH: <classifier summary>

   From: <sender>
   Message: <customer text>

   Suggested replies:
   1. <suggestion 1>
   2. <suggestion 2>
   3. <suggestion 3>

   Reply with 1/2/3 or type your own message.
   ```
5. First owner to reply on WhatsApp wins. They type:
   - `1`, `2`, or `3` → that suggestion is sent verbatim to the customer.
   - Anything else → message is sent verbatim to the customer.
6. Customer receives the reply on Telegram as a plain bot message (no attribution prefix).
7. Losing owner gets a courtesy DM: *"✅ Owner 1 already replied with: '<text>'"*.
8. Reply window is 1 hour from alert delivery. Replies after expiry are ignored silently.

## Architecture

One new table (`pending_replies` in `alerts.db`), one new drone (`suggestionsDrone`), one new hook (whatsapp `message_inbound`), one new send path (forward to origin channel).

```
[telegram inbound]
  → existing classifier drone
  → [NEW] suggestionsDrone → 3 short replies in cfg.language
  → existing INSERT INTO complaints
  → [NEW] INSERT INTO pending_replies (alert_id, origin_*, suggestions, expires_at)
  → existing send to each whatsapp destination (now with formatted body)

[whatsapp inbound from any owner]
  → [NEW] hook: message_inbound on channel=whatsapp
  → SELECT * FROM pending_replies WHERE destinations_json LIKE %sender% AND claimed_by IS NULL AND expires_at > now
  → if none: ignore (normal WA message)
  → UPDATE pending_replies SET claimed_by=?, claimed_at=? WHERE id=? AND claimed_by IS NULL (CAS)
  → if changes()=0: another owner won; ignore
  → resolve reply text from message (preset N or freeform)
  → channelRegistry[origin_channel].sendMessage({to: origin_chat_id, text: reply_text})
  → on success: UPDATE pending_replies SET reply_text, reply_kind, forwarded_ok=1
  → on failure: forwarded_ok=0; DM claimer with the error
  → if cfg.notifyLoser AND multiple destinations: DM losing owners
```

## Data model

```sql
CREATE TABLE pending_replies (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  alert_id INTEGER NOT NULL REFERENCES complaints(id),
  origin_channel TEXT NOT NULL,        -- "telegram"
  origin_account_id TEXT,              -- "default"
  origin_chat_id TEXT NOT NULL,        -- complainer's chat id
  destinations_json TEXT NOT NULL,     -- JSON array of phone numbers alerted
  suggestions_json TEXT NOT NULL,      -- JSON array of 3 strings (may be empty if drone failed)
  language TEXT,                       -- cfg.classifier.language at alert time
  expires_at INTEGER NOT NULL,         -- unix ms = created_at + cfg.replyFlow.windowMs
  claimed_by TEXT,                     -- owner phone that won; NULL until claimed
  claimed_at INTEGER,
  reply_text TEXT,                     -- final text forwarded to complainer
  reply_kind TEXT,                     -- "preset_1" | "preset_2" | "preset_3" | "freeform"
  forwarded_ok INTEGER,                -- 1 success, 0 failure
  created_at INTEGER NOT NULL
);

CREATE INDEX idx_pending_open
  ON pending_replies(expires_at)
  WHERE claimed_by IS NULL;
```

Claim CAS:
```sql
UPDATE pending_replies
   SET claimed_by = ?, claimed_at = ?
 WHERE id = ?
   AND claimed_by IS NULL
   AND expires_at > ?
```
If `changes()` returns 0, another owner won or the window expired. Caller treats this as silent no-op.

## Suggestions drone

TypeBox schema-only drone:

```ts
const SuggestionsInput = T.Object({
  language: T.String(),         // e.g. "es", "en"
  severity: T.Union([T.Literal("low"), T.Literal("med"), T.Literal("high")]),
  category: T.String(),
  summary: T.String(),
  senderMessage: T.String(),
  systemPrompt: T.String(),     // owner's classifier system prompt — same one used by classifier
});

const SuggestionsOutput = T.Object({
  replies: T.Array(T.String(), { minItems: 3, maxItems: 3 }),
});
```

Prompt template (English shown — drone instruction is provider-portable, target language is data, not prompt):

> You are a customer-service assistant generating short reply options for the business owner. The customer sent the following complaint, classified as `<severity>` `<category>`: `<senderMessage>`.
>
> Generate exactly 3 short reply options the owner could send back. Each reply must:
> - Be in **`<language>`**.
> - Be a complete reply (no placeholders, no `<name>`-style fillers).
> - Be ≤140 characters.
> - Cover a distinct response strategy: e.g. apology+escalate, ask clarifying question, offer concrete remedy.
> - Match the tone implied by `<systemPrompt>`.

Model: reuses `classifier.model` (haiku-4.5). Timeout 8s. If drone throws or returns malformed output, store `suggestions_json='[]'` and format the alert body without the numbered list.

## Plugin config additions

In `~/.minion/gateway.json`, `plugins.entries.alert-watcher.config`:

```json
"replyFlow": {
  "enabled": true,
  "windowMs": 3600000,
  "notifyLoser": true,
  "suggestions": {
    "enabled": true,
    "count": 3,
    "maxChars": 140
  }
}
```

Validated by Zod schema in plugin manifest. Defaults filled when absent: `enabled=true`, `windowMs=3600000`, `notifyLoser=true`, suggestions all defaults.

## Number-vs-freeform parser

Accept as preset selection if the trimmed message body matches `^[1-3][.)]?$`. Any other shape (including `"1 actually send Y"`, `"option 2"`, `"#3"`) is treated as freeform. Reasons:
- Owners who want preset N type exactly the digit; the `.` and `)` tolerance covers common typing reflexes.
- Anything richer than that signals intent to edit, so we honor it as freeform.
- Avoids ambiguity in the "1 actually send Y" case where it's unclear whether they want preset 1 or a freeform message starting with "1".

## Forwarding to the origin channel

Reply uses `channelRegistry[origin_channel].sendMessage({...})`. For telegram, the existing `sendMessageTelegram` accepts `{accountId, chatId, text}` — same signature already used by agent dispatch. No new channel adapter work needed for the supported channels (telegram, whatsapp, discord, etc. — anything alert-watcher can subscribe to is by definition something the channel registry can send back through).

Customer sees the reply as a plain text message from the bot, indistinguishable from a normal automated response.

## Error handling

| Failure | Behavior |
|---|---|
| Suggestion drone errors/times out | Alert still sends; body omits the numbered list and says "Reply with your own message." `suggestions_json='[]'`. |
| WhatsApp send fails for one destination | Other destinations still receive alert; `pending_replies` row created normally. Surviving destination can still claim. |
| Owner replies after expiry | Silent ignore. No "too late" reply (noise). |
| Owner replies "1" with no matching pending row | Silent ignore. |
| Two owners reply within ms of each other | SQL CAS guarantees exactly one wins; loser's reply is silently ignored (or triggers a "loser" DM if `notifyLoser=true`). |
| Forwarding to complainer fails | `forwarded_ok=0`. DM the claimer: "❌ Couldn't send to complainer: `<error>`. Reply again to retry." Replying again will hit the CAS check — already claimed — so we need a retry mechanism. **Decision**: on `forwarded_ok=0`, treat the row as un-claimed for retry purposes: `UPDATE pending_replies SET claimed_by=NULL, claimed_at=NULL WHERE id=?` immediately after the failure DM, so a re-reply by anyone (including the same owner) can win again. |
| `notifyLoser` enabled but only one destination | No loser to notify; skip. |

## Tests

**Unit** (vitest, in `index.test.ts` and `src/`):
- Claim CAS race: two parallel claim attempts on same row → exactly one returns success.
- Expiry: claim fails when `expires_at` has passed.
- Number parser: `"1"`, `"1."`, `"1)"`, `" 1 "` → preset_1. `"1!"`, `"option 1"`, `"#1"`, `"1 actually X"` → freeform.
- Suggestion drone failure → alert formats without numbered list.
- Reply text resolver: preset → suggestions[N-1] verbatim; freeform → message text verbatim.

**Integration** (vitest, mocked drones + mocked `channelRegistry`):
- Full happy path: telegram inbound → alert sent → owner replies `2` → complainer receives suggestions[1].
- Multi-destination loser notification: two destinations, owner 1 wins, owner 2 gets DM.
- Forward failure path: `channelRegistry.sendMessage` throws → forwarded_ok=0 → DM with error → row reset to claimed_by=NULL → next reply succeeds.

**Live** (manual, on netcup):
- Re-run today's test #4 with the new flow. Verify suggestion drone fires, suggestions are in Spanish (cfg.language="es"), reply window works, forward succeeds.

## Out of scope (deliberate)

- Editing a preset before sending (per [[reply-ux]] answer: `1` sends verbatim, no confirmation step).
- Reply-to-reply threading (customer replies, owner replies again). Each new customer message creates a new alert if it crosses the severity threshold.
- Multi-language detection. The configured `classifier.language` is the contract; mixed-language input is the classifier's existing problem.
- Persisting suggestion drone telemetry / cost tracking — relies on existing drone observability.
- Interactive WhatsApp buttons. `whatsmeow` (personal WA) cannot send buttons; text-only is intentional.

## Build sequence

1. Schema migration — add `pending_replies` table (+ idempotent migration in `store.ts:initSchema`).
2. Add `suggestionsDrone` (`src/drones/suggestions.ts`).
3. Wire suggestions into existing alert path (`src/sendAlerts.ts` or wherever the classifier→destination handoff lives).
4. Format alert body with numbered suggestions.
5. Insert `pending_replies` row alongside `complaints` row.
6. Register `message_inbound` hook on `whatsapp` channel; implement claim CAS + reply forward.
7. Loser-notification DM path.
8. Forward-failure retry semantics.
9. Plugin config Zod schema additions.
10. Tests (unit + integration).
11. Live test on netcup.

## Open follow-ups not in this design

- `responded_by` audit field on `complaints` table — orthogonal to this flow; tracked in [[project_alert_watcher_ledger_test]].
- Source-side DB migration for the existing `complaints.agent_id NOT NULL` schema mismatch — separate fix.
